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);
}