From 48ff1b3a98646ce02ad7dae44c17a34a0b50b6bf Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 24 Oct 2026 21:36:20 +0200 Subject: [PATCH] feat(assistant): curated cards and undo for the changes of a journal itself Starting a journal, changing its settings, linking or unlinking a trip, bringing back its suggestions, the user's own view of it and deleting it fell back to the generic card, which names ids and offers no way back. They now share a journeyHeader card: the journal with its trips, the one the change links or unlinks marked, what a new journal brings in and what a deletion or an unlink takes, and each setting old to new in the journal's own words. A new journal is deleted again while nothing was written, added or laid out in it, changed settings are written back from what the tool answered, a linked trip is unlinked by its owner while none of its suggestions was written up, and the own switch flips back. --- .../cards/journey/JourneyHeaderCard.test.tsx | 143 +++++++ .../cards/journey/JourneyHeaderSlots.tsx | 148 ++++++++ .../cards/journey/journeyHeaderModel.ts | 82 ++++ .../Assistant/cards/journey/journeyLook.ts | 40 +- .../Assistant/cards/journey/journeyViews.tsx | 10 +- .../src/nest/assistant/cards/journey/index.ts | 54 ++- .../cards/journey/journey-header-undo.ts | 117 ++++++ .../assistant/cards/journey/journey-header.ts | 327 ++++++++++++++++ .../cards/journey/journey-header.test.ts | 350 ++++++++++++++++++ .../cards/journey-header.card.spec.ts | 39 ++ .../assistant/cards/journey-header.card.ts | 47 +++ .../src/assistant/cards/journey.contract.ts | 29 +- shared/src/i18n/ar/assistantJourney.ts | 36 ++ shared/src/i18n/az/assistantJourney.ts | 25 ++ shared/src/i18n/br/assistantJourney.ts | 24 ++ shared/src/i18n/ca/assistantJourney.ts | 24 ++ shared/src/i18n/cs/assistantJourney.ts | 27 ++ shared/src/i18n/de/assistantJourney.ts | 25 ++ shared/src/i18n/en/assistantJourney.ts | 24 ++ shared/src/i18n/es/assistantJourney.ts | 24 ++ shared/src/i18n/et/assistantJourney.ts | 24 ++ shared/src/i18n/fr/assistantJourney.ts | 25 ++ shared/src/i18n/gr/assistantJourney.ts | 25 ++ shared/src/i18n/hu/assistantJourney.ts | 25 ++ shared/src/i18n/id/assistantJourney.ts | 21 ++ shared/src/i18n/it/assistantJourney.ts | 24 ++ shared/src/i18n/ja/assistantJourney.ts | 21 ++ shared/src/i18n/ko/assistantJourney.ts | 21 ++ shared/src/i18n/nl/assistantJourney.ts | 25 ++ shared/src/i18n/pl/assistantJourney.ts | 30 ++ shared/src/i18n/ru/assistantJourney.ts | 30 ++ shared/src/i18n/sk/assistantJourney.ts | 27 ++ shared/src/i18n/sv/assistantJourney.ts | 24 ++ shared/src/i18n/th/assistantJourney.ts | 21 ++ shared/src/i18n/tr/assistantJourney.ts | 24 ++ shared/src/i18n/uk/assistantJourney.ts | 30 ++ shared/src/i18n/vi/assistantJourney.ts | 21 ++ shared/src/i18n/zh-TW/assistantJourney.ts | 21 ++ shared/src/i18n/zh/assistantJourney.ts | 21 ++ 39 files changed, 2044 insertions(+), 11 deletions(-) create mode 100644 client/src/components/Assistant/cards/journey/JourneyHeaderCard.test.tsx create mode 100644 client/src/components/Assistant/cards/journey/JourneyHeaderSlots.tsx create mode 100644 client/src/components/Assistant/cards/journey/journeyHeaderModel.ts create mode 100644 server/src/nest/assistant/cards/journey/journey-header-undo.ts create mode 100644 server/src/nest/assistant/cards/journey/journey-header.ts create mode 100644 server/tests/unit/nest/assistant/cards/journey/journey-header.test.ts create mode 100644 shared/src/assistant/cards/journey-header.card.spec.ts create mode 100644 shared/src/assistant/cards/journey-header.card.ts diff --git a/client/src/components/Assistant/cards/journey/JourneyHeaderCard.test.tsx b/client/src/components/Assistant/cards/journey/JourneyHeaderCard.test.tsx new file mode 100644 index 000000000..e5dc28009 --- /dev/null +++ b/client/src/components/Assistant/cards/journey/JourneyHeaderCard.test.tsx @@ -0,0 +1,143 @@ +/** + * FE-AI-JOURNEYHEADER-001 to FE-AI-JOURNEYHEADER-005 + * + * A change of a journal itself in the chat: the settings an update sets by + * the journal's own names, old to new; the trips under the row with the one + * the change links or unlinks marked; what a new journal brings and what a + * deletion or an unlink takes; the receipt that opens the journal. + */ +import type { AssistantApplied, AssistantChangePreview, JourneyHeaderCard } from '@trek/shared'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { fireEvent, render, screen } from '../../../../../tests/helpers/render'; +import { useAssistantStore } from '../../../../store/assistantStore'; +import AssistantChangeCard from '../../AssistantChangeCard'; +import type { ChangeItem } from '../../assistantParts'; +import { AssistantViewContext } from '../../assistantViewContext'; +import { headerValueKey, settingRows } from './journeyHeaderModel'; + +const ASSISTANT = useAssistantStore.getState(); + +afterEach(() => { + useAssistantStore.setState(ASSISTANT, true); +}); + +const JAPAN: JourneyHeaderCard = { + kind: 'journeyHeader', + journeyId: 12, + title: 'Japan 2026', + subtitle: null, + change: 'changed', + trips: [{ id: 5, title: 'Tokyo days', startDate: '2026-11-02', endDate: '2026-11-08', change: 'kept' }], + entries: 3, + photos: 40, + suggestions: null, +}; + +function change( + state: ChangeItem['state'], + card: JourneyHeaderCard, + over: Partial = {} +): ChangeItem { + const preview: AssistantChangePreview = { + toolCallId: 'h1', + verbKey: 'updateJournalSettings', + subject: 'Japan 2026', + rows: [], + dayId: null, + placeIds: [], + undoable: true, + domain: 'journey', + card, + ...over, + }; + const applied: AssistantApplied = { toolCallId: 'h1', entity: 'journey', ids: [12], dayId: null, undoUntil: null }; + return { toolCallId: 'h1', preview, applied: state === 'applied' ? applied : null, state, card }; +} + +function shown(item: ChangeItem, onOpen?: (target: unknown) => void) { + return render( + + + + ); +} + +describe('a change of a journal itself', () => { + it('FE-AI-JOURNEYHEADER-001: an update names each setting by the journal own words, old to new', () => { + const rows: AssistantChangePreview['rows'] = [ + { op: 'change', labelKey: 'name', before: 'Japan', after: 'Japan 2026' }, + { op: 'change', labelKey: 'field', field: 'show_mood', before: '✓', after: '✗' }, + { op: 'change', labelKey: 'field', field: 'status_override', before: '', after: 'live' }, + ]; + shown(change('pending', JAPAN, { rows })); + expect(screen.getByText("Change the journal's settings")).toBeInTheDocument(); + expect(screen.getByText('Japan')).toHaveClass('line-through'); + expect(screen.getByText('Mood: On')).toHaveClass('line-through'); + expect(screen.getByText('Mood: Off')).toBeInTheDocument(); + expect(screen.getByText('Shown as: Automatic')).toHaveClass('line-through'); + expect(screen.getByText('Shown as: Live')).toBeInTheDocument(); + expect(screen.getByText('Tokyo days')).toBeInTheDocument(); + }); + + it('FE-AI-JOURNEYHEADER-002: a new journal shows its subtitle, the trips it links and the places that come in', () => { + const fresh: JourneyHeaderCard = { + ...JAPAN, + journeyId: null, + change: 'new', + subtitle: 'Autumn', + trips: [{ ...JAPAN.trips[0], change: 'link' }], + entries: null, + photos: null, + suggestions: 9, + }; + shown(change('pending', fresh, { verbKey: 'createJournal', undoable: true })); + expect(screen.getByText('Start a journal')).toBeInTheDocument(); + expect(screen.getByText('Autumn')).toBeInTheDocument(); + expect(screen.getByText('Will be added')).toBeInTheDocument(); + expect(screen.getByText('9 places come in as suggestions')).toBeInTheDocument(); + }); + + it('FE-AI-JOURNEYHEADER-003: a deletion warns that everything goes; an unlink says what stays and what goes', () => { + const first = shown(change('pending', { ...JAPAN, change: 'willDelete' }, { verbKey: 'deleteJournal' })); + expect(screen.getByText('Delete the journal')).toBeInTheDocument(); + expect(screen.getByText(/This cannot be undone\./)).toBeInTheDocument(); + first.unmount(); + const unlink: JourneyHeaderCard = { + ...JAPAN, + trips: [{ ...JAPAN.trips[0], change: 'unlink' }], + entries: 1, + suggestions: 4, + }; + shown(change('pending', unlink, { verbKey: 'unlinkJournalTrip' })); + expect(screen.getByText('Tokyo days')).toHaveClass('line-through'); + expect(screen.getByText('Will be taken out')).toBeInTheDocument(); + expect(screen.getByText('1 written entry stays')).toBeInTheDocument(); + expect(screen.getByText('4 open suggestions go')).toBeInTheDocument(); + }); + + it('FE-AI-JOURNEYHEADER-004: the receipt opens the journal, a change that failed shows its row alone', () => { + const onOpen = vi.fn(); + const applied = shown(change('applied', JAPAN), onOpen); + fireEvent.click(screen.getByRole('button', { name: 'Open the journal' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/12' }); + applied.unmount(); + shown(change('failed', JAPAN)); + expect(screen.queryByText('Tokyo days')).toBeNull(); + }); + + it('FE-AI-JOURNEYHEADER-005: the model reads a status by its name and a switch as on or off', () => { + expect(headerValueKey('status', 'archived')).toBe('journey.status.archived'); + expect(headerValueKey('status_override', null)).toBe('journey.settings.statusAuto'); + expect(headerValueKey('show_mood', '✓')).toBe('settings.on'); + expect(headerValueKey('cover_gradient', 'linear-gradient(red, blue)')).toBeNull(); + expect(headerValueKey('status', 'toString')).toBeNull(); + const rows: AssistantChangePreview['rows'] = [ + { op: 'add', labelKey: 'field', field: 'trip', before: null, after: 'Kyoto' }, + { op: 'add', labelKey: 'field', field: 'show_weather', before: null, after: '✓' }, + { op: 'add', labelKey: 'field', field: 'unknown', before: null, after: 'x' }, + { op: 'change', labelKey: 'name', before: 'A', after: 'B' }, + ]; + expect(settingRows(rows).map((row) => row.field)).toEqual(['show_weather']); + }); +}); diff --git a/client/src/components/Assistant/cards/journey/JourneyHeaderSlots.tsx b/client/src/components/Assistant/cards/journey/JourneyHeaderSlots.tsx new file mode 100644 index 000000000..1910e324b --- /dev/null +++ b/client/src/components/Assistant/cards/journey/JourneyHeaderSlots.tsx @@ -0,0 +1,148 @@ +import type { AssistantChangeRow, JourneyHeaderCard, JourneyHeaderTrip } from '@trek/shared'; +import { AlignLeft, Luggage, Type } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { type PillTone, SoftPill } from '../../../Planner/planParts'; +import type { ChangeItem } from '../../assistantParts'; +import { FactChange } from '../parts/CardFacts'; +import { useCardFormat } from '../parts/useCardFormat'; +import { headerFieldOf, headerValueKey, settingRows } from './journeyHeaderModel'; + +/** + * What a change row of a journal itself adds to the row it already is (its + * verb and the journal's name): on the faint line the name, subtitle and + * settings it changes, each old to new; under the row the journal's trips, + * the one the change links or unlinks marked, and what the change brings or + * takes along. A change that failed, was discarded or ran out shows the row + * alone. + */ + +const GONE: ReadonlySet = new Set(['failed', 'discarded', 'expired']); +const WORD_ICONS = { name: Type, description: AlignLeft } as const; +const NOTE = 'm-0 mt-1.5 text-caption'; + +const TRIP_TONE: Readonly> = { + kept: 'neutral', + link: 'success', + unlink: 'danger', +}; + +/** One setting a change sets, by its name, old to new. */ +function SettingChange({ row }: { row: AssistantChangeRow }) { + const { t } = useTranslation(); + const look = headerFieldOf(row.field); + if (!look) return null; + const named = (value: string | null) => { + const key = headerValueKey(row.field, value); + if (key) return t(key); + return value || null; + }; + const before = row.op === 'add' ? null : named(row.before); + const after = named(row.after) ?? t('assistant.card.common.removed'); + const name = t(look.labelKey); + return ( + + ); +} + +/** The journal's new name and subtitle and the settings a change sets. */ +export function JourneyHeaderFacts({ item }: { card: JourneyHeaderCard; item: ChangeItem }) { + const { t } = useTranslation(); + const rows = item.preview?.rows ?? []; + const words = rows.filter( + (row): row is AssistantChangeRow & { labelKey: keyof typeof WORD_ICONS } => + row.op === 'change' && row.labelKey in WORD_ICONS + ); + return ( + <> + {words.map((row) => ( + + ))} + {settingRows(rows).map((row) => ( + + ))} + + ); +} + +function TripChip({ trip }: { trip: JourneyHeaderTrip }) { + const { t } = useTranslation(); + const format = useCardFormat(); + const days = trip.startDate ? format.date(trip.startDate) : null; + return ( + + ); +} + +/** What the change brings along or takes with it, in the journal's words. */ +function HeaderNotes({ card }: { card: JourneyHeaderCard }) { + const { t } = useTranslation(); + const unlinking = card.trips.some((trip) => trip.change === 'unlink'); + if (card.change === 'willDelete') { + return

{t('assistant.card.journeyHeader.deletes')}

; + } + if (unlinking) { + return ( + <> + {card.entries !== null && card.entries > 0 && ( +

+ {t('assistant.card.journeyHeader.entriesStay', { count: card.entries })} +

+ )} + {card.suggestions !== null && card.suggestions > 0 && ( +

+ {t('assistant.card.journeyHeader.suggestionsGo', { count: card.suggestions })} +

+ )} + + ); + } + if (card.suggestions === null || card.suggestions === 0) return null; + return ( +

+ {t('assistant.card.journeyHeader.suggestionsCome', { count: card.suggestions })} +

+ ); +} + +/** The journal's trips and what the change brings or takes, under the row. */ +export function JourneyHeaderBody({ card, item }: { card: JourneyHeaderCard; item: ChangeItem }) { + if (GONE.has(item.state)) return null; + return ( +
+ {card.subtitle && card.change === 'new' && ( +

+ {card.subtitle} +

+ )} + {card.trips.length > 0 && ( +
+ {card.trips.map((trip, index) => ( + + ))} +
+ )} + +
+ ); +} diff --git a/client/src/components/Assistant/cards/journey/journeyHeaderModel.ts b/client/src/components/Assistant/cards/journey/journeyHeaderModel.ts new file mode 100644 index 000000000..0a8f96e06 --- /dev/null +++ b/client/src/components/Assistant/cards/journey/journeyHeaderModel.ts @@ -0,0 +1,82 @@ +import type { AssistantChangeRow } from '@trek/shared'; +import { + CircleDot, + CloudSun, + Eye, + EyeOff, + Footprints, + Luggage, + MapPin, + Palette, + Route, + Smile, + Sparkles, + ThumbsUp, + type LucideIcon, +} from 'lucide-react'; + +/** + * The pure side of a change of a journal itself: the glyph and name of each + * setting a change row names (under the tool's own field names), and how its + * value reads, a status by its name, a switch as on or off, a cleared status + * override as following the trip dates. The names are the journal's own, + * as its settings dialog writes them. + */ + +interface FieldLook { + Icon: LucideIcon; + labelKey: string; +} + +const FIELDS: Readonly> = { + status: { Icon: CircleDot, labelKey: 'journey.settings.status' }, + status_override: { Icon: Eye, labelKey: 'assistant.card.journeyHeader.field.statusOverride' }, + show_verdict: { Icon: ThumbsUp, labelKey: 'journey.settings.showVerdict' }, + show_mood: { Icon: Smile, labelKey: 'journey.settings.showMood' }, + show_weather: { Icon: CloudSun, labelKey: 'journey.settings.showWeather' }, + photo_location: { Icon: MapPin, labelKey: 'journey.settings.photoLocation' }, + show_trip_tracks: { Icon: Route, labelKey: 'journey.settings.showTripTracks' }, + auto_prepare_recordings: { Icon: Footprints, labelKey: 'journey.settings.autoPrepare' }, + cover_gradient: { Icon: Palette, labelKey: 'assistant.card.journeyHeader.field.coverGradient' }, + trip: { Icon: Luggage, labelKey: 'assistant.card.journeyHeader.field.trip' }, + suggestions: { Icon: Sparkles, labelKey: 'assistant.card.journeyHeader.field.suggestions' }, + hide_skeletons: { Icon: EyeOff, labelKey: 'journey.skeletons.hide' }, +}; + +const STATUSES: Readonly> = { + draft: 'journey.status.draft', + active: 'journey.status.active', + completed: 'journey.status.completed', + archived: 'journey.status.archived', + live: 'journey.frontpage.live', +}; + +const FLAG_ON = '✓'; +const FLAG_OFF = '✗'; + +function own(table: Readonly>, key: string | null | undefined): T | null { + if (!key || !Object.prototype.hasOwnProperty.call(table, key)) return null; + return table[key] ?? null; +} + +/** The look of a setting a row names; null for a field the card has no name for. */ +export function headerFieldOf(field: string | undefined): FieldLook | null { + return own(FIELDS, field); +} + +/** + * The key a value of a setting reads by, or null for one that reads as it is + * (a name, a count). A cleared status override follows the trip dates again. + */ +export function headerValueKey(field: string | undefined, value: string | null): string | null { + if (value === FLAG_ON) return 'settings.on'; + if (value === FLAG_OFF) return 'settings.off'; + if (field === 'status_override' && !value) return 'journey.settings.statusAuto'; + if (field === 'status' || field === 'status_override') return own(STATUSES, value); + return null; +} + +/** The setting rows of a change, the trips aside: the card draws those as its own chips. */ +export function settingRows(rows: readonly AssistantChangeRow[]): AssistantChangeRow[] { + return rows.filter((row) => row.labelKey === 'field' && row.field !== 'trip' && headerFieldOf(row.field) !== null); +} diff --git a/client/src/components/Assistant/cards/journey/journeyLook.ts b/client/src/components/Assistant/cards/journey/journeyLook.ts index 4337dbe00..2e1b3792d 100644 --- a/client/src/components/Assistant/cards/journey/journeyLook.ts +++ b/client/src/components/Assistant/cards/journey/journeyLook.ts @@ -1,4 +1,16 @@ -import { BookOpen, BookOpenText, BookPlus, BookX } from 'lucide-react'; +import { + BookCheck, + BookOpen, + BookOpenText, + BookPlus, + BookX, + Eye, + Link, + RotateCcw, + Settings2, + Type, + Unlink, +} from 'lucide-react'; import type { PackLook } from '../cardTypes'; import { journalTarget } from './journeyCardModel'; @@ -17,6 +29,19 @@ 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' }, + 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' }, + updateJournalSettings: { Icon: Settings2, tone: 'info', labelKey: 'assistant.change.verb.updateJournalSettings' }, + deleteJournal: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.deleteJournal' }, + linkJournalTrip: { Icon: Link, tone: 'success', labelKey: 'assistant.change.verb.linkJournalTrip' }, + unlinkJournalTrip: { Icon: Unlink, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalTrip' }, + restoreJournalSuggestions: { + Icon: RotateCcw, + tone: 'info', + labelKey: 'assistant.change.verb.restoreJournalSuggestions', + }, + setJournalPreferences: { Icon: Eye, tone: 'info', labelKey: 'assistant.change.verb.setJournalPreferences' }, }, tools: { create_journey: { labelKey: 'assistant.tool.create_journey' }, @@ -40,6 +65,14 @@ export const JOURNEY_LOOK = { ? journalTarget(card.journeyId, applied.ids[0] ?? card.entries[0]?.entryId ?? null, card.tripJournal) : null, }, + journey: { + Icon: BookOpen, + openLabelKey: 'assistant.card.journeyHeader.open', + open: (applied, card) => { + const journeyId = applied.ids[0] ?? (card?.kind === 'journeyHeader' ? card.journeyId : null); + return journeyId ? journalTarget(journeyId, null, false) : null; + }, + }, }, cards: { journeyEntries: { @@ -59,5 +92,8 @@ export const JOURNEY_LOOK = { return readIds.length > 0 && readIds.every((id) => id !== null && touched.has(id)); }, }, + journeyHeader: { + entity: () => 'journey', + }, }, -} satisfies PackLook<'journeyEntries'>; +} satisfies PackLook<'journeyEntries' | 'journeyHeader'>; diff --git a/client/src/components/Assistant/cards/journey/journeyViews.tsx b/client/src/components/Assistant/cards/journey/journeyViews.tsx index 2466c2ab9..993cf931a 100644 --- a/client/src/components/Assistant/cards/journey/journeyViews.tsx +++ b/client/src/components/Assistant/cards/journey/journeyViews.tsx @@ -1,12 +1,14 @@ import type { CardView } from '../cardTypes'; import { JourneyEntryAside, JourneyEntryBody, JourneyEntryFacts } from './JourneyChangeSlots'; import JourneyEntriesBlock from './JourneyEntriesBlock'; +import { JourneyHeaderBody, JourneyHeaderFacts } from './JourneyHeaderSlots'; /** * How the kinds of the journeys draw in the chat: the entries a read found * as a block of their own, and a change row of an entry with its photo beside * the subject, its day, time and place and whatever an update changes on the - * faint line, and the start of its text under it. This file only maps each + * faint line, and the start of its text under it; a change of the journal + * itself with its settings on the faint line and its trips under it. This file only maps each * kind to its view: the components live in sibling files, since a file that * exports anything besides components breaks fast refresh, and lint:warnings holds * react-refresh/only-export-components at zero for a new file. @@ -18,4 +20,8 @@ export const JOURNEY_VIEWS = { Facts: JourneyEntryFacts, Body: JourneyEntryBody, }, -} satisfies { [K in 'journeyEntries']: CardView }; + journeyHeader: { + Facts: JourneyHeaderFacts, + Body: JourneyHeaderBody, + }, +} satisfies { [K in 'journeyEntries' | 'journeyHeader']: CardView }; diff --git a/server/src/nest/assistant/cards/journey/index.ts b/server/src/nest/assistant/cards/journey/index.ts index aa833e400..929fde173 100644 --- a/server/src/nest/assistant/cards/journey/index.ts +++ b/server/src/nest/assistant/cards/journey/index.ts @@ -7,16 +7,32 @@ import { loadJournal, type JournalLoaded, } from './journey-change'; +import { + JOURNAL_HEADER_DESCRIBERS, + journalHeaderApplied, + journalHeaderCard, + loadJournalHeader, + type JournalHeaderLoaded, +} from './journey-header'; +import { + DELETE_CREATED_JOURNEY, + FLIP_JOURNEY_PREFERENCE, + RESTORE_JOURNEY_SETTINGS, + UNLINK_LINKED_TRIP, +} from './journey-header-undo'; import { readCard } from './journey-look'; import { DELETE_CREATED_ENTRY } from './journey-undo'; -import type { JourneyEntriesCard } from '@trek/shared'; +import type { AssistantChangeVerbKey, JourneyEntriesCard } from '@trek/shared'; /** * The travel journal in the assistant chat. A read of a journal's entries * shows them as a card beside the answer; a held write of an entry shows the * entry it will write, change or delete, and once applied the entry as it * was stored, its photo and country included. A created entry can be taken - * back while nothing was written to it since. + * back while nothing was written to it since. A change of a journal itself + * (a new one, its settings, its trips, its suggestions, the user's own view + * of it, its deletion) shows the journal with its trips (journey-header.ts), + * and most of them can be taken back (journey-header-undo.ts). */ /** The entry a create or a change answered with, else the one a delete named. */ @@ -46,6 +62,29 @@ function journalWrite(tool: string): ToolCards { }; } +/** The journal a change of the journal itself touched: the one its answer names, else the one it was called on. */ +function journalIds(args: ToolArgs, result: unknown): number[] { + const id = idIn(result, 'journey') ?? argId(args, 'journeyId'); + return id === null ? [] : [id]; +} + +/** A change of a journal itself: its verb, its words, its lookups, its card while held and once applied. */ +function journalHeader( + tool: keyof typeof JOURNAL_HEADER_DESCRIBERS, + verbKey: AssistantChangeVerbKey, + undo?: NonNullable, +): ToolCards { + return { + write: { verbKey, entity: 'journey' }, + describe: JOURNAL_HEADER_DESCRIBERS[tool], + load: (args, read) => loadJournalHeader(tool, args, read), + change: (_ctx, args, loaded) => journalHeaderCard(tool, args, loaded), + applied: ({ result, preview }) => journalHeaderApplied(tool, result, preview), + appliedIds: journalIds, + ...(undo ? { undo } : {}), + }; +} + export const JOURNEY_CARDS: DomainCards = { id: 'journey', tools: { @@ -76,6 +115,17 @@ export const JOURNEY_CARDS: DomainCards = { describe: JOURNEY_DESCRIBERS.delete_journey_entry, ...journalWrite('delete_journey_entry'), }, + create_journey: journalHeader('create_journey', 'createJournal', DELETE_CREATED_JOURNEY), + update_journey: journalHeader('update_journey', 'updateJournalSettings', RESTORE_JOURNEY_SETTINGS), + delete_journey: journalHeader('delete_journey', 'deleteJournal'), + add_journey_trip: journalHeader('add_journey_trip', 'linkJournalTrip', UNLINK_LINKED_TRIP), + remove_journey_trip: journalHeader('remove_journey_trip', 'unlinkJournalTrip'), + restore_journey_suggestions: journalHeader('restore_journey_suggestions', 'restoreJournalSuggestions'), + update_journey_preferences: journalHeader( + 'update_journey_preferences', + 'setJournalPreferences', + FLIP_JOURNEY_PREFERENCE, + ), }, prompt: [ 'Journal: get_trip_journal finds the journal this trip shows. To show entries, call list_journey_entries with from/to for the days asked: the app draws them as cards, so do not list them again in your answer.', diff --git a/server/src/nest/assistant/cards/journey/journey-header-undo.ts b/server/src/nest/assistant/cards/journey/journey-header-undo.ts new file mode 100644 index 000000000..08690dd9a --- /dev/null +++ b/server/src/nest/assistant/cards/journey/journey-header-undo.ts @@ -0,0 +1,117 @@ +import type { ToolArgs, ToolCards, UndoSnapshot } from '../card-types'; +import { argId, idIn, readKey, recordOf, type Row } from '../card-values'; +import { rowsOf } from '../undo-snapshot'; +import { journeyArgs } from './journey-header'; + +/** + * The ways back from a change of a journal itself, each checked against the + * journal as `get_journey` reads it now: + * - a new journal is deleted while nothing was written, added or laid out in + * it since (its trips' suggestions are what it was made with); + * - changed settings are written back as they were, while the journal was not + * changed again since; + * - a linked trip is unlinked again, by its owner and while none of the + * trip's suggestions was written up since; + * - the user's own switch is flipped back while it still stands as set. + */ + +type Undo = NonNullable; + +/** The settings `update_journey` takes no null for. */ +const NEVER_NULL: ReadonlySet = new Set(['title', 'status']); + +function journeyRead(args: ToolArgs) { + return { tool: 'get_journey', args }; +} + +/** The journal a snapshot read, as `get_journey` answered. */ +function journeyIn(snapshot: UndoSnapshot, args: ToolArgs): Row { + const read = journeyRead(args); + return recordOf(recordOf(snapshot.reads.get(readKey(read.tool, read.args))).journey); +} + +/** The new journal by the id its answer names; the reads may not depend on it at boot, so they take it as given. */ +function createdArgs(result: unknown): ToolArgs { + const journeyId = idIn(result, 'journey'); + return journeyId === null ? {} : { journeyId }; +} + +export const DELETE_CREATED_JOURNEY: Undo = { + tool: 'delete_journey', + args: (_args, result) => { + const journeyId = idIn(result, 'journey'); + return journeyId === null ? null : { journeyId }; + }, + reads: (_args, result) => [journeyRead(createdArgs(result)), { tool: 'get_book', args: createdArgs(result) }], + unchanged: (result, snapshot) => { + const created = recordOf(recordOf(result).journey); + const args = createdArgs(result); + if (args.journeyId === undefined) return false; + const current = journeyIn(snapshot, args); + const book = recordOf(snapshot.reads.get(readKey('get_book', args))); + return ( + current.id === created.id && + current.updated_at !== undefined && + current.updated_at === created.updated_at && + rowsOf(current.entries).every((entry) => entry.type === 'skeleton') && + rowsOf(current.gallery).length === 0 && + rowsOf(current.contributors).length <= 1 && + book.exists === false + ); + }, +}; + +export const RESTORE_JOURNEY_SETTINGS: Undo = { + tool: 'update_journey', + args: (_args, result) => { + const journeyId = idIn(result, 'journey'); + // The name and the status take no null; one stored empty has nothing to write back. + const previous = Object.fromEntries( + Object.entries(recordOf(recordOf(result).previous)).filter( + ([key, value]) => value !== null || !NEVER_NULL.has(key), + ), + ); + return journeyId === null || Object.keys(previous).length === 0 ? null : { journeyId, ...previous }; + }, + reads: (_args, result) => [journeyRead(createdArgs(result))], + unchanged: (result, snapshot) => { + const changed = recordOf(recordOf(result).journey); + const current = journeyIn(snapshot, createdArgs(result)); + return current.id === changed.id && current.updated_at !== undefined && current.updated_at === changed.updated_at; + }, +}; + +export const UNLINK_LINKED_TRIP: Undo = { + tool: 'remove_journey_trip', + args: (args, result, preview) => { + const tripId = argId(args, 'tripId'); + if (preview?.undoable === false || tripId === null || recordOf(result).success !== true) return null; + return { ...journeyArgs(args), tripId }; + }, + reads: (args) => [journeyRead(journeyArgs(args))], + unchanged: (_result, snapshot, call) => { + const tripId = argId(call?.args ?? {}, 'tripId'); + if (tripId === null) return false; + const current = journeyIn(snapshot, journeyArgs(call?.args ?? {})); + const stillLinked = rowsOf(current.trips).some((trip) => trip.trip_id === tripId); + const written = rowsOf(current.entries).some( + (entry) => entry.source_trip_id === tripId && entry.type !== 'skeleton', + ); + return stillLinked && !written; + }, +}; + +export const FLIP_JOURNEY_PREFERENCE: Undo = { + tool: 'update_journey_preferences', + args: (args, result, preview) => { + if (preview?.undoable === false || !('hide_skeletons' in args) || !('hide_skeletons' in recordOf(result))) { + return null; + } + return { ...journeyArgs(args), hide_skeletons: !args.hide_skeletons }; + }, + reads: (args) => [journeyRead(journeyArgs(args))], + unchanged: (result, snapshot, call) => { + const current = journeyIn(snapshot, journeyArgs(call?.args ?? {})); + return 'hide_skeletons' in current && !!current.hide_skeletons === !!recordOf(result).hide_skeletons; + }, +}; diff --git a/server/src/nest/assistant/cards/journey/journey-header.ts b/server/src/nest/assistant/cards/journey/journey-header.ts new file mode 100644 index 000000000..c6c82f778 --- /dev/null +++ b/server/src/nest/assistant/cards/journey/journey-header.ts @@ -0,0 +1,327 @@ +import type { CardReader, Described, PreviewContext, ToolArgs } from '../card-types'; +import { argId, clipped, recordOf, type Row } from '../card-values'; +import { Rows, text } from '../preview-format'; +import { rowsOf } from '../undo-snapshot'; +import { + JOURNEY_HEADER_LIMITS, + type AssistantChangePreview, + type AssistantChangeRow, + type AssistantChangeVerbKey, + type JourneyHeaderCard, + type JourneyHeaderTrip, +} from '@trek/shared'; + +/** + * The change cards of a journal itself: a new one, its settings changed, a + * trip linked or unlinked, its suggestions brought back, the user's own view + * of it, the whole journal deleted. The rows name what changes, old to new; + * the card under them is the journal with its trips and what the change takes + * along. Everything is read with the user's rights: the journal as + * `get_journey` answers it, the trips it could link as + * `list_journey_available_trips` does. + */ + +/** What a held journal change looked up before its card is drawn. */ +export interface JournalHeaderLoaded { + /** The journal as `get_journey` answers it; null for a new one or a read that failed. */ + journey: Row | null; + /** The user's trips a journal could link. */ + available: Row[]; +} + +/** The tools these cards are for. */ +export const JOURNAL_HEADER_TOOLS = [ + 'create_journey', + 'update_journey', + 'delete_journey', + 'add_journey_trip', + 'remove_journey_trip', + 'restore_journey_suggestions', + 'update_journey_preferences', +] as const; +type HeaderTool = (typeof JOURNAL_HEADER_TOOLS)[number]; + +/** The journal a call names, or none in a journey's own conversation, where the session pins it. */ +export function journeyArgs(args: ToolArgs): ToolArgs { + const journeyId = argId(args, 'journeyId'); + return journeyId === null ? {} : { journeyId }; +} + +/** At most two reads: the journal (all but a new one), the user's trips (a new journal, a trip linked). */ +export async function loadJournalHeader(tool: string, args: ToolArgs, read: CardReader): Promise { + const withTrips = tool === 'create_journey' || tool === 'add_journey_trip'; + const available = withTrips ? rowsOf(recordOf(await read('list_journey_available_trips')).trips) : []; + const found = tool === 'create_journey' ? null : recordOf(await read('get_journey', journeyArgs(args))).journey; + return { journey: found ? recordOf(found) : null, available }; +} + +/** The settings an update can change beyond the name and subtitle, each a `field` row under the tool's own name. */ +const SETTING_FIELDS = [ + 'status', + 'status_override', + 'show_verdict', + 'show_mood', + 'show_weather', + 'photo_location', + 'show_trip_tracks', + 'auto_prepare_recordings', + 'cover_gradient', +] as const; +const FLAG_FIELDS: ReadonlySet = new Set([ + 'show_verdict', + 'show_mood', + 'show_weather', + 'photo_location', + 'show_trip_tracks', + 'auto_prepare_recordings', + 'hide_skeletons', +]); +const STATUS_FIELDS: ReadonlySet = new Set(['status', 'status_override']); + +/** A setting as its row writes it: a flag (stored 0 or 1, sent as a boolean) as ✓ or ✗, anything else as it is. */ +function settingText(field: string, value: unknown): string { + return FLAG_FIELDS.has(field) ? text(value === true || value === 1) : text(value); +} + +function fieldRow(field: string, before: string | null, after: string): AssistantChangeRow { + return { op: before === null ? 'add' : 'change', labelKey: 'field', field, before, after }; +} + +function described(subject: string, rows: Rows, extra: Partial = {}): Described { + return { subject, rows, dayId: null, placeIds: [], domain: 'journey', ...extra }; +} + +/** Which verb an update reads as: a new name alone, a new status alone, else the settings. */ +function updateVerb(args: ToolArgs): AssistantChangeVerbKey { + const keys = Object.keys(args).filter((key) => key !== 'journeyId'); + if (keys.length > 0 && keys.every((key) => key === 'title')) return 'renameJournal'; + if (keys.length > 0 && keys.every((key) => STATUS_FIELDS.has(key))) return 'setJournalStatus'; + return 'updateJournalSettings'; +} + +/** The trip a call names, by the user's trips or the journal's own. */ +function tripNamed(loaded: JournalHeaderLoaded | null, tripId: number | null): Row | null { + if (tripId === null) return null; + const own = rowsOf(loaded?.journey?.trips).find((trip) => trip.trip_id === tripId); + if (own) return { id: own.trip_id, title: own.title, start_date: own.start_date, end_date: own.end_date }; + return loaded?.available.find((trip) => trip.id === tripId) ?? null; +} + +function linked(loaded: JournalHeaderLoaded | null, tripId: number | null): boolean { + return tripId !== null && rowsOf(loaded?.journey?.trips).some((trip) => trip.trip_id === tripId); +} + +function updateRows(before: Row, args: ToolArgs): Rows { + const rows = new Rows(); + if ('title' in args && text(args.title) !== text(before.title)) + rows.change('name', text(before.title), text(args.title)); + if ('subtitle' in args && text(args.subtitle) !== text(before.subtitle)) { + rows.change('description', text(before.subtitle), text(args.subtitle)); + } + for (const field of SETTING_FIELDS) { + if (!(field in args)) continue; + const was = field in before ? settingText(field, before[field]) : null; + const now = settingText(field, args[field]); + if (was !== now) rows.rows.push(fieldRow(field, was, now)); + } + return rows; +} + +const TRIP_ROW = (verb: 'add' | 'remove', name: string): AssistantChangeRow => + verb === 'add' + ? { op: 'add', labelKey: 'field', field: 'trip', before: null, after: name } + : { op: 'change', labelKey: 'field', field: 'trip', before: name, after: '' }; + +export const JOURNAL_HEADER_DESCRIBERS: Readonly< + Record Described> +> = { + create_journey: (_ctx, args) => + described(text(args.title), new Rows().add('name', text(args.title)).add('description', text(args.subtitle))), + update_journey: (_ctx, args, loaded) => { + const before = loaded?.journey ?? {}; + return described(text(before.title) || text(args.title), updateRows(before, args), { verbKey: updateVerb(args) }); + }, + delete_journey: (_ctx, _args, loaded) => described(text(loaded?.journey?.title), new Rows()), + add_journey_trip: (_ctx, args, loaded) => { + const tripId = argId(args, 'tripId'); + const rows = new Rows(); + rows.rows.push(TRIP_ROW('add', text(tripNamed(loaded, tripId)?.title))); + // Unlinking takes the owner; a trip linked already would be unlinked by a way back that never linked it. + const owner = loaded?.journey?.my_role === 'owner'; + return described(text(loaded?.journey?.title), rows, { undoable: owner && !linked(loaded, tripId) }); + }, + remove_journey_trip: (_ctx, args, loaded) => { + const rows = new Rows(); + rows.rows.push(TRIP_ROW('remove', text(tripNamed(loaded, argId(args, 'tripId'))?.title))); + return described(text(loaded?.journey?.title), rows); + }, + restore_journey_suggestions: (_ctx, _args, loaded) => { + const rows = new Rows(); + const dismissed = Number(loaded?.journey?.dismissed_count); + if (Number.isInteger(dismissed) && dismissed > 0) rows.rows.push(fieldRow('suggestions', null, String(dismissed))); + return described(text(loaded?.journey?.title), rows); + }, + update_journey_preferences: (_ctx, args, loaded) => { + const rows = new Rows(); + const known = loaded?.journey && 'hide_skeletons' in loaded.journey; + const was = known ? settingText('hide_skeletons', loaded?.journey?.hide_skeletons) : null; + const now = 'hide_skeletons' in args ? settingText('hide_skeletons', args.hide_skeletons) : null; + if (now !== null && now !== was) rows.rows.push(fieldRow('hide_skeletons', was, now)); + // A way back that flips the switch is a way back only where the switch was known to change. + return described(text(loaded?.journey?.title), rows, { undoable: was !== null && now !== null && was !== now }); + }, +}; + +const DATE = /^\d{4}-\d{2}-\d{2}$/; + +function dateOf(value: unknown): string | null { + return typeof value === 'string' && DATE.test(value.slice(0, 10)) ? value.slice(0, 10) : null; +} + +function tripLook(row: Row, change: JourneyHeaderTrip['change'], idKey: 'id' | 'trip_id'): JourneyHeaderTrip | null { + const title = clipped(row.title, JOURNEY_HEADER_LIMITS.tripTitle); + if (!title) return null; + const id = argId(row, idKey); + return { id, title, startDate: dateOf(row.start_date), endDate: dateOf(row.end_date), change }; +} + +function count(value: unknown): number | null { + const n = Number(value); + return Number.isInteger(n) && n >= 0 ? Math.min(n, 1_000_000) : null; +} + +/** The journal's own trips, with what the change does to the one it names; that one first, so a cut keeps it. */ +function journalTrips(tool: string, args: ToolArgs, loaded: JournalHeaderLoaded): JourneyHeaderTrip[] { + const tripId = argId(args, 'tripId'); + const own = rowsOf(loaded.journey?.trips).flatMap((row) => { + const change = tool === 'remove_journey_trip' && row.trip_id === tripId ? 'unlink' : 'kept'; + const look = tripLook(row, change, 'trip_id'); + return look ? [look] : []; + }); + const changedFirst = [ + ...own.filter((trip) => trip.change !== 'kept'), + ...own.filter((trip) => trip.change === 'kept'), + ]; + if (tool !== 'add_journey_trip' || linked(loaded, tripId)) return changedFirst; + const added = loaded.available.find((row) => row.id === tripId); + const look = added ? tripLook(added, 'link', 'id') : null; + return look ? [look, ...changedFirst] : changedFirst; +} + +/** The trips a new journal links, by the user's trips; an id the user's trips do not list is left out. */ +function newTrips(args: ToolArgs, loaded: JournalHeaderLoaded): JourneyHeaderTrip[] { + const ids = Array.isArray(args.trip_ids) ? args.trip_ids : []; + return ids.flatMap((id) => { + const row = loaded.available.find((trip) => trip.id === id); + const look = row ? tripLook(row, 'link', 'id') : null; + return look ? [look] : []; + }); +} + +/** The places of the trips linked by the change, which come in as suggestions. */ +function suggestionsOf(trips: readonly JourneyHeaderTrip[], loaded: JournalHeaderLoaded): number | null { + const linking = trips.filter((trip) => trip.change === 'link' && trip.id !== null); + if (linking.length === 0) return null; + const places = linking.map((trip) => count(loaded.available.find((row) => row.id === trip.id)?.place_count) ?? 0); + return places.reduce((sum, n) => sum + n, 0); +} + +/** What the journal holds that the change concerns: all of it, or what one unlinked trip leaves behind. */ +function heldBy(tool: string, args: ToolArgs, journey: Row): { entries: number | null; photos: number | null } { + const entries = rowsOf(journey.entries).filter((entry) => !entry.dismissed); + if (tool === 'remove_journey_trip') { + const tripId = argId(args, 'tripId'); + return { + entries: entries.filter((e) => e.source_trip_id === tripId && e.type !== 'skeleton').length, + photos: null, + }; + } + const written = entries.filter((entry) => entry.type !== 'skeleton').length; + return { entries: written, photos: Array.isArray(journey.gallery) ? journey.gallery.length : null }; +} + +/** The suggestions an unlinked trip takes with it: its places nobody wrote up. */ +function unlinkedSuggestions(args: ToolArgs, journey: Row): number { + const tripId = argId(args, 'tripId'); + return rowsOf(journey.entries).filter((e) => e.source_trip_id === tripId && e.type === 'skeleton').length; +} + +/** The card under a held journal change; null when it has no journal to name. */ +export function journalHeaderCard( + tool: string, + args: ToolArgs, + loaded: JournalHeaderLoaded | null, +): JourneyHeaderCard | null { + if (!loaded) return null; + if (tool === 'create_journey') { + const title = clipped(args.title, JOURNEY_HEADER_LIMITS.title); + if (!title) return null; + const trips = newTrips(args, loaded).slice(0, JOURNEY_HEADER_LIMITS.trips); + const subtitle = clipped(args.subtitle, JOURNEY_HEADER_LIMITS.subtitle); + const suggestions = suggestionsOf(trips, loaded); + return { + kind: 'journeyHeader', + journeyId: null, + title, + subtitle, + change: 'new', + trips, + entries: null, + photos: null, + suggestions, + }; + } + const journey = loaded.journey; + const title = clipped(journey?.title, JOURNEY_HEADER_LIMITS.title); + if (!journey || !title) return null; + const trips = journalTrips(tool, args, loaded).slice(0, JOURNEY_HEADER_LIMITS.trips); + const held = heldBy(tool, args, journey); + const suggestions = + tool === 'remove_journey_trip' ? unlinkedSuggestions(args, journey) : suggestionsOf(trips, loaded); + return { + kind: 'journeyHeader', + journeyId: argId(journey, 'id'), + title, + subtitle: clipped(journey.subtitle, JOURNEY_HEADER_LIMITS.subtitle), + change: tool === 'delete_journey' ? 'willDelete' : 'changed', + trips, + entries: tool === 'add_journey_trip' ? null : held.entries, + photos: tool === 'delete_journey' ? held.photos : null, + suggestions, + }; +} + +/** + * The card of an applied journal change: a new journal as the tool answered + * it (its id, its trips), a changed one with its new name and subtitle over + * the card it was held with. Every other change keeps its held card. + */ +export function journalHeaderApplied( + tool: string, + result: unknown, + preview: AssistantChangePreview | null, +): JourneyHeaderCard | null { + const journey = recordOf(recordOf(result).journey); + const held = preview?.card?.kind === 'journeyHeader' ? preview.card : null; + const title = clipped(journey.title, JOURNEY_HEADER_LIMITS.title); + if (!title) return null; + const subtitle = clipped(journey.subtitle, JOURNEY_HEADER_LIMITS.subtitle); + if (tool === 'update_journey') return held ? { ...held, journeyId: argId(journey, 'id'), title, subtitle } : null; + if (tool !== 'create_journey') return null; + const trips = rowsOf(journey.trips).flatMap((row) => { + const look = tripLook(row, 'link', 'trip_id'); + return look ? [look] : []; + }); + const suggestions = rowsOf(journey.entries).filter((entry) => entry.type === 'skeleton').length; + return { + kind: 'journeyHeader', + journeyId: argId(journey, 'id'), + title, + subtitle, + change: 'new', + trips: trips.slice(0, JOURNEY_HEADER_LIMITS.trips), + entries: null, + photos: null, + suggestions, + }; +} diff --git a/server/tests/unit/nest/assistant/cards/journey/journey-header.test.ts b/server/tests/unit/nest/assistant/cards/journey/journey-header.test.ts new file mode 100644 index 000000000..77bd05a86 --- /dev/null +++ b/server/tests/unit/nest/assistant/cards/journey/journey-header.test.ts @@ -0,0 +1,350 @@ +/** + * ASTCARD-JHD-001 to ASTCARD-JHD-012 + * + * A change of a journal itself in the assistant chat: what a held change + * reads first, the words of its row (the verb an update reads as, each + * setting old to new), the journal card under it with its trips and what the + * change brings or takes, the receipt, and the ways back with their checks. + * 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 { + loadJournalHeader, + type JournalHeaderLoaded, +} from '../../../../../../src/nest/assistant/cards/journey/journey-header'; +import { + appliedCardOf, + ASSISTANT_UNDO_MAP, + ASSISTANT_WRITE_TOOLS, + changeCardOf, + undoReadsOf, +} from '../../../../../../src/nest/assistant/cards/registry'; +import { appliedChange, describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers'; +import { journeyHeaderCardSchema, type AssistantChangePreview, type JourneyHeaderCard } 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, +}; + +const AVAILABLE = [ + { id: 5, title: 'Tokyo days', start_date: '2026-11-02', end_date: '2026-11-08', place_count: 9 }, + { id: 6, title: 'Kyoto days', start_date: '2026-11-09', end_date: '2026-11-12', place_count: 4 }, +]; + +function journey(over: Record = {}): Record { + return { + id: 12, + title: 'Japan 2026', + subtitle: null, + status: 'active', + status_override: null, + show_mood: 1, + show_verdict: 0, + hide_skeletons: 0, + my_role: 'owner', + updated_at: 100, + dismissed_count: 3, + trips: [{ trip_id: 5, title: 'Tokyo days', start_date: '2026-11-02', end_date: '2026-11-08' }], + entries: [ + { id: 1, type: 'entry', source_trip_id: 5, dismissed: false }, + { id: 2, type: 'skeleton', source_trip_id: 5, dismissed: false }, + { id: 3, type: 'skeleton', source_trip_id: 5, dismissed: false }, + ], + gallery: [{ id: 1 }, { id: 2 }], + contributors: [{ user_id: 7 }], + ...over, + }; +} + +function readerOf(answers: Record): { 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 }; +} + +const loadedWith = (over: Record = {}): JournalHeaderLoaded => ({ + journey: journey(over), + available: AVAILABLE, +}); + +function cardOf(value: unknown): JourneyHeaderCard { + return journeyHeaderCardSchema.parse(value); +} + +describe('the lookups of a journal change', () => { + it('ASTCARD-JHD-001: a new journal reads the trips it could link, a change the journal it is on', async () => { + const created = readerOf({ list_journey_available_trips: { trips: AVAILABLE } }); + expect(await loadJournalHeader('create_journey', { title: 'Japan' }, created.read)).toEqual({ + journey: null, + available: AVAILABLE, + }); + expect(created.asked).toEqual([readKey('list_journey_available_trips')]); + + const named = readerOf({ get_journey: { journey: journey() } }); + const loaded = await loadJournalHeader('update_journey', { journeyId: 12, title: 'X' }, named.read); + expect(loaded.journey).toMatchObject({ id: 12 }); + expect(named.asked).toEqual([readKey('get_journey', { journeyId: 12 })]); + // A journey's own conversation pins the journal, so the read names none. + const pinned = readerOf({}); + expect(await loadJournalHeader('delete_journey', {}, pinned.read)).toEqual({ journey: null, available: [] }); + expect(pinned.asked).toEqual([readKey('get_journey', {})]); + const linking = readerOf({}); + await loadJournalHeader('add_journey_trip', { journeyId: 12, tripId: 6 }, linking.read); + expect(linking.asked).toHaveLength(2); + }); +}); + +describe('the words of a journal change', () => { + it('ASTCARD-JHD-002: an update reads as a rename, a status change or a change of settings', () => { + const loaded = loadedWith(); + const rename = describeToolCall('update_journey', 'c1', { journeyId: 12, title: 'Nippon' }, ctx, loaded); + expect(rename).toMatchObject({ verbKey: 'renameJournal', subject: 'Japan 2026', undoable: true }); + expect(rename.rows).toEqual([{ op: 'change', labelKey: 'name', before: 'Japan 2026', after: 'Nippon' }]); + const status = describeToolCall('update_journey', 'c2', { status_override: 'live' }, ctx, loaded); + expect(status.verbKey).toBe('setJournalStatus'); + expect(status.rows).toEqual([ + { op: 'change', labelKey: 'field', field: 'status_override', before: '', after: 'live' }, + ]); + const settings = describeToolCall( + 'update_journey', + 'c3', + { show_mood: false, show_verdict: false, subtitle: 'Autumn', show_trip_tracks: true }, + ctx, + loaded, + ); + expect(settings.verbKey).toBe('updateJournalSettings'); + // A setting set to what it holds gets no row; one the journal does not name has no before. + expect(settings.rows).toEqual([ + { op: 'change', labelKey: 'description', before: null, after: 'Autumn' }, + { op: 'change', labelKey: 'field', field: 'show_mood', before: '✓', after: '✗' }, + { op: 'add', labelKey: 'field', field: 'show_trip_tracks', before: null, after: '✓' }, + ]); + }); + + it('ASTCARD-JHD-003: a trip linked names it, and is undoable only for the owner and a trip not linked yet', () => { + const add = describeToolCall('add_journey_trip', 'c1', { journeyId: 12, tripId: 6 }, ctx, loadedWith()); + expect(add).toMatchObject({ verbKey: 'linkJournalTrip', subject: 'Japan 2026', undoable: true }); + expect(add.rows).toEqual([{ op: 'add', labelKey: 'field', field: 'trip', before: null, after: 'Kyoto days' }]); + const again = describeToolCall('add_journey_trip', 'c2', { journeyId: 12, tripId: 5 }, ctx, loadedWith()); + expect(again.undoable).toBe(false); + const editor = describeToolCall('add_journey_trip', 'c3', { tripId: 6 }, ctx, loadedWith({ my_role: 'editor' })); + expect(editor.undoable).toBe(false); + }); + + it('ASTCARD-JHD-004: the other changes name the journal, and a deletion or an unlink has no way back', () => { + const loaded = loadedWith(); + expect(describeToolCall('delete_journey', 'c1', { journeyId: 12 }, ctx, loaded)).toMatchObject({ + verbKey: 'deleteJournal', + subject: 'Japan 2026', + undoable: false, + }); + const unlink = describeToolCall('remove_journey_trip', 'c2', { tripId: 5 }, ctx, loaded); + expect(unlink).toMatchObject({ verbKey: 'unlinkJournalTrip', undoable: false }); + expect(unlink.rows).toEqual([{ op: 'change', labelKey: 'field', field: 'trip', before: 'Tokyo days', after: '' }]); + const restore = describeToolCall('restore_journey_suggestions', 'c3', {}, ctx, loaded); + expect(restore).toMatchObject({ verbKey: 'restoreJournalSuggestions', undoable: false }); + expect(restore.rows).toEqual([{ op: 'add', labelKey: 'field', field: 'suggestions', before: null, after: '3' }]); + const prefs = describeToolCall('update_journey_preferences', 'c4', { hide_skeletons: true }, ctx, loaded); + expect(prefs).toMatchObject({ verbKey: 'setJournalPreferences', undoable: true }); + expect(prefs.rows).toEqual([{ op: 'change', labelKey: 'field', field: 'hide_skeletons', before: '✗', after: '✓' }]); + // Set to what it holds, the switch has no way back that would mean anything. + expect(describeToolCall('update_journey_preferences', 'c5', { hide_skeletons: false }, ctx, loaded).undoable).toBe( + false, + ); + expect(describeToolCall('create_journey', 'c6', { title: 'Japan' }, ctx, null)).toMatchObject({ + verbKey: 'createJournal', + subject: 'Japan', + undoable: true, + }); + }); +}); + +describe('the journal card under a change', () => { + it('ASTCARD-JHD-005: a new journal shows the trips it links and the places that come in as suggestions', () => { + const loaded: JournalHeaderLoaded = { journey: null, available: AVAILABLE }; + const card = cardOf( + changeCardOf('create_journey', ctx, { title: 'Japan 2026', subtitle: 'Autumn', trip_ids: [5, 6, 99] }, loaded), + ); + expect(card).toMatchObject({ journeyId: null, title: 'Japan 2026', subtitle: 'Autumn', change: 'new' }); + expect(card.trips.map((trip) => [trip.id, trip.change])).toEqual([ + [5, 'link'], + [6, 'link'], + ]); + expect(card.suggestions).toBe(13); + expect(changeCardOf('create_journey', ctx, {}, loaded)).toBeNull(); + }); + + it('ASTCARD-JHD-006: a deletion counts what goes, an unlink what stays and what goes, a link comes first', () => { + const loaded = loadedWith(); + const deleting = cardOf(changeCardOf('delete_journey', ctx, {}, loaded)); + expect(deleting).toMatchObject({ change: 'willDelete', journeyId: 12, entries: 1, photos: 2 }); + const unlinking = cardOf(changeCardOf('remove_journey_trip', ctx, { tripId: 5 }, loaded)); + expect(unlinking).toMatchObject({ change: 'changed', entries: 1, suggestions: 2, photos: null }); + expect(unlinking.trips).toEqual([ + { id: 5, title: 'Tokyo days', startDate: '2026-11-02', endDate: '2026-11-08', change: 'unlink' }, + ]); + const linking = cardOf(changeCardOf('add_journey_trip', ctx, { tripId: 6 }, loaded)); + expect(linking.trips.map((trip) => [trip.id, trip.change])).toEqual([ + [6, 'link'], + [5, 'kept'], + ]); + expect(linking).toMatchObject({ suggestions: 4, entries: null }); + expect(changeCardOf('update_journey', ctx, { title: 'X' }, { journey: null, available: [] })).toBeNull(); + }); + + it('ASTCARD-JHD-007: the receipt of a new journal names it with its id, a change takes its new name', () => { + const result = { journey: journey({ entries: [{ type: 'skeleton' }, { type: 'skeleton' }] }) }; + const created = cardOf(appliedCardOf('create_journey', { args: {}, result, preview: null })); + expect(created).toMatchObject({ journeyId: 12, change: 'new', suggestions: 2 }); + expect(created.trips[0]).toMatchObject({ id: 5, change: 'link' }); + const held = changeCardOf('update_journey', ctx, { title: 'Nippon' }, loadedWith()); + const preview = { card: held } as unknown as AssistantChangePreview; + const renamed = { journey: journey({ title: 'Nippon' }), previous: { title: 'Japan 2026' } }; + expect(appliedCardOf('update_journey', { args: {}, result: renamed, preview })).toMatchObject({ + title: 'Nippon', + journeyId: 12, + }); + expect(appliedCardOf('delete_journey', { args: {}, result: { success: true }, preview })).toBeNull(); + expect(appliedChange('create_journey', 'c1', {}, result, null, null)).toMatchObject({ + entity: 'journey', + ids: [12], + }); + expect(appliedChange('delete_journey', 'c2', { journeyId: 12 }, { success: true }, null, null)?.ids).toEqual([12]); + }); +}); + +function snapshotOf(entries: Array<[string, Record, unknown]>): UndoSnapshot { + return { + summary: null, + places: null, + reads: new Map(entries.map(([tool, args, value]) => [readKey(tool, args), value])), + }; +} + +function undoOf(tool: string) { + const undo = JOURNEY_CARDS.tools[tool]?.undo; + if (!undo) throw new Error(`no undo for ${tool}`); + return undo; +} + +describe('the ways back from a journal change', () => { + it('ASTCARD-JHD-008: a new journal is deleted while nothing was written, added or laid out in it', () => { + const undo = undoOf('create_journey'); + const created = journey({ entries: [{ type: 'skeleton' }], gallery: [] }); + const result = { journey: created }; + expect(ASSISTANT_UNDO_MAP.create_journey?.tool).toBe('delete_journey'); + expect(undo.args({}, result, null)).toEqual({ journeyId: 12 }); + expect(undo.args({}, {}, null)).toBeNull(); + expect(undoReadsOf('create_journey', {}, result)).toEqual([ + { tool: 'get_journey', args: { journeyId: 12 } }, + { tool: 'get_book', args: { journeyId: 12 } }, + ]); + const check = (current: Record, book: unknown = { exists: false }) => + undo.unchanged( + result, + snapshotOf([ + ['get_journey', { journeyId: 12 }, { journey: current }], + ['get_book', { journeyId: 12 }, book], + ]), + ); + expect(check(created)).toBe(true); + expect(check({ ...created, updated_at: 101 })).toBe(false); + expect(check({ ...created, entries: [{ type: 'entry' }] })).toBe(false); + expect(check({ ...created, gallery: [{ id: 1 }] })).toBe(false); + expect(check({ ...created, contributors: [{ user_id: 7 }, { user_id: 8 }] })).toBe(false); + expect(check(created, { exists: true })).toBe(false); + expect(check(created, null)).toBe(false); + }); + + it('ASTCARD-JHD-009: changed settings are written back as they were, while the journal was not changed since', () => { + const undo = undoOf('update_journey'); + const result = { journey: journey({ title: 'Nippon' }), previous: { title: 'Japan 2026', show_mood: true } }; + expect(undo.args({ title: 'Nippon' }, result, null)).toEqual({ + journeyId: 12, + title: 'Japan 2026', + show_mood: true, + }); + expect(undo.args({}, { journey: journey(), previous: {} }, null)).toBeNull(); + // A name or a status stored empty has nothing the tool could take back. + expect(undo.args({}, { journey: journey(), previous: { title: null, subtitle: null } }, null)).toEqual({ + journeyId: 12, + subtitle: null, + }); + const at = (updated: number) => + undo.unchanged( + result, + snapshotOf([['get_journey', { journeyId: 12 }, { journey: journey({ updated_at: updated }) }]]), + ); + expect(at(100)).toBe(true); + expect(at(150)).toBe(false); + }); + + it('ASTCARD-JHD-010: a linked trip is unlinked by its owner while none of its suggestions was written up', () => { + const undo = undoOf('add_journey_trip'); + const args = { journeyId: 12, tripId: 6 }; + expect(undo.args(args, { success: true }, null)).toEqual({ journeyId: 12, tripId: 6 }); + expect(undo.args({ tripId: 6 }, { success: true }, null)).toEqual({ tripId: 6 }); + expect(undo.args(args, { success: false }, null)).toBeNull(); + const notOwner = { undoable: false } as unknown as AssistantChangePreview; + expect(undo.args(args, { success: true }, notOwner)).toBeNull(); + const linked = { trips: [{ trip_id: 6 }], entries: [{ type: 'skeleton', source_trip_id: 6 }] }; + const check = (current: Record) => + undo.unchanged({ success: true }, snapshotOf([['get_journey', { journeyId: 12 }, { journey: current }]]), { + args, + preview: null, + }); + expect(check(linked)).toBe(true); + expect(check({ ...linked, entries: [{ type: 'entry', source_trip_id: 6 }] })).toBe(false); + expect(check({ ...linked, trips: [] })).toBe(false); + expect(undo.unchanged({ success: true }, snapshotOf([]), { args: {}, preview: null })).toBe(false); + }); + + it('ASTCARD-JHD-011: the own switch flips back while it still stands as set', () => { + const undo = undoOf('update_journey_preferences'); + const args = { journeyId: 12, hide_skeletons: true }; + expect(undo.args(args, { hide_skeletons: 1 }, null)).toEqual({ journeyId: 12, hide_skeletons: false }); + expect(undo.args({ journeyId: 12 }, { hide_skeletons: 1 }, null)).toBeNull(); + const same = { undoable: false } as unknown as AssistantChangePreview; + expect(undo.args(args, { hide_skeletons: 1 }, same)).toBeNull(); + const check = (stored: unknown) => + undo.unchanged( + { hide_skeletons: 1 }, + snapshotOf([['get_journey', { journeyId: 12 }, { journey: { hide_skeletons: stored } }]]), + { args, preview: null }, + ); + expect(check(1)).toBe(true); + expect(check(0)).toBe(false); + }); + + it('ASTCARD-JHD-012: the journal changes bring their verbs, the journal as entity, and no way back for a deletion', () => { + expect(ASSISTANT_WRITE_TOOLS.create_journey).toEqual({ verbKey: 'createJournal', entity: 'journey' }); + expect(ASSISTANT_WRITE_TOOLS.update_journey?.verbKey).toBe('updateJournalSettings'); + expect(ASSISTANT_WRITE_TOOLS.delete_journey?.verbKey).toBe('deleteJournal'); + expect(ASSISTANT_WRITE_TOOLS.add_journey_trip?.verbKey).toBe('linkJournalTrip'); + expect(ASSISTANT_WRITE_TOOLS.remove_journey_trip?.verbKey).toBe('unlinkJournalTrip'); + expect(ASSISTANT_WRITE_TOOLS.restore_journey_suggestions?.verbKey).toBe('restoreJournalSuggestions'); + expect(ASSISTANT_WRITE_TOOLS.update_journey_preferences?.verbKey).toBe('setJournalPreferences'); + for (const tool of ['delete_journey', 'remove_journey_trip', 'restore_journey_suggestions']) { + expect(ASSISTANT_UNDO_MAP[tool], tool).toBeUndefined(); + } + }); +}); diff --git a/shared/src/assistant/cards/journey-header.card.spec.ts b/shared/src/assistant/cards/journey-header.card.spec.ts new file mode 100644 index 000000000..32e42de21 --- /dev/null +++ b/shared/src/assistant/cards/journey-header.card.spec.ts @@ -0,0 +1,39 @@ +import { storedTrekCardSchema, trekCardSchema } from './index'; +import { JOURNEY_HEADER_LIMITS, journeyHeaderCardSchema } from './journey-header.card'; + +import { describe, expect, it } from 'vitest'; + +const card = { + kind: 'journeyHeader', + journeyId: 12, + title: 'Japan 2026', + subtitle: null, + change: 'changed', + trips: [{ id: 5, title: 'Tokyo days', startDate: '2026-11-02', endDate: null, change: 'link' }], + entries: 3, + photos: null, + suggestions: 9, +} as const; + +describe('journeyHeaderCardSchema', () => { + it('JOURNEYHEADER-001: is part of the card union and reads as itself', () => { + expect(trekCardSchema.parse(card)).toEqual(card); + expect(storedTrekCardSchema.parse(card)).toEqual(card); + }); + + it('JOURNEYHEADER-002: takes a new journal without an id, and a trip the user trips do not list', () => { + const fresh = { ...card, journeyId: null, change: 'new', trips: [{ ...card.trips[0], id: null }] }; + expect(journeyHeaderCardSchema.parse(fresh)).toEqual(fresh); + }); + + it('JOURNEYHEADER-003: refuses an unknown change, too many trips, an empty title or a negative count', () => { + expect(journeyHeaderCardSchema.safeParse({ ...card, change: 'renamed' }).success).toBe(false); + const many = Array.from({ length: JOURNEY_HEADER_LIMITS.trips + 1 }, () => card.trips[0]); + expect(journeyHeaderCardSchema.safeParse({ ...card, trips: many }).success).toBe(false); + expect(journeyHeaderCardSchema.safeParse({ ...card, title: '' }).success).toBe(false); + expect(journeyHeaderCardSchema.safeParse({ ...card, entries: -1 }).success).toBe(false); + expect(journeyHeaderCardSchema.safeParse({ ...card, trips: [{ ...card.trips[0], change: 'moved' }] }).success).toBe( + false, + ); + }); +}); diff --git a/shared/src/assistant/cards/journey-header.card.ts b/shared/src/assistant/cards/journey-header.card.ts new file mode 100644 index 000000000..6ce70f5d9 --- /dev/null +++ b/shared/src/assistant/cards/journey-header.card.ts @@ -0,0 +1,47 @@ +import { idSchema } from '../../common/primitives.schema'; +import { cardCountSchema, cardDateSchema, cardText } from './card-parts'; + +import { z } from 'zod'; + +/** + * A journal itself as the assistant chat shows a change of it: a new one, + * its settings changed, a trip linked or unlinked, its suggestions brought + * back, or the whole journal deleted. The rows of the change card say what + * changes field by field; this card is the journal under them, its trips and + * what a change takes with it. + */ + +/** How much of a journal the card carries; the server clips to the same numbers. */ +export const JOURNEY_HEADER_LIMITS = { title: 200, subtitle: 300, trips: 12, tripTitle: 120 } as const; + +/** What the change does to the journal: makes it, changes it, deletes it. */ +export const JOURNEY_HEADER_CHANGES = ['new', 'changed', 'willDelete'] as const; + +/** What the change does to one of its trips. */ +export const JOURNEY_HEADER_TRIP_CHANGES = ['kept', 'link', 'unlink'] as const; + +export const journeyHeaderTripSchema = z.object({ + /** Null for a trip a new journal names that the user's trips do not list. */ + id: idSchema.nullable(), + title: cardText(JOURNEY_HEADER_LIMITS.tripTitle), + startDate: cardDateSchema.nullable(), + endDate: cardDateSchema.nullable(), + change: z.enum(JOURNEY_HEADER_TRIP_CHANGES), +}); +export type JourneyHeaderTrip = z.infer; + +export const journeyHeaderCardSchema = z.object({ + kind: z.literal('journeyHeader'), + /** Null for a journal a held change has not made yet. */ + journeyId: idSchema.nullable(), + title: cardText(JOURNEY_HEADER_LIMITS.title), + subtitle: cardText(JOURNEY_HEADER_LIMITS.subtitle).nullable(), + change: z.enum(JOURNEY_HEADER_CHANGES), + trips: z.array(journeyHeaderTripSchema).max(JOURNEY_HEADER_LIMITS.trips), + /** What the journal holds: what a deletion takes with it, what an unlinked trip leaves behind. */ + entries: cardCountSchema.nullable(), + photos: cardCountSchema.nullable(), + /** Places of the linked trips that come in as suggestions; null when unknown. */ + suggestions: cardCountSchema.nullable(), +}); +export type JourneyHeaderCard = z.infer; diff --git a/shared/src/assistant/cards/journey.contract.ts b/shared/src/assistant/cards/journey.contract.ts index 0944698a3..13e92f0d4 100644 --- a/shared/src/assistant/cards/journey.contract.ts +++ b/shared/src/assistant/cards/journey.contract.ts @@ -1,6 +1,8 @@ import { journeyEntriesCardSchema } from './journey-entries.card'; +import { journeyHeaderCardSchema } from './journey-header.card'; export * from './journey-entries.card'; +export * from './journey-header.card'; /** * What the travel journal brings to the assistant chat: the kinds of card it draws, @@ -9,8 +11,25 @@ export * from './journey-entries.card'; * spread these into the card union and the shared enums, so a new kind, verb * or entity is added here and nowhere else. */ -export const JOURNEY_CARD_SCHEMAS = [journeyEntriesCardSchema] as const; -/** An entry written, changed or deleted. */ -export const JOURNEY_VERB_KEYS = ['addJournalEntry', 'updateJournalEntry', 'removeJournalEntry'] as const; -/** An entry of a journal; the receipt opens it there. */ -export const JOURNEY_APPLIED_ENTITIES = ['journey_entry'] as const; +export const JOURNEY_CARD_SCHEMAS = [journeyEntriesCardSchema, journeyHeaderCardSchema] as const; +/** + * An entry written, changed or deleted; 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. + */ +export const JOURNEY_VERB_KEYS = [ + 'addJournalEntry', + 'updateJournalEntry', + 'removeJournalEntry', + 'createJournal', + 'renameJournal', + 'setJournalStatus', + 'updateJournalSettings', + 'deleteJournal', + 'linkJournalTrip', + 'unlinkJournalTrip', + 'restoreJournalSuggestions', + 'setJournalPreferences', +] as const; +/** An entry of a journal, or the journal itself; the receipt opens it there. */ +export const JOURNEY_APPLIED_ENTITIES = ['journey_entry', 'journey'] as const; diff --git a/shared/src/i18n/ar/assistantJourney.ts b/shared/src/i18n/ar/assistantJourney.ts index 18ccad72f..c0ed923d0 100644 --- a/shared/src/i18n/ar/assistantJourney.ts +++ b/shared/src/i18n/ar/assistantJourney.ts @@ -41,5 +41,41 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': 'لخّص هذه الأيام', 'assistant.journal.welcome': 'ماذا نكتب في اليوميات؟', 'assistant.journal.readOnly': 'يمكنك قراءة هذه اليوميات فقط، لذا أستطيع الإجابة عن الأسئلة دون تغيير أي شيء.', + 'assistant.change.verb.createJournal': 'إنشاء يوميات', + 'assistant.change.verb.renameJournal': 'إعادة تسمية اليوميات', + 'assistant.change.verb.setJournalStatus': 'تغيير حالة اليوميات', + 'assistant.change.verb.updateJournalSettings': 'تغيير إعدادات اليوميات', + 'assistant.change.verb.deleteJournal': 'حذف اليوميات', + 'assistant.change.verb.linkJournalTrip': 'إضافة رحلة إلى اليوميات', + 'assistant.change.verb.unlinkJournalTrip': 'إزالة رحلة من اليوميات', + 'assistant.change.verb.restoreJournalSuggestions': 'استعادة الاقتراحات', + 'assistant.change.verb.setJournalPreferences': 'تغيير طريقة عرضك لليوميات', + 'assistant.card.journeyHeader.open': 'فتح اليوميات', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'يظهر كـ', + 'assistant.card.journeyHeader.field.coverGradient': 'ألوان الغلاف', + 'assistant.card.journeyHeader.field.trip': 'الرحلة', + 'assistant.card.journeyHeader.field.suggestions': 'اقتراحات مستعادة', + 'assistant.card.journeyHeader.tripLink': 'ستُضاف', + 'assistant.card.journeyHeader.tripUnlink': 'ستُزال', + 'assistant.card.journeyHeader.deletes': 'تُحذف اليوميات مع كل مدخلاتها وصورها. لا يمكن التراجع عن ذلك.', + 'assistant.card.journeyHeader.entriesStay': 'يبقى {count} مدخل مكتوب', + 'assistant.card.journeyHeader.entriesStay.zero': 'لا تبقى مدخلات مكتوبة {count}', + 'assistant.card.journeyHeader.entriesStay.one': 'يبقى {count} مدخل مكتوب', + 'assistant.card.journeyHeader.entriesStay.two': 'يبقى {count} مدخلان مكتوبان', + 'assistant.card.journeyHeader.entriesStay.few': 'تبقى {count} مدخلات مكتوبة', + 'assistant.card.journeyHeader.entriesStay.many': 'يبقى {count} مدخلًا مكتوبًا', + 'assistant.card.journeyHeader.suggestionsGo': 'يُزال {count} اقتراح مفتوح', + 'assistant.card.journeyHeader.suggestionsGo.zero': 'لا تُزال اقتراحات مفتوحة {count}', + 'assistant.card.journeyHeader.suggestionsGo.one': 'يُزال {count} اقتراح مفتوح', + 'assistant.card.journeyHeader.suggestionsGo.two': 'يُزال {count} اقتراحان مفتوحان', + 'assistant.card.journeyHeader.suggestionsGo.few': 'تُزال {count} اقتراحات مفتوحة', + 'assistant.card.journeyHeader.suggestionsGo.many': 'يُزال {count} اقتراحًا مفتوحًا', + 'assistant.card.journeyHeader.suggestionsCome': 'يُضاف {count} مكان كاقتراح', + 'assistant.card.journeyHeader.suggestionsCome.zero': 'لا تُضاف أماكن كاقتراحات {count}', + 'assistant.card.journeyHeader.suggestionsCome.one': 'يُضاف {count} مكان كاقتراح', + 'assistant.card.journeyHeader.suggestionsCome.two': 'يُضاف {count} مكانان كاقتراحين', + 'assistant.card.journeyHeader.suggestionsCome.few': 'تُضاف {count} أماكن كاقتراحات', + 'assistant.card.journeyHeader.suggestionsCome.many': 'يُضاف {count} مكانًا كاقتراحات', }; export default assistantJourney; diff --git a/shared/src/i18n/az/assistantJourney.ts b/shared/src/i18n/az/assistantJourney.ts index 4153bc48c..d3dd12a43 100644 --- a/shared/src/i18n/az/assistantJourney.ts +++ b/shared/src/i18n/az/assistantJourney.ts @@ -30,5 +30,30 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Gündəliyə nə yazaq?', 'assistant.journal.readOnly': 'Bu gündəliyi yalnız oxuya bilərsən, ona görə suallara cavab verə bilərəm, amma heç nəyi dəyişə bilmərəm.', + 'assistant.change.verb.createJournal': 'Gündəlik başlat', + 'assistant.change.verb.renameJournal': 'Gündəliyin adını dəyiş', + 'assistant.change.verb.setJournalStatus': 'Gündəliyin vəziyyətini dəyiş', + 'assistant.change.verb.updateJournalSettings': 'Gündəliyin ayarlarını dəyiş', + 'assistant.change.verb.deleteJournal': 'Gündəliyi sil', + 'assistant.change.verb.linkJournalTrip': 'Gündəliyə səyahət əlavə et', + 'assistant.change.verb.unlinkJournalTrip': 'Səyahəti gündəlikdən çıxar', + 'assistant.change.verb.restoreJournalSuggestions': 'Təklifləri geri qaytar', + 'assistant.change.verb.setJournalPreferences': 'Gündəliyin sənin üçün görünüşünü dəyiş', + 'assistant.card.journeyHeader.open': 'Gündəliyi aç', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Göstərilir', + 'assistant.card.journeyHeader.field.coverGradient': 'Üz qabığının rəngləri', + 'assistant.card.journeyHeader.field.trip': 'Səyahət', + 'assistant.card.journeyHeader.field.suggestions': 'Geri qaytarılan təkliflər', + 'assistant.card.journeyHeader.tripLink': 'Əlavə olunacaq', + 'assistant.card.journeyHeader.tripUnlink': 'Çıxarılacaq', + 'assistant.card.journeyHeader.deletes': + 'Gündəlik bütün qeydləri və şəkilləri ilə birlikdə silinir. Bunu geri qaytarmaq olmur.', + 'assistant.card.journeyHeader.entriesStay': '{count} yazılmış qeyd qalır', + 'assistant.card.journeyHeader.entriesStay.one': '{count} yazılmış qeyd qalır', + 'assistant.card.journeyHeader.suggestionsGo': '{count} açıq təklif silinir', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/br/assistantJourney.ts b/shared/src/i18n/br/assistantJourney.ts index b5856675f..67ca6ccf4 100644 --- a/shared/src/i18n/br/assistantJourney.ts +++ b/shared/src/i18n/br/assistantJourney.ts @@ -29,5 +29,29 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': 'Resumir estes dias', 'assistant.journal.welcome': 'O que vamos colocar no diário?', 'assistant.journal.readOnly': 'Você só pode ler este diário, então posso responder perguntas, mas não mudar nada.', + 'assistant.change.verb.createJournal': 'Começar um diário', + 'assistant.change.verb.renameJournal': 'Renomear o diário', + 'assistant.change.verb.setJournalStatus': 'Mudar o status do diário', + 'assistant.change.verb.updateJournalSettings': 'Mudar as configurações do diário', + 'assistant.change.verb.deleteJournal': 'Excluir o diário', + 'assistant.change.verb.linkJournalTrip': 'Adicionar uma viagem ao diário', + 'assistant.change.verb.unlinkJournalTrip': 'Tirar uma viagem do diário', + 'assistant.change.verb.restoreJournalSuggestions': 'Trazer de volta as sugestões', + 'assistant.change.verb.setJournalPreferences': 'Mudar a sua visualização do diário', + 'assistant.card.journeyHeader.open': 'Abrir o diário', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Exibido como', + 'assistant.card.journeyHeader.field.coverGradient': 'Cores da capa', + 'assistant.card.journeyHeader.field.trip': 'Viagem', + 'assistant.card.journeyHeader.field.suggestions': 'Sugestões de volta', + 'assistant.card.journeyHeader.tripLink': 'Será adicionada', + 'assistant.card.journeyHeader.tripUnlink': 'Será retirada', + 'assistant.card.journeyHeader.deletes': 'O diário sai com todas as entradas e fotos. Isso não pode ser desfeito.', + 'assistant.card.journeyHeader.entriesStay': '{count} entradas escritas ficam', + 'assistant.card.journeyHeader.entriesStay.one': '{count} entrada escrita fica', + 'assistant.card.journeyHeader.suggestionsGo': '{count} sugestões em aberto saem', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/ca/assistantJourney.ts b/shared/src/i18n/ca/assistantJourney.ts index 711cef2d8..3b6a30cd6 100644 --- a/shared/src/i18n/ca/assistantJourney.ts +++ b/shared/src/i18n/ca/assistantJourney.ts @@ -29,5 +29,29 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': 'Resumeix aquests dies', 'assistant.journal.welcome': 'Què hi posem, al diari?', 'assistant.journal.readOnly': 'Només pots llegir aquest diari, així que puc respondre preguntes però no canviar res.', + 'assistant.change.verb.createJournal': 'Començar un diari', + 'assistant.change.verb.renameJournal': 'Canviar el nom del diari', + 'assistant.change.verb.setJournalStatus': "Canviar l'estat del diari", + 'assistant.change.verb.updateJournalSettings': 'Canviar la configuració del diari', + 'assistant.change.verb.deleteJournal': 'Eliminar el diari', + 'assistant.change.verb.linkJournalTrip': 'Afegir un viatge al diari', + 'assistant.change.verb.unlinkJournalTrip': 'Treure un viatge del diari', + 'assistant.change.verb.restoreJournalSuggestions': 'Recuperar els suggeriments', + 'assistant.change.verb.setJournalPreferences': 'Canviar la teva vista del diari', + 'assistant.card.journeyHeader.open': 'Obrir el diari', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Es mostra com a', + 'assistant.card.journeyHeader.field.coverGradient': 'Colors de la portada', + 'assistant.card.journeyHeader.field.trip': 'Viatge', + 'assistant.card.journeyHeader.field.suggestions': 'Suggeriments recuperats', + 'assistant.card.journeyHeader.tripLink': "S'hi afegirà", + 'assistant.card.journeyHeader.tripUnlink': 'Es traurà', + 'assistant.card.journeyHeader.deletes': "El diari se'n va amb totes les entrades i fotos. No es pot desfer.", + 'assistant.card.journeyHeader.entriesStay': 'Es queden {count} entrades escrites', + 'assistant.card.journeyHeader.entriesStay.one': 'Es queda {count} entrada escrita', + 'assistant.card.journeyHeader.suggestionsGo': "Se'n van {count} suggeriments oberts", + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/cs/assistantJourney.ts b/shared/src/i18n/cs/assistantJourney.ts index f03440ba3..530dfa2a5 100644 --- a/shared/src/i18n/cs/assistantJourney.ts +++ b/shared/src/i18n/cs/assistantJourney.ts @@ -32,5 +32,32 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': 'Shrnout tyto dny', 'assistant.journal.welcome': 'Co zapíšeme do deníku?', 'assistant.journal.readOnly': 'Tento deník můžeš jen číst, takže můžu odpovídat na otázky, ale nic měnit.', + 'assistant.change.verb.createJournal': 'Založit deník', + 'assistant.change.verb.renameJournal': 'Přejmenovat deník', + 'assistant.change.verb.setJournalStatus': 'Změnit stav deníku', + 'assistant.change.verb.updateJournalSettings': 'Změnit nastavení deníku', + 'assistant.change.verb.deleteJournal': 'Smazat deník', + 'assistant.change.verb.linkJournalTrip': 'Přidat cestu do deníku', + 'assistant.change.verb.unlinkJournalTrip': 'Odebrat cestu z deníku', + 'assistant.change.verb.restoreJournalSuggestions': 'Vrátit návrhy', + 'assistant.change.verb.setJournalPreferences': 'Změnit své zobrazení deníku', + 'assistant.card.journeyHeader.open': 'Otevřít deník', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Zobrazeno jako', + 'assistant.card.journeyHeader.field.coverGradient': 'Barvy obálky', + 'assistant.card.journeyHeader.field.trip': 'Cesta', + 'assistant.card.journeyHeader.field.suggestions': 'Vrácené návrhy', + 'assistant.card.journeyHeader.tripLink': 'Bude přidána', + 'assistant.card.journeyHeader.tripUnlink': 'Bude odebrána', + 'assistant.card.journeyHeader.deletes': 'Deník zmizí se všemi záznamy a fotkami. Nelze to vrátit zpět.', + 'assistant.card.journeyHeader.entriesStay': '{count} napsaných záznamů zůstane', + 'assistant.card.journeyHeader.entriesStay.one': '{count} napsaný záznam zůstane', + 'assistant.card.journeyHeader.entriesStay.few': '{count} napsané záznamy zůstanou', + 'assistant.card.journeyHeader.suggestionsGo': '{count} otevřených návrhů zmizí', + 'assistant.card.journeyHeader.suggestionsGo.one': '{count} otevřený návrh zmizí', + 'assistant.card.journeyHeader.suggestionsGo.few': '{count} otevřené návrhy zmizí', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/de/assistantJourney.ts b/shared/src/i18n/de/assistantJourney.ts index 30f591f4c..5d6e4b054 100644 --- a/shared/src/i18n/de/assistantJourney.ts +++ b/shared/src/i18n/de/assistantJourney.ts @@ -30,5 +30,30 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Was soll ins Journal?', 'assistant.journal.readOnly': 'Du kannst dieses Journal nur lesen, darum kann ich Fragen beantworten, aber nichts ändern.', + 'assistant.change.verb.createJournal': 'Journal anlegen', + 'assistant.change.verb.renameJournal': 'Journal umbenennen', + 'assistant.change.verb.setJournalStatus': 'Stand des Journals ändern', + 'assistant.change.verb.updateJournalSettings': 'Einstellungen des Journals ändern', + 'assistant.change.verb.deleteJournal': 'Journal löschen', + 'assistant.change.verb.linkJournalTrip': 'Reise zum Journal hinzufügen', + 'assistant.change.verb.unlinkJournalTrip': 'Reise aus dem Journal nehmen', + 'assistant.change.verb.restoreJournalSuggestions': 'Vorschläge zurückholen', + 'assistant.change.verb.setJournalPreferences': 'Deine Ansicht des Journals ändern', + 'assistant.card.journeyHeader.open': 'Journal öffnen', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Angezeigt als', + 'assistant.card.journeyHeader.field.coverGradient': 'Farben des Covers', + 'assistant.card.journeyHeader.field.trip': 'Reise', + 'assistant.card.journeyHeader.field.suggestions': 'Zurückgeholte Vorschläge', + 'assistant.card.journeyHeader.tripLink': 'Wird hinzugefügt', + 'assistant.card.journeyHeader.tripUnlink': 'Wird herausgenommen', + 'assistant.card.journeyHeader.deletes': + 'Das Journal verschwindet mit allen Einträgen und Fotos. Das lässt sich nicht rückgängig machen.', + 'assistant.card.journeyHeader.entriesStay': '{count} geschriebene Einträge bleiben', + 'assistant.card.journeyHeader.entriesStay.one': '{count} geschriebener Eintrag bleibt', + 'assistant.card.journeyHeader.suggestionsGo': '{count} offene Vorschläge fallen weg', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/en/assistantJourney.ts b/shared/src/i18n/en/assistantJourney.ts index 086f510e4..3eb013b88 100644 --- a/shared/src/i18n/en/assistantJourney.ts +++ b/shared/src/i18n/en/assistantJourney.ts @@ -29,5 +29,29 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': 'Sum up these days', 'assistant.journal.welcome': 'What should go into the journal?', 'assistant.journal.readOnly': 'You can only read this journal, so I can answer questions but not change anything.', + 'assistant.change.verb.createJournal': 'Start a journal', + 'assistant.change.verb.renameJournal': 'Rename the journal', + 'assistant.change.verb.setJournalStatus': "Change the journal's status", + 'assistant.change.verb.updateJournalSettings': "Change the journal's settings", + 'assistant.change.verb.deleteJournal': 'Delete the journal', + 'assistant.change.verb.linkJournalTrip': 'Add a trip to the journal', + 'assistant.change.verb.unlinkJournalTrip': 'Take a trip out of the journal', + 'assistant.change.verb.restoreJournalSuggestions': 'Bring back the suggestions', + 'assistant.change.verb.setJournalPreferences': 'Change your view of the journal', + 'assistant.card.journeyHeader.open': 'Open the journal', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Shown as', + 'assistant.card.journeyHeader.field.coverGradient': 'Cover colours', + 'assistant.card.journeyHeader.field.trip': 'Trip', + 'assistant.card.journeyHeader.field.suggestions': 'Suggestions back', + 'assistant.card.journeyHeader.tripLink': 'Will be added', + 'assistant.card.journeyHeader.tripUnlink': 'Will be taken out', + 'assistant.card.journeyHeader.deletes': 'The journal goes with all its entries and photos. This cannot be undone.', + 'assistant.card.journeyHeader.entriesStay': '{count} written entries stay', + 'assistant.card.journeyHeader.entriesStay.one': '{count} written entry stays', + 'assistant.card.journeyHeader.suggestionsGo': '{count} open suggestions go', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/es/assistantJourney.ts b/shared/src/i18n/es/assistantJourney.ts index 459b7b556..47a5cfcad 100644 --- a/shared/src/i18n/es/assistantJourney.ts +++ b/shared/src/i18n/es/assistantJourney.ts @@ -30,5 +30,29 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': '¿Qué ponemos en el diario?', 'assistant.journal.readOnly': 'Solo puedes leer este diario, así que puedo responder preguntas, pero no cambiar nada.', + 'assistant.change.verb.createJournal': 'Empezar un diario', + 'assistant.change.verb.renameJournal': 'Cambiar el nombre del diario', + 'assistant.change.verb.setJournalStatus': 'Cambiar el estado del diario', + 'assistant.change.verb.updateJournalSettings': 'Cambiar los ajustes del diario', + 'assistant.change.verb.deleteJournal': 'Eliminar el diario', + 'assistant.change.verb.linkJournalTrip': 'Añadir un viaje al diario', + 'assistant.change.verb.unlinkJournalTrip': 'Quitar un viaje del diario', + 'assistant.change.verb.restoreJournalSuggestions': 'Recuperar las sugerencias', + 'assistant.change.verb.setJournalPreferences': 'Cambiar tu vista del diario', + 'assistant.card.journeyHeader.open': 'Abrir el diario', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Se muestra como', + 'assistant.card.journeyHeader.field.coverGradient': 'Colores de la portada', + 'assistant.card.journeyHeader.field.trip': 'Viaje', + 'assistant.card.journeyHeader.field.suggestions': 'Sugerencias recuperadas', + 'assistant.card.journeyHeader.tripLink': 'Se añadirá', + 'assistant.card.journeyHeader.tripUnlink': 'Se quitará', + 'assistant.card.journeyHeader.deletes': 'El diario se va con todas sus entradas y fotos. No se puede deshacer.', + 'assistant.card.journeyHeader.entriesStay': 'Se quedan {count} entradas escritas', + 'assistant.card.journeyHeader.entriesStay.one': 'Se queda {count} entrada escrita', + 'assistant.card.journeyHeader.suggestionsGo': 'Se van {count} sugerencias abiertas', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/et/assistantJourney.ts b/shared/src/i18n/et/assistantJourney.ts index 981f92541..6906dbe16 100644 --- a/shared/src/i18n/et/assistantJourney.ts +++ b/shared/src/i18n/et/assistantJourney.ts @@ -30,5 +30,29 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Mida päevikusse kirjutame?', 'assistant.journal.readOnly': 'Sa saad seda päevikut ainult lugeda, nii et vastan küsimustele, aga midagi muuta ei saa.', + 'assistant.change.verb.createJournal': 'Alusta päevikut', + 'assistant.change.verb.renameJournal': 'Nimeta päevik ümber', + 'assistant.change.verb.setJournalStatus': 'Muuda päeviku olekut', + 'assistant.change.verb.updateJournalSettings': 'Muuda päeviku seadeid', + 'assistant.change.verb.deleteJournal': 'Kustuta päevik', + 'assistant.change.verb.linkJournalTrip': 'Lisa reis päevikusse', + 'assistant.change.verb.unlinkJournalTrip': 'Eemalda reis päevikust', + 'assistant.change.verb.restoreJournalSuggestions': 'Too soovitused tagasi', + 'assistant.change.verb.setJournalPreferences': 'Muuda oma päevikuvaadet', + 'assistant.card.journeyHeader.open': 'Ava päevik', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Kuvatakse kui', + 'assistant.card.journeyHeader.field.coverGradient': 'Kaane värvid', + 'assistant.card.journeyHeader.field.trip': 'Reis', + 'assistant.card.journeyHeader.field.suggestions': 'Tagasi toodud soovitused', + 'assistant.card.journeyHeader.tripLink': 'Lisatakse', + 'assistant.card.journeyHeader.tripUnlink': 'Eemaldatakse', + 'assistant.card.journeyHeader.deletes': 'Päevik kaob koos kõigi sissekannete ja fotodega. Seda ei saa tagasi võtta.', + 'assistant.card.journeyHeader.entriesStay': '{count} kirjutatud sissekannet jääb alles', + 'assistant.card.journeyHeader.entriesStay.one': '{count} kirjutatud sissekanne jääb alles', + 'assistant.card.journeyHeader.suggestionsGo': '{count} avatud soovitust kaob', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/fr/assistantJourney.ts b/shared/src/i18n/fr/assistantJourney.ts index 306c41b1e..a444ed6a8 100644 --- a/shared/src/i18n/fr/assistantJourney.ts +++ b/shared/src/i18n/fr/assistantJourney.ts @@ -30,5 +30,30 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': "Qu'écrivons-nous dans le journal ?", 'assistant.journal.readOnly': 'Vous pouvez seulement lire ce journal : je peux répondre aux questions, sans rien modifier.', + 'assistant.change.verb.createJournal': 'Commencer un journal', + 'assistant.change.verb.renameJournal': 'Renommer le journal', + 'assistant.change.verb.setJournalStatus': 'Changer le statut du journal', + 'assistant.change.verb.updateJournalSettings': 'Modifier les réglages du journal', + 'assistant.change.verb.deleteJournal': 'Supprimer le journal', + 'assistant.change.verb.linkJournalTrip': 'Ajouter un voyage au journal', + 'assistant.change.verb.unlinkJournalTrip': 'Retirer un voyage du journal', + 'assistant.change.verb.restoreJournalSuggestions': 'Faire revenir les suggestions', + 'assistant.change.verb.setJournalPreferences': 'Changer votre affichage du journal', + 'assistant.card.journeyHeader.open': 'Ouvrir le journal', + 'assistant.card.journeyHeader.setting': '{name} : {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Affiché comme', + 'assistant.card.journeyHeader.field.coverGradient': 'Couleurs de la couverture', + 'assistant.card.journeyHeader.field.trip': 'Voyage', + 'assistant.card.journeyHeader.field.suggestions': 'Suggestions revenues', + 'assistant.card.journeyHeader.tripLink': 'Sera ajouté', + 'assistant.card.journeyHeader.tripUnlink': 'Sera retiré', + 'assistant.card.journeyHeader.deletes': + "Le journal disparaît avec toutes ses entrées et ses photos. Impossible d'annuler.", + 'assistant.card.journeyHeader.entriesStay': '{count} entrées écrites restent', + 'assistant.card.journeyHeader.entriesStay.one': '{count} entrée écrite reste', + 'assistant.card.journeyHeader.suggestionsGo': '{count} suggestions ouvertes disparaissent', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/gr/assistantJourney.ts b/shared/src/i18n/gr/assistantJourney.ts index d88c74668..a42266cef 100644 --- a/shared/src/i18n/gr/assistantJourney.ts +++ b/shared/src/i18n/gr/assistantJourney.ts @@ -30,5 +30,30 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Τι να γράψουμε στο ημερολόγιο;', 'assistant.journal.readOnly': 'Μπορείς μόνο να διαβάσεις αυτό το ημερολόγιο, οπότε απαντώ σε ερωτήσεις αλλά δεν αλλάζω τίποτα.', + 'assistant.change.verb.createJournal': 'Δημιουργία ημερολογίου', + 'assistant.change.verb.renameJournal': 'Μετονομασία ημερολογίου', + 'assistant.change.verb.setJournalStatus': 'Αλλαγή κατάστασης ημερολογίου', + 'assistant.change.verb.updateJournalSettings': 'Αλλαγή ρυθμίσεων ημερολογίου', + 'assistant.change.verb.deleteJournal': 'Διαγραφή ημερολογίου', + 'assistant.change.verb.linkJournalTrip': 'Προσθήκη ταξιδιού στο ημερολόγιο', + 'assistant.change.verb.unlinkJournalTrip': 'Αφαίρεση ταξιδιού από το ημερολόγιο', + 'assistant.change.verb.restoreJournalSuggestions': 'Επαναφορά προτάσεων', + 'assistant.change.verb.setJournalPreferences': 'Αλλαγή της δικής σου προβολής του ημερολογίου', + 'assistant.card.journeyHeader.open': 'Άνοιγμα ημερολογίου', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Εμφανίζεται ως', + 'assistant.card.journeyHeader.field.coverGradient': 'Χρώματα εξωφύλλου', + 'assistant.card.journeyHeader.field.trip': 'Ταξίδι', + 'assistant.card.journeyHeader.field.suggestions': 'Προτάσεις που επανέρχονται', + 'assistant.card.journeyHeader.tripLink': 'Θα προστεθεί', + 'assistant.card.journeyHeader.tripUnlink': 'Θα αφαιρεθεί', + 'assistant.card.journeyHeader.deletes': + 'Το ημερολόγιο διαγράφεται με όλες τις καταχωρίσεις και τις φωτογραφίες του. Δεν αναιρείται.', + 'assistant.card.journeyHeader.entriesStay': 'Μένουν {count} γραμμένες καταχωρίσεις', + 'assistant.card.journeyHeader.entriesStay.one': 'Μένει {count} γραμμένη καταχώριση', + 'assistant.card.journeyHeader.suggestionsGo': 'Φεύγουν {count} ανοιχτές προτάσεις', + 'assistant.card.journeyHeader.suggestionsGo.one': 'Φεύγει {count} ανοιχτή πρόταση', + 'assistant.card.journeyHeader.suggestionsCome': '{count} μέρη μπαίνουν ως προτάσεις', + 'assistant.card.journeyHeader.suggestionsCome.one': '{count} μέρος μπαίνει ως πρόταση', }; export default assistantJourney; diff --git a/shared/src/i18n/hu/assistantJourney.ts b/shared/src/i18n/hu/assistantJourney.ts index 0079e7d9f..93e78f1a3 100644 --- a/shared/src/i18n/hu/assistantJourney.ts +++ b/shared/src/i18n/hu/assistantJourney.ts @@ -30,5 +30,30 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Mi kerüljön a naplóba?', 'assistant.journal.readOnly': 'Ezt a naplót csak olvasni tudod, így kérdésekre válaszolhatok, de semmit sem módosíthatok.', + 'assistant.change.verb.createJournal': 'Napló indítása', + 'assistant.change.verb.renameJournal': 'Napló átnevezése', + 'assistant.change.verb.setJournalStatus': 'Napló állapotának módosítása', + 'assistant.change.verb.updateJournalSettings': 'Napló beállításainak módosítása', + 'assistant.change.verb.deleteJournal': 'Napló törlése', + 'assistant.change.verb.linkJournalTrip': 'Utazás hozzáadása a naplóhoz', + 'assistant.change.verb.unlinkJournalTrip': 'Utazás eltávolítása a naplóból', + 'assistant.change.verb.restoreJournalSuggestions': 'Javaslatok visszahozása', + 'assistant.change.verb.setJournalPreferences': 'A napló saját nézetének módosítása', + 'assistant.card.journeyHeader.open': 'Napló megnyitása', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Így látszik', + 'assistant.card.journeyHeader.field.coverGradient': 'Borító színei', + 'assistant.card.journeyHeader.field.trip': 'Utazás', + 'assistant.card.journeyHeader.field.suggestions': 'Visszahozott javaslatok', + 'assistant.card.journeyHeader.tripLink': 'Hozzá lesz adva', + 'assistant.card.journeyHeader.tripUnlink': 'El lesz távolítva', + 'assistant.card.journeyHeader.deletes': + 'A napló az összes bejegyzésével és fotójával együtt törlődik. Ez nem vonható vissza.', + 'assistant.card.journeyHeader.entriesStay': '{count} megírt bejegyzés marad', + 'assistant.card.journeyHeader.entriesStay.one': '{count} megírt bejegyzés marad', + 'assistant.card.journeyHeader.suggestionsGo': '{count} nyitott javaslat eltűnik', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/id/assistantJourney.ts b/shared/src/i18n/id/assistantJourney.ts index 55f021fb5..41fb81836 100644 --- a/shared/src/i18n/id/assistantJourney.ts +++ b/shared/src/i18n/id/assistantJourney.ts @@ -27,5 +27,26 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Apa yang mau ditulis di jurnal?', 'assistant.journal.readOnly': 'Kamu hanya bisa membaca jurnal ini, jadi aku bisa menjawab pertanyaan tetapi tidak bisa mengubah apa pun.', + 'assistant.change.verb.createJournal': 'Mulai jurnal', + 'assistant.change.verb.renameJournal': 'Ganti nama jurnal', + 'assistant.change.verb.setJournalStatus': 'Ubah status jurnal', + 'assistant.change.verb.updateJournalSettings': 'Ubah pengaturan jurnal', + 'assistant.change.verb.deleteJournal': 'Hapus jurnal', + 'assistant.change.verb.linkJournalTrip': 'Tambahkan perjalanan ke jurnal', + 'assistant.change.verb.unlinkJournalTrip': 'Keluarkan perjalanan dari jurnal', + 'assistant.change.verb.restoreJournalSuggestions': 'Kembalikan saran', + 'assistant.change.verb.setJournalPreferences': 'Ubah tampilan jurnal untukmu', + 'assistant.card.journeyHeader.open': 'Buka jurnal', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Ditampilkan sebagai', + 'assistant.card.journeyHeader.field.coverGradient': 'Warna sampul', + 'assistant.card.journeyHeader.field.trip': 'Perjalanan', + 'assistant.card.journeyHeader.field.suggestions': 'Saran yang kembali', + 'assistant.card.journeyHeader.tripLink': 'Akan ditambahkan', + 'assistant.card.journeyHeader.tripUnlink': 'Akan dikeluarkan', + 'assistant.card.journeyHeader.deletes': 'Jurnal terhapus beserta semua entri dan fotonya. Ini tidak bisa dibatalkan.', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/it/assistantJourney.ts b/shared/src/i18n/it/assistantJourney.ts index 84ef1b75f..82c3f40d1 100644 --- a/shared/src/i18n/it/assistantJourney.ts +++ b/shared/src/i18n/it/assistantJourney.ts @@ -30,5 +30,29 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Cosa scriviamo nel diario?', 'assistant.journal.readOnly': 'Puoi solo leggere questo diario, quindi posso rispondere alle domande ma non cambiare nulla.', + 'assistant.change.verb.createJournal': 'Inizia un diario', + 'assistant.change.verb.renameJournal': 'Rinomina il diario', + 'assistant.change.verb.setJournalStatus': 'Cambia lo stato del diario', + 'assistant.change.verb.updateJournalSettings': 'Cambia le impostazioni del diario', + 'assistant.change.verb.deleteJournal': 'Elimina il diario', + 'assistant.change.verb.linkJournalTrip': 'Aggiungi un viaggio al diario', + 'assistant.change.verb.unlinkJournalTrip': 'Togli un viaggio dal diario', + 'assistant.change.verb.restoreJournalSuggestions': 'Recupera i suggerimenti', + 'assistant.change.verb.setJournalPreferences': 'Cambia la tua vista del diario', + 'assistant.card.journeyHeader.open': 'Apri il diario', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Mostrato come', + 'assistant.card.journeyHeader.field.coverGradient': 'Colori della copertina', + 'assistant.card.journeyHeader.field.trip': 'Viaggio', + 'assistant.card.journeyHeader.field.suggestions': 'Suggerimenti recuperati', + 'assistant.card.journeyHeader.tripLink': 'Verrà aggiunto', + 'assistant.card.journeyHeader.tripUnlink': 'Verrà tolto', + 'assistant.card.journeyHeader.deletes': 'Il diario se ne va con tutte le voci e le foto. Non si può annullare.', + 'assistant.card.journeyHeader.entriesStay': 'Restano {count} voci scritte', + 'assistant.card.journeyHeader.entriesStay.one': 'Resta {count} voce scritta', + 'assistant.card.journeyHeader.suggestionsGo': 'Spariscono {count} suggerimenti aperti', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/ja/assistantJourney.ts b/shared/src/i18n/ja/assistantJourney.ts index 066792eca..54d5216ae 100644 --- a/shared/src/i18n/ja/assistantJourney.ts +++ b/shared/src/i18n/ja/assistantJourney.ts @@ -26,5 +26,26 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': 'この数日をまとめる', 'assistant.journal.welcome': 'ジャーナルに何を書きましょうか?', 'assistant.journal.readOnly': 'このジャーナルは閲覧のみなので、質問には答えられますが変更はできません。', + 'assistant.change.verb.createJournal': 'ジャーナルを始める', + 'assistant.change.verb.renameJournal': 'ジャーナルの名前を変更', + 'assistant.change.verb.setJournalStatus': 'ジャーナルの状態を変更', + 'assistant.change.verb.updateJournalSettings': 'ジャーナルの設定を変更', + 'assistant.change.verb.deleteJournal': 'ジャーナルを削除', + 'assistant.change.verb.linkJournalTrip': 'ジャーナルに旅を追加', + 'assistant.change.verb.unlinkJournalTrip': 'ジャーナルから旅を外す', + 'assistant.change.verb.restoreJournalSuggestions': '提案を元に戻す', + 'assistant.change.verb.setJournalPreferences': '自分のジャーナル表示を変更', + 'assistant.card.journeyHeader.open': 'ジャーナルを開く', + 'assistant.card.journeyHeader.setting': '{name}:{value}', + 'assistant.card.journeyHeader.field.statusOverride': '表示', + 'assistant.card.journeyHeader.field.coverGradient': '表紙の色', + 'assistant.card.journeyHeader.field.trip': '旅', + 'assistant.card.journeyHeader.field.suggestions': '戻る提案', + 'assistant.card.journeyHeader.tripLink': '追加されます', + 'assistant.card.journeyHeader.tripUnlink': '外されます', + 'assistant.card.journeyHeader.deletes': 'ジャーナルはすべてのエントリーと写真ごと削除されます。元に戻せません。', + 'assistant.card.journeyHeader.entriesStay': '書かれたエントリー{count}件は残ります', + 'assistant.card.journeyHeader.suggestionsGo': '未記入の提案{count}件はなくなります', + 'assistant.card.journeyHeader.suggestionsCome': '{count}か所が提案として加わります', }; export default assistantJourney; diff --git a/shared/src/i18n/ko/assistantJourney.ts b/shared/src/i18n/ko/assistantJourney.ts index 69548d02a..4948d6946 100644 --- a/shared/src/i18n/ko/assistantJourney.ts +++ b/shared/src/i18n/ko/assistantJourney.ts @@ -26,5 +26,26 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': '이 며칠 요약하기', 'assistant.journal.welcome': '저널에 무엇을 적을까요?', 'assistant.journal.readOnly': '이 저널은 읽기만 할 수 있어서 질문에는 답할 수 있지만 아무것도 바꿀 수 없어요.', + 'assistant.change.verb.createJournal': '저널 시작하기', + 'assistant.change.verb.renameJournal': '저널 이름 바꾸기', + 'assistant.change.verb.setJournalStatus': '저널 상태 바꾸기', + 'assistant.change.verb.updateJournalSettings': '저널 설정 바꾸기', + 'assistant.change.verb.deleteJournal': '저널 삭제', + 'assistant.change.verb.linkJournalTrip': '저널에 여행 추가', + 'assistant.change.verb.unlinkJournalTrip': '저널에서 여행 빼기', + 'assistant.change.verb.restoreJournalSuggestions': '제안 되돌리기', + 'assistant.change.verb.setJournalPreferences': '내 저널 보기 바꾸기', + 'assistant.card.journeyHeader.open': '저널 열기', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': '표시', + 'assistant.card.journeyHeader.field.coverGradient': '표지 색상', + 'assistant.card.journeyHeader.field.trip': '여행', + 'assistant.card.journeyHeader.field.suggestions': '되돌아오는 제안', + 'assistant.card.journeyHeader.tripLink': '추가됩니다', + 'assistant.card.journeyHeader.tripUnlink': '빠집니다', + 'assistant.card.journeyHeader.deletes': '저널이 모든 항목과 사진과 함께 삭제됩니다. 되돌릴 수 없어요.', + 'assistant.card.journeyHeader.entriesStay': '작성된 항목 {count}개는 남아요', + 'assistant.card.journeyHeader.suggestionsGo': '열린 제안 {count}개는 사라져요', + 'assistant.card.journeyHeader.suggestionsCome': '장소 {count}곳이 제안으로 들어와요', }; export default assistantJourney; diff --git a/shared/src/i18n/nl/assistantJourney.ts b/shared/src/i18n/nl/assistantJourney.ts index 814342a5f..3a48bea57 100644 --- a/shared/src/i18n/nl/assistantJourney.ts +++ b/shared/src/i18n/nl/assistantJourney.ts @@ -29,5 +29,30 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': 'Vat deze dagen samen', 'assistant.journal.welcome': 'Wat komt er in het dagboek?', 'assistant.journal.readOnly': 'Je kunt dit dagboek alleen lezen, dus ik kan vragen beantwoorden maar niets wijzigen.', + 'assistant.change.verb.createJournal': 'Dagboek beginnen', + 'assistant.change.verb.renameJournal': 'Dagboek hernoemen', + 'assistant.change.verb.setJournalStatus': 'Status van het dagboek wijzigen', + 'assistant.change.verb.updateJournalSettings': 'Instellingen van het dagboek wijzigen', + 'assistant.change.verb.deleteJournal': 'Dagboek verwijderen', + 'assistant.change.verb.linkJournalTrip': 'Reis aan het dagboek toevoegen', + 'assistant.change.verb.unlinkJournalTrip': 'Reis uit het dagboek halen', + 'assistant.change.verb.restoreJournalSuggestions': 'Suggesties terughalen', + 'assistant.change.verb.setJournalPreferences': 'Je weergave van het dagboek wijzigen', + 'assistant.card.journeyHeader.open': 'Dagboek openen', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Getoond als', + 'assistant.card.journeyHeader.field.coverGradient': 'Kleuren van de omslag', + 'assistant.card.journeyHeader.field.trip': 'Reis', + 'assistant.card.journeyHeader.field.suggestions': 'Teruggehaalde suggesties', + 'assistant.card.journeyHeader.tripLink': 'Wordt toegevoegd', + 'assistant.card.journeyHeader.tripUnlink': 'Wordt eruit gehaald', + 'assistant.card.journeyHeader.deletes': + "Het dagboek verdwijnt met al zijn berichten en foto's. Dit kan niet ongedaan worden gemaakt.", + 'assistant.card.journeyHeader.entriesStay': '{count} geschreven berichten blijven', + 'assistant.card.journeyHeader.entriesStay.one': '{count} geschreven bericht blijft', + 'assistant.card.journeyHeader.suggestionsGo': '{count} open suggesties verdwijnen', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/pl/assistantJourney.ts b/shared/src/i18n/pl/assistantJourney.ts index ed0d92b96..258d377f5 100644 --- a/shared/src/i18n/pl/assistantJourney.ts +++ b/shared/src/i18n/pl/assistantJourney.ts @@ -36,5 +36,35 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Co wpiszemy do dziennika?', 'assistant.journal.readOnly': 'Możesz tylko czytać ten dziennik, więc mogę odpowiadać na pytania, ale niczego nie zmienię.', + 'assistant.change.verb.createJournal': 'Załóż dziennik', + 'assistant.change.verb.renameJournal': 'Zmień nazwę dziennika', + 'assistant.change.verb.setJournalStatus': 'Zmień stan dziennika', + 'assistant.change.verb.updateJournalSettings': 'Zmień ustawienia dziennika', + 'assistant.change.verb.deleteJournal': 'Usuń dziennik', + 'assistant.change.verb.linkJournalTrip': 'Dodaj podróż do dziennika', + 'assistant.change.verb.unlinkJournalTrip': 'Usuń podróż z dziennika', + 'assistant.change.verb.restoreJournalSuggestions': 'Przywróć sugestie', + 'assistant.change.verb.setJournalPreferences': 'Zmień swój widok dziennika', + 'assistant.card.journeyHeader.open': 'Otwórz dziennik', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Wyświetlany jako', + 'assistant.card.journeyHeader.field.coverGradient': 'Kolory okładki', + 'assistant.card.journeyHeader.field.trip': 'Podróż', + 'assistant.card.journeyHeader.field.suggestions': 'Przywrócone sugestie', + 'assistant.card.journeyHeader.tripLink': 'Zostanie dodana', + 'assistant.card.journeyHeader.tripUnlink': 'Zostanie usunięta', + 'assistant.card.journeyHeader.deletes': 'Dziennik zniknie ze wszystkimi wpisami i zdjęciami. Tego nie da się cofnąć.', + 'assistant.card.journeyHeader.entriesStay': 'Zostaje {count} napisanych wpisów', + 'assistant.card.journeyHeader.entriesStay.one': 'Zostaje {count} napisany wpis', + 'assistant.card.journeyHeader.entriesStay.few': 'Zostają {count} napisane wpisy', + 'assistant.card.journeyHeader.entriesStay.many': 'Zostaje {count} napisanych wpisów', + 'assistant.card.journeyHeader.suggestionsGo': 'Znika {count} otwartych sugestii', + 'assistant.card.journeyHeader.suggestionsGo.one': 'Znika {count} otwarta sugestia', + 'assistant.card.journeyHeader.suggestionsGo.few': 'Znikają {count} otwarte sugestie', + 'assistant.card.journeyHeader.suggestionsGo.many': 'Znika {count} otwartych sugestii', + 'assistant.card.journeyHeader.suggestionsCome': '{count} miejsc dojdzie jako sugestie', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/ru/assistantJourney.ts b/shared/src/i18n/ru/assistantJourney.ts index cbf20ca7f..cabfe103f 100644 --- a/shared/src/i18n/ru/assistantJourney.ts +++ b/shared/src/i18n/ru/assistantJourney.ts @@ -36,5 +36,35 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Что запишем в дневник?', 'assistant.journal.readOnly': 'Этот дневник тебе доступен только для чтения, поэтому я могу отвечать на вопросы, но ничего не изменю.', + 'assistant.change.verb.createJournal': 'Начать дневник', + 'assistant.change.verb.renameJournal': 'Переименовать дневник', + 'assistant.change.verb.setJournalStatus': 'Изменить статус дневника', + 'assistant.change.verb.updateJournalSettings': 'Изменить настройки дневника', + 'assistant.change.verb.deleteJournal': 'Удалить дневник', + 'assistant.change.verb.linkJournalTrip': 'Добавить поездку в дневник', + 'assistant.change.verb.unlinkJournalTrip': 'Убрать поездку из дневника', + 'assistant.change.verb.restoreJournalSuggestions': 'Вернуть предложения', + 'assistant.change.verb.setJournalPreferences': 'Изменить свой вид дневника', + 'assistant.card.journeyHeader.open': 'Открыть дневник', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Показывается как', + 'assistant.card.journeyHeader.field.coverGradient': 'Цвета обложки', + 'assistant.card.journeyHeader.field.trip': 'Поездка', + 'assistant.card.journeyHeader.field.suggestions': 'Возвращённые предложения', + 'assistant.card.journeyHeader.tripLink': 'Будет добавлена', + 'assistant.card.journeyHeader.tripUnlink': 'Будет убрана', + 'assistant.card.journeyHeader.deletes': 'Дневник удалится со всеми записями и фото. Отменить это нельзя.', + 'assistant.card.journeyHeader.entriesStay': 'Останутся {count} написанных записей', + 'assistant.card.journeyHeader.entriesStay.one': 'Останется {count} написанная запись', + 'assistant.card.journeyHeader.entriesStay.few': 'Останутся {count} написанные записи', + 'assistant.card.journeyHeader.entriesStay.many': 'Останутся {count} написанных записей', + 'assistant.card.journeyHeader.suggestionsGo': 'Исчезнут {count} открытых предложений', + 'assistant.card.journeyHeader.suggestionsGo.one': 'Исчезнет {count} открытое предложение', + 'assistant.card.journeyHeader.suggestionsGo.few': 'Исчезнут {count} открытых предложения', + 'assistant.card.journeyHeader.suggestionsGo.many': 'Исчезнут {count} открытых предложений', + 'assistant.card.journeyHeader.suggestionsCome': '{count} мест появятся как предложения', + 'assistant.card.journeyHeader.suggestionsCome.one': '{count} место появится как предложение', + 'assistant.card.journeyHeader.suggestionsCome.few': '{count} места появятся как предложения', + 'assistant.card.journeyHeader.suggestionsCome.many': '{count} мест появятся как предложения', }; export default assistantJourney; diff --git a/shared/src/i18n/sk/assistantJourney.ts b/shared/src/i18n/sk/assistantJourney.ts index 93b4c5390..c291ad92b 100644 --- a/shared/src/i18n/sk/assistantJourney.ts +++ b/shared/src/i18n/sk/assistantJourney.ts @@ -32,5 +32,32 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': 'Zhrnúť tieto dni', 'assistant.journal.welcome': 'Čo zapíšeme do denníka?', 'assistant.journal.readOnly': 'Tento denník môžeš iba čítať, takže môžem odpovedať na otázky, ale nič meniť.', + 'assistant.change.verb.createJournal': 'Založiť denník', + 'assistant.change.verb.renameJournal': 'Premenovať denník', + 'assistant.change.verb.setJournalStatus': 'Zmeniť stav denníka', + 'assistant.change.verb.updateJournalSettings': 'Zmeniť nastavenia denníka', + 'assistant.change.verb.deleteJournal': 'Zmazať denník', + 'assistant.change.verb.linkJournalTrip': 'Pridať cestu do denníka', + 'assistant.change.verb.unlinkJournalTrip': 'Odobrať cestu z denníka', + 'assistant.change.verb.restoreJournalSuggestions': 'Vrátiť návrhy', + 'assistant.change.verb.setJournalPreferences': 'Zmeniť svoje zobrazenie denníka', + 'assistant.card.journeyHeader.open': 'Otvoriť denník', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Zobrazené ako', + 'assistant.card.journeyHeader.field.coverGradient': 'Farby obálky', + 'assistant.card.journeyHeader.field.trip': 'Cesta', + 'assistant.card.journeyHeader.field.suggestions': 'Vrátené návrhy', + 'assistant.card.journeyHeader.tripLink': 'Bude pridaná', + 'assistant.card.journeyHeader.tripUnlink': 'Bude odobraná', + 'assistant.card.journeyHeader.deletes': 'Denník zmizne so všetkými záznamami a fotkami. Nedá sa to vrátiť.', + 'assistant.card.journeyHeader.entriesStay': '{count} napísaných záznamov zostane', + 'assistant.card.journeyHeader.entriesStay.one': '{count} napísaný záznam zostane', + 'assistant.card.journeyHeader.entriesStay.few': '{count} napísané záznamy zostanú', + 'assistant.card.journeyHeader.suggestionsGo': '{count} otvorených návrhov zmizne', + 'assistant.card.journeyHeader.suggestionsGo.one': '{count} otvorený návrh zmizne', + 'assistant.card.journeyHeader.suggestionsGo.few': '{count} otvorené návrhy zmiznú', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/sv/assistantJourney.ts b/shared/src/i18n/sv/assistantJourney.ts index 5b35f9351..adacafae8 100644 --- a/shared/src/i18n/sv/assistantJourney.ts +++ b/shared/src/i18n/sv/assistantJourney.ts @@ -29,5 +29,29 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': 'Sammanfatta de här dagarna', 'assistant.journal.welcome': 'Vad ska stå i dagboken?', 'assistant.journal.readOnly': 'Du kan bara läsa den här dagboken, så jag kan svara på frågor men inte ändra något.', + 'assistant.change.verb.createJournal': 'Starta en dagbok', + 'assistant.change.verb.renameJournal': 'Byt namn på dagboken', + 'assistant.change.verb.setJournalStatus': 'Ändra dagbokens status', + 'assistant.change.verb.updateJournalSettings': 'Ändra dagbokens inställningar', + 'assistant.change.verb.deleteJournal': 'Radera dagboken', + 'assistant.change.verb.linkJournalTrip': 'Lägg till en resa i dagboken', + 'assistant.change.verb.unlinkJournalTrip': 'Ta bort en resa ur dagboken', + 'assistant.change.verb.restoreJournalSuggestions': 'Ta tillbaka förslagen', + 'assistant.change.verb.setJournalPreferences': 'Ändra din vy av dagboken', + 'assistant.card.journeyHeader.open': 'Öppna dagboken', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Visas som', + 'assistant.card.journeyHeader.field.coverGradient': 'Omslagets färger', + 'assistant.card.journeyHeader.field.trip': 'Resa', + 'assistant.card.journeyHeader.field.suggestions': 'Förslag tillbaka', + 'assistant.card.journeyHeader.tripLink': 'Läggs till', + 'assistant.card.journeyHeader.tripUnlink': 'Tas bort', + 'assistant.card.journeyHeader.deletes': 'Dagboken försvinner med alla inlägg och foton. Det går inte att ångra.', + 'assistant.card.journeyHeader.entriesStay': '{count} skrivna inlägg finns kvar', + 'assistant.card.journeyHeader.entriesStay.one': '{count} skrivet inlägg finns kvar', + 'assistant.card.journeyHeader.suggestionsGo': '{count} öppna förslag försvinner', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/th/assistantJourney.ts b/shared/src/i18n/th/assistantJourney.ts index bb5b5c4c2..2a962a4b9 100644 --- a/shared/src/i18n/th/assistantJourney.ts +++ b/shared/src/i18n/th/assistantJourney.ts @@ -26,5 +26,26 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': 'สรุปวันเหล่านี้', 'assistant.journal.welcome': 'จะเขียนอะไรลงในบันทึกดี?', 'assistant.journal.readOnly': 'คุณอ่านบันทึกนี้ได้อย่างเดียว ฉันจึงตอบคำถามได้แต่เปลี่ยนแปลงอะไรไม่ได้', + 'assistant.change.verb.createJournal': 'เริ่มบันทึกใหม่', + 'assistant.change.verb.renameJournal': 'เปลี่ยนชื่อบันทึก', + 'assistant.change.verb.setJournalStatus': 'เปลี่ยนสถานะบันทึก', + 'assistant.change.verb.updateJournalSettings': 'เปลี่ยนการตั้งค่าบันทึก', + 'assistant.change.verb.deleteJournal': 'ลบบันทึก', + 'assistant.change.verb.linkJournalTrip': 'เพิ่มทริปลงในบันทึก', + 'assistant.change.verb.unlinkJournalTrip': 'เอาทริปออกจากบันทึก', + 'assistant.change.verb.restoreJournalSuggestions': 'นำคำแนะนำกลับมา', + 'assistant.change.verb.setJournalPreferences': 'เปลี่ยนมุมมองบันทึกของคุณ', + 'assistant.card.journeyHeader.open': 'เปิดบันทึก', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'แสดงเป็น', + 'assistant.card.journeyHeader.field.coverGradient': 'สีหน้าปก', + 'assistant.card.journeyHeader.field.trip': 'ทริป', + 'assistant.card.journeyHeader.field.suggestions': 'คำแนะนำที่กลับมา', + 'assistant.card.journeyHeader.tripLink': 'จะถูกเพิ่ม', + 'assistant.card.journeyHeader.tripUnlink': 'จะถูกนำออก', + 'assistant.card.journeyHeader.deletes': 'บันทึกจะถูกลบพร้อมรายการและรูปภาพทั้งหมด ย้อนกลับไม่ได้', + 'assistant.card.journeyHeader.entriesStay': 'รายการที่เขียนแล้ว {count} รายการยังอยู่', + 'assistant.card.journeyHeader.suggestionsGo': 'คำแนะนำที่ยังไม่เขียน {count} รายการจะหายไป', + 'assistant.card.journeyHeader.suggestionsCome': 'สถานที่ {count} แห่งจะเข้ามาเป็นคำแนะนำ', }; export default assistantJourney; diff --git a/shared/src/i18n/tr/assistantJourney.ts b/shared/src/i18n/tr/assistantJourney.ts index 59ccd57d0..da11a595f 100644 --- a/shared/src/i18n/tr/assistantJourney.ts +++ b/shared/src/i18n/tr/assistantJourney.ts @@ -30,5 +30,29 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Günlüğe ne yazalım?', 'assistant.journal.readOnly': 'Bu günlüğü yalnızca okuyabilirsin, bu yüzden soruları yanıtlayabilirim ama hiçbir şeyi değiştiremem.', + 'assistant.change.verb.createJournal': 'Günlük başlat', + 'assistant.change.verb.renameJournal': 'Günlüğün adını değiştir', + 'assistant.change.verb.setJournalStatus': 'Günlüğün durumunu değiştir', + 'assistant.change.verb.updateJournalSettings': 'Günlüğün ayarlarını değiştir', + 'assistant.change.verb.deleteJournal': 'Günlüğü sil', + 'assistant.change.verb.linkJournalTrip': 'Günlüğe seyahat ekle', + 'assistant.change.verb.unlinkJournalTrip': 'Seyahati günlükten çıkar', + 'assistant.change.verb.restoreJournalSuggestions': 'Önerileri geri getir', + 'assistant.change.verb.setJournalPreferences': 'Günlüğün senin için görünümünü değiştir', + 'assistant.card.journeyHeader.open': 'Günlüğü aç', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Görünen', + 'assistant.card.journeyHeader.field.coverGradient': 'Kapak renkleri', + 'assistant.card.journeyHeader.field.trip': 'Seyahat', + 'assistant.card.journeyHeader.field.suggestions': 'Geri gelen öneriler', + 'assistant.card.journeyHeader.tripLink': 'Eklenecek', + 'assistant.card.journeyHeader.tripUnlink': 'Çıkarılacak', + 'assistant.card.journeyHeader.deletes': 'Günlük tüm kayıtları ve fotoğraflarıyla birlikte silinir. Bu geri alınamaz.', + 'assistant.card.journeyHeader.entriesStay': '{count} yazılmış kayıt kalır', + 'assistant.card.journeyHeader.entriesStay.one': '{count} yazılmış kayıt kalır', + 'assistant.card.journeyHeader.suggestionsGo': '{count} açık öneri kalkar', + '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', }; export default assistantJourney; diff --git a/shared/src/i18n/uk/assistantJourney.ts b/shared/src/i18n/uk/assistantJourney.ts index 9641ee95f..949b49699 100644 --- a/shared/src/i18n/uk/assistantJourney.ts +++ b/shared/src/i18n/uk/assistantJourney.ts @@ -36,5 +36,35 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Що запишемо до щоденника?', 'assistant.journal.readOnly': 'Цей щоденник ти можеш лише читати, тож я можу відповідати на запитання, але нічого не зміню.', + 'assistant.change.verb.createJournal': 'Почати щоденник', + 'assistant.change.verb.renameJournal': 'Перейменувати щоденник', + 'assistant.change.verb.setJournalStatus': 'Змінити статус щоденника', + 'assistant.change.verb.updateJournalSettings': 'Змінити налаштування щоденника', + 'assistant.change.verb.deleteJournal': 'Видалити щоденник', + 'assistant.change.verb.linkJournalTrip': 'Додати подорож до щоденника', + 'assistant.change.verb.unlinkJournalTrip': 'Прибрати подорож зі щоденника', + 'assistant.change.verb.restoreJournalSuggestions': 'Повернути пропозиції', + 'assistant.change.verb.setJournalPreferences': 'Змінити свій вигляд щоденника', + 'assistant.card.journeyHeader.open': 'Відкрити щоденник', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Показується як', + 'assistant.card.journeyHeader.field.coverGradient': 'Кольори обкладинки', + 'assistant.card.journeyHeader.field.trip': 'Подорож', + 'assistant.card.journeyHeader.field.suggestions': 'Повернені пропозиції', + 'assistant.card.journeyHeader.tripLink': 'Буде додана', + 'assistant.card.journeyHeader.tripUnlink': 'Буде прибрана', + 'assistant.card.journeyHeader.deletes': 'Щоденник зникне з усіма записами й фото. Це не можна скасувати.', + 'assistant.card.journeyHeader.entriesStay': 'Залишаться {count} написаних записів', + 'assistant.card.journeyHeader.entriesStay.one': 'Залишиться {count} написаний запис', + 'assistant.card.journeyHeader.entriesStay.few': 'Залишаться {count} написані записи', + 'assistant.card.journeyHeader.entriesStay.many': 'Залишаться {count} написаних записів', + 'assistant.card.journeyHeader.suggestionsGo': 'Зникнуть {count} відкритих пропозицій', + 'assistant.card.journeyHeader.suggestionsGo.one': 'Зникне {count} відкрита пропозиція', + 'assistant.card.journeyHeader.suggestionsGo.few': 'Зникнуть {count} відкриті пропозиції', + 'assistant.card.journeyHeader.suggestionsGo.many': 'Зникнуть {count} відкритих пропозицій', + 'assistant.card.journeyHeader.suggestionsCome': '{count} місць з’являться як пропозиції', + 'assistant.card.journeyHeader.suggestionsCome.one': '{count} місце з’явиться як пропозиція', + 'assistant.card.journeyHeader.suggestionsCome.few': '{count} місця з’являться як пропозиції', + 'assistant.card.journeyHeader.suggestionsCome.many': '{count} місць з’являться як пропозиції', }; export default assistantJourney; diff --git a/shared/src/i18n/vi/assistantJourney.ts b/shared/src/i18n/vi/assistantJourney.ts index e0b78c1e8..6dc40e0b9 100644 --- a/shared/src/i18n/vi/assistantJourney.ts +++ b/shared/src/i18n/vi/assistantJourney.ts @@ -27,5 +27,26 @@ const assistantJourney: TranslationStrings = { 'assistant.journal.welcome': 'Mình viết gì vào nhật ký nhé?', 'assistant.journal.readOnly': 'Bạn chỉ có thể đọc nhật ký này, nên mình có thể trả lời câu hỏi nhưng không thay đổi được gì.', + 'assistant.change.verb.createJournal': 'Bắt đầu nhật ký', + 'assistant.change.verb.renameJournal': 'Đổi tên nhật ký', + 'assistant.change.verb.setJournalStatus': 'Đổi trạng thái nhật ký', + 'assistant.change.verb.updateJournalSettings': 'Đổi cài đặt nhật ký', + 'assistant.change.verb.deleteJournal': 'Xóa nhật ký', + 'assistant.change.verb.linkJournalTrip': 'Thêm chuyến đi vào nhật ký', + 'assistant.change.verb.unlinkJournalTrip': 'Bỏ chuyến đi khỏi nhật ký', + 'assistant.change.verb.restoreJournalSuggestions': 'Khôi phục các gợi ý', + 'assistant.change.verb.setJournalPreferences': 'Đổi cách bạn xem nhật ký', + 'assistant.card.journeyHeader.open': 'Mở nhật ký', + 'assistant.card.journeyHeader.setting': '{name}: {value}', + 'assistant.card.journeyHeader.field.statusOverride': 'Hiển thị là', + 'assistant.card.journeyHeader.field.coverGradient': 'Màu bìa', + 'assistant.card.journeyHeader.field.trip': 'Chuyến đi', + 'assistant.card.journeyHeader.field.suggestions': 'Gợi ý được khôi phục', + 'assistant.card.journeyHeader.tripLink': 'Sẽ được thêm', + 'assistant.card.journeyHeader.tripUnlink': 'Sẽ bị bỏ ra', + 'assistant.card.journeyHeader.deletes': 'Nhật ký sẽ bị xóa cùng mọi mục và ảnh. Không thể hoàn tác.', + '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 ý', }; export default assistantJourney; diff --git a/shared/src/i18n/zh-TW/assistantJourney.ts b/shared/src/i18n/zh-TW/assistantJourney.ts index 03505846b..c0d215cad 100644 --- a/shared/src/i18n/zh-TW/assistantJourney.ts +++ b/shared/src/i18n/zh-TW/assistantJourney.ts @@ -26,5 +26,26 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': '總結這幾天', 'assistant.journal.welcome': '日誌裡要寫些什麼?', 'assistant.journal.readOnly': '你只能查看這本日誌,所以我可以回答問題,但不能做任何更改。', + 'assistant.change.verb.createJournal': '建立日誌', + 'assistant.change.verb.renameJournal': '重新命名日誌', + 'assistant.change.verb.setJournalStatus': '變更日誌狀態', + 'assistant.change.verb.updateJournalSettings': '變更日誌設定', + 'assistant.change.verb.deleteJournal': '刪除日誌', + 'assistant.change.verb.linkJournalTrip': '將旅行加入日誌', + 'assistant.change.verb.unlinkJournalTrip': '從日誌移除旅行', + 'assistant.change.verb.restoreJournalSuggestions': '恢復建議', + 'assistant.change.verb.setJournalPreferences': '變更你的日誌檢視', + 'assistant.card.journeyHeader.open': '開啟日誌', + 'assistant.card.journeyHeader.setting': '{name}:{value}', + 'assistant.card.journeyHeader.field.statusOverride': '顯示為', + 'assistant.card.journeyHeader.field.coverGradient': '封面顏色', + 'assistant.card.journeyHeader.field.trip': '旅行', + 'assistant.card.journeyHeader.field.suggestions': '恢復的建議', + 'assistant.card.journeyHeader.tripLink': '將被加入', + 'assistant.card.journeyHeader.tripUnlink': '將被移除', + 'assistant.card.journeyHeader.deletes': '日誌及其所有條目和照片都會被刪除,且無法復原。', + 'assistant.card.journeyHeader.entriesStay': '已寫的 {count} 則條目會保留', + 'assistant.card.journeyHeader.suggestionsGo': '{count} 則未寫的建議會被移除', + 'assistant.card.journeyHeader.suggestionsCome': '{count} 個地點會作為建議加入', }; export default assistantJourney; diff --git a/shared/src/i18n/zh/assistantJourney.ts b/shared/src/i18n/zh/assistantJourney.ts index 17eefe4e1..0d74c8d1f 100644 --- a/shared/src/i18n/zh/assistantJourney.ts +++ b/shared/src/i18n/zh/assistantJourney.ts @@ -26,5 +26,26 @@ const assistantJourney: TranslationStrings = { 'assistant.followUp.journalSummary': '总结这几天', 'assistant.journal.welcome': '日志里要写些什么?', 'assistant.journal.readOnly': '你只能查看这本日志,所以我可以回答问题,但不能做任何更改。', + 'assistant.change.verb.createJournal': '新建日志', + 'assistant.change.verb.renameJournal': '重命名日志', + 'assistant.change.verb.setJournalStatus': '更改日志状态', + 'assistant.change.verb.updateJournalSettings': '更改日志设置', + 'assistant.change.verb.deleteJournal': '删除日志', + 'assistant.change.verb.linkJournalTrip': '向日志添加旅行', + 'assistant.change.verb.unlinkJournalTrip': '从日志移除旅行', + 'assistant.change.verb.restoreJournalSuggestions': '恢复建议', + 'assistant.change.verb.setJournalPreferences': '更改你的日志视图', + 'assistant.card.journeyHeader.open': '打开日志', + 'assistant.card.journeyHeader.setting': '{name}:{value}', + 'assistant.card.journeyHeader.field.statusOverride': '显示为', + 'assistant.card.journeyHeader.field.coverGradient': '封面颜色', + 'assistant.card.journeyHeader.field.trip': '旅行', + 'assistant.card.journeyHeader.field.suggestions': '恢复的建议', + 'assistant.card.journeyHeader.tripLink': '将被添加', + 'assistant.card.journeyHeader.tripUnlink': '将被移除', + 'assistant.card.journeyHeader.deletes': '日志及其所有条目和照片都会被删除,且无法撤销。', + 'assistant.card.journeyHeader.entriesStay': '已写的 {count} 条条目会保留', + 'assistant.card.journeyHeader.suggestionsGo': '{count} 条未写的建议会被移除', + 'assistant.card.journeyHeader.suggestionsCome': '{count} 个地点会作为建议加入', }; export default assistantJourney;