@aiquants/daily-report 0.8.0 → 0.9.1
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 +5 -0
- package/dist/client.d.mts +86 -7
- package/dist/client.d.ts +86 -7
- 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 +3 -3
- 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 +74 -0
- package/src/client/contexts/daily-report-action-context.tsx +98 -35
- 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 +107 -2
- package/src/client/hooks/use-daily-report.ts +37 -3
- 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/utils/constants.ts +11 -1
- 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
|
@@ -3,16 +3,64 @@
|
|
|
3
3
|
* 日報の楽観的 CRUD と SSE 同期を提供するアクションコンテキスト。
|
|
4
4
|
*/
|
|
5
5
|
import { type Context, createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"
|
|
6
|
+
import { compareIdsStreamOrder, isIdsStreamOrdered, upsertInIdsStreamOrder } from "../../shared/ids-stream"
|
|
6
7
|
import { createLogger, LogLevel } from "../../shared/logger"
|
|
7
8
|
import type { DailyReportSseMessage } from "../../shared/sse-schema"
|
|
8
9
|
import type { DailyReportCommentItem, DailyReportDetail, DailyReportItem, DailyReportUser } from "../../shared/types"
|
|
9
10
|
import { useDailyReportConfig } from "../config-context"
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
acquireMutationLock,
|
|
13
|
+
applyBroadcastReportUpdate,
|
|
14
|
+
applyDailyReportServerUpdates,
|
|
15
|
+
clearDailyReportCache,
|
|
16
|
+
DailyReportCache,
|
|
17
|
+
deleteDailyReportCache,
|
|
18
|
+
getCachedReport,
|
|
19
|
+
registerRecentDeletion,
|
|
20
|
+
releaseMutationLock,
|
|
21
|
+
subscribeCacheReady,
|
|
22
|
+
updateDailyReportCache,
|
|
23
|
+
writeCache,
|
|
24
|
+
} from "../hooks/use-daily-report"
|
|
11
25
|
import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
|
|
12
26
|
import { getDailyReportIdsStreamSessionUserKey, refreshDailyReportIdsStream, removeDailyReportIdsStreamItem, upsertDailyReportIdsStreamItem, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
|
|
27
|
+
import { SSE_INITIAL_CONNECT_DEFER_MS } from "../utils/constants"
|
|
28
|
+
import { useDailyReportErrorSurface } from "./daily-report-error-context"
|
|
13
29
|
|
|
14
30
|
const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
|
|
15
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Operation intent -> default banner label (Japanese).
|
|
34
|
+
* 操作 intent -> 既定バナー用の日本語ラベル。ホストは config.onError で構造化情報 (operation)
|
|
35
|
+
* を受け取り独自メッセージへ差し替えできる。
|
|
36
|
+
*/
|
|
37
|
+
const OPERATION_LABELS: Record<string, string> = {
|
|
38
|
+
create: "日報の作成",
|
|
39
|
+
update: "日報の保存",
|
|
40
|
+
publish: "日報の公開",
|
|
41
|
+
delete: "日報の削除",
|
|
42
|
+
addComment: "コメントの投稿",
|
|
43
|
+
deleteComment: "コメントの削除",
|
|
44
|
+
toggleStar: "スターの更新",
|
|
45
|
+
toggleRead: "既読状態の更新",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** intent から利用者向けの失敗メッセージを組み立てる。 */
|
|
49
|
+
const describeOperationFailure = (intent: string): string => {
|
|
50
|
+
const label = OPERATION_LABELS[intent] ?? "操作"
|
|
51
|
+
return `${label}に失敗しました。時間をおいて再度お試しください。`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Stable empty default for the optional `initialItems` prop.
|
|
56
|
+
* 省略可能な `initialItems` の安定した空既定値。
|
|
57
|
+
*
|
|
58
|
+
* 既定を `= []` にすると毎レンダーで新しい配列参照になり、render-phase の
|
|
59
|
+
* `initialItems !== prevInitialItems` 判定が常に真になって setState が無限ループし、
|
|
60
|
+
* React が描画を中断する。単一の凍結参照を共有して同一性を保つ。
|
|
61
|
+
*/
|
|
62
|
+
const EMPTY_INITIAL_ITEMS: DailyReportItem[] = Object.freeze([]) as readonly DailyReportItem[] as DailyReportItem[]
|
|
63
|
+
|
|
16
64
|
type ActionResponse = {
|
|
17
65
|
status: string
|
|
18
66
|
reportHubId: number
|
|
@@ -120,8 +168,13 @@ const getUserDisplayName = (user: DailyReportUser | undefined): string => {
|
|
|
120
168
|
* 日報のアクション(コメント追加・削除、スター、既読)を管理するプロバイダー。
|
|
121
169
|
* 楽観的UI更新とAPI通信の抽象化を提供します。
|
|
122
170
|
*/
|
|
123
|
-
export const DailyReportActionProvider = ({ children, user, initialItems =
|
|
171
|
+
export const DailyReportActionProvider = ({ children, user, initialItems = EMPTY_INITIAL_ITEMS, userId }: { children: ReactNode; user?: DailyReportUser; initialItems?: DailyReportItem[]; userId?: string | null }) => {
|
|
124
172
|
const { apiBasePath, draftLabelName } = useDailyReportConfig()
|
|
173
|
+
// ミューテーション失敗の通知シーム。executeAction の catch から呼び、失敗を可視化する。
|
|
174
|
+
// ref 経由で読み、executeAction の依存に載せない (通知参照は安定しているが防御的に)
|
|
175
|
+
const { notifyError } = useDailyReportErrorSurface()
|
|
176
|
+
const notifyErrorRef = useRef(notifyError)
|
|
177
|
+
notifyErrorRef.current = notifyError
|
|
125
178
|
const pendingAddCommentIds = useRef<Map<number, Map<number, string>>>(new Map())
|
|
126
179
|
const pendingDeleteCommentIds = useRef<Map<number, Set<number>>>(new Map())
|
|
127
180
|
const pendingStarUpdates = useRef<Map<number, boolean>>(new Map())
|
|
@@ -129,7 +182,10 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
129
182
|
const resolvedIdMap = useRef<Map<number, number>>(new Map())
|
|
130
183
|
const [version, setVersion] = useState(0)
|
|
131
184
|
const [items, setItems] = useState<DailyReportItem[]>(initialItems)
|
|
132
|
-
|
|
185
|
+
// SSE は初回描画とその最初の中身フェッチに接続枠を譲るため、少し遅らせて自動接続する
|
|
186
|
+
// (HTTP/1.1 の 6 接続のうち 1 枠を初回描画へ回す)。手動トグル済みなら自動接続しない
|
|
187
|
+
const [isSseEnabled, setIsSseEnabled] = useState(false)
|
|
188
|
+
const hasManualSseToggleRef = useRef(false)
|
|
133
189
|
const pendingAddPromises = useRef<Map<number, Promise<number>>>(new Map())
|
|
134
190
|
const manuallyTrackingIds = useRef<Set<number>>(new Set())
|
|
135
191
|
/** 削除済み ID を記録し、マージロジックでの復活を防止する */
|
|
@@ -305,24 +361,39 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
305
361
|
upsertDailyReportIdsStreamItem({ reportHubId, businessDate: businessDate ?? null })
|
|
306
362
|
}
|
|
307
363
|
setItems((prev) => {
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const right = b.businessDate ?? ""
|
|
315
|
-
return right.localeCompare(left)
|
|
316
|
-
})
|
|
364
|
+
const existing = prev.find((item) => item.reportHubId === reportHubId)
|
|
365
|
+
const nextBusinessDate = businessDate ?? existing?.businessDate ?? null
|
|
366
|
+
// 正準比較器 (営業日降順 → ID 降順) の位置へ二分探索で upsert する。
|
|
367
|
+
// 以前は SSE 1 件ごとに全件を localeCompare でソートし直していた (O(n log n)
|
|
368
|
+
// かつ ID 降順の副キー無しで、同日内の新規作成が誤った位置に描画されていた)
|
|
369
|
+
return upsertInIdsStreamOrder(prev, { reportHubId, businessDate: nextBusinessDate, sourceType: existing?.sourceType })
|
|
317
370
|
})
|
|
318
371
|
}, [])
|
|
319
372
|
|
|
320
373
|
// 未登録ユーザーは SSE を有効化できない
|
|
321
374
|
const toggleSse = () => {
|
|
322
375
|
if (!userId) return
|
|
376
|
+
// 手動操作後は自動接続タイマーに上書きさせない
|
|
377
|
+
hasManualSseToggleRef.current = true
|
|
323
378
|
setIsSseEnabled((prev) => !prev)
|
|
324
379
|
}
|
|
325
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Defers the initial SSE auto-connect until just after first paint.
|
|
383
|
+
* 初回 SSE 自動接続を初回描画直後まで遅らせる副作用。
|
|
384
|
+
*
|
|
385
|
+
* 依存配列: userId (ユーザーごとに provider が key リマウントされるため、切替で張り直す)。
|
|
386
|
+
* クリーンアップ: タイマー解除 (アンマウント/再マウントでのリークと二重発火を防ぐ)。
|
|
387
|
+
*/
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (!userId) return
|
|
390
|
+
const timer = setTimeout(() => {
|
|
391
|
+
// 手動でトグルされていなければ自動接続する
|
|
392
|
+
if (!hasManualSseToggleRef.current) setIsSseEnabled(true)
|
|
393
|
+
}, SSE_INITIAL_CONNECT_DEFER_MS)
|
|
394
|
+
return () => clearTimeout(timer)
|
|
395
|
+
}, [userId])
|
|
396
|
+
|
|
326
397
|
/**
|
|
327
398
|
* Remove a stale item from the list as a safety net.
|
|
328
399
|
* フェッチ後にレポートが見つからなかった場合の安全弁としてアイテムを除去する。
|
|
@@ -368,27 +439,14 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
368
439
|
mergedMap.set(item.reportHubId, item)
|
|
369
440
|
}
|
|
370
441
|
}
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
// O(n log n) を O(n) へ落とす)
|
|
442
|
+
// 並び順は正準比較器 (compareIdsStreamOrder: 営業日降順 → ID 降順、null 営業日は
|
|
443
|
+
// 末尾) に一本化する。ストリームはソート順どおりに届き末尾へ追記されるため通常は
|
|
444
|
+
// 整列済み — O(n) の検査で確認できたらソートを丸ごと省略する (20 万件級で毎 publish
|
|
445
|
+
// の O(n log n) を O(n) へ落とす)。以前は営業日のみの比較で ID 降順の副キーが無く、
|
|
446
|
+
// 前回全量走査以降に作成された日報が同日グループ内の誤った位置に描画されていた
|
|
377
447
|
const merged = Array.from(mergedMap.values())
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if ((merged[i - 1].businessDate ?? "") < (merged[i].businessDate ?? "")) {
|
|
381
|
-
isSorted = false
|
|
382
|
-
break
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
if (!isSorted) {
|
|
386
|
-
merged.sort((a, b) => {
|
|
387
|
-
const left = a.businessDate ?? ""
|
|
388
|
-
const right = b.businessDate ?? ""
|
|
389
|
-
if (left === right) return 0
|
|
390
|
-
return left < right ? 1 : -1
|
|
391
|
-
})
|
|
448
|
+
if (!isIdsStreamOrdered(merged)) {
|
|
449
|
+
merged.sort(compareIdsStreamOrder)
|
|
392
450
|
}
|
|
393
451
|
setItems(merged)
|
|
394
452
|
}
|
|
@@ -552,7 +610,9 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
552
610
|
case "report-publish": {
|
|
553
611
|
// report はスキーマで必須なのでバリデーション通過後は必ず存在する(if ガード不要)
|
|
554
612
|
const { report } = payload
|
|
555
|
-
|
|
613
|
+
// 全ユーザー放送: report の isRead/isStarred は操作者の状態なので、
|
|
614
|
+
// 受信者自身の per-viewer 状態を保持するマージ経路を使う
|
|
615
|
+
applyBroadcastReportUpdate(report)
|
|
556
616
|
if (payload.type === "report-create" || payload.type === "report-publish") {
|
|
557
617
|
manuallyTrackingIds.current.add(report.reportHubId)
|
|
558
618
|
upsertItem(report.reportHubId, report.date)
|
|
@@ -651,9 +711,11 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
651
711
|
} catch (e) {
|
|
652
712
|
logger.error(`${intent} failed:`, e)
|
|
653
713
|
// 切替後の失敗も同様: onFailure のロールバックが旧値をモジュールキャッシュへ
|
|
654
|
-
// 書き戻すため放棄する (プロバイダー自身の state は key リマウントで破棄済み)
|
|
714
|
+
// 書き戻すため放棄する (プロバイダー自身の state は key リマウントで破棄済み)。
|
|
715
|
+
// 通知も同じ門で守る: ユーザー切替で捨てた継続の失敗は当人に見せない
|
|
655
716
|
if (isSessionStillOurs()) {
|
|
656
717
|
callbacks.onFailure?.()
|
|
718
|
+
notifyErrorRef.current({ operation: intent, message: describeOperationFailure(intent), cause: e })
|
|
657
719
|
}
|
|
658
720
|
throw e
|
|
659
721
|
} finally {
|
|
@@ -1028,7 +1090,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
1028
1090
|
if (targetItem) {
|
|
1029
1091
|
setItems((prev) => {
|
|
1030
1092
|
if (prev.some((i) => i.reportHubId === id)) return prev
|
|
1031
|
-
|
|
1093
|
+
// ロールバックも正準比較器の位置へ戻す (第 3 の比較器を持たない)
|
|
1094
|
+
return upsertInIdsStreamOrder(prev, targetItem)
|
|
1032
1095
|
})
|
|
1033
1096
|
}
|
|
1034
1097
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the mutation error-surface seam.
|
|
3
|
+
* ミューテーション通知シームのテスト。
|
|
4
|
+
*/
|
|
5
|
+
import { fireEvent, render, screen } from "@testing-library/react"
|
|
6
|
+
import { describe, expect, it, vi } from "vitest"
|
|
7
|
+
import { DailyReportConfigProvider } from "../config-context"
|
|
8
|
+
import { DailyReportErrorProvider, useDailyReportErrorSurface } from "./daily-report-error-context"
|
|
9
|
+
|
|
10
|
+
/** notifyError を発火するだけのプローブ。 */
|
|
11
|
+
const Trigger = ({ label = "発火" }: { label?: string }) => {
|
|
12
|
+
const { notifyError } = useDailyReportErrorSurface()
|
|
13
|
+
return (
|
|
14
|
+
<button type="button" onClick={() => notifyError({ operation: "update", message: "日報の保存に失敗しました。" })}>
|
|
15
|
+
{label}
|
|
16
|
+
</button>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const renderWithConfig = (node: React.ReactNode, onError?: (info: { operation: string; message: string }) => void) =>
|
|
21
|
+
render(
|
|
22
|
+
<DailyReportConfigProvider config={onError ? { onError } : undefined}>
|
|
23
|
+
<DailyReportErrorProvider>{node}</DailyReportErrorProvider>
|
|
24
|
+
</DailyReportConfigProvider>,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
describe("DailyReportErrorProvider", () => {
|
|
28
|
+
it("shows a role=alert banner when no onError is injected", () => {
|
|
29
|
+
renderWithConfig(<Trigger />)
|
|
30
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
31
|
+
fireEvent.click(screen.getByText("発火"))
|
|
32
|
+
const banner = screen.getByRole("alert")
|
|
33
|
+
expect(banner).toHaveTextContent("日報の保存に失敗しました。")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("delegates to config.onError and does NOT render the default banner", () => {
|
|
37
|
+
const onError = vi.fn()
|
|
38
|
+
renderWithConfig(<Trigger />, onError)
|
|
39
|
+
fireEvent.click(screen.getByText("発火"))
|
|
40
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ operation: "update", message: "日報の保存に失敗しました。" }))
|
|
41
|
+
// ホストへ委譲したときは内蔵バナーを出さない (二重表示防止)
|
|
42
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("dismisses the banner via the close button", () => {
|
|
46
|
+
renderWithConfig(<Trigger />)
|
|
47
|
+
fireEvent.click(screen.getByText("発火"))
|
|
48
|
+
expect(screen.getByRole("alert")).toBeInTheDocument()
|
|
49
|
+
fireEvent.click(screen.getByLabelText("閉じる"))
|
|
50
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("notifyError is a safe no-op outside the provider", () => {
|
|
54
|
+
// Provider の外でも購読できて壊れない (アクションコンテキストの防御的呼び出しを保証)
|
|
55
|
+
const Standalone = () => {
|
|
56
|
+
const { notifyError } = useDailyReportErrorSurface()
|
|
57
|
+
return (
|
|
58
|
+
<button type="button" onClick={() => notifyError({ operation: "delete", message: "x" })}>
|
|
59
|
+
無所属
|
|
60
|
+
</button>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
render(<Standalone />)
|
|
64
|
+
expect(() => fireEvent.click(screen.getByText("無所属"))).not.toThrow()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error-surface seam for daily-report mutations.
|
|
3
|
+
* 日報ミューテーション失敗を利用者へ可視化するための通知シーム。
|
|
4
|
+
*
|
|
5
|
+
* 保存・公開・削除・コメント等の失敗は、これまで握り潰されてボタンが成功したかのように
|
|
6
|
+
* 復帰していた。ここでミューテーションの失敗を一元的に受け取り、`config.onError` が
|
|
7
|
+
* 注入されていればホストへ委譲し、無ければパッケージ内蔵の `role="alert"` バナーで表示する。
|
|
8
|
+
* `notifyError` は Provider 外でも安全に呼べる (既定は no-op) ため、アクションコンテキストは
|
|
9
|
+
* 存在を前提にせず呼び出せる。
|
|
10
|
+
*/
|
|
11
|
+
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
|
|
12
|
+
import { type DailyReportErrorInfo, useDailyReportConfig } from "../config-context"
|
|
13
|
+
|
|
14
|
+
/** バナーの自動消滅までの既定時間 (ms)。手動クローズでも消せる。 */
|
|
15
|
+
const ERROR_BANNER_AUTO_DISMISS_MS = 8_000
|
|
16
|
+
|
|
17
|
+
type DailyReportErrorSurface = {
|
|
18
|
+
/** ミューテーション失敗を通知する。 */
|
|
19
|
+
notifyError: (info: DailyReportErrorInfo) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 既定は no-op (Provider 外で呼んでも壊れない)。 */
|
|
23
|
+
const DailyReportErrorContext = createContext<DailyReportErrorSurface>({ notifyError: () => {} })
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns the error-surface API (safe no-op outside the provider).
|
|
27
|
+
* 通知 API を返す処理 (Provider 外では no-op)。
|
|
28
|
+
*/
|
|
29
|
+
export const useDailyReportErrorSurface = (): DailyReportErrorSurface => useContext(DailyReportErrorContext)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Provides the mutation error surface and renders the default alert banner.
|
|
33
|
+
* ミューテーション通知シームを供給し、既定のアラートバナーを描画するプロバイダー。
|
|
34
|
+
*
|
|
35
|
+
* `config.onError` があればホストへ委譲し、内蔵バナーは描画しない (二重表示防止)。
|
|
36
|
+
*/
|
|
37
|
+
export const DailyReportErrorProvider = ({ children }: { children: ReactNode }) => {
|
|
38
|
+
const { onError } = useDailyReportConfig()
|
|
39
|
+
const [banner, setBanner] = useState<DailyReportErrorInfo | null>(null)
|
|
40
|
+
// onError を ref 経由で読み、notifyError の参照を安定させる (毎回新関数だと購読側が再レンダー)
|
|
41
|
+
const onErrorRef = useRef(onError)
|
|
42
|
+
onErrorRef.current = onError
|
|
43
|
+
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
44
|
+
|
|
45
|
+
const notifyError = useCallback((info: DailyReportErrorInfo) => {
|
|
46
|
+
const host = onErrorRef.current
|
|
47
|
+
if (host) {
|
|
48
|
+
// ホストが通知 UI を持つ場合は委譲し、内蔵バナーは出さない
|
|
49
|
+
host(info)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
setBanner(info)
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
const dismiss = useCallback(() => {
|
|
56
|
+
if (dismissTimerRef.current !== null) {
|
|
57
|
+
clearTimeout(dismissTimerRef.current)
|
|
58
|
+
dismissTimerRef.current = null
|
|
59
|
+
}
|
|
60
|
+
setBanner(null)
|
|
61
|
+
}, [])
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* バナー表示中は一定時間で自動的に消す副作用。
|
|
65
|
+
* 依存配列: banner (新しい失敗が来るたびにタイマーを張り直す)。
|
|
66
|
+
* クリーンアップ: タイマー解除 (アンマウント/再通知でのリークと二重発火を防ぐ)。
|
|
67
|
+
*/
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!banner) return
|
|
70
|
+
dismissTimerRef.current = setTimeout(() => setBanner(null), ERROR_BANNER_AUTO_DISMISS_MS)
|
|
71
|
+
return () => {
|
|
72
|
+
if (dismissTimerRef.current !== null) {
|
|
73
|
+
clearTimeout(dismissTimerRef.current)
|
|
74
|
+
dismissTimerRef.current = null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}, [banner])
|
|
78
|
+
|
|
79
|
+
const value = useMemo(() => ({ notifyError }), [notifyError])
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<DailyReportErrorContext.Provider value={value}>
|
|
83
|
+
{children}
|
|
84
|
+
{banner && (
|
|
85
|
+
<div
|
|
86
|
+
// role="alert" で SR へ即時読み上げさせる。fixed トーストで操作を妨げない
|
|
87
|
+
role="alert"
|
|
88
|
+
data-testid="daily-report-error-banner"
|
|
89
|
+
className="fade-in slide-in-from-bottom-2 fixed inset-x-0 bottom-4 z-[100] mx-auto flex w-fit max-w-[92vw] animate-in items-center gap-3 rounded-lg border border-red-300 bg-red-600 px-4 py-2.5 text-sm text-white shadow-lg duration-200 dark:border-red-500/60 dark:bg-red-700">
|
|
90
|
+
<span className="font-medium">{banner.message}</span>
|
|
91
|
+
<button type="button" aria-label="閉じる" onClick={dismiss} className="shrink-0 rounded px-1.5 py-0.5 font-bold text-white/90 transition-colors hover:bg-red-700 hover:text-white dark:hover:bg-red-800">
|
|
92
|
+
✕
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</DailyReportErrorContext.Provider>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -30,6 +30,7 @@ describe("DailyReportCache public API", () => {
|
|
|
30
30
|
let getCachedReport: typeof import("./use-daily-report").getCachedReport
|
|
31
31
|
let updateDailyReportCache: typeof import("./use-daily-report").updateDailyReportCache
|
|
32
32
|
let applyDailyReportServerUpdates: typeof import("./use-daily-report").applyDailyReportServerUpdates
|
|
33
|
+
let applyBroadcastReportUpdate: typeof import("./use-daily-report").applyBroadcastReportUpdate
|
|
33
34
|
|
|
34
35
|
beforeEach(async () => {
|
|
35
36
|
// モジュールキャッシュをリセットして、getGlobal がカスタム TTL を読み込むようにする
|
|
@@ -44,6 +45,7 @@ describe("DailyReportCache public API", () => {
|
|
|
44
45
|
getCachedReport = mod.getCachedReport
|
|
45
46
|
updateDailyReportCache = mod.updateDailyReportCache
|
|
46
47
|
applyDailyReportServerUpdates = mod.applyDailyReportServerUpdates
|
|
48
|
+
applyBroadcastReportUpdate = mod.applyBroadcastReportUpdate
|
|
47
49
|
})
|
|
48
50
|
|
|
49
51
|
afterEach(() => {
|
|
@@ -100,8 +102,9 @@ describe("DailyReportCache public API", () => {
|
|
|
100
102
|
})
|
|
101
103
|
})
|
|
102
104
|
|
|
103
|
-
describe("applyDailyReportServerUpdates", () => {
|
|
104
|
-
it("キャッシュ済みレポートを完全置換する", () => {
|
|
105
|
+
describe("applyDailyReportServerUpdates (信頼経路: 自分の HTTP 応答・自分宛 status-update)", () => {
|
|
106
|
+
it("キャッシュ済みレポートを完全置換する (per-viewer 値も配信値を採用)", () => {
|
|
107
|
+
// 信頼経路の値は受信者自身の状態なので、そのまま書いてよい
|
|
105
108
|
writeCache(makeMockReport({ subject: "Old", content: "Old" }), Date.now() + 1000)
|
|
106
109
|
applyDailyReportServerUpdates(1, makeMockReport({ subject: "Fresh", content: "Fresh" }))
|
|
107
110
|
const cached = getCachedReport(1)
|
|
@@ -114,6 +117,51 @@ describe("DailyReportCache public API", () => {
|
|
|
114
117
|
expect(getCachedReport(42)?.subject).toBe("New")
|
|
115
118
|
})
|
|
116
119
|
})
|
|
120
|
+
|
|
121
|
+
describe("applyBroadcastReportUpdate (放送経路: 全ユーザー宛 SSE)", () => {
|
|
122
|
+
it("共有フィールドは配信値で更新し、per-viewer (isRead/isStarred) は受信者の値を保持する", () => {
|
|
123
|
+
// SSE の report が運ぶ isRead/isStarred は送信者 (操作者) の状態であり、
|
|
124
|
+
// 受信者のキャッシュへそのまま書くと他人の既読/スター状態で上書きされる
|
|
125
|
+
writeCache(makeMockReport({ subject: "Old", content: "Old", isRead: true, isStarred: true }), Date.now() + 1000)
|
|
126
|
+
applyBroadcastReportUpdate(makeMockReport({ subject: "Fresh", content: "Fresh", isRead: false, isStarred: false }))
|
|
127
|
+
const cached = getCachedReport(1)
|
|
128
|
+
expect(cached?.subject).toBe("Fresh")
|
|
129
|
+
expect(cached?.content).toBe("Fresh")
|
|
130
|
+
// 受信者自身の per-viewer 状態が生き残る
|
|
131
|
+
expect(cached?.isRead).toBe(true)
|
|
132
|
+
expect(cached?.isStarred).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("commentItems[].isMine は per-viewer: キャッシュ済みは保持、送信者由来の新規は false 化する", () => {
|
|
136
|
+
// isMine はサーバーが送信者視点で計算するため、そのまま取り込むと受信者に
|
|
137
|
+
// 他人のコメントが「自分の」削除ボタン付きで見え、自分のコメントは isMine を失う
|
|
138
|
+
writeCache(makeMockReport({ commentItems: [{ id: 1, userName: "自分", content: "私の", createdAt: "2026-01-01 00:00:00", userId: "ENC8", isMine: true }] }) as never, Date.now() + 1000)
|
|
139
|
+
applyBroadcastReportUpdate(
|
|
140
|
+
makeMockReport({
|
|
141
|
+
commentItems: [
|
|
142
|
+
{ id: 1, userName: "自分", content: "私の", createdAt: "2026-01-01 00:00:00", userId: "ENC8", isMine: false },
|
|
143
|
+
{ id: 2, userName: "他人", content: "他人の", createdAt: "2026-01-01 00:01:00", userId: "ENC9", isMine: true },
|
|
144
|
+
],
|
|
145
|
+
}) as never,
|
|
146
|
+
)
|
|
147
|
+
const cached = getCachedReport(1)
|
|
148
|
+
// 受信者自身のコメント (id=1) の isMine は保持、送信者が「自分の」とした id=2 は false 化
|
|
149
|
+
expect(cached?.commentItems.find((c) => c.id === 1)?.isMine).toBe(true)
|
|
150
|
+
expect(cached?.commentItems.find((c) => c.id === 2)?.isMine).toBe(false)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("未キャッシュの ID も中立化して種まきする (行が stale 安全弁で消えるのを防ぐ)", () => {
|
|
154
|
+
// 未キャッシュを書かないと、鮮度窓内のリストキャッシュに新 id が無く、行が
|
|
155
|
+
// report=null に解決してトゥームストーン化され、作成/公開された日報が消える
|
|
156
|
+
applyBroadcastReportUpdate(makeMockReport({ reportHubId: 42, subject: "New", isRead: true, isStarred: true, commentItems: [{ id: 9, userName: "他人", content: "x", createdAt: "2026-01-01 00:00:00", userId: "ENC9", isMine: true }] }) as never)
|
|
157
|
+
const cached = getCachedReport(42)
|
|
158
|
+
expect(cached?.subject).toBe("New")
|
|
159
|
+
// per-viewer は中立値で入る (送信者の true を持ち込まない)
|
|
160
|
+
expect(cached?.isRead).toBe(false)
|
|
161
|
+
expect(cached?.isStarred).toBe(false)
|
|
162
|
+
expect(cached?.commentItems[0]?.isMine).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
117
165
|
})
|
|
118
166
|
|
|
119
167
|
describe("getGlobal behavior (window グローバル変数の読み取り)", () => {
|
|
@@ -153,3 +201,60 @@ describe("getGlobal behavior (window グローバル変数の読み取り)", ()
|
|
|
153
201
|
expect(mod.getCachedReport(1)).toBeNull()
|
|
154
202
|
})
|
|
155
203
|
})
|
|
204
|
+
|
|
205
|
+
describe("useDailyReportDetail の初回フェッチ (デバウンス適用範囲)", () => {
|
|
206
|
+
let mod: typeof import("./use-daily-report")
|
|
207
|
+
|
|
208
|
+
beforeEach(async () => {
|
|
209
|
+
vi.resetModules()
|
|
210
|
+
mod = await import("./use-daily-report")
|
|
211
|
+
vi.useFakeTimers()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
afterEach(() => {
|
|
215
|
+
vi.useRealTimers()
|
|
216
|
+
vi.unstubAllGlobals()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
/** business-date 応答を 1 件返す fetch スタブ。呼び出し回数を記録する。 */
|
|
220
|
+
const stubFetch = () => {
|
|
221
|
+
const calls: string[] = []
|
|
222
|
+
vi.stubGlobal(
|
|
223
|
+
"fetch",
|
|
224
|
+
vi.fn(async (input: RequestInfo | URL) => {
|
|
225
|
+
calls.push(String(input))
|
|
226
|
+
return new Response(JSON.stringify({ reports: [makeMockReport({ reportHubId: 7, date: "2026-01-01" })] }), { status: 200, headers: { "Content-Type": "application/json" } })
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
return calls
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
it("未キャッシュの初回は即フェッチする (120ms のデバウンスを掛けない)", async () => {
|
|
233
|
+
const { renderHook, act } = await import("@testing-library/react")
|
|
234
|
+
const calls = stubFetch()
|
|
235
|
+
renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
|
|
236
|
+
// タイマーを進めずマイクロタスクだけ流す → 即時 (0ms) 発火の検証
|
|
237
|
+
await act(async () => {
|
|
238
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
239
|
+
})
|
|
240
|
+
expect(calls.length).toBe(1)
|
|
241
|
+
expect(calls[0]).toContain("business-date?businessDate=2026-01-01")
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("キャッシュ済み (stale) の再取得はデバウンスされる", async () => {
|
|
245
|
+
const { renderHook, act } = await import("@testing-library/react")
|
|
246
|
+
const calls = stubFetch()
|
|
247
|
+
// 期限切れのキャッシュを用意 → 表示は即、再取得はデバウンス対象
|
|
248
|
+
mod.writeCache(makeMockReport({ reportHubId: 7, date: "2026-01-01" }), Date.now() - 1)
|
|
249
|
+
renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
|
|
250
|
+
await act(async () => {
|
|
251
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
252
|
+
})
|
|
253
|
+
// 0ms 時点では発火しない (スクロール churn 吸収のためのデバウンス)
|
|
254
|
+
expect(calls.length).toBe(0)
|
|
255
|
+
await act(async () => {
|
|
256
|
+
await vi.advanceTimersByTimeAsync(120)
|
|
257
|
+
})
|
|
258
|
+
expect(calls.length).toBe(1)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
@@ -7,6 +7,7 @@ import { normalizeBusinessDateKey } from "../../shared/business-date"
|
|
|
7
7
|
import { createLogger, LogLevel } from "../../shared/logger"
|
|
8
8
|
import type { DailyReportDetail } from "../../shared/types"
|
|
9
9
|
import { useDailyReportConfig } from "../config-context"
|
|
10
|
+
import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
|
|
10
11
|
|
|
11
12
|
const logger = createLogger(LogLevel.INFO, "use-daily-report")
|
|
12
13
|
|
|
@@ -22,7 +23,6 @@ const getGlobal = (k: string, d: number): number => {
|
|
|
22
23
|
}
|
|
23
24
|
const TTL = getGlobal("__DAILY_REPORT_CACHE_TTL__", 300_000)
|
|
24
25
|
const REVIVE_MS = getGlobal("__DAILY_REPORT_CACHE_REVIVE_MS__", 60_000)
|
|
25
|
-
const FETCH_DEBOUNCE_MS = 120
|
|
26
26
|
|
|
27
27
|
// --- Cache Store (Centralized State) ---
|
|
28
28
|
type CacheEntry<T> = { data: T; expiresAt: number }
|
|
@@ -270,11 +270,40 @@ export const subscribeCacheReady = (reportHubId: number, callback: () => void):
|
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
export const applyDailyReportServerUpdates = (
|
|
273
|
+
export const applyDailyReportServerUpdates = (_reportHubId: number, report: DailyReportDetail) => {
|
|
274
274
|
DailyReportCache.prune()
|
|
275
275
|
DailyReportCache.set(report, Date.now() + TTL)
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Applies a broadcast (cross-user SSE) report while preserving the viewer's own per-viewer state.
|
|
280
|
+
* 全ユーザー放送 (SSE) の report を、受信者自身の per-viewer 状態を保持したまま反映する処理。
|
|
281
|
+
*
|
|
282
|
+
* SSE の report-create/update/publish が運ぶ isRead/isStarred は**操作した本人の状態**。
|
|
283
|
+
* そのまま書くと受信者の既読/スター表示が他人の状態で上書きされる。共有フィールドだけを
|
|
284
|
+
* 取り込み、per-viewer フィールドは受信者のキャッシュ値を保持する。ローカルに無い日報は
|
|
285
|
+
* 種まきしない (送信者の per-viewer 値で汚すより、次のフェッチが受信者視点の正しい値を
|
|
286
|
+
* 取るのに任せるほうが安全)。
|
|
287
|
+
*
|
|
288
|
+
* ❗ 信頼できる経路 (自分の HTTP 応答・recipient フィルタ済みの自分宛 status-update) は
|
|
289
|
+
* {@link applyDailyReportServerUpdates} を使うこと — そちらは値をそのまま書く。
|
|
290
|
+
*/
|
|
291
|
+
export const applyBroadcastReportUpdate = (report: DailyReportDetail) => {
|
|
292
|
+
DailyReportCache.prune()
|
|
293
|
+
const cached = DailyReportCache.getReport(report.reportHubId, true)
|
|
294
|
+
// per-viewer フィールド (isRead / isStarred / commentItems[].isMine) は送信者視点なので
|
|
295
|
+
// 受信者のキャッシュ値を保持し、未キャッシュなら中立値 (false) で置く。共有フィールドは配信値。
|
|
296
|
+
// ❗ 未キャッシュでも必ず書く: 書かないと鮮度窓内のリストキャッシュに新 id が載らず、
|
|
297
|
+
// 作成/公開された他ユーザーの日報が stale 安全弁でトゥームストーン化され、閲覧中の
|
|
298
|
+
// 受信者の一覧から恒久的に消える (set は既定で syncList=true = リストにも反映する)
|
|
299
|
+
const cachedMine = new Map<number | string, boolean>()
|
|
300
|
+
for (const c of cached?.commentItems ?? []) {
|
|
301
|
+
cachedMine.set(c.id, c.isMine ?? false)
|
|
302
|
+
}
|
|
303
|
+
const commentItems = (report.commentItems ?? []).map((c) => ({ ...c, isMine: cachedMine.get(c.id) ?? false }))
|
|
304
|
+
DailyReportCache.set({ ...report, isRead: cached?.isRead ?? false, isStarred: cached?.isStarred ?? false, commentItems }, Date.now() + TTL)
|
|
305
|
+
}
|
|
306
|
+
|
|
278
307
|
// --- Data Loading ---
|
|
279
308
|
/**
|
|
280
309
|
* Fetch a single report by ID.
|
|
@@ -401,6 +430,11 @@ export const useDailyReportDetail = (reportHubId: number, businessDate: string |
|
|
|
401
430
|
setIsLoading(!cached)
|
|
402
431
|
setIsRefetching(true)
|
|
403
432
|
|
|
433
|
+
// 未キャッシュ (骨組み表示中) の初回取得は即時に走らせる。デバウンスはスクロール連打の
|
|
434
|
+
// 無駄取りを抑える目的なので、スクロール churn の無い初回に効かせると純粋な遅延になる。
|
|
435
|
+
// キャッシュ済み (内容表示中) の stale 再取得だけデバウンスして churn を吸収する
|
|
436
|
+
const debounceMs = cached ? DAILY_REPORT_FETCH_DEBOUNCE_MS : 0
|
|
437
|
+
|
|
404
438
|
const timer = setTimeout(async () => {
|
|
405
439
|
if (reqRef.current.token !== token) return
|
|
406
440
|
try {
|
|
@@ -424,7 +458,7 @@ export const useDailyReportDetail = (reportHubId: number, businessDate: string |
|
|
|
424
458
|
setIsRefetching(false)
|
|
425
459
|
}
|
|
426
460
|
}
|
|
427
|
-
},
|
|
461
|
+
}, debounceMs)
|
|
428
462
|
|
|
429
463
|
return () => clearTimeout(timer)
|
|
430
464
|
}, [reportHubId, businessDate, apiBasePath])
|