diff --git a/app/(tabs)/logs.tsx b/app/(tabs)/logs.tsx index cc58901..992d5eb 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, useEffect } 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,52 @@ 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, 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) => { + webScrollState.current.offset = e.nativeEvent.contentOffset.y; + }, + [] + ); + + const handleWebContentSizeChange = useCallback( + (_w: number, h: number) => { + const prev = webScrollState.current; + if (prev.isPrepend && 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.isPrepend = false; + } + prev.contentHeight = h; + }, + [] + ); + const renderItem = useCallback( ({ item }: { item: LogData }) => ( @@ -244,13 +292,31 @@ 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 ListHeader = useMemo( + const ListEmpty = useMemo( () => ( - + + 📝 + + {hasActiveFilter + ? "条件に一致するログがありません" + : "ログがありません"} + + + {hasActiveFilter + ? "フィルター条件を変更してください" + : "WebSocketに接続してログデータを受信してください"} + + + ), + [hasActiveFilter] + ); + + return ( + + {/* ヘッダー部分(スクロールに追従して固定表示) */} + {/* Header with status */} ログ @@ -510,58 +576,25 @@ 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,61 +530,18 @@ 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 ( - ( items: T[], maxPerDevice: number @@ -49,7 +51,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 @@ -57,6 +61,7 @@ function locationReducer(state: LocationState, action: LocationAction): Location return { ...state, logs: newLogs, + messageCount: state.messageCount + 1, }; } case "SET_CONNECTION_STATUS": @@ -117,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" }); @@ -238,6 +247,26 @@ 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", () => { + const log = createMockLog(); + // Simulate server sending a log without an id field + const { id: _removed, ...logWithoutId } = log; + const action: LocationAction = { + type: "ADD_LOG", + payload: logWithoutId as LogData, + }; + + 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); + expect(newState.messageCount).toBe(initialState.messageCount + 1); }); it("should limit logs to 500 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