@aiquants/daily-report 0.1.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/dist/client.d.mts +449 -0
  4. package/dist/client.d.ts +449 -0
  5. package/dist/client.js +7 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/client.mjs +7 -0
  8. package/dist/client.mjs.map +1 -0
  9. package/dist/index.d.mts +42 -0
  10. package/dist/index.d.ts +42 -0
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/index.mjs +2 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/dist/logger-D3krZrNK.d.mts +29 -0
  16. package/dist/logger-D3krZrNK.d.ts +29 -0
  17. package/dist/server.d.mts +1515 -0
  18. package/dist/server.d.ts +1515 -0
  19. package/dist/server.js +10 -0
  20. package/dist/server.js.map +1 -0
  21. package/dist/server.mjs +10 -0
  22. package/dist/server.mjs.map +1 -0
  23. package/dist/sse-schema-CK7cUnEo.d.ts +1986 -0
  24. package/dist/sse-schema-yl5AaSsj.d.mts +1986 -0
  25. package/dist/types-CVhwLhSN.d.mts +76 -0
  26. package/dist/types-CVhwLhSN.d.ts +76 -0
  27. package/package.json +108 -0
  28. package/src/client/components/business-day-thumb-overlay.tsx +19 -0
  29. package/src/client/components/daily-report-comment-item.tsx +81 -0
  30. package/src/client/components/daily-report-comment-section.tsx +166 -0
  31. package/src/client/components/daily-report-detail-list.tsx +676 -0
  32. package/src/client/components/daily-report-edit-form.tsx +81 -0
  33. package/src/client/components/daily-report-list.tsx +1024 -0
  34. package/src/client/components/daily-report-page.tsx +147 -0
  35. package/src/client/components/daily-report-resolved-content.tsx +139 -0
  36. package/src/client/components/unread-indicator.tsx +13 -0
  37. package/src/client/config-context.tsx +129 -0
  38. package/src/client/contexts/daily-report-action-context.tsx +910 -0
  39. package/src/client/hooks/use-daily-report-comments.ts +73 -0
  40. package/src/client/hooks/use-daily-report-sse-connection.ts +86 -0
  41. package/src/client/hooks/use-daily-report.spec.ts +155 -0
  42. package/src/client/hooks/use-daily-report.ts +426 -0
  43. package/src/client/hooks/use-dynamic-viewport-height.ts +127 -0
  44. package/src/client/route-helpers.ts +76 -0
  45. package/src/client/ui/button.tsx +42 -0
  46. package/src/client/ui/cn.ts +14 -0
  47. package/src/client/ui/input.tsx +21 -0
  48. package/src/client/ui/label.tsx +16 -0
  49. package/src/client/ui/switch.tsx +19 -0
  50. package/src/client/ui/tabs.tsx +40 -0
  51. package/src/client/ui/textarea.tsx +19 -0
  52. package/src/client/utils/constants.ts +29 -0
  53. package/src/client.ts +21 -0
  54. package/src/index.ts +10 -0
  55. package/src/server/cache.ts +165 -0
  56. package/src/server/etag.ts +18 -0
  57. package/src/server/external-source.ts +60 -0
  58. package/src/server/handlers.ts +543 -0
  59. package/src/server/ports.ts +68 -0
  60. package/src/server/response.ts +37 -0
  61. package/src/server/schema.ts +266 -0
  62. package/src/server/service.spec.ts +97 -0
  63. package/src/server/service.ts +1308 -0
  64. package/src/server/sse-reader.spec.ts +55 -0
  65. package/src/server/sse-reader.ts +223 -0
  66. package/src/server.ts +83 -0
  67. package/src/shared/business-date.spec.ts +61 -0
  68. package/src/shared/business-date.ts +84 -0
  69. package/src/shared/comment-adapter.ts +78 -0
  70. package/src/shared/logger.ts +47 -0
  71. package/src/shared/sse-schema.ts +147 -0
  72. package/src/shared/text-utils.ts +57 -0
  73. package/src/shared/types.ts +76 -0
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it, vi } from "vitest"
2
+ import { createEpochStore, SqlResultCache } from "./cache"
3
+ import type { DailyReportRedisProvider } from "./ports"
4
+ import { DailyReportSseReader, isStreamIdLte } from "./sse-reader"
5
+
6
+ /**
7
+ * Robustness tests for the SSE fan-out reader (createClient failure recovery + id compare).
8
+ * SSE Fan-Out リーダーの堅牢性テスト (createClient 失敗時の復帰 + Stream ID 比較)。
9
+ */
10
+
11
+ const makeCache = () => new SqlResultCache({ defaultTtlMs: 1000 }, createEpochStore(undefined))
12
+
13
+ describe("isStreamIdLte", () => {
14
+ it("数値比較で ms / seq を正しく順序付ける (辞書順の罠を回避)", () => {
15
+ expect(isStreamIdLte("100-0", "100-0")).toBe(true)
16
+ expect(isStreamIdLte("100-2", "100-10")).toBe(true) // 辞書順なら false になる罠
17
+ expect(isStreamIdLte("101-0", "100-9")).toBe(false)
18
+ })
19
+ })
20
+
21
+ describe("DailyReportSseReader createClient failure recovery", () => {
22
+ it("createClient が reject しても _state が stuck せず、再購読でループを再開できる", async () => {
23
+ let attempts = 0
24
+ const redis: DailyReportRedisProvider = {
25
+ getClient: async () => undefined,
26
+ // 1 回目は例外を投げ、2 回目は undefined を返す (どちらも復帰可能であること)
27
+ createClient: async () => {
28
+ attempts++
29
+ if (attempts === 1) throw new Error("connection refused")
30
+ return undefined
31
+ },
32
+ }
33
+ const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: { debug() {}, info() {}, warn() {}, error() {} } })
34
+
35
+ const errors: Error[] = []
36
+ // 1 回目の購読 → createClient throw → error emit → _state idle へ復帰
37
+ const unsub1 = reader.subscribe(
38
+ () => {},
39
+ (e) => errors.push(e),
40
+ )
41
+ // マイクロタスク/ループ完了を待つ
42
+ await vi.waitFor(() => expect(errors.length).toBeGreaterThanOrEqual(1))
43
+ expect(errors[0].message).toBe("connection refused")
44
+ unsub1()
45
+
46
+ // 2 回目の購読 → createClient は再度呼ばれる (state-stuck していない証拠)
47
+ const unsub2 = reader.subscribe(
48
+ () => {},
49
+ () => {},
50
+ )
51
+ await vi.waitFor(() => expect(attempts).toBe(2))
52
+ unsub2()
53
+ await reader.destroy()
54
+ })
55
+ })
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Fan-Out shared reader for the daily report SSE Redis Stream.
3
+ * 日報 SSE 用 Redis Stream の共有リーダーモジュール。
4
+ *
5
+ * 単一の xRead BLOCK ループで全接続にメッセージをブロードキャストし、
6
+ * 接続ごとに Redis TCP を張る問題を解消する。
7
+ */
8
+
9
+ import { EventEmitter } from "node:events"
10
+ import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
11
+ import type { SqlResultCache } from "./cache"
12
+ import type { DailyReportRedisBlockingClient, DailyReportRedisProvider } from "./ports"
13
+
14
+ /**
15
+ * Compares two Redis Stream IDs numerically.
16
+ * Redis Stream ID を数値比較し、a <= b なら true を返す。
17
+ *
18
+ * Stream ID は `<timestamp_ms>-<sequence>` 形式。
19
+ * 辞書順比較では sequence が可変長のとき不正確になるため、数値分割で比較する。
20
+ */
21
+ export const isStreamIdLte = (a: string, b: string): boolean => {
22
+ const [aMs, aSeq] = a.split("-").map(Number)
23
+ const [bMs, bSeq] = b.split("-").map(Number)
24
+ if (aMs !== bMs) return aMs < bMs
25
+ return aSeq <= bSeq
26
+ }
27
+
28
+ /** xRead 結果の 1 エントリに相当する型。 */
29
+ export type StreamEntry = {
30
+ id: string
31
+ message: Record<string, string>
32
+ }
33
+
34
+ export type DailyReportSseReaderConfig = {
35
+ /** ブロッキング xRead 用の専用クライアントを生成する redis プロバイダー。 */
36
+ redis?: DailyReportRedisProvider
37
+ /** SSE Redis Stream キー。 */
38
+ streamKey: string
39
+ /** 受信メッセージに応じてローカル SQL キャッシュを無効化する対象キャッシュ。 */
40
+ cache: SqlResultCache
41
+ /** ロガー (既定は console ベース)。 */
42
+ logger?: DailyReportLogger
43
+ }
44
+
45
+ /**
46
+ * Shared xRead loop that fans out entries to all active SSE connections.
47
+ * 全 SSE 接続に対してメッセージをファンアウトする共有 xRead ループ。
48
+ *
49
+ * - "entry" イベント: 新着エントリ (StreamEntry) をブロードキャスト
50
+ * - "error" イベント: 回復不能エラーをブロードキャスト
51
+ * */
52
+ export class DailyReportSseReader {
53
+ private _emitter = new EventEmitter()
54
+ private _state: "idle" | "running" | "stopping" = "idle"
55
+ private _refCount = 0
56
+ private _lastId = "0-0"
57
+ private _client: DailyReportRedisBlockingClient | undefined
58
+ private readonly _logger: DailyReportLogger
59
+
60
+ constructor(private readonly config: DailyReportSseReaderConfig) {
61
+ // リスナー上限を緩和 (接続数分)
62
+ this._emitter.setMaxListeners(0)
63
+ this._logger = config.logger ?? createLogger(LogLevel.INFO, "[SSE Reader]")
64
+ }
65
+
66
+ /**
67
+ * Subscribes to the shared reader. Starts the loop on first subscriber.
68
+ * 共有リーダーを購読する。最初の購読者でループを開始する。
69
+ *
70
+ * @param onEntry - 新着エントリのコールバック
71
+ * @param onError - 回復不能エラーのコールバック (省略可)
72
+ * @returns unsubscribe 関数。呼び出すと両リスナーを解除し、最後の購読者解除でループを停止する。
73
+ */
74
+ subscribe(onEntry: (entry: StreamEntry) => void, onError?: (err: Error) => void): () => void {
75
+ this._emitter.on("entry", onEntry)
76
+ if (onError) {
77
+ this._emitter.on("error", onError)
78
+ }
79
+ this._refCount++
80
+
81
+ // 最初の購読者でループ開始
82
+ if (this._refCount === 1) {
83
+ void this._startLoop()
84
+ }
85
+
86
+ // unsubscribe
87
+ return () => {
88
+ this._emitter.removeListener("entry", onEntry)
89
+ if (onError) {
90
+ this._emitter.removeListener("error", onError)
91
+ }
92
+ this._refCount--
93
+ if (this._refCount <= 0) {
94
+ this._refCount = 0
95
+ void this._stopLoop()
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Destroys the reader for test cleanup.
102
+ * テストクリーンアップ用のデストラクタ。
103
+ */
104
+ async destroy(): Promise<void> {
105
+ this._refCount = 0
106
+ await this._stopLoop()
107
+ this._emitter.removeAllListeners()
108
+ this._lastId = "0-0"
109
+ }
110
+
111
+ /**
112
+ * Internal xRead BLOCK loop.
113
+ * 内部 xRead BLOCK ループ。
114
+ */
115
+ private async _startLoop(): Promise<void> {
116
+ // stopping 中なら完了を待つ
117
+ while (this._state === "stopping") {
118
+ await new Promise((resolve) => setTimeout(resolve, 50))
119
+ }
120
+ if (this._state === "running") return
121
+
122
+ this._state = "running"
123
+
124
+ // 専用クライアント取得。createClient が reject した場合も _state を idle へ戻し、
125
+ // 再購読でループを再開できるようにする (注入 redis 実装が例外を投げても state-stuck しない)。
126
+ try {
127
+ this._client = await this.config.redis?.createClient()
128
+ } catch (err) {
129
+ this._state = "idle"
130
+ this._emitter.emit("error", err instanceof Error ? err : new Error("Failed to create Redis client for SSE reader"))
131
+ return
132
+ }
133
+ if (!this._client) {
134
+ this._state = "idle"
135
+ this._emitter.emit("error", new Error("Failed to create Redis client for SSE reader"))
136
+ return
137
+ }
138
+
139
+ // エラーリスナー
140
+ this._client.on("error", (err) => {
141
+ this._logger.error("Redis client error:", err)
142
+ })
143
+
144
+ try {
145
+ while (this._state === "running" && this._client?.isOpen) {
146
+ const results = await this._client.xRead([{ key: this.config.streamKey, id: this._lastId }], { BLOCK: 5000, COUNT: 100 })
147
+ if (!results) continue
148
+
149
+ for (const stream of results) {
150
+ for (const msg of stream.messages) {
151
+ this._lastId = msg.id
152
+ // epoch check の補完: SSE 経由でもローカルキャッシュを即座にクリア
153
+ // (epoch check が主防御線。SSE は Redis ダウン時や非 epoch 対象キャッシュへのフォールバック)
154
+ try {
155
+ const parsed = msg.message?.data ? JSON.parse(msg.message.data) : null
156
+ const msgType = parsed?.type
157
+ if (msgType === "report-create" || msgType === "report-delete" || msgType === "report-publish") {
158
+ this.config.cache.invalidatePrefix("daily-report:ids")
159
+ }
160
+ // コメント・ステータス変更時は対象レポートの詳細キャッシュを無効化
161
+ if (msgType === "comment-add" || msgType === "comment-delete" || msgType === "status-update") {
162
+ const reportHubId = parsed?.reportHubId
163
+ if (typeof reportHubId === "number") {
164
+ this.config.cache.invalidate(`daily-report:detail:${reportHubId}`)
165
+ this.config.cache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
166
+ }
167
+ }
168
+ } catch {
169
+ // JSON パースエラーは無視 (invalidation スキップ = safe 方向)
170
+ }
171
+ this._emitter.emit("entry", { id: msg.id, message: msg.message as Record<string, string> })
172
+ }
173
+ }
174
+ }
175
+ } catch (err) {
176
+ // ClientClosedError は正常停止時に発生する
177
+ const isClientClosed = (err as Error)?.constructor?.name === "ClientClosedError"
178
+ if (!isClientClosed && this._state === "running") {
179
+ this._logger.error("xRead loop error:", err)
180
+ this._emitter.emit("error", err)
181
+ }
182
+ } finally {
183
+ // クライアント切断
184
+ if (this._client?.isOpen) {
185
+ try {
186
+ await this._client.quit()
187
+ } catch (_e) {
188
+ // 切断エラーは無視
189
+ }
190
+ }
191
+ this._client = undefined
192
+ this._state = "idle"
193
+
194
+ // ループ終了後に購読者が残っていれば自動再起動
195
+ if (this._refCount > 0) {
196
+ void this._startLoop()
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Stops the xRead loop by closing the client.
203
+ * クライアントを閉じて xRead ループを停止する。
204
+ */
205
+ private async _stopLoop(): Promise<void> {
206
+ if (this._state !== "running") return
207
+ this._state = "stopping"
208
+
209
+ if (this._client?.isOpen) {
210
+ try {
211
+ await this._client.quit()
212
+ } catch (_e) {
213
+ // 切断エラーは無視
214
+ }
215
+ }
216
+
217
+ // idle になるまで待機。_state は別の非同期ループ (connect の run ループ) が "idle" に戻すが、
218
+ // TS は await を跨いだクラスフィールドの変化を追えず "stopping" に過剰 narrowing するため、宣言型へキャストして比較する。
219
+ while ((this._state as "idle" | "running" | "stopping") !== "idle") {
220
+ await new Promise((resolve) => setTimeout(resolve, 50))
221
+ }
222
+ }
223
+ }
package/src/server.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Server entry of @aiquants/daily-report: schema factory, service, SSE reader, and handler factories.
3
+ * @aiquants/daily-report の server エントリ。スキーマファクトリ・サービス・SSE リーダー・ハンドラ工場を公開。
4
+ */
5
+ import { createEpochStore, SqlResultCache } from "./server/cache"
6
+ import type { DailyReportExternalSource } from "./server/external-source"
7
+ import { createDailyReportHandlers } from "./server/handlers"
8
+ import type { DailyReportAuthenticate } from "./server/ports"
9
+ import { createDailyReportService, type DailyReportServiceConfig } from "./server/service"
10
+ import { DailyReportSseReader } from "./server/sse-reader"
11
+
12
+ export * from "./server/cache"
13
+ export { generateETag } from "./server/etag"
14
+ export * from "./server/external-source"
15
+ export * from "./server/handlers"
16
+ export * from "./server/ports"
17
+ export * from "./server/response"
18
+ export * from "./server/schema"
19
+ export * from "./server/service"
20
+ export * from "./server/sse-reader"
21
+
22
+ /** createDailyReportServer の設定 (サービス設定 + 認証ポート + ハンドラ設定)。 */
23
+ export type DailyReportServerConfig = Omit<DailyReportServiceConfig, "cache" | "epochs"> & {
24
+ /** リクエスト認証ポート。 */
25
+ authenticate: DailyReportAuthenticate
26
+ /** 未ログイン時のリダイレクト先 (index.loader 用、既定 "/auth/login")。 */
27
+ loginRedirectPath?: string
28
+ /** SQL 結果キャッシュの既定 TTL (既定 60,000ms)。 */
29
+ cacheDefaultTtlMs?: number
30
+ /** 外部ソースアダプタ群 (レガシー日報テーブル等)。 */
31
+ externalSources?: DailyReportExternalSource[]
32
+ }
33
+
34
+ /**
35
+ * One-stop factory wiring cache, service, SSE fan-out reader, and route handlers.
36
+ * キャッシュ・サービス・SSE Fan-Out リーダー・ルートハンドラを一括結線するファクトリ。
37
+ *
38
+ * @example
39
+ * const server = createDailyReportServer({ db, tables, userTable, resolveUserId, encodeUserId, authenticate, redis })
40
+ * // routes:
41
+ * // daily_report._index → server.index.loader (+ data() ラップ)
42
+ * // daily_report.api.$endpoint → server.api.loader / server.api.action
43
+ * // sse.daily_report.$endpoint → server.sse.loader
44
+ */
45
+ export function createDailyReportServer(config: DailyReportServerConfig) {
46
+ // epoch ストアと SQL 結果キャッシュ (サービスと SSE リーダーで同一インスタンスを共有する)
47
+ const epochs = createEpochStore(config.redis)
48
+ const cache = new SqlResultCache({ defaultTtlMs: config.cacheDefaultTtlMs ?? 60_000 }, epochs)
49
+
50
+ const service = createDailyReportService({ ...config, cache, epochs })
51
+
52
+ const sseReader = new DailyReportSseReader({
53
+ redis: config.redis,
54
+ streamKey: service.streamKey,
55
+ cache,
56
+ logger: config.logger,
57
+ })
58
+
59
+ const handlers = createDailyReportHandlers({
60
+ authenticate: config.authenticate,
61
+ service,
62
+ encodeUserId: config.encodeUserId,
63
+ redis: config.redis,
64
+ sseReader,
65
+ streamKey: service.streamKey,
66
+ loginRedirectPath: config.loginRedirectPath,
67
+ logger: config.logger,
68
+ })
69
+
70
+ return {
71
+ /** データアクセスサービス (CRUD + キャッシュ + SSE publish)。 */
72
+ service,
73
+ /** SQL 結果キャッシュ (サービス・SSE リーダー共有インスタンス)。 */
74
+ cache,
75
+ /** クロスワーカー epoch ストア。 */
76
+ epochs,
77
+ /** SSE Fan-Out 共有リーダー。 */
78
+ sseReader,
79
+ /** SSE Redis Stream キー。 */
80
+ streamKey: service.streamKey,
81
+ ...handlers,
82
+ }
83
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { formatBusinessDateDisplay, normalizeBusinessDateKey, parseBusinessDateKeyToDate } from "./business-date"
3
+
4
+ describe("business-date utils", () => {
5
+ describe("normalizeBusinessDateKey", () => {
6
+ it("should normalize Date object", () => {
7
+ const date = new Date(2023, 0, 1) // 2023-01-01
8
+ expect(normalizeBusinessDateKey(date)).toBe("2023-01-01")
9
+ })
10
+
11
+ it("should normalize ISO string", () => {
12
+ expect(normalizeBusinessDateKey("2023-01-01")).toBe("2023-01-01")
13
+ })
14
+
15
+ it("should normalize slash string", () => {
16
+ expect(normalizeBusinessDateKey("2023/01/01")).toBe("2023-01-01")
17
+ })
18
+
19
+ it("should normalize other date strings", () => {
20
+ expect(normalizeBusinessDateKey("Jan 1, 2023")).toBe("2023-01-01")
21
+ })
22
+
23
+ it("should return null for null/undefined", () => {
24
+ expect(normalizeBusinessDateKey(null)).toBeNull()
25
+ expect(normalizeBusinessDateKey(undefined)).toBeNull()
26
+ })
27
+
28
+ it("should return null for empty string", () => {
29
+ expect(normalizeBusinessDateKey("")).toBeNull()
30
+ expect(normalizeBusinessDateKey(" ")).toBeNull()
31
+ })
32
+
33
+ it("should return null for invalid date string", () => {
34
+ expect(normalizeBusinessDateKey("invalid-date")).toBeNull()
35
+ })
36
+ })
37
+
38
+ describe("formatBusinessDateDisplay", () => {
39
+ it("should format valid input", () => {
40
+ expect(formatBusinessDateDisplay("2023/01/01")).toBe("2023-01-01")
41
+ })
42
+
43
+ it("should return null for invalid input", () => {
44
+ expect(formatBusinessDateDisplay("invalid")).toBeNull()
45
+ })
46
+ })
47
+
48
+ describe("parseBusinessDateKeyToDate", () => {
49
+ it("should parse valid input", () => {
50
+ const result = parseBusinessDateKeyToDate("2023-01-01")
51
+ expect(result).toBeInstanceOf(Date)
52
+ expect(result?.getFullYear()).toBe(2023)
53
+ expect(result?.getMonth()).toBe(0)
54
+ expect(result?.getDate()).toBe(1)
55
+ })
56
+
57
+ it("should return null for invalid input", () => {
58
+ expect(parseBusinessDateKeyToDate("invalid")).toBeNull()
59
+ })
60
+ })
61
+ })
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Business date normalization and formatting helper functions.
3
+ * 営業日の正規化とフォーマットを支援するユーティリティ群。
4
+ */
5
+
6
+ type BusinessDateInput = string | Date | null | undefined
7
+
8
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
9
+ const SLASH_DATE_PATTERN = /^\d{4}\/\d{2}\/\d{2}$/
10
+
11
+ const zeroPad = (value: number): string => {
12
+ return value < 10 ? `0${value}` : `${value}`
13
+ }
14
+
15
+ const normalizeFromDate = (value: Date): string | null => {
16
+ if (Number.isNaN(value.getTime())) {
17
+ return null
18
+ }
19
+
20
+ const year = value.getFullYear()
21
+ const month = zeroPad(value.getMonth() + 1)
22
+ const day = zeroPad(value.getDate())
23
+ return `${year}-${month}-${day}`
24
+ }
25
+
26
+ /**
27
+ * Normalizes business date input into YYYY-MM-DD format string.
28
+ * 営業日を YYYY-MM-DD 形式の文字列へ正規化する。
29
+ */
30
+ export const normalizeBusinessDateKey = (value: BusinessDateInput): string | null => {
31
+ if (value === null || value === undefined) {
32
+ return null
33
+ }
34
+
35
+ if (value instanceof Date) {
36
+ return normalizeFromDate(value)
37
+ }
38
+
39
+ const trimmed = value.trim()
40
+ if (trimmed === "") {
41
+ return null
42
+ }
43
+
44
+ if (ISO_DATE_PATTERN.test(trimmed)) {
45
+ return trimmed
46
+ }
47
+
48
+ if (SLASH_DATE_PATTERN.test(trimmed)) {
49
+ return trimmed.replaceAll("/", "-")
50
+ }
51
+
52
+ const parsed = new Date(trimmed)
53
+ return normalizeFromDate(parsed)
54
+ }
55
+
56
+ /**
57
+ * Converts normalized business date into display format YYYY-MM-DD.
58
+ * 正規化済みの営業日を YYYY-MM-DD 形式の表示文字列へ変換する。
59
+ */
60
+ export const formatBusinessDateDisplay = (value: BusinessDateInput): string | null => {
61
+ const normalized = normalizeBusinessDateKey(value)
62
+ if (!normalized) {
63
+ return null
64
+ }
65
+ return normalized
66
+ }
67
+
68
+ /**
69
+ * Parses normalized business date into Date object.
70
+ * 正規化した営業日を Date オブジェクトへ変換する。
71
+ */
72
+ export const parseBusinessDateKeyToDate = (value: BusinessDateInput): Date | null => {
73
+ const normalized = normalizeBusinessDateKey(value)
74
+ if (!normalized) {
75
+ return null
76
+ }
77
+
78
+ const [year, month, day] = normalized.split("-").map((part) => Number.parseInt(part, 10))
79
+ if ([year, month, day].some((component) => Number.isNaN(component))) {
80
+ return null
81
+ }
82
+
83
+ return new Date(year, month - 1, day)
84
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Adapters merging legacy JSON comments and relational comments for the UI.
3
+ * レガシー JSON コメントとリレーショナルコメントを UI 向けに統合するアダプタ群。
4
+ */
5
+ import type { DailyReportComment, DailyReportCommentItem } from "./types"
6
+
7
+ export type UIComment = {
8
+ id: string | number
9
+ authorName: string
10
+ content: string
11
+ createdAt?: string
12
+ isMine: boolean
13
+ color?: string
14
+ isLegacy: boolean
15
+ }
16
+
17
+ /**
18
+ * Merges legacy JSON comments and modern relational comments into a unified UI format.
19
+ * レガシーな JSON コメントとモダンなリレーショナルコメントを統合された UI 形式にマージする。
20
+ */
21
+ export const mergeComments = (legacyComments: DailyReportComment[], modernComments: DailyReportCommentItem[], currentUserId?: string | null): UIComment[] => {
22
+ // 1. Legacy コメントの変換
23
+ const legacyUIComments: UIComment[] = legacyComments.map((c, index) => ({
24
+ id: `legacy-${index}`,
25
+ authorName: c.name || "Unknown",
26
+ content: c.text || "",
27
+ isMine: false,
28
+ color: c.color || undefined,
29
+ isLegacy: true,
30
+ }))
31
+
32
+ // 2. Modern コメントの変換
33
+ const modernUIComments: UIComment[] = modernComments.map((c) => ({
34
+ id: c.id,
35
+ authorName: c.userName,
36
+ content: c.content,
37
+ createdAt: c.createdAt,
38
+ isMine: currentUserId ? c.userId === currentUserId : c.isMine,
39
+ isLegacy: false,
40
+ }))
41
+
42
+ // 3. 結合 (Legacy を先に表示)
43
+ return [...legacyUIComments, ...modernUIComments]
44
+ }
45
+
46
+ /**
47
+ * Resolves comment color tokens into Tailwind text classes.
48
+ * コメント色のキーワードを Tailwind のテキストクラスに変換する関数。
49
+ */
50
+ export const resolveCommentColorClass = (color: string | null | undefined): string => {
51
+ if (!color) {
52
+ return "text-gray-700"
53
+ }
54
+
55
+ const normalized = color.toLowerCase()
56
+
57
+ if (normalized.includes("red") || normalized.includes("赤")) {
58
+ return "text-red-600"
59
+ }
60
+
61
+ if (normalized.includes("green") || normalized.includes("緑")) {
62
+ return "text-green-600"
63
+ }
64
+
65
+ if (normalized.includes("blue") || normalized.includes("青")) {
66
+ return "text-blue-600"
67
+ }
68
+
69
+ if (normalized.includes("yellow") || normalized.includes("黄")) {
70
+ return "text-yellow-600"
71
+ }
72
+
73
+ if (normalized.includes("orange") || normalized.includes("橙")) {
74
+ return "text-orange-500"
75
+ }
76
+
77
+ return "text-gray-700"
78
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Minimal level-filtered logger used internally by the daily-report package.
3
+ * daily-report パッケージ内部で使う最小のレベルフィルタ付きロガー。
4
+ *
5
+ * 消費アプリのロガー実装 (console 互換の debug/info/warn/error) を注入でき、
6
+ * 未注入時は console にフォールバックする。
7
+ */
8
+
9
+ /** 出力フィルタリング用のログレベル。 */
10
+ export enum LogLevel {
11
+ DEBUG = 0,
12
+ INFO = 1,
13
+ WARN = 2,
14
+ ERROR = 3,
15
+ NONE = 4,
16
+ }
17
+
18
+ /** Console 互換のロガーインターフェース。 */
19
+ export interface DailyReportLogger {
20
+ debug(message?: unknown, ...optionalParams: unknown[]): void
21
+ info(message?: unknown, ...optionalParams: unknown[]): void
22
+ warn(message?: unknown, ...optionalParams: unknown[]): void
23
+ error(message?: unknown, ...optionalParams: unknown[]): void
24
+ }
25
+
26
+ /**
27
+ * Creates a prefixed, level-filtered logger delegating to the given implementation.
28
+ * 指定実装へ委譲するプレフィックス付き・レベルフィルタ付きロガーを生成する処理。
29
+ */
30
+ export const createLogger = (level: LogLevel, prefix: string, impl: DailyReportLogger = console): DailyReportLogger => {
31
+ // 文字列メッセージはプレフィックスを連結、それ以外は先頭引数として付加する
32
+ const format = (message: unknown): unknown[] => (typeof message === "string" ? [`${prefix} ${message}`] : [prefix, message])
33
+ return {
34
+ debug: (message?: unknown, ...rest: unknown[]) => {
35
+ if (level <= LogLevel.DEBUG) impl.debug(...format(message), ...rest)
36
+ },
37
+ info: (message?: unknown, ...rest: unknown[]) => {
38
+ if (level <= LogLevel.INFO) impl.info(...format(message), ...rest)
39
+ },
40
+ warn: (message?: unknown, ...rest: unknown[]) => {
41
+ if (level <= LogLevel.WARN) impl.warn(...format(message), ...rest)
42
+ },
43
+ error: (message?: unknown, ...rest: unknown[]) => {
44
+ if (level <= LogLevel.ERROR) impl.error(...format(message), ...rest)
45
+ },
46
+ }
47
+ }