@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,73 @@
1
+ /**
2
+ * Comment management hook merging server data with optimistic local updates.
3
+ * サーバーデータとローカルの楽観的更新を統合して日報コメントを管理するフック。
4
+ */
5
+ import { useCallback, useMemo, useRef, useState } from "react"
6
+ import { mergeComments, type UIComment } from "../../shared/comment-adapter"
7
+ import type { DailyReportDetail } from "../../shared/types"
8
+ import { useDailyReportActionContext } from "../contexts/daily-report-action-context"
9
+
10
+ type User = { displayName?: string; name?: { familyName?: string; givenName?: string }; emails?: { value: string }[] }
11
+
12
+ /**
13
+ * Manages daily report comments, integrating server data with optimistic local updates.
14
+ * サーバーデータとローカルの楽観的更新を統合して日報コメントを管理するフック。
15
+ */
16
+ export const useDailyReportComments = (report: DailyReportDetail | null, user: User | null | undefined, userId?: string | null) => {
17
+ const { pendingAddCommentIds, pendingDeleteCommentIds, resolvedIdMap, version, addComment, deleteComment } = useDailyReportActionContext()
18
+ const [pendingDeleteId, setPendingDeleteId] = useState<number | string | null>(null)
19
+
20
+ const handleDeleteComment = useCallback((cId: number) => report && deleteComment(report.reportHubId, cId, report.date ?? ""), [report, deleteComment])
21
+ const handleAddComment = useCallback((content: string) => (report && user ? addComment(report.reportHubId, content, report.date ?? "") : Promise.resolve()), [report, user, addComment])
22
+
23
+ const prevUiCommentsRef = useRef<UIComment[]>([])
24
+
25
+ // UI用コメントリストの生成: サーバーデータ、レガシーコメント、楽観的更新(追加・削除)をマージして表示用データを構築する。
26
+ // 依存関係: [report, user, userId, pendingDeleteCommentIds, pendingAddCommentIds, resolvedIdMap, version]
27
+ // クリーンアップ: なし
28
+ // biome-ignore lint/correctness/useExhaustiveDependencies: version は ref (pending*) 変更時の再計算トリガーとして意図的に依存させる
29
+ const uiComments = useMemo(() => {
30
+ if (!report) return []
31
+
32
+ // 1. Base: Merge legacy and new comments
33
+ // 基本データ: レガシーコメントと新しい形式のコメントをマージ。
34
+ let comments = mergeComments(report.comments, report.commentItems, userId)
35
+
36
+ // 2. Filter: Remove pending deletions
37
+ // フィルタリング: 削除保留中のコメントを除外。
38
+ const deletedIds = pendingDeleteCommentIds.current.get(report.reportHubId)
39
+ if (deletedIds?.size) comments = comments.filter((c) => !deletedIds.has(c.id as number))
40
+
41
+ // 3. Add: Append pending optimistic additions
42
+ // 追加: 楽観的に追加されたコメントを末尾に追加。
43
+ const addedComments = pendingAddCommentIds.current.get(report.reportHubId)
44
+ if (addedComments?.size && user) {
45
+ const existingIds = new Set(comments.map((c) => c.id))
46
+ const authorName = user.displayName?.trim() || (user.name ? `${user.name.familyName ?? ""} ${user.name.givenName ?? ""}`.trim() : "") || user.emails?.[0]?.value || "Unknown"
47
+
48
+ for (const [tempId, content] of addedComments) {
49
+ if (deletedIds?.has(tempId)) continue
50
+ if (existingIds.has(tempId)) continue
51
+
52
+ // Skip if real ID resolved and already present
53
+ // 実IDが解決済みで既にリストに存在する場合はスキップ。
54
+ const realId = resolvedIdMap.current.get(tempId)
55
+ if (realId && existingIds.has(realId)) continue
56
+
57
+ // Skip if content duplicated (server data arrived before ID resolution)
58
+ // 内容が重複している場合(ID解決前にサーバーデータが到達した場合)はスキップ。
59
+ if (comments.some((c) => c.isMine && c.content === content && typeof c.id === "number" && c.id > 0)) continue
60
+
61
+ comments.push({ id: tempId, authorName, content, createdAt: new Date().toISOString(), isMine: true, isLegacy: false })
62
+ }
63
+ }
64
+
65
+ // Stable reference check
66
+ // 参照の安定性チェック: 内容に変更がなければ前回の参照を返すことで不要な再レンダリングを防ぐ。
67
+ if (JSON.stringify(comments) === JSON.stringify(prevUiCommentsRef.current)) return prevUiCommentsRef.current
68
+ prevUiCommentsRef.current = comments
69
+ return comments
70
+ }, [report, user, userId, pendingDeleteCommentIds, pendingAddCommentIds, resolvedIdMap, version])
71
+
72
+ return { uiComments, handleAddComment, handleDeleteComment, pendingDeleteId, setPendingDeleteId, resolvedIdMap: resolvedIdMap.current }
73
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * SSE connection lifecycle hook for daily-report real-time updates.
3
+ * 日報リアルタイム更新の SSE 接続ライフサイクルフック。
4
+ */
5
+ import { useEffect } from "react"
6
+ import { createLogger, LogLevel } from "../../shared/logger"
7
+ import { type DailyReportSseMessage, dailyReportSseMessageSchema } from "../../shared/sse-schema"
8
+ import { useDailyReportConfig } from "../config-context"
9
+
10
+ const logger = createLogger(LogLevel.INFO, "daily-report-sse-connection")
11
+
12
+ type UseDailyReportSseConnectionOptions = {
13
+ isSseEnabled: boolean
14
+ onMessage: (payload: Exclude<DailyReportSseMessage, { type: "connected" }>) => void
15
+ }
16
+
17
+ /**
18
+ * Manages Daily Report SSE connection lifecycle with reconnect handling.
19
+ * Handles open, message parse/dispatch, error logging, retry, and cleanup.
20
+ *
21
+ * 日報 SSE 接続のライフサイクルを管理する Hook。
22
+ * 接続開始、メッセージ解析と配送、エラー時再接続、クリーンアップを管理します。
23
+ */
24
+ export const useDailyReportSseConnection = ({ isSseEnabled, onMessage }: UseDailyReportSseConnectionOptions) => {
25
+ const { ssePath } = useDailyReportConfig()
26
+
27
+ /**
28
+ * Maintains SSE connection while enabled and retries on error.
29
+ * isSseEnabled が true の間は接続を維持し、エラー時は再接続します。
30
+ */
31
+ useEffect(() => {
32
+ if (!isSseEnabled) return
33
+
34
+ let eventSource: EventSource | null = null
35
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
36
+ let reconnectAttempt = 0
37
+ let lastReceivedEventId: string | undefined
38
+
39
+ const connect = () => {
40
+ eventSource?.close()
41
+ eventSource = new EventSource(`${ssePath}${lastReceivedEventId ? `?lastEventId=${lastReceivedEventId}` : ""}`)
42
+
43
+ eventSource.onopen = () => {
44
+ reconnectAttempt = 0
45
+ logger.info("SSE Connected")
46
+ }
47
+
48
+ eventSource.onmessage = (event) => {
49
+ if (event.lastEventId) {
50
+ lastReceivedEventId = event.lastEventId
51
+ }
52
+ try {
53
+ const raw = JSON.parse(event.data)
54
+ const result = dailyReportSseMessageSchema.safeParse(raw)
55
+ if (!result.success) {
56
+ logger.error("SSE Validation Error", result.error.format())
57
+ return
58
+ }
59
+ if (result.data.type === "connected") {
60
+ logger.info("SSE Subscribe Ready")
61
+ return
62
+ }
63
+ onMessage(result.data)
64
+ } catch (e) {
65
+ logger.error("SSE Parse Error", e)
66
+ }
67
+ }
68
+
69
+ eventSource.onerror = (e) => {
70
+ eventSource?.close()
71
+ eventSource = null
72
+ reconnectAttempt++
73
+ const delayMs = Math.min(2000 * 2 ** (reconnectAttempt - 1), 30000)
74
+ logger.error(`SSE Error. Retrying in ${delayMs}ms (attempt ${reconnectAttempt})...`, e)
75
+ reconnectTimer = setTimeout(connect, delayMs)
76
+ }
77
+ }
78
+
79
+ connect()
80
+
81
+ return () => {
82
+ if (eventSource) eventSource.close()
83
+ if (reconnectTimer) clearTimeout(reconnectTimer)
84
+ }
85
+ }, [isSseEnabled, onMessage, ssePath])
86
+ }
@@ -0,0 +1,155 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+ import type { DailyReportDetail } from "../../shared/types"
3
+
4
+ /**
5
+ * Unit tests for DailyReportCache public API and getGlobal behavior.
6
+ * DailyReportCache のパブリック API と getGlobal の挙動を検証するユニットテスト。
7
+ */
8
+
9
+ // テスト用のモック日報データを生成するヘルパー関数
10
+ const makeMockReport = (overrides: Partial<DailyReportDetail> = {}): DailyReportDetail =>
11
+ ({
12
+ reportHubId: 1,
13
+ subject: "Test Report",
14
+ content: "Test Content",
15
+ date: "2026-01-01",
16
+ labels: [],
17
+ commentItems: [],
18
+ isRead: false,
19
+ isStar: false,
20
+ updatedAt: new Date().toISOString(),
21
+ ...overrides,
22
+ }) as DailyReportDetail
23
+
24
+ describe("DailyReportCache public API", () => {
25
+ // vitest は jsdom 環境で動作するため window オブジェクトが存在する。
26
+ // vi.resetModules() + 動的 import でモジュールを再読み込みし、
27
+ // getGlobal がカスタム TTL を読み込むようにする。
28
+
29
+ let writeCache: typeof import("./use-daily-report").writeCache
30
+ let getCachedReport: typeof import("./use-daily-report").getCachedReport
31
+ let updateDailyReportCache: typeof import("./use-daily-report").updateDailyReportCache
32
+ let applyDailyReportServerUpdates: typeof import("./use-daily-report").applyDailyReportServerUpdates
33
+
34
+ beforeEach(async () => {
35
+ // モジュールキャッシュをリセットして、getGlobal がカスタム TTL を読み込むようにする
36
+ vi.resetModules()
37
+ // テスト用に短い TTL を設定
38
+ ;(window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__ = 5000
39
+ ;(window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_REVIVE_MS__ = 1000
40
+
41
+ // モジュールを動的にインポート(各テストで独立したキャッシュインスタンスを使用)
42
+ const mod = await import("./use-daily-report")
43
+ writeCache = mod.writeCache
44
+ getCachedReport = mod.getCachedReport
45
+ updateDailyReportCache = mod.updateDailyReportCache
46
+ applyDailyReportServerUpdates = mod.applyDailyReportServerUpdates
47
+ })
48
+
49
+ afterEach(() => {
50
+ // window グローバル変数をクリーンアップ
51
+ delete (window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__
52
+ delete (window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_REVIVE_MS__
53
+ })
54
+
55
+ describe("writeCache (DailyReportCache.set)", () => {
56
+ it("レポートを格納し、ID で取得できる", () => {
57
+ const report = makeMockReport()
58
+ writeCache(report, Date.now() + 5000)
59
+ const cached = getCachedReport(1)
60
+ expect(cached).toBeDefined()
61
+ expect(cached?.reportHubId).toBe(1)
62
+ expect(cached?.subject).toBe("Test Report")
63
+ })
64
+
65
+ it("同一 ID の set は完全置換される", () => {
66
+ writeCache(makeMockReport({ subject: "Original" }), Date.now() + 5000)
67
+ writeCache(makeMockReport({ subject: "Replaced", content: "New" }), Date.now() + 5000)
68
+ const cached = getCachedReport(1)
69
+ expect(cached?.subject).toBe("Replaced")
70
+ expect(cached?.content).toBe("New")
71
+ })
72
+
73
+ it("期限切れエントリは null を返す", () => {
74
+ writeCache(makeMockReport(), Date.now() - 1)
75
+ expect(getCachedReport(1)).toBeNull()
76
+ })
77
+ })
78
+
79
+ describe("updateDailyReportCache (DailyReportCache.update)", () => {
80
+ it("既存レポートの指定フィールドのみを更新し、他は保持する", () => {
81
+ writeCache(makeMockReport({ subject: "Original", content: "Original Content" }), Date.now() + 5000)
82
+ updateDailyReportCache(1, { subject: "Updated Title" } as Partial<DailyReportDetail>)
83
+ const cached = getCachedReport(1)
84
+ expect(cached?.subject).toBe("Updated Title")
85
+ expect(cached?.content).toBe("Original Content")
86
+ })
87
+
88
+ it("updatedAt が古い更新はスキップされる (stale guard)", () => {
89
+ writeCache(makeMockReport({ subject: "Current", updatedAt: "2026-01-02T00:00:00Z" }), Date.now() + 5000)
90
+ updateDailyReportCache(1, {
91
+ subject: "Stale",
92
+ updatedAt: "2026-01-01T00:00:00Z",
93
+ } as Partial<DailyReportDetail>)
94
+ expect(getCachedReport(1)?.subject).toBe("Current")
95
+ })
96
+
97
+ it("未キャッシュの ID を更新しても何も起きない", () => {
98
+ updateDailyReportCache(999, { subject: "Ghost" } as Partial<DailyReportDetail>)
99
+ expect(getCachedReport(999)).toBeNull()
100
+ })
101
+ })
102
+
103
+ describe("applyDailyReportServerUpdates", () => {
104
+ it("キャッシュ済みレポートを完全置換する", () => {
105
+ writeCache(makeMockReport({ subject: "Old", content: "Old" }), Date.now() + 1000)
106
+ applyDailyReportServerUpdates(1, makeMockReport({ subject: "Fresh", content: "Fresh" }))
107
+ const cached = getCachedReport(1)
108
+ expect(cached?.subject).toBe("Fresh")
109
+ expect(cached?.content).toBe("Fresh")
110
+ })
111
+
112
+ it("未キャッシュの ID でも新規エントリとして格納される", () => {
113
+ applyDailyReportServerUpdates(42, makeMockReport({ reportHubId: 42, subject: "New" }))
114
+ expect(getCachedReport(42)?.subject).toBe("New")
115
+ })
116
+ })
117
+ })
118
+
119
+ describe("getGlobal behavior (window グローバル変数の読み取り)", () => {
120
+ afterEach(() => {
121
+ delete (window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__
122
+ delete (window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_REVIVE_MS__
123
+ })
124
+
125
+ it("window に正の数値が設定されていれば、その値が使われる", async () => {
126
+ vi.resetModules()
127
+ ;(window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__ = 7777
128
+
129
+ const mod = await import("./use-daily-report")
130
+ const report = makeMockReport()
131
+ mod.writeCache(report, Date.now() + 7777)
132
+ expect(mod.getCachedReport(1)).toBeDefined()
133
+ })
134
+
135
+ it("window が undefined ならデフォルト値 (300000ms) が使われる", async () => {
136
+ vi.resetModules()
137
+ delete (window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__
138
+
139
+ const mod = await import("./use-daily-report")
140
+ const report = makeMockReport()
141
+ mod.writeCache(report, Date.now() + 300_000)
142
+ expect(mod.getCachedReport(1)).toBeDefined()
143
+ })
144
+
145
+ it("window に 0 が設定されていれば 0 が使われる (|| バグの修正確認)", async () => {
146
+ vi.resetModules()
147
+ ;(window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__ = 0
148
+
149
+ const mod = await import("./use-daily-report")
150
+ const report = makeMockReport()
151
+ // TTL=0ms → expiresAt = Date.now() + 0 → 即時期限切れ → getCachedReport は null
152
+ mod.writeCache(report, Date.now() + 0)
153
+ expect(mod.getCachedReport(1)).toBeNull()
154
+ })
155
+ })