@aiquants/daily-report 0.7.0 → 0.8.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 (43) hide show
  1. package/README.md +13 -6
  2. package/dist/client.d.mts +363 -21
  3. package/dist/client.d.ts +363 -21
  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 +76 -2
  9. package/dist/index.d.ts +76 -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 +2 -0
  15. package/dist/server.d.ts +2 -0
  16. package/dist/server.js +8 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +7 -5
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/styles/daily-report.standalone.css +1 -1
  21. package/package.json +3 -3
  22. package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
  23. package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
  24. package/src/client/components/daily-report-page.spec.tsx +119 -0
  25. package/src/client/components/daily-report-page.tsx +56 -68
  26. package/src/client/components/daily-report-resolved-content.tsx +31 -7
  27. package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
  28. package/src/client/contexts/daily-report-action-context.tsx +149 -52
  29. package/src/client/hooks/use-animated-number.spec.ts +103 -0
  30. package/src/client/hooks/use-animated-number.ts +69 -0
  31. package/src/client/route-helpers.spec.ts +100 -0
  32. package/src/client/route-helpers.ts +20 -41
  33. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
  34. package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
  35. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
  36. package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
  37. package/src/client.ts +4 -0
  38. package/src/index.ts +1 -0
  39. package/src/server/handlers.ids-stream.spec.ts +362 -0
  40. package/src/server/handlers.ts +245 -5
  41. package/src/server/service.ts +40 -0
  42. package/src/server/test-helpers/handlers-config.ts +40 -1
  43. package/src/shared/ids-stream.ts +92 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Lifecycle tests for the module-resident ids stream session.
