@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/dist/client.d.mts +40 -8
- package/dist/client.d.ts +40 -8
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -5
- package/dist/client.mjs.map +1 -1
- package/package.json +4 -4
- package/src/client/components/daily-report-detail-list.tsx +108 -17
- package/src/client/components/daily-report-list.tsx +36 -30
- package/src/client/components/daily-report-resolved-content.tsx +10 -27
- package/src/client/components/daily-report-scroll-restoration.spec.tsx +422 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiquants/daily-report",
|
|
3
|
-
"version": "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.
|
|
73
|
+
"vitest": "^4.1.10",
|
|
74
74
|
"zod": "^3.25.76",
|
|
75
75
|
"@aiquants/swipe-overlay": "1.2.4",
|
|
76
|
-
"@aiquants/virtualscroll": "1.
|
|
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.
|
|
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
|
-
|
|
88
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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,
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
405
|
+
const container = scrollContainerRef.current
|
|
406
|
+
if (!container) return
|
|
403
407
|
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
if (virtualScrollRef.current && onScrollOffsetChange) {
|
|
415
|
-
onScrollOffsetChange(virtualScrollRef.current.getScrollPosition())
|
|
412
|
+
if (scrollEndTimerRef.current) {
|
|
413
|
+
clearTimeout(scrollEndTimerRef.current)
|
|
416
414
|
}
|
|
417
|
-
|
|
418
|
-
|
|
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 {
|
|
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
|
-
//
|
|
40
|
+
// 各タブのスクロール位置をアンマウント後も保持する共有スロット (子がマウント時に読み、
|
|
41
|
+
// スクロールのたびに書き戻す)。❗ ref の「値」をここで読んで props へ渡してはならない:
|
|
42
|
+
// タブは非制御でこのコンポーネントはタブ切替時に再レンダーされないため、レンダー時読取は
|
|
43
|
+
// 古い値の焼き込みになる (タブ復帰でスクロール位置がリセットされた実害バグの根本原因)
|
|
41
44
|
const listScrollOffsetRef = useRef<number>(0)
|
|
42
|
-
const
|
|
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
|
|
126
|
+
[dailyReportItems, autoMarkRead, selectedItemId, userId],
|
|
144
127
|
)
|
|
145
128
|
|
|
146
129
|
return (
|