From 43c32a5efc2f8013b3a8a6db72b8d705c742fd54 Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Tue, 14 Apr 2026 23:34:15 -0400 Subject: [PATCH] Added gender --- backend/src/app.ts | 6 ++ backend/src/db/schema.ts | 20 ++++ .../src/repositories/birdRepository.test.ts | 3 + backend/src/repositories/birdRepository.ts | 34 ++++--- backend/src/types.ts | 2 + docs/API_REFERENCE.md | 3 + frontend/src/App.tsx | 93 ++++++++++++++++++- frontend/src/index.css | 92 ++++++++++++++++++ 8 files changed, 237 insertions(+), 16 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 0bf74ca..6e24a7e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -54,6 +54,7 @@ import { import type { AuthContext, BillingPlan, + BirdGender, BirdRow, IntegrationTokenRow, ProviderKey, @@ -116,6 +117,7 @@ const workspaceTypeSchema = z.enum(['standard', 'rescue']); const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']); const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); +const birdGenderSchema = z.enum(['unknown', 'male', 'female']); const workspaceSchema = z.object({ name: z.string().trim().min(1).max(160), @@ -147,6 +149,7 @@ 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), + gender: birdGenderSchema.optional(), dateOfBirth: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')), chartColor: chartColorSchema.optional(), @@ -263,6 +266,7 @@ const normalizeBird = (row: BirdRow) => ({ name: row.name, tagId: row.tag_id, species: row.species, + gender: row.gender, dateOfBirth: row.date_of_birth, gotchaDay: row.gotcha_day, chartColor: row.chart_color, @@ -1076,6 +1080,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o name: parsed.data.name, tagId: parsed.data.tagId, species: parsed.data.species, + gender: (parsed.data.gender ?? 'unknown') as BirdGender, dateOfBirth: emptyToNull(parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), chartColor: parsed.data.chartColor ?? '#cb3a35', @@ -1110,6 +1115,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR name: parsed.data.name, tagId: parsed.data.tagId, species: parsed.data.species, + gender: (parsed.data.gender ?? 'unknown') as BirdGender, dateOfBirth: emptyToNull(parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), chartColor: parsed.data.chartColor ?? '#cb3a35', diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 1a853d6..7aeec1e 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -164,6 +164,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { name VARCHAR(120) NOT NULL, tag_id VARCHAR(80) NOT NULL, species VARCHAR(120) NOT NULL, + gender VARCHAR(16) NOT NULL DEFAULT 'unknown', date_of_birth DATE, gotcha_day DATE, chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35', @@ -175,6 +176,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ALTER TABLE birds ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown', ADD COLUMN IF NOT EXISTS date_of_birth DATE, ADD COLUMN IF NOT EXISTS gotcha_day DATE, ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35', @@ -222,5 +224,23 @@ export const ensureSchema = async (database: DatabaseClient = db) => { CREATE INDEX IF NOT EXISTS idx_vet_visits_bird_visited_on ON vet_visits (bird_id, visited_on DESC); + + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'birds' + AND column_name = 'is_female' + ) THEN + UPDATE birds + SET gender = CASE + WHEN is_female IS TRUE THEN 'female' + WHEN is_female IS FALSE THEN 'male' + ELSE gender + END + WHERE gender = 'unknown'; + END IF; + END $$; `); }; diff --git a/backend/src/repositories/birdRepository.test.ts b/backend/src/repositories/birdRepository.test.ts index 5e8ed69..1085399 100644 --- a/backend/src/repositories/birdRepository.test.ts +++ b/backend/src/repositories/birdRepository.test.ts @@ -24,6 +24,7 @@ test('createBird returns the inserted bird row', async () => { name: 'Kiwi', tag_id: 'A-1', species: 'Cockatiel', + gender: 'female', date_of_birth: null, gotcha_day: null, chart_color: '#cb3a35', @@ -42,6 +43,7 @@ test('createBird returns the inserted bird row', async () => { name: 'Kiwi', tagId: 'A-1', species: 'Cockatiel', + gender: 'female', dateOfBirth: null, gotchaDay: null, chartColor: '#cb3a35', @@ -52,6 +54,7 @@ test('createBird returns the inserted bird row', async () => { assert.equal(bird?.name, 'Kiwi'); assert.equal(bird?.workspace_id, 10); + assert.equal(bird?.gender, 'female'); }); test('listWeightsForBird scopes by bird, workspace, and day window', async () => { diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index aaa8772..ec1b938 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -1,5 +1,5 @@ import { db } from '../db/client.js'; -import type { BirdRow, VetVisitRow, WeightRow } from '../types.js'; +import type { BirdGender, BirdRow, VetVisitRow, WeightRow } from '../types.js'; const birdSelectFields = ` birds.id, @@ -7,6 +7,7 @@ const birdSelectFields = ` birds.name, birds.tag_id, birds.species, + birds.gender, birds.date_of_birth::text, birds.gotcha_day::text, birds.chart_color, @@ -63,6 +64,7 @@ export const createBird = async ({ name, tagId, species, + gender, dateOfBirth, gotchaDay, chartColor, @@ -74,6 +76,7 @@ export const createBird = async ({ name: string; tagId: string; species: string; + gender: BirdGender; dateOfBirth: string | null; gotchaDay: string | null; chartColor: string; @@ -82,10 +85,10 @@ export const createBird = async ({ notifyOnGotchaDay: boolean; }) => { const result = await db.query( - `INSERT INTO birds (workspace_id, name, tag_id, species, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING id, workspace_id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, - [workspaceId, name, tagId, species, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay], + `INSERT INTO birds (workspace_id, name, tag_id, species, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, + [workspaceId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay], ); return result.rows[0] ?? null; @@ -97,6 +100,7 @@ export const updateBird = async ({ name, tagId, species, + gender, dateOfBirth, gotchaDay, chartColor, @@ -109,6 +113,7 @@ export const updateBird = async ({ name: string; tagId: string; species: string; + gender: BirdGender; dateOfBirth: string | null; gotchaDay: string | null; chartColor: string; @@ -121,15 +126,16 @@ export const updateBird = async ({ SET name = $2, tag_id = $3, species = $4, - date_of_birth = $5, - gotcha_day = $6, - chart_color = $7, - photo_data_url = $8, - notify_on_dob = $9, - notify_on_gotcha_day = $10 + gender = $5, + date_of_birth = $6, + gotcha_day = $7, + chart_color = $8, + photo_data_url = $9, + notify_on_dob = $10, + notify_on_gotcha_day = $11 WHERE id = $1 - AND workspace_id = $11 - RETURNING id, workspace_id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at, + AND workspace_id = $12 + RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -144,7 +150,7 @@ export const updateBird = async ({ ORDER BY recorded_on DESC LIMIT 1 ) AS latest_recorded_on`, - [birdId, name, tagId, species, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay, workspaceId], + [birdId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay, workspaceId], ); return result.rows[0] ?? null; diff --git a/backend/src/types.ts b/backend/src/types.ts index 6a595cf..23cca19 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -3,6 +3,7 @@ export type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; export type ProviderKey = 'google' | 'microsoft' | 'apple'; export type IntegrationTokenScope = 'read_only' | 'read_write'; +export type BirdGender = 'unknown' | 'male' | 'female'; export type UserRow = { id: string; @@ -89,6 +90,7 @@ export type BirdRow = { name: string; tag_id: string; species: string; + gender: BirdGender; date_of_birth: string | null; gotcha_day: string | null; chart_color: string; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index cb7193a..ab7d6fb 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -208,6 +208,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac "name": "Kiwi", "tagId": "FP-001", "species": "Cockatiel", + "gender": "female", "dateOfBirth": "2023-05-10", "gotchaDay": "2023-08-21", "chartColor": "#cb3a35", @@ -250,6 +251,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac - Dates use `YYYY-MM-DD` - `workspaceType` is `standard` or `rescue` - member `role` is `owner`, `manager`, `staff`, or `viewer` +- bird `gender` is `unknown`, `male`, or `female` - bird `chartColor` must be a `#RRGGBB` hex color - `photoDataUrl` must be a base64 `data:image/...` URL - `weightGrams` must be a positive number up to `10000` @@ -701,6 +703,7 @@ Request body: "name": "Kiwi", "tagId": "FP-001", "species": "Cockatiel", + "gender": "female", "dateOfBirth": "2023-05-10", "gotchaDay": "2023-08-21", "chartColor": "#cb3a35", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb93ac2..2ae6be3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ type HouseholdBillingPlan = Exclude; type WorkspaceType = 'standard' | 'rescue'; type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; type IntegrationTokenScope = 'read_only' | 'read_write'; +type BirdGender = 'unknown' | 'male' | 'female'; type Bird = { id: string; @@ -14,6 +15,7 @@ type Bird = { name: string; tagId: string; species: string; + gender: BirdGender; dateOfBirth: string | null; gotchaDay: string | null; chartColor: string; @@ -113,6 +115,7 @@ type BirdFormState = { name: string; tagId: string; species: string; + gender: BirdGender; dateOfBirth: string; gotchaDay: string; chartColor: string; @@ -207,6 +210,7 @@ const emptyBirdForm: BirdFormState = { name: '', tagId: '', species: '', + gender: 'unknown', dateOfBirth: '', gotchaDay: '', chartColor: '#cb3a35', @@ -303,6 +307,7 @@ const toBirdForm = (bird: Bird): BirdFormState => ({ name: bird.name, tagId: bird.tagId, species: bird.species, + gender: bird.gender, dateOfBirth: bird.dateOfBirth ?? '', gotchaDay: bird.gotchaDay ?? '', chartColor: bird.chartColor, @@ -334,6 +339,26 @@ const formatShortDate = (value: string | null) => { }).format(new Date(`${value}T00:00:00`)); }; +const getBirdGenderLabel = (bird: Pick) => { + if (bird.gender === 'female') { + return 'Female'; + } + if (bird.gender === 'male') { + return 'Male'; + } + return 'Unknown'; +}; + +const getBirdGenderSymbol = (bird: Pick) => { + if (bird.gender === 'female') { + return '♀'; + } + if (bird.gender === 'male') { + return '♂'; + } + return '?'; +}; + const formatDateTime = (value: string | null) => { if (!value) { return 'Never'; @@ -2376,7 +2401,12 @@ function App() { )}
- {bird.name} + + {bird.name} + + {getBirdGenderSymbol(bird)} + + {bird.species}
@@ -2482,7 +2512,15 @@ function App() { )}

Profile

-

{selectedBird.name}

+

+ {selectedBird.name} + + {getBirdGenderSymbol(selectedBird)} + +

{selectedBird.species} • Band {selectedBird.tagId}

@@ -2511,6 +2549,15 @@ function App() { Species {selectedBird.species} +
+ Gender + + + {getBirdGenderLabel(selectedBird)} + +
Latest weight {formatWeight(selectedBird.latestWeightGrams)} @@ -3206,6 +3253,48 @@ function App() {
Search or select a species so alerts and chart references stay consistent. +
+ Gender +
+ + + +
+ Shown on the bird profile card as a symbol. +