3
+ * モジュール常駐 ids ストリームセッションのライフサイクルテスト。
4
+ *
5
+ * ここで固定する不変条件のうち、特に重要なもの:
6
+ *
7
+ * - **userKey (難読化ユーザー ID) が変わったらセッションを作り直す** — 別ユーザーの
8
+ * 受信済みデータを次のユーザーへ見せてはならない。
9
+ * - **failed セッションへの再訪 (ensure) は自動 retry する** — 再訪はユーザーの再試行意思。
10
+ * - **完走から一定時間を過ぎた再訪はバックグラウンド再検証する** (SWR)。
11
+ * - **フックはセッション生成前でも安全に IDLE を返し、生成後は再購読なしで追従する。**
12
+ *
13
+ * モジュールスコープ状態を分離するため、テストごとに `vi.resetModules()` +
14
+ * 動的 import でモジュールを作り直す (use-daily-report.spec.ts と同じ流儀)。
15
+ */
16
+ import { act, renderHook } from "@testing-library/react"
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
18
+ import type { DailyReportIdsStreamState } from "./daily-report-ids-stream-client"
19
+
20
+ /** FakeClient の呼び出し記録と状態操作面。 */
21
+ type FakeClientHandle = {
22
+ startCalls: number
23
+ retryCalls: number
24
+ refreshCalls: Array<{ forceRefresh?: boolean }>
25
+ disposeCalls: number
26
+ upserts: Array<{ reportHubId: number; businessDate: string | null }>
27
+ removals: number[]
28
+ setState: (partial: Partial<DailyReportIdsStreamState>) => void
29
+ options: { apiBasePath?: string }
30
+ }
31
+
32
+ /** 生成された FakeClient のハンドル一覧 (生成順)。 */
33
+ const createdClients: FakeClientHandle[] = []
34
+
35
+ /** ユーザー切替時の詳細キャッシュ破棄の呼び出し記録。 */
36
+ const clearCacheMock = vi.fn()
37
+
38
+ vi.mock("../hooks/use-daily-report", () => ({
39
+ clearDailyReportCache: () => clearCacheMock(),
40
+ }))
41
+
42
+ vi.mock("./daily-report-ids-stream-client", () => {
43
+ class FakeClient {
44
+ private listeners = new Set<() => void>()
45
+ private state: DailyReportIdsStreamState = {
46
+ items: [],
47
+ loadedCount: 0,
48
+ totalCount: null,
49
+ phase: "idle",
50
+ isRevalidating: false,
51
+ hasCompletedOnce: false,
52
+ completedAt: null,
53
+ error: null,
54
+ }
55
+ readonly handle: FakeClientHandle
56
+
57
+ constructor(options: { apiBasePath?: string } = {}) {
58
+ this.handle = {
59
+ startCalls: 0,
60
+ retryCalls: 0,
61
+ refreshCalls: [],
62
+ disposeCalls: 0,
63
+ upserts: [],
64
+ removals: [],
65
+ options,
66
+ setState: (partial) => {
67
+ this.state = { ...this.state, ...partial }
68
+ for (const listener of this.listeners) listener()
69
+ },
70
+ }
71
+ createdClients.push(this.handle)
72
+ }
73
+
74
+ getState() {
75
+ return this.state
76
+ }
77
+
78
+ subscribe(listener: () => void) {
79
+ this.listeners.add(listener)
80
+ return () => this.listeners.delete(listener)
81
+ }
82
+
83
+ start() {
84
+ this.handle.startCalls += 1
85
+ }
86
+
87
+ retry() {
88
+ this.handle.retryCalls += 1
89
+ }
90
+
91
+ refresh(options: { forceRefresh?: boolean } = {}) {
92
+ this.handle.refreshCalls.push(options)
93
+ }
94
+
95
+ dispose() {
96
+ this.handle.disposeCalls += 1
97
+ }
98
+
99
+ applyExternalUpsert(item: { reportHubId: number; businessDate: string | null }) {
100
+ this.handle.upserts.push(item)
101
+ }
102
+
103
+ applyExternalRemoval(reportHubId: number) {
104
+ this.handle.removals.push(reportHubId)
105
+ }
106
+ }
107
+ return { DailyReportIdsStreamClient: FakeClient }
108
+ })
109
+
110
+ /** テスト対象モジュールを新品で読み込む。 */
111
+ const importSession = async () => await import("./daily-report-ids-stream-session")
112
+
113
+ beforeEach(() => {
114
+ vi.resetModules()
115
+ createdClients.length = 0
116
+ clearCacheMock.mockClear()
117
+ vi.useFakeTimers()
118
+ })
119
+
120
+ afterEach(() => {
121
+ vi.useRealTimers()
122
+ })
123
+
124
+ describe("ensureDailyReportIdsStreamSession", () => {
125
+ it("初回 ensure でクライアントを生成して start する", async () => {
126
+ const session = await importSession()
127
+ session.ensureDailyReportIdsStreamSession({ apiBasePath: "/daily_report/api", userKey: "userA" })
128
+ expect(createdClients).toHaveLength(1)
129
+ expect(createdClients[0].startCalls).toBe(1)
130
+ expect(createdClients[0].options.apiBasePath).toBe("/daily_report/api")
131
+ session.resetDailyReportIdsStreamSessionForTesting()
132
+ })
133
+
134
+ it("同一 userKey の再 ensure はセッションを再利用する", async () => {
135
+ const session = await importSession()
136
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
137
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
138
+ expect(createdClients).toHaveLength(1)
139
+ expect(createdClients[0].disposeCalls).toBe(0)
140
+ session.resetDailyReportIdsStreamSessionForTesting()
141
+ })
142
+
143
+ it("userKey が変わったら旧セッションと詳細キャッシュを破棄して作り直す", async () => {
144
+ const session = await importSession()
145
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
146
+ session.ensureDailyReportIdsStreamSession({ userKey: "userB" })
147
+ expect(createdClients).toHaveLength(2)
148
+ expect(createdClients[0].disposeCalls).toBe(1)
149
+ expect(createdClients[1].startCalls).toBe(1)
150
+ // 旧ユーザーが閲覧した詳細 (下書き含む) を新ユーザーへ供給しない
151
+ expect(clearCacheMock).toHaveBeenCalledTimes(1)
152
+ session.resetDailyReportIdsStreamSessionForTesting()
153
+ })
154
+
155
+ it("apiBasePath が変わったら作り直す (同一ユーザーなので詳細キャッシュは保持)", async () => {
156
+ const session = await importSession()
157
+ session.ensureDailyReportIdsStreamSession({ apiBasePath: "/a", userKey: "userA" })
158
+ session.ensureDailyReportIdsStreamSession({ apiBasePath: "/b", userKey: "userA" })
159
+ expect(createdClients).toHaveLength(2)
160
+ expect(createdClients[0].disposeCalls).toBe(1)
161
+ expect(clearCacheMock).not.toHaveBeenCalled()
162
+ session.resetDailyReportIdsStreamSessionForTesting()
163
+ })
164
+
165
+ it("failed セッションへの ensure は retry する", async () => {
166
+ const session = await importSession()
167
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
168
+ createdClients[0].setState({ phase: "failed" })
169
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
170
+ expect(createdClients[0].retryCalls).toBe(1)
171
+ expect(createdClients[0].refreshCalls).toHaveLength(0)
172
+ session.resetDailyReportIdsStreamSessionForTesting()
173
+ })
174
+
175
+ it("完走直後の ensure は再検証しないが、鮮度窓を過ぎた ensure は refresh する", async () => {
176
+ const session = await importSession()
177
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
178
+ createdClients[0].setState({ phase: "complete", hasCompletedOnce: true, completedAt: Date.now() })
179
+
180
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
181
+ expect(createdClients[0].refreshCalls).toHaveLength(0)
182
+
183
+ // 鮮度窓 (60 秒) を越えて再訪
184
+ vi.advanceTimersByTime(61_000)
185
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
186
+ expect(createdClients[0].refreshCalls).toHaveLength(1)
187
+ session.resetDailyReportIdsStreamSessionForTesting()
188
+ })
189
+
190
+ it("再検証の諦め (completedAt 据え置き) 後の ensure は再び refresh する — 古いデータを新鮮扱いしない", async () => {
191
+ const session = await importSession()
192
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
193
+ createdClients[0].setState({ phase: "complete", hasCompletedOnce: true, completedAt: Date.now() })
194
+
195
+ vi.advanceTimersByTime(61_000)
196
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
197
+ expect(createdClients[0].refreshCalls).toHaveLength(1)
198
+
199
+ // 再検証の力尽き publish: phase は complete のまま、completedAt は据え置き
200
+ createdClients[0].setState({ isRevalidating: false, error: new Error("gave up") })
201
+
202
+ // 次の ensure は据え置かれた completedAt を根拠に再検証を再度走らせる
203
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
204
+ expect(createdClients[0].refreshCalls).toHaveLength(2)
205
+ session.resetDailyReportIdsStreamSessionForTesting()
206
+ })
207
+
208
+ it("undefined と既定文字列の apiBasePath は同一視され、セッションを破棄しない", async () => {
209
+ const session = await importSession()
210
+ // clientLoader 相当 (apiBasePath 未指定 = undefined)
211
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
212
+ // ページ effect 相当 (config の既定文字列)
213
+ session.ensureDailyReportIdsStreamSession({ apiBasePath: "/daily_report/api", userKey: "userA" })
214
+ expect(createdClients).toHaveLength(1)
215
+ expect(createdClients[0].disposeCalls).toBe(0)
216
+ session.resetDailyReportIdsStreamSessionForTesting()
217
+ })
218
+ })
219
+
220
+ describe("ensure / isRevalidating ガード", () => {
221
+ it("走行中の再検証 (isRevalidating) がある間の ensure は refresh を重ねない", async () => {
222
+ const session = await importSession()
223
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
224
+ createdClients[0].setState({ phase: "complete", hasCompletedOnce: true, completedAt: Date.now() })
225
+ vi.advanceTimersByTime(61_000)
226
+
227
+ // 1 回目の ensure が再検証を起動する想定 (FakeClient は refresh を記録するだけ)
228
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
229
+ expect(createdClients[0].refreshCalls).toHaveLength(1)
230
+
231
+ // 実クライアントは再検証中 isRevalidating を立てる。以後の ensure は重ねない
232
+ createdClients[0].setState({ isRevalidating: true })
233
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
234
+ session.primeDailyReportIdsStreamSession()
235
+ expect(createdClients[0].refreshCalls).toHaveLength(1)
236
+ session.resetDailyReportIdsStreamSessionForTesting()
237
+ })
238
+ })
239
+
240
+ describe("bootstrapDailyReportIdsStreamSession", () => {
241
+ it("セッションが無ければローダー時点で生成して start する (コールドロードの最重要最適化)", async () => {
242
+ const session = await importSession()
243
+ session.bootstrapDailyReportIdsStreamSession({ apiBasePath: "/daily_report/api", userKey: "userA" })
244
+ expect(createdClients).toHaveLength(1)
245
+ expect(createdClients[0].startCalls).toBe(1)
246
+ session.resetDailyReportIdsStreamSessionForTesting()
247
+ })
248
+
249
+ it("userKey 不一致なら描画前に破棄 (詳細キャッシュ含む) し、新ユーザーで即時再生成する", async () => {
250
+ const session = await importSession()
251
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
252
+ session.bootstrapDailyReportIdsStreamSession({ userKey: "userB" })
253
+ expect(createdClients[0].disposeCalls).toBe(1)
254
+ expect(createdClients).toHaveLength(2)
255
+ expect(createdClients[1].startCalls).toBe(1)
256
+ expect(clearCacheMock).toHaveBeenCalledTimes(1)
257
+ session.resetDailyReportIdsStreamSessionForTesting()
258
+ })
259
+
260
+ it("userKey 一致なら既存セッションを保ち鮮度確認を行う (failed は retry)。パスは照合しない", async () => {
261
+ const session = await importSession()
262
+ session.ensureDailyReportIdsStreamSession({ apiBasePath: "/custom/api", userKey: "userA" })
263
+ createdClients[0].setState({ phase: "failed" })
264
+ // factory 未指定 (undefined → 既定値) でもセッションは破棄されない —
265
+ // 照合するとナビゲーションごとのスラッシングが再発する
266
+ session.bootstrapDailyReportIdsStreamSession({ userKey: "userA" })
267
+ expect(createdClients).toHaveLength(1)
268
+ expect(createdClients[0].disposeCalls).toBe(0)
269
+ expect(createdClients[0].retryCalls).toBe(1)
270
+ session.resetDailyReportIdsStreamSessionForTesting()
271
+ })
272
+ })
273
+
274
+ describe("外部ミューテーション中継", () => {
275
+ it("upsert / removal はセッションのクライアントへ委譲され、セッション無しでは何もしない", async () => {
276
+ const session = await importSession()
277
+ // セッション無し: 例外なく無視される
278
+ session.upsertDailyReportIdsStreamItem({ reportHubId: 1, businessDate: "2026-08-01" })
279
+ session.removeDailyReportIdsStreamItem(1)
280
+ expect(createdClients).toHaveLength(0)
281
+
282
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
283
+ session.upsertDailyReportIdsStreamItem({ reportHubId: 2, businessDate: "2026-08-02" })
284
+ session.removeDailyReportIdsStreamItem(3)
285
+ expect(createdClients[0].upserts).toEqual([{ reportHubId: 2, businessDate: "2026-08-02" }])
286
+ expect(createdClients[0].removals).toEqual([3])
287
+ session.resetDailyReportIdsStreamSessionForTesting()
288
+ })
289
+ })
290
+
291
+ describe("primeDailyReportIdsStreamSession", () => {
292
+ it("セッションが無ければ何もしない", async () => {
293
+ const session = await importSession()
294
+ session.primeDailyReportIdsStreamSession()
295
+ expect(createdClients).toHaveLength(0)
296
+ })
297
+
298
+ it("既存セッションがあれば userKey 未確定でも鮮度確認を先行できる", async () => {
299
+ const session = await importSession()
300
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
301
+ createdClients[0].setState({ phase: "complete", hasCompletedOnce: true, completedAt: Date.now() })
302
+ vi.advanceTimersByTime(61_000)
303
+ session.primeDailyReportIdsStreamSession()
304
+ expect(createdClients[0].refreshCalls).toHaveLength(1)
305
+ // prime はセッションを作り直さない
306
+ expect(createdClients).toHaveLength(1)
307
+ session.resetDailyReportIdsStreamSessionForTesting()
308
+ })
309
+ })
310
+
311
+ describe("useDailyReportIdsStream", () => {
312
+ it("セッション生成前は IDLE、生成後は再購読なしで追従する", async () => {
313
+ const session = await importSession()
314
+ const { result } = renderHook(() => session.useDailyReportIdsStream())
315
+ expect(result.current.phase).toBe("idle")
316
+ expect(result.current.items).toEqual([])
317
+
318
+ act(() => {
319
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
320
+ })
321
+ act(() => {
322
+ createdClients[0].setState({
323
+ phase: "streaming",
324
+ loadedCount: 2,
325
+ items: [
326
+ { reportHubId: 2, businessDate: "2026-08-01" },
327
+ { reportHubId: 1, businessDate: "2026-08-01" },
328
+ ],
329
+ })
330
+ })
331
+ expect(result.current.phase).toBe("streaming")
332
+ expect(result.current.loadedCount).toBe(2)
333
+ session.resetDailyReportIdsStreamSessionForTesting()
334
+ })
335
+
336
+ it("retry / refresh のエントリポイントはクライアントへ委譲する", async () => {
337
+ const session = await importSession()
338
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
339
+ session.retryDailyReportIdsStream()
340
+ session.refreshDailyReportIdsStream({ forceRefresh: true })
341
+ expect(createdClients[0].retryCalls).toBe(1)
342
+ expect(createdClients[0].refreshCalls).toEqual([{ forceRefresh: true }])
343
+ session.resetDailyReportIdsStreamSessionForTesting()
344
+ })
345
+
346
+ it("resetDailyReportIdsStreamSessionForTesting はセッションを破棄して IDLE へ戻す", async () => {
347
+ const session = await importSession()
348
+ const { result } = renderHook(() => session.useDailyReportIdsStream())
349
+ act(() => {
350
+ session.ensureDailyReportIdsStreamSession({ userKey: "userA" })
351
+ createdClients[0].setState({ phase: "streaming", loadedCount: 1 })
352
+ })
353
+ expect(result.current.phase).toBe("streaming")
354
+ act(() => {
355
+ session.resetDailyReportIdsStreamSessionForTesting()
356
+ })
357
+ expect(createdClients[0].disposeCalls).toBe(1)
358
+ expect(result.current.phase).toBe("idle")
359
+ })
360
+ })
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Module-resident session and React hook for the daily-report ids stream.
3
+ * 日報 ID ストリームのモジュール常駐セッションと React フック。
4
+ *
5
+ * セッション (クライアント実体 + 完走時刻) をモジュールスコープに置くことで、
6
+ * ページのアンマウント/再マウントをまたいで受信済みデータが生き残り、
7
+ * 再訪時はローディング画面なしで即座に一覧を復元できる。鮮度は
8
+ * 「完走から一定時間経過後の再訪でバックグラウンド再検証」(SWR) で担保する。
9
+ * マウント中のリアルタイム性は SSE が担うため、これで欠落窓は実質「離脱中」のみになる。
10
+ */
11
+ import { useSyncExternalStore } from "react"
12
+ import type { DailyReportItem } from "../../shared/types"
13
+ import { defaultDailyReportClientConfig } from "../config-context"
14
+ import { clearDailyReportCache } from "../hooks/use-daily-report"
15
+ import { DailyReportIdsStreamClient, type DailyReportIdsStreamState } from "./daily-report-ids-stream-client"
16
+
17
+ /** 完走からこの時間を過ぎた再訪で、バックグラウンド再検証を走らせる。 */
18
+ const REVALIDATE_AFTER_MS = 60_000
19
+
20
+ /** セッション 1 世代分の記録。 */
21
+ type SessionRecord = {
22
+ client: DailyReportIdsStreamClient
23
+ /** セッションの持ち主 (難読化ユーザー ID。未登録ユーザーは null)。 */
24
+ userKey: string | null
25
+ /** 正規化済み API ベースパス (既定値解決済み)。 */
26
+ apiBasePath: string
27
+ unsubscribe: () => void
28
+ }
29
+
30
+ let session: SessionRecord | null = null
31
+
32
+ /** セッションの生成/破棄とクライアント publish の両方を伝えるリスナー群。 */
33
+ const sessionListeners = new Set<() => void>()
34
+
35
+ /** セッション未生成時に返す不変スナップショット (SSR の getServerSnapshot も兼ねる)。 */
36
+ const IDLE_STATE: DailyReportIdsStreamState = {
37
+ items: [],
38
+ loadedCount: 0,
39
+ totalCount: null,
40
+ phase: "idle",
41
+ isRevalidating: false,
42
+ hasCompletedOnce: false,
43
+ completedAt: null,
44
+ error: null,
45
+ }
46
+
47
+ /**
48
+ * Full-document reload indirection (mockable seam for auth-expiry tests).
49
+ * ドキュメント全体を再読込する間接層 (認証切れテストで差し替え可能にする継ぎ目)。
50
+ */
51
+ export const reloadDocument = (): void => {
52
+ if (typeof window !== "undefined") {
53
+ window.location.reload()
54
+ }
55
+ }
56
+
57
+ /** 全セッションリスナーへ変更を通知する。 */
58
+ const notifySessionListeners = (): void => {
59
+ for (const listener of sessionListeners) {
60
+ listener()
61
+ }
62
+ }
63
+
64
+ /** 現行セッションを破棄する (ユーザー切替・HMR・テストリセット用)。 */
65
+ const destroySession = (): void => {
66
+ if (!session) return
67
+ session.unsubscribe()
68
+ session.client.dispose()
69
+ session = null
70
+ notifySessionListeners()
71
+ }
72
+
73
+ /**
74
+ * Ensures a stream session for the given user, creating/refreshing as needed.
75
+ * 指定ユーザーのストリームセッションを確立する処理 (必要に応じ生成/再検証)。
76
+ *
77
+ * - ユーザーまたは API パスが変わっていれば作り直す (別ユーザーのデータを見せない)。
78
+ * - failed なら自動 retry (再訪 = ユーザーの再試行意思とみなす)。
79
+ * - 完走から {@link REVALIDATE_AFTER_MS} 経過していればバックグラウンド再検証。
80
+ *
81
+ * SSR では何もしない。clientLoader とページマウントの両方から呼ばれ、冪等。
82
+ *
83
+ * @param options.apiBasePath API ベースパス (config の apiBasePath)。
84
+ * @param options.userKey 難読化ユーザー ID (未登録ユーザーは null)。
85
+ */
86
+ export const ensureDailyReportIdsStreamSession = (options: { apiBasePath?: string; userKey: string | null }): void => {
87
+ if (typeof window === "undefined") return
88
+
89
+ // 呼び出し元によって undefined / 既定文字列が混在するため、比較前に必ず正規化する。
90
+ // 正規化を欠くと clientLoader (undefined) とページ effect (既定文字列) の往復で
91
+ // セッションが毎回破棄され、モジュール常駐による即時復元が丸ごと無効化される
92
+ const apiBasePath = options.apiBasePath ?? defaultDailyReportClientConfig.apiBasePath
93
+
94
+ if (session && (session.userKey !== options.userKey || session.apiBasePath !== apiBasePath)) {
95
+ // ユーザーが変わる場合はモジュール常駐の詳細キャッシュも破棄する
96
+ // (旧ユーザーが閲覧した下書き詳細等が TTL 内の新ユーザーへ供給されるのを防ぐ)
97
+ if (session.userKey !== options.userKey) {
98
+ clearDailyReportCache()
99
+ }
100
+ destroySession()
101
+ }
102
+
103
+ if (!session) {
104
+ const client = new DailyReportIdsStreamClient({ apiBasePath })
105
+ session = {
106
+ client,
107
+ userKey: options.userKey,
108
+ apiBasePath,
109
+ // 通知はそのままセッションリスナーへ中継する (完走時刻はクライアントが state に刻む)
110
+ unsubscribe: client.subscribe(notifySessionListeners),
111
+ }
112
+ client.start()
113
+ notifySessionListeners()
114
+ return
115
+ }
116
+
117
+ runFreshnessCheck(session)
118
+ }
119
+
120
+ /**
121
+ * Retries a failed session or starts a background revalidation when stale.
122
+ * failed セッションの retry、または鮮度切れ時のバックグラウンド再検証を起動する処理。
123
+ *
124
+ * 再検証が既に走行中 (`isRevalidating`) の間は何もしない。ガードを欠くと、1 回の
125
+ * 再訪で走る複数の確立呼び出し (clientLoader の prime / ensure / ページ effect) が
126
+ * それぞれ走行中の再検証を中断して先頭からやり直し、NDJSON を多重ダウンロードする。
127
+ */
128
+ const runFreshnessCheck = (record: SessionRecord): void => {
129
+ const state = record.client.getState()
130
+ if (state.phase === "failed") {
131
+ record.client.retry()
132
+ return
133
+ }
134
+ if (state.phase === "complete" && !state.isRevalidating && state.completedAt !== null && Date.now() - state.completedAt > REVALIDATE_AFTER_MS) {
135
+ record.client.refresh()
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Bootstraps the session from the clientLoader: create when absent, else reconcile.
141
+ * clientLoader からのセッション起動処理。不在なら生成、既存なら照合と鮮度確認。
142
+ *
143
+ * コールドロードの最重要ゲート対策: 生成をページマウントの effect まで遅らせると、
144
+ * ストリームの最初のバイト要求が「serverLoader 完了 → ハイドレーション → effect flush」
145
+ * の後ろに直列化される。ローダー時点で生成すればハイドレーションと並行してフェッチが走る。
146
+ *
147
+ * パス契約: 生成時のパスは factory オプション (未指定は既定値へ正規化)。
148
+ * `config.apiBasePath` だけを上書きした利用者では初回のみ誤パスで生成されるが、
149
+ * ページマウント側の ensure がパス不一致を検知して作り直す (自己修復・初回 1 回きり)。
150
+ * **既存セッションのパスはここでは照合しない** — 照合すると、factory 未指定 ×
151
+ * config 上書きの組み合わせでナビゲーションごとにセッションが破棄されるスラッシングになる。
152
+ *
153
+ * @param options.apiBasePath 生成時に使う API ベースパス (未指定は既定値)。
154
+ * @param options.userKey 難読化ユーザー ID (未登録ユーザーは null)。
155
+ */
156
+ export const bootstrapDailyReportIdsStreamSession = (options: { apiBasePath?: string; userKey: string | null }): void => {
157
+ if (typeof window === "undefined") return
158
+ if (!session) {
159
+ ensureDailyReportIdsStreamSession(options)
160
+ return
161
+ }
162
+ if (session.userKey !== options.userKey) {
163
+ // 別ユーザーのデータは描画前に破棄し (詳細キャッシュ含む)、新ユーザーのストリームを即時開始する
164
+ clearDailyReportCache()
165
+ destroySession()
166
+ ensureDailyReportIdsStreamSession(options)
167
+ return
168
+ }
169
+ runFreshnessCheck(session)
170
+ }
171
+
172
+ /**
173
+ * Kicks a freshness check for an existing session before the user is known.
174
+ * ユーザー確定前に既存セッションの鮮度確認だけを先行させる処理。
175
+ *
176
+ * clientLoader が serverLoader を await する前に呼び、再訪 (支配的経路) の
177
+ * 再検証開始を数十〜数百 ms 前倒しする。セッションが無ければ何もしない
178
+ * (誰のデータを読むべきか分からないため)。直後の reconcile が userKey を照合し、
179
+ * ユーザーが変わっていればセッションごと破棄されるので先行は安全。
180
+ * 走行中の再検証は中断しない (runFreshnessCheck の isRevalidating ガード)。
181
+ */
182
+ export const primeDailyReportIdsStreamSession = (): void => {
183
+ if (typeof window === "undefined" || !session) return
184
+ runFreshnessCheck(session)
185
+ }
186
+
187
+ /**
188
+ * Relays an externally-known item upsert into the resident stream data.
189
+ * 外部由来のアイテム upsert を常駐ストリームデータへ中継する処理。
190
+ *
191
+ * プロバイダーの作成/公開系ミューテーション (ローカル操作と SSE) から呼ばれる。
192
+ * これを欠くと、鮮度窓内の再訪で「作成したばかりの日報が消える」。
193
+ */
194
+ export const upsertDailyReportIdsStreamItem = (item: DailyReportItem): void => {
195
+ session?.client.applyExternalUpsert(item)
196
+ }
197
+
198
+ /**
199
+ * Relays an externally-known item removal into the resident stream data.
200
+ * 外部由来のアイテム削除を常駐ストリームデータへ中継する処理。
201
+ *
202
+ * report-delete SSE・ローカル削除・stale 安全弁から呼ばれる。
203
+ * これを欠くと、鮮度窓内の再訪で「削除したはずの日報が蘇る」。
204
+ */
205
+ export const removeDailyReportIdsStreamItem = (reportHubId: number): void => {
206
+ session?.client.applyExternalRemoval(reportHubId)
207
+ }
208
+
209
+ /**
210
+ * Restarts a failed stream (UI retry button entry point).
211
+ * failed ストリームを再開する処理 (UI の再試行ボタン入口)。
212
+ */
213
+ export const retryDailyReportIdsStream = (): void => {
214
+ session?.client.retry()
215
+ }
216
+
217
+ /**
218
+ * Re-validates the stream, optionally bypassing the server-side cache.
219
+ * ストリームを再検証する処理 (サーバーキャッシュのバイパス指定可)。
220
+ *
221
+ * 開発向けリロード/キャッシュクリアボタンから呼ばれる。
222
+ *
223
+ * @param options.forceRefresh サーバー側 SqlResultCache をバイパスするか。
224
+ */
225
+ export const refreshDailyReportIdsStream = (options: { forceRefresh?: boolean } = {}): void => {
226
+ session?.client.refresh(options)
227
+ }
228
+
229
+ /**
230
+ * Returns the current session's owner key (undefined when no session exists).
231
+ * 現行セッションの持ち主 (userKey) を返す処理 (セッション不在時は undefined)。
232
+ *
233
+ * 遅延解決するミューテーション継続の門番に使う。POST /action の待機中にリロード無しの
234
+ * ユーザー切替が起きると、切替時の三重遮断 (セッション破棄 + 詳細キャッシュ破棄 +
235
+ * プロバイダーリマウント) の**後**に旧ユーザーの継続が解決し、モジュール常駐状態
236
+ * (新ユーザーのセッション・詳細キャッシュ) へ旧ユーザーのデータを注入してしまう。
237
+ * 継続側は開始時点の userKey とこの値を比較し、不一致 (undefined 含む) なら
238
+ * モジュール常駐状態への書き込みを放棄する (fail-closed)。
239
+ */
240
+ export const getDailyReportIdsStreamSessionUserKey = (): string | null | undefined => session?.userKey
241
+
242
+ /** セッション購読 (useSyncExternalStore 用)。 */
243
+ const subscribeSession = (listener: () => void): (() => void) => {
244
+ sessionListeners.add(listener)
245
+ return () => sessionListeners.delete(listener)
246
+ }
247
+
248
+ /** 現在のスナップショットを返す (セッション未生成時は不変の IDLE)。 */
249
+ const getSessionSnapshot = (): DailyReportIdsStreamState => session?.client.getState() ?? IDLE_STATE
250
+
251
+ /** SSR スナップショット (常に IDLE)。 */
252
+ const getServerSnapshot = (): DailyReportIdsStreamState => IDLE_STATE
253
+
254
+ /**
255
+ * React binding for the module-resident ids stream session.
256
+ * モジュール常駐 ids ストリームセッションの React バインディング。
257
+ *
258
+ * セッションが後から生成されても再購読なしで追従する (リスナーはセッションではなく
259
+ * モジュールに属する)。スナップショット参照は publish 単位で安定している。
260
+ *
261
+ * @returns Current stream state. 現在のストリーム状態。
262
+ */
263
+ export const useDailyReportIdsStream = (): DailyReportIdsStreamState => {
264
+ return useSyncExternalStore(subscribeSession, getSessionSnapshot, getServerSnapshot)
265
+ }
266
+
267
+ /**
268
+ * Destroys the session (test isolation helper).
269
+ * セッションを破棄するテスト用ヘルパー。
270
+ */
271
+ export const resetDailyReportIdsStreamSessionForTesting = (): void => {
272
+ destroySession()
273
+ }
274
+
275
+ // HMR: モジュール差し替え時に旧セッションの接続とリスナーを確実に始末する
276
+ if (typeof import.meta !== "undefined" && (import.meta as { hot?: { dispose: (cb: () => void) => void } }).hot) {
277
+ ;(import.meta as unknown as { hot: { dispose: (cb: () => void) => void } }).hot.dispose(() => {
278
+ destroySession()
279
+ })
280
+ }
package/src/client.ts CHANGED
@@ -9,15 +9,19 @@ export * from "./client/components/daily-report-comment-item"
9
9
  export * from "./client/components/daily-report-comment-section"
10
10
  export * from "./client/components/daily-report-detail-list"
11
11
  export * from "./client/components/daily-report-edit-form"
12
+ export * from "./client/components/daily-report-ids-stream-status"
12
13
  export * from "./client/components/daily-report-list"
13
14
  export * from "./client/components/daily-report-page"
14
15
  export * from "./client/components/daily-report-resolved-content"
15
16
  export * from "./client/components/unread-indicator"
16
17
  export * from "./client/config-context"
17
18
  export * from "./client/contexts/daily-report-action-context"
19
+ export * from "./client/hooks/use-animated-number"
18
20
  export * from "./client/hooks/use-daily-report"
19
21
  export * from "./client/hooks/use-daily-report-comments"
20
22
  export * from "./client/hooks/use-daily-report-sse-connection"
21
23
  export * from "./client/hooks/use-dynamic-viewport-height"
22
24
  export * from "./client/route-helpers"
25
+ export * from "./client/streaming/daily-report-ids-stream-client"
26
+ export * from "./client/streaming/daily-report-ids-stream-session"
23
27
  export * from "./client/utils/constants"
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  export * from "./shared/business-date"
6
6
  export * from "./shared/comment-adapter"
7
+ export * from "./shared/ids-stream"
7
8
  export * from "./shared/logger"
8
9
  export * from "./shared/sse-schema"
9
10
  export * from "./shared/text-utils"