@aiquants/daily-report 0.8.0 → 0.9.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 (58) hide show
  1. package/README.md +5 -0
  2. package/dist/client.d.mts +86 -7
  3. package/dist/client.d.ts +86 -7
  4. package/dist/client.js +5 -5
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +5 -5
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +40 -2
  9. package/dist/index.d.ts +40 -2
  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 +13 -0
  15. package/dist/server.d.ts +13 -0
  16. package/dist/server.js +8 -7
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +8 -7
  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-list.tsx +88 -55
  28. package/src/client/components/daily-report-page.tsx +8 -3
  29. package/src/client/components/daily-report-resolved-content.tsx +5 -3
  30. package/src/client/components/report-views.spec.tsx +37 -2
  31. package/src/client/config-context.tsx +18 -0
  32. package/src/client/contexts/daily-report-action-context.spec.tsx +74 -0
  33. package/src/client/contexts/daily-report-action-context.tsx +98 -35
  34. package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
  35. package/src/client/contexts/daily-report-error-context.tsx +98 -0
  36. package/src/client/hooks/use-daily-report.spec.ts +107 -2
  37. package/src/client/hooks/use-daily-report.ts +37 -3
  38. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +64 -1
  39. package/src/client/streaming/daily-report-ids-stream-client.ts +80 -2
  40. package/src/client/utils/constants.ts +11 -1
  41. package/src/client.ts +1 -0
  42. package/src/server/handlers.action.spec.ts +44 -0
  43. package/src/server/handlers.ids-stream.spec.ts +53 -1
  44. package/src/server/handlers.ts +81 -5
  45. package/src/server/service.spec.ts +83 -7
  46. package/src/server/service.ts +60 -3
  47. package/src/server/test-helpers/handlers-config.ts +9 -1
  48. package/src/server.spec.ts +49 -0
  49. package/src/server.ts +7 -0
  50. package/src/shared/business-date.spec.ts +22 -1
  51. package/src/shared/business-date.ts +20 -0
  52. package/src/shared/comment-adapter.spec.ts +38 -0
  53. package/src/shared/comment-adapter.ts +9 -7
  54. package/src/shared/ids-stream.spec.ts +65 -0
  55. package/src/shared/ids-stream.ts +47 -0
  56. package/src/shared/sse-schema.ts +5 -0
  57. package/src/shared/text-utils.spec.ts +42 -0
  58. package/src/shared/text-utils.ts +8 -0
@@ -3,16 +3,64 @@
3
3
  * 日報の楽観的 CRUD と SSE 同期を提供するアクションコンテキスト。
4
4
  */
