mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +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' },
|
||||
|
||||
@@ -21,7 +21,17 @@ import {
|
||||
UNLINK_LINKED_TRIP,
|
||||
} from './journey-header-undo';
|
||||
import { readCard } from './journey-look';
|
||||
import { DELETE_CREATED_ENTRY } from './journey-undo';
|
||||
import {
|
||||
describeReorder,
|
||||
describeStay,
|
||||
loadJournalOrder,
|
||||
loadStay,
|
||||
reorderCard,
|
||||
stayCard,
|
||||
type JournalOrderLoaded,
|
||||
type StayLoaded,
|
||||
} from './journey-order';
|
||||
import { DELETE_CREATED_ENTRY, RESTORE_CHANGED_ENTRY, RESTORE_ENTRY_ORDER } from './journey-undo';
|
||||
import type { AssistantChangeVerbKey, JourneyEntriesCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
@@ -85,6 +95,35 @@ function journalHeader(
|
||||
};
|
||||
}
|
||||
|
||||
/** A new order of the journal's entries: the entries that move, in their new places, and the way back. */
|
||||
const reorderCards: ToolCards<JournalOrderLoaded> = {
|
||||
write: { verbKey: 'reorderJournalEntries', entity: 'journey_entry' },
|
||||
load: (args, read) => loadJournalOrder(args, read),
|
||||
describe: describeReorder,
|
||||
change: (_ctx, args, loaded) => reorderCard(args, loaded),
|
||||
appliedIds: (args) => {
|
||||
const first = Array.isArray(args.orderedIds) ? args.orderedIds[0] : null;
|
||||
return typeof first === 'number' ? [first] : [];
|
||||
},
|
||||
undo: RESTORE_ENTRY_ORDER,
|
||||
};
|
||||
|
||||
/**
|
||||
* A stay Dawarich recorded taken in as an entry: the entry it writes. No way
|
||||
* back: deleting the entry would leave the stay marked as taken in, with no
|
||||
* way to take it in again.
|
||||
*/
|
||||
const stayCards: ToolCards<StayLoaded> = {
|
||||
write: { verbKey: 'addJournalEntry', entity: 'journey_entry' },
|
||||
load: (args, read) => loadStay(args, read),
|
||||
describe: describeStay,
|
||||
change: (_ctx, args, loaded) => stayCard(args, loaded),
|
||||
appliedIds: (_args, result) => {
|
||||
const id = recordOf(result).createdJournalEntryId;
|
||||
return typeof id === 'number' ? [id] : [];
|
||||
},
|
||||
};
|
||||
|
||||
export const JOURNEY_CARDS: DomainCards = {
|
||||
id: 'journey',
|
||||
tools: {
|
||||
@@ -108,6 +147,7 @@ export const JOURNEY_CARDS: DomainCards = {
|
||||
update_journey_entry: {
|
||||
write: { verbKey: 'updateJournalEntry', entity: 'journey_entry' },
|
||||
describe: JOURNEY_DESCRIBERS.update_journey_entry,
|
||||
undo: RESTORE_CHANGED_ENTRY,
|
||||
...journalWrite('update_journey_entry'),
|
||||
},
|
||||
delete_journey_entry: {
|
||||
@@ -115,6 +155,8 @@ export const JOURNEY_CARDS: DomainCards = {
|
||||
describe: JOURNEY_DESCRIBERS.delete_journey_entry,
|
||||
...journalWrite('delete_journey_entry'),
|
||||
},
|
||||
reorder_journey_entries: reorderCards,
|
||||
accept_dawarich_suggestion_as_journal_entry: stayCards,
|
||||
create_journey: journalHeader('create_journey', 'createJournal', DELETE_CREATED_JOURNEY),
|
||||
update_journey: journalHeader('update_journey', 'updateJournalSettings', RESTORE_JOURNEY_SETTINGS),
|
||||
delete_journey: journalHeader('delete_journey', 'deleteJournal'),
|
||||
|
||||
@@ -47,6 +47,8 @@ const ENTRY_FIELDS = [
|
||||
'visibility',
|
||||
'type',
|
||||
'is_draft',
|
||||
'tags',
|
||||
'pros_cons',
|
||||
] as const;
|
||||
|
||||
/** The states of an entry an update can set, each a row of its own when it changes. */
|
||||
@@ -142,6 +144,41 @@ function addStateRows(rows: Rows, before: Row, args: ToolArgs): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A list the user wrote (tags, a side of the verdict) as one row value: its items in order, joined. */
|
||||
function listText(value: unknown): string {
|
||||
return text(Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim()).join(', ') : '');
|
||||
}
|
||||
|
||||
/** Coordinates as a row shows them, both or none. */
|
||||
function pointText(lat: unknown, lng: unknown): string {
|
||||
return typeof lat === 'number' && typeof lng === 'number' ? `${lat.toFixed(4)}, ${lng.toFixed(4)}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of what an update sets beyond the words and the states: the tags,
|
||||
* each side of the verdict, and the coordinates (the entry placed on the map,
|
||||
* moved or taken off it), each when it changes.
|
||||
*/
|
||||
function addListRows(rows: Rows, before: Row, args: ToolArgs): void {
|
||||
const push = (field: string, was: string, now: string) => {
|
||||
if (was !== now) rows.rows.push({ op: 'change', labelKey: 'field', field, before: was || null, after: now });
|
||||
};
|
||||
if ('tags' in args) push('tags', listText(before.tags), listText(args.tags));
|
||||
if ('pros_cons' in args) {
|
||||
const was = recordOf(before.pros_cons);
|
||||
const now = recordOf(args.pros_cons);
|
||||
push('pros', listText(was.pros), listText(now.pros));
|
||||
push('cons', listText(was.cons), listText(now.cons));
|
||||
}
|
||||
if ('location_lat' in args || 'location_lng' in args) {
|
||||
const lat = 'location_lat' in args ? args.location_lat : before.location_lat;
|
||||
const lng = 'location_lng' in args ? args.location_lng : before.location_lng;
|
||||
const was = pointText(before.location_lat, before.location_lng);
|
||||
const now = pointText(lat, lng);
|
||||
if (was !== now) rows.change('coordinates', was, now);
|
||||
}
|
||||
}
|
||||
|
||||
/** The fields of the entry an update sets: those its arguments name, a null among them clearing one. */
|
||||
function changedFields(args: ToolArgs): Array<(typeof ENTRY_FIELDS)[number]> {
|
||||
return ENTRY_FIELDS.filter((field) => field in args);
|
||||
@@ -165,6 +202,7 @@ export const JOURNEY_DESCRIBERS = {
|
||||
if ('location_name' in args) rows.change('location', text(before.location_name), text(args.location_name));
|
||||
if ('story' in args) rows.change('text', storyText(before.story), storyText(args.story));
|
||||
addStateRows(rows, before, args);
|
||||
addListRows(rows, before, args);
|
||||
return described(subjectOf(ctx, { ...before, ...args }), rows);
|
||||
},
|
||||
delete_journey_entry: (ctx: PreviewContext, _args: ToolArgs, loaded: JournalLoaded | null): Described => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
stripMarkdown,
|
||||
type JourneyEntriesCard,
|
||||
type JourneyEntryLook,
|
||||
type JourneyEntryVerdict,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
@@ -74,6 +75,30 @@ function coverOf(photos: Row[]): { photoId: number | null; video: boolean } {
|
||||
return { photoId: positiveId(cover.photo_id), video: cover.media_type === 'video' };
|
||||
}
|
||||
|
||||
/** Lines of text a list field holds (tags, a side of the verdict), each read like any line of its source. */
|
||||
function linesOf(value: unknown, key: string, max: number, source: LookSource): string[] {
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
return items.flatMap((item) => {
|
||||
const line = lineOf({ [key]: item }, key, max, source);
|
||||
return line === null ? [] : [line];
|
||||
});
|
||||
}
|
||||
|
||||
/** The first tags, as many as a card carries. */
|
||||
function tagsOf(row: Row, source: LookSource): string[] {
|
||||
return linesOf(row.tags, 'tags', JOURNEY_CARD_LIMITS.tag, source).slice(0, JOURNEY_CARD_LIMITS.tags);
|
||||
}
|
||||
|
||||
/** The verdict's first points on each side and how many there are; null for an entry without one. */
|
||||
function verdictOf(row: Row, source: LookSource): JourneyEntryVerdict | null {
|
||||
const verdict = recordOf(row.pros_cons);
|
||||
const pros = linesOf(verdict.pros, 'pros_cons', JOURNEY_CARD_LIMITS.point, source);
|
||||
const cons = linesOf(verdict.cons, 'pros_cons', JOURNEY_CARD_LIMITS.point, source);
|
||||
if (pros.length === 0 && cons.length === 0) return null;
|
||||
const cut = JOURNEY_CARD_LIMITS.points;
|
||||
return { pros: pros.slice(0, cut), cons: cons.slice(0, cut), prosCount: pros.length, consCount: cons.length };
|
||||
}
|
||||
|
||||
/** Both coordinates, or neither. */
|
||||
function pointOf(row: Row): JourneyEntryLook['point'] {
|
||||
const lat = finite(row.location_lat);
|
||||
@@ -110,6 +135,8 @@ export function lookOf(value: unknown, source: LookSource = {}): JourneyEntryLoo
|
||||
draft: row.is_draft === true || row.is_draft === 1,
|
||||
fromTripPlace: positiveId(row.source_place_id) !== null && row.type !== 'skeleton',
|
||||
dayIndex: day >= 0 && day <= 9_999 ? day : null,
|
||||
tags: tagsOf(row, source),
|
||||
verdict: verdictOf(row, source),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { CardReader, Described, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, recordOf, type Row } from '../card-values';
|
||||
import { formatDate, Rows, text } from '../preview-format';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { journeyArgs } from './journey-header';
|
||||
import { entriesCard, lookOf } from './journey-look';
|
||||
import { JOURNEY_CARD_LIMITS, stripMarkdown, type JourneyEntriesCard, type JourneyEntryLook } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Two more entry changes of the journal in the chat: a new order of its
|
||||
* entries, drawn as the entries that move, in their new order; and a stay
|
||||
* Dawarich recorded taken in as a new entry, drawn as the entry it writes
|
||||
* from the stay and what the call corrects of it.
|
||||
*/
|
||||
|
||||
/** What a held reorder looked up: the journal's entries in their reading order, and the journal. */
|
||||
export interface JournalOrderLoaded {
|
||||
entries: Row[];
|
||||
journeyId: number | null;
|
||||
title: unknown;
|
||||
}
|
||||
|
||||
/** One read: the journal's entries as list_journey_entries answers them, on the journal the call names or pins. */
|
||||
export async function loadJournalOrder(args: ToolArgs, read: CardReader): Promise<JournalOrderLoaded | null> {
|
||||
const answer = recordOf(await read('list_journey_entries', journeyArgs(args)));
|
||||
const journey = recordOf(answer.journey);
|
||||
const journeyId = argId(journey, 'id');
|
||||
return journeyId === null ? null : { entries: rowsOf(answer.entries), journeyId, title: journey.title };
|
||||
}
|
||||
|
||||
function orderedIdsOf(args: ToolArgs): number[] {
|
||||
return Array.isArray(args.orderedIds) ? args.orderedIds.filter((id): id is number => Number.isInteger(id)) : [];
|
||||
}
|
||||
|
||||
/** The ids the call names, in the order they stand in now and in the new one, and those that change place. */
|
||||
function orderOf(args: ToolArgs, loaded: JournalOrderLoaded) {
|
||||
const next = orderedIdsOf(args);
|
||||
const named = new Set(next);
|
||||
const now = loaded.entries.map((entry) => entry.id).filter((id): id is number => named.has(id as number));
|
||||
const moved = new Set(next.filter((id, index) => now.indexOf(id) !== index));
|
||||
return { now, next, moved };
|
||||
}
|
||||
|
||||
function nameOf(ctx: PreviewContext, entry: Row | undefined): string {
|
||||
if (!entry) return '';
|
||||
const day = typeof entry.entry_date === 'string' ? formatDate(ctx, entry.entry_date) : '';
|
||||
return text(entry.title) || text(entry.location_name) || day;
|
||||
}
|
||||
|
||||
/** The row of a new order: the entries that move, as they stood and as they will stand. */
|
||||
export function describeReorder(ctx: PreviewContext, args: ToolArgs, loaded: JournalOrderLoaded | null): Described {
|
||||
const rows = new Rows();
|
||||
if (loaded) {
|
||||
const { now, next, moved } = orderOf(args, loaded);
|
||||
const byId = new Map(loaded.entries.map((entry) => [entry.id, entry]));
|
||||
const names = (ids: number[]) =>
|
||||
ids
|
||||
.filter((id) => moved.has(id))
|
||||
.map((id) => nameOf(ctx, byId.get(id)))
|
||||
.join(', ');
|
||||
if (moved.size > 0) rows.change('order', names(now), names(next));
|
||||
}
|
||||
return { subject: text(loaded?.title), rows, dayId: null, placeIds: [], domain: 'journey' };
|
||||
}
|
||||
|
||||
/** The card of a new order: the entries that move in their new places, the rest of the call counted. */
|
||||
export function reorderCard(args: ToolArgs, loaded: JournalOrderLoaded | null): JourneyEntriesCard | null {
|
||||
if (!loaded) return null;
|
||||
const { next, moved } = orderOf(args, loaded);
|
||||
const byId = new Map(loaded.entries.map((entry) => [entry.id, entry]));
|
||||
const looks = next.flatMap((id): JourneyEntryLook[] => {
|
||||
if (!moved.has(id)) return [];
|
||||
const look = lookOf(byId.get(id));
|
||||
return look ? [{ ...look, moved: true }] : [];
|
||||
});
|
||||
const kept = looks.slice(0, JOURNEY_CARD_LIMITS.entries);
|
||||
const journal = {
|
||||
journeyId: loaded.journeyId,
|
||||
title: loaded.title,
|
||||
tripJournal: false,
|
||||
change: 'reordered' as const,
|
||||
};
|
||||
return entriesCard(journal, kept, looks.length - kept.length);
|
||||
}
|
||||
|
||||
/** What a held Dawarich stay taken in as an entry looked up: the stay, and the journal it goes into. */
|
||||
export interface StayLoaded {
|
||||
stay: Row | null;
|
||||
journeyId: number | null;
|
||||
title: unknown;
|
||||
}
|
||||
|
||||
/** The journal a call names as `journalId`, or none in a journey's own conversation, where the session pins it. */
|
||||
function journalArgs(args: ToolArgs): ToolArgs {
|
||||
const journeyId = argId(args, 'journalId');
|
||||
return journeyId === null ? {} : { journeyId };
|
||||
}
|
||||
|
||||
/** Two reads: the stays held for review, to find this one, and the journal. */
|
||||
export async function loadStay(args: ToolArgs, read: CardReader): Promise<StayLoaded | null> {
|
||||
const suggestionId = argId(args, 'suggestionId');
|
||||
if (suggestionId === null) return null;
|
||||
const listed = rowsOf(recordOf(await read('list_dawarich_suggestions', { limit: 200 })).suggestions);
|
||||
const journey = recordOf(recordOf(await read('get_journey', journalArgs(args))).journey);
|
||||
return {
|
||||
stay: listed.find((row) => row.id === suggestionId) ?? null,
|
||||
journeyId: argId(journey, 'id'),
|
||||
title: journey.title,
|
||||
};
|
||||
}
|
||||
|
||||
/** The entry a stay becomes: the call's corrections over what the stay recorded, as the service writes it. */
|
||||
function stayEntry(args: ToolArgs, stay: Row): Row {
|
||||
const name = text(args.name) || text(stay.name);
|
||||
const started = typeof stay.startedAt === 'string' ? stay.startedAt : '';
|
||||
const clock = /^\d{4}-\d{2}-\d{2}T(\d{2}:\d{2})/.exec(started)?.[1] ?? null;
|
||||
return {
|
||||
id: null,
|
||||
type: 'entry',
|
||||
title: name || null,
|
||||
story: typeof args.notes === 'string' ? args.notes : null,
|
||||
entry_date: typeof args.date === 'string' ? args.date : stay.localDate,
|
||||
entry_time: typeof args.time === 'string' ? args.time : clock,
|
||||
location_name: name || null,
|
||||
location_lat: stay.lat,
|
||||
location_lng: stay.lng,
|
||||
photos: [],
|
||||
is_draft: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The row of a stay taken in: what the entry is about, its day, time, place and text. */
|
||||
export function describeStay(ctx: PreviewContext, args: ToolArgs, loaded: StayLoaded | null): Described {
|
||||
const entry = loaded?.stay
|
||||
? stayEntry(args, loaded.stay)
|
||||
: { title: args.name, story: args.notes, entry_date: args.date };
|
||||
const day = typeof entry.entry_date === 'string' ? formatDate(ctx, entry.entry_date) : '';
|
||||
const story = typeof entry.story === 'string' ? text(stripMarkdown(entry.story)) : '';
|
||||
const rows = new Rows()
|
||||
.add('day', day)
|
||||
.add('time', text(entry.entry_time))
|
||||
.add('location', text(entry.location_name))
|
||||
.add('text', story);
|
||||
return { subject: text(entry.title) || day, rows, dayId: null, placeIds: [], domain: 'journey' };
|
||||
}
|
||||
|
||||
/** The card of a stay taken in: the entry it will write, in the journal it goes into. */
|
||||
export function stayCard(args: ToolArgs, loaded: StayLoaded | null): JourneyEntriesCard | null {
|
||||
if (!loaded?.stay) return null;
|
||||
// What the call writes is cut only; what the stay recorded is masked like any stored text.
|
||||
const fromArgs = new Set([...(text(args.name) ? ['title', 'location_name'] : []), ...(args.notes ? ['story'] : [])]);
|
||||
const look = lookOf(stayEntry(args, loaded.stay), { fromArgs });
|
||||
const journal = { journeyId: loaded.journeyId, title: loaded.title, tripJournal: false, change: 'new' as const };
|
||||
return entriesCard(journal, look ? [look] : [], 0);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ToolCards, UndoSnapshot } from '../card-types';
|
||||
import type { ToolArgs, ToolCards, UndoSnapshot } from '../card-types';
|
||||
import { idIn, readKey, recordOf } from '../card-values';
|
||||
import { journeyArgs } from './journey-header';
|
||||
|
||||
/** How the undo reads the created entry again: by its id, whatever the turn read before. */
|
||||
function entryRead(result: unknown) {
|
||||
@@ -39,3 +40,49 @@ export const DELETE_CREATED_ENTRY: NonNullable<ToolCards['undo']> = {
|
||||
reads: (_args, result) => [entryRead(result)],
|
||||
unchanged: entryUnchanged,
|
||||
};
|
||||
|
||||
/**
|
||||
* A changed entry written back as it was, from what the tool answered it
|
||||
* replaced, while nothing was written to it since and it holds the photos it
|
||||
* held then.
|
||||
*/
|
||||
export const RESTORE_CHANGED_ENTRY: NonNullable<ToolCards['undo']> = {
|
||||
tool: 'update_journey_entry',
|
||||
args: (_args, result) => {
|
||||
const entryId = idIn(result, 'entry');
|
||||
const previous = recordOf(recordOf(result).previous);
|
||||
return entryId === null || Object.keys(previous).length === 0 ? null : { entryId, ...previous };
|
||||
},
|
||||
reads: (_args, result) => [entryRead(result)],
|
||||
unchanged: entryUnchanged,
|
||||
};
|
||||
|
||||
/** The ids a reorder named, and the order the tool answered they stood in before. */
|
||||
function ordersOf(args: ToolArgs, result: unknown): { next: number[]; before: number[] } {
|
||||
const ids = (value: unknown) =>
|
||||
Array.isArray(value) ? value.filter((id): id is number => Number.isInteger(id)) : [];
|
||||
return { next: ids(args.orderedIds), before: ids(recordOf(result).previous_order) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A new order taken back to the one before, while the entries still stand
|
||||
* as it left them: each at the place the reorder gave it.
|
||||
*/
|
||||
export const RESTORE_ENTRY_ORDER: NonNullable<ToolCards['undo']> = {
|
||||
tool: 'reorder_journey_entries',
|
||||
args: (args, result) => {
|
||||
const { next, before } = ordersOf(args, result);
|
||||
const same = before.length === next.length && before.every((id, index) => id === next[index]);
|
||||
return before.length === 0 || same ? null : { ...journeyArgs(args), orderedIds: before };
|
||||
},
|
||||
reads: (args) => [{ tool: 'list_journey_entries', args: journeyArgs(args) }],
|
||||
unchanged: (result, snapshot, call) => {
|
||||
const args = call?.args ?? {};
|
||||
const { next } = ordersOf(args, result);
|
||||
const read = recordOf(snapshot.reads.get(readKey('list_journey_entries', journeyArgs(args))));
|
||||
const sortOf = new Map(
|
||||
(Array.isArray(read.entries) ? read.entries.map(recordOf) : []).map((entry) => [entry.id, entry.sort_order]),
|
||||
);
|
||||
return next.length > 0 && next.every((id, index) => sortOf.get(id) === index);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -377,7 +377,7 @@ describe('the applied change', () => {
|
||||
expect(ASSISTANT_WRITE_TOOLS.create_journey_entry).toEqual({ verbKey: 'addJournalEntry', entity: 'journey_entry' });
|
||||
expect(ASSISTANT_WRITE_TOOLS.update_journey_entry?.verbKey).toBe('updateJournalEntry');
|
||||
expect(ASSISTANT_WRITE_TOOLS.delete_journey_entry?.verbKey).toBe('removeJournalEntry');
|
||||
expect(ASSISTANT_UNDO_MAP.update_journey_entry).toBeUndefined();
|
||||
expect(ASSISTANT_UNDO_MAP.update_journey_entry?.tool).toBe('update_journey_entry');
|
||||
expect(CARD_PROMPT_LINES).toEqual(expect.arrayContaining([...(JOURNEY_CARDS.prompt ?? [])]));
|
||||
expect(JOURNEY_CARDS.prompt).toHaveLength(2);
|
||||
// A trip chat sends a book request to the Studio instead of trying it.
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* ASTCARD-JORD-001 to ASTCARD-JORD-007
|
||||
*
|
||||
* The rest of a journal entry's changes in the assistant chat: an update's
|
||||
* tags, verdict and coordinates on the row and the card, a new order of the
|
||||
* entries with its way back, a changed entry written back as it was, and a
|
||||
* Dawarich stay taken in as an entry. Every card is checked against the
|
||||
* shared union through the registry's guard.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import type { CardReader, PreviewContext, UndoSnapshot } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
|
||||
import { JOURNEY_CARDS } from '../../../../../../src/nest/assistant/cards/journey';
|
||||
import type { JournalLoaded } from '../../../../../../src/nest/assistant/cards/journey/journey-change';
|
||||
import { lookOf } from '../../../../../../src/nest/assistant/cards/journey/journey-look';
|
||||
import { loadJournalOrder, loadStay } from '../../../../../../src/nest/assistant/cards/journey/journey-order';
|
||||
import { ASSISTANT_WRITE_TOOLS, changeCardOf } from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import { journeyEntriesCardSchema, type JourneyEntriesCard } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => ({
|
||||
logInfo: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
}));
|
||||
|
||||
const ctx: PreviewContext = {
|
||||
card: buildTripCard(1, 7, { trip: { title: 'Berlin' } }, null, null),
|
||||
categories: new Map(),
|
||||
locale: 'en',
|
||||
today: '2026-10-24',
|
||||
airport: () => null,
|
||||
};
|
||||
|
||||
function cardOf(value: unknown): JourneyEntriesCard {
|
||||
return journeyEntriesCardSchema.parse(value);
|
||||
}
|
||||
|
||||
function readerOf(answers: Record<string, unknown>): { read: CardReader; asked: string[] } {
|
||||
const asked: string[] = [];
|
||||
const read: CardReader = (tool, args = {}) => {
|
||||
asked.push(readKey(tool, args));
|
||||
return Promise.resolve(answers[readKey(tool, args)] ?? answers[tool] ?? null);
|
||||
};
|
||||
return { read, asked };
|
||||
}
|
||||
|
||||
function undoOf(tool: string) {
|
||||
const undo = JOURNEY_CARDS.tools[tool]?.undo;
|
||||
if (!undo) throw new Error(`no undo for ${tool}`);
|
||||
return undo;
|
||||
}
|
||||
|
||||
const SPREE = {
|
||||
id: 12,
|
||||
journey_id: 3,
|
||||
type: 'entry',
|
||||
title: 'Sunset at the Spree',
|
||||
entry_date: '2026-10-21',
|
||||
tags: ['river', 'evening'],
|
||||
pros_cons: { pros: ['the light'], cons: [] },
|
||||
location_lat: null,
|
||||
location_lng: null,
|
||||
photos: [],
|
||||
updated_at: 5,
|
||||
};
|
||||
|
||||
describe('the tags, the verdict and the coordinates of an entry', () => {
|
||||
it('ASTCARD-JORD-001: an entry shows its first tags and how its verdict leans', () => {
|
||||
expect(lookOf(SPREE)).toMatchObject({
|
||||
tags: ['river', 'evening'],
|
||||
verdict: { pros: ['the light'], cons: [], prosCount: 1, consCount: 0 },
|
||||
});
|
||||
const many = { ...SPREE, tags: Array.from({ length: 9 }, (_, i) => `t${i}`), pros_cons: null };
|
||||
expect(lookOf(many)?.tags).toHaveLength(6);
|
||||
expect(lookOf(many)?.verdict).toBeNull();
|
||||
expect(lookOf({ ...SPREE, tags: 'not a list', pros_cons: { pros: [' '], cons: [] } })).toMatchObject({
|
||||
tags: [],
|
||||
verdict: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTCARD-JORD-002: an update names the tags, each side of the verdict and the coordinates it changes', () => {
|
||||
const loaded: JournalLoaded = { entry: SPREE, journeyId: 3, title: 'Berlin', tripJournal: false };
|
||||
const args = {
|
||||
entryId: 12,
|
||||
tags: ['river'],
|
||||
pros_cons: { pros: ['the light', 'calm'], cons: ['cold'] },
|
||||
location_lat: 52.52,
|
||||
location_lng: 13.405,
|
||||
};
|
||||
const preview = describeToolCall('update_journey_entry', 'c1', args, ctx, loaded);
|
||||
expect(preview.rows).toEqual([
|
||||
{ op: 'change', labelKey: 'field', field: 'tags', before: 'river, evening', after: 'river' },
|
||||
{ op: 'change', labelKey: 'field', field: 'pros', before: 'the light', after: 'the light, calm' },
|
||||
{ op: 'change', labelKey: 'field', field: 'cons', before: null, after: 'cold' },
|
||||
{ op: 'change', labelKey: 'coordinates', before: null, after: '52.5200, 13.4050' },
|
||||
]);
|
||||
// The card shows the entry with what the call sets, the verdict and the tags included.
|
||||
const card = cardOf(changeCardOf('update_journey_entry', ctx, args, loaded));
|
||||
expect(card.entries[0]).toMatchObject({ tags: ['river'], verdict: { prosCount: 2, consCount: 1 } });
|
||||
expect(card.entries[0]?.point).toEqual({ lat: 52.52, lng: 13.405 });
|
||||
// A list set to what it holds gets no row.
|
||||
expect(
|
||||
describeToolCall('update_journey_entry', 'c2', { entryId: 12, tags: ['river', 'evening'] }, ctx, loaded).rows,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('ASTCARD-JORD-003: a changed entry is written back from what the tool answered it replaced', () => {
|
||||
const undo = undoOf('update_journey_entry');
|
||||
const result = { entry: { ...SPREE, title: 'Evening' }, previous: { title: 'Sunset at the Spree', tags: [] } };
|
||||
expect(undo.args({}, result, null)).toEqual({ entryId: 12, title: 'Sunset at the Spree', tags: [] });
|
||||
expect(undo.args({}, { entry: SPREE, previous: {} }, null)).toBeNull();
|
||||
const snapshot = (current: unknown): UndoSnapshot => ({
|
||||
summary: null,
|
||||
places: null,
|
||||
reads: new Map([[readKey('get_journey_entry', { entryId: 12 }), current]]),
|
||||
});
|
||||
expect(undo.unchanged(result, snapshot({ entry: { ...SPREE, title: 'Evening' } }))).toBe(true);
|
||||
expect(undo.unchanged(result, snapshot({ entry: { ...SPREE, updated_at: 6 } }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const ENTRIES = [
|
||||
{ id: 1, title: 'Arrival', entry_date: '2026-10-21', sort_order: 0, photos: [] },
|
||||
{ id: 2, title: 'Museum Island', entry_date: '2026-10-21', sort_order: 1, photos: [] },
|
||||
{ id: 3, title: 'Dinner', entry_date: '2026-10-21', sort_order: 2, photos: [] },
|
||||
];
|
||||
|
||||
describe('a new order of the entries', () => {
|
||||
it('ASTCARD-JORD-004: reads the journal, names the entries that move, and draws them in their new places', async () => {
|
||||
const { read, asked } = readerOf({
|
||||
list_journey_entries: { journey: { id: 3, title: 'Berlin' }, entries: ENTRIES },
|
||||
});
|
||||
const loaded = await loadJournalOrder({ journeyId: 3, orderedIds: [2, 1, 3] }, read);
|
||||
expect(asked).toEqual([readKey('list_journey_entries', { journeyId: 3 })]);
|
||||
expect(loaded).toMatchObject({ journeyId: 3, title: 'Berlin' });
|
||||
const preview = describeToolCall('reorder_journey_entries', 'c1', { orderedIds: [2, 1, 3] }, ctx, loaded);
|
||||
expect(preview).toMatchObject({ verbKey: 'reorderJournalEntries', subject: 'Berlin', undoable: true });
|
||||
expect(preview.rows).toEqual([
|
||||
{ op: 'change', labelKey: 'order', before: 'Arrival, Museum Island', after: 'Museum Island, Arrival' },
|
||||
]);
|
||||
const card = cardOf(preview.card);
|
||||
expect(card.change).toBe('reordered');
|
||||
expect(card.entries.map((look) => [look.entryId, look.moved])).toEqual([
|
||||
[2, true],
|
||||
[1, true],
|
||||
]);
|
||||
expect(await loadJournalOrder({}, readerOf({}).read)).toBeNull();
|
||||
expect(ASSISTANT_WRITE_TOOLS.reorder_journey_entries).toEqual({
|
||||
verbKey: 'reorderJournalEntries',
|
||||
entity: 'journey_entry',
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTCARD-JORD-005: is taken back to the order before while every entry still stands where it put it', () => {
|
||||
const undo = undoOf('reorder_journey_entries');
|
||||
const args = { journeyId: 3, orderedIds: [2, 1, 3] };
|
||||
const result = { success: true, previous_order: [1, 2, 3] };
|
||||
expect(undo.args(args, result, null)).toEqual({ journeyId: 3, orderedIds: [1, 2, 3] });
|
||||
expect(undo.args({ orderedIds: [1, 2, 3] }, result, null)).toBeNull();
|
||||
expect(undo.args(args, { success: true }, null)).toBeNull();
|
||||
const check = (entries: unknown[]) =>
|
||||
undo.unchanged(
|
||||
result,
|
||||
{
|
||||
summary: null,
|
||||
places: null,
|
||||
reads: new Map([[readKey('list_journey_entries', { journeyId: 3 }), { entries }]]),
|
||||
},
|
||||
{ args, preview: null },
|
||||
);
|
||||
expect(
|
||||
check([
|
||||
{ id: 2, sort_order: 0 },
|
||||
{ id: 1, sort_order: 1 },
|
||||
{ id: 3, sort_order: 2 },
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
check([
|
||||
{ id: 1, sort_order: 0 },
|
||||
{ id: 2, sort_order: 1 },
|
||||
{ id: 3, sort_order: 2 },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const STAY = {
|
||||
id: 4,
|
||||
name: 'Berlin Hauptbahnhof',
|
||||
lat: 52.525,
|
||||
lng: 13.369,
|
||||
startedAt: '2026-10-20T09:15:00+02:00',
|
||||
localDate: '2026-10-20',
|
||||
};
|
||||
|
||||
describe('a Dawarich stay taken in as an entry', () => {
|
||||
it('ASTCARD-JORD-006: reads the stay and the journal, and shows the entry the stay becomes', async () => {
|
||||
const { read, asked } = readerOf({
|
||||
list_dawarich_suggestions: { suggestions: [STAY] },
|
||||
get_journey: { journey: { id: 3, title: 'Berlin' } },
|
||||
});
|
||||
const args = { suggestionId: 4, journalId: 3, notes: 'Arrived early' };
|
||||
const loaded = await loadStay(args, read);
|
||||
expect(asked).toEqual([
|
||||
readKey('list_dawarich_suggestions', { limit: 200 }),
|
||||
readKey('get_journey', { journeyId: 3 }),
|
||||
]);
|
||||
const preview = describeToolCall('accept_dawarich_suggestion_as_journal_entry', 'c1', args, ctx, loaded);
|
||||
expect(preview).toMatchObject({ verbKey: 'addJournalEntry', subject: 'Berlin Hauptbahnhof', undoable: false });
|
||||
expect(preview.rows.map((row) => [row.labelKey, row.after])).toEqual([
|
||||
['day', 'Tue, Oct 20'],
|
||||
['time', '09:15'],
|
||||
['location', 'Berlin Hauptbahnhof'],
|
||||
['text', 'Arrived early'],
|
||||
]);
|
||||
const card = cardOf(preview.card);
|
||||
expect(card).toMatchObject({ journeyId: 3, journalTitle: 'Berlin', change: 'new' });
|
||||
expect(card.entries[0]).toMatchObject({ entryId: null, date: '2026-10-20', point: { lat: 52.525, lng: 13.369 } });
|
||||
});
|
||||
|
||||
it('ASTCARD-JORD-007: a correction wins over the stay, and a stay no longer listed draws no card', async () => {
|
||||
const loaded = { stay: STAY, journeyId: 3, title: 'Berlin' };
|
||||
const fixed = describeToolCall(
|
||||
'accept_dawarich_suggestion_as_journal_entry',
|
||||
'c1',
|
||||
{ suggestionId: 4, name: 'Hbf', date: '2026-10-19', time: '20:00' },
|
||||
ctx,
|
||||
loaded,
|
||||
);
|
||||
expect(fixed.subject).toBe('Hbf');
|
||||
expect(fixed.rows.map((row) => row.after)).toEqual(['Mon, Oct 19', '20:00', 'Hbf']);
|
||||
expect(
|
||||
changeCardOf('accept_dawarich_suggestion_as_journal_entry', ctx, { suggestionId: 4 }, { ...loaded, stay: null }),
|
||||
).toBeNull();
|
||||
expect(await loadStay({}, readerOf({}).read)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,27 @@ import { z } from 'zod';
|
||||
*/
|
||||
|
||||
/** How much of a journal a card carries; the server clips to the same numbers. */
|
||||
export const JOURNEY_CARD_LIMITS = { entries: 8, title: 100, place: 80, story: 240, journal: 80, weather: 40 } as const;
|
||||
export const JOURNEY_CARD_LIMITS = {
|
||||
entries: 8,
|
||||
title: 100,
|
||||
place: 80,
|
||||
story: 240,
|
||||
journal: 80,
|
||||
weather: 40,
|
||||
tags: 6,
|
||||
tag: 40,
|
||||
points: 3,
|
||||
point: 80,
|
||||
} as const;
|
||||
|
||||
/** The verdict on a place as a card shows it: the first points on each side, and how many each side holds. */
|
||||
export const journeyEntryVerdictSchema = z.object({
|
||||
pros: z.array(cardText(JOURNEY_CARD_LIMITS.point)).max(JOURNEY_CARD_LIMITS.points),
|
||||
cons: z.array(cardText(JOURNEY_CARD_LIMITS.point)).max(JOURNEY_CARD_LIMITS.points),
|
||||
prosCount: cardCountSchema,
|
||||
consCount: cardCountSchema,
|
||||
});
|
||||
export type JourneyEntryVerdict = z.infer<typeof journeyEntryVerdictSchema>;
|
||||
|
||||
/**
|
||||
* One entry as a card shows it. Its texts are plain and on one line (the story
|
||||
@@ -52,11 +72,16 @@ export const journeyEntryLookSchema = z.object({
|
||||
fromTripPlace: z.boolean(),
|
||||
/** Which day of the journal it falls on, counted from 0, for the day's colour; null when unknown. */
|
||||
dayIndex: z.number().int().min(0).max(9_999).nullable(),
|
||||
/** Its first tags. Optional, like the two below: cards stored before they existed leave them out. */
|
||||
tags: z.array(cardText(JOURNEY_CARD_LIMITS.tag)).max(JOURNEY_CARD_LIMITS.tags).optional(),
|
||||
verdict: journeyEntryVerdictSchema.nullable().optional(),
|
||||
/** In a new order of the journal's entries, this one changed its place. */
|
||||
moved: z.boolean().optional(),
|
||||
});
|
||||
export type JourneyEntryLook = z.infer<typeof journeyEntryLookSchema>;
|
||||
|
||||
/** What a change card's entry goes through: nothing on an answer, else what the change does to it. */
|
||||
export const JOURNEY_CARD_CHANGES = ['none', 'new', 'changed', 'willDelete'] as const;
|
||||
/** What a change card's entries go through: nothing on an answer, else what the change does to them. */
|
||||
export const JOURNEY_CARD_CHANGES = ['none', 'new', 'changed', 'willDelete', 'reordered'] as const;
|
||||
|
||||
export const journeyEntriesCardSchema = z.object({
|
||||
kind: z.literal('journeyEntries'),
|
||||
|
||||
@@ -13,7 +13,7 @@ export * from './journey-header.card';
|
||||
*/
|
||||
export const JOURNEY_CARD_SCHEMAS = [journeyEntriesCardSchema, journeyHeaderCardSchema] as const;
|
||||
/**
|
||||
* An entry written, changed or deleted; a journal started, renamed, given
|
||||
* An entry written, changed or deleted, the entries put in a new order; a journal started, renamed, given
|
||||
* another status or other settings, deleted; a trip linked or unlinked; its
|
||||
* suggestions brought back; the user's own view of it changed.
|
||||
*/
|
||||
@@ -21,6 +21,7 @@ export const JOURNEY_VERB_KEYS = [
|
||||
'addJournalEntry',
|
||||
'updateJournalEntry',
|
||||
'removeJournalEntry',
|
||||
'reorderJournalEntries',
|
||||
'createJournal',
|
||||
'renameJournal',
|
||||
'setJournalStatus',
|
||||
|
||||
@@ -77,5 +77,18 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsCome.two': 'يُضاف {count} مكانان كاقتراحين',
|
||||
'assistant.card.journeyHeader.suggestionsCome.few': 'تُضاف {count} أماكن كاقتراحات',
|
||||
'assistant.card.journeyHeader.suggestionsCome.many': 'يُضاف {count} مكانًا كاقتراحات',
|
||||
'assistant.change.verb.reorderJournalEntries': 'إعادة ترتيب المدخلات',
|
||||
'assistant.card.journey.pros': '{count} نقطة إيجابية',
|
||||
'assistant.card.journey.pros.zero': '{count} نقاط إيجابية',
|
||||
'assistant.card.journey.pros.one': '{count} نقطة إيجابية',
|
||||
'assistant.card.journey.pros.two': '{count} نقطتان إيجابيتان',
|
||||
'assistant.card.journey.pros.few': '{count} نقاط إيجابية',
|
||||
'assistant.card.journey.pros.many': '{count} نقطة إيجابية',
|
||||
'assistant.card.journey.cons': '{count} نقطة سلبية',
|
||||
'assistant.card.journey.cons.zero': '{count} نقاط سلبية',
|
||||
'assistant.card.journey.cons.one': '{count} نقطة سلبية',
|
||||
'assistant.card.journey.cons.two': '{count} نقطتان سلبيتان',
|
||||
'assistant.card.journey.cons.few': '{count} نقاط سلبية',
|
||||
'assistant.card.journey.cons.many': '{count} نقطة سلبية',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -55,5 +55,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': '{count} açıq təklif silinir',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} yer təklif kimi əlavə olunur',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} yer təklif kimi əlavə olunur',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Qeydləri yenidən sırala',
|
||||
'assistant.card.journey.pros': '{count} müsbət cəhət',
|
||||
'assistant.card.journey.pros.one': '{count} müsbət cəhət',
|
||||
'assistant.card.journey.cons': '{count} mənfi cəhət',
|
||||
'assistant.card.journey.cons.one': '{count} mənfi cəhət',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -53,5 +53,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': '{count} sugestão em aberto sai',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} lugares entram como sugestões',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} lugar entra como sugestão',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Reordenar as entradas',
|
||||
'assistant.card.journey.pros': '{count} pontos positivos',
|
||||
'assistant.card.journey.pros.one': '{count} ponto positivo',
|
||||
'assistant.card.journey.cons': '{count} pontos negativos',
|
||||
'assistant.card.journey.cons.one': '{count} ponto negativo',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -53,5 +53,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': "Se'n va {count} suggeriment obert",
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} llocs entren com a suggeriments',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} lloc entra com a suggeriment',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Reordenar les entrades',
|
||||
'assistant.card.journey.pros': '{count} punts forts',
|
||||
'assistant.card.journey.pros.one': '{count} punt fort',
|
||||
'assistant.card.journey.cons': '{count} punts febles',
|
||||
'assistant.card.journey.cons.one': '{count} punt feble',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -59,5 +59,12 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} míst přibude jako návrhy',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} místo přibude jako návrh',
|
||||
'assistant.card.journeyHeader.suggestionsCome.few': '{count} místa přibudou jako návrhy',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Přeuspořádat záznamy',
|
||||
'assistant.card.journey.pros': '{count} plusů',
|
||||
'assistant.card.journey.pros.one': '{count} plus',
|
||||
'assistant.card.journey.pros.few': '{count} plusy',
|
||||
'assistant.card.journey.cons': '{count} minusů',
|
||||
'assistant.card.journey.cons.one': '{count} minus',
|
||||
'assistant.card.journey.cons.few': '{count} minusy',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -55,5 +55,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': '{count} offener Vorschlag fällt weg',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} Orte kommen als Vorschläge dazu',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} Ort kommt als Vorschlag dazu',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Einträge neu ordnen',
|
||||
'assistant.card.journey.pros': '{count} Pluspunkte',
|
||||
'assistant.card.journey.pros.one': '{count} Pluspunkt',
|
||||
'assistant.card.journey.cons': '{count} Minuspunkte',
|
||||
'assistant.card.journey.cons.one': '{count} Minuspunkt',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -53,5 +53,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': '{count} open suggestion goes',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} places come in as suggestions',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} place comes in as a suggestion',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Put the entries in a new order',
|
||||
'assistant.card.journey.pros': '{count} good points',
|
||||
'assistant.card.journey.pros.one': '{count} good point',
|
||||
'assistant.card.journey.cons': '{count} weak points',
|
||||
'assistant.card.journey.cons.one': '{count} weak point',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -54,5 +54,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': 'Se va {count} sugerencia abierta',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} lugares entran como sugerencias',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} lugar entra como sugerencia',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Reordenar las entradas',
|
||||
'assistant.card.journey.pros': '{count} puntos a favor',
|
||||
'assistant.card.journey.pros.one': '{count} punto a favor',
|
||||
'assistant.card.journey.cons': '{count} puntos en contra',
|
||||
'assistant.card.journey.cons.one': '{count} punto en contra',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -54,5 +54,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': '{count} avatud soovitus kaob',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} kohta tuleb soovitustena juurde',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} koht tuleb soovitusena juurde',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Järjesta sissekanded ümber',
|
||||
'assistant.card.journey.pros': '{count} plussi',
|
||||
'assistant.card.journey.pros.one': '{count} pluss',
|
||||
'assistant.card.journey.cons': '{count} miinust',
|
||||
'assistant.card.journey.cons.one': '{count} miinus',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -55,5 +55,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': '{count} suggestion ouverte disparaît',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} lieux arrivent comme suggestions',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} lieu arrive comme suggestion',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Réordonner les entrées',
|
||||
'assistant.card.journey.pros': '{count} points forts',
|
||||
'assistant.card.journey.pros.one': '{count} point fort',
|
||||
'assistant.card.journey.cons': '{count} points faibles',
|
||||
'assistant.card.journey.cons.one': '{count} point faible',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -55,5 +55,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': 'Φεύγει {count} ανοιχτή πρόταση',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} μέρη μπαίνουν ως προτάσεις',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} μέρος μπαίνει ως πρόταση',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Αναδιάταξη καταχωρίσεων',
|
||||
'assistant.card.journey.pros': '{count} θετικά',
|
||||
'assistant.card.journey.pros.one': '{count} θετικό',
|
||||
'assistant.card.journey.cons': '{count} αρνητικά',
|
||||
'assistant.card.journey.cons.one': '{count} αρνητικό',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -55,5 +55,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': '{count} nyitott javaslat eltűnik',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} hely javaslatként érkezik',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} hely javaslatként érkezik',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Bejegyzések átrendezése',
|
||||
'assistant.card.journey.pros': '{count} előny',
|
||||
'assistant.card.journey.pros.one': '{count} előny',
|
||||
'assistant.card.journey.cons': '{count} hátrány',
|
||||
'assistant.card.journey.cons.one': '{count} hátrány',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -48,5 +48,8 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.entriesStay': '{count} entri tertulis tetap ada',
|
||||
'assistant.card.journeyHeader.suggestionsGo': '{count} saran terbuka hilang',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} tempat masuk sebagai saran',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Urutkan ulang entri',
|
||||
'assistant.card.journey.pros': '{count} hal positif',
|
||||
'assistant.card.journey.cons': '{count} hal negatif',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -54,5 +54,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': 'Sparisce {count} suggerimento aperto',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} luoghi arrivano come suggerimenti',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} luogo arriva come suggerimento',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Riordina le voci',
|
||||
'assistant.card.journey.pros': '{count} punti a favore',
|
||||
'assistant.card.journey.pros.one': '{count} punto a favore',
|
||||
'assistant.card.journey.cons': '{count} punti a sfavore',
|
||||
'assistant.card.journey.cons.one': '{count} punto a sfavore',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -47,5 +47,8 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.entriesStay': '書かれたエントリー{count}件は残ります',
|
||||
'assistant.card.journeyHeader.suggestionsGo': '未記入の提案{count}件はなくなります',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count}か所が提案として加わります',
|
||||
'assistant.change.verb.reorderJournalEntries': 'エントリーを並べ替える',
|
||||
'assistant.card.journey.pros': '良い点 {count} 件',
|
||||
'assistant.card.journey.cons': '残念な点 {count} 件',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -47,5 +47,8 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.entriesStay': '작성된 항목 {count}개는 남아요',
|
||||
'assistant.card.journeyHeader.suggestionsGo': '열린 제안 {count}개는 사라져요',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '장소 {count}곳이 제안으로 들어와요',
|
||||
'assistant.change.verb.reorderJournalEntries': '항목 순서 바꾸기',
|
||||
'assistant.card.journey.pros': '좋은 점 {count}개',
|
||||
'assistant.card.journey.cons': '아쉬운 점 {count}개',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -54,5 +54,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': '{count} open suggestie verdwijnt',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} plekken komen erbij als suggestie',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} plek komt erbij als suggestie',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Berichten opnieuw ordenen',
|
||||
'assistant.card.journey.pros': '{count} pluspunten',
|
||||
'assistant.card.journey.pros.one': '{count} pluspunt',
|
||||
'assistant.card.journey.cons': '{count} minpunten',
|
||||
'assistant.card.journey.cons.one': '{count} minpunt',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -66,5 +66,14 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} miejsce dojdzie jako sugestia',
|
||||
'assistant.card.journeyHeader.suggestionsCome.few': '{count} miejsca dojdą jako sugestie',
|
||||
'assistant.card.journeyHeader.suggestionsCome.many': '{count} miejsc dojdzie jako sugestie',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Zmień kolejność wpisów',
|
||||
'assistant.card.journey.pros': '{count} plusa',
|
||||
'assistant.card.journey.pros.one': '{count} plus',
|
||||
'assistant.card.journey.pros.few': '{count} plusy',
|
||||
'assistant.card.journey.pros.many': '{count} plusów',
|
||||
'assistant.card.journey.cons': '{count} minusa',
|
||||
'assistant.card.journey.cons.one': '{count} minus',
|
||||
'assistant.card.journey.cons.few': '{count} minusy',
|
||||
'assistant.card.journey.cons.many': '{count} minusów',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -66,5 +66,14 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} место появится как предложение',
|
||||
'assistant.card.journeyHeader.suggestionsCome.few': '{count} места появятся как предложения',
|
||||
'assistant.card.journeyHeader.suggestionsCome.many': '{count} мест появятся как предложения',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Изменить порядок записей',
|
||||
'assistant.card.journey.pros': '{count} плюса',
|
||||
'assistant.card.journey.pros.one': '{count} плюс',
|
||||
'assistant.card.journey.pros.few': '{count} плюса',
|
||||
'assistant.card.journey.pros.many': '{count} плюсов',
|
||||
'assistant.card.journey.cons': '{count} минуса',
|
||||
'assistant.card.journey.cons.one': '{count} минус',
|
||||
'assistant.card.journey.cons.few': '{count} минуса',
|
||||
'assistant.card.journey.cons.many': '{count} минусов',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -59,5 +59,12 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} miest pribudne ako návrhy',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} miesto pribudne ako návrh',
|
||||
'assistant.card.journeyHeader.suggestionsCome.few': '{count} miesta pribudnú ako návrhy',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Preusporiadať záznamy',
|
||||
'assistant.card.journey.pros': '{count} plusov',
|
||||
'assistant.card.journey.pros.one': '{count} plus',
|
||||
'assistant.card.journey.pros.few': '{count} plusy',
|
||||
'assistant.card.journey.cons': '{count} mínusov',
|
||||
'assistant.card.journey.cons.one': '{count} mínus',
|
||||
'assistant.card.journey.cons.few': '{count} mínusy',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -53,5 +53,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': '{count} öppet förslag försvinner',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} platser kommer in som förslag',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} plats kommer in som förslag',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Ordna om inläggen',
|
||||
'assistant.card.journey.pros': '{count} fördelar',
|
||||
'assistant.card.journey.pros.one': '{count} fördel',
|
||||
'assistant.card.journey.cons': '{count} nackdelar',
|
||||
'assistant.card.journey.cons.one': '{count} nackdel',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -47,5 +47,8 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.entriesStay': 'รายการที่เขียนแล้ว {count} รายการยังอยู่',
|
||||
'assistant.card.journeyHeader.suggestionsGo': 'คำแนะนำที่ยังไม่เขียน {count} รายการจะหายไป',
|
||||
'assistant.card.journeyHeader.suggestionsCome': 'สถานที่ {count} แห่งจะเข้ามาเป็นคำแนะนำ',
|
||||
'assistant.change.verb.reorderJournalEntries': 'จัดลำดับรายการใหม่',
|
||||
'assistant.card.journey.pros': 'ข้อดี {count} ข้อ',
|
||||
'assistant.card.journey.cons': 'ข้อเสีย {count} ข้อ',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -54,5 +54,10 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsGo.one': '{count} açık öneri kalkar',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} yer öneri olarak eklenir',
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} yer öneri olarak eklenir',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Kayıtları yeniden sırala',
|
||||
'assistant.card.journey.pros': '{count} artı',
|
||||
'assistant.card.journey.pros.one': '{count} artı',
|
||||
'assistant.card.journey.cons': '{count} eksi',
|
||||
'assistant.card.journey.cons.one': '{count} eksi',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -66,5 +66,14 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.suggestionsCome.one': '{count} місце з’явиться як пропозиція',
|
||||
'assistant.card.journeyHeader.suggestionsCome.few': '{count} місця з’являться як пропозиції',
|
||||
'assistant.card.journeyHeader.suggestionsCome.many': '{count} місць з’являться як пропозиції',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Змінити порядок записів',
|
||||
'assistant.card.journey.pros': '{count} плюса',
|
||||
'assistant.card.journey.pros.one': '{count} плюс',
|
||||
'assistant.card.journey.pros.few': '{count} плюси',
|
||||
'assistant.card.journey.pros.many': '{count} плюсів',
|
||||
'assistant.card.journey.cons': '{count} мінуса',
|
||||
'assistant.card.journey.cons.one': '{count} мінус',
|
||||
'assistant.card.journey.cons.few': '{count} мінуси',
|
||||
'assistant.card.journey.cons.many': '{count} мінусів',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -48,5 +48,8 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.entriesStay': '{count} mục đã viết vẫn được giữ',
|
||||
'assistant.card.journeyHeader.suggestionsGo': '{count} gợi ý chưa viết sẽ bị bỏ',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} địa điểm được thêm vào làm gợi ý',
|
||||
'assistant.change.verb.reorderJournalEntries': 'Sắp xếp lại các mục',
|
||||
'assistant.card.journey.pros': '{count} điểm cộng',
|
||||
'assistant.card.journey.cons': '{count} điểm trừ',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -47,5 +47,8 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.entriesStay': '已寫的 {count} 則條目會保留',
|
||||
'assistant.card.journeyHeader.suggestionsGo': '{count} 則未寫的建議會被移除',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} 個地點會作為建議加入',
|
||||
'assistant.change.verb.reorderJournalEntries': '重新排列條目',
|
||||
'assistant.card.journey.pros': '{count} 個優點',
|
||||
'assistant.card.journey.cons': '{count} 個不足',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -47,5 +47,8 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journeyHeader.entriesStay': '已写的 {count} 条条目会保留',
|
||||
'assistant.card.journeyHeader.suggestionsGo': '{count} 条未写的建议会被移除',
|
||||
'assistant.card.journeyHeader.suggestionsCome': '{count} 个地点会作为建议加入',
|
||||
'assistant.change.verb.reorderJournalEntries': '重新排列条目',
|
||||
'assistant.card.journey.pros': '{count} 个优点',
|
||||
'assistant.card.journey.cons': '{count} 个不足',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
Reference in New Issue
Block a user