import { useEffect, useMemo, useState } from 'react'; import flockPalLandingArt from './assets/flockpal-landing-art.png'; import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference'; type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; type HouseholdBillingPlan = Exclude; 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; species: string; gender: BirdGender; dateOfBirth: string | null; gotchaDay: string | null; chartColor: string; photoDataUrl: string | null; notifyOnDob: boolean; notifyOnGotchaDay: 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 Workspace = { id: number; name: string; workspaceType: WorkspaceType; billingEmail: string | null; billingPlan: BillingPlan; subscriptionStatus: SubscriptionStatus; 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; totalUsers: number; totalWorkspaces: number; rescueWorkspaces: number; pendingRescues: number; dailyUsers: number; }; type AdminRescueWorkspace = { workspace: Workspace; ownerEmail: string | null; birdCount: number; memberCount: number; }; type IntegrationTokenSummary = { id: string; userId: string; workspaceId: number; name: string; tokenPrefix: string; scope: IntegrationTokenScope; lastUsedAt: string | null; expiresAt: string | null; revokedAt: string | null; createdAt: string; }; type IntegrationTokenFormState = { name: string; scope: IntegrationTokenScope; expiresInDays: string; }; type BirdFormState = { name: string; tagId: string; species: string; gender: BirdGender; dateOfBirth: string; gotchaDay: string; chartColor: string; photoDataUrl: string; notifyOnDob: boolean; notifyOnGotchaDay: boolean; }; type WorkspaceFormState = { name: string; workspaceType: WorkspaceType; billingEmail: string; billingPlan: HouseholdBillingPlan; }; type WorkspaceMemberFormState = { name: string; email: string; role: WorkspaceRole; }; type WorkspaceCreateFormState = { name: string; workspaceType: WorkspaceType; billingEmail: string; billingPlan: HouseholdBillingPlan; }; type AuthFormState = { name: string; email: string; }; type AuthNotice = { message: string; previewUrl?: string | null; }; 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 PhotoCropState = { sourceDataUrl: string; fileName: string; naturalWidth: number; naturalHeight: number; zoom: number; offsetX: number; offsetY: number; }; type PhotoDragState = { pointerId: number; startX: number; startY: number; startOffsetX: number; startOffsetY: number; }; type AppPage = 'overview' | 'flock' | 'settings' | 'admin'; type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; const sessionTokenStorageKey = 'flockpal_auth_token'; const emptyBirdForm: BirdFormState = { name: '', tagId: '', species: '', gender: 'unknown', dateOfBirth: '', gotchaDay: '', chartColor: '#cb3a35', photoDataUrl: '', notifyOnDob: false, notifyOnGotchaDay: false, }; const emptyWorkspaceForm: WorkspaceFormState = { name: 'My Flock', workspaceType: 'standard', billingEmail: '', billingPlan: 'household_basic', }; const emptyWorkspaceMemberForm: WorkspaceMemberFormState = { name: '', email: '', role: 'caregiver', }; const emptyWorkspaceCreateForm: WorkspaceCreateFormState = { name: '', workspaceType: 'standard', billingEmail: '', billingPlan: 'household_basic', }; const emptyAuthForm: AuthFormState = { name: '', email: '', }; const emptyIntegrationTokenForm: IntegrationTokenFormState = { name: '', scope: 'read_write', expiresInDays: '', }; const defaultAuthProviders: AuthProvider[] = [ { providerKey: 'google', displayName: 'Google', enabled: false }, { providerKey: 'microsoft', displayName: 'Microsoft', enabled: false }, { providerKey: 'apple', displayName: 'Apple', enabled: false }, ]; const ProviderIcon = ({ providerKey }: { providerKey: AuthProvider['providerKey'] }) => { if (providerKey === 'google') { return ( ); } if (providerKey === 'microsoft') { return ( ); } return ( ); }; const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right) => left.name.localeCompare(right.name)); const toBirdForm = (bird: Bird): BirdFormState => ({ name: bird.name, tagId: bird.tagId, species: bird.species, gender: bird.gender, dateOfBirth: bird.dateOfBirth ?? '', gotchaDay: bird.gotchaDay ?? '', chartColor: bird.chartColor, photoDataUrl: bird.photoDataUrl ?? '', notifyOnDob: bird.notifyOnDob, notifyOnGotchaDay: bird.notifyOnGotchaDay, }); const formatDate = (value: string | null) => { if (!value) { return 'Not set'; } return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', }).format(new Date(`${value}T00:00:00`)); }; const formatShortDate = (value: string | null) => { if (!value) { return 'No data yet'; } return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', }).format(new Date(`${value}T00:00:00`)); }; const getBirdGenderLabel = (bird: Pick) => { if (bird.gender === 'female') { return 'Female'; } if (bird.gender === 'male') { return 'Male'; } return 'Unknown'; }; const getBirdGenderSymbol = (bird: Pick) => { if (bird.gender === 'female') { return '♀'; } if (bird.gender === 'male') { return '♂'; } return '?'; }; const formatDateTime = (value: string | null) => { if (!value) { return 'Never'; } return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', }).format(new Date(value)); }; const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`; const parseDateValue = (value: string) => new Date(`${value}T00:00:00`); const 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 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 oauthStartUrl = (providerKey: AuthProvider['providerKey']) => { const url = new URL(`${apiBaseUrl}/auth/oauth/${providerKey}/start`, window.location.origin); url.searchParams.set('redirectTo', window.location.href); return url.toString(); }; const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan => billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw'; const formatBillingPlanName = (billingPlan: BillingPlan) => { if (billingPlan === 'rescue_free') { return 'Rescue Free'; } if (billingPlan === 'household_basic') { return 'Conure'; } if (billingPlan === 'household_plus') { return 'Indian Ringneck'; } return 'Macaw'; }; const formatBillingPlanCapacity = (billingPlan: BillingPlan) => { if (billingPlan === 'rescue_free') { return 'No billing is applied to rescue flocks.'; } if (billingPlan === 'household_basic') { return 'Permits up to 4 birds in the flock.'; } if (billingPlan === 'household_plus') { return 'Permits 5 to 10 birds in the flock.'; } return 'Permits 11 or more birds in the flock.'; }; const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => { if (billingPlan === 'household_basic') { return '4'; } if (billingPlan === 'household_plus') { return '10'; } if (billingPlan === 'household_macaw') { return '11+'; } return null; }; const formatSubscriptionStatus = (status: SubscriptionStatus) => { if (status === 'trialing') { return 'Trialing'; } if (status === 'past_due') { return 'Past due'; } if (status === 'canceled') { return 'Canceled'; } if (status === 'unpaid') { return 'Unpaid'; } if (status === 'none') { return 'No subscription'; } return 'Active'; }; const formatRescueVerificationStatus = (status: RescueVerificationStatus) => { if (status === 'approved') { return 'Approved'; } 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) => { 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 = parseDateValue(point.recordedOn).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 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 [authLoading, setAuthLoading] = useState(true); const [authSubmitting, setAuthSubmitting] = useState(false); const [workspace, setWorkspace] = useState(null); const [activeMembership, setActiveMembership] = useState(null); const [workspaceMembers, setWorkspaceMembers] = useState([]); const [integrationTokens, setIntegrationTokens] = useState([]); const [adminSummary, setAdminSummary] = useState(null); const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState([]); const [birds, setBirds] = useState([]); const [selectedBirdId, setSelectedBirdId] = useState(''); const [editingBirdId, setEditingBirdId] = useState(''); const [weights, setWeights] = useState([]); const [vetVisits, setVetVisits] = useState([]); const [allBirdWeights, setAllBirdWeights] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [workspaceForm, setWorkspaceForm] = useState(emptyWorkspaceForm); const [workspaceMemberForm, setWorkspaceMemberForm] = useState(emptyWorkspaceMemberForm); const [workspaceCreateForm, setWorkspaceCreateForm] = useState(emptyWorkspaceCreateForm); const [integrationTokenForm, setIntegrationTokenForm] = useState(emptyIntegrationTokenForm); const [birdForm, setBirdForm] = useState(emptyBirdForm); const [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 [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false); const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState(null); const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState(null); const [showWeightAlertModal, setShowWeightAlertModal] = useState(false); const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false); const [bulkWeightOpen, setBulkWeightOpen] = useState(false); const [savingBulkWeights, setSavingBulkWeights] = useState(false); const [bulkWeightDate, setBulkWeightDate] = useState(new Date().toISOString().slice(0, 10)); const [bulkWeightRows, setBulkWeightRows] = useState>({}); const [weightForm, setWeightForm] = useState({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '', }); const [vetVisitForm, setVetVisitForm] = useState({ visitedOn: new Date().toISOString().slice(0, 10), clinicName: '', reason: '', notes: '', }); const [mergeForm, setMergeForm] = useState({ birdId: '', destinationOwnerEmail: '', notes: '', }); const [deletingBird, setDeletingBird] = useState(false); const [editingVetVisitId, setEditingVetVisitId] = useState(''); const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState(''); const [expandedSettingsSection, setExpandedSettingsSection] = useState(null); const selectedBird = useMemo( () => birds.find((bird) => bird.id === selectedBirdId) ?? null, [birds, selectedBirdId], ); const editingBird = useMemo( () => birds.find((bird) => bird.id === editingBirdId) ?? null, [birds, editingBirdId], ); const birdsWithRecentWeights = useMemo( () => birds.filter((bird) => (allBirdWeights[bird.id] ?? []).length > 0), [allBirdWeights, birds], ); const showFlockDetailColumn = bulkWeightOpen || Boolean(selectedBird); const missingFirstWeightCount = useMemo( () => birds.filter((bird) => bird.latestWeightGrams === null).length, [birds], ); const birdWeightAssessments = useMemo( () => Object.fromEntries( birds.map((bird) => [ bird.id, assessBirdWeight(bird), ]), ) as Record, [birds], ); const selectedBirdTrendCopy = useMemo(() => { if (weights.length < 2) { return 'Needs a few more entries before trend detection.'; } const first = weights[0].weightGrams; const last = weights[weights.length - 1].weightGrams; const delta = last - first; if (Math.abs(delta) < 1) { return 'Weight has been steady over the last visible entries.'; } return delta > 0 ? `Weight is up ${delta.toFixed(1)} g over the current window.` : `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`; }, [weights]); const outOfRangeBirds = 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 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(() => { if (!weights.length) { return { points: [] as { id: string; x: number; y: number; label: string }[], path: '', isFlat: false, yTicks: [] as { label: string; y: number }[], xTicks: [] as { label: string; x: number }[], }; } const rawMinWeight = Math.min(...weights.map((entry) => entry.weightGrams)); const rawMaxWeight = Math.max(...weights.map((entry) => entry.weightGrams)); const isFlat = Math.abs(rawMaxWeight - rawMinWeight) < 0.01; const padding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2); const minWeight = Math.max(0, rawMinWeight - padding); const maxWeight = rawMaxWeight + padding; 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 startDate = parseDateValue(weights[0].recordedOn); const endDate = parseDateValue(weights[weights.length - 1].recordedOn); 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); const points = weights.map((entry) => { const pointTime = parseDateValue(entry.recordedOn).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 path = toOverviewPath(points); const midWeight = minWeight + (maxWeight - minWeight) / 2; const midDate = new Date((startMs + endMs) / 2); return { points, path, isFlat, 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(weights[0].recordedOn), x: MEMBER_CHART_PADDING.left }, { label: formatShortDate(midDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH / 2 }, { label: formatShortDate(weights[weights.length - 1].recordedOn), x: MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right }, ], }; }, [weights]); const hasSelectedBirdLine = selectedBirdChart.points.length >= 2 && selectedBirdChart.path.length > 0; const flockWeeklyTrendItems = useMemo(() => { return birds .map((bird) => { const birdWeights = allBirdWeights[bird.id] ?? []; if (!birdWeights.length) { return null; } const latestWeight = birdWeights[birdWeights.length - 1]; const weekStart = new Date(`${latestWeight.recordedOn}T00:00:00`); weekStart.setDate(weekStart.getDate() - 7); const weeklyWeights = birdWeights.filter( (entry) => new Date(`${entry.recordedOn}T00:00:00`) >= weekStart, ); if (weeklyWeights.length < 2) { return null; } const startingWeight = weeklyWeights[0].weightGrams; const percentChange = startingWeight === 0 ? 0 : ((latestWeight.weightGrams - startingWeight) / startingWeight) * 100; return { id: bird.id, name: bird.name, chartColor: bird.chartColor, formattedChange: `${percentChange >= 0 ? '+' : ''}${percentChange.toFixed(1)}%`, direction: percentChange >= 0 ? 'positive' : 'negative', } as const; }) .filter((trend): trend is NonNullable => trend !== null); }, [allBirdWeights, birds]); const overviewChart = useMemo(() => { const plottedBirds = birds .map((bird) => ({ bird, weights: allBirdWeights[bird.id] ?? [] })) .filter((entry) => entry.weights.length > 0); const endDate = new Date(); endDate.setHours(0, 0, 0, 0); const startDate = new Date(endDate); startDate.setDate(startDate.getDate() - 29); if (!plottedBirds.length) { return { plottedBirds, series: [], 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.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), })), 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]); const applySession = (session: AuthSessionPayload, token: string) => { setAuthToken(token); setAuthSession(session); setAuthProviders(session.providers); setAuthNotice(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', }); setWorkspaceCreateForm((current) => ({ ...current, billingEmail: current.billingEmail || session.user.email, })); }; const clearAppSession = () => { clearSessionToken(); setAuthToken(''); setAuthSession(null); setWorkspace(null); setActiveMembership(null); setWorkspaceMembers([]); setIntegrationTokens([]); setAdminSummary(null); setAdminRescueWorkspaces([]); setBirds([]); setWeights([]); setVetVisits([]); setAllBirdWeights({}); setSelectedBirdId(''); setEditingBirdId(''); setWorkspaceForm(emptyWorkspaceForm); setWorkspaceCreateForm(emptyWorkspaceCreateForm); setIntegrationTokenForm(emptyIntegrationTokenForm); setNewIntegrationTokenSecret(''); setAuthNotice(null); }; 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 token = callbackToken || readStoredSessionToken(); if (callbackToken) { persistSessionToken(callbackToken); url.searchParams.delete('auth_token'); window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); } if (!token) { return; } const response = await apiFetch('/auth/session', token); if (!response.ok) { clearAppSession(); return; } const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; if (data.session && (data.token || token)) { persistSessionToken(data.token || token); applySession(data.session, data.token || token); setError(''); } } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load your session.'); } finally { setAuthLoading(false); } }; void bootstrapSession(); }, []); useEffect(() => { if (!authToken || !workspace?.id) { setLoading(false); return; } const loadWorkspaceData = async () => { try { setLoading(true); const [birdsResponse, membersResponse, integrationTokensResponse] = await Promise.all([ apiFetch('/birds', authToken), apiFetch('/workspace/members', authToken), apiFetch('/integration-tokens', authToken), ]); if (!birdsResponse.ok) { if (birdsResponse.status === 401) { clearAppSession(); return; } throw new Error(await readErrorMessage(birdsResponse, 'Unable to load flock members.')); } const data = (await readJsonSafely<{ birds?: Bird[] }>(birdsResponse)) ?? {}; const nextBirds = data.birds ?? []; setBirds(nextBirds); setSelectedBirdId((current) => (current && nextBirds.some((bird) => bird.id === current) ? current : '')); if (membersResponse.ok) { const membersData = (await readJsonSafely<{ members?: WorkspaceMember[] }>(membersResponse)) ?? {}; setWorkspaceMembers( (membersData.members ?? []).map((member) => ({ ...member, email: member.inviteEmail, })), ); } else { setWorkspaceMembers([]); } if (integrationTokensResponse.ok) { const integrationTokensData = (await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {}; setIntegrationTokens(integrationTokensData.integrationTokens ?? []); } else { setIntegrationTokens([]); } } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.'); } finally { setLoading(false); } }; void loadWorkspaceData(); }, [authToken, workspace?.id]); useEffect(() => { if (!authToken || !authSession?.isAdmin || activePage !== 'admin') { return; } const loadAdminDashboard = async () => { try { const [summaryResponse, rescuesResponse] = await Promise.all([ apiFetch('/admin/summary', authToken), apiFetch('/admin/rescue-workspaces', authToken), ]); if (!summaryResponse.ok || !rescuesResponse.ok) { throw new Error('Unable to load admin dashboard.'); } const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {}; const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {}; setAdminSummary(summaryData.summary ?? null); setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []); } catch (adminError) { setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.'); } }; void loadAdminDashboard(); }, [activePage, authSession?.isAdmin, authToken]); useEffect(() => { if (!selectedBird?.id) { setWeights([]); setVetVisits([]); return; } const loadBirdDetail = async () => { try { const [weightsResponse, visitsResponse] = await Promise.all([ apiFetch(`/birds/${selectedBird.id}/weights?days=90`, authToken), apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken), ]); if (!weightsResponse.ok || !visitsResponse.ok) { throw new Error('Unable to load flock member details.'); } const weightsData = (await readJsonSafely<{ weights?: WeightRecord[] }>(weightsResponse)) ?? {}; const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {}; setWeights(weightsData.weights ?? []); setVetVisits(visitsData.vetVisits ?? []); setEditingVetVisitId(''); setDeletingVetVisitId(''); } 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=30`, 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 (!editingBirdId) { return; } if (!editingBird) { setEditingBirdId(''); setBirdForm(emptyBirdForm); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); return; } setBirdForm(toBirdForm(editingBird)); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); }, [editingBird, editingBirdId]); useEffect(() => { setBulkWeightRows((current) => { const nextEntries = birds.map((bird) => [bird.id, current[bird.id] ?? { weightGrams: '' }] as const); return Object.fromEntries(nextEntries); }); }, [birds]); const startCreateBird = () => { setEditingBirdId(''); setBirdForm(emptyBirdForm); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); setError(''); setActivePage('settings'); }; const handleAuthSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); setAuthNotice(null); setAuthSubmitting(true); try { const response = await apiFetch('/auth/magic-link/request', undefined, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: authForm.name.trim(), email: authForm.email, redirectTo: window.location.href, }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to send your sign-in link.')); } const data = (await readJsonSafely<{ message?: string; previewUrl?: string | null }>(response)) ?? {}; setAuthNotice({ message: data.message ?? 'Check your email for a secure sign-in link.', previewUrl: data.previewUrl, }); setAuthForm(emptyAuthForm); } catch (authError) { setError(authError instanceof Error ? authError.message : 'Unable to send your sign-in link.'); } finally { setAuthSubmitting(false); } }; const handleLogout = async () => { setError(''); try { if (authToken) { await apiFetch('/auth/logout', authToken, { method: 'POST' }); } } catch { // Best-effort logout. } finally { clearAppSession(); setAuthForm(emptyAuthForm); } }; const handleWorkspaceSwitch = async (workspaceId: number) => { if (!authToken || workspaceId === workspace?.id) { return; } setError(''); setSwitchingWorkspaceId(workspaceId); try { const response = await apiFetch('/auth/switch-workspace', authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workspaceId }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to switch flocks.')); } const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; if (!data.session) { throw new Error('Unable to switch flocks.'); } const nextToken = data.token || authToken; persistSessionToken(nextToken); applySession(data.session, nextToken); setSelectedBirdId(''); setEditingBirdId(''); setWeights([]); setVetVisits([]); setActivePage('overview'); } catch (switchError) { setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.'); } finally { setSwitchingWorkspaceId(null); } }; const handleCreateIntegrationToken = async (event: React.FormEvent) => { event.preventDefault(); if (!authToken) { return; } setError(''); setCreatingIntegrationToken(true); try { const response = await apiFetch('/integration-tokens', authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: integrationTokenForm.name.trim(), scope: integrationTokenForm.scope, expiresInDays: integrationTokenForm.expiresInDays ? Number(integrationTokenForm.expiresInDays) : undefined, }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to create integration token.')); } const data = (await readJsonSafely<{ integrationToken?: IntegrationTokenSummary; token?: string }>(response)) ?? {}; if (!data.integrationToken || !data.token) { throw new Error('Unable to create integration token.'); } setIntegrationTokens((current) => [data.integrationToken!, ...current]); setIntegrationTokenForm(emptyIntegrationTokenForm); setNewIntegrationTokenSecret(data.token); setExpandedSettingsSection('integration-tokens'); } catch (integrationTokenError) { setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to create integration token.'); } finally { setCreatingIntegrationToken(false); } }; const handleRevokeIntegrationToken = async (tokenId: string) => { if (!authToken) { return; } setError(''); setRevokingIntegrationTokenId(tokenId); try { const response = await apiFetch(`/integration-tokens/${tokenId}`, authToken, { method: 'DELETE', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to revoke integration token.')); } setIntegrationTokens((current) => current.filter((token) => token.id !== tokenId)); } catch (integrationTokenError) { setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to revoke integration token.'); } finally { setRevokingIntegrationTokenId(''); } }; const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => { if (!authToken) { return; } setError(''); setUpdatingRescueWorkspaceId(workspaceId); try { const response = await apiFetch(`/admin/rescue-workspaces/${workspaceId}`, authToken, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rescueVerificationStatus }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to update rescue verification status.')); } const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; if (!data.workspace) { throw new Error('Unable to update rescue verification status.'); } const nextRescueWorkspaces = adminRescueWorkspaces .map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry)) .filter((entry) => entry.workspace.workspaceType === 'rescue'); setAdminRescueWorkspaces(nextRescueWorkspaces); setAdminSummary((current) => current ? { ...current, rescueWorkspaces: nextRescueWorkspaces.length, pendingRescues: nextRescueWorkspaces.filter((entry) => entry.workspace.rescueVerificationStatus === 'pending').length, } : current, ); } catch (adminError) { setError(adminError instanceof Error ? adminError.message : 'Unable to update rescue verification status.'); } finally { setUpdatingRescueWorkspaceId(null); } }; const handleCreateWorkspace = async (event: React.FormEvent) => { event.preventDefault(); if (!authToken) { return; } setError(''); setCreatingWorkspace(true); try { const response = await apiFetch('/workspaces', authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: workspaceCreateForm.name, workspaceType: workspaceCreateForm.workspaceType, billingEmail: workspaceCreateForm.billingEmail, billingPlan: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingPlan, }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to create flock.')); } const workspaceResponse = await apiFetch('/auth/session', authToken); if (!workspaceResponse.ok) { throw new Error(await readErrorMessage(workspaceResponse, 'Flock was created but the session could not be refreshed.')); } const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(workspaceResponse)) ?? {}; if (!data.session) { throw new Error('Unable to refresh your flock list.'); } const nextToken = data.token || authToken; persistSessionToken(nextToken); applySession(data.session, nextToken); setWorkspaceCreateForm({ ...emptyWorkspaceCreateForm, billingEmail: data.session.user.email, }); } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.'); } finally { setCreatingWorkspace(false); } }; const startEditBird = (bird: Bird) => { setSelectedBirdId(bird.id); setEditingBirdId(bird.id); setBirdForm(toBirdForm(bird)); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); setError(''); setActivePage('settings'); }; const handleBirdPhotoChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) { return; } if (file.size > 12_000_000) { setError('Photo is too large to process in the browser. Please choose one under 12 MB.'); event.target.value = ''; return; } try { const photoDataUrl = await readFileAsDataUrl(file); const image = await loadImageElement(photoDataUrl); setPhotoCrop({ sourceDataUrl: photoDataUrl, fileName: file.name, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight, zoom: 1, offsetX: 0, offsetY: 0, }); setPhotoDrag(null); setBirdPhotoName(file.name); setError(''); } catch (photoError) { setError(photoError instanceof Error ? photoError.message : 'Unable to read that photo.'); } finally { event.target.value = ''; } }; const handleApplyPhotoCrop = async () => { if (!photoCrop) { return; } setApplyingPhotoCrop(true); setError(''); try { const photoDataUrl = await exportCroppedPhoto(photoCrop); setBirdForm((current) => ({ ...current, photoDataUrl })); setBirdPhotoName(photoCrop.fileName); setPhotoCrop(null); setPhotoDrag(null); } catch (cropError) { setError(cropError instanceof Error ? cropError.message : 'Unable to crop that photo.'); } finally { setApplyingPhotoCrop(false); } }; const handleCancelPhotoCrop = () => { setPhotoCrop(null); setPhotoDrag(null); setError(''); }; const handleRemovePhoto = () => { setBirdForm((current) => ({ ...current, photoDataUrl: '' })); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); }; const handlePhotoCropPointerDown = (event: React.PointerEvent) => { if (!photoCrop) { return; } event.currentTarget.setPointerCapture(event.pointerId); setPhotoDrag({ pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startOffsetX: photoCrop.offsetX, startOffsetY: photoCrop.offsetY, }); }; const handlePhotoCropPointerMove = (event: React.PointerEvent) => { if (!photoCrop || !photoDrag || photoDrag.pointerId !== event.pointerId) { return; } const metrics = getPhotoCropMetrics(photoCrop); const deltaX = event.clientX - photoDrag.startX; const deltaY = event.clientY - photoDrag.startY; const nextOffsetX = metrics.maxOffsetX > 0 ? clamp(photoDrag.startOffsetX - (deltaX / (metrics.maxOffsetX * metrics.scale)) * 100, -100, 100) : 0; const nextOffsetY = metrics.maxOffsetY > 0 ? clamp(photoDrag.startOffsetY - (deltaY / (metrics.maxOffsetY * metrics.scale)) * 100, -100, 100) : 0; setPhotoCrop((current) => (current ? { ...current, offsetX: nextOffsetX, offsetY: nextOffsetY } : current)); }; const handlePhotoCropPointerUp = (event: React.PointerEvent) => { if (photoDrag?.pointerId === event.pointerId) { event.currentTarget.releasePointerCapture(event.pointerId); setPhotoDrag(null); } }; const handleBirdSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); setSavingBird(true); const isEditing = Boolean(editingBirdId); const method = isEditing ? 'PUT' : 'POST'; try { const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(birdForm), }); if (!response.ok) { throw new Error(await readErrorMessage(response, `Unable to ${isEditing ? 'update' : 'create'} flock member.`)); } const data = await readJsonSafely<{ bird: Bird }>(response); if (!data?.bird) { throw new Error(`Unable to ${isEditing ? 'update' : 'create'} flock member.`); } const savedBird = data.bird as Bird; setBirds((current) => { if (isEditing) { return sortBirdsByName(current.map((bird) => (bird.id === savedBird.id ? savedBird : bird))); } return sortBirdsByName([...current, savedBird]); }); setSelectedBirdId(isEditing ? savedBird.id : ''); setEditingBirdId(savedBird.id); setBirdForm(toBirdForm(savedBird)); setBirdPhotoName(''); setActivePage(isEditing ? 'settings' : 'flock'); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save flock member.'); } finally { setSavingBird(false); } }; const handleWeightSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedBird) { return; } setError(''); try { const response = await apiFetch(`/birds/${selectedBird.id}/weights`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ weightGrams: Number(weightForm.weightGrams), recordedOn: weightForm.recordedOn, notes: weightForm.notes, }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to save weight.')); } const data = await readJsonSafely<{ weight: WeightRecord }>(response); if (!data?.weight) { throw new Error('Unable to save weight.'); } const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn)); setWeights(nextWeights); setAllBirdWeights((current) => ({ ...current, [selectedBird.id]: nextWeights.filter((entry) => { const limitDate = new Date(); limitDate.setDate(limitDate.getDate() - 29); return new Date(`${entry.recordedOn}T00:00:00`) >= new Date(limitDate.toDateString()); }), })); setBirds((current) => current.map((bird) => bird.id === selectedBird.id ? { ...bird, latestWeightGrams: data.weight.weightGrams, latestRecordedOn: data.weight.recordedOn, } : bird, ), ); setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' }); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save weight.'); } }; const handleBulkWeightValueChange = (birdId: string, weightGrams: string) => { setBulkWeightRows((current) => ({ ...current, [birdId]: { weightGrams, }, })); }; const handleBulkWeightSubmit = async (event: React.FormEvent) => { event.preventDefault(); const entries = birds .map((bird) => ({ bird, weightGrams: bulkWeightRows[bird.id]?.weightGrams?.trim() ?? '', })) .filter((entry) => entry.weightGrams); if (!entries.length) { setError('Add at least one weight before saving the bulk update.'); return; } setError(''); setSavingBulkWeights(true); try { const savedWeights: Record = {}; for (const entry of entries) { const response = await apiFetch(`/birds/${entry.bird.id}/weights`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ weightGrams: Number(entry.weightGrams), recordedOn: bulkWeightDate, notes: '', }), }); if (!response.ok) { throw new Error(await readErrorMessage(response, `Unable to save weight for ${entry.bird.name}.`)); } const data = await readJsonSafely<{ weight?: WeightRecord }>(response); if (!data?.weight) { throw new Error(`Unable to save weight for ${entry.bird.name}.`); } savedWeights[entry.bird.id] = data.weight; } setBirds((current) => current.map((bird) => { const savedWeight = savedWeights[bird.id]; if (!savedWeight) { return bird; } return { ...bird, latestWeightGrams: savedWeight.weightGrams, latestRecordedOn: savedWeight.recordedOn, }; }), ); setAllBirdWeights((current) => { const next = { ...current }; const limitDate = new Date(); limitDate.setDate(limitDate.getDate() - 29); const limitMs = new Date(limitDate.toDateString()).getTime(); for (const [birdId, savedWeight] of Object.entries(savedWeights)) { const nextWeights = [...(current[birdId] ?? []), savedWeight] .sort((left, right) => left.recordedOn.localeCompare(right.recordedOn)) .filter((entry) => new Date(`${entry.recordedOn}T00:00:00`).getTime() >= limitMs); next[birdId] = nextWeights; } return next; }); if (selectedBird?.id && savedWeights[selectedBird.id]) { setWeights((current) => [...current.filter((entry) => entry.recordedOn !== bulkWeightDate), savedWeights[selectedBird.id]].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn), ), ); } setBulkWeightRows((current) => Object.fromEntries( Object.entries(current).map(([birdId]) => [birdId, { weightGrams: '' }]), ), ); } catch (bulkError) { setError(bulkError instanceof Error ? bulkError.message : 'Unable to save the bulk weight update.'); } finally { setSavingBulkWeights(false); } }; const handleVetVisitSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedBird) { return; } setError(''); try { const isEditingVetVisit = Boolean(editingVetVisitId); const response = await apiFetch( isEditingVetVisit ? `/birds/${selectedBird.id}/vet-visits/${editingVetVisitId}` : `/birds/${selectedBird.id}/vet-visits`, authToken, { method: isEditingVetVisit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(vetVisitForm), }, ); if (!response.ok) { throw new Error(await readErrorMessage(response, `Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`)); } const data = await readJsonSafely<{ vetVisit: VetVisit }>(response); if (!data?.vetVisit) { throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`); } setVetVisits((current) => (isEditingVetVisit ? current.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...current]).sort( (left, right) => right.visitedOn.localeCompare(left.visitedOn), ), ); 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.')); } setVetVisits((current) => current.filter((visit) => visit.id !== visitId)); if (editingVetVisitId === visitId) { handleCancelVetVisitEdit(); } } catch (removeError) { setError(removeError instanceof Error ? removeError.message : 'Unable to remove vet visit.'); } finally { setDeletingVetVisitId(''); } }; 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; }); setSelectedBirdId(''); setWeights([]); setVetVisits([]); setEditingVetVisitId(''); setDeletingVetVisitId(''); 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 handleMergeSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); try { const response = await apiFetch('/transfers/draft', authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mergeForm), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to save transfer draft.')); } const data = (await readJsonSafely<{ bird?: Bird; destinationOwnerExists?: boolean; inviteSent?: boolean; }>(response)) ?? {}; const transferBirdName = data.bird?.name || birds.find((bird) => bird.id === mergeForm.birdId)?.name || 'bird'; const inviteCopy = data.inviteSent ? `\n\nA FlockPal invite was also sent to ${mergeForm.destinationOwnerEmail} because that email does not have an account yet.` : data.destinationOwnerExists ? `\n\n${mergeForm.destinationOwnerEmail} already has a FlockPal account, so no invite was needed.` : ''; window.alert( `Transfer prep saved for ${transferBirdName}.${inviteCopy}\n\nThis is currently a planning workflow only. Later this page can turn into a real account-to-account transfer flow using verified bird identity and ownership checks.`, ); setMergeForm({ birdId: '', destinationOwnerEmail: '', notes: '', }); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save transfer draft.'); } }; const handleWorkspaceSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); setSavingWorkspace(true); try { const response = await apiFetch('/workspace', authToken, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...workspaceForm, billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan, }), }); 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', }); } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.'); } finally { setSavingWorkspace(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', }); } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to cancel rescue status request.'); } finally { setCancelingRescueRequest(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 (!outOfRangeBirds.length) { return; } setShowWeightAlertModal(true); }; 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.

