Initial app

This commit is contained in:
blaisadmin
2026-04-06 23:36:12 -04:00
parent 9405327f74
commit 68ab8b12d2
20 changed files with 5626 additions and 152 deletions
+879
View File
@@ -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;