@aiquants/daily-report 0.10.0 → 0.12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/daily-report",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Reusable daily-report feature package: shared types/schemas, React (Router v7) UI with virtual scrolling + optimistic updates + SSE sync, and a drizzle (mssql) server layer with DI ports (auth, user resolution, redis, external sources).",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -70,10 +70,10 @@
70
70
  "tailwindcss-animate": "^1.0.7",
71
71
  "tsup": "^8.5.1",
72
72
  "typescript": "^5.9.3",
73
- "vitest": "^4.1.8",
73
+ "vitest": "^4.1.10",
74
74
  "zod": "^3.25.76",
75
75
  "@aiquants/swipe-overlay": "1.2.4",
76
- "@aiquants/virtualscroll": "1.23.0"
76
+ "@aiquants/virtualscroll": "1.25.0"
77
77
  },
78
78
  "peerDependencies": {
79
79
  "drizzle-orm": ">=1.0.0-beta.4 <2.0.0",
@@ -82,7 +82,7 @@
82
82
  "react-router": "^7.0.0",
83
83
  "zod": ">=3.25.0 <5.0.0",
84
84
  "@aiquants/swipe-overlay": "^1.2.4",
85
- "@aiquants/virtualscroll": "^1.23.0"
85
+ "@aiquants/virtualscroll": "^1.25.0"
86
86
  },
