From 9ddd85b5c4c7bfa3269791e4898e032eccd61f51 Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Sun, 28 Jun 2026 22:57:22 -0400 Subject: [PATCH] working on timeline locations --- backend/src/app.ts | 83 ++++- backend/src/db/schema.ts | 4 + .../src/repositories/birdRepository.test.ts | 2 + backend/src/repositories/birdRepository.ts | 38 ++- backend/src/types.ts | 2 + frontend/index.html | 1 + frontend/src/App.tsx | 322 ++++++++++++++++-- frontend/src/index.css | 19 ++ 8 files changed, 430 insertions(+), 41 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 67220f3..ac910ac 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -286,6 +286,19 @@ const birdProfileListSchema = z .optional() .or(z.literal('')); +const verifiedLocationDetailsSchema = z + .object({ + city: z.string().trim().max(120).optional().or(z.literal('')), + region: z.string().trim().max(120).optional().or(z.literal('')), + country: z.string().trim().max(120).optional().or(z.literal('')), + countryCode: z.string().trim().max(2).optional().or(z.literal('')), + latitude: z.coerce.number().min(-90).max(90).optional().nullable().or(z.literal('')), + longitude: z.coerce.number().min(-180).max(180).optional().nullable().or(z.literal('')), + precision: z.enum(['city', 'region', 'country']).optional(), + }) + .optional() + .nullable(); + const birdSchema = z.object({ name: z.string().trim().min(1).max(120), tagId: z.string().trim().max(80).optional().or(z.literal('')), @@ -294,6 +307,7 @@ const birdSchema = z.object({ demotivators: birdProfileListSchema, favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')), locationLabel: z.string().trim().max(160).optional().or(z.literal('')), + locationDetails: verifiedLocationDetailsSchema, 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('')), @@ -323,13 +337,56 @@ const birdTimelineEventSchema = z 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('')), + locationDetails: verifiedLocationDetailsSchema, note: z.string().trim().max(500).optional().or(z.literal('')), }) .refine( - (value) => value.eventType === 'owner_changed' || Boolean(value.locationLabel?.trim() || value.note?.trim()), + (value) => + value.eventType === 'owner_changed' || + Boolean( + value.locationLabel?.trim() || + value.note?.trim() || + value.locationDetails?.city?.trim() || + value.locationDetails?.region?.trim() || + value.locationDetails?.country?.trim(), + ), 'Add a location or note for this timeline item.', ); +type VerifiedLocationDetailsInput = z.infer; + +const normalizeVerifiedLocationDetails = (value: VerifiedLocationDetailsInput) => { + if (!value) { + return null; + } + + const city = value.city?.trim() || null; + const region = value.region?.trim() || null; + const country = value.country?.trim() || null; + const countryCode = value.countryCode?.trim().toUpperCase() || null; + const latitude = typeof value.latitude === 'number' ? Number(value.latitude.toFixed(4)) : null; + const longitude = typeof value.longitude === 'number' ? Number(value.longitude.toFixed(4)) : null; + const precision = value.precision ?? (city ? 'city' : region ? 'region' : country ? 'country' : null); + + if (!city && !region && !country && !countryCode && latitude === null && longitude === null) { + return null; + } + + return { + city, + region, + country, + countryCode, + latitude, + longitude, + precision, + verifiedAt: new Date().toISOString(), + }; +}; + +const formatVerifiedLocationLabel = (details: ReturnType) => + details ? [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null; + const weightSchema = z.object({ weightGrams: z.coerce.number().positive().max(10000), recordedOn: dateStringSchema, @@ -707,6 +764,7 @@ const normalizeBird = (row: BirdRow) => ({ demotivators: row.demotivators, favoriteSnack: row.favorite_snack, locationLabel: row.location_label, + locationDetails: row.location_details, vetClinicName: row.vet_clinic_name, vetClinicAddress: row.vet_clinic_address, vetAccountNumber: row.vet_account_number, @@ -825,6 +883,7 @@ const normalizeBirdTimelineEvent = (row: BirdTimelineEventRow) => ({ fromOwnerEmail: row.from_owner_email, toOwnerEmail: row.to_owner_email, locationLabel: row.location_label, + locationDetails: row.location_details, note: row.note, eventDate: row.event_date, createdByUserId: row.created_by_user_id, @@ -2367,6 +2426,7 @@ const writeBirdTimelineEvent = async ({ fromWorkspaceId, toWorkspaceId, locationLabel, + locationDetails, note, eventDate, createdByUserId, @@ -2376,6 +2436,7 @@ const writeBirdTimelineEvent = async ({ fromWorkspaceId?: number | null; toWorkspaceId?: number | null; locationLabel?: string | null; + locationDetails?: Record | null; note?: string | null; eventDate?: string | null; createdByUserId?: string | null; @@ -2387,6 +2448,7 @@ const writeBirdTimelineEvent = async ({ fromWorkspaceId, toWorkspaceId, locationLabel, + locationDetails, note, eventDate, createdByUserId, @@ -3644,11 +3706,13 @@ app.post( return; } + const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails); const event = await createBirdTimelineEvent({ birdId: bird.id, eventType: parsed.data.eventType as BirdTimelineEventType, toWorkspaceId: req.auth!.workspace.id, - locationLabel: emptyToNull(parsed.data.locationLabel), + locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel), + locationDetails, note: emptyToNull(parsed.data.note), eventDate: emptyToNull(parsed.data.eventDate), createdByUserId: req.auth!.user.id, @@ -3742,6 +3806,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o workspaceId: req.auth!.workspace.id, photoDataUrl: emptyToNull(parsed.data.photoDataUrl), }); + const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails); uploadedObjectKeyToCleanup = photoStorage.photoObjectKey; const bird = await createBird({ birdId, @@ -3752,7 +3817,8 @@ 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), + locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel), + locationDetails, vetClinicName: emptyToNull(parsed.data.vetClinicName), vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), @@ -3781,6 +3847,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o eventType: 'profile_created', toWorkspaceId: req.auth!.workspace.id, locationLabel: bird!.location_label, + locationDetails: bird!.location_details, createdByUserId: req.auth!.user.id, }); res.status(201).json({ bird: normalizeBird(bird!) }); @@ -4109,6 +4176,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR }); uploadedObjectKeyToCleanup = photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null; + const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails); const bird = await updateBird({ birdId: req.params.birdId, workspaceId: req.auth!.workspace.id, @@ -4118,7 +4186,8 @@ 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), + locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel), + locationDetails, vetClinicName: emptyToNull(parsed.data.vetClinicName), vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), @@ -4148,12 +4217,16 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR previousName: existingBird.name, species: bird.species, }); - if ((existingBird.location_label ?? '') !== (bird.location_label ?? '')) { + if ( + (existingBird.location_label ?? '') !== (bird.location_label ?? '') || + JSON.stringify(existingBird.location_details ?? null) !== JSON.stringify(bird.location_details ?? null) + ) { await writeBirdTimelineEvent({ birdId: bird.id, eventType: 'location_updated', toWorkspaceId: req.auth!.workspace.id, locationLabel: bird.location_label, + locationDetails: bird.location_details, createdByUserId: req.auth!.user.id, }); } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 97e0e46..4d83ff6 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -250,6 +250,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { demotivators VARCHAR(1000), favorite_snack VARCHAR(160), location_label VARCHAR(160), + location_details JSONB, vet_clinic_name VARCHAR(160), vet_clinic_address VARCHAR(500), vet_account_number VARCHAR(120), @@ -279,6 +280,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { 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 location_details JSONB, 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), @@ -415,6 +417,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { from_owner_email VARCHAR(255), to_owner_email VARCHAR(255), location_label VARCHAR(160), + location_details JSONB, note TEXT, event_date DATE NOT NULL DEFAULT CURRENT_DATE, created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, @@ -423,6 +426,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ALTER TABLE bird_timeline_events ADD COLUMN IF NOT EXISTS note TEXT, + ADD COLUMN IF NOT EXISTS location_details JSONB, ADD COLUMN IF NOT EXISTS event_date DATE NOT NULL DEFAULT CURRENT_DATE; CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created diff --git a/backend/src/repositories/birdRepository.test.ts b/backend/src/repositories/birdRepository.test.ts index 5c44c46..4f8b355 100644 --- a/backend/src/repositories/birdRepository.test.ts +++ b/backend/src/repositories/birdRepository.test.ts @@ -222,6 +222,7 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet from_owner_email: 'sender@example.com', to_owner_email: 'receiver@example.com', location_label: 'Receiving Flock', + location_details: null, created_by_user_id: 'user-1', created_at: '2026-04-15T00:00:00.000Z', }, @@ -249,5 +250,6 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet null, null, 'user-1', + null, ]); }); diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index 179c0ea..408b81f 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -29,6 +29,7 @@ const birdSelectFields = ` birds.demotivators, birds.favorite_snack, birds.location_label, + birds.location_details, birds.vet_clinic_name, birds.vet_clinic_address, birds.vet_account_number, @@ -171,6 +172,7 @@ export const createBirdTimelineEvent = async ({ fromWorkspaceId, toWorkspaceId, locationLabel, + locationDetails, note, eventDate, createdByUserId, @@ -180,6 +182,7 @@ export const createBirdTimelineEvent = async ({ fromWorkspaceId?: number | null; toWorkspaceId?: number | null; locationLabel?: string | null; + locationDetails?: Record | null; note?: string | null; eventDate?: string | null; createdByUserId?: string | null; @@ -202,10 +205,11 @@ export const createBirdTimelineEvent = async ({ location_label, note, event_date, - created_by_user_id + created_by_user_id, + location_details ) - 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`, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9::text, $6::text, $5::text), $10, COALESCE($11::date, CURRENT_DATE), $12, $13) + 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, location_details, note, event_date::text, created_by_user_id, created_at`, [ birdId, eventType, @@ -219,6 +223,7 @@ export const createBirdTimelineEvent = async ({ note ?? null, eventDate ?? null, createdByUserId ?? null, + locationDetails ?? null, ], ); @@ -227,7 +232,7 @@ export const createBirdTimelineEvent = async ({ 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 + `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, location_details, note, event_date::text, created_by_user_id, created_at FROM bird_timeline_events WHERE bird_id = $1 AND EXISTS ( @@ -477,6 +482,7 @@ export const createBird = async ({ demotivators, favoriteSnack, locationLabel = null, + locationDetails = null, vetClinicName = null, vetClinicAddress = null, vetAccountNumber = null, @@ -503,6 +509,7 @@ export const createBird = async ({ demotivators: string | null; favoriteSnack: string | null; locationLabel?: string | null; + locationDetails?: Record | null; vetClinicName?: string | null; vetClinicAddress?: string | null; vetAccountNumber?: string | null; @@ -521,9 +528,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, 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`, + `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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, $26) + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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, @@ -534,6 +541,7 @@ export const createBird = async ({ demotivators, favoriteSnack, locationLabel, + locationDetails, vetClinicName, vetClinicAddress, vetAccountNumber, @@ -566,6 +574,7 @@ export const updateBird = async ({ demotivators, favoriteSnack, locationLabel, + locationDetails, vetClinicName, vetClinicAddress, vetAccountNumber, @@ -592,6 +601,7 @@ export const updateBird = async ({ demotivators: string | null; favoriteSnack: string | null; locationLabel: string | null; + locationDetails?: Record | null; vetClinicName: string | null; vetClinicAddress: string | null; vetAccountNumber: string | null; @@ -633,11 +643,12 @@ export const updateBird = async ({ notify_on_dob = $21, notify_on_gotcha_day = $22, public_profile_code = $23, - public_profile_enabled = $24 + public_profile_enabled = $24, + location_details = $25 WHERE id = $1 - AND workspace_id = $25 + AND workspace_id = $26 AND memorialized_at IS NULL - 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, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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 @@ -677,6 +688,7 @@ export const updateBird = async ({ notifyOnGotchaDay, publicProfileCode, publicProfileEnabled, + locationDetails ?? null, workspaceId, ], ); @@ -706,7 +718,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, 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, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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 @@ -742,7 +754,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, 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, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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 @@ -782,7 +794,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, 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, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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 diff --git a/backend/src/types.ts b/backend/src/types.ts index 9783482..ff415a0 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -131,6 +131,7 @@ export type BirdRow = { demotivators: string | null; favorite_snack: string | null; location_label: string | null; + location_details: Record | null; vet_clinic_name: string | null; vet_clinic_address: string | null; vet_account_number: string | null; @@ -217,6 +218,7 @@ export type BirdTimelineEventRow = { from_owner_email: string | null; to_owner_email: string | null; location_label: string | null; + location_details: Record | null; note: string | null; event_date: string; created_by_user_id: string | null; diff --git a/frontend/index.html b/frontend/index.html index 4d7fc99..4e1d880 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@ type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='featherFill' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%23cb3a35'/%3E%3Cstop offset='30%25' stop-color='%23f0b63f'/%3E%3Cstop offset='58%25' stop-color='%23238a5a'/%3E%3Cstop offset='100%25' stop-color='%232769b3'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath d='M50.8 10.4C37.9 10.3 27 18.5 22.7 31.1c-3.1 9.1-2.1 18.5-8.6 24.8c-1.5 1.5-0.2 4 1.9 3.6c8.4-1.5 14.6-6.7 18.6-13.7c1 0.5 2.2 0.8 3.4 0.8c3.5 0 6.5-2.3 7.5-5.4c1.9-0.4 3.7-1.3 5.1-2.7c2-2 3-4.6 3.1-7.2c3.3-5.8 4.9-12.9 1.4-20.2c-0.7-1.3-2-0.7-4.3-0.7Z' fill='url(%23featherFill)'/%3E%3Cpath d='M18 56c8.5-3.4 14.2-9.8 18.1-17.8M26.9 48.9c6.9-7.2 13.5-14.8 20.3-22.1M31.8 41.2c6.4-1.3 12.1-4.6 16.5-9.4M36.8 33.8c4.9-0.9 9.2-3.4 12.6-7.1' fill='none' stroke='%23fff8ef' stroke-linecap='round' stroke-width='2.6'/%3E%3Cpath d='M18 56c8.5-3.4 14.2-9.8 18.1-17.8' fill='none' stroke='%2363562d' stroke-linecap='round' stroke-width='2.2'/%3E%3C/svg%3E" /> + FlockPal diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3043284..890714d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,17 @@ type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejec type IntegrationTokenScope = 'read_only' | 'read_write'; type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna'; +type VerifiedLocationDetails = { + city: string; + region: string; + country: string; + countryCode: string; + latitude: number | null; + longitude: number | null; + precision: 'city' | 'region' | 'country'; + verifiedAt?: string; +}; + type Bird = { id: string; workspaceId?: number; @@ -26,6 +37,7 @@ type Bird = { demotivators: string | null; favoriteSnack: string | null; locationLabel: string | null; + locationDetails: VerifiedLocationDetails | null; vetClinicName: string | null; vetClinicAddress: string | null; vetAccountNumber: string | null; @@ -236,6 +248,7 @@ type BirdTimelineEvent = { fromOwnerEmail: string | null; toOwnerEmail: string | null; locationLabel: string | null; + locationDetails: VerifiedLocationDetails | null; note: string | null; eventDate: string; createdByUserId: string | null; @@ -243,10 +256,11 @@ type BirdTimelineEvent = { }; type BirdTimelineEventFormState = { - eventType: 'location_updated' | 'manual_note'; + eventType: 'location_updated' | 'owner_changed' | 'manual_note'; ownerChanged: boolean; eventDate: string; locationLabel: string; + locationDetails: VerifiedLocationDetails; note: string; }; @@ -269,6 +283,7 @@ type BirdFormState = { demotivators: string; favoriteSnack: string; locationLabel: string; + locationDetails: VerifiedLocationDetails; vetClinicName: string; vetClinicAddress: string; vetAccountNumber: string; @@ -696,6 +711,32 @@ const parseBirdImportRows = (rows: Record[]): BirdImportPreview errors, }; }; + +const emptyVerifiedLocationDetails: VerifiedLocationDetails = { + city: '', + region: '', + country: '', + countryCode: '', + latitude: null, + longitude: null, + precision: 'city', +}; + +const normalizeVerifiedLocationDetails = (details: Partial | null | undefined): VerifiedLocationDetails => ({ + ...emptyVerifiedLocationDetails, + ...details, + city: details?.city ?? '', + region: details?.region ?? '', + country: details?.country ?? '', + countryCode: details?.countryCode ?? '', + latitude: typeof details?.latitude === 'number' ? details.latitude : null, + longitude: typeof details?.longitude === 'number' ? details.longitude : null, + precision: details?.precision ?? 'city', +}); + +const formatVerifiedLocationLabel = (details: VerifiedLocationDetails) => + [details.city.trim(), details.region.trim(), details.country.trim()].filter(Boolean).join(', '); + const emptyBirdForm: BirdFormState = { name: '', tagId: '', @@ -704,6 +745,7 @@ const emptyBirdForm: BirdFormState = { demotivators: '', favoriteSnack: '', locationLabel: '', + locationDetails: emptyVerifiedLocationDetails, vetClinicName: '', vetClinicAddress: '', vetAccountNumber: '', @@ -723,6 +765,7 @@ const emptyBirdTimelineEventForm: BirdTimelineEventFormState = { ownerChanged: false, eventDate: new Date().toISOString().slice(0, 10), locationLabel: '', + locationDetails: emptyVerifiedLocationDetails, note: '', }; @@ -869,6 +912,7 @@ const toBirdForm = (bird: Bird): BirdFormState => ({ demotivators: parseBirdProfileList(bird.demotivators).join('\n'), favoriteSnack: bird.favoriteSnack ?? '', locationLabel: bird.locationLabel ?? '', + locationDetails: normalizeVerifiedLocationDetails(bird.locationDetails), vetClinicName: bird.vetClinicName ?? '', vetClinicAddress: bird.vetClinicAddress ?? '', vetAccountNumber: bird.vetAccountNumber ?? '', @@ -1801,6 +1845,7 @@ function App() { const [auditLogLoading, setAuditLogLoading] = useState(false); const [birdTimelineLoading, setBirdTimelineLoading] = useState(false); const [savingBirdTimelineEvent, setSavingBirdTimelineEvent] = useState(false); + const [birdTimelineNotice, setBirdTimelineNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState(null); @@ -1908,6 +1953,7 @@ function App() { setSelectedBirdTab('info'); setBirdTimelineEvents([]); setBirdTimelineEventForm(emptyBirdTimelineEventForm); + setBirdTimelineNotice(null); }, [selectedBirdId]); useEffect(() => { @@ -3759,10 +3805,11 @@ function App() { const method = isEditing ? 'PUT' : 'POST'; try { + const locationLabel = formatVerifiedLocationLabel(birdForm.locationDetails) || birdForm.locationLabel; const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, { method, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(birdForm), + body: JSON.stringify({ ...birdForm, locationLabel }), }); if (!response.ok) { @@ -3803,15 +3850,34 @@ function App() { } setError(''); + setBirdTimelineNotice(null); setSavingBirdTimelineEvent(true); try { + const eventType = birdTimelineEventForm.ownerChanged ? 'owner_changed' : birdTimelineEventForm.eventType; + const verifiedLocationLabel = formatVerifiedLocationLabel(birdTimelineEventForm.locationDetails); + const locationLabel = + birdTimelineEventForm.eventType === 'location_updated' + ? verifiedLocationLabel || birdTimelineEventForm.locationLabel.trim() || selectedBird.locationLabel || '' + : birdTimelineEventForm.locationLabel; + const note = birdTimelineEventForm.note.trim(); + + if (eventType !== 'owner_changed' && !locationLabel.trim() && !note) { + setBirdTimelineNotice({ + kind: 'error', + message: 'Add a location or note before adding this timeline item.', + }); + return; + } + 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, + eventType, + locationLabel, + note, }), }); @@ -3826,8 +3892,12 @@ function App() { setBirdTimelineEvents((current) => sortBirdTimelineEvents([data.event!, ...current.filter((entry) => entry.id !== data.event!.id)])); setBirdTimelineEventForm(emptyBirdTimelineEventForm); + setBirdTimelineNotice({ kind: 'success', message: 'Timeline item added.' }); } catch (timelineError) { - setError(timelineError instanceof Error ? timelineError.message : 'Unable to add timeline item.'); + setBirdTimelineNotice({ + kind: 'error', + message: timelineError instanceof Error ? timelineError.message : 'Unable to add timeline item.', + }); } finally { setSavingBirdTimelineEvent(false); } @@ -6506,8 +6576,103 @@ function App() { ) : null} +
+

Location

+

Mappable location

+
+ + + + + +