Compare commits
7 Commits
cc4a2382c6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a43a450f3 | |||
| 46605d8717 | |||
| 8f1144de1a | |||
| 53b75588a2 | |||
| 1849ecd73b | |||
| 53b7d34520 | |||
| f65a4bed24 |
+57
-1
@@ -66,6 +66,7 @@ import {
|
|||||||
updateBird,
|
updateBird,
|
||||||
updateMemorialReminderPreference,
|
updateMemorialReminderPreference,
|
||||||
updateMedicationForBird,
|
updateMedicationForBird,
|
||||||
|
updateWeightForBird,
|
||||||
upsertMedicationAdministrationForBird,
|
upsertMedicationAdministrationForBird,
|
||||||
updateVetVisitForBird,
|
updateVetVisitForBird,
|
||||||
} from './repositories/birdRepository.js';
|
} from './repositories/birdRepository.js';
|
||||||
@@ -210,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 billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw']);
|
||||||
const billingIntervalSchema = z.enum(['monthly', 'yearly']);
|
const billingIntervalSchema = z.enum(['monthly', 'yearly']);
|
||||||
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
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 rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']);
|
||||||
const rescueOnboardingSchema = z.object({
|
const rescueOnboardingSchema = z.object({
|
||||||
name: z.string().trim().max(160).optional().or(z.literal('')),
|
name: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
@@ -4068,6 +4069,61 @@ app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireW
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put(
|
||||||
|
'/api/birds/:birdId/weights/:weightId',
|
||||||
|
requireAuth,
|
||||||
|
requireWriteAccess,
|
||||||
|
requireWorkspaceRole(['owner', 'assistant', 'caregiver']),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = weightSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid weight payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(bird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weight = await updateWeightForBird(
|
||||||
|
req.params.weightId,
|
||||||
|
req.params.birdId,
|
||||||
|
parsed.data.weightGrams,
|
||||||
|
parsed.data.recordedOn,
|
||||||
|
emptyToNull(parsed.data.notes),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!weight) {
|
||||||
|
res.status(404).json({ error: 'Weight entry not found or no longer editable.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuditLog(req.auth!, 'weight.updated', 'weight', weight.id, bird.name, {
|
||||||
|
birdId: bird.id,
|
||||||
|
weightGrams: parsed.data.weightGrams,
|
||||||
|
recordedOn: parsed.data.recordedOn,
|
||||||
|
});
|
||||||
|
res.json({ weight: normalizeWeight(weight) });
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
|
res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const vetVisits = await listVetVisitsForBird(req.params.birdId, req.auth!.workspace.id);
|
const vetVisits = await listVetVisitsForBird(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|||||||
@@ -61,11 +61,17 @@ const formatWeight = (value: string | number | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const genderLabel = (value: string) => {
|
const genderLabel = (value: string) => {
|
||||||
|
if (value === 'female_dna') {
|
||||||
|
return 'Female (DNA confirmed)';
|
||||||
|
}
|
||||||
|
if (value === 'male_dna') {
|
||||||
|
return 'Male (DNA confirmed)';
|
||||||
|
}
|
||||||
if (value === 'female') {
|
if (value === 'female') {
|
||||||
return 'Female';
|
return 'Female (assumed)';
|
||||||
}
|
}
|
||||||
if (value === 'male') {
|
if (value === 'male') {
|
||||||
return 'Male';
|
return 'Male (assumed)';
|
||||||
}
|
}
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -911,6 +911,34 @@ export const createWeightForBird = async (birdId: string, weightGrams: number, r
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateWeightForBird = async (
|
||||||
|
weightId: string,
|
||||||
|
birdId: string,
|
||||||
|
weightGrams: number,
|
||||||
|
recordedOn: string,
|
||||||
|
notes: string | null,
|
||||||
|
) => {
|
||||||
|
const result = await db.query<WeightRow>(
|
||||||
|
`UPDATE weight_records
|
||||||
|
SET weight_grams = $3,
|
||||||
|
recorded_on = $4,
|
||||||
|
notes = $5
|
||||||
|
WHERE id = $1
|
||||||
|
AND bird_id = $2
|
||||||
|
AND id IN (
|
||||||
|
SELECT recent.id
|
||||||
|
FROM weight_records recent
|
||||||
|
WHERE recent.bird_id = $2
|
||||||
|
ORDER BY recent.recorded_on DESC, recent.created_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
)
|
||||||
|
RETURNING id, bird_id, weight_grams, recorded_on::text, notes`,
|
||||||
|
[weightId, birdId, weightGrams, recordedOn, notes],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
|
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<VetVisitRow>(
|
const result = await db.query<VetVisitRow>(
|
||||||
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
|
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled'
|
|||||||
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||||
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 BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
|
||||||
|
|
||||||
export type UserRow = {
|
export type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
||||||
"vetAccountNumber": "FP-1001",
|
"vetAccountNumber": "FP-1001",
|
||||||
"vetDoctorName": "Dr. Rivera",
|
"vetDoctorName": "Dr. Rivera",
|
||||||
"gender": "female",
|
"gender": "female_dna",
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
"chartColor": "#cb3a35",
|
"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`
|
- Dates use `YYYY-MM-DD`
|
||||||
- `workspaceType` is `standard` or `rescue`
|
- `workspaceType` is `standard` or `rescue`
|
||||||
- member `role` is `owner`, `assistant`, `caregiver`, or `viewer`
|
- 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
|
- 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`
|
||||||
@@ -834,7 +834,7 @@ Request body:
|
|||||||
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
||||||
"vetAccountNumber": "FP-1001",
|
"vetAccountNumber": "FP-1001",
|
||||||
"vetDoctorName": "Dr. Rivera",
|
"vetDoctorName": "Dr. Rivera",
|
||||||
"gender": "female",
|
"gender": "female_dna",
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
"chartColor": "#cb3a35",
|
"chartColor": "#cb3a35",
|
||||||
|
|||||||
+182
-66
@@ -14,7 +14,7 @@ type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
|||||||
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
||||||
type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||||
type IntegrationTokenScope = 'read_only' | 'read_write';
|
type IntegrationTokenScope = 'read_only' | 'read_write';
|
||||||
type BirdGender = 'unknown' | 'male' | 'female';
|
type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
|
||||||
|
|
||||||
type Bird = {
|
type Bird = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -506,6 +506,14 @@ const parseImportGender = (value: unknown): BirdGender | null => {
|
|||||||
return 'unknown';
|
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') {
|
if (gender === 'male' || gender === 'female') {
|
||||||
return gender;
|
return gender;
|
||||||
}
|
}
|
||||||
@@ -653,6 +661,8 @@ const emptyBirdForm: BirdFormState = {
|
|||||||
publicProfileEnabled: false,
|
publicProfileEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const birdGenderOptions: BirdGender[] = ['female', 'female_dna', 'male', 'male_dna', 'unknown'];
|
||||||
|
|
||||||
const emptyVeterinaryInfoForm: VeterinaryInfoFormState = {
|
const emptyVeterinaryInfoForm: VeterinaryInfoFormState = {
|
||||||
vetClinicName: '',
|
vetClinicName: '',
|
||||||
vetClinicAddress: '',
|
vetClinicAddress: '',
|
||||||
@@ -826,25 +836,89 @@ const formatShortDate = (value: string | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getBirdGenderLabel = (bird: Pick<Bird, 'gender'>) => {
|
const getBirdGenderLabel = (bird: Pick<Bird, 'gender'>) => {
|
||||||
|
if (bird.gender === 'female_dna') {
|
||||||
|
return 'Female (DNA confirmed)';
|
||||||
|
}
|
||||||
|
if (bird.gender === 'male_dna') {
|
||||||
|
return 'Male (DNA confirmed)';
|
||||||
|
}
|
||||||
if (bird.gender === 'female') {
|
if (bird.gender === 'female') {
|
||||||
return 'Female';
|
return 'Female (assumed)';
|
||||||
}
|
}
|
||||||
if (bird.gender === 'male') {
|
if (bird.gender === 'male') {
|
||||||
return 'Male';
|
return 'Male (assumed)';
|
||||||
}
|
}
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
|
const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
|
||||||
if (bird.gender === 'female') {
|
if (bird.gender === 'female' || bird.gender === 'female_dna') {
|
||||||
return '♀';
|
return '♀';
|
||||||
}
|
}
|
||||||
if (bird.gender === 'male') {
|
if (bird.gender === 'male' || bird.gender === 'male_dna') {
|
||||||
return '♂';
|
return '♂';
|
||||||
}
|
}
|
||||||
return '?';
|
return '?';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBirdGenderClass = (bird: Pick<Bird, 'gender'>) => {
|
||||||
|
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'>) => bird.gender === 'male_dna' || bird.gender === 'female_dna';
|
||||||
|
|
||||||
|
const getBirdGenderSourceLabel = (bird: Pick<Bird, 'gender'>) => {
|
||||||
|
if (bird.gender === 'unknown') {
|
||||||
|
return 'Sex unknown';
|
||||||
|
}
|
||||||
|
return isDnaConfirmedGender(bird) ? 'DNA confirmed' : 'Assumed sex';
|
||||||
|
};
|
||||||
|
|
||||||
|
const BirdGenderSourceIcon = ({ bird }: { bird: Pick<Bird, 'gender'> }) => {
|
||||||
|
if (bird.gender === 'unknown') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel = getBirdGenderSourceLabel(bird);
|
||||||
|
|
||||||
|
if (isDnaConfirmedGender(bird)) {
|
||||||
|
return (
|
||||||
|
<span className="gender-source-icon dna" aria-label={sourceLabel} title={sourceLabel}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<circle className="dna-ring" cx="12" cy="12" r="8.7" />
|
||||||
|
<path className="dna-strand" d="M14.9 5.4c-4.6 2.6-6.2 4.6-5.8 7.1.3 2.2 2.1 3.8 5.8 6.1" />
|
||||||
|
<path className="dna-strand" d="M9.1 5.4c4.6 2.6 6.2 4.6 5.8 7.1-.3 2.2-2.1 3.8-5.8 6.1" />
|
||||||
|
<path className="dna-rung" d="M10.4 8.1h3.2" />
|
||||||
|
<path className="dna-rung" d="M9.4 10.7h5.2" />
|
||||||
|
<path className="dna-rung" d="M9.5 13.3h5" />
|
||||||
|
<path className="dna-rung" d="M10.4 15.9h3.2" />
|
||||||
|
<circle className="dna-dot" cx="14.9" cy="5.4" r="0.7" />
|
||||||
|
<circle className="dna-dot" cx="10.4" cy="8.1" r="0.85" />
|
||||||
|
<circle className="dna-dot" cx="9.4" cy="10.7" r="0.85" />
|
||||||
|
<circle className="dna-dot" cx="9.5" cy="13.3" r="0.85" />
|
||||||
|
<circle className="dna-dot" cx="10.4" cy="15.9" r="0.85" />
|
||||||
|
<circle className="dna-dot" cx="9.1" cy="18.6" r="0.7" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="gender-source-icon assumed" aria-label={sourceLabel} title={sourceLabel}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path className="assumed-eye" d="M3.8 12s3-4.6 8.2-4.6 8.2 4.6 8.2 4.6-3 4.6-8.2 4.6S3.8 12 3.8 12Z" />
|
||||||
|
<circle className="assumed-pupil" cx="12" cy="12" r="2.1" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const escapeReportHtml = (value: string | number | null | undefined) =>
|
const escapeReportHtml = (value: string | number | null | undefined) =>
|
||||||
String(value ?? '')
|
String(value ?? '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -876,6 +950,8 @@ const formatAuditAction = (value: string) =>
|
|||||||
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
|
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
|
||||||
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
|
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
|
||||||
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
|
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
|
||||||
|
const getEditableWeights = (entries: WeightRecord[]) =>
|
||||||
|
[...entries].sort((left, right) => right.recordedOn.localeCompare(left.recordedOn)).slice(0, 3);
|
||||||
const daysBetweenDates = (startDate: string, endDate: string) =>
|
const daysBetweenDates = (startDate: string, endDate: string) =>
|
||||||
Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000);
|
Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000);
|
||||||
const addYearsToDate = (date: Date, years: number) => {
|
const addYearsToDate = (date: Date, years: number) => {
|
||||||
@@ -1574,6 +1650,7 @@ function App() {
|
|||||||
recordedOn: new Date().toISOString().slice(0, 10),
|
recordedOn: new Date().toISOString().slice(0, 10),
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
|
const [editingWeightId, setEditingWeightId] = useState('');
|
||||||
const [vetVisitForm, setVetVisitForm] = useState({
|
const [vetVisitForm, setVetVisitForm] = useState({
|
||||||
visitedOn: new Date().toISOString().slice(0, 10),
|
visitedOn: new Date().toISOString().slice(0, 10),
|
||||||
clinicName: '',
|
clinicName: '',
|
||||||
@@ -1727,6 +1804,8 @@ function App() {
|
|||||||
),
|
),
|
||||||
[allBirdWeights, birds, overviewWindowStartDate],
|
[allBirdWeights, birds, overviewWindowStartDate],
|
||||||
);
|
);
|
||||||
|
const editableWeights = useMemo(() => getEditableWeights(weights), [weights]);
|
||||||
|
const editableWeightIds = useMemo(() => new Set(editableWeights.map((weight) => weight.id)), [editableWeights]);
|
||||||
|
|
||||||
const showFlockDetailColumn = bulkWeightOpen || birdEditorOpen || Boolean(selectedBird);
|
const showFlockDetailColumn = bulkWeightOpen || birdEditorOpen || Boolean(selectedBird);
|
||||||
|
|
||||||
@@ -3355,8 +3434,9 @@ function App() {
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`/birds/${selectedBird.id}/weights`, authToken, {
|
const isEditingWeight = Boolean(editingWeightId);
|
||||||
method: 'POST',
|
const response = await apiFetch(isEditingWeight ? `/birds/${selectedBird.id}/weights/${editingWeightId}` : `/birds/${selectedBird.id}/weights`, authToken, {
|
||||||
|
method: isEditingWeight ? 'PUT' : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
weightGrams: Number(weightForm.weightGrams),
|
weightGrams: Number(weightForm.weightGrams),
|
||||||
@@ -3373,7 +3453,13 @@ function App() {
|
|||||||
if (!data?.weight) {
|
if (!data?.weight) {
|
||||||
throw new Error('Unable to save weight.');
|
throw new Error('Unable to save weight.');
|
||||||
}
|
}
|
||||||
const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn));
|
const nextWeights = (
|
||||||
|
isEditingWeight ? weights.map((weight) => (weight.id === data.weight.id ? data.weight : weight)) : [...weights, data.weight]
|
||||||
|
).sort((left, right) => left.recordedOn.localeCompare(right.recordedOn));
|
||||||
|
const latestWeight = nextWeights.reduce<WeightRecord | null>(
|
||||||
|
(latest, weight) => (!latest || weight.recordedOn >= latest.recordedOn ? weight : latest),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
setWeights(nextWeights);
|
setWeights(nextWeights);
|
||||||
setAllBirdWeights((current) => ({
|
setAllBirdWeights((current) => ({
|
||||||
@@ -3389,18 +3475,39 @@ function App() {
|
|||||||
bird.id === selectedBird.id
|
bird.id === selectedBird.id
|
||||||
? {
|
? {
|
||||||
...bird,
|
...bird,
|
||||||
latestWeightGrams: data.weight.weightGrams,
|
latestWeightGrams: latestWeight?.weightGrams ?? null,
|
||||||
latestRecordedOn: data.weight.recordedOn,
|
latestRecordedOn: latestWeight?.recordedOn ?? null,
|
||||||
}
|
}
|
||||||
: bird,
|
: bird,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' });
|
setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' });
|
||||||
|
setEditingWeightId('');
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : 'Unable to save weight.');
|
setError(submitError instanceof Error ? submitError.message : 'Unable to save weight.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditWeight = (weight: WeightRecord) => {
|
||||||
|
if (!editableWeightIds.has(weight.id)) {
|
||||||
|
setError('Only the 3 most recent weight entries can be edited.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingWeightId(weight.id);
|
||||||
|
setWeightForm({
|
||||||
|
weightGrams: String(weight.weightGrams),
|
||||||
|
recordedOn: weight.recordedOn,
|
||||||
|
notes: weight.notes ?? '',
|
||||||
|
});
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelWeightEdit = () => {
|
||||||
|
setEditingWeightId('');
|
||||||
|
setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' });
|
||||||
|
};
|
||||||
|
|
||||||
const handleBulkWeightValueChange = (birdId: string, weightGrams: string) => {
|
const handleBulkWeightValueChange = (birdId: string, weightGrams: string) => {
|
||||||
setBulkWeightRows((current) => ({
|
setBulkWeightRows((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -4840,9 +4947,10 @@ function App() {
|
|||||||
<div className="public-profile-copy">
|
<div className="public-profile-copy">
|
||||||
<h1>
|
<h1>
|
||||||
<span>{publicProfile.name}</span>
|
<span>{publicProfile.name}</span>
|
||||||
<span aria-label={getBirdGenderLabel(publicProfile)} className={`gender-symbol ${publicProfile.gender}`}>
|
<span aria-label={getBirdGenderLabel(publicProfile)} className={`gender-symbol ${getBirdGenderClass(publicProfile)}`}>
|
||||||
{getBirdGenderSymbol(publicProfile)}
|
<span className="gender-symbol-mark">{getBirdGenderSymbol(publicProfile)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<BirdGenderSourceIcon bird={publicProfile} />
|
||||||
</h1>
|
</h1>
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<span>Hatch Day</span>
|
<span>Hatch Day</span>
|
||||||
@@ -5518,8 +5626,11 @@ function App() {
|
|||||||
<div className="bird-card-copy">
|
<div className="bird-card-copy">
|
||||||
<span className="bird-card-title">
|
<span className="bird-card-title">
|
||||||
<span>{bird.name}</span>
|
<span>{bird.name}</span>
|
||||||
<span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${bird.gender}`}>
|
<span className="bird-card-gender-cluster">
|
||||||
{getBirdGenderSymbol(bird)}
|
<span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${getBirdGenderClass(bird)}`}>
|
||||||
|
{getBirdGenderSymbol(bird)}
|
||||||
|
</span>
|
||||||
|
<BirdGenderSourceIcon bird={bird} />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<small>{bird.species}</small>
|
<small>{bird.species}</small>
|
||||||
@@ -5716,7 +5827,7 @@ function App() {
|
|||||||
<div className="segmented-field wide-field">
|
<div className="segmented-field wide-field">
|
||||||
<span>Gender</span>
|
<span>Gender</span>
|
||||||
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
||||||
{(['unknown', 'male', 'female'] as BirdGender[]).map((gender) => (
|
{birdGenderOptions.map((gender) => (
|
||||||
<button
|
<button
|
||||||
key={gender}
|
key={gender}
|
||||||
className={`segmented-option ${birdForm.gender === gender ? 'active' : ''}`}
|
className={`segmented-option ${birdForm.gender === gender ? 'active' : ''}`}
|
||||||
@@ -5725,9 +5836,10 @@ function App() {
|
|||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={birdForm.gender === gender}
|
aria-checked={birdForm.gender === gender}
|
||||||
>
|
>
|
||||||
<span className={`gender-symbol ${gender}`} aria-hidden="true">
|
<span className={`gender-symbol ${getBirdGenderClass({ gender })}`} aria-hidden="true">
|
||||||
{getBirdGenderSymbol({ gender })}
|
<span className="gender-symbol-mark">{getBirdGenderSymbol({ gender })}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<BirdGenderSourceIcon bird={{ gender }} />
|
||||||
{getBirdGenderLabel({ gender })}
|
{getBirdGenderLabel({ gender })}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -6286,10 +6398,11 @@ function App() {
|
|||||||
<span>{selectedBird.name}</span>
|
<span>{selectedBird.name}</span>
|
||||||
<span
|
<span
|
||||||
aria-label={getBirdGenderLabel(selectedBird)}
|
aria-label={getBirdGenderLabel(selectedBird)}
|
||||||
className={`gender-symbol ${selectedBird.gender}`}
|
className={`gender-symbol ${getBirdGenderClass(selectedBird)}`}
|
||||||
>
|
>
|
||||||
{getBirdGenderSymbol(selectedBird)}
|
<span className="gender-symbol-mark">{getBirdGenderSymbol(selectedBird)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<BirdGenderSourceIcon bird={selectedBird} />
|
||||||
</h3>
|
</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
|
{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
|
||||||
@@ -6526,11 +6639,11 @@ function App() {
|
|||||||
<label>
|
<label>
|
||||||
Recorded on
|
Recorded on
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={weightForm.recordedOn}
|
value={weightForm.recordedOn}
|
||||||
onChange={(event) => setWeightForm({ ...weightForm, recordedOn: event.target.value })}
|
onChange={(event) => setWeightForm({ ...weightForm, recordedOn: event.target.value })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="wide-field">
|
<label className="wide-field">
|
||||||
Notes
|
Notes
|
||||||
@@ -6541,11 +6654,34 @@ function App() {
|
|||||||
placeholder="Optional notes about appetite, molt, meds, or behavior"
|
placeholder="Optional notes about appetite, molt, meds, or behavior"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" type="submit">
|
<div className="button-row care-form-actions">
|
||||||
Save weight
|
<button className="primary-button" type="submit">
|
||||||
</button>
|
{editingWeightId ? 'Save weight changes' : 'Save weight'}
|
||||||
</form>
|
</button>
|
||||||
</section>
|
{editingWeightId ? (
|
||||||
|
<button className="secondary-button" onClick={handleCancelWeightEdit} type="button">
|
||||||
|
Cancel edit
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="recent-list">
|
||||||
|
{editableWeights
|
||||||
|
.map((weight) => (
|
||||||
|
<article className="vet-visit-card" key={weight.id}>
|
||||||
|
<strong>{formatWeight(weight.weightGrams)}</strong>
|
||||||
|
<span>{formatDate(weight.recordedOn)}</span>
|
||||||
|
<small>{weight.notes || 'No notes recorded.'}</small>
|
||||||
|
<div className="button-row">
|
||||||
|
<button className="secondary-button" onClick={() => handleEditWeight(weight)} type="button">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{!editableWeights.length ? <p className="muted">No weight entries recorded yet.</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -7694,44 +7830,24 @@ function App() {
|
|||||||
<div className="segmented-field wide-field">
|
<div className="segmented-field wide-field">
|
||||||
<span>Gender</span>
|
<span>Gender</span>
|
||||||
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
||||||
<button
|
{birdGenderOptions.map((gender) => (
|
||||||
className={`segmented-option ${birdForm.gender === 'unknown' ? 'active' : ''}`}
|
<button
|
||||||
onClick={() => setBirdForm({ ...birdForm, gender: 'unknown' })}
|
key={gender}
|
||||||
type="button"
|
className={`segmented-option ${birdForm.gender === gender ? 'active' : ''}`}
|
||||||
role="radio"
|
onClick={() => setBirdForm({ ...birdForm, gender })}
|
||||||
aria-checked={birdForm.gender === 'unknown'}
|
type="button"
|
||||||
>
|
role="radio"
|
||||||
<span className="gender-symbol unknown" aria-hidden="true">
|
aria-checked={birdForm.gender === gender}
|
||||||
?
|
>
|
||||||
</span>
|
<span className={`gender-symbol ${getBirdGenderClass({ gender })}`} aria-hidden="true">
|
||||||
Unknown
|
<span className="gender-symbol-mark">{getBirdGenderSymbol({ gender })}</span>
|
||||||
</button>
|
</span>
|
||||||
<button
|
<BirdGenderSourceIcon bird={{ gender }} />
|
||||||
className={`segmented-option ${birdForm.gender === 'male' ? 'active' : ''}`}
|
{getBirdGenderLabel({ gender })}
|
||||||
onClick={() => setBirdForm({ ...birdForm, gender: 'male' })}
|
</button>
|
||||||
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>
|
</div>
|
||||||
<small className="muted">Shown on the bird profile card as a symbol.</small>
|
<small className="muted">Shown on the bird profile card and reports.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-inline-header wide-field">
|
<div className="settings-inline-header wide-field">
|
||||||
<p className="eyebrow">Dates</p>
|
<p className="eyebrow">Dates</p>
|
||||||
|
|||||||
+118
-4
@@ -883,10 +883,19 @@ textarea {
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bird-card-title .bird-card-gender-cluster {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.gender-inline {
|
.gender-inline {
|
||||||
font-size: 1.2rem;
|
font-size: 1.45rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gender-inline.male {
|
.gender-inline.male {
|
||||||
@@ -901,6 +910,99 @@ textarea {
|
|||||||
color: var(--muted);
|
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,
|
.bird-avatar,
|
||||||
.profile-photo {
|
.profile-photo {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
@@ -1258,14 +1360,26 @@ textarea {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 1.9rem;
|
width: 1.9rem;
|
||||||
height: 1.9rem;
|
height: 1.9rem;
|
||||||
|
flex: 0 0 1.9rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 1.2rem;
|
font-size: 1.45rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
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 {
|
.gender-symbol.male {
|
||||||
background: rgba(39, 105, 179, 0.12);
|
background: rgba(39, 105, 179, 0.12);
|
||||||
color: var(--accent-blue);
|
color: var(--accent-blue);
|
||||||
@@ -1288,7 +1402,7 @@ textarea {
|
|||||||
|
|
||||||
.segmented-control {
|
.segmented-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user