@aiquants/daily-report 0.7.0 → 0.9.0

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 (67) hide show
  1. package/README.md +13 -6
  2. package/dist/client.d.mts +438 -26
  3. package/dist/client.d.ts +438 -26
  4. package/dist/client.js +5 -4
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +5 -4
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +115 -3
  9. package/dist/index.d.ts +115 -3
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/server.d.mts +15 -0
  15. package/dist/server.d.ts +15 -0
  16. package/dist/server.js +9 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +9 -6
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
  21. package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
  22. package/dist/styles/daily-report.standalone.css +1 -1
  23. package/package.json +3 -3
  24. package/src/client/components/daily-report-comment-item.tsx +6 -1
  25. package/src/client/components/daily-report-detail-list.tsx +65 -34
  26. package/src/client/components/daily-report-edit-form.tsx +6 -0
  27. package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
  28. package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
  29. package/src/client/components/daily-report-list.tsx +88 -55
  30. package/src/client/components/daily-report-page.spec.tsx +119 -0
  31. package/src/client/components/daily-report-page.tsx +62 -69
  32. package/src/client/components/daily-report-resolved-content.tsx +36 -10
  33. package/src/client/components/report-views.spec.tsx +37 -2
  34. package/src/client/config-context.tsx +18 -0
  35. package/src/client/contexts/daily-report-action-context.spec.tsx +278 -0
  36. package/src/client/contexts/daily-report-action-context.tsx +203 -65
  37. package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
  38. package/src/client/contexts/daily-report-error-context.tsx +98 -0
  39. package/src/client/hooks/use-animated-number.spec.ts +103 -0
  40. package/src/client/hooks/use-animated-number.ts +69 -0
  41. package/src/client/hooks/use-daily-report.spec.ts +50 -2
  42. package/src/client/hooks/use-daily-report.ts +30 -1
  43. package/src/client/route-helpers.spec.ts +100 -0
  44. package/src/client/route-helpers.ts +20 -41
  45. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +704 -0
  46. package/src/client/streaming/daily-report-ids-stream-client.ts +677 -0
  47. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
  48. package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
  49. package/src/client.ts +5 -0
  50. package/src/index.ts +1 -0
  51. package/src/server/handlers.action.spec.ts +44 -0
  52. package/src/server/handlers.ids-stream.spec.ts +414 -0
  53. package/src/server/handlers.ts +324 -8
  54. package/src/server/service.spec.ts +83 -7
  55. package/src/server/service.ts +100 -3
  56. package/src/server/test-helpers/handlers-config.ts +48 -1
  57. package/src/server.spec.ts +49 -0
  58. package/src/server.ts +7 -0
  59. package/src/shared/business-date.spec.ts +22 -1
  60. package/src/shared/business-date.ts +20 -0
  61. package/src/shared/comment-adapter.spec.ts +38 -0
  62. package/src/shared/comment-adapter.ts +9 -7
  63. package/src/shared/ids-stream.spec.ts +65 -0
  64. package/src/shared/ids-stream.ts +139 -0
  65. package/src/shared/sse-schema.ts +5 -0
  66. package/src/shared/text-utils.spec.ts +42 -0
  67. package/src/shared/text-utils.ts +8 -0
@@ -3,16 +3,63 @@
3
3
  * 日報の楽観的 CRUD と SSE 同期を提供するアクションコンテキスト。
4
4
  */
