@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
@@ -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)) {
@@ -805,6 +882,11 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
805
882
 
806
883
  const newId = Number(res.reportHubId)
807
884
 
885
+ // 常駐ストリームセッションへも反映する。自分の SSE エコーは clientTempId で
886
+ // 抑止されるため upsertItem 経由の中継は届かず、ここで直接中継しないと
887
+ // 鮮度窓内の再訪で作成直後の日報が一覧から消える
888
+ upsertDailyReportIdsStreamItem({ reportHubId: newId, businessDate })
889
+
808
890
  // newId を一時的にロックし、createReport 以前に開始された背景 prefetch の
809
891
  // cacheMany による stale データ上書きを防止する
810
892
  acquireMutationLock(newId)
@@ -883,6 +965,10 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
883
965
  applyDailyReportServerUpdates(id, data.report)
884
966
  setVersion((v) => v + 1)
885
967
  }
968
+ // 成功確定後に常駐ストリームセッションへも反映する (自分の SSE エコーは
969
+ // 抑止されるため)。失敗時に走らせると、並行削除済みの日報を誤って
970
+ // 蘇らせトゥームストーンまで解除してしまう
971
+ upsertDailyReportIdsStreamItem({ reportHubId: id, businessDate })
886
972
  },
887
973
  onFailure: () => {
888
974
  // ロールバック
@@ -902,6 +988,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
902
988
 
903
989
  // マージロジックでの復活を防止するために削除済み ID を記録
904
990
  removedIds.current.add(id)
991
+ // 常駐ストリームデータからも除去 (鮮度窓内の再訪で削除済み日報が蘇らないように)
992
+ removeDailyReportIdsStreamItem(id)
905
993
  // Optimistic UI update
906
994
  setItems((prev) => prev.filter((item) => item.reportHubId !== id))
907
995
 
@@ -926,8 +1014,17 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
926
1014
  return
927
1015
  }
928
1016
 
929
- // それ以外のエラーの場合は、楽観的更新をロールバックする
1017
+ // それ以外のエラーの場合は、楽観的更新をロールバックする。
1018
+ // 常駐ストリームデータの除去とトゥームストーンも upsert で巻き戻す
1019
+ // (巻き戻さないと、実在する日報が次の全量走査まで再訪時の一覧から消える)。
1020
+ // ユーザー切替後に解決した継続は巻き戻さない — 旧ユーザーの行を新ユーザーの
1021
+ // セッションへ upsert しトゥームストーンまで解除してしまうため
1022
+ // (元の catch と同様に握り潰す。呼び出し元 UI は既に破棄済み)
1023
+ if (!isSessionStillOurs()) {
1024
+ return
1025
+ }
930
1026
  removedIds.current.delete(id)
1027
+ upsertDailyReportIdsStreamItem({ reportHubId: id, businessDate: targetItem?.businessDate ?? businessDate })
931
1028
  if (targetItem) {
932
1029
  setItems((prev) => {
933
1030
  if (prev.some((i) => i.reportHubId === id)) return prev
@@ -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
+ }
@@ -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
+ })