@aiquants/daily-report 0.4.0 → 0.5.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.
Files changed (37) hide show
  1. package/README.md +59 -0
  2. package/dist/client.d.mts +96 -4
  3. package/dist/client.d.ts +96 -4
  4. package/dist/client.js +4 -4
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +4 -4
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +2 -2
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/server.d.mts +13 -1
  15. package/dist/server.d.ts +13 -1
  16. package/dist/server.js +6 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +6 -6
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-CK7cUnEo.d.ts → sse-schema-ChCypDPj.d.ts} +34 -1
  21. package/dist/{sse-schema-yl5AaSsj.d.mts → sse-schema-Cs1oC2DP.d.mts} +34 -1
  22. package/dist/styles/daily-report.standalone.css +1 -1
  23. package/dist/{types-CVhwLhSN.d.mts → types-BNhYC2j9.d.mts} +2 -0
  24. package/dist/{types-CVhwLhSN.d.ts → types-BNhYC2j9.d.ts} +2 -0
  25. package/package.json +5 -5
  26. package/src/client/components/daily-report-detail-list.tsx +62 -50
  27. package/src/client/components/daily-report-list.tsx +15 -3
  28. package/src/client/components/daily-report-resolved-content.tsx +33 -7
  29. package/src/client/config-context.tsx +28 -1
  30. package/src/client/contexts/daily-report-action-context.tsx +53 -1
  31. package/src/client/hooks/use-daily-report.ts +23 -1
  32. package/src/server/cache.spec.ts +47 -0
  33. package/src/server/cache.ts +27 -0
  34. package/src/server/handlers.ts +4 -0
  35. package/src/server/service.ts +20 -1
  36. package/src/shared/sse-schema.ts +1 -0
  37. package/src/shared/types.ts +2 -0
