@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
@@ -2,35 +2,40 @@ import { Plus, RefreshCw, RotateCw } from "lucide-react"
2
2
  import type { ReactNode } from "react"
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react"
4
4
  import { useSearchParams } from "react-router"
5
- import type { DailyReportItem } from "../../shared/types"
6
5
  import { useDailyReportConfig } from "../config-context"
7
6
  import { useDailyReportActionContext } from "../contexts/daily-report-action-context"
7
+ import { useAnimatedNumber } from "../hooks/use-animated-number"
8
8
  import { Button } from "../ui/button"
9
9
  import { Label } from "../ui/label"
10
10
  import { Switch } from "../ui/switch"
11
11
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
12
12
  import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
13
13
  import { DailyReportDetailList } from "./daily-report-detail-list"
14
+ import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
14
15
  import { DailyReportList } from "./daily-report-list"
15
16
 
16
17
  const tabTriggerClass = "min-w-24 p-2 font-medium text-slate-600 text-xs transition-colors data-[state=active]:bg-white data-[state=active]:text-slate-900 sm:min-w-36 sm:text-sm dark:text-slate-300 dark:data-[state=active]:bg-slate-800 dark:data-[state=active]:text-slate-100"
17
18
 
18
19
  type DailyReportResolvedContentProps = {
19
- dailyReportItems: DailyReportItem[]
20
20
  userId?: string | null
21
21
  }
22
22
 
23
23
  /**
24
- * Displays fetched daily reports with layout and tabbed virtual scroll views.
25
- * レイアウトとタブ付き仮想スクロールで取得済み日報を表示するコンポーネント。
24
+ * Displays streamed daily reports with layout and tabbed virtual scroll views.
25
+ * レイアウトとタブ付き仮想スクロールでストリーム受信済み日報を表示するコンポーネント。
26
+ *
27
+ * アイテムは Action Context (ストリームチャンクをマージ済み) から読む。
26
28
  */
27
- export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, userId }: DailyReportResolvedContentProps) => {
29
+ export const DailyReportResolvedContent = ({ userId }: DailyReportResolvedContentProps) => {
28
30
  // 自動既読設定
29
31
  const [autoMarkRead, setAutoMarkRead] = useState(false)
30
32
  const { title, renderHeader, showDevControls } = useDailyReportConfig()
31
33
  const { isSseEnabled, toggleSse, createReport, items: dailyReportItems, refetchData, clearCacheAndRefetch } = useDailyReportActionContext()
32
34
  const [searchParams] = useSearchParams()
33
35
 
36
+ // 総件数の表示値 (チャンク到着ごとの跳びをイーズアウトで滑らかにする)
37
+ const animatedItemCount = useAnimatedNumber(dailyReportItems.length)
38
+
34
39
  // 各タブのスクロール位置をアンマウント後も保持する Ref
35
40
  const listScrollOffsetRef = useRef<number>(0)
36
41
  const detailListScrollOffsetRef = useRef<number>(0)
@@ -49,6 +54,24 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
49
54
  return dailyReportItems[0].reportHubId
50
55
  })
51
56
 
57
+ // 初回の自動選択を一度きりにするフラグ (ユーザーの明示的な選択解除を上書きしない)
58
+ const hasAutoSelectedRef = useRef(false)
59
+
60
+ /**
61
+ * 最初のアイテム到着時に先頭日報を一度だけ自動選択する副作用。
62
+ * useState 初期化子はマウント時点で context の items がまだ空のため実質機能せず、
63
+ * ストリーミングでは最初のチャンク到着後に選択を確定させる必要がある。
64
+ * モバイルのオーバーレイは isMobileDetailOpen (明示タップ) でのみ開くため、
65
+ * プログラム選択でオーバーレイが開くことはない。
66
+ * 依存配列: dailyReportItems (チャンク到着ごと)。クリーンアップ: なし。
67
+ */
68
+ useEffect(() => {
69
+ if (!hasAutoSelectedRef.current && dailyReportItems.length > 0) {
70
+ hasAutoSelectedRef.current = true
71
+ setSelectedItemId((prev) => prev ?? dailyReportItems[0].reportHubId)
72
+ }
73
+ }, [dailyReportItems])
74
+
52
75
  const handleReload = async () => {
53
76
  await refetchData()
54
77
  }
