@aiquants/daily-report 0.6.2 → 0.7.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 (49) hide show
  1. package/dist/client.d.mts +81 -3
  2. package/dist/client.d.ts +81 -3
  3. package/dist/client.js +4 -4
  4. package/dist/client.js.map +1 -1
  5. package/dist/client.mjs +4 -4
  6. package/dist/client.mjs.map +1 -1
  7. package/dist/index.d.mts +2 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +1 -1
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/server.d.mts +434 -6
  14. package/dist/server.d.ts +434 -6
  15. package/dist/server.js +6 -6
  16. package/dist/server.js.map +1 -1
  17. package/dist/server.mjs +6 -6
  18. package/dist/server.mjs.map +1 -1
  19. package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
  20. package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
  21. package/dist/styles/daily-report.standalone.css +1 -1
  22. package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
  23. package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
  24. package/package.json +3 -3
  25. package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
  26. package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
  27. package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
  28. package/src/client/components/daily-report-attachment-list.tsx +112 -0
  29. package/src/client/components/daily-report-detail-list.tsx +14 -7
  30. package/src/client/components/daily-report-list.tsx +19 -11
  31. package/src/client/components/report-views.spec.tsx +340 -0
  32. package/src/client/config-context.tsx +8 -0
  33. package/src/client/contexts/daily-report-action-context.tsx +2 -0
  34. package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
  35. package/src/client/hooks/use-responsive-layout.ts +56 -0
  36. package/src/client/utils/constants.spec.ts +96 -0
  37. package/src/client/utils/constants.ts +47 -2
  38. package/src/client.ts +2 -0
  39. package/src/server/handlers.attachment.spec.ts +470 -0
  40. package/src/server/handlers.ts +426 -1
  41. package/src/server/ports.ts +74 -0
  42. package/src/server/schema.ts +80 -5
  43. package/src/server/service.spec.ts +280 -14
  44. package/src/server/service.ts +308 -25
  45. package/src/server/test-helpers/handlers-config.ts +142 -0
  46. package/src/server.ts +16 -1
  47. package/src/shared/sse-schema.spec.ts +50 -0
  48. package/src/shared/sse-schema.ts +27 -0
  49. package/src/shared/types.ts +31 -0
@@ -3,7 +3,7 @@
3
3
  * useFetcher ベースのデータフックで日報詳細を提供する仮想スクロールビュー。
4
4
  */
5
5
 
6
- import { type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, tapScrollCircleSampleVisual, VirtualScroll, type VirtualScrollHandle } from "@aiquants/virtualscroll"
6
+ import { type ScrollBarThumbOverlayRenderProps, VirtualScroll, type VirtualScrollHandle } from "@aiquants/virtualscroll"
7
7
 
