Skip to content
Merged
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
21 changes: 21 additions & 0 deletions docs/ride-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 の所要時間は、この抜けを補うために記録します。

## 距離

Expand Down Expand Up @@ -100,6 +115,12 @@ StationAPI の `Station.prefectureId`(1〜47)をそのまま入れます。
- 日付は端末のタイムゾーンで区切ります。乗車は、乗りはじめた時刻(`startedAt`)が入る期間と日(年は月)に数えます。
- 1回の乗車の距離は、出発駅から最後に到着を検出した駅までの `distanceFromPrevious` の合計です。最後の到着より後に通過だけを検出した駅は数えません。
- 1回の乗車の時間は、`startedAt` から最後に到着を検出した時刻までです。
ETA 上の所要時間(`etaMinutesFromPrevious` の合計)の方が長ければ、そちらを使います。
遅れたときは検出した時間の方が長くなるので、そのまま使います。
最後の駅で ETA を引けていない乗車は、検出した時間だけを使います。
- 接続駅を続けて記録した2つ目の駅(同じ駅グループで、あいだに駅を挟まないもの)は、乗った駅間として数えません。
最後の到着にも、路線ごとの集計にも入れません。
このような駅しか到着が無い乗車は、乗車回数にも数えません。
- 駅を検出する間隔が 90 分(`MAX_STOP_GAP_MS`)を超えたら、その手前の駅で乗車が終わったとみなします。アプリを開いたまま放置した乗車で時間が膨らまないようにするためです。
- 路線ごとの距離は、駅と駅のあいだを到着した側の駅の路線に数えます。直通運転で路線が変わる乗車も、区間ごとに分けて数えます。

Expand Down
1 change: 1 addition & 0 deletions src/components/RideMonthCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const sampleRide = (): RideSessionWithStops => {
longitude: null,
pathFromPrevious: null,
prefectureId: null,
etaMinutesFromPrevious: null,
};
return {
id: 'r1',
Expand Down
160 changes: 160 additions & 0 deletions src/hooks/useRideRecorder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
}));
Expand Down Expand Up @@ -97,6 +108,7 @@ const tick = (ms: number) => {

describe('useRideRecorder', () => {
beforeEach(() => {
mockEtaStops = [];
now = 1_000;
jest.spyOn(Date, 'now').mockImplementation(() => now);
});
Expand Down Expand Up @@ -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));
Expand Down
64 changes: 57 additions & 7 deletions src/hooks/useRideRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RideSessionRecord, 'id' | 'startedAt' | 'endedAt'>;
Expand All @@ -37,6 +44,8 @@ type SessionState = {
tainted: boolean;
lastStation: Station | null;
stops: RideStopRecord[];
// ETA 上の所要時間を数える起点。ETA を引けた最後の駅(はじめは出発駅)
etaAnchor: RideEtaAnchor | null;
// 乗車が確定した時点の路線・種別・方面。確定するまでは null
meta: RideSessionMeta | null;
// DB に書けたセッションの ID と、そのうち書けた駅の数
Expand Down Expand Up @@ -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;
Expand All @@ -101,6 +111,7 @@ const toStopRecord = (
longitude: coordinate?.longitude ?? null,
pathFromPrevious: toIntermediatePath(distance.path),
prefectureId: toPrefectureId(station.prefectureId),
etaMinutesFromPrevious,
};
};

Expand Down Expand Up @@ -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<readonly RideEtaStop[] | null>(null);
etaStopsRef.current = etaRoute?.stops ?? null;

const sessionRef = useRef<SessionState>({
mountedAt: Date.now(),
tainted: false,
lastStation: null,
stops: [],
etaAnchor: null,
meta: null,
persistedId: null,
persistedCount: 0,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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) {
Expand Down
Loading
Loading