5
5
  import { type Context, createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"
6
+ import { compareIdsStreamOrder, isIdsStreamOrdered, upsertInIdsStreamOrder } from "../../shared/ids-stream"
6
7
  import { createLogger, LogLevel } from "../../shared/logger"
7
8
  import type { DailyReportSseMessage } from "../../shared/sse-schema"
8
9
  import type { DailyReportCommentItem, DailyReportDetail, DailyReportItem, DailyReportUser } from "../../shared/types"
9
10
  import { useDailyReportConfig } from "../config-context"
10
- 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"
11
25
  import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
12
26
  import { getDailyReportIdsStreamSessionUserKey, refreshDailyReportIdsStream, removeDailyReportIdsStreamItem, upsertDailyReportIdsStreamItem, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
27
+ import { SSE_INITIAL_CONNECT_DEFER_MS } from "../utils/constants"
28
+ import { useDailyReportErrorSurface } from "./daily-report-error-context"
13
29
 
14
30
  const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
15
31
 
32
+ /**
33
+ * Operation intent -> default banner label (Japanese).
34
+ * 操作 intent -> 既定バナー用の日本語ラベル。ホストは config.onError で構造化情報 (operation)
35
+ * を受け取り独自メッセージへ差し替えできる。
36
+ */
37
+ const OPERATION_LABELS: Record<string, string> = {
38
+ create: "日報の作成",
39
+ update: "日報の保存",
40
+ publish: "日報の公開",
41
+ delete: "日報の削除",
42
+ addComment: "コメントの投稿",
43
+ deleteComment: "コメントの削除",
44
+ toggleStar: "スターの更新",
45
+ toggleRead: "既読状態の更新",
46
+ }
47
+
48
+ /** intent から利用者向けの失敗メッセージを組み立てる。 */
49
+ const describeOperationFailure = (intent: string): string => {
50
+ const label = OPERATION_LABELS[intent] ?? "操作"
51
+ return `${label}に失敗しました。時間をおいて再度お試しください。`
52
+ }
53
+
54
+ /**
55
+ * Stable empty default for the optional `initialItems` prop.
56
+ * 省略可能な `initialItems` の安定した空既定値。
57
+ *
58
+ * 既定を `= []` にすると毎レンダーで新しい配列参照になり、render-phase の
59
+ * `initialItems !== prevInitialItems` 判定が常に真になって setState が無限ループし、
60
+ * React が描画を中断する。単一の凍結参照を共有して同一性を保つ。
61
+ */
62
+ const EMPTY_INITIAL_ITEMS: DailyReportItem[] = Object.freeze([]) as readonly DailyReportItem[] as DailyReportItem[]
63
+
16
64
  type ActionResponse = {
17
65
  status: string
18
66
  reportHubId: number
@@ -120,8 +168,13 @@ const getUserDisplayName = (user: DailyReportUser | undefined): string => {
120
168
  * 日報のアクション(コメント追加・削除、スター、既読)を管理するプロバイダー。
121
169
  * 楽観的UI更新とAPI通信の抽象化を提供します。
122
170
  */
123
- export const DailyReportActionProvider = ({ children, user, initialItems = [], userId }: { children: ReactNode; user?: DailyReportUser; initialItems?: DailyReportItem[]; userId?: string | null }) => {
171
+ export const DailyReportActionProvider = ({ children, user, initialItems = EMPTY_INITIAL_ITEMS, userId }: { children: ReactNode; user?: DailyReportUser; initialItems?: DailyReportItem[]; userId?: string | null }) => {
124
172
  const { apiBasePath, draftLabelName } = useDailyReportConfig()
173
+ // ミューテーション失敗の通知シーム。executeAction の catch から呼び、失敗を可視化する。
174
+ // ref 経由で読み、executeAction の依存に載せない (通知参照は安定しているが防御的に)
175
+ const { notifyError } = useDailyReportErrorSurface()
176
+ const notifyErrorRef = useRef(notifyError)
177
+ notifyErrorRef.current = notifyError
125
178
  const pendingAddCommentIds = useRef<Map<number, Map<number, string>>>(new Map())
126
179
  const pendingDeleteCommentIds = useRef<Map<number, Set<number>>>(new Map())
127
180
  const pendingStarUpdates = useRef<Map<number, boolean>>(new Map())
@@ -129,7 +182,10 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
129
182
  const resolvedIdMap = useRef<Map<number, number>>(new Map())
130
183
  const [version, setVersion] = useState(0)
131
184
  const [items, setItems] = useState<DailyReportItem[]>(initialItems)
132
- const [isSseEnabled, setIsSseEnabled] = useState(!!userId)
185
+ // SSE は初回描画とその最初の中身フェッチに接続枠を譲るため、少し遅らせて自動接続する
186
+ // (HTTP/1.1 の 6 接続のうち 1 枠を初回描画へ回す)。手動トグル済みなら自動接続しない
187
+ const [isSseEnabled, setIsSseEnabled] = useState(false)
188
+ const hasManualSseToggleRef = useRef(false)
133
189
  const pendingAddPromises = useRef<Map<number, Promise<number>>>(new Map())
134
190
  const manuallyTrackingIds = useRef<Set<number>>(new Set())
135
191
  /** 削除済み ID を記録し、マージロジックでの復活を防止する */
@@ -305,24 +361,39 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
305
361
  upsertDailyReportIdsStreamItem({ reportHubId, businessDate: businessDate ?? null })
306
362
  }
307
363
  setItems((prev) => {
308
- const nextBusinessDate = businessDate ?? prev.find((item) => item.reportHubId === reportHubId)?.businessDate ?? null
309
- const exists = prev.some((item) => item.reportHubId === reportHubId)
310
- const merged = exists ? prev.map((item) => (item.reportHubId === reportHubId ? { ...item, businessDate: nextBusinessDate } : item)) : [{ reportHubId, businessDate: nextBusinessDate }, ...prev]
311
-
312
- return merged.sort((a, b) => {
313
- const left = a.businessDate ?? ""
314
- const right = b.businessDate ?? ""
315
- return right.localeCompare(left)
316
- })
364
+ const existing = prev.find((item) => item.reportHubId === reportHubId)
365
+ const nextBusinessDate = businessDate ?? existing?.businessDate ?? null
366
+ // 正準比較器 (営業日降順 → ID 降順) の位置へ二分探索で upsert する。
367
+ // 以前は SSE 1 件ごとに全件を localeCompare でソートし直していた (O(n log n)
368
+ // かつ ID 降順の副キー無しで、同日内の新規作成が誤った位置に描画されていた)
369
+ return upsertInIdsStreamOrder(prev, { reportHubId, businessDate: nextBusinessDate, sourceType: existing?.sourceType })
317
370
  })
318
371
  }, [])
319
372
 
320
373
  // 未登録ユーザーは SSE を有効化できない
321
374
  const toggleSse = () => {
322
375
  if (!userId) return
376
+ // 手動操作後は自動接続タイマーに上書きさせない
377
+ hasManualSseToggleRef.current = true
323
378
  setIsSseEnabled((prev) => !prev)
324
379
  }
325
380
 
381
+ /**
382
+ * Defers the initial SSE auto-connect until just after first paint.
383
+ * 初回 SSE 自動接続を初回描画直後まで遅らせる副作用。
384
+ *
385
+ * 依存配列: userId (ユーザーごとに provider が key リマウントされるため、切替で張り直す)。
386
+ * クリーンアップ: タイマー解除 (アンマウント/再マウントでのリークと二重発火を防ぐ)。
387
+ */
388
+ useEffect(() => {
389
+ if (!userId) return
390
+ const timer = setTimeout(() => {
391
+ // 手動でトグルされていなければ自動接続する
392
+ if (!hasManualSseToggleRef.current) setIsSseEnabled(true)
393
+ }, SSE_INITIAL_CONNECT_DEFER_MS)
394
+ return () => clearTimeout(timer)
395
+ }, [userId])
396
+
326
397
  /**
327
398
  * Remove a stale item from the list as a safety net.
328
399
  * フェッチ後にレポートが見つからなかった場合の安全弁としてアイテムを除去する。
@@ -368,27 +439,14 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
368
439
  mergedMap.set(item.reportHubId, item)
369
440
  }
370
441
  }
371
- // businessDate 降順でソート。正規化済み YYYY-MM-DD は辞書順 = 時系列順なので
372
- // 文字列比較で足りる (Date 構築はチャンク到着ごとの全件ソートで比較 1 回あたり
373
- // 2 個生成され、数万件 × 数十チャンクで数百万回の構築になる)。null は末尾。
374
- // ストリームはソート順どおりに届き末尾へ追記されるため、通常は整列済み —
375
- // O(n) の検査で確認できたらソートを丸ごと省略する (20 万件級で毎 publish の
376
- // O(n log n) を O(n) へ落とす)
442
+ // 並び順は正準比較器 (compareIdsStreamOrder: 営業日降順 → ID 降順、null 営業日は
443
+ // 末尾) に一本化する。ストリームはソート順どおりに届き末尾へ追記されるため通常は
444
+ // 整列済み — O(n) の検査で確認できたらソートを丸ごと省略する (20 万件級で毎 publish
445
+ // の O(n log n) を O(n) へ落とす)。以前は営業日のみの比較で ID 降順の副キーが無く、
446
+ // 前回全量走査以降に作成された日報が同日グループ内の誤った位置に描画されていた
377
447
  const merged = Array.from(mergedMap.values())
378
- let isSorted = true
379
- for (let i = 1; i < merged.length; i++) {
380
- if ((merged[i - 1].businessDate ?? "") < (merged[i].businessDate ?? "")) {
381
- isSorted = false
382
- break
383
- }
384
- }
385
- if (!isSorted) {
386
- merged.sort((a, b) => {
387
- const left = a.businessDate ?? ""
388
- const right = b.businessDate ?? ""
389
- if (left === right) return 0
390
- return left < right ? 1 : -1
391
- })
448
+ if (!isIdsStreamOrdered(merged)) {
449
+ merged.sort(compareIdsStreamOrder)
392
450
  }
393
451
  setItems(merged)
394
452
  }
@@ -552,7 +610,9 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
552
610
  case "report-publish": {
553
611
  // report はスキーマで必須なのでバリデーション通過後は必ず存在する(if ガード不要)
554
612
  const { report } = payload
555
- applyDailyReportServerUpdates(report.reportHubId, report)
613
+ // 全ユーザー放送: report の isRead/isStarred は操作者の状態なので、
614
+ // 受信者自身の per-viewer 状態を保持するマージ経路を使う
615
+ applyBroadcastReportUpdate(report)
556
616
  if (payload.type === "report-create" || payload.type === "report-publish") {
557
617
  manuallyTrackingIds.current.add(report.reportHubId)
558
618
  upsertItem(report.reportHubId, report.date)
@@ -651,9 +711,11 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
651
711
  } catch (e) {
652
712
  logger.error(`${intent} failed:`, e)
653
713
  // 切替後の失敗も同様: onFailure のロールバックが旧値をモジュールキャッシュへ
654
- // 書き戻すため放棄する (プロバイダー自身の state は key リマウントで破棄済み)
714
+ // 書き戻すため放棄する (プロバイダー自身の state は key リマウントで破棄済み)。
715
+ // 通知も同じ門で守る: ユーザー切替で捨てた継続の失敗は当人に見せない
655
716
  if (isSessionStillOurs()) {
656
717
  callbacks.onFailure?.()
718
+ notifyErrorRef.current({ operation: intent, message: describeOperationFailure(intent), cause: e })
657
719
  }
658
720
  throw e
659
721
  } finally {
@@ -1028,7 +1090,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
1028
1090
  if (targetItem) {
1029
1091
  setItems((prev) => {
1030
1092
  if (prev.some((i) => i.reportHubId === id)) return prev
1031
- return [targetItem, ...prev].sort((a, b) => (b.businessDate ?? "").localeCompare(a.businessDate ?? ""))
1093
+ // ロールバックも正準比較器の位置へ戻す (第 3 の比較器を持たない)
1094
+ return upsertInIdsStreamOrder(prev, targetItem)
1032
1095
  })
1033
1096
  }
1034
1097
  }
@@ -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
+ }
@@ -30,6 +30,7 @@ describe("DailyReportCache public API", () => {
30
30
  let getCachedReport: typeof import("./use-daily-report").getCachedReport
31
31
  let updateDailyReportCache: typeof import("./use-daily-report").updateDailyReportCache
32
32
  let applyDailyReportServerUpdates: typeof import("./use-daily-report").applyDailyReportServerUpdates
33
+ let applyBroadcastReportUpdate: typeof import("./use-daily-report").applyBroadcastReportUpdate
33
34
 
34
35
  beforeEach(async () => {
35
36
  // モジュールキャッシュをリセットして、getGlobal がカスタム TTL を読み込むようにする
@@ -44,6 +45,7 @@ describe("DailyReportCache public API", () => {
44
45
  getCachedReport = mod.getCachedReport
45
46
  updateDailyReportCache = mod.updateDailyReportCache
46
47
  applyDailyReportServerUpdates = mod.applyDailyReportServerUpdates
48
+ applyBroadcastReportUpdate = mod.applyBroadcastReportUpdate
47
49
  })
48
50
 
49
51
  afterEach(() => {
@@ -100,8 +102,9 @@ describe("DailyReportCache public API", () => {
100
102
  })
101
103
  })
102
104
 
103
- describe("applyDailyReportServerUpdates", () => {
104
- it("キャッシュ済みレポートを完全置換する", () => {
105
+ describe("applyDailyReportServerUpdates (信頼経路: 自分の HTTP 応答・自分宛 status-update)", () => {
106
+ it("キャッシュ済みレポートを完全置換する (per-viewer 値も配信値を採用)", () => {
107
+ // 信頼経路の値は受信者自身の状態なので、そのまま書いてよい
105
108
  writeCache(makeMockReport({ subject: "Old", content: "Old" }), Date.now() + 1000)
106
109
  applyDailyReportServerUpdates(1, makeMockReport({ subject: "Fresh", content: "Fresh" }))
107
110
  const cached = getCachedReport(1)
@@ -114,6 +117,51 @@ describe("DailyReportCache public API", () => {
114
117
  expect(getCachedReport(42)?.subject).toBe("New")
115
118
  })
116
119
  })
120
+
121
+ describe("applyBroadcastReportUpdate (放送経路: 全ユーザー宛 SSE)", () => {
122
+ it("共有フィールドは配信値で更新し、per-viewer (isRead/isStarred) は受信者の値を保持する", () => {
123
+ // SSE の report が運ぶ isRead/isStarred は送信者 (操作者) の状態であり、
124
+ // 受信者のキャッシュへそのまま書くと他人の既読/スター状態で上書きされる
125
+ writeCache(makeMockReport({ subject: "Old", content: "Old", isRead: true, isStarred: true }), Date.now() + 1000)
126
+ applyBroadcastReportUpdate(makeMockReport({ subject: "Fresh", content: "Fresh", isRead: false, isStarred: false }))
127
+ const cached = getCachedReport(1)
128
+ expect(cached?.subject).toBe("Fresh")
129
+ expect(cached?.content).toBe("Fresh")
130
+ // 受信者自身の per-viewer 状態が生き残る
131
+ expect(cached?.isRead).toBe(true)
132
+ expect(cached?.isStarred).toBe(true)
133
+ })
134
+
135
+ it("commentItems[].isMine は per-viewer: キャッシュ済みは保持、送信者由来の新規は false 化する", () => {
136
+ // isMine はサーバーが送信者視点で計算するため、そのまま取り込むと受信者に
137
+ // 他人のコメントが「自分の」削除ボタン付きで見え、自分のコメントは isMine を失う
138
+ writeCache(makeMockReport({ commentItems: [{ id: 1, userName: "自分", content: "私の", createdAt: "2026-01-01 00:00:00", userId: "ENC8", isMine: true }] }) as never, Date.now() + 1000)
139
+ applyBroadcastReportUpdate(
140
+ makeMockReport({
141
+ commentItems: [
142
+ { id: 1, userName: "自分", content: "私の", createdAt: "2026-01-01 00:00:00", userId: "ENC8", isMine: false },
143
+ { id: 2, userName: "他人", content: "他人の", createdAt: "2026-01-01 00:01:00", userId: "ENC9", isMine: true },
144
+ ],
145
+ }) as never,
146
+ )
147
+ const cached = getCachedReport(1)
148
+ // 受信者自身のコメント (id=1) の isMine は保持、送信者が「自分の」とした id=2 は false 化
149
+ expect(cached?.commentItems.find((c) => c.id === 1)?.isMine).toBe(true)
150
+ expect(cached?.commentItems.find((c) => c.id === 2)?.isMine).toBe(false)
151
+ })
152
+
153
+ it("未キャッシュの ID も中立化して種まきする (行が stale 安全弁で消えるのを防ぐ)", () => {
154
+ // 未キャッシュを書かないと、鮮度窓内のリストキャッシュに新 id が無く、行が
155
+ // report=null に解決してトゥームストーン化され、作成/公開された日報が消える
156
+ applyBroadcastReportUpdate(makeMockReport({ reportHubId: 42, subject: "New", isRead: true, isStarred: true, commentItems: [{ id: 9, userName: "他人", content: "x", createdAt: "2026-01-01 00:00:00", userId: "ENC9", isMine: true }] }) as never)
157
+ const cached = getCachedReport(42)
158
+ expect(cached?.subject).toBe("New")
159
+ // per-viewer は中立値で入る (送信者の true を持ち込まない)
160
+ expect(cached?.isRead).toBe(false)
161
+ expect(cached?.isStarred).toBe(false)
162
+ expect(cached?.commentItems[0]?.isMine).toBe(false)
163
+ })
164
+ })
117
165
  })
118
166
 
119
167
  describe("getGlobal behavior (window グローバル変数の読み取り)", () => {
@@ -153,3 +201,60 @@ describe("getGlobal behavior (window グローバル変数の読み取り)", ()
153
201
  expect(mod.getCachedReport(1)).toBeNull()
154
202
  })
155
203
  })
204
+
205
+ describe("useDailyReportDetail の初回フェッチ (デバウンス適用範囲)", () => {
206
+ let mod: typeof import("./use-daily-report")
207
+
208
+ beforeEach(async () => {
209
+ vi.resetModules()
210
+ mod = await import("./use-daily-report")
211
+ vi.useFakeTimers()
212
+ })
213
+
214
+ afterEach(() => {
215
+ vi.useRealTimers()
216
+ vi.unstubAllGlobals()
217
+ })
218
+
219
+ /** business-date 応答を 1 件返す fetch スタブ。呼び出し回数を記録する。 */
220
+ const stubFetch = () => {
221
+ const calls: string[] = []
222
+ vi.stubGlobal(
223
+ "fetch",
224
+ vi.fn(async (input: RequestInfo | URL) => {
225
+ calls.push(String(input))
226
+ return new Response(JSON.stringify({ reports: [makeMockReport({ reportHubId: 7, date: "2026-01-01" })] }), { status: 200, headers: { "Content-Type": "application/json" } })
227
+ }),
228
+ )
229
+ return calls
230
+ }
231
+
232
+ it("未キャッシュの初回は即フェッチする (120ms のデバウンスを掛けない)", async () => {
233
+ const { renderHook, act } = await import("@testing-library/react")
234
+ const calls = stubFetch()
235
+ renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
236
+ // タイマーを進めずマイクロタスクだけ流す → 即時 (0ms) 発火の検証
237
+ await act(async () => {
238
+ await vi.advanceTimersByTimeAsync(0)
239
+ })
240
+ expect(calls.length).toBe(1)
241
+ expect(calls[0]).toContain("business-date?businessDate=2026-01-01")
242
+ })
243
+
244
+ it("キャッシュ済み (stale) の再取得はデバウンスされる", async () => {
245
+ const { renderHook, act } = await import("@testing-library/react")
246
+ const calls = stubFetch()
247
+ // 期限切れのキャッシュを用意 → 表示は即、再取得はデバウンス対象
248
+ mod.writeCache(makeMockReport({ reportHubId: 7, date: "2026-01-01" }), Date.now() - 1)
249
+ renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
250
+ await act(async () => {
251
+ await vi.advanceTimersByTimeAsync(0)
252
+ })
253
+ // 0ms 時点では発火しない (スクロール churn 吸収のためのデバウンス)
254
+ expect(calls.length).toBe(0)
255
+ await act(async () => {
256
+ await vi.advanceTimersByTimeAsync(120)
257
+ })
258
+ expect(calls.length).toBe(1)
259
+ })
260
+ })
@@ -7,6 +7,7 @@ import { normalizeBusinessDateKey } from "../../shared/business-date"
7
7
  import { createLogger, LogLevel } from "../../shared/logger"
8
8
  import type { DailyReportDetail } from "../../shared/types"
9
9
  import { useDailyReportConfig } from "../config-context"
10
+ import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
10
11
 
11
12
  const logger = createLogger(LogLevel.INFO, "use-daily-report")
12
13
 
@@ -22,7 +23,6 @@ const getGlobal = (k: string, d: number): number => {
22
23
  }
23
24
  const TTL = getGlobal("__DAILY_REPORT_CACHE_TTL__", 300_000)
24
25
  const REVIVE_MS = getGlobal("__DAILY_REPORT_CACHE_REVIVE_MS__", 60_000)
25
- const FETCH_DEBOUNCE_MS = 120
26
26
 
27
27
  // --- Cache Store (Centralized State) ---
28
28
  type CacheEntry<T> = { data: T; expiresAt: number }
@@ -270,11 +270,40 @@ export const subscribeCacheReady = (reportHubId: number, callback: () => void):
270
270
  }
