import { useEffect, useMemo, useState } from 'react'; import birdSilhouette from './assets/bird-silhouette.jpg'; import flockPalLandingArt from './assets/flockpal-landing-art.png'; import flockPalTextArt from './assets/flockpal-text.png'; import defaultBirdPhoto from './assets/yoda-default.png'; import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference'; import QRCode from 'qrcode'; type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_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; motivators: string | null; demotivators: string | null; favoriteSnack: string | null; vetClinicName: string | null; vetClinicAddress: string | null; vetAccountNumber: string | null; vetDoctorName: string | null; gender: BirdGender; dateOfBirth: string | null; gotchaDay: string | null; chartColor: string; photoDataUrl: string | null; notifyOnDob: boolean; notifyOnGotchaDay: boolean; publicProfileCode: string | null; publicProfileEnabled: 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; remindersEnabled: boolean; }; 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; birdCount: number; memberCount: number; }; type DailyEducationQuestion = { id: string; prompt: string; options: string[]; correctAnswerIndex: number; explanation: string | null; }; type DailyEducation = { id: string; publishDate: string; fact: string; quizQuestions: DailyEducationQuestion[]; createdAt: string; updatedAt: string; }; 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 FlockNote = { id: string; workspaceId: number; birdId: string | null; birdName: string | null; body: string; createdByUserId: string | null; createdByName: string | null; createdAt: string; updatedAt: string; }; type AuditLogEntry = { id: string; workspaceId: number; userId: string | null; actorName: string | null; actorEmail: string | null; action: string; entityType: string; entityId: string | null; entityName: string | null; details: Record; createdAt: string; }; type IntegrationTokenFormState = { name: string; scope: IntegrationTokenScope; expiresInDays: string; }; type FlockNoteFormState = { birdId: string; body: string; }; type BirdFormState = { name: string; tagId: string; species: string; motivators: string; demotivators: string; favoriteSnack: string; vetClinicName: string; vetClinicAddress: string; vetAccountNumber: string; vetDoctorName: string; gender: BirdGender; dateOfBirth: string; gotchaDay: string; chartColor: string; photoDataUrl: string; notifyOnDob: boolean; notifyOnGotchaDay: boolean; publicProfileEnabled: boolean; }; type VeterinaryInfoFormState = Pick; type BirdImportWeight = { weightGrams: number; recordedOn: string; notes: string; }; type BirdImportProfile = { key: string; name: string; tagId: string; species: string; favoriteSnack: string; motivators: string; demotivators: string; gender: BirdGender; dateOfBirth: string; gotchaDay: string; chartColor: string; weights: BirdImportWeight[]; }; type BirdImportPreview = { profiles: BirdImportProfile[]; errors: string[]; }; type PublicBirdProfile = { id: string; workspaceId: number; name: string; favoriteSnack: string | null; gender: BirdGender; dateOfBirth: string | null; photoDataUrl: string | null; }; 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 DailyEducationQuestionFormState = { prompt: string; options: [string, string, string, string]; correctAnswerIndex: number; explanation: string; }; type DailyEducationFormState = { publishDate: string; fact: 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 BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'reports' | 'audit'; 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' | 'bird-import' | '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 getPublicProfileCodeFromPath = () => window.location.pathname.match(/^\/b\/([A-Za-z0-9_-]{8,32})\/?$/)?.[1] ?? ''; const getPublicProfileUrl = (code: string) => `${window.location.origin}/b/${code}`; const QR_MARGIN = 4; const createQrPath = (value: string) => { const qr = QRCode.create(value, { errorCorrectionLevel: 'H' }); const size = qr.modules.size; const data = qr.modules.data; const pathParts: string[] = []; for (let y = 0; y < size; y += 1) { for (let x = 0; x < size; x += 1) { if (data[y * size + x]) { pathParts.push(`M${x + QR_MARGIN},${y + QR_MARGIN}h1v1h-1z`); } } } return { path: pathParts.join(''), size, viewBoxSize: size + QR_MARGIN * 2, }; }; const QrCodeWithLogo = ({ value, label }: { value: string; label: string }) => { const qr = useMemo(() => createQrPath(value), [value]); const logoSize = Math.max(7, qr.size * 0.18); const logoPosition = (qr.viewBoxSize - logoSize) / 2; return ( ); }; const importHeaderAliases = { name: ['bird name', 'name'], tagId: ['band id', 'tag id', 'band'], species: ['species'], favoriteSnack: ['favorite snack', 'favorite treat', 'treat'], motivators: ['motivators'], demotivators: ['demotivators', 'demotivates'], gender: ['gender'], dateOfBirth: ['hatch day', 'hatch date', 'date of birth', 'dob'], gotchaDay: ['gotcha day', 'gotcha date'], chartColor: ['chart color', 'color'], weightGrams: ['weight grams', 'weight g', 'weight'], weightDate: ['weight date', 'recorded on', 'weight recorded on'], weightNotes: ['weight notes', 'weight note'], } as const; const normalizeImportHeader = (value: string) => value.trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' '); const readImportCell = (row: Record, aliases: readonly string[]) => { const matchingEntry = Object.entries(row).find(([header]) => aliases.includes(normalizeImportHeader(header))); return matchingEntry?.[1] ?? ''; }; const toImportText = (value: unknown) => (value === null || value === undefined ? '' : String(value).trim()); const formatImportDate = (value: Date) => { if (Number.isNaN(value.getTime())) { return ''; } const month = `${value.getMonth() + 1}`.padStart(2, '0'); const day = `${value.getDate()}`.padStart(2, '0'); return `${value.getFullYear()}-${month}-${day}`; }; const toImportDate = (value: unknown) => { if (value === null || value === undefined || value === '') { return ''; } if (value instanceof Date) { return formatImportDate(value); } if (typeof value === 'number') { return formatImportDate(new Date(1899, 11, 30 + Math.floor(value))); } const text = toImportText(value); if (/^\d{4}-\d{2}-\d{2}$/.test(text)) { return text; } return formatImportDate(new Date(text)); }; const parseImportGender = (value: unknown): BirdGender | null => { const gender = toImportText(value).toLowerCase(); if (!gender || gender === 'unknown') { return 'unknown'; } if (gender === 'male' || gender === 'female') { return gender; } return null; }; const getBirdImportKey = (name: string, tagId: string) => (tagId ? `band:${tagId.toLowerCase()}` : `name:${name.toLowerCase()}`); const mergeImportText = (current: string, next: string) => current || next; const birdProfileListLimit = 3; const parseBirdProfileList = (value: string | null | undefined) => (value ?? '') .split(/\r?\n/) .map((item) => item.trim()) .filter(Boolean) .slice(0, birdProfileListLimit); const getBirdProfileListFields = (value: string) => Array.from({ length: birdProfileListLimit }, (_, index) => parseBirdProfileList(value)[index] ?? ''); const updateBirdProfileListField = (value: string, index: number, nextItem: string) => { const items = getBirdProfileListFields(value); items[index] = nextItem; return items.map((item) => item.trim()).filter(Boolean).join('\n'); }; const parseBirdImportRows = (rows: Record[]): BirdImportPreview => { const errors: string[] = []; const profiles = new Map(); rows.forEach((row, index) => { const rowNumber = index + 2; const name = toImportText(readImportCell(row, importHeaderAliases.name)); const tagId = toImportText(readImportCell(row, importHeaderAliases.tagId)); const gender = parseImportGender(readImportCell(row, importHeaderAliases.gender)); const dateOfBirthValue = readImportCell(row, importHeaderAliases.dateOfBirth); const gotchaDayValue = readImportCell(row, importHeaderAliases.gotchaDay); const dateOfBirth = toImportDate(dateOfBirthValue); const gotchaDay = toImportDate(gotchaDayValue); const weightText = toImportText(readImportCell(row, importHeaderAliases.weightGrams)); const weightDateValue = readImportCell(row, importHeaderAliases.weightDate); const weightDate = toImportDate(weightDateValue); if (!name) { errors.push(`Row ${rowNumber}: Bird Name is required.`); return; } if (!gender) { errors.push(`Row ${rowNumber}: Gender must be male, female, unknown, or blank.`); return; } if (dateOfBirthValue && !dateOfBirth) { errors.push(`Row ${rowNumber}: Hatch Day is not a valid date.`); } if (gotchaDayValue && !gotchaDay) { errors.push(`Row ${rowNumber}: Gotcha Day is not a valid date.`); } const key = getBirdImportKey(name, tagId); const current = profiles.get(key) ?? ({ key, name, tagId, species: '', favoriteSnack: '', motivators: '', demotivators: '', gender, dateOfBirth, gotchaDay, chartColor: '', weights: [], } satisfies BirdImportProfile); current.species = mergeImportText(current.species, toImportText(readImportCell(row, importHeaderAliases.species))); current.favoriteSnack = mergeImportText(current.favoriteSnack, toImportText(readImportCell(row, importHeaderAliases.favoriteSnack))); current.motivators = mergeImportText(current.motivators, toImportText(readImportCell(row, importHeaderAliases.motivators))); current.demotivators = mergeImportText(current.demotivators, toImportText(readImportCell(row, importHeaderAliases.demotivators))); current.chartColor = mergeImportText(current.chartColor, toImportText(readImportCell(row, importHeaderAliases.chartColor))); current.dateOfBirth = mergeImportText(current.dateOfBirth, dateOfBirth); current.gotchaDay = mergeImportText(current.gotchaDay, gotchaDay); current.gender = current.gender === 'unknown' ? gender : current.gender; if (weightText) { const weightGrams = Number(weightText); if (!Number.isFinite(weightGrams) || weightGrams <= 0) { errors.push(`Row ${rowNumber}: Weight Grams must be a positive number.`); } else if (!weightDate) { errors.push(`Row ${rowNumber}: Weight Date is required for a weight entry.`); } else { current.weights.push({ weightGrams, recordedOn: weightDate, notes: toImportText(readImportCell(row, importHeaderAliases.weightNotes)), }); } } else if (weightDateValue) { errors.push(`Row ${rowNumber}: Weight Grams is required when Weight Date is set.`); } profiles.set(key, current); }); profiles.forEach((profile) => { if (!profile.species) { errors.push(`${profile.name}: Species is required on at least one row for this bird.`); } if (profile.chartColor && !/^#[0-9a-fA-F]{6}$/.test(profile.chartColor)) { errors.push(`${profile.name}: Chart Color must be a hex color like #cb3a35.`); } }); return { profiles: [...profiles.values()], errors, }; }; const emptyBirdForm: BirdFormState = { name: '', tagId: '', species: '', motivators: '', demotivators: '', favoriteSnack: '', vetClinicName: '', vetClinicAddress: '', vetAccountNumber: '', vetDoctorName: '', gender: 'unknown', dateOfBirth: '', gotchaDay: '', chartColor: '#cb3a35', photoDataUrl: '', notifyOnDob: false, notifyOnGotchaDay: false, publicProfileEnabled: false, }; const emptyVeterinaryInfoForm: VeterinaryInfoFormState = { vetClinicName: '', vetClinicAddress: '', vetAccountNumber: '', vetDoctorName: '', }; 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 emptyFlockNoteForm: FlockNoteFormState = { birdId: '', body: '', }; const emptyDailyEducationQuestion = (): DailyEducationQuestionFormState => ({ prompt: '', options: ['', '', '', ''], correctAnswerIndex: 0, explanation: '', }); const emptyDailyEducationForm = (): DailyEducationFormState => ({ publishDate: new Date().toISOString().slice(0, 10), fact: '', }); 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, motivators: parseBirdProfileList(bird.motivators).join('\n'), demotivators: parseBirdProfileList(bird.demotivators).join('\n'), favoriteSnack: bird.favoriteSnack ?? '', vetClinicName: bird.vetClinicName ?? '', vetClinicAddress: bird.vetClinicAddress ?? '', vetAccountNumber: bird.vetAccountNumber ?? '', vetDoctorName: bird.vetDoctorName ?? '', gender: bird.gender, dateOfBirth: bird.dateOfBirth ?? '', gotchaDay: bird.gotchaDay ?? '', chartColor: bird.chartColor, photoDataUrl: bird.photoDataUrl ?? '', notifyOnDob: bird.notifyOnDob, notifyOnGotchaDay: bird.notifyOnGotchaDay, publicProfileEnabled: bird.publicProfileEnabled, }); const toVeterinaryInfoForm = (bird: Bird): VeterinaryInfoFormState => ({ vetClinicName: bird.vetClinicName ?? '', vetClinicAddress: bird.vetClinicAddress ?? '', vetAccountNumber: bird.vetAccountNumber ?? '', vetDoctorName: bird.vetDoctorName ?? '', }); 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 escapeReportHtml = (value: string | number | null | undefined) => String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); 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 formatAuditAction = (value: string) => value .split('.') .map((part) => part.charAt(0).toUpperCase() + part.slice(1).replace(/_/g, ' ')) .join(' '); 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' || billingPlan === 'household_hyacinth_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'; } if (billingPlan === 'household_macaw') { return 'African Grey'; } return 'Hyacinth 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 9 birds in the flock.'; } if (billingPlan === 'household_macaw') { return 'Permits 11 to 16 birds in the flock.'; } return 'Permits 17 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 (5-9 birds)'; } if (billingPlan === 'household_macaw') { return 'African Grey (11-16 birds)'; } return 'Hyacinth Macaw (17+)'; }; 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', }, household_hyacinth_macaw: { monthly: '$49.99/month', yearly: '$500/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 '9'; } if (billingPlan === 'household_macaw') { return '16'; } if (billingPlan === 'household_hyacinth_macaw') { return '17+'; } return null; }; const formatBillingBirdUsage = (billingPlan: BillingPlan, birdCount: number) => { const birdLimit = formatBillingPlanBirdLimit(billingPlan); if (!birdLimit) { return `${birdCount} bird${birdCount === 1 ? '' : 's'}`; } return `${birdCount} of ${birdLimit} birds used`; }; 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 [publicProfileCode] = useState(getPublicProfileCodeFromPath); const [publicProfile, setPublicProfile] = useState(null); const [publicProfileLoading, setPublicProfileLoading] = useState(Boolean(getPublicProfileCodeFromPath())); const [publicProfileError, setPublicProfileError] = useState(''); const [workspace, setWorkspace] = useState(null); const [activeMembership, setActiveMembership] = useState(null); const [workspaceMembers, setWorkspaceMembers] = useState([]); const [integrationTokens, setIntegrationTokens] = useState([]); const [flockNotes, setFlockNotes] = useState([]); const [auditLogEntries, setAuditLogEntries] = useState([]); const [adminSummary, setAdminSummary] = useState(null); const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState([]); const [adminDailyEducation, setAdminDailyEducation] = useState([]); const [adminEducationQuestions, setAdminEducationQuestions] = useState([]); const [dailyEducationForm, setDailyEducationForm] = useState(emptyDailyEducationForm); const [educationQuestionForm, setEducationQuestionForm] = useState(emptyDailyEducationQuestion); const [editingEducationQuestionId, setEditingEducationQuestionId] = useState(''); const [savingDailyEducation, setSavingDailyEducation] = useState(false); const [savingEducationQuestion, setSavingEducationQuestion] = useState(false); const [deletingDailyEducationId, setDeletingDailyEducationId] = useState(''); const [deletingEducationQuestionId, setDeletingEducationQuestionId] = useState(''); const [todayEducation, setTodayEducation] = useState(null); const [educationOptOut, setEducationOptOut] = useState(false); const [savingEducationPreference, setSavingEducationPreference] = useState(false); const [educationAnswers, setEducationAnswers] = useState>({}); const [dailyEducationOpen, setDailyEducationOpen] = useState(false); const [birds, setBirds] = useState([]); const [memorializedBirds, setMemorializedBirds] = useState([]); const [selectedBirdId, setSelectedBirdId] = useState(''); const [selectedBirdTab, setSelectedBirdTab] = useState('info'); const [editingBirdId, setEditingBirdId] = useState(''); const [birdEditorOpen, setBirdEditorOpen] = useState(false); 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 [flockNoteForm, setFlockNoteForm] = useState(emptyFlockNoteForm); const [birdForm, setBirdForm] = useState(emptyBirdForm); const [birdImportPreview, setBirdImportPreview] = useState(null); const [birdImportFileName, setBirdImportFileName] = useState(''); const [birdImportNotice, setBirdImportNotice] = useState(''); const [importingBirds, setImportingBirds] = useState(false); 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 [savingFlockNote, setSavingFlockNote] = useState(false); const [deletingFlockNoteId, setDeletingFlockNoteId] = useState(''); const [auditLogLoading, setAuditLogLoading] = 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 [qrBird, setQrBird] = useState(null); 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 [veterinaryInfoForm, setVeterinaryInfoForm] = useState(emptyVeterinaryInfoForm); 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: '', remindersEnabled: false, }); const [flockTransferForm, setFlockTransferForm] = useState({ birdId: '', destinationOwnerEmail: '', }); const [transferCodeAcceptForm, setTransferCodeAcceptForm] = useState({ code: '', }); const [transferringBird, setTransferringBird] = useState(false); const [acceptingTransferCode, setAcceptingTransferCode] = useState(false); const [transferError, setTransferError] = useState(''); const [transferCodeError, setTransferCodeError] = useState(''); const [transferNotice, setTransferNotice] = useState<{ message: string; previewUrl?: string | null; } | null>(null); const [transferCodeNotice, setTransferCodeNotice] = useState(''); const [adoptionTransferCodes, setAdoptionTransferCodes] = useState>({}); const [creatingAdoptionReportCode, setCreatingAdoptionReportCode] = useState(false); const [adoptionReportError, setAdoptionReportError] = useState(''); const [deletingBird, setDeletingBird] = useState(false); const [memorializingBird, setMemorializingBird] = useState(false); const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState(''); const [editingVetVisitId, setEditingVetVisitId] = useState(''); const [editingVeterinaryInfo, setEditingVeterinaryInfo] = useState(false); const [savingVeterinaryInfo, setSavingVeterinaryInfo] = useState(false); 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 selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : ''; const editingBird = useMemo( () => birds.find((bird) => bird.id === editingBirdId) ?? null, [birds, editingBirdId], ); const selectedBirdNotes = useMemo( () => (selectedBird ? flockNotes.filter((note) => note.birdId === selectedBird.id) : []), [flockNotes, selectedBird], ); const selectedBirdAuditLogEntries = useMemo( () => selectedBird ? auditLogEntries.filter( (entry) => entry.entityId === selectedBird.id || entry.details.birdId === selectedBird.id || (entry.entityType === 'bird' && entry.entityName === selectedBird.name), ) : [], [auditLogEntries, selectedBird], ); useEffect(() => { setDismissedAlerts(readDismissedAlerts()); }, [workspace?.id]); useEffect(() => { setSelectedBirdTab('info'); }, [selectedBirdId]); useEffect(() => { if (selectedBird) { setVeterinaryInfoForm(toVeterinaryInfoForm(selectedBird)); } else { setVeterinaryInfoForm(emptyVeterinaryInfoForm); } setEditingVeterinaryInfo(false); }, [selectedBird]); 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 || birdEditorOpen || Boolean(selectedBird); useEffect(() => { if (!publicProfile || !authSession || workspace?.id !== publicProfile.workspaceId || !birds.some((bird) => bird.id === publicProfile.id)) { return; } setSelectedBirdId(publicProfile.id); setActivePage('flock'); window.history.replaceState({}, document.title, '/'); }, [authSession, birds, publicProfile, workspace?.id]); 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 vetVisitDueBirdIds = useMemo(() => new Set(vetVisitDueBirds.map((bird) => bird.id)), [vetVisitDueBirds]); const selectedBirdWeightRangeAlert = selectedBird ? outOfRangeBirds.find((alert) => alert.bird.id === selectedBird.id) ?? null : null; const selectedBirdWeightDropAlerts = selectedBird ? weightDropAlerts.filter((alert) => alert.bird.id === selectedBird.id) : []; const selectedBirdVetVisitAlertSignature = selectedBird ? getVetVisitAlertSignature(selectedBird.id) : ''; const selectedBirdHasVetVisitAlert = selectedBird ? vetVisitDueBirdIds.has(selectedBird.id) : false; 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 selectedBirdLatestChartPoint = selectedBirdChart.points[selectedBirdChart.points.length - 1] ?? null; 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([]); setFlockNotes([]); setAuditLogEntries([]); setAdminSummary(null); setAdminRescueWorkspaces([]); setAdminDailyEducation([]); setAdminEducationQuestions([]); setDailyEducationForm(emptyDailyEducationForm()); setEducationQuestionForm(emptyDailyEducationQuestion()); setEditingEducationQuestionId(''); setTodayEducation(null); setEducationOptOut(false); setEducationAnswers({}); setDailyEducationOpen(false); setBirds([]); setMemorializedBirds([]); setWeights([]); setVetVisits([]); setMedications([]); setMedicationAdministrations([]); setAllBirdWeights({}); setAllBirdVetVisits({}); setSelectedBirdId(''); setEditingBirdId(''); setWorkspaceForm(emptyWorkspaceForm); setWorkspaceCreateForm(emptyWorkspaceCreateForm); setIntegrationTokenForm(emptyIntegrationTokenForm); setFlockNoteForm(emptyFlockNoteForm); 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 (!publicProfileCode) { return; } const loadPublicProfile = async () => { try { setPublicProfileLoading(true); setPublicProfileError(''); const response = await apiFetch(`/public/birds/${publicProfileCode}`); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Public bird profile not found.')); } const data = (await readJsonSafely<{ bird?: PublicBirdProfile }>(response)) ?? {}; if (!data.bird) { throw new Error('Public bird profile not found.'); } setPublicProfile(data.bird); } catch (profileError) { setPublicProfileError(profileError instanceof Error ? profileError.message : 'Public bird profile not found.'); } finally { setPublicProfileLoading(false); } }; void loadPublicProfile(); }, [publicProfileCode]); useEffect(() => { if (!authToken || !workspace?.id) { setLoading(false); return; } const loadWorkspaceData = async () => { try { setLoading(true); const [birdsResponse, membersResponse, integrationTokensResponse, notesResponse] = await Promise.all([ apiFetch('/birds', authToken), apiFetch('/workspace/members', authToken), apiFetch('/integration-tokens', authToken), apiFetch('/notes', 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([]); } if (notesResponse.ok) { const notesData = (await readJsonSafely<{ notes?: FlockNote[] }>(notesResponse)) ?? {}; setFlockNotes(notesData.notes ?? []); } else { setFlockNotes([]); } } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.'); } finally { setLoading(false); } }; void loadWorkspaceData(); }, [authToken, workspace?.id]); useEffect(() => { if (!authToken || selectedBirdTab !== 'audit' || !selectedBird || !['owner', 'assistant'].includes(activeMembership?.role ?? '')) { return; } const loadAuditLog = async () => { try { setAuditLogLoading(true); const response = await apiFetch('/audit-log', authToken); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to load audit log.')); } const data = (await readJsonSafely<{ entries?: AuditLogEntry[] }>(response)) ?? {}; setAuditLogEntries(data.entries ?? []); } catch (auditError) { setError(auditError instanceof Error ? auditError.message : 'Unable to load audit log.'); } finally { setAuditLogLoading(false); } }; void loadAuditLog(); }, [activeMembership?.role, authToken, selectedBird, selectedBirdTab]); useEffect(() => { if (!authToken || !authSession?.isAdmin || activePage !== 'admin') { return; } const loadAdminDashboard = async () => { try { const [summaryResponse, rescuesResponse, educationResponse, educationQuestionsResponse] = await Promise.all([ apiFetch('/admin/summary', authToken), apiFetch('/admin/rescue-workspaces', authToken), apiFetch('/admin/daily-education', authToken), apiFetch('/admin/education-questions', authToken), ]); if (!summaryResponse.ok || !rescuesResponse.ok || !educationResponse.ok || !educationQuestionsResponse.ok) { throw new Error('Unable to load admin dashboard.'); } const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {}; const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {}; const educationData = (await readJsonSafely<{ education?: DailyEducation[] }>(educationResponse)) ?? {}; const educationQuestionsData = (await readJsonSafely<{ questions?: DailyEducationQuestion[] }>(educationQuestionsResponse)) ?? {}; setAdminSummary(summaryData.summary ?? null); setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []); setAdminDailyEducation(educationData.education ?? []); setAdminEducationQuestions(educationQuestionsData.questions ?? []); } catch (adminError) { setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.'); } }; void loadAdminDashboard(); }, [activePage, authSession?.isAdmin, authToken]); useEffect(() => { if (!authToken || !authSession) { return; } const loadTodayEducation = async () => { try { const response = await apiFetch('/education/today', authToken); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to load daily education.')); } const data = (await readJsonSafely<{ education?: DailyEducation | null; educationOptOut?: boolean }>(response)) ?? {}; setEducationOptOut(Boolean(data.educationOptOut)); setTodayEducation(data.education ?? null); setEducationAnswers({}); setDailyEducationOpen(false); } catch (educationError) { setError(educationError instanceof Error ? educationError.message : 'Unable to load daily education.'); } }; void loadTodayEducation(); }, [authSession, authToken]); useEffect(() => { if (!selectedBird?.id) { setWeights([]); setVetVisits([]); setMedications([]); setMedicationAdministrations([]); setVeterinaryInfoForm(emptyVeterinaryInfoForm); 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(() => { if (activePage === 'flock' || !birdEditorOpen) { return; } setBirdEditorOpen(false); setEditingBirdId(''); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); }, [activePage, birdEditorOpen]); useEffect(() => { setBulkWeightRows((current) => { const nextEntries = birds.map((bird) => [bird.id, current[bird.id] ?? { weightGrams: '' }] as const); return Object.fromEntries(nextEntries); }); }, [birds]); const startCreateBird = () => { setEditingBirdId(''); setBirdEditorOpen(true); setBirdForm(emptyBirdForm); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); setError(''); setActivePage('flock'); }; 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, nextActivePage: AppPage = 'overview') => { 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(nextActivePage); } catch (switchError) { setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.'); } finally { setSwitchingWorkspaceId(null); } }; const handleOpenPublicProfileBird = async () => { if (!publicProfile || !authSession) { return; } if (workspace?.id !== publicProfile.workspaceId) { await handleWorkspaceSwitch(publicProfile.workspaceId, 'flock'); return; } setSelectedBirdId(publicProfile.id); setActivePage('flock'); window.history.replaceState({}, document.title, '/'); }; 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 handleFlockNoteSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!authToken) { return; } setError(''); setSavingFlockNote(true); try { const response = await apiFetch('/notes', authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ birdId: selectedBirdTab === 'notes' && selectedBird ? selectedBird.id : flockNoteForm.birdId || null, body: flockNoteForm.body.trim(), }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to save note.')); } const data = (await readJsonSafely<{ note?: FlockNote }>(response)) ?? {}; if (!data.note) { throw new Error('Unable to save note.'); } setFlockNotes((current) => [data.note!, ...current]); setFlockNoteForm(emptyFlockNoteForm); } catch (noteError) { setError(noteError instanceof Error ? noteError.message : 'Unable to save note.'); } finally { setSavingFlockNote(false); } }; const handleDeleteFlockNote = async (noteId: string) => { if (!authToken) { return; } setError(''); setDeletingFlockNoteId(noteId); try { const response = await apiFetch(`/notes/${noteId}`, authToken, { method: 'DELETE' }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to delete note.')); } setFlockNotes((current) => current.filter((note) => note.id !== noteId)); } catch (noteError) { setError(noteError instanceof Error ? noteError.message : 'Unable to delete note.'); } finally { setDeletingFlockNoteId(''); } }; 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 loadDailyEducationIntoForm = (education: DailyEducation) => { setDailyEducationForm({ publishDate: education.publishDate, fact: education.fact, }); }; const loadEducationQuestionIntoForm = (question: DailyEducationQuestion) => { setEditingEducationQuestionId(question.id); setEducationQuestionForm({ prompt: question.prompt, options: [ question.options[0] ?? '', question.options[1] ?? '', question.options[2] ?? '', question.options[3] ?? '', ], correctAnswerIndex: question.correctAnswerIndex, explanation: question.explanation ?? '', }); }; const resetEducationQuestionForm = () => { setEditingEducationQuestionId(''); setEducationQuestionForm(emptyDailyEducationQuestion()); }; const handleDailyEducationSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!authToken) { return; } setError(''); setSavingDailyEducation(true); try { const response = await apiFetch('/admin/daily-education', authToken, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ publishDate: dailyEducationForm.publishDate, fact: dailyEducationForm.fact.trim(), }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to save daily education.')); } const data = (await readJsonSafely<{ education?: DailyEducation }>(response)) ?? {}; if (!data.education) { throw new Error('Unable to save daily education.'); } setAdminDailyEducation((current) => [data.education!, ...current.filter((education) => education.id !== data.education!.id && education.publishDate !== data.education!.publishDate)] .sort((left, right) => right.publishDate.localeCompare(left.publishDate)), ); if (data.education.publishDate === new Date().toISOString().slice(0, 10) && !educationOptOut) { const todayResponse = await apiFetch('/education/today', authToken); const todayData = todayResponse.ok ? await readJsonSafely<{ education?: DailyEducation | null }>(todayResponse) : null; setTodayEducation(todayData?.education ?? null); setEducationAnswers({}); } setDailyEducationForm(emptyDailyEducationForm()); } catch (educationError) { setError(educationError instanceof Error ? educationError.message : 'Unable to save daily education.'); } finally { setSavingDailyEducation(false); } }; const handleEducationQuestionSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!authToken) { return; } setError(''); setSavingEducationQuestion(true); try { const response = await apiFetch( editingEducationQuestionId ? `/admin/education-questions/${editingEducationQuestionId}` : '/admin/education-questions', authToken, { method: editingEducationQuestionId ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: educationQuestionForm.prompt.trim(), options: educationQuestionForm.options.map((option) => option.trim()), correctAnswerIndex: educationQuestionForm.correctAnswerIndex, explanation: educationQuestionForm.explanation.trim(), }), }, ); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to save education question.')); } const data = (await readJsonSafely<{ question?: DailyEducationQuestion }>(response)) ?? {}; if (!data.question) { throw new Error('Unable to save education question.'); } setAdminEducationQuestions((current) => [ data.question!, ...current.filter((question) => question.id !== data.question!.id), ]); resetEducationQuestionForm(); } catch (educationError) { setError(educationError instanceof Error ? educationError.message : 'Unable to save education question.'); } finally { setSavingEducationQuestion(false); } }; const handleDeleteEducationQuestion = async (questionId: string) => { if (!authToken) { return; } setError(''); setDeletingEducationQuestionId(questionId); try { const response = await apiFetch(`/admin/education-questions/${questionId}`, authToken, { method: 'DELETE' }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to delete education question.')); } setAdminEducationQuestions((current) => current.filter((question) => question.id !== questionId)); if (editingEducationQuestionId === questionId) { resetEducationQuestionForm(); } } catch (educationError) { setError(educationError instanceof Error ? educationError.message : 'Unable to delete education question.'); } finally { setDeletingEducationQuestionId(''); } }; const handleDeleteDailyEducation = async (educationId: string) => { if (!authToken) { return; } setError(''); setDeletingDailyEducationId(educationId); try { const response = await apiFetch(`/admin/daily-education/${educationId}`, authToken, { method: 'DELETE' }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to delete daily education.')); } setAdminDailyEducation((current) => current.filter((education) => education.id !== educationId)); if (todayEducation?.id === educationId) { setTodayEducation(null); } } catch (educationError) { setError(educationError instanceof Error ? educationError.message : 'Unable to delete daily education.'); } finally { setDeletingDailyEducationId(''); } }; const handleEducationPreferenceChange = async (nextEducationOptOut: boolean) => { if (!authToken) { return; } setError(''); setSavingEducationPreference(true); try { const response = await apiFetch('/education/preferences', authToken, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ educationOptOut: nextEducationOptOut }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to save education preference.')); } setEducationOptOut(nextEducationOptOut); if (nextEducationOptOut) { setTodayEducation(null); setDailyEducationOpen(false); } else { const todayResponse = await apiFetch('/education/today', authToken); const data = todayResponse.ok ? await readJsonSafely<{ education?: DailyEducation | null }>(todayResponse) : null; setTodayEducation(data?.education ?? null); } setEducationAnswers({}); } catch (educationError) { setError(educationError instanceof Error ? educationError.message : 'Unable to save education preference.'); } finally { setSavingEducationPreference(false); } }; 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 createdData = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; 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, }); setExpandedSettingsSection(null); if (createdData.workspace) { await handleWorkspaceSwitch(createdData.workspace.id, 'settings'); } } 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); setBirdEditorOpen(true); setBirdForm(toBirdForm(bird)); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); setError(''); setActivePage('flock'); }; 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 handleBirdImportFileChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) { return; } setError(''); setBirdImportNotice(''); setBirdImportFileName(file.name); try { const { readSheet } = await import('read-excel-file/browser'); const worksheetRows = await readSheet(file); const [headerRow, ...dataRows] = worksheetRows; if (!headerRow?.length) { throw new Error('The first worksheet does not have a header row.'); } const headers = headerRow.map((header) => toImportText(header)); const rows = dataRows .filter((cells) => cells.some((cell) => toImportText(cell))) .map((cells) => Object.fromEntries(headers.map((header, columnIndex) => [header, cells[columnIndex] ?? ''])) as Record, ); if (!rows.length) { throw new Error('The first worksheet does not have any bird rows.'); } setBirdImportPreview(parseBirdImportRows(rows)); } catch (importError) { setBirdImportPreview(null); setError(importError instanceof Error ? importError.message : 'Unable to read the bird spreadsheet.'); } finally { event.currentTarget.value = ''; } }; const handleBirdImportSubmit = async () => { if (!birdImportPreview || birdImportPreview.errors.length || importingBirds) { return; } setError(''); setBirdImportNotice(''); setImportingBirds(true); let importedBirdCount = 0; let importedWeightCount = 0; try { for (const profile of birdImportPreview.profiles) { const birdResponse = await apiFetch('/birds', authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: profile.name, tagId: profile.tagId, species: profile.species, motivators: profile.motivators, demotivators: profile.demotivators, favoriteSnack: profile.favoriteSnack, gender: profile.gender, dateOfBirth: profile.dateOfBirth, gotchaDay: profile.gotchaDay, chartColor: profile.chartColor || '#cb3a35', photoDataUrl: '', notifyOnDob: false, notifyOnGotchaDay: false, publicProfileEnabled: false, }), }); if (!birdResponse.ok) { throw new Error(await readErrorMessage(birdResponse, `Unable to import ${profile.name}.`)); } const birdData = await readJsonSafely<{ bird?: Bird }>(birdResponse); if (!birdData?.bird) { throw new Error(`Unable to import ${profile.name}.`); } let importedBird = birdData.bird; const importedWeights: WeightRecord[] = []; importedBirdCount += 1; setBirds((current) => sortBirdsByName([...current, importedBird])); for (const weight of profile.weights) { const weightResponse = await apiFetch(`/birds/${importedBird.id}/weights`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ weightGrams: weight.weightGrams, recordedOn: weight.recordedOn, notes: weight.notes, }), }); if (!weightResponse.ok) { throw new Error(await readErrorMessage(weightResponse, `Unable to import a weight for ${profile.name}.`)); } const weightData = await readJsonSafely<{ weight?: WeightRecord }>(weightResponse); if (!weightData?.weight) { throw new Error(`Unable to import a weight for ${profile.name}.`); } importedWeights.push(weightData.weight); importedWeightCount += 1; } const latestWeight = importedWeights.reduce( (latest, weight) => (!latest || weight.recordedOn >= latest.recordedOn ? weight : latest), null, ); if (latestWeight) { importedBird = { ...importedBird, latestWeightGrams: latestWeight.weightGrams, latestRecordedOn: latestWeight.recordedOn, }; } setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === importedBird.id ? importedBird : bird)))); setAllBirdWeights((current) => ({ ...current, [importedBird.id]: importedWeights })); } setBirdImportPreview(null); setBirdImportFileName(''); setBirdImportNotice( `Imported ${importedBirdCount} bird${importedBirdCount === 1 ? '' : 's'} and ${importedWeightCount} weight entr${ importedWeightCount === 1 ? 'y' : 'ies' }.`, ); } catch (importError) { setError( `${importError instanceof Error ? importError.message : 'Unable to import bird spreadsheet.'} Imported ${importedBirdCount} bird${ importedBirdCount === 1 ? '' : 's' } before the import stopped.`, ); } finally { setImportingBirds(false); } }; 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); setBirdEditorOpen(false); setBirdForm(toBirdForm(savedBird)); setBirdPhotoName(''); setActivePage('flock'); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save flock member.'); } finally { setSavingBird(false); } }; const handleVeterinaryInfoSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedBird || savingVeterinaryInfo) { return; } setError(''); setSavingVeterinaryInfo(true); try { const response = await apiFetch(`/birds/${selectedBird.id}`, authToken, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...toBirdForm(selectedBird), ...veterinaryInfoForm, }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to save veterinary info.')); } const data = await readJsonSafely<{ bird?: Bird }>(response); if (!data?.bird) { throw new Error('Unable to save veterinary info.'); } const savedBird = data.bird; setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === savedBird.id ? savedBird : bird)))); setVeterinaryInfoForm(toVeterinaryInfoForm(savedBird)); setEditingVeterinaryInfo(false); if (editingBirdId === savedBird.id) { setBirdForm(toBirdForm(savedBird)); } } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save veterinary info.'); } finally { setSavingVeterinaryInfo(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: '', remindersEnabled: false, }); 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 ?? '', remindersEnabled: medication.remindersEnabled, }); setError(''); }; const handleCancelMedicationEdit = () => { setEditingMedicationId(''); setMedicationForm({ name: '', dosage: '', frequency: 'once_daily', doseSchedule: getDefaultMedicationDoseSchedule('once_daily'), route: '', startDate: new Date().toISOString().slice(0, 10), endDate: '', notes: '', remindersEnabled: false, }); }; 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 handleTransferCodeAcceptSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (acceptingTransferCode) { return; } const code = transferCodeAcceptForm.code.trim(); setError(''); setTransferCodeError(''); setTransferCodeNotice(''); setAcceptingTransferCode(true); try { const response = await apiFetch(`/bird-transfer-codes/${encodeURIComponent(code)}/accept`, authToken, { method: 'POST', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to accept bird transfer code.')); } const data = (await readJsonSafely<{ bird?: Bird; sourceWorkspaceName?: string; }>(response)) ?? {}; if (!data.bird) { throw new Error('Unable to accept bird transfer code.'); } setBirds((current) => sortBirdsByName([...current.filter((bird) => bird.id !== data.bird!.id), data.bird!])); setSelectedBirdId(data.bird.id); setTransferCodeAcceptForm({ code: '' }); setTransferCodeNotice(`${data.bird.name} was transferred into ${workspace?.name ?? 'your active flock'}.`); } catch (submitError) { const message = submitError instanceof Error ? submitError.message : 'Unable to accept bird transfer code.'; setTransferCodeError(message); setError(message); } finally { setAcceptingTransferCode(false); } }; const handleCreateAdoptionTransferCode = async () => { if (!selectedBird || creatingAdoptionReportCode) { return null; } const existingCode = adoptionTransferCodes[selectedBird.id]; if (existingCode) { return existingCode; } setAdoptionReportError(''); setCreatingAdoptionReportCode(true); try { const response = await apiFetch(`/birds/${selectedBird.id}/transfer-code`, authToken, { method: 'POST', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to create adoption transfer code.')); } const data = (await readJsonSafely<{ transferCode?: { code?: string; bird?: Bird; }; }>(response)) ?? {}; const code = data.transferCode?.code; if (!code) { throw new Error('Unable to create adoption transfer code.'); } if (data.transferCode?.bird) { setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === data.transferCode!.bird!.id ? data.transferCode!.bird! : bird)))); } setAdoptionTransferCodes((current) => ({ ...current, [selectedBird.id]: code })); return code; } catch (codeError) { const message = codeError instanceof Error ? codeError.message : 'Unable to create adoption transfer code.'; setAdoptionReportError(message); setError(message); return null; } finally { setCreatingAdoptionReportCode(false); } }; const openAdoptionReport = (transferCode: string, reportWindow = window.open('', '_blank'), printFriendly = false) => { if (!selectedBird) { return; } if (!reportWindow) { setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.'); return; } const qr = createQrPath(transferCode); const toReportAssetUrl = (value: string) => value.startsWith('data:') || value.startsWith('http://') || value.startsWith('https://') ? value : new URL(value, window.location.origin).toString(); const reportLogoUrl = toReportAssetUrl(flockPalLandingArt); const reportWordmarkUrl = toReportAssetUrl(flockPalTextArt); const reportPhotoUrl = toReportAssetUrl(selectedBird.photoDataUrl || defaultBirdPhoto); const profileRows = [ ['Name', selectedBird.name], ['Species', selectedBird.species], ['Band/tag ID', selectedBird.tagId || 'Not recorded'], ['Sex', getBirdGenderLabel(selectedBird)], ['Hatch day', formatDate(selectedBird.dateOfBirth)], ['Favorite snack', selectedBird.favoriteSnack || 'Not recorded'], ['Latest weight', selectedBird.latestWeightGrams ? `${formatWeight(selectedBird.latestWeightGrams)}${selectedBird.latestRecordedOn ? ` on ${formatShortDate(selectedBird.latestRecordedOn)}` : ''}` : 'Pending'], ]; const vetRows = [ ['Clinic name', selectedBird.vetClinicName || 'Not recorded'], ['Clinic address', selectedBird.vetClinicAddress || 'Not recorded'], ['Account #', selectedBird.vetAccountNumber || 'Not recorded'], ['Dr. name', selectedBird.vetDoctorName || 'Not recorded'], ]; const detailList = (label: string, value: string | null) => { const entries = parseBirdProfileList(value); return entries.length ? `

