4 Commits

Author SHA1 Message Date
Corey Blais 53b75588a2 weight edit fixes
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m36s
2026-06-16 18:13:54 -04:00
Corey Blais 1849ecd73b Updated weight edit 2026-06-16 18:13:54 -04:00
Corey Blais 53b7d34520 trimmed weight edit 2026-06-16 18:13:54 -04:00
Corey Blais f65a4bed24 adding weight edits 2026-06-16 18:13:54 -04:00
3 changed files with 155 additions and 15 deletions
+56
View File
@@ -66,6 +66,7 @@ import {
updateBird, updateBird,
updateMemorialReminderPreference, updateMemorialReminderPreference,
updateMedicationForBird, updateMedicationForBird,
updateWeightForBird,
upsertMedicationAdministrationForBird, upsertMedicationAdministrationForBird,
updateVetVisitForBird, updateVetVisitForBird,
} from './repositories/birdRepository.js'; } from './repositories/birdRepository.js';
@@ -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);
@@ -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
+71 -15
View File
@@ -876,6 +876,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 +1576,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 +1730,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 +3360,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 +3379,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 +3401,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,
@@ -6526,11 +6559,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 +6574,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}