@aiquants/daily-report 0.7.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.
Files changed (67) hide show
  1. package/README.md +13 -6
  2. package/dist/client.d.mts +438 -26
  3. package/dist/client.d.ts +438 -26
  4. package/dist/client.js +5 -4
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +5 -4
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +115 -3
  9. package/dist/index.d.ts +115 -3
  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 +15 -0
  15. package/dist/server.d.ts +15 -0
  16. package/dist/server.js +9 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +9 -6
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
  21. package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
  22. package/dist/styles/daily-report.standalone.css +1 -1
  23. package/package.json +3 -3
  24. package/src/client/components/daily-report-comment-item.tsx +6 -1
  25. package/src/client/components/daily-report-detail-list.tsx +65 -34
  26. package/src/client/components/daily-report-edit-form.tsx +6 -0
  27. package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
  28. package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
  29. package/src/client/components/daily-report-list.tsx +88 -55
  30. package/src/client/components/daily-report-page.spec.tsx +119 -0
  31. package/src/client/components/daily-report-page.tsx +62 -69
  32. package/src/client/components/daily-report-resolved-content.tsx +36 -10
  33. package/src/client/components/report-views.spec.tsx +37 -2
  34. package/src/client/config-context.tsx +18 -0
  35. package/src/client/contexts/daily-report-action-context.spec.tsx +278 -0
  36. package/src/client/contexts/daily-report-action-context.tsx +203 -65
  37. package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
  38. package/src/client/contexts/daily-report-error-context.tsx +98 -0
  39. package/src/client/hooks/use-animated-number.spec.ts +103 -0
  40. package/src/client/hooks/use-animated-number.ts +69 -0
  41. package/src/client/hooks/use-daily-report.spec.ts +50 -2
  42. package/src/client/hooks/use-daily-report.ts +30 -1
  43. package/src/client/route-helpers.spec.ts +100 -0
  44. package/src/client/route-helpers.ts +20 -41
  45. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +704 -0
  46. package/src/client/streaming/daily-report-ids-stream-client.ts +677 -0
  47. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
  48. package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
  49. package/src/client.ts +5 -0
  50. package/src/index.ts +1 -0
  51. package/src/server/handlers.action.spec.ts +44 -0
  52. package/src/server/handlers.ids-stream.spec.ts +414 -0
  53. package/src/server/handlers.ts +324 -8
  54. package/src/server/service.spec.ts +83 -7
  55. package/src/server/service.ts +100 -3
  56. package/src/server/test-helpers/handlers-config.ts +48 -1
  57. package/src/server.spec.ts +49 -0
  58. package/src/server.ts +7 -0
  59. package/src/shared/business-date.spec.ts +22 -1
  60. package/src/shared/business-date.ts +20 -0
  61. package/src/shared/comment-adapter.spec.ts +38 -0
  62. package/src/shared/comment-adapter.ts +9 -7
  63. package/src/shared/ids-stream.spec.ts +65 -0
  64. package/src/shared/ids-stream.ts +139 -0
  65. package/src/shared/sse-schema.ts +5 -0
  66. package/src/shared/text-utils.spec.ts +42 -0
  67. package/src/shared/text-utils.ts +8 -0
@@ -419,6 +419,45 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
419
419
  }))
420
420
  }
421
421
 
