@aiquants/daily-report 0.8.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 (56) hide show
  1. package/dist/client.d.mts +76 -6
  2. package/dist/client.d.ts +76 -6
  3. package/dist/client.js +5 -5
  4. package/dist/client.js.map +1 -1
  5. package/dist/client.mjs +5 -5
  6. package/dist/client.mjs.map +1 -1
  7. package/dist/index.d.mts +40 -2
  8. package/dist/index.d.ts +40 -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 +13 -0
  14. package/dist/server.d.ts +13 -0
  15. package/dist/server.js +8 -7
  16. package/dist/server.js.map +1 -1
  17. package/dist/server.mjs +8 -7
  18. package/dist/server.mjs.map +1 -1
  19. package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
  20. package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
  21. package/dist/styles/daily-report.standalone.css +1 -1
  22. package/package.json +1 -1
  23. package/src/client/components/daily-report-comment-item.tsx +6 -1
  24. package/src/client/components/daily-report-detail-list.tsx +65 -34
  25. package/src/client/components/daily-report-edit-form.tsx +6 -0
  26. package/src/client/components/daily-report-list.tsx +88 -55
  27. package/src/client/components/daily-report-page.tsx +8 -3
  28. package/src/client/components/daily-report-resolved-content.tsx +5 -3
  29. package/src/client/components/report-views.spec.tsx +37 -2
  30. package/src/client/config-context.tsx +18 -0
  31. package/src/client/contexts/daily-report-action-context.spec.tsx +16 -0
  32. package/src/client/contexts/daily-report-action-context.tsx +75 -34
  33. package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
  34. package/src/client/contexts/daily-report-error-context.tsx +98 -0
  35. package/src/client/hooks/use-daily-report.spec.ts +50 -2
  36. package/src/client/hooks/use-daily-report.ts +30 -1
  37. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +64 -1
  38. package/src/client/streaming/daily-report-ids-stream-client.ts +80 -2
  39. package/src/client.ts +1 -0
  40. package/src/server/handlers.action.spec.ts +44 -0
  41. package/src/server/handlers.ids-stream.spec.ts +53 -1
  42. package/src/server/handlers.ts +81 -5
  43. package/src/server/service.spec.ts +83 -7
  44. package/src/server/service.ts +60 -3
  45. package/src/server/test-helpers/handlers-config.ts +9 -1
  46. package/src/server.spec.ts +49 -0
  47. package/src/server.ts +7 -0
  48. package/src/shared/business-date.spec.ts +22 -1
  49. package/src/shared/business-date.ts +20 -0
  50. package/src/shared/comment-adapter.spec.ts +38 -0
  51. package/src/shared/comment-adapter.ts +9 -7
  52. package/src/shared/ids-stream.spec.ts +65 -0
  53. package/src/shared/ids-stream.ts +47 -0
  54. package/src/shared/sse-schema.ts +5 -0
  55. package/src/shared/text-utils.spec.ts +42 -0
  56. package/src/shared/text-utils.ts +8 -0
@@ -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"
@@ -12,6 +12,7 @@ import { useEffect } from "react"
12
12
  import type { DailyReportUser } from "../../shared/types"
13
13
  import { type DailyReportClientConfigInput, DailyReportConfigProvider, useDailyReportConfig } from "../config-context"
14
14
  import { DailyReportActionProvider } from "../contexts/daily-report-action-context"
15
+ import { DailyReportErrorProvider } from "../contexts/daily-report-error-context"
15
16
  import { ensureDailyReportIdsStreamSession, reloadDocument, retryDailyReportIdsStream, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
16
17
  import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
17
18
  import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
@@ -118,9 +119,13 @@ const DailyReportPageInner = ({ user, userId }: Omit<DailyReportPageProps, "conf
118
119
  // key でユーザーごとにプロバイダーをリマウントする。リロード無しのユーザー切替
119
120
  // (再ログイン・成り代わり) で items / removedIds / 保留キュー等の状態が旧ユーザーの
120
121
  // まま残ると、render-phase マージが旧ユーザーの行を新ユーザーの一覧へ持ち越す
121
- <DailyReportActionProvider key={userId ?? "anonymous"} user={user} initialItems={stream.items} userId={userId}>
122
- {showContent ? <DailyReportResolvedContent userId={userId} /> : stream.phase === "failed" ? <DailyReportLoadError /> : <DailyReportLoading />}
123
- </DailyReportActionProvider>
122
+ // エラーサーフェスはユーザー切替のリマウント境界より外側 (バナーは切替を跨いで消えてよい)。
123
+ // アクションプロバイダーはこの notifyError を購読して失敗を可視化する
124
+ <DailyReportErrorProvider>
125
+ <DailyReportActionProvider key={userId ?? "anonymous"} user={user} initialItems={stream.items} userId={userId}>
126
+ {showContent ? <DailyReportResolvedContent userId={userId} /> : stream.phase === "failed" ? <DailyReportLoadError /> : <DailyReportLoading />}
127
+ </DailyReportActionProvider>
128
+ </DailyReportErrorProvider>
124
129
  )
125
130
  }
