@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
@@ -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>
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Per-phase rendering tests for the ids stream status badge.
3
+ * ids ストリーム状態バッジの局面別描画テスト。
4
+ *
5
+ * ここで固定する不変条件のうち、特に重要なもの:
6
+ *
7
+ * - **進捗率は 0-100 にクランプされ、total 0/null では表示されない** (0 除算・>100% 防止)。
8
+ * - **failed バッジの再試行ボタンはセッション retry へ直結する。**
9
+ * - **idle / 完走済み (再検証なし) では何も描画しない** (ヘッダーを汚さない)。
10
+ */
11
+ import { fireEvent, render, screen } from "@testing-library/react"
12
+ import { describe, expect, it, vi } from "vitest"
13
+ import type { DailyReportIdsStreamState } from "../streaming/daily-report-ids-stream-client"
14
+ import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
15
+
16
+ let mockStreamState: DailyReportIdsStreamState
17
+ const retryMock = vi.fn()
18
+
19
+ vi.mock("../streaming/daily-report-ids-stream-session", () => ({
20
+ useDailyReportIdsStream: () => mockStreamState,
21
+ retryDailyReportIdsStream: () => retryMock(),
22
+ }))
23
+
24
+ /** 状態を組み立ててバッジを描画する。 */
25
+ const renderStatus = (partial: Partial<DailyReportIdsStreamState>) => {
26
+ mockStreamState = {
27
+ items: [],
28
+ loadedCount: 0,
29
+ totalCount: null,
30
+ phase: "idle",
31
+ isRevalidating: false,
32
+ hasCompletedOnce: false,
33
+ completedAt: null,
34
+ error: null,
35
+ ...partial,
36
+ }
37
+ return render(<DailyReportIdsStreamStatus />)
38
+ }
39
+
40
+ describe("DailyReportIdsStreamStatus", () => {
41
+ it("streaming + total あり: 進捗率付きピルを表示する", () => {
42
+ renderStatus({ phase: "streaming", loadedCount: 250, totalCount: 1000 })
43
+ const badge = screen.getByTestId("daily-report-ids-stream-status")
44
+ expect(badge).toHaveAttribute("data-phase", "streaming")
45
+ expect(badge.textContent).toContain("読み込み中")
46
+ expect(badge.textContent).toContain("(25%)")
47
+ })
48
+
49
+ it("streaming + total 未着: 件数のみで率を出さない", () => {
50
+ renderStatus({ phase: "streaming", loadedCount: 42, totalCount: null })
51
+ const badge = screen.getByTestId("daily-report-ids-stream-status")
52
+ expect(badge.textContent).toContain("件")
53
+ expect(badge.textContent).not.toContain("%")
54
+ })
55
+
56
+ it("streaming + total 0: 0 除算せず率を出さない", () => {
57
+ renderStatus({ phase: "streaming", loadedCount: 0, totalCount: 0 })
58
+ expect(screen.getByTestId("daily-report-ids-stream-status").textContent).not.toContain("%")
59
+ })
60
+
61
+ it("loadedCount > total でも率は 100% にクランプされる", () => {
62
+ renderStatus({ phase: "streaming", loadedCount: 12, totalCount: 10 })
63
+ expect(screen.getByTestId("daily-report-ids-stream-status").textContent).toContain("(100%)")
64
+ })
65
+
66
+ it("retrying: 受信済み件数付きの再接続表示", () => {
67
+ renderStatus({ phase: "retrying", loadedCount: 123 })
68
+ const badge = screen.getByTestId("daily-report-ids-stream-status")
69
+ expect(badge).toHaveAttribute("data-phase", "retrying")
70
+ expect(badge.textContent).toContain("再接続中")
71
+ expect(badge.textContent).toContain("123")
72
+ })
73
+
74
+ it("failed: 再試行ボタンがセッション retry を呼ぶ", () => {
75
+ retryMock.mockClear()
76
+ renderStatus({ phase: "failed", error: new Error("x") })
77
+ expect(screen.getByTestId("daily-report-ids-stream-status")).toHaveAttribute("data-phase", "failed")
78
+ fireEvent.click(screen.getByTestId("daily-report-ids-stream-retry"))
79
+ expect(retryMock).toHaveBeenCalledTimes(1)
80
+ })
81
+
82
+ it("complete + 再検証中: 控えめな最新化インジケーター", () => {
83
+ renderStatus({ phase: "complete", isRevalidating: true, hasCompletedOnce: true })
84
+ expect(screen.getByTestId("daily-report-ids-stream-status")).toHaveAttribute("data-phase", "revalidating")
85
+ })
86
+
87
+ it("idle / complete (再検証なし) は何も描画しない", () => {
88
+ const { container: idleContainer } = renderStatus({ phase: "idle" })
89
+ expect(idleContainer.firstChild).toBeNull()
90
+ const { container: completeContainer } = renderStatus({ phase: "complete", hasCompletedOnce: true })
91
+ expect(completeContainer.firstChild).toBeNull()
92
+ })
93
+ })
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Header badge that visualizes the ids stream lifecycle.
3
+ * ID ストリームのライフサイクルをヘッダーへ可視化するバッジコンポーネント。
4
+ *
5
+ * 局面ごとの表示:
6
+ * - streaming: 進捗ピル「読み込み中 n / total (p%)」(total 未着なら件数のみ)
7
+ * - retrying: 「再接続中... (n 件受信済)」
8
+ * - failed: 失敗表示 + 再試行ボタン (受信済みデータは保持されている)
9
+ * - complete + 再検証中: 控えめな「最新化中」インジケーター
10
+ * - それ以外 (idle / complete): 何も描画しない
11
+ */
12
+ import { RefreshCw } from "lucide-react"
13
+ import { useAnimatedNumber } from "../hooks/use-animated-number"
14
+ import { retryDailyReportIdsStream, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
15
+
16
+ /**
17
+ * Renders the streaming progress / recovery badge for the ids stream.
18
+ * ids ストリームの進捗・復旧バッジを描画するコンポーネント。
19
+ */
20
+ export const DailyReportIdsStreamStatus = () => {
21
+ const stream = useDailyReportIdsStream()
22
+ const animatedLoaded = useAnimatedNumber(stream.loadedCount)
23
+ const animatedTotal = useAnimatedNumber(stream.totalCount ?? 0)
24
+
25
+ if (stream.phase === "streaming") {
26
+ // total 到着前は件数のみ、到着後は分母と進捗率を添える
27
+ const percent = stream.totalCount && stream.totalCount > 0 ? Math.min(100, Math.round((stream.loadedCount / stream.totalCount) * 100)) : null
28
+ return (
29
+ <span data-testid="daily-report-ids-stream-status" data-phase="streaming" className="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-2 py-0.5 font-medium text-blue-800 text-xs tabular-nums dark:bg-blue-900/40 dark:text-blue-300">
30
+ <span aria-hidden="true" className="h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500 dark:bg-blue-400" />
31
+ 読み込み中 {animatedLoaded.toLocaleString()}
32
+ {percent !== null ? ` / ${animatedTotal.toLocaleString()} (${percent}%)` : " 件"}
33
+ </span>
34
+ )
35
+ }
36
+
37
+ if (stream.phase === "retrying") {
38
+ return (
39
+ <span role="status" data-testid="daily-report-ids-stream-status" data-phase="retrying" className="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-800 text-xs tabular-nums dark:bg-amber-900/40 dark:text-amber-300">
40
+ <RefreshCw aria-hidden="true" className="h-3 w-3 animate-spin" />
41
+ 再接続中... ({stream.loadedCount.toLocaleString()} 件受信済)
42
+ </span>
43
+ )
44
+ }
45
+
46
+ if (stream.phase === "failed") {
47
+ return (
48
+ <span role="status" data-testid="daily-report-ids-stream-status" data-phase="failed" className="inline-flex items-center gap-1.5 rounded-full bg-rose-100 px-2 py-0.5 font-medium text-rose-800 text-xs dark:bg-rose-900/40 dark:text-rose-300">
49
+ 読み込みに失敗しました
50
+ <button type="button" data-testid="daily-report-ids-stream-retry" onClick={() => retryDailyReportIdsStream()} className="rounded bg-rose-200 px-1.5 py-0.5 font-semibold text-rose-900 hover:bg-rose-300 dark:bg-rose-800 dark:text-rose-100 dark:hover:bg-rose-700">
51
+ 再試行
52
+ </button>
53
+ </span>
54
+ )
55
+ }
56
+
57
+ if (stream.isRevalidating) {
58
+ return (
59
+ <span data-testid="daily-report-ids-stream-status" data-phase="revalidating" className="inline-flex items-center gap-1 text-slate-400 text-xs dark:text-slate-500">
60
+ <RefreshCw aria-hidden="true" className="h-3 w-3 animate-spin" />
61
+ 最新化中
62
+ </span>
63
+ )
64
+ }
65
+
66
+ return null
67
+ }
@@ -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,119 @@
1
+ /**
2
+ * Phase-gating tests for the stream-driven daily report page.
3
+ * ストリーム駆動日報ページの局面ゲーティングテスト。
4
+ *
5
+ * ここで固定する不変条件のうち、特に重要なもの:
6
+ *
7
+ * - **最初のチャンク到着 (items > 0) で即座に本体を描画する** — 全件解決を待つ
8
+ * ゲートが存在しないことがストリーミング化の成果そのもの。
9
+ * - **failed でも受信済みアイテムがあれば本体を描画し続ける** (データを捨てない)。
10
+ * - **マウント時にセッションを ensure する** (clientLoader を経ないマウントの保険)。
11
+ * - **auth-required はフルリロードへ倒す** (サーバー loader のリダイレクトに委譲)。
12
+ */
13
+ import { fireEvent, render, screen } from "@testing-library/react"
14
+ import { describe, expect, it, vi } from "vitest"
15
+ import type { DailyReportItem } from "../../shared/types"
16
+ import type { DailyReportIdsStreamState } from "../streaming/daily-report-ids-stream-client"
17
+ import { DailyReportPage } from "./daily-report-page"
18
+
19
+ /** テストが差し替えるストリーム状態 (各テスト冒頭で上書きする)。 */
20
+ let mockStreamState: DailyReportIdsStreamState = {
21
+ items: [],
22
+ loadedCount: 0,
23
+ totalCount: null,
24
+ phase: "idle",
25
+ isRevalidating: false,
26
+ hasCompletedOnce: false,
27
+ completedAt: null,
28
+ error: null,
29
+ }
30
+
31
+ const ensureMock = vi.fn()
32
+ const retryMock = vi.fn()
33
+ const reloadMock = vi.fn()
34
+
35
+ vi.mock("../streaming/daily-report-ids-stream-session", () => ({
36
+ useDailyReportIdsStream: () => mockStreamState,
37
+ ensureDailyReportIdsStreamSession: (options: unknown) => ensureMock(options),
38
+ retryDailyReportIdsStream: () => retryMock(),
39
+ refreshDailyReportIdsStream: vi.fn(),
40
+ primeDailyReportIdsStreamSession: vi.fn(),
41
+ reloadDocument: () => reloadMock(),
42
+ }))
43
+
44
+ /** 重量級の子孫 (仮想スクロール・SSE) はマーカーへ置換し、ゲーティングだけを検証する。 */
45
+ vi.mock("./daily-report-resolved-content", () => ({
46
+ DailyReportResolvedContent: () => <div data-testid="resolved-content" />,
47
+ }))
48
+
49
+ /** provider は initialItems の受け取りを記録する透過スタブにする。 */
50
+ const providerItemsLog: DailyReportItem[][] = []
51
+ vi.mock("../contexts/daily-report-action-context", () => ({
52
+ DailyReportActionProvider: ({ children, initialItems }: { children: React.ReactNode; initialItems?: DailyReportItem[] }) => {
53
+ providerItemsLog.push(initialItems ?? [])
54
+ return <div>{children}</div>
55
+ },
56
+ }))
57
+
58
+ /** 状態を部分上書きして描画するヘルパー。 */
59
+ const renderPage = (partial: Partial<DailyReportIdsStreamState>) => {
60
+ mockStreamState = { ...mockStreamState, items: [], loadedCount: 0, totalCount: null, phase: "idle", isRevalidating: false, hasCompletedOnce: false, completedAt: null, error: null, ...partial }
61
+ return render(<DailyReportPage userId="ENC8" />)
62
+ }
63
+
64
+ const twoItems: DailyReportItem[] = [
65
+ { reportHubId: 2, businessDate: "2026-08-02" },
66
+ { reportHubId: 1, businessDate: "2026-08-01" },
67
+ ]
68
+
69
+ describe("DailyReportPage / 局面ゲーティング", () => {
70
+ it("未受信 (idle/streaming) はローディング画面を表示する", () => {
71
+ renderPage({ phase: "streaming" })
72
+ expect(screen.getByText("日報 ID を読み込み中です...")).toBeInTheDocument()
73
+ expect(screen.queryByTestId("resolved-content")).not.toBeInTheDocument()
74
+ })
75
+
76
+ it("最初のチャンク到着 (items > 0) で本体を描画し、provider へアイテムを渡す", () => {
77
+ providerItemsLog.length = 0
78
+ renderPage({ phase: "streaming", items: twoItems, loadedCount: 2 })
79
+ expect(screen.getByTestId("resolved-content")).toBeInTheDocument()
80
+ expect(providerItemsLog.at(-1)).toEqual(twoItems)
81
+ })
82
+
83
+ it("空リストの完走 (complete) は本体を描画する (0 件表示は下層の責務)", () => {
84
+ renderPage({ phase: "complete", hasCompletedOnce: true })
85
+ expect(screen.getByTestId("resolved-content")).toBeInTheDocument()
86
+ })
87
+
88
+ it("未受信のまま failed ならエラー画面を出し、再試行がセッション retry を呼ぶ", () => {
89
+ retryMock.mockClear()
90
+ renderPage({ phase: "failed", error: new Error("x") })
91
+ expect(screen.getByText("日報データの読み込みに失敗しました。")).toBeInTheDocument()
92
+ fireEvent.click(screen.getByTestId("daily-report-load-error-retry"))
93
+ expect(retryMock).toHaveBeenCalledTimes(1)
94
+ })
95
+
96
+ it("failed でも受信済みアイテムがあれば本体を描画し続ける", () => {
97
+ renderPage({ phase: "failed", items: twoItems, loadedCount: 2, error: new Error("x") })
98
+ expect(screen.getByTestId("resolved-content")).toBeInTheDocument()
99
+ expect(screen.queryByText("日報データの読み込みに失敗しました。")).not.toBeInTheDocument()
100
+ })
101
+
102
+ it("マウント時にセッションを ensure する (userKey = 難読化ユーザー ID)", () => {
103
+ ensureMock.mockClear()
104
+ renderPage({ phase: "streaming" })
105
+ expect(ensureMock).toHaveBeenCalledWith({ apiBasePath: "/daily_report/api", userKey: "ENC8" })
106
+ })
107
+
108
+ it("auth-required はドキュメント再読込へ倒す (サーバー loader のリダイレクトに委譲)", () => {
109
+ reloadMock.mockClear()
110
+ renderPage({ phase: "auth-required" })
111
+ expect(reloadMock).toHaveBeenCalledTimes(1)
112
+ })
113
+
114
+ it("auth-required 以外の局面ではドキュメント再読込しない", () => {
115
+ reloadMock.mockClear()
116
+ renderPage({ phase: "streaming", items: twoItems, loadedCount: 2 })
117
+ expect(reloadMock).not.toHaveBeenCalled()
118
+ })
119
+ })
@@ -1,54 +1,30 @@
1
1
  /**
2
- * Top-level daily-report page content (chrome-agnostic).
3
- * アプリのレイアウトに依存しない日報ページ本体コンポーネント。
2
+ * Top-level daily-report page content (chrome-agnostic, stream-driven).
3
+ * アプリのレイアウトに依存しない日報ページ本体コンポーネント (ストリーム駆動)。
4
4
  *
5
- * アプリ側のルートは本コンポーネントを自前のレイアウト (Main 等) で包み、
6
- * `config` でヘッダー描画・パス・ラベル名を注入する。
5
+ * ID 一覧はモジュール常駐の NDJSON ストリームセッションから購読し、最初の
6
+ * チャンク到着と同時に一覧を描画する (全件解決を待つ Await ゲートは廃止)。
7
+ * 再訪時はセッションに残る受信済みデータで即座に復元し、鮮度はバックグラウンド
8
+ * 再検証 (SWR) と SSE が担う。アプリ側のルートは本コンポーネントを自前の
9
+ * レイアウトで包み、`config` でヘッダー描画・パス・ラベル名を注入する。
7
10
  */
8
- import { Suspense, useCallback, useEffect, useState } from "react"
9
- import { Await, useRevalidator } from "react-router"
10
- import type { DailyReportItem, DailyReportUser } from "../../shared/types"
11
+ import { useEffect } from "react"
12
+ import type { DailyReportUser } from "../../shared/types"
11
13
  import { type DailyReportClientConfigInput, DailyReportConfigProvider, useDailyReportConfig } from "../config-context"
12
14
  import { DailyReportActionProvider } from "../contexts/daily-report-action-context"
15
+ import { ensureDailyReportIdsStreamSession, reloadDocument, retryDailyReportIdsStream, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
13
16
  import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
17
+ import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
14
18
  import { DailyReportResolvedContent } from "./daily-report-resolved-content"
15
19
 
16
20
  /**
17
- * Synchronizes resolved daily report ids into the action context provider state.
18
- * Also renders the resolved content after synchronization trigger is set.
21
+ * Error display when the ids stream terminally failed before any data arrived.
22
+ * データ未受信のまま ID ストリームが力尽きたときのエラー表示コンポーネント。
19
23
  *
20
- * 解決済みの日報 ID を Action Context 側へ同期し、解決済みコンテンツを描画するコンポーネント。
21
- */
22
- const DailyReportResolvedSync = ({ dailyReportItems, userId, onResolved }: { dailyReportItems: DailyReportItem[]; userId?: string | null; onResolved: (items: DailyReportItem[]) => void }) => {
23
- /**
24
- * Notifies parent when resolved items change.
25
- * Dependencies:
26
- * - dailyReportItems: latest resolved list from Await.
27
- * - onResolved: parent synchronization callback.
28
- * Cleanup:
29
- * - None (pure synchronization side effect).
30
- *
31
- * 解決済み items が変化したときに親へ通知します。
32
- * 依存配列:
33
- * - dailyReportItems: Await で解決された最新リスト
34
- * - onResolved: 親への同期コールバック
35
- * クリーンアップ:
36
- * - なし(同期のみの副作用)
37
- */
38
- useEffect(() => {
39
- onResolved(dailyReportItems)
40
- }, [dailyReportItems, onResolved])
41
-
42
- return <DailyReportResolvedContent dailyReportItems={dailyReportItems} userId={userId} />
43
- }
44
-
45
- /**
46
- * Error display component for daily report data fetch failures.
47
- * 日報データの取得失敗時に表示するエラーコンポーネント。
24
+ * 再試行はストリームセッションの retry へ直結する (受信済みカーソルから再開)。
48
25
  */
49
26
  export const DailyReportLoadError = () => {
50
27
  const { title, renderHeader } = useDailyReportConfig()
51
- const { revalidate } = useRevalidator()
52
28
  return (
53
29
  <>
54
30
  {renderHeader({ title, annotation: <span className="font-bold text-red-500">Error</span> })}
@@ -56,7 +32,11 @@ export const DailyReportLoadError = () => {
56
32
  <div className="mx-auto flex h-full w-full max-w-6xl flex-1 flex-col gap-6">
57
33
  <div className="flex h-full flex-1 flex-col items-center justify-center gap-4 rounded-lg border border-red-200 bg-white p-4 text-slate-500 shadow-sm dark:border-red-900 dark:bg-slate-900/70 dark:text-slate-400">
58
34
  <p className="font-medium text-red-600">日報データの読み込みに失敗しました。</p>
59
- <button type="button" onClick={() => revalidate()} className="rounded bg-slate-100 px-4 py-2 text-slate-700 text-sm hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700">
35
+ <button
36
+ type="button"
37
+ data-testid="daily-report-load-error-retry"
38
+ onClick={() => retryDailyReportIdsStream()}
39
+ className="rounded bg-slate-100 px-4 py-2 text-slate-700 text-sm hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700">
60
40
  再読み込み
61
41
  </button>
62
42
  </div>
@@ -67,8 +47,8 @@ export const DailyReportLoadError = () => {
67
47
  }
68
48
 
69
49
  /**
70
- * Suspense fallback while daily report identifiers resolve.
71
- * 日報 ID 解決中のサスペンスフォールバック。
50
+ * Loading screen shown only until the first stream chunk arrives.
51
+ * 最初のストリームチャンクが届くまでの間だけ表示するローディング画面。
72
52
  */
73
53
  export const DailyReportLoading = () => {
74
54
  const { title, renderHeader } = useDailyReportConfig()
@@ -77,7 +57,8 @@ export const DailyReportLoading = () => {
77
57
  {renderHeader({
78
58
  title,
79
59
  annotation: (
80
- <div className="flex flex-wrap gap-2 leading-none">
60
+ <div className="flex flex-wrap items-center gap-2 leading-none">
61
+ <DailyReportIdsStreamStatus />
81
62
  <span>総件数: 読み込み中...</span>
82
63
  <span>デバウンス: {DAILY_REPORT_FETCH_DEBOUNCE_MS} ms</span>
83
64
  </div>
@@ -97,41 +78,48 @@ export type DailyReportPageProps = {
97
78
  user?: DailyReportUser
98
79
  /** 難読化済みの内部ユーザー ID (未登録ユーザーは null)。 */
99
80
  userId?: string | null
100
- /** clientLoader で defer した日報 ID 一覧 (Promise) または解決済み配列。 */
101
- dailyReportIds?: Promise<DailyReportItem[]> | DailyReportItem[] | null
102
81
  /** クライアント設定の部分上書き (ヘッダー描画・パス・ラベル名・フィールドラベルなど)。 */
103
82
  config?: DailyReportClientConfigInput
104
83
  }
105
84
 
106
85
  /**
107
- * Daily report page content rendering provider, suspense boundary, and resolved views.
108
- * プロバイダー・サスペンス境界・解決済みビューを描画する日報ページ本体。
86
+ * Stream-driven page body: session bootstrap, phase gating, and provider wiring.
87
+ * ストリーム駆動のページ本体。セッション確立・局面ゲーティング・プロバイダー結線を担う。
109
88
  */
110
- const DailyReportPageInner = ({ user, userId, dailyReportIds }: Omit<DailyReportPageProps, "config">) => {
111
- const [resolvedInitialItems, setResolvedInitialItems] = useState<DailyReportItem[]>([])
89
+ const DailyReportPageInner = ({ user, userId }: Omit<DailyReportPageProps, "config">) => {
90
+ const { apiBasePath } = useDailyReportConfig()
91
+ const stream = useDailyReportIdsStream()
112
92
 
113
- const handleResolvedItems = useCallback((items: DailyReportItem[]) => {
114
- setResolvedInitialItems(items)
115
- }, [])
93
+ /**
94
+ * ストリームセッション確立の保険。
95
+ * 通常は clientLoader が先に確立するが、clientLoader を経ないマウントや
96
+ * ユーザー切替 (userKey 不一致でセッション再生成) をここで拾う。
97
+ * 依存配列: apiBasePath / userId (いずれかの変化で再確認)。
98
+ * クリーンアップ: なし (セッションはモジュール常駐で、アンマウント後も生存させる)。
99
+ */
100
+ useEffect(() => {
101
+ ensureDailyReportIdsStreamSession({ apiBasePath, userKey: userId ?? null })
102
+ }, [apiBasePath, userId])
103
+
104
+ /**
105
+ * 認証切れ (ストリームが 401/403) はドキュメントのフルリロードへ倒し、
106
+ * サーバー loader のログインリダイレクトに委譲する。
107
+ * 依存配列: stream.phase。クリーンアップ: なし。
108
+ */
109
+ useEffect(() => {
110
+ if (stream.phase === "auth-required") {
111
+ reloadDocument()
112
+ }
113
+ }, [stream.phase])
116
114
 
115
+ // 1 件でも届いたら (または空リストで完走したら) 本体を描画する
116
+ const showContent = stream.items.length > 0 || stream.phase === "complete"
117
117
  return (
118
- <DailyReportActionProvider user={user} initialItems={resolvedInitialItems} userId={userId}>
119
- <Suspense fallback={<DailyReportLoading />}>
120
- {/* Resolve で非同期取得済み ID を確定表示 */}
121
- {dailyReportIds ? (
122
- <Await resolve={dailyReportIds} errorElement={<DailyReportLoadError />}>
123
- {(resolvedItems) => {
124
- if (!resolvedItems) {
125
- throw new Error("Daily report ids resolve must not be null")
126
- }
127
- // 非 null を保証して以降の処理で利用
128
- return <DailyReportResolvedSync dailyReportItems={resolvedItems as DailyReportItem[]} userId={userId} onResolved={handleResolvedItems} />
129
- }}
130
- </Await>
131
- ) : (
132
- <DailyReportLoading />
133
- )}
134
- </Suspense>
118
+ // key でユーザーごとにプロバイダーをリマウントする。リロード無しのユーザー切替
119
+ // (再ログイン・成り代わり) で items / removedIds / 保留キュー等の状態が旧ユーザーの
120
+ // まま残ると、render-phase マージが旧ユーザーの行を新ユーザーの一覧へ持ち越す
121
+ <DailyReportActionProvider key={userId ?? "anonymous"} user={user} initialItems={stream.items} userId={userId}>
122
+ {showContent ? <DailyReportResolvedContent userId={userId} /> : stream.phase === "failed" ? <DailyReportLoadError /> : <DailyReportLoading />}
135
123
  </DailyReportActionProvider>
136
124
  )
137
125
  }