87
87
  "peerDependenciesMeta": {
88
88
  "drizzle-orm": {
@@ -79,24 +79,78 @@ const formatInterviewerLabel = (interviewer: DailyReportDetail["interviewers"][n
79
79
  return interviewer.name ?? ""
80
80
  }
81
81
 
82
+ /**
83
+ * Scroll restore slot for DailyReportDetailList that survives tab unmounting.
84
+ * タブアンマウントをまたいで DailyReportDetailList のスクロール位置を持ち回る共有スロット。
85
+ *
86
+ * topItem は先頭可視カードの**同一性アンカー** (reportHubId + 行内オフセット px + 保存時 index)。
87
+ * ❗ 生の論理 px を保存してはならない: 可変高さでは px→行変換が再マウントの推定空間で行われ、
88
+ * 深い位置で別のカードに着地する (実機で確定した実害バグ)。同一性アンカーは一覧が留守中に
89
+ * 変わっても findIndex で正しいカードへ再解決される。index は該当カードが消えていたときの
90
+ * 近似フォールバック。offsetPx はカード上端がビューポート上端より上に隠れている px (論理座標)。
91
+ * anchorReportHubId は「記録時点の選択日報」。復元はこれが現在の選択と一致するときだけ行う:
92
+ * 選択が他ビューで変わっていたら、復元よりも「選択日報へのスクロール」(既存 UX) を優先する。
93
+ * measuredHeights は実測カード高さ (reportHubId → px)。着地窓の行内オフセットの厳密さと
94
+ * 総高さ推定の質を保つため、実測をアンマウント越しに持ち回る。
95
+ */
96
+ export type DailyReportDetailListScrollRestore = {
97
+ topItem: { reportHubId: number; index: number; offsetPx: number } | null
98
+ anchorReportHubId: number | null
99
+ measuredHeights: Map<number, number>
100
+ }
101
+
82
102
  type DailyReportDetailListProps = {
83
103
  dailyReportItems: DailyReportItem[]
84
104
  userId?: string | null
85
105
  selectedItemId?: number | null
86
106
  onSelectItem?: (id: number | null) => void
87
- initialScrollOffset?: number
88
- onScrollOffsetChange?: (offset: number) => void
107
+ /**
108
+ * スクロール位置の共有スロット。マウント時に一度だけ読み、スクロールのたびに書き戻す。
109
+ * ❗ 値をレンダー時に読んで props へ焼き込む方式は禁止 (daily-report-list.tsx 参照)
110
+ */
111
+ scrollRestoreRef?: RefObject<DailyReportDetailListScrollRestore>
89
112
  }
90
113
 
91
114
  /**
92
115
  * Daily report detail cards rendered with dynamic height virtual scroll.
93
116
  * 動的高さの仮想スクロールで日報詳細カードを描画するコンポーネント。
94
117
  */
95
- export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId, onSelectItem, initialScrollOffset, onScrollOffsetChange }: DailyReportDetailListProps) => {
118
+ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId, onSelectItem, scrollRestoreRef }: DailyReportDetailListProps) => {
119
+ // 復元アンカーと「復元マウントかどうか」はマウント時に一度だけ共有スロットから決める。
120
+ // 復元判定は選択アンカー (記録時の選択日報) の一致のみで行う。❗ 位置の有無や大小を復元
121
+ // 判定に流用してはならない: 先頭まで戻してからタブを離れた復元マウントが「非復元」に
122
+ // 誤分類され、初回の選択スクロールが復元位置 (先頭) を上書きしてしまう。
123
+ // topItem は reportHubId の同一性で現在の一覧へ再解決する (留守中の並び替え・挿入に不変)。
124
+ // 消えていたら保存時 index への近似フォールバック (行内オフセットは破棄 = カード上端合わせ)
125
+ const [{ initialScrollAnchor, restored: isRestoredMount }] = useState(() => {
126
+ const restore = scrollRestoreRef?.current
127
+ if (!restore || restore.anchorReportHubId !== (selectedItemId ?? null)) {
128
+ return { initialScrollAnchor: undefined, restored: false }
129
+ }
130
+ if (!restore.topItem) {
131
+ return { initialScrollAnchor: undefined, restored: true }
132
+ }
133
+ const foundIndex = dailyReportItems.findIndex((item) => item.reportHubId === restore.topItem?.reportHubId)
134
+ if (foundIndex !== -1) {
135
+ return { initialScrollAnchor: { index: foundIndex, offsetPx: restore.topItem.offsetPx }, restored: true }
136
+ }
137
+ return { initialScrollAnchor: { index: restore.topItem.index, offsetPx: 0 }, restored: true }
138
+ })
139
+ // 復元マウントの初回だけ「選択日報へのスクロール」をスキップするための武装フラグと、
140
+ // 消費条件 (マウント時と同じ選択であること) の基準値。
141
+ // ❗ 消費条件なしで武装したままにしてはならない: アンカー日報がアンマウント中に削除されて
142
+ // いた場合など、マウント効果が index を解決できないとフラグが残留し、その後の正当な
143
+ // 選択変更 (新規作成ボタン等) のスクロールを 1 回無言で飲み込む
144
+ const skipInitialScrollToSelectionRef = useRef(isRestoredMount)
145
+ const mountSelectionIdRef = useRef(selectedItemId ?? null)
96
146
  // 実測高さは ref にインプレースで持つ。以前は測定行ごとに `new Map(prev)` を作っており
97
147
  // 行数に対して O(n^2) だった。VirtualScroll へは updateItemSize で個別に伝えるため、
98
- // ここで state を持って再レンダーを起こす必要はない (getItemHeight は ref を直接読む)
99
- const itemHeightsRef = useRef<Map<number, number>>(new Map())
148
+ // ここで state を持って再レンダーを起こす必要はない (getItemHeight は ref を直接読む)。
149
+ // ❗ 共有スロットがあればその Map を使う (アンマウントで実測空間を捨てない): 同一性アンカー
150
+ // 復元の下で、実測の持ち回りは (a) アンカーカードの行内オフセットの厳密さ (そのカードの
151
+ // 実測高さが行内 px の座標系そのもの) と (b) 総高さ推定・周辺カードの初期配置の質を保つ。
152
+ // カードの同一性は topItem (reportHubId) が保証するため、深い復元でも別カードへは漂着しない
153
+ const itemHeightsRef = useRef<Map<number, number>>(scrollRestoreRef?.current.measuredHeights ?? new Map())
100
154
  const scrollContainerRef = useRef<HTMLDivElement | null>(null)
101
155
  const virtualScrollRef = useRef<VirtualScrollHandle>(null)
102
156
  // list ロールの aria-label に使う (消費側注入の業務ドメイン名)
@@ -119,6 +173,17 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
119
173
  if (index !== -1) {
120
174
  // ID もインデックスも同じなら再スクロール不要
121
175
  if (lastScrolledRef.current?.id === selectedItemId && lastScrolledRef.current?.index === index) return
176
+ // 復元マウントの初回だけはスクロールせず「スクロール済み」として記録する
177
+ // (以後の選択変更・並び替え追従は従来どおり動く)。
178
+ // 消費できるのは「マウント時と同じ選択」のときだけ: 選択が変わっていたら
179
+ // それは正当な選択変更なので、武装解除して通常のスクロールへ落とす
180
+ if (skipInitialScrollToSelectionRef.current) {
181
+ skipInitialScrollToSelectionRef.current = false
182
+ if ((selectedItemId ?? null) === mountSelectionIdRef.current) {
183
+ lastScrolledRef.current = { id: selectedItemId, index }
184
+ return
185
+ }
186
+ }
122
187
  virtualScrollRef.current.scrollToIndex(index, { align: "top" })
123
188
  lastScrolledRef.current = { id: selectedItemId, index }
124
189
  }
@@ -127,6 +192,21 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
127
192
  }