@@ -3,11 +3,12 @@
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"
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 { acquireMutationLock, applyDailyReportServerUpdates, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
11
+ import { acquireMutationLock, applyDailyReportServerUpdates, clearDailyReportCache, DailyReportCache, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
11
12
  import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
12
13
 
13
14
  const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
@@ -80,6 +81,10 @@ type DailyReportActionContextType = {
80
81
  deleteReport: (reportHubId: number, businessDate: string) => Promise<void>
81
82
  /** stale アイテムをリストから除去する安全弁 */
82
83
  removeStaleItem: (reportHubId: number) => void
84
+ /** データのみリロード (ページリロードなし) */
85
+ refetchData: () => Promise<void>
86
+ /** キャッシュを破棄してデータのみ再読み込み (ページリロードなし) */
87
+ clearCacheAndRefetch: () => Promise<void>
83
88
  }
84
89
 
85
90
  // HMR / dual-bundle (ESM+CJS) でもコンテキスト identity を一意に保つため、
@@ -132,6 +137,50 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
132
137
  /** 自分が発行したアクションのID(SSEエコー無視用) */
133
138
  const processedClientTempIds = useRef<Set<string>>(new Set())
134
139
 
140
+ const revalidator = useRevalidator()
141
+
142
+ const refetchData = useCallback(async () => {
143
+ logger.info("[DailyReportActionProvider] Refetching data without page reload...")
144
+ if (revalidator.state === "idle") {
145
+ revalidator.revalidate()
146
+ }
147
+ setVersion((v) => v + 1)
148
+ DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
149
+ listener(-1)
150
+ })
151
+ }, [revalidator])
152
+
153
+ const clearCacheAndRefetch = useCallback(async () => {
154
+ logger.info("[DailyReportActionProvider] Clearing client & server/DB cache and refetching data without page reload...")
155
+ // 1. クライアント側キャッシュ破棄
156
+ clearDailyReportCache()
157
+ removedIds.current.clear()
158
+ try {
159
+ sessionStorage.clear()
160
+ } catch {}
161
+
162
+ // 2. サーバー/DB側キャッシュ破棄 API 呼び出し
163
+ try {
164
+ const formData = new FormData()
165
+ formData.append("intent", "clearCache")
166
+ await fetch(`${apiBasePath}/action`, {
167
+ method: "POST",
168
+ body: formData,
169
+ })
170
+ } catch (e) {
171
+ logger.warn("[DailyReportActionProvider] Failed to clear server-side cache:", e)
172
+ }
173
+
174
+ // 3. ルートローダーの再フェッチ & UI 再描画
175
+ if (revalidator.state === "idle") {
176
+ revalidator.revalidate()
177
+ }
178
+ setVersion((v) => v + 1)
179
+ DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
180
+ listener(-1)
181
+ })
182
+ }, [apiBasePath, revalidator])
183
+
135
184
  /**
136
185
  * Queue for SSE messages that arrived before the target report was cached.
137
186
  * キャッシュ未取得時に到着した SSE メッセージの待機キュー。
@@ -703,6 +752,7 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
703
752
  createdAt: now,
704
753
  author: getUserDisplayName(user),
705
754
  userId: user?.id ?? "unknown",
755
+ sourceType: "Internal",
706
756
  employeeName: getUserDisplayName(user),
707
757
  updatedBy: getUserDisplayName(user),
708
758
  updatedAt: now,
@@ -903,6 +953,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
903
953
  publishReport,
904
954
  deleteReport,
905
955
  removeStaleItem,
956
+ refetchData,
957
+ clearCacheAndRefetch,
906
958
  }}>
907
959
  {children}
908
960
  </DailyReportActionContext.Provider>
@@ -27,7 +27,7 @@ const FETCH_DEBOUNCE_MS = 120
27
27
  // --- Cache Store (Centralized State) ---
28
28
  type CacheEntry<T> = { data: T; expiresAt: number }
29
29
 
30
- const DailyReportCache = {
30
+ export const DailyReportCache = {
31
31
  reports: new Map<number, CacheEntry<DailyReportDetail>>(),
32
32
  lists: new Map<string, CacheEntry<DailyReportDetail[]>>(),
33
33
  pendingLists: new Map<string, Promise<DailyReportDetail[]>>(),
@@ -58,6 +58,23 @@ const DailyReportCache = {
58
58
  }
59
59
  },
60
60
 
61
+ /**
62
+ * Clear all cached entries and pending requests.
63
+ * すべてのキャッシュエントリおよび待機中リクエストをクリアする。
64
+ */
65
+ clear() {
66
+ this.reports.clear()
67
+ this.lists.clear()
68
+ this.pendingLists.clear()
69
+ this.pendingReports.clear()
70
+ this.locks.clear()
71
+ this.lastMutations.clear()
72
+ this.recentDeletes.clear()
73
+ this.listeners.forEach((listener) => {
74
+ listener(-1)
75
+ })
76
+ },
77
+
61
78
  /**
62
79
  * Check if the cache entry is still valid.
63
80
  * キャッシュエントリがまだ有効かを確認する。
@@ -218,6 +235,11 @@ export const updateDailyReportCache = (id: number, u: Partial<DailyReportDetail>
218
235
  * キャッシュからレポートを削除する。
219
236
  */
220
237
  export const deleteDailyReportCache = (id: number, d: string) => DailyReportCache.delete(id, d)
238
+ /**
239
+ * Clear all daily report cache store entries.
240
+ * 日報の全キャッシュストアを消去する。
241
+ */
242
+ export const clearDailyReportCache = () => DailyReportCache.clear()
221
243
 
