Files
TREK/client/src/components/Assistant/useAssistantPlaceStrip.ts
T
Maurice d1b8cb20a3 feat(assistant): found places open the place form, notes kept on its own, city walk stops checked off
- Found places: the strip shows only when the answer names one of them, the
  named ones first. A click on a card, or "Add to trip", opens the planner's
  place form filled in with the place on the day in focus, its Google details
  (photos, hours, description) loading beside it; "+ Day" and the day grid
  are gone.
- Trip notes: remember_for_trip runs at once without a card. The assistant
  keeps lasting facts the user mentions (who travels, needs, likes, budget,
  pace) on its own and offers the unsure ones as a follow-up; the chat shows
  "Noted for this trip: ..." with the notes a tap away, and reads them again
  so the dialog never saves over the new note.
- City walks: set_city_walk_visit checks a station off on its day (visited,
  skipped, or the mark taken back), with day_edit as in the day plan, a
  change card naming the station and its walk, and an undo that restores the
  status before while the station is as the change left it.
- Card heads are a step smaller (32px tile, smaller title and count), and the
  dot beside "Working on it" in the header is gone.
2026-10-23 18:21:54 +02:00

101 lines
4.2 KiB
TypeScript

import { type AssistantFoundPlace, categoryIdForKind } from '@trek/shared';
import { useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
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 { haversineKm } from '../../utils/geo';
import { safeHexColor } from '../../utils/safeColor';
import { formatDistance } from '../../utils/units';
import { useAssistantScope, useAssistantView } from './assistantViewContext';
import { useCardPoint } from './cards/parts/useCardPoint';
/** 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 day in focus, how far a place is from that day's last stop, its
* likely category, whether a place can be added, and the map's part (the pin
* while the pointer rests on a card, the camera on a click).
*
* Adding opens the planner's own place form, filled in with the place and on
* the day in focus: the traveller sees everything about it and saves it as
* any other place, so nothing waits for the chat's open decisions.
*/
export function useAssistantPlaceStrip() {
const { tripId, onRevealMap } = useAssistantView();
const scope = useAssistantScope();
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 = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft);
const point = useCardPoint();
const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
const from = focusDay && assignments ? lastSpot(assignments[String(focusDay.id)]) : null;
/** Whether "Add to trip" shows at all: a trip's chat (a journey's has no days), the planner holds the trip and the user may add to it. */
const canAdd = scope.kind === 'trip' && !!trip && can('place_edit', trip);
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;
/** Opens the place form with it; on the phone the chat steps aside first. */
const open = (place: AssistantFoundPlace) => {
if (!canAdd || !trip) return;
onRevealMap?.();
openPlaceDraft(trip.id, place, focusDay?.id ?? 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 { canAdd, categoryOf, distanceOf, open, show, hover };
}