8
8
  /**
9
9
  * Handle type augmented with E2E test helper methods.
@@ -25,8 +25,10 @@ import { useDailyReportDetail, useDailyReportPrefetch } from "../hooks/use-daily
25
25
  import { useDailyReportComments } from "../hooks/use-daily-report-comments"
26
26
  import { useDynamicViewportHeight } from "../hooks/use-dynamic-viewport-height"
27
27
  import { cn } from "../ui/cn"
28
- import { EMPTY_ITEM, FAST_SCROLL_INERTIA_OPTIONS, FAST_SCROLL_WHEEL_MULTIPLIER, PREFETCH_LOOKAHEAD, PREFETCH_MAX_DATES, VIRTUAL_SCROLL_OVERSCAN_COUNT } from "../utils/constants"
28
+ import { CONTENT_DRAG_POINTER_INPUTS, EMPTY_ITEM, FAST_SCROLL_INERTIA_OPTIONS, FAST_SCROLL_WHEEL_MULTIPLIER, PREFETCH_LOOKAHEAD, PREFETCH_MAX_DATES, SCROLL_BAR_WIDTH, TAP_SCROLL_CIRCLE_OPTIONS, VIRTUAL_SCROLL_OVERSCAN_COUNT, WHEEL_RESET_TIMEOUT_MS } from "../utils/constants"
29
29
  import { BusinessDayThumbOverlay } from "./business-day-thumb-overlay"
30
+ import { DailyReportAttachmentIndicator } from "./daily-report-attachment-indicator"
31
+ import { DailyReportAttachmentList } from "./daily-report-attachment-list"
30
32
  import { DailyReportCommentForm, DailyReportCommentList } from "./daily-report-comment-section"
31
33
  import { DailyReportEditForm } from "./daily-report-edit-form"
32
34
  import { UnreadIndicator } from "./unread-indicator"
@@ -230,7 +232,7 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
230
232
  wheelResetTimerRef.current = window.setTimeout(() => {
231
233
  setIsWheelScrollActive(false)
232
234
  wheelResetTimerRef.current = null
233
- }, 240)
235
+ }, WHEEL_RESET_TIMEOUT_MS)
234
236
  }
235
237
  el.addEventListener("wheel", onWheel, { passive: true })
236
238
  return () => {
@@ -270,8 +272,6 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
270
272
  [getItem],
271
273
  )
272
274
 
273
- const tapScrollCircleOptions = useMemo<ScrollBarTapCircleOptions>(() => ({ enabled: true, renderVisual: tapScrollCircleSampleVisual }), [])
274
-
275
275
  /**
276
276
  * Updates viewport height when item count changes.
277
277
  * アイテム数変更時にビューポート高さを更新する。
@@ -311,11 +311,14 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
311
311
  initialScrollOffset={initialScrollOffset}
312
312
  overscanCount={VIRTUAL_SCROLL_OVERSCAN_COUNT}
313
313
  scrollBarOptions={{
314
- width: 8,
315
- tapScrollCircleOptions: tapScrollCircleOptions,
314
+ width: SCROLL_BAR_WIDTH,
315
+ tapScrollCircleOptions: TAP_SCROLL_CIRCLE_OPTIONS,
316
316
  renderThumbOverlay: renderThumbOverlay,
317
317
  }}
318
318
  behaviorOptions={{
319
+ // ドラッグスクロールは無効化せず、**ポインタ種別で絞る**。
320
+ // 丸ごと切るとタッチ端末からスクロール手段が消える (定数の注記参照)
321
+ pointerDragInputs: CONTENT_DRAG_POINTER_INPUTS,
319
322
  wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
320
323
  inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
321
324
  }}
@@ -472,6 +475,9 @@ const DailyReportHeader = ({
472
475
  const { sourceTypeConfigs } = useDailyReportConfig()
473
476
  return (
474
477
  <div className="flex flex-wrap items-center gap-2 font-medium text-slate-600 text-xs">
478
+ {/* 添付の有無を一目判別できるようにする。仕様書が定める
479
+ [sourceType] [Star] [Read] の相対順は崩さず、その手前に置く */}
480
+ <DailyReportAttachmentIndicator count={report.attachments.length} label={fieldLabels.attachments} />
475
481
  {(() => {
476
482
  const sourceType = itemSourceType ?? report?.sourceType
477
483
  const sourceConfig = resolveSourceTypeConfig(sourceType, sourceTypeConfigs)
@@ -673,6 +679,7 @@ const DailyReportResolvedItem = ({ item, report, onCommentAdded, userId, onDoubl
673
679
  </ul>
674
680
  </div>
675
681
  )}
682
+ <DailyReportAttachmentList attachments={report.attachments} />
676
683
  {uiComments.length > 0 && (
677
684
  <div className="flex flex-col gap-2">
678
685
  <span className="font-semibold text-slate-500 text-xs uppercase tracking-wide">{fieldLabels.comments}</span>
@@ -13,9 +13,11 @@ import { useDailyReportActionContext } from "../contexts/daily-report-action-con
13
13
  import { useDailyReportDetail } from "../hooks/use-daily-report"
14
14
  import { useDailyReportComments } from "../hooks/use-daily-report-comments"
15
15
  import { useDynamicViewportHeight } from "../hooks/use-dynamic-viewport-height"
16
+ import { useIsMobileLayout } from "../hooks/use-responsive-layout"
16
17
  import { cn } from "../ui/cn"
17
18
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
18
19
  import {
20
+ CONTENT_DRAG_POINTER_INPUTS,
19
21
  DEFAULT_ITEM_HEIGHT,
20
22
  EMPTY_DETAIL,
21
23
  EMPTY_ITEM,
@@ -30,6 +32,8 @@ import {
30
32
  WHEEL_RESET_TIMEOUT_MS,
31
33
  } from "../utils/constants"
32
34
  import { BusinessDayThumbOverlay } from "./business-day-thumb-overlay"
35
+ import { DailyReportAttachmentIndicator } from "./daily-report-attachment-indicator"
36
+ import { DailyReportAttachmentList } from "./daily-report-attachment-list"
33
37
  import { DailyReportCommentForm, DailyReportCommentList } from "./daily-report-comment-section"
34
38
  import { DailyReportEditForm } from "./daily-report-edit-form"
35
39
  import { UnreadIndicator } from "./unread-indicator"
@@ -236,7 +240,7 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
236
240
  const { viewportHeight, updateViewportHeight } = useDynamicViewportHeight(scrollContainerRef)
237
241
  const [overlayLabel, setOverlayLabel] = useState<string>("")
238
242
  const [isWheelScrollActive, setIsWheelScrollActive] = useState(false)
239
- const [isMobileLayout, setIsMobileLayout] = useState(false)
243
+ const isMobileLayout = useIsMobileLayout()
240
244
  const wheelResetTimerRef = useRef<number | null>(null)
241
245
  const appliedInitialSignatureRef = useRef<string | null>(null)
242
246
  const itemCount = dailyReportItems.length
@@ -247,21 +251,17 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
247
251
  const columnHeightStyle = useMemo<CSSProperties | undefined>(() => (Number.isFinite(viewportHeight) && viewportHeight > 0 ? { height: viewportHeight } : undefined), [viewportHeight])
248
252
 
249
253
  /**
250
- * Manages global event listeners (Media Query, Wheel, Resize).
251
- * メディアクエリ、ホイール、リサイズなどのグローバルイベントリスナーを管理する副作用。
254
+ * Manages global event listeners (Wheel, Resize).
255
+ * ホイール・リサイズのグローバルイベントリスナーを管理する副作用。
252
256
  *
253
- * Purpose: To handle responsive layout changes and scroll interactions.
257
+ * Purpose: To keep the viewport height in sync and to detect wheel scrolling.
258
+ * (レスポンシブ判定は use-responsive-layout のフックが担当する。)
254
259
  * Dependencies: [updateViewportHeight]
255
260
  * Cleanup: Removes all event listeners and clears timers.
256
261
  */
257
262
  useEffect(() => {
258
263
  if (typeof window === "undefined") return
259
264
 
260
- const mq = window.matchMedia("(max-width: 639px)")
261
- const onMediaChange = (e: MediaQueryListEvent) => setIsMobileLayout(e.matches)
262
- setIsMobileLayout(mq.matches)
263
- mq.addEventListener("change", onMediaChange)
264
-
265
265
  const onResize = () => requestAnimationFrame(updateViewportHeight)
266
266
  window.addEventListener("resize", onResize)
267
267
  updateViewportHeight()
@@ -278,7 +278,6 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
278
278
  el?.addEventListener("wheel", onWheel, { passive: true })
279
279
 
280
280
  return () => {
281
- mq.removeEventListener("change", onMediaChange)
282
281
  window.removeEventListener("resize", onResize)
283
282
  el?.removeEventListener("wheel", onWheel)
284
283
  if (wheelResetTimerRef.current) clearTimeout(wheelResetTimerRef.current)
@@ -453,6 +452,9 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
453
452
  renderThumbOverlay: renderThumbOverlay,
454
453
  }}
455
454
  behaviorOptions={{
455
+ // ドラッグスクロールは無効化せず、**ポインタ種別で絞る**。
456
+ // 丸ごと切るとタッチ端末からスクロール手段が消える (定数の注記参照)
457
+ pointerDragInputs: CONTENT_DRAG_POINTER_INPUTS,
456
458
  inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
457
459
  wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
458
460
  }}
@@ -811,6 +813,9 @@ const DailyReportDisplayPane = ({
811
813
  )}
812
814
  </div>
813
815
  </div>
816
+ {/* 添付節。DetailList タブと同じ位置 (面談者・本文の後、コメントの前) に置く。
817
+ 片方のタブにしか出さないと、利用者は「添付が無い日報」と誤認する */}
818
+ {report && <DailyReportAttachmentList attachments={report.attachments} />}
814
819
  {report && (
815
820
  <div className="fade-in animate-in space-y-2 border-slate-100 border-t pt-4 duration-500 dark:border-slate-800">
816
821
  <div className="font-semibold text-slate-500 text-xs uppercase tracking-wide dark:text-slate-300">{fieldLabels.comments}</div>
@@ -934,7 +939,7 @@ const extractPreviewLines = (content: string | null): string[] => {
934
939
  * List タブ内で表示する日報サマリーカード。
935
940
  */
936
941
  const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; isActive: boolean; onSelect: (id: number) => void }) => {
937
- const { sourceTypeConfigs } = useDailyReportConfig()
942
+ const { fieldLabels, sourceTypeConfigs } = useDailyReportConfig()
938
943
  const { toggleStar, toggleRead, removeStaleItem } = useDailyReportActionContext()
939
944
  const { report, error, isLoading, isRefetching } = useDailyReportDetail(item.reportHubId, item.businessDate)
940
945
 
@@ -1022,6 +1027,9 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
1022
1027
  <span className="truncate font-medium text-slate-900 dark:text-slate-100">{creator}</span>
1023
1028
  </span>
1024
1029
  <div className="ml-auto flex items-center gap-1.5">
1030
+ {/* 添付の有無を一覧で一目判別できるようにする。
1031
+ 仕様書が定める [sourceType] [Star] [Read] の相対順は崩さず、その手前に置く */}
1032
+ <DailyReportAttachmentIndicator count={report.attachments.length} label={fieldLabels.attachments} />
1025
1033
  {(() => {
1026
1034
  const sourceType = item.sourceType ?? report?.sourceType
1027
1035
  const sourceConfig = resolveSourceTypeConfig(sourceType, sourceTypeConfigs)
@@ -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.",
@@ -773,6 +773,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
773
773
  isStarred: false,
774
774
  labels: [{ id: 0, name: draftLabelName, color: "#9ca3af" }],
775
775
  commentItems: [],
776
+ // 楽観更新の仮レポート。作成直後に添付は存在しない
777
+ attachments: [],
776
778
  }
777
779
  writeCache(dummyReport, Date.now() + 300000, false)
778
780
 
@@ -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
+ })