@aiquants/daily-report 0.6.2 → 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 (67) hide show
  1. package/README.md +13 -6
  2. package/dist/client.d.mts +443 -23
  3. package/dist/client.d.ts +443 -23
  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 +77 -3
  9. package/dist/index.d.ts +77 -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 +436 -6
  15. package/dist/server.d.ts +436 -6
  16. package/dist/server.js +8 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +8 -6
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
  21. package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
  22. package/dist/styles/daily-report.standalone.css +1 -1
  23. package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
  24. package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
  25. package/package.json +3 -3
  26. package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
  27. package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
  28. package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
  29. package/src/client/components/daily-report-attachment-list.tsx +112 -0
  30. package/src/client/components/daily-report-detail-list.tsx +14 -7
  31. package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
  32. package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
  33. package/src/client/components/daily-report-list.tsx +19 -11
  34. package/src/client/components/daily-report-page.spec.tsx +119 -0
  35. package/src/client/components/daily-report-page.tsx +56 -68
  36. package/src/client/components/daily-report-resolved-content.tsx +31 -7
  37. package/src/client/components/report-views.spec.tsx +340 -0
  38. package/src/client/config-context.tsx +8 -0
  39. package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
  40. package/src/client/contexts/daily-report-action-context.tsx +151 -52
  41. package/src/client/hooks/use-animated-number.spec.ts +103 -0
  42. package/src/client/hooks/use-animated-number.ts +69 -0
  43. package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
  44. package/src/client/hooks/use-responsive-layout.ts +56 -0
  45. package/src/client/route-helpers.spec.ts +100 -0
  46. package/src/client/route-helpers.ts +20 -41
  47. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
  48. package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
  49. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
  50. package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
  51. package/src/client/utils/constants.spec.ts +96 -0
  52. package/src/client/utils/constants.ts +47 -2
  53. package/src/client.ts +6 -0
  54. package/src/index.ts +1 -0
  55. package/src/server/handlers.attachment.spec.ts +470 -0
  56. package/src/server/handlers.ids-stream.spec.ts +362 -0
  57. package/src/server/handlers.ts +671 -6
  58. package/src/server/ports.ts +74 -0
  59. package/src/server/schema.ts +80 -5
  60. package/src/server/service.spec.ts +280 -14
  61. package/src/server/service.ts +348 -25
  62. package/src/server/test-helpers/handlers-config.ts +181 -0
  63. package/src/server.ts +16 -1
  64. package/src/shared/ids-stream.ts +92 -0
  65. package/src/shared/sse-schema.spec.ts +50 -0
  66. package/src/shared/sse-schema.ts +27 -0
  67. package/src/shared/types.ts +31 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Stale-continuation gating tests for the action provider's deferred mutations.
