@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.
- package/dist/client.d.mts +76 -6
- package/dist/client.d.ts +76 -6
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -5
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +40 -2
- package/dist/index.d.ts +40 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +13 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +8 -7
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +8 -7
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
- package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +1 -1
- package/src/client/components/daily-report-comment-item.tsx +6 -1
- package/src/client/components/daily-report-detail-list.tsx +65 -34
- package/src/client/components/daily-report-edit-form.tsx +6 -0
- package/src/client/components/daily-report-list.tsx +88 -55
- package/src/client/components/daily-report-page.tsx +8 -3
- package/src/client/components/daily-report-resolved-content.tsx +5 -3
- package/src/client/components/report-views.spec.tsx +37 -2
- package/src/client/config-context.tsx +18 -0
- package/src/client/contexts/daily-report-action-context.spec.tsx +16 -0
- package/src/client/contexts/daily-report-action-context.tsx +75 -34
- package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
- package/src/client/contexts/daily-report-error-context.tsx +98 -0
- package/src/client/hooks/use-daily-report.spec.ts +50 -2
- package/src/client/hooks/use-daily-report.ts +30 -1
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +64 -1
- package/src/client/streaming/daily-report-ids-stream-client.ts +80 -2
- package/src/client.ts +1 -0
- package/src/server/handlers.action.spec.ts +44 -0
- package/src/server/handlers.ids-stream.spec.ts +53 -1
- package/src/server/handlers.ts +81 -5
- package/src/server/service.spec.ts +83 -7
- package/src/server/service.ts +60 -3
- package/src/server/test-helpers/handlers-config.ts +9 -1
- package/src/server.spec.ts +49 -0
- package/src/server.ts +7 -0
- package/src/shared/business-date.spec.ts +22 -1
- package/src/shared/business-date.ts +20 -0
- package/src/shared/comment-adapter.spec.ts +38 -0
- package/src/shared/comment-adapter.ts +9 -7
- package/src/shared/ids-stream.spec.ts +65 -0
- package/src/shared/ids-stream.ts +47 -0
- package/src/shared/sse-schema.ts +5 -0
- package/src/shared/text-utils.spec.ts +42 -0
- 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,
|
|
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
|
-
*
|
|
289
|
-
*
|
|
287
|
+
* Clears the selection when the list becomes empty.
|
|
288
|
+
* 一覧が空になったとき選択を解除する副作用。
|
|
290
289
|
*
|
|
291
|
-
*
|
|
292
|
-
* Dependencies: [dailyReportItems, initialSelectedBusinessDate, initialSelectedReportHubId, selectedReportHubId, itemCount, onSelectItem]
|
|
293
|
-
* Cleanup: None.
|
|
290
|
+
* 依存配列: [itemCount, selectedReportHubId, onSelectItem]。クリーンアップ: なし。
|
|
294
291
|
*/
|
|
295
292
|
useEffect(() => {
|
|
296
|
-
if (!itemCount) {
|
|
297
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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-
|
|
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
|
+
})
|