Updated timeline
Deploy / deploy-dev (push) Successful in 2m42s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
Corey Blais
2026-06-30 17:05:26 -04:00
parent 0dfacc0d17
commit 84d850a1ba
4 changed files with 321 additions and 72 deletions
+1 -1
View File
@@ -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);
+4 -1
View File
@@ -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"
/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=move,move_location"
/>
<title>FlockPal</title>
</head>
<body>
+278 -45
View File
@@ -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 (
<div className="verified-location-field wide-field">
@@ -802,19 +811,13 @@ const VerifiedLocationSearchField = ({
<button className="secondary-button" type="button" onClick={onSearch} disabled={searchState.searching}>
{searchState.searching ? 'Searching...' : 'Search'}
</button>
{hasSelectedLocation ? (
<button className="secondary-button" type="button" onClick={onClear}>
Clear
</button>
) : null}
</div>
</div>
{selectedLabel ? (
<div className="verified-location-selected">
<div>
<strong>{selectedLabel}</strong>
<small>{location.provider === 'mapbox' ? 'Verified by Mapbox' : 'Saved location'}</small>
</div>
<button className="secondary-button" type="button" onClick={onClear}>
Clear
</button>
</div>
) : null}
{searchState.error ? (
<p className="error-banner" role="alert">
{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<string, string> = {
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<BirdTimelineGraphEvent>((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<Array<{ year: string; x: number }>>((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() {
<div className="bird-timeline-graph-card">
<svg
className="bird-timeline-graph"
viewBox="0 0 640 150"
viewBox="0 0 640 200"
role="img"
aria-label={`${selectedBird.name} location and owner timeline`}
>
<defs>
<linearGradient id="timelineLine" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="rgba(39, 105, 179, 0.42)" />
<stop offset="100%" stopColor="rgba(35, 138, 90, 0.88)" />
<linearGradient id={`timelineLine-${selectedBird.id}`} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={selectedBird.chartColor} stopOpacity="0.45" />
<stop offset="100%" stopColor={selectedBird.chartColor} />
</linearGradient>
</defs>
<line x1="52" y1="74" x2="588" y2="74" className="bird-timeline-graph-line" />
{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 (
<g key={timelineEvent.id}>
<line x1={x} y1={y} x2={x} y2={connectorEndY} className="bird-timeline-graph-connector" />
<circle
cx={x}
cy={y}
r={timelineEvent.eventType === 'owner_changed' ? 7 : 6}
className={`bird-timeline-graph-dot ${timelineEvent.eventType}`}
<>
<line
x1={timelineGraph.left}
y1={timelineGraph.centerY}
x2={timelineGraph.right}
y2={timelineGraph.centerY}
className="bird-timeline-graph-axis"
/>
<text x={x} y={labelY} textAnchor="middle" className="bird-timeline-graph-label">
{getBirdTimelineLocation(timelineEvent)}
</text>
<text x={x} y={labelY + 14} textAnchor="middle" className="bird-timeline-graph-meta">
{formatShortDate(getBirdTimelineEventDate(timelineEvent))}
</text>
</g>
{timelineGraph.yearMarkers.map((marker) => (
<g key={marker.year}>
<line
x1={marker.x}
y1="66"
x2={marker.x}
y2="110"
className="bird-timeline-graph-year-line"
/>
<text x={marker.x} y="188" textAnchor="middle" className="bird-timeline-graph-year">
{marker.year}
</text>
</g>
))}
{timelineGraph.points.length > 1 ? (
<path
d={timelineGraph.path}
fill="none"
stroke={`url(#timelineLine-${selectedBird.id})`}
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
className="bird-timeline-graph-line"
/>
) : null}
{timelineGraph.points.map(({ event: timelineEvent, x, y, labelY, connectorEndY, textAnchor }) => (
<g key={timelineEvent.id}>
<line x1={x} y1={y} x2={x} y2={connectorEndY} className="bird-timeline-graph-connector" />
{timelineEvent.eventType === 'hatch_date' ? (
<g
className="bird-timeline-graph-hatch-dot"
transform={`translate(${x - 11}, ${y - 11})`}
>
<title>Egg</title>
<path d="M12 21c-2.18 0-3.93-.7-5.25-2.1C5.58 17.66 5 16.05 5 14.06c0-1.28.22-2.56.67-3.84.45-1.28 1.04-2.43 1.78-3.46.74-1.02 1.53-1.88 2.37-2.58C10.66 3.49 11.39 3.15 12 3.15s1.34.34 2.18 1.03c.84.7 1.63 1.56 2.37 2.58.74 1.03 1.33 2.18 1.78 3.46.45 1.28.67 2.56.67 3.84 0 1.99-.58 3.6-1.75 4.84C15.93 20.3 14.18 21 12 21Z" />
</g>
) : (
<circle
cx={x}
cy={y}
r={timelineEvent.eventType === 'owner_changed' ? 7 : 6}
className={`bird-timeline-graph-dot ${timelineEvent.eventType}`}
style={{ stroke: selectedBird.chartColor }}
/>
)}
{timelineEvent.eventType === 'location_updated' ? (
<text x={x} y={labelY} textAnchor={textAnchor} className="bird-timeline-graph-event-icon">
move
</text>
) : timelineEvent.eventType === 'owner_changed' ? (
<text x={x} y={labelY} textAnchor={textAnchor} className="bird-timeline-graph-event-icon">
move_location
</text>
) : timelineEvent.eventType === 'hatch_date' ? (
<text x={x} y={labelY} textAnchor={textAnchor} className="bird-timeline-graph-label">
Hatch date
</text>
) : (
<text x={x} y={labelY} textAnchor={textAnchor} className="bird-timeline-graph-label">
{formatBirdTimelineGraphTitle(timelineEvent)}
</text>
)}
<text x={x} y={labelY + 14} textAnchor={textAnchor} className="bird-timeline-graph-meta">
{formatBirdTimelineGraphMeta(timelineEvent)}
</text>
{timelineEvent.sourceEvent && timelineEvent.eventType !== 'owner_changed' ? (
<text x={x} y={labelY + 28} textAnchor={textAnchor} className="bird-timeline-graph-meta">
{formatBirdTimelineGraphLocation(timelineEvent.sourceEvent)}
</text>
) : null}
</g>
))}
</>
);
})}
})()}
</svg>
</div>
) : 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,
});
}}
>
<option value="location_updated">Location note</option>
<option value="owner_changed">Owner</option>
<option value="manual_note">General note</option>
<option value="location_updated">Location change</option>
<option value="owner_changed">Owner Change</option>
</select>
</label>
<label>
@@ -8016,6 +8234,21 @@ function App() {
}}
/>
) : null}
{birdTimelineEventForm.eventType === 'location_updated' ? (
<label className="checkbox-row">
<input
type="checkbox"
checked={birdTimelineEventForm.ownerChanged}
onChange={(event) =>
setBirdTimelineEventForm({ ...birdTimelineEventForm, ownerChanged: event.target.checked })
}
/>
<span>
<strong>Owner changed</strong>
<small>Records a change without owner names.</small>
</span>
</label>
) : null}
<label className="wide-field">
Note
<textarea
@@ -8023,7 +8256,7 @@ function App() {
value={birdTimelineEventForm.note}
onChange={(event) => setBirdTimelineEventForm({ ...birdTimelineEventForm, note: event.target.value })}
placeholder={
birdTimelineEventForm.eventType === 'owner_changed'
birdTimelineEventForm.ownerChanged || birdTimelineEventForm.eventType === 'owner_changed'
? 'Optional context without owner names'
: 'Optional timeline context'
}
+38 -25
View File
@@ -1337,20 +1337,29 @@ textarea {
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
overflow: hidden;
}
.bird-timeline-graph {
display: block;
width: 100%;
height: auto;
min-height: 140px;
min-height: 200px;
}
.bird-timeline-graph-line {
stroke: url(#timelineLine);
stroke-width: 4;
stroke-linecap: round;
.bird-timeline-graph-axis {
stroke: rgba(31, 42, 42, 0.18);
stroke-width: 1.2;
}
.bird-timeline-graph-year-line {
stroke: rgba(39, 105, 179, 0.16);
stroke-width: 1;
stroke-dasharray: 4 6;
}
.bird-timeline-graph-year {
fill: var(--muted);
font-size: 11px;
}
.bird-timeline-graph-connector {
@@ -1375,7 +1384,7 @@ textarea {
.bird-timeline-graph-label,
.bird-timeline-graph-meta {
font-size: 0.72rem;
font-size: 11px;
fill: var(--ink);
}
@@ -1387,6 +1396,27 @@ textarea {
fill: var(--muted);
}
.bird-timeline-graph-event-icon {
fill: #1f1f1f;
font-family: "Material Symbols Outlined";
font-size: 18px;
font-style: normal;
font-weight: 400;
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
font-feature-settings: "liga";
-webkit-font-feature-settings: "liga";
letter-spacing: 0;
text-transform: none;
}
.bird-timeline-graph-hatch-dot {
fill: #fffdf9;
stroke: #1f1f1f;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.bird-timeline-form {
padding: 0.85rem;
border: 1px solid rgba(35, 138, 90, 0.14);
@@ -2034,28 +2064,11 @@ label {
.verified-location-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.65rem;
align-items: end;
}
.verified-location-selected {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 0.95rem;
border: 1px solid rgba(35, 138, 90, 0.2);
border-radius: 8px;
background: rgba(35, 138, 90, 0.08);
}
.verified-location-selected div {
display: grid;
gap: 0.15rem;
}
.verified-location-selected small,
.verified-location-result small {
color: var(--muted);
}