working on timeline locations
This commit is contained in:
+299
-23
@@ -16,6 +16,17 @@ type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejec
|
||||
type IntegrationTokenScope = 'read_only' | 'read_write';
|
||||
type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
|
||||
|
||||
type VerifiedLocationDetails = {
|
||||
city: string;
|
||||
region: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
precision: 'city' | 'region' | 'country';
|
||||
verifiedAt?: string;
|
||||
};
|
||||
|
||||
type Bird = {
|
||||
id: string;
|
||||
workspaceId?: number;
|
||||
@@ -26,6 +37,7 @@ type Bird = {
|
||||
demotivators: string | null;
|
||||
favoriteSnack: string | null;
|
||||
locationLabel: string | null;
|
||||
locationDetails: VerifiedLocationDetails | null;
|
||||
vetClinicName: string | null;
|
||||
vetClinicAddress: string | null;
|
||||
vetAccountNumber: string | null;
|
||||
@@ -236,6 +248,7 @@ type BirdTimelineEvent = {
|
||||
fromOwnerEmail: string | null;
|
||||
toOwnerEmail: string | null;
|
||||
locationLabel: string | null;
|
||||
locationDetails: VerifiedLocationDetails | null;
|
||||
note: string | null;
|
||||
eventDate: string;
|
||||
createdByUserId: string | null;
|
||||
@@ -243,10 +256,11 @@ type BirdTimelineEvent = {
|
||||
};
|
||||
|
||||
type BirdTimelineEventFormState = {
|
||||
eventType: 'location_updated' | 'manual_note';
|
||||
eventType: 'location_updated' | 'owner_changed' | 'manual_note';
|
||||
ownerChanged: boolean;
|
||||
eventDate: string;
|
||||
locationLabel: string;
|
||||
locationDetails: VerifiedLocationDetails;
|
||||
note: string;
|
||||
};
|
||||
|
||||
@@ -269,6 +283,7 @@ type BirdFormState = {
|
||||
demotivators: string;
|
||||
favoriteSnack: string;
|
||||
locationLabel: string;
|
||||
locationDetails: VerifiedLocationDetails;
|
||||
vetClinicName: string;
|
||||
vetClinicAddress: string;
|
||||
vetAccountNumber: string;
|
||||
@@ -696,6 +711,32 @@ const parseBirdImportRows = (rows: Record<string, unknown>[]): BirdImportPreview
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
const emptyVerifiedLocationDetails: VerifiedLocationDetails = {
|
||||
city: '',
|
||||
region: '',
|
||||
country: '',
|
||||
countryCode: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
precision: 'city',
|
||||
};
|
||||
|
||||
const normalizeVerifiedLocationDetails = (details: Partial<VerifiedLocationDetails> | null | undefined): VerifiedLocationDetails => ({
|
||||
...emptyVerifiedLocationDetails,
|
||||
...details,
|
||||
city: details?.city ?? '',
|
||||
region: details?.region ?? '',
|
||||
country: details?.country ?? '',
|
||||
countryCode: details?.countryCode ?? '',
|
||||
latitude: typeof details?.latitude === 'number' ? details.latitude : null,
|
||||
longitude: typeof details?.longitude === 'number' ? details.longitude : null,
|
||||
precision: details?.precision ?? 'city',
|
||||
});
|
||||
|
||||
const formatVerifiedLocationLabel = (details: VerifiedLocationDetails) =>
|
||||
[details.city.trim(), details.region.trim(), details.country.trim()].filter(Boolean).join(', ');
|
||||
|
||||
const emptyBirdForm: BirdFormState = {
|
||||
name: '',
|
||||
tagId: '',
|
||||
@@ -704,6 +745,7 @@ const emptyBirdForm: BirdFormState = {
|
||||
demotivators: '',
|
||||
favoriteSnack: '',
|
||||
locationLabel: '',
|
||||
locationDetails: emptyVerifiedLocationDetails,
|
||||
vetClinicName: '',
|
||||
vetClinicAddress: '',
|
||||
vetAccountNumber: '',
|
||||
@@ -723,6 +765,7 @@ const emptyBirdTimelineEventForm: BirdTimelineEventFormState = {
|
||||
ownerChanged: false,
|
||||
eventDate: new Date().toISOString().slice(0, 10),
|
||||
locationLabel: '',
|
||||
locationDetails: emptyVerifiedLocationDetails,
|
||||
note: '',
|
||||
};
|
||||
|
||||
@@ -869,6 +912,7 @@ const toBirdForm = (bird: Bird): BirdFormState => ({
|
||||
demotivators: parseBirdProfileList(bird.demotivators).join('\n'),
|
||||
favoriteSnack: bird.favoriteSnack ?? '',
|
||||
locationLabel: bird.locationLabel ?? '',
|
||||
locationDetails: normalizeVerifiedLocationDetails(bird.locationDetails),
|
||||
vetClinicName: bird.vetClinicName ?? '',
|
||||
vetClinicAddress: bird.vetClinicAddress ?? '',
|
||||
vetAccountNumber: bird.vetAccountNumber ?? '',
|
||||
@@ -1801,6 +1845,7 @@ function App() {
|
||||
const [auditLogLoading, setAuditLogLoading] = useState(false);
|
||||
const [birdTimelineLoading, setBirdTimelineLoading] = useState(false);
|
||||
const [savingBirdTimelineEvent, setSavingBirdTimelineEvent] = useState(false);
|
||||
const [birdTimelineNotice, setBirdTimelineNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null);
|
||||
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
||||
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
||||
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
||||
@@ -1908,6 +1953,7 @@ function App() {
|
||||
setSelectedBirdTab('info');
|
||||
setBirdTimelineEvents([]);
|
||||
setBirdTimelineEventForm(emptyBirdTimelineEventForm);
|
||||
setBirdTimelineNotice(null);
|
||||
}, [selectedBirdId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3759,10 +3805,11 @@ function App() {
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const locationLabel = formatVerifiedLocationLabel(birdForm.locationDetails) || birdForm.locationLabel;
|
||||
const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(birdForm),
|
||||
body: JSON.stringify({ ...birdForm, locationLabel }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -3803,15 +3850,34 @@ function App() {
|
||||
}
|
||||
|
||||
setError('');
|
||||
setBirdTimelineNotice(null);
|
||||
setSavingBirdTimelineEvent(true);
|
||||
|
||||
try {
|
||||
const eventType = birdTimelineEventForm.ownerChanged ? 'owner_changed' : birdTimelineEventForm.eventType;
|
||||
const verifiedLocationLabel = formatVerifiedLocationLabel(birdTimelineEventForm.locationDetails);
|
||||
const locationLabel =
|
||||
birdTimelineEventForm.eventType === 'location_updated'
|
||||
? verifiedLocationLabel || birdTimelineEventForm.locationLabel.trim() || selectedBird.locationLabel || ''
|
||||
: birdTimelineEventForm.locationLabel;
|
||||
const note = birdTimelineEventForm.note.trim();
|
||||
|
||||
if (eventType !== 'owner_changed' && !locationLabel.trim() && !note) {
|
||||
setBirdTimelineNotice({
|
||||
kind: 'error',
|
||||
message: 'Add a location or note before adding this timeline item.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await apiFetch(`/birds/${selectedBird.id}/timeline`, authToken, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...birdTimelineEventForm,
|
||||
eventType: birdTimelineEventForm.ownerChanged ? 'owner_changed' : birdTimelineEventForm.eventType,
|
||||
eventType,
|
||||
locationLabel,
|
||||
note,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3826,8 +3892,12 @@ function App() {
|
||||
|
||||
setBirdTimelineEvents((current) => sortBirdTimelineEvents([data.event!, ...current.filter((entry) => entry.id !== data.event!.id)]));
|
||||
setBirdTimelineEventForm(emptyBirdTimelineEventForm);
|
||||
setBirdTimelineNotice({ kind: 'success', message: 'Timeline item added.' });
|
||||
} catch (timelineError) {
|
||||
setError(timelineError instanceof Error ? timelineError.message : 'Unable to add timeline item.');
|
||||
setBirdTimelineNotice({
|
||||
kind: 'error',
|
||||
message: timelineError instanceof Error ? timelineError.message : 'Unable to add timeline item.',
|
||||
});
|
||||
} finally {
|
||||
setSavingBirdTimelineEvent(false);
|
||||
}
|
||||
@@ -6506,8 +6576,103 @@ function App() {
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<div className="settings-inline-header wide-field">
|
||||
<p className="eyebrow">Location</p>
|
||||
<h3>Mappable location</h3>
|
||||
</div>
|
||||
<label>
|
||||
City
|
||||
<input
|
||||
value={birdForm.locationDetails.city}
|
||||
onChange={(event) =>
|
||||
setBirdForm({
|
||||
...birdForm,
|
||||
locationDetails: { ...birdForm.locationDetails, city: event.target.value, precision: 'city' },
|
||||
})
|
||||
}
|
||||
placeholder="City"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Region
|
||||
<input
|
||||
value={birdForm.locationDetails.region}
|
||||
onChange={(event) =>
|
||||
setBirdForm({
|
||||
...birdForm,
|
||||
locationDetails: { ...birdForm.locationDetails, region: event.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="State, province, or region"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Country
|
||||
<input
|
||||
value={birdForm.locationDetails.country}
|
||||
onChange={(event) =>
|
||||
setBirdForm({
|
||||
...birdForm,
|
||||
locationDetails: { ...birdForm.locationDetails, country: event.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="Country"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Country code
|
||||
<input
|
||||
value={birdForm.locationDetails.countryCode}
|
||||
onChange={(event) =>
|
||||
setBirdForm({
|
||||
...birdForm,
|
||||
locationDetails: { ...birdForm.locationDetails, countryCode: event.target.value.toUpperCase().slice(0, 2) },
|
||||
})
|
||||
}
|
||||
placeholder="US"
|
||||
maxLength={2}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Latitude
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="-90"
|
||||
max="90"
|
||||
value={birdForm.locationDetails.latitude ?? ''}
|
||||
onChange={(event) =>
|
||||
setBirdForm({
|
||||
...birdForm,
|
||||
locationDetails: {
|
||||
...birdForm.locationDetails,
|
||||
latitude: event.target.value ? Number(event.target.value) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Longitude
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="-180"
|
||||
max="180"
|
||||
value={birdForm.locationDetails.longitude ?? ''}
|
||||
onChange={(event) =>
|
||||
setBirdForm({
|
||||
...birdForm,
|
||||
locationDetails: {
|
||||
...birdForm.locationDetails,
|
||||
longitude: event.target.value ? Number(event.target.value) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="wide-field">
|
||||
Location
|
||||
Display label
|
||||
<input
|
||||
value={birdForm.locationLabel}
|
||||
onChange={(event) => setBirdForm({ ...birdForm, locationLabel: event.target.value })}
|
||||
@@ -7002,9 +7167,9 @@ function App() {
|
||||
aria-label="Timeline"
|
||||
title="Timeline"
|
||||
>
|
||||
<svg className="timeline-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
|
||||
<path d="M280-120q-66 0-113-47t-47-113q0-56 34.5-99t85.5-56v-90q-51-13-85.5-56T120-680q0-66 47-113t113-47q56 0 99 34.5t56 85.5h90q13-51 56-85.5t99-34.5q66 0 113 47t47 113q0 56-34.5 99t-85.5 56v90q51 13 85.5 56t34.5 99q0 66-47 113t-113 47q-56 0-99-34.5T525-240h-90q-13 51-56 85.5T280-120Zm0-80q33 0 56.5-23.5T360-280q0-33-23.5-56.5T280-360q-33 0-56.5 23.5T200-280q0 33 23.5 56.5T280-200Zm400 0q33 0 56.5-23.5T760-280q0-33-23.5-56.5T680-360q-33 0-56.5 23.5T600-280q0 33 23.5 56.5T680-200ZM280-600q33 0 56.5-23.5T360-680q0-33-23.5-56.5T280-760q-33 0-56.5 23.5T200-680q0 33 23.5 56.5T280-600Zm400 0q33 0 56.5-23.5T760-680q0-33-23.5-56.5T680-760q-33 0-56.5 23.5T600-680q0 33 23.5 56.5T680-600ZM435-320h90q8-30 26-54t44-39q-13-18-20-40t-7-47q0-25 7-47t20-40q-26-15-44-39t-26-54h-90q-8 30-26 54t-44 39q13 18 20 40t7 47q0 25-7 47t-20 40q26 15 44 39t26 54Z" />
|
||||
</svg>
|
||||
<span className="material-symbols-outlined timeline-tab-icon" aria-hidden="true">
|
||||
timeline
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
|
||||
@@ -7732,16 +7897,20 @@ function App() {
|
||||
Type
|
||||
<select
|
||||
value={birdTimelineEventForm.eventType}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
const eventType = event.target.value as BirdTimelineEventFormState['eventType'];
|
||||
setBirdTimelineEventForm({
|
||||
...birdTimelineEventForm,
|
||||
eventType: event.target.value as BirdTimelineEventFormState['eventType'],
|
||||
ownerChanged:
|
||||
event.target.value === 'location_updated' ? birdTimelineEventForm.ownerChanged : false,
|
||||
})
|
||||
}
|
||||
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>
|
||||
</select>
|
||||
</label>
|
||||
@@ -7754,14 +7923,116 @@ function App() {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Location
|
||||
<input
|
||||
value={birdTimelineEventForm.locationLabel}
|
||||
onChange={(event) => setBirdTimelineEventForm({ ...birdTimelineEventForm, locationLabel: event.target.value })}
|
||||
placeholder={selectedBird.locationLabel || 'City, region, or country'}
|
||||
/>
|
||||
</label>
|
||||
{birdTimelineEventForm.eventType !== 'owner_changed' ? (
|
||||
<>
|
||||
<label>
|
||||
City
|
||||
<input
|
||||
value={birdTimelineEventForm.locationDetails.city}
|
||||
onChange={(event) =>
|
||||
setBirdTimelineEventForm({
|
||||
...birdTimelineEventForm,
|
||||
locationDetails: {
|
||||
...birdTimelineEventForm.locationDetails,
|
||||
city: event.target.value,
|
||||
precision: 'city',
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder={selectedBird.locationDetails?.city || 'City'}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Region
|
||||
<input
|
||||
value={birdTimelineEventForm.locationDetails.region}
|
||||
onChange={(event) =>
|
||||
setBirdTimelineEventForm({
|
||||
...birdTimelineEventForm,
|
||||
locationDetails: { ...birdTimelineEventForm.locationDetails, region: event.target.value },
|
||||
})
|
||||
}
|
||||
placeholder={selectedBird.locationDetails?.region || 'State or region'}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Country
|
||||
<input
|
||||
value={birdTimelineEventForm.locationDetails.country}
|
||||
onChange={(event) =>
|
||||
setBirdTimelineEventForm({
|
||||
...birdTimelineEventForm,
|
||||
locationDetails: { ...birdTimelineEventForm.locationDetails, country: event.target.value },
|
||||
})
|
||||
}
|
||||
placeholder={selectedBird.locationDetails?.country || 'Country'}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Country code
|
||||
<input
|
||||
value={birdTimelineEventForm.locationDetails.countryCode}
|
||||
onChange={(event) =>
|
||||
setBirdTimelineEventForm({
|
||||
...birdTimelineEventForm,
|
||||
locationDetails: {
|
||||
...birdTimelineEventForm.locationDetails,
|
||||
countryCode: event.target.value.toUpperCase().slice(0, 2),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder={selectedBird.locationDetails?.countryCode || 'US'}
|
||||
maxLength={2}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Latitude
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="-90"
|
||||
max="90"
|
||||
value={birdTimelineEventForm.locationDetails.latitude ?? ''}
|
||||
onChange={(event) =>
|
||||
setBirdTimelineEventForm({
|
||||
...birdTimelineEventForm,
|
||||
locationDetails: {
|
||||
...birdTimelineEventForm.locationDetails,
|
||||
latitude: event.target.value ? Number(event.target.value) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Longitude
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="-180"
|
||||
max="180"
|
||||
value={birdTimelineEventForm.locationDetails.longitude ?? ''}
|
||||
onChange={(event) =>
|
||||
setBirdTimelineEventForm({
|
||||
...birdTimelineEventForm,
|
||||
locationDetails: {
|
||||
...birdTimelineEventForm.locationDetails,
|
||||
longitude: event.target.value ? Number(event.target.value) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="wide-field">
|
||||
Display label
|
||||
<input
|
||||
value={birdTimelineEventForm.locationLabel}
|
||||
onChange={(event) => setBirdTimelineEventForm({ ...birdTimelineEventForm, locationLabel: event.target.value })}
|
||||
placeholder={selectedBird.locationLabel || 'City, region, or country'}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
{birdTimelineEventForm.eventType === 'location_updated' ? (
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
@@ -7784,7 +8055,7 @@ function App() {
|
||||
value={birdTimelineEventForm.note}
|
||||
onChange={(event) => setBirdTimelineEventForm({ ...birdTimelineEventForm, note: event.target.value })}
|
||||
placeholder={
|
||||
birdTimelineEventForm.ownerChanged
|
||||
birdTimelineEventForm.ownerChanged || birdTimelineEventForm.eventType === 'owner_changed'
|
||||
? 'Optional context without owner names'
|
||||
: 'Optional timeline context'
|
||||
}
|
||||
@@ -7793,6 +8064,11 @@ function App() {
|
||||
<button className="primary-button" type="submit" disabled={savingBirdTimelineEvent}>
|
||||
{savingBirdTimelineEvent ? 'Adding...' : 'Add timeline item'}
|
||||
</button>
|
||||
{birdTimelineNotice ? (
|
||||
<p className={birdTimelineNotice.kind === 'error' ? 'error-banner wide-field' : 'success-banner wide-field'} role="alert">
|
||||
{birdTimelineNotice.message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
<div className="recent-list bird-timeline-list">
|
||||
{birdTimelineEvents.length ? (
|
||||
|
||||
Reference in New Issue
Block a user