@aiquants/daily-report 0.7.0 → 0.9.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 +438 -26
  3. package/dist/client.d.ts +438 -26
  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 +115 -3
  9. package/dist/index.d.ts +115 -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 +15 -0
  15. package/dist/server.d.ts +15 -0
  16. package/dist/server.js +9 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +9 -6
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
  21. package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
  22. package/dist/styles/daily-report.standalone.css +1 -1
  23. package/package.json +3 -3
  24. package/src/client/components/daily-report-comment-item.tsx +6 -1
  25. package/src/client/components/daily-report-detail-list.tsx +65 -34
  26. package/src/client/components/daily-report-edit-form.tsx +6 -0
  27. package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
  28. package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
  29. package/src/client/components/daily-report-list.tsx +88 -55
  30. package/src/client/components/daily-report-page.spec.tsx +119 -0
  31. package/src/client/components/daily-report-page.tsx +62 -69
  32. package/src/client/components/daily-report-resolved-content.tsx +36 -10
  33. package/src/client/components/report-views.spec.tsx +37 -2
  34. package/src/client/config-context.tsx +18 -0
  35. package/src/client/contexts/daily-report-action-context.spec.tsx +278 -0
  36. package/src/client/contexts/daily-report-action-context.tsx +203 -65
  37. package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
  38. package/src/client/contexts/daily-report-error-context.tsx +98 -0
  39. package/src/client/hooks/use-animated-number.spec.ts +103 -0
  40. package/src/client/hooks/use-animated-number.ts +69 -0
  41. package/src/client/hooks/use-daily-report.spec.ts +50 -2
  42. package/src/client/hooks/use-daily-report.ts +30 -1
  43. package/src/client/route-helpers.spec.ts +100 -0
  44. package/src/client/route-helpers.ts +20 -41
  45. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +704 -0
  46. package/src/client/streaming/daily-report-ids-stream-client.ts +677 -0
  47. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
  48. package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
  49. package/src/client.ts +5 -0
  50. package/src/index.ts +1 -0
  51. package/src/server/handlers.action.spec.ts +44 -0
  52. package/src/server/handlers.ids-stream.spec.ts +414 -0
  53. package/src/server/handlers.ts +324 -8
  54. package/src/server/service.spec.ts +83 -7
  55. package/src/server/service.ts +100 -3
  56. package/src/server/test-helpers/handlers-config.ts +48 -1
  57. package/src/server.spec.ts +49 -0
  58. package/src/server.ts +7 -0
  59. package/src/shared/business-date.spec.ts +22 -1
  60. package/src/shared/business-date.ts +20 -0
  61. package/src/shared/comment-adapter.spec.ts +38 -0
  62. package/src/shared/comment-adapter.ts +9 -7
  63. package/src/shared/ids-stream.spec.ts +65 -0
  64. package/src/shared/ids-stream.ts +139 -0
  65. package/src/shared/sse-schema.ts +5 -0
  66. package/src/shared/text-utils.spec.ts +42 -0
  67. package/src/shared/text-utils.ts +8 -0
@@ -34,6 +34,9 @@ export const DailyReportEditForm = ({ report, onCancel, onPublishSuccess }: Dail
34
34
  setIsSaving(true)
35
35
  try {
36
36
  await updateReport(report.reportHubId, { title, content }, report.date ?? "")
37
+ } catch {
38
+ // 失敗はアクションコンテキストが role="alert" バナー / onError で可視化する。
39
+ // ここで握るのは未処理 Promise 拒否を防ぐためだけ (成功時のみ後続へ進む)
37
40
  } finally {
38
41
  setIsSaving(false)
39
42
  }
@@ -45,7 +48,10 @@ export const DailyReportEditForm = ({ report, onCancel, onPublishSuccess }: Dail
45
48
  // Ensure latest changes are saved before publishing
46
49
  await updateReport(report.reportHubId, { title, content }, report.date ?? "")
47
50
  await publishReport(report.reportHubId, report.date ?? "")
51
+ // 保存・公開の両方が成功したときだけ成功コールバックを呼ぶ
48
52
  onPublishSuccess?.()
53
+ } catch {
54
+ // 失敗はアクションコンテキストが可視化する。onPublishSuccess は呼ばない
49
55
  } finally {
50
56
  setIsSaving(false)
51
57
  }
@@ -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
+ }
@@ -5,7 +5,7 @@
5
5
  import { SwipeCloseGuardContext, useSwipeGuardState, useSwipeToDismissOverlay, useTapClick, useTransitionDisplay } from "@aiquants/swipe-overlay"
