@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
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Timing tests for the ease-out animated number hook.
3
+ * イーズアウト数値アニメーションフックのタイミングテスト。
4
+ *
5
+ * ここで固定する不変条件のうち、特に重要なもの:
6
+ *
7
+ * - **経過時間の基準は最初の rAF フレームのタイムスタンプ** — effect 内の
8
+ * `performance.now()` 基準だと混雑時に負の進捗が生まれ 0 に張り付く
9
+ * (contact-map で実測された既知の不具合の再発防止)。
10
+ * - **初回マウントは補間せず即時表示** (カウントアップ演出をしない)。
11
+ * - **中断された補間は表示中の値から次の補間を始める** (跳び戻りしない)。
12
+ */
13
+ import { act, renderHook } from "@testing-library/react"
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
15
+ import { useAnimatedNumber } from "./use-animated-number"
16
+
17
+ /** 手動発火の rAF キュー。 */
18
+ let rafQueue: FrameRequestCallback[] = []
19
+
20
+ beforeEach(() => {
21
+ rafQueue = []
22
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
23
+ rafQueue.push(callback)
24
+ return rafQueue.length
25
+ })
26
+ vi.stubGlobal("cancelAnimationFrame", vi.fn())
27
+ })
28
+
29
+ afterEach(() => {
30
+ vi.unstubAllGlobals()
31
+ })
32
+
33
+ /** 保留中の rAF を 1 フレームだけ指定タイムスタンプで発火する。 */
34
+ const fireFrame = (timestamp: number) => {
35
+ const frame = rafQueue.shift()
36
+ act(() => {
37
+ frame?.(timestamp)
38
+ })
39
+ }
40
+
41
+ describe("useAnimatedNumber", () => {
42
+ it("初回マウントは補間せず即時に値を表示する", () => {
43
+ const { result } = renderHook(() => useAnimatedNumber(42))
44
+ expect(result.current).toBe(42)
45
+ expect(rafQueue).toHaveLength(0)
46
+ })
47
+
48
+ it("値の変化をイーズアウト 3 次で補間する (基準は最初のフレーム時刻)", () => {
49
+ const { result, rerender } = renderHook(({ value }) => useAnimatedNumber(value, 100), { initialProps: { value: 0 } })
50
+ rerender({ value: 100 })
51
+
52
+ // 最初のフレームが基準になる (進捗 0)
53
+ fireFrame(5_000)
54
+ expect(result.current).toBe(0)
55
+
56
+ // 50ms 経過 → eased = 1 - 0.5^3 = 0.875
57
+ fireFrame(5_050)
58
+ expect(result.current).toBe(88)
59
+
60
+ // 100ms 経過 → 完了
61
+ fireFrame(5_100)
62
+ expect(result.current).toBe(100)
63
+ expect(rafQueue).toHaveLength(0)
64
+ })
65
+
66
+ it("基準フレームより過去のような大きなタイムスタンプでも進捗は 0-1 にクランプされる", () => {
67
+ const { result, rerender } = renderHook(({ value }) => useAnimatedNumber(value, 100), { initialProps: { value: 0 } })
68
+ rerender({ value: 100 })
69
+ fireFrame(5_000)
70
+ // 大幅超過は 1 へクランプ
71
+ fireFrame(99_999)
72
+ expect(result.current).toBe(100)
73
+ })
74
+
75
+ it("補間の中断は表示中の値から次の補間を開始する (跳び戻りしない)", () => {
76
+ const { result, rerender } = renderHook(({ value }) => useAnimatedNumber(value, 100), { initialProps: { value: 0 } })
77
+ rerender({ value: 100 })
78
+ fireFrame(1_000)
79
+ fireFrame(1_050)
80
+ expect(result.current).toBe(88)
81
+
82
+ // 補間途中で目標値を更新 → 旧アニメーションは破棄され、88 を起点に 200 へ向かう
83
+ rafQueue.length = 0
84
+ rerender({ value: 200 })
85
+ expect(result.current).toBe(88)
86
+
87
+ // 第 2 アニメーションの基準フレーム (進捗 0 = 起点 88 のまま)
88
+ fireFrame(2_000)
89
+ expect(result.current).toBe(88)
90
+ // 50ms 経過 → eased 0.875 → 88 + (200 - 88) × 0.875 = 186
91
+ fireFrame(2_050)
92
+ expect(result.current).toBe(186)
93
+ // 完了 → 200
94
+ fireFrame(2_100)
95
+ expect(result.current).toBe(200)
96
+ })
97
+
98
+ it("同値への再レンダーはアニメーションを起こさない", () => {
99
+ const { rerender } = renderHook(({ value }) => useAnimatedNumber(value, 100), { initialProps: { value: 7 } })
100
+ rerender({ value: 7 })
101
+ expect(rafQueue).toHaveLength(0)
102
+ })
103
+ })
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Ease-out animated number for streaming counters.
3
+ * ストリーミングカウンター向けのイーズアウト数値アニメーションフック。
4
+ *
5
+ * 経過時間の基準は「最初の rAF フレームのタイムスタンプ」にする。effect 内の
6
+ * `performance.now()` を基準にすると、メインスレッド混雑時に保留中フレームの
7
+ * vsync 時刻が基準より過去になり、負の進捗が 3 乗されて 0 に張り付く
8
+ * (contact-map で実測された既知の不具合)。進捗は念のため 0 でもクランプする。
9
+ */
10
+ import { useEffect, useRef, useState } from "react"
11
+
12
+ /**
13
+ * Animates numeric changes with ease-out cubic.
14
+ * 数値変化をイーズアウト 3 次で補間するフック。
15
+ *
16
+ * @param value Target value. 目標値。
17
+ * @param durationMs Animation duration. アニメーション時間 (既定 250ms)。
18
+ * @returns Displayed (interpolated) value. 表示用の補間値。
19
+ */
20
+ export const useAnimatedNumber = (value: number, durationMs = 250): number => {
21
+ const [display, setDisplay] = useState(value)
22
+ // 表示中の値の同期ミラー (アニメーション中断時の次回開始点に使う)
23
+ const displayRef = useRef(value)
24
+ const isFirstRef = useRef(true)
25
+
26
+ /**
27
+ * Animation effect.
28
+ * アニメーション副作用。
29
+ *
30
+ * 依存配列:
31
+ * - value: 目標値の変化でアニメーションを開始する
32
+ * - durationMs: 時間変更で再スケジュール
33
+ * クリーンアップ:
34
+ * - 実行中の rAF をキャンセルする (中断時は displayRef が現在値を保持)
35
+ */
36
+ useEffect(() => {
37
+ // 初回は補間せず即時表示する (マウント時のカウントアップ演出は不要)
38
+ if (isFirstRef.current) {
39
+ isFirstRef.current = false
40
+ displayRef.current = value
41
+ setDisplay(value)
42
+ return
43
+ }
44
+ const from = displayRef.current
45
+ if (from === value) return
46
+
47
+ let rafId: number | null = null
48
+ let startTimestamp: number | null = null
49
+ const step = (timestamp: number) => {
50
+ if (startTimestamp === null) {
51
+ startTimestamp = timestamp
52
+ }
53
+ const progress = Math.min(1, Math.max(0, (timestamp - startTimestamp) / durationMs))
54
+ const eased = 1 - (1 - progress) ** 3
55
+ const next = Math.round(from + (value - from) * eased)
56
+ displayRef.current = next
57
+ setDisplay(next)
58
+ if (progress < 1) {
59
+ rafId = requestAnimationFrame(step)
60
+ }
61
+ }
62
+ rafId = requestAnimationFrame(step)
63
+ return () => {
64
+ if (rafId !== null) cancelAnimationFrame(rafId)
65
+ }
66
+ }, [value, durationMs])
67
+
68
+ return display
69
+ }
@@ -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 グローバル変数の読み取り)", () => {
@@ -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.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Bootstrap-ordering tests for the daily-report clientLoader factory.
3
+ * 日報 clientLoader 工場の起動順序テスト。
4
+ *
5
+ * ここで固定する不変条件のうち、特に重要なもの:
6
+ *
7
+ * - **prime は serverLoader の解決を待たずに先行する** (再訪の再検証前倒し)。
8
+ * - **ローダー時点でセッションを bootstrap する** (コールドロードの最重要最適化 —
9
+ * ページマウントの effect まで生成を遅らせると、ストリーム開始がハイドレーション
10
+ * 完了の後ろに直列化される)。
11
+ * - **`hydrate = true`** (初期ハイドレーションでも実行される契約)。
12
+ */
13
+ import { describe, expect, it, vi } from "vitest"
14
+
15
+ const calls: string[] = []
16
+ const bootstrapMock = vi.fn((options: { apiBasePath?: string; userKey: string | null }) => {
17
+ calls.push(`bootstrap:${options.userKey}`)
18
+ })
19
+ const primeMock = vi.fn(() => {
20
+ calls.push("prime")
21
+ })
22
+
23
+ vi.mock("./streaming/daily-report-ids-stream-session", () => ({
24
+ bootstrapDailyReportIdsStreamSession: (options: { apiBasePath?: string; userKey: string | null }) => bootstrapMock(options),
25
+ primeDailyReportIdsStreamSession: () => primeMock(),
26
+ }))
27
+
28
+ import { createDailyReportClientLoader, dailyReportShouldRevalidate } from "./route-helpers"
29
+
30
+ describe("createDailyReportClientLoader", () => {
31
+ it("prime → serverLoader → bootstrap(userKey) の順で起動し、serverData を素通しする", async () => {
32
+ calls.length = 0
33
+ const clientLoader = createDailyReportClientLoader<{ userId: string | null; extra: number }>()
34
+ const serverLoader = vi.fn(async () => {
35
+ calls.push("serverLoader")
36
+ return { userId: "ENC8", extra: 42 }
37
+ })
38
+
39
+ const result = await clientLoader({ serverLoader })
40
+
41
+ expect(calls).toEqual(["prime", "serverLoader", "bootstrap:ENC8"])
42
+ expect(result).toEqual({ userId: "ENC8", extra: 42 })
43
+ })
44
+
45
+ it("userId が文字列でない serverData は userKey null で起動する", async () => {
46
+ bootstrapMock.mockClear()
47
+ const clientLoader = createDailyReportClientLoader()
48
+ await clientLoader({ serverLoader: async () => ({ userId: null }) })
49
+ expect(bootstrapMock).toHaveBeenCalledWith({ apiBasePath: undefined, userKey: null })
50
+
51
+ await clientLoader({ serverLoader: async () => ({}) })
52
+ expect(bootstrapMock).toHaveBeenLastCalledWith({ apiBasePath: undefined, userKey: null })
53
+ })
54
+
55
+ it("factory の apiBasePath オプションは bootstrap へ透過する", async () => {
56
+ bootstrapMock.mockClear()
57
+ const clientLoader = createDailyReportClientLoader({ apiBasePath: "/custom/api" })
58
+ await clientLoader({ serverLoader: async () => ({ userId: "ENC9" }) })
59
+ expect(bootstrapMock).toHaveBeenCalledWith({ apiBasePath: "/custom/api", userKey: "ENC9" })
60
+ })
61
+
62
+ it("hydrate フラグが立っている (初期ハイドレーションでも実行される契約)", () => {
63
+ const clientLoader = createDailyReportClientLoader()
64
+ expect(clientLoader.hydrate).toBe(true)
65
+ })
66
+ })
67
+
68
+ describe("dailyReportShouldRevalidate", () => {
69
+ /** 最小限の引数組み立て。 */
70
+ const makeArgs = (over: Record<string, unknown> = {}) =>
71
+ ({
72
+ defaultShouldRevalidate: true,
73
+ currentUrl: new URL("http://localhost/daily_report"),
74
+ nextUrl: new URL("http://localhost/daily_report"),
75
+ actionResult: undefined,
76
+ actionStatus: undefined,
77
+ formAction: undefined,
78
+ formData: undefined,
79
+ formEncType: undefined,
80
+ formMethod: undefined,
81
+ json: undefined,
82
+ text: undefined,
83
+ ...over,
84
+ // biome-ignore lint/suspicious/noExplicitAny: ShouldRevalidateFunctionArgs の完全構築はテストに不要
85
+ }) as any
86
+
87
+ it("同一 URL かつ送信シグナル無しは再検証しない", () => {
88
+ expect(dailyReportShouldRevalidate(makeArgs())).toBe(false)
89
+ })
90
+
91
+ it("URL が変われば既定に従う", () => {
92
+ expect(dailyReportShouldRevalidate(makeArgs({ nextUrl: new URL("http://localhost/daily_report?businessDate=2026-08-01") }))).toBe(true)
93
+ })
94
+
95
+ it("ミューテーション intent は再検証しない (SSE が伝搬を担う)", () => {
96
+ const formData = new FormData()
97
+ formData.set("intent", "toggleRead")
98
+ expect(dailyReportShouldRevalidate(makeArgs({ formData }))).toBe(false)
99
+ })
100
+ })
@@ -3,8 +3,7 @@
3
3
  * 日報ページ向け React Router ルートヘルパー (再検証ポリシー / クライアントローダー)。
