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 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; 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; 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; 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; billingInterval: BillingInterval; }; type WorkspaceMemberFormState = { name: string; email: string; role: WorkspaceRole; }; type WorkspaceCreateFormState = { name: string; workspaceType: WorkspaceType; billingEmail: string; billingPlan: HouseholdBillingPlan; billingInterval: BillingInterval; }; 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 WeightDropAlert = { bird: Bird; previousWeight: WeightRecord; latestWeight: WeightRecord; dropPercent: 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', billingInterval: 'monthly', }; const emptyWorkspaceMemberForm: WorkspaceMemberFormState = { name: '', email: '', role: 'caregiver', }; const emptyWorkspaceCreateForm: WorkspaceCreateFormState = { name: '', workspaceType: 'standard', billingEmail: '', billingPlan: 'household_basic', billingInterval: 'monthly', }; 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 daysBetweenDates = (startDate: string, endDate: string) => Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000); 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 formatBillingIntervalName = (billingInterval: BillingInterval) => (billingInterval === 'yearly' ? 'Annual' : 'Monthly'); const formatBillingPlanName = (billingPlan: BillingPlan) => { if (billingPlan === 'rescue_free') { return 'Rescue Free'; } if (billingPlan === 'household_basic') { return 'Conure'; } if (billingPlan === 'household_plus') { return 'Indian Ringneck'; } return 'Macaw'; }; const formatBillingPlanCapacity = (billingPlan: BillingPlan) => { if (billingPlan === 'rescue_free') { return 'No billing is applied to rescue flocks.'; } if (billingPlan === 'household_basic') { return 'Permits up to 4 birds in the flock.'; } if (billingPlan === 'household_plus') { return 'Permits 5 to 10 birds in the flock.'; } return 'Permits 11 or more birds in the flock.'; }; const formatBillingPlanDropdownLabel = (billingPlan: HouseholdBillingPlan) => { if (billingPlan === 'household_basic') { return 'Conure (4 birds)'; } if (billingPlan === 'household_plus') { return 'Indian Ringneck (10 birds)'; } return 'Macaw (11+)'; }; const householdPlanPrices: Record> = { household_basic: { monthly: '$4.99/month', yearly: '$50/year', }, household_plus: { monthly: '$8.99/month', yearly: '$90/year', }, household_macaw: { monthly: '$15.99/month', yearly: '$160/year', }, }; const formatBillingIntervalDropdownLabel = (billingPlan: HouseholdBillingPlan, billingInterval: BillingInterval) => `${formatBillingIntervalName(billingInterval)} (${householdPlanPrices[billingPlan][billingInterval]})`; const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => { if (billingPlan === 'household_basic') { return '4'; } if (billingPlan === 'household_plus') { return '10'; } if (billingPlan === 'household_macaw') { return '11+'; } return null; }; const formatSubscriptionStatus = (status: SubscriptionStatus) => { if (status === 'trialing') { return 'Trialing'; } if (status === 'past_due') { return 'Past due'; } if (status === 'canceled') { return 'Canceled'; } if (status === 'unpaid') { return 'Unpaid'; } if (status === 'none') { return 'No subscription'; } return 'Active'; }; const subscriptionAllowsFlockWrites = (status: SubscriptionStatus) => status === 'active' || status === 'trialing'; const formatFlockAccessStatus = (status: SubscriptionStatus) => (subscriptionAllowsFlockWrites(status) ? formatSubscriptionStatus(status) : 'Read-only'); const formatFlockAccessDescription = (status: SubscriptionStatus) => subscriptionAllowsFlockWrites(status) ? 'This flock is writable while the subscription is active.' : `This flock is read-only until billing is restored. Current subscription status: ${formatSubscriptionStatus(status)}.`; const formatRescueVerificationStatus = (status: RescueVerificationStatus) => { if (status === 'approved') { return 'Active'; } if (status === 'rejected') { return 'Rejected'; } if (status === 'not_required') { return 'Not required'; } return 'Pending verification'; }; const formatWorkspaceRole = (role: WorkspaceRole) => { if (role === 'owner') { return 'Owner'; } if (role === 'assistant') { return 'Assistant'; } if (role === 'caregiver') { return 'Caregiver'; } return 'Viewer'; }; const readFileAsDataUrl = async (file: File) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); reader.onerror = () => reject(new Error('Unable to read that photo.')); reader.readAsDataURL(file); }); const loadImageElement = async (sourceDataUrl: string) => new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error('Unable to prepare that photo for cropping.')); image.src = sourceDataUrl; }); const blobToDataUrl = async (blob: Blob) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); reader.onerror = () => reject(new Error('Unable to finalize that cropped photo.')); reader.readAsDataURL(blob); }); const canvasToBlob = async (canvas: HTMLCanvasElement, quality: number) => new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (!blob) { reject(new Error('Unable to export that cropped photo.')); return; } resolve(blob); }, 'image/webp', quality, ); }); const exportCroppedPhoto = async (cropState: PhotoCropState) => { const image = await loadImageElement(cropState.sourceDataUrl); const shortestSide = Math.min(cropState.naturalWidth, cropState.naturalHeight); const cropSize = shortestSide / cropState.zoom; const maxOffsetX = Math.max(0, (cropState.naturalWidth - cropSize) / 2); const maxOffsetY = Math.max(0, (cropState.naturalHeight - cropSize) / 2); const sourceX = Math.min( cropState.naturalWidth - cropSize, Math.max(0, (cropState.naturalWidth - cropSize) / 2 + (cropState.offsetX / 100) * maxOffsetX), ); const sourceY = Math.min( cropState.naturalHeight - cropSize, Math.max(0, (cropState.naturalHeight - cropSize) / 2 + (cropState.offsetY / 100) * maxOffsetY), ); let bestBlob: Blob | null = null; for (const size of PHOTO_EXPORT_SIZES) { const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const context = canvas.getContext('2d'); if (!context) { throw new Error('Unable to prepare the crop tool in this browser.'); } context.imageSmoothingEnabled = true; context.imageSmoothingQuality = 'high'; context.drawImage(image, sourceX, sourceY, cropSize, cropSize, 0, 0, size, size); for (const quality of PHOTO_EXPORT_QUALITIES) { const blob = await canvasToBlob(canvas, quality); if (!bestBlob || blob.size < bestBlob.size) { bestBlob = blob; } if (blob.size <= PHOTO_MAX_BYTES) { return blobToDataUrl(blob); } } } if (!bestBlob) { throw new Error('Unable to export that cropped photo.'); } if (bestBlob.size > PHOTO_MAX_BYTES) { throw new Error('That photo is still too large after cropping. Try zooming in a little more.'); } return blobToDataUrl(bestBlob); }; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const getPhotoCropMetrics = (cropState: PhotoCropState, frameSize = PHOTO_PREVIEW_SIZE) => { const shortestSide = Math.min(cropState.naturalWidth, cropState.naturalHeight); const cropSize = shortestSide / cropState.zoom; const scale = frameSize / cropSize; const displayWidth = cropState.naturalWidth * scale; const displayHeight = cropState.naturalHeight * scale; const maxOffsetX = Math.max(0, (cropState.naturalWidth - cropSize) / 2); const maxOffsetY = Math.max(0, (cropState.naturalHeight - cropSize) / 2); const left = (frameSize - displayWidth) / 2 - (cropState.offsetX / 100) * maxOffsetX * scale; const top = (frameSize - displayHeight) / 2 - (cropState.offsetY / 100) * maxOffsetY * scale; return { left, top, displayWidth, displayHeight, scale, maxOffsetX, maxOffsetY, }; }; const chartPath = (points: WeightRecord[], width = 520, height = 180) => { if (!points.length) { return ''; } const weights = points.map((point) => point.weightGrams); const min = Math.min(...weights); const max = Math.max(...weights); const spread = Math.max(max - min, 1); return points .map((point, index) => { const x = (index / Math.max(points.length - 1, 1)) * width; const y = height - ((point.weightGrams - min) / spread) * (height - 24) - 12; return `${index === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`; }) .join(' '); }; const chartDots = (points: WeightRecord[], width = 520, height = 180) => { if (!points.length) { return []; } const weights = points.map((point) => point.weightGrams); const min = Math.min(...weights); const max = Math.max(...weights); const spread = Math.max(max - min, 1); return points.map((point, index) => ({ id: point.id, x: (index / Math.max(points.length - 1, 1)) * width, y: height - ((point.weightGrams - min) / spread) * (height - 24) - 12, label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`, })); }; const buildOverviewSeries = (points: WeightRecord[], minWeight: number, maxWeight: number, startDate: Date, endDate: Date) => { 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 [billingRedirecting, setBillingRedirecting] = useState(false); const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false); const [deletingWorkspace, setDeletingWorkspace] = useState(false); const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState(null); const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState(null); const [showWeightAlertModal, setShowWeightAlertModal] = useState(false); const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false); const [bulkWeightOpen, setBulkWeightOpen] = useState(false); const [savingBulkWeights, setSavingBulkWeights] = useState(false); const [bulkWeightDate, setBulkWeightDate] = useState(new Date().toISOString().slice(0, 10)); const [bulkWeightRows, setBulkWeightRows] = useState>({}); const [weightForm, setWeightForm] = useState({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '', }); const [vetVisitForm, setVetVisitForm] = useState({ visitedOn: new Date().toISOString().slice(0, 10), clinicName: '', reason: '', notes: '', }); const [flockTransferForm, setFlockTransferForm] = useState({ birdId: '', destinationOwnerEmail: '', }); const [transferringBird, setTransferringBird] = useState(false); const [transferError, setTransferError] = useState(''); const [transferNotice, setTransferNotice] = useState<{ message: string; previewUrl?: string | null; } | null>(null); const [deletingBird, setDeletingBird] = useState(false); const [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 weightDropAlerts = 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 totalWeightAlerts = outOfRangeBirds.length + weightDropAlerts.length; 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', billingInterval: session.activeWorkspace.billingInterval, }); 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, billingInterval: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingInterval, }), }); 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 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; }); setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); if (selectedBird?.id === flockTransferForm.birdId) { setSelectedBirdId(''); } if (editingBirdId === flockTransferForm.birdId) { setEditingBirdId(''); setBirdForm(emptyBirdForm); setBirdPhotoName(''); } setFlockTransferForm({ birdId: '', destinationOwnerEmail: '', }); window.alert(`${transferredBirdName} was transferred to ${flockTransferForm.destinationOwnerEmail}.`); } catch (submitError) { const message = submitError instanceof Error ? submitError.message : 'Unable to transfer bird to another flock.'; setTransferError(message); setError(message); } finally { setTransferringBird(false); } }; const saveWorkspaceSettings = async () => { const response = await apiFetch('/workspace', authToken, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...workspaceForm, billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan, billingInterval: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingInterval, }), }); 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, }); return savedWorkspace; }; const handleWorkspaceSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); setSavingWorkspace(true); try { await saveWorkspaceSettings(); } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.'); } finally { setSavingWorkspace(false); } }; const handleDeleteWorkspace = async () => { if (!workspace || !authToken || deletingWorkspace || activeMembership?.role !== 'owner') { return; } const confirmed = window.confirm( `Delete ${workspace.name}?\n\nThis only works when the flock has no birds. Remove or transfer all birds first.\n\nYou will be switched to another flock or a new personal flock automatically.`, ); if (!confirmed) { return; } setError(''); setDeletingWorkspace(true); try { const response = await apiFetch('/workspace', authToken, { method: 'DELETE', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to delete flock.')); } const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; if (!data.session) { throw new Error('Flock was deleted but the session could not be refreshed.'); } const nextToken = data.token || authToken; persistSessionToken(nextToken); applySession(data.session, nextToken); } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to delete flock.'); } finally { setDeletingWorkspace(false); } }; const handleCancelRescueRequest = async () => { if (!authToken) { return; } setError(''); setCancelingRescueRequest(true); try { const response = await apiFetch('/workspace/rescue-status/cancel', authToken, { method: 'POST', }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to cancel rescue status request.')); } const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; if (!data.workspace) { throw new Error('Unable to cancel rescue status request.'); } const savedWorkspace = data.workspace; setWorkspace(savedWorkspace); setAuthSession((current) => current ? { ...current, activeWorkspace: savedWorkspace, workspaces: current.workspaces.map((entry) => entry.workspace.id === savedWorkspace.id ? { ...entry, workspace: savedWorkspace } : entry, ), } : current, ); setWorkspaceForm({ name: savedWorkspace.name, workspaceType: savedWorkspace.workspaceType, billingEmail: savedWorkspace.billingEmail ?? '', billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', billingInterval: savedWorkspace.billingInterval, }); } 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(''); 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(''); 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); }; 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.

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

{totalWeightAlerts ? ( ) : 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} {totalWeightAlerts ? (
Weight alerts {totalWeightAlerts} alert{totalWeightAlerts === 1 ? '' : 's'} need review {outOfRangeBirds.length ? ( {outOfRangeBirds.length} bird{outOfRangeBirds.length === 1 ? '' : 's'} outside typical ranges ) : null} {weightDropAlerts.length ? ( {weightDropAlerts.length} bird{weightDropAlerts.length === 1 ? '' : 's'} down 5-10% between recent entries ) : null}
) : 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'}