128
193
  }, [selectedItemId, dailyReportItems])
129
194
 
195
+ /**
196
+ * Keeps the restore slot's anchor aligned with the current selection.
197
+ * 復元スロットのアンカーを現在の選択日報と同期し続ける副作用。
198
+ *
199
+ * スクロールイベントに依存せず選択変更を即座に反映する (選択日報が既に上端にあり
200
+ * scrollToIndex が onScroll を発火しないケースでもアンカーが古くならない)。
201
+ * 依存配列: [selectedItemId, scrollRestoreRef]。クリーンアップ: なし。
202
+ */
203
+ useEffect(() => {
204
+ const restore = scrollRestoreRef?.current
205
+ if (restore) {
206
+ restore.anchorReportHubId = selectedItemId ?? null
207
+ }
208
+ }, [selectedItemId, scrollRestoreRef])
209
+
130
210
  // onSelectItem の最新参照を保持する ref (useEffect 依存配列を不要にする)
131
211
  const onSelectItemRef = useRef(onSelectItem)
132
212
  onSelectItemRef.current = onSelectItem
@@ -194,9 +274,28 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
194
274
  [getItem, itemCount, schedulePrefetchForRange],
195
275
  )
196
276
 
277
+ // ❗ teardown 用ハンドルミラー: React は削除コミットの mutation phase で virtualScrollRef を
278
+ // null 化するため、アンマウント時の RAF flush (最終 onScroll 配信) の時点では素の ref が
279
+ // 読めない。最後に観測した非 null ハンドルを保持し、フリング中のタブ切替でも最終アンカーを
280
+ // 取りこぼさない (closure 経由の読取は detach 後も直近コミットの値で安全)
281
+ const lastVirtualScrollHandleRef = useRef<VirtualScrollHandle | null>(null)
282
+
197
283
  const handleScroll = useCallback(() => {
198
- if (virtualScrollRef.current && onScrollOffsetChange) {
199
- onScrollOffsetChange(virtualScrollRef.current.getScrollPosition())
284
+ // スクロールのたびに先頭可視カードの同一性アンカーを共有スロットへ書き戻す。
285
+ // ❗ 生の論理 px (onScroll 引数や getScrollPosition) を保存してはならない:
286
+ // 可変高さでは再マウントの推定空間で px→行変換が狂い、別のカードに着地する。
287
+ // getScrollAnchor は可視ウィンドウの実測が照合済みのツリーで計算されるため常に正確
288
+ if (virtualScrollRef.current) {
289
+ lastVirtualScrollHandleRef.current = virtualScrollRef.current
290
+ }
291
+ const restore = scrollRestoreRef?.current
292
+ const handle = virtualScrollRef.current ?? lastVirtualScrollHandleRef.current
293
+ if (restore && handle) {
294
+ const anchor = handle.getScrollAnchor()
295
+ const item = anchor ? dailyReportItemsRef.current[anchor.index] : undefined
296
+ if (anchor && item) {
297
+ restore.topItem = { reportHubId: item.reportHubId, index: anchor.index, offsetPx: anchor.offsetPx }
298
+ }
200
299
  }
201
300
 
202
301
  const container = scrollContainerRef.current
@@ -216,15 +315,7 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
216
315
  }
217
316
  scrollEndTimerRef.current = null
218
317
  }, 150)