5
5
  import { type Context, createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"
6
- import { useRevalidator } from "react-router"
6
+ import { compareIdsStreamOrder, isIdsStreamOrdered, upsertInIdsStreamOrder } from "../../shared/ids-stream"
7
7
  import { createLogger, LogLevel } from "../../shared/logger"
8
8
  import type { DailyReportSseMessage } from "../../shared/sse-schema"
9
9
  import type { DailyReportCommentItem, DailyReportDetail, DailyReportItem, DailyReportUser } from "../../shared/types"
10
10
  import { useDailyReportConfig } from "../config-context"
11
- import { acquireMutationLock, applyDailyReportServerUpdates, clearDailyReportCache, DailyReportCache, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
11
+ import {
12
+ acquireMutationLock,
13
+ applyBroadcastReportUpdate,
14
+ applyDailyReportServerUpdates,
15
+ clearDailyReportCache,
16
+ DailyReportCache,
17
+ deleteDailyReportCache,
18
+ getCachedReport,
19
+ registerRecentDeletion,
20
+ releaseMutationLock,
21
+ subscribeCacheReady,
22
+ updateDailyReportCache,
23
+ writeCache,
24
+ } from "../hooks/use-daily-report"
12
25
  import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
26
+ import { getDailyReportIdsStreamSessionUserKey, refreshDailyReportIdsStream, removeDailyReportIdsStreamItem, upsertDailyReportIdsStreamItem, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
27
+ import { useDailyReportErrorSurface } from "./daily-report-error-context"
13
28
 
14
29
  const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
15
30
 
31
+ /**
32
+ * Operation intent -> default banner label (Japanese).
33
+ * 操作 intent -> 既定バナー用の日本語ラベル。ホストは config.onError で構造化情報 (operation)
34
+ * を受け取り独自メッセージへ差し替えできる。
35
+ */
36
+ const OPERATION_LABELS: Record<string, string> = {
37
+ create: "日報の作成",
38
+ update: "日報の保存",
39
+ publish: "日報の公開",
40
+ delete: "日報の削除",
41
+ addComment: "コメントの投稿",
42
+ deleteComment: "コメントの削除",
43
+ toggleStar: "スターの更新",
44
+ toggleRead: "既読状態の更新",
45
+ }
46
+
47
+ /** intent から利用者向けの失敗メッセージを組み立てる。 */
48
+ const describeOperationFailure = (intent: string): string => {
49
+ const label = OPERATION_LABELS[intent] ?? "操作"
50
+ return `${label}に失敗しました。時間をおいて再度お試しください。`
51
+ }
52
+
53
+ /**
54
+ * Stable empty default for the optional `initialItems` prop.
55
+ * 省略可能な `initialItems` の安定した空既定値。
56
+ *
57
+ * 既定を `= []` にすると毎レンダーで新しい配列参照になり、render-phase の
58
+ * `initialItems !== prevInitialItems` 判定が常に真になって setState が無限ループし、
59
+ * React が描画を中断する。単一の凍結参照を共有して同一性を保つ。
60
+ */
61
+ const EMPTY_INITIAL_ITEMS: DailyReportItem[] = Object.freeze([]) as readonly DailyReportItem[] as DailyReportItem[]
62
+
16
63
  type ActionResponse = {
17
64
  status: string
18
65
  reportHubId: number
@@ -25,7 +72,7 @@ type ActionResponse = {
25
72
  [key: string]: unknown
26
73
  }
27
74
 
28
- type DailyReportActionContextType = {
75
+ export type DailyReportActionContextType = {
29
76
  /** 表示中ユーザー (楽観的コメントの作者名等に使用)。 */
30
77
  user?: DailyReportUser
31
78
  /** reportHubId -> Map<tempCommentId, content> (追加コメントの一時IDと内容) */
@@ -120,8 +167,13 @@ const getUserDisplayName = (user: DailyReportUser | undefined): string => {
120
167
  * 日報のアクション(コメント追加・削除、スター、既読)を管理するプロバイダー。
121
168
  * 楽観的UI更新とAPI通信の抽象化を提供します。
122
169
  */
123
- export const DailyReportActionProvider = ({ children, user, initialItems = [], userId }: { children: ReactNode; user?: DailyReportUser; initialItems?: DailyReportItem[]; userId?: string | null }) => {
170
+ export const DailyReportActionProvider = ({ children, user, initialItems = EMPTY_INITIAL_ITEMS, userId }: { children: ReactNode; user?: DailyReportUser; initialItems?: DailyReportItem[]; userId?: string | null }) => {
124
171
  const { apiBasePath, draftLabelName } = useDailyReportConfig()
172
+ // ミューテーション失敗の通知シーム。executeAction の catch から呼び、失敗を可視化する。
173
+ // ref 経由で読み、executeAction の依存に載せない (通知参照は安定しているが防御的に)
174
+ const { notifyError } = useDailyReportErrorSurface()
175
+ const notifyErrorRef = useRef(notifyError)
176
+ notifyErrorRef.current = notifyError
125
177
  const pendingAddCommentIds = useRef<Map<number, Map<number, string>>>(new Map())
126
178
  const pendingDeleteCommentIds = useRef<Map<number, Set<number>>>(new Map())
127
179
  const pendingStarUpdates = useRef<Map<number, boolean>>(new Map())
@@ -137,18 +189,35 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
137
189
  /** 自分が発行したアクションのID(SSEエコー無視用) */
138
190
  const processedClientTempIds = useRef<Set<string>>(new Set())
139
191
 
140
- const revalidator = useRevalidator()
192
+ // トゥームストーン掃除のゲート参照。ストリーミング途中の部分配列で掃除すると、
193
+ // 後続チャンク到着前に削除された ID のトゥームストーンが失われ、遅れて届いた
194
+ // 削除前スナップショットのチャンクで日報が復活する (effect の依存を増やさないため ref 経由)
195
+ const streamState = useDailyReportIdsStream()
196
+ const isStreamSettledRef = useRef(false)
197
+ isStreamSettledRef.current = streamState.phase === "complete" && !streamState.isRevalidating
198
+
199
+ /**
200
+ * 遅延解決するミューテーション継続の門番。POST /action の待機中にリロード無しの
201
+ * ユーザー切替が起きた場合、この判定が偽になり、継続はモジュール常駐状態
202
+ * (常駐ストリームセッション・DailyReportCache・ミューテーションロック) への
203
+ * 書き込みを放棄する。放棄しないと、旧ユーザーの日報行が新ユーザーの一覧へ
204
+ * 注入され (観測集合に刻まれて完走剪定も免れる)、切替時に破棄したはずの
205
+ * 詳細キャッシュへ旧ユーザーの下書き本文が再シードされる (実測再現済み)。
206
+ * 同一ユーザーの通常ナビゲーション (アンマウント) ではセッションが生き続けるため
207
+ * 真のままであり、作成直後の日報が常駐データから欠ける round-3 の退行は起こさない。
208
+ */
209
+ const isSessionStillOurs = useCallback(() => getDailyReportIdsStreamSessionUserKey() === (userId ?? null), [userId])
141
210
 
142
211
  const refetchData = useCallback(async () => {
143
212
  logger.info("[DailyReportActionProvider] Refetching data without page reload...")
144
- if (revalidator.state === "idle") {
145
- revalidator.revalidate()
146
- }
213
+ // ID 一覧はストリームセッションのバックグラウンド再検証で更新する
214
+ // (ルーター再検証は dailyReportShouldRevalidate に抑止されるため使わない)
215
+ refreshDailyReportIdsStream()
147
216
  setVersion((v) => v + 1)
148
217
  DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
149
218
  listener(-1)
150
219
  })
151
- }, [revalidator])
220
+ }, [])
152
221
 
153
222
  const clearCacheAndRefetch = useCallback(async () => {
154
223
  logger.info("[DailyReportActionProvider] Clearing client & server/DB cache and refetching data without page reload...")
@@ -175,15 +244,16 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
175
244
  logger.warn("[DailyReportActionProvider] Failed to clear server-side cache:", e)
176
245
  }
177
246
 
178
- // 3. ルートローダーの再フェッチ & UI 再描画
179
- if (revalidator.state === "idle") {
180
- revalidator.revalidate()
181
- }
247
+ // 3. ID 一覧ストリームをサーバーキャッシュバイパスで再検証 & UI 再描画。
248
+ // POST 待機中にユーザー切替が起きた場合は放棄する (新ユーザーのストリームを
249
+ // 冗長に強制再ダウンロードさせない)
250
+ if (!isSessionStillOurs()) return
251
+ refreshDailyReportIdsStream({ forceRefresh: true })
182
252
  setVersion((v) => v + 1)
183
253
  DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
184
254
  listener(-1)
185
255
  })
186
- }, [apiBasePath, revalidator])
256
+ }, [apiBasePath, isSessionStillOurs])
187
257
 
188
258
  /**
189
259
  * Queue for SSE messages that arrived before the target report was cached.
@@ -281,16 +351,18 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
281
351
  * レポートアイテムをリストへ upsert し、businessDate 降順を維持します。
282
352
  */
283
353
  const upsertItem = useCallback((reportHubId: number, businessDate: string | null | undefined) => {
354
+ // モジュール常駐ストリームセッションへも反映する (鮮度窓内の再訪で作成直後の日報が
355
+ // 消えないように)。businessDate 未指定は「既存値を保持」の意図なので中継しない
356
+ if (businessDate !== undefined) {
357
+ upsertDailyReportIdsStreamItem({ reportHubId, businessDate: businessDate ?? null })
358
+ }
284
359
  setItems((prev) => {
285
- const nextBusinessDate = businessDate ?? prev.find((item) => item.reportHubId === reportHubId)?.businessDate ?? null
286
- const exists = prev.some((item) => item.reportHubId === reportHubId)
287
- const merged = exists ? prev.map((item) => (item.reportHubId === reportHubId ? { ...item, businessDate: nextBusinessDate } : item)) : [{ reportHubId, businessDate: nextBusinessDate }, ...prev]
288
-
289
- return merged.sort((a, b) => {
290
- const left = a.businessDate ?? ""
291
- const right = b.businessDate ?? ""
292
- return right.localeCompare(left)
293
- })
360
+ const existing = prev.find((item) => item.reportHubId === reportHubId)
361
+ const nextBusinessDate = businessDate ?? existing?.businessDate ?? null
362
+ // 正準比較器 (営業日降順 → ID 降順) の位置へ二分探索で upsert する。
363
+ // 以前は SSE 1 件ごとに全件を localeCompare でソートし直していた (O(n log n)
364
+ // かつ ID 降順の副キー無しで、同日内の新規作成が誤った位置に描画されていた)
365
+ return upsertInIdsStreamOrder(prev, { reportHubId, businessDate: nextBusinessDate, sourceType: existing?.sourceType })
294
366
  })
295
367
  }, [])
