visual improvements

This commit is contained in:
Corey Blais
2026-04-07 18:23:28 -04:00
parent 68ab8b12d2
commit 2aff57ee7f
5 changed files with 961 additions and 370 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "node --import tsx src/app.ts",
"dev": "tsx watch src/app.ts",
"build": "tsc",
"start": "node dist/app.js"
},
+100 -23
View File
@@ -39,6 +39,11 @@ const allowedOrigins = Array.from(
);
const dateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const chartColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/);
const photoDataUrlSchema = z
.string()
.regex(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/)
.max(1_500_000);
const birdSchema = z.object({
name: z.string().trim().min(1).max(120),
@@ -46,6 +51,8 @@ const birdSchema = z.object({
species: z.string().trim().min(1).max(120),
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')),
chartColor: chartColorSchema.optional(),
photoDataUrl: photoDataUrlSchema.optional().or(z.literal('')),
});
const weightSchema = z.object({
@@ -68,6 +75,8 @@ type BirdRow = {
species: string;
date_of_birth: string | null;
gotcha_day: string | null;
chart_color: string;
photo_data_url: string | null;
created_at: string;
latest_weight_grams: string | null;
latest_recorded_on: string | null;
@@ -111,7 +120,7 @@ app.use(
legacyHeaders: false,
}),
);
app.use(express.json({ limit: '300kb' }));
app.use(express.json({ limit: '2mb' }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
const emptyToNull = (value?: string) => {
@@ -126,11 +135,27 @@ const normalizeBird = (row: BirdRow) => ({
species: row.species,
dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day,
chartColor: row.chart_color,
photoDataUrl: row.photo_data_url,
createdAt: row.created_at,
latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null,
latestRecordedOn: row.latest_recorded_on,
});
const birdSelectFields = `
birds.id,
birds.name,
birds.tag_id,
birds.species,
birds.date_of_birth::text,
birds.gotcha_day::text,
birds.chart_color,
birds.photo_data_url,
birds.created_at,
latest.weight_grams AS latest_weight_grams,
latest.recorded_on::text AS latest_recorded_on
`;
const normalizeWeight = (row: WeightRow) => ({
id: row.id,
birdId: row.bird_id,
@@ -159,12 +184,16 @@ const ensureSchema = async () => {
species VARCHAR(120) NOT NULL,
date_of_birth DATE,
gotcha_day DATE,
chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
photo_data_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE birds
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
ADD COLUMN IF NOT EXISTS gotcha_day DATE;
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
ADD COLUMN IF NOT EXISTS photo_data_url TEXT;
CREATE TABLE IF NOT EXISTS weight_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -197,15 +226,7 @@ const ensureSchema = async () => {
const getBirdById = async (birdId: string) => {
const result = await pool.query<BirdRow>(
`SELECT
birds.id,
birds.name,
birds.tag_id,
birds.species,
birds.date_of_birth::text,
birds.gotcha_day::text,
birds.created_at,
latest.weight_grams AS latest_weight_grams,
latest.recorded_on::text AS latest_recorded_on
${birdSelectFields}
FROM birds
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
@@ -229,15 +250,7 @@ app.get('/api/birds', async (_req: Request, res: Response, next: NextFunction) =
try {
const result = await pool.query<BirdRow>(`
SELECT
birds.id,
birds.name,
birds.tag_id,
birds.species,
birds.date_of_birth::text,
birds.gotcha_day::text,
birds.created_at,
latest.weight_grams AS latest_weight_grams,
latest.recorded_on::text AS latest_recorded_on
${birdSelectFields}
FROM birds
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
@@ -265,15 +278,17 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) =
try {
const result = await pool.query<BirdRow>(
`INSERT INTO birds (name, tag_id, species, date_of_birth, gotcha_day)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
`INSERT INTO birds (name, tag_id, species, date_of_birth, gotcha_day, chart_color, photo_data_url)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
[
parsed.data.name,
parsed.data.tagId,
parsed.data.species,
emptyToNull(parsed.data.dateOfBirth),
emptyToNull(parsed.data.gotchaDay),
parsed.data.chartColor ?? '#cb3a35',
emptyToNull(parsed.data.photoDataUrl),
],
);
@@ -288,6 +303,68 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) =
}
});
app.put('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid bird payload', details: parsed.error.flatten() });
return;
}
try {
const result = await pool.query<BirdRow>(
`UPDATE birds
SET name = $2,
tag_id = $3,
species = $4,
date_of_birth = $5,
gotcha_day = $6,
chart_color = $7,
photo_data_url = $8
WHERE id = $1
RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, created_at,
(
SELECT weight_grams::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_weight_grams,
(
SELECT recorded_on::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_recorded_on`,
[
req.params.birdId,
parsed.data.name,
parsed.data.tagId,
parsed.data.species,
emptyToNull(parsed.data.dateOfBirth),
emptyToNull(parsed.data.gotchaDay),
parsed.data.chartColor ?? '#cb3a35',
emptyToNull(parsed.data.photoDataUrl),
],
);
if (!result.rowCount) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
res.json({ bird: normalizeBird(result.rows[0]) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That tag ID is already in use.' });
return;
}
next(error);
}
});
app.delete('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await pool.query<{ id: string }>('DELETE FROM birds WHERE id = $1 RETURNING id', [req.params.birdId]);
-1
View File
@@ -5,7 +5,6 @@ RUN npm install
COPY tsconfig*.json ./
COPY vite.config.ts ./
COPY index.html ./
COPY public ./public
COPY src ./src
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host"]
+432 -76
View File
@@ -7,6 +7,8 @@ type Bird = {
species: string;
dateOfBirth: string | null;
gotchaDay: string | null;
chartColor: string;
photoDataUrl: string | null;
createdAt: string;
latestWeightGrams: number | null;
latestRecordedOn: string | null;
@@ -29,9 +31,40 @@ type VetVisit = {
notes: string | null;
};
type BirdFormState = {
name: string;
tagId: string;
species: string;
dateOfBirth: string;
gotchaDay: string;
chartColor: string;
photoDataUrl: string;
};
type AppPage = 'overview' | 'flock' | 'settings';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
const emptyBirdForm: BirdFormState = {
name: '',
tagId: '',
species: '',
dateOfBirth: '',
gotchaDay: '',
chartColor: '#cb3a35',
photoDataUrl: '',
};
const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right) => left.name.localeCompare(right.name));
const toBirdForm = (bird: Bird): BirdFormState => ({
name: bird.name,
tagId: bird.tagId,
species: bird.species,
dateOfBirth: bird.dateOfBirth ?? '',
gotchaDay: bird.gotchaDay ?? '',
chartColor: bird.chartColor,
photoDataUrl: bird.photoDataUrl ?? '',
});
const formatDate = (value: string | null) => {
if (!value) {
@@ -57,6 +90,45 @@ const formatShortDate = (value: string | null) => {
};
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
const OVERVIEW_WIDTH = 520;
const OVERVIEW_HEIGHT = 220;
const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 };
const readJsonSafely = async <T,>(response: Response): Promise<T | null> => {
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
return null;
}
try {
return (await response.json()) as T;
} catch {
return null;
}
};
const readErrorMessage = async (response: Response, fallback: string) => {
const json = await readJsonSafely<{ error?: string }>(response);
if (json?.error) {
return json.error;
}
const text = await response.text();
const trimmed = text.trim();
if (!trimmed) {
return fallback;
}
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
return fallback;
}
return trimmed;
};
const chartPath = (points: WeightRecord[], width = 520, height = 180) => {
if (!points.length) {
@@ -95,30 +167,44 @@ const chartDots = (points: WeightRecord[], width = 520, height = 180) => {
}));
};
const birdLineStyles = [
{ stroke: '#cb3a35' },
{ stroke: '#238a5a' },
{ stroke: '#2769b3' },
{ stroke: '#f0b63f' },
{ stroke: '#2f8f98' },
];
const buildOverviewSeries = (points: WeightRecord[], minWeight: number, maxWeight: number, startDate: Date, endDate: Date) => {
const innerWidth = OVERVIEW_WIDTH - OVERVIEW_PADDING.left - OVERVIEW_PADDING.right;
const innerHeight = OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom;
const weightSpread = Math.max(maxWeight - minWeight, 1);
const startMs = startDate.getTime();
const endMs = endDate.getTime();
const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
return points.map((point) => {
const pointTime = parseDateValue(point.recordedOn).getTime();
const x = OVERVIEW_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth;
const y = OVERVIEW_PADDING.top + (1 - (point.weightGrams - minWeight) / weightSpread) * innerHeight;
return {
id: point.id,
x,
y,
label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`,
};
});
};
const toOverviewPath = (points: { x: number; y: number }[]) =>
points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' ');
function App() {
const [activePage, setActivePage] = useState<AppPage>('overview');
const [birds, setBirds] = useState<Bird[]>([]);
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
const [editingBirdId, setEditingBirdId] = 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 [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
const [birdPhotoName, setBirdPhotoName] = useState('');
const [savingBird, setSavingBird] = useState(false);
const [weightForm, setWeightForm] = useState({
weightGrams: '',
recordedOn: new Date().toISOString().slice(0, 10),
@@ -143,6 +229,10 @@ function App() {
() => birds.find((bird) => bird.id === selectedBirdId) ?? birds[0] ?? null,
[birds, selectedBirdId],
);
const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId],
);
const totalWeightEntries = useMemo(
() => Object.values(allBirdWeights).reduce((total, entries) => total + entries.length, 0),
@@ -172,12 +262,66 @@ function App() {
: `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`;
}, [weights]);
const overviewChart = useMemo(() => {
const plottedBirds = birds
.map((bird) => ({ bird, weights: allBirdWeights[bird.id] ?? [] }))
.filter((entry) => entry.weights.length > 0);
const endDate = new Date();
endDate.setHours(0, 0, 0, 0);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 29);
if (!plottedBirds.length) {
return {
plottedBirds,
series: [],
xTicks: [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left },
{ label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right },
],
yTicks: [],
};
}
const allWeights = plottedBirds.flatMap((entry) => entry.weights.map((weight) => weight.weightGrams));
const rawMinWeight = Math.min(...allWeights);
const rawMaxWeight = Math.max(...allWeights);
const weightPadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
const minWeight = Math.max(0, rawMinWeight - weightPadding);
const maxWeight = rawMaxWeight + weightPadding;
const midWeight = minWeight + (maxWeight - minWeight) / 2;
return {
plottedBirds,
series: plottedBirds.map(({ bird, weights: birdWeights }) => ({
bird,
points: buildOverviewSeries(birdWeights, minWeight, maxWeight, startDate, endDate),
})),
xTicks: [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left },
{ label: formatShortDate(new Date((startDate.getTime() + endDate.getTime()) / 2).toISOString().slice(0, 10)), x: OVERVIEW_WIDTH / 2 },
{ label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right },
],
yTicks: [
{ label: `${maxWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top },
{ label: `${midWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top + (OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom) / 2 },
{ label: `${minWeight.toFixed(0)} g`, y: OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom },
],
};
}, [allBirdWeights, birds]);
useEffect(() => {
const loadBirds = async () => {
try {
setLoading(true);
const response = await fetch(`${apiBaseUrl}/birds`);
const data = await response.json();
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to load flock members.'));
}
const data = (await readJsonSafely<{ birds?: Bird[] }>(response)) ?? {};
const nextBirds = data.birds ?? [];
setBirds(nextBirds);
@@ -205,8 +349,13 @@ function App() {
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();
if (!weightsResponse.ok || !visitsResponse.ok) {
throw new Error('Unable to load flock member details.');
}
const weightsData = (await readJsonSafely<{ weights?: WeightRecord[] }>(weightsResponse)) ?? {};
const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {};
setWeights(weightsData.weights ?? []);
setVetVisits(visitsData.vetVisits ?? []);
@@ -229,7 +378,12 @@ function App() {
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();
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to load overview weights.'));
}
const data = (await readJsonSafely<{ weights?: WeightRecord[] }>(response)) ?? {};
return [bird.id, (data.weights ?? []) as WeightRecord[]] as const;
}),
);
@@ -243,35 +397,117 @@ function App() {
void loadAllBirdWeights();
}, [birds]);
useEffect(() => {
if (!editingBirdId) {
return;
}
if (!editingBird) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
return;
}
setBirdForm(toBirdForm(editingBird));
setBirdPhotoName('');
}, [editingBird, editingBirdId]);
const startCreateBird = () => {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
setError('');
setActivePage('settings');
};
const startEditBird = (bird: Bird) => {
setSelectedBirdId(bird.id);
setEditingBirdId(bird.id);
setBirdForm(toBirdForm(bird));
setBirdPhotoName('');
setError('');
setActivePage('settings');
};
const handleBirdPhotoChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (file.size > 900_000) {
setError('Photo is too large. Please choose an image under 900 KB.');
event.target.value = '';
return;
}
try {
const photoDataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(new Error('Unable to read that photo.'));
reader.readAsDataURL(file);
});
setBirdForm((current) => ({ ...current, photoDataUrl }));
setBirdPhotoName(file.name);
setError('');
} catch (photoError) {
setError(photoError instanceof Error ? photoError.message : 'Unable to read that photo.');
} finally {
event.target.value = '';
}
};
const handleRemovePhoto = () => {
setBirdForm((current) => ({ ...current, photoDataUrl: '' }));
setBirdPhotoName('');
};
const handleBirdSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
setSavingBird(true);
const isEditing = Boolean(editingBirdId);
const endpoint = isEditing ? `${apiBaseUrl}/birds/${editingBirdId}` : `${apiBaseUrl}/birds`;
const method = isEditing ? 'PUT' : 'POST';
try {
const response = await fetch(`${apiBaseUrl}/birds`, {
method: 'POST',
const response = await fetch(endpoint, {
method,
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.');
throw new Error(await readErrorMessage(response, `Unable to ${isEditing ? 'update' : '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: '',
const data = await readJsonSafely<{ bird: Bird }>(response);
if (!data?.bird) {
throw new Error(`Unable to ${isEditing ? 'update' : 'create'} flock member.`);
}
const savedBird = data.bird as Bird;
setBirds((current) => {
if (isEditing) {
return sortBirdsByName(current.map((bird) => (bird.id === savedBird.id ? savedBird : bird)));
}
return sortBirdsByName([...current, savedBird]);
});
setActivePage('flock');
setSelectedBirdId(savedBird.id);
setEditingBirdId(savedBird.id);
setBirdForm(toBirdForm(savedBird));
setBirdPhotoName('');
setActivePage(isEditing ? 'settings' : 'flock');
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to create flock member.');
setError(submitError instanceof Error ? submitError.message : 'Unable to save flock member.');
} finally {
setSavingBird(false);
}
};
@@ -296,11 +532,13 @@ function App() {
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error ?? 'Unable to save weight.');
throw new Error(await readErrorMessage(response, 'Unable to save weight.'));
}
const data = await response.json();
const data = await readJsonSafely<{ weight: WeightRecord }>(response);
if (!data?.weight) {
throw new Error('Unable to save weight.');
}
const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn));
setWeights(nextWeights);
@@ -346,11 +584,13 @@ function App() {
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error ?? 'Unable to save vet visit.');
throw new Error(await readErrorMessage(response, 'Unable to save vet visit.'));
}
const data = await response.json();
const data = await readJsonSafely<{ vetVisit: VetVisit }>(response);
if (!data?.vetVisit) {
throw new Error('Unable to save vet visit.');
}
setVetVisits((current) =>
[data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)),
);
@@ -387,8 +627,7 @@ function App() {
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error ?? 'Unable to remove flock member.');
throw new Error(await readErrorMessage(response, 'Unable to remove flock member.'));
}
const nextBirds = birds.filter((bird) => bird.id !== selectedBird.id);
@@ -401,6 +640,12 @@ function App() {
setSelectedBirdId(nextBirds[0]?.id ?? '');
setWeights([]);
setVetVisits([]);
if (editingBirdId === selectedBird.id) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
}
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : 'Unable to remove flock member.');
} finally {
@@ -435,10 +680,11 @@ function App() {
return (
<main className="app-shell">
<section className="hero-card">
<aside className="side-nav panel">
<div>
<p className="eyebrow">Bird health tracker</p>
<h1>FlockPal dashboard</h1>
<h2>FlockPal</h2>
</div>
<div className="page-tabs" role="tablist" aria-label="Main navigation">
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
Overview
@@ -450,6 +696,13 @@ function App() {
Settings
</button>
</div>
</aside>
<section className="content-shell">
<section className="hero-card">
<div>
<p className="eyebrow">Dashboard</p>
<h1>FlockPal dashboard</h1>
</div>
<div className="hero-stats">
<article>
@@ -482,42 +735,54 @@ function App() {
<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 (
{overviewChart.yTicks.map((tick) => (
<g key={tick.label}>
<line
x1={OVERVIEW_PADDING.left}
y1={tick.y}
x2={OVERVIEW_WIDTH - OVERVIEW_PADDING.right}
y2={tick.y}
className="chart-grid-line"
/>
<text x={OVERVIEW_PADDING.left - 10} y={tick.y + 4} textAnchor="end" className="chart-axis-label">
{tick.label}
</text>
</g>
))}
<line
x1={OVERVIEW_PADDING.left}
y1={OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom}
x2={OVERVIEW_WIDTH - OVERVIEW_PADDING.right}
y2={OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom}
className="chart-axis-line"
/>
{overviewChart.xTicks.map((tick) => (
<text key={tick.label} x={tick.x} y={OVERVIEW_HEIGHT - 10} textAnchor="middle" className="chart-axis-label">
{tick.label}
</text>
))}
{overviewChart.series.map(({ bird, points }) => (
<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>
{points.length > 1 ? (
<path d={toOverviewPath(points)} fill="none" stroke={bird.chartColor} strokeWidth="3.5" strokeLinecap="round" />
) : null}
{points.map((point) => (
<circle key={point.id} cx={point.x} cy={point.y} r="4.5" fill={bird.chartColor}>
<title>{`${bird.name}: ${point.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] ?? [];
{overviewChart.plottedBirds.map(({ bird }) => {
return (
<article key={bird.id} className="legend-card">
<span className="legend-swatch" style={{ background: style.stroke }} />
<span className="legend-swatch" style={{ background: bird.chartColor }} />
<div>
<strong>{bird.name}</strong>
<small>
{bird.species} {birdWeights.length ? `${birdWeights.length} entries` : 'No entries yet'}
</small>
</div>
</article>
);
@@ -575,7 +840,6 @@ function App() {
) : null}
{activePage === 'flock' ? (
<>
<section className="dashboard-grid">
<aside className="panel bird-list-panel">
<div className="panel-header">
@@ -583,6 +847,9 @@ function App() {
<p className="eyebrow">Flock</p>
<h2>Flock members</h2>
</div>
<button className="secondary-button" onClick={startCreateBird} type="button">
Add bird
</button>
</div>
<div className="bird-list">
{birds.map((bird) => (
@@ -592,10 +859,21 @@ function App() {
onClick={() => setSelectedBirdId(bird.id)}
type="button"
>
<div className="bird-card-header">
{bird.photoDataUrl ? (
<img className="bird-avatar" src={bird.photoDataUrl} alt={`${bird.name}`} />
) : (
<div className="bird-avatar placeholder-avatar" aria-hidden="true">
{bird.name.slice(0, 1).toUpperCase()}
</div>
)}
<div>
<span>{bird.name}</span>
<small>
{bird.species} {bird.tagId}
</small>
</div>
</div>
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
</button>
))}
@@ -609,14 +887,37 @@ function App() {
<h2>{selectedBird ? selectedBird.name : 'Choose a flock member'}</h2>
</div>
{selectedBird ? (
<div className="button-row">
<button className="secondary-button" onClick={() => startEditBird(selectedBird)} type="button">
Edit details
</button>
<button className="danger-button" onClick={handleRemoveBird} type="button" disabled={deletingBird}>
{deletingBird ? 'Removing...' : 'Remove from flock'}
</button>
</div>
) : null}
</div>
{selectedBird ? (
<>
<section className="profile-hero">
{selectedBird.photoDataUrl ? (
<img className="profile-photo" src={selectedBird.photoDataUrl} alt={`${selectedBird.name}`} />
) : (
<div className="profile-photo placeholder-avatar" aria-hidden="true">
{selectedBird.name.slice(0, 1).toUpperCase()}
</div>
)}
<div className="profile-copy">
<p className="eyebrow">Profile</p>
<h3>{selectedBird.name}</h3>
<p className="muted">
{selectedBird.species} Band {selectedBird.tagId}
</p>
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
</div>
</section>
<div className="detail-grid">
<article className="detail-card">
<span>Name</span>
@@ -657,10 +958,8 @@ function App() {
<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" />
<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" />
@@ -781,7 +1080,6 @@ function App() {
)}
</section>
</section>
</>
) : null}
{activePage === 'settings' ? (
@@ -790,9 +1088,33 @@ function App() {
<div className="panel-header">
<div>
<p className="eyebrow">Flock setup</p>
<h2>Add a flock member</h2>
<h2>{editingBird ? `Edit ${editingBird.name}` : 'Add a flock member'}</h2>
</div>
<div className="button-row">
{selectedBird ? (
<button className="secondary-button" onClick={() => startEditBird(selectedBird)} type="button">
Edit selected
</button>
) : null}
<button className="secondary-button" onClick={startCreateBird} type="button">
New bird
</button>
</div>
</div>
<div className="picker-list">
{birds.map((bird) => (
<button
key={bird.id}
className={`picker-chip ${editingBirdId === bird.id ? 'active' : ''}`}
onClick={() => startEditBird(bird)}
type="button"
>
{bird.name}
</button>
))}
</div>
<form className="form-panel" onSubmit={handleBirdSubmit}>
<label>
Bird name
@@ -822,8 +1144,41 @@ function App() {
onChange={(event) => setBirdForm({ ...birdForm, gotchaDay: event.target.value })}
/>
</label>
<button className="primary-button" type="submit">
Save flock member
<label>
Graph color
<input type="color" value={birdForm.chartColor} onChange={(event) => setBirdForm({ ...birdForm, chartColor: event.target.value })} />
</label>
<div className="color-preview-card">
<span className="legend-swatch large-swatch" style={{ background: birdForm.chartColor }} />
<p className="muted">This color will follow this bird across the overview graph and its individual weight trend.</p>
</div>
<div className="photo-editor">
<div className="photo-preview-shell">
{birdForm.photoDataUrl ? (
<img className="profile-photo" src={birdForm.photoDataUrl} alt="Bird preview" />
) : (
<div className="profile-photo placeholder-avatar" aria-hidden="true">
{(birdForm.name || 'B').slice(0, 1).toUpperCase()}
</div>
)}
</div>
<div className="photo-copy">
<label className="file-picker">
Photo
<input accept="image/png,image/jpeg,image/jpg,image/webp,image/gif" onChange={handleBirdPhotoChange} type="file" />
</label>
<p className="muted">{birdPhotoName || (birdForm.photoDataUrl ? 'Current photo ready to save.' : 'Optional. Great for quick identification.')}</p>
{birdForm.photoDataUrl ? (
<button className="secondary-button" onClick={handleRemovePhoto} type="button">
Remove photo
</button>
) : null}
</div>
</div>
<button className="primary-button" type="submit" disabled={savingBird}>
{savingBird ? 'Saving...' : editingBird ? 'Save changes' : 'Save flock member'}
</button>
</form>
</article>
@@ -872,6 +1227,7 @@ function App() {
</article>
</section>
) : null}
</section>
</main>
);
}
+204 -45
View File
@@ -11,10 +11,12 @@
--accent-teal: #2f8f98;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(203, 58, 53, 0.24), transparent 24%),
radial-gradient(circle at 88% 22%, rgba(39, 105, 179, 0.22), transparent 20%),
radial-gradient(circle at bottom right, rgba(35, 138, 90, 0.24), transparent 28%),
linear-gradient(180deg, #fff3e8 0%, #f6ead7 55%, #eef7f2 100%);
radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.28), transparent 22%),
radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.26), transparent 20%),
radial-gradient(circle at 24% 84%, rgba(221, 179, 78, 0.2), transparent 22%),
radial-gradient(circle at 86% 78%, rgba(43, 118, 92, 0.24), transparent 24%),
radial-gradient(circle at 62% 54%, rgba(48, 114, 160, 0.14), transparent 16%),
linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%);
font-family: "Avenir Next", "Segoe UI", sans-serif;
line-height: 1.5;
font-weight: 400;
@@ -34,11 +36,27 @@ body,
body {
min-height: 100vh;
color: var(--ink);
position: relative;
isolation: isolate;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.32;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 720 720'%3E%3Crect width='720' height='720' fill='none'/%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'%3E%3Cg stroke='%236db7b4' transform='translate(72 88) rotate(-18)'%3E%3Cpath d='M0 0 L-24 -42'/%3E%3Cpath d='M0 0 L12 -50'/%3E%3Cpath d='M0 0 L44 -18'/%3E%3C/g%3E%3Cg stroke='%2396c489' transform='translate(248 70) rotate(14)'%3E%3Cpath d='M0 0 L-28 -38'/%3E%3Cpath d='M0 0 L4 -52'/%3E%3Cpath d='M0 0 L40 -30'/%3E%3C/g%3E%3Cg stroke='%23f5a347' transform='translate(430 92) rotate(-10)'%3E%3Cpath d='M0 0 L-30 -32'/%3E%3Cpath d='M0 0 L10 -50'/%3E%3Cpath d='M0 0 L46 -14'/%3E%3C/g%3E%3Cg stroke='%23888690' transform='translate(604 64) rotate(20)'%3E%3Cpath d='M0 0 L-26 -36'/%3E%3Cpath d='M0 0 L2 -54'/%3E%3Cpath d='M0 0 L36 -34'/%3E%3C/g%3E%3Cg stroke='%23b5c948' transform='translate(126 220) rotate(8)'%3E%3Cpath d='M0 0 L-22 -46'/%3E%3Cpath d='M0 0 L6 -54'/%3E%3Cpath d='M0 0 L42 -26'/%3E%3C/g%3E%3Cg stroke='%236db7b4' transform='translate(330 246) rotate(-24)'%3E%3Cpath d='M0 0 L-28 -34'/%3E%3Cpath d='M0 0 L0 -56'/%3E%3Cpath d='M0 0 L38 -30'/%3E%3C/g%3E%3Cg stroke='%23888690' transform='translate(520 224) rotate(18)'%3E%3Cpath d='M0 0 L-20 -46'/%3E%3Cpath d='M0 0 L6 -52'/%3E%3Cpath d='M0 0 L34 -36'/%3E%3C/g%3E%3Cg stroke='%2396c489' transform='translate(654 220) rotate(-14)'%3E%3Cpath d='M0 0 L-34 -26'/%3E%3Cpath d='M0 0 L-4 -54'/%3E%3Cpath d='M0 0 L34 -38'/%3E%3C/g%3E%3Cg stroke='%23f5a347' transform='translate(62 402) rotate(22)'%3E%3Cpath d='M0 0 L-30 -28'/%3E%3Cpath d='M0 0 L2 -54'/%3E%3Cpath d='M0 0 L38 -26'/%3E%3C/g%3E%3Cg stroke='%23888690' transform='translate(250 386) rotate(-18)'%3E%3Cpath d='M0 0 L-24 -42'/%3E%3Cpath d='M0 0 L10 -50'/%3E%3Cpath d='M0 0 L40 -22'/%3E%3C/g%3E%3Cg stroke='%2396c489' transform='translate(438 410) rotate(10)'%3E%3Cpath d='M0 0 L-28 -38'/%3E%3Cpath d='M0 0 L4 -54'/%3E%3Cpath d='M0 0 L44 -24'/%3E%3C/g%3E%3Cg stroke='%236db7b4' transform='translate(610 392) rotate(-20)'%3E%3Cpath d='M0 0 L-26 -34'/%3E%3Cpath d='M0 0 L8 -54'/%3E%3Cpath d='M0 0 L40 -18'/%3E%3C/g%3E%3Cg stroke='%23b5c948' transform='translate(122 564) rotate(16)'%3E%3Cpath d='M0 0 L-30 -30'/%3E%3Cpath d='M0 0 L2 -56'/%3E%3Cpath d='M0 0 L44 -20'/%3E%3C/g%3E%3Cg stroke='%23888690' transform='translate(330 576) rotate(-12)'%3E%3Cpath d='M0 0 L-26 -40'/%3E%3Cpath d='M0 0 L10 -52'/%3E%3Cpath d='M0 0 L40 -28'/%3E%3C/g%3E%3Cg stroke='%23f5a347' transform='translate(520 566) rotate(24)'%3E%3Cpath d='M0 0 L-28 -34'/%3E%3Cpath d='M0 0 L2 -54'/%3E%3Cpath d='M0 0 L42 -24'/%3E%3C/g%3E%3Cg stroke='%2396c489' transform='translate(678 586) rotate(-18)'%3E%3Cpath d='M0 0 L-26 -38'/%3E%3Cpath d='M0 0 L6 -54'/%3E%3Cpath d='M0 0 L36 -28'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
background-position: center center;
background-repeat: repeat;
background-size: 360px 360px;
}
button,
input,
textarea {
textarea,
select {
font: inherit;
}
@@ -51,7 +69,8 @@ button:disabled {
}
input,
textarea {
textarea,
select {
width: 100%;
margin-top: 0.5rem;
border: 1px solid rgba(39, 105, 179, 0.16);
@@ -62,7 +81,8 @@ textarea {
}
input:focus,
textarea:focus {
textarea:focus,
select:focus {
outline: none;
border-color: rgba(39, 105, 179, 0.62);
box-shadow: 0 0 0 4px rgba(39, 105, 179, 0.14);
@@ -73,11 +93,25 @@ textarea {
}
.app-shell {
max-width: 1280px;
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.content-shell {
display: grid;
gap: 1.5rem;
}
.side-nav {
position: sticky;
top: 2rem;
display: grid;
gap: 1.25rem;
}
.stack-grid {
@@ -107,22 +141,6 @@ textarea {
overflow: hidden;
}
.hero-card::after {
content: "";
position: absolute;
inset: auto -8% -42% auto;
width: 280px;
height: 280px;
border-radius: 50%;
background:
radial-gradient(circle at 35% 35%, rgba(240, 182, 63, 0.62), transparent 26%),
radial-gradient(circle at 58% 44%, rgba(35, 138, 90, 0.52), transparent 32%),
radial-gradient(circle at 72% 62%, rgba(39, 105, 179, 0.5), transparent 30%),
radial-gradient(circle at 42% 74%, rgba(203, 58, 53, 0.52), transparent 32%);
pointer-events: none;
opacity: 0.75;
}
.hero-card h1,
.panel h2 {
margin: 0;
@@ -135,21 +153,18 @@ textarea {
}
.page-tabs {
display: flex;
display: grid;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1.25rem;
position: relative;
z-index: 1;
}
.page-tab {
border: 1px solid rgba(39, 105, 179, 0.14);
border-radius: 999px;
padding: 0.7rem 1.1rem;
border-radius: 18px;
padding: 0.95rem 1rem;
background: rgba(255, 255, 255, 0.54);
color: var(--ink);
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
text-align: left;
}
.page-tab.active {
@@ -265,11 +280,49 @@ textarea {
margin-bottom: 1rem;
}
.button-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.bird-list {
display: grid;
gap: 0.9rem;
}
.bird-card-header {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.85rem;
align-items: center;
}
.bird-avatar,
.profile-photo {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 18px;
border: 1px solid rgba(39, 105, 179, 0.16);
background: rgba(255, 255, 255, 0.86);
}
.profile-photo {
width: 112px;
height: 112px;
border-radius: 28px;
}
.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);
font-size: 1.6rem;
font-weight: 700;
}
.bird-card {
width: 100%;
text-align: left;
@@ -311,17 +364,8 @@ textarea {
margin-bottom: 1rem;
}
.chart-card::after {
content: "";
position: absolute;
top: 1rem;
right: 1rem;
width: 110px;
height: 110px;
border-radius: 50%;
background:
radial-gradient(circle, rgba(39, 105, 179, 0.14), transparent 58%);
pointer-events: none;
.overview-chart-card::before {
display: none;
}
.overview-chart-card {
@@ -334,6 +378,22 @@ textarea {
min-height: 180px;
}
.chart-grid-line {
stroke: rgba(39, 105, 179, 0.16);
stroke-width: 1;
stroke-dasharray: 4 6;
}
.chart-axis-line {
stroke: rgba(31, 42, 42, 0.24);
stroke-width: 1.2;
}
.chart-axis-label {
fill: var(--muted);
font-size: 11px;
}
.chart-footer,
.recent-list,
.detail-grid,
@@ -361,6 +421,27 @@ textarea {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.profile-hero {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
align-items: center;
padding: 1rem;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
border: 1px solid rgba(39, 105, 179, 0.1);
}
.profile-copy {
display: grid;
gap: 0.3rem;
}
.profile-copy h3 {
margin: 0;
font-size: 1.6rem;
}
.inline-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -399,6 +480,11 @@ textarea {
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.65);
}
.large-swatch {
width: 24px;
height: 24px;
}
.detail-card span,
.summary-card span {
color: var(--muted);
@@ -443,6 +529,19 @@ label {
background: linear-gradient(135deg, #b7312d, #1f5e9f);
}
.secondary-button {
border: 1px solid rgba(39, 105, 179, 0.18);
border-radius: 18px;
padding: 0.95rem 1.2rem;
color: var(--ink);
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 10px 22px rgba(39, 105, 179, 0.1);
}
.secondary-button:hover {
background: rgba(255, 255, 255, 0.92);
}
.danger-button {
border: 1px solid rgba(171, 44, 44, 0.18);
border-radius: 18px;
@@ -470,13 +569,73 @@ label {
color: #922728;
}
.picker-list {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.color-preview-card {
display: flex;
gap: 0.9rem;
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);
}
.picker-chip {
border: 1px solid rgba(39, 105, 179, 0.16);
border-radius: 999px;
padding: 0.65rem 0.95rem;
background: rgba(255, 255, 255, 0.68);
color: var(--ink);
}
.picker-chip.active {
border-color: transparent;
background: linear-gradient(135deg, rgba(203, 58, 53, 0.94), rgba(39, 105, 179, 0.94));
color: #fffdf9;
}
.photo-editor {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
align-items: center;
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);
}
.photo-preview-shell {
display: flex;
align-items: center;
justify-content: center;
}
.photo-copy {
display: grid;
gap: 0.75rem;
}
.file-picker input[type="file"] {
margin-top: 0.75rem;
padding: 0.75rem;
}
@media (max-width: 980px) {
.app-shell,
.hero-card,
.dashboard-grid,
.forms-grid,
.hero-stats,
.chart-footer,
.inline-form {
.inline-form,
.profile-hero,
.photo-editor {
grid-template-columns: 1fr;
}
@@ -484,7 +643,7 @@ label {
padding: 1rem;
}
.page-tabs {
margin-top: 1rem;
.side-nav {
position: static;
}
}