From 4a43a450f3c342023a8e2b43cec4386dcbcdfb5c Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Wed, 17 Jun 2026 21:59:09 -0400 Subject: [PATCH] Additional Genders --- backend/src/app.ts | 2 +- backend/src/reports/adoptionReport.ts | 10 +- backend/src/types.ts | 2 +- docs/API_REFERENCE.md | 6 +- frontend/src/App.tsx | 162 ++++++++++++++++++-------- frontend/src/index.css | 122 ++++++++++++++++++- 6 files changed, 242 insertions(+), 62 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 7433c0c..079f7e6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -211,7 +211,7 @@ const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer'] const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw']); const billingIntervalSchema = z.enum(['monthly', 'yearly']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); -const birdGenderSchema = z.enum(['unknown', 'male', 'female']); +const birdGenderSchema = z.enum(['unknown', 'male', 'female', 'male_dna', 'female_dna']); const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']); const rescueOnboardingSchema = z.object({ name: z.string().trim().max(160).optional().or(z.literal('')), diff --git a/backend/src/reports/adoptionReport.ts b/backend/src/reports/adoptionReport.ts index 127033b..49a321e 100644 --- a/backend/src/reports/adoptionReport.ts +++ b/backend/src/reports/adoptionReport.ts @@ -61,11 +61,17 @@ const formatWeight = (value: string | number | null) => { }; const genderLabel = (value: string) => { + if (value === 'female_dna') { + return 'Female (DNA confirmed)'; + } + if (value === 'male_dna') { + return 'Male (DNA confirmed)'; + } if (value === 'female') { - return 'Female'; + return 'Female (assumed)'; } if (value === 'male') { - return 'Male'; + return 'Male (assumed)'; } return 'Unknown'; }; diff --git a/backend/src/types.ts b/backend/src/types.ts index eb5b411..f8fd8a1 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -6,7 +6,7 @@ export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected'; export type ProviderKey = 'google' | 'microsoft' | 'apple'; export type IntegrationTokenScope = 'read_only' | 'read_write'; -export type BirdGender = 'unknown' | 'male' | 'female'; +export type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna'; export type UserRow = { id: string; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index e077570..84e9315 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -212,7 +212,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac "vetClinicAddress": "123 Feather Lane, Raleigh, NC", "vetAccountNumber": "FP-1001", "vetDoctorName": "Dr. Rivera", - "gender": "female", + "gender": "female_dna", "dateOfBirth": "2023-05-10", "gotchaDay": "2023-08-21", "chartColor": "#cb3a35", @@ -299,7 +299,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`, `assistant`, `caregiver`, or `viewer` -- bird `gender` is `unknown`, `male`, or `female` +- bird `gender` is `unknown`, `male`, `female`, `male_dna`, or `female_dna`; `male` and `female` indicate assumed sex - 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` @@ -834,7 +834,7 @@ Request body: "vetClinicAddress": "123 Feather Lane, Raleigh, NC", "vetAccountNumber": "FP-1001", "vetDoctorName": "Dr. Rivera", - "gender": "female", + "gender": "female_dna", "dateOfBirth": "2023-05-10", "gotchaDay": "2023-08-21", "chartColor": "#cb3a35", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 80b7d98..4889542 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,7 @@ type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer'; type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none'; type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected'; type IntegrationTokenScope = 'read_only' | 'read_write'; -type BirdGender = 'unknown' | 'male' | 'female'; +type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna'; type Bird = { id: string; @@ -506,6 +506,14 @@ const parseImportGender = (value: unknown): BirdGender | null => { return 'unknown'; } + if (['male dna', 'dna male', 'male_dna', 'dna confirmed male', 'male dna confirmed'].includes(gender)) { + return 'male_dna'; + } + + if (['female dna', 'dna female', 'female_dna', 'dna confirmed female', 'female dna confirmed'].includes(gender)) { + return 'female_dna'; + } + if (gender === 'male' || gender === 'female') { return gender; } @@ -653,6 +661,8 @@ const emptyBirdForm: BirdFormState = { publicProfileEnabled: false, }; +const birdGenderOptions: BirdGender[] = ['female', 'female_dna', 'male', 'male_dna', 'unknown']; + const emptyVeterinaryInfoForm: VeterinaryInfoFormState = { vetClinicName: '', vetClinicAddress: '', @@ -826,25 +836,89 @@ const formatShortDate = (value: string | null) => { }; const getBirdGenderLabel = (bird: Pick) => { + if (bird.gender === 'female_dna') { + return 'Female (DNA confirmed)'; + } + if (bird.gender === 'male_dna') { + return 'Male (DNA confirmed)'; + } if (bird.gender === 'female') { - return 'Female'; + return 'Female (assumed)'; } if (bird.gender === 'male') { - return 'Male'; + return 'Male (assumed)'; } return 'Unknown'; }; const getBirdGenderSymbol = (bird: Pick) => { - if (bird.gender === 'female') { + if (bird.gender === 'female' || bird.gender === 'female_dna') { return '♀'; } - if (bird.gender === 'male') { + if (bird.gender === 'male' || bird.gender === 'male_dna') { return '♂'; } return '?'; }; +const getBirdGenderClass = (bird: Pick) => { + if (bird.gender === 'female' || bird.gender === 'female_dna') { + return 'female'; + } + if (bird.gender === 'male' || bird.gender === 'male_dna') { + return 'male'; + } + return 'unknown'; +}; + +const isDnaConfirmedGender = (bird: Pick) => bird.gender === 'male_dna' || bird.gender === 'female_dna'; + +const getBirdGenderSourceLabel = (bird: Pick) => { + if (bird.gender === 'unknown') { + return 'Sex unknown'; + } + return isDnaConfirmedGender(bird) ? 'DNA confirmed' : 'Assumed sex'; +}; + +const BirdGenderSourceIcon = ({ bird }: { bird: Pick }) => { + if (bird.gender === 'unknown') { + return null; + } + + const sourceLabel = getBirdGenderSourceLabel(bird); + + if (isDnaConfirmedGender(bird)) { + return ( + + + + ); + } + + return ( + + + + ); +}; + const escapeReportHtml = (value: string | number | null | undefined) => String(value ?? '') .replace(/&/g, '&') @@ -4873,9 +4947,10 @@ function App() {