4
4
  */
5
5
  import type { ShouldRevalidateFunction, ShouldRevalidateFunctionArgs } from "react-router"
6
- import type { DailyReportItem } from "../shared/types"
7
- import { defaultDailyReportClientConfig } from "./config-context"
6
+ import { bootstrapDailyReportIdsStreamSession, primeDailyReportIdsStreamSession } from "./streaming/daily-report-ids-stream-session"
8
7
 
9
8
  /**
10
9
  * Prevents loader revalidation when POP navigations keep identical URL state.
@@ -32,50 +31,30 @@ export const dailyReportShouldRevalidate: ShouldRevalidateFunction = ({ actionRe
32
31
  }
33
32
 
34
33
  /**
35
- * Fetches the deferred daily report id list from the API endpoint.
36
- * API エンドポイントから日報 ID 一覧を遅延取得する処理。
37
- */
38
- export const fetchDailyReportIds = (apiBasePath: string = defaultDailyReportClientConfig.apiBasePath, forceRefresh = false): Promise<DailyReportItem[]> => {
39
- const url = new URL(`${apiBasePath}/ids`, typeof window !== "undefined" ? window.location.origin : "http://localhost")
40
- if (forceRefresh) {
41
- url.searchParams.set("forceRefresh", "true")
42
- url.searchParams.set("_t", Date.now().toString())
43
- }
44
- return fetch(url.toString(), {
45
- method: "GET",
46
- headers: { "Content-Type": "application/json" },
47
- cache: "no-cache",
48
- })
49
- .then(async (response) => {
50
- if (!response.ok) {
51
- throw new Error(`Failed to fetch daily report ids: ${response.status} ${response.statusText}`)
52
- }
53
- return response
54
- })
55
- .then(async (response) => response.json())
56
- .then((data) => {
57
- if (!(data.ids && Array.isArray(data.ids))) {
58
- throw new Error("Invalid daily report ids response")
59
- }
60
- return data.ids as DailyReportItem[]
61
- })
62
- }
63
-
64
- /**
65
- * Creates a React Router clientLoader that merges server data with deferred report ids.
66
- * サーバーデータへ遅延日報 ID を合成する React Router clientLoader を生成する処理。
34
+ * Creates a React Router clientLoader that bootstraps the ids stream session.
35
+ * ID ストリームセッションを起動する React Router clientLoader を生成する処理。
36
+ *
37
+ * ID 一覧は Promise で返さず、モジュール常駐のストリームセッションが供給する
38
+ * (ページ側は `useDailyReportIdsStream` で購読する)。ローダー時点でセッションを
39
+ * 生成することが**コールドロードの最重要最適化** — ページマウントの effect まで
40
+ * 遅らせると、ストリームの開始が「ハイドレーション完了 + effect flush」の後ろに
41
+ * 直列化される。再訪 (セッション既存) は serverLoader を待たずに鮮度確認を先行させる。
42
+ *
43
+ * `config.apiBasePath` を上書きするアプリは同じ値を `options.apiBasePath` にも渡すこと
44
+ * (渡し忘れは初回 1 回だけ誤パス生成 → ページマウントの ensure が自己修復する)。
67
45
  *
68
46
  * 返す関数へ `hydrate = true as const` を付与済み (初期ハイドレーションでも実行される)。
69
47
  */
