diff --git a/android/app/build.gradle b/android/app/build.gradle index 5b2f9343eb..3a565b15f2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -170,13 +170,13 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000849 - versionName "10.18.0" + versionCode 100000853 + versionName "10.18.1" } prod { dimension "environment" - versionCode 100000849 - versionName "10.18.0" + versionCode 100000853 + versionName "10.18.1" } // ストア配信する dev (Canary) と applicationId を分けた、ローカル検証専用のフレーバー。 // ストアの Canary 版を消さずに同居させるためだけに存在し、配信には使わない @@ -186,8 +186,8 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.local" versionNameSuffix "-local" - versionCode 100000849 - versionName "10.18.0" + versionCode 100000853 + versionName "10.18.1" } } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index 4e78efbf4f..8b1553b806 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,13 +85,13 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000850 - versionName = "10.18.0" + versionCode = 100000854 + versionName = "10.18.1" } create("prod") { dimension = "environment" - versionCode = 100000850 - versionName = "10.18.0" + versionCode = 100000854 + versionName = "10.18.1" } // :app の local フレーバーと対になるローカル検証専用フレーバー。Wear の Data Layer は // applicationId が一致する場合しか疎通しないため、:app を .local にしたときは @@ -100,8 +100,8 @@ android { dimension = "environment" applicationIdSuffix = ".local" versionNameSuffix = "-local" - versionCode = 100000850 - versionName = "10.18.0" + versionCode = 100000854 + versionName = "10.18.1" } } diff --git a/app.config.ts b/app.config.ts index c7e64f7728..2ef4f9b4a6 100644 --- a/app.config.ts +++ b/app.config.ts @@ -8,7 +8,7 @@ const IS_DEV = process.env.APP_VARIANT === 'dev' || IS_LOCAL; export default { name: 'TrainLCD', slug: 'trainlcd', - version: '10.18.0', + version: '10.18.1', plugins: [ 'expo-image', 'expo-font', @@ -64,7 +64,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2954', + buildNumber: '2956', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -78,7 +78,7 @@ export default { ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000849, + versionCode: 100000853, }, owner: 'trainlcd', experiments: { diff --git a/docs/changelog.md b/docs/changelog.md index 3a46c5c9c3..401d966889 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,37 @@ CLAUDE.md「Security & Configuration Guardrails」に従い、依存更新後に 新しいエントリを上に追加する。 +## 2026-10-05 — ビルドに入っていなかった expo-task-manager のパッチを削除 + +対象バージョン: v10.18.0 時点の `dev` + +### 内容 + +- `patches/expo-task-manager+57.0.13.patch` を削除した。`TaskManagerUtils.createJobInfo` の API 31+ 分岐で + `setExpedited(true)` を `setMinimumLatency(0)` と `setOverrideDeadline` に置き換えるパッチだった。 +- `expo-task-manager` はビルド済みの AAR(`local-maven-repo`)を同梱し、`expo-module.config.json` に `publication` を持つ。 + `buildFromSource` に入っていないため、Gradle は AAR を使い、`node_modules` のソースへのパッチはビルドに入らない。 + このパッチを作った時点の 14.0.9 から 57.0.13 まで、どの版も同じ構成だった。 +- #5163 で `app.json` の `expo.android.autolinking.buildFromSource` に `expo-task-manager` を加えていた。 + `expo-modules-autolinking` が読むのは `package.json` の `expo.autolinking` だけなので、この設定は使われていなかった。 + その後 #5173 で `app.config.ts` へ移行したときに設定自体もなくなっている。 +- ストア版の本番 APK(v10.18.0)を逆アセンブルし、`createJobInfo` が上流のまま(API 31+ は `setExpedited(true)`)であることを確かめた。 + パッチを消しても、ビルドに入るコードは変わらない。 + +### 確認 + +- `./gradlew :app:dependencies --configuration prodReleaseRuntimeClasspath` で、 + `expo-task-manager` が `host.exp.exponent:expo.modules.taskmanager:57.0.13`(AAR)として解決されることを確かめた。 +- Sentry の直近 90 日間で、このパッチが対象にしていた `JobInfo.Builder.build()` の + `You're trying to build a job with no constraints` は 0 件だった。 + +### 学び + +- Expo のモジュールへのネイティブのパッチは、`package.json` の `expo.autolinking.android.buildFromSource` に加えないとビルドに入らない。 + `app.json` に書いても読まれない。 +- 「2026-08-26 — Expo SDK 57 系の依存を推奨バージョンへ更新」の項にある、このパッチの挙動の説明は、 + パッチがビルドに入っていた前提で書かれている。実際の出荷物は上流の実装のままだった。 + ## 2026-10-05 — Android で一覧から消して開き直すと端末内蔵 TTS が鳴らない不具合を修正 対象バージョン: v10.18.0 の本番で発生 diff --git a/docs/location-simulation.md b/docs/location-simulation.md index f08d1fadd0..c42ac010e7 100644 --- a/docs/location-simulation.md +++ b/docs/location-simulation.md @@ -329,6 +329,12 @@ npm run gpx:replay -- --gpx assets/gpx/SampleTohokuShinkansen.gpx --serial = 36, because OEM/patch-level -+ // variance on Android 14 and 15 can still trip the same crash. -+ // Note on the trade-off: the JS-side watchPositionAsync bypass is gated by -+ // NEEDS_JOBSCHEDULER_BYPASS (src/constants/native.ts), which is true only on -+ // API 36+, so it does NOT offset the loss of expedited priority on API 31-35. -+ // There, the Android foreground location service keeping the process alive is -+ // the only mitigation, at the cost of a possible JS delivery delay under Doze. -+ jobBuilder.setMinimumLatency(0) -+ .setOverrideDeadline(DEFAULT_OVERRIDE_DEADLINE); - } - - return jobBuilder.build(); diff --git a/scripts/replay-location-gpx.mjs b/scripts/replay-location-gpx.mjs index 5e3dce7cf3..6706bc3295 100644 --- a/scripts/replay-location-gpx.mjs +++ b/scripts/replay-location-gpx.mjs @@ -9,6 +9,10 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); +// 開発用のスクリプトなので、既定ではステージングの StationAPI に問い合わせる。 +// GQL_API_URL があればそちらを使う。 +const DEFAULT_API_URL = 'https://gql-stg.trainlcd.app/'; + const argv = process.argv.slice(2); const opt = (name, fallback) => { const i = argv.indexOf(`--${name}`); @@ -170,6 +174,35 @@ if (startSec * 1000 > totalMs) { ); } +// --- 行先 ----------------------------------------------------------------- +// 座標だけでは再生中にどこへ向かっているのか分からないので、終点の座標に +// いちばん近い駅を行先として表示する。生成した GPX の終点は駅の座標そのもの。 +// 行先が分からないまま再生しても検証に使えないので、引けなければ再生を始めない。 +async function fetchDestination() { + const last = points[points.length - 1]; + const res = await fetch(process.env.GQL_API_URL ?? DEFAULT_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: `query ReplayGpxDestination($latitude: Float!, $longitude: Float!) { + stationsNearby(latitude: $latitude, longitude: $longitude, limit: 1) { + name + } + }`, + variables: { latitude: last.lat, longitude: last.lon }, + }), + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + if (json.errors) { + throw new Error(json.errors.map((e) => e.message).join(', ')); + } + const name = json.data?.stationsNearby?.[0]?.name; + if (!name) throw new Error('終点の座標の近くに駅が見つかりません'); + return name; +} + // --- テストプロバイダの準備 ---------------------------------------------- const loc = (...rest) => adb('shell', 'cmd', 'location', 'providers', ...rest); @@ -328,12 +361,21 @@ async function play(skipSec) { await push(p, acc); const elapsed = ((p.offset - originOffset) / 1000).toFixed(0); + // 開始時の行先表示はログに流されて見えなくなるので、進捗行にも載せる。 process.stdout.write( - `\r[${i + 1}/${points.length}] +${elapsed}s ${p.lat.toFixed(5)}, ${p.lon.toFixed(5)} acc=${acc}m 再登録${reregistrations}回 ` + `\r[${i + 1}/${points.length}] ${destination}行 +${elapsed}s ${p.lat.toFixed(5)}, ${p.lon.toFixed(5)} acc=${acc}m 再登録${reregistrations}回 ` ); } } +// テストプロバイダを有効にする前に引く。問い合わせを待つあいだ端末の測位が +// 止まったままにならず、失敗して終了するときに後始末も要らない。 +const destination = await fetchDestination().catch((e) => { + console.error(`行先を取得できませんでした: ${e.message}`); + process.exit(1); +}); +console.log(`行先: ${destination}`); + let exitCode = 0; try { await setup(); diff --git a/scripts/replay-location-gpx.test.mjs b/scripts/replay-location-gpx.test.mjs new file mode 100644 index 0000000000..273781c6a4 --- /dev/null +++ b/scripts/replay-location-gpx.test.mjs @@ -0,0 +1,128 @@ +// replay-location-gpx.mjs の行先の取得と表示に対する回帰テスト。 +// adb と StationAPI を偽物に差し替えて、端末もネットワークも無しで最後まで再生させる: +// node --test scripts/replay-location-gpx.test.mjs + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const script = fileURLToPath( + new URL('./replay-location-gpx.mjs', import.meta.url) +); +const dir = mkdtempSync(join(tmpdir(), 'replay-gpx-test-')); + +// devices には 1 台つながっていると答え、それ以外の呼び出しは何もせず成功する。 +writeFileSync( + join(dir, 'adb'), + [ + '#!/bin/sh', + `echo "$@" >> '${join(dir, 'adb.log')}'`, + 'if [ "$1" = "devices" ]; then', + ' printf "List of devices attached\\nemulator-5554\\tdevice\\n"', + 'fi', + '', + ].join('\n') +); +chmodSync(join(dir, 'adb'), 0o755); + +const adbLog = join(dir, 'adb.log'); +const gpxPath = join(dir, 'track.gpx'); +writeFileSync( + gpxPath, + [ + '', + '', + '', + '', + ].join('\n') +); + +let requests = []; +let respond = () => ({ status: 200, body: {} }); +const server = createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => { + raw += chunk; + }); + req.on('end', () => { + requests.push(JSON.parse(raw)); + const { status, body } = respond(); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }); +}); + +before(() => new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))); +after(() => { + server.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +// サーバと同じプロセスで待つので、execFileSync ではなく非同期で起動する。 +const replay = async () => { + requests = []; + const { stdout, stderr } = await execFileAsync( + process.execPath, + [script, '--gpx', gpxPath, '--speed', '1000'], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + GQL_API_URL: `http://127.0.0.1:${server.address().port}/`, + }, + } + ); + return { stdout, stderr }; +}; + +test('終点の座標で 1 回だけ問い合わせ、行先を開始時と進捗行に表示する', async () => { + respond = () => ({ + status: 200, + body: { data: { stationsNearby: [{ name: '京王八王子' }] } }, + }); + const { stdout } = await replay(); + assert.equal(requests.length, 1); + assert.deepEqual(requests[0].variables, { + latitude: 35.657416, + longitude: 139.343851, + }); + assert.match(stdout, /^行先: 京王八王子$/m); + assert.match(stdout, /\[2\/2\] 京王八王子行 /); +}); + +test('行先を引けなければ、テストプロバイダに触れずに異常終了する', async () => { + for (const [response, message] of [ + [{ status: 500, body: {} }, 'HTTP 500'], + [ + { status: 200, body: { data: { stationsNearby: [] } } }, + '終点の座標の近くに駅が見つかりません', + ], + [{ status: 200, body: { errors: [{ message: 'boom' }] } }, 'boom'], + ]) { + respond = () => response; + writeFileSync(adbLog, ''); + await assert.rejects(replay(), (error) => { + assert.equal(error.code, 1); + assert.match( + error.stderr, + new RegExp(`行先を取得できませんでした: ${message}`) + ); + return true; + }); + assert.equal(readFileSync(adbLog, 'utf8'), ''); + } +}); 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 {