A calmer way to care for every bird in your flock

Track weights, vet visits, hatchdays, gotcha days, and the little routines that help your birds thrive.

Daily care, all together Keep reminders, records, and notes in one cheerful home for your flock.
Made for real bird people From one spoiled companion bird to a lively whole flock, FlockPal keeps the details easy to revisit.

Passwordless sign-in

Email link or provider

FlockPal no longer stores passwords. Use a magic link, Google, Microsoft, or Apple.

{error ?

{error}

: null} {authNotice ? (
{authNotice.message} {authNotice.previewUrl ? ( Open the sign-in link ) : ( 'The link expires quickly and can only be used once.' )}
) : null}
); } if (loading) { return (

Loading flock data...

); } const showWorkspaceSwitcher = authSession.workspaces.length > 1; return (
FlockPal
{error ?

{error}

: null} {activePage === 'overview' ? (

Overview

30-day flock weight snapshot

{outOfRangeBirds.length ? ( ) : null}

{birdsWithRecentWeights.length} birds with recent entries

{overviewChart.yTicks.map((tick) => ( {tick.label} ))} {overviewChart.xTicks.map((tick) => ( {tick.label} ))} {overviewChart.series.map(({ bird, points }) => ( {points.length > 1 ? ( ) : null} {points.map((point) => ( {`${bird.name}: ${point.label}`} ))} ))}
{overviewChart.plottedBirds.map(({ bird }) => { return (
{bird.name}
); })}

Flock health Pulse

{missingFirstWeightCount > 0 ? (
{missingFirstWeightCount} Members still needing a first weight
) : null} {outOfRangeBirds.length ? (
Weight range alerts {outOfRangeBirds.length} bird{outOfRangeBirds.length === 1 ? '' : 's'} outside typical ranges
) : null}
Weekly flock changes {flockWeeklyTrendItems.length ? (
{flockWeeklyTrendItems.map((trendItem) => ( {trendItem.name} : {trendItem.formattedChange} ))}
) : ( No weekly changes yet )}
) : null} {activePage === 'admin' && authSession.isAdmin ? (

Admin

Platform pulse

Operational counts for the full FlockPal platform.

Total birds {adminSummary?.totalBirds ?? '-'}
Daily users {adminSummary?.dailyUsers ?? '-'}
Total users {adminSummary?.totalUsers ?? '-'}
Flocks {adminSummary?.totalWorkspaces ?? '-'}
Rescue flocks {adminSummary?.rescueWorkspaces ?? '-'}
Pending rescues {adminSummary?.pendingRescues ?? '-'}

Verification

Rescue flocks

Pending rescues are read-only until approved.

{adminRescueWorkspaces.length ? ( adminRescueWorkspaces.map((entry) => (
{entry.workspace.name} {formatRescueVerificationStatus(entry.workspace.rescueVerificationStatus)} • {entry.birdCount} birds • {entry.memberCount} members Owner {entry.ownerEmail ?? 'unknown'} • Billing {entry.workspace.billingEmail ?? 'not set'}
)) ) : (
No rescue flocks yet New rescue flocks will appear here for verification review.
)}
) : null} {activePage === 'flock' ? (
{showFlockDetailColumn ? (
{bulkWeightOpen ? (

Weigh-in

Bulk add weights

{birds.map((bird) => ( ))}
Flock member Last weight Weight today
{bird.name} {formatWeight(bird.latestWeightGrams)} handleBulkWeightValueChange(bird.id, event.target.value)} placeholder="g" />
) : null} {selectedBird ? (

Flock member

{selectedBird.name}

<>
{selectedBird.photoDataUrl ? ( {`${selectedBird.name}`} ) : ( )}

Profile

{selectedBird.name} {getBirdGenderSymbol(selectedBird)}

{selectedBird.species} • Band {selectedBird.tagId}

Added {formatDate(selectedBird.createdAt.slice(0, 10))}

Name {selectedBird.name}
Band ID {selectedBird.tagId}
DOB {formatDate(selectedBird.dateOfBirth)}
Gotcha day {formatDate(selectedBird.gotchaDay)}
Species {selectedBird.species}
Gender {getBirdGenderLabel(selectedBird)}
Latest weight {formatWeight(selectedBird.latestWeightGrams)}

Weight

Trend and log

Latest reading: {formatShortDate(selectedBird.latestRecordedOn)}

{selectedBirdChart.yTicks.map((tick) => ( {tick.label} ))} {selectedBirdChart.xTicks.map((tick) => ( {tick.label} ))} {hasSelectedBirdLine && selectedBirdChart.isFlat ? ( ) : null} {hasSelectedBirdLine && !selectedBirdChart.isFlat ? ( ) : null} {selectedBirdChart.points.map((point) => ( {point.label} ))}

{selectedBirdTrendCopy}

{weights.length ? `${weights.length} data point${weights.length === 1 ? '' : 's'}` : 'No data yet'}