Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 44 additions & 51 deletions app/(tabs)/logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,16 @@ const LOG_LEVELS: { value: LogLevel; label: string }[] = [

// アコーディオンコンテンツの最大高さ(アニメーション用)
const ACCORDION_MAX_HEIGHT_BASE = 300; // タイプ + レベルフィルターのみ
const ACCORDION_MAX_HEIGHT_WITH_DEVICE = 400; // + デバイスフィルター
const ACCORDION_MAX_HEIGHT_WITH_DEVICE = 400; /**
* Render the Logs screen with search, filter controls, and a list of log entries.
*
* Displays a sticky header with connection status, a search box, and an expandable filter
* accordion (type, level, device). Filters, search query, and device presence control the
* displayed log list. Provides controls to clear stored logs and preserves scroll position
* when new items are prepended.
*
* @returns The JSX element for the Logs screen containing the header, filter UI, and filtered log list.
*/

export default function LogsScreen() {
const { state, clearUpdates } = useLocation();
Expand Down Expand Up @@ -244,13 +253,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 +537,18 @@ 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
data={filteredLogs}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={ListHeader}
ListEmptyComponent={ListEmpty}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
// スクロール中に先頭へアイテムが追加されてもスクロール位置を維持する
maintainVisibleContentPosition={{
minIndexForVisible: 0,
}}
// パフォーマンス最適化
initialNumToRender={10}
maxToRenderPerBatch={5}
Expand All @@ -574,8 +561,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
91 changes: 40 additions & 51 deletions app/(tabs)/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ const MOVING_STATES: { value: MovingState; label: string }[] = [

// アコーディオンコンテンツの最大高さ(アニメーション用)
const ACCORDION_MAX_HEIGHT_BASE = 200; // 状態フィルターのみ
const ACCORDION_MAX_HEIGHT_WITH_EXTRAS = 400; // 状態 + デバイス + 路線IDフィルター
const ACCORDION_MAX_HEIGHT_WITH_EXTRAS = 400; /**
* Renders the timeline screen that displays received location updates with search and multi-level filters.
*
* The screen provides a searchable, filterable list of location updates with controls for selecting moving states, devices, and route IDs; an expandable filter accordion with animated affordance; connection status, update count, and a clear-data action that prompts for confirmation. The list preserves scroll position when new items are prepended and shows a contextual empty state when no data matches the current filters.
*
* @returns The rendered timeline screen as a React element
*/

export default function TimelineScreen() {
const { state, clearUpdates } = useLocation();
Expand Down Expand Up @@ -236,9 +242,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 +536,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 +560,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
23 changes: 22 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 @@ -49,6 +51,23 @@ const initialState: LocationState = {
lineIds: [],
};

/**
* Update the location state in response to a dispatched location action.
*
* Handles the following actions:
* - ADD_UPDATE: adds a new location update (preserving newest-first order), enforces per-device update limits, updates device and line id lists, and increments the message count.
* - ADD_LOG: ensures the log has an id (generates one if missing), adds the log (newest-first), enforces per-device log limits, and increments the message count.
* - SET_CONNECTION_STATUS: updates the connection status.
* - SET_WS_URL: updates the WebSocket URL.
* - SET_ERROR: updates the error message.
* - CLEAR_UPDATES: clears updates, logs, messageCount, deviceIds, and lineIds.
* - LOAD_INITIAL_STATE: merges the provided payload into the current state.
* - default: returns the current state unchanged.
*
* @param state - The current location state to update
* @param action - The action describing the state change to apply
* @returns The new location state after applying the action
*/
function locationReducer(state: LocationState, action: LocationAction): LocationState {
switch (action.type) {
case "ADD_UPDATE": {
Expand All @@ -73,7 +92,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