222
244
  export const acquireMutationLock = (id: number) => {
223
245
  DailyReportCache.locks.add(id)
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it, vi } from "vitest"
2
+ import { createEpochStore, SqlResultCache } from "./cache"
3
+
4
+ describe("SqlResultCache Memory Cleanup", () => {
5
+ it("should clean up expired cache entries when map size exceeds 500", async () => {
6
+ // Mock epoch store
7
+ const epochStore = createEpochStore(undefined)
8
+ vi.spyOn(epochStore, "getEpoch").mockResolvedValue(0)
9
+
10
+ const cache = new SqlResultCache({ defaultTtlMs: 1000 }, epochStore)
11
+
12
+ // 1. 期限切れのキャッシュを 500 件作成 (expireAt を過去に設定)
13
+ // 直接プライベートフィールド buckets に流し込むことでモック状態を作る
14
+ const buckets = (cache as any).buckets
15
+ for (let i = 0; i < 505; i++) {
16
+ buckets.set(`expired-key-${i}`, {
17
+ records: [i],
18
+ expireAt: Date.now() - 1000, // 過去時間
19
+ epoch: 0,
20
+ })
21
+ }
22
+
23
+ // 2. 有効なキャッシュを 10 件作成 (expireAt を未来に設定)
24
+ for (let i = 0; i < 10; i++) {
25
+ buckets.set(`valid-key-${i}`, {
26
+ records: [i],
27
+ expireAt: Date.now() + 100000, // 未来時間
28
+ epoch: 0,
29
+ })
30
+ }
31
+
32
+ expect(buckets.size).toBe(515)
33
+
34
+ // 3. getOrFetch を実行して 500 件超えの閾値トリガーを引く
35
+ const fetcher = vi.fn().mockResolvedValue(["new-value"])
36
+ await cache.getOrFetch({
37
+ cacheKey: "trigger-key",
38
+ fetcher,
39
+ })
40
+
41
+ // 4. 検証: 期限切れの 505 件のデータが cleanExpired で削除され、有効な 10 件 + 新規の 1 件のみ残っていること
42
+ expect(buckets.size).toBe(11) // 10 (valid) + 1 (trigger-key)
43
+ expect(buckets.has("trigger-key")).toBe(true)
44
+ expect(buckets.has("valid-key-0")).toBe(true)
45
+ expect(buckets.has("expired-key-0")).toBe(false)
46
+ })
47
+ })
@@ -123,6 +123,10 @@ export class SqlResultCache {
123
123
  // キャッシュ保存時に現在の epoch を記録
124
124
  const epoch = opts.epochKey ? await this.epochStore.getEpoch(opts.epochKey) : 0
125
125
  this.buckets.set(cacheKey, { records, expireAt, epoch })
126
+ // 蓄積防止のため、サイズが一定値を超えたら期限切れキャッシュを一括クリーンアップ
127
+ if (this.buckets.size > 500) {
128
+ this.cleanExpired()
129
+ }
126
130
  }
127
131
  return records
128
132
  })()
@@ -146,6 +150,15 @@ export class SqlResultCache {
146
150
  this.inFlight.delete(cacheKey)
147
151
  }
148
152
 
153
+ /**
154
+ * Clears all cached buckets and in-flight requests.
155
+ * すべてのキャッシュバケットと進行中のリクエストを全クリアします。
156
+ */
157
+ clearAll = (): void => {
158
+ this.buckets.clear()
159
+ this.inFlight.clear()
160
+ }
161
+
149
162
  /**
150
163
  * Invalidates cache buckets matching a prefix.
151
164
  * 指定されたプレフィックスに一致するキャッシュバケットを無効化します。
@@ -162,4 +175,18 @@ export class SqlResultCache {
162
175
  }
163
176
  }
164
177
  }
178
+
179
+ /**
180
+ * Cleans up all expired cache buckets to prevent memory accumulation.
181
+ * メモリー蓄積を防ぐため、期限切れのキャッシュバケットをすべてクリーンアップする処理。
182
+ */
183
+ private cleanExpired(): void {
184
+ const now = Date.now()
185
+ // 期限切れのキーを削除
186
+ for (const [key, bucket] of this.buckets.entries()) {
187
+ if (bucket.expireAt <= now) {
188
+ this.buckets.delete(key)
189
+ }
190
+ }
191
+ }
165
192
  }
@@ -317,6 +317,10 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
317
317
  throw e
318
318
  }
319
319
  }
320
+ case "clearCache": {
321
+ await service.clearCache()
322
+ return jsonData({ status: "OK", intent: "clearCache" })
323
+ }
320
324
  default:
321
325
  return jsonData({ error: "Invalid intent" }, { status: 400 })
322
326
  }
