From 73c6655d488c5f78edfed8a2ffaaec81bedff52a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 09:36:31 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E3=83=9E=E3=83=83=E3=83=97?= =?UTF-8?q?=E5=90=B9=E3=81=8D=E5=87=BA=E3=81=97=E3=81=AB=E7=B5=8C=E9=81=8E?= =?UTF-8?q?=E6=99=82=E9=96=93=E8=A1=A8=E7=A4=BA=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最新位置の文言の後に中黒(・)をセパレータとして 「〜秒前」「〜分前」「〜時間前」「〜日前」「〜ヶ月前」「〜年前」 の動的な経過時間テキストを表示するように変更 https://claude.ai/code/session_015En85PtwwQhrGDwZZGURDM --- app/(tabs)/map.tsx | 4 ++-- components/location-card.tsx | 28 ++++++++++++++++++++++++++++ hooks/use-device-trajectory.ts | 2 ++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx index efeb638..428be91 100644 --- a/app/(tabs)/map.tsx +++ b/app/(tabs)/map.tsx @@ -17,7 +17,7 @@ import Animated, { import { ScreenContainer } from "@/components/screen-container"; import { ConnectionStatusBadge } from "@/components/connection-status"; -import { stateConfig, defaultStateConfig, formatSpeed, formatAccuracy, formatBatteryLevel } from "@/components/location-card"; +import { stateConfig, defaultStateConfig, formatSpeed, formatAccuracy, formatBatteryLevel, formatTimeAgo } from "@/components/location-card"; import { useLocation } from "@/lib/location-store"; import { useColors } from "@/hooks/use-colors"; import { cn } from "@/lib/utils"; @@ -527,7 +527,7 @@ export default function MapScreen() { - 最新位置 + 最新位置{trajectory.latestTimestamp != null ? `・${formatTimeAgo(trajectory.latestTimestamp)}` : ""} {trajectory.latestLineId && lineNames[trajectory.latestLineId] && ( 🚆 {lineNames[trajectory.latestLineId]} )} diff --git a/components/location-card.tsx b/components/location-card.tsx index 403a3c3..cf69fe1 100644 --- a/components/location-card.tsx +++ b/components/location-card.tsx @@ -62,6 +62,34 @@ export function formatBatteryLevel(level: number): string { return `${Math.round(level * 100)}%`; } +export function formatTimeAgo(timestamp: number): string { + const now = Date.now(); + const diffMs = now - timestamp; + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) { + return `${diffSec}秒前`; + } + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) { + return `${diffMin}分前`; + } + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) { + return `${diffHour}時間前`; + } + const diffDay = Math.floor(diffHour / 24); + if (diffDay < 30) { + return `${diffDay}日前`; + } + const diffMonth = Math.floor(diffDay / 30); + if (diffMonth < 12) { + return `${diffMonth}ヶ月前`; + } + const diffYear = Math.floor(diffMonth / 12); + return `${diffYear}年前`; +} + const batteryStateLabels: Record = { charging: "充電中", unplugged: "未接続", diff --git a/hooks/use-device-trajectory.ts b/hooks/use-device-trajectory.ts index c082729..b3ef676 100644 --- a/hooks/use-device-trajectory.ts +++ b/hooks/use-device-trajectory.ts @@ -22,6 +22,7 @@ export interface DeviceTrajectory { latestBatteryLevel: number | null; latestBatteryState: BatteryState | number | null; latestLineId: string | null; + latestTimestamp: number | null; } /** @@ -95,6 +96,7 @@ export function useDeviceTrajectory( latestBatteryLevel: latestUpdate?.battery_level ?? null, latestBatteryState: latestUpdate?.battery_state ?? null, latestLineId: latestUpdate?.line_id ?? null, + latestTimestamp: latestUpdate?.timestamp ?? null, }); } From 6d92a5af4e8ad211a90564c9e0cccee815f59079 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 09:42:44 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20formatTimeAgo=E3=81=A7=E6=9C=AA?= =?UTF-8?q?=E6=9D=A5=E3=81=AE=E3=82=BF=E3=82=A4=E3=83=A0=E3=82=B9=E3=82=BF?= =?UTF-8?q?=E3=83=B3=E3=83=97=E3=81=AB=E5=AF=BE=E3=81=99=E3=82=8B=E3=82=AC?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diffMsが負の場合に「-N秒前」のような不正な表示になる問題を修正し、 「たった今」を返すようにした https://claude.ai/code/session_015En85PtwwQhrGDwZZGURDM --- components/location-card.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/location-card.tsx b/components/location-card.tsx index cf69fe1..1e564e9 100644 --- a/components/location-card.tsx +++ b/components/location-card.tsx @@ -65,6 +65,9 @@ export function formatBatteryLevel(level: number): string { export function formatTimeAgo(timestamp: number): string { const now = Date.now(); const diffMs = now - timestamp; + if (diffMs <= 0) { + return "たった今"; + } const diffSec = Math.floor(diffMs / 1000); if (diffSec < 60) { From 0c2cb9f1fd86296151946088d1534c5ace516c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 09:45:54 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=E5=90=B9=E3=81=8D=E5=87=BA=E3=81=97?= =?UTF-8?q?=E3=81=AE=E7=B5=8C=E9=81=8E=E6=99=82=E9=96=93=E3=82=92=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=96=E6=9B=B4=E6=96=B0=E3=81=99=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86CalloutContent=E3=82=92=E5=88=86=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatTimeAgoがレンダー時のDate.now()のみで計算されるため、 吹き出しが開いたまま時間が経過しても表示が更新されなかった。 CalloutContentコンポーネントを分離し10秒間隔のタイマーで 再レンダーすることで経過時間をライブ更新する。 https://claude.ai/code/session_015En85PtwwQhrGDwZZGURDM --- app/(tabs)/map.tsx | 96 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 22 deletions(-) diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx index 428be91..7f115e1 100644 --- a/app/(tabs)/map.tsx +++ b/app/(tabs)/map.tsx @@ -47,6 +47,68 @@ if (Platform.OS !== "web") { type MapViewRef = import("react-native-maps").default; type MapMarkerRef = { showCallout: () => void; hideCallout: () => void }; +// 吹き出し内コンテンツ(経過時間をライブ更新するため独立コンポーネント化) +const TIME_AGO_INTERVAL_MS = 10_000; + +function CalloutContent({ + deviceId, + stateLabel, + stateConf, + borderColor, + timestamp, + lineId, + lineNames, + latestSpeed, + latestAccuracy, + latestBatteryLevel, +}: { + deviceId: string; + stateLabel: string; + stateConf: { bgClass: string; textClass: string }; + borderColor: string; + timestamp: number | null; + lineId: string | null; + lineNames: Record; + latestSpeed: number | null | undefined; + latestAccuracy: number | null | undefined; + latestBatteryLevel: number | null; +}) { + const [, setTick] = useState(0); + + useEffect(() => { + if (timestamp == null) return; + const id = setInterval(() => setTick((t) => t + 1), TIME_AGO_INTERVAL_MS); + return () => clearInterval(id); + }, [timestamp]); + + return ( + + + {deviceId} + + + {stateLabel} + + + + + 最新位置{timestamp != null ? `・${formatTimeAgo(timestamp)}` : ""} + + {lineId && lineNames[lineId] && ( + 🚆 {lineNames[lineId]} + )} + + 🏎️ {formatSpeed(latestSpeed)} + 🎯 {formatAccuracy(latestAccuracy)} + 🔋 {latestBatteryLevel != null ? formatBatteryLevel(latestBatteryLevel) : "-"} + + + ); +} + // アコーディオンコンテンツの最大高さ(アニメーション用) const ACCORDION_MAX_HEIGHT_BASE = 100; const ACCORDION_MAX_HEIGHT_WITH_ROUTES = 200; @@ -515,28 +577,18 @@ export default function MapScreen() { > {Callout && ( - - - {trajectory.deviceId} - - - {stateLabel} - - - - 最新位置{trajectory.latestTimestamp != null ? `・${formatTimeAgo(trajectory.latestTimestamp)}` : ""} - {trajectory.latestLineId && lineNames[trajectory.latestLineId] && ( - 🚆 {lineNames[trajectory.latestLineId]} - )} - - 🏎️ {formatSpeed(trajectory.latestSpeed)} - 🎯 {formatAccuracy(trajectory.latestAccuracy)} - 🔋 {trajectory.latestBatteryLevel != null ? formatBatteryLevel(trajectory.latestBatteryLevel) : "-"} - - + )} From 1490be7ccb78f72d539b1099c65c218f1b9c0793 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 09:47:27 +0000 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20setTick=E3=82=92useReducer?= =?UTF-8?q?=E3=81=AEforceUpdate=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 再レンダーのみが目的であることを明確にするため、 useState+setTickパターンからuseReducer+forceUpdateに変更 https://claude.ai/code/session_015En85PtwwQhrGDwZZGURDM --- app/(tabs)/map.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx index 7f115e1..2b6118f 100644 --- a/app/(tabs)/map.tsx +++ b/app/(tabs)/map.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect, useMemo, Fragment } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo, useReducer, Fragment } from "react"; import { Text, View, @@ -73,11 +73,11 @@ function CalloutContent({ latestAccuracy: number | null | undefined; latestBatteryLevel: number | null; }) { - const [, setTick] = useState(0); + const [, forceUpdate] = useReducer((x: number) => x + 1, 0); useEffect(() => { if (timestamp == null) return; - const id = setInterval(() => setTick((t) => t + 1), TIME_AGO_INTERVAL_MS); + const id = setInterval(forceUpdate, TIME_AGO_INTERVAL_MS); return () => clearInterval(id); }, [timestamp]); From 09f88cc6176a2af0686d7bf1edc3e93feb3dd3e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 09:52:21 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E9=96=8B=E3=81=84=E3=81=A6=E3=81=84?= =?UTF-8?q?=E3=82=8B=E5=90=B9=E3=81=8D=E5=87=BA=E3=81=97=E3=81=AE=E3=81=BF?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=9E=E3=83=BC=E3=82=92=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E6=9C=80=E9=81=A9=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalloutContentにisActive propを追加し、表示中の吹き出しだけが setIntervalで経過時間を更新するように変更。 activeCalloutId stateを追加しハンドラーで同期させることで 選択中マーカーを追跡する。 https://claude.ai/code/session_015En85PtwwQhrGDwZZGURDM --- app/(tabs)/map.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx index 2b6118f..fe97af3 100644 --- a/app/(tabs)/map.tsx +++ b/app/(tabs)/map.tsx @@ -61,6 +61,7 @@ function CalloutContent({ latestSpeed, latestAccuracy, latestBatteryLevel, + isActive, }: { deviceId: string; stateLabel: string; @@ -72,14 +73,15 @@ function CalloutContent({ latestSpeed: number | null | undefined; latestAccuracy: number | null | undefined; latestBatteryLevel: number | null; + isActive: boolean; }) { const [, forceUpdate] = useReducer((x: number) => x + 1, 0); useEffect(() => { - if (timestamp == null) return; + if (!isActive || timestamp == null) return; const id = setInterval(forceUpdate, TIME_AGO_INTERVAL_MS); return () => clearInterval(id); - }, [timestamp]); + }, [isActive, timestamp]); return ( @@ -122,6 +124,7 @@ export default function MapScreen() { const lineColors = useLineColors(state.lineIds); const mapRef = useRef(null); const [isFollowing, setIsFollowing] = useState(true); + const [activeCalloutId, setActiveCalloutId] = useState(null); const selectedMarkerIdRef = useRef(null); const markerRefs = useRef>(new Map()); @@ -185,6 +188,7 @@ export default function MapScreen() { // マーカーをタップしたら選択状態にする const handleMarkerPress = useCallback((deviceId: string) => { selectedMarkerIdRef.current = deviceId; + setActiveCalloutId(deviceId); }, []); // マップ背景をタップしたら吹き出しを明示的に閉じる @@ -196,11 +200,13 @@ export default function MapScreen() { } } selectedMarkerIdRef.current = null; + setActiveCalloutId(null); }, []); // 吹き出しをタップしたら閉じる const handleCalloutPress = useCallback((deviceId: string) => { selectedMarkerIdRef.current = null; + setActiveCalloutId(null); const marker = markerRefs.current.get(deviceId); if (marker) { marker.hideCallout(); @@ -588,6 +594,7 @@ export default function MapScreen() { latestSpeed={trajectory.latestSpeed} latestAccuracy={trajectory.latestAccuracy} latestBatteryLevel={trajectory.latestBatteryLevel} + isActive={activeCalloutId === trajectory.deviceId} /> )}