Added gender

This commit is contained in:
blaisadmin
2026-04-14 23:34:15 -04:00
parent 40900a0968
commit 43c32a5efc
8 changed files with 237 additions and 16 deletions
+6
View File
@@ -54,6 +54,7 @@ import {
import type { import type {
AuthContext, AuthContext,
BillingPlan, BillingPlan,
BirdGender,
BirdRow, BirdRow,
IntegrationTokenRow, IntegrationTokenRow,
ProviderKey, ProviderKey,
@@ -116,6 +117,7 @@ const workspaceTypeSchema = z.enum(['standard', 'rescue']);
const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']); const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']);
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']); const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
const workspaceSchema = z.object({ const workspaceSchema = z.object({
name: z.string().trim().min(1).max(160), name: z.string().trim().min(1).max(160),
@@ -147,6 +149,7 @@ const birdSchema = z.object({
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
tagId: z.string().trim().min(1).max(80), tagId: z.string().trim().min(1).max(80),
species: z.string().trim().min(1).max(120), species: z.string().trim().min(1).max(120),
gender: birdGenderSchema.optional(),
dateOfBirth: dateStringSchema.optional().or(z.literal('')), dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')),
chartColor: chartColorSchema.optional(), chartColor: chartColorSchema.optional(),
@@ -263,6 +266,7 @@ const normalizeBird = (row: BirdRow) => ({
name: row.name, name: row.name,
tagId: row.tag_id, tagId: row.tag_id,
species: row.species, species: row.species,
gender: row.gender,
dateOfBirth: row.date_of_birth, dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day, gotchaDay: row.gotcha_day,
chartColor: row.chart_color, chartColor: row.chart_color,
@@ -1076,6 +1080,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
name: parsed.data.name, name: parsed.data.name,
tagId: parsed.data.tagId, tagId: parsed.data.tagId,
species: parsed.data.species, species: parsed.data.species,
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth), dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay), gotchaDay: emptyToNull(parsed.data.gotchaDay),
chartColor: parsed.data.chartColor ?? '#cb3a35', chartColor: parsed.data.chartColor ?? '#cb3a35',
@@ -1110,6 +1115,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
name: parsed.data.name, name: parsed.data.name,
tagId: parsed.data.tagId, tagId: parsed.data.tagId,
species: parsed.data.species, species: parsed.data.species,
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth), dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay), gotchaDay: emptyToNull(parsed.data.gotchaDay),
chartColor: parsed.data.chartColor ?? '#cb3a35', chartColor: parsed.data.chartColor ?? '#cb3a35',
+20
View File
@@ -164,6 +164,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
name VARCHAR(120) NOT NULL, name VARCHAR(120) NOT NULL,
tag_id VARCHAR(80) NOT NULL, tag_id VARCHAR(80) NOT NULL,
species VARCHAR(120) NOT NULL, species VARCHAR(120) NOT NULL,
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
date_of_birth DATE, date_of_birth DATE,
gotcha_day DATE, gotcha_day DATE,
chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35', chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
@@ -175,6 +176,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE birds ALTER TABLE birds
ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1, 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 date_of_birth DATE,
ADD COLUMN IF NOT EXISTS gotcha_day DATE, ADD COLUMN IF NOT EXISTS gotcha_day DATE,
ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35', ADD COLUMN IF NOT EXISTS 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 CREATE INDEX IF NOT EXISTS idx_vet_visits_bird_visited_on
ON vet_visits (bird_id, visited_on DESC); 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 $$;
`); `);
}; };
@@ -24,6 +24,7 @@ test('createBird returns the inserted bird row', async () => {
name: 'Kiwi', name: 'Kiwi',
tag_id: 'A-1', tag_id: 'A-1',
species: 'Cockatiel', species: 'Cockatiel',
gender: 'female',
date_of_birth: null, date_of_birth: null,
gotcha_day: null, gotcha_day: null,
chart_color: '#cb3a35', chart_color: '#cb3a35',
@@ -42,6 +43,7 @@ test('createBird returns the inserted bird row', async () => {
name: 'Kiwi', name: 'Kiwi',
tagId: 'A-1', tagId: 'A-1',
species: 'Cockatiel', species: 'Cockatiel',
gender: 'female',
dateOfBirth: null, dateOfBirth: null,
gotchaDay: null, gotchaDay: null,
chartColor: '#cb3a35', chartColor: '#cb3a35',
@@ -52,6 +54,7 @@ test('createBird returns the inserted bird row', async () => {
assert.equal(bird?.name, 'Kiwi'); assert.equal(bird?.name, 'Kiwi');
assert.equal(bird?.workspace_id, 10); assert.equal(bird?.workspace_id, 10);
assert.equal(bird?.gender, 'female');
}); });
test('listWeightsForBird scopes by bird, workspace, and day window', async () => { test('listWeightsForBird scopes by bird, workspace, and day window', async () => {
+20 -14
View File
@@ -1,5 +1,5 @@
import { db } from '../db/client.js'; 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 = ` const birdSelectFields = `
birds.id, birds.id,
@@ -7,6 +7,7 @@ const birdSelectFields = `
birds.name, birds.name,
birds.tag_id, birds.tag_id,
birds.species, birds.species,
birds.gender,
birds.date_of_birth::text, birds.date_of_birth::text,
birds.gotcha_day::text, birds.gotcha_day::text,
birds.chart_color, birds.chart_color,
@@ -63,6 +64,7 @@ export const createBird = async ({
name, name,
tagId, tagId,
species, species,
gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
chartColor, chartColor,
@@ -74,6 +76,7 @@ export const createBird = async ({
name: string; name: string;
tagId: string; tagId: string;
species: string; species: string;
gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
chartColor: string; chartColor: string;
@@ -82,10 +85,10 @@ export const createBird = async ({
notifyOnGotchaDay: boolean; notifyOnGotchaDay: boolean;
}) => { }) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`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) `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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $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, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, 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, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay], [workspaceId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay],
); );
return result.rows[0] ?? null; return result.rows[0] ?? null;
@@ -97,6 +100,7 @@ export const updateBird = async ({
name, name,
tagId, tagId,
species, species,
gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
chartColor, chartColor,
@@ -109,6 +113,7 @@ export const updateBird = async ({
name: string; name: string;
tagId: string; tagId: string;
species: string; species: string;
gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
chartColor: string; chartColor: string;
@@ -121,15 +126,16 @@ export const updateBird = async ({
SET name = $2, SET name = $2,
tag_id = $3, tag_id = $3,
species = $4, species = $4,
date_of_birth = $5, gender = $5,
gotcha_day = $6, date_of_birth = $6,
chart_color = $7, gotcha_day = $7,
photo_data_url = $8, chart_color = $8,
notify_on_dob = $9, photo_data_url = $9,
notify_on_gotcha_day = $10 notify_on_dob = $10,
notify_on_gotcha_day = $11
WHERE id = $1 WHERE id = $1
AND workspace_id = $11 AND workspace_id = $12
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, 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 SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -144,7 +150,7 @@ export const updateBird = async ({
ORDER BY recorded_on DESC ORDER BY recorded_on DESC
LIMIT 1 LIMIT 1
) AS latest_recorded_on`, ) 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; return result.rows[0] ?? null;
+2
View File
@@ -3,6 +3,7 @@ export type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
export type ProviderKey = 'google' | 'microsoft' | 'apple'; export type ProviderKey = 'google' | 'microsoft' | 'apple';
export type IntegrationTokenScope = 'read_only' | 'read_write'; export type IntegrationTokenScope = 'read_only' | 'read_write';
export type BirdGender = 'unknown' | 'male' | 'female';
export type UserRow = { export type UserRow = {
id: string; id: string;
@@ -89,6 +90,7 @@ export type BirdRow = {
name: string; name: string;
tag_id: string; tag_id: string;
species: string; species: string;
gender: BirdGender;
date_of_birth: string | null; date_of_birth: string | null;
gotcha_day: string | null; gotcha_day: string | null;
chart_color: string; chart_color: string;
+3
View File
@@ -208,6 +208,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
"name": "Kiwi", "name": "Kiwi",
"tagId": "FP-001", "tagId": "FP-001",
"species": "Cockatiel", "species": "Cockatiel",
"gender": "female",
"dateOfBirth": "2023-05-10", "dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21", "gotchaDay": "2023-08-21",
"chartColor": "#cb3a35", "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` - Dates use `YYYY-MM-DD`
- `workspaceType` is `standard` or `rescue` - `workspaceType` is `standard` or `rescue`
- member `role` is `owner`, `manager`, `staff`, or `viewer` - member `role` is `owner`, `manager`, `staff`, or `viewer`
- bird `gender` is `unknown`, `male`, or `female`
- bird `chartColor` must be a `#RRGGBB` hex color - bird `chartColor` must be a `#RRGGBB` hex color
- `photoDataUrl` must be a base64 `data:image/...` URL - `photoDataUrl` must be a base64 `data:image/...` URL
- `weightGrams` must be a positive number up to `10000` - `weightGrams` must be a positive number up to `10000`
@@ -701,6 +703,7 @@ Request body:
"name": "Kiwi", "name": "Kiwi",
"tagId": "FP-001", "tagId": "FP-001",
"species": "Cockatiel", "species": "Cockatiel",
"gender": "female",
"dateOfBirth": "2023-05-10", "dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21", "gotchaDay": "2023-08-21",
"chartColor": "#cb3a35", "chartColor": "#cb3a35",
+90 -1
View File
@@ -7,6 +7,7 @@ type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
type WorkspaceType = 'standard' | 'rescue'; type WorkspaceType = 'standard' | 'rescue';
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
type IntegrationTokenScope = 'read_only' | 'read_write'; type IntegrationTokenScope = 'read_only' | 'read_write';
type BirdGender = 'unknown' | 'male' | 'female';
type Bird = { type Bird = {
id: string; id: string;
@@ -14,6 +15,7 @@ type Bird = {
name: string; name: string;
tagId: string; tagId: string;
species: string; species: string;
gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
chartColor: string; chartColor: string;
@@ -113,6 +115,7 @@ type BirdFormState = {
name: string; name: string;
tagId: string; tagId: string;
species: string; species: string;
gender: BirdGender;
dateOfBirth: string; dateOfBirth: string;
gotchaDay: string; gotchaDay: string;
chartColor: string; chartColor: string;
@@ -207,6 +210,7 @@ const emptyBirdForm: BirdFormState = {
name: '', name: '',
tagId: '', tagId: '',
species: '', species: '',
gender: 'unknown',
dateOfBirth: '', dateOfBirth: '',
gotchaDay: '', gotchaDay: '',
chartColor: '#cb3a35', chartColor: '#cb3a35',
@@ -303,6 +307,7 @@ const toBirdForm = (bird: Bird): BirdFormState => ({
name: bird.name, name: bird.name,
tagId: bird.tagId, tagId: bird.tagId,
species: bird.species, species: bird.species,
gender: bird.gender,
dateOfBirth: bird.dateOfBirth ?? '', dateOfBirth: bird.dateOfBirth ?? '',
gotchaDay: bird.gotchaDay ?? '', gotchaDay: bird.gotchaDay ?? '',
chartColor: bird.chartColor, chartColor: bird.chartColor,
@@ -334,6 +339,26 @@ const formatShortDate = (value: string | null) => {
}).format(new Date(`${value}T00:00:00`)); }).format(new Date(`${value}T00:00:00`));
}; };
const getBirdGenderLabel = (bird: Pick<Bird, 'gender'>) => {
if (bird.gender === 'female') {
return 'Female';
}
if (bird.gender === 'male') {
return 'Male';
}
return 'Unknown';
};
const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
if (bird.gender === 'female') {
return '♀';
}
if (bird.gender === 'male') {
return '♂';
}
return '?';
};
const formatDateTime = (value: string | null) => { const formatDateTime = (value: string | null) => {
if (!value) { if (!value) {
return 'Never'; return 'Never';
@@ -2376,7 +2401,12 @@ function App() {
</div> </div>
)} )}
<div className="bird-card-copy"> <div className="bird-card-copy">
<span className="bird-card-title">
<span>{bird.name}</span> <span>{bird.name}</span>
<span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${bird.gender}`}>
{getBirdGenderSymbol(bird)}
</span>
</span>
<small>{bird.species}</small> <small>{bird.species}</small>
</div> </div>
</div> </div>
@@ -2482,7 +2512,15 @@ function App() {
)} )}
<div className="profile-copy"> <div className="profile-copy">
<p className="eyebrow">Profile</p> <p className="eyebrow">Profile</p>
<h3>{selectedBird.name}</h3> <h3 className="profile-title">
<span>{selectedBird.name}</span>
<span
aria-label={getBirdGenderLabel(selectedBird)}
className={`gender-symbol ${selectedBird.gender}`}
>
{getBirdGenderSymbol(selectedBird)}
</span>
</h3>
<p className="muted"> <p className="muted">
{selectedBird.species} Band {selectedBird.tagId} {selectedBird.species} Band {selectedBird.tagId}
</p> </p>
@@ -2511,6 +2549,15 @@ function App() {
<span>Species</span> <span>Species</span>
<strong>{selectedBird.species}</strong> <strong>{selectedBird.species}</strong>
</article> </article>
<article className="detail-card">
<span>Gender</span>
<strong className="detail-gender">
<span aria-hidden="true" className={`gender-symbol ${selectedBird.gender}`}>
{getBirdGenderSymbol(selectedBird)}
</span>
{getBirdGenderLabel(selectedBird)}
</strong>
</article>
<article className="detail-card"> <article className="detail-card">
<span>Latest weight</span> <span>Latest weight</span>
<strong>{formatWeight(selectedBird.latestWeightGrams)}</strong> <strong>{formatWeight(selectedBird.latestWeightGrams)}</strong>
@@ -3206,6 +3253,48 @@ function App() {
</div> </div>
<small className="muted">Search or select a species so alerts and chart references stay consistent.</small> <small className="muted">Search or select a species so alerts and chart references stay consistent.</small>
</label> </label>
<div className="segmented-field">
<span>Gender</span>
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
<button
className={`segmented-option ${birdForm.gender === 'unknown' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'unknown' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'unknown'}
>
<span className="gender-symbol unknown" aria-hidden="true">
?
</span>
Unknown
</button>
<button
className={`segmented-option ${birdForm.gender === 'male' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'male' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'male'}
>
<span className="gender-symbol male" aria-hidden="true">
</span>
Male
</button>
<button
className={`segmented-option ${birdForm.gender === 'female' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'female' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'female'}
>
<span className="gender-symbol female" aria-hidden="true">
</span>
Female
</button>
</div>
<small className="muted">Shown on the bird profile card as a symbol.</small>
</div>
<label> <label>
DOB DOB
<input <input
+92
View File
@@ -578,6 +578,34 @@ textarea {
font-weight: 600; font-weight: 600;
} }
.bird-card-title {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.bird-card-title span {
display: inline;
}
.gender-inline {
font-size: 1.2rem;
font-weight: 700;
line-height: 1;
}
.gender-inline.male {
color: var(--accent-blue);
}
.gender-inline.female {
color: var(--accent-red);
}
.gender-inline.unknown {
color: var(--muted);
}
.bird-avatar, .bird-avatar,
.profile-photo { .profile-photo {
width: 56px; width: 56px;
@@ -723,6 +751,70 @@ textarea {
font-size: 1.6rem; font-size: 1.6rem;
} }
.profile-title,
.detail-gender {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.gender-symbol {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.9rem;
height: 1.9rem;
border-radius: 999px;
font-size: 1.2rem;
font-weight: 700;
line-height: 1;
}
.gender-symbol.male {
background: rgba(39, 105, 179, 0.12);
color: var(--accent-blue);
}
.gender-symbol.female {
background: rgba(203, 58, 53, 0.12);
color: var(--accent-red);
}
.gender-symbol.unknown {
background: rgba(93, 95, 89, 0.12);
color: var(--muted);
}
.segmented-field {
display: grid;
gap: 0.65rem;
}
.segmented-control {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
.segmented-option {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
border: 1px solid rgba(39, 105, 179, 0.14);
border-radius: 16px;
padding: 0.85rem 1rem;
background: rgba(255, 254, 250, 0.92);
color: var(--ink);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.segmented-option.active {
border-color: rgba(35, 138, 90, 0.4);
box-shadow: 0 10px 20px rgba(39, 105, 179, 0.1);
transform: translateY(-1px);
}
.inline-form { .inline-form {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }