const API_URL = ''; export interface Task { id: string; user_uuid: string; title: string; description?: string; scheduled_datetime: string; reminder_minutes_before: number; advance_notified: boolean; status: 'pending' | 'notified' | 'completed' | 'cancelled'; created_at: string; updated_at: string; } function getToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem('token'); } function setToken(token: string): void { localStorage.setItem('token', token); } function clearToken(): void { localStorage.removeItem('token'); localStorage.removeItem('refresh_token'); } function getRefreshToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem('refresh_token'); } function setRefreshToken(token: string): void { localStorage.setItem('refresh_token', token); } let refreshPromise: Promise | null = null; async function tryRefreshToken(): Promise { // Deduplicate concurrent refresh attempts if (refreshPromise) return refreshPromise; refreshPromise = (async () => { const refreshToken = getRefreshToken(); if (!refreshToken) return false; try { const resp = await fetch(`${API_URL}/api/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (resp.ok) { const data = await resp.json(); if (data.token) { setToken(data.token); return true; } } // Refresh token is invalid/expired - clear everything clearToken(); return false; } catch { return false; } finally { refreshPromise = null; } })(); return refreshPromise; } async function request( endpoint: string, options: RequestInit = {}, _retried = false, ): Promise { const token = getToken(); const headers: HeadersInit = { 'Content-Type': 'application/json', 'X-Timezone-Offset': String(new Date().getTimezoneOffset()), 'X-Timezone-Name': Intl.DateTimeFormat().resolvedOptions().timeZone, ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers, }; const response = await fetch(`${API_URL}${endpoint}`, { ...options, headers, }); // Auto-refresh on 401 if (response.status === 401 && !_retried) { const refreshed = await tryRefreshToken(); if (refreshed) { return request(endpoint, options, true); } } if (!response.ok) { const body = await response.text(); let errorMsg = 'Request failed'; try { const error = JSON.parse(body); errorMsg = error.error || error.message || body; } catch { errorMsg = body || errorMsg; } throw new Error(errorMsg); } return response.json(); } export const api = { // Auth auth: { login: async (username: string, password: string, trustDevice = false) => { const result = await request<{ token: string; refresh_token?: string }>('/api/login', { method: 'POST', body: JSON.stringify({ username, password, trust_device: trustDevice }), }); setToken(result.token); if (result.refresh_token) { setRefreshToken(result.refresh_token); } return result; }, register: async (username: string, password: string) => { return request<{ success: boolean }>('/api/register', { method: 'POST', body: JSON.stringify({ username, password }), }); }, logout: () => { clearToken(); }, getToken, }, // User user: { get: async (userId: string) => { return request<{ id: string; username: string; created_at: string }>( `/api/user/${userId}`, { method: 'GET' } ); }, getUUID: async (username: string) => { return request<{ id: string }>(`/api/getUserUUID/${username}`, { method: 'GET', }); }, update: async (userId: string, data: Record) => { return request<{ success: boolean }>(`/api/user/${userId}`, { method: 'PUT', body: JSON.stringify(data), }); }, }, // Routines routines: { list: async () => { return request>('/api/routines', { method: 'GET' }); }, get: async (routineId: string) => { return request<{ routine: { id: string; user_uuid: string; name: string; description?: string; icon?: string; }; steps: Array<{ id: string; routine_id: string; name: string; instructions?: string; step_type: string; duration_minutes?: number; media_url?: string; position: number; }>; }>(`/api/routines/${routineId}`, { method: 'GET' }); }, create: async (data: { name: string; description?: string; icon?: string }) => { return request<{ id: string }>('/api/routines', { method: 'POST', body: JSON.stringify(data), }); }, update: async (routineId: string, data: Record) => { return request<{ id: string }>(`/api/routines/${routineId}`, { method: 'PUT', body: JSON.stringify(data), }); }, delete: async (routineId: string) => { return request<{ deleted: boolean }>(`/api/routines/${routineId}`, { method: 'DELETE', }); }, // Steps getSteps: async (routineId: string) => { return request>(`/api/routines/${routineId}/steps`, { method: 'GET' }); }, addStep: async ( routineId: string, data: { name: string; duration_minutes?: number; position?: number } ) => { return request<{ id: string }>(`/api/routines/${routineId}/steps`, { method: 'POST', body: JSON.stringify(data), }); }, updateStep: async ( routineId: string, stepId: string, data: Record ) => { return request<{ id: string }>(`/api/routines/${routineId}/steps/${stepId}`, { method: 'PUT', body: JSON.stringify(data), }); }, deleteStep: async (routineId: string, stepId: string) => { return request<{ deleted: boolean }>( `/api/routines/${routineId}/steps/${stepId}`, { method: 'DELETE' } ); }, reorderSteps: async (routineId: string, stepIds: string[]) => { return request>( `/api/routines/${routineId}/steps/reorder`, { method: 'PUT', body: JSON.stringify({ step_ids: stepIds }), } ); }, // Step extended updateStepInstructions: async ( routineId: string, stepId: string, instructions: string ) => { return request<{ id: string }>( `/api/routines/${routineId}/steps/${stepId}/instructions`, { method: 'PUT', body: JSON.stringify({ instructions }), } ); }, updateStepType: async ( routineId: string, stepId: string, stepType: string ) => { return request<{ id: string }>( `/api/routines/${routineId}/steps/${stepId}/type`, { method: 'PUT', body: JSON.stringify({ step_type: stepType }), } ); }, updateStepMedia: async ( routineId: string, stepId: string, mediaUrl: string ) => { return request<{ id: string }>( `/api/routines/${routineId}/steps/${stepId}/media`, { method: 'PUT', body: JSON.stringify({ media_url: mediaUrl }), } ); }, // Scheduling getSchedule: async (routineId: string) => { return request<{ id: string; routine_id: string; days: string[]; time: string; remind: boolean; }>(`/api/routines/${routineId}/schedule`, { method: 'GET' }); }, setSchedule: async ( routineId: string, data: { days: string[]; time: string; remind?: boolean } ) => { return request<{ id: string }>(`/api/routines/${routineId}/schedule`, { method: 'PUT', body: JSON.stringify(data), }); }, deleteSchedule: async (routineId: string) => { return request<{ deleted: boolean }>( `/api/routines/${routineId}/schedule`, { method: 'DELETE' } ); }, listAllSchedules: async () => { return request>('/api/routines/schedules', { method: 'GET' }); }, // History getHistory: async (routineId: string, days = 7) => { return request>(`/api/routines/${routineId}/history?days=${days}`, { method: 'GET', }); }, // Stats getStats: async (routineId: string, days = 30) => { return request<{ routine_id: string; routine_name: string; period_days: number; total_sessions: number; completed: number; aborted: number; completion_rate_percent: number; avg_duration_minutes: number; total_time_minutes: number; }>(`/api/routines/${routineId}/stats?days=${days}`, { method: 'GET' }); }, getStreak: async (routineId: string) => { return request<{ routine_id: string; routine_name: string; current_streak: number; longest_streak: number; last_completed_date?: string; }>(`/api/routines/${routineId}/streak`, { method: 'GET' }); }, // Tags getTags: async (routineId: string) => { return request>( `/api/routines/${routineId}/tags`, { method: 'GET' } ); }, addTags: async (routineId: string, tagIds: string[]) => { return request>( `/api/routines/${routineId}/tags`, { method: 'POST', body: JSON.stringify({ tag_ids: tagIds }), } ); }, removeTag: async (routineId: string, tagId: string) => { return request<{ removed: boolean }>( `/api/routines/${routineId}/tags/${tagId}`, { method: 'DELETE' } ); }, }, // Sessions sessions: { start: async (routineId: string) => { return request<{ session: { id: string; status: string }; current_step: { id: string; name: string; instructions?: string; step_type: string; duration_minutes?: number; position: number; }; }>(`/api/routines/${routineId}/start`, { method: 'POST' }); }, getActive: async () => { return request<{ session: { id: string; routine_id: string; status: string; current_step_index: number; }; routine: { id: string; name: string; icon?: string }; current_step: { id: string; name: string; instructions?: string; step_type: string; duration_minutes?: number; position: number; } | null; }>('/api/sessions/active', { method: 'GET' }); }, completeStep: async (sessionId: string, stepId: string) => { return request<{ session: { status: string; current_step_index?: number }; next_step: { id: string; name: string; instructions?: string; step_type: string; duration_minutes?: number; position: number; } | null; celebration?: { streak_current: number; streak_longest: number; session_duration_minutes: number; total_completions: number; steps_completed: number; steps_skipped: number; }; }>(`/api/sessions/${sessionId}/complete-step`, { method: 'POST', body: JSON.stringify({ step_id: stepId }), }); }, skipStep: async (sessionId: string, stepId: string) => { return request<{ session: { status: string; current_step_index?: number }; next_step: { id: string; name: string; instructions?: string; step_type: string; duration_minutes?: number; position: number; } | null; celebration?: { streak_current: number; streak_longest: number; session_duration_minutes: number; total_completions: number; steps_completed: number; steps_skipped: number; }; }>(`/api/sessions/${sessionId}/skip-step`, { method: 'POST', body: JSON.stringify({ step_id: stepId }), }); }, pause: async (sessionId: string) => { return request<{ status: string }>(`/api/sessions/${sessionId}/pause`, { method: 'POST', }); }, resume: async (sessionId: string) => { return request<{ status: string }>(`/api/sessions/${sessionId}/resume`, { method: 'POST', }); }, cancel: async (sessionId: string) => { return request<{ status: string }>(`/api/sessions/${sessionId}/cancel`, { method: 'POST', }); }, abort: async (sessionId: string, reason?: string) => { return request<{ status: string; reason: string }>( `/api/sessions/${sessionId}/abort`, { method: 'POST', body: JSON.stringify({ reason }), } ); }, addNote: async ( sessionId: string, stepIndex: number | undefined, note: string ) => { return request<{ id: string }>(`/api/sessions/${sessionId}/note`, { method: 'POST', body: JSON.stringify({ step_index: stepIndex, note }), }); }, setDuration: async (sessionId: string, durationMinutes: number) => { return request<{ id: string }>(`/api/sessions/${sessionId}/duration`, { method: 'PUT', body: JSON.stringify({ actual_duration_minutes: durationMinutes }), }); }, getDetails: async (sessionId: string) => { return request<{ session: { id: string; routine_id: string; status: string; current_step_index: number; }; routine: { id: string; name: string }; steps: Array<{ id: string; name: string; position: number }>; notes: Array<{ id: string; step_index?: number; note: string }>; }>(`/api/sessions/${sessionId}`, { method: 'GET' }); }, }, // Templates templates: { list: async () => { return request>('/api/templates', { method: 'GET' }); }, get: async (templateId: string) => { return request<{ template: { id: string; name: string; description?: string; icon?: string; }; steps: Array<{ id: string; name: string; instructions?: string; step_type: string; duration_minutes?: number; position: number; }>; }>(`/api/templates/${templateId}`, { method: 'GET' }); }, clone: async (templateId: string) => { return request<{ id: string }>(`/api/templates/${templateId}/clone`, { method: 'POST', }); }, }, // Tags tags: { list: async () => { return request>( '/api/tags', { method: 'GET' } ); }, create: async (data: { name: string; color?: string }) => { return request<{ id: string }>('/api/tags', { method: 'POST', body: JSON.stringify(data), }); }, delete: async (tagId: string) => { return request<{ deleted: boolean }>(`/api/tags/${tagId}`, { method: 'DELETE', }); }, }, // Stats stats: { getWeeklySummary: async () => { return request<{ total_completed: number; total_time_minutes: number; routines_started: number; routines: Array<{ routine_id: string; name: string; completed_this_week: number; }>; }>('/api/routines/weekly-summary', { method: 'GET' }); }, getStreaks: async () => { return request>('/api/routines/streaks', { method: 'GET' }); }, }, // Victories victories: { get: async (days = 30) => { return request>(`/api/victories?days=${days}`, { method: 'GET' }); }, }, // Rewards rewards: { getRandom: async (context = 'completion') => { return request<{ reward: { id: string; category: string; content: string; emoji?: string; rarity: string; } | null; }>(`/api/rewards/random?context=${context}`, { method: 'GET' }); }, getHistory: async () => { return request>('/api/rewards/history', { method: 'GET' }); }, }, // Preferences preferences: { get: async () => { return request<{ sound_enabled: boolean; haptic_enabled: boolean; show_launch_screen: boolean; celebration_style: string; }>('/api/preferences', { method: 'GET' }); }, update: async (data: { sound_enabled?: boolean; haptic_enabled?: boolean; show_launch_screen?: boolean; celebration_style?: string; timezone_offset?: number; timezone_name?: string; }) => { return request>('/api/preferences', { method: 'PUT', body: JSON.stringify(data), }); }, }, // Notifications notifications: { getVapidPublicKey: async () => { return request<{ public_key: string }>('/api/notifications/vapid-public-key', { method: 'GET', }); }, subscribe: async (subscription: PushSubscriptionJSON) => { return request<{ subscribed: boolean }>('/api/notifications/subscribe', { method: 'POST', body: JSON.stringify(subscription), }); }, unsubscribe: async (endpoint: string) => { return request<{ unsubscribed: boolean }>('/api/notifications/subscribe', { method: 'DELETE', body: JSON.stringify({ endpoint }), }); }, getSettings: async () => { return request<{ discord_user_id: string; discord_enabled: boolean; ntfy_topic: string; ntfy_enabled: boolean; web_push_enabled: boolean; }>('/api/notifications/settings', { method: 'GET' }); }, updateSettings: async (data: { discord_user_id?: string; discord_enabled?: boolean; ntfy_topic?: string; ntfy_enabled?: boolean; }) => { return request<{ updated: boolean }>('/api/notifications/settings', { method: 'PUT', body: JSON.stringify(data), }); }, }, // Adaptive Medications adaptiveMeds: { getSettings: async () => { return request<{ adaptive_timing_enabled: boolean; adaptive_mode: string; presence_tracking_enabled: boolean; nagging_enabled: boolean; nag_interval_minutes: number; max_nag_count: number; quiet_hours_start: string | null; quiet_hours_end: string | null; }>('/api/adaptive-meds/settings', { method: 'GET' }); }, updateSettings: async (data: { adaptive_timing_enabled?: boolean; adaptive_mode?: string; presence_tracking_enabled?: boolean; nagging_enabled?: boolean; nag_interval_minutes?: number; max_nag_count?: number; quiet_hours_start?: string | null; quiet_hours_end?: string | null; }) => { return request<{ success: boolean }>('/api/adaptive-meds/settings', { method: 'PUT', body: JSON.stringify(data), }); }, getPresence: async () => { return request<{ is_online: boolean; last_online_at: string | null; typical_wake_time: string | null; }>('/api/adaptive-meds/presence', { method: 'GET' }); }, getSchedule: async () => { return request>('/api/adaptive-meds/schedule', { method: 'GET' }); }, }, // Snitch System snitch: { getSettings: async () => { return request<{ snitch_enabled: boolean; trigger_after_nags: number; trigger_after_missed_doses: number; max_snitches_per_day: number; require_consent: boolean; consent_given: boolean; snitch_cooldown_hours: number; }>('/api/snitch/settings', { method: 'GET' }); }, updateSettings: async (data: { snitch_enabled?: boolean; trigger_after_nags?: number; trigger_after_missed_doses?: number; max_snitches_per_day?: number; require_consent?: boolean; consent_given?: boolean; snitch_cooldown_hours?: number; }) => { return request<{ success: boolean }>('/api/snitch/settings', { method: 'PUT', body: JSON.stringify(data), }); }, giveConsent: async (consent_given: boolean) => { return request<{ success: boolean; consent_given: boolean }>('/api/snitch/consent', { method: 'POST', body: JSON.stringify({ consent_given }), }); }, getContacts: async () => { return request>('/api/snitch/contacts', { method: 'GET' }); }, addContact: async (data: { contact_name: string; contact_type: string; contact_value: string; priority?: number; notify_all?: boolean; is_active?: boolean; }) => { return request<{ success: boolean; contact_id: string }>('/api/snitch/contacts', { method: 'POST', body: JSON.stringify(data), }); }, updateContact: async (contactId: string, data: { contact_name?: string; contact_type?: string; contact_value?: string; priority?: number; notify_all?: boolean; is_active?: boolean; }) => { return request<{ success: boolean }>(`/api/snitch/contacts/${contactId}`, { method: 'PUT', body: JSON.stringify(data), }); }, deleteContact: async (contactId: string) => { return request<{ success: boolean }>(`/api/snitch/contacts/${contactId}`, { method: 'DELETE', }); }, getHistory: async (days?: number) => { return request>(`/api/snitch/history?days=${days || 7}`, { method: 'GET' }); }, test: async () => { return request<{ success: boolean; message: string }>('/api/snitch/test', { method: 'POST', }); }, }, // Medications medications: { list: async () => { return request>('/api/medications', { method: 'GET' }); }, get: async (medId: string) => { return request<{ id: string; name: string; dosage: string; unit: string; frequency: string; times: string[]; notes?: string; active: boolean; quantity_remaining?: number; refill_date?: string; }>(`/api/medications/${medId}`, { method: 'GET' }); }, create: async (data: { name: string; dosage: string; unit: string; frequency: string; times?: string[]; days_of_week?: string[]; interval_days?: number; start_date?: string; notes?: string; }) => { return request<{ id: string }>('/api/medications', { method: 'POST', body: JSON.stringify(data), }); }, update: async (medId: string, data: Record) => { return request<{ id: string }>(`/api/medications/${medId}`, { method: 'PUT', body: JSON.stringify(data), }); }, delete: async (medId: string) => { return request<{ deleted: boolean }>(`/api/medications/${medId}`, { method: 'DELETE', }); }, take: async (medId: string, scheduledTime?: string, notes?: string) => { return request<{ id: string }>(`/api/medications/${medId}/take`, { method: 'POST', body: JSON.stringify({ scheduled_time: scheduledTime, notes }), }); }, skip: async (medId: string, scheduledTime?: string, reason?: string) => { return request<{ id: string }>(`/api/medications/${medId}/skip`, { method: 'POST', body: JSON.stringify({ scheduled_time: scheduledTime, reason }), }); }, snooze: async (medId: string, minutes = 15) => { return request<{ snoozed_until_minutes: number }>( `/api/medications/${medId}/snooze`, { method: 'POST', body: JSON.stringify({ minutes }), } ); }, getLog: async (medId: string, days = 30) => { return request>(`/api/medications/${medId}/log?days=${days}`, { method: 'GET' }); }, getToday: async () => { return request>('/api/medications/today', { method: 'GET' }); }, getAdherence: async (days = 30) => { return request>(`/api/medications/adherence?days=${days}`, { method: 'GET' }); }, getRefillsDue: async (daysAhead = 7) => { return request>(`/api/medications/refills-due?days_ahead=${daysAhead}`, { method: 'GET', }); }, setRefill: async ( medId: string, data: { quantity_remaining?: number; refill_date?: string; pharmacy_notes?: string; } ) => { return request<{ id: string }>(`/api/medications/${medId}/refill`, { method: 'PUT', body: JSON.stringify(data), }); }, }, tasks: { list: (status = 'pending') => request(`/api/tasks?status=${status}`, { method: 'GET' }), create: (data: { title: string; description?: string; scheduled_datetime: string; reminder_minutes_before?: number }) => request('/api/tasks', { method: 'POST', body: JSON.stringify(data) }), update: (id: string, data: Partial>) => request(`/api/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), delete: (id: string) => request<{ success: boolean }>(`/api/tasks/${id}`, { method: 'DELETE' }), }, ai: { generateSteps: (goal: string) => request<{ steps: { name: string; duration_minutes: number }[] }>( '/api/ai/generate-steps', { method: 'POST', body: JSON.stringify({ goal }) } ), }, }; export default api;