From a988d9662bac696237791f46f06a2035714d13f6 Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Sun, 28 Jun 2026 12:30:36 -0400 Subject: [PATCH] Added timeline feature --- backend/src/app.ts | 165 +++ backend/src/db/schema.ts | 26 + .../src/repositories/birdRepository.test.ts | 53 + backend/src/repositories/birdRepository.ts | 175 +++- backend/src/types.ts | 20 + frontend/src/App.tsx | 947 +++++++----------- frontend/src/index.css | 124 ++- 7 files changed, 878 insertions(+), 632 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 5ee6daf..67220f3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -37,6 +37,7 @@ import { completePendingBirdTransfersForOwner, createBird, createBirdMilestoneReminderDelivery, + createBirdTimelineEvent, createMedicationReminderDelivery, createBirdTransferCode, createMedicationForBird, @@ -51,6 +52,7 @@ import { getBirdByPublicProfileCode, getOpenBirdTransferCode, listBirds, + listBirdTimelineEvents, listDueBirdMilestoneReminders, listDueMedicationReminders, listMemorializedBirds, @@ -133,6 +135,8 @@ import type { BirdGender, BirdMilestoneReminderCandidateRow, BirdRow, + BirdTimelineEventType, + BirdTimelineEventRow, FlockNoteRow, IntegrationTokenRow, LostBirdMatchRow, @@ -289,6 +293,7 @@ const birdSchema = z.object({ motivators: birdProfileListSchema, demotivators: birdProfileListSchema, favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')), + locationLabel: z.string().trim().max(160).optional().or(z.literal('')), vetClinicName: z.string().trim().max(160).optional().or(z.literal('')), vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')), vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')), @@ -313,6 +318,18 @@ const memorialReminderPreferenceSchema = z.object({ notifyOnMemorialDay: z.boolean(), }); +const birdTimelineEventSchema = z + .object({ + eventType: z.enum(['location_updated', 'owner_changed', 'manual_note']), + eventDate: dateStringSchema.optional().or(z.literal('')), + locationLabel: z.string().trim().max(160).optional().or(z.literal('')), + note: z.string().trim().max(500).optional().or(z.literal('')), + }) + .refine( + (value) => value.eventType === 'owner_changed' || Boolean(value.locationLabel?.trim() || value.note?.trim()), + 'Add a location or note for this timeline item.', + ); + const weightSchema = z.object({ weightGrams: z.coerce.number().positive().max(10000), recordedOn: dateStringSchema, @@ -689,6 +706,7 @@ const normalizeBird = (row: BirdRow) => ({ motivators: row.motivators, demotivators: row.demotivators, favoriteSnack: row.favorite_snack, + locationLabel: row.location_label, vetClinicName: row.vet_clinic_name, vetClinicAddress: row.vet_clinic_address, vetAccountNumber: row.vet_account_number, @@ -796,6 +814,23 @@ const normalizeAuditLogEntry = (row: AuditLogEntryRow) => ({ createdAt: row.created_at, }); +const normalizeBirdTimelineEvent = (row: BirdTimelineEventRow) => ({ + id: row.id, + birdId: row.bird_id, + eventType: row.event_type, + fromWorkspaceId: row.from_workspace_id, + toWorkspaceId: row.to_workspace_id, + fromWorkspaceName: row.from_workspace_name, + toWorkspaceName: row.to_workspace_name, + fromOwnerEmail: row.from_owner_email, + toOwnerEmail: row.to_owner_email, + locationLabel: row.location_label, + note: row.note, + eventDate: row.event_date, + createdByUserId: row.created_by_user_id, + createdAt: row.created_at, +}); + const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({ id: row.id, userId: row.user_id, @@ -2326,6 +2361,41 @@ const writeAuditLog = async ( } }; +const writeBirdTimelineEvent = async ({ + birdId, + eventType, + fromWorkspaceId, + toWorkspaceId, + locationLabel, + note, + eventDate, + createdByUserId, +}: { + birdId: string; + eventType: BirdTimelineEventType; + fromWorkspaceId?: number | null; + toWorkspaceId?: number | null; + locationLabel?: string | null; + note?: string | null; + eventDate?: string | null; + createdByUserId?: string | null; +}) => { + try { + await createBirdTimelineEvent({ + birdId, + eventType, + fromWorkspaceId, + toWorkspaceId, + locationLabel, + note, + eventDate, + createdByUserId, + }); + } catch (error) { + console.error('Unable to write bird timeline event', error); + } +}; + const isBillingOnlyWorkspaceUpdate = ( workspace: WorkspaceRow, payload: z.infer, @@ -3532,6 +3602,69 @@ app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: Nex } }); +app.get('/api/birds/:birdId/timeline', requireAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id); + + if (!bird) { + res.status(404).json({ error: 'Bird not found.' }); + return; + } + + const events = await listBirdTimelineEvents(req.params.birdId, req.auth!.workspace.id); + res.json({ events: events.map(normalizeBirdTimelineEvent) }); + } catch (error) { + next(error); + } +}); + +app.post( + '/api/birds/:birdId/timeline', + requireAuth, + requireWriteAccess, + requireSessionAuth, + requireWorkspaceRole(['owner', 'assistant', 'caregiver']), + async (req: Request, res: Response, next: NextFunction) => { + const parsed = birdTimelineEventSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid timeline 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 event = await createBirdTimelineEvent({ + birdId: bird.id, + eventType: parsed.data.eventType as BirdTimelineEventType, + toWorkspaceId: req.auth!.workspace.id, + locationLabel: emptyToNull(parsed.data.locationLabel), + note: emptyToNull(parsed.data.note), + eventDate: emptyToNull(parsed.data.eventDate), + createdByUserId: req.auth!.user.id, + }); + + await writeAuditLog(req.auth!, 'bird.timeline_event_created', 'bird', bird.id, bird.name, { + eventType: parsed.data.eventType, + }); + + res.status(201).json({ event: normalizeBirdTimelineEvent(event!) }); + } catch (error) { + next(error); + } + }, +); + app.get('/api/birds/:birdId/photo', async (req: Request, res: Response, next: NextFunction) => { try { const token = typeof req.query.token === 'string' ? req.query.token : ''; @@ -3619,6 +3752,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o motivators: emptyToNull(parsed.data.motivators), demotivators: emptyToNull(parsed.data.demotivators), favoriteSnack: emptyToNull(parsed.data.favoriteSnack), + locationLabel: emptyToNull(parsed.data.locationLabel), vetClinicName: emptyToNull(parsed.data.vetClinicName), vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), @@ -3642,6 +3776,13 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o species: bird!.species, tagId: bird!.tag_id, }); + await writeBirdTimelineEvent({ + birdId: bird!.id, + eventType: 'profile_created', + toWorkspaceId: req.auth!.workspace.id, + locationLabel: bird!.location_label, + createdByUserId: req.auth!.user.id, + }); res.status(201).json({ bird: normalizeBird(bird!) }); } catch (error) { await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); @@ -3726,6 +3867,13 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require destinationOwnerEmail, destinationWorkspaceId: targetWorkspace.id, }); + await writeBirdTimelineEvent({ + birdId: bird.id, + eventType: 'transferred', + fromWorkspaceId: req.auth!.workspace.id, + toWorkspaceId: targetWorkspace.id, + createdByUserId: req.auth!.user.id, + }); res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { @@ -3836,6 +3984,13 @@ app.post( sourceWorkspaceName: transferCode.workspace_name, transferCodeId: transferCode.transfer_code_id, }); + await writeBirdTimelineEvent({ + birdId: bird.id, + eventType: 'transferred', + fromWorkspaceId: transferCode.source_workspace_id, + toWorkspaceId: req.auth!.workspace.id, + createdByUserId: req.auth!.user.id, + }); res.json({ bird: normalizeBird(bird), sourceWorkspaceName: transferCode.workspace_name, workspace: normalizeWorkspace(req.auth!.workspace) }); } catch (error) { @@ -3963,6 +4118,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR motivators: emptyToNull(parsed.data.motivators), demotivators: emptyToNull(parsed.data.demotivators), favoriteSnack: emptyToNull(parsed.data.favoriteSnack), + locationLabel: emptyToNull(parsed.data.locationLabel), vetClinicName: emptyToNull(parsed.data.vetClinicName), vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), @@ -3992,6 +4148,15 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR previousName: existingBird.name, species: bird.species, }); + if ((existingBird.location_label ?? '') !== (bird.location_label ?? '')) { + await writeBirdTimelineEvent({ + birdId: bird.id, + eventType: 'location_updated', + toWorkspaceId: req.auth!.workspace.id, + locationLabel: bird.location_label, + createdByUserId: req.auth!.user.id, + }); + } res.json({ bird: normalizeBird(bird) }); } catch (error) { await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 7c8ca11..97e0e46 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -249,6 +249,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { motivators VARCHAR(1000), demotivators VARCHAR(1000), favorite_snack VARCHAR(160), + location_label VARCHAR(160), vet_clinic_name VARCHAR(160), vet_clinic_address VARCHAR(500), vet_account_number VARCHAR(120), @@ -277,6 +278,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160), + ADD COLUMN IF NOT EXISTS location_label VARCHAR(160), ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160), ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500), ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120), @@ -402,6 +404,30 @@ export const ensureSchema = async (database: DatabaseClient = db) => { WHERE completed_at IS NULL AND revoked_at IS NULL; + CREATE TABLE IF NOT EXISTS bird_timeline_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, + event_type VARCHAR(40) NOT NULL, + from_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL, + to_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL, + from_workspace_name VARCHAR(160), + to_workspace_name VARCHAR(160), + from_owner_email VARCHAR(255), + to_owner_email VARCHAR(255), + location_label VARCHAR(160), + note TEXT, + event_date DATE NOT NULL DEFAULT CURRENT_DATE, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + ALTER TABLE bird_timeline_events + ADD COLUMN IF NOT EXISTS note TEXT, + ADD COLUMN IF NOT EXISTS event_date DATE NOT NULL DEFAULT CURRENT_DATE; + + CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created + ON bird_timeline_events (bird_id, event_date DESC, created_at DESC); + CREATE TABLE IF NOT EXISTS flock_notes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, diff --git a/backend/src/repositories/birdRepository.test.ts b/backend/src/repositories/birdRepository.test.ts index 80b52f1..5c44c46 100644 --- a/backend/src/repositories/birdRepository.test.ts +++ b/backend/src/repositories/birdRepository.test.ts @@ -188,6 +188,45 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet ], }, { rowCount: 1, rows: [] }, + { + rowCount: 1, + rows: [ + { + workspace_id: 10, + workspace_name: 'Original Flock', + owner_email: 'sender@example.com', + }, + ], + }, + { + rowCount: 1, + rows: [ + { + workspace_id: 22, + workspace_name: 'Receiving Flock', + owner_email: 'receiver@example.com', + }, + ], + }, + { + rowCount: 1, + rows: [ + { + id: 'timeline-1', + bird_id: 'bird-1', + event_type: 'transferred', + from_workspace_id: 10, + to_workspace_id: 22, + from_workspace_name: 'Original Flock', + to_workspace_name: 'Receiving Flock', + from_owner_email: 'sender@example.com', + to_owner_email: 'receiver@example.com', + location_label: 'Receiving Flock', + created_by_user_id: 'user-1', + created_at: '2026-04-15T00:00:00.000Z', + }, + ], + }, ); const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22); @@ -197,4 +236,18 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet assert.deepEqual(calls[1].params, ['bird-1', 10, 22]); assert.deepEqual(calls[2].params, ['transfer-1', 22]); assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/); + assert.deepEqual(calls[5].params, [ + 'bird-1', + 'transferred', + 10, + 22, + 'Original Flock', + 'Receiving Flock', + 'sender@example.com', + 'receiver@example.com', + null, + null, + null, + 'user-1', + ]); }); diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index 15fff9c..179c0ea 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -5,6 +5,8 @@ import type { BirdMilestoneReminderDeliveryRow, BirdMilestoneReminderType, BirdRow, + BirdTimelineEventRow, + BirdTimelineEventType, BirdTransferCodeRow, LostBirdMatchRow, MedicationAdministrationRow, @@ -26,6 +28,7 @@ const birdSelectFields = ` birds.motivators, birds.demotivators, birds.favorite_snack, + birds.location_label, birds.vet_clinic_name, birds.vet_clinic_address, birds.vet_account_number, @@ -51,6 +54,34 @@ const birdSelectFields = ` latest.recorded_on::text AS latest_recorded_on `; +type WorkspaceTimelineSnapshot = { + workspace_id: number; + workspace_name: string; + owner_email: string | null; +}; + +const getWorkspaceTimelineSnapshot = async (workspaceId: number) => { + const result = await db.query( + `SELECT + workspaces.id AS workspace_id, + workspaces.name AS workspace_name, + COALESCE(workspaces.billing_email, owner_member.invite_email, owner_member.email) AS owner_email + FROM workspaces + LEFT JOIN LATERAL ( + SELECT invite_email, email + FROM workspace_members + WHERE workspace_members.workspace_id = workspaces.id + AND workspace_members.role = 'owner' + ORDER BY accepted_at DESC NULLS LAST, created_at ASC + LIMIT 1 + ) owner_member ON TRUE + WHERE workspaces.id = $1`, + [workspaceId], + ); + + return result.rows[0] ?? null; +}; + export const getBirdById = async (birdId: string, workspaceId: number) => { const result = await db.query( `SELECT @@ -134,6 +165,84 @@ export const listMemorializedBirds = async (workspaceId: number) => { return result.rows; }; +export const createBirdTimelineEvent = async ({ + birdId, + eventType, + fromWorkspaceId, + toWorkspaceId, + locationLabel, + note, + eventDate, + createdByUserId, +}: { + birdId: string; + eventType: BirdTimelineEventType; + fromWorkspaceId?: number | null; + toWorkspaceId?: number | null; + locationLabel?: string | null; + note?: string | null; + eventDate?: string | null; + createdByUserId?: string | null; +}) => { + const [fromWorkspace, toWorkspace] = await Promise.all([ + fromWorkspaceId ? getWorkspaceTimelineSnapshot(fromWorkspaceId) : Promise.resolve(null), + toWorkspaceId ? getWorkspaceTimelineSnapshot(toWorkspaceId) : Promise.resolve(null), + ]); + + const result = await db.query( + `INSERT INTO bird_timeline_events ( + bird_id, + event_type, + from_workspace_id, + to_workspace_id, + from_workspace_name, + to_workspace_name, + from_owner_email, + to_owner_email, + location_label, + note, + event_date, + created_by_user_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, $6, $5), $10, COALESCE($11::date, CURRENT_DATE), $12) + RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, note, event_date::text, created_by_user_id, created_at`, + [ + birdId, + eventType, + fromWorkspaceId ?? null, + toWorkspaceId ?? null, + fromWorkspace?.workspace_name ?? null, + toWorkspace?.workspace_name ?? null, + fromWorkspace?.owner_email ?? null, + toWorkspace?.owner_email ?? null, + locationLabel ?? null, + note ?? null, + eventDate ?? null, + createdByUserId ?? null, + ], + ); + + return result.rows[0] ?? null; +}; + +export const listBirdTimelineEvents = async (birdId: string, workspaceId: number) => { + const result = await db.query( + `SELECT id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, note, event_date::text, created_by_user_id, created_at + FROM bird_timeline_events + WHERE bird_id = $1 + AND EXISTS ( + SELECT 1 + FROM birds + WHERE birds.id = bird_timeline_events.bird_id + AND birds.workspace_id = $2 + ) + ORDER BY event_date DESC, created_at DESC`, + [birdId, workspaceId], + ); + + return result.rows; +}; + export const findBirdsByBandId = async (tagId: string) => { const result = await db.query( `SELECT @@ -367,6 +476,7 @@ export const createBird = async ({ motivators, demotivators, favoriteSnack, + locationLabel = null, vetClinicName = null, vetClinicAddress = null, vetAccountNumber = null, @@ -392,6 +502,7 @@ export const createBird = async ({ motivators: string | null; demotivators: string | null; favoriteSnack: string | null; + locationLabel?: string | null; vetClinicName?: string | null; vetClinicAddress?: string | null; vetAccountNumber?: string | null; @@ -410,9 +521,9 @@ export const createBird = async ({ publicProfileEnabled?: boolean; }) => { const result = await db.query( - `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled) - VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, + `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled) + VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, [ birdId ?? null, workspaceId, @@ -422,6 +533,7 @@ export const createBird = async ({ motivators, demotivators, favoriteSnack, + locationLabel, vetClinicName, vetClinicAddress, vetAccountNumber, @@ -453,6 +565,7 @@ export const updateBird = async ({ motivators, demotivators, favoriteSnack, + locationLabel, vetClinicName, vetClinicAddress, vetAccountNumber, @@ -478,6 +591,7 @@ export const updateBird = async ({ motivators: string | null; demotivators: string | null; favoriteSnack: string | null; + locationLabel: string | null; vetClinicName: string | null; vetClinicAddress: string | null; vetAccountNumber: string | null; @@ -503,26 +617,27 @@ export const updateBird = async ({ motivators = $5, demotivators = $6, favorite_snack = $7, - vet_clinic_name = $8, - vet_clinic_address = $9, - vet_account_number = $10, - vet_doctor_name = $11, - gender = $12, - date_of_birth = $13, - gotcha_day = $14, - chart_color = $15, - photo_data_url = $16, - photo_object_key = $17, - photo_content_type = $18, - photo_updated_at = $19, - notify_on_dob = $20, - notify_on_gotcha_day = $21, - public_profile_code = $22, - public_profile_enabled = $23 + location_label = $8, + vet_clinic_name = $9, + vet_clinic_address = $10, + vet_account_number = $11, + vet_doctor_name = $12, + gender = $13, + date_of_birth = $14, + gotcha_day = $15, + chart_color = $16, + photo_data_url = $17, + photo_object_key = $18, + photo_content_type = $19, + photo_updated_at = $20, + notify_on_dob = $21, + notify_on_gotcha_day = $22, + public_profile_code = $23, + public_profile_enabled = $24 WHERE id = $1 - AND workspace_id = $24 + AND workspace_id = $25 AND memorialized_at IS NULL - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -545,6 +660,7 @@ export const updateBird = async ({ motivators, demotivators, favoriteSnack, + locationLabel, vetClinicName, vetClinicAddress, vetAccountNumber, @@ -590,7 +706,7 @@ export const memorializeBird = async ({ WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NULL - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -626,7 +742,7 @@ export const updateMemorialReminderPreference = async ({ WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NOT NULL - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -666,7 +782,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId: WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NULL - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -762,6 +878,17 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t } await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId); + try { + await createBirdTimelineEvent({ + birdId: bird.id, + eventType: 'transferred', + fromWorkspaceId: transfer.source_workspace_id, + toWorkspaceId: targetWorkspaceId, + createdByUserId: transfer.requested_by_user_id, + }); + } catch (timelineError) { + console.error('Unable to write bird timeline event', timelineError); + } completed += 1; } catch (error) { failed += 1; diff --git a/backend/src/types.ts b/backend/src/types.ts index 2629bf5..9783482 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -130,6 +130,7 @@ export type BirdRow = { motivators: string | null; demotivators: string | null; favorite_snack: string | null; + location_label: string | null; vet_clinic_name: string | null; vet_clinic_address: string | null; vet_account_number: string | null; @@ -203,6 +204,25 @@ export type BirdTransferCodeRow = { created_at: string; }; +export type BirdTimelineEventType = 'profile_created' | 'transferred' | 'location_updated' | 'owner_changed' | 'manual_note'; + +export type BirdTimelineEventRow = { + id: string; + bird_id: string; + event_type: BirdTimelineEventType; + from_workspace_id: number | null; + to_workspace_id: number | null; + from_workspace_name: string | null; + to_workspace_name: string | null; + from_owner_email: string | null; + to_owner_email: string | null; + location_label: string | null; + note: string | null; + event_date: string; + created_by_user_id: string | null; + created_at: string; +}; + export type WeightRow = { id: string; bird_id: string; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7bb5a73..3043284 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ type Bird = { motivators: string | null; demotivators: string | null; favoriteSnack: string | null; + locationLabel: string | null; vetClinicName: string | null; vetClinicAddress: string | null; vetAccountNumber: string | null; @@ -224,6 +225,31 @@ type AuditLogEntry = { createdAt: string; }; +type BirdTimelineEvent = { + id: string; + birdId: string; + eventType: 'profile_created' | 'transferred' | 'location_updated' | 'owner_changed' | 'manual_note'; + fromWorkspaceId: number | null; + toWorkspaceId: number | null; + fromWorkspaceName: string | null; + toWorkspaceName: string | null; + fromOwnerEmail: string | null; + toOwnerEmail: string | null; + locationLabel: string | null; + note: string | null; + eventDate: string; + createdByUserId: string | null; + createdAt: string; +}; + +type BirdTimelineEventFormState = { + eventType: 'location_updated' | 'manual_note'; + ownerChanged: boolean; + eventDate: string; + locationLabel: string; + note: string; +}; + type IntegrationTokenFormState = { name: string; scope: IntegrationTokenScope; @@ -242,6 +268,7 @@ type BirdFormState = { motivators: string; demotivators: string; favoriteSnack: string; + locationLabel: string; vetClinicName: string; vetClinicAddress: string; vetAccountNumber: string; @@ -403,7 +430,7 @@ type WeightDropAlert = { }; type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit'; -type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'reports' | 'audit'; +type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'reports' | 'timeline' | 'audit'; type DismissedAlertMap = Record; type PhotoCropState = { @@ -425,7 +452,7 @@ type PhotoDragState = { }; type AppPage = 'overview' | 'flock' | 'settings' | 'admin'; -type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'bird-import' | 'transfer'; +type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'bird-import' | 'transfer'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; const sessionTokenStorageKey = 'flockpal_auth_token'; @@ -676,6 +703,7 @@ const emptyBirdForm: BirdFormState = { motivators: '', demotivators: '', favoriteSnack: '', + locationLabel: '', vetClinicName: '', vetClinicAddress: '', vetAccountNumber: '', @@ -690,6 +718,14 @@ const emptyBirdForm: BirdFormState = { publicProfileEnabled: false, }; +const emptyBirdTimelineEventForm: BirdTimelineEventFormState = { + eventType: 'location_updated', + ownerChanged: false, + eventDate: new Date().toISOString().slice(0, 10), + locationLabel: '', + note: '', +}; + const birdGenderOptions: BirdGender[] = ['female', 'female_dna', 'male', 'male_dna', 'unknown']; const emptyVeterinaryInfoForm: VeterinaryInfoFormState = { @@ -832,6 +868,7 @@ const toBirdForm = (bird: Bird): BirdFormState => ({ motivators: parseBirdProfileList(bird.motivators).join('\n'), demotivators: parseBirdProfileList(bird.demotivators).join('\n'), favoriteSnack: bird.favoriteSnack ?? '', + locationLabel: bird.locationLabel ?? '', vetClinicName: bird.vetClinicName ?? '', vetClinicAddress: bird.vetClinicAddress ?? '', vetAccountNumber: bird.vetAccountNumber ?? '', @@ -988,6 +1025,77 @@ const formatAuditAction = (value: string) => .map((part) => part.charAt(0).toUpperCase() + part.slice(1).replace(/_/g, ' ')) .join(' '); +const formatBirdTimelineTitle = (event: BirdTimelineEvent) => { + if (event.eventType === 'profile_created') { + return 'Added to flock'; + } + + if (event.eventType === 'location_updated') { + return 'Location updated'; + } + + if (event.eventType === 'owner_changed') { + return 'Owner record changed'; + } + + if (event.eventType === 'manual_note') { + return 'Timeline note'; + } + + return 'Moved between flocks'; +}; + +const formatBirdTimelineDescription = (event: BirdTimelineEvent) => { + if (event.eventType === 'profile_created') { + return event.locationLabel ? `Location: ${event.locationLabel}` : `Flock: ${event.toWorkspaceName || 'Unknown flock'}`; + } + + if (event.eventType === 'location_updated') { + return `Location: ${event.locationLabel || 'Not recorded'}`; + } + + if (event.eventType === 'owner_changed') { + return event.locationLabel ? `Owner changed at ${event.locationLabel}` : 'Owner changed'; + } + + if (event.eventType === 'manual_note') { + return event.locationLabel ? `Note at ${event.locationLabel}` : 'General timeline note'; + } + + const fromLocation = event.fromWorkspaceName || 'Previous flock'; + const toLocation = event.toWorkspaceName || event.locationLabel || 'Current flock'; + return `${fromLocation} to ${toLocation}`; +}; + +const formatBirdTimelineSecondary = (event: BirdTimelineEvent) => { + if (event.note) { + return event.note; + } + + if (event.eventType === 'transferred') { + return 'FlockPal transfer recorded'; + } + + if (event.eventType === 'owner_changed') { + return 'Owner changed without owner name'; + } + + return ''; +}; + +const getBirdTimelineLocation = (event: BirdTimelineEvent) => + event.locationLabel || event.toWorkspaceName || event.fromWorkspaceName || 'Unknown location'; + +const getBirdTimelineEventDate = (event: BirdTimelineEvent) => event.eventDate || event.createdAt.slice(0, 10); + +const sortBirdTimelineEvents = (events: BirdTimelineEvent[]) => + [...events].sort((left, right) => { + const dateComparison = getBirdTimelineEventDate(right).localeCompare(getBirdTimelineEventDate(left)); + return dateComparison || right.createdAt.localeCompare(left.createdAt); + }); + +const getBirdTimelineGraphEvents = (events: BirdTimelineEvent[]) => sortBirdTimelineEvents(events).reverse().slice(-8); + 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 parseDateValue = (value: string) => new Date(`${value}T00:00:00`); @@ -1632,6 +1740,8 @@ function App() { const [integrationTokens, setIntegrationTokens] = useState([]); const [flockNotes, setFlockNotes] = useState([]); const [auditLogEntries, setAuditLogEntries] = useState([]); + const [birdTimelineEvents, setBirdTimelineEvents] = useState([]); + const [birdTimelineEventForm, setBirdTimelineEventForm] = useState(emptyBirdTimelineEventForm); const [adminSummary, setAdminSummary] = useState(null); const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState([]); const [adminDailyEducation, setAdminDailyEducation] = useState([]); @@ -1689,6 +1799,8 @@ function App() { const [savingFlockNote, setSavingFlockNote] = useState(false); const [deletingFlockNoteId, setDeletingFlockNoteId] = useState(''); const [auditLogLoading, setAuditLogLoading] = useState(false); + const [birdTimelineLoading, setBirdTimelineLoading] = useState(false); + const [savingBirdTimelineEvent, setSavingBirdTimelineEvent] = useState(false); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState(null); @@ -1794,6 +1906,8 @@ function App() { useEffect(() => { setSelectedBirdTab('info'); + setBirdTimelineEvents([]); + setBirdTimelineEventForm(emptyBirdTimelineEventForm); }, [selectedBirdId]); useEffect(() => { @@ -2522,7 +2636,33 @@ function App() { }; void loadAuditLog(); - }, [activeMembership?.role, authToken, selectedBird, selectedBirdTab]); + }, [activeMembership?.role, authToken, selectedBird, selectedBirdTab]); + + useEffect(() => { + if (!authToken || selectedBirdTab !== 'timeline' || !selectedBird) { + return; + } + + const loadBirdTimeline = async () => { + try { + setBirdTimelineLoading(true); + const response = await apiFetch(`/birds/${selectedBird.id}/timeline`, authToken); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to load bird timeline.')); + } + + const data = (await readJsonSafely<{ events?: BirdTimelineEvent[] }>(response)) ?? {}; + setBirdTimelineEvents(sortBirdTimelineEvents(data.events ?? [])); + } catch (timelineError) { + setError(timelineError instanceof Error ? timelineError.message : 'Unable to load bird timeline.'); + } finally { + setBirdTimelineLoading(false); + } + }; + + void loadBirdTimeline(); + }, [authToken, selectedBird, selectedBirdTab]); useEffect(() => { if (!authToken || !authSession?.isAdmin || activePage !== 'admin') { @@ -3655,6 +3795,44 @@ function App() { } }; + const handleBirdTimelineEventSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!selectedBird || savingBirdTimelineEvent) { + return; + } + + setError(''); + setSavingBirdTimelineEvent(true); + + try { + const response = await apiFetch(`/birds/${selectedBird.id}/timeline`, authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...birdTimelineEventForm, + eventType: birdTimelineEventForm.ownerChanged ? 'owner_changed' : birdTimelineEventForm.eventType, + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to add timeline item.')); + } + + const data = await readJsonSafely<{ event?: BirdTimelineEvent }>(response); + if (!data?.event) { + throw new Error('Unable to add timeline item.'); + } + + setBirdTimelineEvents((current) => sortBirdTimelineEvents([data.event!, ...current.filter((entry) => entry.id !== data.event!.id)])); + setBirdTimelineEventForm(emptyBirdTimelineEventForm); + } catch (timelineError) { + setError(timelineError instanceof Error ? timelineError.message : 'Unable to add timeline item.'); + } finally { + setSavingBirdTimelineEvent(false); + } + }; + const handleVeterinaryInfoSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -6328,6 +6506,14 @@ function App() { ) : null} +
Gender
@@ -6807,6 +6993,19 @@ function App() { +
@@ -7475,6 +7675,151 @@ function App() {
) : null} + {selectedBirdTab === 'timeline' ? ( +
+
+
+
+

Timeline

+

{selectedBird.name} locations

+
+

{birdTimelineLoading ? 'Loading...' : `${birdTimelineEvents.length} events`}

+
+ {birdTimelineEvents.length ? ( +
+ + + + + + + + + {getBirdTimelineGraphEvents(birdTimelineEvents).map((timelineEvent, index, graphEvents) => { + const x = graphEvents.length === 1 ? 320 : 52 + (index / (graphEvents.length - 1)) * 536; + const y = 74; + const labelY = index % 2 === 0 ? 34 : 124; + const connectorEndY = index % 2 === 0 ? 50 : 104; + + return ( + + + + + {getBirdTimelineLocation(timelineEvent)} + + + {formatShortDate(getBirdTimelineEventDate(timelineEvent))} + + + ); + })} + +
+ ) : null} +
+ + + + {birdTimelineEventForm.eventType === 'location_updated' ? ( + + ) : null} +