diff --git a/backend/src/app.ts b/backend/src/app.ts index 1af5330..554889c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -320,6 +320,7 @@ const birdSchema = z.object({ vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')), vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')), gender: birdGenderSchema.optional(), + hatchDay: dateStringSchema.optional().or(z.literal('')), dateOfBirth: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')), chartColor: chartColorSchema.optional(), @@ -862,6 +863,7 @@ const normalizeBird = (row: BirdRow) => ({ vetAccountNumber: row.vet_account_number, vetDoctorName: row.vet_doctor_name, gender: row.gender, + hatchDay: row.date_of_birth, dateOfBirth: row.date_of_birth, gotchaDay: row.gotcha_day, chartColor: row.chart_color, @@ -890,6 +892,7 @@ const normalizePublicBirdProfile = (row: BirdRow) => ({ name: row.name, favoriteSnack: row.favorite_snack, gender: row.gender, + hatchDay: row.date_of_birth, dateOfBirth: row.date_of_birth, photoDataUrl: getBirdPhotoUrl(row), }); @@ -3978,7 +3981,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), vetDoctorName: emptyToNull(parsed.data.vetDoctorName), gender: (parsed.data.gender ?? 'unknown') as BirdGender, - dateOfBirth: emptyToNull(parsed.data.dateOfBirth), + dateOfBirth: emptyToNull(parsed.data.hatchDay || parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), chartColor: parsed.data.chartColor ?? '#cb3a35', photoDataUrl: photoStorage.photoDataUrl, @@ -4347,7 +4350,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), vetDoctorName: emptyToNull(parsed.data.vetDoctorName), gender: (parsed.data.gender ?? 'unknown') as BirdGender, - dateOfBirth: emptyToNull(parsed.data.dateOfBirth), + dateOfBirth: emptyToNull(parsed.data.hatchDay || parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), chartColor: parsed.data.chartColor ?? '#cb3a35', photoDataUrl: photoStorage.photoDataUrl, diff --git a/frontend/index.html b/frontend/index.html index 4e1d880..fcb5097 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,7 +8,7 @@ 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 98c8045..c208b90 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 (
{label} -
+
onSearchStateChange({ ...searchState, query: event.target.value, error: '' })} onKeyDown={(event) => { if (event.key === 'Enter') { @@ -803,19 +806,13 @@ const VerifiedLocationSearchField = ({ + {hasSelectedLocation ? ( + + ) : null}
- {selectedLabel ? ( -
-
- {selectedLabel} - {location.provider === 'mapbox' ? 'Verified by Mapbox' : 'Saved location'} -
- -
- ) : null} {searchState.error ? (

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

Hatch Day - {formatDate(publicProfile.dateOfBirth)} + {formatDate(getBirdHatchDay(publicProfile))}
Favorite treat @@ -7352,7 +7632,7 @@ function App() {
Hatch Day - {formatDate(selectedBird.dateOfBirth)} + {formatDate(getBirdHatchDay(selectedBird))}
Gotcha day @@ -7916,48 +8196,64 @@ function App() {

{birdTimelineLoading ? 'Loading...' : `${birdTimelineEvents.length} events`}

- {birdTimelineEvents.length ? ( -
- - - - - - - - - {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 ? ( +
+
+
+ + {timelineGraphItems.map((timelineItem, index, graphItems) => { + const x = getBirdTimelineGraphX(timelineItem, graphItems, index); + const position = `${(x / 640) * 100}%`; + const branch = getBirdTimelineGraphBranch(timelineItem, graphItems, index); return ( - - - - - {getBirdTimelineLocation(timelineEvent)} - - - {formatShortDate(getBirdTimelineEventDate(timelineEvent))} - - +
+
+
+ + {getBirdTimelineGraphIcon(timelineItem)} + +
+
+ {timelineItem.label} + {formatShortDate(timelineItem.date)} +
+
); - })} - -
- ) : null} + })} +
+
+ ) : null; + })()}