Initial app
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
Generated
+1860
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
Generated
+1721
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user