@aiquants/daily-report 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/dist/client.d.mts +449 -0
  4. package/dist/client.d.ts +449 -0
  5. package/dist/client.js +7 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/client.mjs +7 -0
  8. package/dist/client.mjs.map +1 -0
  9. package/dist/index.d.mts +42 -0
  10. package/dist/index.d.ts +42 -0
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/index.mjs +2 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/dist/logger-D3krZrNK.d.mts +29 -0
  16. package/dist/logger-D3krZrNK.d.ts +29 -0
  17. package/dist/server.d.mts +1515 -0
  18. package/dist/server.d.ts +1515 -0
  19. package/dist/server.js +10 -0
  20. package/dist/server.js.map +1 -0
  21. package/dist/server.mjs +10 -0
  22. package/dist/server.mjs.map +1 -0
  23. package/dist/sse-schema-CK7cUnEo.d.ts +1986 -0
  24. package/dist/sse-schema-yl5AaSsj.d.mts +1986 -0
  25. package/dist/types-CVhwLhSN.d.mts +76 -0
  26. package/dist/types-CVhwLhSN.d.ts +76 -0
  27. package/package.json +108 -0
  28. package/src/client/components/business-day-thumb-overlay.tsx +19 -0
  29. package/src/client/components/daily-report-comment-item.tsx +81 -0
  30. package/src/client/components/daily-report-comment-section.tsx +166 -0
  31. package/src/client/components/daily-report-detail-list.tsx +676 -0
  32. package/src/client/components/daily-report-edit-form.tsx +81 -0
  33. package/src/client/components/daily-report-list.tsx +1024 -0
  34. package/src/client/components/daily-report-page.tsx +147 -0
  35. package/src/client/components/daily-report-resolved-content.tsx +139 -0
  36. package/src/client/components/unread-indicator.tsx +13 -0
  37. package/src/client/config-context.tsx +129 -0
  38. package/src/client/contexts/daily-report-action-context.tsx +910 -0
  39. package/src/client/hooks/use-daily-report-comments.ts +73 -0
  40. package/src/client/hooks/use-daily-report-sse-connection.ts +86 -0
  41. package/src/client/hooks/use-daily-report.spec.ts +155 -0
  42. package/src/client/hooks/use-daily-report.ts +426 -0
  43. package/src/client/hooks/use-dynamic-viewport-height.ts +127 -0
  44. package/src/client/route-helpers.ts +76 -0
  45. package/src/client/ui/button.tsx +42 -0
  46. package/src/client/ui/cn.ts +14 -0
  47. package/src/client/ui/input.tsx +21 -0
  48. package/src/client/ui/label.tsx +16 -0
  49. package/src/client/ui/switch.tsx +19 -0
  50. package/src/client/ui/tabs.tsx +40 -0
  51. package/src/client/ui/textarea.tsx +19 -0
  52. package/src/client/utils/constants.ts +29 -0
  53. package/src/client.ts +21 -0
  54. package/src/index.ts +10 -0
  55. package/src/server/cache.ts +165 -0
  56. package/src/server/etag.ts +18 -0
  57. package/src/server/external-source.ts +60 -0
  58. package/src/server/handlers.ts +543 -0
  59. package/src/server/ports.ts +68 -0
  60. package/src/server/response.ts +37 -0
  61. package/src/server/schema.ts +266 -0
  62. package/src/server/service.spec.ts +97 -0
  63. package/src/server/service.ts +1308 -0
  64. package/src/server/sse-reader.spec.ts +55 -0
  65. package/src/server/sse-reader.ts +223 -0
  66. package/src/server.ts +83 -0
  67. package/src/shared/business-date.spec.ts +61 -0
  68. package/src/shared/business-date.ts +84 -0
  69. package/src/shared/comment-adapter.ts +78 -0
  70. package/src/shared/logger.ts +47 -0
  71. package/src/shared/sse-schema.ts +147 -0
  72. package/src/shared/text-utils.ts +57 -0
  73. package/src/shared/types.ts +76 -0