3
+ * アクションプロバイダーの遅延ミューテーション継続に対する門番テスト。
4
+ *
5
+ * ここで固定する不変条件:
6
+ *
7
+ * - **POST /action の待機中にリロード無しのユーザー切替が起きた場合、解決した継続は
8
+ * モジュール常駐状態 (常駐ストリームセッション・DailyReportCache) へ書き込まない。**
9
+ * 門番を欠くと、旧ユーザーの日報行が新ユーザーの一覧へ注入され (観測集合に刻まれて
10
+ * 完走剪定も免れる)、切替時に破棄した詳細キャッシュへ旧ユーザーの下書き本文が
11
+ * 再シードされる (第 6 ラウンド敵対的レビューで実測再現された漏洩)。
12
+ * - **同一ユーザーのまま解決した継続は従来どおり反映される** (門番が正常経路を壊さない)。
13
+ *
14
+ * セッション・キャッシュはモジュール常駐シングルトンのため、テスト間で明示的に
15
+ * リセットする (このファイル内で直列実行)。
16
+ */
17
+ import { act, render, renderHook, waitFor } from "@testing-library/react"
18
+ import { useEffect } from "react"
19
+ import { afterEach, describe, expect, it, vi } from "vitest"
20
+ import type { DailyReportActionContextType } from "./daily-report-action-context"
21
+
22
+ // SSE 接続は EventSource を jsdom に持ち込まず、onMessage を横取りしてテストから直接叩けるようにする
23
+ const capturedOnMessage: { current: ((payload: unknown) => void) | null } = { current: null }
24
+ vi.mock("../hooks/use-daily-report-sse-connection", () => ({
25
+ useDailyReportSseConnection: ({ onMessage }: { onMessage: (payload: unknown) => void }) => {
26
+ capturedOnMessage.current = onMessage
27
+ },
28
+ }))
29
+
30
+ import { clearDailyReportCache, getCachedReport } from "../hooks/use-daily-report"
31
+ import { ensureDailyReportIdsStreamSession, resetDailyReportIdsStreamSessionForTesting, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
32
+ import { DailyReportActionProvider, useDailyReportActionContext } from "./daily-report-action-context"
33
+
34
+ /** NDJSON 1 行の ids-stream 応答を組み立てる。 */
35
+ const idsStreamResponse = (reportHubIds: number[]): Response => {
36
+ const line = JSON.stringify({
37
+ items: reportHubIds.map((reportHubId) => ({ reportHubId, businessDate: "2026-08-01", sourceType: "Internal" })),
38
+ nextCursor: null,
39
+ isLast: true,
40
+ total: reportHubIds.length,
41
+ })
42
+ return new Response(`${line}\n`, { status: 200, headers: { "Content-Type": "application/x-ndjson" } })
43
+ }
44
+
45
+ /** /action 応答 (create 成功) を組み立てる。clientTempId は要求からエコーする。 */
46
+ const createActionResponse = (clientTempId: string, newId: number): Response =>
47
+ new Response(
48
+ JSON.stringify({
49
+ status: "OK",
50
+ reportHubId: newId,
51
+ clientTempId,
52
+ report: {
53
+ reportHubId: newId,
54
+ date: "2026-08-05",
55
+ createdAt: "2026-08-05 10:00:00",
56
+ author: "User A",
57
+ userId: "ENC_A",
58
+ sourceType: "Internal",
59
+ employeeName: null,
60
+ updatedBy: "User A",
61
+ updatedAt: "2026-08-05 10:00:00",
62
+ category: null,
63
+ creationCategory: null,
64
+ visitTimeFrom: null,
65
+ visitTimeTo: null,
66
+ customerName: null,
67
+ interviewers: [],
68
+ subject: "USER_A confidential draft subject",
69
+ content: "secret",
70
+ comments: [],
71
+ isRead: true,
72
+ isStarred: false,
73
+ labels: [],
74
+ commentItems: [],
75
+ attachments: [],
76
+ },
77
+ }),
78
+ { status: 200, headers: { "Content-Type": "application/json" } },
79
+ )
80
+
81
+ /** コンテキストを外へ取り出すだけの消費者。 */
82
+ const contextRef: { current: DailyReportActionContextType | null } = { current: null }
83
+ const ContextProbe = () => {
84
+ const ctx = useDailyReportActionContext()
85
+ /**
86
+ * コンテキスト参照の同期副作用。
87
+ * 依存配列: ctx (プロバイダー再レンダーごと)。クリーンアップ: なし。
88
+ */
89
+ useEffect(() => {
90
+ contextRef.current = ctx
91
+ }, [ctx])
92
+ return null
93
+ }
94
+
95
+ /**
96
+ * fetch スタブ: ids-stream は即時応答、/action は解放関数で遅延解決する。
97
+ * 解放時の clientTempId エコーのため要求 FormData を記録する。
98
+ */
99
+ const installFetchStub = () => {
100
+ let releaseAction: ((newId: number) => void) | null = null
101
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
102
+ const url = String(input)
103
+ if (url.includes("/ids-stream")) {
104
+ // A のセッションは [101]、B のセッションも [101] を正とする (999 は誰の全量にも無い)
105
+ return idsStreamResponse([101])
106
+ }
107
+ if (url.includes("/action")) {
108
+ const clientTempId = String((init?.body as FormData).get("clientTempId"))
109
+ return new Promise<Response>((resolve) => {
110
+ releaseAction = (newId: number) => resolve(createActionResponse(clientTempId, newId))
111
+ })
112
+ }
113
+ throw new Error(`unexpected fetch: ${url}`)
114
+ })
115
+ vi.stubGlobal("fetch", fetchMock)
116
+ return { release: (newId: number) => releaseAction?.(newId) }
117
+ }
118
+
119
+ afterEach(() => {
120
+ resetDailyReportIdsStreamSessionForTesting()
121
+ clearDailyReportCache()
122
+ contextRef.current = null
123
+ capturedOnMessage.current = null
124
+ vi.unstubAllGlobals()
125
+ })
126
+
127
+ describe("遅延ミューテーション継続の門番 (ユーザー切替汚染防止)", () => {
128
+ it("切替後に解決した createReport はセッションにもキャッシュにも書き込まない", async () => {
129
+ const stub = installFetchStub()
130
+
131
+ // ユーザー A のセッション確立 + プロバイダー描画
132
+ act(() => {
133
+ ensureDailyReportIdsStreamSession({ userKey: "ENC_A" })
134
+ })
135
+ const { unmount } = render(
136
+ <DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A" initialItems={[]}>
137
+ <ContextProbe />
138
+ </DailyReportActionProvider>,
139
+ )
140
+ await waitFor(() => expect(contextRef.current).not.toBeNull())
141
+
142
+ // A が作成を発行 (POST は保留)
143
+ let createPromise: Promise<number> | undefined
144
+ act(() => {
145
+ createPromise = contextRef.current?.createReport("2026-08-05")
146
+ })
147
+ createPromise?.catch(() => {})
148
+
149
+ // リロード無しのユーザー切替: プロバイダー破棄 + セッション B へ
150
+ unmount()
151
+ act(() => {
152
+ ensureDailyReportIdsStreamSession({ userKey: "ENC_B" })
153
+ })
154
+
155
+ // B のストリーム完走を待ってから A の POST を解決させる
156
+ const { result: streamResult } = renderHook(() => useDailyReportIdsStream())
157
+ await waitFor(() => expect(streamResult.current.phase).toBe("complete"))
158
+ act(() => {
159
+ stub.release(999)
160
+ })
161
+ await expect(createPromise).rejects.toThrow()
162
+
163
+ // B のセッションに A の新規日報 999 が注入されていない
164
+ await waitFor(() => {
165
+ expect(streamResult.current.items.map((item) => item.reportHubId)).toEqual([101])
166
+ })
167
+ // 切替時に破棄した詳細キャッシュへ A の下書きが再シードされていない
168
+ expect(getCachedReport(999, { ignoreExpiry: true })).toBeFalsy()
169
+ })
170
+
171
+ it("切替〜アンマウント間の隙間で届いた旧ユーザーの SSE report-create はセッションにもキャッシュにも書き込まない", async () => {
172
+ installFetchStub()
173
+
174
+ // ユーザー A のセッション + プロバイダー (SSE onMessage を横取り)
175
+ act(() => {
176
+ ensureDailyReportIdsStreamSession({ userKey: "ENC_A" })
177
+ })
178
+ render(
179
+ <DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A" initialItems={[]}>
180
+ <ContextProbe />
181
+ </DailyReportActionProvider>,
182
+ )
183
+ await waitFor(() => expect(capturedOnMessage.current).not.toBeNull())
184
+
185
+ // 隙間状態の再現: A のプロバイダー (EventSource) はマウントされたまま、
186
+ // モジュールセッションだけが B へ差し替わる
187
+ act(() => {
188
+ ensureDailyReportIdsStreamSession({ userKey: "ENC_B" })
189
+ })
190
+ const { result: streamResult } = renderHook(() => useDailyReportIdsStream())
191
+ await waitFor(() => expect(streamResult.current.phase).toBe("complete"))
192
+
193
+ // A 宛の下書き report-create が旧接続へ届く
194
+ act(() => {
195
+ capturedOnMessage.current?.({
196
+ type: "report-create",
197
+ clientTempId: "other-tab-op",
198
+ report: {
199
+ reportHubId: 999,
200
+ date: "2026-08-05",
201
+ createdAt: "2026-08-05 10:00:00",
202
+ author: "User A",
203
+ userId: "ENC_A",
204
+ sourceType: "Internal",
205
+ employeeName: null,
206
+ updatedBy: "User A",
207
+ updatedAt: "2026-08-05 10:00:00",
208
+ category: null,
209
+ creationCategory: null,
210
+ visitTimeFrom: null,
211
+ visitTimeTo: null,
212
+ customerName: null,
213
+ interviewers: [],
214
+ subject: "USER_A private draft via SSE",
215
+ content: "secret",
216
+ comments: [],
217
+ isRead: false,
218
+ isStarred: false,
219
+ labels: [],
220
+ commentItems: [],
221
+ attachments: [],
222
+ },
223
+ })
224
+ })
225
+
226
+ // B のセッションに 999 が注入されず、破棄済みキャッシュにも再シードされない
227
+ expect(streamResult.current.items.map((item) => item.reportHubId)).toEqual([101])
228
+ expect(getCachedReport(999, { ignoreExpiry: true })).toBeFalsy()
229
+ })
230
+
231
+ it("同一ユーザーのまま解決した createReport は従来どおりセッションとキャッシュへ反映される", async () => {
232
+ const stub = installFetchStub()
233
+
234
+ act(() => {
235
+ ensureDailyReportIdsStreamSession({ userKey: "ENC_A" })
236
+ })
237
+ render(
238
+ <DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A" initialItems={[]}>
239
+ <ContextProbe />
240
+ </DailyReportActionProvider>,
241
+ )
242
+ await waitFor(() => expect(contextRef.current).not.toBeNull())
243
+
244
+ const { result: streamResult } = renderHook(() => useDailyReportIdsStream())
245
+ await waitFor(() => expect(streamResult.current.phase).toBe("complete"))
246
+
247
+ let createPromise: Promise<number> | undefined
248
+ act(() => {
249
+ createPromise = contextRef.current?.createReport("2026-08-05")
250
+ })
251
+ act(() => {
252
+ stub.release(999)
253
+ })
254
+ await expect(createPromise).resolves.toBe(999)
255
+
256
+ // 門番が正常経路を壊していない: 常駐セッションへ 999 が中継され、キャッシュも温まる
257
+ await waitFor(() => {
258
+ expect(streamResult.current.items.map((item) => item.reportHubId)).toContain(999)
259
+ })
260
+ expect(getCachedReport(999, { ignoreExpiry: true })?.subject).toBe("USER_A confidential draft subject")
261
+ })
262
+ })
@@ -3,13 +3,13 @@
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"
7
6
  import { createLogger, LogLevel } from "../../shared/logger"
