mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
a3fc696510
The cards were rows of badges in badges with no head of their own; they now follow the frame #2541 gave the trip's dialogs and booking cards. - Every answer card and the change card open on a tinted head band: a raised tile with the icon (a route its sketch), the kind as an eyebrow, the title, one quiet line and the count; the change card turns green once decided. - Figures are framed fields under their labels (a route's distance, climb, descent, time), the parts of a card sit under eyebrows (height profile, stops), and the ways onward sit in a foot bar across the card. - A route's stops are a timeline down a line, each walk on the line between two stops; the ways of a route are calm choices with their figures in one line and how they differ from the chosen one in another. - Packing, to-dos, the journal, Vacay and the Atlas get the band; quantities and weights are quiet figures, an expense's place and split share one line.
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import { Check, CheckCheck, ListChecks, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
|
|
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
|
|
|
import { useTranslation } from '../../i18n';
|
|
import { blockKeyOf } from '../../store/assistantMapModel';
|
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
|
import { useTripStore } from '../../store/tripStore';
|
|
import { DialogButton, NEUTRAL_TINT } from '../shared/DialogShell';
|
|
import { useToast } from '../shared/Toast';
|
|
import { Tooltip } from '../shared/Tooltip';
|
|
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
|
import AssistantFitCheck from './AssistantFitCheck';
|
|
import AssistantWeekOverview from './AssistantWeekOverview';
|
|
import { type ChangeItem, undoLeftMs } from './assistantParts';
|
|
import { useAssistantView } from './assistantViewContext';
|
|
import { ChangeRows } from './cards/ChangeCardSlots';
|
|
import CardHeader from './cards/parts/CardHeader';
|
|
import { CardFoot } from './cards/parts/CardSection';
|
|
import { ICON_BUTTON } from './cards/parts/assistantTones';
|
|
import { useChangeExclusions } from './cards/useChangeExclusions';
|
|
import { changePoints, type DayChange } from './dayAfterModel';
|
|
import { useWeekDays } from './useWeekDays';
|
|
|
|
/**
|
|
* The changes the assistant proposes in one go, as one card in plain words:
|
|
* what happens to which place, note or item, field by field. Nothing is
|
|
* written before the user applies it. Each change keeps its own decision, so
|
|
* when the card holds several, each has a switch and the button applies the
|
|
* ones left on. Once decided, the card turns into a receipt per change, with
|
|
* a way to the change on the map and, for a while, undo.
|
|
*
|
|
* Every change wears its kind: a tinted tile with the icon of what it touches
|
|
* and a badge naming the verb, in the tone of what happens (green adds, blue
|
|
* changes, amber moves, red takes away). Its fields sit as badges holding a
|
|
* label badge, so a row of them reads at a glance. The rows themselves, and a
|
|
* change that carries a card of its own, are drawn in cards/ChangeCardSlots.
|
|
*
|
|
* While it waits, the card shows the days it touches as they would read
|
|
* afterwards, and each row lights its pin on the map (and the pin its row).
|
|
* Once applied, the head offers the plan before and after the block on the
|
|
* map, and taking the whole block back at once.
|
|
*/
|
|
|
|
/** The time now, ticking once more when the next undo window closes. */
|
|
function useUndoClock(items: ChangeItem[]): number {
|
|
const [now, setNow] = useState(() => Date.now());
|
|
const next = Math.min(...items.map((i) => undoLeftMs(i.applied, now)).filter((ms) => ms > 0));
|
|
useEffect(() => {
|
|
if (!Number.isFinite(next)) return;
|
|
const timer = window.setTimeout(() => setNow(Date.now()), next + 50);
|
|
return () => window.clearTimeout(timer);
|
|
}, [next]);
|
|
return now;
|
|
}
|
|
|
|
/** Whether an applied change can still be taken back now. */
|
|
function undoableNow(item: ChangeItem, undone: Readonly<Record<string, true>>, now: number): boolean {
|
|
return (
|
|
item.state === 'applied' &&
|
|
!undone[item.toolCallId] &&
|
|
!!item.preview?.undoable &&
|
|
undoLeftMs(item.applied, now) > 0
|
|
);
|
|
}
|
|
|
|
/** The band of a card whose changes are all decided: the green of a done booking. */
|
|
const DONE_TINT = 'color-mix(in srgb, var(--success) 9%, transparent)';
|
|
|
|
/** The waiting changes the user left on that touch the day plan. */
|
|
function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): DayChange[] {
|
|
return items.flatMap((i) =>
|
|
i.state === 'pending' && i.preview?.effect && !excluded.has(i.toolCallId)
|
|
? [{ toolCallId: i.toolCallId, effect: i.preview.effect, subject: i.preview.subject }]
|
|
: []
|
|
);
|
|
}
|
|
|
|
/** The change card's band: tinted green once everything in it is decided, what it holds and its actions. */
|
|
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<CardHeader
|
|
icon={open ? ListChecks : CheckCheck}
|
|
tone={open ? 'info' : 'success'}
|
|
tint={open ? NEUTRAL_TINT : DONE_TINT}
|
|
title={t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
|
count={count}
|
|
actions={actions}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** On the phone the map hides behind the chat: this shrinks the chat and frames what the card would change. */
|
|
function ViewOnMapButton({ changes }: { changes: readonly DayChange[] }) {
|
|
const { t } = useTranslation();
|
|
const { tripId } = useAssistantView();
|
|
const requestCamera = useAssistantStore((s) => s.requestCamera);
|
|
const setMinimized = useAssistantStore((s) => s.setMinimized);
|
|
const show = () => {
|
|
const { assignments, places } = useTripStore.getState();
|
|
requestCamera(tripId, changePoints(changes, assignments, places), 'fit', 'user');
|
|
setMinimized(true);
|
|
};
|
|
return (
|
|
<Tooltip label={t('assistant.change.viewOnMap')} placement="top">
|
|
<button type="button" onClick={show} aria-label={t('assistant.change.viewOnMap')} className={ICON_BUTTON}>
|
|
<MapPin size={14} aria-hidden="true" />
|
|
</button>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
/** Takes back every change of the block that still can be, the last one first, and says how it went. */
|
|
function UndoAllButton({ toolCallIds }: { toolCallIds: readonly string[] }) {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const { tripId } = useAssistantView();
|
|
const undoBlock = useAssistantStore((s) => s.undoBlock);
|
|
const [busy, setBusy] = useState(false);
|
|
const run = async () => {
|
|
setBusy(true);
|
|
try {
|
|
const { done, stale, failed } = await undoBlock(tripId, toolCallIds);
|
|
if (failed > 0) toast.error(t('assistant.change.undoAllFailed', { count: failed }));
|
|
else if (stale > 0) toast.warning(t('assistant.change.undoAllPartial', { done, stale }));
|
|
else toast.success(t('assistant.change.undoneAll', { count: done }));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
return (
|
|
<Tooltip label={t('assistant.change.undoAll')} placement="top">
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => void run()}
|
|
aria-label={t('assistant.change.undoAll')}
|
|
className={ICON_BUTTON}
|
|
>
|
|
{busy ? (
|
|
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
|
|
) : (
|
|
<Undo2 size={14} aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
/** What the head offers: on a waiting card the way to the map on the phone, on a receipt the block as a whole. */
|
|
function HeadActions({
|
|
open,
|
|
items,
|
|
changes,
|
|
undoable,
|
|
}: {
|
|
open: boolean;
|
|
items: readonly ChangeItem[];
|
|
changes: readonly DayChange[];
|
|
undoable: readonly string[];
|
|
}) {
|
|
const { tripId, variant, onOpen } = useAssistantView();
|
|
const here = useTripStore((s) => s.trip?.id === tripId);
|
|
if (open) {
|
|
const toMap = variant === 'fullscreen' && !!onOpen && here && changes.length > 0;
|
|
return (
|
|
<>
|
|
<AssistantFitCheck changes={changes} />
|
|
{toMap && <ViewOnMapButton changes={changes} />}
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
{onOpen && <AssistantCompareSwitch tripId={tripId} blockKey={blockKeyOf(items.map((i) => i.toolCallId))} />}
|
|
{undoable.length >= 2 && <UndoAllButton toolCallIds={undoable} />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function AssistantChangeCard({ items }: { items: ChangeItem[] }) {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const { tripId } = useAssistantView();
|
|
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
|
|
const undone = useAssistantStore((s) => chatOf(s, tripId).undone);
|
|
const decide = useAssistantStore((s) => s.decide);
|
|
const undo = useAssistantStore((s) => s.undo);
|
|
const [excluded, setExcluded] = useChangeExclusions(items);
|
|
const now = useUndoClock(items);
|
|
|
|
const pending = items.filter((i) => i.state === 'pending');
|
|
const chosen = pending.filter((i) => !excluded.has(i.toolCallId)).length;
|
|
const changes = useMemo(() => dayChangesOf(items, excluded), [items, excluded]);
|
|
const undoable = items.filter((i) => undoableNow(i, undone, now)).map((i) => i.toolCallId);
|
|
|
|
const week = useWeekDays(pending);
|
|
const [showAll, setShowAll] = useState(false);
|
|
// A plan over a week reads from its grid; the single changes wait behind one line.
|
|
const folded = week.length > 0 && !showAll;
|
|
|
|
const toggleDay = (ids: readonly string[], include: boolean) =>
|
|
setExcluded((prev) => {
|
|
const next = new Set(prev);
|
|
for (const id of ids) {
|
|
if (include) next.delete(id);
|
|
else next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
|
|
const toggle = (toolCallId: string) =>
|
|
setExcluded((prev) => {
|
|
const next = new Set(prev);
|
|
if (!next.delete(toolCallId)) next.add(toolCallId);
|
|
return next;
|
|
});
|
|
|
|
const submit = (applyChosen: boolean) =>
|
|
decide(
|
|
tripId,
|
|
pending.map((i) => ({ toolCallId: i.toolCallId, approved: applyChosen && !excluded.has(i.toolCallId) }))
|
|
);
|
|
|
|
// An undone change reads as undone from the store; only what stays needs a word.
|
|
const runUndo = async (toolCallId: string) => {
|
|
const outcome = await undo(tripId, toolCallId);
|
|
if (outcome === 'stale') toast.warning(t('assistant.change.undoStale'));
|
|
else if (outcome === 'failed') toast.error(t('assistant.change.undoFailed'));
|
|
};
|
|
|
|
const open = pending.length > 0;
|
|
return (
|
|
<section className="trek-popover-enter overflow-hidden rounded-[16px] border border-edge-faint bg-surface-card px-3.5 pb-3 pt-3 shadow-card">
|
|
<CardHead
|
|
open={open}
|
|
count={items.length}
|
|
actions={<HeadActions open={open} items={items} changes={changes} undoable={undoable} />}
|
|
/>
|
|
{open && week.length > 0 && (
|
|
<AssistantWeekOverview days={week} excluded={excluded} disabled={isRunning} onToggleDay={toggleDay} />
|
|
)}
|
|
{folded && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAll(true)}
|
|
className="mb-1 w-full rounded-lg px-2 py-1.5 text-center text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
|
|
>
|
|
{t('assistant.week.showAll', { count: pending.length })}
|
|
</button>
|
|
)}
|
|
<ChangeRows
|
|
items={items}
|
|
folded={folded}
|
|
excluded={excluded}
|
|
disabled={isRunning}
|
|
undone={undone}
|
|
undoable={undoable}
|
|
onToggle={toggle}
|
|
onUndo={(toolCallId) => void runUndo(toolCallId)}
|
|
/>
|
|
{open && (
|
|
<>
|
|
<p className="sr-only" role="status">
|
|
{t('assistant.change.pending', { count: pending.length })}
|
|
</p>
|
|
<CardFoot>
|
|
<DialogButton disabled={isRunning} onClick={() => void submit(false)}>
|
|
{t('assistant.change.discard')}
|
|
</DialogButton>
|
|
<DialogButton
|
|
variant="primary"
|
|
disabled={isRunning || chosen === 0}
|
|
onClick={() => void submit(true)}
|
|
icon={<Check size={14} aria-hidden="true" />}
|
|
>
|
|
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
|
|
</DialogButton>
|
|
</CardFoot>
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|