Files
TREK/client/src/components/Assistant/useAssistantPlaceStrip.ts
T

164 lines
6.1 KiB
TypeScript

import { type AssistantFoundPlace, categoryIdForKind } from '@trek/shared';
import { useCallback, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useCanDo } from '../../store/permissionsStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import type { Assignment } from '../../types';
import { dayLabel } from '../../utils/dayLabel';
import { haversineKm } from '../../utils/geo';
import { safeHexColor } from '../../utils/safeColor';
import { formatDistance } from '../../utils/units';
import { hasUndecided } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { useCardPoint } from './cards/parts/useCardPoint';
/** A day a found place can be suggested for, named the way the day list names it. */
interface StripDay {
id: number;
/** Its place in the trip, 1 for the first day: "+ Day 3". */
n: number;
label: string;
/** Its weekday, short ("Sat"), for the day grid; null for a day without a date. */
weekday: string | null;
}
/** The short weekday of a `YYYY-MM-DD` date, read as that calendar day wherever the reader is. */
function weekdayOf(date: string | null | undefined, locale: string): string | null {
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
return new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`));
}
/** The trip category a found place most likely belongs in, for its line and its icon. */
interface StripCategory {
name: string;
icon: string;
color: string;
}
/** The last stop of a day that has a spot on the map. */
function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng: number } | null {
const sorted = [...(stops ?? [])].sort((a, b) => a.order_index - b.order_index);
for (let i = sorted.length - 1; i >= 0; i--) {
const place = sorted[i]?.place;
if (place && typeof place.lat === 'number' && typeof place.lng === 'number') {
return { lat: place.lat, lng: place.lng };
}
}
return null;
}
/**
* What the strip of found places needs, the same for the window and the
* phone: the trip's days to suggest a place for, the day in focus, how far a
* place is from that day's last stop, its likely category, whether a
* suggestion can be made now and why not, and the map's part (the pin while
* the pointer rests on a card, the camera on a click).
*
* Suggesting writes nothing: the server holds the place as a change card to
* decide like any other. It waits while another change is undecided, because
* one decision answers all open cards.
*/
export function useAssistantPlaceStrip() {
const { t, locale } = useTranslation();
const { tripId } = useAssistantView();
const { offline } = useNetworkMode();
const can = useCanDo();
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
const { trip, days, assignments, categories } = useTripStore(
useShallow((s) => {
const here = s.trip?.id === tripId;
return {
trip: here ? s.trip : null,
days: here ? s.days : null,
assignments: here ? s.assignments : null,
categories: s.categories,
};
})
);
const { focusDayId, isRunning, threadId, waiting } = useAssistantStore(
useShallow((s) => {
const chat = chatOf(s, tripId);
return {
focusDayId: chat.focus?.dayId ?? null,
isRunning: chat.isRunning,
threadId: chat.threadId,
waiting: hasUndecided(chat.messages),
};
})
);
const addFoundPlace = useAssistantStore((s) => s.addFoundPlace);
const point = useCardPoint();
const [adding, setAdding] = useState<string | null>(null);
const stripDays = useMemo(
(): StripDay[] =>
(days ?? []).map((day, index) => ({
id: day.id,
n: index + 1,
label: dayLabel(day, index, t, locale),
weekday: weekdayOf(day.date, locale),
})),
[days, t, locale]
);
const focusDay = stripDays.find((d) => d.id === focusDayId) ?? null;
const from = focusDay && assignments ? lastSpot(assignments[String(focusDay.id)]) : null;
/** Whether "+ Day" shows at all: the planner holds the trip and the user may add to it. */
const canSuggest = !!trip && can('place_edit', trip) && stripDays.length > 0;
/** Why "+ Day" is off right now, as a tooltip; null when it is on. */
let blockedKey: string | null = null;
if (offline) blockedKey = 'assistant.error.offline';
else if (waiting) blockedKey = 'assistant.composer.decideFirst';
const disabled = blockedKey !== null || isRunning || threadId === null || adding !== null;
const categoryOf = (place: AssistantFoundPlace): StripCategory | null => {
const id = categoryIdForKind(place.kind, categories);
const category = id === null ? undefined : categories.find((c) => c.id === id);
if (!category) return null;
return { name: category.name, icon: category.icon, color: safeHexColor(category.color, 'var(--text-faint)') };
};
const distanceOf = (place: AssistantFoundPlace): string | null =>
from ? formatDistance(haversineKm(from, place), unit) : null;
const suggest = async (place: AssistantFoundPlace, dayId: number) => {
if (disabled) return;
setAdding(place.ref);
try {
await addFoundPlace(tripId, place.ref, dayId);
} finally {
setAdding(null);
}
};
/** Shows the place on the map; on the phone the chat steps aside first. */
const show = point.show;
// The pin goes with the strip: the card point takes it away when it unmounts.
const { hover: hoverPoint } = point;
const hover = useCallback(
(place: AssistantFoundPlace | null) =>
hoverPoint(place ? { ref: place.ref, lat: place.lat, lng: place.lng, name: place.name } : null),
[hoverPoint]
);
return {
days: stripDays,
focusDay,
canSuggest,
disabled,
blockedKey,
adding,
categoryOf,
distanceOf,
suggest,
show,
hover,
};
}