@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/daily-report",
3
- "version": "0.9.0",
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/swipe-overlay": "^1.2.4",
85
- "@aiquants/virtualscroll": "^1.23.0"
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
- const [isSseEnabled, setIsSseEnabled] = useState(!!userId)
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
- }, FETCH_DEBOUNCE_MS)
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 = 180
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