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:
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['expo'],
|
||||
rules: {
|
||||
'react-native/no-inline-styles': 'off',
|
||||
},
|
||||
ignorePatterns: ['node_modules/', '.expo/', 'dist/'],
|
||||
};
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
preset: 'jest-expo',
|
||||
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/__tests__"]
|
||||
}
|
||||
Reference in New Issue
Block a user