Add CI pipeline, pre-commit hooks, and offline caching
CI / lint-typecheck-test (push) Has been cancelled

Configure GitHub Actions for linting, typechecking, and testing.
Add Husky and lint-staged for pre-commit checks. Implement API
response caching in AsyncStorage for offline support. Move core
tests to packages/core and add vitest configuration.
This commit is contained in:
2026-05-19 14:00:38 +02:00
parent 0493fcc939
commit 72f9400756
18 changed files with 809 additions and 2552 deletions
-8
View File
@@ -1,8 +0,0 @@
module.exports = {
root: true,
extends: ['expo'],
rules: {
'react-native/no-inline-styles': 'off',
},
ignorePatterns: ['node_modules/', '.expo/', 'dist/'],
};
+3
View File
@@ -5,4 +5,7 @@ module.exports = {
'^@react-native-async-storage/async-storage$':
'@react-native-async-storage/async-storage/jest/async-storage-mock',
},
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@sentry/.*|@fortawesome/.*)',
],
};
+38 -5
View File
@@ -12,6 +12,14 @@ import { faArrowsRotate, faBicycle, faTrain, faTriangleExclamation } from '@fort
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { loadEvents, loadOriginStation, loadNotificationSettings } from '../store/eventStore';
import {
getCachedJourneys,
getCachedBikeRoute,
getCachedWalkRoute,
setCachedJourneys,
setCachedBikeRoute,
setCachedWalkRoute,
} from '../store/apiCache';
import { api } from '../services/api';
import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } from '@timetoleave/core';
import { useDestinationStation } from '../hooks/useDestinationStation';
@@ -79,6 +87,15 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
// Preload cached data immediately so the UI isn't empty
const cachedJourneys = await getCachedJourneys(eventId);
const cachedBike = await getCachedBikeRoute(eventId);
const cachedWalk = await getCachedWalkRoute(eventId);
if (cachedJourneys) setJourneys(cachedJourneys);
if (cachedBike) setBikeRoute(cachedBike);
if (cachedWalk) setWalkRoute(cachedWalk);
try {
const [events, originStation, settings] = await Promise.all([
loadEvents(),
@@ -111,8 +128,14 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
const walkDurationSeconds = settings.showWalkingOption ? (walkHook.walkRoute?.duration ?? 0) : 0;
const target = stationArrivalTarget(found.eventTime, settings.arrivalBufferMinutes, walkDurationSeconds);
const results = await api.searchJourneys(originStation.extId, destExtId, target, { arriveBy: true });
setJourneys(results);
try {
const results = await api.searchJourneys(originStation.extId, destExtId, target, { arriveBy: true });
setJourneys(results);
await setCachedJourneys(eventId, results);
} catch {
if (!cachedJourneys) throw new Error('Failed to load journeys');
// Keep stale data, mark as offline
}
} else if (destStation.error) {
setError(`Destination station not resolvable: ${destStation.error}`);
}
@@ -125,15 +148,22 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
destCoords.coords.lat, destCoords.coords.lng,
);
setBikeRoute(bike);
await setCachedBikeRoute(eventId, bike);
}
} catch {
setBikeRoute(null);
if (!cachedBike) setBikeRoute(null);
} finally {
setLoadingBike(false);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Error loading');
const msg = err instanceof Error ? err.message : 'Error loading';
// If we have any cached data, show a soft offline warning instead of a hard error
if (cachedJourneys || cachedBike || cachedWalk) {
setError(`${msg} (showing cached data)`);
} else {
setError(msg);
}
} finally {
setLoading(false);
}
@@ -144,7 +174,10 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
useEffect(() => {
setWalkRoute(walkHook.walkRoute);
setLoadingWalk(walkHook.loading);
}, [walkHook.walkRoute, walkHook.loading]);
if (walkHook.walkRoute) {
setCachedWalkRoute(eventId, walkHook.walkRoute).catch(() => {});
}
}, [walkHook.walkRoute, walkHook.loading, eventId]);
const handleRefresh = () => {
setBikeRoute(null);
+84
View File
@@ -0,0 +1,84 @@
/**
* Offline cache for API responses.
*
* Stores the last successful journey, bike-route, and walk-route results
* per event (keyed by event id) so the detail screen can show stale data
* when the device is offline.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Journey, BikeRoute, WalkRoute } from '@timetoleave/core';
const CACHE_PREFIX = '@timetoleave_cache_';
const MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
interface CacheEntry<T> {
data: T;
ts: number;
}
async function getCache<T>(key: string): Promise<T | null> {
try {
const raw = await AsyncStorage.getItem(CACHE_PREFIX + key);
if (!raw) return null;
const entry: CacheEntry<T> = JSON.parse(raw);
if (Date.now() - entry.ts > MAX_AGE_MS) return null;
return entry.data;
} catch {
return null;
}
}
async function setCache<T>(key: string, data: T): Promise<void> {
try {
const entry: CacheEntry<T> = { data, ts: Date.now() };
await AsyncStorage.setItem(CACHE_PREFIX + key, JSON.stringify(entry));
} catch {
// Silently fail on quota exceeded / privacy mode
}
}
export async function getCachedJourneys(eventId: string): Promise<Journey[] | null> {
const raw = await getCache<Array<Omit<Journey, 'sD' | 'rD' | 'sA' | 'rA'> & { sD: string; rD: string; sA: string; rA: string }>>(`journeys_${eventId}`);
if (!raw) return null;
return raw.map((j) => ({
...j,
sD: new Date(j.sD),
rD: new Date(j.rD),
sA: new Date(j.sA),
rA: new Date(j.rA),
}));
}
export async function setCachedJourneys(eventId: string, journeys: Journey[]): Promise<void> {
const serializable = journeys.map((j) => ({
...j,
sD: j.sD.toISOString(),
rD: j.rD.toISOString(),
sA: j.sA.toISOString(),
rA: j.rA.toISOString(),
}));
await setCache(`journeys_${eventId}`, serializable);
}
export async function getCachedBikeRoute(eventId: string): Promise<BikeRoute | null> {
return getCache(`bike_${eventId}`);
}
export async function setCachedBikeRoute(eventId: string, route: BikeRoute): Promise<void> {
await setCache(`bike_${eventId}`, route);
}
export async function getCachedWalkRoute(eventId: string): Promise<WalkRoute | null> {
return getCache(`walk_${eventId}`);
}
export async function setCachedWalkRoute(eventId: string, route: WalkRoute): Promise<void> {
await setCache(`walk_${eventId}`, route);
}
export async function clearApiCache(): Promise<void> {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter((k) => k.startsWith(CACHE_PREFIX));
await AsyncStorage.multiRemove(cacheKeys);
}
-8
View File
@@ -1,8 +0,0 @@
module.exports = {
root: true,
extends: ['next/core-web-vitals'],
rules: {
'@next/next/no-html-link-for-pages': 'off',
},
ignorePatterns: ['node_modules/', '.next/', 'out/', 'dist/'],
};
+8 -3
View File
@@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import { useEventsStore } from "@/hooks/useEventsStore";
import { useOriginStation } from "@/hooks/useOriginStation";
import EventCard from "@/app/event/EventCard";
@@ -15,9 +16,13 @@ export default function Home() {
const { events } = useEventsStore();
const { station: originStation } = useOriginStation();
const upcoming = events
.filter((e) => e.eventTime >= new Date())
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
const upcoming = useMemo(
() =>
events
.filter((e) => e.eventTime >= new Date())
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime()),
[events]
);
const nextEvent = upcoming[0] ?? null;
return (
@@ -1,63 +0,0 @@
import { describe, it, expect } from "vitest";
import { calculateCountdown } from "@timetoleave/core";
describe("countdown-utils", () => {
describe("calculateCountdown", () => {
it("should return 'Now' for events that have already passed", () => {
const pastDate = new Date(Date.now() - 1000);
const result = calculateCountdown(pastDate);
expect(result.label).toBe("Now");
expect(result.color).toBe("red");
expect(result.urgent).toBe(true);
});
it("should return 'Xmin' for events within 10 minutes", () => {
const soonDate = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes from now
const result = calculateCountdown(soonDate);
expect(result.label).toMatch(/\d+min/);
expect(result.color).toBe("orange");
expect(result.urgent).toBe(true);
});
it("should return 'Xmin' for events within 30 minutes", () => {
const mediumDate = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes from now
const result = calculateCountdown(mediumDate);
expect(result.label).toMatch(/\d+min/);
expect(result.color).toBe("yellow");
expect(result.urgent).toBe(false);
});
it("should return 'Xmin' for events within 60 minutes", () => {
const mediumDate = new Date(Date.now() + 45 * 60 * 1000); // 45 minutes from now
const result = calculateCountdown(mediumDate);
expect(result.label).toMatch(/\d+min/);
expect(result.color).toBe("green");
expect(result.urgent).toBe(false);
});
it("should return 'Xh Ymin' for events beyond 60 minutes", () => {
const farDate = new Date(Date.now() + 90 * 60 * 1000); // 90 minutes from now
const result = calculateCountdown(farDate);
expect(result.label).toMatch(/\d+h \d+min/);
expect(result.color).toBe("blue");
expect(result.urgent).toBe(false);
});
it("should handle exact time boundaries correctly", () => {
// Exactly 10 minutes
const tenMinDate = new Date(Date.now() + 10 * 60 * 1000);
const result = calculateCountdown(tenMinDate);
expect(result.color).toBe("orange"); // Should be orange for 10 minutes or less
// Exactly 30 minutes
const thirtyMinDate = new Date(Date.now() + 30 * 60 * 1000);
const result2 = calculateCountdown(thirtyMinDate);
expect(result2.color).toBe("yellow"); // Should be yellow for 30 minutes or less
// Exactly 60 minutes
const sixtyMinDate = new Date(Date.now() + 60 * 60 * 1000);
const result3 = calculateCountdown(sixtyMinDate);
expect(result3.color).toBe("green"); // Should be green for 60 minutes or less
});
});
});
@@ -1,313 +0,0 @@
import { describe, it, expect } from "vitest";
import { parseHafasTime, hafasDateTime } from "@timetoleave/core";
describe("getTimezoneOffsetMinutes (via parseHafasTime)", () => {
/*
* We can't test getTimezoneOffsetMinutes directly (not exported), but we can
* validate its correctness by checking that parseHafasTime produces the
* expected UTC timestamps for known Vienna local times.
*
* CET = UTC+1 (offset 60 min) — winter
* CEST = UTC+2 (offset 120 min) — summer
*
* To verify independence from server timezone, we compare the resulting
* Date's UTC components (getUTCHours, getUTCMinutes, etc.).
*/
describe("Standard Winter (CET, UTC+1)", () => {
it("parses 08:00 Vienna (Jan) as 07:00 UTC", () => {
const d = parseHafasTime("20240115", "080000");
expect(d.getUTCFullYear()).toBe(2024);
expect(d.getUTCMonth()).toBe(0); // January
expect(d.getUTCDate()).toBe(15);
expect(d.getUTCHours()).toBe(7);
expect(d.getUTCMinutes()).toBe(0);
expect(d.getUTCSeconds()).toBe(0);
});
it("parses midnight Vienna as 23:00 UTC previous day", () => {
const d = parseHafasTime("20240201", "000000");
expect(d.getUTCFullYear()).toBe(2024);
expect(d.getUTCMonth()).toBe(0); // January
expect(d.getUTCDate()).toBe(31);
expect(d.getUTCHours()).toBe(23);
expect(d.getUTCMinutes()).toBe(0);
});
it("parses 23:59 Vienna as 22:59 UTC same day", () => {
const d = parseHafasTime("20241231", "235900");
expect(d.getUTCDate()).toBe(31);
expect(d.getUTCHours()).toBe(22);
expect(d.getUTCMinutes()).toBe(59);
});
});
describe("Standard Summer (CEST, UTC+2)", () => {
it("parses 08:00 Vienna (July) as 06:00 UTC", () => {
const d = parseHafasTime("20240715", "080000");
expect(d.getUTCFullYear()).toBe(2024);
expect(d.getUTCMonth()).toBe(6); // July
expect(d.getUTCDate()).toBe(15);
expect(d.getUTCHours()).toBe(6);
expect(d.getUTCMinutes()).toBe(0);
});
it("parses midnight Vienna as 22:00 UTC previous day", () => {
const d = parseHafasTime("20240801", "000000");
expect(d.getUTCDate()).toBe(31);
expect(d.getUTCMonth()).toBe(6); // July
expect(d.getUTCHours()).toBe(22);
});
});
describe("Spring DST Transition (March 31, 2024)", () => {
/*
* In Austria 2024:
* - At 02:00 CET clocks jump to 03:00 CEST
* - 02:00-02:59 does NOT exist
* - 01:59 CET → 03:00 CEST
*/
it("treats 01:00 on Mar 31 as CET (UTC+1) → 00:00 UTC", () => {
const d = parseHafasTime("20240331", "010000");
expect(d.getUTCDate()).toBe(31);
expect(d.getUTCHours()).toBe(0);
expect(d.getUTCMinutes()).toBe(0);
});
it("treats 03:00 on Mar 31 as CEST (UTC+2) → 01:00 UTC", () => {
const d = parseHafasTime("20240331", "030000");
expect(d.getUTCDate()).toBe(31);
expect(d.getUTCHours()).toBe(1);
expect(d.getUTCMinutes()).toBe(0);
});
it("treats 12:00 on Mar 31 as CEST (UTC+2) → 10:00 UTC", () => {
const d = parseHafasTime("20240331", "120000");
expect(d.getUTCHours()).toBe(10);
});
it("treats 00:00 on Mar 31 as CET (still before transition) → 23:00 UTC Mar 30", () => {
const d = parseHafasTime("20240331", "000000");
expect(d.getUTCDate()).toBe(30);
expect(d.getUTCHours()).toBe(23);
});
});
describe("Fall DST Transition (October 27, 2024)", () => {
/*
* In Austria 2024:
* - At 03:00 CEST clocks go back to 02:00 CET
* - 02:00-02:59 occurs twice (ambiguous)
* - Before 03:00 CEST is UTC+2, after is UTC+1
*/
it("treats 00:00 on Oct 27 as CEST (before transition) → 22:00 UTC Oct 26", () => {
const d = parseHafasTime("20241027", "000000");
expect(d.getUTCDate()).toBe(26);
expect(d.getUTCHours()).toBe(22);
});
it("treats 01:00 on Oct 27 as CEST (before transition) → 23:00 UTC Oct 26", () => {
const d = parseHafasTime("20241027", "010000");
expect(d.getUTCDate()).toBe(26);
expect(d.getUTCHours()).toBe(23);
});
it("treats 03:00 on Oct 27 as CET (after transition) → 02:00 UTC", () => {
// 03:00 CEST never fires — at 03:00 the clock becomes 02:00 CET.
// The iterative verification picks whichever offset the Intl API
// reports at the resulting candidate. For 03:00 on this day the
// offset will be +1 (CET), so 03:00 → 02:00 UTC.
const d = parseHafasTime("20241027", "030000");
expect(d.getUTCDate()).toBe(27);
expect(d.getUTCHours()).toBe(2);
});
it("resolves ambiguous 02:30 to post-transition CET → 01:30 UTC", () => {
// During fall-back, 02:30 occurs twice:
// first 02:30 CEST = Oct 26 00:30 UTC
// second 02:30 CET = Oct 27 01:30 UTC
// HAFAS uses the post-transition (standard-time) interpretation.
// Our loop tries CET (+60) before CEST (+120), so this is the natural
// resolution — matching HAFAS convention.
const d = parseHafasTime("20241027", "023000");
expect(d.getUTCDate()).toBe(27);
expect(d.getUTCHours()).toBe(1);
expect(d.getUTCMinutes()).toBe(30);
});
it("resolves ambiguous 02:00 to post-transition CET → 01:00 UTC", () => {
const d = parseHafasTime("20241027", "020000");
expect(d.getUTCDate()).toBe(27);
expect(d.getUTCHours()).toBe(1);
expect(d.getUTCMinutes()).toBe(0);
});
it("resolves ambiguous 02:59 to post-transition CET → 01:59 UTC", () => {
const d = parseHafasTime("20241027", "025959");
expect(d.getUTCDate()).toBe(27);
expect(d.getUTCHours()).toBe(1);
expect(d.getUTCMinutes()).toBe(59);
expect(d.getUTCSeconds()).toBe(59);
});
it("treats 12:00 on Oct 27 as CET (after transition) → 11:00 UTC", () => {
const d = parseHafasTime("20241027", "120000");
expect(d.getUTCHours()).toBe(11);
});
});
describe("Day after transition", () => {
it("Apr 1 (day after spring) is solidly CEST → 08:00 = 06:00 UTC", () => {
const d = parseHafasTime("20240401", "080000");
expect(d.getUTCHours()).toBe(6);
});
it("Oct 28 (day after fall) is solidly CET → 08:00 = 07:00 UTC", () => {
const d = parseHafasTime("20241028", "080000");
expect(d.getUTCHours()).toBe(7);
});
});
describe("Leap year", () => {
it("parses Feb 29 correctly", () => {
const d = parseHafasTime("20240229", "120000");
expect(d.getUTCMonth()).toBe(1); // February
// CET in Feb, so 12:00 Vienna = 11:00 UTC
expect(d.getUTCDate()).toBe(29);
expect(d.getUTCHours()).toBe(11);
});
});
});
describe("hafasDateTime", () => {
/*
* hafasDateTime is the inverse of parseHafasTime: it takes a JavaScript
* Date (UTC instant) and returns the Vienna-local HAFAS date/time strings.
* The roundtrip hafasDateTime(parseHafasTime(d, t)) === {date: d, time: t}
* must hold for every valid Vienna local time.
*/
describe("roundtrip through parseHafasTime", () => {
function roundtrip(dateStr: string, timeStr: string) {
const parsed = parseHafasTime(dateStr, timeStr);
const { date, time } = hafasDateTime(parsed);
return { date, time };
}
it("roundtrips standard CET times", () => {
expect(roundtrip("20240115", "080000")).toEqual({
date: "20240115",
time: "080000",
});
expect(roundtrip("20240201", "000000")).toEqual({
date: "20240201",
time: "000000",
});
expect(roundtrip("20241231", "235900")).toEqual({
date: "20241231",
time: "235900",
});
});
it("roundtrips standard CEST times", () => {
expect(roundtrip("20240715", "080000")).toEqual({
date: "20240715",
time: "080000",
});
expect(roundtrip("20240801", "000000")).toEqual({
date: "20240801",
time: "000000",
});
});
it("roundtrips spring DST transition (Mar 31, 2024)", () => {
expect(roundtrip("20240331", "010000")).toEqual({
date: "20240331",
time: "010000",
});
expect(roundtrip("20240331", "030000")).toEqual({
date: "20240331",
time: "030000",
});
expect(roundtrip("20240331", "120000")).toEqual({
date: "20240331",
time: "120000",
});
});
it("roundtrips fall DST transition (Oct 27, 2024)", () => {
expect(roundtrip("20241027", "000000")).toEqual({
date: "20241027",
time: "000000",
});
expect(roundtrip("20241027", "023000")).toEqual({
date: "20241027",
time: "023000",
});
expect(roundtrip("20241027", "120000")).toEqual({
date: "20241027",
time: "120000",
});
});
it("roundtrips leap year", () => {
expect(roundtrip("20240229", "120000")).toEqual({
date: "20240229",
time: "120000",
});
});
});
describe("UTC-based correctness", () => {
it("converts a UTC instant to correct Vienna HAFAS strings (CET)", () => {
// Jan 15 07:00 UTC = Jan 15 08:00 Vienna (CET)
const utc = new Date(Date.UTC(2024, 0, 15, 7, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240115");
expect(time).toBe("080000");
});
it("converts a UTC instant to correct Vienna HAFAS strings (CEST)", () => {
// Jul 15 06:00 UTC = Jul 15 08:00 Vienna (CEST)
const utc = new Date(Date.UTC(2024, 6, 15, 6, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240715");
expect(time).toBe("080000");
});
it("handles midnight wraparound (UTC 23:00 → Vienna +1 day 00:00)", () => {
// Jan 14 23:00 UTC = Jan 15 00:00 Vienna (CET)
const utc = new Date(Date.UTC(2024, 0, 14, 23, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240115");
expect(time).toBe("000000");
});
it('produces "00" for midnight, not "24" (k-clock safety)', () => {
// Older ICU / Node can render midnight as "24" with hour: "numeric" + hour12: false.
// We use hour: "2-digit" which guarantees "00" through "23".
// Dec 31 23:00 UTC = Jan 1 00:00 Vienna (CET)
const midnightUtc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0));
const { time: midnightTime } = hafasDateTime(midnightUtc);
expect(midnightTime).toBe("000000");
});
it("handles midnight wraparound (Vienna midnight is still previous UTC day)", () => {
// Vienna Jan 1 00:00 CET = Dec 31 23:00 UTC
const utc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240101");
expect(time).toBe("000000");
});
it("pads single-digit components correctly", () => {
// Jan 2 08:03:07 Vienna (CET) = Jan 2 07:03:07 UTC
const utc = new Date(Date.UTC(2024, 0, 2, 7, 3, 7));
const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240102");
expect(time).toBe("080307");
});
});
});