Skip to content
Open
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
10 changes: 8 additions & 2 deletions src/lib/composables/useLayoutSideEffects.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
// neary.setLocation(<lat>, <lon>) // pin a mock GPS fix
// neary.clearLocation() // resume real GPS

import { favoritesStore } from '$lib/stores/favoritesStore.svelte';
import { feedsStore } from '$lib/stores/feedsStore.svelte';
import { getGtfsRepo } from '$lib/data/gtfs/repo';
import { favoritesStore } from '$lib/stores/favoritesStore.svelte';import { feedsStore } from '$lib/stores/feedsStore.svelte';
import { locationStore } from '$lib/stores/gps/locationStore.svelte';
import { reconciledVehiclesStore } from '$lib/stores/reconciledVehiclesStore.svelte';
import { refreshBus } from '$lib/stores/refreshBus.svelte';
Expand Down Expand Up @@ -63,6 +63,12 @@ export function useLayoutSideEffects(): void {
setLocation: (lat: number, lon: number, accuracy = 25) =>
locationStore.setMockPosition(lat, lon, accuracy),
clearLocation: () => locationStore.clearMockPosition(),
// The worker facade. Handy for poking the feed from the console
// (e.g. `neary.repo.getStationBoard('46', Date.now(), 1440)`),
// and it lets a test drive a query with an explicit clock
// instead of the worker's own - the worker's wall clock can't be
// faked from the page.
repo: getGtfsRepo(),
stores: {
locationStore, feedsStore, statusBus, userPrefs, refreshBus,
reconciledVehiclesStore, favoritesStore, stationsViewStore,
Expand Down
6 changes: 2 additions & 4 deletions src/lib/composables/useRouteMapView.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { parseRouteIdWithDirection } from '$lib/data/gtfs/parseRouteIdWithDirect
import { getGtfsRepo } from '$lib/data/gtfs/repo';
import type { Network, Route, RouteTag } from '$lib/domain/types';
import { vehicleTypeLabel } from '$lib/domain/types';
import { dateKeyInTz, minSinceMidnightInTz } from '$lib/domain/pipeline/timeUtils';
import { minSinceMidnightInTz } from '$lib/domain/pipeline/timeUtils';
import { clockToBucket } from '$lib/domain/timeOfDay';
import {
buildTripShapePlan,
Expand Down Expand Up @@ -153,10 +153,8 @@ export function useRouteMapView(): {
try {
const repo = getGtfsRepo();
const nowMs = Date.now();
const localDate = dateKeyInTz(nowMs, tz);
view = await repo.getRouteMapView(
rid, dir, localDate,
minSinceMidnightInTz(nowMs, tz),
rid, dir, nowMs, tz,
LOOKBACK_MIN, LOOKAHEAD_MIN,
);
error = null;
Expand Down
15 changes: 11 additions & 4 deletions src/lib/data/gtfs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,13 @@ export interface GtfsRepo {
localDate: string,
fromMin: number,
windowMinutes: number,
/**
* The previous GTFS service day, when it is still running its
* late-night tail (between local midnight and the rollover hour).
* Its trips are shifted by `offsetMin` onto the same timeline as
* `fromMin`. See domain/pipeline/serviceDay.ts.
*/
previousServiceDay?: { date: string; offsetMin: number } | null,
): Promise<ScheduleTrip[]>;

/**
Expand Down Expand Up @@ -307,8 +314,8 @@ export interface GtfsRepo {
* directions collapse to a single set.
*/
getActiveRouteIdsInWindow(
localDate: string,
nowMin: number,
nowMs: number,
timeZone: string,
windowMinutes: number,
): Promise<string[]>;

Expand Down Expand Up @@ -385,8 +392,8 @@ export interface GtfsRepo {
getRouteMapView(
routeId: string,
directionId: 0 | 1,
localDate: string,
localMin: number,
nowMs: number,
timeZone: string,
lookbackMin: number,
lookaheadMin: number,
): Promise<RouteMapView | null>;
Expand Down
7 changes: 7 additions & 0 deletions src/lib/data/live/gtfsRtClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export interface LiveVehicleObservation {
* (routeId, directionId, startTime) since trip_ids can differ
* between the static-GTFS source and the GTFS-RT feed. */
startTime: string;
/** GTFS-RT `TripDescriptor.start_date` - the SERVICE DATE of the trip
* (not the trip's first departure, per the GTFS-RT spec). This is
* what tells us which service day the observation belongs to: a
* night-service trip that departs at "25:10" has a start_date of the
* PREVIOUS calendar date. Empty when the feed doesn't populate it. */
startDate: string;
/** Last reported position. */
lat: number;
lon: number;
Expand Down Expand Up @@ -138,6 +144,7 @@ export function parseVehiclePositions(buf: Uint8Array): VehiclePositionsSnapshot
routeId: v.trip?.routeId ?? '',
directionId: claimedDir === 0 || claimedDir === 1 ? claimedDir : -1,
startTime: v.trip?.startTime ?? '',
startDate: v.trip?.startDate ?? '',
lat: v.position.latitude ?? 0,
lon: v.position.longitude ?? 0,
bearing: v.position.bearing ?? null,
Expand Down
1 change: 1 addition & 0 deletions src/lib/domain/enrichObservations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function obs(opts: Partial<LiveVehicleObservation> & { tripId: string }): LiveVe
routeId: opts.routeId ?? '14',
directionId: opts.directionId ?? 0,
startTime: opts.startTime ?? '',
startDate: opts.startDate ?? '',
lat: 46.77,
lon: 23.62,
bearing: null,
Expand Down
20 changes: 16 additions & 4 deletions src/lib/domain/pipeline/scheduleScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export interface ScheduleRow {
trip_headsign: string | null;
stop_lat: number;
stop_lon: number;
/**
* Minutes to add to this row's GTFS times to place them on the shared
* timeline. -1440 for rows that belong to the PREVIOUS service day
* (see domain/pipeline/serviceDay.ts) - their GTFS times are already
* measured from that day's midnight, so they sit a day behind "now".
* Undefined / 0 for rows of the current service day.
*/
time_offset_min?: number;
}

export interface ScheduleScannerInputs {
Expand All @@ -60,9 +68,13 @@ export function scanSchedule(inputs: ScheduleScannerInputs): Vehicle[] {

const out: Vehicle[] = [];
for (const r of rows) {
const arrivalMin = timeToMinutes(r.arrival_time);
const departureMin = timeToMinutes(r.departure_time);
const tripEndMin = timeToMinutes(r.trip_end_time);
// Rows from the previous service day are a day behind on the shared
// timeline - shift their times so every comparison against "now"
// is in the same frame. See domain/pipeline/serviceDay.ts.
const offset = r.time_offset_min ?? 0;
const arrivalMin = timeToMinutes(r.arrival_time) + offset;
const departureMin = timeToMinutes(r.departure_time) + offset;
const tripEndMin = timeToMinutes(r.trip_end_time) + offset;

// Drop rows with unparseable arrival_time — adapter-emitted live-only fallback trips (`..._NT001`) ship without per-stop times. Without a real time we'd emit `NaN min` into the UI.
if (!Number.isFinite(arrivalMin)) continue;
Expand All @@ -89,7 +101,7 @@ export function scanSchedule(inputs: ScheduleScannerInputs): Vehicle[] {
scheduledDeparture: departureMin,
headsign: r.trip_headsign ?? undefined,
directionId: r.direction_id === 0 || r.direction_id === 1 ? r.direction_id : -1,
tripStartMin: timeToMinutes(r.trip_start_time),
tripStartMin: timeToMinutes(r.trip_start_time) + offset,
isFirstStop: r.stop_sequence === r.first_seq,
isLastStop: r.stop_sequence === r.last_seq || undefined,
};
Expand Down
110 changes: 110 additions & 0 deletions src/lib/domain/pipeline/serviceDay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* serviceDay.test.ts -- the GTFS service-day model.
*
* A service day is not a calendar day: it runs from 00:00 past
* midnight, so a trip published at "25:10" belongs to the PREVIOUS
* calendar date and departs at 01:10 the next morning. Between
* midnight and the end of the night service, two service days are in
* progress at once.
*/
import { describe, expect, it } from 'vitest';
import {
SERVICE_DAY_OFFSET_MIN,
SERVICE_DAY_ROLLOVER_MIN,
observationTimeOffsetMin,
previousDateKey,
serviceContextFor,
serviceWindowsFor,
} from './serviceDay';

const TZ = 'Europe/Bucharest';

/** Monday 2026-10-26 00:30 local (UTC+2 - Bucharest leaves DST on the
* 25th). */
const MONDAY_0030 = Date.parse('2026-10-25T22:30:00Z');
/** Monday 2026-10-26 12:00 local. */
const MONDAY_1200 = Date.parse('2026-10-26T10:00:00Z');
/** Sunday 2026-10-25 23:30 UTC = Monday 2026-10-26 01:30 in Bucharest
* (UTC+2) - the two disagree about the calendar date, so the feed's
* timezone must win. */
const SUNDAY_2330_UTC = Date.parse('2026-10-25T23:30:00Z');

describe('serviceContextFor', () => {
it('places "now" on the current calendar day outside the night window', () => {
const ctx = serviceContextFor(MONDAY_1200, TZ);
expect(ctx.serviceDate).toBe('20261026');
expect(ctx.nowMin).toBe(12 * 60);
expect(ctx.overlapping).toBe(false);
});

it('flags the post-midnight window as overlapping', () => {
const ctx = serviceContextFor(MONDAY_0030, TZ);
expect(ctx.serviceDate).toBe('20261026');
expect(ctx.previousServiceDate).toBe('20261025');
expect(ctx.nowMin).toBe(30);
expect(ctx.overlapping).toBe(true);
});

it('uses the feed timezone, not the system one', () => {
// In UTC this instant is still Sunday; in Bucharest it is already
// Monday 01:30 - past the rollover, so the two disagree and the
// feed-local answer wins.
const ctx = serviceContextFor(SUNDAY_2330_UTC, TZ);
expect(ctx.serviceDate).toBe('20261026');
expect(ctx.nowMin).toBe(1 * 60 + 30);
expect(ctx.overlapping).toBe(true);
});
});

describe('serviceWindowsFor', () => {
it('queries a single service day outside the night window', () => {
const ctx = serviceContextFor(MONDAY_1200, TZ);
expect(serviceWindowsFor(ctx)).toEqual([
{ date: '20261026', offsetMin: 0 },
]);
});

it('queries both service days during the night window', () => {
const ctx = serviceContextFor(MONDAY_0030, TZ);
expect(serviceWindowsFor(ctx)).toEqual([
// Yesterday's service day is still running its late-night tail.
{ date: '20261025', offsetMin: -SERVICE_DAY_OFFSET_MIN },
// Today's service day has already started.
{ date: '20261026', offsetMin: 0 },
]);
});
});

describe('previousDateKey', () => {
it('steps back a calendar day', () => {
expect(previousDateKey('20261026')).toBe('20261025');
expect(previousDateKey('20260301')).toBe('20260228');
expect(previousDateKey('20240101')).toBe('20231231');
});
});

describe('observationTimeOffsetMin', () => {
const ctx = serviceContextFor(MONDAY_0030, TZ);

it('shifts an observation of the previous service day a day back', () => {
// A night trip departing at "25:10" has Sunday as its service date.
expect(observationTimeOffsetMin('20261025', ctx)).toBe(-SERVICE_DAY_OFFSET_MIN);
});

it('leaves an observation of the current service day alone', () => {
expect(observationTimeOffsetMin('20261026', ctx)).toBe(0);
});

it('is a no-op when the feed does not report a service date', () => {
expect(observationTimeOffsetMin('', ctx)).toBe(0);
});
});

describe('the rollover window', () => {
it('starts at local midnight and ends at the rollover hour', () => {
expect(SERVICE_DAY_ROLLOVER_MIN).toBe(240);
// 03:59 is still overlapping, 04:00 is not.
expect(serviceContextFor(Date.parse('2026-10-25T23:59:00Z'), TZ).overlapping).toBe(true);
expect(serviceContextFor(Date.parse('2026-10-26T02:00:00Z'), TZ).overlapping).toBe(false);
});
});
134 changes: 134 additions & 0 deletions src/lib/domain/pipeline/serviceDay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* GTFS service days.
*
* A GTFS service day is NOT a calendar day. It starts at 00:00 and may
* run past midnight - a trip published at "25:10" departs at 01:10 the
* NEXT calendar morning, and it belongs to the PREVIOUS date's service
* day. Feeds that run night services publish exactly that (the Cluj
* feed's 25N / 5N / 54N lines use 24:00-25:33).
*
* The app resolved "what is running now" against the calendar date, so
* between midnight and the end of the night service the buses that
* were actually running were invisible: they were dropped from the
* station board at exactly the moment they started running, and the
* ETA arithmetic mixed two timelines (a 24 h error).
*
* This module is the single place that answers "which service days are
* in progress right now, and how do their trip times land on the
* timeline the rest of the app uses".
*
* THE TIMELINE
*
* Everything the app compares against "now" lives on one timeline:
* minutes since the CURRENT calendar day's local midnight, extended
* past 24:00. A trip of the current calendar day's service day is
* already on that timeline. A trip of the PREVIOUS service day is one
* day behind on it, so its times are shifted by -1440 minutes
* (`SERVICE_DAY_OFFSET_MIN`).
*
* Both service days are in progress during the post-midnight window:
* yesterday's service day is still running its late-night tail, and
* today's service day has already started. Both must be queried.
*/

import { dateKeyInTz, minSinceMidnightInTz } from './timeUtils';

/**
* Local hour (feed timezone) at which the previous service day is
* considered finished. 04:00 is the usual agency convention and matches
* the assumption already documented in `config.ts` ("18 h from any
* wall-clock reaches a typical 04:00 GTFS end-of-service").
*/
export const SERVICE_DAY_ROLLOVER_MIN = 4 * 60;

/** Minutes in a day - the shift between consecutive service days. */
export const SERVICE_DAY_OFFSET_MIN = 1440;

export interface ServiceContext {
/** Calendar date (YYYYMMDD) of the current service day. */
serviceDate: string;
/** Calendar date (YYYYMMDD) of the previous service day. */
previousServiceDate: string;
/** "Now" in minutes since local midnight (0..1439). */
nowMin: number;
/**
* True during the post-midnight window, when the previous service
* day's late-night tail is still running alongside the current
* service day's first departures.
*/
overlapping: boolean;
}

/**
* Resolve the service-day context for a wall-clock instant.
*
* Pure. `timeZone` is the feed's IANA timezone - every value here is
* feed-local, never system-local.
*/
export function serviceContextFor(nowMs: number, timeZone: string): ServiceContext {
const serviceDate = dateKeyInTz(nowMs, timeZone);
const nowMin = minSinceMidnightInTz(nowMs, timeZone);
return {
serviceDate,
previousServiceDate: previousDateKey(serviceDate),
nowMin,
overlapping: nowMin < SERVICE_DAY_ROLLOVER_MIN,
};
}

/**
* The service days that are in progress, with the offset that places
* each one's trip times on the shared timeline.
*/
export interface ServiceWindow {
/** Calendar date whose services this window covers. */
date: string;
/** Minutes to add to that date's trip times to reach the shared timeline. */
offsetMin: number;
}

export function serviceWindowsFor(ctx: ServiceContext): ServiceWindow[] {
if (!ctx.overlapping) return [{ date: ctx.serviceDate, offsetMin: 0 }];
return [
// The previous service day is still running its late-night tail.
{ date: ctx.previousServiceDate, offsetMin: -SERVICE_DAY_OFFSET_MIN },
// Today's service day has already started.
{ date: ctx.serviceDate, offsetMin: 0 },
];
}

/**
* The offset that places a live observation's GTFS start time on the
* shared timeline.
*
* A GTFS-RT observation carries the trip's SERVICE DATE
* (`TripDescriptor.start_date`), which for a night-service trip is the
* PREVIOUS calendar date - its GTFS start time ("25:10") is measured
* from that day's midnight, so it sits a day behind "now".
*
* Returns 0 when the feed doesn't report a service date, or when the
* observation belongs to the current service day.
*/
export function observationTimeOffsetMin(
startDate: string,
ctx: ServiceContext,
): number {
if (!startDate) return 0;
return startDate === ctx.previousServiceDate ? -SERVICE_DAY_OFFSET_MIN : 0;
}

/** The previous calendar date as YYYYMMDD. Calendar arithmetic, not
* millisecond arithmetic - subtracting 24 h across a DST boundary can
* land on the wrong date. */
export function previousDateKey(dateKey: string): string {
const y = Number(dateKey.slice(0, 4));
const m = Number(dateKey.slice(4, 6));
const d = Number(dateKey.slice(6, 8));
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() - 1);
return formatDateKey(dt);
}

function formatDateKey(dt: Date): string {
return `${dt.getUTCFullYear()}${String(dt.getUTCMonth() + 1).padStart(2, '0')}${String(dt.getUTCDate()).padStart(2, '0')}`;
}
Loading
Loading