6
6
  import { type ScrollBarThumbOverlayRenderProps, VirtualScroll, type VirtualScrollHandle } from "@aiquants/virtualscroll"
7
7
  import { Check, Star, Trash2 } from "lucide-react"
8
- import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"
8
+ import { type CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
9
9
  import { fallbackText, formatToIsoDate } from "../../shared/text-utils"
10
10
  import type { DailyReportDetail, DailyReportItem } from "../../shared/types"
11
11
  import { resolveSourceTypeConfig, useDailyReportConfig } from "../config-context"
@@ -45,8 +45,6 @@ type TestableVirtualScrollHandle = VirtualScrollHandle & {
45
45
 
46
46
  type DailyReportListProps = {
47
47
  dailyReportItems: DailyReportItem[]
48
- initialSelectedBusinessDate?: string | null
49
- initialSelectedReportHubId?: number | null
50
48
  autoMarkRead?: boolean
51
49
  selectedReportHubId: number | null
52
50
  onSelectItem: (reportHubId: number | null) => void
@@ -234,15 +232,16 @@ const useAutoRead = (report: DailyReportDetail | null, isLoading: boolean, autoM
234
232
  * Daily report list cards with key metadata and preview content via virtual scrolling.
235
233
  * 主なメタデータと内容プレビューを仮想スクロール表示する日報リスト。
236
234
  */
237
- export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate = null, initialSelectedReportHubId = null, autoMarkRead = false, selectedReportHubId, onSelectItem, userId, initialScrollOffset, onScrollOffsetChange }: DailyReportListProps) => {
235
+ export const DailyReportList = ({ dailyReportItems, autoMarkRead = false, selectedReportHubId, onSelectItem, userId, initialScrollOffset, onScrollOffsetChange }: DailyReportListProps) => {
238
236
  const scrollContainerRef = useRef<HTMLDivElement | null>(null)
239
237
  const virtualScrollRef = useRef<VirtualScrollHandle>(null)
238
+ // list ロールの aria-label に使う (消費側が注入する業務ドメイン名。既定 "Daily Reports")
239
+ const { title } = useDailyReportConfig()
240
240
  const { viewportHeight, updateViewportHeight } = useDynamicViewportHeight(scrollContainerRef)
241
241
  const [overlayLabel, setOverlayLabel] = useState<string>("")
242
242
  const [isWheelScrollActive, setIsWheelScrollActive] = useState(false)
243
243
  const isMobileLayout = useIsMobileLayout()
244
244
  const wheelResetTimerRef = useRef<number | null>(null)
245
- const appliedInitialSignatureRef = useRef<string | null>(null)
246
245
  const itemCount = dailyReportItems.length
247
246
  const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
248
247
 
@@ -285,34 +284,16 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
285
284
  }, [updateViewportHeight])
286
285
 
287
286
  /**
288
- * Synchronizes initial selection from props when data changes.
289
- * データ変更時にプロパティからの初期選択状態を同期する副作用。
287
+ * Clears the selection when the list becomes empty.
288
+ * 一覧が空になったとき選択を解除する副作用。
290
289
  *
291
- * Purpose: To respect deep-linked selection (ID or Date) when items are loaded.
292
- * Dependencies: [dailyReportItems, initialSelectedBusinessDate, initialSelectedReportHubId, selectedReportHubId, itemCount, onSelectItem]
293
- * Cleanup: None.
290
+ * 依存配列: [itemCount, selectedReportHubId, onSelectItem]。クリーンアップ: なし。
294
291
  */
295
292
  useEffect(() => {
296
- if (!itemCount) {
297
- appliedInitialSignatureRef.current = null
298
- if (selectedReportHubId !== null) onSelectItem(null)
299
- return
300
- }
301
-
302
- const signature = `${initialSelectedReportHubId ?? ""}|${initialSelectedBusinessDate ?? ""}`
303
- const hasInitial = (initialSelectedReportHubId ?? null) !== null || (initialSelectedBusinessDate ?? null) !== null
304
-
305
- if (!hasInitial) appliedInitialSignatureRef.current = null
306
-
307
- if (hasInitial && appliedInitialSignatureRef.current !== signature) {
308
- const match = (initialSelectedReportHubId && dailyReportItems.find((i) => i.reportHubId === initialSelectedReportHubId)) || (initialSelectedBusinessDate && dailyReportItems.find((i) => i.businessDate === initialSelectedBusinessDate))
309
- if (match) {
310
- appliedInitialSignatureRef.current = signature
311
- if (selectedReportHubId !== match.reportHubId) onSelectItem(match.reportHubId)
312
- return
313
- }
293
+ if (!itemCount && selectedReportHubId !== null) {
294
+ onSelectItem(null)
314
295
  }
315
- }, [dailyReportItems, initialSelectedBusinessDate, initialSelectedReportHubId, selectedReportHubId, itemCount, onSelectItem])
296
+ }, [itemCount, selectedReportHubId, onSelectItem])
316
297
 
317
298
  /**
318
299
  * Restores focus after closing overlay (Desktop only).
@@ -329,7 +310,15 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
329
310
 
330
311
  const index = dailyReportItems.findIndex((item) => item.reportHubId === targetId)
331
312
  if (index >= 0) {
332
- requestAnimationFrame(() => virtualScrollRef.current?.focusItemAtIndex(index))
313
+ // スクロールで行を DOM に入れてから、カード本体 (tabIndex=0) へフォーカスを戻す。
314
+ // focusItemAtIndex は enableKeyboardNavigation:false では no-op のため使えない
315
+ // (行ラッパーではなくカードへ直接フォーカスするのが本来の意図でもある)
316
+ virtualScrollRef.current?.scrollToIndex(index)
317
+ requestAnimationFrame(() => {
318
+ // detail-list の行も同じ data 属性を持つため、一覧側スコープで限定する
319
+ const card = document.querySelector<HTMLElement>(`[data-testid="daily-report-list"] [data-daily-report-button="${targetId}"]`)
320
+ card?.focus()
321
+ })
333
322
  }
334
323
  }, [isMobileDetailOpen, isMobileLayout, dailyReportItems, lastClosedItemIdRef])
335
324
 
@@ -377,6 +366,13 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
377
366
  const getItem = useCallback((index: number) => (index < itemCount ? dailyReportItems[index] : EMPTY_ITEM), [dailyReportItems, itemCount])
378
367
  const getItemHeight = useCallback(() => DEFAULT_ITEM_HEIGHT, [])
379
368
 
369
+ // 行レンダラーを安定参照にする。インライン矢印だと毎レンダー新関数になり、
370
+ // React.memo の ListReportItem が全行で再レンダーへ落ちる (選択変更で全行が再描画)
371
+ const renderListRow = useCallback(
372
+ (item: DailyReportItem, index: number) => <ListReportItem key={item.reportHubId} item={item} index={index} setSize={itemCount} isActive={item.reportHubId === selectedReportHubId} onSelect={handleItemSelect} />,
373
+ [itemCount, selectedReportHubId, handleItemSelect],
374
+ )
375
+
380
376
  const handleRangeChange = useCallback(
381
377
  (range: { visibleStartIndex: number }) => {
382
378
  const item = getItem(range.visibleStartIndex)
@@ -389,17 +385,6 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
389
385
  [getItem],
390
386
  )
391
387
 
392
- const handleItemFocus = useCallback(
393
- (index: number) => {
394
- const item = getItem(index)
395
- if (item && item.reportHubId > 0) {
396
- if (item.reportHubId === selectedReportHubId) return
397
- onSelectItem(item.reportHubId)
398
- }
399
- },
400
- [getItem, onSelectItem, selectedReportHubId],
401
- )
402
-
403
388
  const handleScroll = useCallback(() => {
404
389
  if (virtualScrollRef.current && onScrollOffsetChange) {
405
390
  onScrollOffsetChange(virtualScrollRef.current.getScrollPosition())
@@ -457,14 +442,25 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
457
442
  pointerDragInputs: CONTENT_DRAG_POINTER_INPUTS,
458
443
  inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
459
444
  wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
445
+ // 行ラッパーへの tabIndex 付与 (タブ順汚染) と pointerdown フォーカス移動を止める。
446
+ // 「★/既読タップで詳細ペインが切り替わる」不具合は 2 経路あり、これはその 1 つ目
447
+ // (focus 経由)。2 つ目 (useTapClick の pointerup バブリング) はボタン側の
448
+ // stopPointerPropagation が塞ぐ。onItemFocus は発火しなくなる
449
+ enableKeyboardNavigation: false,
460
450
  }}
461
451
  onRangeChange={handleRangeChange}
462
452
  onScroll={handleScroll}
463
- onItemFocus={handleItemFocus}
464
453
  contentInsets={{ top: ITEM_VERTICAL_PADDING, bottom: ITEM_VERTICAL_PADDING }}
465
454
  testId="daily-report-list"
466
- className="relative w-full">
467
- {(item) => <ListReportItem key={item.reportHubId} item={item} isActive={item.reportHubId === selectedReportHubId} onSelect={handleItemSelect} />}
455
+ className="relative w-full"
456
+ contentProps={{
457
+ // 行 (role="option" ではなく role="listitem") だけを所有する list を、
458
+ // スクロールバーと兄弟のコンテンツ要素に置く (virtualscroll 1.23.0 の口)。
459
+ // 各行が aria-posinset/aria-setsize を持つので SR は「全 N 件中 M 番目」を読める
460
+ role: "list",
461
+ "aria-label": title,
462
+ }}>
463
+ {renderListRow}
468
464
  </VirtualScroll>
469
465
  </div>
470
466
  <DailyReportSidePane dailyReportItems={dailyReportItems} selectedItem={selectedItem} heightStyle={columnHeightStyle} autoMarkRead={!isMobileLayout && autoMarkRead} isActive={selectedReportHubId !== null} userId={userId} />
@@ -482,9 +478,12 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
482
478
  /**
483
479
  * Loading skeleton representation for daily report list items.
484
480
  * 日報リストアイテム用読み込み中スケルトン表示。
481
+ *
482
+ * `wrapperProps` は仮想リストの位置属性 (role="listitem" / aria-posinset / aria-setsize) を
483
+ * 外側ラッパーへ透過するための口。読み込み中の行も一覧内の位置を SR へ公開する。
485
484
  */
486
- const ListSkeletonItem = () => (
487
- <div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
485
+ const ListSkeletonItem = (wrapperProps: React.HTMLAttributes<HTMLDivElement> & { "aria-posinset"?: number; "aria-setsize"?: number }) => (
486
+ <div {...wrapperProps} className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
488
487
  <div data-skeleton className="flex h-full animate-pulse flex-col justify-between rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
489
488
  <div className="mb-3 flex flex-wrap items-center gap-3">
490
489
  <div className="h-4 w-24 rounded bg-slate-200"></div>
@@ -657,6 +656,15 @@ const DailyReportSidePane = ({
657
656
  )
658
657
  }
659
658
 
659
+ /**
660
+ * Skeleton line placeholder (module-scope so it is not re-created every render).
661
+ * スケルトンの 1 行プレースホルダー (レンダーごとに再生成しないようモジュールスコープに置く)。
662
+ *
663
+ * 以前は DailyReportDisplayPane の render 本体で宣言していたため、レンダーのたびに
664
+ * 別コンポーネント型となり全スケルトンが再マウントしていた。
665
+ */
666
+ const SkeletonText = ({ className = "w-32" }: { className?: string }) => <div data-skeleton className={`h-4 animate-pulse rounded bg-slate-200 dark:bg-slate-700 ${className}`} />
667
+
660
668
  /**
661
669
  * Shows the selected report details in the display tab, including content and metadata.
662
670
  * 表示タブ内で選択した日報の詳細とメタデータを表示するコンポーネント。
@@ -756,8 +764,6 @@ const DailyReportDisplayPane = ({
756
764
  const isMine = !!(userId && report?.userId === userId)
757
765
  const hasComments = uiComments.length > 0
758
766
 
759
- const SkeletonText = ({ className = "w-32" }: { className?: string }) => <div data-skeleton className={`h-4 animate-pulse rounded bg-slate-200 dark:bg-slate-700 ${className}`} />
760
-
761
767
  return (
762
768
  // biome-ignore lint/a11y/noStaticElementInteractions: Double click to edit feature
763
769
  <div className="relative space-y-4 text-slate-700 dark:text-slate-200" onDoubleClick={onDoubleClick}>
@@ -938,10 +944,13 @@ const extractPreviewLines = (content: string | null): string[] => {
938
944
  * Daily report summary card rendered inside the list tab.
939
945
  * List タブ内で表示する日報サマリーカード。
940
946
  */
941
- const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; isActive: boolean; onSelect: (id: number) => void }) => {
947
+ const ListReportItem = memo(({ item, index, setSize, isActive, onSelect }: { item: DailyReportItem; index: number; setSize: number; isActive: boolean; onSelect: (id: number) => void }) => {
942
948
  const { fieldLabels, sourceTypeConfigs } = useDailyReportConfig()
943
949
  const { toggleStar, toggleRead, removeStaleItem } = useDailyReportActionContext()
944
950
  const { report, error, isLoading, isRefetching } = useDailyReportDetail(item.reportHubId, item.businessDate)
951
+ // list 内の絶対位置を SR へ公開する (仮想化で描画されるのは窓分だけなので必須)。
952
+ // どの表示状態 (読み込み中・エラー・本体) でも同じ位置属性を載せる
953
+ const listItemProps = { role: "listitem", "aria-posinset": index + 1, "aria-setsize": setSize } as const
945
954
 
946
955
  // フェッチが一度でも試行されたかを追跡する ref
947
956
  const hasEverFetched = useRef(false)
@@ -959,19 +968,19 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
959
968
 
960
969
  if (error) {
961
970
  return (
962
- <div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
963
- <div className="flex h-full items-center justify-center rounded-2xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm">
971
+ <div {...listItemProps} className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
972
+ <div className="flex h-full items-center justify-center rounded-2xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-300">
964
973
  <p>日報 {item.reportHubId.toLocaleString()} の概要読み込みに失敗しました。</p>
965
974
  </div>
966
975
  </div>
967
976
  )
968
977
  }
969
978
 
970
- if (isLoading) return <ListSkeletonItem />
979
+ if (isLoading) return <ListSkeletonItem {...listItemProps} />
971
980
 
972
981
  if (!report) {
973
982
  return (
974
- <div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
983
+ <div {...listItemProps} className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
975
984
  <div className="flex h-full items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 p-4 text-slate-500 text-sm dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-400">
976
985
  <p>日報が見つかりません</p>
977
986
  </div>
@@ -1006,8 +1015,19 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
1006
1015
  }
1007
1016
  }
1008
1017
 
1018
+ /**
1019
+ * Keeps card selection from firing when an inner action button is tapped.
1020
+ * カード内のアクションボタン操作でカード選択を発火させないための伝播停止。
1021
+ *
1022
+ * カードの選択は useTapClick (pointerdown/pointerup 駆動) なので、click の
1023
+ * stopPropagation だけでは止まらない。pointer イベント自体を止める必要がある。
1024
+ */
1025
+ const stopPointerPropagation = (e: React.PointerEvent) => {
1026
+ e.stopPropagation()
1027
+ }
1028
+
1009
1029
  return (
1010
- <div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
1030
+ <div {...listItemProps} className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
1011
1031
  {/* biome-ignore lint/a11y/useSemanticElements: Card contains nested interactive elements (Star/Read buttons) */}
1012
1032
  <div
1013
1033
  data-daily-report-button={item.reportHubId}
@@ -1017,7 +1037,11 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
1017
1037
  style={{ touchAction: "manipulation" }}
1018
1038
  role="button"
1019
1039
  tabIndex={0}
1040
+ // aria-current が「一覧の現在項目」の意味論を担う。aria-pressed は
1041
+ // 消費アプリの E2E ヘルパーが選択判定に使う既存契約のため併記を維持する
1042
+ aria-current={isActive ? "true" : undefined}
1020
1043
  aria-pressed={isActive}
1044
+ aria-label={`${date} ${creator}`}
1021
1045
  {...tapHandlers}>
1022
1046
  <div className="mb-3 flex items-center gap-3 text-slate-600 text-sm">
1023
1047
  <span className="inline-flex shrink-0 items-center rounded-full bg-slate-100 px-3 py-1 font-semibold text-xs dark:bg-slate-800 dark:text-slate-200">
@@ -1038,16 +1062,24 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
1038
1062
  <button
1039
1063
  type="button"
1040
1064
  title={report.isStarred ? "スターを外す" : "スターを付ける"}
1065
+ aria-label={report.isStarred ? "スターを外す" : "スターを付ける"}
1066
+ aria-pressed={report.isStarred}
1041
1067
  data-list-star-button={item.reportHubId}
1042
1068
  onClick={handleToggleStar}
1069
+ onPointerDown={stopPointerPropagation}
1070
+ onPointerUp={stopPointerPropagation}
1043
1071
  className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-slate-200 dark:hover:bg-slate-700">
1044
1072
  {report.isStarred ? <Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" /> : <Star className="h-3.5 w-3.5 text-slate-300 dark:text-slate-500" />}
1045
1073
  </button>
1046
1074
  <button
1047
1075
  type="button"
1048
1076
  title={report.isRead ? "未読にする" : "既読にする"}
1077
+ aria-label={report.isRead ? "未読にする" : "既読にする"}
1078
+ aria-pressed={report.isRead}
1049
1079
  data-list-read-button={item.reportHubId}
1050
1080
  onClick={handleToggleRead}
1081
+ onPointerDown={stopPointerPropagation}
1082
+ onPointerUp={stopPointerPropagation}
1051
1083
  className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-slate-200 dark:hover:bg-slate-700">
1052
1084
  {report.isRead ? <Check className="h-3.5 w-3.5 text-blue-500" /> : <UnreadIndicator className="text-xs" />}
1053
1085
  </button>
@@ -1067,4 +1099,5 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
1067
1099
  </div>
1068
1100
  </div>
1069
1101
  )
1070
- }
1102
+ })
1103
+ ListReportItem.displayName = "ListReportItem"
@@ -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
+ })