@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
|
@@ -3,16 +3,63 @@
|
|
|
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 { useDailyReportErrorSurface } from "./daily-report-error-context"
|
|
13
28
|
|
|
14
29
|
const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
|
|
15
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Operation intent -> default banner label (Japanese).
|
|
33
|
+
* 操作 intent -> 既定バナー用の日本語ラベル。ホストは config.onError で構造化情報 (operation)
|
|
34
|
+
* を受け取り独自メッセージへ差し替えできる。
|
|
35
|
+
*/
|
|
36
|
+
const OPERATION_LABELS: Record<string, string> = {
|
|
37
|
+
create: "日報の作成",
|
|
38
|
+
update: "日報の保存",
|
|
39
|
+
publish: "日報の公開",
|
|
40
|
+
delete: "日報の削除",
|
|
41
|
+
addComment: "コメントの投稿",
|
|
42
|
+
deleteComment: "コメントの削除",
|
|
43
|
+
toggleStar: "スターの更新",
|
|
44
|
+
toggleRead: "既読状態の更新",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** intent から利用者向けの失敗メッセージを組み立てる。 */
|
|
48
|
+
const describeOperationFailure = (intent: string): string => {
|
|
49
|
+
const label = OPERATION_LABELS[intent] ?? "操作"
|
|
50
|
+
return `${label}に失敗しました。時間をおいて再度お試しください。`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stable empty default for the optional `initialItems` prop.
|
|
55
|
+
* 省略可能な `initialItems` の安定した空既定値。
|
|
56
|
+
*
|
|
57
|
+
* 既定を `= []` にすると毎レンダーで新しい配列参照になり、render-phase の
|
|
58
|
+
* `initialItems !== prevInitialItems` 判定が常に真になって setState が無限ループし、
|
|
59
|
+
* React が描画を中断する。単一の凍結参照を共有して同一性を保つ。
|
|
60
|
+
*/
|
|
61
|
+
const EMPTY_INITIAL_ITEMS: DailyReportItem[] = Object.freeze([]) as readonly DailyReportItem[] as DailyReportItem[]
|
|
62
|
+
|
|
16
63
|
type ActionResponse = {
|
|
17
64
|
status: string
|
|
18
65
|
reportHubId: number
|
|
@@ -120,8 +167,13 @@ const getUserDisplayName = (user: DailyReportUser | undefined): string => {
|
|
|
120
167
|
* 日報のアクション(コメント追加・削除、スター、既読)を管理するプロバイダー。
|
|
121
168
|
* 楽観的UI更新とAPI通信の抽象化を提供します。
|
|
122
169
|
*/
|
|
123
|
-
export const DailyReportActionProvider = ({ children, user, initialItems =
|
|
170
|
+
export const DailyReportActionProvider = ({ children, user, initialItems = EMPTY_INITIAL_ITEMS, userId }: { children: ReactNode; user?: DailyReportUser; initialItems?: DailyReportItem[]; userId?: string | null }) => {
|
|
124
171
|
const { apiBasePath, draftLabelName } = useDailyReportConfig()
|
|
172
|
+
// ミューテーション失敗の通知シーム。executeAction の catch から呼び、失敗を可視化する。
|
|
173
|
+
// ref 経由で読み、executeAction の依存に載せない (通知参照は安定しているが防御的に)
|
|
174
|
+
const { notifyError } = useDailyReportErrorSurface()
|
|
175
|
+
const notifyErrorRef = useRef(notifyError)
|
|
176
|
+
notifyErrorRef.current = notifyError
|
|
125
177
|
const pendingAddCommentIds = useRef<Map<number, Map<number, string>>>(new Map())
|
|
126
178
|
const pendingDeleteCommentIds = useRef<Map<number, Set<number>>>(new Map())
|
|
127
179
|
const pendingStarUpdates = useRef<Map<number, boolean>>(new Map())
|
|
@@ -305,15 +357,12 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
305
357
|
upsertDailyReportIdsStreamItem({ reportHubId, businessDate: businessDate ?? null })
|
|
306
358
|
}
|
|
307
359
|
setItems((prev) => {
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const right = b.businessDate ?? ""
|
|
315
|
-
return right.localeCompare(left)
|
|
316
|
-
})
|
|
360
|
+
const existing = prev.find((item) => item.reportHubId === reportHubId)
|
|
361
|
+
const nextBusinessDate = businessDate ?? existing?.businessDate ?? null
|
|
362
|
+
// 正準比較器 (営業日降順 → ID 降順) の位置へ二分探索で upsert する。
|
|
363
|
+
// 以前は SSE 1 件ごとに全件を localeCompare でソートし直していた (O(n log n)
|
|
364
|
+
// かつ ID 降順の副キー無しで、同日内の新規作成が誤った位置に描画されていた)
|
|
365
|
+
return upsertInIdsStreamOrder(prev, { reportHubId, businessDate: nextBusinessDate, sourceType: existing?.sourceType })
|
|
317
366
|
})
|
|
318
367
|
}, [])
|
|
319
368
|
|
|
@@ -368,27 +417,14 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
368
417
|
mergedMap.set(item.reportHubId, item)
|
|
369
418
|
}
|
|
370
419
|
}
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
// O(n log n) を O(n) へ落とす)
|
|
420
|
+
// 並び順は正準比較器 (compareIdsStreamOrder: 営業日降順 → ID 降順、null 営業日は
|
|
421
|
+
// 末尾) に一本化する。ストリームはソート順どおりに届き末尾へ追記されるため通常は
|
|
422
|
+
// 整列済み — O(n) の検査で確認できたらソートを丸ごと省略する (20 万件級で毎 publish
|
|
423
|
+
// の O(n log n) を O(n) へ落とす)。以前は営業日のみの比較で ID 降順の副キーが無く、
|
|
424
|
+
// 前回全量走査以降に作成された日報が同日グループ内の誤った位置に描画されていた
|
|
377
425
|
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
|
-
})
|
|
426
|
+
if (!isIdsStreamOrdered(merged)) {
|
|
427
|
+
merged.sort(compareIdsStreamOrder)
|
|
392
428
|
}
|
|
393
429
|
setItems(merged)
|
|
394
430
|
}
|
|
@@ -552,7 +588,9 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
552
588
|
case "report-publish": {
|
|
553
589
|
// report はスキーマで必須なのでバリデーション通過後は必ず存在する(if ガード不要)
|
|
554
590
|
const { report } = payload
|
|
555
|
-
|
|
591
|
+
// 全ユーザー放送: report の isRead/isStarred は操作者の状態なので、
|
|
592
|
+
// 受信者自身の per-viewer 状態を保持するマージ経路を使う
|
|
593
|
+
applyBroadcastReportUpdate(report)
|
|
556
594
|
if (payload.type === "report-create" || payload.type === "report-publish") {
|
|
557
595
|
manuallyTrackingIds.current.add(report.reportHubId)
|
|
558
596
|
upsertItem(report.reportHubId, report.date)
|
|
@@ -651,9 +689,11 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
651
689
|
} catch (e) {
|
|
652
690
|
logger.error(`${intent} failed:`, e)
|
|
653
691
|
// 切替後の失敗も同様: onFailure のロールバックが旧値をモジュールキャッシュへ
|
|
654
|
-
// 書き戻すため放棄する (プロバイダー自身の state は key リマウントで破棄済み)
|
|
692
|
+
// 書き戻すため放棄する (プロバイダー自身の state は key リマウントで破棄済み)。
|
|
693
|
+
// 通知も同じ門で守る: ユーザー切替で捨てた継続の失敗は当人に見せない
|
|
655
694
|
if (isSessionStillOurs()) {
|
|
656
695
|
callbacks.onFailure?.()
|
|
696
|
+
notifyErrorRef.current({ operation: intent, message: describeOperationFailure(intent), cause: e })
|
|
657
697
|
}
|
|
658
698
|
throw e
|
|
659
699
|
} finally {
|
|
@@ -1028,7 +1068,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
1028
1068
|
if (targetItem) {
|
|
1029
1069
|
setItems((prev) => {
|
|
1030
1070
|
if (prev.some((i) => i.reportHubId === id)) return prev
|
|
1031
|
-
|
|
1071
|
+
// ロールバックも正準比較器の位置へ戻す (第 3 の比較器を持たない)
|
|
1072
|
+
return upsertInIdsStreamOrder(prev, targetItem)
|
|
1032
1073
|
})
|
|
1033
1074
|
}
|
|
1034
1075
|
}
|
|
@@ -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 グローバル変数の読み取り)", () => {
|
|
@@ -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.
|
|
@@ -35,7 +35,7 @@ const ndjson = (lines: unknown[]): string => lines.map((line) => `${JSON.stringi
|
|
|
35
35
|
const item = (reportHubId: number, businessDate: string | null = "2026-08-01") => ({ reportHubId, businessDate, sourceType: "Internal" })
|
|
36
36
|
|
|
37
37
|
/** テストハーネス: 応答キュー + 手動タイマー + クライアント組み立て。 */
|
|
38
|
-
const makeHarness = (factories: Array<(init: { signal: AbortSignal }) => Response | Promise<Response>>, options: { publishCoalesceMs?: number } = {}) => {
|
|
38
|
+
const makeHarness = (factories: Array<(init: { signal: AbortSignal }) => Response | Promise<Response>>, options: { publishCoalesceMs?: number; readStallTimeoutMs?: number } = {}) => {
|
|
39
39
|
const fetchUrls: string[] = []
|
|
40
40
|
const fetchFn = vi.fn(async (input: string, init: { signal: AbortSignal }) => {
|
|
41
41
|
fetchUrls.push(input)
|
|
@@ -58,6 +58,9 @@ const makeHarness = (factories: Array<(init: { signal: AbortSignal }) => Respons
|
|
|
58
58
|
// 既存テストは publish タイミングの決定性を優先して合体を無効化する
|
|
59
59
|
// (合体挙動そのものは専用テストで publishCoalesceMs を指定して検証する)
|
|
60
60
|
publishCoalesceMs: options.publishCoalesceMs ?? 0,
|
|
61
|
+
// stall ウォッチドッグも既定で無効化する (recordedDelays に混ざらないよう)。
|
|
62
|
+
// stall 検知そのものは専用テストで readStallTimeoutMs を指定して検証する
|
|
63
|
+
readStallTimeoutMs: options.readStallTimeoutMs ?? 0,
|
|
61
64
|
})
|
|
62
65
|
|
|
63
66
|
/** 状態が条件を満たすまでマイクロタスクを回し、保留タイマーを逐次発火する。 */
|
|
@@ -639,3 +642,63 @@ describe("DailyReportIdsStreamClient / 破棄", () => {
|
|
|
639
642
|
expect(harness.fetchFn).toHaveBeenCalledTimes(1)
|
|
640
643
|
})
|
|
641
644
|
})
|
|
645
|
+
|
|
646
|
+
describe("DailyReportIdsStreamClient / stall ウォッチドッグ", () => {
|
|
647
|
+
/** 初期チャンクを流した後、close せず read を永久保留にする (half-open 接続の模擬)。 */
|
|
648
|
+
const hangingResponse = (chunks: string[]): Response => {
|
|
649
|
+
const encoder = new TextEncoder()
|
|
650
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
651
|
+
start(controller) {
|
|
652
|
+
for (const chunk of chunks) {
|
|
653
|
+
controller.enqueue(encoder.encode(chunk))
|
|
654
|
+
}
|
|
655
|
+
// 意図的に close しない → 後続 read は解決も reject もしない
|
|
656
|
+
},
|
|
657
|
+
})
|
|
658
|
+
return new Response(stream, { status: 200, headers: { "Content-Type": "application/x-ndjson" } })
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
it("無音が続く half-open 接続を検知し、カーソル再開で自己回復する", async () => {
|
|
662
|
+
const harness = makeHarness(
|
|
663
|
+
[
|
|
664
|
+
// 1 本目: 先頭ページを届けた後ハング (isLast 無し・close 無し)
|
|
665
|
+
() => hangingResponse([ndjson([{ items: [item(2)], nextCursor: { businessDate: "2026-08-01", reportHubId: 2 }, isLast: false, total: 2 }])]),
|
|
666
|
+
// 2 本目 (再開): 残りを届けて complete
|
|
667
|
+
() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true }])]),
|
|
668
|
+
],
|
|
669
|
+
{ readStallTimeoutMs: 30_000 },
|
|
670
|
+
)
|
|
671
|
+
harness.client.start()
|
|
672
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
673
|
+
|
|
674
|
+
const state = harness.client.getState()
|
|
675
|
+
// 受信済みデータは保持され、再開で全量に到達する
|
|
676
|
+
expect(state.items.map((i) => i.reportHubId)).toEqual([2, 1])
|
|
677
|
+
expect(state.error).toBeNull()
|
|
678
|
+
// 2 本目はカーソル再開 (先頭ページの nextCursor を採用)
|
|
679
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(2)
|
|
680
|
+
expect(harness.fetchUrls[1]).toBe("/daily_report/api/ids-stream?cursor_report_hub_id=2&cursor_business_date=2026-08-01")
|
|
681
|
+
// stall タイマー (30s) が実際に登録されている
|
|
682
|
+
expect(harness.recordedDelays).toContain(30_000)
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it("readStallTimeoutMs=0 では stall タイマーを張らない (正常系は素の read)", async () => {
|
|
686
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])])], { readStallTimeoutMs: 0 })
|
|
687
|
+
harness.client.start()
|
|
688
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
689
|
+
expect(harness.client.getState().loadedCount).toBe(1)
|
|
690
|
+
// 無効化時は read 監視タイマーを一切登録しない
|
|
691
|
+
expect(harness.recordedDelays).not.toContain(30_000)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it("データが流れている間は誤検知しない (read 勝利でタイマーは解除される)", async () => {
|
|
695
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(3), item(2)], nextCursor: { businessDate: "2026-08-01", reportHubId: 2 }, isLast: false, total: 3 }]), ndjson([{ items: [item(1)], nextCursor: null, isLast: true }])])], {
|
|
696
|
+
readStallTimeoutMs: 30_000,
|
|
697
|
+
})
|
|
698
|
+
harness.client.start()
|
|
699
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
700
|
+
// 正常完走: 再接続は発生しない (stall 誤検知なし)
|
|
701
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(1)
|
|
702
|
+
expect(harness.client.getState().items.map((i) => i.reportHubId)).toEqual([3, 2, 1])
|
|
703
|
+
})
|
|
704
|
+
})
|