@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,426 @@
1
+ /**
2
+ * Suspense-friendly daily report detail loader (fetch-driven).
3
+ * fetch ベースの日報詳細ローダー。
4
+ */
5
+ import { useCallback, useEffect, useRef, useState } from "react"
6
+ import { normalizeBusinessDateKey } from "../../shared/business-date"
7
+ import { createLogger, LogLevel } from "../../shared/logger"
8
+ import type { DailyReportDetail } from "../../shared/types"
9
+ import { useDailyReportConfig } from "../config-context"
10
+
11
+ const logger = createLogger(LogLevel.INFO, "use-daily-report")
12
+
13
+ // --- Configuration & Constants ---
14
+ /**
15
+ * Retrieve global configuration or default.
16
+ * グローバル設定またはデフォルト値を取得する。
17
+ */
18
+ const getGlobal = (k: string, d: number): number => {
19
+ if (typeof window === "undefined") return d
20
+ const v = (window as unknown as Record<string, unknown>)[k]
21
+ return typeof v === "number" && !Number.isNaN(v) ? v : d
22
+ }
23
+ const TTL = getGlobal("__DAILY_REPORT_CACHE_TTL__", 300_000)
24
+ const REVIVE_MS = getGlobal("__DAILY_REPORT_CACHE_REVIVE_MS__", 60_000)
25
+ const FETCH_DEBOUNCE_MS = 120
26
+
27
+ // --- Cache Store (Centralized State) ---
28
+ type CacheEntry<T> = { data: T; expiresAt: number }
29
+
30
+ const DailyReportCache = {
31
+ reports: new Map<number, CacheEntry<DailyReportDetail>>(),
32
+ lists: new Map<string, CacheEntry<DailyReportDetail[]>>(),
33
+ pendingLists: new Map<string, Promise<DailyReportDetail[]>>(),
34
+ pendingReports: new Map<number, Promise<DailyReportDetail>>(),
35
+ listeners: new Set<(id: number) => void>(),
36
+ locks: new Set<number>(),
37
+ lastMutations: new Map<number, number>(),
38
+ recentDeletes: new Map<number, number>(),
39
+ lastPrune: 0,
40
+
41
+ /**
42
+ * Prune expired entries from the cache.
43
+ * キャッシュから期限切れのエントリを削除する。
44
+ */
45
+ prune() {
46
+ const now = Date.now()
47
+ // Prune at most once per minute
48
+ if (now - this.lastPrune < 60_000) return
49
+ this.lastPrune = now
50
+
51
+ // SWR のために古いデータを保持する。TTL 超過後すぐには削除せず、十分に古い(例えば 1時間経過した)データのみを破棄する
52
+ const GC_THRESHOLD = 60 * 60 * 1000 // 1 hour
53
+ for (const [id, entry] of this.reports) {
54
+ if (now - entry.expiresAt > GC_THRESHOLD) this.reports.delete(id)
55
+ }
56
+ for (const [key, entry] of this.lists) {
57
+ if (now - entry.expiresAt > GC_THRESHOLD) this.lists.delete(key)
58
+ }
59
+ },
60
+
61
+ /**
62
+ * Check if the cache entry is still valid.
63
+ * キャッシュエントリがまだ有効かを確認する。
64
+ */
65
+ isValid: (ex: number) => ex > Date.now(),
66
+ /**
67
+ * Notify all listeners of a report update.
68
+ * レポートの更新をすべてのリスナーに通知する。
69
+ */
70
+ notify: (id: number) => {
71
+ DailyReportCache.listeners.forEach((l) => {
72
+ l(id)
73
+ })
74
+ },
75
+
76
+ /**
77
+ * Retrieve a cached report by ID.
78
+ * ID でキャッシュされたレポートを取得する。
79
+ */
80
+ getReport(id: number, ignoreExpiry = false) {
81
+ const e = this.reports.get(id)
82
+ return e && (ignoreExpiry || this.isValid(e.expiresAt)) ? e.data : null
83
+ },
84
+
85
+ /**
86
+ * Retrieve a cached list of reports by business date key.
87
+ * 営業日キーでレポートのキャッシュリストを取得する。
88
+ */
89
+ getList(dateKey: string | null, ignoreExpiry = false) {
90
+ const e = dateKey && this.lists.get(dateKey)
91
+ return e && (ignoreExpiry || this.isValid(e.expiresAt)) ? e.data : null
92
+ },
93
+
94
+ /**
95
+ * Set a report in the cache and optionally sync with the list cache.
96
+ * キャッシュにレポートを設定し、必要に応じてリストキャッシュと同期する。
97
+ */
98
+ set(report: DailyReportDetail, expiresAt: number, syncList = true) {
99
+ this.reports.set(report.reportHubId, { data: report, expiresAt })
100
+ if (syncList) {
101
+ const dateKey = normalizeBusinessDateKey(report.date)
102
+ const listEntry = dateKey ? this.lists.get(dateKey) : null
103
+ if (listEntry && dateKey) {
104
+ const exists = listEntry.data.some((r) => r.reportHubId === report.reportHubId)
105
+ const data = exists ? listEntry.data.map((r) => (r.reportHubId === report.reportHubId ? report : r)) : [report, ...listEntry.data]
106
+ this.lists.set(dateKey, { ...listEntry, data })
107
+ }
108
+ }
109
+ this.notify(report.reportHubId)
110
+ },
111
+
112
+ /**
113
+ * Update a cached report with partial data.
114
+ * 部分的なデータでキャッシュされたレポートを更新する。
115
+ */
116
+ update(id: number, updates: Partial<DailyReportDetail>, strict = true) {
117
+ const entry = this.reports.get(id)
118
+ if (!entry?.data) return
119
+
120
+ if (strict && updates.updatedAt && entry.data.updatedAt) {
121
+ if (new Date(updates.updatedAt).getTime() < new Date(entry.data.updatedAt).getTime()) {
122
+ logger.warn(`[DailyReportCache] Stale update skipped id=${id}`)
123
+ return
124
+ }
125
+ }
126
+ this.set({ ...entry.data, ...updates } as DailyReportDetail, Math.max(entry.expiresAt, Date.now() + REVIVE_MS))
127
+ },
128
+
129
+ /**
130
+ * Delete a report from the cache and update the corresponding list.
131
+ * キャッシュからレポートを削除し、対応するリストを更新する。
132
+ */
133
+ delete(id: number, date: string) {
134
+ this.reports.delete(id)
135
+ const dateKey = normalizeBusinessDateKey(date)
136
+ const list = dateKey && this.lists.get(dateKey)
137
+ if (list && dateKey) {
138
+ this.lists.set(dateKey, { ...list, data: list.data.filter((r) => r.reportHubId !== id) })
139
+ }
140
+ this.notify(id)
141
+ },
142
+
143
+ /**
144
+ * Merge server report with cached report to handle optimistic updates and zombies.
145
+ * サーバーレポートとキャッシュレポートをマージして、楽観的更新とゾンビコメントを処理する。
146
+ */
147
+ merge(serverReport: DailyReportDetail, cachedReport?: DailyReportDetail | null) {
148
+ let report = serverReport
149
+ // Filter zombies
150
+ if (report.commentItems.some((c) => this.recentDeletes.has(c.id))) {
151
+ report = { ...report, commentItems: report.commentItems.filter((c) => !this.recentDeletes.has(c.id)) }
152
+ }
153
+ // Restore missing "my comments"
154
+ if (cachedReport) {
155
+ const serverIds = new Set(report.commentItems.map((c) => c.id))
156
+ const missing = cachedReport.commentItems.filter((c) => !serverIds.has(c.id) && c.isMine && Date.now() - new Date(c.createdAt).getTime() < 10000)
157
+ if (missing.length > 0) {
158
+ report = {
159
+ ...report,
160
+ commentItems: [...report.commentItems, ...missing].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
161
+ }
162
+ }
163
+ }
164
+ return report
165
+ },
166
+
167
+ /**
168
+ * Hydrate the cache with fetched reports, handling comment merges and locks.
169
+ * フェッチされたレポートでキャッシュをハイドレートし、コメントのマージとロックを処理する。
170
+ */
171
+ cacheMany(reports: DailyReportDetail[], dateKey: string | null, fetchStart = 0) {
172
+ if (!reports.length) return []
173
+ this.prune()
174
+ const now = Date.now(),
175
+ expiresAt = now + TTL
176
+ for (const [id, t] of this.recentDeletes) if (now - t > 10000) this.recentDeletes.delete(id)
177
+
178
+ const processed = reports.map((r) => {
179
+ // Original logic: If locked, return server data directly (don't update cache, don't merge)
180
+ // ロックされている場合はサーバーデータをそのまま返す(キャッシュ更新せず、マージもしない)
181
+ if (this.locks.has(r.reportHubId)) return r
182
+
183
+ // Original logic: If recently mutated locally, prefer cached data
184
+ // 最近ローカルで変更された場合は、キャッシュデータを優先する
185
+ const lastMut = this.lastMutations.get(r.reportHubId)
186
+ if (lastMut && fetchStart > 0 && lastMut > fetchStart) {
187
+ return this.reports.get(r.reportHubId)?.data ?? r
188
+ }
189
+
190
+ const cached = this.reports.get(r.reportHubId)?.data
191
+ const merged = this.merge(r, cached)
192
+ this.set(merged, expiresAt, false)
193
+ return merged
194
+ })
195
+ if (dateKey) this.lists.set(dateKey, { data: processed, expiresAt })
196
+ return processed
197
+ },
198
+ }
199
+
200
+ // --- Public API Exports ---
201
+ /**
202
+ * Update the cache for a single report.
203
+ * 単一レポートのキャッシュを更新する。
204
+ */
205
+ export const writeCache = (r: DailyReportDetail, exp: number, sync = true) => DailyReportCache.set(r, exp, sync)
206
+ /**
207
+ * Get a cached report by ID.
208
+ * ID でキャッシュされたレポートを取得する。
209
+ */
210
+ export const getCachedReport = (id: number, opts?: { ignoreExpiry?: boolean }) => DailyReportCache.getReport(id, opts?.ignoreExpiry)
211
+ /**
212
+ * Update specific fields of a cached report.
213
+ * キャッシュされたレポートの特定のフィールドを更新する。
214
+ */
215
+ export const updateDailyReportCache = (id: number, u: Partial<DailyReportDetail>) => DailyReportCache.update(id, u)
216
+ /**
217
+ * Delete a report from the cache.
218
+ * キャッシュからレポートを削除する。
219
+ */
220
+ export const deleteDailyReportCache = (id: number, d: string) => DailyReportCache.delete(id, d)
221
+
222
+ export const acquireMutationLock = (id: number) => {
223
+ DailyReportCache.locks.add(id)
224
+ DailyReportCache.lastMutations.set(id, Date.now())
225
+ }
226
+ export const releaseMutationLock = (id: number) => {
227
+ DailyReportCache.locks.delete(id)
228
+ DailyReportCache.lastMutations.set(id, Date.now())
229
+ }
230
+ export const registerRecentDeletion = (cId: number) => DailyReportCache.recentDeletes.set(cId, Date.now())
231
+
232
+ /**
233
+ * Subscribe to cache-ready events for a specific report.
234
+ * Returns an unsubscribe function.
235
+ *
236
+ * 特定レポートのキャッシュ書き込みを監視する。
237
+ * 解除関数を返す。
238
+ */
239
+ export const subscribeCacheReady = (reportHubId: number, callback: () => void): (() => void) => {
240
+ const listener = (id: number) => {
241
+ if (id === reportHubId) {
242
+ callback()
243
+ }
244
+ }
245
+ DailyReportCache.listeners.add(listener)
246
+ return () => {
247
+ DailyReportCache.listeners.delete(listener)
248
+ }
249
+ }
250
+
251
+ export const applyDailyReportServerUpdates = (_reportHubIdd: number, report: DailyReportDetail) => {
252
+ DailyReportCache.prune()
253
+ DailyReportCache.set(report, Date.now() + TTL)
254
+ }
255
+
256
+ // --- Data Loading ---
257
+ /**
258
+ * Fetch a single report by ID.
259
+ * ID で単一のレポートを取得する。
260
+ */
261
+ const fetchReportById = async (apiBasePath: string, id: number) => {
262
+ // 1. Cache Check
263
+ const cached = DailyReportCache.getReport(id)
264
+ if (cached) return cached
265
+
266
+ // 2. Prevent Duplicate Requests
267
+ const pending = DailyReportCache.pendingReports.get(id)
268
+ if (pending) return pending
269
+
270
+ // 3. Fetch
271
+ const start = Date.now()
272
+ const p = fetch(`${apiBasePath}/report?reportHubId=${id}`, { cache: "no-cache" })
273
+ .then((res) => (res.ok ? res.json() : Promise.reject(`HTTP ${res.status}`)))
274
+ .then((data) => {
275
+ if (data.error) throw new Error(data.error.message)
276
+ const report = data.report as DailyReportDetail
277
+ // 単一レポートの取得時にキャッシュを一括更新する
278
+ return DailyReportCache.cacheMany([report], null, start)[0]
279
+ })
280
+ .finally(() => DailyReportCache.pendingReports.delete(id))
281
+ DailyReportCache.pendingReports.set(id, p)
282
+ return p
283
+ }
284
+
285
+ /**
286
+ * Fetch reports by date key.
287
+ * 日付キーでレポートを取得する。
288
+ */
289
+ const fetchReportsByDate = async (apiBasePath: string, dateKey: string) => {
290
+ const list = DailyReportCache.getList(dateKey)
291
+ if (list) return list
292
+
293
+ const pending = DailyReportCache.pendingLists.get(dateKey)
294
+ if (pending) return pending
295
+
296
+ const start = Date.now()
297
+ const p = fetch(`${apiBasePath}/business-date?businessDate=${encodeURIComponent(dateKey)}`, { cache: "no-cache" })
298
+ .then((res) => (res.ok ? res.json() : Promise.reject(`HTTP ${res.status}`)))
299
+ .then((data) => {
300
+ if (data.error) throw new Error(data.error.message)
301
+ return DailyReportCache.cacheMany(data.reports ?? [], dateKey, start)
302
+ })
303
+ .finally(() => DailyReportCache.pendingLists.delete(dateKey))
304
+ DailyReportCache.pendingLists.set(dateKey, p)
305
+ return p
306
+ }
307
+
308
+ // --- Hooks ---
309
+ export type DailyReportDetailResource = { report: DailyReportDetail | null; error: Error | null; isLoading: boolean; isRefetching: boolean }
310
+
311
+ /**
312
+ * Provides a fetcher-driven daily report detail reader with business-date aware caching.
313
+ * 営業日キャッシュ対応の日報詳細取得フック。
314
+ */
315
+ export const useDailyReportDetail = (reportHubId: number, businessDate: string | null): DailyReportDetailResource => {
316
+ const { apiBasePath } = useDailyReportConfig()
317
+ const [report, setReport] = useState<DailyReportDetail | null>(() => DailyReportCache.getReport(reportHubId))
318
+ const [error, setError] = useState<Error | null>(null)
319
+ const [isLoading, setIsLoading] = useState(false)
320
+ const [isRefetching, setIsRefetching] = useState(false)
321
+ const reqRef = useRef({ id: reportHubId, token: 0 })
322
+
323
+ // 1. Cache Subscription
324
+ // キャッシュの購読: グローバルな DailyReportCache が更新されたときにローカルの状態を反応的に更新する。
325
+ // 依存関係: [reportHubId] - 対象のレポート ID が変更されたときのみ再購読する。
326
+ // クリーンアップ: メモリリークを防ぐためにリスナーを解除する。
327
+ useEffect(() => {
328
+ const onUpdate = (uid: number) => {
329
+ if (uid === reportHubId) {
330
+ const r = DailyReportCache.getReport(reportHubId, true)
331
+ setReport(r)
332
+ }
333
+ }
334
+ DailyReportCache.listeners.add(onUpdate)
335
+ return () => {
336
+ DailyReportCache.listeners.delete(onUpdate)
337
+ }
338
+ }, [reportHubId])
339
+
340
+ // 2. Data Loading & Stale Check
341
+ // データのロードと有効期限切れチェック: データのフェッチサイクル、 stale-while-revalidate ロジック、リクエストのデバウンスを管理する。
342
+ // 依存関係: [reportHubId, businessDate, apiBasePath]
343
+ useEffect(() => {
344
+ const dateKey = normalizeBusinessDateKey(businessDate)
345
+ const token = (reqRef.current.token || 0) + 1
346
+ reqRef.current = { id: reportHubId, token }
347
+
348
+ if (reportHubId <= 0) {
349
+ setReport(null)
350
+ setError(reportHubId === 0 ? new Error("Invalid ID") : null)
351
+ setIsLoading(false)
352
+ setIsRefetching(false)
353
+ return
354
+ }
355
+
356
+ // Cache Check
357
+ let cached = DailyReportCache.getReport(reportHubId, true)
358
+ let isStale = false
359
+ if (cached) {
360
+ isStale = !DailyReportCache.isValid(DailyReportCache.reports.get(reportHubId)?.expiresAt ?? 0)
361
+ } else if (dateKey) {
362
+ const list = DailyReportCache.getList(dateKey, true)
363
+ const found = list?.find((r) => r.reportHubId === reportHubId)
364
+ if (found) {
365
+ cached = found
366
+ isStale = !DailyReportCache.isValid(DailyReportCache.lists.get(dateKey)?.expiresAt ?? 0)
367
+ }
368
+ }
369
+ setReport(cached)
370
+
371
+ // If cached and valid, we are done
372
+ if (cached && !isStale) {
373
+ setError(null)
374
+ setIsLoading(false)
375
+ setIsRefetching(false)
376
+ return
377
+ }
378
+
379
+ setIsLoading(!cached)
380
+ setIsRefetching(true)
381
+
382
+ const timer = setTimeout(async () => {
383
+ if (reqRef.current.token !== token) return
384
+ try {
385
+ let r: DailyReportDetail | null = null
386
+ if (dateKey) {
387
+ const res = await fetchReportsByDate(apiBasePath, dateKey)
388
+ r = res.find((item) => item.reportHubId === reportHubId) ?? null
389
+ } else {
390
+ r = await fetchReportById(apiBasePath, reportHubId)
391
+ }
392
+
393
+ if (reqRef.current.token === token) {
394
+ setReport(r)
395
+ setError(null)
396
+ }
397
+ } catch (e) {
398
+ if (reqRef.current.token === token) setError(e instanceof Error ? e : new Error("Load failed"))
399
+ } finally {
400
+ if (reqRef.current.token === token) {
401
+ setIsLoading(false)
402
+ setIsRefetching(false)
403
+ }
404
+ }
405
+ }, FETCH_DEBOUNCE_MS)
406
+
407
+ return () => clearTimeout(timer)
408
+ }, [reportHubId, businessDate, apiBasePath])
409
+
410
+ return { report, error, isLoading, isRefetching }
411
+ }
412
+
413
+ /**
414
+ * Provides a business-date aware prefetch helper.
415
+ * 営業日キャッシュを活用した日報プリフェッチ補助フック。
416
+ */
417
+ export const useDailyReportPrefetch = () => {
418
+ const { apiBasePath } = useDailyReportConfig()
419
+ return useCallback(
420
+ (date: string | null) => {
421
+ const k = normalizeBusinessDateKey(date)
422
+ if (k && !DailyReportCache.getList(k)) fetchReportsByDate(apiBasePath, k).catch((e) => logger.warn("Prefetch error", k, e))
423
+ },
424
+ [apiBasePath],
425
+ )
426
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Shared hook for calculating viewport height of virtual scroll containers.
3
+ * 仮想スクロールコンテナのビューポート高さを計測する共通フック。
4
+ */
5
+ import { type RefObject, useCallback, useEffect, useState } from "react"
6
+
7
+ type UseDynamicViewportHeightOptions = {
8
+ enableMutationObserver?: boolean
9
+ bottomGap?: number
10
+ minFallbackHeight?: number
11
+ }
12
+
13
+ type UseDynamicViewportHeightResult = {
14
+ viewportHeight: number
15
+ updateViewportHeight: () => void
16
+ }
17
+
18
+ /**
19
+ * Computes and updates the viewport height for scroll containers while reacting to layout changes.
20
+ * レイアウト変化に追従しながらスクロールコンテナのビューポート高さを算出して更新する関数。
21
+ */
22
+ export const useDynamicViewportHeight = (containerRef: RefObject<HTMLDivElement | null>, options?: UseDynamicViewportHeightOptions): UseDynamicViewportHeightResult => {
23
+ const { enableMutationObserver = false, bottomGap = 4, minFallbackHeight = 240 } = options ?? {}
24
+ const [viewportHeight, setViewportHeight] = useState(600)
25
+
26
+ const updateViewportHeight = useCallback(() => {
27
+ if (typeof window === "undefined") {
28
+ return
29
+ }
30
+
31
+ const target = containerRef.current
32
+ if (!target) {
33
+ return
34
+ }
35
+
36
+ // 要素が非表示の場合は計測しない (display: none など)
37
+ if (target.offsetParent === null && window.getComputedStyle(target).position !== "fixed") {
38
+ return
39
+ }
40
+
41
+ const toNumber = (value: string) => Number.parseFloat(value) || 0
42
+ const { top: containerTop } = target.getBoundingClientRect()
43
+ const parentRect = target.parentElement?.getBoundingClientRect()
44
+ const rootStyle = window.getComputedStyle(document.documentElement)
45
+ const footerHeight = toNumber(rootStyle.getPropertyValue("--footer-height"))
46
+ const headerHeight = toNumber(rootStyle.getPropertyValue("--header-height"))
47
+ const scrollbarHeight = toNumber(rootStyle.getPropertyValue("--scrollbar-height"))
48
+ const containerStyle = window.getComputedStyle(target)
49
+ const marginBottom = toNumber(containerStyle.marginBottom)
50
+ const paddingBottom = toNumber(containerStyle.paddingBottom)
51
+ const viewportBottom = window.innerHeight - footerHeight - scrollbarHeight - bottomGap
52
+ const effectiveBottom = parentRect ? Math.min(parentRect.bottom, viewportBottom) : viewportBottom
53
+ const layoutTop = parentRect ? Math.max(parentRect.top, headerHeight) : headerHeight
54
+ const effectiveTop = Math.max(containerTop, layoutTop)
55
+ const availableHeight = effectiveBottom - effectiveTop - marginBottom - paddingBottom
56
+ const safeAvailableHeight = Number.isFinite(availableHeight) ? Math.floor(availableHeight) : 0
57
+ const clampedHeight = safeAvailableHeight > 0 ? safeAvailableHeight : minFallbackHeight
58
+
59
+ if (clampedHeight > 0 && Number.isFinite(clampedHeight)) {
60
+ setViewportHeight((previous) => (Math.abs(previous - clampedHeight) > 1 ? clampedHeight : previous))
61
+ }
62
+ }, [bottomGap, containerRef, minFallbackHeight])
63
+
64
+ useEffect(() => {
65
+ if (typeof window === "undefined") {
66
+ return
67
+ }
68
+
69
+ let frameId: number | null = null
70
+ let timeoutId: number | null = null
71
+ let resizeObserver: ResizeObserver | null = null
72
+ let mutationObserver: MutationObserver | null = null
73
+ const scheduleUpdate = () => {
74
+ if (frameId !== null) {
75
+ window.cancelAnimationFrame(frameId)
76
+ }
77
+ frameId = window.requestAnimationFrame(() => {
78
+ updateViewportHeight()
79
+ })
80
+ }
81
+
82
+ // 初期表示直後とわずかな遅延後に計測して安定させる
83
+ scheduleUpdate()
84
+ timeoutId = window.setTimeout(() => {
85
+ scheduleUpdate()
86
+ }, 150)
87
+
88
+ const containerElement = containerRef.current
89
+ if (typeof ResizeObserver !== "undefined" && containerElement) {
90
+ resizeObserver = new ResizeObserver(() => {
91
+ // コンテナと親要素のリサイズ変化に追従する
92
+ scheduleUpdate()
93
+ })
94
+ resizeObserver.observe(containerElement)
95
+ if (containerElement.parentElement) {
96
+ resizeObserver.observe(containerElement.parentElement)
97
+ }
98
+ }
99
+
100
+ if (enableMutationObserver && typeof MutationObserver !== "undefined") {
101
+ mutationObserver = new MutationObserver(() => {
102
+ // ルートのスタイル変更にも追従する
103
+ scheduleUpdate()
104
+ })
105
+ mutationObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["style"] })
106
+ }
107
+
108
+ window.addEventListener("resize", scheduleUpdate)
109
+
110
+ return () => {
111
+ if (frameId !== null) {
112
+ window.cancelAnimationFrame(frameId)
113
+ }
114
+ if (timeoutId !== null) {
115
+ window.clearTimeout(timeoutId)
116
+ }
117
+ window.removeEventListener("resize", scheduleUpdate)
118
+ resizeObserver?.disconnect()
119
+ mutationObserver?.disconnect()
120
+ }
121
+ }, [enableMutationObserver, updateViewportHeight, containerRef])
122
+
123
+ return {
124
+ viewportHeight,
125
+ updateViewportHeight,
126
+ }
127
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * React Router route helpers for the daily-report page (revalidation policy / client loader).
3
+ * 日報ページ向け React Router ルートヘルパー (再検証ポリシー / クライアントローダー)。
4
+ */
5
+ import type { ShouldRevalidateFunction, ShouldRevalidateFunctionArgs } from "react-router"
6
+ import type { DailyReportItem } from "../shared/types"
7
+ import { defaultDailyReportClientConfig } from "./config-context"
8
+
9
+ /**
10
+ * Prevents loader revalidation when POP navigations keep identical URL state.
11
+ * URL 状態が同一の POP ナビゲーション時にローダー再検証を抑止。
12
+ */
13
+ export const dailyReportShouldRevalidate: ShouldRevalidateFunction = ({ actionResult, actionStatus, currentUrl, defaultShouldRevalidate, formAction, formData, formEncType, formMethod, json, nextUrl, text }: ShouldRevalidateFunctionArgs) => {
14
+ if (!defaultShouldRevalidate) {
15
+ return false
16
+ }
17
+
18
+ const isSameUrl = currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search && currentUrl.hash === nextUrl.hash
19
+ if (!isSameUrl) {
20
+ return defaultShouldRevalidate
21
+ }
22
+
23
+ if (formData) {
24
+ const intent = formData.get("intent")
25
+ if (intent === "toggleRead" || intent === "toggleStar" || intent === "addComment" || intent === "deleteComment" || intent === "create" || intent === "update" || intent === "publish" || intent === "delete") {
26
+ return false
27
+ }
28
+ }
29
+
30
+ const hasSubmission = actionResult !== undefined || actionStatus !== undefined || formAction !== undefined || formData !== undefined || formEncType !== undefined || formMethod !== undefined || json !== undefined || text !== undefined
31
+ return hasSubmission ? defaultShouldRevalidate : false
32
+ }
33
+
34
+ /**
35
+ * Fetches the deferred daily report id list from the API endpoint.
36
+ * API エンドポイントから日報 ID 一覧を遅延取得する処理。
37
+ */
38
+ export const fetchDailyReportIds = (apiBasePath: string = defaultDailyReportClientConfig.apiBasePath): Promise<DailyReportItem[]> =>
39
+ fetch(`${apiBasePath}/ids`, {
40
+ method: "GET",
41
+ headers: { "Content-Type": "application/json" },
42
+ cache: "no-cache",
43
+ })
44
+ .then(async (response) => {
45
+ if (!response.ok) {
46
+ throw new Error(`Failed to fetch daily report ids: ${response.status} ${response.statusText}`)
47
+ }
48
+ return response
49
+ })
50
+ .then(async (response) => response.json())
51
+ .then((data) => {
52
+ if (!(data.ids && Array.isArray(data.ids))) {
53
+ throw new Error("Invalid daily report ids response")
54
+ }
55
+ return data.ids as DailyReportItem[]
56
+ })
57
+
58
+ /**
59
+ * Creates a React Router clientLoader that merges server data with deferred report ids.
60
+ * サーバーデータへ遅延日報 ID を合成する React Router clientLoader を生成する処理。
61
+ *
62
+ * 返す関数へ `hydrate = true as const` を付与済み (初期ハイドレーションでも実行される)。
63
+ */
64
+ export const createDailyReportClientLoader = <TServerData extends Record<string, unknown>>(options?: { apiBasePath?: string }) => {
65
+ const clientLoader = async (args: { serverLoader: () => Promise<unknown> }): Promise<TServerData & { dailyReportIds: Promise<DailyReportItem[]> }> => {
66
+ // サーバーローダーの解決を待つ間に ID 取得を並行開始する
67
+ const idsPromise = fetchDailyReportIds(options?.apiBasePath)
68
+ const serverData = (await args.serverLoader()) as TServerData
69
+ return {
70
+ ...serverData,
71
+ dailyReportIds: idsPromise,
72
+ }
73
+ }
74
+ clientLoader.hydrate = true as const
75
+ return clientLoader
76
+ }
@@ -0,0 +1,42 @@
1
+ import { Slot } from "@radix-ui/react-slot"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import * as React from "react"
4
+ import { cn } from "./cn"
5
+
6
+ const buttonVariants = cva(
7
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
12
+ destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
13
+ outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
14
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
15
+ ghost: "hover:bg-accent hover:text-accent-foreground",
16
+ link: "text-primary underline-offset-4 hover:underline",
17
+ },
18
+ size: {
19
+ default: "h-10 px-4 py-2",
20
+ sm: "h-9 rounded-md px-3",
21
+ lg: "h-11 rounded-md px-8",
22
+ icon: "h-10 w-10",
23
+ },
24
+ },
25
+ defaultVariants: {
26
+ variant: "default",
27
+ size: "default",
28
+ },
29
+ },
30
+ )
31
+
32
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
33
+ asChild?: boolean
34
+ }
35
+
36
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, asChild = false, ...props }, ref) => {
37
+ const Comp = asChild ? Slot : "button"
38
+ return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
39
+ })
40
+ Button.displayName = "Button"
41
+
42
+ export { Button, buttonVariants }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Tailwind class combiner (clsx + tailwind-merge).
3
+ * Tailwind クラスを結合・重複解決するユーティリティ。
4
+ */
5
+ import { type ClassValue, clsx } from "clsx"
6
+ import { twMerge } from "tailwind-merge"
7
+
8
+ /**
9
+ * Combines class values and resolves Tailwind conflicts.
10
+ * クラス値を結合し Tailwind の衝突を解決する処理。
11
+ */
12
+ export function cn(...inputs: ClassValue[]) {
13
+ return twMerge(clsx(inputs))
14
+ }