8
7
  import type { DailyReportSseMessage } from "../../shared/sse-schema"
9
8
  import type { DailyReportCommentItem, DailyReportDetail, DailyReportItem, DailyReportUser } from "../../shared/types"
10
9
  import { useDailyReportConfig } from "../config-context"
11
10
  import { acquireMutationLock, applyDailyReportServerUpdates, clearDailyReportCache, DailyReportCache, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
12
11
  import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
12
+ import { getDailyReportIdsStreamSessionUserKey, refreshDailyReportIdsStream, removeDailyReportIdsStreamItem, upsertDailyReportIdsStreamItem, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
13
13
 
14
14
  const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
15
15
 
@@ -25,7 +25,7 @@ type ActionResponse = {
25
25
  [key: string]: unknown
26
26
  }
27
27
 
28
- type DailyReportActionContextType = {
28
+ export type DailyReportActionContextType = {
29
29
  /** 表示中ユーザー (楽観的コメントの作者名等に使用)。 */
30
30
  user?: DailyReportUser
31
31
  /** reportHubId -> Map<tempCommentId, content> (追加コメントの一時IDと内容) */
@@ -137,18 +137,35 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
137
137
  /** 自分が発行したアクションのID(SSEエコー無視用) */
138
138
  const processedClientTempIds = useRef<Set<string>>(new Set())
139
139
 
140
- const revalidator = useRevalidator()
140
+ // トゥームストーン掃除のゲート参照。ストリーミング途中の部分配列で掃除すると、
141
+ // 後続チャンク到着前に削除された ID のトゥームストーンが失われ、遅れて届いた
142
+ // 削除前スナップショットのチャンクで日報が復活する (effect の依存を増やさないため ref 経由)
143
+ const streamState = useDailyReportIdsStream()
144
+ const isStreamSettledRef = useRef(false)
145
+ isStreamSettledRef.current = streamState.phase === "complete" && !streamState.isRevalidating
146
+
147
+ /**
148
+ * 遅延解決するミューテーション継続の門番。POST /action の待機中にリロード無しの
149
+ * ユーザー切替が起きた場合、この判定が偽になり、継続はモジュール常駐状態
150
+ * (常駐ストリームセッション・DailyReportCache・ミューテーションロック) への
151
+ * 書き込みを放棄する。放棄しないと、旧ユーザーの日報行が新ユーザーの一覧へ
152
+ * 注入され (観測集合に刻まれて完走剪定も免れる)、切替時に破棄したはずの
153
+ * 詳細キャッシュへ旧ユーザーの下書き本文が再シードされる (実測再現済み)。
154
+ * 同一ユーザーの通常ナビゲーション (アンマウント) ではセッションが生き続けるため
155
+ * 真のままであり、作成直後の日報が常駐データから欠ける round-3 の退行は起こさない。
156
+ */
157
+ const isSessionStillOurs = useCallback(() => getDailyReportIdsStreamSessionUserKey() === (userId ?? null), [userId])
141
158
 
142
159
  const refetchData = useCallback(async () => {
143
160
  logger.info("[DailyReportActionProvider] Refetching data without page reload...")
144
- if (revalidator.state === "idle") {
145
- revalidator.revalidate()
146
- }
161
+ // ID 一覧はストリームセッションのバックグラウンド再検証で更新する
162
+ // (ルーター再検証は dailyReportShouldRevalidate に抑止されるため使わない)
163
+ refreshDailyReportIdsStream()
147
164
  setVersion((v) => v + 1)
148
165
  DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
149
166
  listener(-1)
150
167
  })
151
- }, [revalidator])
168
+ }, [])
152
169
 