${escapeReportHtml(label)}

    ${entries.map((entry) => `
  • ${escapeReportHtml(entry)}
  • `).join('')}
` : `

${escapeReportHtml(label)}

Not recorded

`; }; const weightRows = weights.length ? weights .map( (entry) => `${escapeReportHtml(formatDate(entry.recordedOn))}${escapeReportHtml(formatWeight(entry.weightGrams))}${escapeReportHtml(entry.notes || '')}`, ) .join('') : 'No weights recorded.'; const vetVisitRows = vetVisits.length ? vetVisits .map( (visit) => `${escapeReportHtml(formatDate(visit.visitedOn))}${escapeReportHtml(visit.clinicName)}${escapeReportHtml(visit.reason)}${escapeReportHtml(visit.notes || '')}`, ) .join('') : 'No vet visits recorded.'; const noteRows = selectedBirdNotes.length ? selectedBirdNotes .map( (note) => `
${escapeReportHtml(formatDateTime(note.updatedAt))}

${escapeReportHtml(note.body)}

`, ) .join('') : '

No notes recorded.

'; const chartSvg = selectedBirdChart.points.length || selectedBirdChart.historicalPoints.length ? ` ${selectedBirdChart.yTicks .map( (tick) => ``, ) .join('')} ${selectedBirdChart.historicalPath ? `` : ''} ${selectedBirdChart.path ? `` : ''} ${selectedBirdChart.points .map((point) => `${escapeReportHtml(point.label)}`) .join('')} ` : '

No weight graph available yet.

'; const bodyBackground = printFriendly ? 'var(--paper)' : `radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.28), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.26), transparent 20%), radial-gradient(circle at 24% 84%, rgba(221, 179, 78, 0.2), transparent 22%), radial-gradient(circle at 86% 78%, rgba(43, 118, 92, 0.24), transparent 24%), radial-gradient(circle at 62% 54%, rgba(48, 114, 160, 0.14), transparent 16%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%)`; const headerBackground = printFriendly ? '#fff' : 'linear-gradient(135deg, rgba(252, 244, 228, 0.96), rgba(232, 243, 233, 0.9))'; const panelBackground = printFriendly ? '#fff' : 'var(--panel)'; const backgroundOverlayCss = printFriendly ? '' : `body::before { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='420' viewBox='0 0 360 420'%3E%3Cg fill='none' stroke-linecap='round' stroke-width='18' opacity='.5'%3E%3Cg stroke='%235bb3b7' transform='translate(54 42) rotate(-18)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3Cg stroke='%237eb773' transform='translate(204 78) rotate(28)'%3E%3Cpath d='M0 -38v76'/%3E%3Cpath d='M-32 -19l64 38'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(312 54) rotate(-38)'%3E%3Cpath d='M0 -28v56'/%3E%3Cpath d='M-24 -14l48 28'/%3E%3C/g%3E%3Cg stroke='%23898b93' transform='translate(118 172) rotate(42)'%3E%3Cpath d='M0 -30v60'/%3E%3Cpath d='M-26 -15l52 30'/%3E%3C/g%3E%3Cg stroke='%23b9c945' transform='translate(278 208) rotate(-12)'%3E%3Cpath d='M0 -36v72'/%3E%3Cpath d='M-31 -18l62 36'/%3E%3C/g%3E%3Cg stroke='%235bb3b7' transform='translate(52 326) rotate(22)'%3E%3Cpath d='M0 -26v52'/%3E%3Cpath d='M-22 -13l44 26'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(186 352) rotate(-48)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); background-position: center top; background-repeat: repeat; background-size: 360px 420px; content: ""; inset: 0; opacity: 0.42; pointer-events: none; position: fixed; z-index: -1; }`; reportWindow.document.write(` FlockPal Adoption Report - ${escapeReportHtml(selectedBird.name)}
${escapeReportHtml(selectedBird.name)} profile photo

