Skip to content
Merged
141 changes: 90 additions & 51 deletions app/(tabs)/logs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback } from "react";
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
import {
Text,
View,
Expand All @@ -9,6 +9,8 @@ import {
Alert,
ScrollView,
TextInput,
type NativeSyntheticEvent,
type NativeScrollEvent,
} from "react-native";
import * as Haptics from "expo-haptics";
import Animated, {
Expand Down Expand Up @@ -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<FlatList<LogData>>(null);
const webScrollState = useRef({ offset: 0, contentHeight: 0, isPrepend: false });
const prevFirstIdRef = useRef<string | undefined>(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<NativeScrollEvent>) => {
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 }) => (
<View className="mb-3">
Expand All @@ -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(
() => (
<View className="mb-4">
<View className="flex-1 items-center justify-center py-20">
<Text style={{ fontSize: 64, lineHeight: 80 }} className="mb-4">📝</Text>
<Text className="text-lg font-semibold text-foreground mb-2">
{hasActiveFilter
? "条件に一致するログがありません"
: "ログがありません"}
</Text>
<Text className="text-sm text-muted text-center px-8">
{hasActiveFilter
? "フィルター条件を変更してください"
: "WebSocketに接続してログデータを受信してください"}
</Text>
</View>
),
[hasActiveFilter]
);

return (
<ScreenContainer>
{/* ヘッダー部分(スクロールに追従して固定表示) */}
<View style={styles.stickyHeader}>
{/* Header with status */}
<View className="flex-row justify-between items-center mb-4">
<Text className="text-2xl font-bold text-foreground">ログ</Text>
Expand Down Expand Up @@ -510,58 +576,25 @@ export default function LogsScreen() {
)}
</View>
</View>
),
[
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(
() => (
<View className="flex-1 items-center justify-center py-20">
<Text style={{ fontSize: 64, lineHeight: 80 }} className="mb-4">📝</Text>
<Text className="text-lg font-semibold text-foreground mb-2">
{hasActiveFilter
? "条件に一致するログがありません"
: "ログがありません"}
</Text>
<Text className="text-sm text-muted text-center px-8">
{hasActiveFilter
? "フィルター条件を変更してください"
: "WebSocketに接続してログデータを受信してください"}
</Text>
</View>
),
[hasActiveFilter]
);

return (
<ScreenContainer>
<FlatList
ref={isWeb ? listRef : undefined}
data={filteredLogs}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={ListHeader}
ListEmptyComponent={ListEmpty}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
// スクロール中に先頭へアイテムが追加されてもスクロール位置を維持する
// react-native-web では未サポートのため web 向けは手動で補正する
{...(!isWeb && {
maintainVisibleContentPosition: { minIndexForVisible: 0 },
})}
{...(isWeb && {
onScroll: handleWebScroll,
onContentSizeChange: handleWebContentSizeChange,
scrollEventThrottle: 16,
})}
// パフォーマンス最適化
initialNumToRender={10}
maxToRenderPerBatch={5}
Expand All @@ -574,8 +607,14 @@ export default function LogsScreen() {
}

const styles = StyleSheet.create({
stickyHeader: {
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 8,
},
listContent: {
padding: 16,
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 100,
},
filterScrollContent: {
Expand Down
83 changes: 33 additions & 50 deletions app/(tabs)/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,29 @@ export default function TimelineScreen() {

const keyExtractor = useCallback((item: LocationUpdate) => item.id, []);

const ListHeader = useMemo(
const ListEmpty = useMemo(
() => (
<View className="mb-4">
<View className="flex-1 items-center justify-center py-20">
<Text style={{ fontSize: 64, lineHeight: 80 }} className="mb-4">📍</Text>
<Text className="text-lg font-semibold text-foreground mb-2">
{hasActiveFilter
? "条件に一致するデータがありません"
: "データがありません"}
</Text>
<Text className="text-sm text-muted text-center px-8">
{hasActiveFilter
? "フィルター条件を変更してください"
: "WebSocketに接続して位置情報データを受信してください"}
</Text>
</View>
),
[hasActiveFilter]
);

return (
<ScreenContainer>
{/* ヘッダー部分(スクロールに追従して固定表示) */}
<View style={styles.stickyHeader}>
{/* Header with status */}
<View className="flex-row justify-between items-center mb-4">
<Text className="text-2xl font-bold text-foreground">タイムライン</Text>
Expand Down Expand Up @@ -510,61 +530,18 @@ export default function TimelineScreen() {
)}
</View>
</View>
),
[
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(
() => (
<View className="flex-1 items-center justify-center py-20">
<Text style={{ fontSize: 64, lineHeight: 80 }} className="mb-4">📍</Text>
<Text className="text-lg font-semibold text-foreground mb-2">
{hasActiveFilter
? "条件に一致するデータがありません"
: "データがありません"}
</Text>
<Text className="text-sm text-muted text-center px-8">
{hasActiveFilter
? "フィルター条件を変更してください"
: "WebSocketに接続して位置情報データを受信してください"}
</Text>
</View>
),
[hasActiveFilter]
);

return (
<ScreenContainer>
<FlatList
data={filteredUpdates}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={ListHeader}
ListEmptyComponent={ListEmpty}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
// スクロール中に先頭へアイテムが追加されてもスクロール位置を維持する
maintainVisibleContentPosition={{
minIndexForVisible: 0,
}}
// パフォーマンス最適化
initialNumToRender={10}
maxToRenderPerBatch={5}
Expand All @@ -577,8 +554,14 @@ export default function TimelineScreen() {
}

const styles = StyleSheet.create({
stickyHeader: {
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 8,
},
listContent: {
padding: 16,
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 100,
},
filterScrollContent: {
Expand Down
31 changes: 30 additions & 1 deletion lib/__tests__/location-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type {
const MAX_UPDATES_PER_DEVICE = 500;
const MAX_LOGS_PER_DEVICE = 500;

let logIdCounter = 0;

function enforcePerDeviceLimit<T extends { device: string }>(
items: T[],
maxPerDevice: number
Expand Down Expand Up @@ -49,14 +51,17 @@ 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
);
return {
...state,
logs: newLogs,
messageCount: state.messageCount + 1,
};
}
case "SET_CONNECTION_STATUS":
Expand Down Expand Up @@ -117,6 +122,10 @@ const createMockLog = (overrides: Partial<LogData> = {}): 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" });
Expand Down Expand Up @@ -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", () => {
Expand Down
6 changes: 5 additions & 1 deletion lib/location-store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
* デバイスごとに最大件数を制限する
* 配列の先頭が最新なので、先頭から数えて制限を超えた古いエントリを除外する
Expand Down Expand Up @@ -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
Expand Down