@aiquants/daily-report 0.9.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 +11 -2
- package/dist/client.d.ts +11 -2
- 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/package.json +3 -3
- package/src/client/contexts/daily-report-action-context.spec.tsx +58 -0
- package/src/client/contexts/daily-report-action-context.tsx +23 -1
- package/src/client/hooks/use-daily-report.spec.ts +57 -0
- package/src/client/hooks/use-daily-report.ts +7 -2
- package/src/client/utils/constants.ts +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiquants/daily-report",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Reusable daily-report feature package: shared types/schemas, React (Router v7) UI with virtual scrolling + optimistic updates + SSE sync, and a drizzle (mssql) server layer with DI ports (auth, user resolution, redis, external sources).",
|
|
5
5
|
"sideEffects": [
|
|
6
6
|
"**/*.css"
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
"react-dom": ">=18",
|
|
82
82
|
"react-router": "^7.0.0",
|
|
83
83
|
"zod": ">=3.25.0 <5.0.0",
|
|
84
|
-
"@aiquants/
|
|
85
|
-
"@aiquants/
|
|
84
|
+
"@aiquants/virtualscroll": "^1.23.0",
|
|
85
|
+
"@aiquants/swipe-overlay": "^1.2.4"
|
|
86
86
|
},
|
|
87
87
|
"peerDependenciesMeta": {
|
|
88
88
|
"drizzle-orm": {
|
|
@@ -276,3 +276,61 @@ describe("initialItems の既定値 (無限レンダー防止)", () => {
|
|
|
276
276
|
expect(contextRef.current?.items).toEqual([])
|
|
277
277
|
})
|
|
278
278
|
})
|
|
279
|
+
|
|
280
|
+
describe("SSE 自動接続の遅延 (初回描画へ接続枠を譲る)", () => {
|
|
281
|
+
// isSseEnabled は render 時点で読む (effect 経由の contextRef はタイマー flush と 1 ティックずれる)
|
|
282
|
+
const renderState: { current: DailyReportActionContextType | null } = { current: null }
|
|
283
|
+
const RenderCapture = () => {
|
|
284
|
+
renderState.current = useDailyReportActionContext()
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
it("初期は未接続で、遅延後に自動接続する", async () => {
|
|
289
|
+
vi.useFakeTimers()
|
|
290
|
+
try {
|
|
291
|
+
// セッションは張らない (このテストは SSE 遅延だけを見る)。userId ありで自動接続対象
|
|
292
|
+
render(
|
|
293
|
+
<DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A">
|
|
294
|
+
<RenderCapture />
|
|
295
|
+
</DailyReportActionProvider>,
|
|
296
|
+
)
|
|
297
|
+
await act(async () => {
|
|
298
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
299
|
+
})
|
|
300
|
+
// 初回描画時点では未接続 (HTTP/1.1 の接続枠を初回の中身フェッチへ譲る)
|
|
301
|
+
expect(renderState.current?.isSseEnabled).toBe(false)
|
|
302
|
+
// 遅延経過で自動接続する
|
|
303
|
+
await act(async () => {
|
|
304
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
305
|
+
})
|
|
306
|
+
expect(renderState.current?.isSseEnabled).toBe(true)
|
|
307
|
+
} finally {
|
|
308
|
+
vi.useRealTimers()
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it("手動トグルは自動接続タイマーに上書きされない", async () => {
|
|
313
|
+
vi.useFakeTimers()
|
|
314
|
+
try {
|
|
315
|
+
render(
|
|
316
|
+
<DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A">
|
|
317
|
+
<RenderCapture />
|
|
318
|
+
</DailyReportActionProvider>,
|
|
319
|
+
)
|
|
320
|
+
await act(async () => {
|
|
321
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
322
|
+
})
|
|
323
|
+
// ユーザーが手動で ON → OFF した後、遅延タイマーが発火しても手動 OFF を尊重する
|
|
324
|
+
act(() => renderState.current?.toggleSse())
|
|
325
|
+
expect(renderState.current?.isSseEnabled).toBe(true)
|
|
326
|
+
act(() => renderState.current?.toggleSse())
|
|
327
|
+
expect(renderState.current?.isSseEnabled).toBe(false)
|
|
328
|
+
await act(async () => {
|
|
329
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
330
|
+
})
|
|
331
|
+
expect(renderState.current?.isSseEnabled).toBe(false)
|
|
332
|
+
} finally {
|
|
333
|
+
vi.useRealTimers()
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
})
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "../hooks/use-daily-report"
|
|
25
25
|
import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
|
|
26
26
|
import { getDailyReportIdsStreamSessionUserKey, refreshDailyReportIdsStream, removeDailyReportIdsStreamItem, upsertDailyReportIdsStreamItem, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
|
|
27
|
+
import { SSE_INITIAL_CONNECT_DEFER_MS } from "../utils/constants"
|
|
27
28
|
import { useDailyReportErrorSurface } from "./daily-report-error-context"
|
|
28
29
|
|
|
29
30
|
const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
|
|
@@ -181,7 +182,10 @@ export const DailyReportActionProvider = ({ children, user, initialItems = EMPTY
|
|
|
181
182
|
const resolvedIdMap = useRef<Map<number, number>>(new Map())
|
|
182
183
|
const [version, setVersion] = useState(0)
|
|
183
184
|
const [items, setItems] = useState<DailyReportItem[]>(initialItems)
|
|
184
|
-
|
|
185
|
+
// SSE は初回描画とその最初の中身フェッチに接続枠を譲るため、少し遅らせて自動接続する
|
|
186
|
+
// (HTTP/1.1 の 6 接続のうち 1 枠を初回描画へ回す)。手動トグル済みなら自動接続しない
|
|
187
|
+
const [isSseEnabled, setIsSseEnabled] = useState(false)
|
|
188
|
+
const hasManualSseToggleRef = useRef(false)
|
|
185
189
|
const pendingAddPromises = useRef<Map<number, Promise<number>>>(new Map())
|
|
186
190
|
const manuallyTrackingIds = useRef<Set<number>>(new Set())
|
|
187
191
|
/** 削除済み ID を記録し、マージロジックでの復活を防止する */
|
|
@@ -369,9 +373,27 @@ export const DailyReportActionProvider = ({ children, user, initialItems = EMPTY
|
|
|
369
373
|
// 未登録ユーザーは SSE を有効化できない
|
|
370
374
|
const toggleSse = () => {
|
|
371
375
|
if (!userId) return
|
|
376
|
+
// 手動操作後は自動接続タイマーに上書きさせない
|
|
377
|
+
hasManualSseToggleRef.current = true
|
|
372
378
|
setIsSseEnabled((prev) => !prev)
|
|
373
379
|
}
|
|
374
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
|
+
|
|
375
397
|
/**
|
|
376
398
|
* Remove a stale item from the list as a safety net.
|
|
377
399
|
* フェッチ後にレポートが見つからなかった場合の安全弁としてアイテムを除去する。
|
|
@@ -201,3 +201,60 @@ describe("getGlobal behavior (window グローバル変数の読み取り)", ()
|
|
|
201
201
|
expect(mod.getCachedReport(1)).toBeNull()
|
|
202
202
|
})
|
|
203
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 }
|
|
@@ -430,6 +430,11 @@ export const useDailyReportDetail = (reportHubId: number, businessDate: string |
|
|
|
430
430
|
setIsLoading(!cached)
|
|
431
431
|
setIsRefetching(true)
|
|
432
432
|
|
|
433
|
+
// 未キャッシュ (骨組み表示中) の初回取得は即時に走らせる。デバウンスはスクロール連打の
|
|
434
|
+
// 無駄取りを抑える目的なので、スクロール churn の無い初回に効かせると純粋な遅延になる。
|
|
435
|
+
// キャッシュ済み (内容表示中) の stale 再取得だけデバウンスして churn を吸収する
|
|
436
|
+
const debounceMs = cached ? DAILY_REPORT_FETCH_DEBOUNCE_MS : 0
|
|
437
|
+
|
|
433
438
|
const timer = setTimeout(async () => {
|
|
434
439
|
if (reqRef.current.token !== token) return
|
|
435
440
|
try {
|
|
@@ -453,7 +458,7 @@ export const useDailyReportDetail = (reportHubId: number, businessDate: string |
|
|
|
453
458
|
setIsRefetching(false)
|
|
454
459
|
}
|
|
455
460
|
}
|
|
456
|
-
},
|
|
461
|
+
}, debounceMs)
|
|
457
462
|
|
|
458
463
|
return () => clearTimeout(timer)
|
|
459
464
|
}, [reportHubId, businessDate, apiBasePath])
|
|
@@ -6,7 +6,17 @@ import type { DailyReportDetailResource } from "../hooks/use-daily-report"
|
|
|
6
6
|
* Shared constants for Daily Report data fetching parameters.
|
|
7
7
|
* 日報データ取得で利用する共通パラメーター。
|
|
8
8
|
*/
|
|
9
|
-
export const DAILY_REPORT_FETCH_DEBOUNCE_MS =
|
|
9
|
+
export const DAILY_REPORT_FETCH_DEBOUNCE_MS = 120
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Delay before the provider auto-connects SSE, freeing a browser connection slot for first paint.
|
|
13
|
+
* SSE を自動接続するまでの遅延。初回描画とその中身フェッチへ接続枠を譲るための猶予。
|
|
14
|
+
*
|
|
15
|
+
* HTTP/1.1 のオリジン当たり 6 接続のうち、ids ストリームと SSE で 2 枠を常時占有する。
|
|
16
|
+
* SSE 接続を初回描画直後まで遅らせ、その 1 枠を初回の中身フェッチ (`/business-date`) に回す。
|
|
17
|
+
* リアルタイム更新の初動がこの分だけ遅れるが、その間の状態はストリーム / SWR が担保する。
|
|
18
|
+
*/
|
|
19
|
+
export const SSE_INITIAL_CONNECT_DEFER_MS = 1000
|
|
10
20
|
|
|
11
21
|
export const DEFAULT_ITEM_HEIGHT = 160
|
|
12
22
|
export const ITEM_VERTICAL_PADDING = 6
|