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
+6
View File
@@ -0,0 +1,6 @@
POSTGRES_DB=flockpal
POSTGRES_USER=flockpal
POSTGRES_PASSWORD=change_me_for_production
FRONTEND_URL=http://localhost:3000
VITE_API_BASE_URL=http://localhost:5000/api
NODE_ENV=development
+7 -151
View File
@@ -1,152 +1,8 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
node_modules
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
backend/node_modules
frontend/node_modules
backend/dist
frontend/dist
data/
.DS_Store
+33 -1
View File
@@ -1,3 +1,35 @@
# FlockPal
This is an app for flockpal; a tool to help manage the health of your flock.
FlockPal is a Dockerized TypeScript app for tracking flock health with a clean, modern, and casual UI.
## Current scope
- Bird profiles with name, tag ID, and species
- Daily weight recordings
- 30-day weight graph
- Postgres-backed storage
- React frontend and Express backend
- Security-minded defaults like Helmet, CORS allow-listing, rate limiting, and input validation
## Planned next steps
- Vet visit history
- Medication and care reminders
- Accounts, authorization, and role-based rescue access
- Billing and plan management for paid organizations with a free rescue tier
## Run locally
1. Copy `.env.example` to `.env` if you want custom settings.
2. Start the stack:
```bash
docker compose up --build
```
3. Open `http://localhost:3000`.
4. The API health check is available at `http://localhost:5000/api/health`.
## Notes for monetization and security
This starter keeps the data model and deployment simple, but it is intentionally shaped so we can add authentication, organization scoping, audit trails, reminders, and Stripe-style billing later without redesigning the whole app.
+8
View File
@@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY tsconfig.json ./
COPY src ./src
EXPOSE 5000
CMD ["npm", "run", "dev"]
+1860
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "flockpal-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --import tsx src/app.ts",
"build": "tsc",
"start": "node dist/app.js"
},
"dependencies": {
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.21.2",
"express-rate-limit": "7.5.0",
"helmet": "8.1.0",
"morgan": "1.10.0",
"pg": "8.13.1",
"zod": "3.24.1"
},
"devDependencies": {
"@types/cors": "2.8.17",
"@types/express": "4.17.21",
"@types/morgan": "1.9.9",
"@types/node": "22.10.2",
"@types/pg": "8.11.10",
"tsx": "4.19.2",
"typescript": "5.7.2"
}
}
+424
View File
@@ -0,0 +1,424 @@
import cors from 'cors';
import dotenv from 'dotenv';
import express, { type NextFunction, type Request, type Response } from 'express';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import morgan from 'morgan';
import pg from 'pg';
import { z } from 'zod';
dotenv.config();
const app = express();
const port = Number(process.env.PORT ?? 5000);
const { Pool } = pg;
const pool = new Pool({
host: process.env.POSTGRES_HOST ?? 'localhost',
port: Number(process.env.POSTGRES_PORT ?? 5432),
database: process.env.POSTGRES_DB ?? 'flockpal',
user: process.env.POSTGRES_USER ?? 'flockpal',
password: process.env.POSTGRES_PASSWORD ?? 'flockpal_dev_password',
});
const defaultAllowedOrigins = [
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://localhost:5173',
'http://127.0.0.1:5173',
];
const allowedOrigins = Array.from(
new Set(
[process.env.FRONTEND_URL, process.env.FRONTEND_URLS]
.filter(Boolean)
.flatMap((value) => (value ?? '').split(','))
.map((origin) => origin.trim())
.filter(Boolean)
.concat(defaultAllowedOrigins),
),
);
const dateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const birdSchema = z.object({
name: z.string().trim().min(1).max(120),
tagId: z.string().trim().min(1).max(80),
species: z.string().trim().min(1).max(120),
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')),
});
const weightSchema = z.object({
weightGrams: z.coerce.number().positive().max(10000),
recordedOn: dateStringSchema,
notes: z.string().trim().max(280).optional().or(z.literal('')),
});
const vetVisitSchema = z.object({
visitedOn: dateStringSchema,
clinicName: z.string().trim().min(1).max(160),
reason: z.string().trim().min(1).max(160),
notes: z.string().trim().max(1000).optional().or(z.literal('')),
});
type BirdRow = {
id: string;
name: string;
tag_id: string;
species: string;
date_of_birth: string | null;
gotcha_day: string | null;
created_at: string;
latest_weight_grams: string | null;
latest_recorded_on: string | null;
};
type WeightRow = {
id: string;
bird_id: string;
weight_grams: string;
recorded_on: string;
notes: string | null;
};
type VetVisitRow = {
id: string;
bird_id: string;
visited_on: string;
clinic_name: string;
reason: string;
notes: string | null;
};
app.use(helmet({ crossOriginResourcePolicy: false }));
app.use(
cors({
origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
return;
}
callback(new Error('Origin not allowed'));
},
}),
);
app.use(
rateLimit({
windowMs: 15 * 60 * 1000,
limit: 300,
standardHeaders: true,
legacyHeaders: false,
}),
);
app.use(express.json({ limit: '300kb' }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
const emptyToNull = (value?: string) => {
const trimmed = value?.trim() ?? '';
return trimmed ? trimmed : null;
};
const normalizeBird = (row: BirdRow) => ({
id: row.id,
name: row.name,
tagId: row.tag_id,
species: row.species,
dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day,
createdAt: row.created_at,
latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null,
latestRecordedOn: row.latest_recorded_on,
});
const normalizeWeight = (row: WeightRow) => ({
id: row.id,
birdId: row.bird_id,
weightGrams: Number(row.weight_grams),
recordedOn: row.recorded_on,
notes: row.notes,
});
const normalizeVetVisit = (row: VetVisitRow) => ({
id: row.id,
birdId: row.bird_id,
visitedOn: row.visited_on,
clinicName: row.clinic_name,
reason: row.reason,
notes: row.notes,
});
const ensureSchema = async () => {
await pool.query(`
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS birds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(120) NOT NULL,
tag_id VARCHAR(80) NOT NULL UNIQUE,
species VARCHAR(120) NOT NULL,
date_of_birth DATE,
gotcha_day DATE,
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;
CREATE TABLE IF NOT EXISTS weight_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0),
recorded_on DATE NOT NULL,
notes VARCHAR(280),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (bird_id, recorded_on)
);
CREATE TABLE IF NOT EXISTS vet_visits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
visited_on DATE NOT NULL,
clinic_name VARCHAR(160) NOT NULL,
reason VARCHAR(160) NOT NULL,
notes VARCHAR(1000),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_weight_records_bird_recorded_on
ON weight_records (bird_id, recorded_on DESC);
CREATE INDEX IF NOT EXISTS idx_vet_visits_bird_visited_on
ON vet_visits (bird_id, visited_on DESC);
`);
};
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
FROM birds
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE birds.id = $1`,
[birdId],
);
return result.rows[0] ?? null;
};
app.get('/api/health', (_req: Request, res: Response) => {
res.json({ ok: true });
});
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
FROM birds
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
ORDER BY birds.name ASC
`);
res.json({ birds: result.rows.map(normalizeBird) });
} catch (error) {
next(error);
}
});
app.post('/api/birds', 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>(
`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`,
[
parsed.data.name,
parsed.data.tagId,
parsed.data.species,
emptyToNull(parsed.data.dateOfBirth),
emptyToNull(parsed.data.gotchaDay),
],
);
res.status(201).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]);
if (!result.rowCount) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.get('/api/birds/:birdId/weights', async (req: Request, res: Response, next: NextFunction) => {
try {
const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 365);
const result = await pool.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
FROM weight_records
WHERE bird_id = $1
AND recorded_on >= CURRENT_DATE - (($2::int - 1) * INTERVAL '1 day')
ORDER BY recorded_on ASC`,
[req.params.birdId, days],
);
res.json({ weights: result.rows.map(normalizeWeight) });
} catch (error) {
next(error);
}
});
app.post('/api/birds/:birdId/weights', async (req: Request, res: Response, next: NextFunction) => {
const parsed = weightSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid weight payload', details: parsed.error.flatten() });
return;
}
try {
const birdLookup = await pool.query<{ id: string }>('SELECT id FROM birds WHERE id = $1', [req.params.birdId]);
if (!birdLookup.rowCount) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
const result = await pool.query<WeightRow>(
`INSERT INTO weight_records (bird_id, weight_grams, recorded_on, notes)
VALUES ($1, $2, $3, $4)
RETURNING id, bird_id, weight_grams, recorded_on::text, notes`,
[req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes)],
);
res.status(201).json({ weight: normalizeWeight(result.rows[0]) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' });
return;
}
next(error);
}
});
app.get('/api/birds/:birdId/vet-visits', async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await pool.query<VetVisitRow>(
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
FROM vet_visits
WHERE bird_id = $1
ORDER BY visited_on DESC, created_at DESC`,
[req.params.birdId],
);
res.json({ vetVisits: result.rows.map(normalizeVetVisit) });
} catch (error) {
next(error);
}
});
app.post('/api/birds/:birdId/vet-visits', async (req: Request, res: Response, next: NextFunction) => {
const parsed = vetVisitSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid vet visit payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await getBirdById(req.params.birdId);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
const result = await pool.query<VetVisitRow>(
`INSERT INTO vet_visits (bird_id, visited_on, clinic_name, reason, notes)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, bird_id, visited_on::text, clinic_name, reason, notes`,
[
req.params.birdId,
parsed.data.visitedOn,
parsed.data.clinicName,
parsed.data.reason,
emptyToNull(parsed.data.notes),
],
);
res.status(201).json({ vetVisit: normalizeVetVisit(result.rows[0]) });
} catch (error) {
next(error);
}
});
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(error);
res.status(500).json({ error: 'Internal server error' });
});
const start = async () => {
await ensureSchema();
app.listen(port, () => {
console.log(`FlockPal backend listening on port ${port}`);
});
};
start().catch((error) => {
console.error('Failed to start backend', error);
process.exit(1);
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+59
View File
@@ -0,0 +1,59 @@
services:
postgres:
image: postgres:16-alpine
container_name: flockpal-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-flockpal}
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flockpal} -d ${POSTGRES_DB:-flockpal}"]
interval: 10s
timeout: 5s
retries: 10
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: flockpal-backend
environment:
PORT: 5000
NODE_ENV: ${NODE_ENV:-development}
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB:-flockpal}
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
depends_on:
postgres:
condition: service_healthy
ports:
- "5000:5000"
command: >
sh -c "npm install &&
npm run dev"
volumes:
- ./backend:/app
- /app/node_modules
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: flockpal-frontend
environment:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api}
depends_on:
- backend
ports:
- "3000:3000"
command: >
sh -c "npm install &&
npm run dev -- --host"
volumes:
- ./frontend:/app
- /app/node_modules
+11
View File
@@ -0,0 +1,11 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
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"]
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FlockPal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1721
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "flockpal-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.6.3",
"vite": "5.4.10"
}
}
+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;
+490
View File
@@ -0,0 +1,490 @@
: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));
--accent-red: #cb3a35;
--accent-green: #238a5a;
--accent-blue: #2769b3;
--accent-gold: #f0b63f;
--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%);
font-family: "Avenir Next", "Segoe UI", sans-serif;
line-height: 1.5;
font-weight: 400;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
color: var(--ink);
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
input,
textarea {
width: 100%;
margin-top: 0.5rem;
border: 1px solid rgba(39, 105, 179, 0.16);
border-radius: 16px;
padding: 0.9rem 1rem;
background: rgba(255, 254, 250, 0.92);
color: var(--ink);
}
input:focus,
textarea:focus {
outline: none;
border-color: rgba(39, 105, 179, 0.62);
box-shadow: 0 0 0 4px rgba(39, 105, 179, 0.14);
}
textarea {
resize: vertical;
}
.app-shell {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
display: grid;
gap: 1.5rem;
}
.stack-grid {
display: grid;
gap: 1.5rem;
}
.hero-card,
.panel {
background: var(--panel-bg);
border: 1px solid var(--panel-border);
box-shadow: 0 22px 44px rgba(89, 48, 42, 0.13);
backdrop-filter: blur(14px);
}
.hero-card {
border-radius: 32px;
padding: 2rem;
display: grid;
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));
position: relative;
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;
}
.hero-card h1 {
font-size: clamp(2.2rem, 5vw, 4rem);
line-height: 1;
max-width: 12ch;
}
.page-tabs {
display: flex;
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;
background: rgba(255, 255, 255, 0.54);
color: var(--ink);
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.page-tab.active {
background: linear-gradient(135deg, rgba(203, 58, 53, 0.92), rgba(39, 105, 179, 0.92));
color: #fffdf9;
border-color: transparent;
}
.page-tab:hover {
transform: translateY(-1px);
border-color: rgba(35, 138, 90, 0.34);
}
.eyebrow {
margin: 0 0 0.5rem;
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.72rem;
color: var(--accent-red);
}
.lede,
.muted,
.chart-footer span,
.recent-list small,
.bird-card small {
color: var(--muted);
}
.hero-stats,
.dashboard-grid,
.forms-grid {
display: grid;
gap: 1.5rem;
}
.hero-stats {
grid-template-columns: repeat(3, 1fr);
align-self: end;
}
.hero-stats article,
.chart-card,
.recent-list article,
.bird-card {
border-radius: 24px;
background: var(--card-bg);
border: 1px solid rgba(39, 105, 179, 0.12);
}
.hero-stats article {
padding: 1rem;
position: relative;
overflow: hidden;
}
.hero-stats strong {
display: block;
font-size: 1.7rem;
color: var(--accent-green);
}
.hero-stats article::before,
.bird-card::before,
.chart-card::before {
content: "";
display: block;
height: 5px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent-red), var(--accent-gold), var(--accent-green), var(--accent-blue));
}
.hero-stats article::before {
margin-bottom: 0.9rem;
}
.hero-stats article:nth-child(2) strong {
color: var(--accent-blue);
}
.hero-stats article:nth-child(3) strong {
color: var(--accent-red);
}
.dashboard-grid {
grid-template-columns: 320px 1fr;
}
.forms-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-grid {
align-items: start;
}
.flock-member-panel,
.flock-member-sections {
display: grid;
gap: 1.5rem;
}
.panel {
border-radius: 28px;
padding: 1.5rem;
}
.panel-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.bird-list {
display: grid;
gap: 0.9rem;
}
.bird-card {
width: 100%;
text-align: left;
padding: 1rem;
border: 1px solid rgba(95, 121, 77, 0.12);
display: grid;
gap: 0.35rem;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
position: relative;
overflow: hidden;
}
.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;
}
.chart-card {
padding: 1rem;
background:
linear-gradient(180deg, rgba(255, 248, 236, 0.92), rgba(240, 249, 245, 0.88)),
var(--card-bg);
position: relative;
overflow: hidden;
}
.chart-card::before {
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 {
min-height: 260px;
}
.weight-chart {
width: 100%;
height: auto;
min-height: 180px;
}
.chart-footer,
.recent-list,
.detail-grid,
.summary-grid,
.legend-grid,
.inline-form {
display: grid;
gap: 0.85rem;
}
.chart-footer {
grid-template-columns: 1fr auto;
align-items: center;
}
.recent-list article {
padding: 0.9rem 1rem;
display: grid;
gap: 0.25rem;
}
.legend-grid,
.detail-grid,
.summary-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.inline-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.legend-card,
.detail-card,
.summary-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);
display: grid;
gap: 0.35rem;
}
.inset-panel {
padding: 1.25rem;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.74), rgba(241, 248, 244, 0.72));
}
.wide-field {
grid-column: 1 / -1;
}
.legend-card {
grid-template-columns: auto 1fr;
align-items: center;
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.65);
}
.detail-card span,
.summary-card span {
color: var(--muted);
font-size: 0.95rem;
}
.detail-card strong,
.summary-card strong,
.legend-card strong {
font-size: 1.05rem;
}
.vet-visit-card span,
.vet-visit-card small {
color: var(--muted);
}
.empty-card {
opacity: 0.82;
}
.form-panel {
display: grid;
gap: 1rem;
}
label {
display: block;
font-weight: 600;
}
.primary-button {
border: 0;
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);
}
.primary-button:hover {
background: linear-gradient(135deg, #b7312d, #1f5e9f);
}
.danger-button {
border: 1px solid rgba(171, 44, 44, 0.18);
border-radius: 18px;
padding: 0.95rem 1.2rem;
color: #fffaf8;
background: linear-gradient(135deg, #bc3733, #8e2523);
box-shadow: 0 12px 24px rgba(142, 37, 35, 0.18);
}
.danger-button:hover {
background: linear-gradient(135deg, #aa2f2c, #7d201e);
}
.primary-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.error-banner {
margin: 0;
padding: 1rem 1.2rem;
border-radius: 18px;
background: rgba(203, 58, 53, 0.1);
border: 1px solid rgba(203, 58, 53, 0.2);
color: #922728;
}
@media (max-width: 980px) {
.hero-card,
.dashboard-grid,
.forms-grid,
.hero-stats,
.chart-footer,
.inline-form {
grid-template-columns: 1fr;
}
.app-shell {
padding: 1rem;
}
.page-tabs {
margin-top: 1rem;
}
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
},
});