2 Commits

Author SHA1 Message Date
blaisadmin 14bc1c30a0 Merge origin/dev into dev
Deploy / deploy-dev (push) Successful in 3m48s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 23:29:31 -04:00
blaisadmin d03672fcdd Fixed Timeline 2026-06-30 23:25:07 -04:00
4 changed files with 555 additions and 345 deletions
+5 -2
View File
@@ -320,6 +320,7 @@ const birdSchema = z.object({
vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')), vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')),
vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')), vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')),
gender: birdGenderSchema.optional(), gender: birdGenderSchema.optional(),
hatchDay: dateStringSchema.optional().or(z.literal('')),
dateOfBirth: dateStringSchema.optional().or(z.literal('')), dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')),
chartColor: chartColorSchema.optional(), chartColor: chartColorSchema.optional(),
@@ -862,6 +863,7 @@ const normalizeBird = (row: BirdRow) => ({
vetAccountNumber: row.vet_account_number, vetAccountNumber: row.vet_account_number,
vetDoctorName: row.vet_doctor_name, vetDoctorName: row.vet_doctor_name,
gender: row.gender, gender: row.gender,
hatchDay: row.date_of_birth,
dateOfBirth: row.date_of_birth, dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day, gotchaDay: row.gotcha_day,
chartColor: row.chart_color, chartColor: row.chart_color,
@@ -890,6 +892,7 @@ const normalizePublicBirdProfile = (row: BirdRow) => ({
name: row.name, name: row.name,
favoriteSnack: row.favorite_snack, favoriteSnack: row.favorite_snack,
gender: row.gender, gender: row.gender,
hatchDay: row.date_of_birth,
dateOfBirth: row.date_of_birth, dateOfBirth: row.date_of_birth,
photoDataUrl: getBirdPhotoUrl(row), photoDataUrl: getBirdPhotoUrl(row),
}); });
@@ -3978,7 +3981,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
vetDoctorName: emptyToNull(parsed.data.vetDoctorName), vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
gender: (parsed.data.gender ?? 'unknown') as BirdGender, 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), gotchaDay: emptyToNull(parsed.data.gotchaDay),
chartColor: parsed.data.chartColor ?? '#cb3a35', chartColor: parsed.data.chartColor ?? '#cb3a35',
photoDataUrl: photoStorage.photoDataUrl, photoDataUrl: photoStorage.photoDataUrl,
@@ -4347,7 +4350,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
vetDoctorName: emptyToNull(parsed.data.vetDoctorName), vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
gender: (parsed.data.gender ?? 'unknown') as BirdGender, 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), gotchaDay: emptyToNull(parsed.data.gotchaDay),
chartColor: parsed.data.chartColor ?? '#cb3a35', chartColor: parsed.data.chartColor ?? '#cb3a35',
photoDataUrl: photoStorage.photoDataUrl, photoDataUrl: photoStorage.photoDataUrl,
+1 -4
View File
@@ -8,10 +8,7 @@
type="image/svg+xml" 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" 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 <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=egg,manage_accounts,map,move,move_location,sticky_note_2,timeline" />
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> <title>FlockPal</title>
</head> </head>
<body> <body>
+340 -283
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 birdSilhouette from './assets/bird-silhouette.jpg';
import flockPalLandingArt from './assets/flockpal-landing-art.png'; import flockPalLandingArt from './assets/flockpal-landing-art.png';
import flockPalTextArt from './assets/flockpal-text.png'; import flockPalTextArt from './assets/flockpal-text.png';
@@ -53,6 +53,7 @@ type Bird = {
vetAccountNumber: string | null; vetAccountNumber: string | null;
vetDoctorName: string | null; vetDoctorName: string | null;
gender: BirdGender; gender: BirdGender;
hatchDay: string | null;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
chartColor: string; chartColor: string;
@@ -265,14 +266,6 @@ type BirdTimelineEvent = {
createdAt: string; createdAt: string;
}; };
type BirdTimelineGraphEvent = {
id: string;
eventType: BirdTimelineEvent['eventType'] | 'hatch_date';
eventDate: string;
createdAt: string;
sourceEvent?: BirdTimelineEvent;
};
type BirdTimelineEventFormState = { type BirdTimelineEventFormState = {
eventType: 'location_updated' | 'owner_changed' | 'manual_note'; eventType: 'location_updated' | 'owner_changed' | 'manual_note';
ownerChanged: boolean; ownerChanged: boolean;
@@ -350,6 +343,7 @@ type PublicBirdProfile = {
name: string; name: string;
favoriteSnack: string | null; favoriteSnack: string | null;
gender: BirdGender; gender: BirdGender;
hatchDay: string | null;
dateOfBirth: string | null; dateOfBirth: string | null;
photoDataUrl: string | null; photoDataUrl: string | null;
}; };
@@ -789,16 +783,17 @@ const VerifiedLocationSearchField = ({
onSelect, onSelect,
onClear, onClear,
}: VerifiedLocationSearchFieldProps) => { }: VerifiedLocationSearchFieldProps) => {
const hasSelectedLocation = Boolean(formatVerifiedLocationLabel(location) || fallbackLabel); const selectedLabel = formatVerifiedLocationLabel(location) || fallbackLabel || '';
const hasSelectedLocation = Boolean(selectedLabel);
return ( return (
<div className="verified-location-field wide-field"> <div className="verified-location-field wide-field">
<div className="verified-location-label"> <div className="verified-location-label">
<span>{label}</span> <span>{label}</span>
<div className="verified-location-search-row"> <div className={`verified-location-search-row${hasSelectedLocation ? ' has-selected-location' : ''}`}>
<input <input
aria-label={label} aria-label={label}
value={searchState.query} value={searchState.query || selectedLabel}
onChange={(event) => onSearchStateChange({ ...searchState, query: event.target.value, error: '' })} onChange={(event) => onSearchStateChange({ ...searchState, query: event.target.value, error: '' })}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
@@ -812,7 +807,7 @@ const VerifiedLocationSearchField = ({
{searchState.searching ? 'Searching...' : 'Search'} {searchState.searching ? 'Searching...' : 'Search'}
</button> </button>
{hasSelectedLocation ? ( {hasSelectedLocation ? (
<button className="secondary-button" type="button" onClick={onClear}> <button className="secondary-button verified-location-clear" type="button" onClick={onClear}>
Clear Clear
</button> </button>
) : null} ) : null}
@@ -1026,7 +1021,7 @@ const toBirdForm = (bird: Bird): BirdFormState => ({
vetAccountNumber: bird.vetAccountNumber ?? '', vetAccountNumber: bird.vetAccountNumber ?? '',
vetDoctorName: bird.vetDoctorName ?? '', vetDoctorName: bird.vetDoctorName ?? '',
gender: bird.gender, gender: bird.gender,
dateOfBirth: bird.dateOfBirth ?? '', dateOfBirth: getBirdHatchDay(bird) ?? '',
gotchaDay: bird.gotchaDay ?? '', gotchaDay: bird.gotchaDay ?? '',
chartColor: bird.chartColor, chartColor: bird.chartColor,
photoDataUrl: bird.photoDataUrl ?? '', photoDataUrl: bird.photoDataUrl ?? '',
@@ -1183,11 +1178,11 @@ const formatBirdTimelineTitle = (event: BirdTimelineEvent) => {
} }
if (event.eventType === 'location_updated') { if (event.eventType === 'location_updated') {
return 'Location change'; return 'Location updated';
} }
if (event.eventType === 'owner_changed') { if (event.eventType === 'owner_changed') {
return 'Move location'; return 'Owner record changed';
} }
if (event.eventType === 'manual_note') { if (event.eventType === 'manual_note') {
@@ -1238,92 +1233,7 @@ const formatBirdTimelineSecondary = (event: BirdTimelineEvent) => {
const getBirdTimelineLocation = (event: BirdTimelineEvent) => const getBirdTimelineLocation = (event: BirdTimelineEvent) =>
event.locationLabel || event.toWorkspaceName || event.fromWorkspaceName || 'Unknown location'; 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 getBirdTimelineGraphIconX = (x: number, textAnchor: string) => {
if (textAnchor === 'start') {
return x;
}
if (textAnchor === 'end') {
return x - 24;
}
return x - 12;
};
const getBirdTimelineEventDate = (event: BirdTimelineEvent) => event.eventDate || event.createdAt.slice(0, 10); 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[]) => const sortBirdTimelineEvents = (events: BirdTimelineEvent[]) =>
[...events].sort((left, right) => { [...events].sort((left, right) => {
@@ -1331,87 +1241,290 @@ const sortBirdTimelineEvents = (events: BirdTimelineEvent[]) =>
return dateComparison || right.createdAt.localeCompare(left.createdAt); return dateComparison || right.createdAt.localeCompare(left.createdAt);
}); });
const sortBirdTimelineGraphEvents = (events: BirdTimelineGraphEvent[]) => const TIMELINE_GRAPH_START_X = 52;
[...events].sort((left, right) => { const TIMELINE_GRAPH_END_X = 588;
const dateComparison = getBirdTimelineGraphEventDate(left).localeCompare(getBirdTimelineGraphEventDate(right));
return dateComparison || left.createdAt.localeCompare(right.createdAt);
});
const getBirdTimelineGraphEvents = (events: BirdTimelineEvent[], hatchDate?: string | null) => { type BirdTimelineGraphItem = {
const timelineEvents = sortBirdTimelineEvents(events) 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() .reverse()
.map<BirdTimelineGraphEvent>((event) => ({ .filter((event) => event.eventType !== 'profile_created')
.map((event) => ({
id: event.id, id: event.id,
eventType: event.eventType, eventType: event.eventType,
eventDate: getBirdTimelineEventDate(event), date: getBirdTimelineEventDate(event),
createdAt: event.createdAt, label: compactTimelineLocationLabel(getBirdTimelineGraphLabel(event)),
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) { const hatchDay = getBirdHatchDay(bird);
return graphEvents;
}
if (hatchEvent.length) {
const latestEvents = graphEvents.filter((event) => event.id !== 'hatch-date').slice(-7);
return sortBirdTimelineGraphEvents([...hatchEvent, ...latestEvents]);
}
if (!hatchDay) {
return graphEvents.slice(-8); 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 buildBirdTimelineGraph = (events: BirdTimelineEvent[], hatchDate?: string | null) => { const getBirdTimelineGraphBranch = (item: BirdTimelineGraphItem, graphItems: BirdTimelineGraphItem[], index: number) => {
const graphEvents = getBirdTimelineGraphEvents(events, hatchDate); if (item.eventType === 'hatch_date') {
const left = 76; return {
const right = 564; placement: 'on-line',
const centerY = 88; distance: 0,
const width = right - left; offset: 0,
const points = graphEvents.map((timelineEvent, index) => { angle: 0,
const x = graphEvents.length === 1 ? 320 : left + (index / (graphEvents.length - 1)) * width; connectorLength: 0,
const labelY = index % 2 === 0 ? 30 : 134; };
const textAnchor = index === 0 ? 'start' : index === graphEvents.length - 1 ? 'end' : 'middle'; }
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 { return {
event: timelineEvent, placement,
x, distance,
y: centerY, offset,
labelY, angle: offset ? (placement === 'above' ? 1 : -1) * Math.round((Math.atan(offset / distance) * 180) / Math.PI) : 0,
connectorEndY: index % 2 === 0 ? 54 : 112, connectorLength,
textAnchor, };
};
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}`,
}; };
}); });
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)) { if (!ticks.some((tick) => tick.date === today)) {
markers.push({ year, x: point.x }); ticks.push({
id: 'timeline-today',
year: parseDateValue(today).getFullYear(),
date: today,
label: 'Today',
isToday: true,
});
} }
return markers; return ticks.sort((left, right) => left.date.localeCompare(right.date));
}, []);
return {
points,
path,
yearMarkers,
left,
right,
centerY,
};
}; };
const getBirdTimelineGraphTickX = (tick: BirdTimelineGraphTick, graphItems: BirdTimelineGraphItem[]) =>
getTimelineGraphPosition(tick.date, getBirdTimelineGraphDomain(graphItems));
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`; const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`); const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
@@ -4027,6 +4140,7 @@ function App() {
demotivators: profile.demotivators, demotivators: profile.demotivators,
favoriteSnack: profile.favoriteSnack, favoriteSnack: profile.favoriteSnack,
gender: profile.gender, gender: profile.gender,
hatchDay: profile.dateOfBirth,
dateOfBirth: profile.dateOfBirth, dateOfBirth: profile.dateOfBirth,
gotchaDay: profile.gotchaDay, gotchaDay: profile.gotchaDay,
chartColor: profile.chartColor || '#cb3a35', chartColor: profile.chartColor || '#cb3a35',
@@ -4123,7 +4237,7 @@ function App() {
const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, { const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...birdForm, locationLabel }), body: JSON.stringify({ ...birdForm, hatchDay: birdForm.dateOfBirth, locationLabel }),
}); });
if (!response.ok) { if (!response.ok) {
@@ -5077,7 +5191,7 @@ function App() {
['Species', selectedBird.species], ['Species', selectedBird.species],
['Band/tag ID', selectedBird.tagId || 'Not recorded'], ['Band/tag ID', selectedBird.tagId || 'Not recorded'],
['Sex', getBirdGenderLabel(selectedBird)], ['Sex', getBirdGenderLabel(selectedBird)],
['Hatch day', formatDate(selectedBird.dateOfBirth)], ['Hatch day', formatDate(getBirdHatchDay(selectedBird))],
['Favorite snack', selectedBird.favoriteSnack || 'Not recorded'], ['Favorite snack', selectedBird.favoriteSnack || 'Not recorded'],
['Latest weight', selectedBird.latestWeightGrams ? `${formatWeight(selectedBird.latestWeightGrams)}${selectedBird.latestRecordedOn ? ` on ${formatShortDate(selectedBird.latestRecordedOn)}` : ''}` : 'Pending'], ['Latest weight', selectedBird.latestWeightGrams ? `${formatWeight(selectedBird.latestWeightGrams)}${selectedBird.latestRecordedOn ? ` on ${formatShortDate(selectedBird.latestRecordedOn)}` : ''}` : 'Pending'],
]; ];
@@ -5790,7 +5904,7 @@ function App() {
</h1> </h1>
<article className="summary-card"> <article className="summary-card">
<span>Hatch Day</span> <span>Hatch Day</span>
<strong>{formatDate(publicProfile.dateOfBirth)}</strong> <strong>{formatDate(getBirdHatchDay(publicProfile))}</strong>
</article> </article>
<article className="summary-card"> <article className="summary-card">
<span>Favorite treat</span> <span>Favorite treat</span>
@@ -7399,9 +7513,9 @@ function App() {
aria-label="Timeline" aria-label="Timeline"
title="Timeline" title="Timeline"
> >
<svg className="timeline-tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <span className="material-symbols-outlined timeline-tab-icon" aria-hidden="true">
<path d="M21 6a2 2 0 0 0-1.93 2.51l-3.55 3.56A2.15 2.15 0 0 0 15 12c-.18 0-.36.02-.52.07l-2.55-2.55c.05-.16.07-.34.07-.52a2 2 0 1 0-4 0c0 .18.02.36.07.52l-4.56 4.55A2.03 2.03 0 0 0 3 14a2 2 0 1 0 1.93 1.49l4.55-4.56c.16.05.34.07.52.07s.36-.02.52-.07l2.55 2.55c-.05.16-.07.34-.07.52a2 2 0 1 0 4 0c0-.18-.02-.36-.07-.52l3.56-3.55A2 2 0 1 0 21 6Z" /> timeline
</svg> </span>
</button> </button>
<button <button
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`} className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
@@ -7518,7 +7632,7 @@ function App() {
<div className="detail-grid"> <div className="detail-grid">
<article className="detail-card"> <article className="detail-card">
<span>Hatch Day</span> <span>Hatch Day</span>
<strong>{formatDate(selectedBird.dateOfBirth)}</strong> <strong>{formatDate(getBirdHatchDay(selectedBird))}</strong>
</article> </article>
<article className="detail-card"> <article className="detail-card">
<span>Gotcha day</span> <span>Gotcha day</span>
@@ -8082,122 +8196,64 @@ function App() {
</div> </div>
<p className="muted">{birdTimelineLoading ? 'Loading...' : `${birdTimelineEvents.length} events`}</p> <p className="muted">{birdTimelineLoading ? 'Loading...' : `${birdTimelineEvents.length} events`}</p>
</div> </div>
{birdTimelineEvents.length ? (
<div className="bird-timeline-graph-card">
<svg
className="bird-timeline-graph"
viewBox="0 0 640 200"
role="img"
aria-label={`${selectedBird.name} location and owner timeline`}
>
<defs>
<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>
{(() => { {(() => {
const timelineGraph = buildBirdTimelineGraph(birdTimelineEvents, selectedBird.dateOfBirth); 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 ( return (
<> <div
<line key={timelineItem.id}
x1={timelineGraph.left} className={`bird-timeline-graph-point ${branch.placement} ${timelineItem.eventType}`}
y1={timelineGraph.centerY} style={
x2={timelineGraph.right} {
y2={timelineGraph.centerY} left: position,
className="bird-timeline-graph-axis" '--branch-distance': `${branch.distance}px`,
/> '--branch-offset': `${branch.offset}px`,
{timelineGraph.yearMarkers.map((marker) => ( '--branch-angle': `${branch.angle}deg`,
<g key={marker.year}> '--branch-connector-length': `${branch.connectorLength}px`,
<line } as CSSProperties
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> <div className="bird-timeline-graph-connector" />
<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" /> <div className="bird-timeline-graph-dot">
</g> <span className="material-symbols-outlined bird-timeline-graph-icon">
) : ( {getBirdTimelineGraphIcon(timelineItem)}
<circle </span>
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' ? (
<foreignObject
x={getBirdTimelineGraphIconX(x, textAnchor)}
y={labelY - 18}
width="24"
height="24"
className="bird-timeline-graph-event-icon"
>
<span className="material-symbols-outlined" aria-hidden="true">{'move'}</span>
</foreignObject>
) : timelineEvent.eventType === 'owner_changed' ? (
<foreignObject
x={getBirdTimelineGraphIconX(x, textAnchor)}
y={labelY - 18}
width="24"
height="24"
className="bird-timeline-graph-event-icon"
>
<span className="material-symbols-outlined" aria-hidden="true">{'move_location'}</span>
</foreignObject>
) : 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> </div>
) : null} <div className="bird-timeline-graph-copy">
<strong>{timelineItem.label}</strong>
<span>{formatShortDate(timelineItem.date)}</span>
</div>
</div>
);
})}
</div>
</div>
) : null;
})()}
<form className="form-panel inline-form care-entry-form bird-timeline-form" onSubmit={handleBirdTimelineEventSubmit}> <form className="form-panel inline-form care-entry-form bird-timeline-form" onSubmit={handleBirdTimelineEventSubmit}>
<label> <label>
Type Type
@@ -8218,8 +8274,9 @@ function App() {
}); });
}} }}
> >
<option value="location_updated">Location change</option> <option value="location_updated">Location note</option>
<option value="owner_changed">Owner Change</option> <option value="owner_changed">Owner</option>
<option value="manual_note">General note</option>
</select> </select>
</label> </label>
<label> <label>
+207 -54
View File
@@ -1340,93 +1340,217 @@ textarea {
} }
.bird-timeline-graph { .bird-timeline-graph {
display: block; position: relative;
width: 100%; min-height: 340px;
height: auto; isolation: isolate;
min-height: 200px;
} }
.bird-timeline-graph-axis { .bird-timeline-graph-line {
stroke: rgba(31, 42, 42, 0.18); position: absolute;
stroke-width: 1.2; left: calc(8.125% + 18px);
right: 8.125%;
top: 50%;
height: 4px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(39, 105, 179, 0.42), rgba(35, 138, 90, 0.88));
transform: translateY(-50%);
} }
.bird-timeline-graph-year-line { .bird-timeline-graph-scale {
stroke: rgba(39, 105, 179, 0.16); position: absolute;
stroke-width: 1; inset: 0;
stroke-dasharray: 4 6; pointer-events: none;
z-index: 0;
} }
.bird-timeline-graph-year { .bird-timeline-graph-tick {
fill: var(--muted); position: absolute;
font-size: 11px; top: calc(50% + 78px);
transform: translateX(-50%);
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
} }
.bird-timeline-graph-connector { .bird-timeline-graph-tick::before {
stroke: rgba(39, 105, 179, 0.18); content: "";
stroke-width: 2; position: absolute;
stroke-dasharray: 4 5; left: 50%;
bottom: calc(100% + 0.35rem);
width: 1px;
height: 68px;
background: linear-gradient(to bottom, rgba(39, 105, 179, 0.22), rgba(39, 105, 179, 0));
transform: translateX(-50%);
}
.bird-timeline-graph-tick.today {
color: var(--accent-green);
font-weight: 800;
}
.bird-timeline-graph-tick.today::before {
background: linear-gradient(to bottom, rgba(35, 138, 90, 0.42), rgba(35, 138, 90, 0));
}
.bird-timeline-graph-point {
position: absolute;
top: 50%;
width: 136px;
height: 0;
transform: translateX(-50%);
z-index: 1;
} }
.bird-timeline-graph-dot { .bird-timeline-graph-dot {
fill: #fffdf9; position: absolute;
stroke: var(--accent-green); left: 50%;
stroke-width: 4; z-index: 2;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border: 4px solid var(--accent-green);
border-radius: 50%;
background: #fffdf9;
} }
.bird-timeline-graph-dot.owner_changed { .bird-timeline-graph-point.above .bird-timeline-graph-dot {
stroke: var(--accent-blue); left: calc(50% + var(--branch-offset, 0px));
bottom: var(--branch-distance, 34px);
transform: translate(-50%, 50%);
} }
.bird-timeline-graph-dot.transferred { .bird-timeline-graph-point.below .bird-timeline-graph-dot {
stroke: var(--accent-red); left: calc(50% + var(--branch-offset, 0px));
top: var(--branch-distance, 34px);
transform: translate(-50%, -50%);
} }
.bird-timeline-graph-label, .bird-timeline-graph-point.on-line .bird-timeline-graph-dot {
.bird-timeline-graph-meta { top: 0;
font-size: 11px; transform: translate(-50%, -50%);
fill: var(--ink);
} }
.bird-timeline-graph-label { .bird-timeline-graph-point.owner_changed .bird-timeline-graph-dot {
font-weight: 700; border-color: var(--accent-blue);
} }
.bird-timeline-graph-meta { .bird-timeline-graph-point.transferred .bird-timeline-graph-dot {
fill: var(--muted); border-color: var(--accent-red);
} }
.bird-timeline-graph-event-icon { .bird-timeline-graph-point.hatch_date .bird-timeline-graph-dot {
overflow: visible; width: 34px;
height: 34px;
border: 0;
border-radius: 50%;
background: #fffdf9;
} }
.bird-timeline-graph-event-icon .material-symbols-outlined { .bird-timeline-graph-connector {
align-items: center; position: absolute;
color: #1f1f1f; left: 50%;
direction: ltr; width: 2px;
height: var(--branch-connector-length, var(--branch-distance, 34px));
background: repeating-linear-gradient(
to bottom,
rgba(39, 105, 179, 0.18) 0 4px,
transparent 4px 9px
);
transform: translateX(-50%) rotate(var(--branch-angle, 0deg));
}
.bird-timeline-graph-point.above .bird-timeline-graph-connector {
bottom: 0;
transform-origin: bottom center;
}
.bird-timeline-graph-point.below .bird-timeline-graph-connector {
top: 0;
transform-origin: top center;
}
.bird-timeline-graph-point.on-line .bird-timeline-graph-connector {
display: none;
}
.bird-timeline-graph-icon {
display: inline-flex; display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent-green);
font-family: "Material Symbols Outlined"; font-family: "Material Symbols Outlined";
font-size: 22px; font-size: 18px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
line-height: 1;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
direction: ltr;
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24; font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
font-feature-settings: "liga"; font-feature-settings: "liga";
-webkit-font-feature-settings: "liga"; -webkit-font-feature-settings: "liga";
height: 24px; -webkit-font-smoothing: antialiased;
justify-content: center;
letter-spacing: 0;
line-height: 1;
text-transform: none;
white-space: nowrap;
width: 24px;
} }
.bird-timeline-graph-hatch-dot { .bird-timeline-graph-point.hatch_date .bird-timeline-graph-icon {
fill: #fffdf9; color: var(--accent-gold);
stroke: #1f1f1f; font-size: 34px;
stroke-width: 2; }
stroke-linecap: round;
stroke-linejoin: round; .bird-timeline-graph-point.owner_changed .bird-timeline-graph-icon {
color: var(--accent-blue);
}
.bird-timeline-graph-point.transferred .bird-timeline-graph-icon {
color: var(--accent-red);
}
.bird-timeline-graph-copy {
position: absolute;
left: 50%;
width: 124px;
transform: translateX(-50%);
display: grid;
gap: 0.08rem;
justify-items: center;
text-align: center;
color: var(--ink);
font-size: 0.68rem;
}
.bird-timeline-graph-copy strong {
font-weight: 700;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.bird-timeline-graph-copy span {
color: var(--muted);
line-height: 1.1;
}
.bird-timeline-graph-point.above .bird-timeline-graph-copy {
left: calc(50% + var(--branch-offset, 0px));
bottom: calc(var(--branch-distance, 34px) + 24px);
}
.bird-timeline-graph-point.below .bird-timeline-graph-copy {
left: calc(50% + var(--branch-offset, 0px));
top: calc(var(--branch-distance, 34px) + 24px);
}
.bird-timeline-graph-point.on-line .bird-timeline-graph-copy {
bottom: 28px;
} }
.bird-timeline-form { .bird-timeline-form {
@@ -1583,6 +1707,25 @@ textarea {
stroke: none; stroke: none;
} }
.bird-detail-tab .timeline-tab-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: currentColor;
font-family: "Material Symbols Outlined";
font-size: 24px;
font-style: normal;
font-weight: 400;
line-height: 1;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
direction: ltr;
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
-webkit-font-feature-settings: "liga";
-webkit-font-smoothing: antialiased;
}
.bird-detail-tab:hover { .bird-detail-tab:hover {
border-color: rgba(35, 138, 90, 0.28); border-color: rgba(35, 138, 90, 0.28);
color: var(--ink); color: var(--ink);
@@ -2076,11 +2219,21 @@ label {
.verified-location-search-row { .verified-location-search-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 0.65rem; gap: 0.65rem;
align-items: end; align-items: end;
} }
.verified-location-search-row.has-selected-location {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.verified-location-search-row.has-selected-location input {
border-color: rgba(35, 138, 90, 0.45);
background: rgba(35, 138, 90, 0.08);
box-shadow: 0 0 0 3px rgba(35, 138, 90, 0.08);
}
.verified-location-result small { .verified-location-result small {
color: var(--muted); color: var(--muted);
} }