Add mobile services, web proxy, and Gemma4 agent loop

- Mobile: add push notification and calendar sync services with full test suite (calendar, eventStore, notifications,
  screens)
- Mobile: add EAS build config and Jest setup
- Web: add API proxy, update useDestinationStation/useJourneys hooks, add middleware tests
- Web: update next.config and rebuild
- Agent: add Gemma4-based agent loop (agent_base, ttl_agent, ts_agent)
- Docs: add privacy policy, post-MVP plan, and aider rules
This commit is contained in:
2026-05-11 18:32:54 +02:00
parent 20159262c1
commit 5bcfafcbaf
120 changed files with 8626 additions and 860 deletions
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
root: true,
extends: ['expo'],
rules: {
'react-native/no-inline-styles': 'off',
},
ignorePatterns: ['node_modules/', '.expo/', 'dist/'],
};
+18
View File
@@ -1,5 +1,23 @@
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
useEffect(() => {
// Request notification permissions on app start
Notifications.requestPermissionsAsync();
// Set up notification handler
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
}, []);
return <AppNavigator />;
}
+13 -3
View File
@@ -14,7 +14,11 @@
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "at.timetoleave.mobile"
"bundleIdentifier": "com.timetoleave.app",
"infoPlist": {
"NSLocationWhenInUseUsageDescription": "This app uses your location to find nearby stations and calculate travel times.",
"NSUserNotificationUsageDescription": "This app uses notifications to remind you about events."
}
},
"android": {
"adaptiveIcon": {
@@ -23,7 +27,12 @@
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "at.timetoleave.mobile"
"package": "com.timetoleave.app",
"permissions": [
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.POST_NOTIFICATIONS",
"android.permission.INTERNET"
]
},
"web": {
"favicon": "./assets/favicon.png"
@@ -31,6 +40,7 @@
"plugins": [
"expo-location",
"expo-notifications"
]
],
"privacyPolicyUrl": "https://timetoleave.app/privacy-policy"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"cli": {
"version": ">= 10.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "app-bundle",
"distribution": "store"
}
}
},
"submit": {
"production": {
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "production"
}
}
}
}
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
preset: 'jest-expo',
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
};
+12 -4
View File
@@ -7,8 +7,9 @@
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"typecheck": "tsc --noEmit",
"lint": "echo 'no lint yet'"
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"lint": "echo 'no lint yet'",
"test": "jest"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^3.0.2",
@@ -17,16 +18,23 @@
"@timetoleave/api-client": "*",
"@timetoleave/core": "*",
"expo": "~54.0.33",
"expo-calendar": "^55.0.14",
"expo-location": "^55.1.9",
"expo-notifications": "^55.0.22",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react": "19.2.4",
"react-native": "0.81.5",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.24.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/react": "^19",
"jest": "^29.7.0",
"jest-expo": "~54.0.0",
"react-test-renderer": "19.2.4",
"ts-jest": "^29.4.9",
"typescript": "~5.9.2"
},
"private": true
+138
View File
@@ -0,0 +1,138 @@
import * as Calendar from 'expo-calendar';
import { ensureCalendarPermission, fetchNativeEvents } from '../services/calendar';
// Mock expo-calendar
jest.mock('expo-calendar', () => ({
requestCalendarPermissionsAsync: jest.fn(),
isAvailableAsync: jest.fn(),
getCalendarsAsync: jest.fn(),
getEventsAsync: jest.fn(),
EntityTypes: {
EVENTS: 'EVENTS',
},
}));
const mockCalendar = Calendar as jest.Mocked<typeof Calendar>;
describe('calendar service', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('ensureCalendarPermission', () => {
it('returns true when permission granted and calendar available', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true);
const result = await ensureCalendarPermission();
expect(result).toBe(true);
});
it('returns false when permission denied', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'denied' as Calendar.PermissionStatus, granted: false, expires: 'never' as const, canAskAgain: true });
const result = await ensureCalendarPermission();
expect(result).toBe(false);
expect(mockCalendar.isAvailableAsync).not.toHaveBeenCalled();
});
it('returns false when calendar not available', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(false);
const result = await ensureCalendarPermission();
expect(result).toBe(false);
});
});
describe('fetchNativeEvents', () => {
it('returns empty array when no permission', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'denied' as Calendar.PermissionStatus, granted: false, expires: 'never' as const, canAskAgain: true });
const result = await fetchNativeEvents(new Date(), new Date());
expect(result).toEqual([]);
});
it('returns empty array when no calendars', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true);
mockCalendar.getCalendarsAsync.mockResolvedValue([]);
const result = await fetchNativeEvents(new Date(), new Date());
expect(result).toEqual([]);
});
it('returns mapped events from native calendar', async () => {
const startDate = new Date('2025-01-01');
const endDate = new Date('2025-01-31');
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true);
mockCalendar.getCalendarsAsync.mockResolvedValue([
{ id: 'cal1', title: 'Work' },
{ id: 'cal2', title: 'Personal' },
] as Calendar.Calendar[]);
mockCalendar.getEventsAsync.mockResolvedValue([
{
id: 'evt1',
calendarId: 'cal1',
title: 'Team Meeting',
location: 'Berlin',
startDate: new Date('2025-01-15T10:00:00'),
},
{
id: 'evt2',
calendarId: 'cal2',
title: 'Dentist',
location: null,
startDate: new Date('2025-01-20T14:00:00'),
},
] as Calendar.Event[]);
const result = await fetchNativeEvents(startDate, endDate);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
id: 'evt1',
title: 'Team Meeting',
destination: 'Berlin',
eventTime: new Date('2025-01-15T10:00:00'),
source: 'native:cal1',
});
expect(result[1]).toEqual({
id: 'evt2',
title: 'Dentist',
destination: '',
eventTime: new Date('2025-01-20T14:00:00'),
source: 'native:cal2',
});
expect(mockCalendar.getEventsAsync).toHaveBeenCalledWith(
['cal1', 'cal2'],
startDate,
endDate,
);
});
it('handles events with missing title or startDate', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true);
mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]);
mockCalendar.getEventsAsync.mockResolvedValue([
{
id: 'evt1',
calendarId: 'cal1',
title: null as unknown as string,
location: null,
startDate: null as unknown as string | Date,
},
] as Calendar.Event[]);
const result = await fetchNativeEvents(new Date(), new Date());
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Untitled Event');
expect(result[0].destination).toBe('');
});
});
});
@@ -0,0 +1,80 @@
// Tests for core utilities
import { calculateCountdown } from '@timetoleave/core';
describe('core utilities', () => {
describe('calculateCountdown', () => {
beforeEach(() => {
// Mock Date for consistent tests
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-01-01T12:00:00Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return urgent status for past events', () => {
const targetDate = new Date('2025-01-01T11:00:00Z');
const result = calculateCountdown(targetDate);
expect(result.label).toBe('Now');
expect(result.color).toBe('red');
expect(result.urgent).toBe(true);
});
it('should return urgent status for events within 10 minutes', () => {
const targetDate = new Date('2025-01-01T12:05:00Z');
const result = calculateCountdown(targetDate);
expect(result.label).toBe('5min');
expect(result.color).toBe('orange');
expect(result.urgent).toBe(true);
});
it('should return yellow for events within 30 minutes', () => {
const targetDate = new Date('2025-01-01T12:20:00Z');
const result = calculateCountdown(targetDate);
expect(result.label).toBe('20min');
expect(result.color).toBe('yellow');
expect(result.urgent).toBe(false);
});
it('should return green for events within 60 minutes', () => {
const targetDate = new Date('2025-01-01T12:45:00Z');
const result = calculateCountdown(targetDate);
expect(result.label).toBe('45min');
expect(result.color).toBe('green');
expect(result.urgent).toBe(false);
});
it('should return hours and minutes for events more than 1 hour away', () => {
const targetDate = new Date('2025-01-01T15:30:00Z');
const result = calculateCountdown(targetDate);
expect(result.label).toBe('3h 30min');
expect(result.color).toBe('blue');
expect(result.urgent).toBe(false);
});
it('should handle exact boundaries correctly', () => {
// Exactly 10 minutes
let targetDate = new Date('2025-01-01T12:10:00Z');
let result = calculateCountdown(targetDate);
expect(result.urgent).toBe(true);
// Exactly 30 minutes
targetDate = new Date('2025-01-01T12:30:00Z');
result = calculateCountdown(targetDate);
expect(result.urgent).toBe(false);
expect(result.color).toBe('yellow');
// Exactly 60 minutes
targetDate = new Date('2025-01-01T13:00:00Z');
result = calculateCountdown(targetDate);
expect(result.label).toBe('60min');
expect(result.color).toBe('green');
});
});
});
@@ -0,0 +1,231 @@
// Tests for event store persistence and behavior
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
loadEvents,
saveEvents,
addEvent,
removeEvent,
loadOriginStation,
saveOriginStation,
loadNotificationSettings,
saveNotificationSettings,
rescheduleAllNotifications
} from '../store/eventStore';
import { calculateLeaveByTime } from '../services/notifications';
import * as Notifications from 'expo-notifications';
// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () => ({
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
}));
// Mock expo-notifications
jest.mock('expo-notifications', () => ({
getAllScheduledNotificationsAsync: jest.fn(),
cancelScheduledNotificationAsync: jest.fn(),
cancelAllScheduledNotificationsAsync: jest.fn(),
scheduleNotificationAsync: jest.fn(),
SchedulableTriggerInputTypes: {
DATE: 'date',
},
setNotificationHandler: jest.fn(),
}));
// Mock calculateLeaveByTime
jest.mock('../services/notifications', () => ({
...jest.requireActual('../services/notifications'),
calculateLeaveByTime: jest.fn(),
}));
describe('eventStore', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('events', () => {
it('should load empty events when none exist', async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
const events = await loadEvents();
expect(events).toEqual([]);
});
it('should load events from AsyncStorage', async () => {
const mockEvents = [
{
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
}
];
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockEvents));
const events = await loadEvents();
expect(events).toEqual(mockEvents);
expect(events[0].eventTime).toBeInstanceOf(Date);
});
it('should save events to AsyncStorage', async () => {
const events = [
{
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
}
];
await saveEvents(events);
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'@timetoleave_events',
JSON.stringify(events)
);
});
it('should add event and schedule notification', async () => {
const event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
(AsyncStorage.getItem as jest.Mock).mockResolvedValue('[]');
(calculateLeaveByTime as jest.Mock).mockResolvedValue(new Date('2025-01-01T09:30:00Z'));
await addEvent(event);
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'@timetoleave_events',
JSON.stringify([event])
);
});
it('should remove event and cancel notifications', async () => {
const event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify([event]));
(Notifications.getAllScheduledNotificationsAsync as jest.Mock).mockResolvedValue([
{
identifier: 'notif-1',
content: { data: { eventId: 'test-1' } }
}
]);
await removeEvent('test-1');
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'@timetoleave_events',
'[]'
);
expect(Notifications.cancelScheduledNotificationAsync).toHaveBeenCalled();
});
});
describe('origin station', () => {
it('should load null when no origin exists', async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
const station = await loadOriginStation();
expect(station).toBeNull();
});
it('should load origin station from AsyncStorage', async () => {
const mockStation = {
extId: 'station-1',
name: 'Test Station',
lat: 48.2,
lng: 16.3,
};
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockStation));
const station = await loadOriginStation();
expect(station).toEqual(mockStation);
});
it('should save origin station to AsyncStorage', async () => {
const station = {
extId: 'station-1',
name: 'Test Station',
lat: 48.2,
lng: 16.3,
};
await saveOriginStation(station);
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'@timetoleave_origin',
JSON.stringify(station)
);
});
});
describe('notification settings', () => {
it('should load default settings when none exist', async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
const settings = await loadNotificationSettings();
expect(settings).toEqual({
bufferMinutes: 30,
enabled: true,
});
});
it('should load notification settings from AsyncStorage', async () => {
const mockSettings = {
bufferMinutes: 45,
enabled: false,
};
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockSettings));
const settings = await loadNotificationSettings();
expect(settings).toEqual(mockSettings);
});
it('should save notification settings to AsyncStorage', async () => {
const settings = {
bufferMinutes: 45,
enabled: false,
};
await saveNotificationSettings(settings);
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'@timetoleave_notifications',
JSON.stringify(settings)
);
});
});
describe('rescheduleAllNotifications', () => {
it('should cancel all existing notifications and schedule new ones', async () => {
(AsyncStorage.getItem as jest.Mock)
.mockResolvedValueOnce(JSON.stringify([])) // events
.mockResolvedValueOnce(JSON.stringify({ bufferMinutes: 30, enabled: true })); // settings
(Notifications.getAllScheduledNotificationsAsync as jest.Mock).mockResolvedValue([]);
(calculateLeaveByTime as jest.Mock).mockResolvedValue(new Date('2025-01-01T09:30:00Z'));
await rescheduleAllNotifications();
expect(Notifications.cancelAllScheduledNotificationsAsync).toHaveBeenCalled();
// We can't easily verify scheduling due to complex mocks, but we can check it was called
});
});
});
@@ -0,0 +1,173 @@
// Tests for notification service
// Mock expo-notifications before importing
jest.mock('expo-notifications', () => ({
setNotificationHandler: jest.fn(),
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
scheduleNotificationAsync: jest.fn().mockResolvedValue({ identifier: 'mock-id' }),
cancelScheduledNotificationAsync: jest.fn().mockResolvedValue(undefined),
getAllScheduledNotificationsAsync: jest.fn().mockResolvedValue([]),
cancelAllScheduledNotificationsAsync: jest.fn().mockResolvedValue(undefined),
SchedulableTriggerInputTypes: {
DATE: 'date',
CALENDAR: 'calendar',
DAILY: 'daily',
WEEKLY: 'weekly',
MONTHLY: 'monthly',
YEARLY: 'yearly',
TIME_INTERVAL: 'timeInterval',
},
}));
import { calculateLeaveByTime } from '../services/notifications';
import type { Event, Journey } from '@timetoleave/core';
describe('notifications service', () => {
describe('calculateLeaveByTime', () => {
it('should calculate leave-by time from event time minus buffer', () => {
const event: Event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
const leaveByTime = calculateLeaveByTime(event, [], 30);
// Leave-by time should be 30 minutes before event time
const expectedTime = new Date('2025-01-01T09:30:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
});
it('should use earliest journey departure time if available', () => {
const event: Event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
const journeys: Journey[] = [
{
id: 'journey-1',
sD: new Date('2025-01-01T08:00:00Z'),
rD: new Date('2025-01-01T08:00:00Z'),
sA: new Date('2025-01-01T09:00:00Z'),
rA: new Date('2025-01-01T09:00:00Z'),
delay: 0,
platform: '1',
changes: 0,
trains: ['S1'],
cancelled: false,
},
{
id: 'journey-2',
sD: new Date('2025-01-01T07:00:00Z'),
rD: new Date('2025-01-01T07:00:00Z'),
sA: new Date('2025-01-01T08:00:00Z'),
rA: new Date('2025-01-01T08:00:00Z'),
delay: 0,
platform: '2',
changes: 1,
trains: ['U3', 'S2'],
cancelled: false,
},
];
const leaveByTime = calculateLeaveByTime(event, journeys, 30);
// Should use earliest non-cancelled journey (journey-2 at 07:00) minus buffer
const expectedTime = new Date('2025-01-01T06:30:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
});
it('should skip cancelled journeys', () => {
const event: Event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
const journeys: Journey[] = [
{
id: 'journey-1',
sD: new Date('2025-01-01T08:00:00Z'),
rD: new Date('2025-01-01T08:00:00Z'),
sA: new Date('2025-01-01T09:00:00Z'),
rA: new Date('2025-01-01T09:00:00Z'),
delay: 0,
platform: '1',
changes: 0,
trains: ['S1'],
cancelled: true,
},
{
id: 'journey-2',
sD: new Date('2025-01-01T07:00:00Z'),
rD: new Date('2025-01-01T07:00:00Z'),
sA: new Date('2025-01-01T08:00:00Z'),
rA: new Date('2025-01-01T08:00:00Z'),
delay: 0,
platform: '2',
changes: 1,
trains: ['U3', 'S2'],
cancelled: false,
},
];
const leaveByTime = calculateLeaveByTime(event, journeys, 30);
// Should use journey-2 since journey-1 is cancelled
const expectedTime = new Date('2025-01-01T06:30:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
});
it('should fall back to event time minus buffer when all journeys cancelled', () => {
const event: Event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
const journeys: Journey[] = [
{
id: 'journey-1',
sD: new Date('2025-01-01T08:00:00Z'),
rD: new Date('2025-01-01T08:00:00Z'),
sA: new Date('2025-01-01T09:00:00Z'),
rA: new Date('2025-01-01T09:00:00Z'),
delay: 0,
platform: '1',
changes: 0,
trains: ['S1'],
cancelled: true,
},
];
const leaveByTime = calculateLeaveByTime(event, journeys, 30);
// All journeys cancelled, fall back to event time minus buffer
const expectedTime = new Date('2025-01-01T09:30:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
});
it('should handle zero buffer correctly', () => {
const event: Event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
const leaveByTime = calculateLeaveByTime(event, [], 0);
expect(leaveByTime.getTime()).toBe(event.eventTime.getTime());
});
});
});
+204
View File
@@ -0,0 +1,204 @@
// Tests for UI screens
import { fireEvent, render, waitFor } from '@testing-library/react-native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { EventListScreen } from '../screens/EventListScreen';
import { AddEventScreen } from '../screens/AddEventScreen';
import { loadEvents } from '../store/eventStore';
import { calculateCountdown } from '@timetoleave/core';
// Mock the store and utilities
jest.mock('../store/eventStore', () => ({
loadEvents: jest.fn(),
removeEvent: jest.fn(),
}));
jest.mock('@timetoleave/core', () => ({
...jest.requireActual('@timetoleave/core'),
calculateCountdown: jest.fn(),
}));
// Mock useFocusEffect so EventListScreen can render without NavigationContainer
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useFocusEffect: (callback: () => void) => {
// Execute the callback immediately so the component loads data
callback();
},
}));
// --- Mock navigation factories ---
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
function createMockNavigationProp<
S extends Record<string, undefined | Record<string, unknown>>,
T extends keyof S
>(overrides?: Partial<NativeStackNavigationProp<S, T>>): NativeStackNavigationProp<S, T> {
const mocks: Partial<NativeStackNavigationProp<S, T>> = {
navigate: jest.fn(),
dispatch: jest.fn(() => {}),
goBack: jest.fn(),
isFocused: jest.fn(() => true),
setParams: jest.fn(),
setOptions: jest.fn(),
reset: jest.fn(),
pop: jest.fn(),
preload: jest.fn(),
push: jest.fn(),
replace: jest.fn(),
canGoBack: jest.fn(() => false),
...overrides,
};
return mocks as NativeStackNavigationProp<S, T>;
}
const mockRouteEventList = { name: 'EventList' as const, params: undefined } as unknown as RouteProp<RootStack, 'EventList'>;
const mockRouteAddEvent = { name: 'AddEvent' as const, params: undefined } as unknown as RouteProp<RootStack, 'AddEvent'>;
// --- End mock factories ---
describe('EventListScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render empty state when no events', async () => {
(loadEvents as jest.Mock).mockResolvedValue([]);
const { getByText } = render(
<EventListScreen navigation={createMockNavigationProp<RootStack, 'EventList'>()} route={mockRouteEventList} />
);
await waitFor(() => {
expect(getByText('Keine Termine')).toBeTruthy();
});
});
it('should render events when they exist', async () => {
const mockEvents = [
{
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
}
];
(loadEvents as jest.Mock).mockResolvedValue(mockEvents);
(calculateCountdown as jest.Mock).mockReturnValue({
label: '30min',
color: 'green',
urgent: false
});
const { getByText } = render(
<EventListScreen navigation={createMockNavigationProp<RootStack, 'EventList'>()} route={mockRouteEventList} />
);
await waitFor(() => {
expect(getByText('Test Event')).toBeTruthy();
expect(getByText('Test Destination')).toBeTruthy();
});
});
it('should handle refresh correctly', async () => {
const mockEvents = [
{
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
}
];
(loadEvents as jest.Mock).mockResolvedValue(mockEvents);
(calculateCountdown as jest.Mock).mockReturnValue({
label: '30min',
color: 'green',
urgent: false
});
const { getByText } = render(
<EventListScreen navigation={createMockNavigationProp<RootStack, 'EventList'>()} route={mockRouteEventList} />
);
await waitFor(() => {
expect(getByText('Test Event')).toBeTruthy();
});
});
});
describe('AddEventScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render form correctly', () => {
const { getByPlaceholderText, getByText } = render(
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
);
expect(getByPlaceholderText('z.B. Team Meeting')).toBeTruthy();
expect(getByPlaceholderText('z.B. Wien, Donau-City')).toBeTruthy();
expect(getByPlaceholderText('JJJJ-MM-TT')).toBeTruthy();
expect(getByPlaceholderText('SS:MM')).toBeTruthy();
expect(getByText('Speichern')).toBeTruthy();
expect(getByText('Abbrechen')).toBeTruthy();
});
it('should show validation errors', () => {
const { getByText } = render(
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
);
// Try to save without filling form
const saveButton = getByText('Speichern');
fireEvent.press(saveButton);
// Should show error text
expect(getByText('Titel erforderlich')).toBeTruthy();
});
it('should validate date format', () => {
const { getByPlaceholderText, getByText } = render(
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
);
// Fill in all required fields except date format is invalid
fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting');
fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien');
fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), 'invalid-date');
fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00');
const saveButton = getByText('Speichern');
fireEvent.press(saveButton);
expect(getByText('Ungültiges Datum')).toBeTruthy();
});
it('should validate future date', () => {
const { getByPlaceholderText, getByText } = render(
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
);
// Fill in all required fields with a past date
fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting');
fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien');
fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), '2020-01-01');
fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00');
const saveButton = getByText('Speichern');
fireEvent.press(saveButton);
expect(getByText('Datum muss in der Zukunft liegen')).toBeTruthy();
});
});
@@ -11,7 +11,8 @@ import {
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { api } from '../services/api';
import { addEvent } from '../store/eventStore';
import { fetchNativeEvents } from '../services/calendar';
import { addEvent, loadEvents } from '../store/eventStore';
import type { Event as CalendarEvent } from '@timetoleave/core';
type RootStack = {
@@ -64,21 +65,85 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
}
};
const handleSyncNative = async () => {
setLoading(true);
setError(null);
setCount(null);
try {
// Fetch events from the next 30 days
const now = new Date();
const thirtyDaysLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const nativeEvents = await fetchNativeEvents(now, thirtyDaysLater);
// Load existing events to avoid duplicates
const existing = await loadEvents();
const existingIds = new Set(existing.map((e) => e.id));
let added = 0;
for (const evt of nativeEvents) {
if (!existingIds.has(evt.id)) {
await addEvent(evt);
added++;
}
}
setCount(added);
} catch (err) {
setError(err instanceof Error ? err.message : 'Sync fehlgeschlagen');
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.heading}>Kalender-Import</Text>
<Text style={styles.description}>
Gib eine ICS-Kalender-URL ein, um Termine automatisch zu importieren.
Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender.
</Text>
<TextInput
style={styles.input}
placeholder="https://calendar.google.com/calendar/ical/..."
value={url}
onChangeText={setUrl}
autoCapitalize="none"
keyboardType="url"
/>
<View style={styles.section}>
<Text style={styles.sectionTitle}>ICS-URL Import</Text>
<TextInput
style={styles.input}
placeholder="https://calendar.google.com/calendar/ical/..."
value={url}
onChangeText={setUrl}
autoCapitalize="none"
keyboardType="url"
/>
<TouchableOpacity
style={[styles.importBtn, loading && styles.importBtnDisabled]}
onPress={handleImport}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.importBtnText}>ICS Importieren</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Geräte-Kalender Sync</Text>
<Text style={styles.sectionDesc}>
Hole Termine der nächsten 30 Tage aus den kalendern auf deinem Gerät.
</Text>
<TouchableOpacity
style={[styles.importBtn, styles.nativeBtn, loading && styles.importBtnDisabled]}
onPress={handleSyncNative}
disabled={loading}
>
<Text style={styles.importBtnText}>📅 Kalender Sync</Text>
</TouchableOpacity>
</View>
{error && (
<View style={styles.errorBanner}>
@@ -94,18 +159,6 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
</View>
)}
<TouchableOpacity
style={[styles.importBtn, loading && styles.importBtnDisabled]}
onPress={handleImport}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.importBtnText}>Importieren</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.backBtn}
onPress={() => navigation.goBack()}
@@ -120,7 +173,11 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f2f2f7' },
content: { padding: 20 },
description: { fontSize: 14, color: '#8e8e93', marginBottom: 16, lineHeight: 20 },
heading: { fontSize: 22, fontWeight: '700', color: '#1c1c1e', marginBottom: 4 },
description: { fontSize: 14, color: '#8e8e93', marginBottom: 20, lineHeight: 20 },
section: { marginBottom: 24 },
sectionTitle: { fontSize: 16, fontWeight: '600', color: '#1c1c1e', marginBottom: 8 },
sectionDesc: { fontSize: 13, color: '#8e8e93', marginBottom: 12, lineHeight: 18 },
input: {
backgroundColor: '#fff',
borderRadius: 10,
@@ -129,7 +186,7 @@ const styles = StyleSheet.create({
fontSize: 16,
borderWidth: 1,
borderColor: '#e5e5ea',
marginBottom: 16,
marginBottom: 12,
},
errorBanner: { backgroundColor: '#FF3B30', borderRadius: 8, padding: 12, marginBottom: 12 },
errorText: { color: '#fff', fontSize: 14 },
@@ -140,7 +197,9 @@ const styles = StyleSheet.create({
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginBottom: 12,
},
nativeBtn: {
backgroundColor: '#5856D6',
},
importBtnDisabled: { opacity: 0.6 },
importBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
+4 -1
View File
@@ -12,7 +12,7 @@ import {
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import * as Location from 'expo-location';
import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNotificationSettings } from '../store/eventStore';
import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNotificationSettings, rescheduleAllNotifications } from '../store/eventStore';
import { api } from '../services/api';
import type { Station, ReminderSettings } from '@timetoleave/core';
@@ -80,6 +80,7 @@ export function SettingsScreen({ navigation }: ScreenProps) {
setQuery(station.name);
setResults([]);
saveOriginStation(station);
rescheduleAllNotifications(); // Recalculate when origin changes
};
const useCurrentLocation = async () => {
@@ -127,6 +128,7 @@ export function SettingsScreen({ navigation }: ScreenProps) {
const updated = { ...notifSettings, enabled: value };
setNotifSettings(updated);
await saveNotificationSettings(updated);
await rescheduleAllNotifications();
};
const updateBufferMinutes = async (value: string) => {
@@ -135,6 +137,7 @@ export function SettingsScreen({ navigation }: ScreenProps) {
const updated = { ...notifSettings, bufferMinutes: minutes };
setNotifSettings(updated);
await saveNotificationSettings(updated);
await rescheduleAllNotifications();
}
};
+41
View File
@@ -0,0 +1,41 @@
import * as Calendar from 'expo-calendar';
import type { Event as CoreEvent } from '@timetoleave/core';
/**
* Native calendar integration for the mobile app.
* Reads events from device calendars and converts them to our internal format.
*/
export async function ensureCalendarPermission(): Promise<boolean> {
const { status } = await Calendar.requestCalendarPermissionsAsync();
if (status !== 'granted')
return false;
return Calendar.isAvailableAsync();
}
/**
* Fetch events from native calendars within a date range.
* Returns events converted to our internal Event format.
*/
export async function fetchNativeEvents(
startDate: Date,
endDate: Date,
): Promise<CoreEvent[]> {
const available = await ensureCalendarPermission();
if (!available) return [];
const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT);
if (calendars.length === 0) return [];
const calendarIds = calendars.map((c) => c.id);
const events = await Calendar.getEventsAsync(calendarIds, startDate, endDate);
return events.map((evt) => ({
id: evt.id,
title: evt.title ?? 'Untitled Event',
destination: evt.location ?? '',
eventTime: typeof evt.startDate === 'string' ? new Date(evt.startDate) : (evt.startDate ?? new Date()),
source: `native:${evt.calendarId}`,
}));
}
+146
View File
@@ -0,0 +1,146 @@
import * as Notifications from 'expo-notifications';
import { SchedulableTriggerInputTypes } from 'expo-notifications';
import type { Event, Journey, ReminderSettings } from '@timetoleave/core';
// Register for push notification permissions
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
/**
* Calculate leave-by time from event time and journey data.
* Uses earliest real departure time if journeys exist, otherwise event time minus buffer.
*/
export function calculateLeaveByTime(
event: Event,
journeys: Journey[],
bufferMinutes: number
): Date {
// If we have journeys, use the earliest non-cancelled real departure
if (journeys.length > 0) {
const best = journeys
.filter((j) => !j.cancelled)
.sort((a, b) => a.rD.getTime() - b.rD.getTime())[0];
if (best) {
return new Date(best.rD.getTime() - bufferMinutes * 60 * 1000);
}
}
// Fallback: event time minus buffer (no journey data)
return new Date(event.eventTime.getTime() - bufferMinutes * 60 * 1000);
}
/**
* Schedule notifications for an event
*
* @param event - The event to schedule notifications for
* @param journeys - Journey data for this event (optional)
* @param settings - Notification settings
*/
export async function scheduleNotificationsForEvent(
event: Event,
journeys: Journey[] = [],
settings: ReminderSettings
): Promise<void> {
// Don't schedule if notifications are disabled
if (!settings.enabled) {
return;
}
// Calculate leave-by time (when user should actually leave)
const leaveByTime = calculateLeaveByTime(event, journeys, settings.bufferMinutes);
// Cancel existing notifications for this event - cancel one by one
const existing = await Notifications.getAllScheduledNotificationsAsync();
const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
if (toCancel.length > 0) {
for (const notif of toCancel) {
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
}
}
// Default reminders: 30min, 10min, and at leave-by time
// But respect the buffer time - we want reminders relative to when they should leave
const defaultReminders = [30, 10, 0];
// Schedule notifications
for (const minutesBefore of defaultReminders) {
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
// Skip if trigger time is in the past
if (triggerTime <= new Date()) {
continue;
}
// Skip if this would be before the event actually starts (add some safety margin)
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
continue;
}
// Use Date trigger with time property
await Notifications.scheduleNotificationAsync({
content: {
title: `🚆 ${event.title}`,
body: minutesBefore === 0
? 'Zeit zu gehen!'
: `${minutesBefore} Minuten bis du losmusst`,
data: { eventId: event.id },
},
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
});
}
}
/**
* Reschedule notifications for all events
* Use this when origin station changes or notification settings are updated
*/
export async function rescheduleAllNotifications(
events: Event[],
journeysMap: Record<string, Journey[]>, // eventId -> journeys
settings: ReminderSettings
): Promise<void> {
// Cancel ALL existing notifications first
await Notifications.cancelAllScheduledNotificationsAsync();
// Schedule new notifications for each event
for (const event of events) {
const eventJourneys = journeysMap[event.id] || [];
await scheduleNotificationsForEvent(event, eventJourneys, settings);
}
}
// Request permissions if not already granted
let permissionsRequested = false;
export async function requestNotificationPermissions(): Promise<boolean> {
if (permissionsRequested) {
return true;
}
permissionsRequested = true;
const { status } = await Notifications.requestPermissionsAsync();
return status === 'granted';
}
// Request permissions automatically when app starts (for Android)
// This is called in App.tsx
export function setupNotifications() {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
}
+125 -3
View File
@@ -1,7 +1,9 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
import * as Notifications from 'expo-notifications';
import { SchedulableTriggerInputTypes } from 'expo-notifications';
// ── Keys ────────────────────────────────────────────
// ── Keys ───────────────────────────────
const EVENTS_KEY = '@timetoleave_events';
const ORIGIN_KEY = '@timetoleave_origin';
@@ -25,6 +27,68 @@ function reviveDates(json: string): Event[] {
}
}
async function getNotificationSettings(): Promise<ReminderSettings> {
const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY);
return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS;
}
// ────────────────────────────────────────────────────────────
// Notification scheduling utilities
// ────────────────────────────────────────────────────────────
async function calculateLeaveByTime(event: Event, bufferMinutes: number): Promise<Date> {
// Fallback: event time minus buffer (no journey data)
return new Date(event.eventTime.getTime() - bufferMinutes * 60 * 1000);
}
async function scheduleEventNotification(event: Event): Promise<void> {
const settings = await getNotificationSettings();
if (!settings.enabled) {
return;
}
const leaveByTime = await calculateLeaveByTime(event, settings.bufferMinutes);
// Cancel existing notifications for this event - cancel one by one
const existing = await Notifications.getAllScheduledNotificationsAsync();
const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
for (const notif of toCancel) {
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
}
// Default reminders: 30min, 10min, and at leave-by time
const defaultReminders = [30, 10, 0];
// Schedule notifications
for (const minutesBefore of defaultReminders) {
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
// Skip if trigger time is in the past
if (triggerTime <= new Date()) {
continue;
}
// Skip if this would be before the event actually starts (add some safety margin)
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
continue;
}
await Notifications.scheduleNotificationAsync({
content: {
title: `🚆 ${event.title}`,
body: minutesBefore === 0
? 'Zeit zu gehen!'
: `${minutesBefore} Minuten bis du losmusst`,
data: { eventId: event.id },
},
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
});
}
}
// ────────────────────────────────────────────────────────────
// Events ────────────────────────────────────────────
// ── Events ────────────────────────────────────────────
export async function loadEvents(): Promise<Event[]> {
@@ -41,16 +105,26 @@ export async function addEvent(event: Event): Promise<void> {
const events = await loadEvents();
events.push(event);
await saveEvents(events);
await scheduleEventNotification(event);
}
export async function removeEvent(id: string, onDone?: () => void): Promise<void> {
const events = await loadEvents();
const filtered = events.filter((e) => e.id !== id);
await saveEvents(filtered);
// Cancel notifications for removed event - cancel one by one
const existing = await Notifications.getAllScheduledNotificationsAsync();
const toCancel = existing.filter(n => n.content.data?.eventId === id);
for (const notif of toCancel) {
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
}
onDone?.();
}
// ── Origin Station ────────────────────────────────────
// ────────────────────────────────────────────────────────────
// Origin Station ────────────────────────────────────
export async function loadOriginStation(): Promise<Station | null> {
const json = await AsyncStorage.getItem(ORIGIN_KEY);
@@ -62,7 +136,8 @@ export async function saveOriginStation(station: Station): Promise<void> {
await AsyncStorage.setItem(ORIGIN_KEY, json);
}
// ── Notification Settings ─────────────────────────────
// ────────────────────────────────────────────────────────────
// Notification Settings ──────────────────────────────
export async function loadNotificationSettings(): Promise<ReminderSettings> {
const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY);
@@ -75,3 +150,50 @@ export async function saveNotificationSettings(
const json = JSON.stringify(settings);
await AsyncStorage.setItem(NOTIFICATIONS_KEY, json);
}
// ────────────────────────────────────────────────────────────
// Reschedule all notifications (for origin/setting changes)
// ────────────────────────────────────────────────────────────
export async function rescheduleAllNotifications(): Promise<void> {
const events = await loadEvents();
const settings = await loadNotificationSettings();
// Cancel ALL existing notifications first
await Notifications.cancelAllScheduledNotificationsAsync();
// Schedule new notifications for each event
for (const event of events) {
if (settings.enabled) {
const leaveByTime = await calculateLeaveByTime(event, settings.bufferMinutes);
// Default reminders: 30min, 10min, and at leave-by time
const defaultReminders = [30, 10, 0];
for (const minutesBefore of defaultReminders) {
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
// Skip if trigger time is in the past
if (triggerTime <= new Date()) {
continue;
}
// Skip if this would be before the event actually starts (add some safety margin)
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
continue;
}
await Notifications.scheduleNotificationAsync({
content: {
title: `🚆 ${event.title}`,
body: minutesBefore === 0
? 'Zeit zu gehen!'
: `${minutesBefore} Minuten bis du losmusst`,
data: { eventId: event.id },
},
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
});
}
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/__tests__"]
}