153
170
  const clearCacheAndRefetch = useCallback(async () => {
154
171
  logger.info("[DailyReportActionProvider] Clearing client & server/DB cache and refetching data without page reload...")
@@ -175,15 +192,16 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
175
192
  logger.warn("[DailyReportActionProvider] Failed to clear server-side cache:", e)
176
193
  }
177
194
 
178
- // 3. ルートローダーの再フェッチ & UI 再描画
179
- if (revalidator.state === "idle") {
180
- revalidator.revalidate()
181
- }
195
+ // 3. ID 一覧ストリームをサーバーキャッシュバイパスで再検証 & UI 再描画。
196
+ // POST 待機中にユーザー切替が起きた場合は放棄する (新ユーザーのストリームを
197
+ // 冗長に強制再ダウンロードさせない)
198
+ if (!isSessionStillOurs()) return
199
+ refreshDailyReportIdsStream({ forceRefresh: true })
182
200
  setVersion((v) => v + 1)
183
201
  DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
184
202
  listener(-1)
185
203
  })
186
- }, [apiBasePath, revalidator])
204
+ }, [apiBasePath, isSessionStillOurs])
187
205
 
188
206
  /**
189
207
  * Queue for SSE messages that arrived before the target report was cached.
@@ -281,6 +299,11 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
281
299
  * レポートアイテムをリストへ upsert し、businessDate 降順を維持します。
282
300
  */
