Major updates, fixed UI, added features like weight notifications
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 695 KiB |
+552
-14
@@ -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<BillingPlan, 'rescue_free'>;
|
||||
@@ -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<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 = {
|
||||
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 <T,>(response: Response): Promise<T | null> => {
|
||||
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<AppPage>('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<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({
|
||||
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<string, BirdWeightAssessment>,
|
||||
[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<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const entries = birds
|
||||
.map((bird) => ({
|
||||
bird,
|
||||
weightGrams: bulkWeightRows[bird.id]?.weightGrams?.trim() ?? '',
|
||||
}))
|
||||
.filter((entry) => entry.weightGrams);
|
||||
|
||||
if (!entries.length) {
|
||||
setError('Add at least one weight before saving the bulk update.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setSavingBulkWeights(true);
|
||||
|
||||
try {
|
||||
const savedWeights: Record<string, WeightRecord> = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
const response = await apiFetch(`/birds/${entry.bird.id}/weights`, authToken, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
weightGrams: Number(entry.weightGrams),
|
||||
recordedOn: bulkWeightDate,
|
||||
notes: '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, `Unable to save weight for ${entry.bird.name}.`));
|
||||
}
|
||||
|
||||
const data = await readJsonSafely<{ weight?: WeightRecord }>(response);
|
||||
|
||||
if (!data?.weight) {
|
||||
throw new Error(`Unable to save weight for ${entry.bird.name}.`);
|
||||
}
|
||||
|
||||
savedWeights[entry.bird.id] = data.weight;
|
||||
}
|
||||
|
||||
setBirds((current) =>
|
||||
current.map((bird) => {
|
||||
const savedWeight = savedWeights[bird.id];
|
||||
|
||||
if (!savedWeight) {
|
||||
return bird;
|
||||
}
|
||||
|
||||
return {
|
||||
...bird,
|
||||
latestWeightGrams: savedWeight.weightGrams,
|
||||
latestRecordedOn: savedWeight.recordedOn,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
setAllBirdWeights((current) => {
|
||||
const next = { ...current };
|
||||
const limitDate = new Date();
|
||||
limitDate.setDate(limitDate.getDate() - 29);
|
||||
const limitMs = new Date(limitDate.toDateString()).getTime();
|
||||
|
||||
for (const [birdId, savedWeight] of Object.entries(savedWeights)) {
|
||||
const nextWeights = [...(current[birdId] ?? []), savedWeight]
|
||||
.sort((left, right) => left.recordedOn.localeCompare(right.recordedOn))
|
||||
.filter((entry) => new Date(`${entry.recordedOn}T00:00:00`).getTime() >= limitMs);
|
||||
next[birdId] = nextWeights;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
if (selectedBird?.id && savedWeights[selectedBird.id]) {
|
||||
setWeights((current) =>
|
||||
[...current.filter((entry) => entry.recordedOn !== bulkWeightDate), savedWeights[selectedBird.id]].sort((left, right) =>
|
||||
left.recordedOn.localeCompare(right.recordedOn),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setBulkWeightRows((current) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(current).map(([birdId]) => [birdId, { weightGrams: '' }]),
|
||||
),
|
||||
);
|
||||
} catch (bulkError) {
|
||||
setError(bulkError instanceof Error ? bulkError.message : 'Unable to save the bulk weight update.');
|
||||
} finally {
|
||||
setSavingBulkWeights(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVetVisitSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1599,6 +1906,13 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleWeightRangeAlertClick = () => {
|
||||
if (!outOfRangeBirds.length) {
|
||||
return;
|
||||
}
|
||||
setShowWeightAlertModal(true);
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<main className="auth-shell">
|
||||
@@ -1721,6 +2035,10 @@ function App() {
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<div className="side-rail">
|
||||
<div className="brand-lockup">
|
||||
<img className="side-nav-logo" src={flockPalLandingArt} alt="FlockPal" />
|
||||
</div>
|
||||
<aside className="side-nav panel">
|
||||
<div className="page-tabs" role="tablist" aria-label="Main navigation">
|
||||
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
|
||||
@@ -1758,17 +2076,9 @@ function App() {
|
||||
Log out
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
|
||||
{activePage === 'overview' ? (
|
||||
@@ -1779,8 +2089,15 @@ function App() {
|
||||
<p className="eyebrow">Overview</p>
|
||||
<h2>30-day flock weight snapshot</h2>
|
||||
</div>
|
||||
<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 className="chart-card overview-chart-card">
|
||||
<svg viewBox="0 0 520 220" className="weight-chart" role="img" aria-label="All birds weight overview">
|
||||
@@ -1853,6 +2170,17 @@ function App() {
|
||||
<span>Members still needing a first weight</span>
|
||||
</article>
|
||||
) : 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">
|
||||
<span>Weekly flock changes</span>
|
||||
{flockWeeklyTrendItems.length ? (
|
||||
@@ -1884,7 +2212,7 @@ function App() {
|
||||
) : null}
|
||||
|
||||
{activePage === 'flock' ? (
|
||||
<section className={selectedBird ? 'dashboard-grid' : 'stack-grid'}>
|
||||
<section className={showFlockDetailColumn ? 'dashboard-grid' : 'stack-grid'}>
|
||||
<aside className="panel bird-list-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
@@ -1892,10 +2220,15 @@ function App() {
|
||||
<h2>Flock members</h2>
|
||||
<p className="muted">Select a bird to view more details.</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button className="secondary-button" onClick={() => setBulkWeightOpen((current) => !current)} type="button">
|
||||
{bulkWeightOpen ? 'Hide bulk add' : 'Bulk add weights'}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={startCreateBird} type="button">
|
||||
Add bird
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bird-list">
|
||||
{birds.map((bird) => (
|
||||
<button
|
||||
@@ -1903,6 +2236,7 @@ function App() {
|
||||
className={`bird-card ${bird.id === selectedBird?.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdId(bird.id)}
|
||||
type="button"
|
||||
style={bird.id === selectedBird?.id ? { borderColor: bird.chartColor, boxShadow: `0 16px 24px ${bird.chartColor}33` } : undefined}
|
||||
>
|
||||
<div className="bird-card-header">
|
||||
{bird.photoDataUrl ? (
|
||||
@@ -1918,11 +2252,79 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
|
||||
{birdWeightAssessments[bird.id]?.status === 'below' || birdWeightAssessments[bird.id]?.status === 'above' ? (
|
||||
<span className="bird-alert-badge">
|
||||
{birdWeightAssessments[bird.id]?.status === 'below' ? 'Below chart range' : 'Above chart range'}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{showFlockDetailColumn ? (
|
||||
<section className="flock-detail-column">
|
||||
{bulkWeightOpen ? (
|
||||
<section className="panel bulk-weight-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Weigh-in</p>
|
||||
<h2>Bulk add weights</h2>
|
||||
</div>
|
||||
<label className="bulk-date-field">
|
||||
Date
|
||||
<input type="date" value={bulkWeightDate} onChange={(event) => setBulkWeightDate(event.target.value)} required />
|
||||
</label>
|
||||
</div>
|
||||
<form className="bulk-weight-form" onSubmit={handleBulkWeightSubmit}>
|
||||
<div className="bulk-weight-table-shell">
|
||||
<table className="bulk-weight-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Flock member</th>
|
||||
<th>Last weight</th>
|
||||
<th>Weight today</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{birds.map((bird) => (
|
||||
<tr key={bird.id}>
|
||||
<td>{bird.name}</td>
|
||||
<td>{formatWeight(bird.latestWeightGrams)}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.1"
|
||||
value={bulkWeightRows[bird.id]?.weightGrams ?? ''}
|
||||
onChange={(event) => handleBulkWeightValueChange(bird.id, event.target.value)}
|
||||
placeholder="g"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button className="primary-button" type="submit" disabled={savingBulkWeights}>
|
||||
{savingBulkWeights ? 'Saving weights...' : 'Save bulk weights'}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
setBulkWeightRows((current) => Object.fromEntries(Object.keys(current).map((birdId) => [birdId, { weightGrams: '' }])))
|
||||
}
|
||||
type="button"
|
||||
disabled={savingBulkWeights}
|
||||
>
|
||||
Clear entries
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{selectedBird ? (
|
||||
<section className="panel flock-member-panel">
|
||||
<div className="panel-header">
|
||||
@@ -1996,14 +2398,69 @@ function App() {
|
||||
<p className="muted">Latest reading: {formatShortDate(selectedBird.latestRecordedOn)}</p>
|
||||
</div>
|
||||
<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>
|
||||
<linearGradient id="lineGlow" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor={selectedBird.chartColor} stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor={selectedBird.chartColor} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<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>
|
||||
<div className="chart-footer">
|
||||
<p>{selectedBirdTrendCopy}</p>
|
||||
@@ -2120,6 +2577,8 @@ function App() {
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activePage === 'settings' ? (
|
||||
<section className="forms-grid settings-grid">
|
||||
@@ -2471,9 +2930,49 @@ function App() {
|
||||
Band ID
|
||||
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
<label className="species-picker-field">
|
||||
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>
|
||||
DOB
|
||||
@@ -2668,6 +3167,45 @@ function App() {
|
||||
</section>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 690 KiB After Width: | Height: | Size: 695 KiB |
+307
-59
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
Reference in New Issue
Block a user