126
131
 
@@ -2,6 +2,7 @@ import { Plus, RefreshCw, RotateCw } from "lucide-react"
2
2
  import type { ReactNode } from "react"
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react"
4
4
  import { useSearchParams } from "react-router"
5
+ import { resolveTodayBusinessDateKey } from "../../shared/business-date"
5
6
  import { useDailyReportConfig } from "../config-context"
6
7
  import { useDailyReportActionContext } from "../contexts/daily-report-action-context"
7
8
  import { useAnimatedNumber } from "../hooks/use-animated-number"
@@ -82,8 +83,9 @@ export const DailyReportResolvedContent = ({ userId }: DailyReportResolvedConten
82
83
 
83
84
  const handleCreateReport = async () => {
84
85
  const businessDateParam = searchParams.get("businessDate")
85
- const today = new Date().toISOString().split("T")[0]
86
- const dateToUse = businessDateParam || today
86
+ // 「本日」はローカル暦日で決める。toISOString() の UTC 日付だと JST 深夜〜午前帯に
87
+ // 前日の営業日へ新規日報が登録されてしまう
88
+ const dateToUse = businessDateParam || resolveTodayBusinessDateKey()
87
89
  const newId = await createReport(dateToUse)
88
90
  setSelectedItemId(newId)
89
91
  }
@@ -153,7 +155,7 @@ export const DailyReportResolvedContent = ({ userId }: DailyReportResolvedConten
153
155
  </div>
154
156
  ),
155
157
  })}
156
- <div className="h-full bg-slate-50 dark:bg-slate-500">
158
+ <div className="h-full bg-slate-50 dark:bg-slate-950">
157
159
  <div className="flex h-full w-full max-w-6xl flex-1 flex-col p-1">
158
160
  <Tabs defaultValue="list" className="flex h-full flex-1 flex-col">
159
161
  <div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 p-1 transition-colors dark:bg-slate-900/70 dark:text-slate-200">
