Major updates, fixed UI, added features like weight notifications

This commit is contained in:
blaisadmin
2026-04-09 23:02:01 -04:00
parent 0f3da53111
commit 27bc463258
5 changed files with 1016 additions and 114 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

+593 -55
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import flockPalLandingArt from './assets/flockpal-landing-art.png'; 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 BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>; type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
@@ -130,6 +131,35 @@ type AuthNotice = {
previewUrl?: string | null; previewUrl?: string | null;
}; };
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 PhotoCropState = { type PhotoCropState = {
sourceDataUrl: string; sourceDataUrl: string;
fileName: string; fileName: string;
@@ -279,6 +309,7 @@ const formatShortDate = (value: string | null) => {
}; };
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); 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 parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
const OVERVIEW_WIDTH = 520; const OVERVIEW_WIDTH = 520;
const OVERVIEW_HEIGHT = 220; const OVERVIEW_HEIGHT = 220;
@@ -287,6 +318,9 @@ const PHOTO_MAX_BYTES = 900_000;
const PHOTO_EXPORT_SIZES = [720, 600, 480]; const PHOTO_EXPORT_SIZES = [720, 600, 480];
const PHOTO_EXPORT_QUALITIES = [0.9, 0.82, 0.74, 0.66]; const PHOTO_EXPORT_QUALITIES = [0.9, 0.82, 0.74, 0.66];
const PHOTO_PREVIEW_SIZE = 112; 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 <T,>(response: Response): Promise<T | null> => { const readJsonSafely = async <T,>(response: Response): Promise<T | null> => {
const contentType = response.headers.get('content-type') ?? ''; 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) => const apiFetch = (path: string, token?: string, init?: RequestInit) =>
fetch(`${apiBaseUrl}${path}`, { fetch(`${apiBaseUrl}${path}`, {
...init, ...init,
cache: 'no-store',
headers: createApiHeaders(token, init?.headers), headers: createApiHeaders(token, init?.headers),
}); });
@@ -587,6 +622,53 @@ const buildOverviewSeries = (points: WeightRecord[], minWeight: number, maxWeigh
const toOverviewPath = (points: { x: number; y: number }[]) => const toOverviewPath = (points: { x: number; y: number }[]) =>
points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' '); 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() { function App() {
const [activePage, setActivePage] = useState<AppPage>('overview'); const [activePage, setActivePage] = useState<AppPage>('overview');
const [authToken, setAuthToken] = useState(''); const [authToken, setAuthToken] = useState('');
@@ -620,6 +702,12 @@ function App() {
const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false);
const [creatingWorkspace, setCreatingWorkspace] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false);
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null); const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(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<Record<string, BulkWeightRowState>>({});
const [weightForm, setWeightForm] = useState({ const [weightForm, setWeightForm] = useState({
weightGrams: '', weightGrams: '',
recordedOn: new Date().toISOString().slice(0, 10), recordedOn: new Date().toISOString().slice(0, 10),
@@ -654,11 +742,24 @@ function App() {
[allBirdWeights, birds], [allBirdWeights, birds],
); );
const showFlockDetailColumn = bulkWeightOpen || Boolean(selectedBird);
const missingFirstWeightCount = useMemo( const missingFirstWeightCount = useMemo(
() => birds.filter((bird) => bird.latestWeightGrams === null).length, () => birds.filter((bird) => bird.latestWeightGrams === null).length,
[birds], [birds],
); );
const birdWeightAssessments = useMemo(
() =>
Object.fromEntries(
birds.map((bird) => [
bird.id,
assessBirdWeight(bird),
]),
) as Record<string, BirdWeightAssessment>,
[birds],
);
const selectedBirdTrendCopy = useMemo(() => { const selectedBirdTrendCopy = useMemo(() => {
if (weights.length < 2) { if (weights.length < 2) {
return 'Needs a few more entries before trend detection.'; 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.`; : `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`;
}, [weights]); }, [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(() => { const flockWeeklyTrendItems = useMemo(() => {
return birds return birds
.map((bird) => { .map((bird) => {
@@ -993,6 +1187,13 @@ function App() {
setPhotoDrag(null); setPhotoDrag(null);
}, [editingBird, editingBirdId]); }, [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 = () => { const startCreateBird = () => {
setEditingBirdId(''); setEditingBirdId('');
setBirdForm(emptyBirdForm); 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<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>) => { const handleVetVisitSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -1599,6 +1906,13 @@ function App() {
} }
}; };
const handleWeightRangeAlertClick = () => {
if (!outOfRangeBirds.length) {
return;
}
setShowWeightAlertModal(true);
};
if (authLoading) { if (authLoading) {
return ( return (
<main className="auth-shell"> <main className="auth-shell">
@@ -1721,7 +2035,11 @@ function App() {
return ( return (
<main className="app-shell"> <main className="app-shell">
<aside className="side-nav panel"> <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"> <div className="page-tabs" role="tablist" aria-label="Main navigation">
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button"> <button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
Overview Overview
@@ -1757,18 +2075,10 @@ function App() {
<button className="secondary-button" onClick={handleLogout} type="button"> <button className="secondary-button" onClick={handleLogout} type="button">
Log out Log out
</button> </button>
</aside> </aside>
</div>
<section className="content-shell"> <section className="content-shell">
{activePage !== 'settings' ? (
<section className="hero-card">
<div>
<p className="eyebrow">Dashboard</p>
<img className="dashboard-logo" src={flockPalLandingArt} alt="FlockPal" />
</div>
</section>
) : null}
{error ? <p className="error-banner">{error}</p> : null} {error ? <p className="error-banner">{error}</p> : null}
{activePage === 'overview' ? ( {activePage === 'overview' ? (
@@ -1779,7 +2089,14 @@ function App() {
<p className="eyebrow">Overview</p> <p className="eyebrow">Overview</p>
<h2>30-day flock weight snapshot</h2> <h2>30-day flock weight snapshot</h2>
</div> </div>
<p className="muted">{birdsWithRecentWeights.length} birds with recent entries</p> <div className="button-row overview-alert-actions">
{outOfRangeBirds.length ? (
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
{outOfRangeBirds.length} weight range alert{outOfRangeBirds.length === 1 ? '' : 's'}
</button>
) : null}
<p className="muted">{birdsWithRecentWeights.length} birds with recent entries</p>
</div>
</div> </div>
<div className="chart-card overview-chart-card"> <div className="chart-card overview-chart-card">
@@ -1853,6 +2170,17 @@ function App() {
<span>Members still needing a first weight</span> <span>Members still needing a first weight</span>
</article> </article>
) : null} ) : null}
{outOfRangeBirds.length ? (
<article className="summary-card summary-alert-card">
<span>Weight range alerts</span>
<strong>
{outOfRangeBirds.length} bird{outOfRangeBirds.length === 1 ? '' : 's'} outside typical ranges
</strong>
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
Review alerts
</button>
</article>
) : null}
<article className="summary-card"> <article className="summary-card">
<span>Weekly flock changes</span> <span>Weekly flock changes</span>
{flockWeeklyTrendItems.length ? ( {flockWeeklyTrendItems.length ? (
@@ -1884,47 +2212,121 @@ function App() {
) : null} ) : null}
{activePage === 'flock' ? ( {activePage === 'flock' ? (
<section className={selectedBird ? 'dashboard-grid' : 'stack-grid'}> <section className={showFlockDetailColumn ? 'dashboard-grid' : 'stack-grid'}>
<aside className="panel bird-list-panel"> <aside className="panel bird-list-panel">
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Flock</p> <p className="eyebrow">Flock</p>
<h2>Flock members</h2> <h2>Flock members</h2>
<p className="muted">Select a bird to view more details.</p> <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>
<button className="secondary-button" onClick={startCreateBird} type="button"> <div className="bird-list">
Add bird {birds.map((bird) => (
</button> <button
</div> key={bird.id}
<div className="bird-list"> className={`bird-card ${bird.id === selectedBird?.id ? 'active' : ''}`}
{birds.map((bird) => ( onClick={() => setSelectedBirdId(bird.id)}
<button type="button"
key={bird.id} style={bird.id === selectedBird?.id ? { borderColor: bird.chartColor, boxShadow: `0 16px 24px ${bird.chartColor}33` } : undefined}
className={`bird-card ${bird.id === selectedBird?.id ? 'active' : ''}`} >
onClick={() => setSelectedBirdId(bird.id)} <div className="bird-card-header">
type="button" {bird.photoDataUrl ? (
> <img className="bird-avatar" src={bird.photoDataUrl} alt={`${bird.name}`} />
<div className="bird-card-header"> ) : (
{bird.photoDataUrl ? ( <div className="bird-avatar placeholder-avatar" aria-hidden="true">
<img className="bird-avatar" src={bird.photoDataUrl} alt={`${bird.name}`} /> {bird.name.slice(0, 1).toUpperCase()}
) : ( </div>
<div className="bird-avatar placeholder-avatar" aria-hidden="true"> )}
{bird.name.slice(0, 1).toUpperCase()} <div className="bird-card-copy">
<span>{bird.name}</span>
<small>{bird.species}</small>
</div> </div>
)}
<div className="bird-card-copy">
<span>{bird.name}</span>
<small>{bird.species}</small>
</div> </div>
</div> <strong>{formatWeight(bird.latestWeightGrams)}</strong>
<strong>{formatWeight(bird.latestWeightGrams)}</strong> {birdWeightAssessments[bird.id]?.status === 'below' || birdWeightAssessments[bird.id]?.status === 'above' ? (
</button> <span className="bird-alert-badge">
))} {birdWeightAssessments[bird.id]?.status === 'below' ? 'Below chart range' : 'Above chart range'}
</div> </span>
</aside> ) : null}
</button>
))}
</div>
</aside>
{selectedBird ? ( {showFlockDetailColumn ? (
<section className="panel flock-member-panel"> <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}
{selectedBird ? (
<section className="panel flock-member-panel">
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Flock member</p> <p className="eyebrow">Flock member</p>
@@ -1996,14 +2398,69 @@ function App() {
<p className="muted">Latest reading: {formatShortDate(selectedBird.latestRecordedOn)}</p> <p className="muted">Latest reading: {formatShortDate(selectedBird.latestRecordedOn)}</p>
</div> </div>
<div className="chart-card"> <div className="chart-card">
<svg viewBox="0 0 520 180" className="weight-chart" role="img" aria-label="Selected flock member weight trend chart"> <svg
viewBox={`0 0 ${MEMBER_CHART_WIDTH} ${MEMBER_CHART_HEIGHT}`}
className="weight-chart"
role="img"
aria-label="Selected flock member weight trend chart"
>
<defs> <defs>
<linearGradient id="lineGlow" x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id="lineGlow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={selectedBird.chartColor} stopOpacity="0.45" /> <stop offset="0%" stopColor={selectedBird.chartColor} stopOpacity="0.45" />
<stop offset="100%" stopColor={selectedBird.chartColor} /> <stop offset="100%" stopColor={selectedBird.chartColor} />
</linearGradient> </linearGradient>
</defs> </defs>
<path d={chartPath(weights)} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" /> {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>
))}
{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>
))}
</svg> </svg>
<div className="chart-footer"> <div className="chart-footer">
<p>{selectedBirdTrendCopy}</p> <p>{selectedBirdTrendCopy}</p>
@@ -2116,8 +2573,10 @@ function App() {
</section> </section>
</div> </div>
</> </>
</section> </section>
) : null} ) : null}
</section>
) : null}
</section> </section>
) : null} ) : null}
@@ -2471,9 +2930,49 @@ function App() {
Band ID Band ID
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required /> <input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
</label> </label>
<label> <label className="species-picker-field">
Species Species
<input value={birdForm.species} onChange={(event) => setBirdForm({ ...birdForm, species: event.target.value })} required /> <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> </label>
<label> <label>
DOB DOB
@@ -2668,6 +3167,45 @@ function App() {
</section> </section>
) : null} ) : null}
</section> </section>
{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 outside typical chart ranges</h2>
</div>
<button className="secondary-button" onClick={() => setShowWeightAlertModal(false)} type="button">
Close
</button>
</div>
<p className="muted">
These alerts use the BirdSupplies species chart as a general reference. If a reading is unexpected or concerning, please consult your
veterinarian.
</p>
<div className="modal-alert-list">
{outOfRangeBirds.map(({ bird, assessment }) => (
<article key={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>
</article>
))}
</div>
</section>
</div>
) : null}
</main> </main>
); );
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 KiB

After

Width:  |  Height:  |  Size: 695 KiB

+307 -59
View File
@@ -1,9 +1,18 @@
:root { :root {
--ink: #1f2a2a; --ink: #1f2a2a;
--muted: #5d5f59; --muted: #5d5f59;
--panel-border: rgba(31, 110, 78, 0.16); --panel-border: rgba(53, 129, 98, 0.28);
--panel-bg: rgba(255, 248, 241, 0.82); --panel-bg:
--card-bg: linear-gradient(180deg, rgba(255, 240, 231, 0.94), rgba(239, 248, 244, 0.88)); 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-red: #cb3a35;
--accent-green: #238a5a; --accent-green: #238a5a;
--accent-blue: #2769b3; --accent-blue: #2769b3;
@@ -113,13 +122,32 @@ textarea {
gap: 1.5rem; gap: 1.5rem;
} }
.side-nav { .side-rail {
position: sticky; position: sticky;
top: 2rem; top: 2rem;
display: grid;
gap: 1rem;
align-self: start;
}
.side-nav {
display: grid; display: grid;
gap: 1.25rem; 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 { .auth-panel {
display: grid; display: grid;
grid-template-columns: 1.1fr 0.9fr; grid-template-columns: 1.1fr 0.9fr;
@@ -145,8 +173,8 @@ textarea {
.auth-card { .auth-card {
padding: 1.25rem; padding: 1.25rem;
border-radius: 24px; border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.9), rgba(240, 248, 244, 0.82)); background: var(--card-bg);
border: 1px solid rgba(39, 105, 179, 0.12); border: 1px solid var(--panel-border);
} }
.auth-illustration-card { .auth-illustration-card {
@@ -154,9 +182,9 @@ textarea {
padding: 1rem; padding: 1rem;
border-radius: 28px; border-radius: 28px;
background: background:
linear-gradient(135deg, rgba(255, 255, 255, 0.6), transparent 44%), linear-gradient(135deg, rgba(255, 253, 248, 0.56), transparent 44%),
linear-gradient(180deg, rgba(255, 249, 238, 0.92), rgba(234, 245, 238, 0.84)); linear-gradient(180deg, rgba(255, 247, 232, 0.94), rgba(229, 241, 231, 0.86));
border: 1px solid rgba(39, 105, 179, 0.12); border: 1px solid var(--panel-border);
box-shadow: 0 18px 34px rgba(89, 48, 42, 0.1); box-shadow: 0 18px 34px rgba(89, 48, 42, 0.1);
} }
@@ -182,11 +210,11 @@ textarea {
grid-template-columns: 46px 1fr; grid-template-columns: 46px 1fr;
align-items: center; align-items: center;
gap: 0.9rem; gap: 0.9rem;
border: 1px solid rgba(39, 105, 179, 0.12); border: 1px solid var(--button-border);
border-radius: 18px; border-radius: 18px;
padding: 0.85rem 0.95rem; padding: 0.85rem 0.95rem;
background: rgba(255, 255, 255, 0.88); background: var(--button-surface);
box-shadow: 0 12px 24px rgba(39, 105, 179, 0.08); box-shadow: var(--button-shadow);
color: var(--ink); color: var(--ink);
} }
@@ -222,13 +250,17 @@ textarea {
} }
.provider-google { .provider-google {
background: #ffffff; background:
border-color: rgba(66, 133, 244, 0.18); linear-gradient(135deg, rgba(255, 255, 255, 0.56), transparent 54%),
var(--button-surface);
border-color: rgba(66, 133, 244, 0.14);
} }
.provider-microsoft { .provider-microsoft {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(246, 250, 255, 0.92)); background:
border-color: rgba(0, 164, 239, 0.18); 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 { .provider-apple {
@@ -253,7 +285,7 @@ textarea {
.provider-button:not(.disabled):hover { .provider-button:not(.disabled):hover {
transform: translateY(-1px); 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 { .stack-grid {
@@ -265,7 +297,7 @@ textarea {
.panel { .panel {
background: var(--panel-bg); background: var(--panel-bg);
border: 1px solid var(--panel-border); 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); backdrop-filter: blur(14px);
} }
@@ -276,9 +308,9 @@ textarea {
grid-template-columns: 1.3fr 0.7fr; grid-template-columns: 1.3fr 0.7fr;
gap: 1.5rem; gap: 1.5rem;
background: background:
linear-gradient(135deg, rgba(203, 58, 53, 0.12), transparent 34%), linear-gradient(135deg, rgba(203, 58, 53, 0.1), transparent 34%),
linear-gradient(225deg, rgba(39, 105, 179, 0.1), transparent 36%), linear-gradient(225deg, rgba(39, 105, 179, 0.08), transparent 36%),
linear-gradient(180deg, rgba(255, 248, 241, 0.92), rgba(245, 251, 248, 0.86)); linear-gradient(180deg, rgba(255, 247, 235, 0.94), rgba(232, 243, 233, 0.86));
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
@@ -294,32 +326,26 @@ textarea {
max-width: 12ch; max-width: 12ch;
} }
.dashboard-logo {
display: block;
width: min(320px, 100%);
height: auto;
border-radius: 22px;
}
.page-tabs { .page-tabs {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
} }
.page-tab { .page-tab {
border: 1px solid rgba(39, 105, 179, 0.14); border: 1px solid var(--button-border);
border-radius: 18px; border-radius: 18px;
padding: 0.95rem 1rem; 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); color: var(--ink);
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease; transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
text-align: left; text-align: left;
} }
.page-tab.active { .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; color: #fffdf9;
border-color: transparent; border-color: transparent;
box-shadow: 0 14px 26px rgba(88, 110, 62, 0.2);
} }
.page-tab:hover { .page-tab:hover {
@@ -362,7 +388,9 @@ textarea {
.bird-card { .bird-card {
border-radius: 24px; border-radius: 24px;
background: var(--card-bg); 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 { .hero-stats article {
@@ -377,8 +405,7 @@ textarea {
color: var(--accent-green); color: var(--accent-green);
} }
.hero-stats article::before, .hero-stats article::before {
.bird-card::before {
content: ""; content: "";
display: block; display: block;
height: 5px; height: 5px;
@@ -427,6 +454,12 @@ textarea {
gap: 1.5rem; gap: 1.5rem;
} }
.flock-detail-column {
display: grid;
gap: 1.5rem;
align-content: start;
}
.panel { .panel {
border-radius: 28px; border-radius: 28px;
padding: 1.5rem; padding: 1.5rem;
@@ -459,6 +492,30 @@ textarea {
flex-wrap: wrap; 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 { .workspace-switcher {
display: grid; display: grid;
gap: 0.9rem; gap: 0.9rem;
@@ -530,8 +587,8 @@ textarea {
.placeholder-avatar { .placeholder-avatar {
display: grid; display: grid;
place-items: center; place-items: center;
background: linear-gradient(135deg, rgba(203, 58, 53, 0.14), rgba(39, 105, 179, 0.18)); background: linear-gradient(180deg, rgba(255, 245, 227, 0.95), rgba(226, 241, 229, 0.9));
color: var(--accent-red); color: var(--accent-green);
font-size: 1.6rem; font-size: 1.6rem;
font-weight: 700; font-weight: 700;
} }
@@ -540,28 +597,33 @@ textarea {
width: 100%; width: 100%;
text-align: left; text-align: left;
padding: 1rem; padding: 1rem;
border: 1px solid rgba(95, 121, 77, 0.12); border: 1px solid rgba(95, 121, 77, 0.16);
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
position: relative; background: var(--card-bg);
overflow: hidden; }
.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:hover,
.bird-card.active { .bird-card.active {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 16px 24px rgba(39, 105, 179, 0.15); box-shadow: 0 16px 24px rgba(39, 105, 179, 0.15);
border-color: rgba(35, 138, 90, 0.42); border-color: rgba(35, 138, 90, 0.28);
}
.bird-card::before {
position: absolute;
inset: 0 auto 0 0;
width: 6px;
height: auto;
margin: 0;
border-radius: 0 999px 999px 0;
} }
.chart-card { .chart-card {
@@ -658,18 +720,22 @@ textarea {
.legend-card, .legend-card,
.detail-card, .detail-card,
.summary-card, .summary-card,
.weight-reference-card,
.vet-visit-card { .vet-visit-card {
padding: 1rem; padding: 1rem;
border-radius: 20px; border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.9), rgba(240, 248, 244, 0.82)); background: var(--card-bg);
border: 1px solid rgba(39, 105, 179, 0.1); border: 1px solid rgba(53, 129, 98, 0.24);
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
box-shadow: 0 16px 30px rgba(86, 63, 34, 0.14);
} }
.inset-panel { .inset-panel {
padding: 1.25rem; 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 { .wide-field {
@@ -711,6 +777,48 @@ textarea {
gap: 0.2rem; 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 { .summary-trend-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -749,13 +857,116 @@ label {
font-weight: 600; 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 { .toggle-card {
display: grid; display: grid;
gap: 0.45rem; gap: 0.45rem;
padding: 1rem; padding: 1rem;
border-radius: 20px; border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.9), rgba(240, 248, 244, 0.82)); background: var(--card-bg);
border: 1px solid rgba(39, 105, 179, 0.1); 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"] { .toggle-card input[type="checkbox"] {
@@ -771,12 +982,12 @@ label {
border-radius: 18px; border-radius: 18px;
padding: 0.95rem 1.2rem; padding: 0.95rem 1.2rem;
color: #fffdf9; color: #fffdf9;
background: linear-gradient(135deg, var(--accent-red), var(--accent-blue)); background: linear-gradient(135deg, #d89a2f, #3c8f65 58%, #2f8f98);
box-shadow: 0 14px 28px rgba(39, 105, 179, 0.2); box-shadow: 0 14px 28px rgba(88, 110, 62, 0.22);
} }
.primary-button:hover { .primary-button:hover {
background: linear-gradient(135deg, #b7312d, #1f5e9f); background: linear-gradient(135deg, #c98b22, #327e59 58%, #277c84);
} }
.secondary-button { .secondary-button {
@@ -831,8 +1042,9 @@ label {
align-items: center; align-items: center;
padding: 0.95rem 1rem; padding: 0.95rem 1rem;
border-radius: 20px; border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.9), rgba(240, 248, 244, 0.82)); background: var(--card-bg);
border: 1px solid rgba(39, 105, 179, 0.1); border: 1px solid rgba(53, 129, 98, 0.24);
box-shadow: 0 16px 30px rgba(86, 63, 34, 0.14);
} }
.picker-chip { .picker-chip {
@@ -913,6 +1125,38 @@ label {
padding: 0.75rem; 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) { @media (max-width: 980px) {
.app-shell, .app-shell,
.auth-panel, .auth-panel,
@@ -938,4 +1182,8 @@ label {
.side-nav { .side-nav {
position: static; position: static;
} }
.side-rail {
position: static;
}
} }
+116
View File
@@ -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<string, ParrotWeightReference>();
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),
);