70
48
  export const createDailyReportClientLoader = <TServerData extends Record<string, unknown>>(options?: { apiBasePath?: string }) => {
71
- const clientLoader = async (args: { serverLoader: () => Promise<unknown> }): Promise<TServerData & { dailyReportIds: Promise<DailyReportItem[]> }> => {
72
- // サーバーローダーの解決を待つ間に ID 取得を並行開始する
73
- const idsPromise = fetchDailyReportIds(options?.apiBasePath)
49
+ const clientLoader = async (args: { serverLoader: () => Promise<unknown> }): Promise<TServerData> => {
50
+ // 再訪の主経路: 既存セッションの鮮度確認を serverLoader より先に始める
51
+ primeDailyReportIdsStreamSession()
74
52
  const serverData = (await args.serverLoader()) as TServerData
75
- return {
76
- ...serverData,
77
- dailyReportIds: idsPromise,
78
- }
53
+ // 不在なら生成 (コールドロードでハイドレーションと並行してフェッチを開始)、
54
+ // 既存なら userKey 照合 (別ユーザーは描画前に破棄・再生成) + 鮮度確認
55
+ const rawUserId = (serverData as { userId?: unknown }).userId
56
+ bootstrapDailyReportIdsStreamSession({ apiBasePath: options?.apiBasePath, userKey: typeof rawUserId === "string" ? rawUserId : null })
57
+ return serverData
79
58
  }
80
59
  clientLoader.hydrate = true as const
81
60
  return clientLoader