Initial app
This commit is contained in:
@@ -0,0 +1,879 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type Bird = {
|
||||
id: string;
|
||||
name: string;
|
||||
tagId: string;
|
||||
species: string;
|
||||
dateOfBirth: string | null;
|
||||
gotchaDay: string | null;
|
||||
createdAt: string;
|
||||
latestWeightGrams: number | null;
|
||||
latestRecordedOn: string | null;
|
||||
};
|
||||
|
||||
type WeightRecord = {
|
||||
id: string;
|
||||
birdId: string;
|
||||
weightGrams: number;
|
||||
recordedOn: string;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
type VetVisit = {
|
||||
id: string;
|
||||
birdId: string;
|
||||
visitedOn: string;
|
||||
clinicName: string;
|
||||
reason: string;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
type AppPage = 'overview' | 'flock' | 'settings';
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
|
||||
|
||||
const formatDate = (value: string | null) => {
|
||||
if (!value) {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(new Date(`${value}T00:00:00`));
|
||||
};
|
||||
|
||||
const formatShortDate = (value: string | null) => {
|
||||
if (!value) {
|
||||
return 'No data yet';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(`${value}T00:00:00`));
|
||||
};
|
||||
|
||||
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
|
||||
|
||||
const chartPath = (points: WeightRecord[], width = 520, height = 180) => {
|
||||
if (!points.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const weights = points.map((point) => point.weightGrams);
|
||||
const min = Math.min(...weights);
|
||||
const max = Math.max(...weights);
|
||||
const spread = Math.max(max - min, 1);
|
||||
|
||||
return points
|
||||
.map((point, index) => {
|
||||
const x = (index / Math.max(points.length - 1, 1)) * width;
|
||||
const y = height - ((point.weightGrams - min) / spread) * (height - 24) - 12;
|
||||
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const chartDots = (points: WeightRecord[], width = 520, height = 180) => {
|
||||
if (!points.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const weights = points.map((point) => point.weightGrams);
|
||||
const min = Math.min(...weights);
|
||||
const max = Math.max(...weights);
|
||||
const spread = Math.max(max - min, 1);
|
||||
|
||||
return points.map((point, index) => ({
|
||||
id: point.id,
|
||||
x: (index / Math.max(points.length - 1, 1)) * width,
|
||||
y: height - ((point.weightGrams - min) / spread) * (height - 24) - 12,
|
||||
label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`,
|
||||
}));
|
||||
};
|
||||
|
||||
const birdLineStyles = [
|
||||
{ stroke: '#cb3a35' },
|
||||
{ stroke: '#238a5a' },
|
||||
{ stroke: '#2769b3' },
|
||||
{ stroke: '#f0b63f' },
|
||||
{ stroke: '#2f8f98' },
|
||||
];
|
||||
|
||||
function App() {
|
||||
const [activePage, setActivePage] = useState<AppPage>('overview');
|
||||
const [birds, setBirds] = useState<Bird[]>([]);
|
||||
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
||||
const [weights, setWeights] = useState<WeightRecord[]>([]);
|
||||
const [vetVisits, setVetVisits] = useState<VetVisit[]>([]);
|
||||
const [allBirdWeights, setAllBirdWeights] = useState<Record<string, WeightRecord[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [birdForm, setBirdForm] = useState({
|
||||
name: '',
|
||||
tagId: '',
|
||||
species: '',
|
||||
dateOfBirth: '',
|
||||
gotchaDay: '',
|
||||
});
|
||||
const [weightForm, setWeightForm] = useState({
|
||||
weightGrams: '',
|
||||
recordedOn: new Date().toISOString().slice(0, 10),
|
||||
notes: '',
|
||||
});
|
||||
const [vetVisitForm, setVetVisitForm] = useState({
|
||||
visitedOn: new Date().toISOString().slice(0, 10),
|
||||
clinicName: '',
|
||||
reason: '',
|
||||
notes: '',
|
||||
});
|
||||
const [mergeForm, setMergeForm] = useState({
|
||||
fromOwner: '',
|
||||
toOwner: '',
|
||||
birdName: '',
|
||||
tagId: '',
|
||||
notes: '',
|
||||
});
|
||||
const [deletingBird, setDeletingBird] = useState(false);
|
||||
|
||||
const selectedBird = useMemo(
|
||||
() => birds.find((bird) => bird.id === selectedBirdId) ?? birds[0] ?? null,
|
||||
[birds, selectedBirdId],
|
||||
);
|
||||
|
||||
const totalWeightEntries = useMemo(
|
||||
() => Object.values(allBirdWeights).reduce((total, entries) => total + entries.length, 0),
|
||||
[allBirdWeights],
|
||||
);
|
||||
|
||||
const birdsWithRecentWeights = useMemo(
|
||||
() => birds.filter((bird) => (allBirdWeights[bird.id] ?? []).length > 0),
|
||||
[allBirdWeights, birds],
|
||||
);
|
||||
|
||||
const trendCopy = useMemo(() => {
|
||||
if (weights.length < 2) {
|
||||
return 'Needs a few more entries before trend detection.';
|
||||
}
|
||||
|
||||
const first = weights[0].weightGrams;
|
||||
const last = weights[weights.length - 1].weightGrams;
|
||||
const delta = last - first;
|
||||
|
||||
if (Math.abs(delta) < 1) {
|
||||
return 'Weight has been steady over the last visible entries.';
|
||||
}
|
||||
|
||||
return delta > 0
|
||||
? `Weight is up ${delta.toFixed(1)} g over the current window.`
|
||||
: `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`;
|
||||
}, [weights]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBirds = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${apiBaseUrl}/birds`);
|
||||
const data = await response.json();
|
||||
const nextBirds = data.birds ?? [];
|
||||
|
||||
setBirds(nextBirds);
|
||||
setSelectedBirdId((current) => current || nextBirds[0]?.id || '');
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadBirds();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBird?.id) {
|
||||
setWeights([]);
|
||||
setVetVisits([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadBirdDetail = async () => {
|
||||
try {
|
||||
const [weightsResponse, visitsResponse] = await Promise.all([
|
||||
fetch(`${apiBaseUrl}/birds/${selectedBird.id}/weights?days=90`),
|
||||
fetch(`${apiBaseUrl}/birds/${selectedBird.id}/vet-visits`),
|
||||
]);
|
||||
const weightsData = await weightsResponse.json();
|
||||
const visitsData = await visitsResponse.json();
|
||||
|
||||
setWeights(weightsData.weights ?? []);
|
||||
setVetVisits(visitsData.vetVisits ?? []);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.');
|
||||
}
|
||||
};
|
||||
|
||||
void loadBirdDetail();
|
||||
}, [selectedBird?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!birds.length) {
|
||||
setAllBirdWeights({});
|
||||
return;
|
||||
}
|
||||
|
||||
const loadAllBirdWeights = async () => {
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
birds.map(async (bird) => {
|
||||
const response = await fetch(`${apiBaseUrl}/birds/${bird.id}/weights?days=30`);
|
||||
const data = await response.json();
|
||||
return [bird.id, (data.weights ?? []) as WeightRecord[]] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
setAllBirdWeights(Object.fromEntries(responses));
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Unable to load overview weights.');
|
||||
}
|
||||
};
|
||||
|
||||
void loadAllBirdWeights();
|
||||
}, [birds]);
|
||||
|
||||
const handleBirdSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/birds`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(birdForm),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error ?? 'Unable to create flock member.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setBirds((current) => [...current, data.bird].sort((left, right) => left.name.localeCompare(right.name)));
|
||||
setSelectedBirdId(data.bird.id);
|
||||
setBirdForm({
|
||||
name: '',
|
||||
tagId: '',
|
||||
species: '',
|
||||
dateOfBirth: '',
|
||||
gotchaDay: '',
|
||||
});
|
||||
setActivePage('flock');
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : 'Unable to create flock member.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleWeightSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedBird) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/birds/${selectedBird.id}/weights`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
weightGrams: Number(weightForm.weightGrams),
|
||||
recordedOn: weightForm.recordedOn,
|
||||
notes: weightForm.notes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error ?? 'Unable to save weight.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn));
|
||||
|
||||
setWeights(nextWeights);
|
||||
setAllBirdWeights((current) => ({
|
||||
...current,
|
||||
[selectedBird.id]: nextWeights.filter((entry) => {
|
||||
const limitDate = new Date();
|
||||
limitDate.setDate(limitDate.getDate() - 29);
|
||||
return new Date(`${entry.recordedOn}T00:00:00`) >= new Date(limitDate.toDateString());
|
||||
}),
|
||||
}));
|
||||
setBirds((current) =>
|
||||
current.map((bird) =>
|
||||
bird.id === selectedBird.id
|
||||
? {
|
||||
...bird,
|
||||
latestWeightGrams: data.weight.weightGrams,
|
||||
latestRecordedOn: data.weight.recordedOn,
|
||||
}
|
||||
: bird,
|
||||
),
|
||||
);
|
||||
setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' });
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : 'Unable to save weight.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVetVisitSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedBird) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/birds/${selectedBird.id}/vet-visits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(vetVisitForm),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error ?? 'Unable to save vet visit.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setVetVisits((current) =>
|
||||
[data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)),
|
||||
);
|
||||
setVetVisitForm({
|
||||
visitedOn: new Date().toISOString().slice(0, 10),
|
||||
clinicName: '',
|
||||
reason: '',
|
||||
notes: '',
|
||||
});
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveBird = async () => {
|
||||
if (!selectedBird || deletingBird) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Remove ${selectedBird.name} from the flock?\n\nThis will also remove weight records and vet visits for this flock member.`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingBird(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/birds/${selectedBird.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error ?? 'Unable to remove flock member.');
|
||||
}
|
||||
|
||||
const nextBirds = birds.filter((bird) => bird.id !== selectedBird.id);
|
||||
setBirds(nextBirds);
|
||||
setAllBirdWeights((current) => {
|
||||
const next = { ...current };
|
||||
delete next[selectedBird.id];
|
||||
return next;
|
||||
});
|
||||
setSelectedBirdId(nextBirds[0]?.id ?? '');
|
||||
setWeights([]);
|
||||
setVetVisits([]);
|
||||
} catch (removeError) {
|
||||
setError(removeError instanceof Error ? removeError.message : 'Unable to remove flock member.');
|
||||
} finally {
|
||||
setDeletingBird(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMergeSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
window.alert(
|
||||
`Transfer prep saved for ${mergeForm.birdName || 'bird'}.\n\nThis is currently a planning workflow only. Later this page can turn into a real account-to-account transfer flow using verified bird identity and ownership checks.`,
|
||||
);
|
||||
setMergeForm({
|
||||
fromOwner: '',
|
||||
toOwner: '',
|
||||
birdName: '',
|
||||
tagId: '',
|
||||
notes: '',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero-card">
|
||||
<p>Loading flock data...</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero-card">
|
||||
<div>
|
||||
<p className="eyebrow">Bird health tracker</p>
|
||||
<h1>FlockPal dashboard</h1>
|
||||
<div className="page-tabs" role="tablist" aria-label="Main navigation">
|
||||
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
|
||||
Overview
|
||||
</button>
|
||||
<button className={`page-tab ${activePage === 'flock' ? 'active' : ''}`} onClick={() => setActivePage('flock')} type="button">
|
||||
Flock
|
||||
</button>
|
||||
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-stats">
|
||||
<article>
|
||||
<strong>{birds.length}</strong>
|
||||
<span>Flock members</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>{totalWeightEntries}</strong>
|
||||
<span>Weight records</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>{selectedBird ? formatWeight(selectedBird.latestWeightGrams) : 'Pending'}</strong>
|
||||
<span>Selected member</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? <p className="error-banner">{error}</p> : null}
|
||||
|
||||
{activePage === 'overview' ? (
|
||||
<section className="stack-grid">
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Overview</p>
|
||||
<h2>30-day flock weight snapshot</h2>
|
||||
</div>
|
||||
<p className="muted">{birdsWithRecentWeights.length} birds with recent entries</p>
|
||||
</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">
|
||||
{birds.map((bird, index) => {
|
||||
const birdWeights = allBirdWeights[bird.id] ?? [];
|
||||
const style = birdLineStyles[index % birdLineStyles.length];
|
||||
const dots = chartDots(birdWeights, 520, 220);
|
||||
|
||||
if (!birdWeights.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={bird.id}>
|
||||
<path d={chartPath(birdWeights, 520, 220)} fill="none" stroke={style.stroke} strokeWidth="3.5" strokeLinecap="round" />
|
||||
{dots.map((dot) => (
|
||||
<circle key={dot.id} cx={dot.x} cy={dot.y} r="4.5" fill={style.stroke}>
|
||||
<title>{`${bird.name}: ${dot.label}`}</title>
|
||||
</circle>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="legend-grid">
|
||||
{birds.map((bird, index) => {
|
||||
const style = birdLineStyles[index % birdLineStyles.length];
|
||||
const birdWeights = allBirdWeights[bird.id] ?? [];
|
||||
|
||||
return (
|
||||
<article key={bird.id} className="legend-card">
|
||||
<span className="legend-swatch" style={{ background: style.stroke }} />
|
||||
<div>
|
||||
<strong>{bird.name}</strong>
|
||||
<small>
|
||||
{bird.species} • {birdWeights.length ? `${birdWeights.length} entries` : 'No entries yet'}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="forms-grid">
|
||||
<article className="panel form-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Highlights</p>
|
||||
<h2>Flock health pulse</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-grid">
|
||||
<article className="summary-card">
|
||||
<strong>{birdsWithRecentWeights.length}</strong>
|
||||
<span>Flock members with recent measurements</span>
|
||||
</article>
|
||||
<article className="summary-card">
|
||||
<strong>{birds.filter((bird) => bird.latestWeightGrams === null).length}</strong>
|
||||
<span>Members still needing a first weight</span>
|
||||
</article>
|
||||
<article className="summary-card">
|
||||
<strong>{selectedBird ? trendCopy : 'Pick a bird'}</strong>
|
||||
<span>Selected flock member trend</span>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel form-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent activity</p>
|
||||
<h2>Latest check-ins</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-list">
|
||||
{birds
|
||||
.filter((bird) => bird.latestRecordedOn)
|
||||
.sort((left, right) => (right.latestRecordedOn ?? '').localeCompare(left.latestRecordedOn ?? ''))
|
||||
.slice(0, 5)
|
||||
.map((bird) => (
|
||||
<article key={bird.id}>
|
||||
<strong>{bird.name}</strong>
|
||||
<span>{formatWeight(bird.latestWeightGrams)}</span>
|
||||
<small>{formatShortDate(bird.latestRecordedOn)}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activePage === 'flock' ? (
|
||||
<>
|
||||
<section className="dashboard-grid">
|
||||
<aside className="panel bird-list-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Flock</p>
|
||||
<h2>Flock members</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bird-list">
|
||||
{birds.map((bird) => (
|
||||
<button
|
||||
key={bird.id}
|
||||
className={`bird-card ${bird.id === selectedBird?.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdId(bird.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{bird.name}</span>
|
||||
<small>
|
||||
{bird.species} • {bird.tagId}
|
||||
</small>
|
||||
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="panel flock-member-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Flock member</p>
|
||||
<h2>{selectedBird ? selectedBird.name : 'Choose a flock member'}</h2>
|
||||
</div>
|
||||
{selectedBird ? (
|
||||
<button className="danger-button" onClick={handleRemoveBird} type="button" disabled={deletingBird}>
|
||||
{deletingBird ? 'Removing...' : 'Remove from flock'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedBird ? (
|
||||
<>
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<span>Name</span>
|
||||
<strong>{selectedBird.name}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Band ID</span>
|
||||
<strong>{selectedBird.tagId}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>DOB</span>
|
||||
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Gotcha day</span>
|
||||
<strong>{formatDate(selectedBird.gotchaDay)}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Species</span>
|
||||
<strong>{selectedBird.species}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Latest weight</span>
|
||||
<strong>{formatWeight(selectedBird.latestWeightGrams)}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="flock-member-sections">
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Weight</p>
|
||||
<h2>Trend and log</h2>
|
||||
</div>
|
||||
<p className="muted">Latest 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">
|
||||
<defs>
|
||||
<linearGradient id="lineGlow" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#cb3a35" />
|
||||
<stop offset="38%" stopColor="#f0b63f" />
|
||||
<stop offset="68%" stopColor="#238a5a" />
|
||||
<stop offset="100%" stopColor="#2769b3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={chartPath(weights)} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" />
|
||||
</svg>
|
||||
<div className="chart-footer">
|
||||
<p>{trendCopy}</p>
|
||||
<span>{weights.length ? `${weights.length} data point${weights.length === 1 ? '' : 's'}` : 'No data yet'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="form-panel inline-form" onSubmit={handleWeightSubmit}>
|
||||
<label>
|
||||
Weight in grams
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.1"
|
||||
value={weightForm.weightGrams}
|
||||
onChange={(event) => setWeightForm({ ...weightForm, weightGrams: event.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Recorded on
|
||||
<input
|
||||
type="date"
|
||||
value={weightForm.recordedOn}
|
||||
onChange={(event) => setWeightForm({ ...weightForm, recordedOn: event.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="wide-field">
|
||||
Notes
|
||||
<textarea
|
||||
rows={3}
|
||||
value={weightForm.notes}
|
||||
onChange={(event) => setWeightForm({ ...weightForm, notes: event.target.value })}
|
||||
placeholder="Optional notes about appetite, molt, meds, or behavior"
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
Save weight
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Vet visits</p>
|
||||
<h2>Care history and notes</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="form-panel inline-form" onSubmit={handleVetVisitSubmit}>
|
||||
<label>
|
||||
Visit date
|
||||
<input
|
||||
type="date"
|
||||
value={vetVisitForm.visitedOn}
|
||||
onChange={(event) => setVetVisitForm({ ...vetVisitForm, visitedOn: event.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Clinic
|
||||
<input
|
||||
value={vetVisitForm.clinicName}
|
||||
onChange={(event) => setVetVisitForm({ ...vetVisitForm, clinicName: event.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Reason
|
||||
<input
|
||||
value={vetVisitForm.reason}
|
||||
onChange={(event) => setVetVisitForm({ ...vetVisitForm, reason: event.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="wide-field">
|
||||
Notes
|
||||
<textarea
|
||||
rows={3}
|
||||
value={vetVisitForm.notes}
|
||||
onChange={(event) => setVetVisitForm({ ...vetVisitForm, notes: event.target.value })}
|
||||
placeholder="Exam notes, medications, follow-ups, or restrictions"
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
Save vet visit
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="recent-list">
|
||||
{vetVisits.length ? (
|
||||
vetVisits.map((visit) => (
|
||||
<article key={visit.id} className="vet-visit-card">
|
||||
<strong>{visit.reason}</strong>
|
||||
<span>
|
||||
{formatDate(visit.visitedOn)} • {visit.clinicName}
|
||||
</span>
|
||||
<small>{visit.notes || 'No notes recorded.'}</small>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<article className="vet-visit-card empty-card">
|
||||
<strong>No vet visits yet</strong>
|
||||
<small>Add the first visit above to start this care history.</small>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">Add a flock member in Settings to start tracking individual health records.</p>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activePage === 'settings' ? (
|
||||
<section className="forms-grid settings-grid">
|
||||
<article className="panel form-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Flock setup</p>
|
||||
<h2>Add a flock member</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form className="form-panel" onSubmit={handleBirdSubmit}>
|
||||
<label>
|
||||
Bird name
|
||||
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Band ID
|
||||
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Species
|
||||
<input value={birdForm.species} onChange={(event) => setBirdForm({ ...birdForm, species: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
DOB
|
||||
<input
|
||||
type="date"
|
||||
value={birdForm.dateOfBirth}
|
||||
onChange={(event) => setBirdForm({ ...birdForm, dateOfBirth: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Gotcha day
|
||||
<input
|
||||
type="date"
|
||||
value={birdForm.gotchaDay}
|
||||
onChange={(event) => setBirdForm({ ...birdForm, gotchaDay: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
Save flock member
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article className="panel form-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h2>Bird transfer prep</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted">
|
||||
This is the first step toward rescue handoffs and owner-to-owner transfers. For now it captures the matching details we would later
|
||||
use to safely move a bird record between accounts.
|
||||
</p>
|
||||
<form className="form-panel" onSubmit={handleMergeSubmit}>
|
||||
<label>
|
||||
Current owner account
|
||||
<input value={mergeForm.fromOwner} onChange={(event) => setMergeForm({ ...mergeForm, fromOwner: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Destination owner account
|
||||
<input value={mergeForm.toOwner} onChange={(event) => setMergeForm({ ...mergeForm, toOwner: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Bird name
|
||||
<input value={mergeForm.birdName} onChange={(event) => setMergeForm({ ...mergeForm, birdName: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Band / Tag info
|
||||
<input value={mergeForm.tagId} onChange={(event) => setMergeForm({ ...mergeForm, tagId: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Transfer notes
|
||||
<textarea
|
||||
rows={4}
|
||||
value={mergeForm.notes}
|
||||
onChange={(event) => setMergeForm({ ...mergeForm, notes: event.target.value })}
|
||||
placeholder="Optional context for rescue release, adoption, or household transfer"
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
Save transfer draft
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user