219
- }, [onScrollOffsetChange])
220
-
221
- useEffect(() => {
222
- return () => {
223
- if (virtualScrollRef.current && onScrollOffsetChange) {
224
- onScrollOffsetChange(virtualScrollRef.current.getScrollPosition())
225
- }
226
- }
227
- }, [onScrollOffsetChange])
318
+ }, [scrollRestoreRef])
228
319
 
229
320
  const renderThumbOverlay = useCallback((props: ScrollBarThumbOverlayRenderProps) => <BusinessDayThumbOverlay {...props} label={overlayLabel} isWheelScrollActive={isWheelScrollActive} />, [overlayLabel, isWheelScrollActive])
230
321
 
@@ -318,7 +409,7 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
318
409
  getItemKey={getItemKey}
319
410
  getItemHeight={getItemHeight}
320
411
  viewportSize={viewportHeight}
321
- initialScrollOffset={initialScrollOffset}
412
+ initialScrollAnchor={initialScrollAnchor}
322
413
  overscanCount={VIRTUAL_SCROLL_OVERSCAN_COUNT}
323
414
  scrollBarOptions={{
324
415
  width: SCROLL_BAR_WIDTH,
@@ -5,7 +5,7 @@
5
5
  import { SwipeCloseGuardContext, useSwipeGuardState, useSwipeToDismissOverlay, useTapClick, useTransitionDisplay } from "@aiquants/swipe-overlay"
6
6
  import { type ScrollBarThumbOverlayRenderProps, VirtualScroll, type VirtualScrollHandle } from "@aiquants/virtualscroll"
7
7
  import { Check, Star, Trash2 } from "lucide-react"
8
- import { type CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
8
+ import { type CSSProperties, memo, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"
9
9
  import { fallbackText, formatToIsoDate } from "../../shared/text-utils"
10
10
  import type { DailyReportDetail, DailyReportItem } from "../../shared/types"
11
11
  import { resolveSourceTypeConfig, useDailyReportConfig } from "../config-context"
@@ -49,8 +49,14 @@ type DailyReportListProps = {
49
49
  selectedReportHubId: number | null
50
50
  onSelectItem: (reportHubId: number | null) => void
51
51
  userId?: string | null
52
- initialScrollOffset?: number
53
- onScrollOffsetChange?: (offset: number) => void
52
+ /**
53
+ * タブ切替 (アンマウント) をまたいでスクロール位置を持ち回る共有スロット。
54
+ * 値は VirtualScroll の**論理座標** (inset 非依存。onScroll が配信する座標系)。
55
+ * マウント時に一度だけ読み、スクロールのたびに書き戻す。
56
+ * ❗ 値をレンダー時に読んで props へ焼き込む方式は禁止: 非制御タブでは親が
57
+ * 再レンダーされず、復帰マウントに古い値が渡る (実測で再現した実害バグ)
58
+ */
59
+ scrollOffsetRef?: RefObject<number>
54
60
  }
55
61
 
56
62
  /**
@@ -232,7 +238,9 @@ const useAutoRead = (report: DailyReportDetail | null, isLoading: boolean, autoM
232
238
  * Daily report list cards with key metadata and preview content via virtual scrolling.
233
239
  * 主なメタデータと内容プレビューを仮想スクロール表示する日報リスト。
234
240
  */
235
- export const DailyReportList = ({ dailyReportItems, autoMarkRead = false, selectedReportHubId, onSelectItem, userId, initialScrollOffset, onScrollOffsetChange }: DailyReportListProps) => {
241
+ export const DailyReportList = ({ dailyReportItems, autoMarkRead = false, selectedReportHubId, onSelectItem, userId, scrollOffsetRef }: DailyReportListProps) => {
242
+ // 復元位置はマウント時に一度だけ共有スロットから読む (レンダー時読取の焼き込みを構造的に排除)
243
+ const [initialScrollOffset] = useState(() => scrollOffsetRef?.current ?? 0)
236
244
  const scrollContainerRef = useRef<HTMLDivElement | null>(null)
237
245
  const virtualScrollRef = useRef<VirtualScrollHandle>(null)
238
246
  // list ロールの aria-label に使う (消費側が注入する業務ドメイン名。既定 "Daily Reports")
@@ -385,37 +393,35 @@ export const DailyReportList = ({ dailyReportItems, autoMarkRead = false, select
385
393
  [getItem],
386
394
  )
387
395
 
388
- const handleScroll = useCallback(() => {
389
- if (virtualScrollRef.current && onScrollOffsetChange) {
390
- onScrollOffsetChange(virtualScrollRef.current.getScrollPosition())
391
- }
392
-
393
- const container = scrollContainerRef.current
394
- if (!container) return
395
-
396
- if (container.dataset.scrolling !== "true") {
397
- container.dataset.scrolling = "true"
398
- }
396
+ const handleScroll = useCallback(
397
+ (position: number) => {
398
+ // onScroll が配信する論理座標をそのまま共有スロットへ書く (保存と復元で座標系を一致させる)。
399
+ // ❗ getScrollPosition() の読み戻しへ戻さないこと: あれはペイン座標 (inset 込み) で、
400
+ // 論理座標を受け取る initialScrollOffset との往復ごとに inset 分ずれる
401
+ if (scrollOffsetRef) {
402
+ scrollOffsetRef.current = position
403
+ }
399
404
 
400
- if (scrollEndTimerRef.current) {
401
- clearTimeout(scrollEndTimerRef.current)
402
- }
405
+ const container = scrollContainerRef.current
406
+ if (!container) return
403
407
 
404
- scrollEndTimerRef.current = setTimeout(() => {
405
- if (container) {
406
- delete container.dataset.scrolling
408
+ if (container.dataset.scrolling !== "true") {
409
+ container.dataset.scrolling = "true"
407
410
  }
408
- scrollEndTimerRef.current = null
409
- }, 150)
410
- }, [onScrollOffsetChange])
411
411
 
412
- useEffect(() => {
413
- return () => {
414
- if (virtualScrollRef.current && onScrollOffsetChange) {
415
- onScrollOffsetChange(virtualScrollRef.current.getScrollPosition())
412
+ if (scrollEndTimerRef.current) {
413
+ clearTimeout(scrollEndTimerRef.current)
416
414
  }
417
- }
418
- }, [onScrollOffsetChange])
415
+
416
+ scrollEndTimerRef.current = setTimeout(() => {
417
+ if (container) {
418
+ delete container.dataset.scrolling
419
+ }
420
+ scrollEndTimerRef.current = null
421
+ }, 150)
422
+ },
423
+ [scrollOffsetRef],
424
+ )
419
425
 
420
426
  const renderThumbOverlay = useCallback((props: ScrollBarThumbOverlayRenderProps) => <BusinessDayThumbOverlay {...props} label={overlayLabel} isWheelScrollActive={isWheelScrollActive} />, [overlayLabel, isWheelScrollActive])
421
427
 
@@ -1,6 +1,6 @@
1
1
  import { Plus, RefreshCw, RotateCw } from "lucide-react"
2
2
  import type { ReactNode } from "react"
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react"
3
+ import { useEffect, useMemo, useRef, useState } from "react"
4
4
  import { useSearchParams } from "react-router"
5
5
  import { resolveTodayBusinessDateKey } from "../../shared/business-date"
6
6
  import { useDailyReportConfig } from "../config-context"
@@ -11,7 +11,7 @@ import { Label } from "../ui/label"
11
11
  import { Switch } from "../ui/switch"
12
12
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
13
13
  import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
14
- import { DailyReportDetailList } from "./daily-report-detail-list"
14
+ import { DailyReportDetailList, type DailyReportDetailListScrollRestore } from "./daily-report-detail-list"
15
15
  import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
16
16
  import { DailyReportList } from "./daily-report-list"
17
17
 
@@ -37,17 +37,12 @@ export const DailyReportResolvedContent = ({ userId }: DailyReportResolvedConten
37
37
  // 総件数の表示値 (チャンク到着ごとの跳びをイーズアウトで滑らかにする)
38
38
  const animatedItemCount = useAnimatedNumber(dailyReportItems.length)
39
39
 
40
- // 各タブのスクロール位置をアンマウント後も保持する Ref
40
+ // 各タブのスクロール位置をアンマウント後も保持する共有スロット (子がマウント時に読み、
41
+ // スクロールのたびに書き戻す)。❗ ref の「値」をここで読んで props へ渡してはならない:
42
+ // タブは非制御でこのコンポーネントはタブ切替時に再レンダーされないため、レンダー時読取は
43
+ // 古い値の焼き込みになる (タブ復帰でスクロール位置がリセットされた実害バグの根本原因)
41
44
  const listScrollOffsetRef = useRef<number>(0)
42
- const detailListScrollOffsetRef = useRef<number>(0)
43
-
44
- const handleListScrollOffsetChange = useCallback((offset: number) => {
45
- listScrollOffsetRef.current = offset
46
- }, [])
47
-
48
- const handleDetailListScrollOffsetChange = useCallback((offset: number) => {
49
- detailListScrollOffsetRef.current = offset
50
- }, [])
45
+ const detailListScrollRestoreRef = useRef<DailyReportDetailListScrollRestore>({ topItem: null, anchorReportHubId: null, measuredHeights: new Map() })
51
46
 
52
47
  // 選択状態の管理 (タブ間で共有)
53
48
  const [selectedItemId, setSelectedItemId] = useState<number | null>(() => {
@@ -115,17 +110,7 @@ export const DailyReportResolvedContent = ({ userId }: DailyReportResolvedConten
115
110
  {
116
111
  value: "list",
117
112
  label: "📄 List",
118
- content: (
119
- <DailyReportList
120
- dailyReportItems={dailyReportItems}
121
- autoMarkRead={autoMarkRead}
122
- selectedReportHubId={selectedItemId}
123
- onSelectItem={setSelectedItemId}
124
- userId={userId}
125
- initialScrollOffset={listScrollOffsetRef.current}
126
- onScrollOffsetChange={handleListScrollOffsetChange}
127
- />
128
- ),
113
+ content: <DailyReportList dailyReportItems={dailyReportItems} autoMarkRead={autoMarkRead} selectedReportHubId={selectedItemId} onSelectItem={setSelectedItemId} userId={userId} scrollOffsetRef={listScrollOffsetRef} />,
129
114
  },
130
115
  {
131
116
  value: "tree",
@@ -135,12 +120,10 @@ export const DailyReportResolvedContent = ({ userId }: DailyReportResolvedConten
135
120
  {
136
121
  value: "detailList",
137
122
  label: "📋 DetailList",
138
- content: (
139
- <DailyReportDetailList dailyReportItems={dailyReportItems} userId={userId} selectedItemId={selectedItemId} onSelectItem={setSelectedItemId} initialScrollOffset={detailListScrollOffsetRef.current} onScrollOffsetChange={handleDetailListScrollOffsetChange} />
140
- ),
123
+ content: <DailyReportDetailList dailyReportItems={dailyReportItems} userId={userId} selectedItemId={selectedItemId} onSelectItem={setSelectedItemId} scrollRestoreRef={detailListScrollRestoreRef} />,
141
124
  },
142
125
  ],
143
- [dailyReportItems, autoMarkRead, selectedItemId, userId, handleListScrollOffsetChange, handleDetailListScrollOffsetChange],
126
+ [dailyReportItems, autoMarkRead, selectedItemId, userId],
144
127
  )
145
128
 
146
129
  return (