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
+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('');
});
});
});