@@ -0,0 +1,910 @@
1
+ /**
2
+ * Action context providing optimistic CRUD + SSE synchronization for daily reports.
3
+ * 日報の楽観的 CRUD と SSE 同期を提供するアクションコンテキスト。
4
+ */
5
+ import { type Context, createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"
6
+ import { createLogger, LogLevel } from "../../shared/logger"
7
+ import type { DailyReportSseMessage } from "../../shared/sse-schema"
8
+ import type { DailyReportCommentItem, DailyReportDetail, DailyReportItem, DailyReportUser } from "../../shared/types"
9
+ import { useDailyReportConfig } from "../config-context"
10
+ import { acquireMutationLock, applyDailyReportServerUpdates, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
11
+ import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
12
+
13
+ const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
14
+
15
+ type ActionResponse = {
16
+ status: string
17
+ reportHubId: number
18
+ newComment?: DailyReportCommentItem
19
+ updatedStatus?: { isStarred?: boolean; isRead?: boolean }
20
+ deletedCommentId?: string
21
+ updatedAt?: string // ISO string from server
22
+ report?: DailyReportDetail // Full report for creation/updates
23
+ clientTempId?: string // Validation token for optimistic updates
24
+ [key: string]: unknown
25
+ }
26
+
27
+ type DailyReportActionContextType = {
28
+ /** 表示中ユーザー (楽観的コメントの作者名等に使用)。 */
29
+ user?: DailyReportUser
30
+ /** reportHubId -> Map<tempCommentId, content> (追加コメントの一時IDと内容) */
31
+ pendingAddCommentIds: React.MutableRefObject<Map<number, Map<number, string>>>
32
+ /** reportHubId -> Set<tempCommentId> (削除された一時ID) */
33
+ pendingDeleteCommentIds: React.MutableRefObject<Map<number, Set<number>>>
34
+ /** reportHubId -> boolean (スターの保留状態) */
35
+ pendingStarUpdates: React.MutableRefObject<Map<number, boolean>>
36
+ /** reportHubId -> boolean (既読の保留状態) */
37
+ pendingReadUpdates: React.MutableRefObject<Map<number, boolean>>
38
+ /** tempId -> realId (解決されたID) */
39
+ resolvedIdMap: React.MutableRefObject<Map<number, number>>
40
+ /** 状態変更通知用バージョン */
41
+ version: number
42
+ /** 日報リスト */
43
+ items: DailyReportItem[]
44
+ /** SSE購読が有効かどうか */
45
+ isSseEnabled: boolean
46
+ /** SSE購読の有効/無効を切り替える */
47
+ toggleSse: () => void
48
+
49
+ /**
50
+ * コメントを追加します。
51
+ * @param reportHubId 日報HubID
52
+ * @param content コメント内容
53
+ * @param businessDate 営業日
54
+ */
55
+ addComment: (reportHubId: number, content: string, businessDate: string) => Promise<void>
56
+ /**
57
+ * コメントを削除します。
58
+ * @param reportHubId 日報HubID
59
+ * @param commentId コメントID (一時IDまたは実ID)
60
+ * @param businessDate 営業日
61
+ */
62
+ deleteComment: (reportHubId: number, commentId: number, businessDate: string) => Promise<void>
63
+ /**
64
+ * スターの状態を切り替えます。
65
+ * @param reportHubId 日報HubID
66
+ * @param isStarred 新しいスター状態
67
+ * @param businessDate 営業日
68
+ */
69
+ toggleStar: (reportHubId: number, isStarred: boolean, businessDate: string) => Promise<void>
70
+ /**
71
+ * 既読の状態を切り替えます。
72
+ * @param reportHubId 日報HubID
73
+ * @param isRead 新しい既読状態
74
+ * @param businessDate 営業日
75
+ */
76
+ toggleRead: (reportHubId: number, isRead: boolean, businessDate: string) => Promise<void>
77
+ createReport: (businessDate: string) => Promise<number>
78
+ updateReport: (reportHubId: number, data: { title?: string; content?: string }, businessDate: string) => Promise<void>
79
+ publishReport: (reportHubId: number, businessDate: string) => Promise<void>
80
+ deleteReport: (reportHubId: number, businessDate: string) => Promise<void>
81
+ /** stale アイテムをリストから除去する安全弁 */
82
+ removeStaleItem: (reportHubId: number) => void
83
+ }
84
+
85
+ // HMR / dual-bundle (ESM+CJS) でもコンテキスト identity を一意に保つため、
86
+ // グローバルへ登録した単一インスタンスを常に使用する。
87
+ declare global {
88
+ var __DailyReportActionContext: Context<DailyReportActionContextType | null> | undefined
89
+ }
90
+
91
+ const DailyReportActionContext: Context<DailyReportActionContextType | null> = globalThis.__DailyReportActionContext ?? createContext<DailyReportActionContextType | null>(null)
92
+ globalThis.__DailyReportActionContext = DailyReportActionContext
93
+
94
+ export const useDailyReportActionContext = () => {
95
+ const context = useContext(DailyReportActionContext)
96
+ if (!context) {
97
+ throw new Error("useDailyReportActionContext must be used within a DailyReportActionProvider")
98
+ }
99
+ return context
100
+ }
101
+
102
+ const getUserDisplayName = (user: DailyReportUser | undefined): string => {
103
+ if (!user) return "Unknown"
104
+ if (user.displayName?.trim()) return user.displayName
105
+ if (user.name) {
106
+ const fullName = `${user.name.familyName ?? ""} ${user.name.givenName ?? ""}`.trim()
107
+ if (fullName) return fullName
108
+ }
109
+ return user.emails?.[0]?.value ?? "Unknown"
110
+ }
111
+
112
+ /**
113
+ * Provides a context for daily report actions including comments, stars, leads, and basic CRUD operations.
114
+ * Also manages optimistic UI updates and real-time synchronization via SSE.
115
+ * 日報のアクション(コメント追加・削除、スター、既読)を管理するプロバイダー。
116
+ * 楽観的UI更新とAPI通信の抽象化を提供します。
117
+ */
118
+ export const DailyReportActionProvider = ({ children, user, initialItems = [], userId }: { children: ReactNode; user?: DailyReportUser; initialItems?: DailyReportItem[]; userId?: string | null }) => {
119
+ const { apiBasePath, draftLabelName } = useDailyReportConfig()
120
+ const pendingAddCommentIds = useRef<Map<number, Map<number, string>>>(new Map())
121
+ const pendingDeleteCommentIds = useRef<Map<number, Set<number>>>(new Map())
122
+ const pendingStarUpdates = useRef<Map<number, boolean>>(new Map())
123
+ const pendingReadUpdates = useRef<Map<number, boolean>>(new Map())
124
+ const resolvedIdMap = useRef<Map<number, number>>(new Map())
125
+ const [version, setVersion] = useState(0)
126
+ const [items, setItems] = useState<DailyReportItem[]>(initialItems)
127
+ const [isSseEnabled, setIsSseEnabled] = useState(!!userId)
128
+ const pendingAddPromises = useRef<Map<number, Promise<number>>>(new Map())
129
+ const manuallyTrackingIds = useRef<Set<number>>(new Set())
130
+ /** 削除済み ID を記録し、マージロジックでの復活を防止する */
131
+ const removedIds = useRef<Set<number>>(new Set())
132
+ /** 自分が発行したアクションのID(SSEエコー無視用) */
133
+ const processedClientTempIds = useRef<Set<string>>(new Set())
134
+
135
+ /**
136
+ * Queue for SSE messages that arrived before the target report was cached.
137
+ * キャッシュ未取得時に到着した SSE メッセージの待機キュー。
138
+ * key: reportHubId, value: { messages, unsubscribe, timer }
139
+ */
140
+ const pendingSseQueue = useRef<
141
+ Map<
142
+ number,
143
+ {
144
+ messages: Exclude<DailyReportSseMessage, { type: "connected" }>[]
145
+ unsubscribe: () => void
146
+ timer: ReturnType<typeof setTimeout>
147
+ }
148
+ >
149
+ >(new Map())
150
+
151
+ /**
152
+ * Ref to always access the latest handleSseMessage for queue replay.
153
+ * キューの再処理時に常に最新の handleSseMessage を参照するための ref。
154
+ */
155
+ const handleSseMessageRef = useRef<(payload: Exclude<DailyReportSseMessage, { type: "connected" }>) => void>(() => {})
156
+
157
+ /**
158
+ * Enqueue an SSE message for a report that isn't cached yet.
159
+ * Subscribes to cache-ready events and replays queued messages when the cache becomes available.
160
+ *
161
+ * キャッシュ未取得のレポートへの SSE メッセージをキューに追加する。
162
+ * キャッシュ書き込みを監視し、書き込まれたらキューのメッセージを再処理する。
163
+ */
164
+ const enqueuePendingSseMessage = useCallback((reportHubId: number, message: Exclude<DailyReportSseMessage, { type: "connected" }>) => {
165
+ const queue = pendingSseQueue.current
166
+ const existing = queue.get(reportHubId)
167
+
168
+ if (existing) {
169
+ // 既存キューにメッセージを追加するだけ
170
+ existing.messages.push(message)
171
+ return
172
+ }
173
+
174
+ // 新規キューを作成
175
+ const entry = {
176
+ messages: [message],
177
+ unsubscribe: () => {},
178
+ timer: setTimeout(() => {
179
+ // 30秒経過してもキャッシュが来なければキューを破棄
180
+ const e = queue.get(reportHubId)
181
+ if (e) {
182
+ e.unsubscribe()
183
+ queue.delete(reportHubId)
184
+ logger.warn(`[SSE Queue] Expired: reportHubId=${reportHubId}, dropped=${e.messages.length} messages`)
185
+ }
186
+ }, 30_000),
187
+ }
188
+
189
+ // キャッシュ書き込みを監視
190
+ entry.unsubscribe = subscribeCacheReady(reportHubId, () => {
191
+ const e = queue.get(reportHubId)
192
+ if (!e) return
193
+
194
+ // キャッシュが書き込まれたか確認
195
+ if (!getCachedReport(reportHubId, { ignoreExpiry: true })) return
196
+
197
+ // キューをクリーンアップ
198
+ e.unsubscribe()
199
+ clearTimeout(e.timer)
200
+ queue.delete(reportHubId)
201
+
202
+ // キューのメッセージを最新の handleSseMessage で再処理
203
+ logger.info(`[SSE Queue] Replaying ${e.messages.length} messages for reportHubId=${reportHubId}`)
204
+ for (const msg of e.messages) {
205
+ handleSseMessageRef.current(msg)
206
+ }
207
+ })
208
+
209
+ queue.set(reportHubId, entry)
210
+ }, [])
211
+
212
+ const addProcessedId = (id: string) => {
213
+ processedClientTempIds.current.add(id)
214
+ // Auto-remove after 30 seconds to prevent memory leaks
215
+ // 30秒後に自動削除(メモリリーク防止)
216
+ setTimeout(() => {
217
+ processedClientTempIds.current.delete(id)
218
+ }, 30000)
219
+ }
220
+
221
+ const isProcessedId = useCallback((id: string | undefined): boolean => {
222
+ return !!id && processedClientTempIds.current.has(id)
223
+ }, [])
224
+
225
+ /**
226
+ * Upserts a report item into the list while keeping businessDate-desc order.
227
+ *
228
+ * レポートアイテムをリストへ upsert し、businessDate 降順を維持します。
229
+ */
230
+ const upsertItem = useCallback((reportHubId: number, businessDate: string | null | undefined) => {
231
+ setItems((prev) => {
232
+ const nextBusinessDate = businessDate ?? prev.find((item) => item.reportHubId === reportHubId)?.businessDate ?? null
233
+ const exists = prev.some((item) => item.reportHubId === reportHubId)
234
+ const merged = exists ? prev.map((item) => (item.reportHubId === reportHubId ? { ...item, businessDate: nextBusinessDate } : item)) : [{ reportHubId, businessDate: nextBusinessDate }, ...prev]
235
+
236
+ return merged.sort((a, b) => {
237
+ const left = a.businessDate ?? ""
238
+ const right = b.businessDate ?? ""
239
+ return right.localeCompare(left)
240
+ })
241
+ })
242
+ }, [])
243
+
244
+ // 未登録ユーザーは SSE を有効化できない
245
+ const toggleSse = () => {
246
+ if (!userId) return
247
+ setIsSseEnabled((prev) => !prev)
248
+ }
249
+
250
+ /**
251
+ * Remove a stale item from the list as a safety net.
252
+ * フェッチ後にレポートが見つからなかった場合の安全弁としてアイテムを除去する。
253
+ */
254
+ const removeStaleItem = useCallback((reportHubId: number) => {
255
+ removedIds.current.add(reportHubId)
256
+ setItems((prev) => prev.filter((item) => item.reportHubId !== reportHubId))
257
+ }, [])
258
+
259
+ /**
260
+ * Merge initialItems with prevItems instead of replacing.
261
+ * Items already in the list are kept even if temporarily absent from the API response.
262
+ * Removal is handled exclusively by the report-delete SSE handler.
263
+ *
264
+ * initialItems と prevItems をマージする(全置換ではない)。
265
+ * API レスポンスから一時的に欠落した ID も prevItems に残っていれば保持する。
266
+ * 削除は report-delete SSE ハンドラーでのみ行う。
267
+ */
268
+ // initialItems と prevItems のマージ — 削除済み ID の復活を防止する
269
+ // 外部リソース同期: React Router loader の initialItems 更新に反応する
270
+ useEffect(() => {
271
+ // initialItems に含まれない removedIds エントリは不要なのでクリーンアップ
272
+ const initialIds = new Set(initialItems.map((item) => item.reportHubId))
273
+ // Set のイテレーション中の delete は JS 仕様で安全 (visited は再訪問しない)
274
+ for (const id of removedIds.current) {
275
+ if (!initialIds.has(id)) {
276
+ removedIds.current.delete(id)
277
+ }
278
+ }
279
+
280
+ setItems((prevItems) => {
281
+ // initialItems をベースにマップを構築(removedIds を除外)
282
+ const mergedMap = new Map<number, DailyReportItem>()
283
+ for (const item of initialItems) {
284
+ if (!removedIds.current.has(item.reportHubId)) {
285
+ mergedMap.set(item.reportHubId, item)
286
+ }
287
+ }
288
+
289
+ // prevItems にのみ存在する ID を保持(削除は report-delete SSE で行う)
290
+ for (const item of prevItems) {
291
+ if (!(mergedMap.has(item.reportHubId) || removedIds.current.has(item.reportHubId))) {
292
+ mergedMap.set(item.reportHubId, item)
293
+ }
294
+ }
295
+
296
+ // businessDate 降順でソート
297
+ return Array.from(mergedMap.values()).sort((a, b) => {
298
+ const dateA = a.businessDate ? new Date(a.businessDate).getTime() : 0
299
+ const dateB = b.businessDate ? new Date(b.businessDate).getTime() : 0
300
+ return dateB - dateA
301
+ })
302
+ })
303
+ }, [initialItems])
304
+
305
+ const handleSseMessage = useCallback(
306
+ (payload: Exclude<DailyReportSseMessage, { type: "connected" }>) => {
307
+ // Ignore echo of own actions
308
+ // 自分が発行したアクションのエコーは無視する
309
+ // clientTempId は connected 以外すべてのメッセージに存在する(型で保証済み)
310
+ if (isProcessedId(payload.clientTempId)) {
311
+ return
312
+ }
313
+
314
+ switch (payload.type) {
315
+ case "comment-add": {
316
+ const { comment, reportHubId } = payload
317
+ const cached = getCachedReport(reportHubId, { ignoreExpiry: true })
318
+ if (cached) {
319
+ // Check for duplicates
320
+ // 重複チェック
321
+ if (cached.commentItems.some((c) => c.id === comment.id)) return
322
+
323
+ // Check if this comment matches a pending optimistic comment
324
+ let isMyComment = false
325
+ const pendingMap = pendingAddCommentIds.current.get(reportHubId)
326
+ if (pendingMap) {
327
+ for (const [tempId, content] of pendingMap.entries()) {
328
+ if (content === comment.content) {
329
+ pendingMap.delete(tempId)
330
+ resolvedIdMap.current.set(tempId, comment.id)
331
+ isMyComment = true
332
+ break
333
+ }
334
+ }
335
+ }
336
+
337
+ const newComment: DailyReportCommentItem = {
338
+ ...comment,
339
+ userName: comment.userName || (isMyComment ? getUserDisplayName(user) : "Unknown"),
340
+ createdAt: comment.createdAt || new Date().toISOString(),
341
+ isMine: isMyComment, // Use the detected ownership
342
+ }
343
+
344
+ const updatedReport = {
345
+ ...cached,
346
+ commentItems: [...cached.commentItems, newComment].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
347
+ }
348
+ applyDailyReportServerUpdates(reportHubId, updatedReport)
349
+ // Update mutation time to prevent stale fetch overwrite
350
+ // 古いフェッチによる上書きを防ぐために変更時刻を更新
351
+ acquireMutationLock(reportHubId)
352
+ releaseMutationLock(reportHubId)
353
+ setVersion((v) => v + 1) // UI更新トリガー
354
+ } else {
355
+ // キャッシュ未取得 → フェッチ完了後にリプレイ
356
+ enqueuePendingSseMessage(reportHubId, payload)
357
+ }
358
+ break
359
+ }
360
+ case "comment-delete": {
361
+ const { commentId, reportHubId } = payload
362
+ const cached = getCachedReport(reportHubId, { ignoreExpiry: true })
363
+ if (cached) {
364
+ const updatedReport = {
365
+ ...cached,
366
+ commentItems: cached.commentItems.filter((c) => c.id !== commentId),
367
+ }
368
+ applyDailyReportServerUpdates(reportHubId, updatedReport)
369
+ // Update mutation time to prevent stale fetch overwrite
370
+ // 古いフェッチによる上書きを防ぐために変更時刻を更新
371
+ acquireMutationLock(reportHubId)
372
+ releaseMutationLock(reportHubId)
373
+ setVersion((v) => v + 1)
374
+ } else {
375
+ enqueuePendingSseMessage(reportHubId, payload)
376
+ }
377
+ break
378
+ }
379
+ case "status-update": {
380
+ const { statusType, value, reportHubId } = payload
381
+ const cached = getCachedReport(reportHubId, { ignoreExpiry: true })
382
+
383
+ if (statusType === "star") {
384
+ if (pendingStarUpdates.current.get(reportHubId) === value) {
385
+ pendingStarUpdates.current.delete(reportHubId)
386
+ }
387
+ if (cached) {
388
+ const updatedReport = { ...cached, isStarred: value }
389
+ applyDailyReportServerUpdates(reportHubId, updatedReport)
390
+ setVersion((v) => v + 1)
391
+ } else {
392
+ enqueuePendingSseMessage(reportHubId, payload)
393
+ }
394
+ } else if (statusType === "read") {
395
+ if (pendingReadUpdates.current.get(reportHubId) === value) {
396
+ pendingReadUpdates.current.delete(reportHubId)
397
+ }
398
+ if (cached) {
399
+ const updatedReport = { ...cached, isRead: value }
400
+ applyDailyReportServerUpdates(reportHubId, updatedReport)
401
+ setVersion((v) => v + 1)
402
+ } else {
403
+ enqueuePendingSseMessage(reportHubId, payload)
404
+ }
405
+ }
406
+ break
407
+ }
408
+ case "report-delete": {
409
+ const { reportHubId } = payload
410
+ manuallyTrackingIds.current.delete(reportHubId)
411
+ // マージロジックでの復活を防止するために削除済み ID を記録
412
+ removedIds.current.add(reportHubId)
413
+
414
+ // 削除されたレポートへの待機中 SSE メッセージを破棄
415
+ const pendingEntry = pendingSseQueue.current.get(reportHubId)
416
+ if (pendingEntry) {
417
+ pendingEntry.unsubscribe()
418
+ clearTimeout(pendingEntry.timer)
419
+ pendingSseQueue.current.delete(reportHubId)
420
+ }
421
+
422
+ const cached = getCachedReport(reportHubId, { ignoreExpiry: true })
423
+ if (cached?.date) {
424
+ deleteDailyReportCache(reportHubId, cached.date)
425
+ setVersion((v) => v + 1)
426
+ }
427
+ setItems((prev) => prev.filter((item) => item.reportHubId !== reportHubId))
428
+ break
429
+ }
430
+ case "report-create":
431
+ case "report-update":
432
+ case "report-publish": {
433
+ // report はスキーマで必須なのでバリデーション通過後は必ず存在する(if ガード不要)
434
+ const { report } = payload
435
+ applyDailyReportServerUpdates(report.reportHubId, report)
436
+ if (payload.type === "report-create" || payload.type === "report-publish") {
437
+ manuallyTrackingIds.current.add(report.reportHubId)
438
+ upsertItem(report.reportHubId, report.date)
439
+ }
440
+ setVersion((v) => v + 1)
441
+ break
442
+ }
443
+ }
444
+ },
445
+ [isProcessedId, upsertItem, user, enqueuePendingSseMessage],
446
+ )
447
+
448
+ // 常に最新の handleSseMessage をキューのリプレイから参照できるようにする
449
+ handleSseMessageRef.current = handleSseMessage
450
+
451
+ /**
452
+ * Cleanup all pending SSE queues on unmount.
453
+ * アンマウント時に全キューをクリーンアップする。
454
+ */
455
+ useEffect(() => {
456
+ return () => {
457
+ for (const entry of pendingSseQueue.current.values()) {
458
+ entry.unsubscribe()
459
+ clearTimeout(entry.timer)
460
+ }
461
+ pendingSseQueue.current.clear()
462
+ }
463
+ }, [])
464
+
465
+ useDailyReportSseConnection({ isSseEnabled, onMessage: handleSseMessage })
466
+
467
+ /**
468
+ * Common helper function to execute API actions.
469
+ * APIアクションを実行する共通ヘルパー関数。
470
+ */
471
+ const executeAction = async <T,>(
472
+ reportHubId: number,
473
+ intent: string,
474
+ businessDate: string,
475
+ params: Record<string, string>,
476
+ callbacks: {
477
+ onOptimistic?: () => void
478
+ onSuccess?: (data: ActionResponse) => Promise<T> | T
479
+ onFailure?: () => void
480
+ },
481
+ options?: {
482
+ keepLock?: boolean
483
+ skipLock?: boolean
484
+ },
485
+ ): Promise<T> => {
486
+ if (!options?.skipLock) acquireMutationLock(reportHubId)
487
+ callbacks.onOptimistic?.()
488
+
489
+ // Generate or use provided clientTempId
490
+ const clientTempId = params.clientTempId || crypto.randomUUID()
491
+ addProcessedId(clientTempId)
492
+
493
+ const formData = new FormData()
494
+ formData.append("intent", intent)
495
+ formData.append("reportHubId", String(reportHubId))
496
+ formData.append("businessDate", businessDate)
497
+ formData.append("operationTimestamp", String(Date.now()))
498
+ formData.append("clientTempId", clientTempId)
499
+
500
+ for (const [k, v] of Object.entries(params)) {
501
+ if (k !== "clientTempId") {
502
+ formData.append(k, v)
503
+ }
504
+ }
505
+
506
+ try {
507
+ const res = await fetch(`${apiBasePath}/action`, {
508
+ method: "POST",
509
+ body: formData,
510
+ headers: { Accept: "application/json" },
511
+ })
512
+ if (!res.ok) {
513
+ const error = new Error(`Failed to ${intent}`)
514
+ Object.assign(error, { status: res.status })
515
+ throw error
516
+ }
517
+ const data = await res.json()
518
+ if (data.status !== "OK" || (intent !== "create" && String(data.reportHubId) !== String(reportHubId))) {
519
+ throw new Error(`Invalid response for ${intent}`)
520
+ }
521
+ if (typeof data.clientTempId !== "string" || data.clientTempId !== clientTempId) {
522
+ throw new Error(`Invalid clientTempId for ${intent}`)
523
+ }
524
+ return (await callbacks.onSuccess?.(data)) as T
525
+ } catch (e) {
526
+ logger.error(`${intent} failed:`, e)
527
+ callbacks.onFailure?.()
528
+ throw e
529
+ } finally {
530
+ if (!(options?.keepLock || options?.skipLock)) {
531
+ releaseMutationLock(reportHubId)
532
+ }
533
+ }
534
+ }
535
+
536
+ const addComment = async (reportHubId: number, content: string, businessDate: string) => {
537
+ const id = Number(reportHubId)
538
+ const tempId = -1 * Date.now()
539
+ const promise = executeAction(
540
+ id,
541
+ "addComment",
542
+ businessDate,
543
+ { content, clientTempId: String(tempId) },
544
+ {
545
+ onOptimistic: () => {
546
+ if (!pendingAddCommentIds.current.has(id)) pendingAddCommentIds.current.set(id, new Map())
547
+ pendingAddCommentIds.current.get(id)?.set(tempId, content)
548
+ setVersion((v) => v + 1)
549
+ },
550
+ onSuccess: (data) => {
551
+ if (!data.newComment) throw new Error("No new comment returned")
552
+ const realId = data.newComment.id
553
+ resolvedIdMap.current.set(tempId, realId)
554
+ pendingAddCommentIds.current.get(id)?.delete(tempId)
555
+
556
+ const cachedReport = getCachedReport(id, { ignoreExpiry: true })
557
+ if (cachedReport && !cachedReport.commentItems.some((c) => c.id === realId)) {
558
+ updateDailyReportCache(id, {
559
+ commentItems: [...cachedReport.commentItems, { ...data.newComment, id: realId, isMine: true, userName: getUserDisplayName(user) }],
560
+ updatedAt: data.updatedAt,
561
+ })
562
+ }
563
+ return realId
564
+ },
565
+ onFailure: () => {
566
+ pendingAddCommentIds.current.get(id)?.delete(tempId)
567
+ },
568
+ },
569
+ { skipLock: true },
570
+ ).finally(() => pendingAddPromises.current.delete(tempId))
571
+
572
+ pendingAddPromises.current.set(tempId, promise)
573
+ await promise
574
+ }
575
+
576
+ const deleteComment = async (reportHubId: number, commentId: number, businessDate: string) => {
577
+ const id = Number(reportHubId)
578
+ // 1. Optimistic UI Update
579
+ if (!pendingDeleteCommentIds.current.has(id)) pendingDeleteCommentIds.current.set(id, new Set())
580
+ pendingDeleteCommentIds.current.get(id)?.add(commentId)
581
+ setVersion((v) => v + 1)
582
+
583
+ // 2. Resolve ID
584
+ let realId = commentId
585
+ if (commentId < 0) {
586
+ const resolved = resolvedIdMap.current.get(commentId)
587
+ if (resolved !== undefined) {
588
+ realId = resolved
589
+ } else {
590
+ const promise = pendingAddPromises.current.get(commentId)
591
+ if (!promise) return // 既に削除済みかエラー
592
+ try {
593
+ realId = await promise
594
+ } catch {
595
+ return // 追加失敗時は削除不要
596
+ }
597
+ }
598
+ }
599
+
600
+ // 3. Server Request
601
+ await executeAction(
602
+ id,
603
+ "deleteComment",
604
+ businessDate,
605
+ { commentId: String(realId) },
606
+ {
607
+ onSuccess: (data) => {
608
+ const deletedId = typeof data.deletedCommentId === "string" ? Number(data.deletedCommentId) : realId
609
+ const cachedReport = getCachedReport(id, { ignoreExpiry: true })
610
+ if (cachedReport) {
611
+ updateDailyReportCache(id, {
612
+ commentItems: cachedReport.commentItems.filter((c) => c.id !== deletedId),
613
+ updatedAt: data.updatedAt,
614
+ })
615
+ registerRecentDeletion(deletedId)
616
+ }
617
+ pendingDeleteCommentIds.current.get(id)?.delete(commentId)
618
+ },
619
+ onFailure: () => {
620
+ pendingDeleteCommentIds.current.get(id)?.delete(commentId)
621
+ },
622
+ },
623
+ { skipLock: true },
624
+ )
625
+ }
626
+
627
+ const toggleStar = async (reportHubId: number, isStarred: boolean, businessDate: string) => {
628
+ const id = Number(reportHubId)
629
+ await executeAction(
630
+ id,
631
+ "toggleStar",
632
+ businessDate,
633
+ { isStarred: String(isStarred) },
634
+ {
635
+ onOptimistic: () => {
636
+ updateDailyReportCache(id, { isStarred })
637
+ pendingStarUpdates.current.set(id, isStarred)
638
+ setVersion((v) => v + 1)
639
+ },
640
+ onSuccess: (data) => {
641
+ if (data.updatedStatus && typeof data.updatedStatus.isStarred === "boolean") {
642
+ const cached = getCachedReport(id, { ignoreExpiry: true })
643
+ if (cached) {
644
+ updateDailyReportCache(id, { isStarred: data.updatedStatus.isStarred, updatedAt: data.updatedAt })
645
+ setVersion((v) => v + 1)
646
+ }
647
+ }
648
+ pendingStarUpdates.current.delete(id)
649
+ },
650
+ onFailure: () => {
651
+ updateDailyReportCache(id, { isStarred: !isStarred })
652
+ pendingStarUpdates.current.delete(id)
653
+ },
654
+ },
655
+ { skipLock: true },
656
+ )
657
+ }
658
+
659
+ const toggleReadImpl = async (reportHubId: number, isRead: boolean, businessDate: string) => {
660
+ const id = Number(reportHubId)
661
+ await executeAction(
662
+ id,
663
+ "toggleRead",
664
+ businessDate,
665
+ { isRead: String(isRead) },
666
+ {
667
+ onOptimistic: () => {
668
+ updateDailyReportCache(id, { isRead })
669
+ pendingReadUpdates.current.set(id, isRead)
670
+ setVersion((v) => v + 1)
671
+ },
672
+ onSuccess: (data) => {
673
+ if (data.updatedStatus && typeof data.updatedStatus.isRead === "boolean") {
674
+ const cached = getCachedReport(id, { ignoreExpiry: true })
675
+ if (cached) {
676
+ updateDailyReportCache(id, { isRead: data.updatedStatus.isRead, updatedAt: data.updatedAt })
677
+ setVersion((v) => v + 1)
678
+ }
679
+ }
680
+ pendingReadUpdates.current.delete(id)
681
+ },
682
+ onFailure: () => {
683
+ updateDailyReportCache(id, { isRead: !isRead })
684
+ pendingReadUpdates.current.delete(id)
685
+ },
686
+ },
687
+ { skipLock: true },
688
+ )
689
+ }
690
+ // ref 経由で最新実装を参照し、安定した関数 identity を提供する
691
+ // (SSE イベントによるコンテキスト再レンダリングで auto-read タイマーがリセットされるのを防止)
692
+ const toggleReadRef = useRef(toggleReadImpl)
693
+ toggleReadRef.current = toggleReadImpl
694
+ const toggleRead = useCallback((reportHubId: number, isRead: boolean, businessDate: string) => toggleReadRef.current(reportHubId, isRead, businessDate), [])
695
+
696
+ const createReport = async (businessDate: string) => {
697
+ const tempId = -1 * Date.now()
698
+ // Optimistic UI update
699
+ const now = new Date().toISOString()
700
+ const dummyReport: DailyReportDetail = {
701
+ reportHubId: tempId,
702
+ date: businessDate,
703
+ createdAt: now,
704
+ author: getUserDisplayName(user),
705
+ userId: user?.id ?? "unknown",
706
+ employeeName: getUserDisplayName(user),
707
+ updatedBy: getUserDisplayName(user),
708
+ updatedAt: now,
709
+ category: null,
710
+ creationCategory: null,
711
+ visitTimeFrom: null,
712
+ visitTimeTo: null,
713
+ customerName: null,
714
+ interviewers: [],
715
+ subject: "",
716
+ content: "",
717
+ comments: [],
718
+ isRead: true,
719
+ isStarred: false,
720
+ labels: [{ id: 0, name: draftLabelName, color: "#9ca3af" }],
721
+ commentItems: [],
722
+ }
723
+ writeCache(dummyReport, Date.now() + 300000, false)
724
+
725
+ setItems((prev) => [
726
+ {
727
+ reportHubId: tempId,
728
+ businessDate: businessDate,
729
+ },
730
+ ...prev,
731
+ ])
732
+
733
+ try {
734
+ const res = await executeAction<ActionResponse>(
735
+ 0,
736
+ "create",
737
+ businessDate,
738
+ { clientTempId: String(tempId) },
739
+ {
740
+ onSuccess: (data) => data,
741
+ },
742
+ )
743
+ // Validate response mapping
744
+ if (res.clientTempId && res.clientTempId !== String(tempId)) {
745
+ logger.warn(`[createReport] ID mismatch. req=${tempId}, res=${res.clientTempId}`)
746
+ // Mismatch implies this response is not for this specific request. Throwing here triggers revert.
747
+ throw new Error("Optimistic ID mismatch")
748
+ }
749
+
750
+ const newId = Number(res.reportHubId)
751
+
752
+ // newId を一時的にロックし、createReport 以前に開始された背景 prefetch の
753
+ // cacheMany による stale データ上書きを防止する
754
+ acquireMutationLock(newId)
755
+
756
+ // Seed cache with server response (checking staleness)
757
+ if (res.report) {
758
+ applyDailyReportServerUpdates(newId, res.report)
759
+ setVersion((v) => v + 1)
760
+ }
761
+
762
+ setItems((prev) => {
763
+ // If newId is already present (e.g. from SSE), just remove tempId to avoid duplication
764
+ if (prev.some((item) => item.reportHubId === newId)) {
765
+ return prev.filter((item) => item.reportHubId !== tempId)
766
+ }
767
+ return prev.map((item) => (item.reportHubId === tempId ? { ...item, reportHubId: newId } : item))
768
+ })
769
+
770
+ // ロック解放: lastMutations に newId が記録され、以降の cacheMany でも保護される
771
+ setTimeout(() => releaseMutationLock(newId), 1000)
772
+
773
+ return newId
774
+ } catch (e) {
775
+ logger.error("[createReport] Failed or mismatched", e)
776
+ // Revert optimistic update
777
+ setItems((prev) => prev.filter((item) => item.reportHubId !== tempId))
778
+ throw e
779
+ }
780
+ }
781
+
782
+ const updateReport = async (reportHubId: number, data: { title?: string; content?: string }, businessDate: string) => {
783
+ const id = Number(reportHubId)
784
+ await executeAction(
785
+ id,
786
+ "update",
787
+ businessDate,
788
+ { ...(data.title && { title: data.title }), ...(data.content && { content: data.content }) },
789
+ {
790
+ onSuccess: () => {
791
+ const cached = getCachedReport(id, { ignoreExpiry: true })
792
+ if (cached) {
793
+ updateDailyReportCache(id, {
794
+ subject: data.title ?? cached.subject,
795
+ content: data.content ?? cached.content,
796
+ })
797
+ setVersion((v) => v + 1)
798
+ }
799
+ },
800
+ },
801
+ )
802
+ }
803
+
804
+ const publishReport = async (reportHubId: number, businessDate: string) => {
805
+ const id = Number(reportHubId)
806
+ const cached = getCachedReport(id, { ignoreExpiry: true })
807
+ const oldLabels = cached?.labels || []
808
+
809
+ await executeAction(
810
+ id,
811
+ "publish",
812
+ businessDate,
813
+ {},
814
+ {
815
+ onOptimistic: () => {
816
+ if (cached) {
817
+ const newLabels = cached.labels.filter((l) => l.name !== draftLabelName)
818
+ updateDailyReportCache(id, {
819
+ labels: newLabels,
820
+ })
821
+ setVersion((v) => v + 1)
822
+ }
823
+ },
824
+ onSuccess: (data) => {
825
+ // サーバーからのレスポンスを正とする
826
+ if (data.report) {
827
+ applyDailyReportServerUpdates(id, data.report)
828
+ setVersion((v) => v + 1)
829
+ }
830
+ },
831
+ onFailure: () => {
832
+ // ロールバック
833
+ updateDailyReportCache(id, {
834
+ labels: oldLabels,
835
+ })
836
+ setVersion((v) => v + 1)
837
+ },
838
+ },
839
+ { skipLock: true },
840
+ )
841
+ }
842
+
843
+ const deleteReport = async (reportHubId: number, businessDate: string) => {
844
+ const id = Number(reportHubId)
845
+ const targetItem = items.find((i) => i.reportHubId === id)
846
+
847
+ // マージロジックでの復活を防止するために削除済み ID を記録
848
+ removedIds.current.add(id)
849
+ // Optimistic UI update
850
+ setItems((prev) => prev.filter((item) => item.reportHubId !== id))
851
+
852
+ try {
853
+ await executeAction(
854
+ id,
855
+ "delete",
856
+ businessDate,
857
+ {},
858
+ {
859
+ onSuccess: (_data) => {
860
+ deleteDailyReportCache(id, businessDate)
861
+ // Note: Item already removed from list optimistically
862
+ },
863
+ },
864
+ )
865
+ } catch (e: unknown) {
866
+ const error = e as { status?: number }
867
+ // 既に削除済み(404)の場合は、成功とみなしてロールバックしない(キャッシュはクリアする)
868
+ if (error.status === 404) {
869
+ deleteDailyReportCache(id, businessDate)
870
+ return
871
+ }
872
+
873
+ // それ以外のエラーの場合は、楽観的更新をロールバックする
874
+ removedIds.current.delete(id)
875
+ if (targetItem) {
876
+ setItems((prev) => {
877
+ if (prev.some((i) => i.reportHubId === id)) return prev
878
+ return [targetItem, ...prev].sort((a, b) => (b.businessDate ?? "").localeCompare(a.businessDate ?? ""))
879
+ })
880
+ }
881
+ }
882
+ }
883
+
884
+ return (
885
+ <DailyReportActionContext.Provider
886
+ value={{
887
+ user,
888
+ pendingAddCommentIds,
889
+ pendingDeleteCommentIds,
890
+ pendingStarUpdates,
891
+ pendingReadUpdates,
892
+ resolvedIdMap,
893
+ version,
894
+ items,
895
+ isSseEnabled,
896
+ toggleSse,
897
+ addComment,
898
+ deleteComment,
899
+ toggleStar,
900
+ toggleRead,
901
+ createReport,
902
+ updateReport,
903
+ publishReport,
904
+ deleteReport,
905
+ removeStaleItem,
906
+ }}>
907
+ {children}
908
+ </DailyReportActionContext.Provider>
909
+ )
910
+ }