@@ -326,6 +326,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
326
326
  date: formatDateValue(hub.businessDate, "YYYY-MM-DD"),
327
327
  author: row.creatorName ?? maskAuditActor(hub.createdBy) ?? "",
328
328
  userId: hub.userId ? encodeUserId(hub.userId) : "",
329
+ sourceType: hub.sourceType ?? "Internal",
329
330
  createdAt: formatDateValue(hub.createdAt, "YYYY-MM-DD HH:mm:ss"),
330
331
  updatedAt: formatDateValue(hub.updatedAt, "YYYY-MM-DD HH:mm:ss"),
331
332
  updatedBy: maskAuditActor(hub.updatedBy),
@@ -362,9 +363,10 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
362
363
  const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
363
364
 
364
365
  const result = await db
365
- .select<{ reportHubId: number; businessDate: Date | string | null }>({
366
+ .select<{ reportHubId: number; businessDate: Date | string | null; sourceType: string }>({
366
367
  reportHubId: DailyReportHub.id,
367
368
  businessDate: DailyReportHub.businessDate,
369
+ sourceType: DailyReportHub.sourceType,
368
370
  })
369
371
  .from(DailyReportHub)
370
372
  .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), eq(DraftLabelRelation.labelId, db.select({ id: DailyReportLabel.id }).top(1).from(DailyReportLabel).where(eq(DailyReportLabel.name, draftLabelName)))))
@@ -1034,6 +1036,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1034
1036
  createdAt: formatDateValue(hub.createdAt, "YYYY-MM-DD HH:mm:ss"),
1035
1037
  author: userName,
1036
1038
  userId: encodeUserId(userId),
1039
+ sourceType: "Internal",
1037
1040
  employeeName: userName,
1038
1041
  // 監査列と同様、クライアント公開時は生内部 ID を難読化する (mapHubRecord と整合)
1039
1042
  updatedBy: encodeUserId(userId),
@@ -1277,10 +1280,26 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1277
1280
  return fullDetail ?? null
1278
1281
  }
1279
1282
 
1283
+ /**
1284
+ * Clears all server-side SQL result and user ID caches.
1285
+ * サーバー側のすべての SQL 結果キャッシュおよびユーザー ID キャッシュを全消去する。
1286
+ */
1287
+ const clearCache = async (): Promise<void> => {
1288
+ sqlResultCache.clearAll()
1289
+ userIdCache.clear()
1290
+ cachedDraftLabelId = null
1291
+ if (epochs) {
1292
+ await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
1293
+ }
1294
+ logger.info("[DailyReportService] Server-side DB/SQL caches cleared successfully.")
1295
+ }
1296
+
1280
1297
  return {
1281
1298
  // 定数 (アプリ側の互換 export 用)
1282
1299
  streamKey: DAILY_REPORT_SSE_STREAM_KEY,
1283
1300
  streamMaxLen: DAILY_REPORT_SSE_STREAM_MAXLEN,
1301
+ // キャッシュクリア
1302
+ clearCache,
1284
1303
  // ユーザー解決
1285
1304
  getUserIdByExternalId,
1286
1305
  // 参照系
@@ -39,6 +39,7 @@ export const dailyReportDetailSchema = z.object({
39
39
  createdAt: z.string().nullable(),
40
40
  author: z.string(),
41
41
  userId: z.string(),
42
+ sourceType: z.string().nullable(),
42
43
  employeeName: z.string().nullable(),
43
44
  updatedBy: z.string().nullable(),
44
45
  updatedAt: z.string().nullable(),
@@ -7,6 +7,7 @@
7
7
  export type DailyReportItem = {
8
8
  reportHubId: number
9
9
  businessDate: string | null
10
+ sourceType?: string
10
11
  }
11
12
 
12
13
  /** 面談者情報。 */
@@ -46,6 +47,7 @@ export type DailyReportDetail = {
46
47
  createdAt: string | null
47
48
  author: string
48
49
  userId: string
50
+ sourceType: string | null
49
51
  employeeName: string | null
50
52
  updatedBy: string | null
51
53
  updatedAt: string | null