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() {

- - - + + + - - {getBirdTimelineGraphEvents(birdTimelineEvents).map((timelineEvent, index, graphEvents) => { - const x = graphEvents.length === 1 ? 320 : 52 + (index / (graphEvents.length - 1)) * 536; - const y = 74; - const labelY = index % 2 === 0 ? 34 : 124; - const connectorEndY = index % 2 === 0 ? 50 : 104; + {(() => { + const timelineGraph = buildBirdTimelineGraph(birdTimelineEvents, selectedBird.dateOfBirth); return ( - - - + - - {getBirdTimelineLocation(timelineEvent)} - - - {formatShortDate(getBirdTimelineEventDate(timelineEvent))} - - + {timelineGraph.yearMarkers.map((marker) => ( + + + + {marker.year} + + + ))} + {timelineGraph.points.length > 1 ? ( + + ) : null} + {timelineGraph.points.map(({ event: timelineEvent, x, y, labelY, connectorEndY, textAnchor }) => ( + + + {timelineEvent.eventType === 'hatch_date' ? ( + + Egg + + + ) : ( + + )} + {timelineEvent.eventType === 'location_updated' ? ( + + move + + ) : timelineEvent.eventType === 'owner_changed' ? ( + + move_location + + ) : timelineEvent.eventType === 'hatch_date' ? ( + + Hatch date + + ) : ( + + {formatBirdTimelineGraphTitle(timelineEvent)} + + )} + + {formatBirdTimelineGraphMeta(timelineEvent)} + + {timelineEvent.sourceEvent && timelineEvent.eventType !== 'owner_changed' ? ( + + {formatBirdTimelineGraphLocation(timelineEvent.sourceEvent)} + + ) : null} + + ))} + ); - })} + })()}
) : 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, }); }} > - - - + +