@aiquants/daily-report 0.7.0 → 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 +363 -21
- package/dist/client.d.ts +363 -21
- 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 +76 -2
- package/dist/index.d.ts +76 -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 +2 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +8 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +7 -5
- package/dist/server.mjs.map +1 -1
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +3 -3
- 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-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/contexts/daily-report-action-context.spec.tsx +262 -0
- package/src/client/contexts/daily-report-action-context.tsx +149 -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/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.ts +4 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.ids-stream.spec.ts +362 -0
- package/src/server/handlers.ts +245 -5
- package/src/server/service.ts +40 -0
- package/src/server/test-helpers/handlers-config.ts +40 -1
- package/src/shared/ids-stream.ts +92 -0
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
* 日報の楽観的 CRUD と SSE 同期を提供するアクションコンテキスト。
|
|
4
4
|
*/
|
|
5
5
|
import { type Context, createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"
|
|
6
|
-
import { useRevalidator } from "react-router"
|
|
7
6
|
import { createLogger, LogLevel } from "../../shared/logger"
|
|
8
7
|
import type { DailyReportSseMessage } from "../../shared/sse-schema"
|
|
9
8
|
import type { DailyReportCommentItem, DailyReportDetail, DailyReportItem, DailyReportUser } from "../../shared/types"
|
|
10
9
|
import { useDailyReportConfig } from "../config-context"
|
|
11
10
|
import { acquireMutationLock, applyDailyReportServerUpdates, clearDailyReportCache, DailyReportCache, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
|
|
12
11
|
import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
|
|
12
|
+
import { getDailyReportIdsStreamSessionUserKey, refreshDailyReportIdsStream, removeDailyReportIdsStreamItem, upsertDailyReportIdsStreamItem, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
|
|
13
13
|
|
|
14
14
|
const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
|
|
15
15
|
|
|
@@ -25,7 +25,7 @@ type ActionResponse = {
|
|
|
25
25
|
[key: string]: unknown
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
type DailyReportActionContextType = {
|
|
28
|
+
export type DailyReportActionContextType = {
|
|
29
29
|
/** 表示中ユーザー (楽観的コメントの作者名等に使用)。 */
|
|
30
30
|
user?: DailyReportUser
|
|
31
31
|
/** reportHubId -> Map<tempCommentId, content> (追加コメントの一時IDと内容) */
|
|
@@ -137,18 +137,35 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
137
137
|
/** 自分が発行したアクションのID(SSEエコー無視用) */
|
|
138
138
|
const processedClientTempIds = useRef<Set<string>>(new Set())
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
// トゥームストーン掃除のゲート参照。ストリーミング途中の部分配列で掃除すると、
|
|
141
|
+
// 後続チャンク到着前に削除された ID のトゥームストーンが失われ、遅れて届いた
|
|
142
|
+
// 削除前スナップショットのチャンクで日報が復活する (effect の依存を増やさないため ref 経由)
|
|
143
|
+
const streamState = useDailyReportIdsStream()
|
|
144
|
+
const isStreamSettledRef = useRef(false)
|
|
145
|
+
isStreamSettledRef.current = streamState.phase === "complete" && !streamState.isRevalidating
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 遅延解決するミューテーション継続の門番。POST /action の待機中にリロード無しの
|
|
149
|
+
* ユーザー切替が起きた場合、この判定が偽になり、継続はモジュール常駐状態
|
|
150
|
+
* (常駐ストリームセッション・DailyReportCache・ミューテーションロック) への
|
|
151
|
+
* 書き込みを放棄する。放棄しないと、旧ユーザーの日報行が新ユーザーの一覧へ
|
|
152
|
+
* 注入され (観測集合に刻まれて完走剪定も免れる)、切替時に破棄したはずの
|
|
153
|
+
* 詳細キャッシュへ旧ユーザーの下書き本文が再シードされる (実測再現済み)。
|
|
154
|
+
* 同一ユーザーの通常ナビゲーション (アンマウント) ではセッションが生き続けるため
|
|
155
|
+
* 真のままであり、作成直後の日報が常駐データから欠ける round-3 の退行は起こさない。
|
|
156
|
+
*/
|
|
157
|
+
const isSessionStillOurs = useCallback(() => getDailyReportIdsStreamSessionUserKey() === (userId ?? null), [userId])
|
|
141
158
|
|
|
142
159
|
const refetchData = useCallback(async () => {
|
|
143
160
|
logger.info("[DailyReportActionProvider] Refetching data without page reload...")
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
161
|
+
// ID 一覧はストリームセッションのバックグラウンド再検証で更新する
|
|
162
|
+
// (ルーター再検証は dailyReportShouldRevalidate に抑止されるため使わない)
|
|
163
|
+
refreshDailyReportIdsStream()
|
|
147
164
|
setVersion((v) => v + 1)
|
|
148
165
|
DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
|
|
149
166
|
listener(-1)
|
|
150
167
|
})
|
|
151
|
-
}, [
|
|
168
|
+
}, [])
|
|
152
169
|
|
|
153
170
|
const clearCacheAndRefetch = useCallback(async () => {
|
|
154
171
|
logger.info("[DailyReportActionProvider] Clearing client & server/DB cache and refetching data without page reload...")
|
|
@@ -175,15 +192,16 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
175
192
|
logger.warn("[DailyReportActionProvider] Failed to clear server-side cache:", e)
|
|
176
193
|
}
|
|
177
194
|
|
|
178
|
-
// 3.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
195
|
+
// 3. ID 一覧ストリームをサーバーキャッシュバイパスで再検証 & UI 再描画。
|
|
196
|
+
// POST 待機中にユーザー切替が起きた場合は放棄する (新ユーザーのストリームを
|
|
197
|
+
// 冗長に強制再ダウンロードさせない)
|
|
198
|
+
if (!isSessionStillOurs()) return
|
|
199
|
+
refreshDailyReportIdsStream({ forceRefresh: true })
|
|
182
200
|
setVersion((v) => v + 1)
|
|
183
201
|
DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
|
|
184
202
|
listener(-1)
|
|
185
203
|
})
|
|
186
|
-
}, [apiBasePath,
|
|
204
|
+
}, [apiBasePath, isSessionStillOurs])
|
|
187
205
|
|
|
188
206
|
/**
|
|
189
207
|
* Queue for SSE messages that arrived before the target report was cached.
|
|
@@ -281,6 +299,11 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
281
299
|
* レポートアイテムをリストへ upsert し、businessDate 降順を維持します。
|
|
282
300
|
*/
|
|
283
301
|
const upsertItem = useCallback((reportHubId: number, businessDate: string | null | undefined) => {
|
|
302
|
+
// モジュール常駐ストリームセッションへも反映する (鮮度窓内の再訪で作成直後の日報が
|
|
303
|
+
// 消えないように)。businessDate 未指定は「既存値を保持」の意図なので中継しない
|
|
304
|
+
if (businessDate !== undefined) {
|
|
305
|
+
upsertDailyReportIdsStreamItem({ reportHubId, businessDate: businessDate ?? null })
|
|
306
|
+
}
|
|
284
307
|
setItems((prev) => {
|
|
285
308
|
const nextBusinessDate = businessDate ?? prev.find((item) => item.reportHubId === reportHubId)?.businessDate ?? null
|
|
286
309
|
const exists = prev.some((item) => item.reportHubId === reportHubId)
|
|
@@ -304,59 +327,101 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
304
327
|
* Remove a stale item from the list as a safety net.
|
|
305
328
|
* フェッチ後にレポートが見つからなかった場合の安全弁としてアイテムを除去する。
|
|
306
329
|
*/
|
|
307
|
-
const removeStaleItem = useCallback(
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
330
|
+
const removeStaleItem = useCallback(
|
|
331
|
+
(reportHubId: number) => {
|
|
332
|
+
// ユーザー切替後の遅延発火は新ユーザーのセッションへトゥームストーンを刻んでしまう
|
|
333
|
+
if (!isSessionStillOurs()) return
|
|
334
|
+
removedIds.current.add(reportHubId)
|
|
335
|
+
// 常駐ストリームデータからも除去する (再訪時に安全弁が再発動し続けるのを防ぐ)
|
|
336
|
+
removeDailyReportIdsStreamItem(reportHubId)
|
|
337
|
+
setItems((prev) => prev.filter((item) => item.reportHubId !== reportHubId))
|
|
338
|
+
},
|
|
339
|
+
[isSessionStillOurs],
|
|
340
|
+
)
|
|
311
341
|
|
|
312
342
|
/**
|
|
313
|
-
* Merge initialItems with prevItems instead of replacing.
|
|
343
|
+
* Merge initialItems with prevItems instead of replacing (render-phase derived state).
|
|
314
344
|
* Items already in the list are kept even if temporarily absent from the API response.
|
|
315
345
|
* Removal is handled exclusively by the report-delete SSE handler.
|
|
316
346
|
*
|
|
317
|
-
* initialItems と prevItems
|
|
318
|
-
*
|
|
319
|
-
*
|
|
347
|
+
* initialItems と prevItems をマージする (全置換ではない)。render-phase の
|
|
348
|
+
* 派生状態パターン (prev 比較 + render 中の setState) で行うことが重要 —
|
|
349
|
+
* effect で行うと「最初のチャンクのコミットは items が空 (VirtualScroll が
|
|
350
|
+
* 一瞬 No items を描画) → effect 後の 2 コミット目で行が出る」という
|
|
351
|
+
* 1 コミット分の遅延と空表示フラッシュが生まれる。render 中の setState は
|
|
352
|
+
* React が子のコミット前に同一コンポーネントを再レンダーする公式パターン。
|
|
353
|
+
* マージは純粋 (ref は読み取りのみ)。StrictMode の二重レンダーにも冪等。
|
|
320
354
|
*/
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
// initialItems
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
removedIds.current.delete(id)
|
|
355
|
+
const [prevInitialItems, setPrevInitialItems] = useState(initialItems)
|
|
356
|
+
if (initialItems !== prevInitialItems) {
|
|
357
|
+
setPrevInitialItems(initialItems)
|
|
358
|
+
// initialItems をベースにマップを構築 (removedIds を除外)
|
|
359
|
+
const mergedMap = new Map<number, DailyReportItem>()
|
|
360
|
+
for (const item of initialItems) {
|
|
361
|
+
if (!removedIds.current.has(item.reportHubId)) {
|
|
362
|
+
mergedMap.set(item.reportHubId, item)
|
|
330
363
|
}
|
|
331
364
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
for (const item of initialItems) {
|
|
337
|
-
if (!removedIds.current.has(item.reportHubId)) {
|
|
338
|
-
mergedMap.set(item.reportHubId, item)
|
|
339
|
-
}
|
|
365
|
+
// 現在の items にのみ存在する ID を保持 (削除は report-delete SSE で行う)
|
|
366
|
+
for (const item of items) {
|
|
367
|
+
if (!(mergedMap.has(item.reportHubId) || removedIds.current.has(item.reportHubId))) {
|
|
368
|
+
mergedMap.set(item.reportHubId, item)
|
|
340
369
|
}
|
|
370
|
+
}
|
|
371
|
+
// businessDate 降順でソート。正規化済み YYYY-MM-DD は辞書順 = 時系列順なので
|
|
372
|
+
// 文字列比較で足りる (Date 構築はチャンク到着ごとの全件ソートで比較 1 回あたり
|
|
373
|
+
// 2 個生成され、数万件 × 数十チャンクで数百万回の構築になる)。null は末尾。
|
|
374
|
+
// ストリームはソート順どおりに届き末尾へ追記されるため、通常は整列済み —
|
|
375
|
+
// O(n) の検査で確認できたらソートを丸ごと省略する (20 万件級で毎 publish の
|
|
376
|
+
// O(n log n) を O(n) へ落とす)
|
|
377
|
+
const merged = Array.from(mergedMap.values())
|
|
378
|
+
let isSorted = true
|
|
379
|
+
for (let i = 1; i < merged.length; i++) {
|
|
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
|
+
})
|
|
392
|
+
}
|
|
393
|
+
setItems(merged)
|
|
394
|
+
}
|
|
341
395
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
396
|
+
/**
|
|
397
|
+
* トゥームストーン (removedIds) のクリーンアップ副作用。
|
|
398
|
+
* ストリームの全量が出揃っている (complete かつ再検証中でない) ときにのみ行う。
|
|
399
|
+
* 部分配列からの欠落は「サーバーに存在しない」証拠にならない。
|
|
400
|
+
* 依存配列: initialItems (チャンク到着・再検証ごと)。クリーンアップ: なし。
|
|
401
|
+
* ref の変異を伴うため render-phase ではなく effect に置く。
|
|
402
|
+
*/
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
if (isStreamSettledRef.current) {
|
|
405
|
+
const initialIds = new Set(initialItems.map((item) => item.reportHubId))
|
|
406
|
+
// Set のイテレーション中の delete は JS 仕様で安全 (visited は再訪問しない)
|
|
407
|
+
for (const id of removedIds.current) {
|
|
408
|
+
if (!initialIds.has(id)) {
|
|
409
|
+
removedIds.current.delete(id)
|
|
346
410
|
}
|
|
347
411
|
}
|
|
348
|
-
|
|
349
|
-
// businessDate 降順でソート
|
|
350
|
-
return Array.from(mergedMap.values()).sort((a, b) => {
|
|
351
|
-
const dateA = a.businessDate ? new Date(a.businessDate).getTime() : 0
|
|
352
|
-
const dateB = b.businessDate ? new Date(b.businessDate).getTime() : 0
|
|
353
|
-
return dateB - dateA
|
|
354
|
-
})
|
|
355
|
-
})
|
|
412
|
+
}
|
|
356
413
|
}, [initialItems])
|
|
357
414
|
|
|
358
415
|
const handleSseMessage = useCallback(
|
|
359
416
|
(payload: Exclude<DailyReportSseMessage, { type: "connected" }>) => {
|
|
417
|
+
// セッション切替〜プロバイダーアンマウントの隙間で届いた旧ユーザーの SSE は破棄する。
|
|
418
|
+
// clientLoader の bootstrap は React のコミット前にモジュールセッションを新ユーザーへ
|
|
419
|
+
// 差し替えるため、旧プロバイダーの EventSource が閉じるまでの間に届いたメッセージ
|
|
420
|
+
// (下書きの recipient 宛配信を含む) が、破棄済みキャッシュと新セッションへ
|
|
421
|
+
// 旧ユーザーのデータを書き込んでしまう (POST 継続と同じ汚染シグネチャ・実測再現済み)
|
|
422
|
+
if (!isSessionStillOurs()) {
|
|
423
|
+
return
|
|
424
|
+
}
|
|
360
425
|
// Ignore echo of own actions
|
|
361
426
|
// 自分が発行したアクションのエコーは無視する
|
|
362
427
|
// clientTempId は connected 以外すべてのメッセージに存在する(型で保証済み)
|
|
@@ -463,6 +528,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
463
528
|
manuallyTrackingIds.current.delete(reportHubId)
|
|
464
529
|
// マージロジックでの復活を防止するために削除済み ID を記録
|
|
465
530
|
removedIds.current.add(reportHubId)
|
|
531
|
+
// 常駐ストリームデータからも除去 (鮮度窓内の再訪で削除済み日報が蘇らないように)
|
|
532
|
+
removeDailyReportIdsStreamItem(reportHubId)
|
|
466
533
|
|
|
467
534
|
// 削除されたレポートへの待機中 SSE メッセージを破棄
|
|
468
535
|
const pendingEntry = pendingSseQueue.current.get(reportHubId)
|
|
@@ -495,7 +562,7 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
495
562
|
}
|
|
496
563
|
}
|
|
497
564
|
},
|
|
498
|
-
[isProcessedId, upsertItem, user, enqueuePendingSseMessage],
|
|
565
|
+
[isProcessedId, isSessionStillOurs, upsertItem, user, enqueuePendingSseMessage],
|
|
499
566
|
)
|
|
500
567
|
|
|
501
568
|
// 常に最新の handleSseMessage をキューのリプレイから参照できるようにする
|
|
@@ -574,10 +641,20 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
574
641
|
if (typeof data.clientTempId !== "string" || data.clientTempId !== clientTempId) {
|
|
575
642
|
throw new Error(`Invalid clientTempId for ${intent}`)
|
|
576
643
|
}
|
|
644
|
+
// ユーザー切替後に解決した継続は、モジュール常駐状態 (常駐セッション・詳細キャッシュ)
|
|
645
|
+
// を旧ユーザーのデータで汚染しないよう onSuccess を放棄する
|
|
646
|
+
if (!isSessionStillOurs()) {
|
|
647
|
+
logger.warn(`[executeAction] ${intent} resolved after a user switch; discarding continuation`)
|
|
648
|
+
throw new Error(`Stale continuation for ${intent}`)
|
|
649
|
+
}
|
|
577
650
|
return (await callbacks.onSuccess?.(data)) as T
|
|
578
651
|
} catch (e) {
|
|
579
652
|
logger.error(`${intent} failed:`, e)
|
|
580
|
-
|
|
653
|
+
// 切替後の失敗も同様: onFailure のロールバックが旧値をモジュールキャッシュへ
|
|
654
|
+
// 書き戻すため放棄する (プロバイダー自身の state は key リマウントで破棄済み)
|
|
655
|
+
if (isSessionStillOurs()) {
|
|
656
|
+
callbacks.onFailure?.()
|
|
657
|
+
}
|
|
581
658
|
throw e
|
|
582
659
|
} finally {
|
|
583
660
|
if (!(options?.keepLock || options?.skipLock)) {
|
|
@@ -805,6 +882,11 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
805
882
|
|
|
806
883
|
const newId = Number(res.reportHubId)
|
|
807
884
|
|
|
885
|
+
// 常駐ストリームセッションへも反映する。自分の SSE エコーは clientTempId で
|
|
886
|
+
// 抑止されるため upsertItem 経由の中継は届かず、ここで直接中継しないと
|
|
887
|
+
// 鮮度窓内の再訪で作成直後の日報が一覧から消える
|
|
888
|
+
upsertDailyReportIdsStreamItem({ reportHubId: newId, businessDate })
|
|
889
|
+
|
|
808
890
|
// newId を一時的にロックし、createReport 以前に開始された背景 prefetch の
|
|
809
891
|
// cacheMany による stale データ上書きを防止する
|
|
810
892
|
acquireMutationLock(newId)
|
|
@@ -883,6 +965,10 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
883
965
|
applyDailyReportServerUpdates(id, data.report)
|
|
884
966
|
setVersion((v) => v + 1)
|
|
885
967
|
}
|
|
968
|
+
// 成功確定後に常駐ストリームセッションへも反映する (自分の SSE エコーは
|
|
969
|
+
// 抑止されるため)。失敗時に走らせると、並行削除済みの日報を誤って
|
|
970
|
+
// 蘇らせトゥームストーンまで解除してしまう
|
|
971
|
+
upsertDailyReportIdsStreamItem({ reportHubId: id, businessDate })
|
|
886
972
|
},
|
|
887
973
|
onFailure: () => {
|
|
888
974
|
// ロールバック
|
|
@@ -902,6 +988,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
902
988
|
|
|
903
989
|
// マージロジックでの復活を防止するために削除済み ID を記録
|
|
904
990
|
removedIds.current.add(id)
|
|
991
|
+
// 常駐ストリームデータからも除去 (鮮度窓内の再訪で削除済み日報が蘇らないように)
|
|
992
|
+
removeDailyReportIdsStreamItem(id)
|
|
905
993
|
// Optimistic UI update
|
|
906
994
|
setItems((prev) => prev.filter((item) => item.reportHubId !== id))
|
|
907
995
|
|
|
@@ -926,8 +1014,17 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
926
1014
|
return
|
|
927
1015
|
}
|
|
928
1016
|
|
|
929
|
-
//
|
|
1017
|
+
// それ以外のエラーの場合は、楽観的更新をロールバックする。
|
|
1018
|
+
// 常駐ストリームデータの除去とトゥームストーンも upsert で巻き戻す
|
|
1019
|
+
// (巻き戻さないと、実在する日報が次の全量走査まで再訪時の一覧から消える)。
|
|
1020
|
+
// ユーザー切替後に解決した継続は巻き戻さない — 旧ユーザーの行を新ユーザーの
|
|
1021
|
+
// セッションへ upsert しトゥームストーンまで解除してしまうため
|
|
1022
|
+
// (元の catch と同様に握り潰す。呼び出し元 UI は既に破棄済み)
|
|
1023
|
+
if (!isSessionStillOurs()) {
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
930
1026
|
removedIds.current.delete(id)
|
|
1027
|
+
upsertDailyReportIdsStreamItem({ reportHubId: id, businessDate: targetItem?.businessDate ?? businessDate })
|
|
931
1028
|
if (targetItem) {
|
|
932
1029
|
setItems((prev) => {
|
|
933
1030
|
if (prev.some((i) => i.reportHubId === id)) return prev
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timing tests for the ease-out animated number hook.
|
|
3
|
+
* イーズアウト数値アニメーションフックのタイミングテスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定する不変条件のうち、特に重要なもの:
|
|
6
|
+
*
|
|
7
|
+
* - **経過時間の基準は最初の rAF フレームのタイムスタンプ** — effect 内の
|
|
8
|
+
* `performance.now()` 基準だと混雑時に負の進捗が生まれ 0 に張り付く
|
|
9
|
+
* (contact-map で実測された既知の不具合の再発防止)。
|
|
10
|
+
* - **初回マウントは補間せず即時表示** (カウントアップ演出をしない)。
|
|
11
|
+
* - **中断された補間は表示中の値から次の補間を始める** (跳び戻りしない)。
|
|
12
|
+
*/
|
|
13
|
+
import { act, renderHook } from "@testing-library/react"
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
15
|
+
import { useAnimatedNumber } from "./use-animated-number"
|
|
16
|
+
|
|
17
|
+
/** 手動発火の rAF キュー。 */
|
|
18
|
+
let rafQueue: FrameRequestCallback[] = []
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
rafQueue = []
|
|
22
|
+
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
|
23
|
+
rafQueue.push(callback)
|
|
24
|
+
return rafQueue.length
|
|
25
|
+
})
|
|
26
|
+
vi.stubGlobal("cancelAnimationFrame", vi.fn())
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.unstubAllGlobals()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** 保留中の rAF を 1 フレームだけ指定タイムスタンプで発火する。 */
|
|
34
|
+
const fireFrame = (timestamp: number) => {
|
|
35
|
+
const frame = rafQueue.shift()
|
|
36
|
+
act(() => {
|
|
37
|
+
frame?.(timestamp)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("useAnimatedNumber", () => {
|
|
42
|
+
it("初回マウントは補間せず即時に値を表示する", () => {
|
|
43
|
+
const { result } = renderHook(() => useAnimatedNumber(42))
|
|
44
|
+
expect(result.current).toBe(42)
|
|
45
|
+
expect(rafQueue).toHaveLength(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("値の変化をイーズアウト 3 次で補間する (基準は最初のフレーム時刻)", () => {
|
|
49
|
+
const { result, rerender } = renderHook(({ value }) => useAnimatedNumber(value, 100), { initialProps: { value: 0 } })
|
|
50
|
+
rerender({ value: 100 })
|
|
51
|
+
|
|
52
|
+
// 最初のフレームが基準になる (進捗 0)
|
|
53
|
+
fireFrame(5_000)
|
|
54
|
+
expect(result.current).toBe(0)
|
|
55
|
+
|
|
56
|
+
// 50ms 経過 → eased = 1 - 0.5^3 = 0.875
|
|
57
|
+
fireFrame(5_050)
|
|
58
|
+
expect(result.current).toBe(88)
|
|
59
|
+
|
|
60
|
+
// 100ms 経過 → 完了
|
|
61
|
+
fireFrame(5_100)
|
|
62
|
+
expect(result.current).toBe(100)
|
|
63
|
+
expect(rafQueue).toHaveLength(0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("基準フレームより過去のような大きなタイムスタンプでも進捗は 0-1 にクランプされる", () => {
|
|
67
|
+
const { result, rerender } = renderHook(({ value }) => useAnimatedNumber(value, 100), { initialProps: { value: 0 } })
|
|
68
|
+
rerender({ value: 100 })
|
|
69
|
+
fireFrame(5_000)
|
|
70
|
+
// 大幅超過は 1 へクランプ
|
|
71
|
+
fireFrame(99_999)
|
|
72
|
+
expect(result.current).toBe(100)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("補間の中断は表示中の値から次の補間を開始する (跳び戻りしない)", () => {
|
|
76
|
+
const { result, rerender } = renderHook(({ value }) => useAnimatedNumber(value, 100), { initialProps: { value: 0 } })
|
|
77
|
+
rerender({ value: 100 })
|
|
78
|
+
fireFrame(1_000)
|
|
79
|
+
fireFrame(1_050)
|
|
80
|
+
expect(result.current).toBe(88)
|
|
81
|
+
|
|
82
|
+
// 補間途中で目標値を更新 → 旧アニメーションは破棄され、88 を起点に 200 へ向かう
|
|
83
|
+
rafQueue.length = 0
|
|
84
|
+
rerender({ value: 200 })
|
|
85
|
+
expect(result.current).toBe(88)
|
|
86
|
+
|
|
87
|
+
// 第 2 アニメーションの基準フレーム (進捗 0 = 起点 88 のまま)
|
|
88
|
+
fireFrame(2_000)
|
|
89
|
+
expect(result.current).toBe(88)
|
|
90
|
+
// 50ms 経過 → eased 0.875 → 88 + (200 - 88) × 0.875 = 186
|
|
91
|
+
fireFrame(2_050)
|
|
92
|
+
expect(result.current).toBe(186)
|
|
93
|
+
// 完了 → 200
|
|
94
|
+
fireFrame(2_100)
|
|
95
|
+
expect(result.current).toBe(200)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("同値への再レンダーはアニメーションを起こさない", () => {
|
|
99
|
+
const { rerender } = renderHook(({ value }) => useAnimatedNumber(value, 100), { initialProps: { value: 7 } })
|
|
100
|
+
rerender({ value: 7 })
|
|
101
|
+
expect(rafQueue).toHaveLength(0)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ease-out animated number for streaming counters.
|
|
3
|
+
* ストリーミングカウンター向けのイーズアウト数値アニメーションフック。
|
|
4
|
+
*
|
|
5
|
+
* 経過時間の基準は「最初の rAF フレームのタイムスタンプ」にする。effect 内の
|
|
6
|
+
* `performance.now()` を基準にすると、メインスレッド混雑時に保留中フレームの
|
|
7
|
+
* vsync 時刻が基準より過去になり、負の進捗が 3 乗されて 0 に張り付く
|
|
8
|
+
* (contact-map で実測された既知の不具合)。進捗は念のため 0 でもクランプする。
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef, useState } from "react"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Animates numeric changes with ease-out cubic.
|
|
14
|
+
* 数値変化をイーズアウト 3 次で補間するフック。
|
|
15
|
+
*
|
|
16
|
+
* @param value Target value. 目標値。
|
|
17
|
+
* @param durationMs Animation duration. アニメーション時間 (既定 250ms)。
|
|
18
|
+
* @returns Displayed (interpolated) value. 表示用の補間値。
|
|
19
|
+
*/
|
|
20
|
+
export const useAnimatedNumber = (value: number, durationMs = 250): number => {
|
|
21
|
+
const [display, setDisplay] = useState(value)
|
|
22
|
+
// 表示中の値の同期ミラー (アニメーション中断時の次回開始点に使う)
|
|
23
|
+
const displayRef = useRef(value)
|
|
24
|
+
const isFirstRef = useRef(true)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Animation effect.
|
|
28
|
+
* アニメーション副作用。
|
|
29
|
+
*
|
|
30
|
+
* 依存配列:
|
|
31
|
+
* - value: 目標値の変化でアニメーションを開始する
|
|
32
|
+
* - durationMs: 時間変更で再スケジュール
|
|
33
|
+
* クリーンアップ:
|
|
34
|
+
* - 実行中の rAF をキャンセルする (中断時は displayRef が現在値を保持)
|
|
35
|
+
*/
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
// 初回は補間せず即時表示する (マウント時のカウントアップ演出は不要)
|
|
38
|
+
if (isFirstRef.current) {
|
|
39
|
+
isFirstRef.current = false
|
|
40
|
+
displayRef.current = value
|
|
41
|
+
setDisplay(value)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const from = displayRef.current
|
|
45
|
+
if (from === value) return
|
|
46
|
+
|
|
47
|
+
let rafId: number | null = null
|
|
48
|
+
let startTimestamp: number | null = null
|
|
49
|
+
const step = (timestamp: number) => {
|
|
50
|
+
if (startTimestamp === null) {
|
|
51
|
+
startTimestamp = timestamp
|
|
52
|
+
}
|
|
53
|
+
const progress = Math.min(1, Math.max(0, (timestamp - startTimestamp) / durationMs))
|
|
54
|
+
const eased = 1 - (1 - progress) ** 3
|
|
55
|
+
const next = Math.round(from + (value - from) * eased)
|
|
56
|
+
displayRef.current = next
|
|
57
|
+
setDisplay(next)
|
|
58
|
+
if (progress < 1) {
|
|
59
|
+
rafId = requestAnimationFrame(step)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
rafId = requestAnimationFrame(step)
|
|
63
|
+
return () => {
|
|
64
|
+
if (rafId !== null) cancelAnimationFrame(rafId)
|
|
65
|
+
}
|
|
66
|
+
}, [value, durationMs])
|
|
67
|
+
|
|
68
|
+
return display
|
|
69
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap-ordering tests for the daily-report clientLoader factory.
|
|
3
|
+
* 日報 clientLoader 工場の起動順序テスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定する不変条件のうち、特に重要なもの:
|
|
6
|
+
*
|
|
7
|
+
* - **prime は serverLoader の解決を待たずに先行する** (再訪の再検証前倒し)。
|
|
8
|
+
* - **ローダー時点でセッションを bootstrap する** (コールドロードの最重要最適化 —
|
|
9
|
+
* ページマウントの effect まで生成を遅らせると、ストリーム開始がハイドレーション
|
|
10
|
+
* 完了の後ろに直列化される)。
|
|
11
|
+
* - **`hydrate = true`** (初期ハイドレーションでも実行される契約)。
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it, vi } from "vitest"
|
|
14
|
+
|
|
15
|
+
const calls: string[] = []
|
|
16
|
+
const bootstrapMock = vi.fn((options: { apiBasePath?: string; userKey: string | null }) => {
|
|
17
|
+
calls.push(`bootstrap:${options.userKey}`)
|
|
18
|
+
})
|
|
19
|
+
const primeMock = vi.fn(() => {
|
|
20
|
+
calls.push("prime")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
vi.mock("./streaming/daily-report-ids-stream-session", () => ({
|
|
24
|
+
bootstrapDailyReportIdsStreamSession: (options: { apiBasePath?: string; userKey: string | null }) => bootstrapMock(options),
|
|
25
|
+
primeDailyReportIdsStreamSession: () => primeMock(),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
import { createDailyReportClientLoader, dailyReportShouldRevalidate } from "./route-helpers"
|
|
29
|
+
|
|
30
|
+
describe("createDailyReportClientLoader", () => {
|
|
31
|
+
it("prime → serverLoader → bootstrap(userKey) の順で起動し、serverData を素通しする", async () => {
|
|
32
|
+
calls.length = 0
|
|
33
|
+
const clientLoader = createDailyReportClientLoader<{ userId: string | null; extra: number }>()
|
|
34
|
+
const serverLoader = vi.fn(async () => {
|
|
35
|
+
calls.push("serverLoader")
|
|
36
|
+
return { userId: "ENC8", extra: 42 }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const result = await clientLoader({ serverLoader })
|
|
40
|
+
|
|
41
|
+
expect(calls).toEqual(["prime", "serverLoader", "bootstrap:ENC8"])
|
|
42
|
+
expect(result).toEqual({ userId: "ENC8", extra: 42 })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("userId が文字列でない serverData は userKey null で起動する", async () => {
|
|
46
|
+
bootstrapMock.mockClear()
|
|
47
|
+
const clientLoader = createDailyReportClientLoader()
|
|
48
|
+
await clientLoader({ serverLoader: async () => ({ userId: null }) })
|
|
49
|
+
expect(bootstrapMock).toHaveBeenCalledWith({ apiBasePath: undefined, userKey: null })
|
|
50
|
+
|
|
51
|
+
await clientLoader({ serverLoader: async () => ({}) })
|
|
52
|
+
expect(bootstrapMock).toHaveBeenLastCalledWith({ apiBasePath: undefined, userKey: null })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("factory の apiBasePath オプションは bootstrap へ透過する", async () => {
|
|
56
|
+
bootstrapMock.mockClear()
|
|
57
|
+
const clientLoader = createDailyReportClientLoader({ apiBasePath: "/custom/api" })
|
|
58
|
+
await clientLoader({ serverLoader: async () => ({ userId: "ENC9" }) })
|
|
59
|
+
expect(bootstrapMock).toHaveBeenCalledWith({ apiBasePath: "/custom/api", userKey: "ENC9" })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("hydrate フラグが立っている (初期ハイドレーションでも実行される契約)", () => {
|
|
63
|
+
const clientLoader = createDailyReportClientLoader()
|
|
64
|
+
expect(clientLoader.hydrate).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe("dailyReportShouldRevalidate", () => {
|
|
69
|
+
/** 最小限の引数組み立て。 */
|
|
70
|
+
const makeArgs = (over: Record<string, unknown> = {}) =>
|
|
71
|
+
({
|
|
72
|
+
defaultShouldRevalidate: true,
|
|
73
|
+
currentUrl: new URL("http://localhost/daily_report"),
|
|
74
|
+
nextUrl: new URL("http://localhost/daily_report"),
|
|
75
|
+
actionResult: undefined,
|
|
76
|
+
actionStatus: undefined,
|
|
77
|
+
formAction: undefined,
|
|
78
|
+
formData: undefined,
|
|
79
|
+
formEncType: undefined,
|
|
80
|
+
formMethod: undefined,
|
|
81
|
+
json: undefined,
|
|
82
|
+
text: undefined,
|
|
83
|
+
...over,
|
|
84
|
+
// biome-ignore lint/suspicious/noExplicitAny: ShouldRevalidateFunctionArgs の完全構築はテストに不要
|
|
85
|
+
}) as any
|
|
86
|
+
|
|
87
|
+
it("同一 URL かつ送信シグナル無しは再検証しない", () => {
|
|
88
|
+
expect(dailyReportShouldRevalidate(makeArgs())).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("URL が変われば既定に従う", () => {
|
|
92
|
+
expect(dailyReportShouldRevalidate(makeArgs({ nextUrl: new URL("http://localhost/daily_report?businessDate=2026-08-01") }))).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("ミューテーション intent は再検証しない (SSE が伝搬を担う)", () => {
|
|
96
|
+
const formData = new FormData()
|
|
97
|
+
formData.set("intent", "toggleRead")
|
|
98
|
+
expect(dailyReportShouldRevalidate(makeArgs({ formData }))).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
})
|