import { useEffect, useMemo, useState } from 'react'; import flockPalLandingArt from './assets/flockpal-landing-art.png'; import defaultBirdPhoto from './assets/yoda-default.png'; import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference'; type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; type HouseholdBillingPlan = Exclude; type BillingInterval = 'monthly' | 'yearly'; type WorkspaceType = 'standard' | 'rescue'; type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer'; type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none'; type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected'; type IntegrationTokenScope = 'read_only' | 'read_write'; type BirdGender = 'unknown' | 'male' | 'female'; type Bird = { id: string; workspaceId?: number; name: string; tagId: string | null; species: string; gender: BirdGender; dateOfBirth: string | null; gotchaDay: string | null; chartColor: string; photoDataUrl: string | null; notifyOnDob: boolean; notifyOnGotchaDay: boolean; memorializedAt: string | null; memorializedOn: string | null; memorialNote: string | null; notifyOnMemorialDay: boolean; createdAt: string; latestWeightGrams: number | null; latestRecordedOn: string | null; }; type WeightRecord = { id: string; birdId: string; weightGrams: number; recordedOn: string; notes: string | null; }; type VetVisit = { id: string; birdId: string; visitedOn: string; clinicName: string; reason: string; notes: string | null; }; type Medication = { id: string; birdId: string; name: string; dosage: string; frequency: MedicationFrequency; doseSchedule: MedicationDoseScheduleItem[]; route: string | null; startDate: string; endDate: string | null; notes: string | null; }; type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed'; type MedicationDoseScheduleItem = { key: string; label: string; time: string; }; type MedicationAdministration = { id: string; medicationId: string; birdId: string; administeredOn: string; administrationSlot: string; status: 'administered' | 'missed'; notes: string | null; createdAt: string; }; type Workspace = { id: number; name: string; workspaceType: WorkspaceType; billingEmail: string | null; billingPlan: BillingPlan; billingInterval: BillingInterval; subscriptionStatus: SubscriptionStatus; stripeCustomerId: string | null; stripeSubscriptionId: string | null; rescueVerificationStatus: RescueVerificationStatus; createdAt: string; updatedAt: string; }; type WorkspaceMember = { id: string; workspaceId: number; userId?: string | null; inviteEmail?: string; name: string; email?: string; role: WorkspaceRole; acceptedAt?: string | null; createdAt: string; }; type WorkspaceSummary = { membership: WorkspaceMember; workspace: Workspace; }; type AuthProvider = { providerKey: 'google' | 'microsoft' | 'apple'; displayName: string; enabled: boolean; }; type AuthUser = { id: string; email: string; name: string; createdAt: string; }; type AuthSessionPayload = { user: AuthUser; activeWorkspace: Workspace; activeMembership: WorkspaceMember; workspaces: WorkspaceSummary[]; isAdmin: boolean; providers: AuthProvider[]; }; type AdminSummary = { totalBirds: number; memorializedBirds: number; totalUsers: number; totalWorkspaces: number; rescueWorkspaces: number; rescueBirds: number; pendingRescues: number; dailyUsers: number; }; type AdminRescueWorkspace = { workspace: Workspace; ownerEmail: string | null; birdCount: number; memberCount: number; }; type IntegrationTokenSummary = { id: string; userId: string; workspaceId: number; name: string; tokenPrefix: string; scope: IntegrationTokenScope; lastUsedAt: string | null; expiresAt: string | null; revokedAt: string | null; createdAt: string; }; type IntegrationTokenFormState = { name: string; scope: IntegrationTokenScope; expiresInDays: string; }; type BirdFormState = { name: string; tagId: string; species: string; gender: BirdGender; dateOfBirth: string; gotchaDay: string; chartColor: string; photoDataUrl: string; notifyOnDob: boolean; notifyOnGotchaDay: boolean; }; type MemorializeBirdFormState = { memorializedOn: string; memorialNote: string; notifyOnMemorialDay: boolean; }; type RescueOnboardingFormState = { name: string; city: string; state: string; ein: string; website: string; }; type WorkspaceFormState = { name: string; workspaceType: WorkspaceType; billingEmail: string; billingPlan: HouseholdBillingPlan; billingInterval: BillingInterval; rescueOnboarding: RescueOnboardingFormState; }; type WorkspaceMemberFormState = { name: string; email: string; role: WorkspaceRole; }; type WorkspaceCreateFormState = { name: string; workspaceType: WorkspaceType; billingEmail: string; billingPlan: HouseholdBillingPlan; billingInterval: BillingInterval; rescueOnboarding: RescueOnboardingFormState; }; type AuthFormState = { name: string; email: string; }; type LostBirdReportFormState = { tagId: string; finderEmail: string; foundLocation: string; message: string; }; type AuthNotice = { message: string; previewUrl?: string | null; }; type BillingNotice = { kind: 'success' | 'info' | 'error'; message: string; }; type BulkWeightRowState = { weightGrams: string; }; type BirdWeightAssessment = | { status: 'no_match'; reference: null; } | { status: 'no_weight'; reference: ParrotWeightReference; } | { status: 'reference_only'; reference: Extract; } | { status: 'within' | 'below' | 'above'; reference: Extract; varianceGrams: number; }; type OutOfRangeBirdWeightAssessment = { status: 'below' | 'above'; reference: Extract; varianceGrams: number; }; type WeightDropAlert = { bird: Bird; previousWeight: WeightRecord; latestWeight: WeightRecord; dropPercent: number; }; type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit'; type DismissedAlertMap = Record; type PhotoCropState = { sourceDataUrl: string; fileName: string; naturalWidth: number; naturalHeight: number; zoom: number; offsetX: number; offsetY: number; }; type PhotoDragState = { pointerId: number; startX: number; startY: number; startOffsetX: number; startOffsetY: number; }; type AppPage = 'overview' | 'flock' | 'settings' | 'admin'; type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; const sessionTokenStorageKey = 'flockpal_auth_token'; const dismissedAlertsStorageKey = 'flockpal_dismissed_alerts'; const emptyBirdForm: BirdFormState = { name: '', tagId: '', species: '', gender: 'unknown', dateOfBirth: '', gotchaDay: '', chartColor: '#cb3a35', photoDataUrl: '', notifyOnDob: false, notifyOnGotchaDay: false, }; const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({ memorializedOn: new Date().toISOString().slice(0, 10), memorialNote: '', notifyOnMemorialDay: false, }); const emptyRescueOnboardingForm = (): RescueOnboardingFormState => ({ name: '', city: '', state: '', ein: '', website: '', }); const emptyWorkspaceForm: WorkspaceFormState = { name: 'My Flock', workspaceType: 'standard', billingEmail: '', billingPlan: 'household_basic', billingInterval: 'monthly', rescueOnboarding: emptyRescueOnboardingForm(), }; const emptyWorkspaceMemberForm: WorkspaceMemberFormState = { name: '', email: '', role: 'caregiver', }; const emptyWorkspaceCreateForm: WorkspaceCreateFormState = { name: '', workspaceType: 'standard', billingEmail: '', billingPlan: 'household_basic', billingInterval: 'monthly', rescueOnboarding: emptyRescueOnboardingForm(), }; const emptyAuthForm: AuthFormState = { name: '', email: '', }; const emptyLostBirdReportForm: LostBirdReportFormState = { tagId: '', finderEmail: '', foundLocation: '', message: '', }; const emptyIntegrationTokenForm: IntegrationTokenFormState = { name: '', scope: 'read_write', expiresInDays: '', }; const defaultAuthProviders: AuthProvider[] = [ { providerKey: 'google', displayName: 'Google', enabled: false }, { providerKey: 'microsoft', displayName: 'Microsoft', enabled: false }, { providerKey: 'apple', displayName: 'Apple', enabled: false }, ]; const ProviderIcon = ({ providerKey }: { providerKey: AuthProvider['providerKey'] }) => { if (providerKey === 'google') { return ( ); } if (providerKey === 'microsoft') { return ( ); } return ( ); }; const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right) => left.name.localeCompare(right.name)); const toBirdForm = (bird: Bird): BirdFormState => ({ name: bird.name, tagId: bird.tagId ?? '', species: bird.species, gender: bird.gender, dateOfBirth: bird.dateOfBirth ?? '', gotchaDay: bird.gotchaDay ?? '', chartColor: bird.chartColor, photoDataUrl: bird.photoDataUrl ?? '', notifyOnDob: bird.notifyOnDob, notifyOnGotchaDay: bird.notifyOnGotchaDay, }); const formatDate = (value: string | null) => { if (!value) { return 'Not set'; } return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', }).format(new Date(`${value}T00:00:00`)); }; const formatShortDate = (value: string | null) => { if (!value) { return 'No data yet'; } return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', }).format(new Date(`${value}T00:00:00`)); }; const getBirdGenderLabel = (bird: Pick) => { if (bird.gender === 'female') { return 'Female'; } if (bird.gender === 'male') { return 'Male'; } return 'Unknown'; }; const getBirdGenderSymbol = (bird: Pick) => { if (bird.gender === 'female') { return '♀'; } if (bird.gender === 'male') { return '♂'; } return '?'; }; const formatDateTime = (value: string | null) => { if (!value) { return 'Never'; } return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', }).format(new Date(value)); }; const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`; const parseDateValue = (value: string) => new Date(`${value}T00:00:00`); const daysBetweenDates = (startDate: string, endDate: string) => Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000); const addYearsToDate = (date: Date, years: number) => { const nextDate = new Date(date); nextDate.setFullYear(nextDate.getFullYear() + years); return nextDate; }; const OVERVIEW_WINDOW_DAYS = 30; const OVERVIEW_HISTORY_DAYS = 425; const OVERVIEW_WIDTH = 520; const OVERVIEW_HEIGHT = 220; const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 }; const PHOTO_MAX_BYTES = 900_000; const PHOTO_EXPORT_SIZES = [720, 600, 480]; const PHOTO_EXPORT_QUALITIES = [0.9, 0.82, 0.74, 0.66]; const PHOTO_PREVIEW_SIZE = 112; const MEMBER_CHART_WIDTH = 520; const MEMBER_CHART_HEIGHT = 180; const MEMBER_CHART_PADDING = { top: 16, right: 18, bottom: 34, left: 52 }; const medicationFrequencyOptions: { value: MedicationFrequency; label: string; doseSchedule: MedicationDoseScheduleItem[] }[] = [ { value: 'once_daily', label: 'Once daily', doseSchedule: [{ key: 'dose-1', label: 'Morning', time: '08:00' }] }, { value: 'twice_daily', label: 'Twice daily', doseSchedule: [ { key: 'dose-1', label: 'Morning', time: '08:00' }, { key: 'dose-2', label: 'Evening', time: '20:00' }, ], }, { value: 'every_8_hours', label: 'Every 8 hours', doseSchedule: [ { key: 'dose-1', label: 'Morning', time: '06:00' }, { key: 'dose-2', label: 'Afternoon', time: '14:00' }, { key: 'dose-3', label: 'Night', time: '22:00' }, ], }, { value: 'every_6_hours', label: 'Every 6 hours', doseSchedule: [ { key: 'dose-1', label: 'Early morning', time: '06:00' }, { key: 'dose-2', label: 'Midday', time: '12:00' }, { key: 'dose-3', label: 'Evening', time: '18:00' }, { key: 'dose-4', label: 'Night', time: '00:00' }, ], }, { value: 'as_needed', label: 'As needed', doseSchedule: [{ key: 'dose-1', label: 'As needed', time: '' }] }, ]; const readJsonSafely = async (response: Response): Promise => { const contentType = response.headers.get('content-type') ?? ''; if (!contentType.includes('application/json')) { return null; } try { return (await response.json()) as T; } catch { return null; } }; const readErrorMessage = async (response: Response, fallback: string) => { const json = await readJsonSafely<{ error?: string }>(response); if (json?.error) { return json.error; } const text = await response.text(); const trimmed = text.trim(); if (!trimmed) { return fallback; } if (trimmed.startsWith(' { const nextHeaders = new Headers(headers); if (token) { nextHeaders.set('Authorization', `Bearer ${token}`); } return nextHeaders; }; const apiFetch = (path: string, token?: string, init?: RequestInit) => fetch(`${apiBaseUrl}${path}`, { ...init, cache: 'no-store', headers: createApiHeaders(token, init?.headers), }); const persistSessionToken = (token: string) => { window.localStorage.setItem(sessionTokenStorageKey, token); }; const clearSessionToken = () => { window.localStorage.removeItem(sessionTokenStorageKey); }; const readStoredSessionToken = () => window.localStorage.getItem(sessionTokenStorageKey) ?? ''; const readDismissedAlerts = (): DismissedAlertMap => { const storedDismissals = window.localStorage.getItem(dismissedAlertsStorageKey); if (!storedDismissals) { return {}; } try { const parsed = JSON.parse(storedDismissals); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as DismissedAlertMap) : {}; } catch { return {}; } }; const persistDismissedAlerts = (dismissedAlerts: DismissedAlertMap) => { window.localStorage.setItem(dismissedAlertsStorageKey, JSON.stringify(dismissedAlerts)); }; const buildDismissedAlertKey = ( workspaceId: number | undefined, birdId: string, alertType: DismissibleAlertType, signature: string, ) => `${workspaceId ?? 'workspace'}:${birdId}:${alertType}:${signature}`; const oauthStartUrl = (providerKey: AuthProvider['providerKey']) => { const url = new URL(`${apiBaseUrl}/auth/oauth/${providerKey}/start`, window.location.origin); url.searchParams.set('redirectTo', window.location.href); return url.toString(); }; const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan => billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw'; const formatBillingIntervalName = (billingInterval: BillingInterval) => (billingInterval === 'yearly' ? 'Annual' : 'Monthly'); const formatBillingPlanName = (billingPlan: BillingPlan) => { if (billingPlan === 'rescue_free') { return 'Rescue Free'; } if (billingPlan === 'household_basic') { return 'Conure'; } if (billingPlan === 'household_plus') { return 'Indian Ringneck'; } return 'Macaw'; }; const formatBillingPlanCapacity = (billingPlan: BillingPlan) => { if (billingPlan === 'rescue_free') { return 'No billing is applied to rescue flocks.'; } if (billingPlan === 'household_basic') { return 'Permits up to 4 birds in the flock.'; } if (billingPlan === 'household_plus') { return 'Permits 5 to 10 birds in the flock.'; } return 'Permits 11 or more birds in the flock.'; }; const formatBillingPlanDropdownLabel = (billingPlan: HouseholdBillingPlan) => { if (billingPlan === 'household_basic') { return 'Conure (4 birds)'; } if (billingPlan === 'household_plus') { return 'Indian Ringneck (10 birds)'; } return 'Macaw (11+)'; }; const householdPlanPrices: Record> = { household_basic: { monthly: '$4.99/month', yearly: '$50/year', }, household_plus: { monthly: '$8.99/month', yearly: '$90/year', }, household_macaw: { monthly: '$15.99/month', yearly: '$160/year', }, }; const formatBillingIntervalDropdownLabel = (billingPlan: HouseholdBillingPlan, billingInterval: BillingInterval) => `${formatBillingIntervalName(billingInterval)} (${householdPlanPrices[billingPlan][billingInterval]})`; const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => { if (billingPlan === 'household_basic') { return '4'; } if (billingPlan === 'household_plus') { return '10'; } if (billingPlan === 'household_macaw') { return '11+'; } return null; }; const formatSubscriptionStatus = (status: SubscriptionStatus) => { if (status === 'trialing') { return 'Trialing'; } if (status === 'past_due') { return 'Past due'; } if (status === 'canceled') { return 'Canceled'; } if (status === 'unpaid') { return 'Unpaid'; } if (status === 'none') { return 'No subscription'; } return 'Active'; }; const subscriptionAllowsFlockWrites = (status: SubscriptionStatus) => status === 'active' || status === 'trialing'; const formatFlockAccessStatus = (status: SubscriptionStatus) => (subscriptionAllowsFlockWrites(status) ? formatSubscriptionStatus(status) : 'Read-only'); const formatFlockAccessDescription = (status: SubscriptionStatus) => subscriptionAllowsFlockWrites(status) ? 'This flock is writable while the subscription is active.' : `This flock is read-only until billing is restored. Current subscription status: ${formatSubscriptionStatus(status)}.`; const formatRescueVerificationStatus = (status: RescueVerificationStatus) => { if (status === 'approved') { return 'Active'; } if (status === 'rejected') { return 'Rejected'; } if (status === 'not_required') { return 'Not required'; } return 'Pending verification'; }; const formatWorkspaceRole = (role: WorkspaceRole) => { if (role === 'owner') { return 'Owner'; } if (role === 'assistant') { return 'Assistant'; } if (role === 'caregiver') { return 'Caregiver'; } return 'Viewer'; }; const readFileAsDataUrl = async (file: File) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); reader.onerror = () => reject(new Error('Unable to read that photo.')); reader.readAsDataURL(file); }); const loadImageElement = async (sourceDataUrl: string) => new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error('Unable to prepare that photo for cropping.')); image.src = sourceDataUrl; }); const blobToDataUrl = async (blob: Blob) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); reader.onerror = () => reject(new Error('Unable to finalize that cropped photo.')); reader.readAsDataURL(blob); }); const canvasToBlob = async (canvas: HTMLCanvasElement, quality: number) => new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (!blob) { reject(new Error('Unable to export that cropped photo.')); return; } resolve(blob); }, 'image/webp', quality, ); }); const exportCroppedPhoto = async (cropState: PhotoCropState) => { const image = await loadImageElement(cropState.sourceDataUrl); const shortestSide = Math.min(cropState.naturalWidth, cropState.naturalHeight); const cropSize = shortestSide / cropState.zoom; const maxOffsetX = Math.max(0, (cropState.naturalWidth - cropSize) / 2); const maxOffsetY = Math.max(0, (cropState.naturalHeight - cropSize) / 2); const sourceX = Math.min( cropState.naturalWidth - cropSize, Math.max(0, (cropState.naturalWidth - cropSize) / 2 + (cropState.offsetX / 100) * maxOffsetX), ); const sourceY = Math.min( cropState.naturalHeight - cropSize, Math.max(0, (cropState.naturalHeight - cropSize) / 2 + (cropState.offsetY / 100) * maxOffsetY), ); let bestBlob: Blob | null = null; for (const size of PHOTO_EXPORT_SIZES) { const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const context = canvas.getContext('2d'); if (!context) { throw new Error('Unable to prepare the crop tool in this browser.'); } context.imageSmoothingEnabled = true; context.imageSmoothingQuality = 'high'; context.drawImage(image, sourceX, sourceY, cropSize, cropSize, 0, 0, size, size); for (const quality of PHOTO_EXPORT_QUALITIES) { const blob = await canvasToBlob(canvas, quality); if (!bestBlob || blob.size < bestBlob.size) { bestBlob = blob; } if (blob.size <= PHOTO_MAX_BYTES) { return blobToDataUrl(blob); } } } if (!bestBlob) { throw new Error('Unable to export that cropped photo.'); } if (bestBlob.size > PHOTO_MAX_BYTES) { throw new Error('That photo is still too large after cropping. Try zooming in a little more.'); } return blobToDataUrl(bestBlob); }; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const getPhotoCropMetrics = (cropState: PhotoCropState, frameSize = PHOTO_PREVIEW_SIZE) => { const shortestSide = Math.min(cropState.naturalWidth, cropState.naturalHeight); const cropSize = shortestSide / cropState.zoom; const scale = frameSize / cropSize; const displayWidth = cropState.naturalWidth * scale; const displayHeight = cropState.naturalHeight * scale; const maxOffsetX = Math.max(0, (cropState.naturalWidth - cropSize) / 2); const maxOffsetY = Math.max(0, (cropState.naturalHeight - cropSize) / 2); const left = (frameSize - displayWidth) / 2 - (cropState.offsetX / 100) * maxOffsetX * scale; const top = (frameSize - displayHeight) / 2 - (cropState.offsetY / 100) * maxOffsetY * scale; return { left, top, displayWidth, displayHeight, scale, maxOffsetX, maxOffsetY, }; }; const chartPath = (points: WeightRecord[], width = 520, height = 180) => { if (!points.length) { return ''; } const weights = points.map((point) => point.weightGrams); const min = Math.min(...weights); const max = Math.max(...weights); const spread = Math.max(max - min, 1); return points .map((point, index) => { const x = (index / Math.max(points.length - 1, 1)) * width; const y = height - ((point.weightGrams - min) / spread) * (height - 24) - 12; return `${index === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`; }) .join(' '); }; const chartDots = (points: WeightRecord[], width = 520, height = 180) => { if (!points.length) { return []; } const weights = points.map((point) => point.weightGrams); const min = Math.min(...weights); const max = Math.max(...weights); const spread = Math.max(max - min, 1); return points.map((point, index) => ({ id: point.id, x: (index / Math.max(points.length - 1, 1)) * width, y: height - ((point.weightGrams - min) / spread) * (height - 24) - 12, label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`, })); }; const buildOverviewSeries = ( points: WeightRecord[], minWeight: number, maxWeight: number, startDate: Date, endDate: Date, dateOffsetYears = 0, ) => { const innerWidth = OVERVIEW_WIDTH - OVERVIEW_PADDING.left - OVERVIEW_PADDING.right; const innerHeight = OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom; const weightSpread = Math.max(maxWeight - minWeight, 1); const startMs = startDate.getTime(); const endMs = endDate.getTime(); const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000); return points.map((point) => { const pointTime = addYearsToDate(parseDateValue(point.recordedOn), dateOffsetYears).getTime(); const x = OVERVIEW_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth; const y = OVERVIEW_PADDING.top + (1 - (point.weightGrams - minWeight) / weightSpread) * innerHeight; return { id: point.id, x, y, label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`, }; }); }; const toOverviewPath = (points: { x: number; y: number }[]) => points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' '); const buildMemberSeries = ( points: WeightRecord[], minWeight: number, maxWeight: number, startDate: Date, endDate: Date, dateOffsetYears = 0, ) => { const innerWidth = MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.left - MEMBER_CHART_PADDING.right; const innerHeight = MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.top - MEMBER_CHART_PADDING.bottom; const startMs = startDate.getTime(); const endMs = endDate.getTime(); const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000); const weightSpread = Math.max(maxWeight - minWeight, 1); return points.map((entry) => { const pointTime = addYearsToDate(parseDateValue(entry.recordedOn), dateOffsetYears).getTime(); const x = MEMBER_CHART_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth; const y = MEMBER_CHART_PADDING.top + (1 - (entry.weightGrams - minWeight) / weightSpread) * innerHeight; return { id: entry.id, x, y, label: `${entry.weightGrams.toFixed(1)} g on ${formatShortDate(entry.recordedOn)}`, }; }); }; const getDefaultMedicationDoseSchedule = (frequency: MedicationFrequency) => (medicationFrequencyOptions.find((option) => option.value === frequency)?.doseSchedule ?? medicationFrequencyOptions[0].doseSchedule).map((slot) => ({ ...slot, })); const formatMedicationFrequency = (frequency: MedicationFrequency | string) => medicationFrequencyOptions.find((option) => option.value === frequency)?.label ?? frequency; const normalizeMedicationFrequency = (frequency: MedicationFrequency | string): MedicationFrequency => { if (medicationFrequencyOptions.some((option) => option.value === frequency)) { return frequency as MedicationFrequency; } const normalizedFrequency = frequency.toLowerCase(); if (normalizedFrequency.includes('12') || normalizedFrequency.includes('twice') || normalizedFrequency.includes('bid')) { return 'twice_daily'; } if (normalizedFrequency.includes('8') || normalizedFrequency.includes('three') || normalizedFrequency.includes('tid')) { return 'every_8_hours'; } if (normalizedFrequency.includes('6') || normalizedFrequency.includes('four') || normalizedFrequency.includes('qid')) { return 'every_6_hours'; } if (normalizedFrequency.includes('needed') || normalizedFrequency.includes('prn')) { return 'as_needed'; } return 'once_daily'; }; const formatDoseTime = (time: string) => { if (!time) { return ''; } const [hourValue, minuteValue] = time.split(':').map(Number); const date = new Date(); date.setHours(hourValue, minuteValue, 0, 0); return new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' }).format(date); }; const assessBirdWeight = (bird: Bird): BirdWeightAssessment => { const reference = findParrotWeightReference(bird.species); if (!reference) { return { status: 'no_match', reference: null, }; } if (bird.latestWeightGrams === null) { return { status: 'no_weight', reference, }; } if (reference.kind === 'approximate') { return { status: 'reference_only', reference, }; } if (bird.latestWeightGrams < reference.minGrams) { return { status: 'below', reference, varianceGrams: reference.minGrams - bird.latestWeightGrams, }; } if (bird.latestWeightGrams > reference.maxGrams) { return { status: 'above', reference, varianceGrams: bird.latestWeightGrams - reference.maxGrams, }; } return { status: 'within', reference, varianceGrams: 0, }; }; function App() { const [activePage, setActivePage] = useState('overview'); const [authToken, setAuthToken] = useState(''); const [authSession, setAuthSession] = useState(null); const [authProviders, setAuthProviders] = useState([]); const [authForm, setAuthForm] = useState(emptyAuthForm); const [authNotice, setAuthNotice] = useState(null); const [billingNotice, setBillingNotice] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [authSubmitting, setAuthSubmitting] = useState(false); const [lostBirdReportForm, setLostBirdReportForm] = useState(emptyLostBirdReportForm); const [lostBirdReportNotice, setLostBirdReportNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null); const [lostBirdReportSubmitting, setLostBirdReportSubmitting] = useState(false); const [workspace, setWorkspace] = useState(null); const [activeMembership, setActiveMembership] = useState(null); const [workspaceMembers, setWorkspaceMembers] = useState([]); const [integrationTokens, setIntegrationTokens] = useState([]); const [adminSummary, setAdminSummary] = useState(null); const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState([]); const [birds, setBirds] = useState([]); const [memorializedBirds, setMemorializedBirds] = useState([]); const [selectedBirdId, setSelectedBirdId] = useState(''); const [editingBirdId, setEditingBirdId] = useState(''); const [weights, setWeights] = useState([]); const [vetVisits, setVetVisits] = useState([]); const [medications, setMedications] = useState([]); const [medicationAdministrations, setMedicationAdministrations] = useState([]); const [allBirdWeights, setAllBirdWeights] = useState>({}); const [allBirdVetVisits, setAllBirdVetVisits] = useState>({}); const [dismissedAlerts, setDismissedAlerts] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [workspaceForm, setWorkspaceForm] = useState(emptyWorkspaceForm); const [workspaceMemberForm, setWorkspaceMemberForm] = useState(emptyWorkspaceMemberForm); const [workspaceCreateForm, setWorkspaceCreateForm] = useState(emptyWorkspaceCreateForm); const [integrationTokenForm, setIntegrationTokenForm] = useState(emptyIntegrationTokenForm); const [birdForm, setBirdForm] = useState(emptyBirdForm); const [memorializeBirdForm, setMemorializeBirdForm] = useState(emptyMemorializeBirdForm); const [birdPhotoName, setBirdPhotoName] = useState(''); const [photoCrop, setPhotoCrop] = useState(null); const [photoDrag, setPhotoDrag] = useState(null); const [applyingPhotoCrop, setApplyingPhotoCrop] = useState(false); const [savingBird, setSavingBird] = useState(false); const [savingWorkspace, setSavingWorkspace] = useState(false); const [cancelingRescueRequest, setCancelingRescueRequest] = useState(false); const [billingRedirecting, setBillingRedirecting] = useState(false); const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false); const [deletingWorkspace, setDeletingWorkspace] = useState(false); const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState(null); const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState(null); const [showWeightAlertModal, setShowWeightAlertModal] = useState(false); const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false); const [bulkWeightOpen, setBulkWeightOpen] = useState(false); const [savingBulkWeights, setSavingBulkWeights] = useState(false); const [bulkWeightDate, setBulkWeightDate] = useState(new Date().toISOString().slice(0, 10)); const [bulkWeightRows, setBulkWeightRows] = useState>({}); const [weightForm, setWeightForm] = useState({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '', }); const [vetVisitForm, setVetVisitForm] = useState({ visitedOn: new Date().toISOString().slice(0, 10), clinicName: '', reason: '', notes: '', }); const [medicationForm, setMedicationForm] = useState({ name: '', dosage: '', frequency: 'once_daily' as MedicationFrequency, doseSchedule: getDefaultMedicationDoseSchedule('once_daily'), route: '', startDate: new Date().toISOString().slice(0, 10), endDate: '', notes: '', }); const [flockTransferForm, setFlockTransferForm] = useState({ birdId: '', destinationOwnerEmail: '', }); const [transferringBird, setTransferringBird] = useState(false); const [transferError, setTransferError] = useState(''); const [transferNotice, setTransferNotice] = useState<{ message: string; previewUrl?: string | null; } | null>(null); const [deletingBird, setDeletingBird] = useState(false); const [memorializingBird, setMemorializingBird] = useState(false); const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState(''); const [editingVetVisitId, setEditingVetVisitId] = useState(''); const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); const [editingMedicationId, setEditingMedicationId] = useState(''); const [deletingMedicationId, setDeletingMedicationId] = useState(''); const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState(''); const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState(''); const [expandedSettingsSection, setExpandedSettingsSection] = useState(null); const selectedBird = useMemo( () => birds.find((bird) => bird.id === selectedBirdId) ?? null, [birds, selectedBirdId], ); const editingBird = useMemo( () => birds.find((bird) => bird.id === editingBirdId) ?? null, [birds, editingBirdId], ); useEffect(() => { setDismissedAlerts(readDismissedAlerts()); }, [workspace?.id]); const overviewWindowStartDate = useMemo(() => { const startDate = new Date(); startDate.setHours(0, 0, 0, 0); startDate.setDate(startDate.getDate() - (OVERVIEW_WINDOW_DAYS - 1)); return startDate; }, []); const birdsWithRecentWeights = useMemo( () => birds.filter((bird) => (allBirdWeights[bird.id] ?? []).some((entry) => parseDateValue(entry.recordedOn) >= overviewWindowStartDate), ), [allBirdWeights, birds, overviewWindowStartDate], ); const showFlockDetailColumn = bulkWeightOpen || Boolean(selectedBird); const missingFirstWeightCount = useMemo( () => birds.filter((bird) => bird.latestWeightGrams === null).length, [birds], ); const birdWeightAssessments = useMemo( () => Object.fromEntries( birds.map((bird) => [ bird.id, assessBirdWeight(bird), ]), ) as Record, [birds], ); const getWeightRangeAlertSignature = (bird: Bird, assessment: OutOfRangeBirdWeightAssessment) => `${assessment.status}:${bird.latestRecordedOn ?? 'none'}:${bird.latestWeightGrams ?? 'none'}:${assessment.reference.minGrams}-${assessment.reference.maxGrams}`; const getWeightDropAlertSignature = (alert: WeightDropAlert) => `${alert.previousWeight.id}:${alert.previousWeight.recordedOn}:${alert.previousWeight.weightGrams}:${alert.latestWeight.id}:${alert.latestWeight.recordedOn}:${alert.latestWeight.weightGrams}`; const getVetVisitAlertSignature = (birdId: string) => { const latestVisit = allBirdVetVisits[birdId]?.[0] ?? null; return latestVisit ? `${latestVisit.id}:${latestVisit.visitedOn}` : 'no-vet-visit'; }; const isAlertDismissed = (birdId: string, alertType: DismissibleAlertType, signature: string) => Boolean(dismissedAlerts[buildDismissedAlertKey(workspace?.id, birdId, alertType, signature)]); const dismissAlert = (birdId: string, alertType: DismissibleAlertType, signature: string) => { const alertKey = buildDismissedAlertKey(workspace?.id, birdId, alertType, signature); setDismissedAlerts((current) => { const next = { ...current, [alertKey]: true, }; persistDismissedAlerts(next); return next; }); }; const selectedBirdTrendCopy = useMemo(() => { const visibleWeights = weights.filter((entry) => parseDateValue(entry.recordedOn) >= overviewWindowStartDate); if (visibleWeights.length < 2) { return 'Needs a few more entries before trend detection.'; } const first = visibleWeights[0].weightGrams; const last = visibleWeights[visibleWeights.length - 1].weightGrams; const delta = last - first; if (Math.abs(delta) < 1) { return 'Weight has been steady over the current 30-day window.'; } return delta > 0 ? `Weight is up ${delta.toFixed(1)} g over the current 30-day window.` : `Weight is down ${Math.abs(delta).toFixed(1)} g over the current 30-day window.`; }, [overviewWindowStartDate, weights]); const activeOutOfRangeBirds = useMemo( () => birds .map((bird) => { const assessment = birdWeightAssessments[bird.id]; if (!assessment || (assessment.status !== 'below' && assessment.status !== 'above')) { return null; } return { bird, assessment: assessment as OutOfRangeBirdWeightAssessment, }; }) .filter((item): item is { bird: Bird; assessment: OutOfRangeBirdWeightAssessment } => item !== null), [birdWeightAssessments, birds], ); const outOfRangeBirds = useMemo( () => activeOutOfRangeBirds.filter( ({ bird, assessment }) => !isAlertDismissed(bird.id, 'weight-range', getWeightRangeAlertSignature(bird, assessment)), ), [activeOutOfRangeBirds, dismissedAlerts, workspace?.id], ); const activeWeightDropAlerts = useMemo( () => birds .map((bird) => { const birdWeights = [...(allBirdWeights[bird.id] ?? [])].sort( (firstEntry, secondEntry) => parseDateValue(firstEntry.recordedOn).getTime() - parseDateValue(secondEntry.recordedOn).getTime(), ); if (birdWeights.length < 2) { return null; } const latestWeight = birdWeights[birdWeights.length - 1]; const previousWeight = birdWeights[birdWeights.length - 2]; if (previousWeight.weightGrams <= 0 || daysBetweenDates(previousWeight.recordedOn, latestWeight.recordedOn) > 2) { return null; } const dropPercent = ((previousWeight.weightGrams - latestWeight.weightGrams) / previousWeight.weightGrams) * 100; if (dropPercent < 5 || dropPercent > 10) { return null; } return { bird, previousWeight, latestWeight, dropPercent, }; }) .filter((alert): alert is WeightDropAlert => alert !== null), [allBirdWeights, birds], ); const weightDropAlerts = useMemo( () => activeWeightDropAlerts.filter((alert) => !isAlertDismissed(alert.bird.id, 'weight-drop', getWeightDropAlertSignature(alert))), [activeWeightDropAlerts, dismissedAlerts, workspace?.id], ); const totalWeightAlerts = outOfRangeBirds.length + weightDropAlerts.length; const vetVisitOverviewLoaded = birds.length > 0 && birds.every((bird) => Object.prototype.hasOwnProperty.call(allBirdVetVisits, bird.id)); const activeVetVisitDueBirds = useMemo(() => { if (!vetVisitOverviewLoaded) { return []; } const cutoffDate = new Date(); cutoffDate.setHours(0, 0, 0, 0); cutoffDate.setDate(cutoffDate.getDate() - 364); return birds.filter((bird) => { const visits = allBirdVetVisits[bird.id] ?? []; return !visits.some((visit) => parseDateValue(visit.visitedOn) >= cutoffDate); }); }, [allBirdVetVisits, birds, vetVisitOverviewLoaded]); const vetVisitDueBirds = useMemo( () => activeVetVisitDueBirds.filter( (bird) => !isAlertDismissed(bird.id, 'vet-visit', getVetVisitAlertSignature(bird.id)), ), [activeVetVisitDueBirds, allBirdVetVisits, dismissedAlerts, workspace?.id], ); const vetVisitDueNames = vetVisitDueBirds.slice(0, 3).map((bird) => bird.name).join(', '); const vetVisitDueOverflowCount = Math.max(vetVisitDueBirds.length - 3, 0); const vetVisitDueBirdIds = useMemo(() => new Set(vetVisitDueBirds.map((bird) => bird.id)), [vetVisitDueBirds]); const activeMedications = useMemo( () => medications.filter((medication) => !medication.endDate || parseDateValue(medication.endDate) >= parseDateValue(new Date().toISOString().slice(0, 10))), [medications], ); const pastMedications = useMemo( () => medications.filter((medication) => medication.endDate && parseDateValue(medication.endDate) < parseDateValue(new Date().toISOString().slice(0, 10))), [medications], ); const filteredSpeciesOptions = useMemo(() => { const query = birdForm.species.trim().toLowerCase(); if (!query) { return parrotSpeciesOptions.slice(0, 12); } return parrotSpeciesOptions .filter((speciesOption) => speciesOption.toLowerCase().includes(query)) .slice(0, 12); }, [birdForm.species]); const selectedBirdChart = useMemo(() => { const endDate = new Date(); endDate.setHours(0, 0, 0, 0); const startDate = new Date(endDate); startDate.setDate(startDate.getDate() - (OVERVIEW_WINDOW_DAYS - 1)); const historicalStartDate = addYearsToDate(startDate, -1); const historicalEndDate = addYearsToDate(endDate, -1); const visibleWeights = weights.filter((entry) => { const recordedOn = parseDateValue(entry.recordedOn); return recordedOn >= startDate && recordedOn <= endDate; }); const historicalWeights = weights.filter((entry) => { const recordedOn = parseDateValue(entry.recordedOn); return recordedOn >= historicalStartDate && recordedOn <= historicalEndDate; }); if (!visibleWeights.length) { return { points: [] as { id: string; x: number; y: number; label: string }[], historicalPoints: [] as { id: string; x: number; y: number; label: string }[], path: '', historicalPath: '', isFlat: false, historicalIsFlat: false, yTicks: [] as { label: string; y: number }[], xTicks: [ { label: formatShortDate(startDate.toISOString().slice(0, 10)), x: MEMBER_CHART_PADDING.left }, { label: formatShortDate(endDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right }, ] as { label: string; x: number }[], visibleCount: 0, historicalCount: historicalWeights.length, }; } const allPlottedWeights = [...visibleWeights, ...historicalWeights]; const rawMinWeight = Math.min(...allPlottedWeights.map((entry) => entry.weightGrams)); const rawMaxWeight = Math.max(...allPlottedWeights.map((entry) => entry.weightGrams)); const isFlat = Math.abs(Math.max(...visibleWeights.map((entry) => entry.weightGrams)) - Math.min(...visibleWeights.map((entry) => entry.weightGrams))) < 0.01; const historicalIsFlat = historicalWeights.length > 1 && Math.abs( Math.max(...historicalWeights.map((entry) => entry.weightGrams)) - Math.min(...historicalWeights.map((entry) => entry.weightGrams)), ) < 0.01; const padding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2); const minWeight = Math.max(0, rawMinWeight - padding); const maxWeight = rawMaxWeight + padding; const innerHeight = MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.top - MEMBER_CHART_PADDING.bottom; const startMs = startDate.getTime(); const endMs = endDate.getTime(); const points = buildMemberSeries(visibleWeights, minWeight, maxWeight, startDate, endDate); const historicalPoints = buildMemberSeries(historicalWeights, minWeight, maxWeight, startDate, endDate, 1); const path = toOverviewPath(points); const historicalPath = toOverviewPath(historicalPoints); const midWeight = minWeight + (maxWeight - minWeight) / 2; const midDate = new Date((startMs + endMs) / 2); return { points, historicalPoints, path, historicalPath, isFlat, historicalIsFlat, yTicks: [ { label: `${maxWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top }, { label: `${midWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top + innerHeight / 2 }, { label: `${minWeight.toFixed(0)} g`, y: MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom }, ], xTicks: [ { label: formatShortDate(startDate.toISOString().slice(0, 10)), x: MEMBER_CHART_PADDING.left }, { label: formatShortDate(midDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH / 2 }, { label: formatShortDate(endDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right }, ], visibleCount: visibleWeights.length, historicalCount: historicalWeights.length, }; }, [weights]); const hasSelectedBirdLine = selectedBirdChart.points.length >= 2 && selectedBirdChart.path.length > 0; const hasSelectedBirdHistoricalLine = selectedBirdChart.historicalPoints.length >= 2 && selectedBirdChart.historicalPath.length > 0; const flockWeeklyTrendItems = useMemo(() => { return birds .map((bird) => { const birdWeights = allBirdWeights[bird.id] ?? []; if (!birdWeights.length) { return null; } const latestWeight = birdWeights[birdWeights.length - 1]; const weekStart = new Date(`${latestWeight.recordedOn}T00:00:00`); weekStart.setDate(weekStart.getDate() - 7); const weeklyWeights = birdWeights.filter( (entry) => new Date(`${entry.recordedOn}T00:00:00`) >= weekStart, ); if (weeklyWeights.length < 2) { return null; } const startingWeight = weeklyWeights[0].weightGrams; const percentChange = startingWeight === 0 ? 0 : ((latestWeight.weightGrams - startingWeight) / startingWeight) * 100; return { id: bird.id, name: bird.name, chartColor: bird.chartColor, formattedChange: `${percentChange >= 0 ? '+' : ''}${percentChange.toFixed(1)}%`, direction: percentChange >= 0 ? 'positive' : 'negative', } as const; }) .filter((trend): trend is NonNullable => trend !== null); }, [allBirdWeights, birds]); const overviewChart = useMemo(() => { const endDate = new Date(); endDate.setHours(0, 0, 0, 0); const startDate = new Date(endDate); startDate.setDate(startDate.getDate() - (OVERVIEW_WINDOW_DAYS - 1)); const historicalStartDate = addYearsToDate(startDate, -1); const historicalEndDate = addYearsToDate(endDate, -1); const plottedBirds = birds .map((bird) => ({ bird, weights: (allBirdWeights[bird.id] ?? []).filter((entry) => { const recordedOn = parseDateValue(entry.recordedOn); return recordedOn >= overviewWindowStartDate && recordedOn <= endDate; }), historicalWeights: (allBirdWeights[bird.id] ?? []).filter((entry) => { const recordedOn = parseDateValue(entry.recordedOn); return recordedOn >= historicalStartDate && recordedOn <= historicalEndDate; }), })) .filter((entry) => entry.weights.length > 0); if (!plottedBirds.length) { return { plottedBirds, series: [], historicalSeries: [], xTicks: [ { label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left }, { label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right }, ], yTicks: [], }; } const allWeights = plottedBirds.flatMap((entry) => [...entry.weights, ...entry.historicalWeights].map((weight) => weight.weightGrams), ); const rawMinWeight = Math.min(...allWeights); const rawMaxWeight = Math.max(...allWeights); const weightPadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2); const minWeight = Math.max(0, rawMinWeight - weightPadding); const maxWeight = rawMaxWeight + weightPadding; const midWeight = minWeight + (maxWeight - minWeight) / 2; return { plottedBirds, series: plottedBirds.map(({ bird, weights: birdWeights }) => ({ bird, points: buildOverviewSeries(birdWeights, minWeight, maxWeight, startDate, endDate), })), historicalSeries: plottedBirds .map(({ bird, historicalWeights }) => ({ bird, points: buildOverviewSeries(historicalWeights, minWeight, maxWeight, startDate, endDate, 1), })) .filter((entry) => entry.points.length > 0), xTicks: [ { label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left }, { label: formatShortDate(new Date((startDate.getTime() + endDate.getTime()) / 2).toISOString().slice(0, 10)), x: OVERVIEW_WIDTH / 2 }, { label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right }, ], yTicks: [ { label: `${maxWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top }, { label: `${midWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top + (OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom) / 2 }, { label: `${minWeight.toFixed(0)} g`, y: OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom }, ], }; }, [allBirdWeights, birds, overviewWindowStartDate]); const overviewHistoricalSeriesCount = overviewChart.historicalSeries.length; const applySession = (session: AuthSessionPayload, token: string) => { setAuthToken(token); setAuthSession(session); setAuthProviders(session.providers); setAuthNotice(null); setBillingNotice(null); setNewIntegrationTokenSecret(''); setWorkspace(session.activeWorkspace); setActiveMembership({ ...session.activeMembership, email: session.activeMembership.inviteEmail, }); setWorkspaceForm({ name: session.activeWorkspace.name, workspaceType: session.activeWorkspace.workspaceType, billingEmail: session.activeWorkspace.billingEmail ?? '', billingPlan: isHouseholdPlan(session.activeWorkspace.billingPlan) ? session.activeWorkspace.billingPlan : 'household_basic', billingInterval: session.activeWorkspace.billingInterval, rescueOnboarding: emptyRescueOnboardingForm(), }); setWorkspaceCreateForm((current) => ({ ...current, billingEmail: current.billingEmail || session.user.email, })); }; const clearAppSession = () => { clearSessionToken(); setAuthToken(''); setAuthSession(null); setWorkspace(null); setActiveMembership(null); setWorkspaceMembers([]); setIntegrationTokens([]); setAdminSummary(null); setAdminRescueWorkspaces([]); setBirds([]); setMemorializedBirds([]); setWeights([]); setVetVisits([]); setMedications([]); setMedicationAdministrations([]); setAllBirdWeights({}); setAllBirdVetVisits({}); setSelectedBirdId(''); setEditingBirdId(''); setWorkspaceForm(emptyWorkspaceForm); setWorkspaceCreateForm(emptyWorkspaceCreateForm); setIntegrationTokenForm(emptyIntegrationTokenForm); setNewIntegrationTokenSecret(''); setAuthNotice(null); setBillingNotice(null); }; const refreshAuthSession = async (token: string) => { const response = await apiFetch('/auth/session', token); if (!response.ok) { if (response.status === 401) { clearAppSession(); } throw new Error(await readErrorMessage(response, 'Unable to refresh your billing status.')); } const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; if (!data.session) { throw new Error('Unable to refresh your billing status.'); } const nextToken = data.token || token; persistSessionToken(nextToken); applySession(data.session, nextToken); return { session: data.session, token: nextToken, }; }; useEffect(() => { const loadProviders = async () => { try { const response = await apiFetch('/auth/providers'); if (!response.ok) { setAuthProviders(defaultAuthProviders); return; } const data = (await readJsonSafely<{ providers?: AuthProvider[] }>(response)) ?? {}; const mergedProviders = defaultAuthProviders.map((defaultProvider) => { const matchingProvider = (data.providers ?? []).find((provider) => provider.providerKey === defaultProvider.providerKey); return matchingProvider ?? defaultProvider; }); setAuthProviders(mergedProviders); } catch { setAuthProviders(defaultAuthProviders); } }; const bootstrapSession = async () => { try { setAuthLoading(true); await loadProviders(); const url = new URL(window.location.href); const callbackToken = url.searchParams.get('auth_token') ?? ''; const billingState = url.searchParams.get('billing'); const token = callbackToken || readStoredSessionToken(); if (callbackToken) { persistSessionToken(callbackToken); url.searchParams.delete('auth_token'); } if (!token) { return; } const { session, token: sessionToken } = await refreshAuthSession(token); if (billingState === 'success' || billingState === 'portal') { try { const syncResponse = await apiFetch('/billing/sync', sessionToken, { method: 'POST' }); if (!syncResponse.ok) { throw new Error(await readErrorMessage(syncResponse, 'Returned from Stripe, but billing could not be refreshed yet.')); } const { session: refreshedSession } = await refreshAuthSession(sessionToken); const syncedWorkspace = refreshedSession.activeWorkspace; const planName = formatBillingPlanName(syncedWorkspace.billingPlan); const intervalName = formatBillingIntervalName(syncedWorkspace.billingInterval); setBillingNotice({ kind: 'success', message: billingState === 'success' ? `Stripe checkout completed. Billing is now ${planName} on ${intervalName}.` : `Stripe billing changes synced. Current plan: ${planName} on ${intervalName}.`, }); } catch (billingSyncError) { setBillingNotice({ kind: 'info', message: billingSyncError instanceof Error ? billingSyncError.message : 'Returned from Stripe. Billing changes may still be syncing.', }); } } else if (billingState === 'cancelled') { setBillingNotice({ kind: 'info', message: 'Stripe checkout was cancelled. No billing changes were applied.', }); } else { setBillingNotice(null); } if (session && (callbackToken || billingState)) { url.searchParams.delete('billing'); window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); setError(''); } } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load your session.'); } finally { setAuthLoading(false); } }; void bootstrapSession(); }, []); useEffect(() => { if (!authToken || !workspace?.id) { setLoading(false); return; } const loadWorkspaceData = async () => { try { setLoading(true); const [birdsResponse, membersResponse, integrationTokensResponse] = await Promise.all([ apiFetch('/birds', authToken), apiFetch('/workspace/members', authToken), apiFetch('/integration-tokens', authToken), ]); if (!birdsResponse.ok) { if (birdsResponse.status === 401) { clearAppSession(); return; } throw new Error(await readErrorMessage(birdsResponse, 'Unable to load flock members.')); } const data = (await readJsonSafely<{ birds?: Bird[]; memorializedBirds?: Bird[] }>(birdsResponse)) ?? {}; const nextBirds = data.birds ?? []; setBirds(nextBirds); setMemorializedBirds(data.memorializedBirds ?? []); setSelectedBirdId((current) => (current && nextBirds.some((bird) => bird.id === current) ? current : '')); if (membersResponse.ok) { const membersData = (await readJsonSafely<{ members?: WorkspaceMember[] }>(membersResponse)) ?? {}; setWorkspaceMembers( (membersData.members ?? []).map((member) => ({ ...member, email: member.inviteEmail, })), ); } else { setWorkspaceMembers([]); } if (integrationTokensResponse.ok) { const integrationTokensData = (await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {}; setIntegrationTokens(integrationTokensData.integrationTokens ?? []); } else { setIntegrationTokens([]); } } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.'); } finally { setLoading(false); } }; void loadWorkspaceData(); }, [authToken, workspace?.id]); useEffect(() => { if (!authToken || !authSession?.isAdmin || activePage !== 'admin') { return; } const loadAdminDashboard = async () => { try { const [summaryResponse, rescuesResponse] = await Promise.all([ apiFetch('/admin/summary', authToken), apiFetch('/admin/rescue-workspaces', authToken), ]); if (!summaryResponse.ok || !rescuesResponse.ok) { throw new Error('Unable to load admin dashboard.'); } const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {}; const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {}; setAdminSummary(summaryData.summary ?? null); setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []); } catch (adminError) { setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.'); } }; void loadAdminDashboard(); }, [activePage, authSession?.isAdmin, authToken]); useEffect(() => { if (!selectedBird?.id) { setWeights([]); setVetVisits([]); setMedications([]); setMedicationAdministrations([]); return; } const loadBirdDetail = async () => { try { const [weightsResponse, visitsResponse, medicationsResponse, medicationAdministrationsResponse] = await Promise.all([ apiFetch(`/birds/${selectedBird.id}/weights?days=${OVERVIEW_HISTORY_DAYS}`, authToken), apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken), apiFetch(`/birds/${selectedBird.id}/medications`, authToken), apiFetch(`/birds/${selectedBird.id}/medication-administrations`, authToken), ]); if (!weightsResponse.ok || !visitsResponse.ok || !medicationsResponse.ok || !medicationAdministrationsResponse.ok) { throw new Error('Unable to load flock member details.'); } const weightsData = (await readJsonSafely<{ weights?: WeightRecord[] }>(weightsResponse)) ?? {}; const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {}; const medicationsData = (await readJsonSafely<{ medications?: Medication[] }>(medicationsResponse)) ?? {}; const medicationAdministrationsData = (await readJsonSafely<{ administrations?: MedicationAdministration[] }>(medicationAdministrationsResponse)) ?? {}; setWeights(weightsData.weights ?? []); const nextVetVisits = visitsData.vetVisits ?? []; setVetVisits(nextVetVisits); setAllBirdVetVisits((current) => ({ ...current, [selectedBird.id]: nextVetVisits, })); setMedications(medicationsData.medications ?? []); setMedicationAdministrations(medicationAdministrationsData.administrations ?? []); setEditingVetVisitId(''); setDeletingVetVisitId(''); setEditingMedicationId(''); setDeletingMedicationId(''); setSavingMedicationAdministrationId(''); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.'); } }; void loadBirdDetail(); }, [authToken, selectedBird?.id]); useEffect(() => { if (!authToken || !birds.length) { setAllBirdWeights({}); return; } const loadAllBirdWeights = async () => { try { const responses = await Promise.all( birds.map(async (bird) => { const response = await apiFetch(`/birds/${bird.id}/weights?days=${OVERVIEW_HISTORY_DAYS}`, authToken); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to load overview weights.')); } const data = (await readJsonSafely<{ weights?: WeightRecord[] }>(response)) ?? {}; return [bird.id, (data.weights ?? []) as WeightRecord[]] as const; }), ); setAllBirdWeights(Object.fromEntries(responses)); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load overview weights.'); } }; void loadAllBirdWeights(); }, [authToken, birds]); useEffect(() => { if (!authToken || !birds.length) { setAllBirdVetVisits({}); return; } const loadAllBirdVetVisits = async () => { try { const responses = await Promise.all( birds.map(async (bird) => { const response = await apiFetch(`/birds/${bird.id}/vet-visits`, authToken); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to load overview vet visits.')); } const data = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(response)) ?? {}; return [bird.id, (data.vetVisits ?? []) as VetVisit[]] as const; }), ); setAllBirdVetVisits(Object.fromEntries(responses)); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load overview vet visits.'); } }; void loadAllBirdVetVisits(); }, [authToken, birds]); useEffect(() => { if (!editingBirdId) { return; } if (!editingBird) { setEditingBirdId(''); setBirdForm(emptyBirdForm); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); return; } setBirdForm(toBirdForm(editingBird)); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); }, [editingBird, editingBirdId]); useEffect(() => { setBulkWeightRows((current) => { const nextEntries = birds.map((bird) => [bird.id, current[bird.id] ?? { weightGrams: '' }] as const); return Object.fromEntries(nextEntries); }); }, [birds]); const startCreateBird = () => { setEditingBirdId(''); setBirdForm(emptyBirdForm); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); setError(''); setActivePage('settings'); }; const handleAuthSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); setAuthNotice(null); setAuthSubmitting(true); try { const response = await apiFetch('/auth/magic-link/request', undefined, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: authForm.name.trim(), email: authForm.email, redirectTo: window.location.href, }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to send your sign-in link.')); } const data = (await readJsonSafely<{ message?: string; previewUrl?: string | null }>(response)) ?? {}; setAuthNotice({ message: data.message ?? 'Check your email for a secure sign-in link.', previewUrl: data.previewUrl, }); setAuthForm(emptyAuthForm); } catch (authError) { setError(authError instanceof Error ? authError.message : 'Unable to send your sign-in link.'); } finally { setAuthSubmitting(false); } }; const handleLostBirdReportSubmit = async (event: React.FormEvent) => { event.preventDefault(); setLostBirdReportNotice(null); setLostBirdReportSubmitting(true); try { const response = await apiFetch('/lost-bird/report', undefined, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tagId: lostBirdReportForm.tagId.trim(), finderEmail: lostBirdReportForm.finderEmail.trim(), foundLocation: lostBirdReportForm.foundLocation.trim(), message: lostBirdReportForm.message.trim(), }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to send this report right now.')); } const data = (await readJsonSafely<{ message?: string }>(response)) ?? {}; setLostBirdReportNotice({ message: data.message ?? 'Report received.', kind: 'success', }); setLostBirdReportForm(emptyLostBirdReportForm); } catch (reportError) { setLostBirdReportNotice({ message: reportError instanceof Error ? reportError.message : 'Unable to send this report right now.', kind: 'error', }); } finally { setLostBirdReportSubmitting(false); } }; const handleLogout = async () => { setError(''); try { if (authToken) { await apiFetch('/auth/logout', authToken, { method: 'POST' }); } } catch { // Best-effort logout. } finally { clearAppSession(); setAuthForm(emptyAuthForm); } }; const handleWorkspaceSwitch = async (workspaceId: number) => { if (!authToken || workspaceId === workspace?.id) { return; } setError(''); setSwitchingWorkspaceId(workspaceId); try { const response = await apiFetch('/auth/switch-workspace', authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workspaceId }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to switch flocks.')); } const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; if (!data.session) { throw new Error('Unable to switch flocks.'); } const nextToken = data.token || authToken; persistSessionToken(nextToken); applySession(data.session, nextToken); setSelectedBirdId(''); setEditingBirdId(''); setWeights([]); setVetVisits([]); setMedications([]); setMedicationAdministrations([]); setAllBirdVetVisits({}); setActivePage('overview'); } catch (switchError) { setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.'); } finally { setSwitchingWorkspaceId(null); } }; const handleCreateIntegrationToken = async (event: React.FormEvent) => { event.preventDefault(); if (!authToken) { return; } setError(''); setCreatingIntegrationToken(true); try { const response = await apiFetch('/integration-tokens', authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: integrationTokenForm.name.trim(), scope: integrationTokenForm.scope, expiresInDays: integrationTokenForm.expiresInDays ? Number(integrationTokenForm.expiresInDays) : undefined, }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to create integration token.')); } const data = (await readJsonSafely<{ integrationToken?: IntegrationTokenSummary; token?: string }>(response)) ?? {}; if (!data.integrationToken || !data.token) { throw new Error('Unable to create integration token.'); } setIntegrationTokens((current) => [data.integrationToken!, ...current]); setIntegrationTokenForm(emptyIntegrationTokenForm); setNewIntegrationTokenSecret(data.token); setExpandedSettingsSection('integration-tokens'); } catch (integrationTokenError) { setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to create integration token.'); } finally { setCreatingIntegrationToken(false); } }; const handleRevokeIntegrationToken = async (tokenId: string) => { if (!authToken) { return; } setError(''); setRevokingIntegrationTokenId(tokenId); try { const response = await apiFetch(`/integration-tokens/${tokenId}`, authToken, { method: 'DELETE', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to revoke integration token.')); } setIntegrationTokens((current) => current.filter((token) => token.id !== tokenId)); } catch (integrationTokenError) { setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to revoke integration token.'); } finally { setRevokingIntegrationTokenId(''); } }; const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => { if (!authToken) { return; } setError(''); setUpdatingRescueWorkspaceId(workspaceId); try { const response = await apiFetch(`/admin/rescue-workspaces/${workspaceId}`, authToken, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rescueVerificationStatus }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to update rescue verification status.')); } const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; if (!data.workspace) { throw new Error('Unable to update rescue verification status.'); } const nextRescueWorkspaces = adminRescueWorkspaces .map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry)) .filter((entry) => entry.workspace.workspaceType === 'rescue'); setAdminRescueWorkspaces(nextRescueWorkspaces); setAdminSummary((current) => current ? { ...current, rescueWorkspaces: nextRescueWorkspaces.length, pendingRescues: nextRescueWorkspaces.filter((entry) => entry.workspace.rescueVerificationStatus === 'pending').length, } : current, ); } catch (adminError) { setError(adminError instanceof Error ? adminError.message : 'Unable to update rescue verification status.'); } finally { setUpdatingRescueWorkspaceId(null); } }; const handleCreateWorkspace = async (event: React.FormEvent) => { event.preventDefault(); if (!authToken) { return; } setError(''); setCreatingWorkspace(true); try { const response = await apiFetch('/workspaces', authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: workspaceCreateForm.name, workspaceType: workspaceCreateForm.workspaceType, billingEmail: workspaceCreateForm.billingEmail, billingPlan: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingPlan, billingInterval: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingInterval, rescueOnboarding: workspaceCreateForm.workspaceType === 'rescue' ? workspaceCreateForm.rescueOnboarding : undefined, }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to create flock.')); } const workspaceResponse = await apiFetch('/auth/session', authToken); if (!workspaceResponse.ok) { throw new Error(await readErrorMessage(workspaceResponse, 'Flock was created but the session could not be refreshed.')); } const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(workspaceResponse)) ?? {}; if (!data.session) { throw new Error('Unable to refresh your flock list.'); } const nextToken = data.token || authToken; persistSessionToken(nextToken); applySession(data.session, nextToken); setWorkspaceCreateForm({ ...emptyWorkspaceCreateForm, billingEmail: data.session.user.email, }); } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.'); } finally { setCreatingWorkspace(false); } }; const startEditBird = (bird: Bird) => { setSelectedBirdId(bird.id); setEditingBirdId(bird.id); setBirdForm(toBirdForm(bird)); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); setError(''); setActivePage('settings'); }; const handleBirdPhotoChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) { return; } if (file.size > 12_000_000) { setError('Photo is too large to process in the browser. Please choose one under 12 MB.'); event.target.value = ''; return; } try { const photoDataUrl = await readFileAsDataUrl(file); const image = await loadImageElement(photoDataUrl); setPhotoCrop({ sourceDataUrl: photoDataUrl, fileName: file.name, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight, zoom: 1, offsetX: 0, offsetY: 0, }); setPhotoDrag(null); setBirdPhotoName(file.name); setError(''); } catch (photoError) { setError(photoError instanceof Error ? photoError.message : 'Unable to read that photo.'); } finally { event.target.value = ''; } }; const handleApplyPhotoCrop = async () => { if (!photoCrop) { return; } setApplyingPhotoCrop(true); setError(''); try { const photoDataUrl = await exportCroppedPhoto(photoCrop); setBirdForm((current) => ({ ...current, photoDataUrl })); setBirdPhotoName(photoCrop.fileName); setPhotoCrop(null); setPhotoDrag(null); } catch (cropError) { setError(cropError instanceof Error ? cropError.message : 'Unable to crop that photo.'); } finally { setApplyingPhotoCrop(false); } }; const handleCancelPhotoCrop = () => { setPhotoCrop(null); setPhotoDrag(null); setError(''); }; const handleRemovePhoto = () => { setBirdForm((current) => ({ ...current, photoDataUrl: '' })); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); }; const handlePhotoCropPointerDown = (event: React.PointerEvent) => { if (!photoCrop) { return; } event.currentTarget.setPointerCapture(event.pointerId); setPhotoDrag({ pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startOffsetX: photoCrop.offsetX, startOffsetY: photoCrop.offsetY, }); }; const handlePhotoCropPointerMove = (event: React.PointerEvent) => { if (!photoCrop || !photoDrag || photoDrag.pointerId !== event.pointerId) { return; } const metrics = getPhotoCropMetrics(photoCrop); const deltaX = event.clientX - photoDrag.startX; const deltaY = event.clientY - photoDrag.startY; const nextOffsetX = metrics.maxOffsetX > 0 ? clamp(photoDrag.startOffsetX - (deltaX / (metrics.maxOffsetX * metrics.scale)) * 100, -100, 100) : 0; const nextOffsetY = metrics.maxOffsetY > 0 ? clamp(photoDrag.startOffsetY - (deltaY / (metrics.maxOffsetY * metrics.scale)) * 100, -100, 100) : 0; setPhotoCrop((current) => (current ? { ...current, offsetX: nextOffsetX, offsetY: nextOffsetY } : current)); }; const handlePhotoCropPointerUp = (event: React.PointerEvent) => { if (photoDrag?.pointerId === event.pointerId) { event.currentTarget.releasePointerCapture(event.pointerId); setPhotoDrag(null); } }; const handleBirdSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); setSavingBird(true); const isEditing = Boolean(editingBirdId); const method = isEditing ? 'PUT' : 'POST'; try { const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(birdForm), }); if (!response.ok) { throw new Error(await readErrorMessage(response, `Unable to ${isEditing ? 'update' : 'create'} flock member.`)); } const data = await readJsonSafely<{ bird: Bird }>(response); if (!data?.bird) { throw new Error(`Unable to ${isEditing ? 'update' : 'create'} flock member.`); } const savedBird = data.bird as Bird; setBirds((current) => { if (isEditing) { return sortBirdsByName(current.map((bird) => (bird.id === savedBird.id ? savedBird : bird))); } return sortBirdsByName([...current, savedBird]); }); setSelectedBirdId(isEditing ? savedBird.id : ''); setEditingBirdId(savedBird.id); setBirdForm(toBirdForm(savedBird)); setBirdPhotoName(''); setActivePage(isEditing ? 'settings' : 'flock'); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save flock member.'); } finally { setSavingBird(false); } }; const handleWeightSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedBird) { return; } setError(''); try { const response = await apiFetch(`/birds/${selectedBird.id}/weights`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ weightGrams: Number(weightForm.weightGrams), recordedOn: weightForm.recordedOn, notes: weightForm.notes, }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to save weight.')); } const data = await readJsonSafely<{ weight: WeightRecord }>(response); if (!data?.weight) { throw new Error('Unable to save weight.'); } const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn)); setWeights(nextWeights); setAllBirdWeights((current) => ({ ...current, [selectedBird.id]: nextWeights.filter((entry) => { const limitDate = new Date(); limitDate.setDate(limitDate.getDate() - 29); return new Date(`${entry.recordedOn}T00:00:00`) >= new Date(limitDate.toDateString()); }), })); setBirds((current) => current.map((bird) => bird.id === selectedBird.id ? { ...bird, latestWeightGrams: data.weight.weightGrams, latestRecordedOn: data.weight.recordedOn, } : bird, ), ); setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' }); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save weight.'); } }; const handleBulkWeightValueChange = (birdId: string, weightGrams: string) => { setBulkWeightRows((current) => ({ ...current, [birdId]: { weightGrams, }, })); }; const handleBulkWeightSubmit = async (event: React.FormEvent) => { event.preventDefault(); const entries = birds .map((bird) => ({ bird, weightGrams: bulkWeightRows[bird.id]?.weightGrams?.trim() ?? '', })) .filter((entry) => entry.weightGrams); if (!entries.length) { setError('Add at least one weight before saving the bulk update.'); return; } setError(''); setSavingBulkWeights(true); try { const savedWeights: Record = {}; for (const entry of entries) { const response = await apiFetch(`/birds/${entry.bird.id}/weights`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ weightGrams: Number(entry.weightGrams), recordedOn: bulkWeightDate, notes: '', }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, `Unable to save weight for ${entry.bird.name}.`)); } const data = await readJsonSafely<{ weight?: WeightRecord }>(response); if (!data?.weight) { throw new Error(`Unable to save weight for ${entry.bird.name}.`); } savedWeights[entry.bird.id] = data.weight; } setBirds((current) => current.map((bird) => { const savedWeight = savedWeights[bird.id]; if (!savedWeight) { return bird; } return { ...bird, latestWeightGrams: savedWeight.weightGrams, latestRecordedOn: savedWeight.recordedOn, }; }), ); setAllBirdWeights((current) => { const next = { ...current }; const limitDate = new Date(); limitDate.setDate(limitDate.getDate() - 29); const limitMs = new Date(limitDate.toDateString()).getTime(); for (const [birdId, savedWeight] of Object.entries(savedWeights)) { const nextWeights = [...(current[birdId] ?? []), savedWeight] .sort((left, right) => left.recordedOn.localeCompare(right.recordedOn)) .filter((entry) => new Date(`${entry.recordedOn}T00:00:00`).getTime() >= limitMs); next[birdId] = nextWeights; } return next; }); if (selectedBird?.id && savedWeights[selectedBird.id]) { setWeights((current) => [...current.filter((entry) => entry.recordedOn !== bulkWeightDate), savedWeights[selectedBird.id]].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn), ), ); } setBulkWeightRows((current) => Object.fromEntries( Object.entries(current).map(([birdId]) => [birdId, { weightGrams: '' }]), ), ); } catch (bulkError) { setError(bulkError instanceof Error ? bulkError.message : 'Unable to save the bulk weight update.'); } finally { setSavingBulkWeights(false); } }; const handleVetVisitSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedBird) { return; } setError(''); try { const isEditingVetVisit = Boolean(editingVetVisitId); const response = await apiFetch( isEditingVetVisit ? `/birds/${selectedBird.id}/vet-visits/${editingVetVisitId}` : `/birds/${selectedBird.id}/vet-visits`, authToken, { method: isEditingVetVisit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(vetVisitForm), }, ); if (!response.ok) { throw new Error(await readErrorMessage(response, `Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`)); } const data = await readJsonSafely<{ vetVisit: VetVisit }>(response); if (!data?.vetVisit) { throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`); } const nextVetVisits = ( isEditingVetVisit ? vetVisits.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...vetVisits] ).sort( (left, right) => right.visitedOn.localeCompare(left.visitedOn), ); setVetVisits(nextVetVisits); setAllBirdVetVisits((current) => ({ ...current, [selectedBird.id]: nextVetVisits, })); setVetVisitForm({ visitedOn: new Date().toISOString().slice(0, 10), clinicName: '', reason: '', notes: '', }); setEditingVetVisitId(''); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.'); } }; const handleEditVetVisit = (visit: VetVisit) => { setEditingVetVisitId(visit.id); setVetVisitForm({ visitedOn: visit.visitedOn, clinicName: visit.clinicName, reason: visit.reason, notes: visit.notes ?? '', }); setError(''); }; const handleCancelVetVisitEdit = () => { setEditingVetVisitId(''); setVetVisitForm({ visitedOn: new Date().toISOString().slice(0, 10), clinicName: '', reason: '', notes: '', }); }; const handleDeleteVetVisit = async (visitId: string) => { if (!selectedBird || deletingVetVisitId) { return; } setDeletingVetVisitId(visitId); setError(''); try { const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits/${visitId}`, authToken, { method: 'DELETE', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to remove vet visit.')); } const nextVetVisits = vetVisits.filter((visit) => visit.id !== visitId); setVetVisits(nextVetVisits); setAllBirdVetVisits((current) => ({ ...current, [selectedBird.id]: nextVetVisits, })); if (editingVetVisitId === visitId) { handleCancelVetVisitEdit(); } } catch (removeError) { setError(removeError instanceof Error ? removeError.message : 'Unable to remove vet visit.'); } finally { setDeletingVetVisitId(''); } }; const handleMedicationSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedBird) { return; } setError(''); try { const isEditingMedication = Boolean(editingMedicationId); const response = await apiFetch( isEditingMedication ? `/birds/${selectedBird.id}/medications/${editingMedicationId}` : `/birds/${selectedBird.id}/medications`, authToken, { method: isEditingMedication ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(medicationForm), }, ); if (!response.ok) { throw new Error(await readErrorMessage(response, `Unable to ${isEditingMedication ? 'update' : 'save'} medication.`)); } const data = await readJsonSafely<{ medication: Medication }>(response); if (!data?.medication) { throw new Error(`Unable to ${isEditingMedication ? 'update' : 'save'} medication.`); } setMedications((current) => (isEditingMedication ? current.map((medication) => (medication.id === data.medication.id ? data.medication : medication)) : [data.medication, ...current] ).sort((left, right) => { const leftEnd = left.endDate ?? '9999-12-31'; const rightEnd = right.endDate ?? '9999-12-31'; return rightEnd.localeCompare(leftEnd) || right.startDate.localeCompare(left.startDate); }), ); setMedicationForm({ name: '', dosage: '', frequency: 'once_daily', doseSchedule: getDefaultMedicationDoseSchedule('once_daily'), route: '', startDate: new Date().toISOString().slice(0, 10), endDate: '', notes: '', }); setEditingMedicationId(''); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save medication.'); } }; const handleEditMedication = (medication: Medication) => { const frequency = normalizeMedicationFrequency(medication.frequency); setEditingMedicationId(medication.id); setMedicationForm({ name: medication.name, dosage: medication.dosage, frequency, doseSchedule: medication.doseSchedule?.length ? medication.doseSchedule : getDefaultMedicationDoseSchedule(frequency), route: medication.route ?? '', startDate: medication.startDate, endDate: medication.endDate ?? '', notes: medication.notes ?? '', }); setError(''); }; const handleCancelMedicationEdit = () => { setEditingMedicationId(''); setMedicationForm({ name: '', dosage: '', frequency: 'once_daily', doseSchedule: getDefaultMedicationDoseSchedule('once_daily'), route: '', startDate: new Date().toISOString().slice(0, 10), endDate: '', notes: '', }); }; const handleDeleteMedication = async (medicationId: string) => { if (!selectedBird || deletingMedicationId) { return; } setDeletingMedicationId(medicationId); setError(''); try { const response = await apiFetch(`/birds/${selectedBird.id}/medications/${medicationId}`, authToken, { method: 'DELETE', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to remove medication.')); } setMedications((current) => current.filter((medication) => medication.id !== medicationId)); setMedicationAdministrations((current) => current.filter((administration) => administration.medicationId !== medicationId)); if (editingMedicationId === medicationId) { handleCancelMedicationEdit(); } } catch (removeError) { setError(removeError instanceof Error ? removeError.message : 'Unable to remove medication.'); } finally { setDeletingMedicationId(''); } }; const handleMedicationAdministrationSubmit = async ( medicationId: string, administrationSlot: string, status: MedicationAdministration['status'], ) => { if (!selectedBird || savingMedicationAdministrationId) { return; } setSavingMedicationAdministrationId(`${medicationId}-${administrationSlot}-${status}`); setError(''); try { const administeredOn = new Date().toISOString().slice(0, 10); const response = await apiFetch(`/birds/${selectedBird.id}/medications/${medicationId}/administrations`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ administeredOn, administrationSlot, status }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to update medication administration.')); } const data = await readJsonSafely<{ administration: MedicationAdministration }>(response); if (!data?.administration) { throw new Error('Unable to update medication administration.'); } setMedicationAdministrations((current) => [data.administration, ...current.filter((administration) => administration.id !== data.administration.id)] .filter( (administration, index, all) => all.findIndex( (candidate) => candidate.medicationId === administration.medicationId && candidate.administeredOn === administration.administeredOn && candidate.administrationSlot === administration.administrationSlot, ) === index, ) .sort((left, right) => right.administeredOn.localeCompare(left.administeredOn) || right.createdAt.localeCompare(left.createdAt)), ); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to update medication administration.'); } finally { setSavingMedicationAdministrationId(''); } }; const handleRemoveBird = async () => { if (!selectedBird || deletingBird) { return; } const confirmed = window.confirm( `Remove ${selectedBird.name} from the flock?\n\nThis will also remove weight records and vet visits for this flock member.`, ); if (!confirmed) { return; } setDeletingBird(true); setError(''); try { const response = await apiFetch(`/birds/${selectedBird.id}`, authToken, { method: 'DELETE', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to remove flock member.')); } const nextBirds = birds.filter((bird) => bird.id !== selectedBird.id); setBirds(nextBirds); setAllBirdWeights((current) => { const next = { ...current }; delete next[selectedBird.id]; return next; }); setAllBirdVetVisits((current) => { const next = { ...current }; delete next[selectedBird.id]; return next; }); setSelectedBirdId(''); setWeights([]); setVetVisits([]); setMedications([]); setMedicationAdministrations([]); setEditingVetVisitId(''); setDeletingVetVisitId(''); setEditingMedicationId(''); setDeletingMedicationId(''); setSavingMedicationAdministrationId(''); if (editingBirdId === selectedBird.id) { setEditingBirdId(''); setBirdForm(emptyBirdForm); setBirdPhotoName(''); } } catch (removeError) { setError(removeError instanceof Error ? removeError.message : 'Unable to remove flock member.'); } finally { setDeletingBird(false); } }; const handleMemorializeBird = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedBird || memorializingBird) { return; } const confirmed = window.confirm( `Memorialize ${selectedBird.name}?\n\nThis cannot be undone by you. ${selectedBird.name} will become read-only, hidden from the standard flock view, and excluded from the subscription bird count.`, ); if (!confirmed) { return; } setMemorializingBird(true); setError(''); try { const response = await apiFetch(`/birds/${selectedBird.id}/memorialize`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(memorializeBirdForm), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to memorialize bird.')); } const data = await readJsonSafely<{ bird: Bird }>(response); if (!data?.bird) { throw new Error('Unable to memorialize bird.'); } const memorializedBird = data.bird; setBirds((current) => current.filter((bird) => bird.id !== memorializedBird.id)); setMemorializedBirds((current) => sortBirdsByName([...current.filter((bird) => bird.id !== memorializedBird.id), memorializedBird])); setSelectedBirdId(''); setEditingBirdId(''); setBirdForm(emptyBirdForm); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); setMemorializeBirdForm(emptyMemorializeBirdForm()); setWeights([]); setVetVisits([]); setMedications([]); setMedicationAdministrations([]); setAllBirdWeights((current) => { const next = { ...current }; delete next[memorializedBird.id]; return next; }); setAllBirdVetVisits((current) => { const next = { ...current }; delete next[memorializedBird.id]; return next; }); } catch (memorializeError) { setError(memorializeError instanceof Error ? memorializeError.message : 'Unable to memorialize bird.'); } finally { setMemorializingBird(false); } }; const handleMemorialReminderPreferenceChange = async (bird: Bird, notifyOnMemorialDay: boolean) => { if (savingMemorialReminderBirdId) { return; } setSavingMemorialReminderBirdId(bird.id); setError(''); try { const response = await apiFetch(`/birds/${bird.id}/memorial-reminders`, authToken, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notifyOnMemorialDay }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to update memorial reminder setting.')); } const data = await readJsonSafely<{ bird: Bird }>(response); if (!data?.bird) { throw new Error('Unable to update memorial reminder setting.'); } setMemorializedBirds((current) => current.map((currentBird) => (currentBird.id === data.bird.id ? data.bird : currentBird))); } catch (preferenceError) { setError(preferenceError instanceof Error ? preferenceError.message : 'Unable to update memorial reminder setting.'); } finally { setSavingMemorialReminderBirdId(''); } }; const handleFlockTransferSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (transferringBird) { return; } setError(''); setTransferError(''); setTransferNotice(null); setTransferringBird(true); try { const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ destinationOwnerEmail: flockTransferForm.destinationOwnerEmail, }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to transfer bird to another flock.')); } const data = (await readJsonSafely<{ bird?: Bird; inviteSent?: boolean; invitePreviewUrl?: string | null; message?: string; }>(response)) ?? {}; const transferredBirdName = data.bird?.name || birds.find((bird) => bird.id === flockTransferForm.birdId)?.name || 'Bird'; if (data.inviteSent) { setTransferNotice({ message: data.message ?? `A bird transfer invite was sent to ${flockTransferForm.destinationOwnerEmail}. The transfer will complete automatically after they sign in.`, previewUrl: data.invitePreviewUrl, }); setFlockTransferForm({ birdId: '', destinationOwnerEmail: '', }); return; } setBirds((current) => current.filter((bird) => bird.id !== flockTransferForm.birdId)); setAllBirdWeights((current) => { const next = { ...current }; delete next[flockTransferForm.birdId]; return next; }); setAllBirdVetVisits((current) => { const next = { ...current }; delete next[flockTransferForm.birdId]; return next; }); setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); setMedications((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); if (selectedBird?.id === flockTransferForm.birdId) { setSelectedBirdId(''); } if (editingBirdId === flockTransferForm.birdId) { setEditingBirdId(''); setBirdForm(emptyBirdForm); setBirdPhotoName(''); } setFlockTransferForm({ birdId: '', destinationOwnerEmail: '', }); window.alert(`${transferredBirdName} was transferred to ${flockTransferForm.destinationOwnerEmail}.`); } catch (submitError) { const message = submitError instanceof Error ? submitError.message : 'Unable to transfer bird to another flock.'; setTransferError(message); setError(message); } finally { setTransferringBird(false); } }; const saveWorkspaceSettings = async () => { const response = await apiFetch('/workspace', authToken, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...workspaceForm, billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan, billingInterval: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingInterval, rescueOnboarding: workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue' ? workspaceForm.rescueOnboarding : undefined, }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to save flock settings.')); } const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; if (!data.workspace) { throw new Error('Unable to save flock settings.'); } const savedWorkspace = data.workspace; setWorkspace(savedWorkspace); setAuthSession((current) => current ? { ...current, activeWorkspace: savedWorkspace, workspaces: current.workspaces.map((entry) => entry.workspace.id === savedWorkspace.id ? { ...entry, workspace: savedWorkspace } : entry, ), } : current, ); setWorkspaceForm({ name: savedWorkspace.name, workspaceType: savedWorkspace.workspaceType, billingEmail: savedWorkspace.billingEmail ?? '', billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', billingInterval: savedWorkspace.billingInterval, rescueOnboarding: emptyRescueOnboardingForm(), }); return savedWorkspace; }; const handleWorkspaceSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); setSavingWorkspace(true); try { await saveWorkspaceSettings(); } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.'); } finally { setSavingWorkspace(false); } }; const handleDeleteWorkspace = async () => { if (!workspace || !authToken || deletingWorkspace || activeMembership?.role !== 'owner') { return; } const confirmed = window.confirm( `Delete ${workspace.name}?\n\nThis only works when the flock has no birds. Remove or transfer all birds first.\n\nYou will be switched to another flock or a new personal flock automatically.`, ); if (!confirmed) { return; } setError(''); setDeletingWorkspace(true); try { const response = await apiFetch('/workspace', authToken, { method: 'DELETE', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to delete flock.')); } const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; if (!data.session) { throw new Error('Flock was deleted but the session could not be refreshed.'); } const nextToken = data.token || authToken; persistSessionToken(nextToken); applySession(data.session, nextToken); } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to delete flock.'); } finally { setDeletingWorkspace(false); } }; const handleCancelRescueRequest = async () => { if (!authToken) { return; } setError(''); setCancelingRescueRequest(true); try { const response = await apiFetch('/workspace/rescue-status/cancel', authToken, { method: 'POST', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to cancel rescue status request.')); } const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; if (!data.workspace) { throw new Error('Unable to cancel rescue status request.'); } const savedWorkspace = data.workspace; setWorkspace(savedWorkspace); setAuthSession((current) => current ? { ...current, activeWorkspace: savedWorkspace, workspaces: current.workspaces.map((entry) => entry.workspace.id === savedWorkspace.id ? { ...entry, workspace: savedWorkspace } : entry, ), } : current, ); setWorkspaceForm({ name: savedWorkspace.name, workspaceType: savedWorkspace.workspaceType, billingEmail: savedWorkspace.billingEmail ?? '', billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', billingInterval: savedWorkspace.billingInterval, rescueOnboarding: emptyRescueOnboardingForm(), }); } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to cancel rescue status request.'); } finally { setCancelingRescueRequest(false); } }; const handleStartBillingCheckout = async () => { if (!authToken || !workspace) { return; } setError(''); setBillingNotice(null); setBillingRedirecting(true); setSavingWorkspace(true); try { const savedWorkspace = await saveWorkspaceSettings(); const billingPlan = isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : workspaceForm.billingPlan; const billingInterval = savedWorkspace.billingInterval; const response = await apiFetch('/billing/checkout-session', authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ billingPlan, billingInterval }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to start Stripe checkout.')); } const data = (await readJsonSafely<{ url?: string }>(response)) ?? {}; if (!data.url) { throw new Error('Unable to start Stripe checkout.'); } window.location.assign(data.url); } catch (billingError) { setError(billingError instanceof Error ? billingError.message : 'Unable to start Stripe checkout.'); setBillingRedirecting(false); setSavingWorkspace(false); } }; const handleOpenBillingPortal = async () => { if (!authToken) { return; } setError(''); setBillingNotice(null); setBillingRedirecting(true); try { const response = await apiFetch('/billing/portal-session', authToken, { method: 'POST', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to open Stripe billing portal.')); } const data = (await readJsonSafely<{ url?: string }>(response)) ?? {}; if (!data.url) { throw new Error('Unable to open Stripe billing portal.'); } window.location.assign(data.url); } catch (billingError) { setError(billingError instanceof Error ? billingError.message : 'Unable to open Stripe billing portal.'); setBillingRedirecting(false); } }; const handleWorkspaceMemberSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); setSavingWorkspaceMember(true); try { const response = await apiFetch('/workspace/members', authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(workspaceMemberForm), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to add rescue team member.')); } const data = (await readJsonSafely<{ member?: WorkspaceMember }>(response)) ?? {}; if (!data.member) { throw new Error('Unable to add rescue team member.'); } const nextMember = { ...data.member, email: data.member.inviteEmail, }; setWorkspaceMembers((current) => [...current, nextMember]); setWorkspaceMemberForm(emptyWorkspaceMemberForm); } catch (memberError) { setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.'); } finally { setSavingWorkspaceMember(false); } }; const handleRemoveWorkspaceMember = async (memberId: string) => { setError(''); setRemovingWorkspaceMemberId(memberId); try { const response = await apiFetch(`/workspace/members/${memberId}`, authToken, { method: 'DELETE', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to remove rescue team member.')); } setWorkspaceMembers((current) => current.filter((member) => member.id !== memberId)); } catch (memberError) { setError(memberError instanceof Error ? memberError.message : 'Unable to remove rescue team member.'); } finally { setRemovingWorkspaceMemberId(''); } }; const handleWeightRangeAlertClick = () => { if (!totalWeightAlerts) { return; } setShowWeightAlertModal(true); }; const handleVetVisitReminderClick = () => { const firstDueBird = vetVisitDueBirds[0]; if (!firstDueBird) { return; } setSelectedBirdId(firstDueBird.id); setBulkWeightOpen(false); setActivePage('flock'); }; if (authLoading) { return (

FlockPal

Loading your flock spaces...

Checking your sign-in and flock access.

); } if (!authSession) { return (
FlockPal branding with a colorful trio of birds perched together.

Organized care for companion birds and rescue flocks

Keep every bird's care story in one place; your flock's health, history, and routines together and easier to visualize.

Report a missing bird

Enter the band ID and FlockPal will notify the flock if that bird is in the system.