${escapeReportHtml(selectedBird.name)}

Adoption Report

Generated ${escapeReportHtml(formatDateTime(new Date().toISOString()))}

Join

FlockPal

${escapeReportHtml(transferCode)}

Enter this code to keep ${escapeReportHtml(selectedBird.name)}'s care history flying forward.

Flock Member Info

${profileRows.map(([label, value]) => `
${escapeReportHtml(label)}${escapeReportHtml(value)}
`).join('')}
${detailList('Motivators', selectedBird.motivators)} ${detailList('Demotivators', selectedBird.demotivators)}

Weight Graph

${chartSvg}

Weight History

${weightRows}
DateWeightNotes

Veterinary Clinic Info

${vetRows.map(([label, value]) => `
${escapeReportHtml(label)}${escapeReportHtml(value)}
`).join('')}

Vet Visit History

${vetVisitRows}
DateClinicReasonNotes

Notes

${noteRows}
`); reportWindow.document.close(); reportWindow.focus(); }; const handleOpenAdoptionReport = async (printFriendly = false) => { const reportWindow = window.open('', '_blank'); if (!reportWindow) { setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.'); return; } if (!selectedBird) { reportWindow.close(); return; } setAdoptionReportError(''); setCreatingAdoptionReportCode(true); try { const response = await apiFetch( `/birds/${selectedBird.id}/reports/adoption${printFriendly ? '?printFriendly=true' : ''}`, authToken, { method: 'POST' }, ); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to create adoption report.')); } const transferCode = response.headers.get('X-FlockPal-Transfer-Code'); if (transferCode) { setAdoptionTransferCodes((current) => ({ ...current, [selectedBird.id]: transferCode })); } const reportBlob = await response.blob(); const reportUrl = URL.createObjectURL(reportBlob); reportWindow.location.href = reportUrl; window.setTimeout(() => URL.revokeObjectURL(reportUrl), 60_000); } catch (reportError) { reportWindow.close(); const message = reportError instanceof Error ? reportError.message : 'Unable to create adoption report.'; setAdoptionReportError(message); setError(message); } finally { setCreatingAdoptionReportCode(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. If this flock has a Stripe subscription, FlockPal will cancel it before deleting the flock.\n\nYou will be switched to another flock or a new empty 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'); }; const publicProfileWorkspaceMembership = publicProfile ? authSession?.workspaces.find((entry) => entry.workspace.id === publicProfile.workspaceId) ?? null : null; const shouldShowPublicProfilePage = Boolean(publicProfileCode) && (!authSession || !publicProfile || workspace?.id !== publicProfile.workspaceId || !birds.some((bird) => bird.id === publicProfile.id)); if (shouldShowPublicProfilePage) { return (
FlockPal {publicProfileLoading || authLoading ? (

Loading bird profile...

) : publicProfileError || !publicProfile ? ( <>

FlockPal

Public profile unavailable

{publicProfileError || 'This bird profile is not available publicly.'}

) : ( <> {publicProfile.name}

{publicProfile.name} {getBirdGenderSymbol(publicProfile)}

Hatch Day {formatDate(publicProfile.dateOfBirth)}
Favorite treat {publicProfile.favoriteSnack || 'Not recorded'}
{publicProfileWorkspaceMembership ? ( ) : null}
)}
); } 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.