@@ -123,8 +146,9 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
123
146
  {renderHeader({
124
147
  title,
125
148
  annotation: (
126
- <div className="flex flex-wrap gap-2 leading-1">
127
- <span>総件数: {dailyReportItems.length.toLocaleString()} 件</span>
149
+ <div className="flex flex-wrap items-center gap-2 leading-1">
150
+ <DailyReportIdsStreamStatus />
151
+ <span className="tabular-nums">総件数: {animatedItemCount.toLocaleString()} 件</span>
128
152
  <span>デバウンス: {DAILY_REPORT_FETCH_DEBOUNCE_MS} ms</span>
129
153
  </div>
130
154
  ),
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Rendering guards for the daily-report views (List / DetailList).
3
+ * 日報ビュー (List / DetailList) の描画ガード。
4
+ *
5
+ * 実際に起きた欠陥: 添付節を DetailList タブにだけ入れて List タブのサイドペインに入れ忘れ、
6
+ * List タブしか使わない利用者からは「添付が 1 件も無い」ように見えていた。型チェックでも
7
+ * 既存のどのテストでも検出できず、画面を開いて初めて分かる種類の抜けである。
8
+ *
9
+ * ❗ **ここはソース文字列の grep では守れない。** 以前の版は
10
+ * `expect(source).toContain("<DailyReportAttachmentList ... />")` というソース検査だったが、
11
+ * それでは `{false && ...}` や JSX コメントで**描画だけ止めた**場合に素通りする
12
+ * (= 上記の欠陥そのものを再現しても全 green)。逆に、属性の並べ替えや改行といった
13
+ * 挙動の変わらない整形で赤くなる。そこで実際に描画し、
14
+ * `VirtualScroll` へ渡した設定も差し替えたモックで捕まえて値を検査する。
15
+ *
16
+ * 添付 1 件分の見た目そのものは `daily-report-attachment-list.spec.tsx` が担当する。
17
+ */
18
+
19
+ // biome-ignore lint/correctness/noNodejsModules: ビュー一覧の網羅性をディレクトリ走査で機械的に確かめるため (テスト専用)
20
+ import { readdirSync, readFileSync } from "node:fs"
21
+ // biome-ignore lint/correctness/noNodejsModules: 同上
22
+ import { dirname, join } from "node:path"
23
+ // biome-ignore lint/correctness/noNodejsModules: 同上
24
+ import { fileURLToPath } from "node:url"
25
+ import { act, render, screen } from "@testing-library/react"
26
+ import type { ReactNode } from "react"
27
+ import { beforeEach, describe, expect, it, vi } from "vitest"
28
+ import type { DailyReportDetail, DailyReportItem } from "../../shared/types"
29
+ import { CONTENT_DRAG_POINTER_INPUTS, ITEM_VERTICAL_PADDING, TAP_SCROLL_CIRCLE_OPTIONS, VIRTUAL_SCROLL_OVERSCAN_COUNT } from "../utils/constants"
30
+
31
+ /**
32
+ * `VirtualScroll` が受け取った props。ビューごとに 1 件積まれる。
33
+ *
34
+ * ❗ **代役は窓計算を捨てて全件描画するため、渡し忘れても描画結果が変わらない props がある。**
35
+ * それらは値を記録して個別に検査しないと「モックだから通る」状態になる
36
+ * (実際に `onRangeChange` を no-op へ差し替えると窓フェッチが止まり一覧が空になるが、
37
+ * 記録しなければテストは緑のまま)。
38
+ */
39
+ type CapturedVirtualScrollProps = {
40
+ testId?: string
41
+ itemCount: number
42
+ behaviorOptions?: { pointerDragInputs?: unknown; enablePointerDrag?: unknown }
43
+ scrollBarOptions?: { tapScrollCircleOptions?: unknown; renderThumbOverlay?: (props: Record<string, unknown>) => ReactNode }
44
+ getItemKey?: unknown
45
+ getItemHeight?: unknown
46
+ viewportSize?: unknown
47
+ overscanCount?: unknown
48
+ onRangeChange?: unknown
49
+ onScroll?: unknown
50
+ contentInsets?: unknown
51
+ }
52
+
53
+ const captured: CapturedVirtualScrollProps[] = []
54
+
55
+ /**
56
+ * Stand-in for VirtualScroll that renders every item and records its options.
57
+ * 全アイテムを描画しつつ設定を記録する `VirtualScroll` の代役。
58
+ *
59
+ * 本物は要素の実測高さに依存するため jsdom では 1 件も描画されない。
60
+ * 「どのペインに何が描かれるか」を検査したいので、窓の計算は捨てて全件描画する。
61
+ */
62
+ vi.mock("@aiquants/virtualscroll", async () => {
63
+ const { forwardRef: fwd, useImperativeHandle: useHandle } = await import("react")
64
+ return {
65
+ tapScrollCircleSampleVisual: () => null,
66
+ VirtualScroll: fwd(
67
+ (
68
+ props: {
69
+ itemCount: number
70
+ getItem: (index: number) => unknown
71
+ children: (item: unknown, index: number) => ReactNode
72
+ } & CapturedVirtualScrollProps,
73
+ ref: React.Ref<unknown>,
74
+ ) => {
75
+ captured.push({
76
+ testId: props.testId,
77
+ itemCount: props.itemCount,
78
+ behaviorOptions: props.behaviorOptions,
79
+ scrollBarOptions: props.scrollBarOptions,
80
+ getItemKey: props.getItemKey,
81
+ getItemHeight: props.getItemHeight,
82
+ viewportSize: props.viewportSize,
83
+ overscanCount: props.overscanCount,
84
+ onRangeChange: props.onRangeChange,
85
+ onScroll: props.onScroll,
86
+ contentInsets: props.contentInsets,
87
+ })
88
+ useHandle(ref, () => ({ scrollTo: () => 0, scrollToIndex: () => {}, getScrollPosition: () => 0, getContentSize: () => 0, getViewportSize: () => 0, updateItemSize: () => {}, focusItemAtIndex: () => {} }))
89
+ return (
90
+ <div data-testid={props.testId}>
91
+ {Array.from({ length: props.itemCount }, (_, index) => (
92
+ // biome-ignore lint/suspicious/noArrayIndexKey: 代役なので index キーで十分
93
+ <div key={index}>{props.children(props.getItem(index), index)}</div>
94
+ ))}
95
+ </div>
96
+ )
97
+ },
98
+ ),
99
+ }
100
+ })
101
+
102
+ /** 日報アクションはネットワーク/ルーターに依存するため、呼び出しを受け止めるだけの代役にする。 */
103
+ vi.mock("../contexts/daily-report-action-context", async (importOriginal) => {
104
+ const actual = await importOriginal<typeof import("../contexts/daily-report-action-context")>()
105
+ const noop = async () => {}
106
+ return {
107
+ ...actual,
108
+ useDailyReportActionContext: () => ({
109
+ // 楽観的更新の台帳はすべて ref。1 つでも欠けると描画が例外で落ちる
110
+ pendingAddCommentIds: { current: new Map() },
111
+ pendingDeleteCommentIds: { current: new Map() },
112
+ pendingReadUpdates: { current: new Map() },
113
+ pendingStarUpdates: { current: new Map() },
114
+ resolvedIdMap: { current: new Map() },
115
+ version: 0,
116
+ items: [],
117
+ user: null,
118
+ isSseEnabled: false,
119
+ toggleSse: () => {},
120
+ addComment: noop,
121
+ deleteComment: noop,
122
+ toggleStar: noop,
123
+ toggleRead: noop,
124
+ createReport: async () => 0,
125
+ updateReport: noop,
126
+ publishReport: noop,
127
+ deleteReport: noop,
128
+ removeStaleItem: () => {},
129
+ refetchData: noop,
130
+ clearCacheAndRefetch: noop,
131
+ }),
132
+ }
133
+ })
134
+
135
+ /** 詳細取得はキャッシュ層ごと差し替え、常に用意した 1 件を返す。 */
136
+ const detailRef: { current: DailyReportDetail | null } = { current: null }
137
+ vi.mock("../hooks/use-daily-report", async (importOriginal) => {
138
+ const actual = await importOriginal<typeof import("../hooks/use-daily-report")>()
139
+ return {
140
+ ...actual,
141
+ useDailyReportDetail: () => ({ report: detailRef.current, error: null, isLoading: false, isRefetching: false }),
142
+ useDailyReportPrefetch: () => () => {},
143
+ }
144
+ })
145
+
146
+ const { DailyReportList } = await import("./daily-report-list")
147
+ const { DailyReportDetailList } = await import("./daily-report-detail-list")
148
+ const { DailyReportConfigProvider } = await import("../config-context")
149
+
150
+ const componentsDir = dirname(fileURLToPath(import.meta.url))
151
+
152
+ /**
153
+ * 日報の詳細を描画するビューの一覧。**すべての検査はこの表から導出する。**
154
+ *
155
+ * ❗ 追加したらここへ足すこと。足し忘れは下の網羅テストが検知し、足せば
156
+ * 添付節・マーカー・スクロール結線の全検査が自動的にそのビューへ適用される
157
+ * (表への登録だけで黙らせられる、という抜け道を作らない)。
158
+ */
159
+ const REPORT_DETAIL_VIEWS = [
160
+ { file: "daily-report-list.tsx", testId: "daily-report-list", render: () => <DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} /> },
161
+ { file: "daily-report-detail-list.tsx", testId: "daily-report-detail-list", render: () => <DailyReportDetailList dailyReportItems={[item]} /> },
162
+ ] as const
163
+
164
+ /** `it.each` のタイトル用に (名前, 定義) の組へ落とす。 */
165
+ const VIEW_CASES = REPORT_DETAIL_VIEWS.map((view) => [view.file, view] as const)
166
+
167
+ const REPORT_HUB_ID = 1
168
+
169
+ const item: DailyReportItem = { reportHubId: REPORT_HUB_ID, businessDate: "2026-07-31" }
170
+
171
+ /** 添付 1 件を持つ日報詳細。 */
172
+ const buildDetail = (): DailyReportDetail => ({
173
+ reportHubId: REPORT_HUB_ID,
174
+ date: "2026-07-31",
175
+ interviewers: [],
176
+ comments: [],
177
+ isRead: false,
178
+ isStarred: false,
179
+ labels: [],
180
+ commentItems: [],
181
+ attachments: [{ id: "TOKEN1", fileName: "report.png", fileType: "image/png", fileSize: 2048, createdAt: "2026-07-31 00:00:00", state: "unknown" }],
182
+ })
183
+
184
+ /** 設定コンテキストを与えて描画する。 */
185
+ const renderView = (node: ReactNode) =>
186
+ render(
187
+ <DailyReportConfigProvider
188
+ config={{
189
+ apiBasePath: "/daily_report/api",
190
+ fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" },
191
+ }}>
192
+ {node}
193
+ </DailyReportConfigProvider>,
194
+ )
195
+
196
+ /**
197
+ * 指定 testId のビューが受け取った `VirtualScroll` の props (最新のレンダー分)。
198
+ *
199
+ * ❗ 再レンダーのたびに積まれるので**最後の 1 件**を返す。先頭を返すと、
200
+ * 状態更新後に渡し直されたコールバックではなく古いクロージャを掴んでしまう。
201
+ */
202
+ const optionsOf = (testId: string) => {
203
+ const props = [...captured].reverse().find((entry) => entry.testId === testId)
204
+ if (!props) throw new Error(`VirtualScroll props for ${testId} were not captured`)
205
+ return props
206
+ }
207
+
208
+ beforeEach(() => {
209
+ captured.length = 0
210
+ detailRef.current = buildDetail()
211
+ // DetailList は行の実測に ResizeObserver を使う (jsdom には無い)
212
+ vi.stubGlobal(
213
+ "ResizeObserver",
214
+ class {
215
+ observe() {}
216
+ unobserve() {}
217
+ disconnect() {}
218
+ },
219
+ )
220
+ vi.stubGlobal(
221
+ "matchMedia",
222
+ vi.fn((query: string) => ({ matches: false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn() })),
223
+ )
224
+ })
225
+
226
+ describe("日報ビューの描画と結線", () => {
227
+ it.each(VIEW_CASES)("%s は添付節を描画する", (_file, view) => {
228
+ renderView(view.render())
229
+ expect(screen.getAllByTestId("daily-report-attachment-section").length).toBeGreaterThan(0)
230
+ expect(screen.getAllByTestId("daily-report-attachment-item").length).toBe(1)
231
+ })
232
+
233
+ it.each(VIEW_CASES)("%s は添付マーカーを描画する", (_file, view) => {
234
+ // 節そのものはカードを開かないと見えないため、スクロールしながら判別できる目印が要る
235
+ renderView(view.render())
236
+ expect(screen.getAllByTestId("daily-report-attachment-indicator").length).toBeGreaterThan(0)
237
+ })
238
+
239
+ it("添付が 0 件のときは節もマーカーも描画されない (誤検知の裏取り)", () => {
240
+ detailRef.current = { ...buildDetail(), attachments: [] }
241
+ renderView(<DailyReportDetailList dailyReportItems={[item]} />)
242
+ expect(screen.queryByTestId("daily-report-attachment-section")).toBeNull()
243
+ expect(screen.queryByTestId("daily-report-attachment-indicator")).toBeNull()
244
+ })
245
+
246
+ it.each(VIEW_CASES)("%s はドラッグを無効化せずポインタ種別で絞る", (_file, view) => {
247
+ const [testId, renderNode] = [view.testId, view.render]
248
+ // ❗ `enablePointerDrag` を切ってはならない。ScrollPane は transform 描画で
249
+ // ネイティブのスクロール領域を持たないため、切るとタッチ端末から
250
+ // スクロール手段が完全に消える。マウスのドラッグだけを外すのが正しい
251
+ renderView(renderNode())
252
+ const { behaviorOptions } = optionsOf(testId)
253
+ expect(behaviorOptions?.pointerDragInputs).toBe(CONTENT_DRAG_POINTER_INPUTS)
254
+ expect(behaviorOptions?.enablePointerDrag).toBeUndefined()
255
+ })
256
+
257
+ it.each(VIEW_CASES)("%s は共有のタップスクロール設定を使う", (_file, view) => {
258
+ const [testId, renderNode] = [view.testId, view.render]
259
+ // 片方だけ独自定義にすると、同じ指の位置でタブごとに速度が何十倍も違う
260
+ // (是正前は実測で最大 77 倍の差があった)
261
+ renderView(renderNode())
262
+ expect(optionsOf(testId).scrollBarOptions?.tapScrollCircleOptions).toBe(TAP_SCROLL_CIRCLE_OPTIONS)
263
+ })
264
+
265
+ it.each(VIEW_CASES)("%s は仮想スクロールの必須結線を渡している", (_file, view) => {
266
+ const [testId, renderNode] = [view.testId, view.render]
267
+ // 代役は全件描画するため、これらを壊しても描画結果は変わらない。値そのものを検査する
268
+ renderView(renderNode())
269
+ const props = optionsOf(testId)
270
+ // 窓フェッチとプリフェッチの起点。no-op にすると一覧が空のままになる
271
+ expect(typeof props.onRangeChange).toBe("function")
272
+ expect(typeof props.onScroll).toBe("function")
273
+ expect(typeof props.getItemHeight).toBe("function")
274
+ // 白飛び防止のオーバースキャン。0 にするとスクロール中に空白が出る
275
+ expect(props.overscanCount).toBe(VIRTUAL_SCROLL_OVERSCAN_COUNT)
276
+ expect(props.viewportSize).toEqual(expect.any(Number))
277
+ })
278
+
279
+ it("❗ onRangeChange は実際のハンドラである (型が function なだけでは足りない)", () => {
280
+ // no-op へ差し替えると窓の日付表示が止まる。`typeof === "function"` は
281
+ // no-op も通してしまうので、**呼んだ結果が観測できること**まで確かめる
282
+ const second: DailyReportItem = { reportHubId: 2, businessDate: "2026-08-05" }
283
+ renderView(<DailyReportList dailyReportItems={[item, second]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
284
+
285
+ /** つまみのバブルに出ている文言。 */
286
+ const overlayText = () => {
287
+ const renderThumbOverlay = optionsOf("daily-report-list").scrollBarOptions?.renderThumbOverlay
288
+ if (typeof renderThumbOverlay !== "function") throw new Error("renderThumbOverlay was not captured")
289
+ const view = render(<div>{renderThumbOverlay({ orientation: "vertical", thumbCenter: 0, isDragging: true, isTapScrollActive: false })}</div>)
290
+ const text = view.container.textContent ?? ""
291
+ view.unmount()
292
+ return text
293
+ }
294
+
295
+ expect(overlayText()).not.toContain("2026-08-05")
296
+
297
+ act(() => {
298
+ ;(optionsOf("daily-report-list").onRangeChange as (range: { visibleStartIndex: number }) => void)({ visibleStartIndex: 1 })
299
+ })
300
+
301
+ // no-op へ差し替えると、ここが古い日付のまま変わらない
302
+ expect(overlayText()).toContain("2026-08-05")
303
+ })
304
+
305
+ it("DetailList は行の同一性を保つキーを渡している (React の再利用が壊れない)", () => {
306
+ // 可変高さのカードは key が潰れると再利用が壊れ、実測高さが別の行へ付く
307
+ renderView(<DailyReportDetailList dailyReportItems={[item]} />)
308
+ const { getItemKey } = optionsOf("daily-report-detail-list")
309
+ expect(typeof getItemKey).toBe("function")
310
+ expect((getItemKey as (index: number) => unknown)(0)).toBe(REPORT_HUB_ID)
311
+ })
312
+
313
+ it("List は上下のインセットを渡している (先頭・末尾の行が縁に貼り付かない)", () => {
314
+ renderView(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
315
+ expect(optionsOf("daily-report-list").contentInsets).toEqual({ top: ITEM_VERTICAL_PADDING, bottom: ITEM_VERTICAL_PADDING })
316
+ })
317
+
318
+ it("レイアウト判定の閾値を各ビューが独自に持たない (単一の情報源にする)", () => {
319
+ // 空白の有無に左右されないよう、空白を除去してから照合する
320
+ for (const view of REPORT_DETAIL_VIEWS) {
321
+ const source = readFileSync(join(componentsDir, view.file), "utf8").replace(/\s+/g, "")
322
+ expect(source, view.file).not.toContain("max-width:639px")
323
+ }
324
+ })
325
+
326
+ it("詳細を描画するビューを網羅している (新しいビューの追加を検知する)", () => {
327
+ // ❗ 走査対象はディレクトリの実体。登録済みリストを絞り込む書き方だと
328
+ // 「新しく増えたファイル」は原理的に視界へ入らない。
329
+ //
330
+ // 判定は **`use-daily-report-comments` を import しているか**で行う。
331
+ // 日報のコメントを取りに行くのは「その日報の詳細を描画するビュー」だけであり、
332
+ // import 文なので `fieldLabels.comments` のような字面の揺れ
333
+ // (`fieldLabels["comments"]` / 分割代入) に左右されない
334
+ const detailViews = readdirSync(componentsDir)
335
+ .filter((file) => file.endsWith(".tsx") && !file.endsWith(".spec.tsx"))
336
+ .filter((file) => readFileSync(join(componentsDir, file), "utf8").includes('from "../hooks/use-daily-report-comments"'))
337
+ .sort()
338
+ expect(detailViews).toEqual(REPORT_DETAIL_VIEWS.map((view) => view.file).sort())
339
+ })
340
+ })
@@ -33,6 +33,11 @@ export type DailyReportFieldLabels = {
33
33
  content: string
34
34
  interviewers: string
35
35
  comments: string
36
+ attachments: string
37
+ /** 実体の消失が確認された添付に添える注記。 */
38
+ attachmentMissing: string
39
+ /** 添付をダウンロードするリンクのラベル。 */
40
+ attachmentDownload: string
36
41
  tabArticle: string
37
42
  tabRelations: string
38
43
  relationsEmpty: string
@@ -92,6 +97,9 @@ export const defaultDailyReportFieldLabels: DailyReportFieldLabels = {
92
97
  content: "Content",
93
98
  interviewers: "Attendees",
94
99
  comments: "Comments",
100
+ attachments: "Attachments",
101
+ attachmentMissing: "File is missing",
102
+ attachmentDownload: "Download",
95
103
  tabArticle: "Overview",
96
104
  tabRelations: "Related",
97
105
  relationsEmpty: "No related view configured yet.",