283
301
  const upsertItem = useCallback((reportHubId: number, businessDate: string | null | undefined) => {
302
+ // モジュール常駐ストリームセッションへも反映する (鮮度窓内の再訪で作成直後の日報が
303
+ // 消えないように)。businessDate 未指定は「既存値を保持」の意図なので中継しない
304
+ if (businessDate !== undefined) {
305
+ upsertDailyReportIdsStreamItem({ reportHubId, businessDate: businessDate ?? null })
306
+ }
284
307
  setItems((prev) => {
285
308
  const nextBusinessDate = businessDate ?? prev.find((item) => item.reportHubId === reportHubId)?.businessDate ?? null
286
309
  const exists = prev.some((item) => item.reportHubId === reportHubId)
@@ -304,59 +327,101 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
304
327
  * Remove a stale item from the list as a safety net.
305
328
  * フェッチ後にレポートが見つからなかった場合の安全弁としてアイテムを除去する。
306
329
  */
307
- const removeStaleItem = useCallback((reportHubId: number) => {
308
- removedIds.current.add(reportHubId)
309
- setItems((prev) => prev.filter((item) => item.reportHubId !== reportHubId))
310
- }, [])
330
+ const removeStaleItem = useCallback(
331
+ (reportHubId: number) => {
332
+ // ユーザー切替後の遅延発火は新ユーザーのセッションへトゥームストーンを刻んでしまう
333
+ if (!isSessionStillOurs()) return
334
+ removedIds.current.add(reportHubId)
335
+ // 常駐ストリームデータからも除去する (再訪時に安全弁が再発動し続けるのを防ぐ)
336
+ removeDailyReportIdsStreamItem(reportHubId)
337
+ setItems((prev) => prev.filter((item) => item.reportHubId !== reportHubId))
338
+ },
339
+ [isSessionStillOurs],
340
+ )
311
341
 
312
342
  /**
313
- * Merge initialItems with prevItems instead of replacing.
343
+ * Merge initialItems with prevItems instead of replacing (render-phase derived state).
314
344
  * Items already in the list are kept even if temporarily absent from the API response.
315
345
  * Removal is handled exclusively by the report-delete SSE handler.
316
346
  *
317
- * initialItems と prevItems をマージする(全置換ではない)。
318
- * API レスポンスから一時的に欠落した ID も prevItems に残っていれば保持する。
319
- * 削除は report-delete SSE ハンドラーでのみ行う。
347
+ * initialItems と prevItems をマージする (全置換ではない)。render-phase の
348
+ * 派生状態パターン (prev 比較 + render 中の setState) で行うことが重要 —
349
+ * effect で行うと「最初のチャンクのコミットは items が空 (VirtualScroll が
350
+ * 一瞬 No items を描画) → effect 後の 2 コミット目で行が出る」という
351
+ * 1 コミット分の遅延と空表示フラッシュが生まれる。render 中の setState は
352
+ * React が子のコミット前に同一コンポーネントを再レンダーする公式パターン。
353
+ * マージは純粋 (ref は読み取りのみ)。StrictMode の二重レンダーにも冪等。
320
354
  */
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)
355
+ const [prevInitialItems, setPrevInitialItems] = useState(initialItems)
356
+ if (initialItems !== prevInitialItems) {
357
+ setPrevInitialItems(initialItems)
358
+ // initialItems をベースにマップを構築 (removedIds を除外)
359
+ const mergedMap = new Map<number, DailyReportItem>()
360
+ for (const item of initialItems) {
361
+ if (!removedIds.current.has(item.reportHubId)) {
362
+ mergedMap.set(item.reportHubId, item)
330
363
  }
331
364
  }
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
- }
365
+ // 現在の items にのみ存在する ID を保持 (削除は report-delete SSE で行う)
366
+ for (const item of items) {
367
+ if (!(mergedMap.has(item.reportHubId) || removedIds.current.has(item.reportHubId))) {
368
+ mergedMap.set(item.reportHubId, item)
340
369
  }
370
+ }
371
+ // businessDate 降順でソート。正規化済み YYYY-MM-DD は辞書順 = 時系列順なので
372
+ // 文字列比較で足りる (Date 構築はチャンク到着ごとの全件ソートで比較 1 回あたり
373
+ // 2 個生成され、数万件 × 数十チャンクで数百万回の構築になる)。null は末尾。
374
+ // ストリームはソート順どおりに届き末尾へ追記されるため、通常は整列済み —
375
+ // O(n) の検査で確認できたらソートを丸ごと省略する (20 万件級で毎 publish の
376
+ // O(n log n) を O(n) へ落とす)
377
+ 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
+ })
392
+ }
393
+ setItems(merged)
394
+ }
341
395
 
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)
396
+ /**
397
+ * トゥームストーン (removedIds) のクリーンアップ副作用。
398
+ * ストリームの全量が出揃っている (complete かつ再検証中でない) ときにのみ行う。
399
+ * 部分配列からの欠落は「サーバーに存在しない」証拠にならない。
400
+ * 依存配列: initialItems (チャンク到着・再検証ごと)。クリーンアップ: なし。
401
+ * ref の変異を伴うため render-phase ではなく effect に置く。
402
+ */
403
+ useEffect(() => {
404
+ if (isStreamSettledRef.current) {
405
+ const initialIds = new Set(initialItems.map((item) => item.reportHubId))
406
+ // Set のイテレーション中の delete は JS 仕様で安全 (visited は再訪問しない)
407
+ for (const id of removedIds.current) {
408
+ if (!initialIds.has(id)) {
409
+ removedIds.current.delete(id)
346
410
  }
347
411
  }
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
- })
412
+ }
356
413
  }, [initialItems])
357
414
 
358
415
  const handleSseMessage = useCallback(
359
416
  (payload: Exclude<DailyReportSseMessage, { type: "connected" }>) => {
417
+ // セッション切替〜プロバイダーアンマウントの隙間で届いた旧ユーザーの SSE は破棄する。
418
+ // clientLoader の bootstrap は React のコミット前にモジュールセッションを新ユーザーへ
419
+ // 差し替えるため、旧プロバイダーの EventSource が閉じるまでの間に届いたメッセージ
420
+ // (下書きの recipient 宛配信を含む) が、破棄済みキャッシュと新セッションへ
421
+ // 旧ユーザーのデータを書き込んでしまう (POST 継続と同じ汚染シグネチャ・実測再現済み)
422
+ if (!isSessionStillOurs()) {
423
+ return
424
+ }
360
425
  // Ignore echo of own actions
361
426
  // 自分が発行したアクションのエコーは無視する
362
427
  // clientTempId は connected 以外すべてのメッセージに存在する(型で保証済み)
@@ -463,6 +528,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
463
528
  manuallyTrackingIds.current.delete(reportHubId)
464
529
  // マージロジックでの復活を防止するために削除済み ID を記録
465
530
  removedIds.current.add(reportHubId)
531
+ // 常駐ストリームデータからも除去 (鮮度窓内の再訪で削除済み日報が蘇らないように)
532
+ removeDailyReportIdsStreamItem(reportHubId)
466
533
 
467
534
  // 削除されたレポートへの待機中 SSE メッセージを破棄
468
535
  const pendingEntry = pendingSseQueue.current.get(reportHubId)
@@ -495,7 +562,7 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
495
562
  }
496
563
  }
