Fixed Timeline

This commit is contained in:
blaisadmin
2026-06-30 23:25:07 -04:00
parent 7ef20ab0fb
commit d03672fcdd
4 changed files with 560 additions and 102 deletions
+355 -59
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import { useEffect, useMemo, useState, type CSSProperties, type Dispatch, type SetStateAction } from 'react';
import birdSilhouette from './assets/bird-silhouette.jpg';
import flockPalLandingArt from './assets/flockpal-landing-art.png';
import flockPalTextArt from './assets/flockpal-text.png';
@@ -53,6 +53,7 @@ type Bird = {
vetAccountNumber: string | null;
vetDoctorName: string | null;
gender: BirdGender;
hatchDay: string | null;
dateOfBirth: string | null;
gotchaDay: string | null;
chartColor: string;
@@ -342,6 +343,7 @@ type PublicBirdProfile = {
name: string;
favoriteSnack: string | null;
gender: BirdGender;
hatchDay: string | null;
dateOfBirth: string | null;
photoDataUrl: string | null;
};
@@ -782,15 +784,16 @@ const VerifiedLocationSearchField = ({
onClear,
}: VerifiedLocationSearchFieldProps) => {
const selectedLabel = formatVerifiedLocationLabel(location) || fallbackLabel || '';
const hasSelectedLocation = Boolean(selectedLabel);
return (
<div className="verified-location-field wide-field">
<div className="verified-location-label">
<span>{label}</span>
<div className="verified-location-search-row">
<div className={`verified-location-search-row${hasSelectedLocation ? ' has-selected-location' : ''}`}>
<input
aria-label={label}
value={searchState.query}
value={searchState.query || selectedLabel}
onChange={(event) => onSearchStateChange({ ...searchState, query: event.target.value, error: '' })}
onKeyDown={(event) => {
if (event.key === 'Enter') {
@@ -803,19 +806,13 @@ const VerifiedLocationSearchField = ({
<button className="secondary-button" type="button" onClick={onSearch} disabled={searchState.searching}>
{searchState.searching ? 'Searching...' : 'Search'}
</button>
{hasSelectedLocation ? (
<button className="secondary-button verified-location-clear" 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}
@@ -1024,7 +1021,7 @@ const toBirdForm = (bird: Bird): BirdFormState => ({
vetAccountNumber: bird.vetAccountNumber ?? '',
vetDoctorName: bird.vetDoctorName ?? '',
gender: bird.gender,
dateOfBirth: bird.dateOfBirth ?? '',
dateOfBirth: getBirdHatchDay(bird) ?? '',
gotchaDay: bird.gotchaDay ?? '',
chartColor: bird.chartColor,
photoDataUrl: bird.photoDataUrl ?? '',
@@ -1244,7 +1241,289 @@ const sortBirdTimelineEvents = (events: BirdTimelineEvent[]) =>
return dateComparison || right.createdAt.localeCompare(left.createdAt);
});
const getBirdTimelineGraphEvents = (events: BirdTimelineEvent[]) => sortBirdTimelineEvents(events).reverse().slice(-8);
const TIMELINE_GRAPH_START_X = 52;
const TIMELINE_GRAPH_END_X = 588;
type BirdTimelineGraphItem = {
id: string;
eventType: BirdTimelineEvent['eventType'] | 'hatch_date';
date: string;
label: string;
};
type BirdTimelineGraphTick = {
id: string;
year: number;
date: string;
label: string;
isToday?: boolean;
};
type BirdTimelineGraphDomain = {
startTime: number;
endTime: number;
};
const getBirdHatchDay = (bird: Pick<Bird, 'hatchDay' | 'dateOfBirth'>) => {
const hatchDay = bird.hatchDay?.trim();
const dateOfBirth = bird.dateOfBirth?.trim();
return hatchDay || dateOfBirth || null;
};
const getBirdTimelineGraphLabel = (event: BirdTimelineEvent) => {
if (event.eventType === 'location_updated') {
return event.locationLabel || 'Location updated';
}
if (event.eventType === 'owner_changed') {
return 'Owner changed';
}
if (event.eventType === 'transferred') {
return event.toWorkspaceName || 'Flock transfer';
}
if (event.eventType === 'manual_note') {
return event.locationLabel || 'Timeline note';
}
return event.locationLabel || 'Added to flock';
};
const getBirdTimelineGraphIcon = (item: BirdTimelineGraphItem) => {
if (item.eventType === 'hatch_date') {
return 'egg';
}
if (item.eventType === 'owner_changed') {
return 'manage_accounts';
}
if (item.eventType === 'transferred') {
return 'move';
}
if (item.eventType === 'manual_note') {
return 'sticky_note_2';
}
return 'map';
};
const compactTimelineLocationPartMap: Record<string, string> = {
alabama: 'AL',
alaska: 'AK',
arizona: 'AZ',
arkansas: 'AR',
california: 'CA',
colorado: 'CO',
connecticut: 'CT',
delaware: 'DE',
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',
'district of columbia': 'DC',
'united states': 'US',
'united states of america': 'US',
};
const compactTimelineLocationLabel = (label: string) =>
label
.split(',')
.map((part) => {
const trimmedPart = part.trim();
return compactTimelineLocationPartMap[trimmedPart.toLowerCase()] || trimmedPart;
})
.filter(Boolean)
.join(', ');
const getBirdTimelineGraphItems = (bird: Bird, events: BirdTimelineEvent[]): BirdTimelineGraphItem[] => {
const graphEvents: BirdTimelineGraphItem[] = sortBirdTimelineEvents(events)
.reverse()
.filter((event) => event.eventType !== 'profile_created')
.map((event) => ({
id: event.id,
eventType: event.eventType,
date: getBirdTimelineEventDate(event),
label: compactTimelineLocationLabel(getBirdTimelineGraphLabel(event)),
}));
const hatchDay = getBirdHatchDay(bird);
if (!hatchDay) {
return graphEvents.slice(-8);
}
const hatchItem: BirdTimelineGraphItem = {
id: `${bird.id}-hatch-date`,
eventType: 'hatch_date',
date: hatchDay,
label: 'Hatch Day',
};
return [
hatchItem,
...graphEvents.filter((event) => event.date >= hatchDay).slice(-7),
].sort((left, right) => left.date.localeCompare(right.date));
};
const getBirdTimelineGraphBranch = (item: BirdTimelineGraphItem, graphItems: BirdTimelineGraphItem[], index: number) => {
if (item.eventType === 'hatch_date') {
return {
placement: 'on-line',
distance: 0,
offset: 0,
angle: 0,
connectorLength: 0,
};
}
const itemX = getBirdTimelineGraphX(item, graphItems, index);
const closeItems = graphItems
.map((graphItem, graphItemIndex) => ({
item: graphItem,
index: graphItemIndex,
x: getBirdTimelineGraphX(graphItem, graphItems, graphItemIndex),
}))
.filter((entry) => Math.abs(entry.x - itemX) < 112)
.sort((left, right) => left.x - right.x || left.item.date.localeCompare(right.item.date) || left.index - right.index);
const hasNearbyHatch = closeItems.some((entry) => entry.item.eventType === 'hatch_date');
const closeEventItems = closeItems.filter((entry) => entry.item.eventType !== 'hatch_date');
const closeEventIndex = Math.max(0, closeEventItems.findIndex((entry) => entry.item.id === item.id));
const useCollisionLane = closeItems.length > 1;
const placement = useCollisionLane
? hasNearbyHatch
? closeEventIndex % 2 === 0 ? 'below' : 'above'
: closeEventIndex % 2 === 0 ? 'above' : 'below'
: index % 2 === 0 ? 'above' : 'below';
const lane = useCollisionLane ? Math.floor(closeEventIndex / 2) : 0;
const distance = useCollisionLane ? 70 + lane * 56 : 42;
const offset = useCollisionLane ? 44 + lane * 14 : 0;
const connectorLength = Math.round(Math.sqrt(distance ** 2 + offset ** 2));
return {
placement,
distance,
offset,
angle: offset ? (placement === 'above' ? 1 : -1) * Math.round((Math.atan(offset / distance) * 180) / Math.PI) : 0,
connectorLength,
};
};
const getLocalDateString = (date = new Date()) => {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
};
const getBirdTimelineGraphDomain = (graphItems: BirdTimelineGraphItem[]): BirdTimelineGraphDomain | null => {
if (!graphItems.length) {
return null;
}
const todayTime = parseDateValue(getLocalDateString()).getTime();
const eventTimes = graphItems.map((entry) => parseDateValue(entry.date).getTime());
return {
startTime: Math.min(...eventTimes),
endTime: Math.max(todayTime, ...eventTimes),
};
};
const getTimelineGraphPosition = (date: string, domain: BirdTimelineGraphDomain | null, fallbackIndex = 0, fallbackCount = 1) => {
if (!domain || domain.startTime === domain.endTime) {
if (fallbackCount <= 1) {
return (TIMELINE_GRAPH_START_X + TIMELINE_GRAPH_END_X) / 2;
}
return TIMELINE_GRAPH_START_X + (fallbackIndex / (fallbackCount - 1)) * (TIMELINE_GRAPH_END_X - TIMELINE_GRAPH_START_X);
}
const eventTime = parseDateValue(date).getTime();
return TIMELINE_GRAPH_START_X + ((eventTime - domain.startTime) / (domain.endTime - domain.startTime)) * (TIMELINE_GRAPH_END_X - TIMELINE_GRAPH_START_X);
};
const getBirdTimelineGraphX = (item: BirdTimelineGraphItem, graphItems: BirdTimelineGraphItem[], index: number) =>
getTimelineGraphPosition(item.date, getBirdTimelineGraphDomain(graphItems), index, graphItems.length);
const getBirdTimelineGraphTicks = (graphItems: BirdTimelineGraphItem[]): BirdTimelineGraphTick[] => {
const domain = getBirdTimelineGraphDomain(graphItems);
if (!domain) {
return [];
}
const { startTime, endTime } = domain;
const today = getLocalDateString();
const startDate = new Date(startTime);
const endDate = new Date(endTime);
const startYear = startDate.getFullYear();
const endYear = endDate.getFullYear();
const ticks: BirdTimelineGraphTick[] = Array.from({ length: endYear - startYear + 1 }, (_, index) => {
const year = startYear + index;
const yearStart = parseDateValue(`${year}-01-01`).getTime();
const tickTime = Math.min(Math.max(yearStart, startTime), endTime);
return {
id: `timeline-year-${year}`,
year,
date: new Date(tickTime).toISOString().slice(0, 10),
label: `${year}`,
};
});
if (!ticks.some((tick) => tick.date === today)) {
ticks.push({
id: 'timeline-today',
year: parseDateValue(today).getFullYear(),
date: today,
label: 'Today',
isToday: true,
});
}
return ticks.sort((left, right) => left.date.localeCompare(right.date));
};
const getBirdTimelineGraphTickX = (tick: BirdTimelineGraphTick, graphItems: BirdTimelineGraphItem[]) =>
getTimelineGraphPosition(tick.date, getBirdTimelineGraphDomain(graphItems));
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
@@ -3861,6 +4140,7 @@ function App() {
demotivators: profile.demotivators,
favoriteSnack: profile.favoriteSnack,
gender: profile.gender,
hatchDay: profile.dateOfBirth,
dateOfBirth: profile.dateOfBirth,
gotchaDay: profile.gotchaDay,
chartColor: profile.chartColor || '#cb3a35',
@@ -3957,7 +4237,7 @@ function App() {
const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...birdForm, locationLabel }),
body: JSON.stringify({ ...birdForm, hatchDay: birdForm.dateOfBirth, locationLabel }),
});
if (!response.ok) {
@@ -4911,7 +5191,7 @@ function App() {
['Species', selectedBird.species],
['Band/tag ID', selectedBird.tagId || 'Not recorded'],
['Sex', getBirdGenderLabel(selectedBird)],
['Hatch day', formatDate(selectedBird.dateOfBirth)],
['Hatch day', formatDate(getBirdHatchDay(selectedBird))],
['Favorite snack', selectedBird.favoriteSnack || 'Not recorded'],
['Latest weight', selectedBird.latestWeightGrams ? `${formatWeight(selectedBird.latestWeightGrams)}${selectedBird.latestRecordedOn ? ` on ${formatShortDate(selectedBird.latestRecordedOn)}` : ''}` : 'Pending'],
];
@@ -5624,7 +5904,7 @@ function App() {
</h1>
<article className="summary-card">
<span>Hatch Day</span>
<strong>{formatDate(publicProfile.dateOfBirth)}</strong>
<strong>{formatDate(getBirdHatchDay(publicProfile))}</strong>
</article>
<article className="summary-card">
<span>Favorite treat</span>
@@ -7352,7 +7632,7 @@ function App() {
<div className="detail-grid">
<article className="detail-card">
<span>Hatch Day</span>
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
<strong>{formatDate(getBirdHatchDay(selectedBird))}</strong>
</article>
<article className="detail-card">
<span>Gotcha day</span>
@@ -7916,48 +8196,64 @@ function App() {
</div>
<p className="muted">{birdTimelineLoading ? 'Loading...' : `${birdTimelineEvents.length} events`}</p>
</div>
{birdTimelineEvents.length ? (
<div className="bird-timeline-graph-card">
<svg
className="bird-timeline-graph"
viewBox="0 0 640 150"
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>
</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 timelineGraphItems = getBirdTimelineGraphItems(selectedBird, birdTimelineEvents);
const timelineGraphTicks = getBirdTimelineGraphTicks(timelineGraphItems);
return timelineGraphItems.length ? (
<div className="bird-timeline-graph-card">
<div className="bird-timeline-graph" role="img" aria-label={`${selectedBird.name} location and owner timeline`}>
<div className="bird-timeline-graph-line" />
<div className="bird-timeline-graph-scale" aria-hidden="true">
{timelineGraphTicks.map((tick) => {
const x = getBirdTimelineGraphTickX(tick, timelineGraphItems);
return (
<span
key={tick.id}
className={`bird-timeline-graph-tick${tick.isToday ? ' today' : ''}`}
style={{ left: `${(x / 640) * 100}%` }}
>
{tick.label}
</span>
);
})}
</div>
{timelineGraphItems.map((timelineItem, index, graphItems) => {
const x = getBirdTimelineGraphX(timelineItem, graphItems, index);
const position = `${(x / 640) * 100}%`;
const branch = getBirdTimelineGraphBranch(timelineItem, graphItems, index);
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}`}
/>
<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>
<div
key={timelineItem.id}
className={`bird-timeline-graph-point ${branch.placement} ${timelineItem.eventType}`}
style={
{
left: position,
'--branch-distance': `${branch.distance}px`,
'--branch-offset': `${branch.offset}px`,
'--branch-angle': `${branch.angle}deg`,
'--branch-connector-length': `${branch.connectorLength}px`,
} as CSSProperties
}
>
<div className="bird-timeline-graph-connector" />
<div className="bird-timeline-graph-dot">
<span className="material-symbols-outlined bird-timeline-graph-icon">
{getBirdTimelineGraphIcon(timelineItem)}
</span>
</div>
<div className="bird-timeline-graph-copy">
<strong>{timelineItem.label}</strong>
<span>{formatShortDate(timelineItem.date)}</span>
</div>
</div>
);
})}
</svg>
</div>
) : null}
})}
</div>
</div>
) : null;
})()}
<form className="form-panel inline-form care-entry-form bird-timeline-form" onSubmit={handleBirdTimelineEventSubmit}>
<label>
Type