271
271
  }
272
272
 
273
- export const applyDailyReportServerUpdates = (_reportHubIdd: number, report: DailyReportDetail) => {
273
+ export const applyDailyReportServerUpdates = (_reportHubId: number, report: DailyReportDetail) => {
274
274
  DailyReportCache.prune()
275
275
  DailyReportCache.set(report, Date.now() + TTL)
276
276
  }
277
277
 
278
+ /**
279
+ * Applies a broadcast (cross-user SSE) report while preserving the viewer's own per-viewer state.
280
+ * 全ユーザー放送 (SSE) の report を、受信者自身の per-viewer 状態を保持したまま反映する処理。
281
+ *
282
+ * SSE の report-create/update/publish が運ぶ isRead/isStarred は**操作した本人の状態**。
283
+ * そのまま書くと受信者の既読/スター表示が他人の状態で上書きされる。共有フィールドだけを
284
+ * 取り込み、per-viewer フィールドは受信者のキャッシュ値を保持する。ローカルに無い日報は
285
+ * 種まきしない (送信者の per-viewer 値で汚すより、次のフェッチが受信者視点の正しい値を
286
+ * 取るのに任せるほうが安全)。
287
+ *
288
+ * ❗ 信頼できる経路 (自分の HTTP 応答・recipient フィルタ済みの自分宛 status-update) は
289
+ * {@link applyDailyReportServerUpdates} を使うこと — そちらは値をそのまま書く。
290
+ */
291
+ export const applyBroadcastReportUpdate = (report: DailyReportDetail) => {
292
+ DailyReportCache.prune()
293
+ const cached = DailyReportCache.getReport(report.reportHubId, true)
294
+ // per-viewer フィールド (isRead / isStarred / commentItems[].isMine) は送信者視点なので
295
+ // 受信者のキャッシュ値を保持し、未キャッシュなら中立値 (false) で置く。共有フィールドは配信値。
296
+ // ❗ 未キャッシュでも必ず書く: 書かないと鮮度窓内のリストキャッシュに新 id が載らず、
297
+ // 作成/公開された他ユーザーの日報が stale 安全弁でトゥームストーン化され、閲覧中の
298
+ // 受信者の一覧から恒久的に消える (set は既定で syncList=true = リストにも反映する)
299
+ const cachedMine = new Map<number | string, boolean>()
300
+ for (const c of cached?.commentItems ?? []) {
301
+ cachedMine.set(c.id, c.isMine ?? false)
302
+ }
303
+ const commentItems = (report.commentItems ?? []).map((c) => ({ ...c, isMine: cachedMine.get(c.id) ?? false }))
304
+ DailyReportCache.set({ ...report, isRead: cached?.isRead ?? false, isStarred: cached?.isStarred ?? false, commentItems }, Date.now() + TTL)
305
+ }
306
+
278
307
  // --- Data Loading ---
279
308
  /**
280
309
  * Fetch a single report by ID.
@@ -401,6 +430,11 @@ export const useDailyReportDetail = (reportHubId: number, businessDate: string |
401
430
  setIsLoading(!cached)
402
431
  setIsRefetching(true)
403
432
 
433
+ // 未キャッシュ (骨組み表示中) の初回取得は即時に走らせる。デバウンスはスクロール連打の
434
+ // 無駄取りを抑える目的なので、スクロール churn の無い初回に効かせると純粋な遅延になる。
435
+ // キャッシュ済み (内容表示中) の stale 再取得だけデバウンスして churn を吸収する
436
+ const debounceMs = cached ? DAILY_REPORT_FETCH_DEBOUNCE_MS : 0
437
+
404
438
  const timer = setTimeout(async () => {
405
439
  if (reqRef.current.token !== token) return
406
440
  try {
@@ -424,7 +458,7 @@ export const useDailyReportDetail = (reportHubId: number, businessDate: string |
424
458
  setIsRefetching(false)
425
459
  }
426
460
  }
427
- }, FETCH_DEBOUNCE_MS)
461
+ }, debounceMs)
428
462
 
429
463
  return () => clearTimeout(timer)
430
464
  }, [reportHubId, businessDate, apiBasePath])