@@ -39,7 +39,7 @@ import { CONTENT_DRAG_POINTER_INPUTS, ITEM_VERTICAL_PADDING, TAP_SCROLL_CIRCLE_O
39
39
  type CapturedVirtualScrollProps = {
40
40
  testId?: string
41
41
  itemCount: number
42
- behaviorOptions?: { pointerDragInputs?: unknown; enablePointerDrag?: unknown }
42
+ behaviorOptions?: { pointerDragInputs?: unknown; enablePointerDrag?: unknown; enableKeyboardNavigation?: unknown }
43
43
  scrollBarOptions?: { tapScrollCircleOptions?: unknown; renderThumbOverlay?: (props: Record<string, unknown>) => ReactNode }
44
44
  getItemKey?: unknown
45
45
  getItemHeight?: unknown
@@ -48,6 +48,7 @@ type CapturedVirtualScrollProps = {
48
48
  onRangeChange?: unknown
49
49
  onScroll?: unknown
50
50
  contentInsets?: unknown
51
+ contentProps?: Record<string, unknown>
51
52
  }
52
53
 
53
54
  const captured: CapturedVirtualScrollProps[] = []
@@ -84,10 +85,13 @@ vi.mock("@aiquants/virtualscroll", async () => {
84
85
  onRangeChange: props.onRangeChange,
85
86
  onScroll: props.onScroll,
86
87
  contentInsets: props.contentInsets,
88
+ contentProps: props.contentProps,
87
89
  })
88
90
  useHandle(ref, () => ({ scrollTo: () => 0, scrollToIndex: () => {}, getScrollPosition: () => 0, getContentSize: () => 0, getViewportSize: () => 0, updateItemSize: () => {}, focusItemAtIndex: () => {} }))
91
+ // 本物は contentProps をコンテンツ要素 (行だけを所有する list) へ透過する。
92
+ // ロール階層 (list → listitem) を DOM で検査できるよう代役でも同じ口へ流す
89
93
  return (
90
- <div data-testid={props.testId}>
94
+ <div data-testid={props.testId} {...props.contentProps}>
91
95
  {Array.from({ length: props.itemCount }, (_, index) => (
92
96
  // biome-ignore lint/suspicious/noArrayIndexKey: 代役なので index キーで十分
93
97
  <div key={index}>{props.children(props.getItem(index), index)}</div>
@@ -338,3 +342,34 @@ describe("日報ビューの描画と結線", () => {
338
342
  expect(detailViews).toEqual(REPORT_DETAIL_VIEWS.map((view) => view.file).sort())
339
343
  })
340
344
  })
345
+
346
+ describe("日報ビューのアクセシビリティ (仮想化リストのセマンティクス)", () => {
347
+ it.each(VIEW_CASES)("%s は行だけを所有する list をコンテンツ要素へ置く", (_file, view) => {
348
+ // role="list" を「スクロールバーと兄弟の」コンテンツ要素へ置く (contentProps 経由)。
349
+ // ペイン以上の階層に置くと ScrollBar の role="scrollbar" が不許可の所有子になる
350
+ renderView(view.render())
351
+ const props = optionsOf(view.testId)
352
+ expect(props.contentProps?.role).toBe("list")
353
+ expect(typeof props.contentProps?.["aria-label"]).toBe("string")
354
+ expect((props.contentProps?.["aria-label"] as string).length).toBeGreaterThan(0)
355
+ })
356
+
357
+ it.each(VIEW_CASES)("%s は enableKeyboardNavigation を false にする (タブ順汚染と focus 奪取の防止)", (_file, view) => {
358
+ // 既定 true は行ラッパーへ tabIndex=-1 を付けてタブ順を汚し、pointerdown で
359
+ // 行へフォーカスを移す (★/既読タップで詳細ペインが黙って切り替わる不具合の原因)。
360
+ // false にすることで list/listitem のロール階層も有効になる
361
+ renderView(view.render())
362
+ expect(optionsOf(view.testId).behaviorOptions?.enableKeyboardNavigation).toBe(false)
363
+ })
364
+
365
+ it.each(VIEW_CASES)("%s は各行へ role=listitem と aria-posinset/aria-setsize を付ける", (_file, view) => {
366
+ // 仮想化で描画されるのは窓分だけなので、絶対位置 (posinset) と全件数 (setsize) を
367
+ // 各行に載せないと SR は「全 N 件中 M 番目」を読めない
368
+ const { container } = renderView(view.render())
369
+ const listItems = container.querySelectorAll('[role="listitem"]')
370
+ expect(listItems.length).toBe(1)
371
+ const row = listItems[0]
372
+ expect(row.getAttribute("aria-posinset")).toBe("1")
373
+ expect(row.getAttribute("aria-setsize")).toBe("1")
374
+ })
375
+ })
@@ -43,6 +43,19 @@ export type DailyReportFieldLabels = {
43
43
  relationsEmpty: string
44
44
  }
45
45
 
46
+ /**
47
+ * Mutation-failure notification payload (for a host-supplied error surface).
48
+ * ミューテーション失敗の通知情報 (ホストが独自の通知 UI を出すための口)。
49
+ */
50
+ export type DailyReportErrorInfo = {
51
+ /** 失敗した操作の種別 (create / update / publish / delete / addComment / deleteComment / toggleStar / toggleRead)。 */
52
+ operation: string
53
+ /** 表示用のメッセージ (パッケージ内蔵バナー用)。 */
54
+ message: string
55
+ /** 元例外 (診断用)。 */
56
+ cause?: unknown
57
+ }
58
+
46
59
  /** データソースごとのバッジ表示設定。 */
47
60
  export type SourceTypeConfig = {
48
61
  label: string
@@ -69,6 +82,11 @@ export type DailyReportClientConfig = {
69
82
  renderHeader: (props: DailyReportHeaderProps) => ReactNode
70
83
  /** 開発向けコントロール (SSE 購読トグル等) を表示するか。 */
71
84
  showDevControls: boolean
85
+ /**
86
+ * ミューテーション失敗時の通知ポート。未指定ならパッケージ内蔵の role="alert"
87
+ * バナーで表示する。ホストが独自トースト等を持つ場合はここへ委譲する。
88
+ */
89
+ onError?: (info: DailyReportErrorInfo) => void
72
90
  }
73
91
 
74
92
  /**
@@ -260,3 +260,19 @@ describe("遅延ミューテーション継続の門番 (ユーザー切替汚
260
260
  expect(getCachedReport(999, { ignoreExpiry: true })?.subject).toBe("USER_A confidential draft subject")
261
261
  })
262
262
  })
263
+
264
+ describe("initialItems の既定値 (無限レンダー防止)", () => {
265
+ it("initialItems を省略してもマウントできる (render-phase の再帰を起こさない)", async () => {
266
+ // 既定を `= []` にすると毎レンダーで新配列参照になり、`initialItems !== prevInitialItems`
267
+ // が常に真 → setState 無限ループで React が描画を中断していた。安定既定でこれを防ぐ
268
+ expect(() =>
269
+ render(
270
+ <DailyReportActionProvider user={{ id: "a", displayName: "User A" }}>
271
+ <ContextProbe />
272
+ </DailyReportActionProvider>,
273
+ ),
274
+ ).not.toThrow()
275
+ await waitFor(() => expect(contextRef.current).not.toBeNull())
276
+ expect(contextRef.current?.items).toEqual([])
277
+ })
278
+ })