Added gender
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 $$;
|
||||
`);
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<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)
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
+91
-2
@@ -7,6 +7,7 @@ type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
|
||||
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<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) => {
|
||||
if (!value) {
|
||||
return 'Never';
|
||||
@@ -2376,7 +2401,12 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
<div className="bird-card-copy">
|
||||
<span>{bird.name}</span>
|
||||
<span className="bird-card-title">
|
||||
<span>{bird.name}</span>
|
||||
<span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${bird.gender}`}>
|
||||
{getBirdGenderSymbol(bird)}
|
||||
</span>
|
||||
</span>
|
||||
<small>{bird.species}</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2482,7 +2512,15 @@ function App() {
|
||||
)}
|
||||
<div className="profile-copy">
|
||||
<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">
|
||||
{selectedBird.species} • Band {selectedBird.tagId}
|
||||
</p>
|
||||
@@ -2511,6 +2549,15 @@ function App() {
|
||||
<span>Species</span>
|
||||
<strong>{selectedBird.species}</strong>
|
||||
</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">
|
||||
<span>Latest weight</span>
|
||||
<strong>{formatWeight(selectedBird.latestWeightGrams)}</strong>
|
||||
@@ -3206,6 +3253,48 @@ function App() {
|
||||
</div>
|
||||
<small className="muted">Search or select a species so alerts and chart references stay consistent.</small>
|
||||
</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>
|
||||
DOB
|
||||
<input
|
||||
|
||||
@@ -578,6 +578,34 @@ textarea {
|
||||
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,
|
||||
.profile-photo {
|
||||
width: 56px;
|
||||
@@ -723,6 +751,70 @@ textarea {
|
||||
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 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user