296
368
 
@@ -304,59 +376,88 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
304
376
  * Remove a stale item from the list as a safety net.
305
377
  * フェッチ後にレポートが見つからなかった場合の安全弁としてアイテムを除去する。
306
378
  */
307
- const removeStaleItem = useCallback((reportHubId: number) => {
308
- removedIds.current.add(reportHubId)
309
- setItems((prev) => prev.filter((item) => item.reportHubId !== reportHubId))
310
- }, [])
379
+ const removeStaleItem = useCallback(
380
+ (reportHubId: number) => {
381
+ // ユーザー切替後の遅延発火は新ユーザーのセッションへトゥームストーンを刻んでしまう
382
+ if (!isSessionStillOurs()) return
383
+ removedIds.current.add(reportHubId)
384
+ // 常駐ストリームデータからも除去する (再訪時に安全弁が再発動し続けるのを防ぐ)
385
+ removeDailyReportIdsStreamItem(reportHubId)
386
+ setItems((prev) => prev.filter((item) => item.reportHubId !== reportHubId))
387
+ },
388
+ [isSessionStillOurs],
389
+ )
311
390
 
312
391
  /**
313
- * Merge initialItems with prevItems instead of replacing.
392
+ * Merge initialItems with prevItems instead of replacing (render-phase derived state).
314
393
  * Items already in the list are kept even if temporarily absent from the API response.
315
394
  * Removal is handled exclusively by the report-delete SSE handler.
316
395
  *
317
- * initialItems と prevItems をマージする(全置換ではない)。
318
- * API レスポンスから一時的に欠落した ID も prevItems に残っていれば保持する。
319
- * 削除は report-delete SSE ハンドラーでのみ行う。
396
+ * initialItems と prevItems をマージする (全置換ではない)。render-phase の
397
+ * 派生状態パターン (prev 比較 + render 中の setState) で行うことが重要 —
398
+ * effect で行うと「最初のチャンクのコミットは items が空 (VirtualScroll が
399
+ * 一瞬 No items を描画) → effect 後の 2 コミット目で行が出る」という
400
+ * 1 コミット分の遅延と空表示フラッシュが生まれる。render 中の setState は
401
+ * React が子のコミット前に同一コンポーネントを再レンダーする公式パターン。
402
+ * マージは純粋 (ref は読み取りのみ)。StrictMode の二重レンダーにも冪等。
320
403
  */
321
- // initialItems と prevItems のマージ — 削除済み ID の復活を防止する
322
- // 外部リソース同期: React Router loader の initialItems 更新に反応する
323
- useEffect(() => {
324
- // initialItems に含まれない removedIds エントリは不要なのでクリーンアップ
325
- const initialIds = new Set(initialItems.map((item) => item.reportHubId))
326
- // Set のイテレーション中の delete は JS 仕様で安全 (visited は再訪問しない)
327
- for (const id of removedIds.current) {
328
- if (!initialIds.has(id)) {
329
- removedIds.current.delete(id)
404
+ const [prevInitialItems, setPrevInitialItems] = useState(initialItems)
405
+ if (initialItems !== prevInitialItems) {
406
+ setPrevInitialItems(initialItems)
407
+ // initialItems をベースにマップを構築 (removedIds を除外)
408
+ const mergedMap = new Map<number, DailyReportItem>()
409
+ for (const item of initialItems) {
410
+ if (!removedIds.current.has(item.reportHubId)) {
411
+ mergedMap.set(item.reportHubId, item)
330
412
  }
331
413
  }
332
-
333
- setItems((prevItems) => {
334
- // initialItems をベースにマップを構築(removedIds を除外)
335
- const mergedMap = new Map<number, DailyReportItem>()
336
- for (const item of initialItems) {
337
- if (!removedIds.current.has(item.reportHubId)) {
338
- mergedMap.set(item.reportHubId, item)
339
- }
414
+ // 現在の items にのみ存在する ID を保持 (削除は report-delete SSE で行う)
415
+ for (const item of items) {
416
+ if (!(mergedMap.has(item.reportHubId) || removedIds.current.has(item.reportHubId))) {
417
+ mergedMap.set(item.reportHubId, item)
340
418
  }
419
+ }
420
+ // 並び順は正準比較器 (compareIdsStreamOrder: 営業日降順 → ID 降順、null 営業日は
421
+ // 末尾) に一本化する。ストリームはソート順どおりに届き末尾へ追記されるため通常は
422
+ // 整列済み — O(n) の検査で確認できたらソートを丸ごと省略する (20 万件級で毎 publish
423
+ // の O(n log n) を O(n) へ落とす)。以前は営業日のみの比較で ID 降順の副キーが無く、
424
+ // 前回全量走査以降に作成された日報が同日グループ内の誤った位置に描画されていた
425
+ const merged = Array.from(mergedMap.values())
426
+ if (!isIdsStreamOrdered(merged)) {
427
+ merged.sort(compareIdsStreamOrder)
428
+ }
429
+ setItems(merged)
430
+ }
341
431
 
342
- // prevItems にのみ存在する ID を保持(削除は report-delete SSE で行う)
343
- for (const item of prevItems) {
344
- if (!(mergedMap.has(item.reportHubId) || removedIds.current.has(item.reportHubId))) {
345
- mergedMap.set(item.reportHubId, item)
432
+ /**
433
+ * トゥームストーン (removedIds) のクリーンアップ副作用。
434
+ * ストリームの全量が出揃っている (complete かつ再検証中でない) ときにのみ行う。
435
+ * 部分配列からの欠落は「サーバーに存在しない」証拠にならない。
436
+ * 依存配列: initialItems (チャンク到着・再検証ごと)。クリーンアップ: なし。
437
+ * ref の変異を伴うため render-phase ではなく effect に置く。
438
+ */
439
+ useEffect(() => {
440
+ if (isStreamSettledRef.current) {
441
+ const initialIds = new Set(initialItems.map((item) => item.reportHubId))
442
+ // Set のイテレーション中の delete は JS 仕様で安全 (visited は再訪問しない)
443
+ for (const id of removedIds.current) {
444
+ if (!initialIds.has(id)) {
445
+ removedIds.current.delete(id)
346
446
  }
347
447
  }
348
-
349
- // businessDate 降順でソート
350
- return Array.from(mergedMap.values()).sort((a, b) => {
351
- const dateA = a.businessDate ? new Date(a.businessDate).getTime() : 0
352
- const dateB = b.businessDate ? new Date(b.businessDate).getTime() : 0
353
- return dateB - dateA
354
- })
355
- })
448
+ }
356
449
  }, [initialItems])
357
450
 
358
451
  const handleSseMessage = useCallback(
359
452
  (payload: Exclude<DailyReportSseMessage, { type: "connected" }>) => {
453
+ // セッション切替〜プロバイダーアンマウントの隙間で届いた旧ユーザーの SSE は破棄する。
454
+ // clientLoader の bootstrap は React のコミット前にモジュールセッションを新ユーザーへ
455
+ // 差し替えるため、旧プロバイダーの EventSource が閉じるまでの間に届いたメッセージ
456
+ // (下書きの recipient 宛配信を含む) が、破棄済みキャッシュと新セッションへ
457
+ // 旧ユーザーのデータを書き込んでしまう (POST 継続と同じ汚染シグネチャ・実測再現済み)
458
+ if (!isSessionStillOurs()) {
459
+ return
460
+ }
360
461
  // Ignore echo of own actions
361
462
  // 自分が発行したアクションのエコーは無視する
362
463
  // clientTempId は connected 以外すべてのメッセージに存在する(型で保証済み)
@@ -463,6 +564,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
463
564
  manuallyTrackingIds.current.delete(reportHubId)
464
565
  // マージロジックでの復活を防止するために削除済み ID を記録
465
566
  removedIds.current.add(reportHubId)
567
+ // 常駐ストリームデータからも除去 (鮮度窓内の再訪で削除済み日報が蘇らないように)
568
+ removeDailyReportIdsStreamItem(reportHubId)
466
569
 
467
570
  // 削除されたレポートへの待機中 SSE メッセージを破棄
468
571
  const pendingEntry = pendingSseQueue.current.get(reportHubId)
@@ -485,7 +588,9 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
485
588
  case "report-publish": {
486
589
  // report はスキーマで必須なのでバリデーション通過後は必ず存在する(if ガード不要)
487
590
  const { report } = payload
488
- applyDailyReportServerUpdates(report.reportHubId, report)
591
+ // 全ユーザー放送: report の isRead/isStarred は操作者の状態なので、
592
+ // 受信者自身の per-viewer 状態を保持するマージ経路を使う
593
+ applyBroadcastReportUpdate(report)
489
594
  if (payload.type === "report-create" || payload.type === "report-publish") {
490
595
  manuallyTrackingIds.current.add(report.reportHubId)
491
596
  upsertItem(report.reportHubId, report.date)
@@ -495,7 +600,7 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
495
600
  }
496
601
  }
497
602
  },
498
- [isProcessedId, upsertItem, user, enqueuePendingSseMessage],
603
+ [isProcessedId, isSessionStillOurs, upsertItem, user, enqueuePendingSseMessage],
499
604
  )
500
605
 
501
606
  // 常に最新の handleSseMessage をキューのリプレイから参照できるようにする
@@ -574,10 +679,22 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
574
679
  if (typeof data.clientTempId !== "string" || data.clientTempId !== clientTempId) {
575
680
  throw new Error(`Invalid clientTempId for ${intent}`)
576
681
  }
682
+ // ユーザー切替後に解決した継続は、モジュール常駐状態 (常駐セッション・詳細キャッシュ)
683
+ // を旧ユーザーのデータで汚染しないよう onSuccess を放棄する
684
+ if (!isSessionStillOurs()) {
685
+ logger.warn(`[executeAction] ${intent} resolved after a user switch; discarding continuation`)
686
+ throw new Error(`Stale continuation for ${intent}`)
687
+ }
577
688
  return (await callbacks.onSuccess?.(data)) as T
578
689
  } catch (e) {
579
690
  logger.error(`${intent} failed:`, e)
580
- callbacks.onFailure?.()
691
+ // 切替後の失敗も同様: onFailure のロールバックが旧値をモジュールキャッシュへ
692
+ // 書き戻すため放棄する (プロバイダー自身の state は key リマウントで破棄済み)。
693
+ // 通知も同じ門で守る: ユーザー切替で捨てた継続の失敗は当人に見せない
694
+ if (isSessionStillOurs()) {
695
+ callbacks.onFailure?.()
696
+ notifyErrorRef.current({ operation: intent, message: describeOperationFailure(intent), cause: e })
697
+ }
581
698
  throw e
582
699
  } finally {
583
700
  if (!(options?.keepLock || options?.skipLock)) {
@@ -805,6 +922,11 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
805
922
 
806
923
  const newId = Number(res.reportHubId)
807
924
 
925
+ // 常駐ストリームセッションへも反映する。自分の SSE エコーは clientTempId で
926
+ // 抑止されるため upsertItem 経由の中継は届かず、ここで直接中継しないと
927
+ // 鮮度窓内の再訪で作成直後の日報が一覧から消える
928
+ upsertDailyReportIdsStreamItem({ reportHubId: newId, businessDate })
929
+
808
930
  // newId を一時的にロックし、createReport 以前に開始された背景 prefetch の
809
931
  // cacheMany による stale データ上書きを防止する
810
932
  acquireMutationLock(newId)
@@ -883,6 +1005,10 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
883
1005
  applyDailyReportServerUpdates(id, data.report)
884
1006
  setVersion((v) => v + 1)
885
1007
  }
1008
+ // 成功確定後に常駐ストリームセッションへも反映する (自分の SSE エコーは
1009
+ // 抑止されるため)。失敗時に走らせると、並行削除済みの日報を誤って
1010
+ // 蘇らせトゥームストーンまで解除してしまう
1011
+ upsertDailyReportIdsStreamItem({ reportHubId: id, businessDate })
886
1012
  },
887
1013
  onFailure: () => {
888
1014
  // ロールバック
@@ -902,6 +1028,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
902
1028
 
903
1029
  // マージロジックでの復活を防止するために削除済み ID を記録
904
1030
  removedIds.current.add(id)
1031
+ // 常駐ストリームデータからも除去 (鮮度窓内の再訪で削除済み日報が蘇らないように)
1032
+ removeDailyReportIdsStreamItem(id)
905
1033
  // Optimistic UI update
906
1034
  setItems((prev) => prev.filter((item) => item.reportHubId !== id))
907
1035
 
@@ -926,12 +1054,22 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
926
1054
  return
927
1055
  }
928
1056
 
929
- // それ以外のエラーの場合は、楽観的更新をロールバックする
1057
+ // それ以外のエラーの場合は、楽観的更新をロールバックする。
1058
+ // 常駐ストリームデータの除去とトゥームストーンも upsert で巻き戻す
1059
+ // (巻き戻さないと、実在する日報が次の全量走査まで再訪時の一覧から消える)。
1060
+ // ユーザー切替後に解決した継続は巻き戻さない — 旧ユーザーの行を新ユーザーの
1061
+ // セッションへ upsert しトゥームストーンまで解除してしまうため
1062
+ // (元の catch と同様に握り潰す。呼び出し元 UI は既に破棄済み)
1063
+ if (!isSessionStillOurs()) {
1064
+ return
1065
+ }
930
1066
  removedIds.current.delete(id)
1067
+ upsertDailyReportIdsStreamItem({ reportHubId: id, businessDate: targetItem?.businessDate ?? businessDate })
931
1068
  if (targetItem) {
932
1069
  setItems((prev) => {
933
1070
  if (prev.some((i) => i.reportHubId === id)) return prev
934
- return [targetItem, ...prev].sort((a, b) => (b.businessDate ?? "").localeCompare(a.businessDate ?? ""))
1071
+ // ロールバックも正準比較器の位置へ戻す (第 3 の比較器を持たない)
1072
+ return upsertInIdsStreamOrder(prev, targetItem)
935
1073
  })
936
1074
  }
937
1075
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tests for the mutation error-surface seam.
3
+ * ミューテーション通知シームのテスト。
4
+ */
5
+ import { fireEvent, render, screen } from "@testing-library/react"
6
+ import { describe, expect, it, vi } from "vitest"
7
+ import { DailyReportConfigProvider } from "../config-context"
8
+ import { DailyReportErrorProvider, useDailyReportErrorSurface } from "./daily-report-error-context"
9
+
10
+ /** notifyError を発火するだけのプローブ。 */
11
+ const Trigger = ({ label = "発火" }: { label?: string }) => {
12
+ const { notifyError } = useDailyReportErrorSurface()
13
+ return (
14
+ <button type="button" onClick={() => notifyError({ operation: "update", message: "日報の保存に失敗しました。" })}>
15
+ {label}
16
+ </button>
17
+ )
18
+ }
19
+
20
+ const renderWithConfig = (node: React.ReactNode, onError?: (info: { operation: string; message: string }) => void) =>
21
+ render(
22
+ <DailyReportConfigProvider config={onError ? { onError } : undefined}>
23
+ <DailyReportErrorProvider>{node}</DailyReportErrorProvider>
24
+ </DailyReportConfigProvider>,
25
+ )
26
+
27
+ describe("DailyReportErrorProvider", () => {
28
+ it("shows a role=alert banner when no onError is injected", () => {
29
+ renderWithConfig(<Trigger />)
30
+ expect(screen.queryByRole("alert")).toBeNull()
31
+ fireEvent.click(screen.getByText("発火"))
32
+ const banner = screen.getByRole("alert")
33
+ expect(banner).toHaveTextContent("日報の保存に失敗しました。")
34
+ })
35
+
36
+ it("delegates to config.onError and does NOT render the default banner", () => {
37
+ const onError = vi.fn()
38
+ renderWithConfig(<Trigger />, onError)
39
+ fireEvent.click(screen.getByText("発火"))
40
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ operation: "update", message: "日報の保存に失敗しました。" }))
41
+ // ホストへ委譲したときは内蔵バナーを出さない (二重表示防止)
42
+ expect(screen.queryByRole("alert")).toBeNull()
43
+ })
44
+
45
+ it("dismisses the banner via the close button", () => {
46
+ renderWithConfig(<Trigger />)
47
+ fireEvent.click(screen.getByText("発火"))
48
+ expect(screen.getByRole("alert")).toBeInTheDocument()
49
+ fireEvent.click(screen.getByLabelText("閉じる"))
50
+ expect(screen.queryByRole("alert")).toBeNull()
51
+ })
52
+
53
+ it("notifyError is a safe no-op outside the provider", () => {
54
+ // Provider の外でも購読できて壊れない (アクションコンテキストの防御的呼び出しを保証)
55
+ const Standalone = () => {
56
+ const { notifyError } = useDailyReportErrorSurface()
57
+ return (
58
+ <button type="button" onClick={() => notifyError({ operation: "delete", message: "x" })}>
59
+ 無所属
60
+ </button>
61
+ )
62
+ }
63
+ render(<Standalone />)
64
+ expect(() => fireEvent.click(screen.getByText("無所属"))).not.toThrow()
65
+ })
66
+ })
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Error-surface seam for daily-report mutations.
3
+ * 日報ミューテーション失敗を利用者へ可視化するための通知シーム。
4
+ *
5
+ * 保存・公開・削除・コメント等の失敗は、これまで握り潰されてボタンが成功したかのように
6
+ * 復帰していた。ここでミューテーションの失敗を一元的に受け取り、`config.onError` が
7
+ * 注入されていればホストへ委譲し、無ければパッケージ内蔵の `role="alert"` バナーで表示する。
8
+ * `notifyError` は Provider 外でも安全に呼べる (既定は no-op) ため、アクションコンテキストは
9
+ * 存在を前提にせず呼び出せる。
10
+ */
11
+ import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
12
+ import { type DailyReportErrorInfo, useDailyReportConfig } from "../config-context"
13
+
14
+ /** バナーの自動消滅までの既定時間 (ms)。手動クローズでも消せる。 */
15
+ const ERROR_BANNER_AUTO_DISMISS_MS = 8_000
16
+
17
+ type DailyReportErrorSurface = {
18
+ /** ミューテーション失敗を通知する。 */
19
+ notifyError: (info: DailyReportErrorInfo) => void
20
+ }
21
+
22
+ /** 既定は no-op (Provider 外で呼んでも壊れない)。 */
23
+ const DailyReportErrorContext = createContext<DailyReportErrorSurface>({ notifyError: () => {} })
24
+
25
+ /**
26
+ * Returns the error-surface API (safe no-op outside the provider).
27
+ * 通知 API を返す処理 (Provider 外では no-op)。
28
+ */
29
+ export const useDailyReportErrorSurface = (): DailyReportErrorSurface => useContext(DailyReportErrorContext)
30
+
31
+ /**
32
+ * Provides the mutation error surface and renders the default alert banner.
33
+ * ミューテーション通知シームを供給し、既定のアラートバナーを描画するプロバイダー。
34
+ *
35
+ * `config.onError` があればホストへ委譲し、内蔵バナーは描画しない (二重表示防止)。
36
+ */
37
+ export const DailyReportErrorProvider = ({ children }: { children: ReactNode }) => {
38
+ const { onError } = useDailyReportConfig()
39
+ const [banner, setBanner] = useState<DailyReportErrorInfo | null>(null)
40
+ // onError を ref 経由で読み、notifyError の参照を安定させる (毎回新関数だと購読側が再レンダー)
41
+ const onErrorRef = useRef(onError)
42
+ onErrorRef.current = onError
43
+ const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
44
+
45
+ const notifyError = useCallback((info: DailyReportErrorInfo) => {
46
+ const host = onErrorRef.current
47
+ if (host) {
48
+ // ホストが通知 UI を持つ場合は委譲し、内蔵バナーは出さない
49
+ host(info)
50
+ return
51
+ }
52
+ setBanner(info)
53
+ }, [])
54
+
55
+ const dismiss = useCallback(() => {
56
+ if (dismissTimerRef.current !== null) {
57
+ clearTimeout(dismissTimerRef.current)
58
+ dismissTimerRef.current = null
59
+ }
60
+ setBanner(null)
61
+ }, [])
62
+
63
+ /**
64
+ * バナー表示中は一定時間で自動的に消す副作用。
65
+ * 依存配列: banner (新しい失敗が来るたびにタイマーを張り直す)。
66
+ * クリーンアップ: タイマー解除 (アンマウント/再通知でのリークと二重発火を防ぐ)。
67
+ */
68
+ useEffect(() => {
69
+ if (!banner) return
70
+ dismissTimerRef.current = setTimeout(() => setBanner(null), ERROR_BANNER_AUTO_DISMISS_MS)
71
+ return () => {
72
+ if (dismissTimerRef.current !== null) {
73
+ clearTimeout(dismissTimerRef.current)
74
+ dismissTimerRef.current = null
75
+ }
76
+ }
77
+ }, [banner])
78
+
79
+ const value = useMemo(() => ({ notifyError }), [notifyError])
80
+
81
+ return (
82
+ <DailyReportErrorContext.Provider value={value}>
83
+ {children}
84
+ {banner && (
85
+ <div
86
+ // role="alert" で SR へ即時読み上げさせる。fixed トーストで操作を妨げない
87
+ role="alert"
88
+ data-testid="daily-report-error-banner"
89
+ className="fade-in slide-in-from-bottom-2 fixed inset-x-0 bottom-4 z-[100] mx-auto flex w-fit max-w-[92vw] animate-in items-center gap-3 rounded-lg border border-red-300 bg-red-600 px-4 py-2.5 text-sm text-white shadow-lg duration-200 dark:border-red-500/60 dark:bg-red-700">
90
+ <span className="font-medium">{banner.message}</span>
91
+ <button type="button" aria-label="閉じる" onClick={dismiss} className="shrink-0 rounded px-1.5 py-0.5 font-bold text-white/90 transition-colors hover:bg-red-700 hover:text-white dark:hover:bg-red-800">
92
+ ✕
93
+ </button>
94
+ </div>
95
+ )}
96
+ </DailyReportErrorContext.Provider>
97
+ )
98
+ }