Add CI pipeline, pre-commit hooks, and offline caching
CI / lint-typecheck-test (push) Has been cancelled
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:
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['expo'],
|
||||
rules: {
|
||||
'react-native/no-inline-styles': 'off',
|
||||
},
|
||||
ignorePatterns: ['node_modules/', '.expo/', 'dist/'],
|
||||
};
|
||||
@@ -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/.*)',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user