422
+ /**
423
+ * Fetches only the first page of report ids for time-to-first-render (uncached).
424
+ * 最初の描画までの時間専用に、ID 一覧の先頭ページだけを取得する内部処理 (キャッシュ無し)。
425
+ *
426
+ * `ids-stream` の新規接続でキャッシュミスした場合、全量クエリの確定を待たずに
427
+ * この軽量クエリ (TOP n・インデックス順で n 行走査した時点で打ち切り) の結果を
428
+ * 最初のチャンクとして送出する。可視性述語と並び順は全量クエリと完全同一。
429
+ * `.top(n)` は `.select()` の直後でなければならない (drizzle-mssql の構造的制約)。
430
+ *
431
+ * @param externalId External user id. 外部ユーザー ID。
432
+ * @param limit Page size. ページサイズ。
433
+ * @returns First-page items in stream order. ストリーム順の先頭ページ。
434
+ */
435
+ const getDailyReportIdsFirstPageByExternalId = async (externalId: string, limit: number): Promise<DailyReportItem[]> => {
436
+ const userId = await getUserIdByExternalId(externalId)
437
+ if (!userId) {
438
+ return []
439
+ }
440
+ const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
441
+ const draftLabelJoinIds = await getDraftLabelJoinIds()
442
+
443
+ const result = await db
444
+ .select<{ reportHubId: number; businessDate: Date | string | null; sourceType: string }>({
445
+ reportHubId: DailyReportHub.id,
446
+ businessDate: DailyReportHub.businessDate,
447
+ sourceType: DailyReportHub.sourceType,
448
+ })
449
+ .top(limit)
450
+ .from(DailyReportHub)
451
+ .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
452
+ .where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId))))
453
+ .orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
454
+
455
+ return result.map((item) => ({
456
+ ...item,
457
+ businessDate: formatDateValue(item.businessDate, "YYYY-MM-DD"),
458
+ }))
459
+ }
460
+
422
461
  /**
423
462
  * Fetches daily report details for the provided business date.
424
463
  * 指定した営業日の日報詳細を取得する内部処理。
@@ -1114,15 +1153,50 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1114
1153
  return updatedStatus
1115
1154
  }
1116
1155
 
1156
+ /**
1157
+ * Resolves the SSE recipient for comment events on a possibly-draft report.
1158
+ * 下書きの可能性がある日報のコメントイベントについて SSE の宛先を解決する処理。
1159
+ *
1160
+ * 下書きは所有者にしか見えないため、下書きラベルが付いている間はコメント本文も
1161
+ * 所有者だけに配信する (無指定だと本文が全ユーザーへブロードキャストされる)。
1162
+ * 公開済み (下書きラベル無し) は全員配信 = undefined を返す。
1163
+ *
1164
+ * @param hubId Parent hub id. 親日報の hub ID。
1165
+ * @param ownerUserId Hub owner's raw user id. 所有者の内部ユーザー ID。
1166
+ * @returns Recipient raw user id, or undefined to broadcast. 宛先 (全員配信は undefined)。
1167
+ */
1168
+ const resolveCommentRecipient = async (hubId: number, ownerUserId: number | null): Promise<number | undefined> => {
1169
+ // 所有者不明 (共有日報など) は従来どおり全員配信
1170
+ if (ownerUserId === null) return undefined
1171
+ const draftIds = await getDraftLabelJoinIds()
1172
+ const [rel] = await db
1173
+ .select<{ hubId: number }>({ hubId: DailyReportHub_Label.hubId })
1174
+ .top(1)
1175
+ .from(DailyReportHub_Label)
1176
+ .where(and(eq(DailyReportHub_Label.hubId, hubId), inArray(DailyReportHub_Label.labelId, draftIds)))
1177
+ return rel ? ownerUserId : undefined
1178
+ }
1179
+
1117
1180
  /**
1118
1181
  * Adds a comment to a daily report.
1119
1182
  * 日報にコメントを追加する。
1120
1183
  */
1121
1184
  const addComment = async (userId: number, reportHubId: number, content: string, businessDate: string | null, clientTempId: string): Promise<DailyReportCommentItem> => {
1122
- const [hub] = await db.select<{ sourceType: string }>({ sourceType: DailyReportHub.sourceType }).top(1).from(DailyReportHub).where(eq(DailyReportHub.id, reportHubId))
1123
- if (hub && hub.sourceType.toLowerCase() !== "internal") {
1185
+ const [hub] = await db
1186
+ .select<{ sourceType: string; deletedAt: Date | string | null; userId: number | null }>({ sourceType: DailyReportHub.sourceType, deletedAt: DailyReportHub.deletedAt, userId: DailyReportHub.userId })
1187
+ .top(1)
1188
+ .from(DailyReportHub)
1189
+ .where(eq(DailyReportHub.id, reportHubId))
1190
+ // 存在しない / 論理削除済みの日報にはコメントを付けない (以前は hub 未取得でも
1191
+ // フォールスルーして孤児コメントを INSERT していた)
1192
+ if (!hub || hub.deletedAt) {
1193
+ throw new Error("Not Found")
1194
+ }
1195
+ if (hub.sourceType.toLowerCase() !== "internal") {
1124
1196
  throw new Error("Comments are restricted for external daily report sources")
1125
1197
  }
1198
+ // 下書きなら SSE を所有者宛に絞る (本文の全員配信を防ぐ)
1199
+ const recipientRawUserId = await resolveCommentRecipient(reportHubId, hub.userId)
1126
1200
 
1127
1201
  const [inserted] = await db
1128
1202
  .insert<DailyReportCommentRow>(DailyReportCommentModel)
@@ -1167,6 +1241,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1167
1241
  reportHubId: reportHubId,
1168
1242
  comment: commentItem,
1169
1243
  clientTempId,
1244
+ recipientRawUserId,
1170
1245
  }),
