@aiquants/daily-report 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/dist/client.d.mts +443 -23
- package/dist/client.d.ts +443 -23
- package/dist/client.js +5 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +77 -3
- package/dist/index.d.ts +77 -3
- 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 +436 -6
- package/dist/server.d.ts +436 -6
- package/dist/server.js +8 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +8 -6
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
- package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
- package/dist/styles/daily-report.standalone.css +1 -1
- package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
- package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
- package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
- package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
- package/src/client/components/daily-report-attachment-list.tsx +112 -0
- package/src/client/components/daily-report-detail-list.tsx +14 -7
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
- package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
- package/src/client/components/daily-report-list.tsx +19 -11
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +56 -68
- package/src/client/components/daily-report-resolved-content.tsx +31 -7
- package/src/client/components/report-views.spec.tsx +340 -0
- package/src/client/config-context.tsx +8 -0
- package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
- package/src/client/contexts/daily-report-action-context.tsx +151 -52
- package/src/client/hooks/use-animated-number.spec.ts +103 -0
- package/src/client/hooks/use-animated-number.ts +69 -0
- package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
- package/src/client/hooks/use-responsive-layout.ts +56 -0
- package/src/client/route-helpers.spec.ts +100 -0
- package/src/client/route-helpers.ts +20 -41
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
- package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
- package/src/client/utils/constants.spec.ts +96 -0
- package/src/client/utils/constants.ts +47 -2
- package/src/client.ts +6 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.attachment.spec.ts +470 -0
- package/src/server/handlers.ids-stream.spec.ts +362 -0
- package/src/server/handlers.ts +671 -6
- package/src/server/ports.ts +74 -0
- package/src/server/schema.ts +80 -5
- package/src/server/service.spec.ts +280 -14
- package/src/server/service.ts +348 -25
- package/src/server/test-helpers/handlers-config.ts +181 -0
- package/src/server.ts +16 -1
- package/src/shared/ids-stream.ts +92 -0
- package/src/shared/sse-schema.spec.ts +50 -0
- package/src/shared/sse-schema.ts +27 -0
- package/src/shared/types.ts +31 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* useFetcher ベースのデータフックで日報詳細を提供する仮想スクロールビュー。
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { type
|
|
6
|
+
import { type ScrollBarThumbOverlayRenderProps, VirtualScroll, type VirtualScrollHandle } from "@aiquants/virtualscroll"
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Handle type augmented with E2E test helper methods.
|
|
@@ -25,8 +25,10 @@ import { useDailyReportDetail, useDailyReportPrefetch } from "../hooks/use-daily
|
|
|
25
25
|
import { useDailyReportComments } from "../hooks/use-daily-report-comments"
|
|
26
26
|
import { useDynamicViewportHeight } from "../hooks/use-dynamic-viewport-height"
|
|
27
27
|
import { cn } from "../ui/cn"
|
|
28
|
-
import { EMPTY_ITEM, FAST_SCROLL_INERTIA_OPTIONS, FAST_SCROLL_WHEEL_MULTIPLIER, PREFETCH_LOOKAHEAD, PREFETCH_MAX_DATES, VIRTUAL_SCROLL_OVERSCAN_COUNT } from "../utils/constants"
|
|
28
|
+
import { CONTENT_DRAG_POINTER_INPUTS, EMPTY_ITEM, FAST_SCROLL_INERTIA_OPTIONS, FAST_SCROLL_WHEEL_MULTIPLIER, PREFETCH_LOOKAHEAD, PREFETCH_MAX_DATES, SCROLL_BAR_WIDTH, TAP_SCROLL_CIRCLE_OPTIONS, VIRTUAL_SCROLL_OVERSCAN_COUNT, WHEEL_RESET_TIMEOUT_MS } from "../utils/constants"
|
|
29
29
|
import { BusinessDayThumbOverlay } from "./business-day-thumb-overlay"
|
|
30
|
+
import { DailyReportAttachmentIndicator } from "./daily-report-attachment-indicator"
|
|
31
|
+
import { DailyReportAttachmentList } from "./daily-report-attachment-list"
|
|
30
32
|
import { DailyReportCommentForm, DailyReportCommentList } from "./daily-report-comment-section"
|
|
31
33
|
import { DailyReportEditForm } from "./daily-report-edit-form"
|
|
32
34
|
import { UnreadIndicator } from "./unread-indicator"
|
|
@@ -230,7 +232,7 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
|
|
|
230
232
|
wheelResetTimerRef.current = window.setTimeout(() => {
|
|
231
233
|
setIsWheelScrollActive(false)
|
|
232
234
|
wheelResetTimerRef.current = null
|
|
233
|
-
},
|
|
235
|
+
}, WHEEL_RESET_TIMEOUT_MS)
|
|
234
236
|
}
|
|
235
237
|
el.addEventListener("wheel", onWheel, { passive: true })
|
|
236
238
|
return () => {
|
|
@@ -270,8 +272,6 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
|
|
|
270
272
|
[getItem],
|
|
271
273
|
)
|
|
272
274
|
|
|
273
|
-
const tapScrollCircleOptions = useMemo<ScrollBarTapCircleOptions>(() => ({ enabled: true, renderVisual: tapScrollCircleSampleVisual }), [])
|
|
274
|
-
|
|
275
275
|
/**
|
|
276
276
|
* Updates viewport height when item count changes.
|
|
277
277
|
* アイテム数変更時にビューポート高さを更新する。
|
|
@@ -311,11 +311,14 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
|
|
|
311
311
|
initialScrollOffset={initialScrollOffset}
|
|
312
312
|
overscanCount={VIRTUAL_SCROLL_OVERSCAN_COUNT}
|
|
313
313
|
scrollBarOptions={{
|
|
314
|
-
width:
|
|
315
|
-
tapScrollCircleOptions:
|
|
314
|
+
width: SCROLL_BAR_WIDTH,
|
|
315
|
+
tapScrollCircleOptions: TAP_SCROLL_CIRCLE_OPTIONS,
|
|
316
316
|
renderThumbOverlay: renderThumbOverlay,
|
|
317
317
|
}}
|
|
318
318
|
behaviorOptions={{
|
|
319
|
+
// ドラッグスクロールは無効化せず、**ポインタ種別で絞る**。
|
|
320
|
+
// 丸ごと切るとタッチ端末からスクロール手段が消える (定数の注記参照)
|
|
321
|
+
pointerDragInputs: CONTENT_DRAG_POINTER_INPUTS,
|
|
319
322
|
wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
|
|
320
323
|
inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
|
|
321
324
|
}}
|
|
@@ -472,6 +475,9 @@ const DailyReportHeader = ({
|
|
|
472
475
|
const { sourceTypeConfigs } = useDailyReportConfig()
|
|
473
476
|
return (
|
|
474
477
|
<div className="flex flex-wrap items-center gap-2 font-medium text-slate-600 text-xs">
|
|
478
|
+
{/* 添付の有無を一目判別できるようにする。仕様書が定める
|
|
479
|
+
[sourceType] [Star] [Read] の相対順は崩さず、その手前に置く */}
|
|
480
|
+
<DailyReportAttachmentIndicator count={report.attachments.length} label={fieldLabels.attachments} />
|
|
475
481
|
{(() => {
|
|
476
482
|
const sourceType = itemSourceType ?? report?.sourceType
|
|
477
483
|
const sourceConfig = resolveSourceTypeConfig(sourceType, sourceTypeConfigs)
|
|
@@ -673,6 +679,7 @@ const DailyReportResolvedItem = ({ item, report, onCommentAdded, userId, onDoubl
|
|
|
673
679
|
</ul>
|
|
674
680
|
</div>
|
|
675
681
|
)}
|
|
682
|
+
<DailyReportAttachmentList attachments={report.attachments} />
|
|
676
683
|
{uiComments.length > 0 && (
|
|
677
684
|
<div className="flex flex-col gap-2">
|
|
678
685
|
<span className="font-semibold text-slate-500 text-xs uppercase tracking-wide">{fieldLabels.comments}</span>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-phase rendering tests for the ids stream status badge.
|
|
3
|
+
* ids ストリーム状態バッジの局面別描画テスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定する不変条件のうち、特に重要なもの:
|
|
6
|
+
*
|
|
7
|
+
* - **進捗率は 0-100 にクランプされ、total 0/null では表示されない** (0 除算・>100% 防止)。
|
|
8
|
+
* - **failed バッジの再試行ボタンはセッション retry へ直結する。**
|
|
9
|
+
* - **idle / 完走済み (再検証なし) では何も描画しない** (ヘッダーを汚さない)。
|
|
10
|
+
*/
|
|
11
|
+
import { fireEvent, render, screen } from "@testing-library/react"
|
|
12
|
+
import { describe, expect, it, vi } from "vitest"
|
|
13
|
+
import type { DailyReportIdsStreamState } from "../streaming/daily-report-ids-stream-client"
|
|
14
|
+
import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
|
|
15
|
+
|
|
16
|
+
let mockStreamState: DailyReportIdsStreamState
|
|
17
|
+
const retryMock = vi.fn()
|
|
18
|
+
|
|
19
|
+
vi.mock("../streaming/daily-report-ids-stream-session", () => ({
|
|
20
|
+
useDailyReportIdsStream: () => mockStreamState,
|
|
21
|
+
retryDailyReportIdsStream: () => retryMock(),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
/** 状態を組み立ててバッジを描画する。 */
|
|
25
|
+
const renderStatus = (partial: Partial<DailyReportIdsStreamState>) => {
|
|
26
|
+
mockStreamState = {
|
|
27
|
+
items: [],
|
|
28
|
+
loadedCount: 0,
|
|
29
|
+
totalCount: null,
|
|
30
|
+
phase: "idle",
|
|
31
|
+
isRevalidating: false,
|
|
32
|
+
hasCompletedOnce: false,
|
|
33
|
+
completedAt: null,
|
|
34
|
+
error: null,
|
|
35
|
+
...partial,
|
|
36
|
+
}
|
|
37
|
+
return render(<DailyReportIdsStreamStatus />)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("DailyReportIdsStreamStatus", () => {
|
|
41
|
+
it("streaming + total あり: 進捗率付きピルを表示する", () => {
|
|
42
|
+
renderStatus({ phase: "streaming", loadedCount: 250, totalCount: 1000 })
|
|
43
|
+
const badge = screen.getByTestId("daily-report-ids-stream-status")
|
|
44
|
+
expect(badge).toHaveAttribute("data-phase", "streaming")
|
|
45
|
+
expect(badge.textContent).toContain("読み込み中")
|
|
46
|
+
expect(badge.textContent).toContain("(25%)")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("streaming + total 未着: 件数のみで率を出さない", () => {
|
|
50
|
+
renderStatus({ phase: "streaming", loadedCount: 42, totalCount: null })
|
|
51
|
+
const badge = screen.getByTestId("daily-report-ids-stream-status")
|
|
52
|
+
expect(badge.textContent).toContain("件")
|
|
53
|
+
expect(badge.textContent).not.toContain("%")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("streaming + total 0: 0 除算せず率を出さない", () => {
|
|
57
|
+
renderStatus({ phase: "streaming", loadedCount: 0, totalCount: 0 })
|
|
58
|
+
expect(screen.getByTestId("daily-report-ids-stream-status").textContent).not.toContain("%")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("loadedCount > total でも率は 100% にクランプされる", () => {
|
|
62
|
+
renderStatus({ phase: "streaming", loadedCount: 12, totalCount: 10 })
|
|
63
|
+
expect(screen.getByTestId("daily-report-ids-stream-status").textContent).toContain("(100%)")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("retrying: 受信済み件数付きの再接続表示", () => {
|
|
67
|
+
renderStatus({ phase: "retrying", loadedCount: 123 })
|
|
68
|
+
const badge = screen.getByTestId("daily-report-ids-stream-status")
|
|
69
|
+
expect(badge).toHaveAttribute("data-phase", "retrying")
|
|
70
|
+
expect(badge.textContent).toContain("再接続中")
|
|
71
|
+
expect(badge.textContent).toContain("123")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("failed: 再試行ボタンがセッション retry を呼ぶ", () => {
|
|
75
|
+
retryMock.mockClear()
|
|
76
|
+
renderStatus({ phase: "failed", error: new Error("x") })
|
|
77
|
+
expect(screen.getByTestId("daily-report-ids-stream-status")).toHaveAttribute("data-phase", "failed")
|
|
78
|
+
fireEvent.click(screen.getByTestId("daily-report-ids-stream-retry"))
|
|
79
|
+
expect(retryMock).toHaveBeenCalledTimes(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("complete + 再検証中: 控えめな最新化インジケーター", () => {
|
|
83
|
+
renderStatus({ phase: "complete", isRevalidating: true, hasCompletedOnce: true })
|
|
84
|
+
expect(screen.getByTestId("daily-report-ids-stream-status")).toHaveAttribute("data-phase", "revalidating")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("idle / complete (再検証なし) は何も描画しない", () => {
|
|
88
|
+
const { container: idleContainer } = renderStatus({ phase: "idle" })
|
|
89
|
+
expect(idleContainer.firstChild).toBeNull()
|
|
90
|
+
const { container: completeContainer } = renderStatus({ phase: "complete", hasCompletedOnce: true })
|
|
91
|
+
expect(completeContainer.firstChild).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header badge that visualizes the ids stream lifecycle.
|
|
3
|
+
* ID ストリームのライフサイクルをヘッダーへ可視化するバッジコンポーネント。
|
|
4
|
+
*
|
|
5
|
+
* 局面ごとの表示:
|
|
6
|
+
* - streaming: 進捗ピル「読み込み中 n / total (p%)」(total 未着なら件数のみ)
|
|
7
|
+
* - retrying: 「再接続中... (n 件受信済)」
|
|
8
|
+
* - failed: 失敗表示 + 再試行ボタン (受信済みデータは保持されている)
|
|
9
|
+
* - complete + 再検証中: 控えめな「最新化中」インジケーター
|
|
10
|
+
* - それ以外 (idle / complete): 何も描画しない
|
|
11
|
+
*/
|
|
12
|
+
import { RefreshCw } from "lucide-react"
|
|
13
|
+
import { useAnimatedNumber } from "../hooks/use-animated-number"
|
|
14
|
+
import { retryDailyReportIdsStream, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Renders the streaming progress / recovery badge for the ids stream.
|
|
18
|
+
* ids ストリームの進捗・復旧バッジを描画するコンポーネント。
|
|
19
|
+
*/
|
|
20
|
+
export const DailyReportIdsStreamStatus = () => {
|
|
21
|
+
const stream = useDailyReportIdsStream()
|
|
22
|
+
const animatedLoaded = useAnimatedNumber(stream.loadedCount)
|
|
23
|
+
const animatedTotal = useAnimatedNumber(stream.totalCount ?? 0)
|
|
24
|
+
|
|
25
|
+
if (stream.phase === "streaming") {
|
|
26
|
+
// total 到着前は件数のみ、到着後は分母と進捗率を添える
|
|
27
|
+
const percent = stream.totalCount && stream.totalCount > 0 ? Math.min(100, Math.round((stream.loadedCount / stream.totalCount) * 100)) : null
|
|
28
|
+
return (
|
|
29
|
+
<span data-testid="daily-report-ids-stream-status" data-phase="streaming" className="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-2 py-0.5 font-medium text-blue-800 text-xs tabular-nums dark:bg-blue-900/40 dark:text-blue-300">
|
|
30
|
+
<span aria-hidden="true" className="h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500 dark:bg-blue-400" />
|
|
31
|
+
読み込み中 {animatedLoaded.toLocaleString()}
|
|
32
|
+
{percent !== null ? ` / ${animatedTotal.toLocaleString()} (${percent}%)` : " 件"}
|
|
33
|
+
</span>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (stream.phase === "retrying") {
|
|
38
|
+
return (
|
|
39
|
+
<span role="status" data-testid="daily-report-ids-stream-status" data-phase="retrying" className="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-800 text-xs tabular-nums dark:bg-amber-900/40 dark:text-amber-300">
|
|
40
|
+
<RefreshCw aria-hidden="true" className="h-3 w-3 animate-spin" />
|
|
41
|
+
再接続中... ({stream.loadedCount.toLocaleString()} 件受信済)
|
|
42
|
+
</span>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (stream.phase === "failed") {
|
|
47
|
+
return (
|
|
48
|
+
<span role="status" data-testid="daily-report-ids-stream-status" data-phase="failed" className="inline-flex items-center gap-1.5 rounded-full bg-rose-100 px-2 py-0.5 font-medium text-rose-800 text-xs dark:bg-rose-900/40 dark:text-rose-300">
|
|
49
|
+
読み込みに失敗しました
|
|
50
|
+
<button type="button" data-testid="daily-report-ids-stream-retry" onClick={() => retryDailyReportIdsStream()} className="rounded bg-rose-200 px-1.5 py-0.5 font-semibold text-rose-900 hover:bg-rose-300 dark:bg-rose-800 dark:text-rose-100 dark:hover:bg-rose-700">
|
|
51
|
+
再試行
|
|
52
|
+
</button>
|
|
53
|
+
</span>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (stream.isRevalidating) {
|
|
58
|
+
return (
|
|
59
|
+
<span data-testid="daily-report-ids-stream-status" data-phase="revalidating" className="inline-flex items-center gap-1 text-slate-400 text-xs dark:text-slate-500">
|
|
60
|
+
<RefreshCw aria-hidden="true" className="h-3 w-3 animate-spin" />
|
|
61
|
+
最新化中
|
|
62
|
+
</span>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
@@ -13,9 +13,11 @@ import { useDailyReportActionContext } from "../contexts/daily-report-action-con
|
|
|
13
13
|
import { useDailyReportDetail } from "../hooks/use-daily-report"
|
|
14
14
|
import { useDailyReportComments } from "../hooks/use-daily-report-comments"
|
|
15
15
|
import { useDynamicViewportHeight } from "../hooks/use-dynamic-viewport-height"
|
|
16
|
+
import { useIsMobileLayout } from "../hooks/use-responsive-layout"
|
|
16
17
|
import { cn } from "../ui/cn"
|
|
17
18
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
|
18
19
|
import {
|
|
20
|
+
CONTENT_DRAG_POINTER_INPUTS,
|
|
19
21
|
DEFAULT_ITEM_HEIGHT,
|
|
20
22
|
EMPTY_DETAIL,
|
|
21
23
|
EMPTY_ITEM,
|
|
@@ -30,6 +32,8 @@ import {
|
|
|
30
32
|
WHEEL_RESET_TIMEOUT_MS,
|
|
31
33
|
} from "../utils/constants"
|
|
32
34
|
import { BusinessDayThumbOverlay } from "./business-day-thumb-overlay"
|
|
35
|
+
import { DailyReportAttachmentIndicator } from "./daily-report-attachment-indicator"
|
|
36
|
+
import { DailyReportAttachmentList } from "./daily-report-attachment-list"
|
|
33
37
|
import { DailyReportCommentForm, DailyReportCommentList } from "./daily-report-comment-section"
|
|
34
38
|
import { DailyReportEditForm } from "./daily-report-edit-form"
|
|
35
39
|
import { UnreadIndicator } from "./unread-indicator"
|
|
@@ -236,7 +240,7 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
236
240
|
const { viewportHeight, updateViewportHeight } = useDynamicViewportHeight(scrollContainerRef)
|
|
237
241
|
const [overlayLabel, setOverlayLabel] = useState<string>("")
|
|
238
242
|
const [isWheelScrollActive, setIsWheelScrollActive] = useState(false)
|
|
239
|
-
const
|
|
243
|
+
const isMobileLayout = useIsMobileLayout()
|
|
240
244
|
const wheelResetTimerRef = useRef<number | null>(null)
|
|
241
245
|
const appliedInitialSignatureRef = useRef<string | null>(null)
|
|
242
246
|
const itemCount = dailyReportItems.length
|
|
@@ -247,21 +251,17 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
247
251
|
const columnHeightStyle = useMemo<CSSProperties | undefined>(() => (Number.isFinite(viewportHeight) && viewportHeight > 0 ? { height: viewportHeight } : undefined), [viewportHeight])
|
|
248
252
|
|
|
249
253
|
/**
|
|
250
|
-
* Manages global event listeners (
|
|
251
|
-
*
|
|
254
|
+
* Manages global event listeners (Wheel, Resize).
|
|
255
|
+
* ホイール・リサイズのグローバルイベントリスナーを管理する副作用。
|
|
252
256
|
*
|
|
253
|
-
* Purpose: To
|
|
257
|
+
* Purpose: To keep the viewport height in sync and to detect wheel scrolling.
|
|
258
|
+
* (レスポンシブ判定は use-responsive-layout のフックが担当する。)
|
|
254
259
|
* Dependencies: [updateViewportHeight]
|
|
255
260
|
* Cleanup: Removes all event listeners and clears timers.
|
|
256
261
|
*/
|
|
257
262
|
useEffect(() => {
|
|
258
263
|
if (typeof window === "undefined") return
|
|
259
264
|
|
|
260
|
-
const mq = window.matchMedia("(max-width: 639px)")
|
|
261
|
-
const onMediaChange = (e: MediaQueryListEvent) => setIsMobileLayout(e.matches)
|
|
262
|
-
setIsMobileLayout(mq.matches)
|
|
263
|
-
mq.addEventListener("change", onMediaChange)
|
|
264
|
-
|
|
265
265
|
const onResize = () => requestAnimationFrame(updateViewportHeight)
|
|
266
266
|
window.addEventListener("resize", onResize)
|
|
267
267
|
updateViewportHeight()
|
|
@@ -278,7 +278,6 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
278
278
|
el?.addEventListener("wheel", onWheel, { passive: true })
|
|
279
279
|
|
|
280
280
|
return () => {
|
|
281
|
-
mq.removeEventListener("change", onMediaChange)
|
|
282
281
|
window.removeEventListener("resize", onResize)
|
|
283
282
|
el?.removeEventListener("wheel", onWheel)
|
|
284
283
|
if (wheelResetTimerRef.current) clearTimeout(wheelResetTimerRef.current)
|
|
@@ -453,6 +452,9 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
453
452
|
renderThumbOverlay: renderThumbOverlay,
|
|
454
453
|
}}
|
|
455
454
|
behaviorOptions={{
|
|
455
|
+
// ドラッグスクロールは無効化せず、**ポインタ種別で絞る**。
|
|
456
|
+
// 丸ごと切るとタッチ端末からスクロール手段が消える (定数の注記参照)
|
|
457
|
+
pointerDragInputs: CONTENT_DRAG_POINTER_INPUTS,
|
|
456
458
|
inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
|
|
457
459
|
wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
|
|
458
460
|
}}
|
|
@@ -811,6 +813,9 @@ const DailyReportDisplayPane = ({
|
|
|
811
813
|
)}
|
|
812
814
|
</div>
|
|
813
815
|
</div>
|
|
816
|
+
{/* 添付節。DetailList タブと同じ位置 (面談者・本文の後、コメントの前) に置く。
|
|
817
|
+
片方のタブにしか出さないと、利用者は「添付が無い日報」と誤認する */}
|
|
818
|
+
{report && <DailyReportAttachmentList attachments={report.attachments} />}
|
|
814
819
|
{report && (
|
|
815
820
|
<div className="fade-in animate-in space-y-2 border-slate-100 border-t pt-4 duration-500 dark:border-slate-800">
|
|
816
821
|
<div className="font-semibold text-slate-500 text-xs uppercase tracking-wide dark:text-slate-300">{fieldLabels.comments}</div>
|
|
@@ -934,7 +939,7 @@ const extractPreviewLines = (content: string | null): string[] => {
|
|
|
934
939
|
* List タブ内で表示する日報サマリーカード。
|
|
935
940
|
*/
|
|
936
941
|
const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; isActive: boolean; onSelect: (id: number) => void }) => {
|
|
937
|
-
const { sourceTypeConfigs } = useDailyReportConfig()
|
|
942
|
+
const { fieldLabels, sourceTypeConfigs } = useDailyReportConfig()
|
|
938
943
|
const { toggleStar, toggleRead, removeStaleItem } = useDailyReportActionContext()
|
|
939
944
|
const { report, error, isLoading, isRefetching } = useDailyReportDetail(item.reportHubId, item.businessDate)
|
|
940
945
|
|
|
@@ -1022,6 +1027,9 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
|
|
|
1022
1027
|
<span className="truncate font-medium text-slate-900 dark:text-slate-100">{creator}</span>
|
|
1023
1028
|
</span>
|
|
1024
1029
|
<div className="ml-auto flex items-center gap-1.5">
|
|
1030
|
+
{/* 添付の有無を一覧で一目判別できるようにする。
|
|
1031
|
+
仕様書が定める [sourceType] [Star] [Read] の相対順は崩さず、その手前に置く */}
|
|
1032
|
+
<DailyReportAttachmentIndicator count={report.attachments.length} label={fieldLabels.attachments} />
|
|
1025
1033
|
{(() => {
|
|
1026
1034
|
const sourceType = item.sourceType ?? report?.sourceType
|
|
1027
1035
|
const sourceConfig = resolveSourceTypeConfig(sourceType, sourceTypeConfigs)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase-gating tests for the stream-driven daily report page.
|
|
3
|
+
* ストリーム駆動日報ページの局面ゲーティングテスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定する不変条件のうち、特に重要なもの:
|
|
6
|
+
*
|
|
7
|
+
* - **最初のチャンク到着 (items > 0) で即座に本体を描画する** — 全件解決を待つ
|
|
8
|
+
* ゲートが存在しないことがストリーミング化の成果そのもの。
|
|
9
|
+
* - **failed でも受信済みアイテムがあれば本体を描画し続ける** (データを捨てない)。
|
|
10
|
+
* - **マウント時にセッションを ensure する** (clientLoader を経ないマウントの保険)。
|
|
11
|
+
* - **auth-required はフルリロードへ倒す** (サーバー loader のリダイレクトに委譲)。
|
|
12
|
+
*/
|
|
13
|
+
import { fireEvent, render, screen } from "@testing-library/react"
|
|
14
|
+
import { describe, expect, it, vi } from "vitest"
|
|
15
|
+
import type { DailyReportItem } from "../../shared/types"
|
|
16
|
+
import type { DailyReportIdsStreamState } from "../streaming/daily-report-ids-stream-client"
|
|
17
|
+
import { DailyReportPage } from "./daily-report-page"
|
|
18
|
+
|
|
19
|
+
/** テストが差し替えるストリーム状態 (各テスト冒頭で上書きする)。 */
|
|
20
|
+
let mockStreamState: DailyReportIdsStreamState = {
|
|
21
|
+
items: [],
|
|
22
|
+
loadedCount: 0,
|
|
23
|
+
totalCount: null,
|
|
24
|
+
phase: "idle",
|
|
25
|
+
isRevalidating: false,
|
|
26
|
+
hasCompletedOnce: false,
|
|
27
|
+
completedAt: null,
|
|
28
|
+
error: null,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ensureMock = vi.fn()
|
|
32
|
+
const retryMock = vi.fn()
|
|
33
|
+
const reloadMock = vi.fn()
|
|
34
|
+
|
|
35
|
+
vi.mock("../streaming/daily-report-ids-stream-session", () => ({
|
|
36
|
+
useDailyReportIdsStream: () => mockStreamState,
|
|
37
|
+
ensureDailyReportIdsStreamSession: (options: unknown) => ensureMock(options),
|
|
38
|
+
retryDailyReportIdsStream: () => retryMock(),
|
|
39
|
+
refreshDailyReportIdsStream: vi.fn(),
|
|
40
|
+
primeDailyReportIdsStreamSession: vi.fn(),
|
|
41
|
+
reloadDocument: () => reloadMock(),
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
/** 重量級の子孫 (仮想スクロール・SSE) はマーカーへ置換し、ゲーティングだけを検証する。 */
|
|
45
|
+
vi.mock("./daily-report-resolved-content", () => ({
|
|
46
|
+
DailyReportResolvedContent: () => <div data-testid="resolved-content" />,
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
/** provider は initialItems の受け取りを記録する透過スタブにする。 */
|
|
50
|
+
const providerItemsLog: DailyReportItem[][] = []
|
|
51
|
+
vi.mock("../contexts/daily-report-action-context", () => ({
|
|
52
|
+
DailyReportActionProvider: ({ children, initialItems }: { children: React.ReactNode; initialItems?: DailyReportItem[] }) => {
|
|
53
|
+
providerItemsLog.push(initialItems ?? [])
|
|
54
|
+
return <div>{children}</div>
|
|
55
|
+
},
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
/** 状態を部分上書きして描画するヘルパー。 */
|
|
59
|
+
const renderPage = (partial: Partial<DailyReportIdsStreamState>) => {
|
|
60
|
+
mockStreamState = { ...mockStreamState, items: [], loadedCount: 0, totalCount: null, phase: "idle", isRevalidating: false, hasCompletedOnce: false, completedAt: null, error: null, ...partial }
|
|
61
|
+
return render(<DailyReportPage userId="ENC8" />)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const twoItems: DailyReportItem[] = [
|
|
65
|
+
{ reportHubId: 2, businessDate: "2026-08-02" },
|
|
66
|
+
{ reportHubId: 1, businessDate: "2026-08-01" },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
describe("DailyReportPage / 局面ゲーティング", () => {
|
|
70
|
+
it("未受信 (idle/streaming) はローディング画面を表示する", () => {
|
|
71
|
+
renderPage({ phase: "streaming" })
|
|
72
|
+
expect(screen.getByText("日報 ID を読み込み中です...")).toBeInTheDocument()
|
|
73
|
+
expect(screen.queryByTestId("resolved-content")).not.toBeInTheDocument()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("最初のチャンク到着 (items > 0) で本体を描画し、provider へアイテムを渡す", () => {
|
|
77
|
+
providerItemsLog.length = 0
|
|
78
|
+
renderPage({ phase: "streaming", items: twoItems, loadedCount: 2 })
|
|
79
|
+
expect(screen.getByTestId("resolved-content")).toBeInTheDocument()
|
|
80
|
+
expect(providerItemsLog.at(-1)).toEqual(twoItems)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("空リストの完走 (complete) は本体を描画する (0 件表示は下層の責務)", () => {
|
|
84
|
+
renderPage({ phase: "complete", hasCompletedOnce: true })
|
|
85
|
+
expect(screen.getByTestId("resolved-content")).toBeInTheDocument()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("未受信のまま failed ならエラー画面を出し、再試行がセッション retry を呼ぶ", () => {
|
|
89
|
+
retryMock.mockClear()
|
|
90
|
+
renderPage({ phase: "failed", error: new Error("x") })
|
|
91
|
+
expect(screen.getByText("日報データの読み込みに失敗しました。")).toBeInTheDocument()
|
|
92
|
+
fireEvent.click(screen.getByTestId("daily-report-load-error-retry"))
|
|
93
|
+
expect(retryMock).toHaveBeenCalledTimes(1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("failed でも受信済みアイテムがあれば本体を描画し続ける", () => {
|
|
97
|
+
renderPage({ phase: "failed", items: twoItems, loadedCount: 2, error: new Error("x") })
|
|
98
|
+
expect(screen.getByTestId("resolved-content")).toBeInTheDocument()
|
|
99
|
+
expect(screen.queryByText("日報データの読み込みに失敗しました。")).not.toBeInTheDocument()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("マウント時にセッションを ensure する (userKey = 難読化ユーザー ID)", () => {
|
|
103
|
+
ensureMock.mockClear()
|
|
104
|
+
renderPage({ phase: "streaming" })
|
|
105
|
+
expect(ensureMock).toHaveBeenCalledWith({ apiBasePath: "/daily_report/api", userKey: "ENC8" })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("auth-required はドキュメント再読込へ倒す (サーバー loader のリダイレクトに委譲)", () => {
|
|
109
|
+
reloadMock.mockClear()
|
|
110
|
+
renderPage({ phase: "auth-required" })
|
|
111
|
+
expect(reloadMock).toHaveBeenCalledTimes(1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("auth-required 以外の局面ではドキュメント再読込しない", () => {
|
|
115
|
+
reloadMock.mockClear()
|
|
116
|
+
renderPage({ phase: "streaming", items: twoItems, loadedCount: 2 })
|
|
117
|
+
expect(reloadMock).not.toHaveBeenCalled()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -1,54 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Top-level daily-report page content (chrome-agnostic).
|
|
3
|
-
*
|
|
2
|
+
* Top-level daily-report page content (chrome-agnostic, stream-driven).
|
|
3
|
+
* アプリのレイアウトに依存しない日報ページ本体コンポーネント (ストリーム駆動)。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* ID 一覧はモジュール常駐の NDJSON ストリームセッションから購読し、最初の
|
|
6
|
+
* チャンク到着と同時に一覧を描画する (全件解決を待つ Await ゲートは廃止)。
|
|
7
|
+
* 再訪時はセッションに残る受信済みデータで即座に復元し、鮮度はバックグラウンド
|
|
8
|
+
* 再検証 (SWR) と SSE が担う。アプリ側のルートは本コンポーネントを自前の
|
|
9
|
+
* レイアウトで包み、`config` でヘッダー描画・パス・ラベル名を注入する。
|
|
7
10
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import type { DailyReportItem, DailyReportUser } from "../../shared/types"
|
|
11
|
+
import { useEffect } from "react"
|
|
12
|
+
import type { DailyReportUser } from "../../shared/types"
|
|
11
13
|
import { type DailyReportClientConfigInput, DailyReportConfigProvider, useDailyReportConfig } from "../config-context"
|
|
12
14
|
import { DailyReportActionProvider } from "../contexts/daily-report-action-context"
|
|
15
|
+
import { ensureDailyReportIdsStreamSession, reloadDocument, retryDailyReportIdsStream, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
|
|
13
16
|
import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
|
|
17
|
+
import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
|
|
14
18
|
import { DailyReportResolvedContent } from "./daily-report-resolved-content"
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
21
|
+
* Error display when the ids stream terminally failed before any data arrived.
|
|
22
|
+
* データ未受信のまま ID ストリームが力尽きたときのエラー表示コンポーネント。
|
|
19
23
|
*
|
|
20
|
-
*
|
|
21
|
-
*/
|
|
22
|
-
const DailyReportResolvedSync = ({ dailyReportItems, userId, onResolved }: { dailyReportItems: DailyReportItem[]; userId?: string | null; onResolved: (items: DailyReportItem[]) => void }) => {
|
|
23
|
-
/**
|
|
24
|
-
* Notifies parent when resolved items change.
|
|
25
|
-
* Dependencies:
|
|
26
|
-
* - dailyReportItems: latest resolved list from Await.
|
|
27
|
-
* - onResolved: parent synchronization callback.
|
|
28
|
-
* Cleanup:
|
|
29
|
-
* - None (pure synchronization side effect).
|
|
30
|
-
*
|
|
31
|
-
* 解決済み items が変化したときに親へ通知します。
|
|
32
|
-
* 依存配列:
|
|
33
|
-
* - dailyReportItems: Await で解決された最新リスト
|
|
34
|
-
* - onResolved: 親への同期コールバック
|
|
35
|
-
* クリーンアップ:
|
|
36
|
-
* - なし(同期のみの副作用)
|
|
37
|
-
*/
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
onResolved(dailyReportItems)
|
|
40
|
-
}, [dailyReportItems, onResolved])
|
|
41
|
-
|
|
42
|
-
return <DailyReportResolvedContent dailyReportItems={dailyReportItems} userId={userId} />
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Error display component for daily report data fetch failures.
|
|
47
|
-
* 日報データの取得失敗時に表示するエラーコンポーネント。
|
|
24
|
+
* 再試行はストリームセッションの retry へ直結する (受信済みカーソルから再開)。
|
|
48
25
|
*/
|
|
49
26
|
export const DailyReportLoadError = () => {
|
|
50
27
|
const { title, renderHeader } = useDailyReportConfig()
|
|
51
|
-
const { revalidate } = useRevalidator()
|
|
52
28
|
return (
|
|
53
29
|
<>
|
|
54
30
|
{renderHeader({ title, annotation: <span className="font-bold text-red-500">Error</span> })}
|
|
@@ -56,7 +32,11 @@ export const DailyReportLoadError = () => {
|
|
|
56
32
|
<div className="mx-auto flex h-full w-full max-w-6xl flex-1 flex-col gap-6">
|
|
57
33
|
<div className="flex h-full flex-1 flex-col items-center justify-center gap-4 rounded-lg border border-red-200 bg-white p-4 text-slate-500 shadow-sm dark:border-red-900 dark:bg-slate-900/70 dark:text-slate-400">
|
|
58
34
|
<p className="font-medium text-red-600">日報データの読み込みに失敗しました。</p>
|
|
59
|
-
<button
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
data-testid="daily-report-load-error-retry"
|
|
38
|
+
onClick={() => retryDailyReportIdsStream()}
|
|
39
|
+
className="rounded bg-slate-100 px-4 py-2 text-slate-700 text-sm hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700">
|
|
60
40
|
再読み込み
|
|
61
41
|
</button>
|
|
62
42
|
</div>
|
|
@@ -67,8 +47,8 @@ export const DailyReportLoadError = () => {
|
|
|
67
47
|
}
|
|
68
48
|
|
|
69
49
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
50
|
+
* Loading screen shown only until the first stream chunk arrives.
|
|
51
|
+
* 最初のストリームチャンクが届くまでの間だけ表示するローディング画面。
|
|
72
52
|
*/
|
|
73
53
|
export const DailyReportLoading = () => {
|
|
74
54
|
const { title, renderHeader } = useDailyReportConfig()
|
|
@@ -77,7 +57,8 @@ export const DailyReportLoading = () => {
|
|
|
77
57
|
{renderHeader({
|
|
78
58
|
title,
|
|
79
59
|
annotation: (
|
|
80
|
-
<div className="flex flex-wrap gap-2 leading-none">
|
|
60
|
+
<div className="flex flex-wrap items-center gap-2 leading-none">
|
|
61
|
+
<DailyReportIdsStreamStatus />
|
|
81
62
|
<span>総件数: 読み込み中...</span>
|
|
82
63
|
<span>デバウンス: {DAILY_REPORT_FETCH_DEBOUNCE_MS} ms</span>
|
|
83
64
|
</div>
|
|
@@ -97,41 +78,48 @@ export type DailyReportPageProps = {
|
|
|
97
78
|
user?: DailyReportUser
|
|
98
79
|
/** 難読化済みの内部ユーザー ID (未登録ユーザーは null)。 */
|
|
99
80
|
userId?: string | null
|
|
100
|
-
/** clientLoader で defer した日報 ID 一覧 (Promise) または解決済み配列。 */
|
|
101
|
-
dailyReportIds?: Promise<DailyReportItem[]> | DailyReportItem[] | null
|
|
102
81
|
/** クライアント設定の部分上書き (ヘッダー描画・パス・ラベル名・フィールドラベルなど)。 */
|
|
103
82
|
config?: DailyReportClientConfigInput
|
|
104
83
|
}
|
|
105
84
|
|
|
106
85
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
86
|
+
* Stream-driven page body: session bootstrap, phase gating, and provider wiring.
|
|
87
|
+
* ストリーム駆動のページ本体。セッション確立・局面ゲーティング・プロバイダー結線を担う。
|
|
109
88
|
*/
|
|
110
|
-
const DailyReportPageInner = ({ user, userId
|
|
111
|
-
const
|
|
89
|
+
const DailyReportPageInner = ({ user, userId }: Omit<DailyReportPageProps, "config">) => {
|
|
90
|
+
const { apiBasePath } = useDailyReportConfig()
|
|
91
|
+
const stream = useDailyReportIdsStream()
|
|
112
92
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
93
|
+
/**
|
|
94
|
+
* ストリームセッション確立の保険。
|
|
95
|
+
* 通常は clientLoader が先に確立するが、clientLoader を経ないマウントや
|
|
96
|
+
* ユーザー切替 (userKey 不一致でセッション再生成) をここで拾う。
|
|
97
|
+
* 依存配列: apiBasePath / userId (いずれかの変化で再確認)。
|
|
98
|
+
* クリーンアップ: なし (セッションはモジュール常駐で、アンマウント後も生存させる)。
|
|
99
|
+
*/
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
ensureDailyReportIdsStreamSession({ apiBasePath, userKey: userId ?? null })
|
|
102
|
+
}, [apiBasePath, userId])
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 認証切れ (ストリームが 401/403) はドキュメントのフルリロードへ倒し、
|
|
106
|
+
* サーバー loader のログインリダイレクトに委譲する。
|
|
107
|
+
* 依存配列: stream.phase。クリーンアップ: なし。
|
|
108
|
+
*/
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (stream.phase === "auth-required") {
|
|
111
|
+
reloadDocument()
|
|
112
|
+
}
|
|
113
|
+
}, [stream.phase])
|
|
116
114
|
|
|
115
|
+
// 1 件でも届いたら (または空リストで完走したら) 本体を描画する
|
|
116
|
+
const showContent = stream.items.length > 0 || stream.phase === "complete"
|
|
117
117
|
return (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
{(resolvedItems) => {
|
|
124
|
-
if (!resolvedItems) {
|
|
125
|
-
throw new Error("Daily report ids resolve must not be null")
|
|
126
|
-
}
|
|
127
|
-
// 非 null を保証して以降の処理で利用
|
|
128
|
-
return <DailyReportResolvedSync dailyReportItems={resolvedItems as DailyReportItem[]} userId={userId} onResolved={handleResolvedItems} />
|
|
129
|
-
}}
|
|
130
|
-
</Await>
|
|
131
|
-
) : (
|
|
132
|
-
<DailyReportLoading />
|
|
133
|
-
)}
|
|
134
|
-
</Suspense>
|
|
118
|
+
// key でユーザーごとにプロバイダーをリマウントする。リロード無しのユーザー切替
|
|
119
|
+
// (再ログイン・成り代わり) で items / removedIds / 保留キュー等の状態が旧ユーザーの
|
|
120
|
+
// まま残ると、render-phase マージが旧ユーザーの行を新ユーザーの一覧へ持ち越す
|
|
121
|
+
<DailyReportActionProvider key={userId ?? "anonymous"} user={user} initialItems={stream.items} userId={userId}>
|
|
122
|
+
{showContent ? <DailyReportResolvedContent userId={userId} /> : stream.phase === "failed" ? <DailyReportLoadError /> : <DailyReportLoading />}
|
|
135
123
|
</DailyReportActionProvider>
|
|
136
124
|
)
|
|
137
125
|
}
|