Added gender
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+91
-2
@@ -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>{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>
|
<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
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user