497
564
  },
498
- [isProcessedId, upsertItem, user, enqueuePendingSseMessage],
565
+ [isProcessedId, isSessionStillOurs, upsertItem, user, enqueuePendingSseMessage],
499
566
  )
500
567
 
501
568
  // 常に最新の handleSseMessage をキューのリプレイから参照できるようにする
@@ -574,10 +641,20 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
574
641
  if (typeof data.clientTempId !== "string" || data.clientTempId !== clientTempId) {
575
642
  throw new Error(`Invalid clientTempId for ${intent}`)
576
643
  }
644
+ // ユーザー切替後に解決した継続は、モジュール常駐状態 (常駐セッション・詳細キャッシュ)
645
+ // を旧ユーザーのデータで汚染しないよう onSuccess を放棄する
646
+ if (!isSessionStillOurs()) {
647
+ logger.warn(`[executeAction] ${intent} resolved after a user switch; discarding continuation`)
648
+ throw new Error(`Stale continuation for ${intent}`)
649
+ }
577
650
  return (await callbacks.onSuccess?.(data)) as T
578
651
  } catch (e) {
579
652
  logger.error(`${intent} failed:`, e)
580
- callbacks.onFailure?.()
653
+ // 切替後の失敗も同様: onFailure のロールバックが旧値をモジュールキャッシュへ
654
+ // 書き戻すため放棄する (プロバイダー自身の state は key リマウントで破棄済み)
655
+ if (isSessionStillOurs()) {
656
+ callbacks.onFailure?.()
657
+ }
581
658
  throw e
582
659
  } finally {
583
660
  if (!(options?.keepLock || options?.skipLock)) {
@@ -773,6 +850,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
773
850
  isStarred: false,
774
851
  labels: [{ id: 0, name: draftLabelName, color: "#9ca3af" }],
775
852
  commentItems: [],
853
+ // 楽観更新の仮レポート。作成直後に添付は存在しない
854
+ attachments: [],
776
855
  }
777
856
  writeCache(dummyReport, Date.now() + 300000, false)
778
857
 
@@ -803,6 +882,11 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
803
882
 
804
883
  const newId = Number(res.reportHubId)
805
884
 
885
+ // 常駐ストリームセッションへも反映する。自分の SSE エコーは clientTempId で
886
+ // 抑止されるため upsertItem 経由の中継は届かず、ここで直接中継しないと
887
+ // 鮮度窓内の再訪で作成直後の日報が一覧から消える
888
+ upsertDailyReportIdsStreamItem({ reportHubId: newId, businessDate })
889
+
806
890
  // newId を一時的にロックし、createReport 以前に開始された背景 prefetch の
807
891
  // cacheMany による stale データ上書きを防止する
808
892
  acquireMutationLock(newId)
@@ -881,6 +965,10 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
881
965
  applyDailyReportServerUpdates(id, data.report)
882
966
  setVersion((v) => v + 1)
883
967
  }
