mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(assistant): change cards in the style of the planner's day list
A waiting change is one row as in the day list: a round choice in front, what it touches in bold, a faint line with what happens and when. No switch, nothing to fold open, no day band: the planner's day list already shows the suggestion among the day's stops. Whether the days still fit is one pill in the card's head, with what to look at on hover. Also mends the comment the rebase onto dev split in json-columns.ts.
This commit is contained in:
@@ -128,13 +128,10 @@ describe('AssistantChangeCard: deciding', () => {
|
||||
renderCard([pending('c1', 'Kinkaku-ji')]);
|
||||
expect(screen.getByText('Kinkaku-ji')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add place')).toBeInTheDocument();
|
||||
// The time is a chip of its own, old to new; the address waits behind "Details".
|
||||
// The faint line says when, old to new; the address is for the planner, not the card.
|
||||
expect(screen.getByText('09:00')).toBeInTheDocument();
|
||||
expect(screen.getByText('10:30')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Kyoto')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Details' }));
|
||||
expect(screen.getByText('Address')).toBeInTheDocument();
|
||||
expect(screen.getByText('Kyoto')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Include this change' })).toBeNull();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('1 change is waiting for you');
|
||||
});
|
||||
@@ -239,36 +236,24 @@ describe('AssistantChangeCard: on the map', () => {
|
||||
expect(row).toHaveClass('bg-surface-hover');
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-009: the days a waiting card touches read as named stops, with the schedule folded away', () => {
|
||||
it('FE-AI-CARD-009: the head says whether the days still fit, without drawing the days again', () => {
|
||||
plannerHere();
|
||||
renderCard([onTheMap('c1', 'Pantheon')]);
|
||||
expect(screen.getByText('Rome')).toBeInTheDocument();
|
||||
// Folded, each stop is a chip with its name, not a blank block behind a tooltip.
|
||||
expect(screen.getByText('Louvre')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Pantheon')).toHaveLength(2);
|
||||
expect(screen.queryByText('New')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show schedule' }));
|
||||
expect(screen.getByRole('button', { name: 'Hide schedule' })).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('Louvre')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Pantheon')).toHaveLength(2);
|
||||
expect(screen.getByText('New')).toBeInTheDocument();
|
||||
expect(screen.getByText(/min travel/)).toBeInTheDocument();
|
||||
|
||||
// Hovering the stop in the schedule lights the same pin as its row.
|
||||
fireEvent.mouseEnter(screen.getByText('New'));
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBe('c1');
|
||||
expect(screen.getByText(/Fits the time|to check/)).toBeInTheDocument();
|
||||
// The day list of the planner shows the suggestion; the card keeps to one line per change.
|
||||
expect(screen.getAllByText('Pantheon')).toHaveLength(1);
|
||||
expect(screen.queryByRole('button', { name: 'Show schedule' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-010: no band while the planner holds another trip, or once nothing waits', () => {
|
||||
it('FE-AI-CARD-010: no check while the planner holds another trip, or once nothing waits', () => {
|
||||
plannerHere(TRIP + 1);
|
||||
const first = renderCard([onTheMap('c1', 'Pantheon')]);
|
||||
expect(screen.queryByRole('button', { name: 'Show schedule' })).toBeNull();
|
||||
expect(screen.queryByText(/Fits the time|to check/)).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
plannerHere();
|
||||
renderCard([{ ...onTheMap('c1', 'Pantheon'), state: 'applied' }]);
|
||||
expect(screen.queryByRole('button', { name: 'Show schedule' })).toBeNull();
|
||||
expect(screen.queryByText(/Fits the time|to check/)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-011: on the phone the card hops to the map and frames what it would change', () => {
|
||||
|
||||
@@ -26,12 +26,11 @@ import { useTranslation } from '../../i18n';
|
||||
import { blockKeyOf } from '../../store/assistantMapModel';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantChangeFields from './AssistantChangeFields';
|
||||
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||
import AssistantDayBand from './AssistantDayBand';
|
||||
import AssistantFitCheck from './AssistantFitCheck';
|
||||
import AssistantWeekOverview from './AssistantWeekOverview';
|
||||
import { type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
@@ -137,18 +136,18 @@ function ChangeHead({ preview, struck = false }: { preview: AssistantChangePrevi
|
||||
if (!preview) return <span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />;
|
||||
return (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-caption text-content-muted">
|
||||
<bdi className={`truncate text-body font-semibold leading-tight text-content ${struck ? 'line-through' : ''}`}>
|
||||
{preview.subject}
|
||||
</bdi>
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
|
||||
<span className="truncate">{verbLabel(preview, t)}</span>
|
||||
{preview.domain && (
|
||||
// Where in TREK a change outside the trip plan lands: the vacation planner, the journal.
|
||||
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 font-medium text-content-secondary">
|
||||
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
|
||||
{t(`assistant.domain.${preview.domain}`)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<bdi className={`truncate text-body font-semibold text-content ${struck ? 'line-through' : ''}`}>
|
||||
{preview.subject}
|
||||
</bdi>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -245,38 +244,69 @@ function Receipt({
|
||||
);
|
||||
}
|
||||
|
||||
/** A change waiting for its decision; resting on it lights its pin on the map. */
|
||||
/**
|
||||
* A change waiting for its decision, as a row of the planner's day list: the
|
||||
* round choice in front (only when there is more than one to choose from),
|
||||
* what it touches in bold, and a faint line saying what happens and when.
|
||||
* Resting on it lights its pin on the map.
|
||||
*/
|
||||
function PendingRow({
|
||||
item,
|
||||
excluded,
|
||||
withSwitch,
|
||||
withChoice,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
item: ChangeItem;
|
||||
excluded: boolean;
|
||||
withSwitch: boolean;
|
||||
withChoice: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const { litFromMap, handlers } = useChangeHover(tripId, item.preview?.effect ? item.toolCallId : null);
|
||||
const preview = item.preview;
|
||||
return (
|
||||
<div
|
||||
{...handlers}
|
||||
className={`-mx-1.5 flex gap-2.5 rounded-lg px-1.5 py-2.5 transition-[opacity,background-color] ${
|
||||
excluded ? 'opacity-45' : ''
|
||||
} ${litFromMap ? 'bg-surface-hover' : ''}`}
|
||||
className={`-mx-1.5 flex items-center gap-2.5 rounded-[12px] px-1.5 py-2 transition-[opacity,background-color] ${
|
||||
excluded ? 'opacity-50' : ''
|
||||
} ${litFromMap ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
|
||||
>
|
||||
<KindTile verbKey={item.preview?.verbKey ?? null} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChangeHead preview={item.preview} />
|
||||
{item.preview && <AssistantChangeFields rows={item.preview.rows} />}
|
||||
</div>
|
||||
{withSwitch && (
|
||||
<ToggleSwitch on={!excluded} onToggle={onToggle} label={t('assistant.change.include')} disabled={disabled} />
|
||||
{withChoice && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={disabled}
|
||||
aria-pressed={!excluded}
|
||||
aria-label={t('assistant.change.include')}
|
||||
className={`grid h-[18px] w-[18px] flex-none place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 ${
|
||||
excluded ? 'border-[1.5px] border-edge bg-surface-card' : 'bg-accent'
|
||||
}`}
|
||||
>
|
||||
{!excluded && <Check size={11} strokeWidth={3} aria-hidden="true" className="text-accent-text" />}
|
||||
</button>
|
||||
)}
|
||||
<KindTile verbKey={preview?.verbKey ?? null} size="sm" dimmed={excluded} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{preview ? (
|
||||
<>
|
||||
<bdi className="block truncate text-body font-semibold leading-tight text-content">{preview.subject}</bdi>
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
|
||||
<span className="flex-shrink-0">{verbLabel(preview, t)}</span>
|
||||
{preview.domain && (
|
||||
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
|
||||
{t(`assistant.domain.${preview.domain}`)}
|
||||
</span>
|
||||
)}
|
||||
<AssistantChangeFields rows={preview.rows} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -368,7 +398,12 @@ function HeadActions({
|
||||
const here = useTripStore((s) => s.trip?.id === tripId);
|
||||
if (open) {
|
||||
const toMap = variant === 'fullscreen' && !!onShowOnMap && here && changes.length > 0;
|
||||
return toMap ? <ViewOnMapButton changes={changes} /> : null;
|
||||
return (
|
||||
<>
|
||||
<AssistantFitCheck changes={changes} />
|
||||
{toMap && <ViewOnMapButton changes={changes} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -440,7 +475,6 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
{open && week.length > 0 && (
|
||||
<AssistantWeekOverview days={week} excluded={excluded} disabled={isRunning} onToggleDay={toggleDay} />
|
||||
)}
|
||||
{open && !folded && <AssistantDayBand changes={changes} />}
|
||||
{folded && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -450,7 +484,7 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
{t('assistant.week.showAll', { count: pending.length })}
|
||||
</button>
|
||||
)}
|
||||
<div className={open ? 'divide-y divide-edge-faint' : ''}>
|
||||
<div>
|
||||
{items.map((item) =>
|
||||
item.state === 'pending' ? (
|
||||
folded ? null : (
|
||||
@@ -458,7 +492,7 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
key={item.toolCallId}
|
||||
item={item}
|
||||
excluded={excluded.has(item.toolCallId)}
|
||||
withSwitch={pending.length > 1}
|
||||
withChoice={pending.length > 1}
|
||||
disabled={isRunning}
|
||||
onToggle={() => toggle(item.toolCallId)}
|
||||
/>
|
||||
|
||||
@@ -3,28 +3,26 @@ import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Hash,
|
||||
ListOrdered,
|
||||
LogIn,
|
||||
LogOut,
|
||||
type LucideIcon,
|
||||
Tag,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { CHANGE_LABELS } from './assistantParts';
|
||||
|
||||
/**
|
||||
* The fields of one change, read at a glance: the few that decide whether it
|
||||
* fits (when, what, what it costs) as quiet icon chips, a value that changes
|
||||
* as old to new, everything else folded behind one "Details" line. Positions
|
||||
* and colours never show; the map and the trip show those better.
|
||||
* The few facts that tell a change apart at a glance, on the faint line under
|
||||
* its name the way the planner's day list shows a place: when, what kind,
|
||||
* what it costs, a new order. A value that changes reads old to new. The rest
|
||||
* of what the change sets is not listed: the planner shows it once applied.
|
||||
*/
|
||||
|
||||
/** The fields worth a chip of their own, each with its glyph, in this order. */
|
||||
/** The fields worth a place on the line, each with its glyph, in this order. */
|
||||
const KEY_FIELDS: Partial<Record<AssistantChangeLabelKey, LucideIcon>> = {
|
||||
day: CalendarDays,
|
||||
time: Clock,
|
||||
@@ -35,17 +33,10 @@ const KEY_FIELDS: Partial<Record<AssistantChangeLabelKey, LucideIcon>> = {
|
||||
category: Tag,
|
||||
price: Wallet,
|
||||
quantity: Hash,
|
||||
order: ListOrdered,
|
||||
};
|
||||
const KEY_ORDER = Object.keys(KEY_FIELDS) as AssistantChangeLabelKey[];
|
||||
|
||||
/** Fields that mean nothing written out. */
|
||||
const HIDDEN: ReadonlySet<AssistantChangeLabelKey> = new Set(['coordinates', 'position', 'icon', 'color', 'currency']);
|
||||
|
||||
/** What a row is called: its label, or for a tool's own argument the argument's name in words. */
|
||||
function labelOf(row: AssistantChangeRow, t: (key: string) => string): string {
|
||||
if (row.labelKey === 'field' && row.field) return row.field.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
return t(CHANGE_LABELS[row.labelKey]?.labelKey ?? 'assistant.change.label.field');
|
||||
}
|
||||
const MAX_FACTS = 3;
|
||||
|
||||
/** "#3" is how a day comes from the server; it reads as "Day 3". */
|
||||
function shown(row: AssistantChangeRow, value: string | null | undefined, dayWord: string): string {
|
||||
@@ -53,92 +44,32 @@ function shown(row: AssistantChangeRow, value: string | null | undefined, dayWor
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
function Chip({ row }: { row: AssistantChangeRow }) {
|
||||
export default function AssistantChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
|
||||
const label = labelOf(row, t);
|
||||
const dayWord = t('assistant.week.day');
|
||||
const changed = row.op !== 'add' && !!row.before;
|
||||
const facts = rows
|
||||
.filter((row) => row.labelKey in KEY_FIELDS && row.after)
|
||||
.sort((a, b) => KEY_ORDER.indexOf(a.labelKey) - KEY_ORDER.indexOf(b.labelKey))
|
||||
.slice(0, MAX_FACTS);
|
||||
if (facts.length === 0) return null;
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md bg-surface-secondary px-1.5 py-0.5 text-caption text-content-secondary"
|
||||
>
|
||||
<Icon size={11} aria-label={label} className="flex-shrink-0 text-content-faint" />
|
||||
{changed && (
|
||||
<>
|
||||
<bdi className="min-w-0 truncate text-content-faint line-through">{shown(row, row.before, dayWord)}</bdi>
|
||||
<ArrowRight size={10} aria-hidden="true" className="flex-shrink-0 text-content-faint rtl:-scale-x-100" />
|
||||
</>
|
||||
)}
|
||||
<bdi className="min-w-0 truncate font-medium text-content">{shown(row, row.after, dayWord)}</bdi>
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
{facts.map((row, i) => {
|
||||
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
|
||||
const changed = row.op !== 'add' && !!row.before;
|
||||
return (
|
||||
<span key={`${row.labelKey}-${i}`} className="inline-flex min-w-0 items-center gap-1">
|
||||
<Icon size={11} aria-hidden="true" className="flex-shrink-0" />
|
||||
{changed && (
|
||||
<>
|
||||
<bdi className="truncate line-through">{shown(row, row.before, dayWord)}</bdi>
|
||||
<ArrowRight size={10} aria-hidden="true" className="flex-shrink-0 rtl:-scale-x-100" />
|
||||
</>
|
||||
)}
|
||||
<bdi className="truncate text-content-muted">{shown(row, row.after, dayWord)}</bdi>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A field behind "Details": its name, then the value, old to new where it changes. */
|
||||
function DetailLine({ row }: { row: AssistantChangeRow }) {
|
||||
const { t } = useTranslation();
|
||||
const changed = row.op !== 'add' && !!row.before;
|
||||
return (
|
||||
<div className="flex min-w-0 gap-2 text-caption">
|
||||
<span className="w-24 flex-shrink-0 truncate text-content-faint">{labelOf(row, t)}</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1 text-content-secondary">
|
||||
{changed && (
|
||||
<>
|
||||
<bdi className="min-w-0 truncate text-content-faint line-through">{row.before}</bdi>
|
||||
<ArrowRight size={10} aria-hidden="true" className="flex-shrink-0 text-content-faint rtl:-scale-x-100" />
|
||||
</>
|
||||
)}
|
||||
<bdi className="min-w-0 truncate">{row.after || t('assistant.change.empty')}</bdi>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const visible = rows.filter((row) => !HIDDEN.has(row.labelKey) && row.labelKey !== 'name');
|
||||
const key = visible
|
||||
.filter((row) => row.labelKey in KEY_FIELDS && row.after)
|
||||
.sort((a, b) => KEY_ORDER.indexOf(a.labelKey) - KEY_ORDER.indexOf(b.labelKey));
|
||||
const rest = visible.filter((row) => !key.includes(row));
|
||||
if (key.length === 0 && rest.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-col gap-1.5">
|
||||
{key.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.map((row, i) => (
|
||||
<Chip key={`${row.labelKey}-${i}`} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rest.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="inline-flex items-center gap-1 self-start rounded-md text-caption text-content-faint transition-colors hover:text-content"
|
||||
>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
{t(open ? 'assistant.change.detailsHide' : 'assistant.change.details')}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="trek-assistant-enter flex flex-col gap-1 rounded-lg bg-surface-secondary px-2.5 py-2">
|
||||
{rest.map((row, i) => (
|
||||
<DetailLine key={`${row.labelKey}-${i}`} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import { ChevronDown, CircleCheck, Minus, Plus, TriangleAlert } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { dayLabel } from '../../utils/dayLabel';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import {
|
||||
buildDayAfter,
|
||||
changedDays,
|
||||
type DayAfter,
|
||||
type DayChange,
|
||||
dayIssues,
|
||||
stopIssue,
|
||||
type TimelineStop,
|
||||
} from './dayAfterModel';
|
||||
import { useChangeHover } from './useChangeHover';
|
||||
|
||||
/**
|
||||
* The days a waiting card touches, as they would read once it ran: per day its
|
||||
* date and its stops in order. What stays reads as quiet text; only what the
|
||||
* card adds is a filled pill and what it takes away is struck, so a glance
|
||||
* finds the suggestion among the rest. The details (times, the way between stops,
|
||||
* free time, a clash) stay folded away until asked for. Two days at most; the
|
||||
* rest are counted. Shown only while the planner holds this trip, since the day
|
||||
* plans come from it.
|
||||
*/
|
||||
|
||||
/** Days shown; the rest are counted. */
|
||||
const MAX_DAYS = 2;
|
||||
/** Stops shown as chips on a day; the rest are counted. */
|
||||
const MAX_CHIPS = 6;
|
||||
|
||||
const CHIP: Record<TimelineStop['status'], string> = {
|
||||
kept: 'px-0.5 text-content-muted',
|
||||
new: 'rounded-full bg-accent px-2 py-0.5 font-medium text-accent-text shadow-sm',
|
||||
removed: 'rounded-full px-1.5 py-0.5 text-content-faint line-through',
|
||||
};
|
||||
|
||||
/** One stop of the day, named; the change it belongs to lights up with it. */
|
||||
function StopChip({ stop }: { stop: TimelineStop }) {
|
||||
const { tripId } = useAssistantView();
|
||||
const { lit, handlers } = useChangeHover(tripId, stop.toolCallId);
|
||||
const issue = stop.lead?.overlapsWith ? 'ring-2 ring-danger' : stopIssue(stop) ? 'ring-2 ring-warning' : '';
|
||||
const Mark = stop.status === 'new' ? Plus : stop.status === 'removed' ? Minus : null;
|
||||
return (
|
||||
<span
|
||||
{...(stop.toolCallId ? handlers : {})}
|
||||
className={`inline-flex max-w-[12rem] items-center gap-1 text-caption transition-all ${CHIP[stop.status]} ${issue} ${lit ? 'scale-[1.04]' : ''}`}
|
||||
>
|
||||
{Mark && <Mark size={11} strokeWidth={2.6} aria-hidden="true" className="flex-shrink-0" />}
|
||||
<bdi className="truncate">{stop.name}</bdi>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The thin rule between two stops, read as "then". */
|
||||
function Then() {
|
||||
return <span aria-hidden="true" className="h-px w-2 flex-shrink-0 bg-edge" />;
|
||||
}
|
||||
|
||||
function Lead({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const lead = stop.lead;
|
||||
if (!lead) return null;
|
||||
const travel = lead.travelMinutes !== null && lead.travelMinutes > 0;
|
||||
if (!travel && lead.freeMinutes === null && !lead.overlapsWith && lead.shortBy === null) return null;
|
||||
return (
|
||||
<li
|
||||
className={`flex flex-wrap items-center gap-x-2 text-caption text-content-faint ${timed ? 'ps-[3.25rem]' : 'ps-1'}`}
|
||||
>
|
||||
{travel && <span>{t('assistant.change.timeline.travel', { count: lead.travelMinutes ?? 0 })}</span>}
|
||||
{lead.freeMinutes !== null && <span>{t('assistant.change.timeline.gap', { count: lead.freeMinutes })}</span>}
|
||||
{lead.shortBy !== null && (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-warning">
|
||||
<TriangleAlert size={11} aria-hidden="true" className="flex-shrink-0" />
|
||||
{t('assistant.change.timeline.tight', { count: lead.shortBy })}
|
||||
</span>
|
||||
)}
|
||||
{lead.overlapsWith && (
|
||||
<span className="inline-flex items-center gap-1 text-content-muted">
|
||||
<TriangleAlert size={11} aria-hidden="true" className="flex-shrink-0 text-danger" />
|
||||
<bdi>{t('assistant.change.timeline.overlap', { place: lead.overlapsWith })}</bdi>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function StopLine({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const { lit, handlers } = useChangeHover(tripId, stop.toolCallId);
|
||||
const removed = stop.status === 'removed';
|
||||
return (
|
||||
<li
|
||||
{...(stop.toolCallId ? handlers : {})}
|
||||
className={`flex items-center gap-2 rounded-md px-1 py-0.5 ${lit ? 'bg-surface-hover' : ''}`}
|
||||
>
|
||||
{timed && (
|
||||
<span className="w-10 flex-shrink-0 text-caption tabular-nums text-content-faint">{stop.time ?? ''}</span>
|
||||
)}
|
||||
<bdi className={`min-w-0 truncate text-caption ${removed ? 'text-content-faint line-through' : 'text-content'}`}>
|
||||
{stop.name}
|
||||
</bdi>
|
||||
{stop.status === 'new' && (
|
||||
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-caption text-content-muted">
|
||||
{t('assistant.change.timeline.new')}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DayRow({ day, label, open }: { day: DayAfter; label: string; open: boolean }) {
|
||||
const timed = day.stops.some((s) => s.time !== null);
|
||||
const chips = day.stops.slice(0, MAX_CHIPS);
|
||||
return (
|
||||
<div>
|
||||
<bdi className="block truncate text-caption font-semibold uppercase tracking-wide text-content-faint">
|
||||
{label}
|
||||
</bdi>
|
||||
{!open && (
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1 gap-y-1.5">
|
||||
{chips.map((stop, i) => (
|
||||
<span key={stop.key} className="contents">
|
||||
{i > 0 && <Then />}
|
||||
<StopChip stop={stop} />
|
||||
</span>
|
||||
))}
|
||||
{day.stops.length > MAX_CHIPS && (
|
||||
<span className="text-caption tabular-nums text-content-faint">+{day.stops.length - MAX_CHIPS}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{open && (
|
||||
<ol className="trek-assistant-enter mt-1 space-y-0.5" aria-label={label}>
|
||||
{day.stops.map((stop) => (
|
||||
<StopGroup key={stop.key} stop={stop} timed={timed} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StopGroup({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Lead stop={stop} timed={timed} />
|
||||
<StopLine stop={stop} timed={timed} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantDayBand({ changes }: { changes: readonly DayChange[] }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const [open, setOpen] = useState(false);
|
||||
const plan = useTripStore(
|
||||
useShallow((s) => ({
|
||||
here: s.trip?.id === tripId,
|
||||
days: s.days,
|
||||
assignments: s.assignments,
|
||||
places: s.places,
|
||||
}))
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!plan.here || changes.length === 0) return [];
|
||||
return changedDays(changes).flatMap((dayId) => {
|
||||
const index = plan.days.findIndex((d) => d.id === dayId);
|
||||
const day = plan.days[index];
|
||||
if (!day) return [];
|
||||
const after = buildDayAfter({
|
||||
dayId,
|
||||
assignments: plan.assignments,
|
||||
places: plan.places,
|
||||
changes,
|
||||
defaultMode: day.default_transport_mode,
|
||||
});
|
||||
return after.stops.length > 0 ? [{ after, label: dayLabel(day, index, t, locale) }] : [];
|
||||
});
|
||||
}, [plan, changes, t, locale]);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
const shown = rows.slice(0, MAX_DAYS);
|
||||
const issues = rows.reduce((sum, row) => sum + dayIssues(row.after), 0);
|
||||
const measured = rows.some((row) => row.after.stops.some((stop) => stop.lead?.travelMinutes != null));
|
||||
const toggleLabel = t(open ? 'assistant.change.timeline.hide' : 'assistant.change.timeline.show');
|
||||
return (
|
||||
<div className="mb-1 flex gap-1.5">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{shown.map(({ after, label }) => (
|
||||
<DayRow key={after.dayId} day={after} label={label} open={open} />
|
||||
))}
|
||||
{rows.length > MAX_DAYS && (
|
||||
<p className="text-caption text-content-faint">
|
||||
{t('assistant.change.timeline.moreDays', { count: rows.length - MAX_DAYS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(issues > 0 || measured) && (
|
||||
// What the check found, at a glance; a tap opens the schedule that says where.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={`inline-flex h-6 flex-shrink-0 items-center gap-1 self-start rounded-full px-2 text-caption font-medium transition-colors ${
|
||||
issues > 0 ? 'bg-warning-soft text-warning hover:brightness-95' : 'bg-success-soft text-success'
|
||||
}`}
|
||||
>
|
||||
{issues > 0 ? <TriangleAlert size={12} aria-hidden="true" /> : <CircleCheck size={12} aria-hidden="true" />}
|
||||
{issues > 0 ? t('assistant.check.issues', { count: issues }) : t('assistant.check.ok')}
|
||||
</button>
|
||||
)}
|
||||
<Tooltip label={toggleLabel} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-label={toggleLabel}
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-content-muted outline-none transition-colors hover:bg-surface-hover hover:text-content focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CircleCheck, TriangleAlert } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { buildDayAfter, changedDays, type DayChange, type TimelineStop } from './dayAfterModel';
|
||||
|
||||
/** Lines the tooltip names; the rest are only counted on the pill. */
|
||||
const MAX_LINES = 3;
|
||||
|
||||
/** What does not fit on a stop of the day: arriving after the next thing starts, or a way too tight. */
|
||||
function problemOf(stop: TimelineStop, t: (key: string, params?: Record<string, string | number>) => string) {
|
||||
const lead = stop.lead;
|
||||
if (lead?.overlapsWith) return t('assistant.change.timeline.overlap', { place: lead.overlapsWith });
|
||||
if (lead?.shortBy != null) return `${stop.name}: ${t('assistant.change.timeline.tight', { count: lead.shortBy })}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the days a waiting card changes still work out in time, as one
|
||||
* small pill in the card's head: "fits" in green, or how many things to look
|
||||
* at in amber, with what they are on hover. The days themselves show the
|
||||
* suggestion in the planner's day list, so the card does not draw them again.
|
||||
* Shown only while the planner holds this trip, whose day plans it reads.
|
||||
*/
|
||||
export default function AssistantFitCheck({ changes }: { changes: readonly DayChange[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const plan = useTripStore(
|
||||
useShallow((s) => ({ here: s.trip?.id === tripId, days: s.days, assignments: s.assignments, places: s.places }))
|
||||
);
|
||||
|
||||
const check = useMemo(() => {
|
||||
if (!plan.here || changes.length === 0) return null;
|
||||
const stops = changedDays(changes).flatMap((dayId) => {
|
||||
const day = plan.days.find((d) => d.id === dayId);
|
||||
if (!day) return [];
|
||||
return buildDayAfter({
|
||||
dayId,
|
||||
assignments: plan.assignments,
|
||||
places: plan.places,
|
||||
changes,
|
||||
defaultMode: day.default_transport_mode,
|
||||
}).stops;
|
||||
});
|
||||
const measured = stops.some((stop) => stop.lead?.travelMinutes != null);
|
||||
const problems = stops.map((stop) => problemOf(stop, t)).filter((line): line is string => line !== null);
|
||||
return measured || problems.length > 0 ? problems : null;
|
||||
}, [plan, changes, t]);
|
||||
|
||||
if (!check) return null;
|
||||
const ok = check.length === 0;
|
||||
const pill = (
|
||||
<span
|
||||
className={`inline-flex h-6 flex-shrink-0 items-center gap-1 rounded-full px-2 text-caption font-medium ${
|
||||
ok ? 'bg-success-soft text-success' : 'bg-warning-soft text-warning'
|
||||
}`}
|
||||
>
|
||||
{ok ? <CircleCheck size={12} aria-hidden="true" /> : <TriangleAlert size={12} aria-hidden="true" />}
|
||||
{ok ? t('assistant.check.ok') : t('assistant.check.issues', { count: check.length })}
|
||||
</span>
|
||||
);
|
||||
if (ok) return pill;
|
||||
return (
|
||||
<Tooltip label={check.slice(0, MAX_LINES).join('\n')} placement="top">
|
||||
<span tabIndex={0} className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||
{pill}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -10,15 +10,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories';
|
||||
import type { AssignmentsMap } from '../../types';
|
||||
import {
|
||||
buildDayAfter,
|
||||
changedDays,
|
||||
changePoints,
|
||||
type DayChange,
|
||||
dayIssues,
|
||||
minutesOf,
|
||||
stopIssue,
|
||||
} from './dayAfterModel';
|
||||
import { buildDayAfter, changedDays, changePoints, type DayChange, minutesOf } from './dayAfterModel';
|
||||
|
||||
// 0.01° of latitude is 1.112 km: with the detour factor that is 19 minutes on foot, 6 by bike.
|
||||
const louvre = buildPlace({ id: 10, name: 'Louvre', lat: 48.0, lng: 2.0, duration_minutes: 60 });
|
||||
@@ -148,14 +140,11 @@ describe('dayAfterModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-AI-DAYAFTER-009: a way longer than the time between two stops is too tight, and counts as an issue', () => {
|
||||
it('FE-AI-DAYAFTER-009: a way longer than the time between two stops is too tight by the difference', () => {
|
||||
// Louvre until 10:00, then 19 minutes on foot to Orsay, which starts at 10:10: nine minutes short.
|
||||
const tight = plan();
|
||||
tight[3] = (tight[3] ?? []).map((a) => (a.id === 101 ? { ...a, assignment_time: '10:10' } : a));
|
||||
const after = day(3, [], tight);
|
||||
expect(after.stops[1]?.lead).toMatchObject({ travelMinutes: 19, shortBy: 9 });
|
||||
expect(after.stops.map(stopIssue)).toEqual([false, true]);
|
||||
expect(dayIssues(after)).toBe(1);
|
||||
expect(dayIssues(day(3, []))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,27 +234,3 @@ export function changedDays(changes: readonly DayChange[]): number[] {
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/** A leg this long without a time says the day asks a lot of travel. */
|
||||
const FAR_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* Whether a day still works once the changes ran: stops that take the same
|
||||
* time, a way there longer than the time between two stops, and legs over an
|
||||
* hour. Counted over the stops that stay on the day.
|
||||
*/
|
||||
export function dayIssues(day: DayAfter): number {
|
||||
return day.stops.reduce((sum, stop) => {
|
||||
const lead = stop.lead;
|
||||
if (stop.status === 'removed' || !lead) return sum;
|
||||
const far = lead.travelMinutes !== null && lead.travelMinutes >= FAR_MINUTES;
|
||||
return sum + (lead.overlapsWith || lead.shortBy !== null || far ? 1 : 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Whether a stop is one of those `dayIssues` counts. */
|
||||
export function stopIssue(stop: TimelineStop): boolean {
|
||||
const lead = stop.lead;
|
||||
if (stop.status === 'removed' || !lead) return false;
|
||||
return !!lead.overlapsWith || lead.shortBy !== null || (lead.travelMinutes ?? 0) >= FAR_MINUTES;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user