@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,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,74 @@
1
+ /**
2
+ * Tests for the shared responsive-layout hooks.
3
+ * 共有のレスポンシブ判定フックのテスト。
4
+ *
5
+ * 閾値は 1 箇所でだけ定義する。各ビューが独自に `matchMedia` を持つと、片方だけ変更されて
6
+ * レイアウト分岐が食い違う。
7
+ *
8
+ * ❗ スクロール操作の可否はここで決めない (画面幅では「主入力がマウスのタッチ対応ノート」を
9
+ * 取りこぼす)。ドラッグはポインタ種別ごとに `pointerDragInputs` で制御する。
10
+ */
11
+ import { act, renderHook } from "@testing-library/react"
12
+ import { afterEach, describe, expect, it, vi } from "vitest"
13
+ import { MOBILE_LAYOUT_MEDIA_QUERY, useIsMobileLayout } from "./use-responsive-layout"
14
+
15
+ /** 購読中のリスナー。`change` を発火させて再評価させるために保持する。 */
16
+ type Listener = () => void
17
+
18
+ /**
19
+ * Installs a `matchMedia` stub whose result is driven by the given query set.
20
+ * 指定したクエリ集合で結果が決まる `matchMedia` スタブを差し込む処理。
21
+ *
22
+ * @param matching Queries that should report a match. 一致させるクエリ群。
23
+ * @returns A setter to change the matching set and re-notify. 一致集合を差し替えて再通知するための関数。
24
+ */
25
+ const stubMatchMedia = (matching: string[]) => {
26
+ let current = new Set(matching)
27
+ const listeners: Listener[] = []
28
+
29
+ vi.stubGlobal("matchMedia", (query: string) => ({
30
+ get matches() {
31
+ return current.has(query)
32
+ },
33
+ media: query,
34
+ addEventListener: (_type: string, listener: Listener) => listeners.push(listener),
35
+ removeEventListener: (_type: string, listener: Listener) => {
36
+ const index = listeners.indexOf(listener)
37
+ if (index >= 0) listeners.splice(index, 1)
38
+ },
39
+ }))
40
+
41
+ return {
42
+ set: (next: string[]) => {
43
+ current = new Set(next)
44
+ act(() => {
45
+ for (const listener of [...listeners]) listener()
46
+ })
47
+ },
48
+ listenerCount: () => listeners.length,
49
+ }
50
+ }
51
+
52
+ afterEach(() => {
53
+ vi.unstubAllGlobals()
54
+ })
55
+
56
+ describe("useIsMobileLayout", () => {
57
+ it("ビューポートが閾値以下ならモバイルレイアウトと判定する", () => {
58
+ stubMatchMedia([MOBILE_LAYOUT_MEDIA_QUERY])
59
+ expect(renderHook(() => useIsMobileLayout()).result.current).toBe(true)
60
+ })
61
+
62
+ it("閾値を超えるならデスクトップレイアウトと判定する", () => {
63
+ stubMatchMedia([])
64
+ expect(renderHook(() => useIsMobileLayout()).result.current).toBe(false)
65
+ })
66
+
67
+ it("リサイズ (change) に追随する", () => {
68
+ const media = stubMatchMedia([])
69
+ const { result } = renderHook(() => useIsMobileLayout())
70
+ expect(result.current).toBe(false)
71
+ media.set([MOBILE_LAYOUT_MEDIA_QUERY])
72
+ expect(result.current).toBe(true)
73
+ })
74
+ })
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Responsive layout detection shared by the daily-report views.
3
+ * 日報ビューが共有するレスポンシブ判定。
4
+ *
5
+ * 閾値を 1 箇所に集約する。各ビューが独自に `matchMedia` を持つと、片方だけ変更されて
6
+ * レイアウト分岐が食い違う。
7
+ *
8
+ * ❗ **スクロール操作の可否をここで決めてはならない。** 画面幅で判定すると
9
+ * 「主入力がマウスのタッチ対応ノート」のような端末を取りこぼす。ドラッグスクロールは
10
+ * `ScrollPane` の `pointerDragInputs` で**イベントのポインタ種別ごとに**制御する
11
+ * (`CONTENT_DRAG_POINTER_INPUTS` 参照)。
12
+ */
13
+ import { useEffect, useState } from "react"
14
+
15
+ /**
16
+ * Viewport width at which the daily-report views switch to the mobile layout.
17
+ * 日報ビューがモバイルレイアウトへ切り替わるビューポート幅。
18
+ *
19
+ * Tailwind の `sm` (640px) の直下に合わせてある。リスト + サイドペインの 2 カラムを
20
+ * 畳んでオーバーレイ表示にする閾値であり、**この値は 1 箇所でだけ定義する**。
21
+ */
22
+ export const MOBILE_LAYOUT_MEDIA_QUERY = "(max-width: 639px)"
23
+
24
+ /**
25
+ * Reports whether the viewport is narrow enough for the mobile layout.
26
+ * ビューポートがモバイルレイアウトの幅かどうかを返す処理。
27
+ *
28
+ * SSR と初回描画では `false` を返し、マウント後に実環境で更新する
29
+ * (サーバーとクライアントで初期値を揃えてハイドレーション不一致を避ける)。
30
+ *
31
+ * @returns True on the mobile layout. モバイルレイアウトなら true。
32
+ */
33
+ export const useIsMobileLayout = (): boolean => {
34
+ const [isMobileLayout, setIsMobileLayout] = useState(false)
35
+
36
+ /**
37
+ * Tracks the viewport width.
38
+ * ビューポート幅を追跡する副作用。
39
+ *
40
+ * 目的: 画面回転・ウィンドウリサイズに追随する。
41
+ * 依存関係: [] - 監視対象は固定の定数なので購読は 1 度だけ張る。
42
+ * クリーンアップ: リスナーを解除する。
43
+ */
44
+ useEffect(() => {
45
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return
46
+
47
+ const query = window.matchMedia(MOBILE_LAYOUT_MEDIA_QUERY)
48
+ const sync = () => setIsMobileLayout(query.matches)
49
+
50
+ sync()
51
+ query.addEventListener("change", sync)
52
+ return () => query.removeEventListener("change", sync)
53
+ }, [])
54
+
55
+ return isMobileLayout
56
+ }
@@ -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