mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
feat(assistant): entry cards show tags, verdict and order, and changes can be taken back
The update card silently left out the tags and the pros and cons it was about to write, so the user approved a change the card did not show. An entry now shows its first tags and how its verdict leans, and an update names the tags, each side of the verdict and the coordinates it changes. A new order of the entries is drawn as the entries that move, in their new places. A changed entry is written back from what the tool answered it replaced, and a reorder is taken back to the order before, each while nothing changed since. A Dawarich stay taken in as an entry gets the entry card; it has no way back, since deleting the entry would leave the stay marked as taken in.
This commit is contained in:
@@ -1,7 +1,20 @@
|
||||
import type { AssistantChangeRow, JourneyEntriesCard } from '@trek/shared';
|
||||
import { AlignLeft, CalendarDays, Clock, MapPin, Tag, Type } from 'lucide-react';
|
||||
import {
|
||||
AlignLeft,
|
||||
ArrowDownUp,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
LocateFixed,
|
||||
MapPin,
|
||||
Tag,
|
||||
Tags,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { cardDateLabel } from '../../../Journey/journeyCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { FactChange } from '../parts/CardFacts';
|
||||
import { EntryMarks, EntryStory, EntryThumb } from './JourneyEntryParts';
|
||||
@@ -20,7 +33,9 @@ import { entryHoverProps, useEntryHover } from './useEntryHover';
|
||||
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||
|
||||
/** The facts a journal change row names on its faint line, in this order: when, then where. */
|
||||
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin } as const;
|
||||
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin, coordinates: LocateFixed } as const;
|
||||
/** The lists an update rewrites, each by its glyph: the tags and the two sides of the verdict. */
|
||||
const LIST_ICONS: Readonly<Record<string, typeof Tag>> = { tags: Tags, pros: ThumbsUp, cons: ThumbsDown };
|
||||
/** The words an update rewrites, named only when they change: the entry's title and its text. */
|
||||
const WORD_ICONS = { name: Type, text: AlignLeft } as const;
|
||||
type FactRow = AssistantChangeRow & { labelKey: keyof typeof FACT_ICONS };
|
||||
@@ -45,7 +60,9 @@ function StateChange({ row }: { row: AssistantChangeRow }) {
|
||||
if (flag) return <FactChange icon={flag.Icon} before={null} after={t(flag.labelKey)} tone="info" />;
|
||||
const was = stateOf(row.field, row.before);
|
||||
const now = stateOf(row.field, row.after);
|
||||
const Icon = (now ?? was)?.Icon ?? Tag;
|
||||
const listed =
|
||||
row.field && Object.prototype.hasOwnProperty.call(LIST_ICONS, row.field) ? LIST_ICONS[row.field] : undefined;
|
||||
const Icon = (now ?? was)?.Icon ?? listed ?? Tag;
|
||||
const named = (tone: typeof now, value: string | null) => (tone ? t(tone.labelKey) : value);
|
||||
return (
|
||||
<FactChange
|
||||
@@ -110,12 +127,32 @@ export function JourneyEntryAside({ card, item }: { card: JourneyEntriesCard; it
|
||||
);
|
||||
}
|
||||
|
||||
/** A new order: the entries that move, in their new places, each with its day; the rest counted. */
|
||||
function NewOrder({ card }: { card: JourneyEntriesCard }) {
|
||||
const { t, locale } = useTranslation();
|
||||
return (
|
||||
<ol className="m-0 mt-1 list-none space-y-0.5 p-0">
|
||||
{card.entries.map((look, index) => (
|
||||
<li key={look.entryId ?? index} className="flex items-center gap-1.5 text-caption text-content-secondary">
|
||||
<ArrowDownUp size={11} aria-hidden="true" className="flex-none text-info" />
|
||||
<bdi className="min-w-0 truncate">{look.title ?? look.place ?? cardDateLabel(look.date, locale)}</bdi>
|
||||
<span className="flex-none text-content-faint">{cardDateLabel(look.date, locale)}</span>
|
||||
</li>
|
||||
))}
|
||||
{card.more > 0 && (
|
||||
<li className="text-caption text-content-faint">{t('assistant.card.common.more', { count: card.more })}</li>
|
||||
)}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** The start of the entry's text and its marks, under the row; resting on them shows its place on the map. */
|
||||
export function JourneyEntryBody({ card, item }: { card: JourneyEntriesCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
const hover = useEntryHover(card.journeyId, item.toolCallId);
|
||||
const look = card.entries[0];
|
||||
if (!look || GONE.has(item.state)) return null;
|
||||
if (card.change === 'reordered') return <NewOrder card={card} />;
|
||||
return (
|
||||
<div {...entryHoverProps(hover, look)}>
|
||||
<EntryStory look={look} roomy={false} />
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* FE-AI-JOURNEY-020 to FE-AI-JOURNEY-022
|
||||
*
|
||||
* The parts of a journal entry the chat added later: its tags and how its
|
||||
* verdict leans among its marks, an update's tags, verdict and coordinates
|
||||
* old to new, and a new order of the entries drawn as the entries that move.
|
||||
*/
|
||||
import type { AssistantChangePreview, JourneyEntriesCard, JourneyEntryLook } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../../../tests/helpers/render';
|
||||
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { AssistantViewContext } from '../../assistantViewContext';
|
||||
|
||||
const ENTRY: JourneyEntryLook = {
|
||||
entryId: 12,
|
||||
type: 'entry',
|
||||
title: 'Sunset at the Spree',
|
||||
date: '2026-10-21',
|
||||
time: null,
|
||||
place: null,
|
||||
point: null,
|
||||
countryCode: null,
|
||||
story: null,
|
||||
mood: null,
|
||||
weather: null,
|
||||
photoId: null,
|
||||
video: false,
|
||||
photoCount: 0,
|
||||
visibility: 'private',
|
||||
draft: false,
|
||||
fromTripPlace: false,
|
||||
dayIndex: null,
|
||||
tags: ['river', 'evening'],
|
||||
verdict: { pros: ['the light'], cons: ['cold', 'wind'], prosCount: 1, consCount: 2 },
|
||||
};
|
||||
|
||||
function journal(entries: JourneyEntryLook[], change: JourneyEntriesCard['change'], more = 0): JourneyEntriesCard {
|
||||
return { kind: 'journeyEntries', journeyId: 3, journalTitle: 'Berlin', tripJournal: false, change, entries, more };
|
||||
}
|
||||
|
||||
function pending(
|
||||
card: JourneyEntriesCard,
|
||||
verbKey: AssistantChangePreview['verbKey'],
|
||||
rows: AssistantChangePreview['rows']
|
||||
) {
|
||||
const preview: AssistantChangePreview = {
|
||||
toolCallId: 'e1',
|
||||
verbKey,
|
||||
subject: 'Berlin',
|
||||
rows,
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
domain: 'journey',
|
||||
card,
|
||||
};
|
||||
const item: ChangeItem = { toolCallId: 'e1', preview, applied: null, state: 'pending', card };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window' }}>
|
||||
<AssistantChangeCard items={[item]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('the later parts of a journal entry', () => {
|
||||
it('FE-AI-JOURNEY-020: a new entry shows its tags and how its verdict leans', () => {
|
||||
pending(journal([ENTRY], 'new'), 'addJournalEntry', []);
|
||||
expect(screen.getByText('river')).toBeInTheDocument();
|
||||
expect(screen.getByText('evening')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 good point')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 weak points')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEY-021: an update names its tags, each side of its verdict and its coordinates, old to new', () => {
|
||||
const rows: AssistantChangePreview['rows'] = [
|
||||
{ op: 'change', labelKey: 'field', field: 'tags', before: 'river, evening', after: 'river' },
|
||||
{ op: 'change', labelKey: 'field', field: 'cons', before: null, after: 'cold' },
|
||||
{ op: 'change', labelKey: 'coordinates', before: null, after: '52.5200, 13.4050' },
|
||||
];
|
||||
pending(journal([{ ...ENTRY, tags: [], verdict: null }], 'changed'), 'updateJournalEntry', rows);
|
||||
expect(screen.getByText('river, evening')).toHaveClass('line-through');
|
||||
expect(screen.getByText('cold')).toBeInTheDocument();
|
||||
expect(screen.getByText('52.5200, 13.4050')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEY-022: a new order lists the entries that move in their new places, the rest counted', () => {
|
||||
const moved = [
|
||||
{ ...ENTRY, entryId: 2, title: 'Museum Island', moved: true },
|
||||
{ ...ENTRY, entryId: 1, title: 'Arrival', moved: true },
|
||||
];
|
||||
pending(journal(moved, 'reordered', 3), 'reorderJournalEntries', []);
|
||||
expect(screen.getByText('Put the entries in a new order')).toBeInTheDocument();
|
||||
const text = document.body.textContent ?? '';
|
||||
expect(text.indexOf('Museum Island')).toBeGreaterThan(-1);
|
||||
expect(text.indexOf('Museum Island')).toBeLessThan(text.indexOf('Arrival'));
|
||||
expect(screen.getByText('3 more')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JourneyEntryLook } from '@trek/shared';
|
||||
import { CalendarDays, Globe, Images, MapPin, Play, Users } from 'lucide-react';
|
||||
import { CalendarDays, Globe, Images, MapPin, Play, Tag, ThumbsDown, ThumbsUp, Users } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { cardDateLabel, cardPlace } from '../../../Journey/journeyCard';
|
||||
@@ -101,11 +101,39 @@ function WeatherMark({ weather }: { weather: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first. */
|
||||
/** Its tags and how its verdict leans: the good and the weak points counted. */
|
||||
function ListMarks({ look }: { look: JourneyEntryLook }) {
|
||||
const { t } = useTranslation();
|
||||
const verdict = look.verdict ?? null;
|
||||
return (
|
||||
<>
|
||||
{(look.tags ?? []).map((tag, index) => (
|
||||
<SoftPill key={`${tag}-${index}`} icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}>
|
||||
<bdi className="truncate">{tag}</bdi>
|
||||
</SoftPill>
|
||||
))}
|
||||
{verdict && verdict.prosCount > 0 && (
|
||||
<SoftPill tone="success" icon={<ThumbsUp size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.journey.pros', { count: verdict.prosCount })}
|
||||
</SoftPill>
|
||||
)}
|
||||
{verdict && verdict.consCount > 0 && (
|
||||
<SoftPill tone="warning" icon={<ThumbsDown size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.journey.cons', { count: verdict.consCount })}
|
||||
</SoftPill>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first, its tags and verdict. */
|
||||
export function EntryMarks({ look }: { look: JourneyEntryLook }) {
|
||||
const { t } = useTranslation();
|
||||
const mood = moodOf(look.mood) !== null;
|
||||
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2) return null;
|
||||
const lists = (look.tags?.length ?? 0) > 0 || !!look.verdict;
|
||||
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2 && !lists) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<CardFacts>
|
||||
<VisibilityMark visibility={look.visibility} />
|
||||
@@ -117,6 +145,7 @@ export function EntryMarks({ look }: { look: JourneyEntryLook }) {
|
||||
{t('assistant.card.journey.photos', { count: look.photoCount })}
|
||||
</SoftPill>
|
||||
)}
|
||||
<ListMarks look={look} />
|
||||
</CardFacts>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrowDownUp,
|
||||
BookCheck,
|
||||
BookOpen,
|
||||
BookOpenText,
|
||||
@@ -29,6 +30,11 @@ export const JOURNEY_LOOK = {
|
||||
addJournalEntry: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.addJournalEntry' },
|
||||
updateJournalEntry: { Icon: BookOpenText, tone: 'info', labelKey: 'assistant.change.verb.updateJournalEntry' },
|
||||
removeJournalEntry: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.removeJournalEntry' },
|
||||
reorderJournalEntries: {
|
||||
Icon: ArrowDownUp,
|
||||
tone: 'info',
|
||||
labelKey: 'assistant.change.verb.reorderJournalEntries',
|
||||
},
|
||||
createJournal: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.createJournal' },
|
||||
renameJournal: { Icon: Type, tone: 'info', labelKey: 'assistant.change.verb.renameJournal' },
|
||||
setJournalStatus: { Icon: BookCheck, tone: 'info', labelKey: 'assistant.change.verb.setJournalStatus' },
|
||||
|
||||
Reference in New Issue
Block a user