From 30ef834f10f5e00c5b81ef6246605ce51b0c56a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 10:25:49 +0000 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=81=AE=E3=82=B9=E3=82=AF=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E4=B8=AD=E3=81=AB=E6=96=B0=E3=83=87=E3=83=BC=E3=82=BF?= =?UTF-8?q?=E3=81=8C=E5=85=A5=E3=81=A3=E3=81=9F=E9=9A=9B=E3=81=AE=E3=81=8C?= =?UTF-8?q?=E3=81=9F=E3=81=A4=E3=81=8D=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlatListにmaintainVisibleContentPositionを設定し、 リスト先頭にアイテムが追加された際にスクロール位置を自動調整する。 https://claude.ai/code/session_01MPkqpw4PNwHcRH6Gb74HD9 --- app/(tabs)/timeline.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/(tabs)/timeline.tsx b/app/(tabs)/timeline.tsx index 5d2fd47..dfdc432 100644 --- a/app/(tabs)/timeline.tsx +++ b/app/(tabs)/timeline.tsx @@ -565,6 +565,10 @@ export default function TimelineScreen() { ListEmptyComponent={ListEmpty} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} + // スクロール中に先頭へアイテムが追加されてもスクロール位置を維持する + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + }} // パフォーマンス最適化 initialNumToRender={10} maxToRenderPerBatch={5} From 72f4ab1c496212f3ec99f02223bee5ff3396165c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 10:30:57 +0000 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=E3=82=BF=E3=82=A4=E3=83=A0?= =?UTF-8?q?=E3=83=A9=E3=82=A4=E3=83=B3=E3=83=BB=E3=83=AD=E3=82=B0=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E3=81=AE=E3=83=98=E3=83=83=E3=83=80=E3=83=BC=E3=82=92?= =?UTF-8?q?=E3=82=B9=E3=82=AF=E3=83=AD=E3=83=BC=E3=83=AB=E8=BF=BD=E5=BE=93?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検索、フィルター、件数表示をFlatListの外に移動し、 スクロールしても常に画面上部に固定表示されるようにした。 ログ画面にもmaintainVisibleContentPositionを追加。 https://claude.ai/code/session_01MPkqpw4PNwHcRH6Gb74HD9 --- app/(tabs)/logs.tsx | 80 +++++++++++++++++------------------------ app/(tabs)/timeline.tsx | 79 +++++++++++++++------------------------- 2 files changed, 62 insertions(+), 97 deletions(-) diff --git a/app/(tabs)/logs.tsx b/app/(tabs)/logs.tsx index cc58901..be02c8e 100644 --- a/app/(tabs)/logs.tsx +++ b/app/(tabs)/logs.tsx @@ -248,9 +248,29 @@ export default function LogsScreen() { return item.id || `log-${item.timestamp}-${index}`; }, []); - const ListHeader = useMemo( + const ListEmpty = useMemo( () => ( - + + 📝 + + {hasActiveFilter + ? "条件に一致するログがありません" + : "ログがありません"} + + + {hasActiveFilter + ? "フィルター条件を変更してください" + : "WebSocketに接続してログデータを受信してください"} + + + ), + [hasActiveFilter] + ); + + return ( + + {/* ヘッダー部分(スクロールに追従して固定表示) */} + {/* Header with status */} ログ @@ -510,58 +530,18 @@ export default function LogsScreen() { )} - ), - [ - state.connectionStatus, - state.logs.length, - searchQuery, - selectedTypes, - selectedLevels, - selectedDevices, - logDeviceIds, - filteredLogs.length, - hasActiveFilter, - handleClearData, - handleTypeSelect, - handleLevelSelect, - handleDeviceSelect, - handleClearSearch, - toggleFilter, - arrowStyle, - contentStyle, - colors.muted, - ] - ); - - const ListEmpty = useMemo( - () => ( - - 📝 - - {hasActiveFilter - ? "条件に一致するログがありません" - : "ログがありません"} - - - {hasActiveFilter - ? "フィルター条件を変更してください" - : "WebSocketに接続してログデータを受信してください"} - - - ), - [hasActiveFilter] - ); - return ( - item.id, []); - const ListHeader = useMemo( + const ListEmpty = useMemo( () => ( - + + 📍 + + {hasActiveFilter + ? "条件に一致するデータがありません" + : "データがありません"} + + + {hasActiveFilter + ? "フィルター条件を変更してください" + : "WebSocketに接続して位置情報データを受信してください"} + + + ), + [hasActiveFilter] + ); + + return ( + + {/* ヘッダー部分(スクロールに追従して固定表示) */} + {/* Header with status */} タイムライン @@ -510,58 +530,11 @@ export default function TimelineScreen() { )} - ), - [ - state.connectionStatus, - state.deviceIds, - state.lineIds, - lineNames, - lineColors, - state.updates.length, - searchQuery, - selectedStates, - selectedDevices, - selectedRoutes, - filteredUpdates.length, - hasActiveFilter, - handleClearData, - handleStateSelect, - handleDeviceSelect, - handleRouteSelect, - handleClearSearch, - toggleFilter, - arrowStyle, - contentStyle, - colors.muted, - ] - ); - - const ListEmpty = useMemo( - () => ( - - 📍 - - {hasActiveFilter - ? "条件に一致するデータがありません" - : "データがありません"} - - - {hasActiveFilter - ? "フィルター条件を変更してください" - : "WebSocketに接続して位置情報データを受信してください"} - - - ), - [hasActiveFilter] - ); - return ( - Date: Mon, 2 Mar 2026 10:43:37 +0000 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=E3=83=AD=E3=82=B0=E3=81=AEkeyExtrac?= =?UTF-8?q?tor=E3=81=8B=E3=82=89index=20fallback=E3=82=92=E9=99=A4?= =?UTF-8?q?=E5=8E=BB=E3=81=97=E5=AE=89=E5=AE=9A=E3=81=97=E3=81=9FID?= =?UTF-8?q?=E3=82=92=E4=BF=9D=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADD_LOG reducerでidが空の場合にカウンタベースのIDを生成するようにし、 keyExtractorをitem.idのみに依存させた。 maintainVisibleContentPositionとの組み合わせで 先頭にログが追加されてもキーが変わらないようにした。 https://claude.ai/code/session_01MPkqpw4PNwHcRH6Gb74HD9 --- app/(tabs)/logs.tsx | 4 +--- lib/__tests__/location-store.test.ts | 4 +++- lib/location-store.tsx | 6 +++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/(tabs)/logs.tsx b/app/(tabs)/logs.tsx index be02c8e..0ba84f8 100644 --- a/app/(tabs)/logs.tsx +++ b/app/(tabs)/logs.tsx @@ -244,9 +244,7 @@ export default function LogsScreen() { [] ); - const keyExtractor = useCallback((item: LogData, index: number) => { - return item.id || `log-${item.timestamp}-${index}`; - }, []); + const keyExtractor = useCallback((item: LogData) => item.id, []); const ListEmpty = useMemo( () => ( diff --git a/lib/__tests__/location-store.test.ts b/lib/__tests__/location-store.test.ts index 188aa40..a4e8a6e 100644 --- a/lib/__tests__/location-store.test.ts +++ b/lib/__tests__/location-store.test.ts @@ -49,7 +49,9 @@ function locationReducer(state: LocationState, action: LocationAction): Location }; } case "ADD_LOG": { - const log = action.payload; + const log = action.payload.id + ? action.payload + : { ...action.payload, id: `log-gen-test-${Date.now()}` }; const newLogs = enforcePerDeviceLimit( [log, ...state.logs], MAX_LOGS_PER_DEVICE diff --git a/lib/location-store.tsx b/lib/location-store.tsx index 0e1508e..36fea9a 100644 --- a/lib/location-store.tsx +++ b/lib/location-store.tsx @@ -16,6 +16,8 @@ const WS_PROTOCOLS = ["thq", `thq-auth-${WS_AUTH_TOKEN}`]; const MAX_UPDATES_PER_DEVICE = 500; // デバイスごとに保持する最大更新数 const MAX_LOGS_PER_DEVICE = 500; // デバイスごとに保持する最大ログ数 +let logIdCounter = 0; + /** * デバイスごとに最大件数を制限する * 配列の先頭が最新なので、先頭から数えて制限を超えた古いエントリを除外する @@ -73,7 +75,9 @@ function locationReducer(state: LocationState, action: LocationAction): Location }; } case "ADD_LOG": { - const log = action.payload; + const log = action.payload.id + ? action.payload + : { ...action.payload, id: `log-gen-${++logIdCounter}` }; const newLogs = enforcePerDeviceLimit( [log, ...state.logs], MAX_LOGS_PER_DEVICE From 7005de77eddf9fa6b27d0106b679f71f85b6a34f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 10:52:17 +0000 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=E3=83=86=E3=82=B9=E3=83=88=E7=94=A8?= =?UTF-8?q?reducer=E3=82=92=E3=83=97=E3=83=AD=E3=83=80=E3=82=AF=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E5=AE=9F=E8=A3=85=E3=81=A8=E6=95=B4=E5=90=88?= =?UTF-8?q?=E3=81=95=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADD_LOGケースにmessageCountインクリメントを追加(プロダクションと一致) - ID生成をDate.now()からカウンタベースに変更(プロダクションと同一ロジック) - payload.idが空の場合にIDが生成されることを検証するテストを追加 https://claude.ai/code/session_01MPkqpw4PNwHcRH6Gb74HD9 --- lib/__tests__/location-store.test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/location-store.test.ts b/lib/__tests__/location-store.test.ts index a4e8a6e..7a30008 100644 --- a/lib/__tests__/location-store.test.ts +++ b/lib/__tests__/location-store.test.ts @@ -12,6 +12,8 @@ import type { const MAX_UPDATES_PER_DEVICE = 500; const MAX_LOGS_PER_DEVICE = 500; +let logIdCounter = 0; + function enforcePerDeviceLimit( items: T[], maxPerDevice: number @@ -51,7 +53,7 @@ function locationReducer(state: LocationState, action: LocationAction): Location case "ADD_LOG": { const log = action.payload.id ? action.payload - : { ...action.payload, id: `log-gen-test-${Date.now()}` }; + : { ...action.payload, id: `log-gen-${++logIdCounter}` }; const newLogs = enforcePerDeviceLimit( [log, ...state.logs], MAX_LOGS_PER_DEVICE @@ -59,6 +61,7 @@ function locationReducer(state: LocationState, action: LocationAction): Location return { ...state, logs: newLogs, + messageCount: state.messageCount + 1, }; } case "SET_CONNECTION_STATUS": @@ -242,6 +245,21 @@ describe("Location Store Reducer", () => { expect(newState.logs[0]).toEqual(log); }); + it("should generate a stable ID when payload.id is missing", () => { + const log = createMockLog(); + // Simulate server sending a log without an id field + const logWithoutId = { ...log, id: "" } as LogData; + const action: LocationAction = { type: "ADD_LOG", payload: logWithoutId }; + + const newState = locationReducer(initialState, action); + + expect(newState.logs).toHaveLength(1); + expect(newState.logs[0].id).toBeTruthy(); + expect(newState.logs[0].id).toMatch(/^log-gen-/); + expect(newState.logs[0].device).toBe(log.device); + expect(newState.logs[0].log.message).toBe(log.log.message); + }); + it("should limit logs to 500 per device", () => { const existingLogs = Array.from({ length: 500 }, (_, i) => createMockLog({ id: `existing-log-${i}`, device: "device-a" }) From 35e9f251feabec0ecc182dcf14310bda858ea07f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 11:06:45 +0000 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE?= =?UTF-8?q?ID=E7=94=9F=E6=88=90=E3=82=B1=E3=83=BC=E3=82=B9=E3=82=92?= =?UTF-8?q?=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - payload.idをundefined(プロパティ除去)に変更し、実際のサーバー欠落ケースに合わせた - messageCountのアサーションを追加してリグレッション防止 - beforeEachでlogIdCounterをリセットしテスト間の状態漏れを防止 https://claude.ai/code/session_01MPkqpw4PNwHcRH6Gb74HD9 --- lib/__tests__/location-store.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/location-store.test.ts b/lib/__tests__/location-store.test.ts index 7a30008..221c79d 100644 --- a/lib/__tests__/location-store.test.ts +++ b/lib/__tests__/location-store.test.ts @@ -122,6 +122,10 @@ const createMockLog = (overrides: Partial = {}): LogData => ({ }); describe("Location Store Reducer", () => { + beforeEach(() => { + logIdCounter = 0; + }); + describe("ADD_UPDATE", () => { it("should add a new location update to the beginning of the list", () => { const update = createMockUpdate({ id: "update-1" }); @@ -248,8 +252,11 @@ describe("Location Store Reducer", () => { it("should generate a stable ID when payload.id is missing", () => { const log = createMockLog(); // Simulate server sending a log without an id field - const logWithoutId = { ...log, id: "" } as LogData; - const action: LocationAction = { type: "ADD_LOG", payload: logWithoutId }; + const { id: _removed, ...logWithoutId } = log; + const action: LocationAction = { + type: "ADD_LOG", + payload: logWithoutId as LogData, + }; const newState = locationReducer(initialState, action); @@ -258,6 +265,7 @@ describe("Location Store Reducer", () => { expect(newState.logs[0].id).toMatch(/^log-gen-/); expect(newState.logs[0].device).toBe(log.device); expect(newState.logs[0].log.message).toBe(log.log.message); + expect(newState.messageCount).toBe(initialState.messageCount + 1); }); it("should limit logs to 500 per device", () => { From 546e27af14ec3de1486f8aefcd28d27fd05d6c93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 11:28:33 +0000 Subject: [PATCH 6/8] test: assert messageCount increment in ADD_LOG basic test The "should add a new log to the beginning of the list" test verified the logs array but not that messageCount was incremented. Add the missing assertion to match the reducer behavior. https://claude.ai/code/session_01MPkqpw4PNwHcRH6Gb74HD9 --- lib/__tests__/location-store.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/__tests__/location-store.test.ts b/lib/__tests__/location-store.test.ts index 221c79d..f1cc0f3 100644 --- a/lib/__tests__/location-store.test.ts +++ b/lib/__tests__/location-store.test.ts @@ -247,6 +247,7 @@ describe("Location Store Reducer", () => { expect(newState.logs).toHaveLength(1); expect(newState.logs[0]).toEqual(log); + expect(newState.messageCount).toBe(initialState.messageCount + 1); }); it("should generate a stable ID when payload.id is missing", () => { From 9d1a810841392ddada3b6560194271e3ff50a38c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 01:57:53 +0000 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20web=E5=90=91=E3=81=91maintainVisible?= =?UTF-8?q?ContentPosition=E3=83=95=E3=82=A9=E3=83=BC=E3=83=AB=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-native-web ~0.21.2ではmaintainVisibleContentPositionが 未サポートのため、web環境ではonScroll/onContentSizeChangeで スクロール位置を手動補正する方式に切り替える。 ネイティブ環境では従来通りmaintainVisibleContentPositionを使用。 https://claude.ai/code/session_01MPkqpw4PNwHcRH6Gb74HD9 --- app/(tabs)/logs.tsx | 47 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/app/(tabs)/logs.tsx b/app/(tabs)/logs.tsx index 0ba84f8..e232563 100644 --- a/app/(tabs)/logs.tsx +++ b/app/(tabs)/logs.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from "react"; +import { useState, useMemo, useCallback, useRef } from "react"; import { Text, View, @@ -9,6 +9,8 @@ import { Alert, ScrollView, TextInput, + type NativeSyntheticEvent, + type NativeScrollEvent, } from "react-native"; import * as Haptics from "expo-haptics"; import Animated, { @@ -235,6 +237,36 @@ export default function LogsScreen() { setSearchQuery(""); }, []); + // Web fallback for maintainVisibleContentPosition (not supported in react-native-web) + const isWeb = Platform.OS === "web"; + const listRef = useRef>(null); + const webScrollState = useRef({ offset: 0, contentHeight: 0 }); + + const handleWebScroll = useCallback( + (e: NativeSyntheticEvent) => { + webScrollState.current.offset = e.nativeEvent.contentOffset.y; + }, + [] + ); + + const handleWebContentSizeChange = useCallback( + (_w: number, h: number) => { + const prev = webScrollState.current; + if (prev.contentHeight > 0 && prev.offset > 0) { + const delta = h - prev.contentHeight; + if (delta > 0) { + listRef.current?.scrollToOffset({ + offset: prev.offset + delta, + animated: false, + }); + prev.offset += delta; + } + } + prev.contentHeight = h; + }, + [] + ); + const renderItem = useCallback( ({ item }: { item: LogData }) => ( @@ -530,6 +562,7 @@ export default function LogsScreen() { Date: Tue, 3 Mar 2026 02:07:47 +0000 Subject: [PATCH 8/8] fix(logs): restrict web scroll correction to prepend-only events The handleWebContentSizeChange handler was adjusting scroll position on any content height increase, which would cause unwanted jumps when filters reveal more items. Now tracks the first item ID to detect actual prepends and gates scroll correction behind an isPrepend flag that is set on prepend detection and cleared after correction. https://claude.ai/code/session_01MPkqpw4PNwHcRH6Gb74HD9 --- app/(tabs)/logs.tsx | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/app/(tabs)/logs.tsx b/app/(tabs)/logs.tsx index e232563..992d5eb 100644 --- a/app/(tabs)/logs.tsx +++ b/app/(tabs)/logs.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, useRef } from "react"; +import { useState, useMemo, useCallback, useRef, useEffect } from "react"; import { Text, View, @@ -240,7 +240,22 @@ export default function LogsScreen() { // Web fallback for maintainVisibleContentPosition (not supported in react-native-web) const isWeb = Platform.OS === "web"; const listRef = useRef>(null); - const webScrollState = useRef({ offset: 0, contentHeight: 0 }); + const webScrollState = useRef({ offset: 0, contentHeight: 0, isPrepend: false }); + const prevFirstIdRef = useRef(undefined); + + // Detect when items are prepended (first item ID changes while list grows) + useEffect(() => { + if (!isWeb) return; + const firstId = filteredLogs.length > 0 ? filteredLogs[0].id : undefined; + if ( + prevFirstIdRef.current !== undefined && + firstId !== undefined && + firstId !== prevFirstIdRef.current + ) { + webScrollState.current.isPrepend = true; + } + prevFirstIdRef.current = firstId; + }, [isWeb, filteredLogs]); const handleWebScroll = useCallback( (e: NativeSyntheticEvent) => { @@ -252,7 +267,7 @@ export default function LogsScreen() { const handleWebContentSizeChange = useCallback( (_w: number, h: number) => { const prev = webScrollState.current; - if (prev.contentHeight > 0 && prev.offset > 0) { + if (prev.isPrepend && prev.contentHeight > 0 && prev.offset > 0) { const delta = h - prev.contentHeight; if (delta > 0) { listRef.current?.scrollToOffset({ @@ -261,6 +276,7 @@ export default function LogsScreen() { }); prev.offset += delta; } + prev.isPrepend = false; } prev.contentHeight = h; },