working on timeline locations
This commit is contained in:
+78
-5
@@ -286,6 +286,19 @@ const birdProfileListSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.or(z.literal(''));
|
.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({
|
const birdSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
||||||
@@ -294,6 +307,7 @@ const birdSchema = z.object({
|
|||||||
demotivators: birdProfileListSchema,
|
demotivators: birdProfileListSchema,
|
||||||
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
|
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
locationLabel: 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('')),
|
vetClinicName: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')),
|
vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')),
|
||||||
vetAccountNumber: z.string().trim().max(120).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']),
|
eventType: z.enum(['location_updated', 'owner_changed', 'manual_note']),
|
||||||
eventDate: dateStringSchema.optional().or(z.literal('')),
|
eventDate: dateStringSchema.optional().or(z.literal('')),
|
||||||
locationLabel: z.string().trim().max(160).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('')),
|
note: z.string().trim().max(500).optional().or(z.literal('')),
|
||||||
})
|
})
|
||||||
.refine(
|
.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.',
|
'Add a location or note for this timeline item.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type VerifiedLocationDetailsInput = z.infer<typeof verifiedLocationDetailsSchema>;
|
||||||
|
|
||||||
|
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<typeof normalizeVerifiedLocationDetails>) =>
|
||||||
|
details ? [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null;
|
||||||
|
|
||||||
const weightSchema = z.object({
|
const weightSchema = z.object({
|
||||||
weightGrams: z.coerce.number().positive().max(10000),
|
weightGrams: z.coerce.number().positive().max(10000),
|
||||||
recordedOn: dateStringSchema,
|
recordedOn: dateStringSchema,
|
||||||
@@ -707,6 +764,7 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
demotivators: row.demotivators,
|
demotivators: row.demotivators,
|
||||||
favoriteSnack: row.favorite_snack,
|
favoriteSnack: row.favorite_snack,
|
||||||
locationLabel: row.location_label,
|
locationLabel: row.location_label,
|
||||||
|
locationDetails: row.location_details,
|
||||||
vetClinicName: row.vet_clinic_name,
|
vetClinicName: row.vet_clinic_name,
|
||||||
vetClinicAddress: row.vet_clinic_address,
|
vetClinicAddress: row.vet_clinic_address,
|
||||||
vetAccountNumber: row.vet_account_number,
|
vetAccountNumber: row.vet_account_number,
|
||||||
@@ -825,6 +883,7 @@ const normalizeBirdTimelineEvent = (row: BirdTimelineEventRow) => ({
|
|||||||
fromOwnerEmail: row.from_owner_email,
|
fromOwnerEmail: row.from_owner_email,
|
||||||
toOwnerEmail: row.to_owner_email,
|
toOwnerEmail: row.to_owner_email,
|
||||||
locationLabel: row.location_label,
|
locationLabel: row.location_label,
|
||||||
|
locationDetails: row.location_details,
|
||||||
note: row.note,
|
note: row.note,
|
||||||
eventDate: row.event_date,
|
eventDate: row.event_date,
|
||||||
createdByUserId: row.created_by_user_id,
|
createdByUserId: row.created_by_user_id,
|
||||||
@@ -2367,6 +2426,7 @@ const writeBirdTimelineEvent = async ({
|
|||||||
fromWorkspaceId,
|
fromWorkspaceId,
|
||||||
toWorkspaceId,
|
toWorkspaceId,
|
||||||
locationLabel,
|
locationLabel,
|
||||||
|
locationDetails,
|
||||||
note,
|
note,
|
||||||
eventDate,
|
eventDate,
|
||||||
createdByUserId,
|
createdByUserId,
|
||||||
@@ -2376,6 +2436,7 @@ const writeBirdTimelineEvent = async ({
|
|||||||
fromWorkspaceId?: number | null;
|
fromWorkspaceId?: number | null;
|
||||||
toWorkspaceId?: number | null;
|
toWorkspaceId?: number | null;
|
||||||
locationLabel?: string | null;
|
locationLabel?: string | null;
|
||||||
|
locationDetails?: Record<string, unknown> | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
eventDate?: string | null;
|
eventDate?: string | null;
|
||||||
createdByUserId?: string | null;
|
createdByUserId?: string | null;
|
||||||
@@ -2387,6 +2448,7 @@ const writeBirdTimelineEvent = async ({
|
|||||||
fromWorkspaceId,
|
fromWorkspaceId,
|
||||||
toWorkspaceId,
|
toWorkspaceId,
|
||||||
locationLabel,
|
locationLabel,
|
||||||
|
locationDetails,
|
||||||
note,
|
note,
|
||||||
eventDate,
|
eventDate,
|
||||||
createdByUserId,
|
createdByUserId,
|
||||||
@@ -3644,11 +3706,13 @@ app.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
|
||||||
const event = await createBirdTimelineEvent({
|
const event = await createBirdTimelineEvent({
|
||||||
birdId: bird.id,
|
birdId: bird.id,
|
||||||
eventType: parsed.data.eventType as BirdTimelineEventType,
|
eventType: parsed.data.eventType as BirdTimelineEventType,
|
||||||
toWorkspaceId: req.auth!.workspace.id,
|
toWorkspaceId: req.auth!.workspace.id,
|
||||||
locationLabel: emptyToNull(parsed.data.locationLabel),
|
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
|
||||||
|
locationDetails,
|
||||||
note: emptyToNull(parsed.data.note),
|
note: emptyToNull(parsed.data.note),
|
||||||
eventDate: emptyToNull(parsed.data.eventDate),
|
eventDate: emptyToNull(parsed.data.eventDate),
|
||||||
createdByUserId: req.auth!.user.id,
|
createdByUserId: req.auth!.user.id,
|
||||||
@@ -3742,6 +3806,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
workspaceId: req.auth!.workspace.id,
|
workspaceId: req.auth!.workspace.id,
|
||||||
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
||||||
});
|
});
|
||||||
|
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
|
||||||
uploadedObjectKeyToCleanup = photoStorage.photoObjectKey;
|
uploadedObjectKeyToCleanup = photoStorage.photoObjectKey;
|
||||||
const bird = await createBird({
|
const bird = await createBird({
|
||||||
birdId,
|
birdId,
|
||||||
@@ -3752,7 +3817,8 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
motivators: emptyToNull(parsed.data.motivators),
|
motivators: emptyToNull(parsed.data.motivators),
|
||||||
demotivators: emptyToNull(parsed.data.demotivators),
|
demotivators: emptyToNull(parsed.data.demotivators),
|
||||||
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
||||||
locationLabel: emptyToNull(parsed.data.locationLabel),
|
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
|
||||||
|
locationDetails,
|
||||||
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
||||||
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
||||||
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
||||||
@@ -3781,6 +3847,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
eventType: 'profile_created',
|
eventType: 'profile_created',
|
||||||
toWorkspaceId: req.auth!.workspace.id,
|
toWorkspaceId: req.auth!.workspace.id,
|
||||||
locationLabel: bird!.location_label,
|
locationLabel: bird!.location_label,
|
||||||
|
locationDetails: bird!.location_details,
|
||||||
createdByUserId: req.auth!.user.id,
|
createdByUserId: req.auth!.user.id,
|
||||||
});
|
});
|
||||||
res.status(201).json({ bird: normalizeBird(bird!) });
|
res.status(201).json({ bird: normalizeBird(bird!) });
|
||||||
@@ -4109,6 +4176,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
});
|
});
|
||||||
uploadedObjectKeyToCleanup =
|
uploadedObjectKeyToCleanup =
|
||||||
photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null;
|
photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null;
|
||||||
|
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
|
||||||
const bird = await updateBird({
|
const bird = await updateBird({
|
||||||
birdId: req.params.birdId,
|
birdId: req.params.birdId,
|
||||||
workspaceId: req.auth!.workspace.id,
|
workspaceId: req.auth!.workspace.id,
|
||||||
@@ -4118,7 +4186,8 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
motivators: emptyToNull(parsed.data.motivators),
|
motivators: emptyToNull(parsed.data.motivators),
|
||||||
demotivators: emptyToNull(parsed.data.demotivators),
|
demotivators: emptyToNull(parsed.data.demotivators),
|
||||||
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
||||||
locationLabel: emptyToNull(parsed.data.locationLabel),
|
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
|
||||||
|
locationDetails,
|
||||||
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
||||||
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
||||||
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
||||||
@@ -4148,12 +4217,16 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
previousName: existingBird.name,
|
previousName: existingBird.name,
|
||||||
species: bird.species,
|
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({
|
await writeBirdTimelineEvent({
|
||||||
birdId: bird.id,
|
birdId: bird.id,
|
||||||
eventType: 'location_updated',
|
eventType: 'location_updated',
|
||||||
toWorkspaceId: req.auth!.workspace.id,
|
toWorkspaceId: req.auth!.workspace.id,
|
||||||
locationLabel: bird.location_label,
|
locationLabel: bird.location_label,
|
||||||
|
locationDetails: bird.location_details,
|
||||||
createdByUserId: req.auth!.user.id,
|
createdByUserId: req.auth!.user.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
demotivators VARCHAR(1000),
|
demotivators VARCHAR(1000),
|
||||||
favorite_snack VARCHAR(160),
|
favorite_snack VARCHAR(160),
|
||||||
location_label VARCHAR(160),
|
location_label VARCHAR(160),
|
||||||
|
location_details JSONB,
|
||||||
vet_clinic_name VARCHAR(160),
|
vet_clinic_name VARCHAR(160),
|
||||||
vet_clinic_address VARCHAR(500),
|
vet_clinic_address VARCHAR(500),
|
||||||
vet_account_number VARCHAR(120),
|
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 demotivators VARCHAR(1000),
|
||||||
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
|
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
|
||||||
ADD COLUMN IF NOT EXISTS location_label 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_name VARCHAR(160),
|
||||||
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
|
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
|
||||||
ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120),
|
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),
|
from_owner_email VARCHAR(255),
|
||||||
to_owner_email VARCHAR(255),
|
to_owner_email VARCHAR(255),
|
||||||
location_label VARCHAR(160),
|
location_label VARCHAR(160),
|
||||||
|
location_details JSONB,
|
||||||
note TEXT,
|
note TEXT,
|
||||||
event_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
event_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
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
|
ALTER TABLE bird_timeline_events
|
||||||
ADD COLUMN IF NOT EXISTS note TEXT,
|
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;
|
ADD COLUMN IF NOT EXISTS event_date DATE NOT NULL DEFAULT CURRENT_DATE;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created
|
CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
|||||||
from_owner_email: 'sender@example.com',
|
from_owner_email: 'sender@example.com',
|
||||||
to_owner_email: 'receiver@example.com',
|
to_owner_email: 'receiver@example.com',
|
||||||
location_label: 'Receiving Flock',
|
location_label: 'Receiving Flock',
|
||||||
|
location_details: null,
|
||||||
created_by_user_id: 'user-1',
|
created_by_user_id: 'user-1',
|
||||||
created_at: '2026-04-15T00:00:00.000Z',
|
created_at: '2026-04-15T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
@@ -249,5 +250,6 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
'user-1',
|
'user-1',
|
||||||
|
null,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const birdSelectFields = `
|
|||||||
birds.demotivators,
|
birds.demotivators,
|
||||||
birds.favorite_snack,
|
birds.favorite_snack,
|
||||||
birds.location_label,
|
birds.location_label,
|
||||||
|
birds.location_details,
|
||||||
birds.vet_clinic_name,
|
birds.vet_clinic_name,
|
||||||
birds.vet_clinic_address,
|
birds.vet_clinic_address,
|
||||||
birds.vet_account_number,
|
birds.vet_account_number,
|
||||||
@@ -171,6 +172,7 @@ export const createBirdTimelineEvent = async ({
|
|||||||
fromWorkspaceId,
|
fromWorkspaceId,
|
||||||
toWorkspaceId,
|
toWorkspaceId,
|
||||||
locationLabel,
|
locationLabel,
|
||||||
|
locationDetails,
|
||||||
note,
|
note,
|
||||||
eventDate,
|
eventDate,
|
||||||
createdByUserId,
|
createdByUserId,
|
||||||
@@ -180,6 +182,7 @@ export const createBirdTimelineEvent = async ({
|
|||||||
fromWorkspaceId?: number | null;
|
fromWorkspaceId?: number | null;
|
||||||
toWorkspaceId?: number | null;
|
toWorkspaceId?: number | null;
|
||||||
locationLabel?: string | null;
|
locationLabel?: string | null;
|
||||||
|
locationDetails?: Record<string, unknown> | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
eventDate?: string | null;
|
eventDate?: string | null;
|
||||||
createdByUserId?: string | null;
|
createdByUserId?: string | null;
|
||||||
@@ -202,10 +205,11 @@ export const createBirdTimelineEvent = async ({
|
|||||||
location_label,
|
location_label,
|
||||||
note,
|
note,
|
||||||
event_date,
|
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)
|
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, note, event_date::text, created_by_user_id, created_at`,
|
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,
|
birdId,
|
||||||
eventType,
|
eventType,
|
||||||
@@ -219,6 +223,7 @@ export const createBirdTimelineEvent = async ({
|
|||||||
note ?? null,
|
note ?? null,
|
||||||
eventDate ?? null,
|
eventDate ?? null,
|
||||||
createdByUserId ?? null,
|
createdByUserId ?? null,
|
||||||
|
locationDetails ?? null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -227,7 +232,7 @@ export const createBirdTimelineEvent = async ({
|
|||||||
|
|
||||||
export const listBirdTimelineEvents = async (birdId: string, workspaceId: number) => {
|
export const listBirdTimelineEvents = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<BirdTimelineEventRow>(
|
const result = await db.query<BirdTimelineEventRow>(
|
||||||
`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
|
FROM bird_timeline_events
|
||||||
WHERE bird_id = $1
|
WHERE bird_id = $1
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
@@ -477,6 +482,7 @@ export const createBird = async ({
|
|||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
locationLabel = null,
|
locationLabel = null,
|
||||||
|
locationDetails = null,
|
||||||
vetClinicName = null,
|
vetClinicName = null,
|
||||||
vetClinicAddress = null,
|
vetClinicAddress = null,
|
||||||
vetAccountNumber = null,
|
vetAccountNumber = null,
|
||||||
@@ -503,6 +509,7 @@ export const createBird = async ({
|
|||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favoriteSnack: string | null;
|
favoriteSnack: string | null;
|
||||||
locationLabel?: string | null;
|
locationLabel?: string | null;
|
||||||
|
locationDetails?: Record<string, unknown> | null;
|
||||||
vetClinicName?: string | null;
|
vetClinicName?: string | null;
|
||||||
vetClinicAddress?: string | null;
|
vetClinicAddress?: string | null;
|
||||||
vetAccountNumber?: string | null;
|
vetAccountNumber?: string | null;
|
||||||
@@ -521,9 +528,9 @@ export const createBird = async ({
|
|||||||
publicProfileEnabled?: boolean;
|
publicProfileEnabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`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)
|
`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)
|
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, 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`,
|
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,
|
birdId ?? null,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -534,6 +541,7 @@ export const createBird = async ({
|
|||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
locationLabel,
|
locationLabel,
|
||||||
|
locationDetails,
|
||||||
vetClinicName,
|
vetClinicName,
|
||||||
vetClinicAddress,
|
vetClinicAddress,
|
||||||
vetAccountNumber,
|
vetAccountNumber,
|
||||||
@@ -566,6 +574,7 @@ export const updateBird = async ({
|
|||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
locationLabel,
|
locationLabel,
|
||||||
|
locationDetails,
|
||||||
vetClinicName,
|
vetClinicName,
|
||||||
vetClinicAddress,
|
vetClinicAddress,
|
||||||
vetAccountNumber,
|
vetAccountNumber,
|
||||||
@@ -592,6 +601,7 @@ export const updateBird = async ({
|
|||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favoriteSnack: string | null;
|
favoriteSnack: string | null;
|
||||||
locationLabel: string | null;
|
locationLabel: string | null;
|
||||||
|
locationDetails?: Record<string, unknown> | null;
|
||||||
vetClinicName: string | null;
|
vetClinicName: string | null;
|
||||||
vetClinicAddress: string | null;
|
vetClinicAddress: string | null;
|
||||||
vetAccountNumber: string | null;
|
vetAccountNumber: string | null;
|
||||||
@@ -633,11 +643,12 @@ export const updateBird = async ({
|
|||||||
notify_on_dob = $21,
|
notify_on_dob = $21,
|
||||||
notify_on_gotcha_day = $22,
|
notify_on_gotcha_day = $22,
|
||||||
public_profile_code = $23,
|
public_profile_code = $23,
|
||||||
public_profile_enabled = $24
|
public_profile_enabled = $24,
|
||||||
|
location_details = $25
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $25
|
AND workspace_id = $26
|
||||||
AND memorialized_at IS NULL
|
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
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -677,6 +688,7 @@ export const updateBird = async ({
|
|||||||
notifyOnGotchaDay,
|
notifyOnGotchaDay,
|
||||||
publicProfileCode,
|
publicProfileCode,
|
||||||
publicProfileEnabled,
|
publicProfileEnabled,
|
||||||
|
locationDetails ?? null,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -706,7 +718,7 @@ export const memorializeBird = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
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
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -742,7 +754,7 @@ export const updateMemorialReminderPreference = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NOT NULL
|
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
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -782,7 +794,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
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
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export type BirdRow = {
|
|||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favorite_snack: string | null;
|
favorite_snack: string | null;
|
||||||
location_label: string | null;
|
location_label: string | null;
|
||||||
|
location_details: Record<string, unknown> | null;
|
||||||
vet_clinic_name: string | null;
|
vet_clinic_name: string | null;
|
||||||
vet_clinic_address: string | null;
|
vet_clinic_address: string | null;
|
||||||
vet_account_number: string | null;
|
vet_account_number: string | null;
|
||||||
@@ -217,6 +218,7 @@ export type BirdTimelineEventRow = {
|
|||||||
from_owner_email: string | null;
|
from_owner_email: string | null;
|
||||||
to_owner_email: string | null;
|
to_owner_email: string | null;
|
||||||
location_label: string | null;
|
location_label: string | null;
|
||||||
|
location_details: Record<string, unknown> | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
event_date: string;
|
event_date: string;
|
||||||
created_by_user_id: string | null;
|
created_by_user_id: string | null;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
type="image/svg+xml"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
|
||||||
<title>FlockPal</title>
|
<title>FlockPal</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+299
-23
@@ -16,6 +16,17 @@ type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejec
|
|||||||
type IntegrationTokenScope = 'read_only' | 'read_write';
|
type IntegrationTokenScope = 'read_only' | 'read_write';
|
||||||
type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
|
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 = {
|
type Bird = {
|
||||||
id: string;
|
id: string;
|
||||||
workspaceId?: number;
|
workspaceId?: number;
|
||||||
@@ -26,6 +37,7 @@ type Bird = {
|
|||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favoriteSnack: string | null;
|
favoriteSnack: string | null;
|
||||||
locationLabel: string | null;
|
locationLabel: string | null;
|
||||||
|
locationDetails: VerifiedLocationDetails | null;
|
||||||
vetClinicName: string | null;
|
vetClinicName: string | null;
|
||||||
vetClinicAddress: string | null;
|
vetClinicAddress: string | null;
|
||||||
vetAccountNumber: string | null;
|
vetAccountNumber: string | null;
|
||||||
@@ -236,6 +248,7 @@ type BirdTimelineEvent = {
|
|||||||
fromOwnerEmail: string | null;
|
fromOwnerEmail: string | null;
|
||||||
toOwnerEmail: string | null;
|
toOwnerEmail: string | null;
|
||||||
locationLabel: string | null;
|
locationLabel: string | null;
|
||||||
|
locationDetails: VerifiedLocationDetails | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
eventDate: string;
|
eventDate: string;
|
||||||
createdByUserId: string | null;
|
createdByUserId: string | null;
|
||||||
@@ -243,10 +256,11 @@ type BirdTimelineEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type BirdTimelineEventFormState = {
|
type BirdTimelineEventFormState = {
|
||||||
eventType: 'location_updated' | 'manual_note';
|
eventType: 'location_updated' | 'owner_changed' | 'manual_note';
|
||||||
ownerChanged: boolean;
|
ownerChanged: boolean;
|
||||||
eventDate: string;
|
eventDate: string;
|
||||||
locationLabel: string;
|
locationLabel: string;
|
||||||
|
locationDetails: VerifiedLocationDetails;
|
||||||
note: string;
|
note: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -269,6 +283,7 @@ type BirdFormState = {
|
|||||||
demotivators: string;
|
demotivators: string;
|
||||||
favoriteSnack: string;
|
favoriteSnack: string;
|
||||||
locationLabel: string;
|
locationLabel: string;
|
||||||
|
locationDetails: VerifiedLocationDetails;
|
||||||
vetClinicName: string;
|
vetClinicName: string;
|
||||||
vetClinicAddress: string;
|
vetClinicAddress: string;
|
||||||
vetAccountNumber: string;
|
vetAccountNumber: string;
|
||||||
@@ -696,6 +711,32 @@ const parseBirdImportRows = (rows: Record<string, unknown>[]): BirdImportPreview
|
|||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyVerifiedLocationDetails: VerifiedLocationDetails = {
|
||||||
|
city: '',
|
||||||
|
region: '',
|
||||||
|
country: '',
|
||||||
|
countryCode: '',
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
precision: 'city',
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeVerifiedLocationDetails = (details: Partial<VerifiedLocationDetails> | 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 = {
|
const emptyBirdForm: BirdFormState = {
|
||||||
name: '',
|
name: '',
|
||||||
tagId: '',
|
tagId: '',
|
||||||
@@ -704,6 +745,7 @@ const emptyBirdForm: BirdFormState = {
|
|||||||
demotivators: '',
|
demotivators: '',
|
||||||
favoriteSnack: '',
|
favoriteSnack: '',
|
||||||
locationLabel: '',
|
locationLabel: '',
|
||||||
|
locationDetails: emptyVerifiedLocationDetails,
|
||||||
vetClinicName: '',
|
vetClinicName: '',
|
||||||
vetClinicAddress: '',
|
vetClinicAddress: '',
|
||||||
vetAccountNumber: '',
|
vetAccountNumber: '',
|
||||||
@@ -723,6 +765,7 @@ const emptyBirdTimelineEventForm: BirdTimelineEventFormState = {
|
|||||||
ownerChanged: false,
|
ownerChanged: false,
|
||||||
eventDate: new Date().toISOString().slice(0, 10),
|
eventDate: new Date().toISOString().slice(0, 10),
|
||||||
locationLabel: '',
|
locationLabel: '',
|
||||||
|
locationDetails: emptyVerifiedLocationDetails,
|
||||||
note: '',
|
note: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -869,6 +912,7 @@ const toBirdForm = (bird: Bird): BirdFormState => ({
|
|||||||
demotivators: parseBirdProfileList(bird.demotivators).join('\n'),
|
demotivators: parseBirdProfileList(bird.demotivators).join('\n'),
|
||||||
favoriteSnack: bird.favoriteSnack ?? '',
|
favoriteSnack: bird.favoriteSnack ?? '',
|
||||||
locationLabel: bird.locationLabel ?? '',
|
locationLabel: bird.locationLabel ?? '',
|
||||||
|
locationDetails: normalizeVerifiedLocationDetails(bird.locationDetails),
|
||||||
vetClinicName: bird.vetClinicName ?? '',
|
vetClinicName: bird.vetClinicName ?? '',
|
||||||
vetClinicAddress: bird.vetClinicAddress ?? '',
|
vetClinicAddress: bird.vetClinicAddress ?? '',
|
||||||
vetAccountNumber: bird.vetAccountNumber ?? '',
|
vetAccountNumber: bird.vetAccountNumber ?? '',
|
||||||
@@ -1801,6 +1845,7 @@ function App() {
|
|||||||
const [auditLogLoading, setAuditLogLoading] = useState(false);
|
const [auditLogLoading, setAuditLogLoading] = useState(false);
|
||||||
const [birdTimelineLoading, setBirdTimelineLoading] = useState(false);
|
const [birdTimelineLoading, setBirdTimelineLoading] = useState(false);
|
||||||
const [savingBirdTimelineEvent, setSavingBirdTimelineEvent] = useState(false);
|
const [savingBirdTimelineEvent, setSavingBirdTimelineEvent] = useState(false);
|
||||||
|
const [birdTimelineNotice, setBirdTimelineNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null);
|
||||||
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
||||||
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
||||||
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
||||||
@@ -1908,6 +1953,7 @@ function App() {
|
|||||||
setSelectedBirdTab('info');
|
setSelectedBirdTab('info');
|
||||||
setBirdTimelineEvents([]);
|
setBirdTimelineEvents([]);
|
||||||
setBirdTimelineEventForm(emptyBirdTimelineEventForm);
|
setBirdTimelineEventForm(emptyBirdTimelineEventForm);
|
||||||
|
setBirdTimelineNotice(null);
|
||||||
}, [selectedBirdId]);
|
}, [selectedBirdId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3759,10 +3805,11 @@ function App() {
|
|||||||
const method = isEditing ? 'PUT' : 'POST';
|
const method = isEditing ? 'PUT' : 'POST';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const locationLabel = formatVerifiedLocationLabel(birdForm.locationDetails) || birdForm.locationLabel;
|
||||||
const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, {
|
const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(birdForm),
|
body: JSON.stringify({ ...birdForm, locationLabel }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -3803,15 +3850,34 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
|
setBirdTimelineNotice(null);
|
||||||
setSavingBirdTimelineEvent(true);
|
setSavingBirdTimelineEvent(true);
|
||||||
|
|
||||||
try {
|
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, {
|
const response = await apiFetch(`/birds/${selectedBird.id}/timeline`, authToken, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...birdTimelineEventForm,
|
...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)]));
|
setBirdTimelineEvents((current) => sortBirdTimelineEvents([data.event!, ...current.filter((entry) => entry.id !== data.event!.id)]));
|
||||||
setBirdTimelineEventForm(emptyBirdTimelineEventForm);
|
setBirdTimelineEventForm(emptyBirdTimelineEventForm);
|
||||||
|
setBirdTimelineNotice({ kind: 'success', message: 'Timeline item added.' });
|
||||||
} catch (timelineError) {
|
} 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 {
|
} finally {
|
||||||
setSavingBirdTimelineEvent(false);
|
setSavingBirdTimelineEvent(false);
|
||||||
}
|
}
|
||||||
@@ -6506,8 +6576,103 @@ function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="settings-inline-header wide-field">
|
||||||
|
<p className="eyebrow">Location</p>
|
||||||
|
<h3>Mappable location</h3>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
City
|
||||||
|
<input
|
||||||
|
value={birdForm.locationDetails.city}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBirdForm({
|
||||||
|
...birdForm,
|
||||||
|
locationDetails: { ...birdForm.locationDetails, city: event.target.value, precision: 'city' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="City"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Region
|
||||||
|
<input
|
||||||
|
value={birdForm.locationDetails.region}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBirdForm({
|
||||||
|
...birdForm,
|
||||||
|
locationDetails: { ...birdForm.locationDetails, region: event.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="State, province, or region"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Country
|
||||||
|
<input
|
||||||
|
value={birdForm.locationDetails.country}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBirdForm({
|
||||||
|
...birdForm,
|
||||||
|
locationDetails: { ...birdForm.locationDetails, country: event.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Country"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Country code
|
||||||
|
<input
|
||||||
|
value={birdForm.locationDetails.countryCode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBirdForm({
|
||||||
|
...birdForm,
|
||||||
|
locationDetails: { ...birdForm.locationDetails, countryCode: event.target.value.toUpperCase().slice(0, 2) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="US"
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Latitude
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="-90"
|
||||||
|
max="90"
|
||||||
|
value={birdForm.locationDetails.latitude ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBirdForm({
|
||||||
|
...birdForm,
|
||||||
|
locationDetails: {
|
||||||
|
...birdForm.locationDetails,
|
||||||
|
latitude: event.target.value ? Number(event.target.value) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Longitude
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="-180"
|
||||||
|
max="180"
|
||||||
|
value={birdForm.locationDetails.longitude ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBirdForm({
|
||||||
|
...birdForm,
|
||||||
|
locationDetails: {
|
||||||
|
...birdForm.locationDetails,
|
||||||
|
longitude: event.target.value ? Number(event.target.value) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="wide-field">
|
<label className="wide-field">
|
||||||
Location
|
Display label
|
||||||
<input
|
<input
|
||||||
value={birdForm.locationLabel}
|
value={birdForm.locationLabel}
|
||||||
onChange={(event) => setBirdForm({ ...birdForm, locationLabel: event.target.value })}
|
onChange={(event) => setBirdForm({ ...birdForm, locationLabel: event.target.value })}
|
||||||
@@ -7002,9 +7167,9 @@ function App() {
|
|||||||
aria-label="Timeline"
|
aria-label="Timeline"
|
||||||
title="Timeline"
|
title="Timeline"
|
||||||
>
|
>
|
||||||
<svg className="timeline-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
|
<span className="material-symbols-outlined timeline-tab-icon" aria-hidden="true">
|
||||||
<path d="M280-120q-66 0-113-47t-47-113q0-56 34.5-99t85.5-56v-90q-51-13-85.5-56T120-680q0-66 47-113t113-47q56 0 99 34.5t56 85.5h90q13-51 56-85.5t99-34.5q66 0 113 47t47 113q0 56-34.5 99t-85.5 56v90q51 13 85.5 56t34.5 99q0 66-47 113t-113 47q-56 0-99-34.5T525-240h-90q-13 51-56 85.5T280-120Zm0-80q33 0 56.5-23.5T360-280q0-33-23.5-56.5T280-360q-33 0-56.5 23.5T200-280q0 33 23.5 56.5T280-200Zm400 0q33 0 56.5-23.5T760-280q0-33-23.5-56.5T680-360q-33 0-56.5 23.5T600-280q0 33 23.5 56.5T680-200ZM280-600q33 0 56.5-23.5T360-680q0-33-23.5-56.5T280-760q-33 0-56.5 23.5T200-680q0 33 23.5 56.5T280-600Zm400 0q33 0 56.5-23.5T760-680q0-33-23.5-56.5T680-760q-33 0-56.5 23.5T600-680q0 33 23.5 56.5T680-600ZM435-320h90q8-30 26-54t44-39q-13-18-20-40t-7-47q0-25 7-47t20-40q-26-15-44-39t-26-54h-90q-8 30-26 54t-44 39q13 18 20 40t7 47q0 25-7 47t-20 40q26 15 44 39t26 54Z" />
|
timeline
|
||||||
</svg>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
|
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
|
||||||
@@ -7732,16 +7897,20 @@ function App() {
|
|||||||
Type
|
Type
|
||||||
<select
|
<select
|
||||||
value={birdTimelineEventForm.eventType}
|
value={birdTimelineEventForm.eventType}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
|
const eventType = event.target.value as BirdTimelineEventFormState['eventType'];
|
||||||
setBirdTimelineEventForm({
|
setBirdTimelineEventForm({
|
||||||
...birdTimelineEventForm,
|
...birdTimelineEventForm,
|
||||||
eventType: event.target.value as BirdTimelineEventFormState['eventType'],
|
eventType,
|
||||||
ownerChanged:
|
ownerChanged: eventType === 'location_updated' ? birdTimelineEventForm.ownerChanged : false,
|
||||||
event.target.value === 'location_updated' ? birdTimelineEventForm.ownerChanged : false,
|
locationLabel: eventType === 'owner_changed' ? '' : birdTimelineEventForm.locationLabel,
|
||||||
})
|
locationDetails:
|
||||||
}
|
eventType === 'owner_changed' ? emptyVerifiedLocationDetails : birdTimelineEventForm.locationDetails,
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="location_updated">Location note</option>
|
<option value="location_updated">Location note</option>
|
||||||
|
<option value="owner_changed">Owner</option>
|
||||||
<option value="manual_note">General note</option>
|
<option value="manual_note">General note</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -7754,14 +7923,116 @@ function App() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
{birdTimelineEventForm.eventType !== 'owner_changed' ? (
|
||||||
Location
|
<>
|
||||||
<input
|
<label>
|
||||||
value={birdTimelineEventForm.locationLabel}
|
City
|
||||||
onChange={(event) => setBirdTimelineEventForm({ ...birdTimelineEventForm, locationLabel: event.target.value })}
|
<input
|
||||||
placeholder={selectedBird.locationLabel || 'City, region, or country'}
|
value={birdTimelineEventForm.locationDetails.city}
|
||||||
/>
|
onChange={(event) =>
|
||||||
</label>
|
setBirdTimelineEventForm({
|
||||||
|
...birdTimelineEventForm,
|
||||||
|
locationDetails: {
|
||||||
|
...birdTimelineEventForm.locationDetails,
|
||||||
|
city: event.target.value,
|
||||||
|
precision: 'city',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={selectedBird.locationDetails?.city || 'City'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Region
|
||||||
|
<input
|
||||||
|
value={birdTimelineEventForm.locationDetails.region}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBirdTimelineEventForm({
|
||||||
|
...birdTimelineEventForm,
|
||||||
|
locationDetails: { ...birdTimelineEventForm.locationDetails, region: event.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={selectedBird.locationDetails?.region || 'State or region'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Country
|
||||||
|
<input
|
||||||
|
value={birdTimelineEventForm.locationDetails.country}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBirdTimelineEventForm({
|
||||||
|
...birdTimelineEventForm,
|
||||||
|
locationDetails: { ...birdTimelineEventForm.locationDetails, country: event.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={selectedBird.locationDetails?.country || 'Country'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Country code
|
||||||
|
<input
|
||||||
|
value={birdTimelineEventForm.locationDetails.countryCode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBirdTimelineEventForm({
|
||||||
|
...birdTimelineEventForm,
|
||||||
|
locationDetails: {
|
||||||
|
...birdTimelineEventForm.locationDetails,
|
||||||
|
countryCode: event.target.value.toUpperCase().slice(0, 2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={selectedBird.locationDetails?.countryCode || 'US'}
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Latitude
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="-90"
|
||||||
|
max="90"
|
||||||
|
value={birdTimelineEventForm.locationDetails.latitude ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBirdTimelineEventForm({
|
||||||
|
...birdTimelineEventForm,
|
||||||
|
locationDetails: {
|
||||||
|
...birdTimelineEventForm.locationDetails,
|
||||||
|
latitude: event.target.value ? Number(event.target.value) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Longitude
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="-180"
|
||||||
|
max="180"
|
||||||
|
value={birdTimelineEventForm.locationDetails.longitude ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBirdTimelineEventForm({
|
||||||
|
...birdTimelineEventForm,
|
||||||
|
locationDetails: {
|
||||||
|
...birdTimelineEventForm.locationDetails,
|
||||||
|
longitude: event.target.value ? Number(event.target.value) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="wide-field">
|
||||||
|
Display label
|
||||||
|
<input
|
||||||
|
value={birdTimelineEventForm.locationLabel}
|
||||||
|
onChange={(event) => setBirdTimelineEventForm({ ...birdTimelineEventForm, locationLabel: event.target.value })}
|
||||||
|
placeholder={selectedBird.locationLabel || 'City, region, or country'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{birdTimelineEventForm.eventType === 'location_updated' ? (
|
{birdTimelineEventForm.eventType === 'location_updated' ? (
|
||||||
<label className="checkbox-row">
|
<label className="checkbox-row">
|
||||||
<input
|
<input
|
||||||
@@ -7784,7 +8055,7 @@ function App() {
|
|||||||
value={birdTimelineEventForm.note}
|
value={birdTimelineEventForm.note}
|
||||||
onChange={(event) => setBirdTimelineEventForm({ ...birdTimelineEventForm, note: event.target.value })}
|
onChange={(event) => setBirdTimelineEventForm({ ...birdTimelineEventForm, note: event.target.value })}
|
||||||
placeholder={
|
placeholder={
|
||||||
birdTimelineEventForm.ownerChanged
|
birdTimelineEventForm.ownerChanged || birdTimelineEventForm.eventType === 'owner_changed'
|
||||||
? 'Optional context without owner names'
|
? 'Optional context without owner names'
|
||||||
: 'Optional timeline context'
|
: 'Optional timeline context'
|
||||||
}
|
}
|
||||||
@@ -7793,6 +8064,11 @@ function App() {
|
|||||||
<button className="primary-button" type="submit" disabled={savingBirdTimelineEvent}>
|
<button className="primary-button" type="submit" disabled={savingBirdTimelineEvent}>
|
||||||
{savingBirdTimelineEvent ? 'Adding...' : 'Add timeline item'}
|
{savingBirdTimelineEvent ? 'Adding...' : 'Add timeline item'}
|
||||||
</button>
|
</button>
|
||||||
|
{birdTimelineNotice ? (
|
||||||
|
<p className={birdTimelineNotice.kind === 'error' ? 'error-banner wide-field' : 'success-banner wide-field'} role="alert">
|
||||||
|
{birdTimelineNotice.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
<div className="recent-list bird-timeline-list">
|
<div className="recent-list bird-timeline-list">
|
||||||
{birdTimelineEvents.length ? (
|
{birdTimelineEvents.length ? (
|
||||||
|
|||||||
@@ -1541,6 +1541,25 @@ textarea {
|
|||||||
stroke: none;
|
stroke: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab .timeline-tab-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: currentColor;
|
||||||
|
font-family: "Material Symbols Outlined";
|
||||||
|
font-size: 24px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
direction: ltr;
|
||||||
|
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
|
||||||
|
-webkit-font-feature-settings: "liga";
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
.bird-detail-tab:hover {
|
.bird-detail-tab:hover {
|
||||||
border-color: rgba(35, 138, 90, 0.28);
|
border-color: rgba(35, 138, 90, 0.28);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
|||||||
Reference in New Issue
Block a user