{publicProfile.name} - - {getBirdGenderSymbol(publicProfile)} + + {getBirdGenderSymbol(publicProfile)} +

Hatch Day @@ -5551,8 +5626,11 @@ function App() {
{bird.name} - - {getBirdGenderSymbol(bird)} + + + {getBirdGenderSymbol(bird)} + + {bird.species} @@ -5749,7 +5827,7 @@ function App() {
Gender
- {(['unknown', 'male', 'female'] as BirdGender[]).map((gender) => ( + {birdGenderOptions.map((gender) => ( ))} @@ -6319,10 +6398,11 @@ function App() { {selectedBird.name} - {getBirdGenderSymbol(selectedBird)} + {getBirdGenderSymbol(selectedBird)} +

{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'} @@ -7750,44 +7830,24 @@ function App() {

Gender
- - - + {birdGenderOptions.map((gender) => ( + + ))}
- Shown on the bird profile card as a symbol. + Shown on the bird profile card and reports.

Dates

diff --git a/frontend/src/index.css b/frontend/src/index.css index a965df1..697ac01 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -883,10 +883,19 @@ textarea { display: inline; } +.bird-card-title .bird-card-gender-cluster { + display: inline-flex; + align-items: center; + gap: 0.28rem; + line-height: 1; +} + .gender-inline { - font-size: 1.2rem; + font-size: 1.45rem; font-weight: 700; line-height: 1; + display: inline-flex; + align-items: center; } .gender-inline.male { @@ -901,6 +910,99 @@ textarea { color: var(--muted); } +.gender-source-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.45rem; + height: 1.45rem; + flex: 0 0 1.45rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 800; + line-height: 1; + vertical-align: middle; +} + +.gender-source-icon svg { + width: 1.28rem; + height: 1.28rem; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} + +.gender-source-icon.dna { + width: 1.9rem; + height: 1.9rem; + flex-basis: 1.9rem; + background: rgba(91, 74, 161, 0.1); + color: #5b4aa1; +} + +.gender-source-icon.dna svg { + width: 1.72rem; + height: 1.72rem; +} + +.gender-source-icon .dna-ring { + fill: none; + stroke: currentColor; + stroke-width: 1.15; +} + +.gender-source-icon .dna-strand { + stroke: currentColor; + stroke-width: 1.35; +} + +.gender-source-icon .dna-rung { + stroke: currentColor; + stroke-width: 1.05; + opacity: 0.82; +} + +.gender-source-icon .dna-dot { + fill: currentColor; +} + +.gender-source-icon.assumed { + width: 1.9rem; + height: 1.9rem; + flex-basis: 1.9rem; + background: rgba(93, 95, 89, 0.12); + color: var(--muted); +} + +.gender-source-icon.assumed svg { + width: 1.72rem; + height: 1.72rem; +} + +.gender-source-icon .assumed-eye { + stroke: currentColor; + stroke-width: 1.7; +} + +.gender-source-icon .assumed-pupil { + fill: currentColor; +} + +.bird-card-title .gender-source-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.05rem; + height: 1.05rem; + flex-basis: 1.05rem; +} + +.bird-card-title .gender-source-icon svg { + display: block; + width: 0.92rem; + height: 0.92rem; +} + .bird-avatar, .profile-photo { width: 56px; @@ -1258,14 +1360,26 @@ textarea { display: inline-flex; align-items: center; justify-content: center; - min-width: 1.9rem; + width: 1.9rem; height: 1.9rem; + flex: 0 0 1.9rem; border-radius: 999px; - font-size: 1.2rem; + font-size: 1.45rem; font-weight: 700; line-height: 1; } +.gender-symbol-mark { + display: block; + line-height: 0.82; + transform: scale(1.16); + transform-origin: center; +} + +.profile-title .gender-symbol-mark { + transform: scale(1.28); +} + .gender-symbol.male { background: rgba(39, 105, 179, 0.12); color: var(--accent-blue); @@ -1288,7 +1402,7 @@ textarea { .segmented-control { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr)); gap: 0.55rem; }