diff --git a/FlockPal-transparent.png b/FlockPal-transparent.png new file mode 100644 index 0000000..31283c0 Binary files /dev/null and b/FlockPal-transparent.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a2e7db..564abd6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import flockPalLandingArt from './assets/flockpal-landing-art.png'; +import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference'; type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; type HouseholdBillingPlan = Exclude; @@ -130,6 +131,35 @@ type AuthNotice = { previewUrl?: string | null; }; +type BulkWeightRowState = { + weightGrams: string; +}; + +type BirdWeightAssessment = + | { + status: 'no_match'; + reference: null; + } + | { + status: 'no_weight'; + reference: ParrotWeightReference; + } + | { + status: 'reference_only'; + reference: Extract; + } + | { + status: 'within' | 'below' | 'above'; + reference: Extract; + varianceGrams: number; + }; + +type OutOfRangeBirdWeightAssessment = { + status: 'below' | 'above'; + reference: Extract; + varianceGrams: number; +}; + type PhotoCropState = { sourceDataUrl: string; fileName: string; @@ -279,6 +309,7 @@ const formatShortDate = (value: string | null) => { }; const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); +const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`; const parseDateValue = (value: string) => new Date(`${value}T00:00:00`); const OVERVIEW_WIDTH = 520; const OVERVIEW_HEIGHT = 220; @@ -287,6 +318,9 @@ const PHOTO_MAX_BYTES = 900_000; const PHOTO_EXPORT_SIZES = [720, 600, 480]; const PHOTO_EXPORT_QUALITIES = [0.9, 0.82, 0.74, 0.66]; const PHOTO_PREVIEW_SIZE = 112; +const MEMBER_CHART_WIDTH = 520; +const MEMBER_CHART_HEIGHT = 180; +const MEMBER_CHART_PADDING = { top: 16, right: 18, bottom: 34, left: 52 }; const readJsonSafely = async (response: Response): Promise => { const contentType = response.headers.get('content-type') ?? ''; @@ -336,6 +370,7 @@ const createApiHeaders = (token?: string, headers?: HeadersInit) => { const apiFetch = (path: string, token?: string, init?: RequestInit) => fetch(`${apiBaseUrl}${path}`, { ...init, + cache: 'no-store', headers: createApiHeaders(token, init?.headers), }); @@ -587,6 +622,53 @@ const buildOverviewSeries = (points: WeightRecord[], minWeight: number, maxWeigh const toOverviewPath = (points: { x: number; y: number }[]) => points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' '); +const assessBirdWeight = (bird: Bird): BirdWeightAssessment => { + const reference = findParrotWeightReference(bird.species); + + if (!reference) { + return { + status: 'no_match', + reference: null, + }; + } + + if (bird.latestWeightGrams === null) { + return { + status: 'no_weight', + reference, + }; + } + + if (reference.kind === 'approximate') { + return { + status: 'reference_only', + reference, + }; + } + + if (bird.latestWeightGrams < reference.minGrams) { + return { + status: 'below', + reference, + varianceGrams: reference.minGrams - bird.latestWeightGrams, + }; + } + + if (bird.latestWeightGrams > reference.maxGrams) { + return { + status: 'above', + reference, + varianceGrams: bird.latestWeightGrams - reference.maxGrams, + }; + } + + return { + status: 'within', + reference, + varianceGrams: 0, + }; +}; + function App() { const [activePage, setActivePage] = useState('overview'); const [authToken, setAuthToken] = useState(''); @@ -620,6 +702,12 @@ function App() { const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false); const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState(null); + const [showWeightAlertModal, setShowWeightAlertModal] = useState(false); + const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false); + const [bulkWeightOpen, setBulkWeightOpen] = useState(false); + const [savingBulkWeights, setSavingBulkWeights] = useState(false); + const [bulkWeightDate, setBulkWeightDate] = useState(new Date().toISOString().slice(0, 10)); + const [bulkWeightRows, setBulkWeightRows] = useState>({}); const [weightForm, setWeightForm] = useState({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), @@ -654,11 +742,24 @@ function App() { [allBirdWeights, birds], ); + const showFlockDetailColumn = bulkWeightOpen || Boolean(selectedBird); + const missingFirstWeightCount = useMemo( () => birds.filter((bird) => bird.latestWeightGrams === null).length, [birds], ); + const birdWeightAssessments = useMemo( + () => + Object.fromEntries( + birds.map((bird) => [ + bird.id, + assessBirdWeight(bird), + ]), + ) as Record, + [birds], + ); + const selectedBirdTrendCopy = useMemo(() => { if (weights.length < 2) { return 'Needs a few more entries before trend detection.'; @@ -677,6 +778,99 @@ function App() { : `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`; }, [weights]); + const outOfRangeBirds = useMemo( + () => + birds + .map((bird) => { + const assessment = birdWeightAssessments[bird.id]; + + if (!assessment || (assessment.status !== 'below' && assessment.status !== 'above')) { + return null; + } + + return { + bird, + assessment: assessment as OutOfRangeBirdWeightAssessment, + }; + }) + .filter((item): item is { bird: Bird; assessment: OutOfRangeBirdWeightAssessment } => item !== null), + [birdWeightAssessments, birds], + ); + + const filteredSpeciesOptions = useMemo(() => { + const query = birdForm.species.trim().toLowerCase(); + + if (!query) { + return parrotSpeciesOptions.slice(0, 12); + } + + return parrotSpeciesOptions + .filter((speciesOption) => speciesOption.toLowerCase().includes(query)) + .slice(0, 12); + }, [birdForm.species]); + + const selectedBirdChart = useMemo(() => { + if (!weights.length) { + return { + points: [] as { id: string; x: number; y: number; label: string }[], + path: '', + isFlat: false, + yTicks: [] as { label: string; y: number }[], + xTicks: [] as { label: string; x: number }[], + }; + } + + const rawMinWeight = Math.min(...weights.map((entry) => entry.weightGrams)); + const rawMaxWeight = Math.max(...weights.map((entry) => entry.weightGrams)); + const isFlat = Math.abs(rawMaxWeight - rawMinWeight) < 0.01; + const padding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2); + const minWeight = Math.max(0, rawMinWeight - padding); + const maxWeight = rawMaxWeight + padding; + const innerWidth = MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.left - MEMBER_CHART_PADDING.right; + const innerHeight = MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.top - MEMBER_CHART_PADDING.bottom; + const startDate = parseDateValue(weights[0].recordedOn); + const endDate = parseDateValue(weights[weights.length - 1].recordedOn); + const startMs = startDate.getTime(); + const endMs = endDate.getTime(); + const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000); + const weightSpread = Math.max(maxWeight - minWeight, 1); + + const points = weights.map((entry) => { + const pointTime = parseDateValue(entry.recordedOn).getTime(); + const x = MEMBER_CHART_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth; + const y = MEMBER_CHART_PADDING.top + (1 - (entry.weightGrams - minWeight) / weightSpread) * innerHeight; + + return { + id: entry.id, + x, + y, + label: `${entry.weightGrams.toFixed(1)} g on ${formatShortDate(entry.recordedOn)}`, + }; + }); + + const path = toOverviewPath(points); + const midWeight = minWeight + (maxWeight - minWeight) / 2; + const midDate = new Date((startMs + endMs) / 2); + + return { + points, + path, + isFlat, + yTicks: [ + { label: `${maxWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top }, + { label: `${midWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top + innerHeight / 2 }, + { label: `${minWeight.toFixed(0)} g`, y: MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom }, + ], + xTicks: [ + { label: formatShortDate(weights[0].recordedOn), x: MEMBER_CHART_PADDING.left }, + { label: formatShortDate(midDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH / 2 }, + { label: formatShortDate(weights[weights.length - 1].recordedOn), x: MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right }, + ], + }; + }, [weights]); + + const hasSelectedBirdLine = selectedBirdChart.points.length >= 2 && selectedBirdChart.path.length > 0; + const flockWeeklyTrendItems = useMemo(() => { return birds .map((bird) => { @@ -993,6 +1187,13 @@ function App() { setPhotoDrag(null); }, [editingBird, editingBirdId]); + useEffect(() => { + setBulkWeightRows((current) => { + const nextEntries = birds.map((bird) => [bird.id, current[bird.id] ?? { weightGrams: '' }] as const); + return Object.fromEntries(nextEntries); + }); + }, [birds]); + const startCreateBird = () => { setEditingBirdId(''); setBirdForm(emptyBirdForm); @@ -1361,6 +1562,112 @@ function App() { } }; + const handleBulkWeightValueChange = (birdId: string, weightGrams: string) => { + setBulkWeightRows((current) => ({ + ...current, + [birdId]: { + weightGrams, + }, + })); + }; + + const handleBulkWeightSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const entries = birds + .map((bird) => ({ + bird, + weightGrams: bulkWeightRows[bird.id]?.weightGrams?.trim() ?? '', + })) + .filter((entry) => entry.weightGrams); + + if (!entries.length) { + setError('Add at least one weight before saving the bulk update.'); + return; + } + + setError(''); + setSavingBulkWeights(true); + + try { + const savedWeights: Record = {}; + + for (const entry of entries) { + const response = await apiFetch(`/birds/${entry.bird.id}/weights`, authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + weightGrams: Number(entry.weightGrams), + recordedOn: bulkWeightDate, + notes: '', + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, `Unable to save weight for ${entry.bird.name}.`)); + } + + const data = await readJsonSafely<{ weight?: WeightRecord }>(response); + + if (!data?.weight) { + throw new Error(`Unable to save weight for ${entry.bird.name}.`); + } + + savedWeights[entry.bird.id] = data.weight; + } + + setBirds((current) => + current.map((bird) => { + const savedWeight = savedWeights[bird.id]; + + if (!savedWeight) { + return bird; + } + + return { + ...bird, + latestWeightGrams: savedWeight.weightGrams, + latestRecordedOn: savedWeight.recordedOn, + }; + }), + ); + + setAllBirdWeights((current) => { + const next = { ...current }; + const limitDate = new Date(); + limitDate.setDate(limitDate.getDate() - 29); + const limitMs = new Date(limitDate.toDateString()).getTime(); + + for (const [birdId, savedWeight] of Object.entries(savedWeights)) { + const nextWeights = [...(current[birdId] ?? []), savedWeight] + .sort((left, right) => left.recordedOn.localeCompare(right.recordedOn)) + .filter((entry) => new Date(`${entry.recordedOn}T00:00:00`).getTime() >= limitMs); + next[birdId] = nextWeights; + } + + return next; + }); + + if (selectedBird?.id && savedWeights[selectedBird.id]) { + setWeights((current) => + [...current.filter((entry) => entry.recordedOn !== bulkWeightDate), savedWeights[selectedBird.id]].sort((left, right) => + left.recordedOn.localeCompare(right.recordedOn), + ), + ); + } + + setBulkWeightRows((current) => + Object.fromEntries( + Object.entries(current).map(([birdId]) => [birdId, { weightGrams: '' }]), + ), + ); + } catch (bulkError) { + setError(bulkError instanceof Error ? bulkError.message : 'Unable to save the bulk weight update.'); + } finally { + setSavingBulkWeights(false); + } + }; + const handleVetVisitSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -1599,6 +1906,13 @@ function App() { } }; + const handleWeightRangeAlertClick = () => { + if (!outOfRangeBirds.length) { + return; + } + setShowWeightAlertModal(true); + }; + if (authLoading) { return (
@@ -1721,7 +2035,11 @@ function App() { return (
- +
- {activePage !== 'settings' ? ( -
-
-

Dashboard

- FlockPal -
-
- ) : null} - {error ?

{error}

: null} {activePage === 'overview' ? ( @@ -1779,7 +2089,14 @@ function App() {

Overview

30-day flock weight snapshot

-

{birdsWithRecentWeights.length} birds with recent entries

+
+ {outOfRangeBirds.length ? ( + + ) : null} +

{birdsWithRecentWeights.length} birds with recent entries

+
@@ -1853,6 +2170,17 @@ function App() { Members still needing a first weight ) : null} + {outOfRangeBirds.length ? ( +
+ Weight range alerts + + {outOfRangeBirds.length} bird{outOfRangeBirds.length === 1 ? '' : 's'} outside typical ranges + + +
+ ) : null}
Weekly flock changes {flockWeeklyTrendItems.length ? ( @@ -1884,47 +2212,121 @@ function App() { ) : null} {activePage === 'flock' ? ( -
- + {formatWeight(bird.latestWeightGrams)} + {birdWeightAssessments[bird.id]?.status === 'below' || birdWeightAssessments[bird.id]?.status === 'above' ? ( + + {birdWeightAssessments[bird.id]?.status === 'below' ? 'Below chart range' : 'Above chart range'} + + ) : null} + + ))} +
+ - {selectedBird ? ( -
+ {showFlockDetailColumn ? ( +
+ {bulkWeightOpen ? ( +
+
+
+

Weigh-in

+

Bulk add weights

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

Flock member

@@ -1996,14 +2398,69 @@ function App() {

Latest reading: {formatShortDate(selectedBird.latestRecordedOn)}

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

{selectedBirdTrendCopy}

@@ -2116,8 +2573,10 @@ function App() {
-
- ) : null} +
+ ) : null} +
+ ) : null} ) : null} @@ -2471,9 +2930,49 @@ function App() { Band ID setBirdForm({ ...birdForm, tagId: event.target.value })} required /> -
); } diff --git a/frontend/src/assets/flockpal-landing-art.png b/frontend/src/assets/flockpal-landing-art.png index 3cfc7c7..31283c0 100644 Binary files a/frontend/src/assets/flockpal-landing-art.png and b/frontend/src/assets/flockpal-landing-art.png differ diff --git a/frontend/src/index.css b/frontend/src/index.css index fabcde9..0e62264 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,9 +1,18 @@ :root { --ink: #1f2a2a; --muted: #5d5f59; - --panel-border: rgba(31, 110, 78, 0.16); - --panel-bg: rgba(255, 248, 241, 0.82); - --card-bg: linear-gradient(180deg, rgba(255, 240, 231, 0.94), rgba(239, 248, 244, 0.88)); + --panel-border: rgba(53, 129, 98, 0.28); + --panel-bg: + linear-gradient(135deg, rgba(255, 255, 255, 0.36), transparent 42%), + linear-gradient(180deg, rgba(255, 249, 238, 0.94), rgba(228, 244, 230, 0.88)); + --card-bg: + linear-gradient(135deg, rgba(255, 255, 255, 0.34), transparent 42%), + linear-gradient(180deg, rgba(255, 248, 235, 0.94), rgba(227, 243, 229, 0.9)); + --button-surface: linear-gradient(180deg, rgba(252, 244, 228, 0.96), rgba(232, 243, 233, 0.9)); + --button-surface-hover: linear-gradient(180deg, rgba(255, 248, 238, 0.98), rgba(236, 246, 237, 0.94)); + --button-border: rgba(64, 120, 87, 0.18); + --button-shadow: 0 12px 24px rgba(72, 97, 62, 0.12); + --card-shadow: 0 20px 38px rgba(86, 63, 34, 0.18); --accent-red: #cb3a35; --accent-green: #238a5a; --accent-blue: #2769b3; @@ -113,13 +122,32 @@ textarea { gap: 1.5rem; } -.side-nav { +.side-rail { position: sticky; top: 2rem; + display: grid; + gap: 1rem; + align-self: start; +} + +.side-nav { display: grid; gap: 1.25rem; } +.brand-lockup { + display: grid; + justify-items: start; + padding-left: 0.4rem; +} + +.side-nav-logo { + display: block; + width: min(210px, 100%); + height: auto; + filter: drop-shadow(0 10px 18px rgba(86, 63, 34, 0.14)); +} + .auth-panel { display: grid; grid-template-columns: 1.1fr 0.9fr; @@ -145,8 +173,8 @@ textarea { .auth-card { padding: 1.25rem; border-radius: 24px; - background: linear-gradient(180deg, rgba(255, 252, 247, 0.9), rgba(240, 248, 244, 0.82)); - border: 1px solid rgba(39, 105, 179, 0.12); + background: var(--card-bg); + border: 1px solid var(--panel-border); } .auth-illustration-card { @@ -154,9 +182,9 @@ textarea { padding: 1rem; border-radius: 28px; background: - linear-gradient(135deg, rgba(255, 255, 255, 0.6), transparent 44%), - linear-gradient(180deg, rgba(255, 249, 238, 0.92), rgba(234, 245, 238, 0.84)); - border: 1px solid rgba(39, 105, 179, 0.12); + linear-gradient(135deg, rgba(255, 253, 248, 0.56), transparent 44%), + linear-gradient(180deg, rgba(255, 247, 232, 0.94), rgba(229, 241, 231, 0.86)); + border: 1px solid var(--panel-border); box-shadow: 0 18px 34px rgba(89, 48, 42, 0.1); } @@ -182,11 +210,11 @@ textarea { grid-template-columns: 46px 1fr; align-items: center; gap: 0.9rem; - border: 1px solid rgba(39, 105, 179, 0.12); + border: 1px solid var(--button-border); border-radius: 18px; padding: 0.85rem 0.95rem; - background: rgba(255, 255, 255, 0.88); - box-shadow: 0 12px 24px rgba(39, 105, 179, 0.08); + background: var(--button-surface); + box-shadow: var(--button-shadow); color: var(--ink); } @@ -222,13 +250,17 @@ textarea { } .provider-google { - background: #ffffff; - border-color: rgba(66, 133, 244, 0.18); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.56), transparent 54%), + var(--button-surface); + border-color: rgba(66, 133, 244, 0.14); } .provider-microsoft { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(246, 250, 255, 0.92)); - border-color: rgba(0, 164, 239, 0.18); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.48), transparent 54%), + linear-gradient(180deg, rgba(251, 245, 232, 0.96), rgba(230, 242, 235, 0.9)); + border-color: rgba(0, 164, 239, 0.14); } .provider-apple { @@ -253,7 +285,7 @@ textarea { .provider-button:not(.disabled):hover { transform: translateY(-1px); - box-shadow: 0 16px 28px rgba(39, 105, 179, 0.12); + box-shadow: 0 16px 28px rgba(72, 97, 62, 0.16); } .stack-grid { @@ -265,7 +297,7 @@ textarea { .panel { background: var(--panel-bg); border: 1px solid var(--panel-border); - box-shadow: 0 22px 44px rgba(89, 48, 42, 0.13); + box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14); backdrop-filter: blur(14px); } @@ -276,9 +308,9 @@ textarea { grid-template-columns: 1.3fr 0.7fr; gap: 1.5rem; background: - linear-gradient(135deg, rgba(203, 58, 53, 0.12), transparent 34%), - linear-gradient(225deg, rgba(39, 105, 179, 0.1), transparent 36%), - linear-gradient(180deg, rgba(255, 248, 241, 0.92), rgba(245, 251, 248, 0.86)); + linear-gradient(135deg, rgba(203, 58, 53, 0.1), transparent 34%), + linear-gradient(225deg, rgba(39, 105, 179, 0.08), transparent 36%), + linear-gradient(180deg, rgba(255, 247, 235, 0.94), rgba(232, 243, 233, 0.86)); position: relative; overflow: hidden; } @@ -294,32 +326,26 @@ textarea { max-width: 12ch; } -.dashboard-logo { - display: block; - width: min(320px, 100%); - height: auto; - border-radius: 22px; -} - .page-tabs { display: grid; gap: 0.75rem; } .page-tab { - border: 1px solid rgba(39, 105, 179, 0.14); + border: 1px solid var(--button-border); border-radius: 18px; padding: 0.95rem 1rem; - background: rgba(255, 255, 255, 0.54); + background: linear-gradient(180deg, rgba(251, 244, 229, 0.82), rgba(231, 242, 232, 0.74)); color: var(--ink); transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease; text-align: left; } .page-tab.active { - background: linear-gradient(135deg, rgba(203, 58, 53, 0.92), rgba(39, 105, 179, 0.92)); + background: linear-gradient(135deg, #d89a2f, #3c8f65 58%, #2f8f98); color: #fffdf9; border-color: transparent; + box-shadow: 0 14px 26px rgba(88, 110, 62, 0.2); } .page-tab:hover { @@ -362,7 +388,9 @@ textarea { .bird-card { border-radius: 24px; background: var(--card-bg); - border: 1px solid rgba(39, 105, 179, 0.12); + border: 1px solid var(--panel-border); + box-shadow: var(--card-shadow); + backdrop-filter: blur(10px); } .hero-stats article { @@ -377,8 +405,7 @@ textarea { color: var(--accent-green); } -.hero-stats article::before, -.bird-card::before { +.hero-stats article::before { content: ""; display: block; height: 5px; @@ -427,6 +454,12 @@ textarea { gap: 1.5rem; } +.flock-detail-column { + display: grid; + gap: 1.5rem; + align-content: start; +} + .panel { border-radius: 28px; padding: 1.5rem; @@ -459,6 +492,30 @@ textarea { flex-wrap: wrap; } +.overview-alert-actions { + align-items: center; + justify-content: end; +} + +.overview-alert-actions .muted { + margin: 0; +} + +.range-alert-button { + border: 1px solid rgba(203, 58, 53, 0.24); + border-radius: 999px; + padding: 0.6rem 0.95rem; + background: linear-gradient(180deg, rgba(255, 246, 242, 0.96), rgba(255, 236, 228, 0.94)); + color: var(--accent-red); + font-weight: 700; + box-shadow: 0 10px 22px rgba(203, 58, 53, 0.14); +} + +.range-alert-button:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(203, 58, 53, 0.18); +} + .workspace-switcher { display: grid; gap: 0.9rem; @@ -530,8 +587,8 @@ textarea { .placeholder-avatar { display: grid; place-items: center; - background: linear-gradient(135deg, rgba(203, 58, 53, 0.14), rgba(39, 105, 179, 0.18)); - color: var(--accent-red); + background: linear-gradient(180deg, rgba(255, 245, 227, 0.95), rgba(226, 241, 229, 0.9)); + color: var(--accent-green); font-size: 1.6rem; font-weight: 700; } @@ -540,28 +597,33 @@ textarea { width: 100%; text-align: left; padding: 1rem; - border: 1px solid rgba(95, 121, 77, 0.12); + border: 1px solid rgba(95, 121, 77, 0.16); display: grid; gap: 0.35rem; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; - position: relative; - overflow: hidden; + background: var(--card-bg); +} + +.bird-alert-badge { + display: inline-flex; + align-items: center; + justify-self: start; + padding: 0.28rem 0.7rem; + border-radius: 999px; + background: rgba(203, 58, 53, 0.12); + border: 1px solid rgba(203, 58, 53, 0.18); + color: var(--accent-red); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; } .bird-card:hover, .bird-card.active { transform: translateY(-2px); box-shadow: 0 16px 24px rgba(39, 105, 179, 0.15); - border-color: rgba(35, 138, 90, 0.42); -} - -.bird-card::before { - position: absolute; - inset: 0 auto 0 0; - width: 6px; - height: auto; - margin: 0; - border-radius: 0 999px 999px 0; + border-color: rgba(35, 138, 90, 0.28); } .chart-card { @@ -658,18 +720,22 @@ textarea { .legend-card, .detail-card, .summary-card, +.weight-reference-card, .vet-visit-card { padding: 1rem; border-radius: 20px; - background: linear-gradient(180deg, rgba(255, 252, 247, 0.9), rgba(240, 248, 244, 0.82)); - border: 1px solid rgba(39, 105, 179, 0.1); + background: var(--card-bg); + border: 1px solid rgba(53, 129, 98, 0.24); display: grid; gap: 0.35rem; + box-shadow: 0 16px 30px rgba(86, 63, 34, 0.14); } .inset-panel { padding: 1.25rem; - background: linear-gradient(180deg, rgba(255, 252, 247, 0.74), rgba(241, 248, 244, 0.72)); + background: var(--card-bg); + border: 1px solid rgba(53, 129, 98, 0.2); + box-shadow: 0 14px 28px rgba(86, 63, 34, 0.12); } .wide-field { @@ -711,6 +777,48 @@ textarea { gap: 0.2rem; } +.summary-alert-card { + border-color: rgba(203, 58, 53, 0.22); + background: linear-gradient(180deg, rgba(255, 247, 244, 0.97), rgba(255, 240, 234, 0.94)); +} + +.summary-alert-card strong { + color: var(--accent-red); +} + +.weight-reference-card { + gap: 0.55rem; +} + +.weight-reference-card h3, +.weight-reference-card p, +.weight-reference-card small { + margin: 0; +} + +.weight-reference-card.neutral { + border-color: rgba(39, 105, 179, 0.24); + background: linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(241, 249, 245, 0.92)); +} + +.weight-reference-card.success { + border-color: rgba(35, 138, 90, 0.28); + background: linear-gradient(180deg, rgba(244, 252, 246, 0.97), rgba(234, 248, 239, 0.94)); +} + +.weight-reference-card.warning { + border-color: rgba(203, 58, 53, 0.22); + background: linear-gradient(180deg, rgba(255, 247, 244, 0.97), rgba(255, 240, 234, 0.94)); +} + +.weight-reference-card.success h3 { + color: var(--accent-green); +} + +.weight-reference-card.warning h3 { + color: var(--accent-red); +} + .summary-trend-row { display: flex; flex-wrap: wrap; @@ -749,13 +857,116 @@ label { font-weight: 600; } +.bulk-date-field { + min-width: 180px; +} + +.bulk-date-field input { + margin-top: 0.35rem; +} + +.bulk-weight-form { + display: grid; + gap: 1rem; +} + +.bulk-weight-table-shell { + overflow-x: auto; + border-radius: 22px; + border: 1px solid rgba(53, 129, 98, 0.18); + background: rgba(255, 252, 246, 0.72); +} + +.bulk-weight-table { + width: 100%; + border-collapse: collapse; + min-width: 620px; +} + +.bulk-weight-table th, +.bulk-weight-table td { + padding: 0.95rem 1rem; + text-align: left; + border-bottom: 1px solid rgba(53, 129, 98, 0.12); +} + +.bulk-weight-table th { + font-size: 0.82rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + background: rgba(255, 247, 233, 0.9); +} + +.bulk-weight-table tbody tr:last-child td { + border-bottom: 0; +} + +.bulk-weight-table td input { + margin-top: 0; + min-width: 120px; +} + .toggle-card { display: grid; gap: 0.45rem; padding: 1rem; border-radius: 20px; - background: linear-gradient(180deg, rgba(255, 252, 247, 0.9), rgba(240, 248, 244, 0.82)); - border: 1px solid rgba(39, 105, 179, 0.1); + background: var(--card-bg); + border: 1px solid rgba(53, 129, 98, 0.24); + box-shadow: 0 16px 30px rgba(86, 63, 34, 0.14); +} + +.species-picker-field { + position: relative; +} + +.species-picker { + position: relative; +} + +.species-picker-menu { + position: absolute; + top: calc(100% + 0.45rem); + left: 0; + right: 0; + z-index: 12; + display: grid; + gap: 0.3rem; + padding: 0.45rem; + border-radius: 18px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.48), transparent 42%), + linear-gradient(180deg, rgba(255, 249, 238, 0.98), rgba(228, 244, 230, 0.95)); + border: 1px solid rgba(53, 129, 98, 0.22); + box-shadow: 0 22px 42px rgba(86, 63, 34, 0.18); + backdrop-filter: blur(12px); + max-height: 280px; + overflow-y: auto; +} + +.species-picker-option { + width: 100%; + text-align: left; + border: 1px solid transparent; + border-radius: 14px; + padding: 0.75rem 0.85rem; + background: rgba(255, 255, 255, 0.56); + color: var(--ink); + transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease; +} + +.species-picker-option:hover, +.species-picker-option.active { + background: linear-gradient(180deg, rgba(255, 248, 235, 0.98), rgba(229, 241, 231, 0.94)); + border-color: rgba(39, 105, 179, 0.18); + transform: translateY(-1px); +} + +.species-picker-empty { + padding: 0.8rem 0.85rem; + color: var(--muted); + font-size: 0.95rem; } .toggle-card input[type="checkbox"] { @@ -771,12 +982,12 @@ label { border-radius: 18px; padding: 0.95rem 1.2rem; color: #fffdf9; - background: linear-gradient(135deg, var(--accent-red), var(--accent-blue)); - box-shadow: 0 14px 28px rgba(39, 105, 179, 0.2); + background: linear-gradient(135deg, #d89a2f, #3c8f65 58%, #2f8f98); + box-shadow: 0 14px 28px rgba(88, 110, 62, 0.22); } .primary-button:hover { - background: linear-gradient(135deg, #b7312d, #1f5e9f); + background: linear-gradient(135deg, #c98b22, #327e59 58%, #277c84); } .secondary-button { @@ -831,8 +1042,9 @@ label { align-items: center; padding: 0.95rem 1rem; border-radius: 20px; - background: linear-gradient(180deg, rgba(255, 252, 247, 0.9), rgba(240, 248, 244, 0.82)); - border: 1px solid rgba(39, 105, 179, 0.1); + background: var(--card-bg); + border: 1px solid rgba(53, 129, 98, 0.24); + box-shadow: 0 16px 30px rgba(86, 63, 34, 0.14); } .picker-chip { @@ -913,6 +1125,38 @@ label { padding: 0.75rem; } +.app-modal-backdrop { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + padding: 1.5rem; + background: rgba(31, 42, 42, 0.34); + backdrop-filter: blur(8px); +} + +.app-modal { + width: min(720px, 100%); + max-height: min(82vh, 760px); + overflow: auto; + border-radius: 28px; + padding: 1.5rem; + background: linear-gradient(180deg, rgba(255, 251, 245, 0.98), rgba(241, 249, 245, 0.96)); + border: 1px solid rgba(53, 129, 98, 0.2); + box-shadow: 0 28px 60px rgba(31, 42, 42, 0.24); +} + +.weight-alert-modal { + display: grid; + gap: 1rem; +} + +.modal-alert-list { + display: grid; + gap: 0.9rem; +} + @media (max-width: 980px) { .app-shell, .auth-panel, @@ -938,4 +1182,8 @@ label { .side-nav { position: static; } + + .side-rail { + position: static; + } } diff --git a/frontend/src/parrotWeightReference.ts b/frontend/src/parrotWeightReference.ts new file mode 100644 index 0000000..edda962 --- /dev/null +++ b/frontend/src/parrotWeightReference.ts @@ -0,0 +1,116 @@ +export type ParrotWeightReference = + | { + kind: 'range'; + label: string; + aliases: string[]; + minGrams: number; + maxGrams: number; + } + | { + kind: 'approximate'; + label: string; + aliases: string[]; + approximateGrams: number; + }; + +const references: ParrotWeightReference[] = [ + { kind: 'range', label: 'Cameroon African Grey', aliases: ['cameroon african grey', 'african grey cameroon'], minGrams: 400, maxGrams: 750 }, + { kind: 'range', label: 'Congo African Grey', aliases: ['congo african grey', 'african grey congo'], minGrams: 470, maxGrams: 700 }, + { kind: 'range', label: 'Timneh African Grey', aliases: ['timneh african grey', 'african grey timneh'], minGrams: 300, maxGrams: 360 }, + { kind: 'range', label: 'Blue-fronted Amazon', aliases: ['blue-fronted amazon', 'amazon blue-fronted', 'blue fronted amazon'], minGrams: 275, maxGrams: 510 }, + { kind: 'approximate', label: 'Cuban Amazon', aliases: ['cuban amazon', 'amazon cuban'], approximateGrams: 240 }, + { kind: 'range', label: 'Double Yellow-Headed Amazon', aliases: ['double yellow-headed amazon', 'double yellow headed amazon', 'dyh amazon', 'amazon dyh'], minGrams: 450, maxGrams: 650 }, + { kind: 'approximate', label: 'Lilac-crowned Amazon', aliases: ['lilac-crowned amazon', 'lilac crowned amazon', 'amazon lilac-crown'], approximateGrams: 325 }, + { kind: 'range', label: 'Mealy Amazon', aliases: ['mealy amazon', 'amazon mealy'], minGrams: 540, maxGrams: 700 }, + { kind: 'range', label: 'Orange-winged Amazon', aliases: ['orange-winged amazon', 'orange winged amazon', 'amazon orange-winged'], minGrams: 360, maxGrams: 490 }, + { kind: 'approximate', label: 'Red-lored Amazon', aliases: ['red-lored amazon', 'red lored amazon', 'amazon red-lored'], approximateGrams: 350 }, + { kind: 'range', label: 'White-fronted Amazon', aliases: ['white-fronted amazon', 'white front amazon', 'amazon white front'], minGrams: 205, maxGrams: 235 }, + { kind: 'range', label: 'Yellow-fronted Amazon', aliases: ['yellow-fronted amazon', 'yellow fronted amazon', 'amazon yellow-fronted'], minGrams: 380, maxGrams: 480 }, + { kind: 'range', label: 'Yellow-naped Amazon', aliases: ['yellow-naped amazon', 'yellow naped amazon', 'amazon yellow-naped'], minGrams: 480, maxGrams: 680 }, + { kind: 'range', label: 'American Budgie', aliases: ['american budgie', 'american parakeet', 'budgie american', 'parakeet american'], minGrams: 25, maxGrams: 40 }, + { kind: 'range', label: 'Bourke Parakeet', aliases: ['bourke parakeet', 'bourke budgie', 'budgie bourke', 'parakeet bourke'], minGrams: 41, maxGrams: 49 }, + { kind: 'range', label: 'English Budgie', aliases: ['english budgie', 'english parakeet', 'budgie english', 'parakeet english'], minGrams: 45, maxGrams: 65 }, + { kind: 'range', label: 'Indian Ringneck', aliases: ['indian ringneck', 'budgie indian ringneck', 'parakeet indian ringneck'], minGrams: 116, maxGrams: 140 }, + { kind: 'range', label: 'Moustache Parakeet', aliases: ['moustache parakeet', 'moustache budgie', 'budgie moustache', 'parakeet moustache'], minGrams: 100, maxGrams: 140 }, + { kind: 'range', label: 'Black-headed Caique', aliases: ['black-headed caique', 'blackheaded caique', 'caique blackheaded'], minGrams: 145, maxGrams: 170 }, + { kind: 'approximate', label: 'White-bellied Caique', aliases: ['white-bellied caique', 'white bellied caique', 'caique white bellied'], approximateGrams: 165 }, + { kind: 'approximate', label: 'Galah Cockatoo', aliases: ['galah cockatoo', 'cockatoo galah', 'rose-breasted cockatoo'], approximateGrams: 345 }, + { kind: 'range', label: 'Goffin Cockatoo', aliases: ['goffin cockatoo', 'goffins cockatoo', 'cockatoo goffins'], minGrams: 221, maxGrams: 386 }, + { kind: 'approximate', label: 'Greater Sulphur-crested Cockatoo', aliases: ['greater sulphur-crested cockatoo', 'greater sulphur crested cockatoo', 'cockatoo greater sulphur crested'], approximateGrams: 880 }, + { kind: 'approximate', label: 'Lesser Sulphur-crested Cockatoo', aliases: ['lesser sulphur-crested cockatoo', 'lesser sulphur crested cockatoo', 'cockatoo lesser sulphur crested'], approximateGrams: 350 }, + { kind: 'range', label: 'Moluccan Cockatoo', aliases: ['moluccan cockatoo', 'cockatoo moluccan'], minGrams: 640, maxGrams: 1025 }, + { kind: 'range', label: 'Rose-breasted Cockatoo', aliases: ['rose-breasted cockatoo', 'rose breasted cockatoo', 'cockatoo rose-breasted', 'galah'], minGrams: 281, maxGrams: 390 }, + { kind: 'range', label: 'Umbrella Cockatoo', aliases: ['umbrella cockatoo', 'cockatoo umbrella'], minGrams: 600, maxGrams: 900 }, + { kind: 'range', label: 'Blue-crowned Conure', aliases: ['blue-crowned conure', 'blue crowned conure', 'conure blue-crowned'], minGrams: 84, maxGrams: 100 }, + { kind: 'approximate', label: 'Dusky Conure', aliases: ['dusky conure', 'conure dusky'], approximateGrams: 90 }, + { kind: 'range', label: 'Greater Patagonian Conure', aliases: ['greater patagonian conure', 'conure greater patagonian'], minGrams: 315, maxGrams: 390 }, + { kind: 'range', label: 'Green Cheek Conure', aliases: ['green cheek conure', 'green-cheek conure', 'green cheeked conure', 'conure green cheek'], minGrams: 60, maxGrams: 89 }, + { kind: 'approximate', label: 'Jenday Conure', aliases: ['jenday conure', 'conure jenday'], approximateGrams: 120 }, + { kind: 'range', label: 'Lesser Patagonian Conure', aliases: ['lesser patagonian conure', 'conure lesser patagonian'], minGrams: 240, maxGrams: 310 }, + { kind: 'approximate', label: 'Mitred Conure', aliases: ['mitred conure', 'conure mitred'], approximateGrams: 200 }, + { kind: 'approximate', label: 'Nanday Conure', aliases: ['nanday conure', 'conure nanday'], approximateGrams: 140 }, + { kind: 'approximate', label: 'Orange-fronted Conure', aliases: ['orange-fronted conure', 'orange fronted conure', 'conure orange-fronted'], approximateGrams: 73 }, + { kind: 'approximate', label: 'Painted Conure', aliases: ['painted conure', 'conure painted'], approximateGrams: 55 }, + { kind: 'approximate', label: 'Golden Conure', aliases: ['golden conure', 'queen of bavaria conure', 'conure queen of bavaria'], approximateGrams: 270 }, + { kind: 'approximate', label: 'Red-masked Conure', aliases: ['red-masked conure', 'red masked conure', 'conure red-masked'], approximateGrams: 200 }, + { kind: 'range', label: 'Sun Conure', aliases: ['sun conure', 'conure sun'], minGrams: 100, maxGrams: 130 }, + { kind: 'approximate', label: 'White-eyed Conure', aliases: ['white-eyed conure', 'white eyed conure', 'conure white-eyed'], approximateGrams: 140 }, + { kind: 'approximate', label: 'Greater Vasa Eclectus', aliases: ['greater vasa eclectus', 'eclectus greater vasa'], approximateGrams: 480 }, + { kind: 'range', label: 'Red-sided Eclectus', aliases: ['red-sided eclectus', 'red sided eclectus', 'eclectus red-sided'], minGrams: 380, maxGrams: 450 }, + { kind: 'range', label: 'Solomon Island Eclectus', aliases: ['solomon island eclectus', 'eclectus solomon island'], minGrams: 350, maxGrams: 425 }, + { kind: 'range', label: 'Vosmaeri Eclectus', aliases: ['vosmaeri eclectus', 'eclectus vosmaeri'], minGrams: 430, maxGrams: 550 }, + { kind: 'approximate', label: 'Zebra Finch', aliases: ['zebra finch', 'finch zebra'], approximateGrams: 16 }, + { kind: 'approximate', label: 'Blue-streaked Lory', aliases: ['blue-streaked lory', 'blue streaked lory', 'lory blue-streaked'], approximateGrams: 160 }, + { kind: 'approximate', label: 'Chattering Lory', aliases: ['chattering lory', 'lory chattering'], approximateGrams: 200 }, + { kind: 'approximate', label: 'Dusky Lory', aliases: ['dusky lory', 'lory dusky'], approximateGrams: 155 }, + { kind: 'approximate', label: 'Rainbow Lory', aliases: ['rainbow lory', 'lory rainbow', 'rainbow lorikeet'], approximateGrams: 130 }, + { kind: 'approximate', label: 'Red Lory', aliases: ['red lory', 'lory red'], approximateGrams: 170 }, + { kind: 'approximate', label: "Fischer's Lovebird", aliases: ["fischer's lovebird", 'fischers lovebird', "lovebird fischer's"], approximateGrams: 50 }, + { kind: 'approximate', label: 'Masked Lovebird', aliases: ['masked lovebird', 'lovebird masked'], approximateGrams: 50 }, + { kind: 'approximate', label: 'Peach-faced Lovebird', aliases: ['peach-faced lovebird', 'peach faced lovebird', 'lovebird peach-faced'], approximateGrams: 55 }, + { kind: 'range', label: 'Blue and Gold Macaw', aliases: ['blue and gold macaw', 'blue & gold macaw', 'macaw blue & gold', 'blue gold macaw'], minGrams: 800, maxGrams: 1292 }, + { kind: 'range', label: 'Green-wing Macaw', aliases: ['green-wing macaw', 'green wing macaw', 'macaw green winged', 'green-winged macaw'], minGrams: 900, maxGrams: 1529 }, + { kind: 'approximate', label: "Hahn's Macaw", aliases: ["hahn's macaw", 'hahns macaw', "macaw hahn's"], approximateGrams: 165 }, + { kind: 'range', label: 'Hyacinth Macaw', aliases: ['hyacinth macaw', 'macaw hyacinth'], minGrams: 1200, maxGrams: 1450 }, + { kind: 'approximate', label: "Illiger's Macaw", aliases: ["illiger's macaw", 'illigers macaw', "macaw illiger's"], approximateGrams: 265 }, + { kind: 'approximate', label: "Lear's Macaw", aliases: ["lear's macaw", 'lears macaw', "macaw lear's"], approximateGrams: 940 }, + { kind: 'approximate', label: 'Military Macaw', aliases: ['military macaw', 'macaw military'], approximateGrams: 900 }, + { kind: 'approximate', label: 'Noble Macaw', aliases: ['noble macaw', 'macaw noble'], approximateGrams: 190 }, + { kind: 'approximate', label: 'Red-fronted Macaw', aliases: ['red-fronted macaw', 'red fronted macaw', 'macaw red-fronted'], approximateGrams: 525 }, + { kind: 'range', label: 'Scarlet Macaw', aliases: ['scarlet macaw', 'macaw scarlet'], minGrams: 900, maxGrams: 1100 }, + { kind: 'approximate', label: 'Severe Macaw', aliases: ['severe macaw', 'macaw severe'], approximateGrams: 360 }, + { kind: 'approximate', label: 'Spix Macaw', aliases: ['spix macaw', 'macaw spix'], approximateGrams: 360 }, + { kind: 'approximate', label: 'Yellow-collared Macaw', aliases: ['yellow-collared macaw', 'yellow collared macaw', 'macaw yellow-collared'], approximateGrams: 250 }, + { kind: 'approximate', label: 'Brown-headed Parrot', aliases: ['brown-headed parrot', 'brown headed parrot', 'parrots misc brown-headed'], approximateGrams: 125 }, + { kind: 'approximate', label: 'Cape Parrot', aliases: ['cape parrot', 'parrots misc cape'], approximateGrams: 320 }, + { kind: 'approximate', label: 'Great-billed Parrot', aliases: ['great-billed parrot', 'great billed parrot', 'parrots misc great-billed'], approximateGrams: 260 }, + { kind: 'approximate', label: 'Hawk-headed Parrot', aliases: ['hawk-headed parrot', 'hawk headed parrot', 'parrots misc hawk-headed'], approximateGrams: 260 }, + { kind: 'approximate', label: 'Jardine Parrot', aliases: ['jardine parrot', 'parrots misc jardine'], approximateGrams: 200 }, + { kind: 'approximate', label: 'Meyers Parrot', aliases: ['meyers parrot', "meyer's parrot", 'parrots misc meyers'], approximateGrams: 120 }, + { kind: 'approximate', label: 'Painted Parrot', aliases: ['painted parrot', 'parrots misc painted'], approximateGrams: 55 }, + { kind: 'range', label: 'Quaker Parrot', aliases: ['quaker parrot', 'parrots misc quaker parrot', 'monk parakeet'], minGrams: 90, maxGrams: 150 }, + { kind: 'approximate', label: 'Red-bellied Parrot', aliases: ['red-bellied parrot', 'red bellied parrot', 'parrots misc red bellied'], approximateGrams: 125 }, + { kind: 'range', label: 'Senegal Parrot', aliases: ['senegal parrot', 'parrots misc senegal'], minGrams: 110, maxGrams: 130 }, + { kind: 'range', label: 'Blue-headed Pionus', aliases: ['blue-headed pionus', 'blue headed pionus', 'pionus blue-headed'], minGrams: 230, maxGrams: 260 }, + { kind: 'approximate', label: 'Bronze-winged Pionus', aliases: ['bronze-winged pionus', 'bronze winged pionus', 'pionus bronze-winged'], approximateGrams: 210 }, + { kind: 'approximate', label: 'Dusky Pionus', aliases: ['dusky pionus', 'pionus dusky'], approximateGrams: 200 }, + { kind: 'approximate', label: 'White-capped Pionus', aliases: ['white-capped pionus', 'white capped pionus', 'pionus white-capped'], approximateGrams: 180 }, +]; + +const normalizedReferenceMap = new Map(); + +const normalizeSpeciesKey = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().replace(/\s+/g, ' '); + +for (const reference of references) { + normalizedReferenceMap.set(normalizeSpeciesKey(reference.label), reference); + + for (const alias of reference.aliases) { + normalizedReferenceMap.set(normalizeSpeciesKey(alias), reference); + } +} + +export const findParrotWeightReference = (species: string) => normalizedReferenceMap.get(normalizeSpeciesKey(species)) ?? null; + +export const parrotSpeciesOptions = [...new Set(references.map((reference) => reference.label))].sort((left, right) => + left.localeCompare(right), +);