diff --git a/backend/src/app.ts b/backend/src/app.ts
index 1af5330..684dc5e 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -3779,7 +3779,7 @@ app.get('/api/locations/search', requireAuth, locationSearchLimiter, async (req:
searchUrl.searchParams.set('access_token', mapboxAccessToken);
searchUrl.searchParams.set('autocomplete', 'false');
searchUrl.searchParams.set('types', 'place,locality,region,country');
- searchUrl.searchParams.set('limit', '5');
+ searchUrl.searchParams.set('limit', '3');
searchUrl.searchParams.set('permanent', 'true');
const mapboxResponse = await fetch(searchUrl);
diff --git a/frontend/index.html b/frontend/index.html
index 4e1d880..97532db 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -8,7 +8,10 @@
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 4af3295..b8be13f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -265,8 +265,17 @@ type BirdTimelineEvent = {
createdAt: string;
};
+type BirdTimelineGraphEvent = {
+ id: string;
+ eventType: BirdTimelineEvent['eventType'] | 'hatch_date';
+ eventDate: string;
+ createdAt: string;
+ sourceEvent?: BirdTimelineEvent;
+};
+
type BirdTimelineEventFormState = {
eventType: 'location_updated' | 'owner_changed' | 'manual_note';
+ ownerChanged: boolean;
eventDate: string;
locationLabel: string;
locationDetails: VerifiedLocationDetails;
@@ -780,7 +789,7 @@ const VerifiedLocationSearchField = ({
onSelect,
onClear,
}: VerifiedLocationSearchFieldProps) => {
- const selectedLabel = formatVerifiedLocationLabel(location) || fallbackLabel || '';
+ const hasSelectedLocation = Boolean(formatVerifiedLocationLabel(location) || fallbackLabel);
return (
@@ -802,19 +811,13 @@ const VerifiedLocationSearchField = ({
+ {hasSelectedLocation ? (
+
+ ) : null}
- {selectedLabel ? (
-
-
- {selectedLabel}
- {location.provider === 'mapbox' ? 'Verified by Mapbox' : 'Saved location'}
-
-
-
- ) : null}
{searchState.error ? (
{searchState.error}
@@ -867,6 +870,7 @@ const emptyBirdForm: BirdFormState = {
const emptyBirdTimelineEventForm: BirdTimelineEventFormState = {
eventType: 'location_updated',
+ ownerChanged: false,
eventDate: new Date().toISOString().slice(0, 10),
locationLabel: '',
locationDetails: emptyVerifiedLocationDetails,
@@ -1179,11 +1183,11 @@ const formatBirdTimelineTitle = (event: BirdTimelineEvent) => {
}
if (event.eventType === 'location_updated') {
- return 'Location updated';
+ return 'Location change';
}
if (event.eventType === 'owner_changed') {
- return 'Owner record changed';
+ return 'Move location';
}
if (event.eventType === 'manual_note') {
@@ -1234,7 +1238,80 @@ const formatBirdTimelineSecondary = (event: BirdTimelineEvent) => {
const getBirdTimelineLocation = (event: BirdTimelineEvent) =>
event.locationLabel || event.toWorkspaceName || event.fromWorkspaceName || 'Unknown location';
+const commonTimelineLocationAliases: Record = {
+ Alabama: 'AL',
+ Alaska: 'AK',
+ Arizona: 'AZ',
+ Arkansas: 'AR',
+ California: 'CA',
+ Colorado: 'CO',
+ Connecticut: 'CT',
+ Delaware: 'DE',
+ 'District of Columbia': 'DC',
+ Florida: 'FL',
+ Georgia: 'GA',
+ Hawaii: 'HI',
+ Idaho: 'ID',
+ Illinois: 'IL',
+ Indiana: 'IN',
+ Iowa: 'IA',
+ Kansas: 'KS',
+ Kentucky: 'KY',
+ Louisiana: 'LA',
+ Maine: 'ME',
+ Maryland: 'MD',
+ Massachusetts: 'MA',
+ Michigan: 'MI',
+ Minnesota: 'MN',
+ Mississippi: 'MS',
+ Missouri: 'MO',
+ Montana: 'MT',
+ Nebraska: 'NE',
+ Nevada: 'NV',
+ 'New Hampshire': 'NH',
+ 'New Jersey': 'NJ',
+ 'New Mexico': 'NM',
+ 'New York': 'NY',
+ 'North Carolina': 'NC',
+ 'North Dakota': 'ND',
+ Ohio: 'OH',
+ Oklahoma: 'OK',
+ Oregon: 'OR',
+ Pennsylvania: 'PA',
+ 'Rhode Island': 'RI',
+ 'South Carolina': 'SC',
+ 'South Dakota': 'SD',
+ Tennessee: 'TN',
+ Texas: 'TX',
+ Utah: 'UT',
+ Vermont: 'VT',
+ Virginia: 'VA',
+ Washington: 'WA',
+ 'West Virginia': 'WV',
+ Wisconsin: 'WI',
+ Wyoming: 'WY',
+ 'United States': 'US',
+ 'United States of America': 'US',
+ 'United Kingdom': 'UK',
+};
+
+const formatBirdTimelineGraphLocation = (event: BirdTimelineEvent) =>
+ getBirdTimelineLocation(event)
+ .split(',')
+ .map((part) => {
+ const trimmedPart = part.trim();
+ return commonTimelineLocationAliases[trimmedPart] ?? trimmedPart;
+ })
+ .filter(Boolean)
+ .join(', ');
+
+const formatBirdTimelineGraphTitle = (event: BirdTimelineGraphEvent) =>
+ event.eventType === 'hatch_date' ? 'Hatch date' : formatBirdTimelineTitle(event.sourceEvent!);
+
+const formatBirdTimelineGraphMeta = (event: BirdTimelineGraphEvent) => formatShortDate(getBirdTimelineGraphEventDate(event));
+
const getBirdTimelineEventDate = (event: BirdTimelineEvent) => event.eventDate || event.createdAt.slice(0, 10);
+const getBirdTimelineGraphEventDate = (event: BirdTimelineGraphEvent) => event.eventDate || event.createdAt.slice(0, 10);
const sortBirdTimelineEvents = (events: BirdTimelineEvent[]) =>
[...events].sort((left, right) => {
@@ -1242,7 +1319,86 @@ const sortBirdTimelineEvents = (events: BirdTimelineEvent[]) =>
return dateComparison || right.createdAt.localeCompare(left.createdAt);
});
-const getBirdTimelineGraphEvents = (events: BirdTimelineEvent[]) => sortBirdTimelineEvents(events).reverse().slice(-8);
+const sortBirdTimelineGraphEvents = (events: BirdTimelineGraphEvent[]) =>
+ [...events].sort((left, right) => {
+ const dateComparison = getBirdTimelineGraphEventDate(left).localeCompare(getBirdTimelineGraphEventDate(right));
+ return dateComparison || left.createdAt.localeCompare(right.createdAt);
+ });
+
+const getBirdTimelineGraphEvents = (events: BirdTimelineEvent[], hatchDate?: string | null) => {
+ const timelineEvents = sortBirdTimelineEvents(events)
+ .reverse()
+ .map((event) => ({
+ id: event.id,
+ eventType: event.eventType,
+ eventDate: getBirdTimelineEventDate(event),
+ createdAt: event.createdAt,
+ sourceEvent: event,
+ }));
+ const hatchEvent = hatchDate
+ ? [
+ {
+ id: 'hatch-date',
+ eventType: 'hatch_date' as const,
+ eventDate: hatchDate,
+ createdAt: `${hatchDate}T00:00:00.000Z`,
+ },
+ ]
+ : [];
+ const graphEvents = sortBirdTimelineGraphEvents([...hatchEvent, ...timelineEvents]);
+
+ if (graphEvents.length <= 8) {
+ return graphEvents;
+ }
+
+ if (hatchEvent.length) {
+ const latestEvents = graphEvents.filter((event) => event.id !== 'hatch-date').slice(-7);
+ return sortBirdTimelineGraphEvents([...hatchEvent, ...latestEvents]);
+ }
+
+ return graphEvents.slice(-8);
+};
+
+const buildBirdTimelineGraph = (events: BirdTimelineEvent[], hatchDate?: string | null) => {
+ const graphEvents = getBirdTimelineGraphEvents(events, hatchDate);
+ const left = 76;
+ const right = 564;
+ const centerY = 88;
+ const width = right - left;
+ const points = graphEvents.map((timelineEvent, index) => {
+ const x = graphEvents.length === 1 ? 320 : left + (index / (graphEvents.length - 1)) * width;
+ const labelY = index % 2 === 0 ? 30 : 134;
+ const textAnchor = index === 0 ? 'start' : index === graphEvents.length - 1 ? 'end' : 'middle';
+
+ return {
+ event: timelineEvent,
+ x,
+ y: centerY,
+ labelY,
+ connectorEndY: index % 2 === 0 ? 54 : 112,
+ textAnchor,
+ };
+ });
+ const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y}`).join(' ');
+ const yearMarkers = points.reduce>((markers, point) => {
+ const year = getBirdTimelineGraphEventDate(point.event).slice(0, 4);
+
+ if (!markers.some((marker) => marker.year === year)) {
+ markers.push({ year, x: point.x });
+ }
+
+ return markers;
+ }, []);
+
+ return {
+ points,
+ path,
+ yearMarkers,
+ left,
+ right,
+ centerY,
+ };
+};
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
@@ -4000,7 +4156,7 @@ function App() {
setSavingBirdTimelineEvent(true);
try {
- const eventType = birdTimelineEventForm.eventType;
+ const eventType = birdTimelineEventForm.ownerChanged ? 'owner_changed' : birdTimelineEventForm.eventType;
const verifiedLocationLabel = formatVerifiedLocationLabel(birdTimelineEventForm.locationDetails);
const locationLabel =
birdTimelineEventForm.eventType === 'location_updated'
@@ -7918,41 +8074,103 @@ function App() {
) : null}
@@ -7969,15 +8187,15 @@ function App() {
setBirdTimelineEventForm({
...birdTimelineEventForm,
eventType,
+ ownerChanged: eventType === 'location_updated' ? birdTimelineEventForm.ownerChanged : false,
locationLabel: eventType === 'owner_changed' ? '' : birdTimelineEventForm.locationLabel,
locationDetails:
eventType === 'owner_changed' ? emptyVerifiedLocationDetails : birdTimelineEventForm.locationDetails,
});
}}
>
-
-
-
+
+