1171
1246
  "addComment",
1172
1247
  )
@@ -1194,6 +1269,15 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1194
1269
  if (comments[0].userId !== userId) {
1195
1270
  throw new Error("Unauthorized")
1196
1271
  }
1272
+ // 渡された reportHubId がコメントの実際の所属と一致することを確認する。
1273
+ // 不一致のまま進むと、無関係な日報のキャッシュ無効化と SSE 配信を引き起こす
1274
+ if (comments[0].hubId !== reportHubId) {
1275
+ throw new Error("Not Found")
1276
+ }
1277
+
1278
+ // 下書きなら削除イベントも所有者宛に絞る (comment-add と対称)
1279
+ const [hubForDelete] = await db.select<{ userId: number | null }>({ userId: DailyReportHub.userId }).top(1).from(DailyReportHub).where(eq(DailyReportHub.id, reportHubId))
1280
+ const recipientRawUserId = await resolveCommentRecipient(reportHubId, hubForDelete?.userId ?? null)
1197
1281
 
1198
1282
  await db.delete(DailyReportCommentModel).where(eq(DailyReportCommentModel.id, commentId))
1199
1283
 
@@ -1215,6 +1299,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1215
1299
  reportHubId: reportHubId,
1216
1300
  commentId,
1217
1301
  clientTempId,
1302
+ recipientRawUserId,
1218
1303
  }),
1219
1304
  "deleteComment",
1220
1305
  )
@@ -1465,9 +1550,20 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1465
1550
  }
1466
1551
  })
1467
1552
 
1468
- // キャッシュ無効化
1553
+ // キャッシュ無効化: 一覧プレビューは詳細キャッシュから引くため、詳細を全プロセス横断で落とす。
1554
+ // 以前は読み取りに使われないプレフィックスを無効化し epoch も上げていなかったため、
1555
+ // 編集後の件名/本文が他ワーカーで古いまま配信されていた
1556
+ sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
1469
1557
  sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
1470
1558
  sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