968
+ // 成功確定後に常駐ストリームセッションへも反映する (自分の SSE エコーは
969
+ // 抑止されるため)。失敗時に走らせると、並行削除済みの日報を誤って
970
+ // 蘇らせトゥームストーンまで解除してしまう
971
+ upsertDailyReportIdsStreamItem({ reportHubId: id, businessDate })
884
972
  },
885
973
  onFailure: () => {
886
974
  // ロールバック
@@ -900,6 +988,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
900
988
 
901
989
  // マージロジックでの復活を防止するために削除済み ID を記録
902
990
  removedIds.current.add(id)
991
+ // 常駐ストリームデータからも除去 (鮮度窓内の再訪で削除済み日報が蘇らないように)
992
+ removeDailyReportIdsStreamItem(id)
903
993
  // Optimistic UI update
904
994
  setItems((prev) => prev.filter((item) => item.reportHubId !== id))
905
995
 
@@ -924,8 +1014,17 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
924
1014
  return
925
1015
  }
926
1016
 
927
- // それ以外のエラーの場合は、楽観的更新をロールバックする
1017
+ // それ以外のエラーの場合は、楽観的更新をロールバックする。
1018
+ // 常駐ストリームデータの除去とトゥームストーンも upsert で巻き戻す
1019
+ // (巻き戻さないと、実在する日報が次の全量走査まで再訪時の一覧から消える)。
1020
+ // ユーザー切替後に解決した継続は巻き戻さない — 旧ユーザーの行を新ユーザーの
1021
+ // セッションへ upsert しトゥームストーンまで解除してしまうため
1022
+ // (元の catch と同様に握り潰す。呼び出し元 UI は既に破棄済み)
1023
+ if (!isSessionStillOurs()) {
1024
+ return
1025
+ }
928
1026
  removedIds.current.delete(id)
1027
+ upsertDailyReportIdsStreamItem({ reportHubId: id, businessDate: targetItem?.businessDate ?? businessDate })
929
1028
  if (targetItem) {
930
1029
  setItems((prev) => {
931
1030
  if (prev.some((i) => i.reportHubId === id)) return prev