import { useEffect, useMemo, useState } from 'react'; import flockPalLandingArt from './assets/flockpal-landing-art.png'; type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; type HouseholdBillingPlan = Exclude; type WorkspaceType = 'standard' | 'rescue'; type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; type Bird = { id: string; workspaceId?: number; name: string; tagId: string; species: string; 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; 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[]; providers: AuthProvider[]; }; type BirdFormState = { name: string; tagId: string; species: string; 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 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'; type SettingsSection = 'collaborators' | '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: '', 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: 'staff', }; const emptyWorkspaceCreateForm: WorkspaceCreateFormState = { name: '', workspaceType: 'standard', billingEmail: '', billingPlan: 'household_basic', }; const emptyAuthForm: AuthFormState = { name: '', email: '', }; 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, 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 formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); 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 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, 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`); 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 workspaces.'; } if (billingPlan === 'household_basic') { return 'Permits up to 4 birds in the workspace.'; } if (billingPlan === 'household_plus') { return 'Permits 5 to 10 birds in the workspace.'; } return 'Permits 11 or more birds in the workspace.'; }; 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 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(' '); 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 [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 [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 [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false); const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState(null); 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 [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 missingFirstWeightCount = useMemo( () => birds.filter((bird) => bird.latestWeightGrams === null).length, [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 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); 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([]); setBirds([]); setWeights([]); setVetVisits([]); setAllBirdWeights({}); setSelectedBirdId(''); setEditingBirdId(''); setWorkspaceForm(emptyWorkspaceForm); setWorkspaceCreateForm(emptyWorkspaceCreateForm); 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 loadBirds = async () => { try { setLoading(true); const [birdsResponse, membersResponse] = await Promise.all([apiFetch('/birds', authToken), apiFetch('/workspace/members', 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([]); } } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.'); } finally { setLoading(false); } }; void loadBirds(); }, [authToken, workspace?.id]); 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 ?? []); } 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]); 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 workspaces.')); } const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; if (!data.session) { throw new Error('Unable to switch workspaces.'); } 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 workspaces.'); } finally { setSwitchingWorkspaceId(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 workspace.')); } const workspaceResponse = await apiFetch('/auth/session', authToken); if (!workspaceResponse.ok) { throw new Error(await readErrorMessage(workspaceResponse, 'Workspace 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 workspace 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 workspace.'); } 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 handleVetVisitSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedBird) { return; } setError(''); try { const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(vetVisitForm), }); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to save vet visit.')); } const data = await readJsonSafely<{ vetVisit: VetVisit }>(response); if (!data?.vetVisit) { throw new Error('Unable to save vet visit.'); } setVetVisits((current) => [data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)), ); setVetVisitForm({ visitedOn: new Date().toISOString().slice(0, 10), clinicName: '', reason: '', notes: '', }); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.'); } }; 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([]); 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 workspace settings.')); } const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; if (!data.workspace) { throw new Error('Unable to save workspace 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 workspace settings.'); } finally { setSavingWorkspace(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(''); } }; if (authLoading) { return (

FlockPal

Loading your flock spaces...

Checking your sign-in and workspace 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 (
{activePage !== 'settings' ? (

Dashboard

FlockPal
) : null} {error ?

{error}

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

Overview

30-day flock weight snapshot

{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}
Weekly flock changes {flockWeeklyTrendItems.length ? (
{flockWeeklyTrendItems.map((trendItem) => ( {trendItem.name} : {trendItem.formattedChange} ))}
) : ( No weekly changes yet )}
) : null} {activePage === 'flock' ? (
{selectedBird ? (

Flock member

{selectedBird.name}

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

Profile

{selectedBird.name}

{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}
Latest weight {formatWeight(selectedBird.latestWeightGrams)}

Weight

Trend and log

Latest reading: {formatShortDate(selectedBird.latestRecordedOn)}

{selectedBirdTrendCopy}

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