1559
+ await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
1560
+ // 編集内容は全ユーザーの一覧に映るため、営業日別一覧は全ユーザー分 + 全ワーカー横断で
1561
+ // 無効化する (addComment と同じ流儀。編集者本人のキーだけでは他者へ古い件名が残る)
1562
+ const editedBusinessDate = normalizeBusinessDateKey(formatDateValue(report[0].businessDate, "YYYY-MM-DD"))
1563
+ if (editedBusinessDate) {
1564
+ sqlResultCache.invalidatePrefix(`daily-report:date:${editedBusinessDate}:user:`)
1565
+ await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${editedBusinessDate}`)
1566
+ }
1471
1567
 
1472
1568
  const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true })
1473
1569
  if (fullDetail) {
@@ -1600,6 +1696,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1600
1696
  getUserIdByExternalId,
1601
1697
  // 参照系
1602
1698
  getDailyReportIdsByExternalId,
1699
+ getDailyReportIdsFirstPageByExternalId,
1603
1700
  getDailyReportsByBusinessDate,
1604
1701
  getDailyReportsByBusinessDateByExternalId,
1605
1702
  getDailyReportDetailById,
@@ -51,15 +51,31 @@ export type HandlersFixtureOptions = {
51
51
  attachmentRateLimitPerMinute?: number
52
52
  /** 同時実行上限。 */
53
53
  attachmentConcurrency?: number
54
+ /** `intent=clearCache` (開発用の全キャッシュ破棄) を許可するか。 */
55
+ enableDevCacheClear?: boolean
56
+ /** ids ストリームが返すアイテム一覧 (ストリーム順で渡す)。 */
57
+ idsStreamItems?: Array<{ reportHubId: number; businessDate: string | null; sourceType?: string }>
58
+ /** ids 取得 (全量・先頭ページの両方) を失敗させるエラー。 */
59
+ idsStreamError?: Error
60
+ /** 全量クエリの解決を保留にする Promise (高速先頭ページ経路の検証用)。 */
61
+ idsStreamFullGate?: Promise<void>
62
+ /** 先頭ページの結果を上書きする (既定は idsStreamItems の先頭スライス)。 */
63
+ idsStreamFirstPageItems?: Array<{ reportHubId: number; businessDate: string | null; sourceType?: string }>
54
64
  }
55
65
 
56
66
  /** 呼び出し記録。テストが副作用を検証するために使う。 */
57
67
  export type HandlersFixtureCalls = {
68
+ /** `service.clearCache` の呼び出し回数。 */
69
+ clearCache: number
58
70
  /** `service.getAttachmentForUser` が受け取った `(attachmentId, viewerUserId)`。 */
59
71
  getAttachmentForUser: Array<[number, number]>
60
72
  readAttachment: Array<{ filePath: string; maxBytes: number; head: boolean; principal?: string }>
61
73
  markAttachmentMissing: number[]
62
74
  markAttachmentPresent: number[]
75
+ /** `service.getDailyReportIdsByExternalId` が受け取った `(externalId, forceRefresh)`。 */
76
+ getDailyReportIds: Array<[string, boolean]>
77
+ /** `service.getDailyReportIdsFirstPageByExternalId` が受け取った `(externalId, limit)`。 */
78
+ getDailyReportIdsFirstPage: Array<[string, number]>
63
79
  }
64
80
 
65
81
  /**
@@ -70,7 +86,7 @@ export type HandlersFixtureCalls = {
70
86
  * @returns The config and its call log. 設定と呼び出し記録。
71
87
  */
72
88
  export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { config: DailyReportHandlersConfig; calls: HandlersFixtureCalls } => {
73
- const calls: HandlersFixtureCalls = { getAttachmentForUser: [], readAttachment: [], markAttachmentMissing: [], markAttachmentPresent: [] }
89
+ const calls: HandlersFixtureCalls = { clearCache: 0, getAttachmentForUser: [], readAttachment: [], markAttachmentMissing: [], markAttachmentPresent: [], getDailyReportIds: [], getDailyReportIdsFirstPage: [] }
74
90
 
75
91
  const user = options.user === undefined ? { id: TEST_EXTERNAL_USER_ID } : options.user
76
92
  const internalUserId = options.internalUserId === undefined ? TEST_INTERNAL_USER_ID : options.internalUserId
@@ -79,6 +95,20 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
79
95
 
80
96
  const service = {
81
97
  getUserIdByExternalId: async () => internalUserId,
98
+ clearCache: async () => {
99
+ calls.clearCache += 1
100
+ },
101
+ getDailyReportIdsByExternalId: async (externalId: string, opts?: { forceRefresh?: boolean }) => {
102
+ calls.getDailyReportIds.push([externalId, opts?.forceRefresh ?? false])
103
+ if (options.idsStreamError) throw options.idsStreamError
104
+ if (options.idsStreamFullGate) await options.idsStreamFullGate
105
+ return options.idsStreamItems ?? []
106
+ },
107
+ getDailyReportIdsFirstPageByExternalId: async (externalId: string, limit: number) => {
108
+ calls.getDailyReportIdsFirstPage.push([externalId, limit])
109
+ if (options.idsStreamError) throw options.idsStreamError
110
+ return options.idsStreamFirstPageItems ?? (options.idsStreamItems ?? []).slice(0, limit)
111
+ },
82
112
  getAttachmentForUser: async (attachmentId: number, viewerUserId: number) => {
83
113
  // 引数を記録する。記録しないと「閲覧者 ID を渡し忘れた」欠陥がテストに映らない
84
114
  calls.getAttachmentForUser.push([attachmentId, viewerUserId])
@@ -101,6 +131,7 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
101
131
  attachmentMaxBytes: options.attachmentMaxBytes,
102
132
  attachmentRateLimitPerMinute: options.attachmentRateLimitPerMinute,
103
133
  attachmentConcurrency: options.attachmentConcurrency,
134
+ enableDevCacheClear: options.enableDevCacheClear,
104
135
  }
105
136
 
106
137
  if (options.omitPorts) {
@@ -133,6 +164,22 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
133
164
  * @param init Optional method / query / headers. メソッド・クエリ・ヘッダーの任意指定。
134
165
  * @returns Loader args. loader 引数。
135
166
  */
167
+ /**
168
+ * Builds loader args for the `:endpoint` API router.
169
+ * `:endpoint` API ルーター用の loader 引数を組み立てる処理。
170
+ *
171
+ * @param endpoint Endpoint segment (e.g. "ids-stream"). エンドポイントセグメント。
172
+ * @param init Optional method / query / signal. メソッド・クエリ・中断シグナルの任意指定。
173
+ * @returns Loader args. loader 引数。
174
+ */
175
+ export const makeApiArgs = (endpoint: string, init: { method?: string; query?: string; signal?: AbortSignal } = {}) => ({
176
+ request: new Request(`http://localhost/daily_report/api/${endpoint}${init.query ?? ""}`, {
177
+ method: init.method ?? "GET",
178
+ signal: init.signal ?? undefined,
179
+ }),
180
+ params: { endpoint } as Record<string, string | undefined>,
181
+ })
182
+
136
183
  export const makeAttachmentArgs = (token: string, init: { method?: string; query?: string; headers?: Record<string, string> } = {}) => ({
137
184
  request: new Request(`http://localhost/daily_report/api/attachment/${encodeURIComponent(token)}${init.query ?? ""}`, {
138
185
  method: init.method ?? "GET",
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Facade tests for createDailyReportServer: the wiring the explicit-enumeration call must forward.
3
+ * createDailyReportServer ファサードのテスト。明示列挙の結線が取りこぼしていないことを固定する。
4
+ */
5
+ import { bigint, mssqlSchema, nvarchar, primaryKey } from "drizzle-orm/mssql-core"
6
+ import { describe, expect, it } from "vitest"
7
+ import { createDailyReportServer, type DailyReportDb } from "./server"
8
+ import { defineDailyReportSchema } from "./server/schema"
9
+
10
+ const testUsers = mssqlSchema("test").table("Users", { id: bigint("id", { mode: "number" }).notNull(), displayName: nvarchar("display_name", { length: 100 }) }, (t) => [primaryKey({ columns: [t.id] })])
11
+ const tables = defineDailyReportSchema("test", { userTable: testUsers })
12
+
13
+ /** どのクエリも空行を返す最小 db (clearCache 経路は DB に触れない)。 */
14
+ const noopDb = { select: () => ({ from: () => ({ where: () => Promise.resolve([]), top: () => ({ from: () => ({ where: () => Promise.resolve([]) }) }) }) }) } as unknown as DailyReportDb
15
+
16
+ /** clearCache の POST 引数。 */
17
+ const clearCacheArgs = () => {
18
+ const body = new FormData()
19
+ body.set("intent", "clearCache")
20
+ return { request: new Request("http://localhost/daily_report/api/action", { method: "POST", body }), params: { endpoint: "action" } as Record<string, string | undefined> }
21
+ }
22
+
23
+ const makeServer = (enableDevCacheClear?: boolean) =>
24
+ createDailyReportServer({
25
+ db: noopDb,
26
+ tables,
27
+ userTable: testUsers,
28
+ resolveUserId: async () => 8,
29
+ encodeUserId: (id) => `ENC${id}`,
30
+ authenticate: async () => ({ user: { id: "ext-8" }, cookie: null }),
31
+ draftLabelName: "draft",
32
+ enableDevCacheClear,
33
+ })
34
+
35
+ describe("createDailyReportServer: enableDevCacheClear の結線", () => {
36
+ it("既定 (未指定) では clearCache を 400 で拒否する", async () => {
37
+ const server = makeServer()
38
+ const res = await server.api.action(clearCacheArgs())
39
+ expect(res.status).toBe(400)
40
+ })
41
+
42
+ it("enableDevCacheClear:true をファサードへ渡すとハンドラまで伝播し 200 になる", async () => {
43
+ // ❗ この経路が createDailyReportServer の明示列挙で取りこぼされると、アプリが
44
+ // フラグを渡しても黙って無視される (回帰検知の要)
45
+ const server = makeServer(true)
46
+ const res = await server.api.action(clearCacheArgs())
47
+ expect(res.status).toBe(200)
48
+ })
49
+ })
package/src/server.ts CHANGED
@@ -34,6 +34,12 @@ export type DailyReportServerConfig = Omit<DailyReportServiceConfig, "cache" | "
34
34
  attachmentConcurrency?: number
35
35
  /** 未ログイン時のリダイレクト先 (index.loader 用、既定 "/auth/login")。 */
36
36
  loginRedirectPath?: string
37
+ /**
38
+ * `intent=clearCache` (全サーバーキャッシュの一括破棄) を許可するか。既定 false。
39
+ * 開発用アフォーダンス。有効化すると認証済みなら誰でも全ワーカーのキャッシュを
40
+ * 無制限破棄できるため、本番では無効のままにする (消費アプリは開発環境のみ true 注入)。
41
+ */
42
+ enableDevCacheClear?: boolean
37
43
  /** SQL 結果キャッシュの既定 TTL (既定 60,000ms)。 */
38
44
  cacheDefaultTtlMs?: number
39
45
  /** 外部ソースアダプタ群 (レガシー日報テーブル等)。 */
@@ -80,6 +86,7 @@ export function createDailyReportServer(config: DailyReportServerConfig) {
80
86
  sseReader,
81
87
  streamKey: service.streamKey,
82
88
  loginRedirectPath: config.loginRedirectPath,
89
+ enableDevCacheClear: config.enableDevCacheClear,
83
90
  logger: config.logger,
84
91
  })
85
92
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { formatBusinessDateDisplay, normalizeBusinessDateKey, parseBusinessDateKeyToDate } from "./business-date"
2
+ import { formatBusinessDateDisplay, normalizeBusinessDateKey, parseBusinessDateKeyToDate, resolveTodayBusinessDateKey } from "./business-date"
3
3
 
4
4
  describe("business-date utils", () => {
5
5
  describe("normalizeBusinessDateKey", () => {
@@ -58,4 +58,25 @@ describe("business-date utils", () => {
58
58
  expect(parseBusinessDateKeyToDate("invalid")).toBeNull()
59
59
  })
60
60
  })
61
+
62
+ describe("resolveTodayBusinessDateKey", () => {
63
+ it("uses the LOCAL calendar day (getFullYear/Month/Date), not the UTC date", () => {
64
+ // ローカルコンストラクタで作った時刻はローカル getter で読むため、ホストの TZ に
65
+ // 依存せず必ずその暦日を返す (旧実装の toISOString は UTC 日付で前日へずれ得た)。
66
+ // ❗ この検証は toISOString と対比しない: 対比はホスト TZ 依存でテスト自体が脆くなる
67
+ const localMorning = new Date(2026, 7, 9, 2, 30, 0)
68
+ expect(resolveTodayBusinessDateKey(localMorning)).toBe("2026-08-09")
69
+ // 別の暦日でも成立する (getter 経由であることの確認)
70
+ expect(resolveTodayBusinessDateKey(new Date(2025, 11, 31, 23, 59, 59))).toBe("2025-12-31")
71
+ })
72
+
73
+ it("zero-pads month and day", () => {
74
+ expect(resolveTodayBusinessDateKey(new Date(2026, 0, 3, 12, 0, 0))).toBe("2026-01-03")
75
+ })
76
+
77
+ it("falls back to now for an invalid injected date", () => {
78
+ const result = resolveTodayBusinessDateKey(new Date(Number.NaN))
79
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
80
+ })
81
+ })
61
82
  })
@@ -23,6 +23,26 @@ const normalizeFromDate = (value: Date): string | null => {
23
23
  return `${year}-${month}-${day}`
24
24
  }
25
25
 
26
+ /**
27
+ * Returns today's business-date key (YYYY-MM-DD) in the local calendar.
28
+ * ローカルカレンダー上の「本日」の営業日キー (YYYY-MM-DD) を返す。
29
+ *
30
+ * `new Date().toISOString()` の日付部は UTC 日付になり、UTC より東のタイムゾーン
31
+ * (JST など) では深夜〜午前帯に前日へずれる。営業日はブラウザのローカル暦日で判定する
32
+ * ため、ローカル getter から直接組む。タイムゾーンはパッケージに固定しない (消費側の
33
+ * 実行環境が決める) 方針に沿う。
34
+ *
35
+ * @param now Clock injection for tests (defaults to the current time). テスト用の時刻注入 (既定は現在時刻)。
36
+ * @returns Local calendar day as YYYY-MM-DD. ローカル暦日の YYYY-MM-DD。
37
+ */
38
+ export const resolveTodayBusinessDateKey = (now: Date = new Date()): string => {
39
+ if (Number.isNaN(now.getTime())) {
40
+ // 不正な Date が注入されたら現在時刻で解決する (空の営業日キーを返さない)
41
+ return resolveTodayBusinessDateKey(new Date())
42
+ }
43
+ return `${now.getFullYear()}-${zeroPad(now.getMonth() + 1)}-${zeroPad(now.getDate())}`
44
+ }
45
+
26
46
  /**
27
47
  * Normalizes business date input into YYYY-MM-DD format string.
28
48
  * 営業日を YYYY-MM-DD 形式の文字列へ正規化する。
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Tests for comment adapters (legacy + relational merge, colour mapping).
3
+ * コメントアダプタ (レガシー + リレーショナル統合・色マッピング) のテスト。
4
+ */
5
+ import { describe, expect, it } from "vitest"
6
+ import { mergeComments, resolveCommentColorClass } from "./comment-adapter"
7
+
8
+ describe("comment-adapter", () => {
9
+ describe("resolveCommentColorClass", () => {
10
+ // ダークカード上で暗色トークン単体だと文字が沈む。全分岐に dark: 対応を要求する
11
+ it.each([
12
+ [null, "text-gray-700 dark:text-gray-300"],
13
+ ["", "text-gray-700 dark:text-gray-300"],
14
+ ["red", "text-red-600 dark:text-red-400"],
15
+ ["赤", "text-red-600 dark:text-red-400"],
16
+ ["green", "text-green-600 dark:text-green-400"],
17
+ ["青", "text-blue-600 dark:text-blue-400"],
18
+ ["yellow", "text-yellow-600 dark:text-yellow-400"],
19
+ ["橙", "text-orange-500 dark:text-orange-400"],
20
+ ["unknown-colour", "text-gray-700 dark:text-gray-300"],
21
+ ])("maps %s to a class that carries a dark counterpart", (input, expected) => {
22
+ const result = resolveCommentColorClass(input)
23
+ expect(result).toBe(expected)
24
+ // すべての分岐がダークモード対応を必ず含む (退行検知)
25
+ expect(result).toContain("dark:")
26
+ })
27
+ })
28
+
29
+ describe("mergeComments", () => {
30
+ it("keeps legacy JSON comments (NI 由来) alongside relational comments", () => {
31
+ // レガシー経路は NI 旧日報テーブルの `コメント一覧` 列由来の実データ。撤去不可
32
+ const merged = mergeComments([{ name: "田中", text: "レガシー本文", color: "red" }], [{ id: 7, userName: "佐藤", content: "新規本文", createdAt: "2026-08-08 10:00:00", userId: "u1", isMine: false }], "u1")
33
+ expect(merged).toHaveLength(2)
34
+ expect(merged[0]).toMatchObject({ authorName: "田中", content: "レガシー本文", isLegacy: true })
35
+ expect(merged[1]).toMatchObject({ authorName: "佐藤", content: "新規本文", isLegacy: false, isMine: true })
36
+ })
37
+ })
38
+ })
@@ -48,31 +48,33 @@ export const mergeComments = (legacyComments: DailyReportComment[], modernCommen
48
48
  * コメント色のキーワードを Tailwind のテキストクラスに変換する関数。
49
49
  */
50
50
  export const resolveCommentColorClass = (color: string | null | undefined): string => {
51
+ // 各色にダークモードの明色対応を必ず添える。ダークカード上で暗色トークン
52
+ // (text-gray-700 など) 単体だと文字が背景に沈んで読めない
51
53
  if (!color) {
52
- return "text-gray-700"
54
+ return "text-gray-700 dark:text-gray-300"
53
55
  }
54
56
 
55
57
  const normalized = color.toLowerCase()
56
58
 
57
59
  if (normalized.includes("red") || normalized.includes("赤")) {
58
- return "text-red-600"
60
+ return "text-red-600 dark:text-red-400"
59
61
  }
60
62
 
61
63
  if (normalized.includes("green") || normalized.includes("緑")) {
62
- return "text-green-600"
64
+ return "text-green-600 dark:text-green-400"
63
65
  }
64
66
 
65
67
  if (normalized.includes("blue") || normalized.includes("青")) {
66
- return "text-blue-600"
68
+ return "text-blue-600 dark:text-blue-400"
67
69
  }
68
70
 
69
71
  if (normalized.includes("yellow") || normalized.includes("黄")) {
70
- return "text-yellow-600"
72
+ return "text-yellow-600 dark:text-yellow-400"
71
73
  }
72
74
 
73
75
  if (normalized.includes("orange") || normalized.includes("橙")) {
74
- return "text-orange-500"
76
+ return "text-orange-500 dark:text-orange-400"
75
77
  }
76
78
 
77
- return "text-gray-700"
79
+ return "text-gray-700 dark:text-gray-300"
78
80
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Tests for the ids-stream ordering helpers (single source of truth for list order).
3
+ * ids ストリーム並び順ヘルパー (一覧順序の単一情報源) のテスト。
4
+ */
5
+ import { describe, expect, it } from "vitest"
6
+ import { compareIdsStreamOrder, findIdsStreamResumeIndex, isIdsStreamOrdered, upsertInIdsStreamOrder } from "./ids-stream"
7
+ import type { DailyReportItem } from "./types"
8
+
9
+ const item = (reportHubId: number, businessDate: string | null): DailyReportItem => ({ reportHubId, businessDate, sourceType: "Internal" })
10
+
11
+ describe("compareIdsStreamOrder", () => {
12
+ it("orders businessDate DESC, then id DESC, with null dates last (SQL と同一)", () => {
13
+ const sorted = [item(1, "2026-08-01"), item(9, "2026-08-08"), item(3, null), item(5, "2026-08-08"), item(2, null)].sort(compareIdsStreamOrder)
14
+ expect(sorted.map((i) => i.reportHubId)).toEqual([9, 5, 1, 3, 2])
15
+ })
16
+ })
17
+
18
+ describe("isIdsStreamOrdered", () => {
19
+ it("accepts an ordered array and rejects date/id violations", () => {
20
+ expect(isIdsStreamOrdered([item(9, "2026-08-08"), item(5, "2026-08-08"), item(1, "2026-08-01"), item(3, null)])).toBe(true)
21
+ // 日付は揃っているが同日内の ID 昇順 (副キー違反)
22
+ expect(isIdsStreamOrdered([item(5, "2026-08-08"), item(9, "2026-08-08")])).toBe(false)
23
+ // 日付昇順
24
+ expect(isIdsStreamOrdered([item(1, "2026-08-01"), item(9, "2026-08-08")])).toBe(false)
25
+ expect(isIdsStreamOrdered([])).toBe(true)
26
+ expect(isIdsStreamOrdered([item(1, "2026-08-01")])).toBe(true)
27
+ })
28
+ })
29
+
30
+ describe("upsertInIdsStreamOrder", () => {
31
+ const base = [item(9, "2026-08-08"), item(5, "2026-08-08"), item(1, "2026-08-01"), item(3, null)]
32
+
33
+ it("inserts a new item at its ordered position (same-date id DESC を維持)", () => {
34
+ // 同日 (08-08) 内で 9 と 5 の間に入る
35
+ const next = upsertInIdsStreamOrder(base, item(7, "2026-08-08"))
36
+ expect(next.map((i) => i.reportHubId)).toEqual([9, 7, 5, 1, 3])
37
+ // 元配列は不変
38
+ expect(base.map((i) => i.reportHubId)).toEqual([9, 5, 1, 3])
39
+ })
40
+
41
+ it("inserts at head / before the null-date tail", () => {
42
+ expect(upsertInIdsStreamOrder(base, item(2, "2026-08-09")).map((i) => i.reportHubId)).toEqual([2, 9, 5, 1, 3])
43
+ expect(upsertInIdsStreamOrder(base, item(8, "2026-07-01")).map((i) => i.reportHubId)).toEqual([9, 5, 1, 8, 3])
44
+ })
45
+
46
+ it("moves an existing item when its businessDate changes (単一経路の更新)", () => {
47
+ const next = upsertInIdsStreamOrder(base, item(1, "2026-08-08"))
48
+ expect(next.map((i) => i.reportHubId)).toEqual([9, 5, 1, 3])
49
+ expect(next[2].businessDate).toBe("2026-08-08")
50
+ expect(next).toHaveLength(4)
51
+ })
52
+
53
+ it("null-date items land in id-DESC order within the tail", () => {
54
+ const next = upsertInIdsStreamOrder(base, item(6, null))
55
+ expect(next.map((i) => i.reportHubId)).toEqual([9, 5, 1, 6, 3])
56
+ })
57
+ })
58
+
59
+ describe("findIdsStreamResumeIndex", () => {
60
+ it("resumes strictly after the cursor", () => {
61
+ const items = [item(9, "2026-08-08"), item(5, "2026-08-08"), item(1, "2026-08-01")]
62
+ expect(findIdsStreamResumeIndex(items, { businessDate: "2026-08-08", reportHubId: 9 })).toBe(1)
63
+ expect(findIdsStreamResumeIndex(items, { businessDate: "2026-08-01", reportHubId: 1 })).toBe(3)
64
+ })
65
+ })