Files
FlockPal/frontend/src/App.tsx
T
2026-06-05 21:42:20 -04:00

8388 lines
342 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import birdSilhouette from './assets/bird-silhouette.jpg';
import flockPalLandingArt from './assets/flockpal-landing-art.png';
import flockPalTextArt from './assets/flockpal-text.png';
import defaultBirdPhoto from './assets/yoda-default.png';
import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference';
import QRCode from 'qrcode';
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw';
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
type BillingInterval = 'monthly' | 'yearly';
type WorkspaceType = 'standard' | 'rescue';
type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
type IntegrationTokenScope = 'read_only' | 'read_write';
type BirdGender = 'unknown' | 'male' | 'female';
type Bird = {
id: string;
workspaceId?: number;
name: string;
tagId: string | null;
species: string;
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
vetClinicName: string | null;
vetClinicAddress: string | null;
vetAccountNumber: string | null;
vetDoctorName: string | null;
gender: BirdGender;
dateOfBirth: string | null;
gotchaDay: string | null;
chartColor: string;
photoDataUrl: string | null;
notifyOnDob: boolean;
notifyOnGotchaDay: boolean;
publicProfileCode: string | null;
publicProfileEnabled: boolean;
memorializedAt: string | null;
memorializedOn: string | null;
memorialNote: string | null;
notifyOnMemorialDay: boolean;
createdAt: string;
latestWeightGrams: number | null;
latestRecordedOn: string | null;
};
type WeightRecord = {
id: string;
birdId: string;
weightGrams: number;
recordedOn: string;
notes: string | null;
};
type VetVisit = {
id: string;
birdId: string;
visitedOn: string;
clinicName: string;
reason: string;
notes: string | null;
};
type Medication = {
id: string;
birdId: string;
name: string;
dosage: string;
frequency: MedicationFrequency;
doseSchedule: MedicationDoseScheduleItem[];
route: string | null;
startDate: string;
endDate: string | null;
notes: string | null;
};
type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed';
type MedicationDoseScheduleItem = {
key: string;
label: string;
time: string;
};
type MedicationAdministration = {
id: string;
medicationId: string;
birdId: string;
administeredOn: string;
administrationSlot: string;
status: 'administered' | 'missed';
notes: string | null;
createdAt: string;
};
type Workspace = {
id: number;
name: string;
workspaceType: WorkspaceType;
billingEmail: string | null;
billingPlan: BillingPlan;
billingInterval: BillingInterval;
subscriptionStatus: SubscriptionStatus;
stripeCustomerId: string | null;
stripeSubscriptionId: string | null;
rescueVerificationStatus: RescueVerificationStatus;
createdAt: string;
updatedAt: string;
};
type WorkspaceMember = {
id: string;
workspaceId: number;
userId?: string | null;
inviteEmail?: string;
name: string;
email?: string;
role: WorkspaceRole;
acceptedAt?: string | null;
createdAt: string;
};
type WorkspaceSummary = {
membership: WorkspaceMember;
workspace: Workspace;
};
type AuthProvider = {
providerKey: 'google' | 'microsoft' | 'apple';
displayName: string;
enabled: boolean;
};
type AuthUser = {
id: string;
email: string;
name: string;
createdAt: string;
};
type AuthSessionPayload = {
user: AuthUser;
activeWorkspace: Workspace;
activeMembership: WorkspaceMember;
workspaces: WorkspaceSummary[];
isAdmin: boolean;
providers: AuthProvider[];
};
type AdminSummary = {
totalBirds: number;
memorializedBirds: number;
totalUsers: number;
totalWorkspaces: number;
rescueWorkspaces: number;
rescueBirds: number;
pendingRescues: number;
dailyUsers: number;
};
type AdminRescueWorkspace = {
workspace: Workspace;
birdCount: number;
memberCount: number;
};
type IntegrationTokenSummary = {
id: string;
userId: string;
workspaceId: number;
name: string;
tokenPrefix: string;
scope: IntegrationTokenScope;
lastUsedAt: string | null;
expiresAt: string | null;
revokedAt: string | null;
createdAt: string;
};
type FlockNote = {
id: string;
workspaceId: number;
birdId: string | null;
birdName: string | null;
body: string;
createdByUserId: string | null;
createdByName: string | null;
createdAt: string;
updatedAt: string;
};
type AuditLogEntry = {
id: string;
workspaceId: number;
userId: string | null;
actorName: string | null;
actorEmail: string | null;
action: string;
entityType: string;
entityId: string | null;
entityName: string | null;
details: Record<string, unknown>;
createdAt: string;
};
type IntegrationTokenFormState = {
name: string;
scope: IntegrationTokenScope;
expiresInDays: string;
};
type FlockNoteFormState = {
birdId: string;
body: string;
};
type BirdFormState = {
name: string;
tagId: string;
species: string;
motivators: string;
demotivators: string;
favoriteSnack: string;
vetClinicName: string;
vetClinicAddress: string;
vetAccountNumber: string;
vetDoctorName: string;
gender: BirdGender;
dateOfBirth: string;
gotchaDay: string;
chartColor: string;
photoDataUrl: string;
notifyOnDob: boolean;
notifyOnGotchaDay: boolean;
publicProfileEnabled: boolean;
};
type VeterinaryInfoFormState = Pick<BirdFormState, 'vetClinicName' | 'vetClinicAddress' | 'vetAccountNumber' | 'vetDoctorName'>;
type BirdImportWeight = {
weightGrams: number;
recordedOn: string;
notes: string;
};
type BirdImportProfile = {
key: string;
name: string;
tagId: string;
species: string;
favoriteSnack: string;
motivators: string;
demotivators: string;
gender: BirdGender;
dateOfBirth: string;
gotchaDay: string;
chartColor: string;
weights: BirdImportWeight[];
};
type BirdImportPreview = {
profiles: BirdImportProfile[];
errors: string[];
};
type PublicBirdProfile = {
id: string;
workspaceId: number;
name: string;
favoriteSnack: string | null;
gender: BirdGender;
dateOfBirth: string | null;
photoDataUrl: string | null;
};
type MemorializeBirdFormState = {
memorializedOn: string;
memorialNote: string;
notifyOnMemorialDay: boolean;
};
type RescueOnboardingFormState = {
name: string;
city: string;
state: string;
ein: string;
website: string;
};
type WorkspaceFormState = {
name: string;
workspaceType: WorkspaceType;
billingEmail: string;
billingPlan: HouseholdBillingPlan;
billingInterval: BillingInterval;
rescueOnboarding: RescueOnboardingFormState;
};
type WorkspaceMemberFormState = {
name: string;
email: string;
role: WorkspaceRole;
};
type WorkspaceCreateFormState = {
name: string;
workspaceType: WorkspaceType;
billingEmail: string;
billingPlan: HouseholdBillingPlan;
billingInterval: BillingInterval;
rescueOnboarding: RescueOnboardingFormState;
};
type AuthFormState = {
name: string;
email: string;
};
type LostBirdReportFormState = {
tagId: string;
finderEmail: string;
foundLocation: string;
message: string;
};
type AuthNotice = {
message: string;
previewUrl?: string | null;
};
type BillingNotice = {
kind: 'success' | 'info' | 'error';
message: string;
};
type BulkWeightRowState = {
weightGrams: string;
};
type BirdWeightAssessment =
| {
status: 'no_match';
reference: null;
}
| {
status: 'no_weight';
reference: ParrotWeightReference;
}
| {
status: 'reference_only';
reference: Extract<ParrotWeightReference, { kind: 'approximate' }>;
}
| {
status: 'within' | 'below' | 'above';
reference: Extract<ParrotWeightReference, { kind: 'range' }>;
varianceGrams: number;
};
type OutOfRangeBirdWeightAssessment = {
status: 'below' | 'above';
reference: Extract<ParrotWeightReference, { kind: 'range' }>;
varianceGrams: number;
};
type WeightDropAlert = {
bird: Bird;
previousWeight: WeightRecord;
latestWeight: WeightRecord;
dropPercent: number;
};
type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit';
type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'reports' | 'audit';
type DismissedAlertMap = Record<string, boolean>;
type PhotoCropState = {
sourceDataUrl: string;
fileName: string;
naturalWidth: number;
naturalHeight: number;
zoom: number;
offsetX: number;
offsetY: number;
};
type PhotoDragState = {
pointerId: number;
startX: number;
startY: number;
startOffsetX: number;
startOffsetY: number;
};
type AppPage = 'overview' | 'flock' | 'settings' | 'admin';
type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'bird-import' | 'transfer';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
const sessionTokenStorageKey = 'flockpal_auth_token';
const dismissedAlertsStorageKey = 'flockpal_dismissed_alerts';
const getPublicProfileCodeFromPath = () => window.location.pathname.match(/^\/b\/([A-Za-z0-9_-]{8,32})\/?$/)?.[1] ?? '';
const getPublicProfileUrl = (code: string) => `${window.location.origin}/b/${code}`;
const QR_MARGIN = 4;
const createQrPath = (value: string) => {
const qr = QRCode.create(value, { errorCorrectionLevel: 'H' });
const size = qr.modules.size;
const data = qr.modules.data;
const pathParts: string[] = [];
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
if (data[y * size + x]) {
pathParts.push(`M${x + QR_MARGIN},${y + QR_MARGIN}h1v1h-1z`);
}
}
}
return {
path: pathParts.join(''),
size,
viewBoxSize: size + QR_MARGIN * 2,
};
};
const QrCodeWithLogo = ({ value, label }: { value: string; label: string }) => {
const qr = useMemo(() => createQrPath(value), [value]);
const logoSize = Math.max(7, qr.size * 0.18);
const logoPosition = (qr.viewBoxSize - logoSize) / 2;
return (
<svg className="qr-code" viewBox={`0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}`} role="img" aria-label={label}>
<rect width={qr.viewBoxSize} height={qr.viewBoxSize} fill="#fff" />
<path d={qr.path} fill="#111418" />
<g className="qr-bird-mark" aria-hidden="true">
<rect x={logoPosition - 0.45} y={logoPosition - 0.45} width={logoSize + 0.9} height={logoSize + 0.9} rx="1.7" />
<image href={birdSilhouette} x={logoPosition} y={logoPosition} width={logoSize} height={logoSize} preserveAspectRatio="xMidYMid meet" />
</g>
</svg>
);
};
const importHeaderAliases = {
name: ['bird name', 'name'],
tagId: ['band id', 'tag id', 'band'],
species: ['species'],
favoriteSnack: ['favorite snack', 'favorite treat', 'treat'],
motivators: ['motivators'],
demotivators: ['demotivators', 'demotivates'],
gender: ['gender'],
dateOfBirth: ['hatch day', 'hatch date', 'date of birth', 'dob'],
gotchaDay: ['gotcha day', 'gotcha date'],
chartColor: ['chart color', 'color'],
weightGrams: ['weight grams', 'weight g', 'weight'],
weightDate: ['weight date', 'recorded on', 'weight recorded on'],
weightNotes: ['weight notes', 'weight note'],
} as const;
const normalizeImportHeader = (value: string) => value.trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
const readImportCell = (row: Record<string, unknown>, aliases: readonly string[]) => {
const matchingEntry = Object.entries(row).find(([header]) => aliases.includes(normalizeImportHeader(header)));
return matchingEntry?.[1] ?? '';
};
const toImportText = (value: unknown) => (value === null || value === undefined ? '' : String(value).trim());
const formatImportDate = (value: Date) => {
if (Number.isNaN(value.getTime())) {
return '';
}
const month = `${value.getMonth() + 1}`.padStart(2, '0');
const day = `${value.getDate()}`.padStart(2, '0');
return `${value.getFullYear()}-${month}-${day}`;
};
const toImportDate = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '';
}
if (value instanceof Date) {
return formatImportDate(value);
}
if (typeof value === 'number') {
return formatImportDate(new Date(1899, 11, 30 + Math.floor(value)));
}
const text = toImportText(value);
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
return text;
}
return formatImportDate(new Date(text));
};
const parseImportGender = (value: unknown): BirdGender | null => {
const gender = toImportText(value).toLowerCase();
if (!gender || gender === 'unknown') {
return 'unknown';
}
if (gender === 'male' || gender === 'female') {
return gender;
}
return null;
};
const getBirdImportKey = (name: string, tagId: string) => (tagId ? `band:${tagId.toLowerCase()}` : `name:${name.toLowerCase()}`);
const mergeImportText = (current: string, next: string) => current || next;
const birdProfileListLimit = 3;
const parseBirdProfileList = (value: string | null | undefined) =>
(value ?? '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, birdProfileListLimit);
const getBirdProfileListFields = (value: string) =>
Array.from({ length: birdProfileListLimit }, (_, index) => parseBirdProfileList(value)[index] ?? '');
const updateBirdProfileListField = (value: string, index: number, nextItem: string) => {
const items = getBirdProfileListFields(value);
items[index] = nextItem;
return items.map((item) => item.trim()).filter(Boolean).join('\n');
};
const parseBirdImportRows = (rows: Record<string, unknown>[]): BirdImportPreview => {
const errors: string[] = [];
const profiles = new Map<string, BirdImportProfile>();
rows.forEach((row, index) => {
const rowNumber = index + 2;
const name = toImportText(readImportCell(row, importHeaderAliases.name));
const tagId = toImportText(readImportCell(row, importHeaderAliases.tagId));
const gender = parseImportGender(readImportCell(row, importHeaderAliases.gender));
const dateOfBirthValue = readImportCell(row, importHeaderAliases.dateOfBirth);
const gotchaDayValue = readImportCell(row, importHeaderAliases.gotchaDay);
const dateOfBirth = toImportDate(dateOfBirthValue);
const gotchaDay = toImportDate(gotchaDayValue);
const weightText = toImportText(readImportCell(row, importHeaderAliases.weightGrams));
const weightDateValue = readImportCell(row, importHeaderAliases.weightDate);
const weightDate = toImportDate(weightDateValue);
if (!name) {
errors.push(`Row ${rowNumber}: Bird Name is required.`);
return;
}
if (!gender) {
errors.push(`Row ${rowNumber}: Gender must be male, female, unknown, or blank.`);
return;
}
if (dateOfBirthValue && !dateOfBirth) {
errors.push(`Row ${rowNumber}: Hatch Day is not a valid date.`);
}
if (gotchaDayValue && !gotchaDay) {
errors.push(`Row ${rowNumber}: Gotcha Day is not a valid date.`);
}
const key = getBirdImportKey(name, tagId);
const current =
profiles.get(key) ??
({
key,
name,
tagId,
species: '',
favoriteSnack: '',
motivators: '',
demotivators: '',
gender,
dateOfBirth,
gotchaDay,
chartColor: '',
weights: [],
} satisfies BirdImportProfile);
current.species = mergeImportText(current.species, toImportText(readImportCell(row, importHeaderAliases.species)));
current.favoriteSnack = mergeImportText(current.favoriteSnack, toImportText(readImportCell(row, importHeaderAliases.favoriteSnack)));
current.motivators = mergeImportText(current.motivators, toImportText(readImportCell(row, importHeaderAliases.motivators)));
current.demotivators = mergeImportText(current.demotivators, toImportText(readImportCell(row, importHeaderAliases.demotivators)));
current.chartColor = mergeImportText(current.chartColor, toImportText(readImportCell(row, importHeaderAliases.chartColor)));
current.dateOfBirth = mergeImportText(current.dateOfBirth, dateOfBirth);
current.gotchaDay = mergeImportText(current.gotchaDay, gotchaDay);
current.gender = current.gender === 'unknown' ? gender : current.gender;
if (weightText) {
const weightGrams = Number(weightText);
if (!Number.isFinite(weightGrams) || weightGrams <= 0) {
errors.push(`Row ${rowNumber}: Weight Grams must be a positive number.`);
} else if (!weightDate) {
errors.push(`Row ${rowNumber}: Weight Date is required for a weight entry.`);
} else {
current.weights.push({
weightGrams,
recordedOn: weightDate,
notes: toImportText(readImportCell(row, importHeaderAliases.weightNotes)),
});
}
} else if (weightDateValue) {
errors.push(`Row ${rowNumber}: Weight Grams is required when Weight Date is set.`);
}
profiles.set(key, current);
});
profiles.forEach((profile) => {
if (!profile.species) {
errors.push(`${profile.name}: Species is required on at least one row for this bird.`);
}
if (profile.chartColor && !/^#[0-9a-fA-F]{6}$/.test(profile.chartColor)) {
errors.push(`${profile.name}: Chart Color must be a hex color like #cb3a35.`);
}
});
return {
profiles: [...profiles.values()],
errors,
};
};
const emptyBirdForm: BirdFormState = {
name: '',
tagId: '',
species: '',
motivators: '',
demotivators: '',
favoriteSnack: '',
vetClinicName: '',
vetClinicAddress: '',
vetAccountNumber: '',
vetDoctorName: '',
gender: 'unknown',
dateOfBirth: '',
gotchaDay: '',
chartColor: '#cb3a35',
photoDataUrl: '',
notifyOnDob: false,
notifyOnGotchaDay: false,
publicProfileEnabled: false,
};
const emptyVeterinaryInfoForm: VeterinaryInfoFormState = {
vetClinicName: '',
vetClinicAddress: '',
vetAccountNumber: '',
vetDoctorName: '',
};
const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({
memorializedOn: new Date().toISOString().slice(0, 10),
memorialNote: '',
notifyOnMemorialDay: false,
});
const emptyRescueOnboardingForm = (): RescueOnboardingFormState => ({
name: '',
city: '',
state: '',
ein: '',
website: '',
});
const emptyWorkspaceForm: WorkspaceFormState = {
name: 'My Flock',
workspaceType: 'standard',
billingEmail: '',
billingPlan: 'household_basic',
billingInterval: 'monthly',
rescueOnboarding: emptyRescueOnboardingForm(),
};
const emptyWorkspaceMemberForm: WorkspaceMemberFormState = {
name: '',
email: '',
role: 'caregiver',
};
const emptyWorkspaceCreateForm: WorkspaceCreateFormState = {
name: '',
workspaceType: 'standard',
billingEmail: '',
billingPlan: 'household_basic',
billingInterval: 'monthly',
rescueOnboarding: emptyRescueOnboardingForm(),
};
const emptyAuthForm: AuthFormState = {
name: '',
email: '',
};
const emptyLostBirdReportForm: LostBirdReportFormState = {
tagId: '',
finderEmail: '',
foundLocation: '',
message: '',
};
const emptyIntegrationTokenForm: IntegrationTokenFormState = {
name: '',
scope: 'read_write',
expiresInDays: '',
};
const emptyFlockNoteForm: FlockNoteFormState = {
birdId: '',
body: '',
};
const 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 (
<svg viewBox="0 0 24 24" aria-hidden="true" className="provider-icon-svg">
<path
d="M21.8 12.2c0-.7-.1-1.4-.2-2H12v3.8h5.5a4.7 4.7 0 0 1-2 3.1v2.6h3.2c1.9-1.8 3.1-4.4 3.1-7.5Z"
fill="#4285F4"
/>
<path
d="M12 22c2.7 0 4.9-.9 6.6-2.4l-3.2-2.6c-.9.6-2 .9-3.4.9-2.6 0-4.8-1.8-5.6-4.1H3.1v2.7A10 10 0 0 0 12 22Z"
fill="#34A853"
/>
<path
d="M6.4 13.8a6 6 0 0 1 0-3.7V7.4H3.1a10 10 0 0 0 0 9l3.3-2.6Z"
fill="#FBBC05"
/>
<path
d="M12 6.1c1.5 0 2.8.5 3.9 1.5l2.9-2.9A10 10 0 0 0 3.1 7.4l3.3 2.7C7.2 7.8 9.4 6.1 12 6.1Z"
fill="#EA4335"
/>
</svg>
);
}
if (providerKey === 'microsoft') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="provider-icon-svg">
<rect x="2" y="2" width="9" height="9" fill="#f25022" />
<rect x="13" y="2" width="9" height="9" fill="#7fba00" />
<rect x="2" y="13" width="9" height="9" fill="#00a4ef" />
<rect x="13" y="13" width="9" height="9" fill="#ffb900" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="provider-icon-svg provider-icon-apple">
<path
d="M16.7 12.8c0-2.2 1.8-3.3 1.9-3.4-1-1.5-2.7-1.7-3.2-1.7-1.4-.1-2.7.8-3.4.8-.7 0-1.8-.8-2.9-.8-1.5 0-2.9.9-3.7 2.2-1.6 2.7-.4 6.7 1.2 8.9.8 1.1 1.7 2.3 2.9 2.2 1.1 0 1.6-.7 3-.7s1.8.7 3 .7c1.2 0 2-.9 2.8-2 .9-1.2 1.2-2.5 1.2-2.5-.1 0-2.8-1.1-2.8-3.7Zm-2.2-6.6c.6-.7 1-1.7.9-2.7-.9 0-2 .6-2.6 1.3-.6.7-1.1 1.7-1 2.7 1 .1 2-.5 2.7-1.3Z"
fill="currentColor"
/>
</svg>
);
};
const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right) => left.name.localeCompare(right.name));
const toBirdForm = (bird: Bird): BirdFormState => ({
name: bird.name,
tagId: bird.tagId ?? '',
species: bird.species,
motivators: parseBirdProfileList(bird.motivators).join('\n'),
demotivators: parseBirdProfileList(bird.demotivators).join('\n'),
favoriteSnack: bird.favoriteSnack ?? '',
vetClinicName: bird.vetClinicName ?? '',
vetClinicAddress: bird.vetClinicAddress ?? '',
vetAccountNumber: bird.vetAccountNumber ?? '',
vetDoctorName: bird.vetDoctorName ?? '',
gender: bird.gender,
dateOfBirth: bird.dateOfBirth ?? '',
gotchaDay: bird.gotchaDay ?? '',
chartColor: bird.chartColor,
photoDataUrl: bird.photoDataUrl ?? '',
notifyOnDob: bird.notifyOnDob,
notifyOnGotchaDay: bird.notifyOnGotchaDay,
publicProfileEnabled: bird.publicProfileEnabled,
});
const toVeterinaryInfoForm = (bird: Bird): VeterinaryInfoFormState => ({
vetClinicName: bird.vetClinicName ?? '',
vetClinicAddress: bird.vetClinicAddress ?? '',
vetAccountNumber: bird.vetAccountNumber ?? '',
vetDoctorName: bird.vetDoctorName ?? '',
});
const formatDate = (value: string | null) => {
if (!value) {
return 'Not set';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(`${value}T00:00:00`));
};
const formatShortDate = (value: string | null) => {
if (!value) {
return 'No data yet';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
}).format(new Date(`${value}T00:00:00`));
};
const getBirdGenderLabel = (bird: Pick<Bird, 'gender'>) => {
if (bird.gender === 'female') {
return 'Female';
}
if (bird.gender === 'male') {
return 'Male';
}
return 'Unknown';
};
const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
if (bird.gender === 'female') {
return '♀';
}
if (bird.gender === 'male') {
return '♂';
}
return '?';
};
const escapeReportHtml = (value: string | number | null | undefined) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const formatDateTime = (value: string | null) => {
if (!value) {
return 'Never';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
};
const formatAuditAction = (value: string) =>
value
.split('.')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).replace(/_/g, ' '))
.join(' ');
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
const daysBetweenDates = (startDate: string, endDate: string) =>
Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000);
const addYearsToDate = (date: Date, years: number) => {
const nextDate = new Date(date);
nextDate.setFullYear(nextDate.getFullYear() + years);
return nextDate;
};
const OVERVIEW_WINDOW_DAYS = 30;
const OVERVIEW_HISTORY_DAYS = 425;
const OVERVIEW_WIDTH = 520;
const OVERVIEW_HEIGHT = 220;
const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 };
const PHOTO_MAX_BYTES = 900_000;
const PHOTO_EXPORT_SIZES = [720, 600, 480];
const PHOTO_EXPORT_QUALITIES = [0.9, 0.82, 0.74, 0.66];
const PHOTO_PREVIEW_SIZE = 112;
const MEMBER_CHART_WIDTH = 520;
const MEMBER_CHART_HEIGHT = 180;
const MEMBER_CHART_PADDING = { top: 16, right: 18, bottom: 34, left: 52 };
const medicationFrequencyOptions: { value: MedicationFrequency; label: string; doseSchedule: MedicationDoseScheduleItem[] }[] = [
{ value: 'once_daily', label: 'Once daily', doseSchedule: [{ key: 'dose-1', label: 'Morning', time: '08:00' }] },
{
value: 'twice_daily',
label: 'Twice daily',
doseSchedule: [
{ key: 'dose-1', label: 'Morning', time: '08:00' },
{ key: 'dose-2', label: 'Evening', time: '20:00' },
],
},
{
value: 'every_8_hours',
label: 'Every 8 hours',
doseSchedule: [
{ key: 'dose-1', label: 'Morning', time: '06:00' },
{ key: 'dose-2', label: 'Afternoon', time: '14:00' },
{ key: 'dose-3', label: 'Night', time: '22:00' },
],
},
{
value: 'every_6_hours',
label: 'Every 6 hours',
doseSchedule: [
{ key: 'dose-1', label: 'Early morning', time: '06:00' },
{ key: 'dose-2', label: 'Midday', time: '12:00' },
{ key: 'dose-3', label: 'Evening', time: '18:00' },
{ key: 'dose-4', label: 'Night', time: '00:00' },
],
},
{ value: 'as_needed', label: 'As needed', doseSchedule: [{ key: 'dose-1', label: 'As needed', time: '' }] },
];
const readJsonSafely = async <T,>(response: Response): Promise<T | null> => {
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('<!DOCTYPE') || trimmed.startsWith('<html')) {
return fallback;
}
return trimmed;
};
const createApiHeaders = (token?: string, headers?: HeadersInit) => {
const nextHeaders = new Headers(headers);
if (token) {
nextHeaders.set('Authorization', `Bearer ${token}`);
}
return nextHeaders;
};
const apiFetch = (path: string, token?: string, init?: RequestInit) =>
fetch(`${apiBaseUrl}${path}`, {
...init,
cache: 'no-store',
headers: createApiHeaders(token, init?.headers),
});
const persistSessionToken = (token: string) => {
window.localStorage.setItem(sessionTokenStorageKey, token);
};
const clearSessionToken = () => {
window.localStorage.removeItem(sessionTokenStorageKey);
};
const readStoredSessionToken = () => window.localStorage.getItem(sessionTokenStorageKey) ?? '';
const readDismissedAlerts = (): DismissedAlertMap => {
const storedDismissals = window.localStorage.getItem(dismissedAlertsStorageKey);
if (!storedDismissals) {
return {};
}
try {
const parsed = JSON.parse(storedDismissals);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as DismissedAlertMap) : {};
} catch {
return {};
}
};
const persistDismissedAlerts = (dismissedAlerts: DismissedAlertMap) => {
window.localStorage.setItem(dismissedAlertsStorageKey, JSON.stringify(dismissedAlerts));
};
const buildDismissedAlertKey = (
workspaceId: number | undefined,
birdId: string,
alertType: DismissibleAlertType,
signature: string,
) => `${workspaceId ?? 'workspace'}:${birdId}:${alertType}:${signature}`;
const oauthStartUrl = (providerKey: AuthProvider['providerKey']) => {
const url = new URL(`${apiBaseUrl}/auth/oauth/${providerKey}/start`, window.location.origin);
url.searchParams.set('redirectTo', window.location.href);
return url.toString();
};
const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan =>
billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw' || billingPlan === 'household_hyacinth_macaw';
const formatBillingIntervalName = (billingInterval: BillingInterval) => (billingInterval === 'yearly' ? 'Annual' : 'Monthly');
const formatBillingPlanName = (billingPlan: BillingPlan) => {
if (billingPlan === 'rescue_free') {
return 'Rescue Free';
}
if (billingPlan === 'household_basic') {
return 'Conure';
}
if (billingPlan === 'household_plus') {
return 'Indian Ringneck';
}
if (billingPlan === 'household_macaw') {
return 'African Grey';
}
return 'Hyacinth Macaw';
};
const formatBillingPlanCapacity = (billingPlan: BillingPlan) => {
if (billingPlan === 'rescue_free') {
return 'No billing is applied to rescue flocks.';
}
if (billingPlan === 'household_basic') {
return 'Permits up to 4 birds in the flock.';
}
if (billingPlan === 'household_plus') {
return 'Permits 5 to 10 birds in the flock.';
}
if (billingPlan === 'household_macaw') {
return 'Permits 11 to 16 birds in the flock.';
}
return 'Permits 17 or more birds in the flock.';
};
const formatBillingPlanDropdownLabel = (billingPlan: HouseholdBillingPlan) => {
if (billingPlan === 'household_basic') {
return 'Conure (up to 4 birds)';
}
if (billingPlan === 'household_plus') {
return 'Indian Ringneck (5-10 birds)';
}
if (billingPlan === 'household_macaw') {
return 'African Grey (11-16 birds)';
}
return 'Hyacinth Macaw (17+)';
};
const householdPlanPrices: Record<HouseholdBillingPlan, Record<BillingInterval, string>> = {
household_basic: {
monthly: '$4.99/month',
yearly: '$50/year',
},
household_plus: {
monthly: '$8.99/month',
yearly: '$90/year',
},
household_macaw: {
monthly: '$15.99/month',
yearly: '$160/year',
},
household_hyacinth_macaw: {
monthly: '$49.99/month',
yearly: '$500/year',
},
};
const formatBillingIntervalDropdownLabel = (billingPlan: HouseholdBillingPlan, billingInterval: BillingInterval) =>
`${formatBillingIntervalName(billingInterval)} (${householdPlanPrices[billingPlan][billingInterval]})`;
const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
if (billingPlan === 'household_basic') {
return '4';
}
if (billingPlan === 'household_plus') {
return '10';
}
if (billingPlan === 'household_macaw') {
return '16';
}
if (billingPlan === 'household_hyacinth_macaw') {
return '17+';
}
return null;
};
const formatBillingBirdUsage = (billingPlan: BillingPlan, birdCount: number) => {
const birdLimit = formatBillingPlanBirdLimit(billingPlan);
if (!birdLimit) {
return `${birdCount} bird${birdCount === 1 ? '' : 's'}`;
}
return `${birdCount} of ${birdLimit} birds used`;
};
const formatSubscriptionStatus = (status: SubscriptionStatus) => {
if (status === 'trialing') {
return 'Trialing';
}
if (status === 'past_due') {
return 'Past due';
}
if (status === 'canceled') {
return 'Canceled';
}
if (status === 'unpaid') {
return 'Unpaid';
}
if (status === 'none') {
return 'No subscription';
}
return 'Active';
};
const subscriptionAllowsFlockWrites = (status: SubscriptionStatus) => status === 'active' || status === 'trialing';
const formatFlockAccessStatus = (status: SubscriptionStatus) => (subscriptionAllowsFlockWrites(status) ? formatSubscriptionStatus(status) : 'Read-only');
const formatFlockAccessDescription = (status: SubscriptionStatus) =>
subscriptionAllowsFlockWrites(status)
? 'This flock is writable while the subscription is active.'
: `This flock is read-only until billing is restored. Current subscription status: ${formatSubscriptionStatus(status)}.`;
const formatRescueVerificationStatus = (status: RescueVerificationStatus) => {
if (status === 'approved') {
return 'Active';
}
if (status === 'rejected') {
return 'Rejected';
}
if (status === 'not_required') {
return 'Not required';
}
return 'Pending verification';
};
const formatWorkspaceRole = (role: WorkspaceRole) => {
if (role === 'owner') {
return 'Owner';
}
if (role === 'assistant') {
return 'Assistant';
}
if (role === 'caregiver') {
return 'Caregiver';
}
return 'Viewer';
};
const readFileAsDataUrl = async (file: File) =>
new Promise<string>((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<HTMLImageElement>((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<string>((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<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Unable to export that cropped photo.'));
return;
}
resolve(blob);
},
'image/webp',
quality,
);
});
const exportCroppedPhoto = async (cropState: PhotoCropState) => {
const image = await loadImageElement(cropState.sourceDataUrl);
const shortestSide = Math.min(cropState.naturalWidth, cropState.naturalHeight);
const cropSize = shortestSide / cropState.zoom;
const maxOffsetX = Math.max(0, (cropState.naturalWidth - cropSize) / 2);
const maxOffsetY = Math.max(0, (cropState.naturalHeight - cropSize) / 2);
const sourceX = Math.min(
cropState.naturalWidth - cropSize,
Math.max(0, (cropState.naturalWidth - cropSize) / 2 + (cropState.offsetX / 100) * maxOffsetX),
);
const sourceY = Math.min(
cropState.naturalHeight - cropSize,
Math.max(0, (cropState.naturalHeight - cropSize) / 2 + (cropState.offsetY / 100) * maxOffsetY),
);
let bestBlob: Blob | null = null;
for (const size of PHOTO_EXPORT_SIZES) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Unable to prepare the crop tool in this browser.');
}
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, sourceX, sourceY, cropSize, cropSize, 0, 0, size, size);
for (const quality of PHOTO_EXPORT_QUALITIES) {
const blob = await canvasToBlob(canvas, quality);
if (!bestBlob || blob.size < bestBlob.size) {
bestBlob = blob;
}
if (blob.size <= PHOTO_MAX_BYTES) {
return blobToDataUrl(blob);
}
}
}
if (!bestBlob) {
throw new Error('Unable to export that cropped photo.');
}
if (bestBlob.size > PHOTO_MAX_BYTES) {
throw new Error('That photo is still too large after cropping. Try zooming in a little more.');
}
return blobToDataUrl(bestBlob);
};
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const getPhotoCropMetrics = (cropState: PhotoCropState, frameSize = PHOTO_PREVIEW_SIZE) => {
const shortestSide = Math.min(cropState.naturalWidth, cropState.naturalHeight);
const cropSize = shortestSide / cropState.zoom;
const scale = frameSize / cropSize;
const displayWidth = cropState.naturalWidth * scale;
const displayHeight = cropState.naturalHeight * scale;
const maxOffsetX = Math.max(0, (cropState.naturalWidth - cropSize) / 2);
const maxOffsetY = Math.max(0, (cropState.naturalHeight - cropSize) / 2);
const left = (frameSize - displayWidth) / 2 - (cropState.offsetX / 100) * maxOffsetX * scale;
const top = (frameSize - displayHeight) / 2 - (cropState.offsetY / 100) * maxOffsetY * scale;
return {
left,
top,
displayWidth,
displayHeight,
scale,
maxOffsetX,
maxOffsetY,
};
};
const chartPath = (points: WeightRecord[], width = 520, height = 180) => {
if (!points.length) {
return '';
}
const weights = points.map((point) => point.weightGrams);
const min = Math.min(...weights);
const max = Math.max(...weights);
const spread = Math.max(max - min, 1);
return points
.map((point, index) => {
const x = (index / Math.max(points.length - 1, 1)) * width;
const y = height - ((point.weightGrams - min) / spread) * (height - 24) - 12;
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(' ');
};
const chartDots = (points: WeightRecord[], width = 520, height = 180) => {
if (!points.length) {
return [];
}
const weights = points.map((point) => point.weightGrams);
const min = Math.min(...weights);
const max = Math.max(...weights);
const spread = Math.max(max - min, 1);
return points.map((point, index) => ({
id: point.id,
x: (index / Math.max(points.length - 1, 1)) * width,
y: height - ((point.weightGrams - min) / spread) * (height - 24) - 12,
label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`,
}));
};
const buildOverviewSeries = (
points: WeightRecord[],
minWeight: number,
maxWeight: number,
startDate: Date,
endDate: Date,
dateOffsetYears = 0,
) => {
const innerWidth = OVERVIEW_WIDTH - OVERVIEW_PADDING.left - OVERVIEW_PADDING.right;
const innerHeight = OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom;
const weightSpread = Math.max(maxWeight - minWeight, 1);
const startMs = startDate.getTime();
const endMs = endDate.getTime();
const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
return points.map((point) => {
const pointTime = addYearsToDate(parseDateValue(point.recordedOn), dateOffsetYears).getTime();
const x = OVERVIEW_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth;
const y = OVERVIEW_PADDING.top + (1 - (point.weightGrams - minWeight) / weightSpread) * innerHeight;
return {
id: point.id,
x,
y,
label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`,
};
});
};
const toOverviewPath = (points: { x: number; y: number }[]) =>
points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' ');
const buildMemberSeries = (
points: WeightRecord[],
minWeight: number,
maxWeight: number,
startDate: Date,
endDate: Date,
dateOffsetYears = 0,
) => {
const innerWidth = MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.left - MEMBER_CHART_PADDING.right;
const innerHeight = MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.top - MEMBER_CHART_PADDING.bottom;
const startMs = startDate.getTime();
const endMs = endDate.getTime();
const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
const weightSpread = Math.max(maxWeight - minWeight, 1);
return points.map((entry) => {
const pointTime = addYearsToDate(parseDateValue(entry.recordedOn), dateOffsetYears).getTime();
const x = MEMBER_CHART_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth;
const y = MEMBER_CHART_PADDING.top + (1 - (entry.weightGrams - minWeight) / weightSpread) * innerHeight;
return {
id: entry.id,
x,
y,
label: `${entry.weightGrams.toFixed(1)} g on ${formatShortDate(entry.recordedOn)}`,
};
});
};
const getDefaultMedicationDoseSchedule = (frequency: MedicationFrequency) =>
(medicationFrequencyOptions.find((option) => option.value === frequency)?.doseSchedule ?? medicationFrequencyOptions[0].doseSchedule).map((slot) => ({
...slot,
}));
const formatMedicationFrequency = (frequency: MedicationFrequency | string) =>
medicationFrequencyOptions.find((option) => option.value === frequency)?.label ?? frequency;
const normalizeMedicationFrequency = (frequency: MedicationFrequency | string): MedicationFrequency => {
if (medicationFrequencyOptions.some((option) => option.value === frequency)) {
return frequency as MedicationFrequency;
}
const normalizedFrequency = frequency.toLowerCase();
if (normalizedFrequency.includes('12') || normalizedFrequency.includes('twice') || normalizedFrequency.includes('bid')) {
return 'twice_daily';
}
if (normalizedFrequency.includes('8') || normalizedFrequency.includes('three') || normalizedFrequency.includes('tid')) {
return 'every_8_hours';
}
if (normalizedFrequency.includes('6') || normalizedFrequency.includes('four') || normalizedFrequency.includes('qid')) {
return 'every_6_hours';
}
if (normalizedFrequency.includes('needed') || normalizedFrequency.includes('prn')) {
return 'as_needed';
}
return 'once_daily';
};
const formatDoseTime = (time: string) => {
if (!time) {
return '';
}
const [hourValue, minuteValue] = time.split(':').map(Number);
const date = new Date();
date.setHours(hourValue, minuteValue, 0, 0);
return new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' }).format(date);
};
const assessBirdWeight = (bird: Bird): BirdWeightAssessment => {
const reference = findParrotWeightReference(bird.species);
if (!reference) {
return {
status: 'no_match',
reference: null,
};
}
if (bird.latestWeightGrams === null) {
return {
status: 'no_weight',
reference,
};
}
if (reference.kind === 'approximate') {
return {
status: 'reference_only',
reference,
};
}
if (bird.latestWeightGrams < reference.minGrams) {
return {
status: 'below',
reference,
varianceGrams: reference.minGrams - bird.latestWeightGrams,
};
}
if (bird.latestWeightGrams > reference.maxGrams) {
return {
status: 'above',
reference,
varianceGrams: bird.latestWeightGrams - reference.maxGrams,
};
}
return {
status: 'within',
reference,
varianceGrams: 0,
};
};
function App() {
const [activePage, setActivePage] = useState<AppPage>('overview');
const [authToken, setAuthToken] = useState('');
const [authSession, setAuthSession] = useState<AuthSessionPayload | null>(null);
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authForm, setAuthForm] = useState<AuthFormState>(emptyAuthForm);
const [authNotice, setAuthNotice] = useState<AuthNotice | null>(null);
const [billingNotice, setBillingNotice] = useState<BillingNotice | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [authSubmitting, setAuthSubmitting] = useState(false);
const [lostBirdReportForm, setLostBirdReportForm] = useState<LostBirdReportFormState>(emptyLostBirdReportForm);
const [lostBirdReportNotice, setLostBirdReportNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null);
const [lostBirdReportSubmitting, setLostBirdReportSubmitting] = useState(false);
const [publicProfileCode] = useState(getPublicProfileCodeFromPath);
const [publicProfile, setPublicProfile] = useState<PublicBirdProfile | null>(null);
const [publicProfileLoading, setPublicProfileLoading] = useState(Boolean(getPublicProfileCodeFromPath()));
const [publicProfileError, setPublicProfileError] = useState('');
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
const [flockNotes, setFlockNotes] = useState<FlockNote[]>([]);
const [auditLogEntries, setAuditLogEntries] = useState<AuditLogEntry[]>([]);
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
const [birds, setBirds] = useState<Bird[]>([]);
const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
const [selectedBirdTab, setSelectedBirdTab] = useState<BirdDetailTab>('info');
const [editingBirdId, setEditingBirdId] = useState<string>('');
const [birdEditorOpen, setBirdEditorOpen] = useState(false);
const [weights, setWeights] = useState<WeightRecord[]>([]);
const [vetVisits, setVetVisits] = useState<VetVisit[]>([]);
const [medications, setMedications] = useState<Medication[]>([]);
const [medicationAdministrations, setMedicationAdministrations] = useState<MedicationAdministration[]>([]);
const [allBirdWeights, setAllBirdWeights] = useState<Record<string, WeightRecord[]>>({});
const [allBirdVetVisits, setAllBirdVetVisits] = useState<Record<string, VetVisit[]>>({});
const [dismissedAlerts, setDismissedAlerts] = useState<DismissedAlertMap>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [workspaceForm, setWorkspaceForm] = useState<WorkspaceFormState>(emptyWorkspaceForm);
const [workspaceMemberForm, setWorkspaceMemberForm] = useState<WorkspaceMemberFormState>(emptyWorkspaceMemberForm);
const [workspaceCreateForm, setWorkspaceCreateForm] = useState<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
const [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
const [flockNoteForm, setFlockNoteForm] = useState<FlockNoteFormState>(emptyFlockNoteForm);
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
const [birdImportPreview, setBirdImportPreview] = useState<BirdImportPreview | null>(null);
const [birdImportFileName, setBirdImportFileName] = useState('');
const [birdImportNotice, setBirdImportNotice] = useState('');
const [importingBirds, setImportingBirds] = useState(false);
const [memorializeBirdForm, setMemorializeBirdForm] = useState<MemorializeBirdFormState>(emptyMemorializeBirdForm);
const [birdPhotoName, setBirdPhotoName] = useState('');
const [photoCrop, setPhotoCrop] = useState<PhotoCropState | null>(null);
const [photoDrag, setPhotoDrag] = useState<PhotoDragState | null>(null);
const [applyingPhotoCrop, setApplyingPhotoCrop] = useState(false);
const [savingBird, setSavingBird] = useState(false);
const [savingWorkspace, setSavingWorkspace] = useState(false);
const [cancelingRescueRequest, setCancelingRescueRequest] = useState(false);
const [billingRedirecting, setBillingRedirecting] = useState(false);
const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false);
const [creatingWorkspace, setCreatingWorkspace] = useState(false);
const [deletingWorkspace, setDeletingWorkspace] = useState(false);
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
const [savingFlockNote, setSavingFlockNote] = useState(false);
const [deletingFlockNoteId, setDeletingFlockNoteId] = useState('');
const [auditLogLoading, setAuditLogLoading] = useState(false);
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
const [showWeightAlertModal, setShowWeightAlertModal] = useState(false);
const [qrBird, setQrBird] = useState<Bird | null>(null);
const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false);
const [bulkWeightOpen, setBulkWeightOpen] = useState(false);
const [savingBulkWeights, setSavingBulkWeights] = useState(false);
const [bulkWeightDate, setBulkWeightDate] = useState(new Date().toISOString().slice(0, 10));
const [bulkWeightRows, setBulkWeightRows] = useState<Record<string, BulkWeightRowState>>({});
const [weightForm, setWeightForm] = useState({
weightGrams: '',
recordedOn: new Date().toISOString().slice(0, 10),
notes: '',
});
const [vetVisitForm, setVetVisitForm] = useState({
visitedOn: new Date().toISOString().slice(0, 10),
clinicName: '',
reason: '',
notes: '',
});
const [veterinaryInfoForm, setVeterinaryInfoForm] = useState<VeterinaryInfoFormState>(emptyVeterinaryInfoForm);
const [medicationForm, setMedicationForm] = useState({
name: '',
dosage: '',
frequency: 'once_daily' as MedicationFrequency,
doseSchedule: getDefaultMedicationDoseSchedule('once_daily'),
route: '',
startDate: new Date().toISOString().slice(0, 10),
endDate: '',
notes: '',
});
const [flockTransferForm, setFlockTransferForm] = useState({
birdId: '',
destinationOwnerEmail: '',
});
const [transferCodeAcceptForm, setTransferCodeAcceptForm] = useState({
code: '',
});
const [transferringBird, setTransferringBird] = useState(false);
const [acceptingTransferCode, setAcceptingTransferCode] = useState(false);
const [transferError, setTransferError] = useState('');
const [transferCodeError, setTransferCodeError] = useState('');
const [transferNotice, setTransferNotice] = useState<{
message: string;
previewUrl?: string | null;
} | null>(null);
const [transferCodeNotice, setTransferCodeNotice] = useState('');
const [adoptionTransferCodes, setAdoptionTransferCodes] = useState<Record<string, string>>({});
const [creatingAdoptionReportCode, setCreatingAdoptionReportCode] = useState(false);
const [adoptionReportError, setAdoptionReportError] = useState('');
const [deletingBird, setDeletingBird] = useState(false);
const [memorializingBird, setMemorializingBird] = useState(false);
const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState('');
const [editingVetVisitId, setEditingVetVisitId] = useState('');
const [editingVeterinaryInfo, setEditingVeterinaryInfo] = useState(false);
const [savingVeterinaryInfo, setSavingVeterinaryInfo] = useState(false);
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
const [editingMedicationId, setEditingMedicationId] = useState('');
const [deletingMedicationId, setDeletingMedicationId] = useState('');
const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState('');
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
const selectedBird = useMemo(
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
[birds, selectedBirdId],
);
const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : '';
const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId],
);
const selectedBirdNotes = useMemo(
() => (selectedBird ? flockNotes.filter((note) => note.birdId === selectedBird.id) : []),
[flockNotes, selectedBird],
);
const selectedBirdAuditLogEntries = useMemo(
() =>
selectedBird
? auditLogEntries.filter(
(entry) =>
entry.entityId === selectedBird.id ||
entry.details.birdId === selectedBird.id ||
(entry.entityType === 'bird' && entry.entityName === selectedBird.name),
)
: [],
[auditLogEntries, selectedBird],
);
useEffect(() => {
setDismissedAlerts(readDismissedAlerts());
}, [workspace?.id]);
useEffect(() => {
setSelectedBirdTab('info');
}, [selectedBirdId]);
useEffect(() => {
if (selectedBird) {
setVeterinaryInfoForm(toVeterinaryInfoForm(selectedBird));
} else {
setVeterinaryInfoForm(emptyVeterinaryInfoForm);
}
setEditingVeterinaryInfo(false);
}, [selectedBird]);
useEffect(() => {
const selectedBirdId = selectedBird?.id;
if (!selectedBirdId || !authToken || adoptionTransferCodes[selectedBirdId]) {
return;
}
let canceled = false;
const loadOpenTransferCode = async () => {
try {
const response = await apiFetch(`/birds/${selectedBirdId}/transfer-code`, authToken);
if (!response.ok) {
return;
}
const data =
(await readJsonSafely<{
transferCode?: {
code?: string;
} | null;
}>(response)) ?? {};
const code = data.transferCode?.code;
if (!canceled && code) {
setAdoptionTransferCodes((current) => ({ ...current, [selectedBirdId]: code }));
}
} catch {
// Transfer codes are optional until a report/code is created.
}
};
void loadOpenTransferCode();
return () => {
canceled = true;
};
}, [adoptionTransferCodes, authToken, selectedBird?.id]);
const overviewWindowStartDate = useMemo(() => {
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
startDate.setDate(startDate.getDate() - (OVERVIEW_WINDOW_DAYS - 1));
return startDate;
}, []);
const birdsWithRecentWeights = useMemo(
() =>
birds.filter((bird) =>
(allBirdWeights[bird.id] ?? []).some((entry) => parseDateValue(entry.recordedOn) >= overviewWindowStartDate),
),
[allBirdWeights, birds, overviewWindowStartDate],
);
const showFlockDetailColumn = bulkWeightOpen || birdEditorOpen || Boolean(selectedBird);
useEffect(() => {
if (!publicProfile || !authSession || workspace?.id !== publicProfile.workspaceId || !birds.some((bird) => bird.id === publicProfile.id)) {
return;
}
setSelectedBirdId(publicProfile.id);
setActivePage('flock');
window.history.replaceState({}, document.title, '/');
}, [authSession, birds, publicProfile, workspace?.id]);
const missingFirstWeightCount = useMemo(
() => birds.filter((bird) => bird.latestWeightGrams === null).length,
[birds],
);
const birdWeightAssessments = useMemo(
() =>
Object.fromEntries(
birds.map((bird) => [
bird.id,
assessBirdWeight(bird),
]),
) as Record<string, BirdWeightAssessment>,
[birds],
);
const getWeightRangeAlertSignature = (bird: Bird, assessment: OutOfRangeBirdWeightAssessment) =>
`${assessment.status}:${bird.latestRecordedOn ?? 'none'}:${bird.latestWeightGrams ?? 'none'}:${assessment.reference.minGrams}-${assessment.reference.maxGrams}`;
const getWeightDropAlertSignature = (alert: WeightDropAlert) =>
`${alert.previousWeight.id}:${alert.previousWeight.recordedOn}:${alert.previousWeight.weightGrams}:${alert.latestWeight.id}:${alert.latestWeight.recordedOn}:${alert.latestWeight.weightGrams}`;
const getVetVisitAlertSignature = (birdId: string) => {
const latestVisit = allBirdVetVisits[birdId]?.[0] ?? null;
return latestVisit ? `${latestVisit.id}:${latestVisit.visitedOn}` : 'no-vet-visit';
};
const isAlertDismissed = (birdId: string, alertType: DismissibleAlertType, signature: string) =>
Boolean(dismissedAlerts[buildDismissedAlertKey(workspace?.id, birdId, alertType, signature)]);
const dismissAlert = (birdId: string, alertType: DismissibleAlertType, signature: string) => {
const alertKey = buildDismissedAlertKey(workspace?.id, birdId, alertType, signature);
setDismissedAlerts((current) => {
const next = {
...current,
[alertKey]: true,
};
persistDismissedAlerts(next);
return next;
});
};
const selectedBirdTrendCopy = useMemo(() => {
const visibleWeights = weights.filter((entry) => parseDateValue(entry.recordedOn) >= overviewWindowStartDate);
if (visibleWeights.length < 2) {
return 'Needs a few more entries before trend detection.';
}
const first = visibleWeights[0].weightGrams;
const last = visibleWeights[visibleWeights.length - 1].weightGrams;
const delta = last - first;
if (Math.abs(delta) < 1) {
return 'Weight has been steady over the current 30-day window.';
}
return delta > 0
? `Weight is up ${delta.toFixed(1)} g over the current 30-day window.`
: `Weight is down ${Math.abs(delta).toFixed(1)} g over the current 30-day window.`;
}, [overviewWindowStartDate, weights]);
const activeOutOfRangeBirds = useMemo(
() =>
birds
.map((bird) => {
const assessment = birdWeightAssessments[bird.id];
if (!assessment || (assessment.status !== 'below' && assessment.status !== 'above')) {
return null;
}
return {
bird,
assessment: assessment as OutOfRangeBirdWeightAssessment,
};
})
.filter((item): item is { bird: Bird; assessment: OutOfRangeBirdWeightAssessment } => item !== null),
[birdWeightAssessments, birds],
);
const outOfRangeBirds = useMemo(
() =>
activeOutOfRangeBirds.filter(
({ bird, assessment }) => !isAlertDismissed(bird.id, 'weight-range', getWeightRangeAlertSignature(bird, assessment)),
),
[activeOutOfRangeBirds, dismissedAlerts, workspace?.id],
);
const activeWeightDropAlerts = useMemo(
() =>
birds
.map((bird) => {
const birdWeights = [...(allBirdWeights[bird.id] ?? [])].sort(
(firstEntry, secondEntry) => parseDateValue(firstEntry.recordedOn).getTime() - parseDateValue(secondEntry.recordedOn).getTime(),
);
if (birdWeights.length < 2) {
return null;
}
const latestWeight = birdWeights[birdWeights.length - 1];
const previousWeight = birdWeights[birdWeights.length - 2];
if (previousWeight.weightGrams <= 0 || daysBetweenDates(previousWeight.recordedOn, latestWeight.recordedOn) > 2) {
return null;
}
const dropPercent = ((previousWeight.weightGrams - latestWeight.weightGrams) / previousWeight.weightGrams) * 100;
if (dropPercent < 5 || dropPercent > 10) {
return null;
}
return {
bird,
previousWeight,
latestWeight,
dropPercent,
};
})
.filter((alert): alert is WeightDropAlert => alert !== null),
[allBirdWeights, birds],
);
const weightDropAlerts = useMemo(
() => activeWeightDropAlerts.filter((alert) => !isAlertDismissed(alert.bird.id, 'weight-drop', getWeightDropAlertSignature(alert))),
[activeWeightDropAlerts, dismissedAlerts, workspace?.id],
);
const totalWeightAlerts = outOfRangeBirds.length + weightDropAlerts.length;
const vetVisitOverviewLoaded =
birds.length > 0 && birds.every((bird) => Object.prototype.hasOwnProperty.call(allBirdVetVisits, bird.id));
const activeVetVisitDueBirds = useMemo(() => {
if (!vetVisitOverviewLoaded) {
return [];
}
const cutoffDate = new Date();
cutoffDate.setHours(0, 0, 0, 0);
cutoffDate.setDate(cutoffDate.getDate() - 364);
return birds.filter((bird) => {
const visits = allBirdVetVisits[bird.id] ?? [];
return !visits.some((visit) => parseDateValue(visit.visitedOn) >= cutoffDate);
});
}, [allBirdVetVisits, birds, vetVisitOverviewLoaded]);
const vetVisitDueBirds = useMemo(
() =>
activeVetVisitDueBirds.filter(
(bird) => !isAlertDismissed(bird.id, 'vet-visit', getVetVisitAlertSignature(bird.id)),
),
[activeVetVisitDueBirds, allBirdVetVisits, dismissedAlerts, workspace?.id],
);
const vetVisitDueBirdIds = useMemo(() => new Set(vetVisitDueBirds.map((bird) => bird.id)), [vetVisitDueBirds]);
const selectedBirdWeightRangeAlert = selectedBird
? outOfRangeBirds.find((alert) => alert.bird.id === selectedBird.id) ?? null
: null;
const selectedBirdWeightDropAlerts = selectedBird ? weightDropAlerts.filter((alert) => alert.bird.id === selectedBird.id) : [];
const selectedBirdVetVisitAlertSignature = selectedBird ? getVetVisitAlertSignature(selectedBird.id) : '';
const selectedBirdHasVetVisitAlert = selectedBird ? vetVisitDueBirdIds.has(selectedBird.id) : false;
const activeMedications = useMemo(
() => medications.filter((medication) => !medication.endDate || parseDateValue(medication.endDate) >= parseDateValue(new Date().toISOString().slice(0, 10))),
[medications],
);
const pastMedications = useMemo(
() => medications.filter((medication) => medication.endDate && parseDateValue(medication.endDate) < parseDateValue(new Date().toISOString().slice(0, 10))),
[medications],
);
const filteredSpeciesOptions = useMemo(() => {
const query = birdForm.species.trim().toLowerCase();
if (!query) {
return parrotSpeciesOptions.slice(0, 12);
}
return parrotSpeciesOptions
.filter((speciesOption) => speciesOption.toLowerCase().includes(query))
.slice(0, 12);
}, [birdForm.species]);
const selectedBirdChart = useMemo(() => {
const endDate = new Date();
endDate.setHours(0, 0, 0, 0);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - (OVERVIEW_WINDOW_DAYS - 1));
const historicalStartDate = addYearsToDate(startDate, -1);
const historicalEndDate = addYearsToDate(endDate, -1);
const visibleWeights = weights.filter((entry) => {
const recordedOn = parseDateValue(entry.recordedOn);
return recordedOn >= startDate && recordedOn <= endDate;
});
const historicalWeights = weights.filter((entry) => {
const recordedOn = parseDateValue(entry.recordedOn);
return recordedOn >= historicalStartDate && recordedOn <= historicalEndDate;
});
if (!visibleWeights.length) {
return {
points: [] as { id: string; x: number; y: number; label: string }[],
historicalPoints: [] as { id: string; x: number; y: number; label: string }[],
path: '',
historicalPath: '',
isFlat: false,
historicalIsFlat: false,
yTicks: [] as { label: string; y: number }[],
xTicks: [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: MEMBER_CHART_PADDING.left },
{ label: formatShortDate(endDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right },
] as { label: string; x: number }[],
visibleCount: 0,
historicalCount: historicalWeights.length,
};
}
const allPlottedWeights = [...visibleWeights, ...historicalWeights];
const rawMinWeight = Math.min(...allPlottedWeights.map((entry) => entry.weightGrams));
const rawMaxWeight = Math.max(...allPlottedWeights.map((entry) => entry.weightGrams));
const isFlat = Math.abs(Math.max(...visibleWeights.map((entry) => entry.weightGrams)) - Math.min(...visibleWeights.map((entry) => entry.weightGrams))) < 0.01;
const historicalIsFlat =
historicalWeights.length > 1 &&
Math.abs(
Math.max(...historicalWeights.map((entry) => entry.weightGrams)) - Math.min(...historicalWeights.map((entry) => entry.weightGrams)),
) < 0.01;
const padding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
const minWeight = Math.max(0, rawMinWeight - padding);
const maxWeight = rawMaxWeight + padding;
const innerHeight = MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.top - MEMBER_CHART_PADDING.bottom;
const startMs = startDate.getTime();
const endMs = endDate.getTime();
const points = buildMemberSeries(visibleWeights, minWeight, maxWeight, startDate, endDate);
const historicalPoints = buildMemberSeries(historicalWeights, minWeight, maxWeight, startDate, endDate, 1);
const path = toOverviewPath(points);
const historicalPath = toOverviewPath(historicalPoints);
const midWeight = minWeight + (maxWeight - minWeight) / 2;
const midDate = new Date((startMs + endMs) / 2);
return {
points,
historicalPoints,
path,
historicalPath,
isFlat,
historicalIsFlat,
yTicks: [
{ label: `${maxWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top },
{ label: `${midWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top + innerHeight / 2 },
{ label: `${minWeight.toFixed(0)} g`, y: MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom },
],
xTicks: [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: MEMBER_CHART_PADDING.left },
{ label: formatShortDate(midDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH / 2 },
{ label: formatShortDate(endDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right },
],
visibleCount: visibleWeights.length,
historicalCount: historicalWeights.length,
};
}, [weights]);
const hasSelectedBirdLine = selectedBirdChart.points.length >= 2 && selectedBirdChart.path.length > 0;
const hasSelectedBirdHistoricalLine = selectedBirdChart.historicalPoints.length >= 2 && selectedBirdChart.historicalPath.length > 0;
const selectedBirdLatestChartPoint = selectedBirdChart.points[selectedBirdChart.points.length - 1] ?? null;
const flockWeeklyTrendItems = useMemo(() => {
return birds
.map((bird) => {
const birdWeights = allBirdWeights[bird.id] ?? [];
if (!birdWeights.length) {
return null;
}
const latestWeight = birdWeights[birdWeights.length - 1];
const weekStart = new Date(`${latestWeight.recordedOn}T00:00:00`);
weekStart.setDate(weekStart.getDate() - 7);
const weeklyWeights = birdWeights.filter(
(entry) => new Date(`${entry.recordedOn}T00:00:00`) >= weekStart,
);
if (weeklyWeights.length < 2) {
return null;
}
const startingWeight = weeklyWeights[0].weightGrams;
const percentChange = startingWeight === 0 ? 0 : ((latestWeight.weightGrams - startingWeight) / startingWeight) * 100;
return {
id: bird.id,
name: bird.name,
chartColor: bird.chartColor,
formattedChange: `${percentChange >= 0 ? '+' : ''}${percentChange.toFixed(1)}%`,
direction: percentChange >= 0 ? 'positive' : 'negative',
} as const;
})
.filter((trend): trend is NonNullable<typeof trend> => trend !== null);
}, [allBirdWeights, birds]);
const overviewChart = useMemo(() => {
const endDate = new Date();
endDate.setHours(0, 0, 0, 0);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - (OVERVIEW_WINDOW_DAYS - 1));
const historicalStartDate = addYearsToDate(startDate, -1);
const historicalEndDate = addYearsToDate(endDate, -1);
const plottedBirds = birds
.map((bird) => ({
bird,
weights: (allBirdWeights[bird.id] ?? []).filter((entry) => {
const recordedOn = parseDateValue(entry.recordedOn);
return recordedOn >= overviewWindowStartDate && recordedOn <= endDate;
}),
historicalWeights: (allBirdWeights[bird.id] ?? []).filter((entry) => {
const recordedOn = parseDateValue(entry.recordedOn);
return recordedOn >= historicalStartDate && recordedOn <= historicalEndDate;
}),
}))
.filter((entry) => entry.weights.length > 0);
if (!plottedBirds.length) {
return {
plottedBirds,
series: [],
historicalSeries: [],
xTicks: [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left },
{ label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right },
],
yTicks: [],
};
}
const allWeights = plottedBirds.flatMap((entry) =>
[...entry.weights, ...entry.historicalWeights].map((weight) => weight.weightGrams),
);
const rawMinWeight = Math.min(...allWeights);
const rawMaxWeight = Math.max(...allWeights);
const weightPadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
const minWeight = Math.max(0, rawMinWeight - weightPadding);
const maxWeight = rawMaxWeight + weightPadding;
const midWeight = minWeight + (maxWeight - minWeight) / 2;
return {
plottedBirds,
series: plottedBirds.map(({ bird, weights: birdWeights }) => ({
bird,
points: buildOverviewSeries(birdWeights, minWeight, maxWeight, startDate, endDate),
})),
historicalSeries: plottedBirds
.map(({ bird, historicalWeights }) => ({
bird,
points: buildOverviewSeries(historicalWeights, minWeight, maxWeight, startDate, endDate, 1),
}))
.filter((entry) => entry.points.length > 0),
xTicks: [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left },
{ label: formatShortDate(new Date((startDate.getTime() + endDate.getTime()) / 2).toISOString().slice(0, 10)), x: OVERVIEW_WIDTH / 2 },
{ label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right },
],
yTicks: [
{ label: `${maxWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top },
{ label: `${midWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top + (OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom) / 2 },
{ label: `${minWeight.toFixed(0)} g`, y: OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom },
],
};
}, [allBirdWeights, birds, overviewWindowStartDate]);
const overviewHistoricalSeriesCount = overviewChart.historicalSeries.length;
const applySession = (session: AuthSessionPayload, token: string) => {
setAuthToken(token);
setAuthSession(session);
setAuthProviders(session.providers);
setAuthNotice(null);
setBillingNotice(null);
setNewIntegrationTokenSecret('');
setWorkspace(session.activeWorkspace);
setActiveMembership({
...session.activeMembership,
email: session.activeMembership.inviteEmail,
});
setWorkspaceForm({
name: session.activeWorkspace.name,
workspaceType: session.activeWorkspace.workspaceType,
billingEmail: session.activeWorkspace.billingEmail ?? '',
billingPlan: isHouseholdPlan(session.activeWorkspace.billingPlan) ? session.activeWorkspace.billingPlan : 'household_basic',
billingInterval: session.activeWorkspace.billingInterval,
rescueOnboarding: emptyRescueOnboardingForm(),
});
setWorkspaceCreateForm((current) => ({
...current,
billingEmail: current.billingEmail || session.user.email,
}));
};
const clearAppSession = () => {
clearSessionToken();
setAuthToken('');
setAuthSession(null);
setWorkspace(null);
setActiveMembership(null);
setWorkspaceMembers([]);
setIntegrationTokens([]);
setFlockNotes([]);
setAuditLogEntries([]);
setAdminSummary(null);
setAdminRescueWorkspaces([]);
setBirds([]);
setMemorializedBirds([]);
setWeights([]);
setVetVisits([]);
setMedications([]);
setMedicationAdministrations([]);
setAllBirdWeights({});
setAllBirdVetVisits({});
setSelectedBirdId('');
setEditingBirdId('');
setWorkspaceForm(emptyWorkspaceForm);
setWorkspaceCreateForm(emptyWorkspaceCreateForm);
setIntegrationTokenForm(emptyIntegrationTokenForm);
setFlockNoteForm(emptyFlockNoteForm);
setNewIntegrationTokenSecret('');
setAuthNotice(null);
setBillingNotice(null);
};
const refreshAuthSession = async (token: string) => {
const response = await apiFetch('/auth/session', token);
if (!response.ok) {
if (response.status === 401) {
clearAppSession();
}
throw new Error(await readErrorMessage(response, 'Unable to refresh your billing status.'));
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
if (!data.session) {
throw new Error('Unable to refresh your billing status.');
}
const nextToken = data.token || token;
persistSessionToken(nextToken);
applySession(data.session, nextToken);
return {
session: data.session,
token: nextToken,
};
};
useEffect(() => {
const loadProviders = async () => {
try {
const response = await apiFetch('/auth/providers');
if (!response.ok) {
setAuthProviders(defaultAuthProviders);
return;
}
const data = (await readJsonSafely<{ providers?: AuthProvider[] }>(response)) ?? {};
const mergedProviders = defaultAuthProviders.map((defaultProvider) => {
const matchingProvider = (data.providers ?? []).find((provider) => provider.providerKey === defaultProvider.providerKey);
return matchingProvider ?? defaultProvider;
});
setAuthProviders(mergedProviders);
} catch {
setAuthProviders(defaultAuthProviders);
}
};
const bootstrapSession = async () => {
try {
setAuthLoading(true);
await loadProviders();
const url = new URL(window.location.href);
const callbackToken = url.searchParams.get('auth_token') ?? '';
const billingState = url.searchParams.get('billing');
const token = callbackToken || readStoredSessionToken();
if (callbackToken) {
persistSessionToken(callbackToken);
url.searchParams.delete('auth_token');
}
if (!token) {
return;
}
const { session, token: sessionToken } = await refreshAuthSession(token);
if (billingState === 'success' || billingState === 'portal') {
try {
const syncResponse = await apiFetch('/billing/sync', sessionToken, { method: 'POST' });
if (!syncResponse.ok) {
throw new Error(await readErrorMessage(syncResponse, 'Returned from Stripe, but billing could not be refreshed yet.'));
}
const { session: refreshedSession } = await refreshAuthSession(sessionToken);
const syncedWorkspace = refreshedSession.activeWorkspace;
const planName = formatBillingPlanName(syncedWorkspace.billingPlan);
const intervalName = formatBillingIntervalName(syncedWorkspace.billingInterval);
setBillingNotice({
kind: 'success',
message:
billingState === 'success'
? `Stripe checkout completed. Billing is now ${planName} on ${intervalName}.`
: `Stripe billing changes synced. Current plan: ${planName} on ${intervalName}.`,
});
} catch (billingSyncError) {
setBillingNotice({
kind: 'info',
message:
billingSyncError instanceof Error
? billingSyncError.message
: 'Returned from Stripe. Billing changes may still be syncing.',
});
}
} else if (billingState === 'cancelled') {
setBillingNotice({
kind: 'info',
message: 'Stripe checkout was cancelled. No billing changes were applied.',
});
} else {
setBillingNotice(null);
}
if (session && (callbackToken || billingState)) {
url.searchParams.delete('billing');
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
setError('');
}
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load your session.');
} finally {
setAuthLoading(false);
}
};
void bootstrapSession();
}, []);
useEffect(() => {
if (!publicProfileCode) {
return;
}
const loadPublicProfile = async () => {
try {
setPublicProfileLoading(true);
setPublicProfileError('');
const response = await apiFetch(`/public/birds/${publicProfileCode}`);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Public bird profile not found.'));
}
const data = (await readJsonSafely<{ bird?: PublicBirdProfile }>(response)) ?? {};
if (!data.bird) {
throw new Error('Public bird profile not found.');
}
setPublicProfile(data.bird);
} catch (profileError) {
setPublicProfileError(profileError instanceof Error ? profileError.message : 'Public bird profile not found.');
} finally {
setPublicProfileLoading(false);
}
};
void loadPublicProfile();
}, [publicProfileCode]);
useEffect(() => {
if (!authToken || !workspace?.id) {
setLoading(false);
return;
}
const loadWorkspaceData = async () => {
try {
setLoading(true);
const [birdsResponse, membersResponse, integrationTokensResponse, notesResponse] = await Promise.all([
apiFetch('/birds', authToken),
apiFetch('/workspace/members', authToken),
apiFetch('/integration-tokens', authToken),
apiFetch('/notes', authToken),
]);
if (!birdsResponse.ok) {
if (birdsResponse.status === 401) {
clearAppSession();
return;
}
throw new Error(await readErrorMessage(birdsResponse, 'Unable to load flock members.'));
}
const data = (await readJsonSafely<{ birds?: Bird[]; memorializedBirds?: Bird[] }>(birdsResponse)) ?? {};
const nextBirds = data.birds ?? [];
setBirds(nextBirds);
setMemorializedBirds(data.memorializedBirds ?? []);
setSelectedBirdId((current) => (current && nextBirds.some((bird) => bird.id === current) ? current : ''));
if (membersResponse.ok) {
const membersData = (await readJsonSafely<{ members?: WorkspaceMember[] }>(membersResponse)) ?? {};
setWorkspaceMembers(
(membersData.members ?? []).map((member) => ({
...member,
email: member.inviteEmail,
})),
);
} else {
setWorkspaceMembers([]);
}
if (integrationTokensResponse.ok) {
const integrationTokensData =
(await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {};
setIntegrationTokens(integrationTokensData.integrationTokens ?? []);
} else {
setIntegrationTokens([]);
}
if (notesResponse.ok) {
const notesData = (await readJsonSafely<{ notes?: FlockNote[] }>(notesResponse)) ?? {};
setFlockNotes(notesData.notes ?? []);
} else {
setFlockNotes([]);
}
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.');
} finally {
setLoading(false);
}
};
void loadWorkspaceData();
}, [authToken, workspace?.id]);
useEffect(() => {
if (!authToken || selectedBirdTab !== 'audit' || !selectedBird || !['owner', 'assistant'].includes(activeMembership?.role ?? '')) {
return;
}
const loadAuditLog = async () => {
try {
setAuditLogLoading(true);
const response = await apiFetch('/audit-log', authToken);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to load audit log.'));
}
const data = (await readJsonSafely<{ entries?: AuditLogEntry[] }>(response)) ?? {};
setAuditLogEntries(data.entries ?? []);
} catch (auditError) {
setError(auditError instanceof Error ? auditError.message : 'Unable to load audit log.');
} finally {
setAuditLogLoading(false);
}
};
void loadAuditLog();
}, [activeMembership?.role, authToken, selectedBird, selectedBirdTab]);
useEffect(() => {
if (!authToken || !authSession?.isAdmin || activePage !== 'admin') {
return;
}
const loadAdminDashboard = async () => {
try {
const [summaryResponse, rescuesResponse] = await Promise.all([
apiFetch('/admin/summary', authToken),
apiFetch('/admin/rescue-workspaces', authToken),
]);
if (!summaryResponse.ok || !rescuesResponse.ok) {
throw new Error('Unable to load admin dashboard.');
}
const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {};
const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {};
setAdminSummary(summaryData.summary ?? null);
setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []);
} catch (adminError) {
setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.');
}
};
void loadAdminDashboard();
}, [activePage, authSession?.isAdmin, authToken]);
useEffect(() => {
if (!selectedBird?.id) {
setWeights([]);
setVetVisits([]);
setMedications([]);
setMedicationAdministrations([]);
setVeterinaryInfoForm(emptyVeterinaryInfoForm);
return;
}
const loadBirdDetail = async () => {
try {
const [weightsResponse, visitsResponse, medicationsResponse, medicationAdministrationsResponse] = await Promise.all([
apiFetch(`/birds/${selectedBird.id}/weights?days=${OVERVIEW_HISTORY_DAYS}`, authToken),
apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken),
apiFetch(`/birds/${selectedBird.id}/medications`, authToken),
apiFetch(`/birds/${selectedBird.id}/medication-administrations`, authToken),
]);
if (!weightsResponse.ok || !visitsResponse.ok || !medicationsResponse.ok || !medicationAdministrationsResponse.ok) {
throw new Error('Unable to load flock member details.');
}
const weightsData = (await readJsonSafely<{ weights?: WeightRecord[] }>(weightsResponse)) ?? {};
const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {};
const medicationsData = (await readJsonSafely<{ medications?: Medication[] }>(medicationsResponse)) ?? {};
const medicationAdministrationsData =
(await readJsonSafely<{ administrations?: MedicationAdministration[] }>(medicationAdministrationsResponse)) ?? {};
setWeights(weightsData.weights ?? []);
const nextVetVisits = visitsData.vetVisits ?? [];
setVetVisits(nextVetVisits);
setAllBirdVetVisits((current) => ({
...current,
[selectedBird.id]: nextVetVisits,
}));
setMedications(medicationsData.medications ?? []);
setMedicationAdministrations(medicationAdministrationsData.administrations ?? []);
setEditingVetVisitId('');
setDeletingVetVisitId('');
setEditingMedicationId('');
setDeletingMedicationId('');
setSavingMedicationAdministrationId('');
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.');
}
};
void loadBirdDetail();
}, [authToken, selectedBird?.id]);
useEffect(() => {
if (!authToken || !birds.length) {
setAllBirdWeights({});
return;
}
const loadAllBirdWeights = async () => {
try {
const responses = await Promise.all(
birds.map(async (bird) => {
const response = await apiFetch(`/birds/${bird.id}/weights?days=${OVERVIEW_HISTORY_DAYS}`, authToken);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to load overview weights.'));
}
const data = (await readJsonSafely<{ weights?: WeightRecord[] }>(response)) ?? {};
return [bird.id, (data.weights ?? []) as WeightRecord[]] as const;
}),
);
setAllBirdWeights(Object.fromEntries(responses));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load overview weights.');
}
};
void loadAllBirdWeights();
}, [authToken, birds]);
useEffect(() => {
if (!authToken || !birds.length) {
setAllBirdVetVisits({});
return;
}
const loadAllBirdVetVisits = async () => {
try {
const responses = await Promise.all(
birds.map(async (bird) => {
const response = await apiFetch(`/birds/${bird.id}/vet-visits`, authToken);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to load overview vet visits.'));
}
const data = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(response)) ?? {};
return [bird.id, (data.vetVisits ?? []) as VetVisit[]] as const;
}),
);
setAllBirdVetVisits(Object.fromEntries(responses));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load overview vet visits.');
}
};
void loadAllBirdVetVisits();
}, [authToken, birds]);
useEffect(() => {
if (!editingBirdId) {
return;
}
if (!editingBird) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
return;
}
setBirdForm(toBirdForm(editingBird));
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
}, [editingBird, editingBirdId]);
useEffect(() => {
if (activePage === 'flock' || !birdEditorOpen) {
return;
}
setBirdEditorOpen(false);
setEditingBirdId('');
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
}, [activePage, birdEditorOpen]);
useEffect(() => {
setBulkWeightRows((current) => {
const nextEntries = birds.map((bird) => [bird.id, current[bird.id] ?? { weightGrams: '' }] as const);
return Object.fromEntries(nextEntries);
});
}, [birds]);
const startCreateBird = () => {
setEditingBirdId('');
setBirdEditorOpen(true);
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
setError('');
setActivePage('flock');
};
const handleAuthSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
setAuthNotice(null);
setAuthSubmitting(true);
try {
const response = await apiFetch('/auth/magic-link/request', undefined, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: authForm.name.trim(),
email: authForm.email,
redirectTo: window.location.href,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to send your sign-in link.'));
}
const data = (await readJsonSafely<{ message?: string; previewUrl?: string | null }>(response)) ?? {};
setAuthNotice({
message: data.message ?? 'Check your email for a secure sign-in link.',
previewUrl: data.previewUrl,
});
setAuthForm(emptyAuthForm);
} catch (authError) {
setError(authError instanceof Error ? authError.message : 'Unable to send your sign-in link.');
} finally {
setAuthSubmitting(false);
}
};
const handleLostBirdReportSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLostBirdReportNotice(null);
setLostBirdReportSubmitting(true);
try {
const response = await apiFetch('/lost-bird/report', undefined, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tagId: lostBirdReportForm.tagId.trim(),
finderEmail: lostBirdReportForm.finderEmail.trim(),
foundLocation: lostBirdReportForm.foundLocation.trim(),
message: lostBirdReportForm.message.trim(),
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to send this report right now.'));
}
const data = (await readJsonSafely<{ message?: string }>(response)) ?? {};
setLostBirdReportNotice({
message: data.message ?? 'Report received.',
kind: 'success',
});
setLostBirdReportForm(emptyLostBirdReportForm);
} catch (reportError) {
setLostBirdReportNotice({
message: reportError instanceof Error ? reportError.message : 'Unable to send this report right now.',
kind: 'error',
});
} finally {
setLostBirdReportSubmitting(false);
}
};
const handleLogout = async () => {
setError('');
try {
if (authToken) {
await apiFetch('/auth/logout', authToken, { method: 'POST' });
}
} catch {
// Best-effort logout.
} finally {
clearAppSession();
setAuthForm(emptyAuthForm);
}
};
const handleWorkspaceSwitch = async (workspaceId: number, nextActivePage: AppPage = 'overview') => {
if (!authToken || workspaceId === workspace?.id) {
return;
}
setError('');
setSwitchingWorkspaceId(workspaceId);
try {
const response = await apiFetch('/auth/switch-workspace', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to switch flocks.'));
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
if (!data.session) {
throw new Error('Unable to switch flocks.');
}
const nextToken = data.token || authToken;
persistSessionToken(nextToken);
applySession(data.session, nextToken);
setSelectedBirdId('');
setEditingBirdId('');
setWeights([]);
setVetVisits([]);
setMedications([]);
setMedicationAdministrations([]);
setAllBirdVetVisits({});
setActivePage(nextActivePage);
} catch (switchError) {
setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.');
} finally {
setSwitchingWorkspaceId(null);
}
};
const handleOpenPublicProfileBird = async () => {
if (!publicProfile || !authSession) {
return;
}
if (workspace?.id !== publicProfile.workspaceId) {
await handleWorkspaceSwitch(publicProfile.workspaceId, 'flock');
return;
}
setSelectedBirdId(publicProfile.id);
setActivePage('flock');
window.history.replaceState({}, document.title, '/');
};
const handleCreateIntegrationToken = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!authToken) {
return;
}
setError('');
setCreatingIntegrationToken(true);
try {
const response = await apiFetch('/integration-tokens', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: integrationTokenForm.name.trim(),
scope: integrationTokenForm.scope,
expiresInDays: integrationTokenForm.expiresInDays ? Number(integrationTokenForm.expiresInDays) : undefined,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to create integration token.'));
}
const data =
(await readJsonSafely<{ integrationToken?: IntegrationTokenSummary; token?: string }>(response)) ?? {};
if (!data.integrationToken || !data.token) {
throw new Error('Unable to create integration token.');
}
setIntegrationTokens((current) => [data.integrationToken!, ...current]);
setIntegrationTokenForm(emptyIntegrationTokenForm);
setNewIntegrationTokenSecret(data.token);
setExpandedSettingsSection('integration-tokens');
} catch (integrationTokenError) {
setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to create integration token.');
} finally {
setCreatingIntegrationToken(false);
}
};
const handleRevokeIntegrationToken = async (tokenId: string) => {
if (!authToken) {
return;
}
setError('');
setRevokingIntegrationTokenId(tokenId);
try {
const response = await apiFetch(`/integration-tokens/${tokenId}`, authToken, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to revoke integration token.'));
}
setIntegrationTokens((current) => current.filter((token) => token.id !== tokenId));
} catch (integrationTokenError) {
setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to revoke integration token.');
} finally {
setRevokingIntegrationTokenId('');
}
};
const handleFlockNoteSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!authToken) {
return;
}
setError('');
setSavingFlockNote(true);
try {
const response = await apiFetch('/notes', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
birdId: selectedBirdTab === 'notes' && selectedBird ? selectedBird.id : flockNoteForm.birdId || null,
body: flockNoteForm.body.trim(),
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save note.'));
}
const data = (await readJsonSafely<{ note?: FlockNote }>(response)) ?? {};
if (!data.note) {
throw new Error('Unable to save note.');
}
setFlockNotes((current) => [data.note!, ...current]);
setFlockNoteForm(emptyFlockNoteForm);
} catch (noteError) {
setError(noteError instanceof Error ? noteError.message : 'Unable to save note.');
} finally {
setSavingFlockNote(false);
}
};
const handleDeleteFlockNote = async (noteId: string) => {
if (!authToken) {
return;
}
setError('');
setDeletingFlockNoteId(noteId);
try {
const response = await apiFetch(`/notes/${noteId}`, authToken, { method: 'DELETE' });
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to delete note.'));
}
setFlockNotes((current) => current.filter((note) => note.id !== noteId));
} catch (noteError) {
setError(noteError instanceof Error ? noteError.message : 'Unable to delete note.');
} finally {
setDeletingFlockNoteId('');
}
};
const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => {
if (!authToken) {
return;
}
setError('');
setUpdatingRescueWorkspaceId(workspaceId);
try {
const response = await apiFetch(`/admin/rescue-workspaces/${workspaceId}`, authToken, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rescueVerificationStatus }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to update rescue verification status.'));
}
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
if (!data.workspace) {
throw new Error('Unable to update rescue verification status.');
}
const nextRescueWorkspaces = adminRescueWorkspaces
.map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry))
.filter((entry) => entry.workspace.workspaceType === 'rescue');
setAdminRescueWorkspaces(nextRescueWorkspaces);
setAdminSummary((current) =>
current
? {
...current,
rescueWorkspaces: nextRescueWorkspaces.length,
pendingRescues: nextRescueWorkspaces.filter((entry) => entry.workspace.rescueVerificationStatus === 'pending').length,
}
: current,
);
} catch (adminError) {
setError(adminError instanceof Error ? adminError.message : 'Unable to update rescue verification status.');
} finally {
setUpdatingRescueWorkspaceId(null);
}
};
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!authToken) {
return;
}
setError('');
setCreatingWorkspace(true);
try {
const response = await apiFetch('/workspaces', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: workspaceCreateForm.name,
workspaceType: workspaceCreateForm.workspaceType,
billingEmail: workspaceCreateForm.billingEmail,
billingPlan: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingPlan,
billingInterval: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingInterval,
rescueOnboarding: workspaceCreateForm.workspaceType === 'rescue' ? workspaceCreateForm.rescueOnboarding : undefined,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to create flock.'));
}
const createdData = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
const workspaceResponse = await apiFetch('/auth/session', authToken);
if (!workspaceResponse.ok) {
throw new Error(await readErrorMessage(workspaceResponse, 'Flock was created but the session could not be refreshed.'));
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(workspaceResponse)) ?? {};
if (!data.session) {
throw new Error('Unable to refresh your flock list.');
}
const nextToken = data.token || authToken;
persistSessionToken(nextToken);
applySession(data.session, nextToken);
setWorkspaceCreateForm({
...emptyWorkspaceCreateForm,
billingEmail: data.session.user.email,
});
setExpandedSettingsSection(null);
if (createdData.workspace) {
await handleWorkspaceSwitch(createdData.workspace.id, 'settings');
}
} catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.');
} finally {
setCreatingWorkspace(false);
}
};
const startEditBird = (bird: Bird) => {
setSelectedBirdId(bird.id);
setEditingBirdId(bird.id);
setBirdEditorOpen(true);
setBirdForm(toBirdForm(bird));
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
setError('');
setActivePage('flock');
};
const handleBirdPhotoChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (photoDrag?.pointerId === event.pointerId) {
event.currentTarget.releasePointerCapture(event.pointerId);
setPhotoDrag(null);
}
};
const handleBirdImportFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
setError('');
setBirdImportNotice('');
setBirdImportFileName(file.name);
try {
const { readSheet } = await import('read-excel-file/browser');
const worksheetRows = await readSheet(file);
const [headerRow, ...dataRows] = worksheetRows;
if (!headerRow?.length) {
throw new Error('The first worksheet does not have a header row.');
}
const headers = headerRow.map((header) => toImportText(header));
const rows = dataRows
.filter((cells) => cells.some((cell) => toImportText(cell)))
.map((cells) =>
Object.fromEntries(headers.map((header, columnIndex) => [header, cells[columnIndex] ?? ''])) as Record<string, unknown>,
);
if (!rows.length) {
throw new Error('The first worksheet does not have any bird rows.');
}
setBirdImportPreview(parseBirdImportRows(rows));
} catch (importError) {
setBirdImportPreview(null);
setError(importError instanceof Error ? importError.message : 'Unable to read the bird spreadsheet.');
} finally {
event.currentTarget.value = '';
}
};
const handleBirdImportSubmit = async () => {
if (!birdImportPreview || birdImportPreview.errors.length || importingBirds) {
return;
}
setError('');
setBirdImportNotice('');
setImportingBirds(true);
let importedBirdCount = 0;
let importedWeightCount = 0;
try {
for (const profile of birdImportPreview.profiles) {
const birdResponse = await apiFetch('/birds', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: profile.name,
tagId: profile.tagId,
species: profile.species,
motivators: profile.motivators,
demotivators: profile.demotivators,
favoriteSnack: profile.favoriteSnack,
gender: profile.gender,
dateOfBirth: profile.dateOfBirth,
gotchaDay: profile.gotchaDay,
chartColor: profile.chartColor || '#cb3a35',
photoDataUrl: '',
notifyOnDob: false,
notifyOnGotchaDay: false,
publicProfileEnabled: false,
}),
});
if (!birdResponse.ok) {
throw new Error(await readErrorMessage(birdResponse, `Unable to import ${profile.name}.`));
}
const birdData = await readJsonSafely<{ bird?: Bird }>(birdResponse);
if (!birdData?.bird) {
throw new Error(`Unable to import ${profile.name}.`);
}
let importedBird = birdData.bird;
const importedWeights: WeightRecord[] = [];
importedBirdCount += 1;
setBirds((current) => sortBirdsByName([...current, importedBird]));
for (const weight of profile.weights) {
const weightResponse = await apiFetch(`/birds/${importedBird.id}/weights`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
weightGrams: weight.weightGrams,
recordedOn: weight.recordedOn,
notes: weight.notes,
}),
});
if (!weightResponse.ok) {
throw new Error(await readErrorMessage(weightResponse, `Unable to import a weight for ${profile.name}.`));
}
const weightData = await readJsonSafely<{ weight?: WeightRecord }>(weightResponse);
if (!weightData?.weight) {
throw new Error(`Unable to import a weight for ${profile.name}.`);
}
importedWeights.push(weightData.weight);
importedWeightCount += 1;
}
const latestWeight = importedWeights.reduce<WeightRecord | null>(
(latest, weight) => (!latest || weight.recordedOn >= latest.recordedOn ? weight : latest),
null,
);
if (latestWeight) {
importedBird = {
...importedBird,
latestWeightGrams: latestWeight.weightGrams,
latestRecordedOn: latestWeight.recordedOn,
};
}
setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === importedBird.id ? importedBird : bird))));
setAllBirdWeights((current) => ({ ...current, [importedBird.id]: importedWeights }));
}
setBirdImportPreview(null);
setBirdImportFileName('');
setBirdImportNotice(
`Imported ${importedBirdCount} bird${importedBirdCount === 1 ? '' : 's'} and ${importedWeightCount} weight entr${
importedWeightCount === 1 ? 'y' : 'ies'
}.`,
);
} catch (importError) {
setError(
`${importError instanceof Error ? importError.message : 'Unable to import bird spreadsheet.'} Imported ${importedBirdCount} bird${
importedBirdCount === 1 ? '' : 's'
} before the import stopped.`,
);
} finally {
setImportingBirds(false);
}
};
const handleBirdSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
setSavingBird(true);
const isEditing = Boolean(editingBirdId);
const method = isEditing ? 'PUT' : 'POST';
try {
const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(birdForm),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, `Unable to ${isEditing ? 'update' : 'create'} flock member.`));
}
const data = await readJsonSafely<{ bird: Bird }>(response);
if (!data?.bird) {
throw new Error(`Unable to ${isEditing ? 'update' : 'create'} flock member.`);
}
const savedBird = data.bird as Bird;
setBirds((current) => {
if (isEditing) {
return sortBirdsByName(current.map((bird) => (bird.id === savedBird.id ? savedBird : bird)));
}
return sortBirdsByName([...current, savedBird]);
});
setSelectedBirdId(isEditing ? savedBird.id : '');
setEditingBirdId(savedBird.id);
setBirdEditorOpen(false);
setBirdForm(toBirdForm(savedBird));
setBirdPhotoName('');
setActivePage('flock');
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save flock member.');
} finally {
setSavingBird(false);
}
};
const handleVeterinaryInfoSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedBird || savingVeterinaryInfo) {
return;
}
setError('');
setSavingVeterinaryInfo(true);
try {
const response = await apiFetch(`/birds/${selectedBird.id}`, authToken, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...toBirdForm(selectedBird),
...veterinaryInfoForm,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save veterinary info.'));
}
const data = await readJsonSafely<{ bird?: Bird }>(response);
if (!data?.bird) {
throw new Error('Unable to save veterinary info.');
}
const savedBird = data.bird;
setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === savedBird.id ? savedBird : bird))));
setVeterinaryInfoForm(toVeterinaryInfoForm(savedBird));
setEditingVeterinaryInfo(false);
if (editingBirdId === savedBird.id) {
setBirdForm(toBirdForm(savedBird));
}
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save veterinary info.');
} finally {
setSavingVeterinaryInfo(false);
}
};
const handleWeightSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
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<HTMLFormElement>) => {
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<string, WeightRecord> = {};
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<HTMLFormElement>) => {
event.preventDefault();
if (!selectedBird) {
return;
}
setError('');
try {
const isEditingVetVisit = Boolean(editingVetVisitId);
const response = await apiFetch(
isEditingVetVisit ? `/birds/${selectedBird.id}/vet-visits/${editingVetVisitId}` : `/birds/${selectedBird.id}/vet-visits`,
authToken,
{
method: isEditingVetVisit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(vetVisitForm),
},
);
if (!response.ok) {
throw new Error(await readErrorMessage(response, `Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`));
}
const data = await readJsonSafely<{ vetVisit: VetVisit }>(response);
if (!data?.vetVisit) {
throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`);
}
const nextVetVisits = (
isEditingVetVisit ? vetVisits.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...vetVisits]
).sort(
(left, right) => right.visitedOn.localeCompare(left.visitedOn),
);
setVetVisits(nextVetVisits);
setAllBirdVetVisits((current) => ({
...current,
[selectedBird.id]: nextVetVisits,
}));
setVetVisitForm({
visitedOn: new Date().toISOString().slice(0, 10),
clinicName: '',
reason: '',
notes: '',
});
setEditingVetVisitId('');
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.');
}
};
const handleEditVetVisit = (visit: VetVisit) => {
setEditingVetVisitId(visit.id);
setVetVisitForm({
visitedOn: visit.visitedOn,
clinicName: visit.clinicName,
reason: visit.reason,
notes: visit.notes ?? '',
});
setError('');
};
const handleCancelVetVisitEdit = () => {
setEditingVetVisitId('');
setVetVisitForm({
visitedOn: new Date().toISOString().slice(0, 10),
clinicName: '',
reason: '',
notes: '',
});
};
const handleDeleteVetVisit = async (visitId: string) => {
if (!selectedBird || deletingVetVisitId) {
return;
}
setDeletingVetVisitId(visitId);
setError('');
try {
const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits/${visitId}`, authToken, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to remove vet visit.'));
}
const nextVetVisits = vetVisits.filter((visit) => visit.id !== visitId);
setVetVisits(nextVetVisits);
setAllBirdVetVisits((current) => ({
...current,
[selectedBird.id]: nextVetVisits,
}));
if (editingVetVisitId === visitId) {
handleCancelVetVisitEdit();
}
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : 'Unable to remove vet visit.');
} finally {
setDeletingVetVisitId('');
}
};
const handleMedicationSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedBird) {
return;
}
setError('');
try {
const isEditingMedication = Boolean(editingMedicationId);
const response = await apiFetch(
isEditingMedication ? `/birds/${selectedBird.id}/medications/${editingMedicationId}` : `/birds/${selectedBird.id}/medications`,
authToken,
{
method: isEditingMedication ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(medicationForm),
},
);
if (!response.ok) {
throw new Error(await readErrorMessage(response, `Unable to ${isEditingMedication ? 'update' : 'save'} medication.`));
}
const data = await readJsonSafely<{ medication: Medication }>(response);
if (!data?.medication) {
throw new Error(`Unable to ${isEditingMedication ? 'update' : 'save'} medication.`);
}
setMedications((current) =>
(isEditingMedication
? current.map((medication) => (medication.id === data.medication.id ? data.medication : medication))
: [data.medication, ...current]
).sort((left, right) => {
const leftEnd = left.endDate ?? '9999-12-31';
const rightEnd = right.endDate ?? '9999-12-31';
return rightEnd.localeCompare(leftEnd) || right.startDate.localeCompare(left.startDate);
}),
);
setMedicationForm({
name: '',
dosage: '',
frequency: 'once_daily',
doseSchedule: getDefaultMedicationDoseSchedule('once_daily'),
route: '',
startDate: new Date().toISOString().slice(0, 10),
endDate: '',
notes: '',
});
setEditingMedicationId('');
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save medication.');
}
};
const handleEditMedication = (medication: Medication) => {
const frequency = normalizeMedicationFrequency(medication.frequency);
setEditingMedicationId(medication.id);
setMedicationForm({
name: medication.name,
dosage: medication.dosage,
frequency,
doseSchedule: medication.doseSchedule?.length ? medication.doseSchedule : getDefaultMedicationDoseSchedule(frequency),
route: medication.route ?? '',
startDate: medication.startDate,
endDate: medication.endDate ?? '',
notes: medication.notes ?? '',
});
setError('');
};
const handleCancelMedicationEdit = () => {
setEditingMedicationId('');
setMedicationForm({
name: '',
dosage: '',
frequency: 'once_daily',
doseSchedule: getDefaultMedicationDoseSchedule('once_daily'),
route: '',
startDate: new Date().toISOString().slice(0, 10),
endDate: '',
notes: '',
});
};
const handleDeleteMedication = async (medicationId: string) => {
if (!selectedBird || deletingMedicationId) {
return;
}
setDeletingMedicationId(medicationId);
setError('');
try {
const response = await apiFetch(`/birds/${selectedBird.id}/medications/${medicationId}`, authToken, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to remove medication.'));
}
setMedications((current) => current.filter((medication) => medication.id !== medicationId));
setMedicationAdministrations((current) => current.filter((administration) => administration.medicationId !== medicationId));
if (editingMedicationId === medicationId) {
handleCancelMedicationEdit();
}
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : 'Unable to remove medication.');
} finally {
setDeletingMedicationId('');
}
};
const handleMedicationAdministrationSubmit = async (
medicationId: string,
administrationSlot: string,
status: MedicationAdministration['status'],
) => {
if (!selectedBird || savingMedicationAdministrationId) {
return;
}
setSavingMedicationAdministrationId(`${medicationId}-${administrationSlot}-${status}`);
setError('');
try {
const administeredOn = new Date().toISOString().slice(0, 10);
const response = await apiFetch(`/birds/${selectedBird.id}/medications/${medicationId}/administrations`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ administeredOn, administrationSlot, status }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to update medication administration.'));
}
const data = await readJsonSafely<{ administration: MedicationAdministration }>(response);
if (!data?.administration) {
throw new Error('Unable to update medication administration.');
}
setMedicationAdministrations((current) =>
[data.administration, ...current.filter((administration) => administration.id !== data.administration.id)]
.filter(
(administration, index, all) =>
all.findIndex(
(candidate) =>
candidate.medicationId === administration.medicationId &&
candidate.administeredOn === administration.administeredOn &&
candidate.administrationSlot === administration.administrationSlot,
) === index,
)
.sort((left, right) => right.administeredOn.localeCompare(left.administeredOn) || right.createdAt.localeCompare(left.createdAt)),
);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to update medication administration.');
} finally {
setSavingMedicationAdministrationId('');
}
};
const handleRemoveBird = async () => {
if (!selectedBird || deletingBird) {
return;
}
const confirmed = window.confirm(
`Remove ${selectedBird.name} from the flock?\n\nThis will also remove weight records and vet visits for this flock member.`,
);
if (!confirmed) {
return;
}
setDeletingBird(true);
setError('');
try {
const response = await apiFetch(`/birds/${selectedBird.id}`, authToken, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to remove flock member.'));
}
const nextBirds = birds.filter((bird) => bird.id !== selectedBird.id);
setBirds(nextBirds);
setAllBirdWeights((current) => {
const next = { ...current };
delete next[selectedBird.id];
return next;
});
setAllBirdVetVisits((current) => {
const next = { ...current };
delete next[selectedBird.id];
return next;
});
setSelectedBirdId('');
setWeights([]);
setVetVisits([]);
setMedications([]);
setMedicationAdministrations([]);
setEditingVetVisitId('');
setDeletingVetVisitId('');
setEditingMedicationId('');
setDeletingMedicationId('');
setSavingMedicationAdministrationId('');
if (editingBirdId === selectedBird.id) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
}
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : 'Unable to remove flock member.');
} finally {
setDeletingBird(false);
}
};
const handleMemorializeBird = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedBird || memorializingBird) {
return;
}
const confirmed = window.confirm(
`Memorialize ${selectedBird.name}?\n\nThis cannot be undone by you. ${selectedBird.name} will become read-only, hidden from the standard flock view, and excluded from the subscription bird count.`,
);
if (!confirmed) {
return;
}
setMemorializingBird(true);
setError('');
try {
const response = await apiFetch(`/birds/${selectedBird.id}/memorialize`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(memorializeBirdForm),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to memorialize bird.'));
}
const data = await readJsonSafely<{ bird: Bird }>(response);
if (!data?.bird) {
throw new Error('Unable to memorialize bird.');
}
const memorializedBird = data.bird;
setBirds((current) => current.filter((bird) => bird.id !== memorializedBird.id));
setMemorializedBirds((current) => sortBirdsByName([...current.filter((bird) => bird.id !== memorializedBird.id), memorializedBird]));
setSelectedBirdId('');
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
setMemorializeBirdForm(emptyMemorializeBirdForm());
setWeights([]);
setVetVisits([]);
setMedications([]);
setMedicationAdministrations([]);
setAllBirdWeights((current) => {
const next = { ...current };
delete next[memorializedBird.id];
return next;
});
setAllBirdVetVisits((current) => {
const next = { ...current };
delete next[memorializedBird.id];
return next;
});
} catch (memorializeError) {
setError(memorializeError instanceof Error ? memorializeError.message : 'Unable to memorialize bird.');
} finally {
setMemorializingBird(false);
}
};
const handleMemorialReminderPreferenceChange = async (bird: Bird, notifyOnMemorialDay: boolean) => {
if (savingMemorialReminderBirdId) {
return;
}
setSavingMemorialReminderBirdId(bird.id);
setError('');
try {
const response = await apiFetch(`/birds/${bird.id}/memorial-reminders`, authToken, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notifyOnMemorialDay }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to update memorial reminder setting.'));
}
const data = await readJsonSafely<{ bird: Bird }>(response);
if (!data?.bird) {
throw new Error('Unable to update memorial reminder setting.');
}
setMemorializedBirds((current) => current.map((currentBird) => (currentBird.id === data.bird.id ? data.bird : currentBird)));
} catch (preferenceError) {
setError(preferenceError instanceof Error ? preferenceError.message : 'Unable to update memorial reminder setting.');
} finally {
setSavingMemorialReminderBirdId('');
}
};
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (transferringBird) {
return;
}
setError('');
setTransferError('');
setTransferNotice(null);
setTransferringBird(true);
try {
const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
destinationOwnerEmail: flockTransferForm.destinationOwnerEmail,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to transfer bird to another flock.'));
}
const data =
(await readJsonSafely<{
bird?: Bird;
inviteSent?: boolean;
invitePreviewUrl?: string | null;
message?: string;
}>(response)) ?? {};
const transferredBirdName = data.bird?.name || birds.find((bird) => bird.id === flockTransferForm.birdId)?.name || 'Bird';
if (data.inviteSent) {
setTransferNotice({
message:
data.message ??
`A bird transfer invite was sent to ${flockTransferForm.destinationOwnerEmail}. The transfer will complete automatically after they sign in.`,
previewUrl: data.invitePreviewUrl,
});
setFlockTransferForm({
birdId: '',
destinationOwnerEmail: '',
});
return;
}
setBirds((current) => current.filter((bird) => bird.id !== flockTransferForm.birdId));
setAllBirdWeights((current) => {
const next = { ...current };
delete next[flockTransferForm.birdId];
return next;
});
setAllBirdVetVisits((current) => {
const next = { ...current };
delete next[flockTransferForm.birdId];
return next;
});
setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
setMedications((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
if (selectedBird?.id === flockTransferForm.birdId) {
setSelectedBirdId('');
}
if (editingBirdId === flockTransferForm.birdId) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
}
setFlockTransferForm({
birdId: '',
destinationOwnerEmail: '',
});
window.alert(`${transferredBirdName} was transferred to ${flockTransferForm.destinationOwnerEmail}.`);
} catch (submitError) {
const message = submitError instanceof Error ? submitError.message : 'Unable to transfer bird to another flock.';
setTransferError(message);
setError(message);
} finally {
setTransferringBird(false);
}
};
const handleTransferCodeAcceptSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (acceptingTransferCode) {
return;
}
const code = transferCodeAcceptForm.code.trim();
setError('');
setTransferCodeError('');
setTransferCodeNotice('');
setAcceptingTransferCode(true);
try {
const response = await apiFetch(`/bird-transfer-codes/${encodeURIComponent(code)}/accept`, authToken, {
method: 'POST',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to accept bird transfer code.'));
}
const data =
(await readJsonSafely<{
bird?: Bird;
sourceWorkspaceName?: string;
}>(response)) ?? {};
if (!data.bird) {
throw new Error('Unable to accept bird transfer code.');
}
setBirds((current) => sortBirdsByName([...current.filter((bird) => bird.id !== data.bird!.id), data.bird!]));
setSelectedBirdId(data.bird.id);
setTransferCodeAcceptForm({ code: '' });
setTransferCodeNotice(`${data.bird.name} was transferred into ${workspace?.name ?? 'your active flock'}.`);
} catch (submitError) {
const message = submitError instanceof Error ? submitError.message : 'Unable to accept bird transfer code.';
setTransferCodeError(message);
setError(message);
} finally {
setAcceptingTransferCode(false);
}
};
const handleCreateAdoptionTransferCode = async () => {
if (!selectedBird || creatingAdoptionReportCode) {
return null;
}
const existingCode = adoptionTransferCodes[selectedBird.id];
if (existingCode) {
return existingCode;
}
setAdoptionReportError('');
setCreatingAdoptionReportCode(true);
try {
const response = await apiFetch(`/birds/${selectedBird.id}/transfer-code`, authToken, {
method: 'POST',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to create adoption transfer code.'));
}
const data =
(await readJsonSafely<{
transferCode?: {
code?: string;
bird?: Bird;
};
}>(response)) ?? {};
const code = data.transferCode?.code;
if (!code) {
throw new Error('Unable to create adoption transfer code.');
}
if (data.transferCode?.bird) {
setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === data.transferCode!.bird!.id ? data.transferCode!.bird! : bird))));
}
setAdoptionTransferCodes((current) => ({ ...current, [selectedBird.id]: code }));
return code;
} catch (codeError) {
const message = codeError instanceof Error ? codeError.message : 'Unable to create adoption transfer code.';
setAdoptionReportError(message);
setError(message);
return null;
} finally {
setCreatingAdoptionReportCode(false);
}
};
const openAdoptionReport = (transferCode: string, reportWindow = window.open('', '_blank'), printFriendly = false) => {
if (!selectedBird) {
return;
}
if (!reportWindow) {
setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.');
return;
}
const qr = createQrPath(transferCode);
const toReportAssetUrl = (value: string) =>
value.startsWith('data:') || value.startsWith('http://') || value.startsWith('https://') ? value : new URL(value, window.location.origin).toString();
const reportLogoUrl = toReportAssetUrl(flockPalLandingArt);
const reportWordmarkUrl = toReportAssetUrl(flockPalTextArt);
const reportPhotoUrl = toReportAssetUrl(selectedBird.photoDataUrl || defaultBirdPhoto);
const profileRows = [
['Name', selectedBird.name],
['Species', selectedBird.species],
['Band/tag ID', selectedBird.tagId || 'Not recorded'],
['Sex', getBirdGenderLabel(selectedBird)],
['Hatch day', formatDate(selectedBird.dateOfBirth)],
['Favorite snack', selectedBird.favoriteSnack || 'Not recorded'],
['Latest weight', selectedBird.latestWeightGrams ? `${formatWeight(selectedBird.latestWeightGrams)}${selectedBird.latestRecordedOn ? ` on ${formatShortDate(selectedBird.latestRecordedOn)}` : ''}` : 'Pending'],
];
const vetRows = [
['Clinic name', selectedBird.vetClinicName || 'Not recorded'],
['Clinic address', selectedBird.vetClinicAddress || 'Not recorded'],
['Account #', selectedBird.vetAccountNumber || 'Not recorded'],
['Dr. name', selectedBird.vetDoctorName || 'Not recorded'],
];
const detailList = (label: string, value: string | null) => {
const entries = parseBirdProfileList(value);
return entries.length
? `<section><h3>${escapeReportHtml(label)}</h3><ul>${entries.map((entry) => `<li>${escapeReportHtml(entry)}</li>`).join('')}</ul></section>`
: `<section><h3>${escapeReportHtml(label)}</h3><p>Not recorded</p></section>`;
};
const weightRows = weights.length
? weights
.map(
(entry) =>
`<tr><td>${escapeReportHtml(formatDate(entry.recordedOn))}</td><td>${escapeReportHtml(formatWeight(entry.weightGrams))}</td><td>${escapeReportHtml(entry.notes || '')}</td></tr>`,
)
.join('')
: '<tr><td colspan="3">No weights recorded.</td></tr>';
const vetVisitRows = vetVisits.length
? vetVisits
.map(
(visit) =>
`<tr><td>${escapeReportHtml(formatDate(visit.visitedOn))}</td><td>${escapeReportHtml(visit.clinicName)}</td><td>${escapeReportHtml(visit.reason)}</td><td>${escapeReportHtml(visit.notes || '')}</td></tr>`,
)
.join('')
: '<tr><td colspan="4">No vet visits recorded.</td></tr>';
const noteRows = selectedBirdNotes.length
? selectedBirdNotes
.map(
(note) =>
`<article class="note"><strong>${escapeReportHtml(formatDateTime(note.updatedAt))}</strong><p>${escapeReportHtml(note.body)}</p></article>`,
)
.join('')
: '<p>No notes recorded.</p>';
const chartSvg =
selectedBirdChart.points.length || selectedBirdChart.historicalPoints.length
? `<svg viewBox="0 0 ${MEMBER_CHART_WIDTH} ${MEMBER_CHART_HEIGHT}" role="img" aria-label="Weight graph">
${selectedBirdChart.yTicks
.map(
(tick) =>
`<line x1="${MEMBER_CHART_PADDING.left}" y1="${tick.y}" x2="${MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right}" y2="${tick.y}" class="grid" />`,
)
.join('')}
${selectedBirdChart.historicalPath ? `<path d="${escapeReportHtml(selectedBirdChart.historicalPath)}" class="historical" />` : ''}
${selectedBirdChart.path ? `<path d="${escapeReportHtml(selectedBirdChart.path)}" class="current" />` : ''}
${selectedBirdChart.points
.map((point) => `<circle cx="${point.x}" cy="${point.y}" r="4" class="dot"><title>${escapeReportHtml(point.label)}</title></circle>`)
.join('')}
</svg>`
: '<p>No weight graph available yet.</p>';
const bodyBackground = printFriendly
? 'var(--paper)'
: `radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.28), transparent 22%),
radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.26), transparent 20%),
radial-gradient(circle at 24% 84%, rgba(221, 179, 78, 0.2), transparent 22%),
radial-gradient(circle at 86% 78%, rgba(43, 118, 92, 0.24), transparent 24%),
radial-gradient(circle at 62% 54%, rgba(48, 114, 160, 0.14), transparent 16%),
linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%)`;
const headerBackground = printFriendly
? '#fff'
: 'linear-gradient(135deg, rgba(252, 244, 228, 0.96), rgba(232, 243, 233, 0.9))';
const panelBackground = printFriendly ? '#fff' : 'var(--panel)';
const backgroundOverlayCss = printFriendly
? ''
: `body::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='420' viewBox='0 0 360 420'%3E%3Cg fill='none' stroke-linecap='round' stroke-width='18' opacity='.5'%3E%3Cg stroke='%235bb3b7' transform='translate(54 42) rotate(-18)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3Cg stroke='%237eb773' transform='translate(204 78) rotate(28)'%3E%3Cpath d='M0 -38v76'/%3E%3Cpath d='M-32 -19l64 38'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(312 54) rotate(-38)'%3E%3Cpath d='M0 -28v56'/%3E%3Cpath d='M-24 -14l48 28'/%3E%3C/g%3E%3Cg stroke='%23898b93' transform='translate(118 172) rotate(42)'%3E%3Cpath d='M0 -30v60'/%3E%3Cpath d='M-26 -15l52 30'/%3E%3C/g%3E%3Cg stroke='%23b9c945' transform='translate(278 208) rotate(-12)'%3E%3Cpath d='M0 -36v72'/%3E%3Cpath d='M-31 -18l62 36'/%3E%3C/g%3E%3Cg stroke='%235bb3b7' transform='translate(52 326) rotate(22)'%3E%3Cpath d='M0 -26v52'/%3E%3Cpath d='M-22 -13l44 26'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(186 352) rotate(-48)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
background-position: center top;
background-repeat: repeat;
background-size: 360px 420px;
content: "";
inset: 0;
opacity: 0.42;
pointer-events: none;
position: fixed;
z-index: -1;
}`;
reportWindow.document.write(`<!doctype html>
<html>
<head>
<title>FlockPal Adoption Report - ${escapeReportHtml(selectedBird.name)}</title>
<style>
:root {
--ink: #1f2a2a;
--muted: #5d5f59;
--paper: #fffdf9;
--panel: #fbf7ee;
--border: rgba(53, 129, 98, 0.28);
--red: #cb3a35;
--green: #238a5a;
--blue: #2769b3;
--gold: #f0b63f;
}
body {
background: ${bodyBackground};
color: var(--ink);
font-family: Inter, Arial, sans-serif;
line-height: 1.45;
margin: 32px;
min-height: 100vh;
position: relative;
}
${backgroundOverlayCss}
header {
background: ${headerBackground};
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 16px 34px rgba(86, 63, 34, 0.14);
display: grid;
gap: 22px;
grid-template-columns: 210px 1fr 320px;
min-height: 228px;
padding: 18px;
}
h1, h2, h3, p { margin: 0; }
h1 { color: var(--red); font-size: 34px; letter-spacing: 0; }
h2 {
border-bottom: 1px solid var(--border);
color: var(--green);
font-size: 19px;
margin: 28px 0 12px;
padding-bottom: 8px;
}
h3 { color: var(--blue); font-size: 14px; margin: 18px 0 8px; text-transform: uppercase; }
.muted { color: var(--muted); margin-top: 6px; }
.brand-logo {
align-self: center;
height: 210px;
justify-self: start;
object-fit: contain;
width: 210px;
}
.report-title {
align-self: center;
justify-self: center;
text-align: center;
}
.report-title .muted { margin-top: 8px; }
.profile-photo {
aspect-ratio: 1;
background: #fff;
border: 3px solid var(--paper);
border-radius: 18px;
box-shadow: 0 10px 22px rgba(86, 63, 34, 0.16);
height: 132px;
margin: 0 auto 12px;
object-fit: cover;
width: 132px;
}
.qr { align-self: center; justify-self: end; text-align: center; width: 320px; }
.qr svg { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 8px; width: 136px; }
.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; overflow-wrap: anywhere; }
.qr-join-label {
color: var(--green);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
line-height: 1;
margin-bottom: -28px;
position: relative;
text-transform: uppercase;
z-index: 1;
}
.qr-wordmark {
display: block;
height: 150px;
margin: -28px auto -12px;
object-fit: contain;
width: 340px;
}
.qr-note {
color: var(--blue);
font-family: "Avenir Next", "Arial Rounded MT Bold", Arial, sans-serif;
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
line-height: 1.28;
margin-top: 8px;
}
.grid { stroke: rgba(53, 129, 98, 0.16); }
.current { fill: none; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 4; }
.historical { fill: none; opacity: .45; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 3; }
.dot { fill: ${escapeReportHtml(selectedBird.chartColor)}; stroke: white; stroke-width: 2; }
.facts { display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fact { background: ${panelBackground}; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.fact span { color: var(--muted); display: block; font-size: 12px; margin-bottom: 4px; text-transform: uppercase; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
th { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.note { border-bottom: 1px solid var(--border); padding: 10px 0; }
.note p { margin-top: 6px; white-space: pre-wrap; }
main { margin-top: 24px; }
@media print {
body { margin: 14mm; }
header { box-shadow: none; break-inside: avoid; }
button { display: none; }
}
</style>
</head>
<body>
<header>
<img class="brand-logo" src="${escapeReportHtml(reportLogoUrl)}" alt="FlockPal logo">
<div class="report-title">
<img class="profile-photo" src="${escapeReportHtml(reportPhotoUrl)}" alt="${escapeReportHtml(selectedBird.name)} profile photo">
<h1>${escapeReportHtml(selectedBird.name)}</h1>
<p class="muted">Adoption Report</p>
<p class="muted">Generated ${escapeReportHtml(formatDateTime(new Date().toISOString()))}</p>
</div>
<div class="qr">
<p class="qr-join-label">Join</p>
<img class="qr-wordmark" src="${escapeReportHtml(reportWordmarkUrl)}" alt="FlockPal">
<svg viewBox="0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}" role="img" aria-label="Transfer code QR">
<rect width="${qr.viewBoxSize}" height="${qr.viewBoxSize}" fill="#fff"></rect>
<path d="${escapeReportHtml(qr.path)}" fill="#111418"></path>
</svg>
<p class="code">${escapeReportHtml(transferCode)}</p>
<p class="qr-note">Enter this code to keep ${escapeReportHtml(selectedBird.name)}'s care history flying forward.</p>
</div>
</header>
<main>
<h2>Flock Member Info</h2>
<section class="facts">
${profileRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
</section>
${detailList('Motivators', selectedBird.motivators)}
${detailList('Demotivators', selectedBird.demotivators)}
<h2>Weight Graph</h2>
${chartSvg}
<h2>Weight History</h2>
<table><thead><tr><th>Date</th><th>Weight</th><th>Notes</th></tr></thead><tbody>${weightRows}</tbody></table>
<h2>Veterinary Clinic Info</h2>
<section class="facts">
${vetRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
</section>
<h2>Vet Visit History</h2>
<table><thead><tr><th>Date</th><th>Clinic</th><th>Reason</th><th>Notes</th></tr></thead><tbody>${vetVisitRows}</tbody></table>
<h2>Notes</h2>
${noteRows}
</main>
</body>
</html>`);
reportWindow.document.close();
reportWindow.focus();
};
const handleOpenAdoptionReport = async (printFriendly = false) => {
const reportWindow = window.open('', '_blank');
if (!reportWindow) {
setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.');
return;
}
if (!selectedBird) {
reportWindow.close();
return;
}
setAdoptionReportError('');
setCreatingAdoptionReportCode(true);
try {
const response = await apiFetch(
`/birds/${selectedBird.id}/reports/adoption${printFriendly ? '?printFriendly=true' : ''}`,
authToken,
{ method: 'POST' },
);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to create adoption report.'));
}
const transferCode = response.headers.get('X-FlockPal-Transfer-Code');
if (transferCode) {
setAdoptionTransferCodes((current) => ({ ...current, [selectedBird.id]: transferCode }));
}
const reportBlob = await response.blob();
const reportUrl = URL.createObjectURL(reportBlob);
reportWindow.location.href = reportUrl;
window.setTimeout(() => URL.revokeObjectURL(reportUrl), 60_000);
} catch (reportError) {
reportWindow.close();
const message = reportError instanceof Error ? reportError.message : 'Unable to create adoption report.';
setAdoptionReportError(message);
setError(message);
} finally {
setCreatingAdoptionReportCode(false);
}
};
const saveWorkspaceSettings = async () => {
const response = await apiFetch('/workspace', authToken, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...workspaceForm,
billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan,
billingInterval: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingInterval,
rescueOnboarding: workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue' ? workspaceForm.rescueOnboarding : undefined,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save flock settings.'));
}
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
if (!data.workspace) {
throw new Error('Unable to save flock settings.');
}
const savedWorkspace = data.workspace;
setWorkspace(savedWorkspace);
setAuthSession((current) =>
current
? {
...current,
activeWorkspace: savedWorkspace,
workspaces: current.workspaces.map((entry) =>
entry.workspace.id === savedWorkspace.id ? { ...entry, workspace: savedWorkspace } : entry,
),
}
: current,
);
setWorkspaceForm({
name: savedWorkspace.name,
workspaceType: savedWorkspace.workspaceType,
billingEmail: savedWorkspace.billingEmail ?? '',
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
billingInterval: savedWorkspace.billingInterval,
rescueOnboarding: emptyRescueOnboardingForm(),
});
return savedWorkspace;
};
const handleWorkspaceSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
setSavingWorkspace(true);
try {
await saveWorkspaceSettings();
} catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.');
} finally {
setSavingWorkspace(false);
}
};
const handleDeleteWorkspace = async () => {
if (!workspace || !authToken || deletingWorkspace || activeMembership?.role !== 'owner') {
return;
}
const confirmed = window.confirm(
`Delete ${workspace.name}?\n\nThis only works when the flock has no birds. Remove or transfer all birds first. If this flock has a Stripe subscription, FlockPal will cancel it before deleting the flock.\n\nYou will be switched to another flock or a new empty flock automatically.`,
);
if (!confirmed) {
return;
}
setError('');
setDeletingWorkspace(true);
try {
const response = await apiFetch('/workspace', authToken, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to delete flock.'));
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
if (!data.session) {
throw new Error('Flock was deleted but the session could not be refreshed.');
}
const nextToken = data.token || authToken;
persistSessionToken(nextToken);
applySession(data.session, nextToken);
} catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to delete flock.');
} finally {
setDeletingWorkspace(false);
}
};
const handleCancelRescueRequest = async () => {
if (!authToken) {
return;
}
setError('');
setCancelingRescueRequest(true);
try {
const response = await apiFetch('/workspace/rescue-status/cancel', authToken, {
method: 'POST',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to cancel rescue status request.'));
}
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
if (!data.workspace) {
throw new Error('Unable to cancel rescue status request.');
}
const savedWorkspace = data.workspace;
setWorkspace(savedWorkspace);
setAuthSession((current) =>
current
? {
...current,
activeWorkspace: savedWorkspace,
workspaces: current.workspaces.map((entry) =>
entry.workspace.id === savedWorkspace.id ? { ...entry, workspace: savedWorkspace } : entry,
),
}
: current,
);
setWorkspaceForm({
name: savedWorkspace.name,
workspaceType: savedWorkspace.workspaceType,
billingEmail: savedWorkspace.billingEmail ?? '',
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
billingInterval: savedWorkspace.billingInterval,
rescueOnboarding: emptyRescueOnboardingForm(),
});
} catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to cancel rescue status request.');
} finally {
setCancelingRescueRequest(false);
}
};
const handleStartBillingCheckout = async () => {
if (!authToken || !workspace) {
return;
}
setError('');
setBillingNotice(null);
setBillingRedirecting(true);
setSavingWorkspace(true);
try {
const savedWorkspace = await saveWorkspaceSettings();
const billingPlan = isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : workspaceForm.billingPlan;
const billingInterval = savedWorkspace.billingInterval;
const response = await apiFetch('/billing/checkout-session', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ billingPlan, billingInterval }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to start Stripe checkout.'));
}
const data = (await readJsonSafely<{ url?: string }>(response)) ?? {};
if (!data.url) {
throw new Error('Unable to start Stripe checkout.');
}
window.location.assign(data.url);
} catch (billingError) {
setError(billingError instanceof Error ? billingError.message : 'Unable to start Stripe checkout.');
setBillingRedirecting(false);
setSavingWorkspace(false);
}
};
const handleOpenBillingPortal = async () => {
if (!authToken) {
return;
}
setError('');
setBillingNotice(null);
setBillingRedirecting(true);
try {
const response = await apiFetch('/billing/portal-session', authToken, {
method: 'POST',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to open Stripe billing portal.'));
}
const data = (await readJsonSafely<{ url?: string }>(response)) ?? {};
if (!data.url) {
throw new Error('Unable to open Stripe billing portal.');
}
window.location.assign(data.url);
} catch (billingError) {
setError(billingError instanceof Error ? billingError.message : 'Unable to open Stripe billing portal.');
setBillingRedirecting(false);
}
};
const handleWorkspaceMemberSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
setSavingWorkspaceMember(true);
try {
const response = await apiFetch('/workspace/members', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workspaceMemberForm),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to add rescue team member.'));
}
const data = (await readJsonSafely<{ member?: WorkspaceMember }>(response)) ?? {};
if (!data.member) {
throw new Error('Unable to add rescue team member.');
}
const nextMember = {
...data.member,
email: data.member.inviteEmail,
};
setWorkspaceMembers((current) => [...current, nextMember]);
setWorkspaceMemberForm(emptyWorkspaceMemberForm);
} catch (memberError) {
setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.');
} finally {
setSavingWorkspaceMember(false);
}
};
const handleRemoveWorkspaceMember = async (memberId: string) => {
setError('');
setRemovingWorkspaceMemberId(memberId);
try {
const response = await apiFetch(`/workspace/members/${memberId}`, authToken, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to remove rescue team member.'));
}
setWorkspaceMembers((current) => current.filter((member) => member.id !== memberId));
} catch (memberError) {
setError(memberError instanceof Error ? memberError.message : 'Unable to remove rescue team member.');
} finally {
setRemovingWorkspaceMemberId('');
}
};
const handleWeightRangeAlertClick = () => {
if (!totalWeightAlerts) {
return;
}
setShowWeightAlertModal(true);
};
const handleVetVisitReminderClick = () => {
const firstDueBird = vetVisitDueBirds[0];
if (!firstDueBird) {
return;
}
setSelectedBirdId(firstDueBird.id);
setBulkWeightOpen(false);
setActivePage('flock');
};
const publicProfileWorkspaceMembership = publicProfile
? authSession?.workspaces.find((entry) => entry.workspace.id === publicProfile.workspaceId) ?? null
: null;
const shouldShowPublicProfilePage =
Boolean(publicProfileCode) &&
(!authSession ||
!publicProfile ||
workspace?.id !== publicProfile.workspaceId ||
!birds.some((bird) => bird.id === publicProfile.id));
if (shouldShowPublicProfilePage) {
return (
<main className="auth-shell public-profile-shell">
<section className="panel public-profile-card">
<a className="public-profile-logo-link" href="/" aria-label="Go to FlockPal sign in">
<img className="public-profile-logo" src={flockPalLandingArt} alt="FlockPal" />
</a>
{publicProfileLoading || authLoading ? (
<p>Loading bird profile...</p>
) : publicProfileError || !publicProfile ? (
<>
<p className="eyebrow">FlockPal</p>
<h1>Public profile unavailable</h1>
<p className="muted">{publicProfileError || 'This bird profile is not available publicly.'}</p>
</>
) : (
<>
<img className="public-profile-photo" src={publicProfile.photoDataUrl || defaultBirdPhoto} alt={publicProfile.name} />
<div className="public-profile-copy">
<h1>
<span>{publicProfile.name}</span>
<span aria-label={getBirdGenderLabel(publicProfile)} className={`gender-symbol ${publicProfile.gender}`}>
{getBirdGenderSymbol(publicProfile)}
</span>
</h1>
<article className="summary-card">
<span>Hatch Day</span>
<strong>{formatDate(publicProfile.dateOfBirth)}</strong>
</article>
<article className="summary-card">
<span>Favorite treat</span>
<strong>{publicProfile.favoriteSnack || 'Not recorded'}</strong>
</article>
{publicProfileWorkspaceMembership ? (
<button className="primary-button" onClick={handleOpenPublicProfileBird} type="button">
Open full profile
</button>
) : null}
</div>
</>
)}
</section>
</main>
);
}
if (authLoading) {
return (
<main className="auth-shell">
<section className="hero-card auth-hero-card">
<div>
<p className="eyebrow">FlockPal</p>
<h1>Loading your flock spaces...</h1>
<p className="muted">Checking your sign-in and flock access.</p>
</div>
</section>
</main>
);
}
if (!authSession) {
return (
<main className="auth-shell">
<section className="panel auth-panel">
<div className="auth-copy">
<figure className="auth-illustration-card">
<img src={flockPalLandingArt} alt="FlockPal branding with a colorful trio of birds perched together." />
</figure>
<h1>Organized care for companion birds and rescue flocks</h1>
<p className="muted">
Keep every bird's care story in one place; your flock's health, history, and routines together and easier to visualize.
</p>
<details className="summary-card lost-bird-login-card">
<summary>
<span>
<span className="eyebrow">Report a missing bird</span>
</span>
</summary>
<p className="muted">Enter the band ID and FlockPal will notify the flock if that bird is in the system.</p>
<form className="form-panel" onSubmit={handleLostBirdReportSubmit}>
<label>
Bird band ID
<input
value={lostBirdReportForm.tagId}
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, tagId: event.target.value })}
placeholder="Example: ABC-123"
required
/>
</label>
<label>
Your email
<input
type="email"
value={lostBirdReportForm.finderEmail}
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, finderEmail: event.target.value })}
placeholder="Optional, but helpful"
/>
</label>
<label>
Where was the bird found?
<input
value={lostBirdReportForm.foundLocation}
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, foundLocation: event.target.value })}
placeholder="City, neighborhood, or nearby landmark"
/>
</label>
<label>
Message for the flock
<textarea
value={lostBirdReportForm.message}
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, message: event.target.value })}
placeholder="Add any safe details that could help the flock contact you."
rows={3}
/>
</label>
<button className="primary-button" type="submit" disabled={lostBirdReportSubmitting}>
{lostBirdReportSubmitting ? 'Sending report...' : 'Notify the flock'}
</button>
</form>
{lostBirdReportNotice ? (
<p className={lostBirdReportNotice.kind === 'error' ? 'error-banner' : 'success-banner'}>{lostBirdReportNotice.message}</p>
) : null}
</details>
</div>
<div className="auth-card">
<div>
<p className="eyebrow">Passwordless sign-in</p>
<h2>Email link or provider</h2>
<p className="muted">FlockPal no longer stores passwords. Use a magic link, Google, Microsoft, or Apple.</p>
</div>
{error ? <p className="error-banner">{error}</p> : null}
{authNotice ? (
<article className="summary-card">
<strong>{authNotice.message}</strong>
<span>
{authNotice.previewUrl ? (
<a href={authNotice.previewUrl}>Open the sign-in link</a>
) : (
'The link expires quickly and can only be used once.'
)}
</span>
</article>
) : null}
<form className="form-panel" onSubmit={handleAuthSubmit}>
<label>
Your name
<input value={authForm.name} onChange={(event) => setAuthForm({ ...authForm, name: event.target.value })} />
</label>
<label>
Email
<input
type="email"
value={authForm.email}
onChange={(event) => setAuthForm({ ...authForm, email: event.target.value })}
required
/>
</label>
<button className="primary-button" type="submit" disabled={authSubmitting}>
{authSubmitting ? 'Sending link...' : 'Email me a sign-in link'}
</button>
</form>
<div className="auth-provider-stack">
<p className="muted">Or continue with a common sign-in provider.</p>
{authProviders.map((provider) => (
<a
key={provider.providerKey}
className={`provider-button provider-${provider.providerKey} ${provider.enabled ? '' : 'disabled'}`}
href={provider.enabled ? oauthStartUrl(provider.providerKey) : undefined}
onClick={(event) => {
if (!provider.enabled) {
event.preventDefault();
}
}}
aria-disabled={!provider.enabled}
>
<span className="provider-button-mark" aria-hidden="true">
<ProviderIcon providerKey={provider.providerKey} />
</span>
<span className="provider-button-copy">
<strong>Continue with {provider.displayName}</strong>
<small>{provider.enabled ? 'Use your existing account to sign in.' : 'Not configured on this server yet.'}</small>
</span>
</a>
))}
</div>
</div>
</section>
</main>
);
}
if (loading) {
return (
<main className="app-shell">
<section className="hero-card">
<p>Loading flock data...</p>
</section>
</main>
);
}
const showWorkspaceSwitcher = authSession.workspaces.length > 1;
const todayDate = new Date().toISOString().slice(0, 10);
const renderMedicationCard = (medication: Medication, options: { showActions?: boolean; showAdministrationControls?: boolean }) => {
const doseSlots = medication.doseSchedule?.length ? medication.doseSchedule : getDefaultMedicationDoseSchedule(medication.frequency);
const latestAdministration = medicationAdministrations.find((administration) => administration.medicationId === medication.id);
return (
<article key={medication.id} className="vet-visit-card">
<strong>{medication.name}</strong>
<span>
{medication.dosage} {formatMedicationFrequency(medication.frequency)}
{medication.route ? ` • ${medication.route}` : ''}
</span>
<small>
{formatDate(medication.startDate)} to {formatDate(medication.endDate)}
</small>
<small>{medication.notes || 'No notes recorded.'}</small>
{latestAdministration ? (
<small>
Last update: {latestAdministration.status === 'administered' ? 'Given' : 'Not administered'} on{' '}
{formatShortDate(latestAdministration.administeredOn)}
</small>
) : null}
{options.showAdministrationControls ? (
<div className="medication-admin-actions">
<small>Today's interval events</small>
{doseSlots.map((slot) => {
const todayAdministration = medicationAdministrations.find(
(administration) =>
administration.medicationId === medication.id &&
administration.administeredOn === todayDate &&
administration.administrationSlot === slot.key,
);
const givenActionId = `${medication.id}-${slot.key}-administered`;
const missedActionId = `${medication.id}-${slot.key}-missed`;
return (
<div className="medication-dose-row" key={slot.key}>
<span>
<strong>{slot.label}</strong>
{slot.time ? <small>{formatDoseTime(slot.time)}</small> : null}
<small>{todayAdministration ? (todayAdministration.status === 'administered' ? 'Administered' : 'Not administered') : 'Unmarked'}</small>
</span>
<div className="button-row">
<button
className="primary-button"
onClick={() => handleMedicationAdministrationSubmit(medication.id, slot.key, 'administered')}
type="button"
disabled={Boolean(savingMedicationAdministrationId)}
>
{savingMedicationAdministrationId === givenActionId ? 'Saving...' : 'Administered'}
</button>
<button
className="secondary-button"
onClick={() => handleMedicationAdministrationSubmit(medication.id, slot.key, 'missed')}
type="button"
disabled={Boolean(savingMedicationAdministrationId)}
>
{savingMedicationAdministrationId === missedActionId ? 'Saving...' : 'Not administered'}
</button>
</div>
</div>
);
})}
</div>
) : null}
{options.showActions ? (
<div className="button-row">
<button className="secondary-button" onClick={() => handleEditMedication(medication)} type="button">
Edit
</button>
{editingMedicationId === medication.id ? (
<button
className="secondary-button"
onClick={() => handleDeleteMedication(medication.id)}
type="button"
disabled={deletingMedicationId === medication.id}
>
{deletingMedicationId === medication.id ? 'Deleting...' : 'Delete'}
</button>
) : null}
</div>
) : null}
</article>
);
};
const renderMedicationList = (options: { showActions?: boolean; showAdministrationControls?: boolean }) =>
medications.length ? (
<>
{activeMedications.length ? <strong>Active medication</strong> : null}
{activeMedications.map((medication) => renderMedicationCard(medication, options))}
{pastMedications.length ? <strong>Past medication</strong> : null}
{pastMedications.map((medication) => renderMedicationCard(medication, options))}
</>
) : (
<article className="vet-visit-card empty-card">
<strong>No medication configured yet</strong>
<small>Add medication here to make it visible on the flock member care page.</small>
</article>
);
return (
<main className="app-shell">
<div className="side-rail">
<div className="brand-lockup">
<img className="side-nav-logo" src={flockPalLandingArt} alt="FlockPal" />
</div>
<aside className="side-nav panel">
<div className="page-tabs" role="tablist" aria-label="Main navigation">
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
Overview
</button>
<button className={`page-tab ${activePage === 'flock' ? 'active' : ''}`} onClick={() => setActivePage('flock')} type="button">
Flock
</button>
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
Settings
</button>
{authSession.isAdmin ? (
<button className={`page-tab ${activePage === 'admin' ? 'active' : ''}`} onClick={() => setActivePage('admin')} type="button">
Admin
</button>
) : null}
</div>
{showWorkspaceSwitcher ? (
<section className="workspace-switcher">
<div className="workspace-switcher-list">
{authSession.workspaces.map((entry) => (
<button
key={`${entry.workspace.id}-${entry.membership.id}`}
className={`workspace-switcher-item ${entry.workspace.id === workspace?.id ? 'active' : ''}`}
onClick={() => handleWorkspaceSwitch(entry.workspace.id)}
type="button"
disabled={switchingWorkspaceId === entry.workspace.id}
>
<span>{entry.workspace.name}</span>
<small>
{formatBillingPlanName(entry.workspace.billingPlan)} • {formatWorkspaceRole(entry.membership.role)}
</small>
</button>
))}
</div>
</section>
) : null}
<button className="secondary-button" onClick={handleLogout} type="button">
Log out
</button>
</aside>
</div>
<section className="content-shell">
{error ? <p className="error-banner">{error}</p> : null}
{(activePage === 'overview' || activePage === 'flock') && (totalWeightAlerts || vetVisitDueBirds.length) ? (
<section className="top-alert-notification" role="alert" aria-label="Critical flock alert">
<span className="notification-bell" aria-hidden="true" />
<div>
<strong>
{totalWeightAlerts + vetVisitDueBirds.length} critical alert{totalWeightAlerts + vetVisitDueBirds.length === 1 ? '' : 's'}
</strong>
<span>
{totalWeightAlerts ? `${totalWeightAlerts} weight alert${totalWeightAlerts === 1 ? '' : 's'}` : ''}
{totalWeightAlerts && vetVisitDueBirds.length ? ' ' : ''}
{vetVisitDueBirds.length
? `${vetVisitDueBirds.length} annual vet reminder${vetVisitDueBirds.length === 1 ? '' : 's'}`
: ''}
</span>
</div>
<div className="top-alert-actions">
{totalWeightAlerts ? (
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
Review weights
</button>
) : null}
{vetVisitDueBirds.length ? (
<button className="range-alert-button" onClick={handleVetVisitReminderClick} type="button">
Review vet visits
</button>
) : null}
</div>
</section>
) : null}
{activePage === 'overview' ? (
<section className="stack-grid">
<section className="panel">
<div className="panel-header">
<div>
<p className="eyebrow">Overview</p>
<h2>30-day flock weight snapshot</h2>
</div>
<div className="button-row overview-alert-actions">
<p className="muted">
{birdsWithRecentWeights.length} current
{overviewHistoricalSeriesCount > 0 ? `, ${overviewHistoricalSeriesCount} previous-year` : ''}
</p>
</div>
</div>
<div className="chart-card overview-chart-card">
<svg viewBox="0 0 520 220" className="weight-chart" role="img" aria-label="All birds weight overview">
{overviewChart.yTicks.map((tick) => (
<g key={tick.label}>
<line
x1={OVERVIEW_PADDING.left}
y1={tick.y}
x2={OVERVIEW_WIDTH - OVERVIEW_PADDING.right}
y2={tick.y}
className="chart-grid-line"
/>
<text x={OVERVIEW_PADDING.left - 10} y={tick.y + 4} textAnchor="end" className="chart-axis-label">
{tick.label}
</text>
</g>
))}
<line
x1={OVERVIEW_PADDING.left}
y1={OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom}
x2={OVERVIEW_WIDTH - OVERVIEW_PADDING.right}
y2={OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom}
className="chart-axis-line"
/>
{overviewChart.xTicks.map((tick) => (
<text key={tick.label} x={tick.x} y={OVERVIEW_HEIGHT - 10} textAnchor="middle" className="chart-axis-label">
{tick.label}
</text>
))}
{overviewChart.historicalSeries.map(({ bird, points }) => (
<g key={`${bird.id}-historical`}>
{points.length > 1 ? (
<path
d={toOverviewPath(points)}
fill="none"
stroke={bird.chartColor}
strokeWidth="3"
strokeLinecap="round"
className="historical-weight-line"
/>
) : null}
{points.map((point) => (
<circle key={`${point.id}-historical`} cx={point.x} cy={point.y} r="3.5" fill={bird.chartColor} className="historical-weight-dot">
<title>{`${bird.name} previous year: ${point.label}`}</title>
</circle>
))}
</g>
))}
{overviewChart.series.map(({ bird, points }) => (
<g key={bird.id}>
{points.length > 1 ? (
<path d={toOverviewPath(points)} fill="none" stroke={bird.chartColor} strokeWidth="3.5" strokeLinecap="round" />
) : null}
{points.map((point) => (
<circle key={point.id} cx={point.x} cy={point.y} r="4.5" fill={bird.chartColor}>
<title>{`${bird.name}: ${point.label}`}</title>
</circle>
))}
</g>
))}
</svg>
</div>
<div className="legend-grid">
{overviewChart.plottedBirds.map(({ bird }) => {
const hasHistoricalData = overviewChart.historicalSeries.some((entry) => entry.bird.id === bird.id);
return (
<article key={bird.id} className="legend-card">
<span className="legend-swatch" style={{ background: bird.chartColor }} />
<div>
<strong>{bird.name}</strong>
{hasHistoricalData ? (
<div className="legend-line-key" aria-label="Solid line is current data. Dashed line is previous-year data.">
<span>
<svg viewBox="0 0 44 8" aria-hidden="true" focusable="false">
<line x1="2" y1="4" x2="42" y2="4" stroke={bird.chartColor} strokeWidth="3" strokeLinecap="round" />
</svg>
Current
</span>
<span>
<svg viewBox="0 0 44 8" aria-hidden="true" focusable="false">
<line
x1="2"
y1="4"
x2="42"
y2="4"
stroke={bird.chartColor}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="7 6"
opacity="0.48"
/>
</svg>
Previous year
</span>
</div>
) : null}
</div>
</article>
);
})}
</div>
</section>
<section className="forms-grid">
<article className="panel form-panel pulse-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock health Pulse</p>
</div>
</div>
<div className="summary-grid">
{missingFirstWeightCount > 0 ? (
<article className="summary-card">
<strong>{missingFirstWeightCount}</strong>
<span>Members still needing a first weight</span>
</article>
) : (
<article className="summary-card">
<span>First weights</span>
<strong>All recorded</strong>
</article>
)}
<article className="summary-card">
<span>Weekly flock changes</span>
{flockWeeklyTrendItems.length ? (
<div className="summary-list">
{flockWeeklyTrendItems.map((trendItem) => (
<strong key={trendItem.id} className="summary-trend-row">
<span className="summary-trend-name" style={{ color: trendItem.chartColor }}>
{trendItem.name}
</span>
<span>: </span>
<span
className={
trendItem.direction === 'positive' ? 'summary-trend-change positive' : 'summary-trend-change negative'
}
>
{trendItem.formattedChange}
</span>
</strong>
))}
</div>
) : (
<strong>No weekly changes yet</strong>
)}
</article>
</div>
</article>
</section>
</section>
) : null}
{activePage === 'admin' && authSession.isAdmin ? (
<section className="stack-grid">
<article className="panel">
<div className="panel-header">
<div>
<p className="eyebrow">Admin</p>
<h2>Platform pulse</h2>
</div>
<p className="muted">Operational counts for the full FlockPal platform.</p>
</div>
<div className="summary-grid">
<article className="summary-card">
<span>Total birds</span>
<strong>{adminSummary?.totalBirds ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Memorialized birds</span>
<strong>{adminSummary?.memorializedBirds ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Daily users</span>
<strong>{adminSummary?.dailyUsers ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Total users</span>
<strong>{adminSummary?.totalUsers ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Flocks</span>
<strong>{adminSummary?.totalWorkspaces ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Rescue flocks</span>
<strong>{adminSummary?.rescueWorkspaces ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Total rescue birds</span>
<strong>{adminSummary?.rescueBirds ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Pending rescues</span>
<strong>{adminSummary?.pendingRescues ?? '-'}</strong>
</article>
</div>
</article>
<article className="panel">
<div className="panel-header">
<div>
<p className="eyebrow">Verification</p>
<h2>Rescue flocks</h2>
</div>
<p className="muted">Pending rescues are read-only until approved.</p>
</div>
<div className="recent-list">
{adminRescueWorkspaces.length ? (
adminRescueWorkspaces.map((entry) => (
<article key={entry.workspace.id} className="vet-visit-card">
<strong>{entry.workspace.name}</strong>
<span>
{formatRescueVerificationStatus(entry.workspace.rescueVerificationStatus)} • {entry.birdCount} birds • {entry.memberCount} members
</span>
<small>
Billing {entry.workspace.billingEmail ?? 'not set'}
</small>
<div className="button-row">
<button
className="secondary-button"
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'approved')}
type="button"
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'approved'}
>
Approve
</button>
<button
className="secondary-button"
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'pending')}
type="button"
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'pending'}
>
Mark pending
</button>
<button
className="secondary-button"
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'rejected')}
type="button"
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'rejected'}
>
Reject and make household
</button>
</div>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No rescue flocks yet</strong>
<small>New rescue flocks will appear here for verification review.</small>
</article>
)}
</div>
</article>
</section>
) : null}
{activePage === 'flock' ? (
<section className={showFlockDetailColumn ? 'dashboard-grid' : 'stack-grid'}>
<aside className="panel bird-list-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock</p>
<h2>Flock members</h2>
<p className="muted">Select a bird to view more details.</p>
</div>
<div className="button-row">
<button className="secondary-button" onClick={() => setBulkWeightOpen((current) => !current)} type="button">
{bulkWeightOpen ? 'Hide bulk add' : 'Bulk add weights'}
</button>
<button className="secondary-button" onClick={startCreateBird} type="button">
Add bird
</button>
</div>
</div>
<div className="bird-list">
{birds.map((bird) => {
const weightRangeAlert = outOfRangeBirds.find((alert) => alert.bird.id === bird.id) ?? null;
const vetVisitAlertSignature = getVetVisitAlertSignature(bird.id);
const hasVetVisitAlert = vetVisitDueBirdIds.has(bird.id);
return (
<article
key={bird.id}
className={`bird-card ${bird.id === selectedBird?.id ? 'active' : ''}`}
style={bird.id === selectedBird?.id ? { borderColor: bird.chartColor, boxShadow: `0 16px 24px ${bird.chartColor}33` } : undefined}
>
<button className="bird-card-select" onClick={() => setSelectedBirdId(bird.id)} type="button">
<div className="bird-card-header">
<img className="bird-avatar" src={bird.photoDataUrl || defaultBirdPhoto} alt={`${bird.name}`} />
<div className="bird-card-copy">
<span className="bird-card-title">
<span>{bird.name}</span>
<span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${bird.gender}`}>
{getBirdGenderSymbol(bird)}
</span>
</span>
<small>{bird.species}</small>
</div>
</div>
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
</button>
{weightRangeAlert || hasVetVisitAlert ? (
<div className="bird-alert-stack">
{weightRangeAlert ? (
<span className="bird-alert-badge">
{weightRangeAlert.assessment.status === 'below' ? 'Below chart range' : 'Above chart range'}
<button
className="alert-dismiss-button"
onClick={() =>
dismissAlert(
bird.id,
'weight-range',
getWeightRangeAlertSignature(bird, weightRangeAlert.assessment),
)
}
type="button"
aria-label={`Dismiss weight range alert for ${bird.name}`}
>
Dismiss
</button>
</span>
) : null}
{hasVetVisitAlert ? (
<span className="bird-alert-badge">
Annual vet visit due
<button
className="alert-dismiss-button"
onClick={() => dismissAlert(bird.id, 'vet-visit', vetVisitAlertSignature)}
type="button"
aria-label={`Dismiss annual vet visit alert for ${bird.name}`}
>
Dismiss
</button>
</span>
) : null}
</div>
) : null}
</article>
);
})}
</div>
</aside>
{showFlockDetailColumn ? (
<section className="flock-detail-column">
{bulkWeightOpen ? (
<section className="panel bulk-weight-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Weigh-in</p>
<h2>Bulk add weights</h2>
</div>
<label className="bulk-date-field">
Date
<input type="date" value={bulkWeightDate} onChange={(event) => setBulkWeightDate(event.target.value)} required />
</label>
</div>
<form className="bulk-weight-form" onSubmit={handleBulkWeightSubmit}>
<div className="bulk-weight-table-shell">
<table className="bulk-weight-table">
<thead>
<tr>
<th>Flock member</th>
<th>Last weight</th>
<th>Weight today</th>
</tr>
</thead>
<tbody>
{birds.map((bird) => (
<tr key={bird.id}>
<td>{bird.name}</td>
<td>{formatWeight(bird.latestWeightGrams)}</td>
<td>
<input
type="number"
min="1"
step="0.1"
value={bulkWeightRows[bird.id]?.weightGrams ?? ''}
onChange={(event) => handleBulkWeightValueChange(bird.id, event.target.value)}
placeholder="g"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="button-row">
<button className="primary-button" type="submit" disabled={savingBulkWeights}>
{savingBulkWeights ? 'Saving weights...' : 'Save bulk weights'}
</button>
<button
className="secondary-button"
onClick={() =>
setBulkWeightRows((current) => Object.fromEntries(Object.keys(current).map((birdId) => [birdId, { weightGrams: '' }])))
}
type="button"
disabled={savingBulkWeights}
>
Clear entries
</button>
</div>
</form>
</section>
) : null}
{birdEditorOpen ? (
<section className="panel flock-member-panel flock-profile-editor">
<div className="panel-header">
<div>
<p className="eyebrow">Bird profile</p>
<h2>{editingBird ? `Edit ${editingBird.name}` : 'Add flock member'}</h2>
</div>
<button
className="secondary-button"
onClick={() => {
setBirdEditorOpen(false);
setEditingBirdId('');
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
}}
type="button"
>
Close
</button>
</div>
<form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}>
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Identity</p>
<h3>Profile details</h3>
</div>
<div className="settings-nested-grid">
<label>
Bird name
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label>
<label>
Band ID, if known
<input
value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
placeholder="Optional if unknown"
/>
</label>
<label className="species-picker-field wide-field">
Species
<div className="species-picker">
<input
value={birdForm.species}
onChange={(event) => {
setBirdForm({ ...birdForm, species: event.target.value });
setSpeciesPickerOpen(true);
}}
onFocus={() => setSpeciesPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => setSpeciesPickerOpen(false), 120);
}}
placeholder="Start typing a species"
autoComplete="off"
required
/>
{speciesPickerOpen ? (
<div className="species-picker-menu">
{filteredSpeciesOptions.length ? (
filteredSpeciesOptions.map((speciesOption) => (
<button
key={speciesOption}
className={`species-picker-option ${birdForm.species === speciesOption ? 'active' : ''}`}
onMouseDown={(event) => {
event.preventDefault();
setBirdForm({ ...birdForm, species: speciesOption });
setSpeciesPickerOpen(false);
}}
type="button"
>
{speciesOption}
</button>
))
) : (
<div className="species-picker-empty">No matching species yet. Keep typing to add a custom entry.</div>
)}
</div>
) : null}
</div>
</label>
<div className="segmented-field wide-field">
<span>Gender</span>
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
{(['unknown', 'male', 'female'] as BirdGender[]).map((gender) => (
<button
key={gender}
className={`segmented-option ${birdForm.gender === gender ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender })}
type="button"
role="radio"
aria-checked={birdForm.gender === gender}
>
<span className={`gender-symbol ${gender}`} aria-hidden="true">
{getBirdGenderSymbol({ gender })}
</span>
{getBirdGenderLabel({ gender })}
</button>
))}
</div>
</div>
<div className="settings-inline-header wide-field">
<p className="eyebrow">Dates</p>
<h3>Milestones and reminders</h3>
</div>
<label>
Hatch Day
<input
type="date"
value={birdForm.dateOfBirth}
onChange={(event) => setBirdForm({ ...birdForm, dateOfBirth: event.target.value })}
/>
</label>
<label>
Gotcha day
<input
type="date"
value={birdForm.gotchaDay}
onChange={(event) => setBirdForm({ ...birdForm, gotchaDay: event.target.value })}
/>
</label>
<label className="toggle-card">
<span>Notify on Hatch Day</span>
<input
type="checkbox"
checked={birdForm.notifyOnDob}
onChange={(event) => setBirdForm({ ...birdForm, notifyOnDob: event.target.checked })}
/>
</label>
<label className="toggle-card">
<span>Notify on gotcha day</span>
<input
type="checkbox"
checked={birdForm.notifyOnGotchaDay}
onChange={(event) => setBirdForm({ ...birdForm, notifyOnGotchaDay: event.target.checked })}
/>
</label>
<label>
Favorite snack
<input
value={birdForm.favoriteSnack}
onChange={(event) => setBirdForm({ ...birdForm, favoriteSnack: event.target.value })}
placeholder="Optional"
/>
</label>
<div className="settings-inline-header wide-field">
<p className="eyebrow">Veterinary</p>
<h3>Clinic account</h3>
</div>
<label>
Clinic name
<input
value={birdForm.vetClinicName}
onChange={(event) => setBirdForm({ ...birdForm, vetClinicName: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Account #
<input
value={birdForm.vetAccountNumber}
onChange={(event) => setBirdForm({ ...birdForm, vetAccountNumber: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Dr. name
<input
value={birdForm.vetDoctorName}
onChange={(event) => setBirdForm({ ...birdForm, vetDoctorName: event.target.value })}
placeholder="Optional"
/>
</label>
<label className="wide-field">
Clinic address
<textarea
rows={2}
value={birdForm.vetClinicAddress}
onChange={(event) => setBirdForm({ ...birdForm, vetClinicAddress: event.target.value })}
placeholder="Optional"
/>
</label>
<label className="wide-field">
Motivators
<div className="profile-list-fields">
{getBirdProfileListFields(birdForm.motivators).map((motivator, index) => (
<input
key={`flock-motivator-${index}`}
value={motivator}
onChange={(event) =>
setBirdForm({
...birdForm,
motivators: updateBirdProfileListField(birdForm.motivators, index, event.target.value),
})
}
placeholder={index === 0 ? 'Training reward, sound, person, toy, or routine' : `Motivator ${index + 1}`}
/>
))}
</div>
</label>
<label className="wide-field">
Demotivators
<div className="profile-list-fields">
{getBirdProfileListFields(birdForm.demotivators).map((demotivator, index) => (
<input
key={`flock-demotivator-${index}`}
value={demotivator}
onChange={(event) =>
setBirdForm({
...birdForm,
demotivators: updateBirdProfileListField(birdForm.demotivators, index, event.target.value),
})
}
placeholder={index === 0 ? 'Stressor, disliked handling, noise, or situation' : `Demotivator ${index + 1}`}
/>
))}
</div>
</label>
</div>
</section>
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Display</p>
<h3>Chart color and photo</h3>
</div>
<div className="settings-nested-grid">
<label>
Graph color
<input
type="color"
value={birdForm.chartColor}
onChange={(event) => setBirdForm({ ...birdForm, chartColor: event.target.value })}
/>
</label>
<div className="color-preview-card">
<span className="legend-swatch large-swatch" style={{ background: birdForm.chartColor }} />
<p className="muted">This color will follow this bird across the overview graph and its individual weight trend.</p>
</div>
<label className="toggle-field wide-field">
<input
type="checkbox"
checked={birdForm.publicProfileEnabled}
onChange={(event) => setBirdForm({ ...birdForm, publicProfileEnabled: event.target.checked })}
/>
<span>Enable QR code</span>
</label>
<div className="photo-editor wide-field">
<div className="photo-preview-shell">
{photoCrop ? (
<div
className={`crop-preview-frame ${photoDrag ? 'dragging' : ''}`}
onPointerDown={handlePhotoCropPointerDown}
onPointerMove={handlePhotoCropPointerMove}
onPointerUp={handlePhotoCropPointerUp}
onPointerCancel={handlePhotoCropPointerUp}
>
<img
className="crop-preview-image"
src={photoCrop.sourceDataUrl}
alt="Crop preview"
style={{
width: `${getPhotoCropMetrics(photoCrop).displayWidth}px`,
height: `${getPhotoCropMetrics(photoCrop).displayHeight}px`,
left: `${getPhotoCropMetrics(photoCrop).left}px`,
top: `${getPhotoCropMetrics(photoCrop).top}px`,
}}
/>
<div className="crop-preview-overlay" aria-hidden="true" />
</div>
) : (
<img className="profile-photo" src={birdForm.photoDataUrl || defaultBirdPhoto} alt="Bird preview" />
)}
</div>
<div className="photo-copy">
<label className="file-picker">
Photo
<input accept="image/png,image/jpeg,image/jpg,image/webp,image/gif" onChange={handleBirdPhotoChange} type="file" />
</label>
{photoCrop ? (
<div className="crop-control-stack">
<p className="muted">{photoCrop.fileName} ready to crop.</p>
<label>
Zoom
<input
type="range"
min="1"
max="3"
step="0.01"
value={photoCrop.zoom}
onChange={(event) =>
setPhotoCrop((current) => (current ? { ...current, zoom: Number(event.target.value) } : current))
}
/>
</label>
<div className="button-row">
<button className="primary-button" onClick={handleApplyPhotoCrop} type="button" disabled={applyingPhotoCrop}>
{applyingPhotoCrop ? 'Applying crop...' : 'Apply crop'}
</button>
<button className="secondary-button" onClick={handleCancelPhotoCrop} type="button" disabled={applyingPhotoCrop}>
Cancel
</button>
</div>
</div>
) : null}
{birdForm.photoDataUrl ? (
<button className="secondary-button" onClick={handleRemovePhoto} type="button">
Remove photo
</button>
) : null}
</div>
</div>
</div>
</section>
<div className="settings-save-row">
<button className="primary-button" type="submit" disabled={savingBird}>
{savingBird ? 'Saving...' : editingBird ? 'Save profile changes' : 'Save bird profile'}
</button>
</div>
</form>
{editingBird && selectedBird ? (
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Medication</p>
<h3>Medication configuration</h3>
</div>
<form className="form-panel inline-form care-entry-form" onSubmit={handleMedicationSubmit}>
<label>
Medication
<input
value={medicationForm.name}
onChange={(event) => setMedicationForm({ ...medicationForm, name: event.target.value })}
required
/>
</label>
<label>
Dosage
<input
value={medicationForm.dosage}
onChange={(event) => setMedicationForm({ ...medicationForm, dosage: event.target.value })}
required
/>
</label>
<label>
Frequency
<select
value={medicationForm.frequency}
onChange={(event) => {
const frequency = event.target.value as MedicationFrequency;
setMedicationForm({
...medicationForm,
frequency,
doseSchedule: getDefaultMedicationDoseSchedule(frequency),
});
}}
required
>
{medicationFrequencyOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
Route
<input value={medicationForm.route} onChange={(event) => setMedicationForm({ ...medicationForm, route: event.target.value })} />
</label>
<label>
Start date
<input
type="date"
value={medicationForm.startDate}
onChange={(event) => setMedicationForm({ ...medicationForm, startDate: event.target.value })}
required
/>
</label>
<label>
End date
<input
type="date"
value={medicationForm.endDate}
onChange={(event) => setMedicationForm({ ...medicationForm, endDate: event.target.value })}
/>
</label>
<label className="wide-field">
Dose labels and times
<div className="dose-schedule-editor">
{medicationForm.doseSchedule.map((slot, index) => (
<div className="dose-schedule-row" key={slot.key}>
<input
value={slot.label}
onChange={(event) =>
setMedicationForm({
...medicationForm,
doseSchedule: medicationForm.doseSchedule.map((currentSlot, currentIndex) =>
currentIndex === index ? { ...currentSlot, label: event.target.value } : currentSlot,
),
})
}
required
/>
<input
type="time"
value={slot.time}
onChange={(event) =>
setMedicationForm({
...medicationForm,
doseSchedule: medicationForm.doseSchedule.map((currentSlot, currentIndex) =>
currentIndex === index ? { ...currentSlot, time: event.target.value } : currentSlot,
),
})
}
/>
</div>
))}
</div>
</label>
<label className="wide-field">
Notes
<textarea
rows={3}
value={medicationForm.notes}
onChange={(event) => setMedicationForm({ ...medicationForm, notes: event.target.value })}
/>
</label>
<div className="button-row care-form-actions">
<button className="primary-button" type="submit">
{editingMedicationId ? 'Save medication changes' : 'Save medication'}
</button>
{editingMedicationId ? (
<button className="secondary-button" onClick={handleCancelMedicationEdit} type="button">
Cancel edit
</button>
) : null}
</div>
</form>
<div className="recent-list">{renderMedicationList({ showActions: true })}</div>
</section>
) : null}
{editingBird && selectedBird ? (
<section className="settings-nested-card settings-danger-card">
<div className="settings-nested-header">
<p className="eyebrow">Danger zone</p>
<h3>Destructive profile actions</h3>
</div>
<form className="form-panel inline-form care-entry-form" onSubmit={handleMemorializeBird}>
<label>
Memorial date
<input
type="date"
value={memorializeBirdForm.memorializedOn}
onChange={(event) => setMemorializeBirdForm({ ...memorializeBirdForm, memorializedOn: event.target.value })}
required
/>
</label>
<label className="wide-field">
Memorial note
<textarea
rows={3}
value={memorializeBirdForm.memorialNote}
onChange={(event) => setMemorializeBirdForm({ ...memorializeBirdForm, memorialNote: event.target.value })}
/>
</label>
<div className="button-row care-form-actions">
<button className="danger-button" type="submit" disabled={memorializingBird || activeMembership?.role !== 'owner'}>
{memorializingBird ? 'Memorializing...' : `Memorialize ${selectedBird.name}`}
</button>
</div>
</form>
<p className="muted">Removing permanently deletes weight records, vet visits, and medication history for this bird.</p>
<div className="button-row">
<button className="danger-button" onClick={handleRemoveBird} type="button" disabled={deletingBird}>
{deletingBird ? 'Removing...' : 'Remove from flock'}
</button>
</div>
</section>
) : null}
</section>
) : null}
{selectedBird && !birdEditorOpen ? (
<section className="panel flock-member-panel bird-detail-panel">
<div className="bird-detail-tabs" role="tablist" aria-label={`${selectedBird.name} detail sections`}>
<button
className={`bird-detail-tab ${selectedBirdTab === 'info' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('info')}
type="button"
role="tab"
aria-selected={selectedBirdTab === 'info'}
aria-label="Info"
title="Info"
>
<svg className="info-tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M11 17h2v-6h-2v6Zm1-14C6.48 3 2 7.48 2 13s4.48 10 10 10 10-4.48 10-10S17.52 3 12 3Zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8Zm-1-12h2V7h-2v2Z" />
</svg>
</button>
<button
className={`bird-detail-tab ${selectedBirdTab === 'weight' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('weight')}
type="button"
role="tab"
aria-selected={selectedBirdTab === 'weight'}
aria-label="Weight"
title="Weight"
>
<svg className="weight-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
<path d="M240-200h480l-57-400H297l-57 400Zm240-480q17 0 28.5-11.5T520-720q0-17-11.5-28.5T480-760q-17 0-28.5 11.5T440-720q0 17 11.5 28.5T480-680Zm113 0h70q30 0 52 20t27 49l57 400q5 36-18.5 63.5T720-120H240q-37 0-60.5-27.5T161-211l57-400q5-29 27-49t52-20h70q-3-10-5-19.5t-2-20.5q0-50 35-85t85-35q50 0 85 35t35 85q0 11-2 20.5t-5 19.5ZM240-200h480-480Z" />
</svg>
</button>
<button
className={`bird-detail-tab ${selectedBirdTab === 'vet' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('vet')}
type="button"
role="tab"
aria-selected={selectedBirdTab === 'vet'}
aria-label="Vet"
title="Vet"
>
<svg className="vet-tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M20 6h-4V4c0-1.11-.89-2-2-2h-4c-1.11 0-2 .89-2 2v2H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2ZM10 4h4v2h-4V4Zm6 11h-3v3h-2v-3H8v-2h3v-3h2v3h3v2Z" />
</svg>
</button>
<button
className={`bird-detail-tab ${selectedBirdTab === 'notes' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('notes')}
type="button"
role="tab"
aria-selected={selectedBirdTab === 'notes'}
aria-label="Notes"
title="Notes"
>
<svg className="note-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
<path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360l280 280v360q0 33-23.5 56.5T760-120H200Zm320-400v-240H200v560h560v-320H520ZM280-280h400v-80H280v80Zm0-160h240v-80H280v80Zm-80-320v240-240 560-560Z" />
</svg>
</button>
<button
className={`bird-detail-tab ${selectedBirdTab === 'reports' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('reports')}
type="button"
role="tab"
aria-selected={selectedBirdTab === 'reports'}
aria-label="Reports"
title="Reports"
>
<svg className="report-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
<path d="M280-280h80v-240h-80v240Zm160 0h80v-400h-80v400Zm160 0h80v-120h-80v120ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Z" />
</svg>
</button>
<button
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('audit')}
type="button"
role="tab"
aria-selected={selectedBirdTab === 'audit'}
aria-label="Audit log"
title="Audit log"
>
<svg className="audit-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
<path d="M480-120q-75 0-140.5-28.5t-114-77q-48.5-48.5-77-114T120-480h80q0 117 81.5 198.5T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-96 88h105v80H120v-240h80v94q47-64 120.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z" />
</svg>
</button>
</div>
<div className="panel-header">
<div>
<p className="eyebrow">Flock member</p>
</div>
<div className="member-header-actions">
{selectedBirdWeightRangeAlert || selectedBirdWeightDropAlerts.length || selectedBirdHasVetVisitAlert ? (
<div className="bird-alert-stack" aria-label={`Critical alerts for ${selectedBird.name}`}>
{selectedBirdWeightRangeAlert ? (
<span className="bird-alert-badge">
{selectedBirdWeightRangeAlert.assessment.status === 'below' ? 'Below chart range' : 'Above chart range'}
<button
className="alert-dismiss-button"
onClick={() =>
dismissAlert(
selectedBird.id,
'weight-range',
getWeightRangeAlertSignature(selectedBird, selectedBirdWeightRangeAlert.assessment),
)
}
type="button"
aria-label={`Dismiss weight range alert for ${selectedBird.name}`}
>
Dismiss
</button>
</span>
) : null}
{selectedBirdWeightDropAlerts.map((alert) => (
<span className="bird-alert-badge" key={`selected-drop-${alert.latestWeight.id}`}>
Down {alert.dropPercent.toFixed(1)}%
<button
className="alert-dismiss-button"
onClick={() => dismissAlert(selectedBird.id, 'weight-drop', getWeightDropAlertSignature(alert))}
type="button"
aria-label={`Dismiss weight drop alert for ${selectedBird.name}`}
>
Dismiss
</button>
</span>
))}
{selectedBirdHasVetVisitAlert ? (
<span className="bird-alert-badge">
Annual vet visit due
<button
className="alert-dismiss-button"
onClick={() => dismissAlert(selectedBird.id, 'vet-visit', selectedBirdVetVisitAlertSignature)}
type="button"
aria-label={`Dismiss annual vet visit alert for ${selectedBird.name}`}
>
Dismiss
</button>
</span>
) : null}
</div>
) : null}
</div>
</div>
<>
<section className="profile-hero">
<img className="profile-photo" src={selectedBird.photoDataUrl || defaultBirdPhoto} alt={`${selectedBird.name}`} />
<div className="profile-actions">
{selectedBird.publicProfileEnabled && selectedBird.publicProfileCode ? (
<button className="profile-icon-button qr-profile-button" onClick={() => setQrBird(selectedBird)} type="button" aria-label={`Open QR code for ${selectedBird.name}`} title="QR code">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 3h7v7H3V3Zm2 2v3h3V5H5Zm9-2h7v7h-7V3Zm2 2v3h3V5h-3ZM3 14h7v7H3v-7Zm2 2v3h3v-3H5Zm10-1h2v2h-2v-2Zm4 0h2v2h-2v-2Zm-5 4h2v2h-2v-2Zm3-2h2v2h-2v-2Zm2 2h2v2h-2v-2Z" />
</svg>
</button>
) : null}
<button className="profile-icon-button" onClick={() => startEditBird(selectedBird)} type="button" aria-label={`Edit details for ${selectedBird.name}`} title="Edit details">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M4 20h4l10.5-10.5-4-4L4 16v4Z" />
<path d="m13.5 6.5 4 4" />
<path d="M15 5l1.5-1.5a2.1 2.1 0 0 1 3 3L18 8" />
</svg>
</button>
</div>
<div className="profile-copy">
<h3 className="profile-title">
<span>{selectedBird.name}</span>
<span
aria-label={getBirdGenderLabel(selectedBird)}
className={`gender-symbol ${selectedBird.gender}`}
>
{getBirdGenderSymbol(selectedBird)}
</span>
</h3>
<p className="muted">
{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
</p>
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
</div>
</section>
<div className="bird-detail-tab-panel">
{selectedBirdTab === 'info' ? (
<div className="flock-member-sections" role="tabpanel">
<div className="detail-grid">
<article className="detail-card">
<span>Hatch Day</span>
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
</article>
<article className="detail-card">
<span>Gotcha day</span>
<strong>{formatDate(selectedBird.gotchaDay)}</strong>
</article>
<article className="detail-card">
<span>Favorite snack</span>
<strong>{selectedBird.favoriteSnack || 'Not recorded'}</strong>
</article>
<article className="detail-card">
<span>Motivators</span>
{parseBirdProfileList(selectedBird.motivators).length ? (
<ul className="detail-item-list">
{parseBirdProfileList(selectedBird.motivators).map((motivator, index) => (
<li key={`${motivator}-${index}`}>{motivator}</li>
))}
</ul>
) : (
<strong>Not recorded</strong>
)}
</article>
<article className="detail-card">
<span>Demotivators</span>
{parseBirdProfileList(selectedBird.demotivators).length ? (
<ul className="detail-item-list">
{parseBirdProfileList(selectedBird.demotivators).map((demotivator, index) => (
<li key={`${demotivator}-${index}`}>{demotivator}</li>
))}
</ul>
) : (
<strong>Not recorded</strong>
)}
</article>
</div>
{medications.length ? (
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Medication</p>
<h2>Medication schedule</h2>
</div>
</div>
<div className="recent-list">{renderMedicationList({ showAdministrationControls: true })}</div>
</section>
) : null}
</div>
) : null}
{selectedBirdTab === 'weight' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Weight</p>
<h2>Trend and log</h2>
</div>
<p className="muted">
Latest: {formatWeight(selectedBird.latestWeightGrams)}
{selectedBird.latestRecordedOn ? ` on ${formatShortDate(selectedBird.latestRecordedOn)}` : ''}
</p>
</div>
<div className="chart-card">
<svg
viewBox={`0 0 ${MEMBER_CHART_WIDTH} ${MEMBER_CHART_HEIGHT}`}
className="weight-chart"
role="img"
aria-label="Selected flock member weight trend chart"
>
<defs>
<linearGradient id="lineGlow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={selectedBird.chartColor} stopOpacity="0.45" />
<stop offset="100%" stopColor={selectedBird.chartColor} />
</linearGradient>
</defs>
{selectedBirdChart.yTicks.map((tick) => (
<g key={tick.label}>
<line
x1={MEMBER_CHART_PADDING.left}
y1={tick.y}
x2={MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right}
y2={tick.y}
className="chart-grid-line"
/>
<text x={MEMBER_CHART_PADDING.left - 10} y={tick.y + 4} textAnchor="end" className="chart-axis-label">
{tick.label}
</text>
</g>
))}
<line
x1={MEMBER_CHART_PADDING.left}
y1={MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom}
x2={MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right}
y2={MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom}
className="chart-axis-line"
/>
{selectedBirdChart.xTicks.map((tick) => (
<text
key={`${tick.label}-${tick.x}`}
x={tick.x}
y={MEMBER_CHART_HEIGHT - 10}
textAnchor="middle"
className="chart-axis-label"
>
{tick.label}
</text>
))}
{hasSelectedBirdHistoricalLine && selectedBirdChart.historicalIsFlat ? (
<line
x1={selectedBirdChart.historicalPoints[0].x}
y1={selectedBirdChart.historicalPoints[0].y}
x2={selectedBirdChart.historicalPoints[selectedBirdChart.historicalPoints.length - 1].x}
y2={selectedBirdChart.historicalPoints[selectedBirdChart.historicalPoints.length - 1].y}
stroke={selectedBird.chartColor}
strokeWidth="3"
strokeLinecap="round"
className="historical-weight-line"
/>
) : null}
{hasSelectedBirdHistoricalLine && !selectedBirdChart.historicalIsFlat ? (
<path
d={selectedBirdChart.historicalPath}
fill="none"
stroke={selectedBird.chartColor}
strokeWidth="3"
strokeLinecap="round"
className="historical-weight-line"
/>
) : null}
{selectedBirdChart.historicalPoints.map((point) => (
<circle
key={`${point.id}-historical`}
cx={point.x}
cy={point.y}
r="4"
fill={selectedBird.chartColor}
stroke="#fffdf9"
strokeWidth="2"
className="historical-weight-dot"
>
<title>{`${selectedBird.name} previous year: ${point.label}`}</title>
</circle>
))}
{hasSelectedBirdLine && selectedBirdChart.isFlat ? (
<line
x1={selectedBirdChart.points[0].x}
y1={selectedBirdChart.points[0].y}
x2={selectedBirdChart.points[selectedBirdChart.points.length - 1].x}
y2={selectedBirdChart.points[selectedBirdChart.points.length - 1].y}
stroke={selectedBird.chartColor}
strokeWidth="4"
strokeLinecap="round"
/>
) : null}
{hasSelectedBirdLine && !selectedBirdChart.isFlat ? (
<path d={selectedBirdChart.path} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" />
) : null}
{selectedBirdChart.points.map((point) => (
<circle key={point.id} cx={point.x} cy={point.y} r="5" fill={selectedBird.chartColor} stroke="#fffdf9" strokeWidth="2">
<title>{point.label}</title>
</circle>
))}
{selectedBirdLatestChartPoint ? (
<g
className="latest-weight-callout"
transform={`translate(${
selectedBirdLatestChartPoint.x > MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right - 115
? selectedBirdLatestChartPoint.x - 10
: selectedBirdLatestChartPoint.x + 10
} ${
selectedBirdLatestChartPoint.y < MEMBER_CHART_PADDING.top + 18
? selectedBirdLatestChartPoint.y + 26
: selectedBirdLatestChartPoint.y - 18
})`}
>
<rect
x={selectedBirdLatestChartPoint.x > MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right - 115 ? -104 : 0}
y="-14"
width="104"
height="24"
rx="6"
/>
<text
x={selectedBirdLatestChartPoint.x > MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right - 115 ? -10 : 10}
y="2"
textAnchor={
selectedBirdLatestChartPoint.x > MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right - 115 ? 'end' : 'start'
}
>
Latest {formatWeight(selectedBird.latestWeightGrams)}
</text>
</g>
) : null}
</svg>
<div className="chart-footer">
<p>{selectedBirdTrendCopy}</p>
<span>
{selectedBirdChart.visibleCount
? `${selectedBirdChart.visibleCount} current${
selectedBirdChart.historicalCount > 0 ? `, ${selectedBirdChart.historicalCount} previous-year` : ''
}`
: 'No current data yet'}
</span>
</div>
</div>
<form className="form-panel inline-form" onSubmit={handleWeightSubmit}>
<label>
Weight in grams
<input
type="number"
min="1"
step="0.1"
value={weightForm.weightGrams}
onChange={(event) => setWeightForm({ ...weightForm, weightGrams: event.target.value })}
required
/>
</label>
<label>
Recorded on
<input
type="date"
value={weightForm.recordedOn}
onChange={(event) => setWeightForm({ ...weightForm, recordedOn: event.target.value })}
required
/>
</label>
<label className="wide-field">
Notes
<textarea
rows={3}
value={weightForm.notes}
onChange={(event) => setWeightForm({ ...weightForm, notes: event.target.value })}
placeholder="Optional notes about appetite, molt, meds, or behavior"
/>
</label>
<button className="primary-button" type="submit">
Save weight
</button>
</form>
</section>
</div>
) : null}
{selectedBirdTab === 'vet' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Veterinary</p>
<h2>Clinic account</h2>
</div>
{!editingVeterinaryInfo ? (
<button
className="profile-icon-button"
onClick={() => {
setVeterinaryInfoForm(toVeterinaryInfoForm(selectedBird));
setEditingVeterinaryInfo(true);
}}
type="button"
aria-label={`Edit veterinary info for ${selectedBird.name}`}
title="Edit veterinary info"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M4 20h4l10.5-10.5-4-4L4 16v4Z" />
<path d="m13.5 6.5 4 4" />
<path d="M15 5l1.5-1.5a2.1 2.1 0 0 1 3 3L18 8" />
</svg>
</button>
) : null}
</div>
{editingVeterinaryInfo ? (
<form className="form-panel inline-form care-entry-form" onSubmit={handleVeterinaryInfoSubmit}>
<label>
Clinic name
<input
value={veterinaryInfoForm.vetClinicName}
onChange={(event) => setVeterinaryInfoForm({ ...veterinaryInfoForm, vetClinicName: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Account #
<input
value={veterinaryInfoForm.vetAccountNumber}
onChange={(event) => setVeterinaryInfoForm({ ...veterinaryInfoForm, vetAccountNumber: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Dr. name
<input
value={veterinaryInfoForm.vetDoctorName}
onChange={(event) => setVeterinaryInfoForm({ ...veterinaryInfoForm, vetDoctorName: event.target.value })}
placeholder="Optional"
/>
</label>
<label className="wide-field">
Clinic address
<textarea
rows={2}
value={veterinaryInfoForm.vetClinicAddress}
onChange={(event) => setVeterinaryInfoForm({ ...veterinaryInfoForm, vetClinicAddress: event.target.value })}
placeholder="Optional"
/>
</label>
<div className="button-row care-form-actions">
<button className="primary-button" type="submit" disabled={savingVeterinaryInfo}>
{savingVeterinaryInfo ? 'Saving veterinary info...' : 'Save veterinary info'}
</button>
<button
className="secondary-button"
onClick={() => {
setVeterinaryInfoForm(toVeterinaryInfoForm(selectedBird));
setEditingVeterinaryInfo(false);
}}
type="button"
disabled={savingVeterinaryInfo}
>
Cancel
</button>
</div>
</form>
) : (
<div className="detail-grid">
<article className="detail-card">
<span>Clinic name</span>
<strong>{selectedBird.vetClinicName || 'Not recorded'}</strong>
</article>
<article className="detail-card">
<span>Account #</span>
<strong>{selectedBird.vetAccountNumber || 'Not recorded'}</strong>
</article>
<article className="detail-card">
<span>Dr. name</span>
<strong>{selectedBird.vetDoctorName || 'Not recorded'}</strong>
</article>
<article className="detail-card wide-field">
<span>Clinic address</span>
<strong>{selectedBird.vetClinicAddress || 'Not recorded'}</strong>
</article>
</div>
)}
</section>
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Vet visits</p>
<h2>Care history and notes</h2>
</div>
</div>
<form className="form-panel inline-form care-entry-form" onSubmit={handleVetVisitSubmit}>
<label>
Visit date
<input
type="date"
value={vetVisitForm.visitedOn}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, visitedOn: event.target.value })}
required
/>
</label>
<label>
Clinic
<input
value={vetVisitForm.clinicName}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, clinicName: event.target.value })}
required
/>
</label>
<label>
Reason
<input
value={vetVisitForm.reason}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, reason: event.target.value })}
required
/>
</label>
<label className="wide-field">
Notes
<textarea
rows={3}
value={vetVisitForm.notes}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, notes: event.target.value })}
placeholder="Exam notes, medications, follow-ups, or restrictions"
/>
</label>
<div className="button-row care-form-actions">
<button className="primary-button" type="submit">
{editingVetVisitId ? 'Save vet visit changes' : 'Save vet visit'}
</button>
{editingVetVisitId ? (
<button className="secondary-button" onClick={handleCancelVetVisitEdit} type="button">
Cancel edit
</button>
) : null}
</div>
</form>
<div className="recent-list">
{vetVisits.length ? (
vetVisits.map((visit) => (
<article key={visit.id} className="vet-visit-card">
<strong>{visit.reason}</strong>
<span>
{formatDate(visit.visitedOn)} • {visit.clinicName}
</span>
<small>{visit.notes || 'No notes recorded.'}</small>
<div className="button-row">
<button className="secondary-button" onClick={() => handleEditVetVisit(visit)} type="button">
Edit
</button>
{editingVetVisitId === visit.id ? (
<button
className="secondary-button"
onClick={() => handleDeleteVetVisit(visit.id)}
type="button"
disabled={deletingVetVisitId === visit.id}
>
{deletingVetVisitId === visit.id ? 'Deleting...' : 'Delete'}
</button>
) : null}
</div>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No vet visits yet</strong>
<small>Add the first visit above to start this care history.</small>
</article>
)}
</div>
</section>
</div>
) : null}
{selectedBirdTab === 'notes' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Notes</p>
<h2>{selectedBird.name} notes</h2>
</div>
<p className="muted">{selectedBirdNotes.length} total</p>
</div>
<form className="form-panel care-entry-form" onSubmit={handleFlockNoteSubmit}>
<label className="wide-field">
Note
<textarea
rows={4}
value={flockNoteForm.body}
onChange={(event) => setFlockNoteForm({ ...flockNoteForm, body: event.target.value })}
maxLength={5000}
required
/>
</label>
<button className="primary-button" type="submit" disabled={savingFlockNote}>
{savingFlockNote ? 'Saving...' : 'Save note'}
</button>
</form>
<div className="recent-list note-list">
{selectedBirdNotes.length ? (
selectedBirdNotes.map((note) => (
<article key={note.id} className="note-card">
<div>
<span>Updated {formatDateTime(note.updatedAt)}</span>
</div>
<p>{note.body}</p>
<div className="button-row">
<small>{note.createdByName ? `By ${note.createdByName}` : 'Author unavailable'}</small>
<button
className="secondary-button"
type="button"
onClick={() => handleDeleteFlockNote(note.id)}
disabled={deletingFlockNoteId === note.id}
>
{deletingFlockNoteId === note.id ? 'Deleting...' : 'Delete'}
</button>
</div>
</article>
))
) : (
<article className="empty-card">
<strong>No notes yet</strong>
<small>Add the first note for {selectedBird.name} above.</small>
</article>
)}
</div>
</section>
</div>
) : null}
{selectedBirdTab === 'reports' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Reports</p>
<h2>Adoption report</h2>
</div>
</div>
<p className="muted">
Create a print-ready adoption report with profile details, weight history, veterinary clinic info, vet visits,
notes, and a transfer code for accepting {selectedBird.name} into another active flock.
</p>
<div className="detail-grid">
<article className="detail-card">
<span>Transfer code</span>
<strong>{selectedBirdAdoptionTransferCode || 'Not generated'}</strong>
</article>
</div>
<div className="button-row">
<button className="primary-button" onClick={() => handleOpenAdoptionReport(false)} type="button" disabled={creatingAdoptionReportCode}>
{creatingAdoptionReportCode ? 'Preparing report...' : 'Open adoption report'}
</button>
<button className="secondary-button" onClick={() => handleOpenAdoptionReport(true)} type="button" disabled={creatingAdoptionReportCode}>
Print-friendly report
</button>
</div>
{adoptionReportError ? (
<p className="error-banner" role="alert">
{adoptionReportError}
</p>
) : null}
</section>
</div>
) : null}
{selectedBirdTab === 'audit' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Audit log</p>
<h2>{selectedBird.name} activity</h2>
</div>
<p className="muted">{auditLogLoading ? 'Loading...' : `${selectedBirdAuditLogEntries.length} recent events`}</p>
</div>
{['owner', 'assistant'].includes(activeMembership?.role ?? '') ? (
<div className="recent-list audit-log-list">
{selectedBirdAuditLogEntries.length ? (
selectedBirdAuditLogEntries.map((entry) => (
<article key={entry.id} className="audit-log-card">
<div>
<strong>{formatAuditAction(entry.action)}</strong>
<span>{formatDateTime(entry.createdAt)}</span>
</div>
<small>
{entry.actorName || entry.actorEmail || 'Unknown user'}
{entry.entityName ? ` • ${entry.entityName}` : ` • ${entry.entityType}`}
</small>
</article>
))
) : (
<article className="empty-card">
<strong>No audit events yet</strong>
<small>New activity for {selectedBird.name} will appear here.</small>
</article>
)}
</div>
) : (
<p className="muted">Only owners and assistants can view the audit log.</p>
)}
</section>
</div>
) : null}
</div>
</>
</section>
) : null}
</section>
) : null}
</section>
) : null}
{activePage === 'settings' ? (
<section className="forms-grid settings-grid">
<div className="settings-column settings-column-left">
<article className="panel form-panel settings-card-flock-profile">
<div className="panel-header">
<div>
<p className="eyebrow">Flock</p>
<h2>Flock profile</h2>
</div>
<button
className="secondary-button"
onClick={() => setExpandedSettingsSection((current) => (current === 'new-workspace' ? null : 'new-workspace'))}
type="button"
aria-expanded={expandedSettingsSection === 'new-workspace'}
>
{expandedSettingsSection === 'new-workspace' ? 'Close' : 'New flock'}
</button>
</div>
<p className="muted">
Choose the flock to manage here. Household billing details live in the Billing info card below.
</p>
<form className="form-panel" onSubmit={handleWorkspaceSubmit}>
{authSession.workspaces.length > 1 ? (
<label>
Flock
<select
value={workspace?.id ?? ''}
onChange={(event) => handleWorkspaceSwitch(Number(event.target.value), 'settings')}
disabled={switchingWorkspaceId !== null || savingWorkspace || deletingWorkspace}
>
{authSession.workspaces.map((entry) => (
<option key={entry.workspace.id} value={entry.workspace.id}>
{entry.workspace.name}
</option>
))}
</select>
</label>
) : null}
<label>
Flock name
<input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required />
</label>
<label>
Flock type
<select
value={workspaceForm.workspaceType}
onChange={(event) => {
const workspaceType = event.target.value as WorkspaceFormState['workspaceType'];
setWorkspaceForm({
...workspaceForm,
workspaceType,
rescueOnboarding:
workspaceType === 'rescue'
? {
...workspaceForm.rescueOnboarding,
name: workspaceForm.rescueOnboarding.name || workspaceForm.name,
}
: workspaceForm.rescueOnboarding,
});
}}
>
<option value="standard">Standard household</option>
<option value="rescue">Rescue</option>
</select>
</label>
{workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue' ? (
<article className="summary-card summary-alert-card">
<strong>This converts the current flock</strong>
<span>
Saving here updates this household flock into a rescue flock. It does not create a separate flock space. The flock will be
read-only until FlockPal approves rescue verification.
</span>
</article>
) : null}
{workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue' ? (
<section className="settings-nested-card">
<h3>Rescue onboarding</h3>
<label>
Rescue Name
<input
value={workspaceForm.rescueOnboarding.name}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
rescueOnboarding: { ...workspaceForm.rescueOnboarding, name: event.target.value },
})
}
/>
</label>
<label>
City
<input
value={workspaceForm.rescueOnboarding.city}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
rescueOnboarding: { ...workspaceForm.rescueOnboarding, city: event.target.value },
})
}
/>
</label>
<label>
State
<input
value={workspaceForm.rescueOnboarding.state}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
rescueOnboarding: { ...workspaceForm.rescueOnboarding, state: event.target.value },
})
}
/>
</label>
<label>
EIN
<input
value={workspaceForm.rescueOnboarding.ein}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
rescueOnboarding: { ...workspaceForm.rescueOnboarding, ein: event.target.value },
})
}
/>
</label>
<label>
Website
<input
type="url"
value={workspaceForm.rescueOnboarding.website}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
rescueOnboarding: { ...workspaceForm.rescueOnboarding, website: event.target.value },
})
}
/>
</label>
</section>
) : null}
{workspaceForm.workspaceType === 'rescue' ? (
<article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>Rescue flocks stay free while still supporting shared team access.</span>
</article>
) : null}
<button className="primary-button" type="submit" disabled={savingWorkspace}>
{savingWorkspace
? 'Saving flock...'
: workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue'
? 'Convert current flock to rescue'
: 'Save flock settings'}
</button>
{activeMembership?.role === 'owner' ? (
<button className="danger-button" onClick={handleDeleteWorkspace} type="button" disabled={deletingWorkspace}>
{deletingWorkspace ? 'Deleting flock...' : 'Delete flock'}
</button>
) : null}
{activeMembership?.role === 'owner' ? (
<small className="muted">Delete is only available when this flock has no birds. Collaborators and tokens are removed with it.</small>
) : null}
</form>
{expandedSettingsSection === 'new-workspace' ? (
<section className="settings-subsection">
<div className="settings-nested-header">
<p className="eyebrow">New flock</p>
<h3>Add an additional flock</h3>
</div>
<p className="muted">
Use this only when you need a separate flock. To turn the current household flock into a rescue, update the current flock type above.
</p>
<form className="form-panel" onSubmit={handleCreateWorkspace}>
<label>
Flock name
<input
value={workspaceCreateForm.name}
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })}
required
/>
</label>
<label>
Flock type
<select
value={workspaceCreateForm.workspaceType}
onChange={(event) => {
const workspaceType = event.target.value as WorkspaceCreateFormState['workspaceType'];
setWorkspaceCreateForm({
...workspaceCreateForm,
workspaceType,
rescueOnboarding:
workspaceType === 'rescue'
? {
...workspaceCreateForm.rescueOnboarding,
name: workspaceCreateForm.rescueOnboarding.name || workspaceCreateForm.name,
}
: workspaceCreateForm.rescueOnboarding,
});
}}
>
<option value="standard">Standard household</option>
<option value="rescue">Rescue</option>
</select>
</label>
{workspaceCreateForm.workspaceType === 'standard' ? (
<>
<label>
Household plan
<select
value={workspaceCreateForm.billingPlan}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
billingPlan: event.target.value as WorkspaceCreateFormState['billingPlan'],
})
}
>
<option value="household_basic">Conure (up to 4 birds)</option>
<option value="household_plus">Indian Ringneck (5-10 birds)</option>
<option value="household_macaw">African Grey (11-16 birds)</option>
<option value="household_hyacinth_macaw">Hyacinth Macaw (17+)</option>
</select>
</label>
<label>
Billing frequency
<select
value={workspaceCreateForm.billingInterval}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
billingInterval: event.target.value as WorkspaceCreateFormState['billingInterval'],
})
}
>
<option value="monthly">{formatBillingIntervalDropdownLabel(workspaceCreateForm.billingPlan, 'monthly')}</option>
<option value="yearly">{formatBillingIntervalDropdownLabel(workspaceCreateForm.billingPlan, 'yearly')}</option>
</select>
</label>
<article className="summary-card">
<strong>
{formatBillingPlanName(workspaceCreateForm.billingPlan)} •{' '}
{formatBillingIntervalName(workspaceCreateForm.billingInterval)}
</strong>
<span>{formatBillingPlanCapacity(workspaceCreateForm.billingPlan)}</span>
</article>
</>
) : (
<>
<section className="settings-nested-card">
<h3>Rescue onboarding</h3>
<label>
Rescue Name
<input
value={workspaceCreateForm.rescueOnboarding.name}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, name: event.target.value },
})
}
/>
</label>
<label>
City
<input
value={workspaceCreateForm.rescueOnboarding.city}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, city: event.target.value },
})
}
/>
</label>
<label>
State
<input
value={workspaceCreateForm.rescueOnboarding.state}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, state: event.target.value },
})
}
/>
</label>
<label>
EIN
<input
value={workspaceCreateForm.rescueOnboarding.ein}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, ein: event.target.value },
})
}
/>
</label>
<label>
Website
<input
type="url"
value={workspaceCreateForm.rescueOnboarding.website}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, website: event.target.value },
})
}
/>
</label>
</section>
<article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>No billing is applied to rescue flocks.</span>
</article>
</>
)}
<label>
Billing contact email
<input
type="email"
value={workspaceCreateForm.billingEmail}
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, billingEmail: event.target.value })}
placeholder="Used for household billing and account notices"
/>
</label>
<button className="primary-button" type="submit" disabled={creatingWorkspace}>
{creatingWorkspace ? 'Creating flock...' : 'Create flock'}
</button>
</form>
</section>
) : null}
</article>
<article className="panel form-panel settings-card-billing">
<div className="panel-header">
<div>
<p className="eyebrow">Billing</p>
<h2>Billing info</h2>
</div>
</div>
{workspace?.workspaceType !== 'rescue' ? (
<div className="form-panel">
{billingNotice ? <p className={billingNotice.kind === 'error' ? 'error-banner' : 'success-banner'}>{billingNotice.message}</p> : null}
<label>
Household plan
<select
value={workspaceForm.billingPlan}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
billingPlan: event.target.value as WorkspaceFormState['billingPlan'],
})
}
>
<option value="household_basic">Conure (up to 4 birds)</option>
<option value="household_plus">Indian Ringneck (5-10 birds)</option>
<option value="household_macaw">African Grey (11-16 birds)</option>
<option value="household_hyacinth_macaw">Hyacinth Macaw (17+)</option>
</select>
</label>
<label>
Billing frequency
<select
value={workspaceForm.billingInterval}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
billingInterval: event.target.value as WorkspaceFormState['billingInterval'],
})
}
>
<option value="monthly">{formatBillingIntervalDropdownLabel(workspaceForm.billingPlan, 'monthly')}</option>
<option value="yearly">{formatBillingIntervalDropdownLabel(workspaceForm.billingPlan, 'yearly')}</option>
</select>
</label>
<label>
Billing contact email
<input
type="email"
value={workspaceForm.billingEmail}
onChange={(event) => setWorkspaceForm({ ...workspaceForm, billingEmail: event.target.value })}
placeholder="Optional for billing and account management"
/>
</label>
<div className="billing-inline-action">
<div>
<strong>{workspace?.stripeSubscriptionId ? 'Manage household billing' : 'Start household subscription'}</strong>
<span>
{workspace?.stripeSubscriptionId
? 'Open Stripe to update payment methods, invoices, cancellation, or plan changes for this flock.'
: 'Continue to Stripe with the selected plan and frequency. Billing is tracked separately for each household flock.'}
</span>
</div>
{activeMembership?.role === 'owner' || activeMembership?.role === 'assistant' ? (
<button
className="primary-button"
type="button"
onClick={workspace?.stripeSubscriptionId ? handleOpenBillingPortal : handleStartBillingCheckout}
disabled={billingRedirecting || savingWorkspace || !workspace}
>
{billingRedirecting || savingWorkspace
? 'Opening Stripe...'
: workspace?.stripeSubscriptionId
? 'Manage billing'
: 'Start subscription'}
</button>
) : (
<small className="muted">Ask a flock owner or assistant to manage billing for this flock.</small>
)}
</div>
</div>
) : null}
<div className="summary-grid">
<article className="summary-card">
<strong>
{workspace ? `${formatBillingPlanName(workspace.billingPlan)} • ${formatBillingIntervalName(workspace.billingInterval)}` : 'No plan yet'}
</strong>
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span>
</article>
{workspace?.workspaceType !== 'rescue' ? (
<article className="summary-card">
<strong>{workspace ? formatFlockAccessStatus(workspace.subscriptionStatus) : 'Unknown'}</strong>
<span>{workspace ? formatFlockAccessDescription(workspace.subscriptionStatus) : 'Subscription status controls flock write access.'}</span>
</article>
) : null}
{workspace?.workspaceType === 'rescue' ? (
<article className="summary-card">
<strong>{formatRescueVerificationStatus(workspace.rescueVerificationStatus)}</strong>
<span>
{workspace.rescueVerificationStatus === 'approved'
? 'Rescue verification is approved and this flock is fully active.'
: workspace.rescueVerificationStatus === 'rejected'
? 'This rescue request was rejected. Update the flock or contact support before trying again.'
: 'Rescue flocks are read-only until an admin approves their verification.'}
</span>
{workspace.rescueVerificationStatus === 'pending' &&
(activeMembership?.role === 'owner' || activeMembership?.role === 'assistant') ? (
<button
className="secondary-button"
type="button"
onClick={handleCancelRescueRequest}
disabled={cancelingRescueRequest}
>
{cancelingRescueRequest ? 'Canceling request...' : 'Cancel rescue request'}
</button>
) : null}
</article>
) : null}
<article className="summary-card">
<strong className="billing-contact-email">{workspace?.billingEmail || authSession.user.email}</strong>
<span>Billing contact for invoices, receipts, and account notices.</span>
</article>
<article className="summary-card">
<strong>{workspace ? formatBillingBirdUsage(workspace.billingPlan, birds.length) : `${birds.length} bird${birds.length === 1 ? '' : 's'}`}</strong>
<span>
{workspace && formatBillingPlanBirdLimit(workspace.billingPlan)
? 'Current bird count against your paid plan allowance.'
: 'Current bird count in this flock.'}
</span>
</article>
</div>
</article>
</div>
<div className="settings-column settings-column-right">
<article className="panel form-panel settings-card-collaborators">
<div className="panel-header">
<div>
<p className="eyebrow">Collaborators</p>
<h2>Shared flock access</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'collaborators' ? null : 'collaborators'))
}
type="button"
aria-expanded={expandedSettingsSection === 'collaborators'}
>
{expandedSettingsSection === 'collaborators' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'collaborators' ? (
<>
<p className="muted">
Invite other people to help manage this flock. Rescue flocks support teams, and household flocks can also support
co-caregivers without changing who owns the billing.
</p>
<form className="form-panel" onSubmit={handleWorkspaceMemberSubmit}>
<label>
Team member name
<input
value={workspaceMemberForm.name}
onChange={(event) => setWorkspaceMemberForm({ ...workspaceMemberForm, name: event.target.value })}
required
/>
</label>
<label>
Email
<input
type="email"
value={workspaceMemberForm.email}
onChange={(event) => setWorkspaceMemberForm({ ...workspaceMemberForm, email: event.target.value })}
required
/>
</label>
<label>
Role
<select
value={workspaceMemberForm.role}
onChange={(event) =>
setWorkspaceMemberForm({
...workspaceMemberForm,
role: event.target.value as WorkspaceMemberFormState['role'],
})
}
>
<option value="owner">Owner</option>
<option value="assistant">Assistant</option>
<option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option>
</select>
</label>
<button className="primary-button" type="submit" disabled={savingWorkspaceMember}>
{savingWorkspaceMember ? 'Adding member...' : 'Add collaborator'}
</button>
</form>
<div className="recent-list">
{workspaceMembers.length ? (
workspaceMembers.map((member) => (
<article key={member.id} className="vet-visit-card">
<strong>{member.name}</strong>
<span>
{formatWorkspaceRole(member.role)} • {member.email || member.inviteEmail}
</span>
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
<button
className="secondary-button"
onClick={() => handleRemoveWorkspaceMember(member.id)}
type="button"
disabled={removingWorkspaceMemberId === member.id || member.role === 'owner'}
>
{member.role === 'owner' ? 'Owner' : removingWorkspaceMemberId === member.id ? 'Removing...' : 'Remove'}
</button>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No collaborators yet</strong>
<small>Add the people who should be able to help care for birds in this flock.</small>
</article>
)}
</div>
</>
) : null}
</article>
<article className="panel form-panel settings-card-automation">
<div className="panel-header">
<div>
<p className="eyebrow">Automation</p>
<h2>Integration tokens</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'integration-tokens' ? null : 'integration-tokens'))
}
type="button"
aria-expanded={expandedSettingsSection === 'integration-tokens'}
>
{expandedSettingsSection === 'integration-tokens' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'integration-tokens' ? (
<>
<p className="muted">
Create a flock-scoped token for automations like n8n. The secret is shown only once, so store it in your automation tool when it appears.
</p>
<form className="form-panel" onSubmit={handleCreateIntegrationToken}>
<label>
Token name
<input
value={integrationTokenForm.name}
onChange={(event) => setIntegrationTokenForm({ ...integrationTokenForm, name: event.target.value })}
placeholder="n8n household sync"
required
/>
</label>
<label>
Access level
<select
value={integrationTokenForm.scope}
onChange={(event) =>
setIntegrationTokenForm({
...integrationTokenForm,
scope: event.target.value as IntegrationTokenFormState['scope'],
})
}
>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<label>
Expire after days
<input
type="number"
min="1"
max="3650"
value={integrationTokenForm.expiresInDays}
onChange={(event) => setIntegrationTokenForm({ ...integrationTokenForm, expiresInDays: event.target.value })}
placeholder="Optional"
/>
</label>
<button className="primary-button" type="submit" disabled={creatingIntegrationToken}>
{creatingIntegrationToken ? 'Creating token...' : 'Create integration token'}
</button>
</form>
{newIntegrationTokenSecret ? (
<article className="summary-card integration-token-secret">
<strong>Copy this token now</strong>
<span>It will not be shown again after you leave this page or create another token.</span>
<input readOnly value={newIntegrationTokenSecret} onFocus={(event) => event.currentTarget.select()} />
</article>
) : null}
<div className="recent-list">
{integrationTokens.length ? (
integrationTokens.map((token) => (
<article key={token.id} className="vet-visit-card">
<strong>{token.name}</strong>
<span>
{token.tokenPrefix}... • {token.scope === 'read_only' ? 'read only' : 'read and write'}
</span>
<small>
Last used {formatDateTime(token.lastUsedAt)} • Expires {token.expiresAt ? formatDateTime(token.expiresAt) : 'Never'}
</small>
<button
className="secondary-button"
onClick={() => handleRevokeIntegrationToken(token.id)}
type="button"
disabled={revokingIntegrationTokenId === token.id}
>
{revokingIntegrationTokenId === token.id ? 'Revoking...' : 'Revoke'}
</button>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No integration tokens yet</strong>
<small>Create one for n8n, scripts, or other personal automations tied to this flock.</small>
</article>
)}
</div>
</>
) : null}
</article>
<article className="panel form-panel settings-card-bird-profiles" hidden>
<div className="panel-header">
<div>
<p className="eyebrow">Bird profiles</p>
<h2>{editingBird ? `Update ${editingBird.name}` : 'Flock member details'}</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'flock-member' ? null : 'flock-member'))
}
type="button"
aria-expanded={expandedSettingsSection === 'flock-member'}
>
{expandedSettingsSection === 'flock-member' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'flock-member' ? (
<>
<div className="button-row">
{selectedBird ? (
<button className="secondary-button" onClick={() => startEditBird(selectedBird)} type="button">
Edit selected profile
</button>
) : null}
<button className="secondary-button" onClick={startCreateBird} type="button">
New bird profile
</button>
</div>
<div className="picker-list">
{birds.map((bird) => (
<button
key={bird.id}
className={`picker-chip ${editingBirdId === bird.id ? 'active' : ''}`}
onClick={() => startEditBird(bird)}
type="button"
>
{bird.name}
</button>
))}
</div>
<form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}>
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Identity</p>
<h3>Basic profile</h3>
</div>
<div className="settings-nested-grid">
<label>
Bird name
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label>
<label>
Band ID, if known
<input
value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
placeholder="Optional if unknown"
/>
</label>
<label className="species-picker-field wide-field">
Species
<div className="species-picker">
<input
value={birdForm.species}
onChange={(event) => {
setBirdForm({ ...birdForm, species: event.target.value });
setSpeciesPickerOpen(true);
}}
onFocus={() => setSpeciesPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setSpeciesPickerOpen(false);
}, 120);
}}
placeholder="Start typing a species"
autoComplete="off"
required
/>
{speciesPickerOpen ? (
<div className="species-picker-menu">
{filteredSpeciesOptions.length ? (
filteredSpeciesOptions.map((speciesOption) => (
<button
key={speciesOption}
className={`species-picker-option ${birdForm.species === speciesOption ? 'active' : ''}`}
onMouseDown={(event) => {
event.preventDefault();
setBirdForm({ ...birdForm, species: speciesOption });
setSpeciesPickerOpen(false);
}}
type="button"
>
{speciesOption}
</button>
))
) : (
<div className="species-picker-empty">No matching species yet. Keep typing to add a custom entry.</div>
)}
</div>
) : null}
</div>
<small className="muted">Search or select a species so alerts and chart references stay consistent.</small>
</label>
<div className="segmented-field wide-field">
<span>Gender</span>
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
<button
className={`segmented-option ${birdForm.gender === 'unknown' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'unknown' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'unknown'}
>
<span className="gender-symbol unknown" aria-hidden="true">
?
</span>
Unknown
</button>
<button
className={`segmented-option ${birdForm.gender === 'male' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'male' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'male'}
>
<span className="gender-symbol male" aria-hidden="true">
</span>
Male
</button>
<button
className={`segmented-option ${birdForm.gender === 'female' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'female' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'female'}
>
<span className="gender-symbol female" aria-hidden="true">
</span>
Female
</button>
</div>
<small className="muted">Shown on the bird profile card as a symbol.</small>
</div>
<div className="settings-inline-header wide-field">
<p className="eyebrow">Dates</p>
<h3>Milestones and reminders</h3>
</div>
<label>
Hatch Day
<input
type="date"
value={birdForm.dateOfBirth}
onChange={(event) => setBirdForm({ ...birdForm, dateOfBirth: event.target.value })}
/>
</label>
<label>
Gotcha day
<input
type="date"
value={birdForm.gotchaDay}
onChange={(event) => setBirdForm({ ...birdForm, gotchaDay: event.target.value })}
/>
</label>
<label className="toggle-card">
<span>Notify on Hatch Day</span>
<input
type="checkbox"
checked={birdForm.notifyOnDob}
onChange={(event) => setBirdForm({ ...birdForm, notifyOnDob: event.target.checked })}
/>
<small className="muted">Send a reminder on this bird's Hatch Day each year.</small>
</label>
<label className="toggle-card">
<span>Notify on gotcha day</span>
<input
type="checkbox"
checked={birdForm.notifyOnGotchaDay}
onChange={(event) => setBirdForm({ ...birdForm, notifyOnGotchaDay: event.target.checked })}
/>
<small className="muted">Send a reminder on this bird's gotcha day anniversary.</small>
</label>
<label>
Favorite snack
<input
value={birdForm.favoriteSnack}
onChange={(event) => setBirdForm({ ...birdForm, favoriteSnack: event.target.value })}
placeholder="Optional"
/>
</label>
<div className="settings-inline-header wide-field">
<p className="eyebrow">Veterinary</p>
<h3>Clinic account</h3>
</div>
<label>
Clinic name
<input
value={birdForm.vetClinicName}
onChange={(event) => setBirdForm({ ...birdForm, vetClinicName: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Account #
<input
value={birdForm.vetAccountNumber}
onChange={(event) => setBirdForm({ ...birdForm, vetAccountNumber: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Dr. name
<input
value={birdForm.vetDoctorName}
onChange={(event) => setBirdForm({ ...birdForm, vetDoctorName: event.target.value })}
placeholder="Optional"
/>
</label>
<label className="wide-field">
Clinic address
<textarea
rows={2}
value={birdForm.vetClinicAddress}
onChange={(event) => setBirdForm({ ...birdForm, vetClinicAddress: event.target.value })}
placeholder="Optional"
/>
</label>
<label className="wide-field">
Motivators
<div className="profile-list-fields">
{getBirdProfileListFields(birdForm.motivators).map((motivator, index) => (
<input
key={`motivator-${index}`}
value={motivator}
onChange={(event) =>
setBirdForm({
...birdForm,
motivators: updateBirdProfileListField(birdForm.motivators, index, event.target.value),
})
}
placeholder={index === 0 ? 'Training reward, sound, person, toy, or routine' : `Motivator ${index + 1}`}
/>
))}
</div>
</label>
<label className="wide-field">
Demotivators
<div className="profile-list-fields">
{getBirdProfileListFields(birdForm.demotivators).map((demotivator, index) => (
<input
key={`demotivator-${index}`}
value={demotivator}
onChange={(event) =>
setBirdForm({
...birdForm,
demotivators: updateBirdProfileListField(birdForm.demotivators, index, event.target.value),
})
}
placeholder={index === 0 ? 'Stressor, disliked handling, noise, or situation' : `Demotivator ${index + 1}`}
/>
))}
</div>
</label>
</div>
</section>
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Display</p>
<h3>Chart color and photo</h3>
</div>
<div className="settings-nested-grid">
<label>
Graph color
<input type="color" value={birdForm.chartColor} onChange={(event) => setBirdForm({ ...birdForm, chartColor: event.target.value })} />
</label>
<div className="color-preview-card">
<span className="legend-swatch large-swatch" style={{ background: birdForm.chartColor }} />
<p className="muted">This color will follow this bird across the overview graph and its individual weight trend.</p>
</div>
<label className="toggle-field wide-field">
<input
type="checkbox"
checked={birdForm.publicProfileEnabled}
onChange={(event) => setBirdForm({ ...birdForm, publicProfileEnabled: event.target.checked })}
/>
<span>Enable QR code</span>
</label>
<div className="photo-editor wide-field">
<div className="photo-preview-shell">
{photoCrop ? (
<div
className={`crop-preview-frame ${photoDrag ? 'dragging' : ''}`}
onPointerDown={handlePhotoCropPointerDown}
onPointerMove={handlePhotoCropPointerMove}
onPointerUp={handlePhotoCropPointerUp}
onPointerCancel={handlePhotoCropPointerUp}
>
<img
className="crop-preview-image"
src={photoCrop.sourceDataUrl}
alt="Crop preview"
style={{
width: `${getPhotoCropMetrics(photoCrop).displayWidth}px`,
height: `${getPhotoCropMetrics(photoCrop).displayHeight}px`,
left: `${getPhotoCropMetrics(photoCrop).left}px`,
top: `${getPhotoCropMetrics(photoCrop).top}px`,
}}
/>
<div className="crop-preview-overlay" aria-hidden="true" />
</div>
) : (
<img className="profile-photo" src={birdForm.photoDataUrl || defaultBirdPhoto} alt="Bird preview" />
)}
</div>
<div className="photo-copy">
<label className="file-picker">
Photo
<input accept="image/png,image/jpeg,image/jpg,image/webp,image/gif" onChange={handleBirdPhotoChange} type="file" />
</label>
<p className="muted">
{photoCrop
? `${photoCrop.fileName} ready to crop. Adjust it below and save the crop.`
: birdPhotoName || (birdForm.photoDataUrl ? 'Current photo ready to save.' : 'Optional. Great for quick identification.')}
</p>
{photoCrop ? (
<div className="crop-control-stack">
<p className="muted">Drag the photo to position it inside the square crop. The saved image will match this preview.</p>
<label>
Zoom
<input
type="range"
min="1"
max="3"
step="0.01"
value={photoCrop.zoom}
onChange={(event) =>
setPhotoCrop((current) => (current ? { ...current, zoom: Number(event.target.value) } : current))
}
/>
</label>
<div className="button-row">
<button className="primary-button" onClick={handleApplyPhotoCrop} type="button" disabled={applyingPhotoCrop}>
{applyingPhotoCrop ? 'Applying crop...' : 'Apply crop'}
</button>
<button className="secondary-button" onClick={handleCancelPhotoCrop} type="button" disabled={applyingPhotoCrop}>
Cancel
</button>
</div>
<p className="muted">Photos are cropped to a square and optimized automatically to stay within the current upload limit.</p>
</div>
) : birdForm.photoDataUrl ? (
<button className="secondary-button" onClick={handleRemovePhoto} type="button">
Remove photo
</button>
) : null}
</div>
</div>
</div>
</section>
<div className="settings-save-row">
<button className="primary-button" type="submit" disabled={savingBird}>
{savingBird ? 'Saving...' : editingBird ? 'Save profile changes' : 'Save bird profile'}
</button>
</div>
</form>
<section className="settings-subsection settings-nested-card">
<div className="panel-header">
<div>
<p className="eyebrow">Medication</p>
<h3>Medication configuration</h3>
<p className="muted">
Add per-bird medications here. The medication section only appears on the flock member page after medication is configured.
</p>
</div>
</div>
{selectedBird ? (
<>
<form className="form-panel inline-form care-entry-form" onSubmit={handleMedicationSubmit}>
<label>
Medication
<input
value={medicationForm.name}
onChange={(event) => setMedicationForm({ ...medicationForm, name: event.target.value })}
placeholder="Meloxicam"
required
/>
</label>
<label>
Dosage
<input
value={medicationForm.dosage}
onChange={(event) => setMedicationForm({ ...medicationForm, dosage: event.target.value })}
placeholder="0.05 mL"
required
/>
</label>
<label>
Frequency
<select
value={medicationForm.frequency}
onChange={(event) => {
const frequency = event.target.value as MedicationFrequency;
setMedicationForm({
...medicationForm,
frequency,
doseSchedule: getDefaultMedicationDoseSchedule(frequency),
});
}}
required
>
{medicationFrequencyOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
Route
<input
value={medicationForm.route}
onChange={(event) => setMedicationForm({ ...medicationForm, route: event.target.value })}
placeholder="Oral"
/>
</label>
<label>
Start date
<input
type="date"
value={medicationForm.startDate}
onChange={(event) => setMedicationForm({ ...medicationForm, startDate: event.target.value })}
required
/>
</label>
<label>
End date
<input
type="date"
value={medicationForm.endDate}
onChange={(event) => setMedicationForm({ ...medicationForm, endDate: event.target.value })}
/>
</label>
<label className="wide-field">
Dose labels and times
<div className="dose-schedule-editor">
{medicationForm.doseSchedule.map((slot, index) => (
<div className="dose-schedule-row" key={slot.key}>
<input
value={slot.label}
onChange={(event) =>
setMedicationForm({
...medicationForm,
doseSchedule: medicationForm.doseSchedule.map((currentSlot, currentIndex) =>
currentIndex === index ? { ...currentSlot, label: event.target.value } : currentSlot,
),
})
}
aria-label={`${slot.key} label`}
required
/>
<input
type="time"
value={slot.time}
onChange={(event) =>
setMedicationForm({
...medicationForm,
doseSchedule: medicationForm.doseSchedule.map((currentSlot, currentIndex) =>
currentIndex === index ? { ...currentSlot, time: event.target.value } : currentSlot,
),
})
}
aria-label={`${slot.key} time`}
/>
</div>
))}
</div>
</label>
<label className="wide-field">
Notes
<textarea
rows={3}
value={medicationForm.notes}
onChange={(event) => setMedicationForm({ ...medicationForm, notes: event.target.value })}
placeholder="Instructions, response, side effects, or prescribing vet"
/>
</label>
<div className="button-row care-form-actions">
<button className="primary-button" type="submit">
{editingMedicationId ? 'Save medication changes' : 'Save medication'}
</button>
{editingMedicationId ? (
<button className="secondary-button" onClick={handleCancelMedicationEdit} type="button">
Cancel edit
</button>
) : null}
</div>
</form>
<div className="recent-list">{renderMedicationList({ showActions: true })}</div>
</>
) : (
<article className="vet-visit-card empty-card">
<strong>Select a bird profile first</strong>
<small>Choose an existing bird above before adding medication configuration.</small>
</article>
)}
</section>
{selectedBird ? (
<section className="settings-subsection settings-nested-card settings-danger-card">
<div className="panel-header">
<div>
<p className="eyebrow">Danger zone</p>
<h3>Destructive profile actions</h3>
<p className="muted">
Memorializing is not reversible by you. It makes {selectedBird.name} read-only, hides the profile from the standard flock view,
and removes the bird from subscription counting.
</p>
</div>
</div>
<form className="form-panel inline-form care-entry-form" onSubmit={handleMemorializeBird}>
<label>
Memorial date
<input
type="date"
value={memorializeBirdForm.memorializedOn}
onChange={(event) => setMemorializeBirdForm({ ...memorializeBirdForm, memorializedOn: event.target.value })}
required
/>
</label>
<label className="wide-field">
Memorial note
<textarea
rows={3}
value={memorializeBirdForm.memorialNote}
onChange={(event) => setMemorializeBirdForm({ ...memorializeBirdForm, memorialNote: event.target.value })}
placeholder="Optional words to include in future memorial reminders."
/>
</label>
<div className="button-row care-form-actions">
<button className="danger-button" type="submit" disabled={memorializingBird || activeMembership?.role !== 'owner'}>
{memorializingBird ? 'Memorializing...' : `Memorialize ${selectedBird.name}`}
</button>
</div>
{activeMembership?.role !== 'owner' ? <p className="muted">Only flock owners can memorialize a bird profile.</p> : null}
</form>
<p className="muted">
Removing is separate from memorializing and permanently deletes weight records, vet visits, and medication history for this bird.
</p>
<div className="button-row">
<button className="danger-button" onClick={handleRemoveBird} type="button" disabled={deletingBird}>
{deletingBird ? 'Removing...' : 'Remove from flock'}
</button>
</div>
</section>
) : null}
{memorializedBirds.length ? (
<section className="settings-subsection settings-nested-card">
<div className="panel-header">
<div>
<p className="eyebrow">Memorials</p>
<h3>Memorialized birds</h3>
<p className="muted">
These profiles are read-only, hidden from the standard flock view, and excluded from household plan bird counts.
</p>
</div>
</div>
<div className="recent-list">
{memorializedBirds.map((bird) => (
<article className="vet-visit-card" key={bird.id}>
<strong>{bird.name}</strong>
<small>
{bird.species} • Memorialized {formatDate(bird.memorializedOn)}
</small>
{bird.memorialNote ? <p className="muted">{bird.memorialNote}</p> : null}
<label className="toggle-card">
<span>Send memorial reminders</span>
<input
type="checkbox"
checked={bird.notifyOnMemorialDay}
disabled={savingMemorialReminderBirdId === bird.id || activeMembership?.role !== 'owner'}
onChange={(event) => handleMemorialReminderPreferenceChange(bird, event.target.checked)}
/>
<small className="muted">Send an annual rememberance notificaiton.</small>
</label>
{activeMembership?.role !== 'owner' ? <small>Only flock owners can change memorial reminder settings.</small> : null}
</article>
))}
</div>
</section>
) : null}
</>
) : null}
</article>
<article className="panel form-panel settings-card-bird-import">
<div className="panel-header">
<div>
<p className="eyebrow">Import</p>
<h2>Bulk Import</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'bird-import' ? null : 'bird-import'))
}
type="button"
aria-expanded={expandedSettingsSection === 'bird-import'}
>
{expandedSettingsSection === 'bird-import' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'bird-import' ? (
<>
<p className="muted">
Import the first worksheet from an Excel file. Use one row per weight entry and repeat the Bird Name for rows that belong to the
same profile.
</p>
<div className="import-column-guide">
<span>Required: Bird Name, Species</span>
<span>
Optional: Band ID, Gender, Hatch Day, Gotcha Day, Favorite Snack, Motivators, Demotivators, Chart Color, Weight Grams, Weight
Date, Weight Notes
</span>
</div>
<label className="file-picker">
Excel file
<input type="file" accept=".xlsx" onChange={handleBirdImportFileChange} disabled={importingBirds} />
</label>
{birdImportNotice ? <p className="success-banner">{birdImportNotice}</p> : null}
{birdImportPreview ? (
<>
<div className="summary-grid">
<article className="summary-card">
<strong>
{birdImportPreview.profiles.length} profile{birdImportPreview.profiles.length === 1 ? '' : 's'}
</strong>
<span>{birdImportFileName}</span>
</article>
<article className="summary-card">
<strong>{birdImportPreview.profiles.reduce((count, profile) => count + profile.weights.length, 0)} weight entries</strong>
<span>Rows with Weight Grams and Weight Date</span>
</article>
</div>
{birdImportPreview.errors.length ? (
<article className="summary-card summary-alert-card" role="alert">
<strong>Fix the spreadsheet before importing</strong>
<div className="summary-list">
{birdImportPreview.errors.map((importIssue) => (
<span key={importIssue}>{importIssue}</span>
))}
</div>
</article>
) : (
<div className="recent-list import-preview-list">
{birdImportPreview.profiles.map((profile) => (
<article key={profile.key} className="vet-visit-card">
<strong>{profile.name}</strong>
<span>
{profile.species} • {profile.tagId ? `Band ${profile.tagId}` : 'No Band ID'} • {profile.weights.length} weight entr
{profile.weights.length === 1 ? 'y' : 'ies'}
</span>
</article>
))}
</div>
)}
<div className="button-row">
<button
className="primary-button"
onClick={handleBirdImportSubmit}
type="button"
disabled={importingBirds || birdImportPreview.errors.length > 0 || birdImportPreview.profiles.length === 0}
>
{importingBirds ? 'Importing...' : 'Import spreadsheet'}
</button>
<button
className="secondary-button"
onClick={() => {
setBirdImportPreview(null);
setBirdImportFileName('');
setBirdImportNotice('');
}}
type="button"
disabled={importingBirds}
>
Clear
</button>
</div>
</>
) : null}
</>
) : null}
</article>
<article className="panel form-panel settings-card-transfer">
<div className="panel-header">
<div>
<p className="eyebrow">Transfer</p>
<h2>Bird transfer</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'transfer' ? null : 'transfer'))
}
type="button"
aria-expanded={expandedSettingsSection === 'transfer'}
>
{expandedSettingsSection === 'transfer' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'transfer' ? (
<>
<p className="muted">
Transfer a bird to another flock by entering the receiving flock owner's email. This keeps the bird record, weight history, and
vet visits attached while changing which flock owns it.
</p>
<div className="settings-nested-stack">
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Owner transfer</p>
<h3>Send to a known flock owner</h3>
</div>
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
<label>
Bird to move
<select
value={flockTransferForm.birdId}
onChange={(event) => {
setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value });
setTransferError('');
setTransferNotice(null);
}}
required
>
<option value="">Select a bird from this flock</option>
{birds.map((bird) => (
<option key={bird.id} value={bird.id}>
{bird.name} {bird.species} {bird.tagId ? `Band ${bird.tagId}` : 'Band ID not recorded'}
</option>
))}
</select>
</label>
<label>
Receiving flock owner email
<input
type="email"
value={flockTransferForm.destinationOwnerEmail}
onChange={(event) => {
setFlockTransferForm({ ...flockTransferForm, destinationOwnerEmail: event.target.value });
setTransferError('');
setTransferNotice(null);
}}
placeholder="owner@example.com"
required
/>
</label>
<button className="primary-button" type="submit" disabled={transferringBird}>
{transferringBird ? 'Transferring bird...' : 'Transfer bird'}
</button>
{transferError ? (
<p className="error-banner" role="alert">
{transferError}
</p>
) : null}
{transferNotice ? (
<article className="summary-card" role="status">
<strong>Pending transfer invite sent</strong>
<span>{transferNotice.message}</span>
{transferNotice.previewUrl ? <a href={transferNotice.previewUrl}>Open invite link</a> : null}
</article>
) : null}
</form>
</section>
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Transfer code</p>
<h3>Accept a bird into this flock</h3>
</div>
<p className="muted">
Enter a transfer code from an adoption or handoff report. The bird will move into your active flock automatically.
</p>
<form className="form-panel" onSubmit={handleTransferCodeAcceptSubmit}>
<label>
Transfer code
<input
value={transferCodeAcceptForm.code}
onChange={(event) => {
setTransferCodeAcceptForm({ code: event.target.value });
setTransferCodeError('');
setTransferCodeNotice('');
}}
placeholder="Paste transfer code"
required
/>
</label>
<button className="primary-button" type="submit" disabled={acceptingTransferCode}>
{acceptingTransferCode ? 'Accepting transfer...' : 'Accept bird transfer'}
</button>
{transferCodeError ? (
<p className="error-banner" role="alert">
{transferCodeError}
</p>
) : null}
{transferCodeNotice ? (
<article className="summary-card" role="status">
<strong>Transfer accepted</strong>
<span>{transferCodeNotice}</span>
</article>
) : null}
</form>
</section>
</div>
</>
) : null}
</article>
</div>
</section>
) : null}
</section>
{qrBird?.publicProfileCode ? (
<div className="app-modal-backdrop" role="presentation" onClick={() => setQrBird(null)}>
<section
className="app-modal qr-modal"
role="dialog"
aria-modal="true"
aria-labelledby="qr-modal-title"
onClick={(event) => event.stopPropagation()}
>
<div className="panel-header no-print">
<div>
<p className="eyebrow">QR profile</p>
<h2 id="qr-modal-title">{qrBird.name}</h2>
</div>
<div className="button-row">
<button className="secondary-button" onClick={() => window.print()} type="button">
Print
</button>
<button className="secondary-button" onClick={() => setQrBird(null)} type="button">
Close
</button>
</div>
</div>
<div className="qr-print-card">
<QrCodeWithLogo value={getPublicProfileUrl(qrBird.publicProfileCode)} label={`QR code for ${qrBird.name}`} />
<h3>{qrBird.name}</h3>
<p>{getPublicProfileUrl(qrBird.publicProfileCode)}</p>
</div>
</section>
</div>
) : null}
{showWeightAlertModal ? (
<div className="app-modal-backdrop" role="presentation" onClick={() => setShowWeightAlertModal(false)}>
<section
className="app-modal weight-alert-modal"
role="dialog"
aria-modal="true"
aria-labelledby="weight-alert-title"
onClick={(event) => event.stopPropagation()}
>
<div className="panel-header">
<div>
<p className="eyebrow">Weight alert</p>
<h2 id="weight-alert-title">Birds needing weight review</h2>
</div>
<button className="secondary-button" onClick={() => setShowWeightAlertModal(false)} type="button">
Close
</button>
</div>
<p className="muted">
Range alerts use the BirdSupplies species chart as a general reference. Drop alerts compare the two most recent recorded days and
flag a 5-10% decrease. If a reading is unexpected or concerning, please consult your veterinarian.
</p>
<div className="modal-alert-list">
{outOfRangeBirds.map(({ bird, assessment }) => (
<article key={`range-${bird.id}`} className="summary-card summary-alert-card">
<strong>
{bird.name} is {assessment.status === 'below' ? 'below' : 'above'} the typical range
</strong>
<span>
{bird.species} Latest weight {formatWeight(bird.latestWeightGrams)} Typical range{' '}
{formatRange(assessment.reference.minGrams, assessment.reference.maxGrams)}
</span>
<button
className="range-alert-button"
onClick={() => dismissAlert(bird.id, 'weight-range', getWeightRangeAlertSignature(bird, assessment))}
type="button"
>
Dismiss for {bird.name}
</button>
</article>
))}
{weightDropAlerts.map(({ bird, previousWeight, latestWeight, dropPercent }) => (
<article key={`drop-${bird.id}`} className="summary-card summary-alert-card">
<strong>{bird.name} is down {dropPercent.toFixed(1)}% between recent entries</strong>
<span>
{formatWeight(previousWeight.weightGrams)} on {formatShortDate(previousWeight.recordedOn)} to{' '}
{formatWeight(latestWeight.weightGrams)} on {formatShortDate(latestWeight.recordedOn)}
</span>
<button
className="range-alert-button"
onClick={() =>
dismissAlert(
bird.id,
'weight-drop',
getWeightDropAlertSignature({
bird,
previousWeight,
latestWeight,
dropPercent,
})
)
}
type="button"
>
Dismiss for {bird.name}
</button>
</article>
))}
</div>
</section>
</div>
) : null}
</main>
);
}
export default App;