diff --git a/docs/ride-log.md b/docs/ride-log.md index d3e85c3cef..88e6e7638f 100644 --- a/docs/ride-log.md +++ b/docs/ride-log.md @@ -34,12 +34,27 @@ Main 画面を抜けたときの最後の書き込みが終わってから消す - 出発駅の次の駅への到着を1回検出するまでは、DB に書かずメモリに溜めます。画面を開いただけのセッションや、ホームで待っているだけのセッションを数えないためです。通過駅を検出しただけでは確定しません。 - 確定したら、それまでに検出した駅をまとめて1トランザクションで書きます。書き込みに失敗したら、次に駅を検出したとき・発車したとき・Main 画面を抜けるときに、それまでの駅ごと書き直します。書く内容は書き込みを実行する時点のメモリから決めるので、書き込み待ちのあいだに検出した駅も取りこぼしません。以降は駅を検出するたびに追記するので、アプリが終了されても最後の到着までは残ります。 - オートモードを一度でも有効にしたら、Main 画面を抜けるまで記録しません。オートモードの駅移動は実際の乗車ではないためです。 +- 直通運転の接続駅は、駅リストに前の路線の駅と次の路線の駅として2回並びます(`src/utils/dropJunctionStation.ts`)。 + 到着判定がこの2つを行き来しても、同じ駅なので2度目は書きません。 + 次の駅までの距離は、後から検出した方の駅から測ります。 + 通過として書いた駅に停車したと分かったときだけ、到着を残すために書きます。 ## 時刻 - `ride_sessions.startedAt` は、出発駅を発車した時刻です。発車を検出できなかった場合は、Main 画面を開いた時刻にします。 - `ride_sessions.endedAt` は、最後に到着を検出した時刻です。通過では更新しません。 - `ride_stops.departedAt` は、駅の到着圏を出た時刻です。出入りを繰り返した場合は、最後に出た時刻で上書きします。 +- `ride_stops.etaMinutesFromPrevious` は、ETA 上の所要時間(分)です。 + StationAPI の `estimateArrivalTimes` が駅間距離と車両性能から求めた時刻を、`useEstimateArrivalTimesRoute` で取得して使います(`src/utils/rideEta.ts`)。 + この乗車で前に ETA を引けた駅から、その駅までを数えます。 + 前の駅が出発駅なら発車の時刻から、それ以外なら到着(通過)の時刻から数えるので、途中の駅の停車時間も入ります。 + 乗車の中で足すと、出発駅の発車から最後の駅までの所要時間になります。 + ETA を引けなかった駅と、この列を足す前に記録した行は `null` です。 + +到着圏を出入りした時刻だけでは、発車直後と停車直前の時間が抜けます。 +到着圏の半径は駅間距離の 1/4 で、75〜200 m に収めています(`src/hooks/useThreshold.ts`)。 +駅間が 1 km ほどの区間では、測れるのは真ん中の 600 m ほどだけです。 +ETA の所要時間は、この抜けを補うために記録します。 ## 距離 @@ -100,6 +115,12 @@ StationAPI の `Station.prefectureId`(1〜47)をそのまま入れます。 - 日付は端末のタイムゾーンで区切ります。乗車は、乗りはじめた時刻(`startedAt`)が入る期間と日(年は月)に数えます。 - 1回の乗車の距離は、出発駅から最後に到着を検出した駅までの `distanceFromPrevious` の合計です。最後の到着より後に通過だけを検出した駅は数えません。 - 1回の乗車の時間は、`startedAt` から最後に到着を検出した時刻までです。 + ETA 上の所要時間(`etaMinutesFromPrevious` の合計)の方が長ければ、そちらを使います。 + 遅れたときは検出した時間の方が長くなるので、そのまま使います。 + 最後の駅で ETA を引けていない乗車は、検出した時間だけを使います。 +- 接続駅を続けて記録した2つ目の駅(同じ駅グループで、あいだに駅を挟まないもの)は、乗った駅間として数えません。 + 最後の到着にも、路線ごとの集計にも入れません。 + このような駅しか到着が無い乗車は、乗車回数にも数えません。 - 駅を検出する間隔が 90 分(`MAX_STOP_GAP_MS`)を超えたら、その手前の駅で乗車が終わったとみなします。アプリを開いたまま放置した乗車で時間が膨らまないようにするためです。 - 路線ごとの距離は、駅と駅のあいだを到着した側の駅の路線に数えます。直通運転で路線が変わる乗車も、区間ごとに分けて数えます。 diff --git a/src/components/RideMonthCard.test.tsx b/src/components/RideMonthCard.test.tsx index a81dd71bcf..2f5cec9610 100644 --- a/src/components/RideMonthCard.test.tsx +++ b/src/components/RideMonthCard.test.tsx @@ -55,6 +55,7 @@ const sampleRide = (): RideSessionWithStops => { longitude: null, pathFromPrevious: null, prefectureId: null, + etaMinutesFromPrevious: null, }; return { id: 'r1', diff --git a/src/hooks/useRideRecorder.test.tsx b/src/hooks/useRideRecorder.test.tsx index 863bf4ca15..2054c0ffe8 100644 --- a/src/hooks/useRideRecorder.test.tsx +++ b/src/hooks/useRideRecorder.test.tsx @@ -46,6 +46,17 @@ jest.mock('./useCurrentLine', () => ({ jest.mock('./useCurrentTrainType', () => ({ useCurrentTrainType: () => null, })); +let mockEtaStops: { + stationId: number; + stationGroupId: number; + cumulativeMinutes: number | null; + departureCumulativeMinutes: number | null; +}[] = []; +jest.mock('./useEstimateArrivalTimesRoute', () => ({ + useEstimateArrivalTimesRoute: () => ({ + route: mockEtaStops.length ? { id: 1, stops: mockEtaStops } : null, + }), +})); jest.mock('./useLoopLine', () => ({ useLoopLine: () => ({ isLoopLine: false }), })); @@ -97,6 +108,7 @@ const tick = (ms: number) => { describe('useRideRecorder', () => { beforeEach(() => { + mockEtaStops = []; now = 1_000; jest.spyOn(Date, 'now').mockImplementation(() => now); }); @@ -432,6 +444,154 @@ describe('useRideRecorder', () => { resetTrackDistancesForTesting(); }); + describe('ETA 上の所要時間', () => { + const etaStop = ( + id: number, + cumulativeMinutes: number | null, + departureCumulativeMinutes: number | null = cumulativeMinutes + ) => ({ + stationId: id, + stationGroupId: id, + cumulativeMinutes, + departureCumulativeMinutes, + }); + + it('出発駅は発車から、それ以外は到着(通過)から次の駅までを記録する', async () => { + // 出発駅は 0.5 分に発車、駅3には 2.0 分に着いて 2.5 分に発車する + mockEtaStops = [ + etaStop(1, 0, 0.5), + etaStop(2, 1.5), + etaStop(3, 2.0, 2.5), + etaStop(4, 4.0), + ]; + const { store } = setup(); + act(() => store.set(arrivedAtom, false)); + act(() => store.set(stationAtom, b)); + act(() => { + store.set(stationAtom, c); + store.set(arrivedAtom, true); + }); + await flush(); + expect( + (insertRideSession as jest.Mock).mock.calls[0][1].map( + (s: { etaMinutesFromPrevious: number | null }) => + s.etaMinutesFromPrevious + ) + ).toEqual([null, 1.0, 0.5]); + + act(() => store.set(arrivedAtom, false)); + act(() => { + store.set(stationAtom, d); + store.set(arrivedAtom, true); + }); + await flush(); + // 駅3の停車時間を含めて、到着から数える + expect(appendRideStop).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ stationId: 4, etaMinutesFromPrevious: 2.0 }) + ); + }); + + it('ETA を引けない駅は null にし、次の駅は前に引けた駅から数える', async () => { + mockEtaStops = [etaStop(1, 0, 0.5), etaStop(2, null), etaStop(3, 3.0)]; + const { store } = setup(); + act(() => store.set(arrivedAtom, false)); + act(() => store.set(stationAtom, b)); + act(() => { + store.set(stationAtom, c); + store.set(arrivedAtom, true); + }); + await flush(); + expect( + (insertRideSession as jest.Mock).mock.calls[0][1].map( + (s: { etaMinutesFromPrevious: number | null }) => + s.etaMinutesFromPrevious + ) + ).toEqual([null, null, 2.5]); + }); + }); + + describe('直通運転の接続駅', () => { + // 接続駅 c は、駅リストに前の路線の駅(c)と次の路線の駅(c2)として2回並ぶ + const nextLine = { id: 22, nameShort: '総武線', color: '#FFD400' }; + const c2 = createStation(30, { + groupId: c.groupId, + latitude: c.latitude, + longitude: c.longitude, + stopCondition: StopCondition.All, + line: nextLine, + prefectureId: 13, + }); + const d2 = createStation(40, { + latitude: 35.68, + longitude: 139.733, + stopCondition: StopCondition.All, + line: nextLine, + prefectureId: 13, + }); + const throughStations = [a, b, c, c2, d2]; + + it('前の路線の駅と次の路線の駅を行き来しても、同じ駅を2度書かない', async () => { + const { store } = setup(); + act(() => store.set(stationsAtom, throughStations)); + act(() => store.set(arrivedAtom, false)); + act(() => { + store.set(stationAtom, c); + store.set(arrivedAtom, true); + }); + act(() => store.set(stationAtom, c2)); + act(() => store.set(stationAtom, c)); + act(() => store.set(stationAtom, c2)); + await flush(); + expect(insertRideSession).toHaveBeenCalledTimes(1); + expect( + (insertRideSession as jest.Mock).mock.calls[0][1].map( + (s: { stationId: number }) => s.stationId + ) + ).toEqual([1, 3]); + expect(appendRideStop).not.toHaveBeenCalled(); + + act(() => store.set(arrivedAtom, false)); + act(() => { + store.set(stationAtom, d2); + store.set(arrivedAtom, true); + }); + await flush(); + expect(appendRideStop).toHaveBeenCalledTimes(1); + expect(appendRideStop).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + seq: 2, + stationId: 40, + lineId: 22, + distanceFromPrevious: getRideDistanceMeters( + throughStations, + c2, + d2, + false + ), + }) + ); + }); + + it('出発駅で前の路線の駅と次の路線の駅を行き来しても、乗車を確定しない', async () => { + const a2 = createStation(10, { + groupId: a.groupId, + latitude: a.latitude, + longitude: a.longitude, + stopCondition: StopCondition.All, + line: nextLine, + prefectureId: 13, + }); + const { store } = setup(); + act(() => store.set(stationsAtom, [a, a2, b, c])); + act(() => store.set(stationAtom, a2)); + act(() => store.set(stationAtom, a)); + await flush(); + expect(insertRideSession).not.toHaveBeenCalled(); + }); + }); + it('オートモードを一度でも有効にしたら、画面を抜けるまで記録しない', async () => { const { store } = setup(); act(() => store.set(autoModeEnabledAtom, true)); diff --git a/src/hooks/useRideRecorder.ts b/src/hooks/useRideRecorder.ts index ea818f8d48..2030683ae1 100644 --- a/src/hooks/useRideRecorder.ts +++ b/src/hooks/useRideRecorder.ts @@ -24,8 +24,15 @@ import { measureRideSegment, type RideSegmentMeasurement, } from '~/utils/rideDistance'; +import { + estimateRideEtaMinutes, + type RideEtaAnchor, + type RideEtaStop, +} from '~/utils/rideEta'; +import { isJunctionDuplicate } from '~/utils/rideStats'; import { useCurrentLine } from './useCurrentLine'; import { useCurrentTrainType } from './useCurrentTrainType'; +import { useEstimateArrivalTimesRoute } from './useEstimateArrivalTimesRoute'; import { useLoopLine } from './useLoopLine'; type RideSessionMeta = Omit; @@ -37,6 +44,8 @@ type SessionState = { tainted: boolean; lastStation: Station | null; stops: RideStopRecord[]; + // ETA 上の所要時間を数える起点。ETA を引けた最後の駅(はじめは出発駅) + etaAnchor: RideEtaAnchor | null; // 乗車が確定した時点の路線・種別・方面。確定するまでは null meta: RideSessionMeta | null; // DB に書けたセッションの ID と、そのうち書けた駅の数 @@ -78,7 +87,8 @@ const toStopRecord = ( seq: number, kind: RideStopKind, arrivedAt: number | null, - distance: RideSegmentMeasurement + distance: RideSegmentMeasurement, + etaMinutesFromPrevious: number | null ): RideStopRecord | null => { if (station.id == null) { return null; @@ -101,6 +111,7 @@ const toStopRecord = ( longitude: coordinate?.longitude ?? null, pathFromPrevious: toIntermediatePath(distance.path), prefectureId: toPrefectureId(station.prefectureId), + etaMinutesFromPrevious, }; }; @@ -194,12 +205,17 @@ export const useRideRecorder = (): void => { const currentLine = useCurrentLine(); const trainType = useCurrentTrainType(); const { isLoopLine } = useLoopLine(); + // 駅を検出したときに読むだけなので、経路が変わっても記録の effect は動かさない + const { route: etaRoute } = useEstimateArrivalTimesRoute(); + const etaStopsRef = useRef(null); + etaStopsRef.current = etaRoute?.stops ?? null; const sessionRef = useRef({ mountedAt: Date.now(), tainted: false, lastStation: null, stops: [], + etaAnchor: null, meta: null, persistedId: null, persistedCount: 0, @@ -233,16 +249,24 @@ export const useRideRecorder = (): void => { } if (!session.lastStation) { - const origin = toStopRecord(station, 0, 'arrived', null, { - meters: 0, - source: 'haversine', - path: [station], - }); + const origin = toStopRecord( + station, + 0, + 'arrived', + null, + { meters: 0, source: 'haversine', path: [station] }, + null + ); if (!origin) { return; } session.lastStation = station; session.stops = [origin]; + session.etaAnchor = { + stationId: origin.stationId, + stationGroupId: origin.stationGroupId, + isOrigin: true, + }; return; } @@ -253,18 +277,44 @@ export const useRideRecorder = (): void => { station, isLoopLine ); + const stationIds = { + stationId: station.id ?? 0, + stationGroupId: station.groupId ?? null, + }; + const etaMinutes = session.etaAnchor + ? estimateRideEtaMinutes( + etaStopsRef.current, + session.etaAnchor, + stationIds + ) + : null; const stop = toStopRecord( station, session.stops.length, kind, Date.now(), - distance + distance, + etaMinutes ); if (!stop) { return; } + // 接続駅の前の路線の駅と次の路線の駅を行き来しただけなら、同じ駅なので書かない。 + // 次の駅間は今の駅から測る。通過で記録した駅に停車したと分かったときは、 + // 到着を残すために書く(集計は isJunctionDuplicate で路線に数えない) + const last = session.stops[session.stops.length - 1]; + if ( + isJunctionDuplicate(last, stop) && + !(last.kind === 'passed' && kind === 'arrived') + ) { + session.lastStation = station; + return; + } session.lastStation = station; session.stops = [...session.stops, stop]; + if (etaMinutes != null) { + session.etaAnchor = { ...stationIds, isOrigin: false }; + } // 通過だけでは乗車を確定しない。確定したら溜めていた通過駅もまとめて書く if (!session.meta) { diff --git a/src/lib/rideLog.test.ts b/src/lib/rideLog.test.ts index 6992faeb94..11941b3806 100644 --- a/src/lib/rideLog.test.ts +++ b/src/lib/rideLog.test.ts @@ -60,6 +60,7 @@ const stop = (seq: number, overrides: Partial = {}) => ({ longitude: null, pathFromPrevious: null, prefectureId: null, + etaMinutesFromPrevious: null, ...overrides, }); @@ -99,6 +100,7 @@ describe('rideLog', () => { 'ALTER TABLE ride_stops ADD COLUMN longitude REAL;', 'ALTER TABLE ride_stops ADD COLUMN pathFromPrevious TEXT;', 'ALTER TABLE ride_stops ADD COLUMN prefectureId INTEGER;', + 'ALTER TABLE ride_stops ADD COLUMN etaMinutesFromPrevious REAL;', ]) ); @@ -129,6 +131,7 @@ describe('rideLog', () => { null, null, null, + null, ], ]); }); @@ -146,7 +149,7 @@ describe('rideLog', () => { }) ); const [, params] = mockDb.runAsync.mock.calls[0] as [string, unknown[]]; - expect(params.slice(-4, -1)).toEqual([ + expect(params.slice(-5, -2)).toEqual([ 35.6812, 139.7671, '[[35.6918,139.7709],[35.6995,139.765]]', @@ -164,6 +167,7 @@ describe('rideLog', () => { { name: 'longitude' }, { name: 'pathFromPrevious' }, { name: 'prefectureId' }, + { name: 'etaMinutesFromPrevious' }, ] : [] ) @@ -180,7 +184,14 @@ describe('rideLog', () => { it('駅の都道府県を書く', async () => { await appendRideStop('s1', stop(2, { prefectureId: 13 })); const [, params] = mockDb.runAsync.mock.calls[0] as [string, unknown[]]; - expect(params.at(-1)).toBe(13); + expect(params.at(-2)).toBe(13); + }); + + it('ETA 上の所要時間を書く', async () => { + await appendRideStop('s1', stop(2, { etaMinutesFromPrevious: 2.5 })); + const [sql, params] = mockDb.runAsync.mock.calls[0] as [string, unknown[]]; + expect(sql).toContain('etaMinutesFromPrevious'); + expect(params.at(-1)).toBe(2.5); }); it('到着した駅を足すと終了時刻も進める', async () => { diff --git a/src/lib/rideLog.ts b/src/lib/rideLog.ts index b0d3576e0c..7b8750a342 100644 --- a/src/lib/rideLog.ts +++ b/src/lib/rideLog.ts @@ -60,6 +60,12 @@ export type RideStopRecord = { // PREFECTURES_JA などを prefectureId - 1 で引く)。都道府県の列を足す前に記録した行と、 // 駅データに都道府県が無い駅は null prefectureId: number | null; + // ETA(StationAPI の estimateArrivalTimes)上の所要時間(分)。この乗車で前に ETA を + // 引けた駅(出発駅ならその発車、それ以外ならその到着か通過)からこの駅までを数えるので、 + // 乗車の中で足すと出発から最後の駅までになる。乗車時間の下限に使う + // (src/utils/rideStats.ts の measureRide)。列を足す前に記録した行と、ETA を引けなかった + // 駅は null + etaMinutesFromPrevious: number | null; }; // DB は最初に使うときに開く。設定画面など記録しない画面からも import されるため、 @@ -107,6 +113,7 @@ const initDb = async (): Promise => { longitude REAL, pathFromPrevious TEXT, prefectureId INTEGER, + etaMinutesFromPrevious REAL, PRIMARY KEY (sessionId, seq) );` ); @@ -133,6 +140,12 @@ const initDb = async (): Promise => { 'ALTER TABLE ride_stops ADD COLUMN prefectureId INTEGER;' ); } + // 乗車時間を ETA で補う(駅の検出だけでは発車直後と停車直前の時間が抜ける)ために後から足した列 + if (!columnNames.has('etaMinutesFromPrevious')) { + await db.execAsync( + 'ALTER TABLE ride_stops ADD COLUMN etaMinutesFromPrevious REAL;' + ); + } // 期間ごとの集計は乗りはじめた時刻で絞り込む await db.execAsync( 'CREATE INDEX IF NOT EXISTS idx_ride_sessions_startedAt ON ride_sessions(startedAt);' @@ -202,8 +215,8 @@ const parsePath = (value: string | null): RideCoordinate[] | null => { const insertStop = (sessionId: string, stop: RideStopRecord) => getDb().runAsync( - `INSERT INTO ride_stops (sessionId, seq, stationId, stationGroupId, stationName, lineId, lineName, lineColor, kind, arrivedAt, departedAt, distanceFromPrevious, distanceSource, latitude, longitude, pathFromPrevious, prefectureId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO ride_stops (sessionId, seq, stationId, stationGroupId, stationName, lineId, lineName, lineColor, kind, arrivedAt, departedAt, distanceFromPrevious, distanceSource, latitude, longitude, pathFromPrevious, prefectureId, etaMinutesFromPrevious) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ sessionId, stop.seq, @@ -222,6 +235,7 @@ const insertStop = (sessionId: string, stop: RideStopRecord) => stop.longitude, stop.pathFromPrevious ? serializePath(stop.pathFromPrevious) : null, stop.prefectureId, + stop.etaMinutesFromPrevious, ] ); @@ -360,7 +374,7 @@ export const getRideSessionsStartedBetween = async ( return []; } const rows = await db.getAllAsync( - `SELECT s.sessionId, s.seq, s.stationId, s.stationGroupId, s.stationName, s.lineId, s.lineName, s.lineColor, s.kind, s.arrivedAt, s.departedAt, s.distanceFromPrevious, s.distanceSource, s.latitude, s.longitude, s.pathFromPrevious, s.prefectureId + `SELECT s.sessionId, s.seq, s.stationId, s.stationGroupId, s.stationName, s.lineId, s.lineName, s.lineColor, s.kind, s.arrivedAt, s.departedAt, s.distanceFromPrevious, s.distanceSource, s.latitude, s.longitude, s.pathFromPrevious, s.prefectureId, s.etaMinutesFromPrevious FROM ride_stops s JOIN ride_sessions r ON r.id = s.sessionId WHERE r.startedAt >= ? AND r.startedAt < ? ORDER BY s.sessionId, s.seq`, diff --git a/src/screens/RideReview.test.tsx b/src/screens/RideReview.test.tsx index f449de85c3..bc3c7319ec 100644 --- a/src/screens/RideReview.test.tsx +++ b/src/screens/RideReview.test.tsx @@ -90,6 +90,7 @@ const sampleRide = (): RideSessionWithStops => { longitude: null, pathFromPrevious: null, prefectureId: null, + etaMinutesFromPrevious: null, }; return { id: 'r1', diff --git a/src/utils/rideEta.test.ts b/src/utils/rideEta.test.ts new file mode 100644 index 0000000000..02ecb113d8 --- /dev/null +++ b/src/utils/rideEta.test.ts @@ -0,0 +1,77 @@ +import { estimateRideEtaMinutes } from './rideEta'; + +const stops = [ + { + stationId: 1, + stationGroupId: 101, + cumulativeMinutes: 0, + departureCumulativeMinutes: 0.5, + }, + { + stationId: 2, + stationGroupId: 102, + cumulativeMinutes: 2, + departureCumulativeMinutes: 2.5, + }, + { + stationId: 3, + stationGroupId: 103, + cumulativeMinutes: 4, + departureCumulativeMinutes: 4, + }, +]; + +describe('estimateRideEtaMinutes', () => { + it('出発駅は発車の時刻から数える', () => { + expect( + estimateRideEtaMinutes( + stops, + { stationId: 1, stationGroupId: 101, isOrigin: true }, + { stationId: 2, stationGroupId: 102 } + ) + ).toBe(1.5); + }); + + it('出発駅でなければ到着の時刻から数え、停車時間を含める', () => { + expect( + estimateRideEtaMinutes( + stops, + { stationId: 2, stationGroupId: 102, isOrigin: false }, + { stationId: 3, stationGroupId: 103 } + ) + ).toBe(2); + }); + + it('駅 ID が経路に無ければ駅グループで探す(接続駅の別の路線の駅)', () => { + expect( + estimateRideEtaMinutes( + stops, + { stationId: 1, stationGroupId: 101, isOrigin: true }, + { stationId: 99, stationGroupId: 102 } + ) + ).toBe(1.5); + }); + + it('経路に無い駅、経路と逆向き、ETA が無いときは null', () => { + const origin = { stationId: 1, stationGroupId: 101, isOrigin: true }; + expect( + estimateRideEtaMinutes(stops, origin, { + stationId: 99, + stationGroupId: 199, + }) + ).toBeNull(); + expect( + estimateRideEtaMinutes( + stops, + { stationId: 3, stationGroupId: 103, isOrigin: false }, + { stationId: 2, stationGroupId: 102 } + ) + ).toBeNull(); + expect( + estimateRideEtaMinutes(null, origin, { + stationId: 2, + stationGroupId: 102, + }) + ).toBeNull(); + }); +}); diff --git a/src/utils/rideEta.ts b/src/utils/rideEta.ts new file mode 100644 index 0000000000..01dacc5e54 --- /dev/null +++ b/src/utils/rideEta.ts @@ -0,0 +1,61 @@ +/** + * 振り返り機能(#5751)の乗車時間を補う、ETA 上の所要時間を求める。 + * + * 駅の検出で測る乗車時間は、出発駅の到着圏を出てから最後の駅の到着圏に入るまでで、 + * 発車直後と停車直前の時間が抜ける。駅間が短いほど短く出るため、StationAPI の + * estimateArrivalTimes が駅間距離と車両性能から求めた所要時間を下限に使う。 + */ + +// estimateArrivalTimes の stops の1件。累積分は経路の始点からの絶対値 +export type RideEtaStop = { + stationId?: number | null; + stationGroupId?: number | null; + cumulativeMinutes?: number | null; + departureCumulativeMinutes?: number | null; +}; + +// ETA の起点にする記録済みの駅 +export type RideEtaAnchor = { + stationId: number; + stationGroupId: number | null; + // 乗車の出発駅なら、到着ではなく発車の時刻から数える + isOrigin: boolean; +}; + +// 接続駅は前後の路線で駅 ID が変わるので、ID で見つからなければ駅グループで探す +const findStop = ( + stops: readonly RideEtaStop[], + stationId: number, + stationGroupId: number | null +): RideEtaStop | undefined => + stops.find((s) => s.stationId === stationId) ?? + (stationGroupId != null + ? stops.find((s) => s.stationGroupId === stationGroupId) + : undefined); + +/** + * anchor から station までの ETA 上の所要時間(分)。どちらかが経路に無い、時刻が無い、 + * 経路の向きと逆になるときは null。 + */ +export const estimateRideEtaMinutes = ( + stops: readonly RideEtaStop[] | null | undefined, + anchor: RideEtaAnchor, + station: { stationId: number; stationGroupId: number | null } +): number | null => { + if (!stops?.length) { + return null; + } + const from = findStop(stops, anchor.stationId, anchor.stationGroupId); + const to = findStop(stops, station.stationId, station.stationGroupId); + if (!from || !to || from === to) { + return null; + } + const fromMinutes = anchor.isOrigin + ? (from.departureCumulativeMinutes ?? from.cumulativeMinutes) + : from.cumulativeMinutes; + const toMinutes = to.cumulativeMinutes; + if (fromMinutes == null || toMinutes == null || toMinutes < fromMinutes) { + return null; + } + return toMinutes - fromMinutes; +}; diff --git a/src/utils/ridePrefectures.test.ts b/src/utils/ridePrefectures.test.ts index dd7d962e02..2b720fdcc7 100644 --- a/src/utils/ridePrefectures.test.ts +++ b/src/utils/ridePrefectures.test.ts @@ -32,6 +32,7 @@ const ride = ( longitude: null, pathFromPrevious: null, prefectureId: input.prefectureId, + etaMinutesFromPrevious: null, })); return { id, diff --git a/src/utils/rideRoutes.test.ts b/src/utils/rideRoutes.test.ts index f95b581b96..95653dfdc4 100644 --- a/src/utils/rideRoutes.test.ts +++ b/src/utils/rideRoutes.test.ts @@ -51,6 +51,7 @@ const ride = ( longitude: input.at?.longitude ?? null, pathFromPrevious: input.at ? (input.path ?? []) : null, prefectureId: null, + etaMinutesFromPrevious: null, })); return { id, diff --git a/src/utils/rideStats.test.ts b/src/utils/rideStats.test.ts index 8d6aff7a2e..94f7242d66 100644 --- a/src/utils/rideStats.test.ts +++ b/src/utils/rideStats.test.ts @@ -30,6 +30,7 @@ const stop = ( longitude: null, pathFromPrevious: null, prefectureId: null, + etaMinutesFromPrevious: null, ...overrides, }); @@ -120,6 +121,71 @@ describe('measureRide', () => { expect(result.durationMs).toBe(6 * MIN); }); + it('接続駅を続けて記録しただけの到着は、乗車に数えない', () => { + const start = at('2026-09-28T08:00:00Z'); + // 出発駅を次の路線の駅としても記録しただけの乗車 + const result = measureRide( + ride('r', start, [ + { + stationId: 300, + stationGroupId: 200, + lineId: 22, + distanceFromPrevious: 30, + pathFromPrevious: [], + }, + ]) + ); + expect(result).toEqual({ distanceMeters: 0, durationMs: 0, stops: [] }); + }); + + it('通過で記録した接続駅に停車した記録は、到着として数える', () => { + const start = at('2026-09-28T08:00:00Z'); + const result = measureRide( + ride('r', start, [ + { kind: 'passed' }, + { + stationId: 301, + stationGroupId: 201, + lineId: 22, + distanceFromPrevious: 30, + pathFromPrevious: [], + }, + ]) + ); + expect(result.distanceMeters).toBe(1030); + expect(result.durationMs).toBe(6 * MIN); + }); + + it('ETA 上の所要時間より短く検出した乗車は、ETA の時間にする', () => { + const start = at('2026-09-28T08:00:00Z'); + // 検出では1分ずつ。ETA では駅1まで2分、駅2まで2.5分 + const result = measureRide( + ride( + 'r', + start, + [{ etaMinutesFromPrevious: 2 }, { etaMinutesFromPrevious: 2.5 }], + MIN + ) + ); + expect(result.durationMs).toBe(4.5 * MIN); + }); + + it('ETA より長く検出した乗車(遅れたとき)は、検出した時間のままにする', () => { + const start = at('2026-09-28T08:00:00Z'); + const result = measureRide( + ride('r', start, [{ etaMinutesFromPrevious: 2 }], 5 * MIN) + ); + expect(result.durationMs).toBe(5 * MIN); + }); + + it('最後の駅で ETA を引けていなければ、検出した時間のままにする', () => { + const start = at('2026-09-28T08:00:00Z'); + const result = measureRide( + ride('r', start, [{ etaMinutesFromPrevious: 5 }, {}], MIN) + ); + expect(result.durationMs).toBe(2 * MIN); + }); + it('到着を1つも検出していない乗車は距離も時間も0', () => { const start = at('2026-09-28T08:00:00Z'); const result = measureRide(ride('r', start, [{ kind: 'passed' }])); @@ -217,6 +283,46 @@ describe('summarizeRides', () => { ]); }); + it('直通運転の接続駅を続けて記録していても、次の路線に乗ったとは数えない', () => { + const start = at('2026-09-28T08:00:00Z'); + // 中央線快速で駅2(接続駅)に着き、同じ駅を総武線の駅としても記録した乗車 + const junction = ride('junction', start, [ + {}, + {}, + { + stationId: 302, + stationGroupId: 202, + lineId: 22, + lineName: '総武線', + lineColor: '#FFD400', + distanceFromPrevious: 30, + pathFromPrevious: [], + }, + ]); + const stats = summarizeRides([junction], 'week', week); + expect( + stats.lines.map((l) => [l.lineId, l.distanceMeters, l.rideCount]) + ).toEqual([[11, 2000, 1]]); + }); + + it('あいだに駅を挟んで同じ駅に戻った駅間は、乗った駅間として数える', () => { + const start = at('2026-09-28T08:00:00Z'); + // 都庁前(駅1)から新宿を取りこぼし、もう一方の都庁前に着いた乗車 + const loop = ride('loop', start, [ + {}, + { + stationId: 301, + stationGroupId: 201, + distanceFromPrevious: 1600, + pathFromPrevious: [{ latitude: 35.69, longitude: 139.7 }], + }, + ]); + const stats = summarizeRides([loop], 'week', week); + expect( + stats.lines.map((l) => [l.lineId, l.distanceMeters, l.rideCount]) + ).toEqual([[11, 2600, 1]]); + }); + it('距離が短くても乗った回数の多い路線を先に並べる', () => { // 総武線を1回で5000m、中央線快速を2回で計2000m const sobu = ride( diff --git a/src/utils/rideStats.ts b/src/utils/rideStats.ts index 6eafa62b84..095cda4303 100644 --- a/src/utils/rideStats.ts +++ b/src/utils/rideStats.ts @@ -55,6 +55,20 @@ const stopTime = ( session: RideSessionWithStops ): number => stop.arrivedAt ?? stop.departedAt ?? session.startedAt; +/** + * 直通運転の接続駅は、駅リストに前の路線の駅と次の路線の駅として2回並ぶ + * (src/utils/dropJunctionStation.ts)。到着判定がこの2つを行き来すると、同じ駅を + * 続けて記録することがある。その2つ目は乗った駅間ではないので、路線の集計に数えない。 + * 間に駅を挟んで同じ駅に戻った記録(大江戸線の都庁前など)は、乗った駅間として残す。 + */ +export const isJunctionDuplicate = ( + prev: RideStopRecord, + stop: RideStopRecord +): boolean => + prev.stationGroupId != null && + prev.stationGroupId === stop.stationGroupId && + (stop.pathFromPrevious?.length ?? 0) === 0; + export type RideMeasurement = { distanceMeters: number; durationMs: number; @@ -62,13 +76,31 @@ export type RideMeasurement = { stops: RideStopRecord[]; }; +/** + * ETA 上の、出発から最後の駅までの所要時間。各駅の etaMinutesFromPrevious は前に ETA を + * 引けた駅から数えているので、足せば出発からになる。最後の駅で ETA を引けていなければ + * 末尾が欠けるので null(列を足す前に記録した乗車も null)。 + */ +const etaDurationMs = (stops: RideStopRecord[]): number | null => { + if (stops[stops.length - 1]?.etaMinutesFromPrevious == null) { + return null; + } + const minutes = stops + .slice(1) + .reduce((sum, stop) => sum + (stop.etaMinutesFromPrevious ?? 0), 0); + return minutes * 60 * 1000; +}; + /** * 1回の乗車の距離と時間を求める。 * * 距離は、出発駅から最後に到着を検出した駅までの distanceFromPrevious の合計。 * 最後の到着より後に通過だけを検出した駅は数えない。時間は乗りはじめた時刻から - * 最後の到着まで。駅の検出が MAX_STOP_GAP_MS を超えて途切れたら、その手前で + * 最後の到着まで。ただし ETA 上の所要時間より短ければ、そちらを使う。駅の検出は + * 到着圏を出入りした時刻なので、発車直後と停車直前の時間が抜けて短く出るため。 + * 遅れたときは検出した時間の方が長くなるので、そのまま使う。駅の検出が MAX_STOP_GAP_MS を超えて途切れたら、その手前で * 乗車が終わったとみなし、後ろの駅は距離にも時間にも入れない。 + * 接続駅を続けて記録しただけの到着(isJunctionDuplicate)は、最後の到着に数えない。 */ export const measureRide = (session: RideSessionWithStops): RideMeasurement => { const ordered = [...session.stops].sort((a, b) => a.seq - b.seq); @@ -84,9 +116,18 @@ export const measureRide = (session: RideSessionWithStops): RideMeasurement => { } const kept = ordered.slice(0, cut); + // 接続駅の2つ目の記録は、前の記録が通過だったときだけ到着として扱う + // (同じ駅を行き来しただけの記録で、乗車を数えないため) let lastArrival = -1; for (let i = kept.length - 1; i >= 1; i--) { - if (kept[i].kind === 'arrived' && kept[i].arrivedAt != null) { + const isRepeatedArrival = + isJunctionDuplicate(kept[i - 1], kept[i]) && + kept[i - 1].kind === 'arrived'; + if ( + kept[i].kind === 'arrived' && + kept[i].arrivedAt != null && + !isRepeatedArrival + ) { lastArrival = i; break; } @@ -100,9 +141,10 @@ export const measureRide = (session: RideSessionWithStops): RideMeasurement => { .slice(1) .reduce((sum, stop) => sum + stop.distanceFromPrevious, 0); const endedAt = stops[stops.length - 1].arrivedAt as number; + const measuredMs = Math.max(0, endedAt - session.startedAt); return { distanceMeters, - durationMs: Math.max(0, endedAt - session.startedAt), + durationMs: Math.max(measuredMs, etaDurationMs(stops) ?? 0), stops, }; }; @@ -194,9 +236,13 @@ export const summarizeRides = ( // 駅間の距離は、到着した側の駅の路線に数える(直通運転で路線が変わるため) const usedLines = new Set(); - for (const stop of measured.stops.slice(1)) { - if (stop.lineId == null) { - continue; + measured.stops.forEach((stop, index) => { + if ( + index === 0 || + stop.lineId == null || + isJunctionDuplicate(measured.stops[index - 1], stop) + ) { + return; } const line = lines.get(stop.lineId) ?? { lineId: stop.lineId, @@ -211,7 +257,7 @@ export const summarizeRides = ( line.rideCount += 1; } lines.set(stop.lineId, line); - } + }); } return {