feat(assistant): collection list and saved place cards

This commit is contained in:
Maurice
2026-10-22 15:50:52 +02:00
parent 237e4e4897
commit fd15a7343e
68 changed files with 6052 additions and 92 deletions
-1
View File
@@ -12,7 +12,6 @@
"src/components/Collections/CollectionPlaceDetail.tsx": 1,
"src/components/Collections/ListEditorModal.tsx": 1,
"src/components/Collections/MSaveToCollectionSheet.tsx": 3,
"src/components/Collections/StatusBadge.tsx": 1,
"src/components/Dawarich/DawarichAcceptDialog.tsx": 3,
"src/components/Dawarich/DawarichAtlasDialog.tsx": 3,
"src/components/Dawarich/DawarichSuggestionsPanel.tsx": 1,
-1
View File
@@ -312,7 +312,6 @@
"pages/TripPlannerPage.tsx": 7,
"pages/VacayPage.tsx": 13,
"pages/atlas/useAtlas.ts": 2,
"pages/collections/collectionsModel.ts": 1,
"pages/collections/useCollections.ts": 1,
"pages/sharedTrip/SharedChrome.tsx": 2,
"pages/sharedTrip/SharedMap.tsx": 1,
@@ -0,0 +1,147 @@
/**
* FE-AI-COLL-010 to FE-AI-COLL-017
*
* The answer cards of the collections as the chat draws them: a list's head
* with its size, people and places per status, its saved places as rows
* (status, labels, rating, the trip having them), the rest as a way to the
* list, the map pin a row lights and where a click takes it, the phone
* without hover, and the lists card with invites and where a place is saved.
*/
import type { AssistantMessage, TrekCard } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { listRow, listsCard, placesCard, savedPlace } from '../../../../../tests/helpers/collectionCards';
import { fireEvent, render, screen, within } from '../../../../../tests/helpers/render';
import { useAssistantStore } from '../../../../store/assistantStore';
import type { CardsBlock } from '../../assistantParts';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import AssistantCardBlock from '../AssistantCardBlock';
const INITIAL = useAssistantStore.getState();
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
vi.restoreAllMocks();
});
const spots = (count: number) =>
Array.from({ length: count }, (_, i) => savedPlace({ id: 100 + i, name: `Spot ${i}`, labelIds: [] }));
function draw(cards: TrekCard[], view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', density: 'compact', onOpen: vi.fn(), ...view };
const block: CardsBlock = {
kind: 'cards',
key: 'cards-1',
group: cards[0]?.kind ?? 'none',
entries: cards.map((card, index) => ({ toolCallId: `call_${index}`, card })),
};
const message = { id: 'msg-1', role: 'assistant', parts: [] } as unknown as AssistantMessage;
render(
<AssistantViewContext.Provider value={value}>
<AssistantCardBlock block={block} message={message} />
</AssistantViewContext.Provider>
);
return value;
}
describe('collection answer cards', () => {
it('FE-AI-COLL-010: a list heads its card with its size, its people, its places per status and the way to it', () => {
const view = draw([placesCard()]);
const card = screen.getByRole('region', { name: 'Saved places in Paris food' });
expect(within(card).getByText('Paris food')).toBeInTheDocument();
expect(within(card).getByText('12 places')).toBeInTheDocument();
expect(within(card).getByText('1 member')).toBeInTheDocument();
expect(within(card).getByText('Idea')).toBeInTheDocument();
expect(within(card).getByText('3')).toBeInTheDocument();
fireEvent.click(within(card).getByRole('button', { name: /Open list/ }));
expect(view.onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/collections/12' });
});
it('FE-AI-COLL-011: a saved place shows its status, two labels and the rest counted, its rating and the trip having it', () => {
draw([placesCard({ places: [savedPlace({ inTripPlaceId: 6 })] })]);
expect(screen.getByText('Septime')).toBeInTheDocument();
expect(screen.getByText('80 Rue de Charonne')).toBeInTheDocument();
expect(screen.getAllByText('Want to go').length).toBeGreaterThan(0);
expect(screen.getByText('Dinner')).toBeInTheDocument();
expect(screen.getByText('Late')).toBeInTheDocument();
expect(screen.queryByText('Terrace')).toBeNull();
expect(screen.getByText('+1')).toBeInTheDocument();
expect(screen.getByText('4.5')).toBeInTheDocument();
expect(screen.getByText('Already in your trip')).toBeInTheDocument();
});
it('FE-AI-COLL-012: a narrow chat shows five rows and counts the rest as a way to the list; a roomy one eight', () => {
const view = draw([placesCard({ places: spots(8), more: 4 })]);
expect(screen.getByText('Spot 4')).toBeInTheDocument();
expect(screen.queryByText('Spot 5')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '7 more in this list' }));
expect(view.onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/collections/12' });
});
it('FE-AI-COLL-013: a roomy chat shows eight rows', () => {
draw([placesCard({ places: spots(8) })], { density: 'roomy' });
expect(screen.getByText('Spot 7')).toBeInTheDocument();
expect(screen.queryByText(/more in this list/)).toBeNull();
});
it('FE-AI-COLL-014: resting on a row pins it on the map; a click selects the trip place or moves the map to the saved one', () => {
const requestCamera = vi.fn();
useAssistantStore.setState({ requestCamera });
const onRevealMap = vi.fn();
const inTrip = savedPlace({ id: 34, name: 'Septime', inTripPlaceId: 6, labelIds: [] });
const elsewhere = savedPlace({ id: 35, name: 'Le Comptoir', lat: 48.85, lng: 2.33, labelIds: [] });
const view = draw([placesCard({ places: [inTrip, elsewhere] })], { onRevealMap });
const row = screen.getByRole('button', { name: /Septime/ });
fireEvent.mouseEnter(row);
expect(useAssistantStore.getState().preview).toMatchObject({ ref: 'col:34', placeId: 6, lat: 48.853 });
fireEvent.mouseLeave(row);
expect(useAssistantStore.getState().preview).toBeNull();
fireEvent.click(row);
expect(view.onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 6, dayId: null });
fireEvent.click(screen.getByRole('button', { name: /Le Comptoir/ }));
expect(onRevealMap).toHaveBeenCalled();
expect(requestCamera).toHaveBeenCalledWith(4, [[48.85, 2.33]], 'pan', 'user');
});
it('FE-AI-COLL-015: on the phone a row does not pin on hover, and a place without a position is no button', () => {
draw([placesCard({ places: [savedPlace({ lat: null, lng: null, labelIds: [] })] })], { variant: 'fullscreen' });
expect(screen.queryByRole('button', { name: /Septime/ })).toBeNull();
fireEvent.mouseEnter(screen.getByText('Septime'));
expect(useAssistantStore.getState().preview).toBeNull();
});
it('FE-AI-COLL-016: two lists read in one answer share one card, each under its head; one read twice shows once', () => {
const list = { id: 13, name: 'Rome', color: null, placeCount: 2, memberCount: 0, counts: null };
const rome = placesCard({ list });
const again = placesCard({ places: [savedPlace({ name: 'Septime again', labelIds: [] })] });
draw([placesCard(), rome, again]);
const card = screen.getByRole('region', { name: 'Saved places' });
expect(within(card).getByText('Paris food')).toBeInTheDocument();
expect(within(card).getByText('Rome')).toBeInTheDocument();
expect(within(card).getByText('Septime again')).toBeInTheDocument();
expect(within(card).getAllByText('Paris food')).toHaveLength(1);
});
it('FE-AI-COLL-017: the lists card names each list with its size, an invite with who sent it, and where a place is saved', () => {
const person = { id: 4, name: 'Maya', avatarKey: null };
const view = draw([
listsCard({ lists: [listRow(), listRow({ id: 40, name: 'Rome', invite: true, person })] }),
listsCard({ lists: [listRow({ name: 'Paris eats' })] }),
]);
expect(screen.getByText('Your lists')).toBeInTheDocument();
expect(screen.getByText('Paris eats')).toBeInTheDocument();
expect(screen.queryByText('Paris food')).toBeNull();
expect(screen.getByText('Invite from Maya')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Rome/ }));
expect(view.onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/collections/40' });
});
it('FE-AI-COLL-018: where a place is saved reads as its lists with its status in each', () => {
const lists = [listRow({ status: 'visited' }), listRow({ id: 13, name: 'Later', status: 'idea' })];
draw([listsCard({ place: 'Septime', lists })]);
expect(screen.getByText('Septime')).toBeInTheDocument();
expect(screen.getByText('Saved in 2 lists')).toBeInTheDocument();
expect(screen.getByText('Visited')).toBeInTheDocument();
expect(screen.getByText('Idea')).toBeInTheDocument();
});
});
@@ -0,0 +1,117 @@
import type { CollectionListsCard, CollectionPlacesCard } from '@trek/shared';
import { Bookmark } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
import { useAssistantView } from '../../assistantViewContext';
import type { CardEntry } from '../cardTypes';
import { CardFacts } from '../parts/CardFacts';
import CardFrame from '../parts/CardFrame';
import CardHeader from '../parts/CardHeader';
import { CardMore, CardRow, CardRows } from '../parts/CardRows';
import { labelsById, listTarget, mergeListRows, mergePlacesCards, rowsShown } from './collectionCardModel';
import { ListHead, ListLead, SavedPlaceRow, StatusPill } from './collectionCardParts';
/**
* The answer cards of the collections: saved places under their list's head
* (two lists read in one answer sit in one card, one head each), and the
* user's lists with the invites waiting or the lists a place is saved in.
* Window and phone draw the same; the phone has taller rows and no hover.
*/
function PlacesSection({ card, first }: { card: CollectionPlacesCard; first: boolean }) {
const { density } = useAssistantView();
const shown = card.places.slice(0, rowsShown(density));
const labels = labelsById(card.labels);
const more = card.more + card.places.length - shown.length;
return (
<div className={first ? '' : 'mt-2 border-t border-edge-faint pt-2.5'}>
{card.list && <ListHead list={card.list} first={first} />}
<CardRows>
{shown.map((place, index) => (
<SavedPlaceRow
key={place.id ?? `new-${index}`}
place={place}
fallbackId={-(index + 1)}
labels={place.labelIds.flatMap((id) => labels.get(id) ?? [])}
/>
))}
</CardRows>
<CardMore
count={more}
labelKey="assistant.card.collection.moreInList"
target={listTarget(card.list?.id ?? null)}
/>
</div>
);
}
export function CollectionPlacesBlock({ cards }: { cards: readonly CardEntry[] }) {
const { t } = useTranslation();
const placesCards = mergePlacesCards(
cards.flatMap((entry) => (entry.card.kind === 'collectionPlaces' ? [entry.card] : []))
);
const [only] = placesCards;
if (!only) return null;
const label =
placesCards.length === 1 && only.list
? t('assistant.card.collection.places', { name: only.list.name })
: t('assistant.card.collection.saved');
return (
<CardFrame label={label}>
{placesCards.map((card, index) => (
<PlacesSection key={card.list?.id ?? `places-${index}`} card={card} first={index === 0} />
))}
</CardFrame>
);
}
export function CollectionListsBlock({ cards }: { cards: readonly CardEntry[] }) {
const { t } = useTranslation();
const { onOpen } = useAssistantView();
const listsCards = cards.flatMap((entry) => (entry.card.kind === 'collectionLists' ? [entry.card] : []));
const { rows, more } = mergeListRows(listsCards);
if (rows.length === 0) return null;
const place = listsCards.reduce<CollectionListsCard['place']>((found, card) => card.place ?? found, null);
const title = place ?? t('assistant.card.collection.lists');
return (
<CardFrame label={title}>
<CardHeader
icon={Bookmark}
tone="info"
title={title}
count={place ? undefined : rows.length + more}
meta={place ? t('assistant.card.collection.savedIn', { count: rows.length + more }) : undefined}
/>
<CardRows>
{rows.map((row, index) => (
<CardRow
key={row.id ?? `list-${index}`}
lead={<ListLead color={row.color} />}
title={
<bdi title={row.name} className="block truncate">
{row.name}
</bdi>
}
aside={row.status ? <StatusPill status={row.status} /> : undefined}
onActivate={row.id !== null && onOpen ? () => onOpen(listTarget(row.id)) : undefined}
facts={
<CardFacts>
{row.invite && row.person && (
<SoftPill tone="info">{t('assistant.card.collection.invite', { name: row.person.name })}</SoftPill>
)}
{row.placeCount !== null && (
<WhiteBadge>{t('collections.placeCount', { count: row.placeCount })}</WhiteBadge>
)}
{row.memberCount > 0 && (
<WhiteBadge>{t('assistant.card.collection.members', { count: row.memberCount })}</WhiteBadge>
)}
</CardFacts>
}
/>
))}
</CardRows>
<CardMore count={more} target={listTarget(null)} />
</CardFrame>
);
}
@@ -0,0 +1,183 @@
/**
* FE-AI-COLL-020 to FE-AI-COLL-031
*
* The collections inside the change card: a held save with its list, its
* status and the lists that hold the place already; a change of status, of
* labels and of fields; an applied save opened in Collections, a duplicate
* named, a copy into the trip with its map pin and its count; a list deleted
* with what goes; a label renamed; the facts past four counted; trip places
* saved to a list without a warning; and a new order of the lists.
*/
import type { AssistantApplied, AssistantChangePreview, TrekCard } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { emptyDelta, listRow, listsCard, placesCard, savedPlace } from '../../../../../tests/helpers/collectionCards';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
import AssistantChangeCard from '../../AssistantChangeCard';
import type { ChangeItem } from '../../assistantParts';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
type Verb = AssistantChangePreview['verbKey'];
function preview(tool: string, subject: string, verbKey: Verb = 'genericCreate'): AssistantChangePreview {
return { toolCallId: 'c1', verbKey, subject, rows: [], dayId: null, placeIds: [], undoable: false, tool };
}
function pending(tool: string, subject: string, card: TrekCard, verbKey?: Verb): ChangeItem {
return { toolCallId: 'c1', preview: preview(tool, subject, verbKey), applied: null, state: 'pending', card };
}
function applied(item: ChangeItem, entity: AssistantApplied['entity'], ids: number[]): ChangeItem {
return { ...item, state: 'applied', applied: { toolCallId: 'c1', entity, ids, dayId: null, undoUntil: null } };
}
function draw(items: ChangeItem[], view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', onOpen: vi.fn(), ...view };
render(
<AssistantViewContext.Provider value={value}>
<AssistantChangeCard items={items} />
</AssistantViewContext.Provider>
);
return value;
}
beforeEach(() => {
useAssistantStore.setState({
decide: vi.fn(async () => {}),
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 1 } },
});
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
vi.clearAllMocks();
});
const SAVE = placesCard({
places: [savedPlace({ id: null, labelIds: [], savedIn: [{ listId: 13, name: 'Later', status: 'idea' }] })],
labels: [],
});
describe('collection change cards', () => {
it('FE-AI-COLL-020: a held save names its list, the status it gets and the list that holds the place already', () => {
draw([pending('save_place_to_collection', 'Septime', SAVE)]);
expect(screen.getByText('Septime')).toBeInTheDocument();
expect(screen.getByText('Save to a list')).toBeInTheDocument();
expect(screen.getByText('Paris food')).toBeInTheDocument();
expect(screen.getByText('Want to go')).toBeInTheDocument();
expect(screen.getByText('Already in Later')).toBeInTheDocument();
});
it('FE-AI-COLL-021: a change reads its status from old to new, labels added and taken away, and the fields it touches', () => {
const delta = emptyDelta({
status: { from: 'idea', to: 'visited' },
labelsAdded: [53],
labelsRemoved: [52],
fields: ['photo'],
});
const card = placesCard({ list: null, places: [savedPlace({ labelIds: [51, 52], delta })] });
draw([pending('update_collection_place', 'Septime', card, 'genericUpdate')]);
expect(screen.getByText('Change the saved place')).toBeInTheDocument();
expect(screen.getByText('Idea')).toHaveClass('line-through');
expect(screen.getByText('Visited')).toBeInTheDocument();
expect(screen.getByText('Terrace')).not.toHaveClass('line-through');
expect(screen.getByText('Late')).toHaveClass('line-through');
expect(screen.getByText('Photo')).toBeInTheDocument();
});
it('FE-AI-COLL-022: an applied save opens the saved place in its list', () => {
const card = placesCard({ places: [savedPlace({ id: 77 })] });
const view = draw([applied(pending('save_place_to_collection', 'Septime', card), 'collection_place', [77])]);
expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Open in Collections' }));
expect(view.onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/collections/12?place=77' });
});
it('FE-AI-COLL-023: a save the place matched already says where it is saved', () => {
const card = { ...SAVE, outcome: { type: 'duplicate' as const, name: 'Rodin museum' } };
draw([applied(pending('save_place_to_collection', 'Septime', card), 'collection_place', [])]);
expect(screen.getByText('Already in Rodin museum')).toBeInTheDocument();
});
it('FE-AI-COLL-024: a copy into the trip shows its new trip place on the map and says how many went in', () => {
const card = placesCard({
places: [savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir', inTripPlaceId: 6 })],
outcome: { type: 'copied', copied: 1, skipped: 1, skippedNames: ['Le Comptoir'] },
});
const copy = pending('copy_collection_places_to_trip', 'Septime, Le Comptoir', card);
const view = draw([applied(copy, 'place', [900])]);
expect(screen.getByText('1 added')).toBeInTheDocument();
expect(screen.getByText('1 already there')).toBeInTheDocument();
// The held copy's warning gives way to what the copy did.
expect(screen.queryByText('1 already in the trip')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
expect(view.onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 900, dayId: null });
});
it('FE-AI-COLL-025: a held copy warns of the places the trip has, and a copy into another trip says so', () => {
const card = placesCard({
places: [savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir', inTripPlaceId: 6 })],
});
draw([pending('copy_collection_places_to_trip', 'Septime, Le Comptoir', card)]);
expect(screen.getByText('2 places')).toBeInTheDocument();
expect(screen.getByText('1 already in the trip')).toBeInTheDocument();
expect(screen.queryByText('Into another trip')).toBeNull();
});
it('FE-AI-COLL-031: a copy into another trip carries no mark of this trip, only the flag', () => {
const card = placesCard({ places: [savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir' })], otherTrip: true });
draw([pending('copy_collection_places_to_trip', 'Septime, Le Comptoir', card)]);
expect(screen.getByText('Into another trip')).toBeInTheDocument();
expect(screen.queryByText(/already in the trip/)).toBeNull();
});
it('FE-AI-COLL-026: a list to delete shows what goes; a renamed label reads from its old name to its new one', () => {
const removal = listsCard({ lists: [listRow({ placeCount: 12, memberCount: 2, change: 'remove' })] });
draw([pending('delete_collection', 'Paris food', removal, 'genericRemove')]);
expect(screen.getByText('Will be deleted')).toBeInTheDocument();
expect(screen.getByText('12 places')).toBeInTheDocument();
expect(screen.getByText('2 members')).toBeInTheDocument();
});
it('FE-AI-COLL-027: a renamed label reads from its old name to its new one with the places that carry it', () => {
const label = {
id: 51,
name: 'Supper',
color: '#2563eb',
places: 3,
change: 'rename' as const,
before: { name: 'Dinner', color: '#2563eb' },
};
draw([pending('update_collection_label', 'Supper', placesCard({ places: [], labels: [label] }), 'genericUpdate')]);
expect(screen.getByText('Dinner')).toHaveClass('line-through');
expect(screen.getAllByText('Supper').length).toBeGreaterThan(0);
expect(screen.getByText('On 3 places')).toBeInTheDocument();
});
it('FE-AI-COLL-028: past four facts the rest is counted', () => {
const delta = emptyDelta({ fields: ['name', 'address', 'notes', 'link', 'photo'] });
const card = placesCard({ places: [savedPlace({ delta })] });
draw([pending('update_collection_place', 'Septime', card, 'genericUpdate')]);
expect(screen.getByText('+2')).toBeInTheDocument();
});
it('FE-AI-COLL-029: trip places saved to a list are trip places by nature, so no warning is given', () => {
const louvre = savedPlace({ id: null, name: 'Louvre', inTripPlaceId: 6 });
const card = placesCard({ places: [savedPlace({ id: null, inTripPlaceId: 5 }), louvre] });
draw([pending('save_trip_places_to_collection', 'Septime, Louvre', card)]);
expect(screen.getByText('2 places')).toBeInTheDocument();
expect(screen.queryByText(/already in the trip/)).toBeNull();
});
it('FE-AI-COLL-030: a new order of the lists numbers them in turn', () => {
const rome = listRow({ id: 13, name: 'Rome', change: 'order' });
const order = listsCard({ lists: [rome, listRow({ id: 12, name: 'Paris food', change: 'order' })] });
draw([pending('reorder_collections', 'Rome, Paris food', order, 'genericUpdate')]);
expect(screen.getByText('1 Rome')).toBeInTheDocument();
expect(screen.getByText('2 Paris food')).toBeInTheDocument();
});
});
@@ -0,0 +1,285 @@
import type {
CollectionCardDelta,
CollectionCardLabel,
CollectionCardList,
CollectionCardPlace,
CollectionListsCard,
CollectionPlacesCard,
} from '@trek/shared';
import { Bookmark, Info, Tag } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../../i18n';
import { STATUS_META } from '../../../Collections/collectionStatus';
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
import PlaceAvatar from '../../../shared/PlaceAvatar';
import type { ChangeItem } from '../../assistantParts';
import { verbLookOf } from '../cardLooks';
import { ChangeMark, FactChange } from '../parts/CardFacts';
import KindTile from '../parts/KindTile';
import { avatarCategory, avatarPlace, labelsById, MAX_FACTS } from './collectionCardModel';
import { LabelChip, ListDot, RatingPill, StatusPill } from './collectionCardParts';
/**
* The collections inside a change card: the saved place in front, with the
* tile of what happens on its corner (an overlapping stack for several), and
* on the faint line what the change does in small pills (its list, the
* status from and to, labels added or taken away, a vote, the fields it
* touches, a move), with warnings where a place is saved or in the trip
* already. An applied change says what it did instead: saved, a duplicate,
* how many went in.
*/
type T = (key: string, params?: Record<string, string | number>) => string;
const COPY_TOOL = 'copy_collection_places_to_trip';
/** The fields a change touches, each by the label the other change cards name it with. */
const FIELD_LOOK: Record<CollectionCardDelta['fields'][number], { labelKey: string }> = {
name: { labelKey: 'assistant.change.label.name' },
address: { labelKey: 'assistant.change.label.address' },
coordinates: { labelKey: 'assistant.change.label.coordinates' },
description: { labelKey: 'assistant.change.label.description' },
notes: { labelKey: 'assistant.change.label.notes' },
category: { labelKey: 'assistant.change.label.category' },
link: { labelKey: 'assistant.change.label.link' },
photo: { labelKey: 'assistant.card.collection.photo' },
};
/** The verb's tile, as the row shows it without a place. */
function VerbTile({ item, size }: { item: ChangeItem; size: 'xs' | 'sm' }) {
const look = item.preview ? verbLookOf(item.preview.verbKey) : null;
return <KindTile icon={look?.Icon ?? null} tone={look?.tone ?? 'info'} size={size} />;
}
export function CollectionPlaceLead({ card, item }: { card: CollectionPlacesCard; item: ChangeItem }) {
const [single] = card.places;
if (!single) return <VerbTile item={item} size="sm" />;
if (card.places.length === 1) {
return (
<span className="relative flex-none">
<PlaceAvatar place={avatarPlace(single, -1)} size={28} category={avatarCategory(single)} />
<span className="absolute -bottom-0.5 -end-0.5 flex">
<VerbTile item={item} size="xs" />
</span>
</span>
);
}
const stacked = card.places.slice(0, 4);
const extra = card.places.length - stacked.length + card.more;
return (
<span className="flex flex-none items-center">
{stacked.map((place, index) => (
<span key={place.id ?? `new-${index}`} className={index === 0 ? 'flex' : '-ms-1.5 flex'}>
<PlaceAvatar place={avatarPlace(place, -(index + 1))} size={20} category={avatarCategory(place)} />
</span>
))}
{extra > 0 && <WhiteBadge className="ms-1">+{extra}</WhiteBadge>}
</span>
);
}
/** One small fact in words. */
function Pill({ tone, text }: { tone?: PillTone; text: string }) {
return (
<SoftPill tone={tone}>
<bdi className="truncate">{text}</bdi>
</SoftPill>
);
}
/** At most MAX_FACTS pills, the rest counted. */
function Capped({ pills }: { pills: ReactNode[] }) {
const rest = pills.length - MAX_FACTS;
return (
<>
{pills.slice(0, MAX_FACTS)}
{rest > 0 && <WhiteBadge>+{rest}</WhiteBadge>}
</>
);
}
function ListChip({ list }: { list: CollectionCardList }) {
return (
<SoftPill icon={<ListDot color={list.color} size={8} />}>
<bdi className="truncate">{list.name}</bdi>
</SoftPill>
);
}
function statusChange(status: NonNullable<CollectionCardDelta['status']>, t: T): ReactNode {
const to = STATUS_META[status.to];
const before = status.from ? t(STATUS_META[status.from].labelKey) : null;
const tone = status.to === 'visited' ? 'success' : 'neutral';
return <FactChange key="status" icon={to.icon} before={before} after={t(to.labelKey)} tone={tone} />;
}
/** What an applied change did, from the tool's answer. */
function outcomePills(card: CollectionPlacesCard, t: T): ReactNode[] {
const outcome = card.outcome;
if (!outcome) return [];
if (outcome.type === 'duplicate') {
const text = t('assistant.card.collection.alreadySaved', { name: outcome.name });
return [<FactChange key="duplicate" icon={Info} before={null} after={text} tone="info" />];
}
if (outcome.type === 'statusSet') {
const text = t('assistant.card.collection.statusSet', { count: outcome.updated });
return [<Pill key="set" tone="success" text={text} />];
}
const copied = t('assistant.card.collection.copied', { count: outcome.copied });
const pills: ReactNode[] = [<Pill key="copied" tone="success" text={copied} />];
if (outcome.skipped > 0) {
const skipped = t('assistant.card.collection.skipped', { count: outcome.skipped });
pills.push(<Pill key="skipped" text={skipped} />);
}
return pills;
}
/** Labels a change adds and takes away, as chips in their colours. */
function labelPills(added: number[], removed: number[], labels: Map<number, CollectionCardLabel>): ReactNode[] {
const chip = (id: number, mark: 'add' | 'remove') => {
const label = labels.get(id);
return label ? [<LabelChip key={`${mark}-${id}`} label={label} mark={mark} />] : [];
};
return [...added.flatMap((id) => chip(id, 'add')), ...removed.flatMap((id) => chip(id, 'remove'))];
}
/** What a held change does to a saved place: its status, labels, vote, fields, list, removal. */
function deltaPills(delta: CollectionCardDelta, card: CollectionPlacesCard, t: T): ReactNode[] {
const pills: ReactNode[] = [];
if (delta.status) pills.push(statusChange(delta.status, t));
pills.push(...labelPills(delta.labelsAdded, delta.labelsRemoved, labelsById(card.labels)));
if (delta.rating) {
const to = delta.rating.to;
const cleared = t('assistant.card.collection.ratingCleared');
pills.push(to === null ? <Pill key="rating" text={cleared} /> : <RatingPill key="rating" rating={to} />);
}
for (const field of delta.fields) pills.push(<Pill key={field} text={t(FIELD_LOOK[field].labelKey)} />);
if (delta.movedTo) {
const text = t('assistant.card.collection.moveTo', { name: delta.movedTo.name });
pills.push(<Pill key="move" tone="info" text={text} />);
}
if (delta.removed) pills.push(<Pill key="removed" tone="danger" text={t('assistant.card.collection.removed')} />);
return pills;
}
/** What a change does to its one saved place; a place to save shows the status it gets. */
function placePills(place: CollectionCardPlace, card: CollectionPlacesCard, tool: string | null, t: T): ReactNode[] {
const pills: ReactNode[] = place.delta
? deltaPills(place.delta, card, t)
: [<StatusPill key="status" status={place.status} />];
for (const saved of place.savedIn) {
const text = t('assistant.card.collection.alreadySaved', { name: saved.name });
pills.push(<Pill key={`saved-${saved.listId}`} tone="warning" text={text} />);
}
// Once a copy ran, its outcome names what it skipped; the warning is for the held copy.
if (place.inTripPlaceId !== null && !(tool === COPY_TOOL && card.outcome)) {
const skipped = tool === COPY_TOOL;
const text = skipped ? t('assistant.card.collection.willSkip', { count: 1 }) : t('assistant.place.inTrip');
pills.push(<Pill key="trip" tone={skipped ? 'warning' : 'info'} text={text} />);
}
return pills;
}
/**
* What a change does to several saved places at once. Only a held copy into
* the trip warns of the places the trip has: a save from the trip names trip
* places by nature, and a copy that ran says what it skipped.
*/
function manyPills(card: CollectionPlacesCard, tool: string | null, t: T): ReactNode[] {
const status = card.places.find((place) => place.delta?.status)?.delta?.status ?? null;
const added = [...new Set(card.places.flatMap((place) => place.delta?.labelsAdded ?? []))];
const removed = [...new Set(card.places.flatMap((place) => place.delta?.labelsRemoved ?? []))];
const warns = tool === COPY_TOOL && !card.outcome;
const inTrip = warns ? card.places.filter((place) => place.inTripPlaceId !== null).length : 0;
const pills: ReactNode[] = [
<Pill key="count" text={t('collections.placeCount', { count: card.places.length + card.more })} />,
];
if (status) pills.push(statusChange(status, t));
if (card.everyList) pills.push(<Pill key="every" tone="info" text={t('assistant.card.collection.everyList')} />);
pills.push(...labelPills(added, removed, labelsById(card.labels)));
if (inTrip > 0) {
const text = t('assistant.card.collection.willSkip', { count: inTrip });
pills.push(<Pill key="trip" tone="warning" text={text} />);
}
return pills;
}
/** A label a change makes, renames or deletes, with how many places carry it. */
function labelChangePills(card: CollectionPlacesCard, t: T): ReactNode[] {
return card.labels.flatMap((label) => {
const mark = label.change === 'add' ? 'add' : label.change === 'remove' ? 'remove' : undefined;
const renamed = label.before !== null && label.before.name !== label.name;
const pills: ReactNode[] = [
renamed ? (
<FactChange key={`label-${label.name}`} icon={Tag} before={label.before?.name ?? null} after={label.name} />
) : (
<LabelChip key={`label-${label.name}`} label={label} mark={mark} />
),
];
if (label.places !== null && label.change !== 'add') {
const text = t('assistant.card.collection.labelUsedBy', { count: label.places });
pills.push(<Pill key={`used-${label.name}`} text={text} />);
}
return pills;
});
}
export function CollectionPlaceFacts({ card, item }: { card: CollectionPlacesCard; item: ChangeItem }) {
const { t } = useTranslation();
const [single] = card.places;
const tool = item.preview?.tool ?? null;
const pills = outcomePills(card, t);
if (card.list) pills.push(<ListChip key="list" list={card.list} />);
if (!single) pills.push(...labelChangePills(card, t));
else if (card.places.length === 1) pills.push(...placePills(single, card, tool, t));
else pills.push(...manyPills(card, tool, t));
if (card.otherTrip) {
pills.push(<Pill key="other" tone="warning" text={t('assistant.card.collection.otherTrip')} />);
}
return <Capped pills={pills} />;
}
/** What a change does to a list: new, renamed, deleted with what goes, an invite answered or withdrawn, a new order. */
function listPills(card: CollectionListsCard, t: T, locale: string): ReactNode[] {
const [row] = card.lists;
if (!row) return [];
const person = row.person?.name ?? '';
switch (row.change) {
case 'add':
return [<ChangeMark key="mark" change="new" />];
case 'update':
return row.before && row.before.name !== row.name
? [<FactChange key="name" icon={Bookmark} before={row.before.name} after={row.name} />]
: [<ChangeMark key="mark" change="changed" />];
case 'remove': {
const pills: ReactNode[] = [<ChangeMark key="mark" change="willDelete" />];
if (row.placeCount !== null) {
pills.push(<Pill key="places" text={t('collections.placeCount', { count: row.placeCount })} />);
}
if (row.memberCount > 0) {
const members = t('assistant.card.collection.members', { count: row.memberCount });
pills.push(<Pill key="members" text={members} />);
}
return pills;
}
case 'join':
case 'decline': {
const tone = row.change === 'join' ? 'info' : 'neutral';
return [<Pill key="invite" tone={tone} text={t('assistant.card.collection.invite', { name: person })} />];
}
case 'withdraw':
return [<Pill key="withdraw" tone="warning" text={t('assistant.card.collection.withdraw', { name: person })} />];
case 'order':
return card.lists.map((list, index) => (
<Pill key={list.id ?? index} text={`${(index + 1).toLocaleString(locale)} ${list.name}`} />
));
default:
return [];
}
}
export function CollectionListFacts({ card }: { card: CollectionListsCard }) {
const { t, locale } = useTranslation();
return <Capped pills={listPills(card, t, locale)} />;
}
@@ -0,0 +1,130 @@
import type {
AssistantApplied,
CollectionCardLabel,
CollectionCardPlace,
CollectionListRow,
CollectionListsCard,
CollectionPlacesCard,
TrekCard,
} from '@trek/shared';
import type { EntityTarget } from '../cardTypes';
/**
* The pure side of the collections' cards: where a list and a saved place
* open, the saved place a card is about, how cards of several reads lay out
* as one, and how many rows a card shows. No components here, so the look
* (and the main chunk with it) reads it without pulling in a card.
*/
/** Rows a places card shows in a narrow chat and in a roomy one; the server sends at most the second. */
const ROWS_COMPACT = 5;
const ROWS_ROOMY = 8;
/** Pills a change row shows before it counts the rest. */
export const MAX_FACTS = 4;
/** Where a list opens in Collections. */
export function listTarget(listId: number | null): EntityTarget {
return { kind: 'page', path: listId === null ? '/collections' : `/collections/${listId}` };
}
/** Where a saved place opens: its list, with the place selected. */
function placeTarget(listId: number, placeId: number | null): EntityTarget {
const path = `/collections/${listId}`;
return { kind: 'page', path: placeId === null ? path : `${path}?place=${placeId}` };
}
function placesCardOf(card: TrekCard | null): CollectionPlacesCard | null {
return card?.kind === 'collectionPlaces' ? card : null;
}
/** The saved place a change card is about: its only one. */
export function firstPlace(card: TrekCard | null): CollectionCardPlace | null {
const places = placesCardOf(card)?.places ?? [];
return places.length === 1 ? (places[0] ?? null) : null;
}
/** The list an applied change of a list opens; none for a list it deleted or an invite it declined. */
export function appliedListTarget(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null {
const row = card?.kind === 'collectionLists' ? card.lists[0] : undefined;
if (row && (row.change === 'remove' || row.change === 'decline')) return null;
const id = applied.ids[0] ?? row?.id ?? placesCardOf(card)?.list?.id ?? null;
return id === null ? null : listTarget(id);
}
/** The saved place an applied change opens; its list once the change removed it. */
export function appliedPlaceTarget(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null {
const place = firstPlace(card);
const listId = place?.listId ?? placesCardOf(card)?.list?.id ?? null;
if (listId === null) return null;
if (place?.delta?.removed) return listTarget(listId);
return placeTarget(listId, applied.ids[0] ?? place?.id ?? null);
}
/** The trip place an applied change of a saved place is, for "Show on map". */
export function appliedPlaceOnMap(card: TrekCard | null): { placeId: number; dayId: null } | null {
const placeId = firstPlace(card)?.inTripPlaceId ?? null;
return placeId === null ? null : { placeId, dayId: null };
}
/** How many rows a places card shows at this density. */
export function rowsShown(density: 'compact' | 'roomy' | undefined): number {
return density === 'roomy' ? ROWS_ROOMY : ROWS_COMPACT;
}
/**
* The places cards of one answer as one: a list read twice shows once (the
* later read wins, in the place of the first), places of several lists each
* under their own head.
*/
export function mergePlacesCards(cards: readonly CollectionPlacesCard[]): CollectionPlacesCard[] {
const merged: CollectionPlacesCard[] = [];
for (const card of cards) {
const id = card.list?.id ?? null;
const at = id === null ? -1 : merged.findIndex((seen) => seen.list?.id === id);
if (at >= 0) merged[at] = card;
else merged.push(card);
}
return merged;
}
/** The rows of several lists cards as one: a list twice shows once, as the later card has it. */
export function mergeListRows(cards: readonly CollectionListsCard[]): { rows: CollectionListRow[]; more: number } {
const rows: CollectionListRow[] = [];
let more = 0;
for (const card of cards) {
more += card.more;
for (const row of card.lists) {
const at = row.id === null ? -1 : rows.findIndex((seen) => seen.id === row.id);
if (at >= 0) rows[at] = row;
else rows.push(row);
}
}
return { rows, more };
}
/** The labels a card names, by id. */
export function labelsById(labels: readonly CollectionCardLabel[]): Map<number, CollectionCardLabel> {
return new Map(labels.flatMap((label) => (label.id === null ? [] : [[label.id, label] as const])));
}
/** A saved place as the shared place avatar takes it: its own photo by TREK's address, else its provider ids. */
export function avatarPlace(place: CollectionCardPlace, fallbackId: number) {
return {
id: place.id ?? fallbackId,
name: place.name,
image_url: place.imageKey ? `/uploads/places/${place.imageKey}` : null,
google_place_id: place.googlePlaceId,
osm_id: place.osmId,
lat: place.lat,
lng: place.lng,
};
}
/** The category colour and icon the avatar falls back to without a photo. */
export function avatarCategory(place: CollectionCardPlace): { color?: string; icon?: string } | null {
const category = place.category;
if (!category) return null;
return { color: category.color ?? undefined, icon: category.icon ?? undefined };
}
@@ -0,0 +1,211 @@
import type { CollectionCardLabel, CollectionCardList, CollectionCardPlace, CollectionStatus } from '@trek/shared';
import { Star } from 'lucide-react';
import type { CSSProperties } from 'react';
import { useTranslation } from '../../../../i18n';
import { safeHexColor } from '../../../../utils/safeColor';
import { STATUS_META } from '../../../Collections/collectionStatus';
import { SoftPill, tintOf, WhiteBadge } from '../../../Planner/planParts';
import PlaceAvatar from '../../../shared/PlaceAvatar';
import { Tooltip } from '../../../shared/Tooltip';
import { useAssistantView } from '../../assistantViewContext';
import { CardFacts } from '../parts/CardFacts';
import { CardOpenButton } from '../parts/CardOpen';
import { CardRow } from '../parts/CardRows';
import { useCardPoint } from '../parts/useCardPoint';
import { avatarCategory, avatarPlace, listTarget } from './collectionCardModel';
/**
* The pieces the collections' cards are drawn with, the way the Collections
* page draws them: a list's colour dot and head, a saved place as a row with
* its photo, status, labels and rating, and a label as a chip in its colour.
* Every fact is a small pill, as on the planner's rows.
*/
/** A list's colour as a dot; the accent where the list has none. */
export function ListDot({ color, size = 10 }: { color: string | null; size?: 8 | 10 }) {
return (
<span
aria-hidden="true"
className={`flex-none rounded-full ${size === 8 ? 'h-2 w-2' : 'h-2.5 w-2.5'}`}
style={{ background: safeHexColor(color, 'var(--accent)') }}
/>
);
}
/** A list's dot in front of its row, where a place shows its photo. */
export function ListLead({ color }: { color: string | null }) {
return (
<span className="grid h-7 w-7 flex-none place-items-center">
<ListDot color={color} />
</span>
);
}
/** A label as a chip in its colour; one a change adds or takes away says so. */
export function LabelChip({
label,
mark,
}: {
label: Pick<CollectionCardLabel, 'name' | 'color'>;
mark?: 'add' | 'remove';
}) {
const dot = { '--label': safeHexColor(label.color, 'var(--accent)') } as CSSProperties;
return (
<span style={dot} className="inline-flex min-w-0">
<SoftPill
tone={mark === 'add' ? 'success' : mark === 'remove' ? 'danger' : 'neutral'}
icon={<span aria-hidden="true" className="h-2 w-2 flex-none rounded-full bg-[var(--label)]" />}
>
{mark === 'add' && <span aria-hidden="true">+</span>}
{mark === 'remove' && <span aria-hidden="true">−</span>}
<bdi className={`truncate ${mark === 'remove' ? 'line-through' : ''}`}>{label.name}</bdi>
</SoftPill>
</span>
);
}
/** The first two labels of a place as chips, the rest counted; their names in the count's tooltip. */
function LabelChips({ labels }: { labels: readonly CollectionCardLabel[] }) {
const rest = labels.slice(2);
return (
<>
{labels.slice(0, 2).map((label) => (
<LabelChip key={label.id ?? label.name} label={label} />
))}
{rest.length > 0 && (
<Tooltip label={rest.map((label) => label.name).join(', ')} placement="top">
<span tabIndex={0} className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
<WhiteBadge>+{rest.length}</WhiteBadge>
</span>
</Tooltip>
)}
</>
);
}
/** A status with its icon in its colour, as the Collections page shows it; a count beside it on a list head. */
export function StatusPill({ status, count }: { status: CollectionStatus; count?: number }) {
const { t } = useTranslation();
const meta = STATUS_META[status];
const Icon = meta.icon;
const icon = (
<Icon size={10} strokeWidth={2.4} aria-hidden="true" className="flex-none" style={{ color: meta.color }} />
);
return (
<SoftPill tone={status === 'visited' ? 'success' : 'neutral'} icon={icon}>
{count !== undefined && <span className="tabular-nums">{count}</span>}
<span>{t(meta.labelKey)}</span>
</SoftPill>
);
}
/** The members' average vote, to a tenth. */
export function RatingPill({ rating }: { rating: number }) {
const { locale } = useTranslation();
return (
<SoftPill icon={<Star size={10} aria-hidden="true" className="flex-none fill-current text-warning" />}>
<span className="tabular-nums">{rating.toLocaleString(locale, { maximumFractionDigits: 1 })}</span>
</SoftPill>
);
}
/**
* A list's head, tinted in its colour like a planner card's head: its dot and
* name, its size and the people sharing it, the way to open it, and where
* the whole list was read, its places per status.
*/
export function ListHead({ list, first = true }: { list: CollectionCardList; first?: boolean }) {
const { t } = useTranslation();
const color = safeHexColor(list.color, 'var(--accent)');
const counts = list.counts;
const size = list.placeCount === null ? null : t('collections.placeCount', { count: list.placeCount });
return (
<div
className={`-mx-3 -mt-2.5 px-3 pb-2 pt-2.5 ${first ? 'rounded-t-xl' : ''}`}
style={{ background: tintOf(color) }}
>
<div className="flex min-w-0 items-center gap-2">
<ListDot color={list.color} />
<bdi title={list.name} className="min-w-0 flex-1 truncate text-body font-semibold leading-tight text-content">
{list.name}
</bdi>
{size && <WhiteBadge>{size}</WhiteBadge>}
{list.memberCount > 0 && (
<WhiteBadge>{t('assistant.card.collection.members', { count: list.memberCount })}</WhiteBadge>
)}
{list.id !== null && (
<CardOpenButton target={listTarget(list.id)} labelKey="assistant.card.collection.openList" />
)}
</div>
{counts && (
<CardFacts>
{(['idea', 'want', 'visited'] as const).map((status) => (
<StatusPill key={status} status={status} count={counts[status]} />
))}
</CardFacts>
)}
</div>
);
}
/**
* A saved place as a row: its photo, its name, its status at the end, and on
* the line under it its address, labels, rating and whether the trip has it.
* Resting on a row with a position shows it on the map; a click selects the
* trip's own place, or moves the map to the saved one.
*/
export function SavedPlaceRow({
place,
labels,
fallbackId,
}: {
place: CollectionCardPlace;
labels: readonly CollectionCardLabel[];
fallbackId: number;
}) {
const { t } = useTranslation();
const { variant, onOpen } = useAssistantView();
const { hover, show } = useCardPoint();
const { lat, lng, inTripPlaceId } = place;
const point = lat !== null && lng !== null ? { lat, lng } : null;
const ref = `col:${place.id ?? fallbackId}`;
const activate =
inTripPlaceId !== null && onOpen
? () => onOpen({ kind: 'map', placeId: inTripPlaceId, dayId: null })
: point
? () => show(point)
: undefined;
const onHover =
point && variant !== 'fullscreen'
? (on: boolean) => hover(on ? { ref, ...point, name: place.name, placeId: inTripPlaceId ?? undefined } : null)
: undefined;
return (
<CardRow
lead={<PlaceAvatar place={avatarPlace(place, fallbackId)} size={28} category={avatarCategory(place)} />}
title={
<bdi title={place.name} className="block truncate">
{place.name}
</bdi>
}
aside={<StatusPill status={place.status} />}
onActivate={activate}
onHover={onHover}
facts={
<CardFacts>
{place.address && (
<bdi
title={place.address}
className="min-w-0 max-w-full truncate text-caption font-normal text-content-faint"
>
{place.address}
</bdi>
)}
<LabelChips labels={labels} />
{place.rating !== null && <RatingPill rating={place.rating} />}
{inTripPlaceId !== null && <SoftPill tone="info">{t('assistant.place.inTrip')}</SoftPill>}
</CardFacts>
}
/>
);
}
@@ -0,0 +1,126 @@
/**
* FE-AI-COLL-001 to FE-AI-COLL-006
*
* The pure look of the collections in the chat: each write named by its
* own action, what an applied change opens (a list, a saved place selected
* in it, the trip place of a copy on the map), the reading steps, the
* follow-ups only an editor is offered, and how cards of several reads lay
* out as one.
*/
import type { AssistantApplied } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
emptyDelta,
LIST,
listRow,
listsCard,
placesCard,
savedPlace,
} from '../../../../../tests/helpers/collectionCards';
import { CARD_LOOKS, ENTITY_LOOK, STEP_LOOKS, TOOL_LABELS } from '../cardLooks';
import { avatarPlace, mergeListRows, mergePlacesCards, rowsShown } from './collectionCardModel';
const applied = (entity: AssistantApplied['entity'], ids: number[]): AssistantApplied => ({
toolCallId: 'c1',
entity,
ids,
dayId: null,
undoUntil: null,
});
const WRITES = [
'create_collection',
'update_collection',
'delete_collection',
'reorder_collections',
'save_place_to_collection',
'save_trip_places_to_collection',
'update_collection_place',
'set_collection_place_status',
'set_collection_place_status_from_trip',
'rate_collection_place',
'delete_collection_place',
'copy_collection_places_to_trip',
'create_collection_label',
'update_collection_label',
'delete_collection_label',
'assign_collection_labels',
'accept_collection_invite',
'decline_collection_invite',
'cancel_collection_invite',
];
describe('collection look', () => {
it('FE-AI-COLL-001: every write of the collections is named by its own action', () => {
for (const tool of WRITES) expect(TOOL_LABELS[tool]?.labelKey).toBe(`assistant.tool.${tool}`);
expect(STEP_LOOKS.get_collection?.stepKey).toBe('assistant.step.readingList');
expect(STEP_LOOKS.list_collections?.stepKey).toBe('assistant.step.lookingAtLists');
expect(STEP_LOOKS.find_place_in_collections?.stepKey).toBe('assistant.step.checkingLists');
});
it('FE-AI-COLL-002: an applied list opens in Collections, unless the change deleted it or declined its invite', () => {
const look = ENTITY_LOOK.collection;
expect(look.openLabelKey).toBe('assistant.card.collection.openList');
expect(look.open?.(applied('collection', [12]), null)).toEqual({ kind: 'page', path: '/collections/12' });
const card = listsCard({ lists: [listRow({ id: 13 })] });
expect(look.open?.(applied('collection', []), card)).toEqual({ kind: 'page', path: '/collections/13' });
for (const change of ['remove', 'decline'] as const) {
expect(look.open?.(applied('collection', [12]), listsCard({ lists: [listRow({ change })] }))).toBeNull();
}
expect(look.open?.(applied('collection', []), null)).toBeNull();
});
it('FE-AI-COLL-003: an applied saved place opens selected in its list; a removed one opens the list', () => {
const look = ENTITY_LOOK.collection_place;
const card = placesCard();
expect(look.open?.(applied('collection_place', [34]), card)).toEqual({
kind: 'page',
path: '/collections/12?place=34',
});
const removed = placesCard({ places: [savedPlace({ delta: emptyDelta({ removed: true }) })] });
expect(look.open?.(applied('collection_place', [34]), removed)).toEqual({ kind: 'page', path: '/collections/12' });
expect(look.open?.(applied('collection_place', [34]), null)).toBeNull();
});
it('FE-AI-COLL-004: a saved place that is in the trip shows its trip place on the map; one that is not, nothing', () => {
const look = ENTITY_LOOK.collection_place;
const inTrip = placesCard({ places: [savedPlace({ inTripPlaceId: 6 })] });
expect(look.onMap?.(applied('collection_place', [34]), inTrip)).toEqual({ placeId: 6, dayId: null });
expect(look.onMap?.(applied('collection_place', [34]), placesCard())).toBeNull();
});
it('FE-AI-COLL-005: a list of saved places offers to plan with it, to editors only', () => {
const followUps = CARD_LOOKS.collectionPlaces.followUps ?? [];
expect(followUps.map((followUp) => [followUp.labelKey, followUp.edits])).toEqual([
['assistant.followUp.collectionToTrip', true],
['assistant.followUp.collectionDay', true],
]);
expect(CARD_LOOKS.collectionLists.followUps).toBeUndefined();
});
it('FE-AI-COLL-006: a list read twice shows once, as read last; lists cards merge their rows by id', () => {
const first = placesCard({ places: [savedPlace({ name: 'Old' })] });
const other = placesCard({ list: { ...LIST, id: 13, name: 'Rome' } });
const again = placesCard({ places: [savedPlace({ name: 'New' })] });
const spread = placesCard({ list: null });
const merged = mergePlacesCards([first, other, again, spread, spread]);
expect(merged.map((card) => [card.list?.id ?? null, card.places[0]?.name])).toEqual([
[12, 'New'],
[13, 'Septime'],
[null, 'Septime'],
[null, 'Septime'],
]);
const rows = mergeListRows([
listsCard({ lists: [listRow({ id: 1, name: 'A' }), listRow({ id: 2, name: 'B' })], more: 2 }),
listsCard({ lists: [listRow({ id: 2, name: 'B2' }), listRow({ id: 3, name: 'C' })], more: 1 }),
]);
expect(rows.rows.map((row) => row.name)).toEqual(['A', 'B2', 'C']);
expect(rows.more).toBe(3);
expect([rowsShown('compact'), rowsShown('roomy'), rowsShown(undefined)]).toEqual([5, 8, 5]);
expect(avatarPlace(savedPlace({ id: null, imageKey: 'a.jpg' }), -2)).toMatchObject({
id: -2,
image_url: '/uploads/places/a.jpg',
});
});
});
@@ -1,19 +1,66 @@
import { Bookmark, BookmarkCheck } from 'lucide-react';
import type { PackLook } from '../cardTypes';
import { appliedListTarget, appliedPlaceOnMap, appliedPlaceTarget } from './collectionCardModel';
/**
* What the collections bring to the chat's pure look: the verbs, tool names and
* steps they add, what an applied change of theirs opens, and the look of each
* of their kinds. Empty until the collections cards fill it. A look stays pure,
* icons and literals only: the chat's main chunk reads every look, so whatever
* a look imports ships with it.
* What the collections bring to the chat's pure look: the name of each of
* their writes, the words their reads run under, where an applied change of
* theirs opens (a list, or a saved place selected in it; a copy into the trip
* is a trip place and shows on the map), and what a list of saved places
* offers to ask next. A look stays pure, icons and literals only: the chat's
* main chunk reads every look, so whatever a look imports ships with it.
*/
export const COLLECTION_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {},
tools: {
create_collection: { labelKey: 'assistant.tool.create_collection' },
update_collection: { labelKey: 'assistant.tool.update_collection' },
delete_collection: { labelKey: 'assistant.tool.delete_collection' },
reorder_collections: { labelKey: 'assistant.tool.reorder_collections' },
save_place_to_collection: { labelKey: 'assistant.tool.save_place_to_collection' },
save_trip_places_to_collection: { labelKey: 'assistant.tool.save_trip_places_to_collection' },
update_collection_place: { labelKey: 'assistant.tool.update_collection_place' },
set_collection_place_status: { labelKey: 'assistant.tool.set_collection_place_status' },
set_collection_place_status_from_trip: { labelKey: 'assistant.tool.set_collection_place_status_from_trip' },
rate_collection_place: { labelKey: 'assistant.tool.rate_collection_place' },
delete_collection_place: { labelKey: 'assistant.tool.delete_collection_place' },
copy_collection_places_to_trip: { labelKey: 'assistant.tool.copy_collection_places_to_trip' },
create_collection_label: { labelKey: 'assistant.tool.create_collection_label' },
update_collection_label: { labelKey: 'assistant.tool.update_collection_label' },
delete_collection_label: { labelKey: 'assistant.tool.delete_collection_label' },
assign_collection_labels: { labelKey: 'assistant.tool.assign_collection_labels' },
accept_collection_invite: { labelKey: 'assistant.tool.accept_collection_invite' },
decline_collection_invite: { labelKey: 'assistant.tool.decline_collection_invite' },
cancel_collection_invite: { labelKey: 'assistant.tool.cancel_collection_invite' },
},
steps: {
get_collection: { stepKey: 'assistant.step.readingList' },
get_collection_items: { stepKey: 'assistant.step.readingList' },
list_collections: { stepKey: 'assistant.step.lookingAtLists' },
available_collection_users: { stepKey: 'assistant.step.lookingAtLists' },
find_place_in_collections: { stepKey: 'assistant.step.checkingLists' },
},
entities: {
collection: {
Icon: Bookmark,
openLabelKey: 'assistant.card.collection.openList',
open: appliedListTarget,
},
collection_place: {
Icon: BookmarkCheck,
openLabelKey: 'assistant.card.collection.openPlace',
onMap: (_applied, card) => appliedPlaceOnMap(card),
open: appliedPlaceTarget,
},
},
cards: {
collectionPlaces: {},
collectionPlaces: {
followUps: [
{ id: 'collectionToTrip', labelKey: 'assistant.followUp.collectionToTrip', edits: true },
{ id: 'collectionDay', labelKey: 'assistant.followUp.collectionDay', edits: true },
],
},
collectionLists: {},
},
} satisfies PackLook<'collectionPlaces' | 'collectionLists'>;
@@ -1,13 +1,17 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
import type { CardView } from '../cardTypes';
import { CollectionListsBlock, CollectionPlacesBlock } from './CollectionCardBlocks';
import { CollectionListFacts, CollectionPlaceFacts, CollectionPlaceLead } from './CollectionChangeSlots';
/**
* How the kinds of the collections draw in the chat; each one draws nothing
* until its cards are built. 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.
* How the kinds of the collections draw in the chat: saved places under their
* list's head beside an answer, and in a change card their photo in front and
* what the change does on the faint line; the lists the same way. 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.
*/
export const COLLECTION_VIEWS = {
collectionPlaces: PENDING_VIEW,
collectionLists: PENDING_VIEW,
collectionPlaces: { Block: CollectionPlacesBlock, Lead: CollectionPlaceLead, Facts: CollectionPlaceFacts },
collectionLists: { Block: CollectionListsBlock, Facts: CollectionListFacts },
} satisfies { [K in 'collectionPlaces' | 'collectionLists']: CardView<K> };
@@ -1,7 +1,7 @@
import React from 'react'
import type { CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { STATUS_META, nextStatus } from '../../pages/collections/collectionsModel'
import { STATUS_META, nextStatus } from './collectionStatus'
interface StatusBadgeProps {
status: CollectionStatus
@@ -0,0 +1,38 @@
import { COLLECTION_STATUSES, type CollectionStatus } from '@trek/shared';
import { Bookmark, CheckCircle2, Circle, type LucideIcon } from 'lucide-react';
/**
* How a saved place's status looks wherever it shows: its icon, its name and
* its colour, on the Collections page, on the phone and in the assistant's
* cards alike. Pure presentation data, so a component of any layer reads it.
*/
interface StatusMeta {
icon: LucideIcon;
/** i18n key for the human label. */
labelKey: string;
/** CSS colour token for the badge on a light/surface background. */
color: string;
/** Brighter variant for a pill sitting over a photo cover / the hero scrim. */
coverColor: string;
}
export const STATUS_META: Record<CollectionStatus, StatusMeta> = {
idea: { icon: Circle, labelKey: 'collections.status.idea', color: 'var(--text-muted)', coverColor: '#e5e7eb' },
want: { icon: Bookmark, labelKey: 'collections.status.want', color: 'var(--accent)', coverColor: '#c7d2fe' },
visited: {
icon: CheckCircle2,
labelKey: 'collections.status.visited',
color: 'var(--success)',
coverColor: '#6ee7b7',
},
};
/** Stable order for the filter chips + the one-tap cycle. */
export const STATUS_ORDER: CollectionStatus[] = [...COLLECTION_STATUSES];
/** idea → want → visited → idea */
export function nextStatus(status: CollectionStatus): CollectionStatus {
const i = STATUS_ORDER.indexOf(status);
return STATUS_ORDER[(i + 1) % STATUS_ORDER.length];
}
@@ -1,7 +1,4 @@
import { Circle, Bookmark, CheckCircle2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
import { COLLECTION_STATUSES } from '@trek/shared'
import type { CollectionPlace, CollectionLabel } from '@trek/shared'
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
/**
@@ -10,30 +7,9 @@ import type { StatusFilter, CollectionSortMode } from '../../store/collectionSto
* page hook (useCollections) and the view components share these helpers.
*/
export interface StatusMeta {
icon: LucideIcon
/** i18n key for the human label. */
labelKey: string
/** CSS colour token / hex for the badge on a light/surface background. */
color: string
/** Brighter variant for a pill sitting over a photo cover / the hero scrim. */
coverColor: string
}
export const STATUS_META: Record<CollectionStatus, StatusMeta> = {
idea: { icon: Circle, labelKey: 'collections.status.idea', color: 'var(--text-muted)', coverColor: '#e5e7eb' },
want: { icon: Bookmark, labelKey: 'collections.status.want', color: 'var(--accent)', coverColor: '#c7d2fe' },
visited: { icon: CheckCircle2, labelKey: 'collections.status.visited', color: '#10b981', coverColor: '#6ee7b7' },
}
/** Stable order for the filter chips + the one-tap cycle. */
export const STATUS_ORDER: CollectionStatus[] = [...COLLECTION_STATUSES]
/** idea → want → visited → idea */
export function nextStatus(status: CollectionStatus): CollectionStatus {
const i = STATUS_ORDER.indexOf(status)
return STATUS_ORDER[(i + 1) % STATUS_ORDER.length]
}
// The status presentation lives with the components (the assistant's cards
// draw it too); re-exported here for the page and its views.
export { STATUS_META, STATUS_ORDER, nextStatus } from '../../components/Collections/collectionStatus'
/** Order the places for display. 'default' keeps the saved order (sort_order,
* then newest first); 'name_asc' sorts alphabetically by name (locale-aware). */
@@ -1,4 +1,4 @@
// FE-PAGE-COLL-001 to FE-PAGE-COLL-093
// FE-PAGE-COLL-001 to FE-PAGE-COLL-095
// The Collections page hook. The collection store is replaced by a fixture so
// every handler/branch can be driven directly; the categories request goes
// through MSW, and the websocket module is already mocked in tests/setup.ts.
@@ -16,9 +16,12 @@ import { useCollections } from './useCollections'
const navigate = vi.fn()
let routeParams: { id?: string } = {}
let searchParams = new URLSearchParams()
const setSearchParams = vi.fn()
vi.mock('react-router', () => ({
useNavigate: () => navigate,
useParams: () => routeParams,
useSearchParams: () => [searchParams, setSearchParams],
}))
// The fixture is handed out by the mocked store hook; tests mutate it and rerender.
@@ -152,6 +155,8 @@ beforeEach(() => {
store = makeStore()
hoisted.store = store
routeParams = {}
searchParams = new URLSearchParams()
setSearchParams.mockClear()
wideMedia = false
darkMedia = false
navigate.mockClear()
@@ -1315,4 +1320,42 @@ describe('useCollections — route that is an invite (#2645)', () => {
await act(async () => {})
expect(navigate).not.toHaveBeenCalled()
})
it('FE-PAGE-COLL-094: a link to a saved place opens it once its list holds it, then drops the param', async () => {
routeParams = { id: '14' }
searchParams = new URLSearchParams('place=34&tab=map')
store.collections = [collection({ id: 14 })]
store.activeId = 14
store.places = [place({ id: 34, collection_id: 14 })]
await setup()
expect(store.setSelectedPlaceId).toHaveBeenCalledWith(34)
expect(setSearchParams).toHaveBeenCalledTimes(1)
const [update, options] = setSearchParams.mock.calls[0] as [(prev: URLSearchParams) => URLSearchParams, object]
expect(update(searchParams).toString()).toBe('tab=map')
expect(options).toEqual({ replace: true })
})
it('FE-PAGE-COLL-095: a linked place waits for its list: not another list, not while loading; a list without it drops the link', async () => {
routeParams = { id: '14' }
searchParams = new URLSearchParams('place=34')
store.collections = [collection({ id: 14 }), collection({ id: 2 })]
store.activeId = 2
store.places = [place({ id: 34, collection_id: 2 })]
const view = await setup()
expect(store.setSelectedPlaceId).not.toHaveBeenCalled()
store.activeId = 14
store.placesLoading = true
view.rerender()
expect(store.setSelectedPlaceId).not.toHaveBeenCalled()
store.placesLoading = false
view.rerender()
// The places on screen are still another list's: the place may just not have arrived.
expect(setSearchParams).not.toHaveBeenCalled()
store.places = [place({ id: 35, collection_id: 14 })]
view.rerender()
expect(store.setSelectedPlaceId).not.toHaveBeenCalled()
expect(setSearchParams).toHaveBeenCalledTimes(1)
const drop = setSearchParams.mock.calls[0]?.[0] as (prev: URLSearchParams) => URLSearchParams
expect(drop(new URLSearchParams('place=34&x=1')).toString()).toBe('x=1')
})
})
+21 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import { useNavigate, useParams, useSearchParams } from 'react-router'
import { useTranslation } from '../../i18n'
import { useElementSize } from '../../hooks/useElementSize'
import { useElementRect } from '../../hooks/useElementRect'
@@ -147,6 +147,26 @@ export function useCollections() {
navigate('/collections', { replace: true })
}, [listsLoaded, loading, routeId, collections, incomingInvites, navigate])
// A link to one saved place (/collections/:id?place=<id>, the assistant's
// "Open in Collections") opens it once the list on the route holds it, and
// then drops the param, so a reload or the back button does not reopen it.
// A list that has loaded without the place (deleted or moved since) drops
// the param too, rather than leave the URL on a place the page cannot show.
const [searchParams, setSearchParams] = useSearchParams()
const linkedPlaceId = Number(searchParams.get('place'))
useEffect(() => {
if (!Number.isInteger(linkedPlaceId) || linkedPlaceId <= 0 || placesLoading) return
if (typeof activeId !== 'number' || String(activeId) !== routeId) return
const held = places.some(p => p.id === linkedPlaceId && p.collection_id === activeId)
if (!held && places.some(p => p.collection_id !== activeId)) return
if (held) setSelectedPlaceId(linkedPlaceId)
setSearchParams(prev => {
const next = new URLSearchParams(prev)
next.delete('place')
return next
}, { replace: true })
}, [linkedPlaceId, placesLoading, activeId, routeId, places, setSelectedPlaceId, setSearchParams])
// Live sync via WebSocket. collections:deleted / :removed must bounce a member
// off a list that was removed (or that they were kicked from) under them.
const wsReloadTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
+67
View File
@@ -0,0 +1,67 @@
import type { CollectionCardPlace, CollectionListRow, CollectionListsCard, CollectionPlacesCard } from '@trek/shared';
/** Cards of the collections as the server sends them, for the card tests. */
export const LIST = { id: 12, name: 'Paris food', color: '#e11d48', placeCount: 12, memberCount: 1, counts: null };
export function savedPlace(over: Partial<CollectionCardPlace> = {}): CollectionCardPlace {
return {
id: 34,
listId: 12,
name: 'Septime',
address: '80 Rue de Charonne',
lat: 48.853,
lng: 2.381,
category: { name: 'Restaurant', icon: 'Utensils', color: '#f97316' },
status: 'want',
labelIds: [51, 52, 53],
rating: 4.5,
ratingCount: 3,
googlePlaceId: null,
osmId: null,
imageKey: null,
inTripPlaceId: null,
savedIn: [],
delta: null,
...over,
};
}
export function placesCard(over: Partial<CollectionPlacesCard> = {}): CollectionPlacesCard {
return {
kind: 'collectionPlaces',
list: { ...LIST, counts: { idea: 3, want: 5, visited: 4 } },
places: [savedPlace()],
more: 0,
labels: [
{ id: 51, name: 'Dinner', color: '#2563eb', places: 3, change: 'none', before: null },
{ id: 52, name: 'Late', color: null, places: 1, change: 'none', before: null },
{ id: 53, name: 'Terrace', color: '#16a34a', places: 2, change: 'none', before: null },
],
outcome: null,
everyList: false,
otherTrip: false,
...over,
};
}
export function listRow(over: Partial<CollectionListRow> = {}): CollectionListRow {
return { ...LIST, status: null, person: null, invite: false, change: 'none', before: null, ...over };
}
export function listsCard(over: Partial<CollectionListsCard> = {}): CollectionListsCard {
return { kind: 'collectionLists', lists: [listRow()], more: 0, place: null, ...over };
}
export function emptyDelta(over: Partial<NonNullable<CollectionCardPlace['delta']>> = {}) {
return {
status: null,
rating: null,
labelsAdded: [],
labelsRemoved: [],
fields: [],
movedTo: null,
removed: false,
...over,
};
}
@@ -378,14 +378,14 @@ export class CollectionPlacesRepository extends TrekRepository<CollectionPlaces>
collectionIds: number[],
query: { google_place_id?: string; google_ftid?: string; lat?: number; lng?: number },
tolerance: number,
): Promise<{ place_id: number; collection_id: number; name: string; status: string }[]> {
): Promise<{ place_id: number; collection_id: number; name: string; color: string | null; status: string }[]> {
if (collectionIds.length === 0) return [];
const hasCoords = query.lat != null && query.lng != null;
if (!query.google_place_id && !query.google_ftid && !hasCoords) return [];
return await this.kysely<CollectionPlacesMembershipKyselyDB>()
.selectFrom('collection_places as cp')
.innerJoin('collections as c', 'c.id', 'cp.collection_id')
.select(['cp.id as place_id', 'cp.collection_id', 'c.name', 'cp.status'])
.select(['cp.id as place_id', 'cp.collection_id', 'c.name', 'c.color', 'cp.status'])
.where('cp.collection_id', 'in', collectionIds)
.where((eb) => {
const clauses = [];
@@ -0,0 +1,121 @@
import type { AnswerInput } from '../card-types';
import { clipped, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import {
colorOf,
listOf,
listRowOf,
MAX_LISTS,
MAX_PLACES,
positiveId,
savedPlacesOf,
statusOf,
tally,
usedLabels,
} from './collection-values';
import type { CollectionCardList, CollectionListRow, CollectionListsCard, CollectionPlacesCard } from '@trek/shared';
/**
* The cards the collections' reads draw beside the answer: a list with its
* first places (`get_collection`), saved places asked for by id
* (`get_collection_items`), the user's lists with the invites waiting
* (`list_collections`) and the lists a place is saved in
* (`find_place_in_collections`). Who may be invited (`available_collection_users`)
* stays a sentence: a list of user names says nothing a card would.
*/
/** A places card of `rows`, under `list` when one list holds them all. */
function placesCard(
list: CollectionCardList | null,
rows: readonly Row[],
input: AnswerInput,
labelPool: unknown,
whole: readonly Row[] | null,
): CollectionPlacesCard | null {
const places = savedPlacesOf(rows, input.card);
if (!list && places.length === 0) return null;
const shown = places.slice(0, MAX_PLACES);
return {
kind: 'collectionPlaces',
list,
places: shown,
more: places.length - shown.length,
labels: usedLabels(labelPool, shown, whole),
outcome: null,
everyList: false,
otherTrip: false,
};
}
/** One list read whole: its head with the places per status, its first places, the labels they carry. */
export function collectionAnswer(input: AnswerInput): CollectionPlacesCard | null {
const root = recordOf(input.value);
const collection = recordOf(root.collection);
const places = rowsOf(root.places);
const list = listOf(collection, tally(places));
return list ? placesCard(list, places, input, collection.labels, places) : null;
}
/** Saved places asked for by id: under their list when they share one, labels named but not counted. */
export function itemsAnswer(input: AnswerInput): CollectionPlacesCard | null {
const root = recordOf(input.value);
const places = rowsOf(root.places);
const lists = rowsOf(root.lists);
const listIds = new Set(places.map((place) => positiveId(place.collection_id)));
const only = listIds.size === 1 ? lists.find((row) => listIds.has(positiveId(row.id))) : undefined;
const pool = lists.flatMap((row) => rowsOf(row.labels));
return places.length > 0 ? placesCard(only ? listOf(only) : null, places, input, pool, null) : null;
}
/** The user's lists in their order, then the invites that wait for an answer. */
export function listsAnswer({ value }: AnswerInput): CollectionListsCard | null {
const root = recordOf(value);
const own = rowsOf(root.collections)
.map((row) => listOf(row))
.filter((list): list is CollectionCardList => list !== null)
.map((list) => listRowOf(list));
const invites = rowsOf(root.incomingInvites)
.map((row) => inviteRowOf(row))
.filter((row): row is CollectionListRow => row !== null);
const rows = [...own, ...invites];
if (rows.length === 0) return null;
return listsCard(rows, null);
}
/** A lists card of `rows`, the ones past its bound counted. */
export function listsCard(rows: readonly CollectionListRow[], place: string | null): CollectionListsCard {
const more = Math.max(0, rows.length - MAX_LISTS);
return { kind: 'collectionLists', lists: rows.slice(0, MAX_LISTS), more, place };
}
/** An invite as a row: the list's name and who sent it; its size is not the invitee's to see. */
export function inviteRowOf(raw: unknown): CollectionListRow | null {
const row = recordOf(raw);
const id = positiveId(row.collection_id);
const name = clipped(row.name, 120);
if (id === null || !name) return null;
const from = recordOf(row.from);
const fromId = positiveId(from.id);
const fromName = clipped(from.username, 80);
return listRowOf(
{ id, name, color: null, placeCount: null, memberCount: 0, counts: null },
{ invite: true, person: fromId !== null && fromName ? { id: fromId, name: fromName, avatarKey: null } : null },
);
}
/** Where a place is saved, with its status in each list; nothing when it is saved nowhere. */
export function membershipAnswer({ args, value }: AnswerInput): CollectionListsCard | null {
const root = recordOf(value);
if (root.saved !== true) return null;
const seen = new Set<number>();
const rows: CollectionListRow[] = [];
for (const row of rowsOf(root.lists)) {
const id = positiveId(row.collection_id);
const name = clipped(row.name, 120);
if (id === null || !name || seen.has(id)) continue;
seen.add(id);
const list = { id, name, color: colorOf(row.color), placeCount: null, memberCount: 0, counts: null };
rows.push(listRowOf(list, { status: statusOf(row.status) }));
}
return rows.length > 0 ? listsCard(rows, clipped(args.name, 120)) : null;
}
@@ -0,0 +1,258 @@
import type { ToolArgs, ToolCards, UndoSnapshot } from '../card-types';
import { argId, clipped, readKey, recordOf, type Row } from '../card-values';
import { rowsOf, same } from '../undo-snapshot';
import { listsCard } from './collection-answers';
import { colorOf, idsIn, listOf, listRowOf, positiveId, savedPlaceOf } from './collection-values';
import type {
AssistantChangePreview,
CollectionCardPlace,
CollectionListsCard,
CollectionPlacesCard,
TrekCard,
} from '@trek/shared';
/**
* What an applied change of the collections shows in place of its preview:
* the saved place as it is now (its id, its own photo), what a copy or a
* status set across lists did, the list or label as the tool answered. Each
* builds on the stored preview card, so it needs no read, and keeps what only
* the preview knew (the place in the trip, what the change did to it). Also:
* the ids a receipt names, and the way back from a created list or a saved
* place while nothing changed it since.
*/
type AppliedInput = { args: ToolArgs; result: unknown; preview: AssistantChangePreview | null };
/** The preview's card, when it is of the kind asked for. */
function previewCard<K extends TrekCard['kind']>(
preview: AssistantChangePreview | null,
kind: K,
): Extract<TrekCard, { kind: K }> | null {
const card = preview?.card ?? null;
return card?.kind === kind ? (card as Extract<TrekCard, { kind: K }>) : null;
}
/** The saved place a tool answered with, keeping what only the preview knew about it. */
function answeredPlace(result: unknown, before: CollectionCardPlace | undefined): CollectionCardPlace | null {
const place = savedPlaceOf(recordOf(result).place, null);
if (!place) return null;
return {
...place,
inTripPlaceId: before?.inTripPlaceId ?? null,
savedIn: before?.savedIn ?? [],
delta: before?.delta ?? null,
};
}
/** A change of one saved place: the place as it is now, under the preview's list. */
function placeApplied({ result, preview }: AppliedInput): CollectionPlacesCard | null {
const card = previewCard(preview, 'collectionPlaces');
const place = answeredPlace(result, card?.places[0]);
if (!place) return null;
return {
kind: 'collectionPlaces',
list: card?.list ?? null,
places: [place],
more: 0,
labels: card?.labels ?? [],
outcome: null,
everyList: false,
otherTrip: false,
};
}
function countOf(value: unknown): number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? Math.min(value, 1_000_000) : 0;
}
/**
* A copy (into a trip or into a list): how many went in and which were there
* already. The outcome names the ones skipped, so the places no longer carry
* the warning the preview gave.
*/
function copyApplied({ result, preview }: AppliedInput): CollectionPlacesCard | null {
const card = previewCard(preview, 'collectionPlaces');
if (!card) return null;
const places = card.otherTrip ? card.places.map((place) => ({ ...place, inTripPlaceId: null })) : card.places;
const skipped = rowsOf(recordOf(result).skipped);
const skippedNames = skipped
.map((row) => clipped(row.name, 120))
.filter((name): name is string => name !== null)
.slice(0, 5);
return {
...card,
places,
outcome: { type: 'copied', copied: countOf(recordOf(result).copied), skipped: skipped.length, skippedNames },
};
}
function savedApplied(input: AppliedInput): CollectionPlacesCard | null {
const root = recordOf(input.result);
if (root.duplicate !== true) return placeApplied(input);
const card = previewCard(input.preview, 'collectionPlaces');
const name = clipped(recordOf(root.duplicateOf).name, 120) ?? card?.places[0]?.name ?? null;
return card && name ? { ...card, outcome: { type: 'duplicate', name } } : null;
}
function statusSetApplied({ result, preview }: AppliedInput): CollectionPlacesCard | null {
const card = previewCard(preview, 'collectionPlaces');
const root = recordOf(result);
if (!card) return null;
return { ...card, outcome: { type: 'statusSet', updated: countOf(root.updated), found: countOf(root.places) } };
}
/** A list the tool created or changed, as it answered; a change keeps what it was before. */
function listApplied(change: 'add' | 'update') {
return ({ result, preview }: AppliedInput): CollectionListsCard | null => {
const list = listOf(recordOf(result).collection);
if (!list) return null;
const before = previewCard(preview, 'collectionLists')?.lists[0]?.before ?? null;
return listsCard([listRowOf(list, { change, before: change === 'update' ? before : null })], null);
};
}
/** A label the tool created or changed, in place of the one the preview named. */
function labelApplied({ result, preview }: AppliedInput): CollectionPlacesCard | null {
const card = previewCard(preview, 'collectionPlaces');
const label = recordOf(recordOf(result).label);
const name = clipped(label.name, 60);
const shown = card?.labels[0];
if (!card || !shown || !name) return null;
const color = colorOf(label.color) ?? shown.color;
return { ...card, labels: [{ ...shown, id: positiveId(label.id), name, color }] };
}
export const COLLECTION_APPLIED: Readonly<Record<string, (input: AppliedInput) => TrekCard | null>> = {
save_place_to_collection: savedApplied,
update_collection_place: placeApplied,
set_collection_place_status: placeApplied,
rate_collection_place: placeApplied,
copy_collection_places_to_trip: copyApplied,
save_trip_places_to_collection: copyApplied,
set_collection_place_status_from_trip: statusSetApplied,
create_collection: listApplied('add'),
update_collection: listApplied('update'),
create_collection_label: labelApplied,
update_collection_label: labelApplied,
};
/** The ids a receipt names where the answer does not name them under the usual keys. */
export const COLLECTION_APPLIED_IDS: Readonly<Record<string, (args: ToolArgs, result: unknown) => number[]>> = {
create_collection: (_args, result) => idsOf(positiveId(recordOf(recordOf(result).collection).id)),
update_collection: (args) => idsOf(argId(args, 'collectionId')),
delete_collection: (args) => idsOf(argId(args, 'collectionId')),
save_trip_places_to_collection: (args) => idsOf(argId(args, 'collectionId')),
accept_collection_invite: (args) => idsOf(argId(args, 'collectionId')),
decline_collection_invite: (args) => idsOf(argId(args, 'collectionId')),
cancel_collection_invite: (args) => idsOf(argId(args, 'collectionId')),
create_collection_label: (args) => idsOf(argId(args, 'collection_id')),
update_collection_label: (_args, result) => idsOf(positiveId(recordOf(recordOf(result).label).collection_id)),
// The new trip places, so "Show on map" opens the first of them.
copy_collection_places_to_trip: (_args, result) =>
rowsOf(recordOf(result).created)
.map((row) => positiveId(row.id))
.filter((id): id is number => id !== null),
};
function idsOf(id: number | null): number[] {
return id === null ? [] : [id];
}
/** The id of what a create answered with under `key`, as it came (the read is made whatever it is). */
function createdId(result: unknown, key: string): unknown {
return recordOf(recordOf(result)[key]).id;
}
/** What a read the undo made answered, by its tool and arguments. */
function readOf(snapshot: UndoSnapshot, tool: string, args: Row): Row {
return recordOf(snapshot.reads.get(readKey(tool, args)));
}
/** A list the change created, still as it was: same name, no places, no labels, nobody else in it. */
function listUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const created = recordOf(recordOf(result).collection);
const read = readOf(snapshot, 'get_collection', { collectionId: created.id });
const current = recordOf(read.collection);
if (positiveId(current.id) === null || current.id !== created.id) return false;
const others = rowsOf(current.members).filter((member) => member.is_owner !== true);
const labels = rowsOf(current.labels);
return (
same(created, current, ['name', 'color', 'updated_at']) &&
rowsOf(read.places).length === 0 &&
others.length === 0 &&
labels.length === 0
);
}
/** The fields of a saved place that must read as the save left them for the undo to remove it. */
const SAVED_FIELDS = [
'name',
'collection_id',
'status',
'updated_at',
'lat',
'lng',
'address',
'notes',
'description',
'category_id',
'image_url',
] as const;
function sortedLabelIds(row: Row): string {
const ids = idsIn(row.label_ids, 1000);
return ids.sort((a, b) => a - b).join(',');
}
/**
* A saved place the change created, still as it was: same fields, same list,
* same labels, not rated since, in a list the user may still delete from.
*/
function placeUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const created = recordOf(recordOf(result).place);
const read = readOf(snapshot, 'get_collection_items', { placeIds: [created.id] });
const current = rowsOf(read.places).find((row) => row.id === created.id);
if (!current) return false;
const list = rowsOf(read.lists).find((row) => row.id === current.collection_id);
return (
list?.can_delete === true &&
same(created, current, SAVED_FIELDS) &&
rowsOf(current.ratings).length === rowsOf(created.ratings).length &&
sortedLabelIds(current) === sortedLabelIds(created)
);
}
/**
* Whether the user who saved a place may remove it again: only a list's owner
* or admin deletes, an editor only adds. The answer names who saved it and
* whose list it is; an admin who is not the owner cannot be told apart from an
* editor here, so the undo is offered to owners only.
*/
function saverOwnsList(place: Row): boolean {
const savedBy = positiveId(place.saved_by);
const owner = positiveId(place.owner_id);
return savedBy === null || owner === null || savedBy === owner;
}
/** The way back from a created list: deleting it, while nothing was added to it or shared since. */
export const DELETE_CREATED_LIST: NonNullable<ToolCards['undo']> = {
tool: 'delete_collection',
args: (_args, result) => {
const collectionId = positiveId(recordOf(recordOf(result).collection).id);
return collectionId === null ? null : { collectionId };
},
reads: (_args, result) => [{ tool: 'get_collection', args: { collectionId: createdId(result, 'collection') } }],
unchanged: listUnchanged,
};
/** The way back from a saved place: removing it while nobody changed, rated or labelled it since. */
export const DELETE_SAVED_PLACE: NonNullable<ToolCards['undo']> = {
tool: 'delete_collection_place',
args: (_args, result) => {
const place = recordOf(recordOf(result).place);
const placeId = positiveId(place.id);
return placeId === null || !saverOwnsList(place) ? null : { placeId };
},
reads: (_args, result) => [{ tool: 'get_collection_items', args: { placeIds: [createdId(result, 'place')] } }],
unchanged: placeUnchanged,
};
@@ -0,0 +1,411 @@
import type { Described, PreviewContext, ToolArgs } from '../card-types';
import { argId, clipped, recordOf } from '../card-values';
import { Rows } from '../preview-format';
import { rowsOf } from '../undo-snapshot';
import { inviteRowOf, listsCard } from './collection-answers';
import { type CollectionLookup, NO_LOOKUP } from './collection-load';
import {
carriedLabelIds,
commonListId,
emptyDelta,
foundPlaces,
itemListOf,
itemsOf,
labelIdSet,
listLabelIds,
namesOf,
placeFromArgs,
placeFromTrip,
placesCard,
tripPlaceIds,
wholeList,
type WholeList,
} from './collection-parts';
import {
argText,
colorOf,
idsIn,
labelOf,
listOf,
listRowOf,
positiveId,
statusArg,
statusOf,
} from './collection-values';
import {
COLLECTION_CARD_FIELDS,
type CollectionCardDelta,
type CollectionCardList,
type CollectionCardPlace,
type CollectionListRow,
type TrekCard,
} from '@trek/shared';
/**
* The change cards of the collections' writes: what a held call will do to a
* list, a saved place or a label, from its arguments and what its lookup read
* (collection-load.ts). Every describer reads `{}` arguments and a failed
* lookup too; the card then shows what the arguments say, or nothing. The
* words of the row stay plain (the subject, the tool's own label); the card
* carries the facts. Only trip places light up on the map: a saved place's id
* is never a trip place's.
*/
type Lookup = CollectionLookup | null;
interface CollectionChange {
describe(ctx: PreviewContext, args: ToolArgs, loaded: Lookup): Described;
change(ctx: PreviewContext, args: ToolArgs, loaded: Lookup): TrekCard | null;
}
function described(tool: string, subject: string | null, placeIds: number[] = []): Described {
return { subject: subject ?? '', rows: new Rows(), dayId: null, placeIds, tool, domain: 'collections' };
}
function lookupOf(loaded: Lookup): CollectionLookup {
return loaded ?? NO_LOOKUP;
}
/** The arguments of a saved place's change that name a field of it, by the card's field names. */
const FIELD_OF: Readonly<Record<string, (typeof COLLECTION_CARD_FIELDS)[number]>> = {
name: 'name',
address: 'address',
lat: 'coordinates',
lng: 'coordinates',
description: 'description',
notes: 'notes',
category_id: 'category',
links: 'link',
image_url: 'photo',
};
function fieldsOf(args: ToolArgs): CollectionCardDelta['fields'] {
const fields = Object.keys(args).flatMap((key) => (FIELD_OF[key] && args[key] !== undefined ? [FIELD_OF[key]] : []));
return COLLECTION_CARD_FIELDS.filter((field) => fields.includes(field));
}
/** The lists a membership lookup found the place to save in already, at most three. */
function savedInOf(membership: unknown): CollectionCardPlace['savedIn'] {
const found: CollectionCardPlace['savedIn'] = [];
for (const row of rowsOf(recordOf(membership).lists)) {
const listId = positiveId(row.collection_id);
const name = clipped(row.name, 120);
if (listId !== null && name && !found.some((saved) => saved.listId === listId)) {
found.push({ listId, name, status: statusOf(row.status) });
}
}
return found.slice(0, 3);
}
/** The names of the first trip places a call names, for its subject. */
function tripNames(ctx: PreviewContext, ids: readonly number[]): string {
return namesOf(ids.slice(0, 3).flatMap((id) => ctx.card.places.get(id) ?? []));
}
/** The one saved place a change names, as its lookup found it. */
function currentPlace(ctx: PreviewContext, args: ToolArgs, loaded: Lookup): CollectionCardPlace | null {
const placeId = argId(args, 'placeId');
return placeId === null ? null : (foundPlaces(ctx, lookupOf(loaded), [placeId])[0] ?? null);
}
/**
* The card of a change to one saved place, under its list; the place carries
* what changes. `moreLabels` names labels of another list (the one it moves to).
*/
function onePlaceCard(
loaded: Lookup,
place: CollectionCardPlace | null,
delta: CollectionCardPlace['delta'],
moreLabels: unknown = null,
) {
if (!place) return null;
const items = itemsOf(lookupOf(loaded));
const list = itemListOf(items.lists, place.listId);
const pool = [...items.lists.flatMap((row) => rowsOf(row.labels)), ...rowsOf(moreLabels)];
return placesCard(list, [{ ...place, delta }], pool);
}
/** A place change: its subject is the place, the card its list and what happens to it. */
function placeChange(tool: string, deltaOf: (args: ToolArgs, place: CollectionCardPlace) => CollectionCardDelta) {
return {
describe: (ctx, args, loaded) => described(tool, currentPlace(ctx, args, loaded)?.name ?? null),
change: (ctx, args, loaded) => {
const place = currentPlace(ctx, args, loaded);
return place ? onePlaceCard(loaded, place, deltaOf(args, place)) : null;
},
} satisfies CollectionChange;
}
/** A change over several saved places, by the ids the call names. */
function manyPlaces(ctx: PreviewContext, args: ToolArgs, loaded: Lookup): CollectionCardPlace[] {
return foundPlaces(ctx, lookupOf(loaded), idsIn(args.place_ids, 50));
}
/** Labels added to (or taken from) each place, as the service applies them: only its own list's, only a real change. */
function labelDeltaOf(ctx: PreviewContext, args: ToolArgs, loaded: Lookup): CollectionCardPlace[] {
const lists = itemsOf(lookupOf(loaded)).lists;
const asked = idsIn(args.label_ids, 50);
const remove = args.remove === true;
return manyPlaces(ctx, args, loaded).map((place) => {
const own = listLabelIds(lists, place.listId);
const carried = carriedLabelIds(lookupOf(loaded), place.id);
const applicable = asked.filter((id) => own.has(id));
const changed = remove
? applicable.filter((id) => carried.includes(id))
: applicable.filter((id) => !carried.includes(id));
const delta = emptyDelta(remove ? { labelsRemoved: changed.slice(0, 10) } : { labelsAdded: changed.slice(0, 10) });
return { ...place, delta };
});
}
/** A list row from the list a lookup read whole. */
function currentListRow(loaded: Lookup): WholeList {
return wholeList(lookupOf(loaded).collection);
}
/** The user's lists as `list_collections` gave them, and the invites waiting. */
function userLists(loaded: Lookup): { own: CollectionCardList[]; invites: CollectionListRow[] } {
const root = recordOf(lookupOf(loaded).lists);
return {
own: rowsOf(root.collections)
.map((row) => listOf(row))
.filter((list): list is CollectionCardList => list !== null),
invites: rowsOf(root.incomingInvites)
.map((row) => inviteRowOf(row))
.filter((row): row is CollectionListRow => row !== null),
};
}
function inviteFor(loaded: Lookup, args: ToolArgs): CollectionListRow | null {
const id = argId(args, 'collectionId');
return userLists(loaded).invites.find((row) => row.id === id) ?? null;
}
/** The label a change names, as its lookup found it, with its list read whole. */
function currentLabel(args: ToolArgs, loaded: Lookup) {
const labelId = argId(args, 'labelId');
const raw = itemsOf(lookupOf(loaded)).labels.find((label) => positiveId(label.id) === labelId);
const whole = wholeList(lookupOf(loaded).collection);
return { label: raw ? labelOf(raw, whole.places) : null, list: whole.list };
}
const LIST_CHANGES: Readonly<Record<string, CollectionChange>> = {
create_collection: {
describe: (_ctx, args) => described('create_collection', argText(args.name, 120)),
change: (_ctx, args) => {
const name = argText(args.name, 120);
if (!name) return null;
const list = { id: null, name, color: colorOf(args.color), placeCount: 0, memberCount: 0, counts: null };
return listsCard([listRowOf(list, { change: 'add' })], null);
},
},
update_collection: {
describe: (_ctx, args, loaded) =>
described('update_collection', argText(args.name, 120) ?? currentListRow(loaded).list?.name ?? null),
change: (_ctx, args, loaded) => {
const { list } = currentListRow(loaded);
if (!list) return null;
const name = argText(args.name, 120) ?? list.name;
const color = args.color === undefined ? list.color : colorOf(args.color);
const renamed = name !== list.name || color !== list.color;
const before = renamed ? { name: list.name, color: list.color } : null;
return listsCard([listRowOf({ ...list, name, color }, { change: 'update', before })], null);
},
},
delete_collection: {
describe: (_ctx, _args, loaded) => described('delete_collection', currentListRow(loaded).list?.name ?? null),
change: (_ctx, _args, loaded) => {
const { list } = currentListRow(loaded);
return list ? listsCard([listRowOf(list, { change: 'remove' })], null) : null;
},
},
reorder_collections: {
describe: (_ctx, args, loaded) => {
const { own } = userLists(loaded);
const order = idsIn(args.orderedIds, 1000).map((id) => own.find((list) => list.id === id));
return described('reorder_collections', namesOf(order.filter((list) => list !== undefined).slice(0, 3)));
},
change: (_ctx, args, loaded) => {
const { own } = userLists(loaded);
const rows = idsIn(args.orderedIds, 1000)
.map((id) => own.find((list) => list.id === id))
.filter((list): list is CollectionCardList => list !== undefined)
.map((list) => listRowOf(list, { change: 'order' }));
return rows.length > 0 ? listsCard(rows, null) : null;
},
},
accept_collection_invite: {
describe: (_ctx, args, loaded) => described('accept_collection_invite', inviteFor(loaded, args)?.name ?? null),
change: (_ctx, args, loaded) => {
const invite = inviteFor(loaded, args);
return invite ? listsCard([{ ...invite, change: 'join' }], null) : null;
},
},
decline_collection_invite: {
describe: (_ctx, args, loaded) => described('decline_collection_invite', inviteFor(loaded, args)?.name ?? null),
change: (_ctx, args, loaded) => {
const invite = inviteFor(loaded, args);
return invite ? listsCard([{ ...invite, change: 'decline' }], null) : null;
},
},
cancel_collection_invite: {
describe: (_ctx, _args, loaded) => described('cancel_collection_invite', currentListRow(loaded).list?.name ?? null),
change: (_ctx, args, loaded) => {
const { list, members } = currentListRow(loaded);
if (!list) return null;
const userId = argId(args, 'userId');
const member = members.find((row) => positiveId(row.user_id) === userId);
const name = clipped(member?.username, 80);
const person = userId !== null && name ? { id: userId, name, avatarKey: null } : null;
return listsCard([listRowOf(list, { change: 'withdraw', person })], null);
},
},
};
const PLACE_CHANGES: Readonly<Record<string, CollectionChange>> = {
save_place_to_collection: {
describe: (_ctx, args) => described('save_place_to_collection', argText(args.name, 120)),
change: (ctx, args, loaded) => {
const whole = wholeList(lookupOf(loaded).collection);
const place = placeFromArgs(ctx, args, whole.list?.id ?? argId(args, 'collection_id'));
if (!place) return null;
return placesCard(whole.list, [{ ...place, savedIn: savedInOf(lookupOf(loaded).membership) }], whole.labels);
},
},
save_trip_places_to_collection: {
describe: (ctx, args) => {
const ids = tripPlaceIds(ctx, args.placeIds);
return described('save_trip_places_to_collection', tripNames(ctx, ids), ids);
},
change: (ctx, args, loaded) => {
const whole = wholeList(lookupOf(loaded).collection);
const ids = tripPlaceIds(ctx, args.placeIds);
const listId = whole.list?.id ?? argId(args, 'collectionId');
const places = ids
.slice(0, 8)
.map((id) => placeFromTrip(ctx, id, listId, 'idea'))
.filter((place): place is CollectionCardPlace => place !== null);
return places.length > 0 ? placesCard(whole.list, places, whole.labels, { total: ids.length }) : null;
},
},
set_collection_place_status_from_trip: {
describe: (ctx, args) => {
const ids = tripPlaceIds(ctx, args.place_ids);
return described('set_collection_place_status_from_trip', tripNames(ctx, ids), ids);
},
change: (ctx, args) => {
const to = statusArg(args.status) ?? 'idea';
const ids = tripPlaceIds(ctx, args.place_ids);
const places = ids
.slice(0, 8)
.map((id) => placeFromTrip(ctx, id, null, to))
.filter((place): place is CollectionCardPlace => place !== null)
.map((place) => ({ ...place, delta: emptyDelta({ status: { from: null, to } }) }));
return places.length > 0 ? placesCard(null, places, null, { total: ids.length, everyList: true }) : null;
},
},
update_collection_place: {
describe: (ctx, args, loaded) =>
described('update_collection_place', argText(args.name, 120) ?? currentPlace(ctx, args, loaded)?.name ?? null),
change: (ctx, args, loaded) => {
const place = currentPlace(ctx, args, loaded);
if (!place) return null;
const to = statusArg(args.status);
const moveTo = argId(args, 'collection_id');
const moving = moveTo !== null && moveTo !== place.listId;
const target = wholeList(lookupOf(loaded).target);
// A move takes every label of the old list off; the labels asked for then come from the new list.
const had = carriedLabelIds(lookupOf(loaded), place.id);
const carried: number[] = moving ? [] : had;
const allowed = moving ? labelIdSet(target.labels) : listLabelIds(itemsOf(lookupOf(loaded)).lists, place.listId);
const wanted = args.label_ids === undefined ? null : idsIn(args.label_ids, 50).filter((id) => allowed.has(id));
const dropped: number[] = moving ? had : wanted ? had.filter((id) => !wanted.includes(id)) : [];
const delta = emptyDelta({
status: to !== null && to !== place.status ? { from: place.status, to } : null,
labelsAdded: wanted ? wanted.filter((id) => !carried.includes(id)).slice(0, 10) : [],
labelsRemoved: dropped.slice(0, 10),
fields: fieldsOf(args),
movedTo: moving ? target.list : null,
});
return onePlaceCard(loaded, place, delta, moving ? target.labels : null);
},
},
set_collection_place_status: placeChange('set_collection_place_status', (args, place) =>
emptyDelta({ status: { from: place.status, to: statusArg(args.status) ?? 'idea' } }),
),
rate_collection_place: placeChange('rate_collection_place', (args) => {
const rating = typeof args.rating === 'number' && Number.isInteger(args.rating) ? args.rating : null;
return emptyDelta({ rating: { to: rating !== null && rating >= 1 && rating <= 5 ? rating : null } });
}),
delete_collection_place: placeChange('delete_collection_place', () => emptyDelta({ removed: true })),
assign_collection_labels: {
describe: (ctx, args, loaded) =>
described('assign_collection_labels', namesOf(manyPlaces(ctx, args, loaded).slice(0, 3))),
change: (ctx, args, loaded) => {
const places = labelDeltaOf(ctx, args, loaded);
if (places.length === 0) return null;
const items = itemsOf(lookupOf(loaded));
const list = itemListOf(items.lists, commonListId(places));
const pool = items.lists.flatMap((row) => rowsOf(row.labels));
return placesCard(list, places, pool, { total: idsIn(args.place_ids, 1000).length });
},
},
copy_collection_places_to_trip: {
describe: (ctx, args, loaded) =>
described('copy_collection_places_to_trip', namesOf(manyPlaces(ctx, args, loaded).slice(0, 3))),
change: (ctx, args, loaded) => {
const found = manyPlaces(ctx, args, loaded);
if (found.length === 0) return null;
const items = itemsOf(lookupOf(loaded));
const tripId = argId(args, 'trip_id');
const otherTrip = tripId !== null && tripId !== ctx.card.tripId;
// Whether another trip has a place already is not known here: the chat's trip says nothing about it.
const places = otherTrip ? found.map((place) => ({ ...place, inTripPlaceId: null })) : found;
const pool = items.lists.flatMap((row) => rowsOf(row.labels));
return placesCard(itemListOf(items.lists, commonListId(places)), places, pool, {
total: idsIn(args.place_ids, 1000).length,
otherTrip,
});
},
},
};
const LABEL_CHANGES: Readonly<Record<string, CollectionChange>> = {
create_collection_label: {
describe: (_ctx, args) => described('create_collection_label', argText(args.name, 60)),
change: (_ctx, args, loaded) => {
const name = argText(args.name, 60);
if (!name) return null;
const label = { id: null, name, color: colorOf(args.color), places: 0, change: 'add' as const, before: null };
return { ...placesCard(wholeList(lookupOf(loaded).collection).list, [], null), labels: [label] };
},
},
update_collection_label: {
describe: (_ctx, args, loaded) =>
described('update_collection_label', argText(args.name, 60) ?? currentLabel(args, loaded).label?.name ?? null),
change: (_ctx, args, loaded) => {
const { label, list } = currentLabel(args, loaded);
if (!label) return null;
const name = argText(args.name, 60) ?? label.name;
const color = args.color === undefined ? label.color : colorOf(args.color);
const before = name !== label.name || color !== label.color ? { name: label.name, color: label.color } : null;
const changed = { ...label, name, color, change: 'rename' as const, before };
return { ...placesCard(list, [], null), labels: [changed] };
},
},
delete_collection_label: {
describe: (_ctx, args, loaded) =>
described('delete_collection_label', currentLabel(args, loaded).label?.name ?? null),
change: (_ctx, args, loaded) => {
const { label, list } = currentLabel(args, loaded);
return label ? { ...placesCard(list, [], null), labels: [{ ...label, change: 'remove' as const }] } : null;
},
},
};
/** Every held change of the collections with its card. */
export const COLLECTION_CHANGES: Readonly<Record<string, CollectionChange>> = {
...LIST_CHANGES,
...PLACE_CHANGES,
...LABEL_CHANGES,
};
@@ -0,0 +1,114 @@
import type { CardReader, ToolArgs } from '../card-types';
import { argId, recordOf } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import { idsIn, positiveId } from './collection-values';
/**
* What describing a held change of the collections reads first, through the
* turn's memo (at most four reads, each a null on failure): the list a change
* works in (`get_collection`), the saved places or labels it names
* (`get_collection_items`), the lists a place to save is in already
* (`find_place_in_collections`) and the user's lists with their invites
* (`list_collections`). Its arguments are the tool's own; nothing here writes.
*/
export interface CollectionLookup {
/** The list the change works in, read whole. */
collection: unknown;
/** The list a saved place moves to. */
target: unknown;
items: unknown;
membership: unknown;
lists: unknown;
}
export const NO_LOOKUP: CollectionLookup = {
collection: null,
target: null,
items: null,
membership: null,
lists: null,
};
type Loader = (args: ToolArgs, read: CardReader) => Promise<CollectionLookup>;
function collectionOf(read: CardReader, collectionId: number | null): Promise<unknown> {
return collectionId === null ? Promise.resolve(null) : read('get_collection', { collectionId });
}
function placesOf(read: CardReader, placeIds: number[]): Promise<unknown> {
return placeIds.length === 0 ? Promise.resolve(null) : read('get_collection_items', { placeIds });
}
/** How to ask where a place is saved: by its provider ids or its position; nothing to ask without either. */
function membershipQuery(args: ToolArgs): ToolArgs | null {
const query: ToolArgs = {};
for (const key of ['google_place_id', 'google_ftid']) {
if (typeof args[key] === 'string' && args[key] !== '') query[key] = args[key];
}
if (typeof args.lat === 'number' && typeof args.lng === 'number') {
query.lat = args.lat;
query.lng = args.lng;
}
return Object.keys(query).length > 0 ? query : null;
}
/** The one saved place the change names. */
const onePlace: Loader = async (args, read) => ({
...NO_LOOKUP,
items: await placesOf(read, idsIn([args.placeId], 1)),
});
/** The saved places of a change over several. */
const manyPlaces: Loader = async (args, read) => ({
...NO_LOOKUP,
items: await placesOf(read, idsIn(args.place_ids, 50)),
});
/** The list a change works in, by the argument that names it. */
const listBy =
(key: string): Loader =>
async (args, read) => ({ ...NO_LOOKUP, collection: await collectionOf(read, argId(args, key)) });
/** The user's lists and the invites waiting. */
const allLists: Loader = async (_args, read) => ({ ...NO_LOOKUP, lists: await read('list_collections') });
/** A label by its id, then its list read whole, so the card can say how many places carry it. */
const oneLabel: Loader = async (args, read) => {
const labelId = argId(args, 'labelId');
const items = labelId === null ? null : await read('get_collection_items', { labelIds: [labelId] });
const label = rowsOf(recordOf(items).labels)[0];
return { ...NO_LOOKUP, items, collection: await collectionOf(read, positiveId(recordOf(label).collection_id)) };
};
export const COLLECTION_LOADS: Readonly<Record<string, Loader>> = {
save_place_to_collection: async (args, read) => {
const query = membershipQuery(args);
const [collection, membership] = await Promise.all([
collectionOf(read, argId(args, 'collection_id')),
query ? read('find_place_in_collections', query) : Promise.resolve(null),
]);
return { ...NO_LOOKUP, collection, membership };
},
save_trip_places_to_collection: listBy('collectionId'),
update_collection_place: async (args, read) => {
const [items, target] = await Promise.all([
placesOf(read, idsIn([args.placeId], 1)),
collectionOf(read, argId(args, 'collection_id')),
]);
return { ...NO_LOOKUP, items, target };
},
set_collection_place_status: onePlace,
rate_collection_place: onePlace,
delete_collection_place: onePlace,
assign_collection_labels: manyPlaces,
copy_collection_places_to_trip: manyPlaces,
update_collection: listBy('collectionId'),
delete_collection: listBy('collectionId'),
cancel_collection_invite: listBy('collectionId'),
create_collection_label: listBy('collection_id'),
update_collection_label: oneLabel,
delete_collection_label: oneLabel,
reorder_collections: allLists,
accept_collection_invite: allLists,
decline_collection_invite: allLists,
};
@@ -0,0 +1,230 @@
import type { TripCard } from '../../assistant-context';
import type { PreviewContext, ToolArgs } from '../card-types';
import { finite, isLat, isLng, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import type { CollectionLookup } from './collection-load';
import {
argText,
googleIdOf,
idsIn,
inTripOf,
listOf,
MAX_PLACES,
positiveId,
savedPlaceOf,
statusOf,
tally,
usedLabels,
} from './collection-values';
import type {
CollectionCardDelta,
CollectionCardList,
CollectionCardPlace,
CollectionPlacesCard,
CollectionStatus,
} from '@trek/shared';
/**
* The pieces the change cards of the collections are put together from: the
* list a lookup read, the saved places it found, a place to save as its
* arguments describe it, a trip place as a saved place would be, and the card
* that holds them.
*/
/** A list read whole by `get_collection`: its head, its places and its label definitions. */
export interface WholeList {
list: CollectionCardList | null;
places: Row[];
labels: unknown;
members: Row[];
}
export function wholeList(value: unknown): WholeList {
const root = recordOf(value);
const collection = recordOf(root.collection);
const places = rowsOf(root.places);
const members = rowsOf(collection.members);
return { list: listOf(collection, tally(places)), places, labels: collection.labels, members };
}
/** What `get_collection_items` found: the saved places and the lists they sit in. */
export function itemsOf(lookup: CollectionLookup): { places: Row[]; lists: Row[]; labels: Row[] } {
const root = recordOf(lookup.items);
return { places: rowsOf(root.places), lists: rowsOf(root.lists), labels: rowsOf(root.labels) };
}
/** The list one of the found places sits in, as a card names it (not read whole: no counts). */
export function itemListOf(lists: readonly Row[], listId: number | null): CollectionCardList | null {
const row = listId === null ? undefined : lists.find((list) => positiveId(list.id) === listId);
return row ? listOf(row) : null;
}
/** The ids of a list's label definitions. */
export function labelIdSet(labels: unknown): Set<number> {
return new Set(rowsOf(labels).map((label) => positiveId(label.id) ?? 0));
}
/** The label ids a list defines, from the lists `get_collection_items` gave. */
export function listLabelIds(lists: readonly Row[], listId: number | null): Set<number> {
return labelIdSet(lists.find((list) => positiveId(list.id) === listId)?.labels);
}
/**
* Every label a found place carries (a list holds up to 50), not only the
* ones its card names: what a change adds or takes away is worked out on these.
*/
export function carriedLabelIds(lookup: CollectionLookup, placeId: number | null): number[] {
const row = itemsOf(lookup).places.find((place) => positiveId(place.id) === placeId);
return idsIn(row?.label_ids, 50);
}
/** The one list every place sits in; null when they sit in several. */
export function commonListId(places: readonly CollectionCardPlace[]): number | null {
const ids = new Set(places.map((place) => place.listId));
const [only] = [...ids];
return ids.size === 1 && only !== undefined ? only : null;
}
export function emptyDelta(over: Partial<CollectionCardDelta> = {}): CollectionCardDelta {
return {
status: null,
rating: null,
labelsAdded: [],
labelsRemoved: [],
fields: [],
movedTo: null,
removed: false,
...over,
};
}
/** A places card; `total` counts the places the change names beyond the ones shown. */
export function placesCard(
list: CollectionCardList | null,
places: readonly CollectionCardPlace[],
labelPool: unknown,
extra: Partial<CollectionPlacesCard> & { total?: number } = {},
): CollectionPlacesCard {
const { total, ...rest } = extra;
const shown = places.slice(0, MAX_PLACES);
// The labels a change adds or takes away are named too.
const named = shown.map((place) => ({
...place,
labelIds: [...place.labelIds, ...(place.delta?.labelsAdded ?? []), ...(place.delta?.labelsRemoved ?? [])],
}));
return {
kind: 'collectionPlaces',
list,
places: shown,
more: Math.max(0, (total ?? places.length) - shown.length),
labels: usedLabels(labelPool, named, null),
outcome: null,
everyList: false,
otherTrip: false,
...rest,
};
}
/** A category by its id, as the turn's category list names it. */
function categoryArg(ctx: PreviewContext, value: unknown): CollectionCardPlace['category'] {
const id = positiveId(value);
const name = id === null ? null : argText(ctx.categories.get(id), 60);
return name ? { name, icon: null, color: null } : null;
}
/** A place to save as the call describes it: not saved yet, so without an id. */
export function placeFromArgs(ctx: PreviewContext, args: ToolArgs, listId: number | null): CollectionCardPlace | null {
const name = argText(args.name, 120);
if (!name) return null;
const lat = finite(args.lat);
const lng = finite(args.lng);
const coords = isLat(lat) && isLng(lng);
const googlePlaceId = googleIdOf(args.google_place_id);
const osmId = argText(args.osm_id, 40);
return {
id: null,
listId,
name,
address: argText(args.address, 200),
lat: coords ? lat : null,
lng: coords ? lng : null,
category: categoryArg(ctx, args.category_id),
status: statusOf(args.status),
labelIds: [],
rating: null,
ratingCount: 0,
googlePlaceId,
osmId,
imageKey: null,
inTripPlaceId: inTripOf(ctx.card, {
googlePlaceId,
osmId,
sourceTripId: args.source_trip_id,
sourcePlaceId: args.source_place_id,
}),
savedIn: [],
delta: null,
};
}
const tripImages = new WeakMap<TripCard, Map<number, unknown>>();
/** The photos of the trip's places, read once per trip card from the places the turn read. */
function tripImageOf(card: TripCard, placeId: number): unknown {
let images = tripImages.get(card);
if (!images) {
const rows = rowsOf(recordOf(card.sources.places).places);
images = new Map(rows.map((row) => [positiveId(row.id) ?? 0, row.image_url]));
tripImages.set(card, images);
}
return images.get(placeId) ?? null;
}
/** A trip place as it would be saved in a list: the trip's own place, so it is "already in the trip". */
export function placeFromTrip(
ctx: PreviewContext,
placeId: number,
listId: number | null,
status: CollectionStatus,
): CollectionCardPlace | null {
const place = ctx.card.places.get(placeId);
if (!place) return null;
const saved = savedPlaceOf(
{
name: place.name,
address: place.address,
lat: place.lat,
lng: place.lng,
google_place_id: place.googlePlaceId,
osm_id: place.osmId,
image_url: tripImageOf(ctx.card, placeId),
status,
collection_id: listId,
},
null,
);
return saved ? { ...saved, category: categoryArg(ctx, place.categoryId), inTripPlaceId: placeId } : null;
}
/** The trip places a call names, in its order: those the trip card holds. */
export function tripPlaceIds(ctx: PreviewContext, value: unknown): number[] {
return idsIn(value, 1000).filter((id) => ctx.card.places.has(id));
}
/** The saved places a lookup found, in the order the call names them. */
export function foundPlaces(
ctx: PreviewContext,
lookup: CollectionLookup,
ids: readonly number[],
): CollectionCardPlace[] {
const rows = itemsOf(lookup).places;
return ids
.map((id) => rows.find((row) => positiveId(row.id) === id))
.map((row) => (row ? savedPlaceOf(row, ctx.card) : null))
.filter((place): place is CollectionCardPlace => place !== null);
}
/** "Septime, Le Comptoir": the names a change works on, for its subject. */
export function namesOf(places: readonly { name: string }[]): string {
return places.map((place) => place.name).join(', ');
}
@@ -0,0 +1,218 @@
import type { TripCard } from '../../assistant-context';
import { clipped, fileKeyOf, finite, hexOrNull, isLat, isLng, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import {
cardPhotoKeySchema,
COLLECTION_STATUSES,
type CollectionCardLabel,
type CollectionCardList,
type CollectionCardPlace,
type CollectionListRow,
type CollectionStatus,
} from '@trek/shared';
/**
* How the collections' answers read as card pieces: a list, a label, a saved
* place. Every text is masked and cut (`clipped`), every colour checked, a
* photo only TREK's own upload, and notes, descriptions, phone numbers, links
* and the members' addresses are never read. A saved place is matched to the
* chat's trip by its provider id, or by the trip place it was saved from.
*/
/** The rows a card shows at most, and the labels it names. */
export const MAX_PLACES = 8;
export const MAX_LISTS = 12;
const MAX_LABELS = 24;
/**
* A list's or label's colour as a card carries it: the short `#abc` the
* forms accept spelled out to `#aabbcc`, anything else checked as usual.
*/
export function colorOf(value: unknown): string | null {
if (typeof value === 'string' && /^#[0-9a-fA-F]{3}$/.test(value)) {
return value.replace(/[0-9a-fA-F]/g, (digit) => digit + digit);
}
return hexOrNull(value);
}
export function positiveId(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
}
/** The positive ids in a list argument, in order and each once; at most `max`. */
export function idsIn(value: unknown, max: number): number[] {
if (!Array.isArray(value)) return [];
const ids = value.map(positiveId).filter((id): id is number => id !== null);
return [...new Set(ids)].slice(0, max);
}
function countOf(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 1_000_000 ? value : null;
}
export function statusOf(value: unknown): CollectionStatus {
return COLLECTION_STATUSES.find((status) => status === value) ?? 'idea';
}
/** A status a change asks for; null when the argument names none. */
export function statusArg(value: unknown): CollectionStatus | null {
return COLLECTION_STATUSES.find((status) => status === value) ?? null;
}
/** The places of a whole list by status. */
export function tally(places: readonly Row[]): NonNullable<CollectionCardList['counts']> {
const counts = { idea: 0, want: 0, visited: 0 };
for (const place of places) counts[statusOf(place.status)] += 1;
return counts;
}
/** The members who share a list besides its owner: accepted ones only. */
function sharedWith(members: unknown): number {
return rowsOf(members).filter((member) => member.status === 'accepted' && member.is_owner !== true).length;
}
/** A list from a collection row of `get_collection`, `list_collections` or `get_collection_items`. */
export function listOf(raw: unknown, counts: CollectionCardList['counts'] = null): CollectionCardList | null {
const row = recordOf(raw);
const id = positiveId(row.id);
const name = clipped(row.name, 120);
if (id === null || !name) return null;
return {
id,
name,
color: colorOf(row.color),
placeCount: countOf(row.place_count),
memberCount: sharedWith(row.members),
counts,
};
}
/** A list row of the lists card: a list as it is, unless the caller says what changes. */
export function listRowOf(list: CollectionCardList, extra: Partial<CollectionListRow> = {}): CollectionListRow {
return { ...list, status: null, person: null, invite: false, change: 'none', before: null, ...extra };
}
/** A label of a list; `places` counts the places of the whole list that carry it, where the list was read. */
export function labelOf(
raw: unknown,
places: readonly Row[] | null,
extra: Partial<CollectionCardLabel> = {},
): CollectionCardLabel | null {
const row = recordOf(raw);
const id = positiveId(row.id);
const name = clipped(row.name, 60);
if (!name) return null;
const carriers = places && id !== null ? places.filter((place) => idsIn(place.label_ids, 50).includes(id)) : null;
return {
id,
name,
color: colorOf(row.color),
places: carriers ? carriers.length : null,
change: 'none',
before: null,
...extra,
};
}
/** The labels of `pool` the shown places carry, counted over `whole` where the whole list was read. */
export function usedLabels(
pool: unknown,
shown: readonly CollectionCardPlace[],
whole: readonly Row[] | null,
): CollectionCardLabel[] {
const used = new Set(shown.flatMap((place) => place.labelIds));
return rowsOf(pool)
.filter((label) => used.has(positiveId(label.id) ?? 0))
.map((label) => labelOf(label, whole))
.filter((label): label is CollectionCardLabel => label !== null)
.slice(0, MAX_LABELS);
}
/** The file name of a place photo TREK keeps itself; a photo on another host gives none. */
function placeImageKey(value: unknown): string | null {
return typeof value === 'string' && value.startsWith('/uploads/places/') ? fileKeyOf(value) : null;
}
/**
* A text from a call's arguments as a card shows it: on one line and cut to
* `max`, not masked, since it is what the change will store.
*/
export function argText(value: unknown, max: number): string | null {
if (typeof value !== 'string') return null;
const line = value.replace(/\s+/g, ' ').trim();
if (line === '') return null;
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
}
export function googleIdOf(value: unknown): string | null {
return typeof value === 'string' && cardPhotoKeySchema.safeParse(value).success ? value : null;
}
/** An average of the members' votes, to a tenth; null without votes. */
function ratingOf(value: unknown): number | null {
const rating = finite(value);
return rating !== null && rating >= 1 && rating <= 5 ? Math.round(rating * 10) / 10 : null;
}
function categoryOf(raw: unknown): CollectionCardPlace['category'] {
const row = recordOf(raw);
const name = clipped(row.name, 60);
if (!name) return null;
const icon = typeof row.icon === 'string' && /^[A-Za-z0-9]{1,40}$/.test(row.icon) ? row.icon : null;
return { name, icon, color: colorOf(row.color) };
}
/** The trip place that is this saved place: the same Google or OpenStreetMap id, else the one it was saved from. */
export function inTripOf(
card: TripCard | null,
ids: { googlePlaceId: string | null; osmId: string | null; sourceTripId?: unknown; sourcePlaceId?: unknown },
): number | null {
if (!card) return null;
for (const place of card.places.values()) {
if (ids.googlePlaceId !== null && place.googlePlaceId === ids.googlePlaceId) return place.id;
if (ids.osmId !== null && place.osmId === ids.osmId) return place.id;
}
const source = positiveId(ids.sourcePlaceId);
return ids.sourceTripId === card.tripId && source !== null && card.places.has(source) ? source : null;
}
/** A saved place from a row of the collections' answers; `card` is the chat's trip, null where none is at hand. */
export function savedPlaceOf(raw: unknown, card: TripCard | null): CollectionCardPlace | null {
const row = recordOf(raw);
const name = clipped(row.name, 120);
if (!name) return null;
const lat = finite(row.lat);
const lng = finite(row.lng);
const googlePlaceId = googleIdOf(row.google_place_id);
const osmId = clipped(row.osm_id, 40);
const coords = isLat(lat) && isLng(lng);
return {
id: positiveId(row.id),
listId: positiveId(row.collection_id),
name,
address: clipped(row.address, 200),
lat: coords ? lat : null,
lng: coords ? lng : null,
category: categoryOf(row.category),
status: statusOf(row.status),
labelIds: idsIn(row.label_ids, 10),
rating: ratingOf(row.rating_avg),
ratingCount: countOf(row.rating_count) ?? 0,
googlePlaceId,
osmId,
imageKey: placeImageKey(row.image_url),
inTripPlaceId: inTripOf(card, {
googlePlaceId,
osmId,
sourceTripId: row.source_trip_id,
sourcePlaceId: row.source_place_id,
}),
savedIn: [],
delta: null,
};
}
/** The saved places of rows that read as one, each once. */
export function savedPlacesOf(rows: readonly Row[], card: TripCard | null): CollectionCardPlace[] {
return rows.map((row) => savedPlaceOf(row, card)).filter((place): place is CollectionCardPlace => place !== null);
}
@@ -1,4 +1,84 @@
import type { DomainCards } from '../card-types';
import type { AssistantWriteTool } from '../../assistant-tools.constants';
import type { DomainCards, ToolCards } from '../card-types';
import { collectionAnswer, itemsAnswer, listsAnswer, membershipAnswer } from './collection-answers';
import {
COLLECTION_APPLIED,
COLLECTION_APPLIED_IDS,
DELETE_CREATED_LIST,
DELETE_SAVED_PLACE,
} from './collection-applied';
import { COLLECTION_CHANGES } from './collection-changes';
import { COLLECTION_LOADS, type CollectionLookup } from './collection-load';
/** The cards of the collections and their places. None yet: this package fills them in. */
export const COLLECTION_CARDS: DomainCards = { id: 'collection', tools: {} };
/**
* The collections in the assistant chat: a list read shows its head and
* first places, saved places by id their rows, the lists with the invites
* waiting, and where a place is saved; every write is held as a change card
* built from what it will touch, and replaced by what it did once applied.
* A saved place's id is its own, never a trip place's, so the collections'
* place ids never name a trip place on the generic card either.
*/
/** Each write's verb and what its receipt names: a list, a saved place, or for a copy into the trip, trip places. */
const WRITES: Readonly<Record<string, AssistantWriteTool>> = {
create_collection: { verbKey: 'genericCreate', entity: 'collection' },
update_collection: { verbKey: 'genericUpdate', entity: 'collection' },
delete_collection: { verbKey: 'genericRemove', entity: 'collection' },
reorder_collections: { verbKey: 'genericUpdate', entity: 'collection' },
accept_collection_invite: { verbKey: 'genericUpdate', entity: 'collection' },
decline_collection_invite: { verbKey: 'genericRemove', entity: 'collection' },
cancel_collection_invite: { verbKey: 'genericRemove', entity: 'collection' },
save_place_to_collection: { verbKey: 'genericCreate', entity: 'collection_place' },
save_trip_places_to_collection: { verbKey: 'genericCreate', entity: 'collection' },
update_collection_place: { verbKey: 'genericUpdate', entity: 'collection_place' },
set_collection_place_status: { verbKey: 'genericUpdate', entity: 'collection_place' },
set_collection_place_status_from_trip: { verbKey: 'genericUpdate', entity: 'collection' },
rate_collection_place: { verbKey: 'genericUpdate', entity: 'collection_place' },
delete_collection_place: { verbKey: 'genericRemove', entity: 'collection_place' },
copy_collection_places_to_trip: { verbKey: 'genericCreate', entity: 'place' },
create_collection_label: { verbKey: 'genericCreate', entity: 'collection' },
update_collection_label: { verbKey: 'genericUpdate', entity: 'collection' },
delete_collection_label: { verbKey: 'genericRemove', entity: 'collection' },
assign_collection_labels: { verbKey: 'genericUpdate', entity: 'collection' },
};
/** A place's own photo is uploaded in the app, never set from an address the model picked (as for trip places). */
const NO_IMAGE = { stripped: ['image_url'] } as const;
/** The change card of one write, its lookup and what it shows once applied. */
function writeCards(tool: string, write: AssistantWriteTool): ToolCards<CollectionLookup> {
const change = COLLECTION_CHANGES[tool];
const applied = COLLECTION_APPLIED[tool];
const appliedIds = COLLECTION_APPLIED_IDS[tool];
const load = COLLECTION_LOADS[tool];
return {
write,
...(change ? { describe: change.describe, change: change.change } : {}),
...(load ? { load } : {}),
...(applied ? { applied } : {}),
...(appliedIds ? { appliedIds } : {}),
};
}
const WRITE_CARDS = Object.fromEntries(
Object.entries(WRITES).map(([tool, write]): [string, ToolCards<CollectionLookup>] => [tool, writeCards(tool, write)]),
);
export const COLLECTION_CARDS: DomainCards = {
id: 'collection',
foreignIdGroups: ['collections'],
tools: {
get_collection: { answer: collectionAnswer },
get_collection_items: { answer: itemsAnswer },
list_collections: { answer: listsAnswer },
find_place_in_collections: { answer: membershipAnswer },
...WRITE_CARDS,
create_collection: { ...WRITE_CARDS.create_collection, undo: DELETE_CREATED_LIST },
save_place_to_collection: {
...WRITE_CARDS.save_place_to_collection,
undo: DELETE_SAVED_PLACE,
argPolicy: NO_IMAGE,
},
update_collection_place: { ...WRITE_CARDS.update_collection_place, argPolicy: NO_IMAGE },
},
};
@@ -0,0 +1,83 @@
import { CollectionLabels } from '../../db/entities/CollectionLabels.entity';
import { Collections } from '../../db/entities/Collections.entity';
import { CollectionLabelsRepository } from '../../db/repositories/CollectionLabels.repository';
import { CollectionsRepository } from '../../db/repositories/Collections.repository';
import { DomainError } from '../common/domain-error';
import { CollectionsService } from './collections.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type { CollectionDetailResponse, CollectionItemsRequest, CollectionItemsResponse } from '@trek/shared';
/** The most lists one lookup opens: 50 places and 20 labels can sit in no more than 70. */
const MAX_LISTS = 70;
/**
* Saved places and labels by their own ids, wherever they are filed (the
* get_collection_items tool): what an assistant's card needs to name a place
* a change touches, and what the model needs to read one place of a long list
* without the whole list. Every list is read through the collections
* service's own access check, so an id the caller cannot see is simply left
* out, the same as one that does not exist.
*/
@Injectable()
export class CollectionsItemsService {
constructor(
private readonly collections: CollectionsService,
@InjectRepository(Collections) private readonly collectionsRepo: CollectionsRepository,
@InjectRepository(CollectionLabels) private readonly labels: CollectionLabelsRepository,
) {}
async getItems(userId: number, request: CollectionItemsRequest): Promise<CollectionItemsResponse> {
const placeIds = [...new Set(request.placeIds ?? [])];
const labelIds = [...new Set(request.labelIds ?? [])];
const listIds = new Set<number>();
for (const placeId of placeIds) {
const listId = await this.collectionsRepo.collectionIdOfPlace(placeId);
if (listId !== undefined) listIds.add(listId);
}
for (const labelId of labelIds) {
const listId = await this.labels.collectionIdOf(labelId);
if (listId !== undefined) listIds.add(listId);
}
const read: Array<CollectionDetailResponse & { canDelete: boolean }> = [];
for (const listId of [...listIds].slice(0, MAX_LISTS)) {
const detail = await this.readable(userId, listId);
if (!detail) continue;
// Places leave a list only by its owner or an admin (assertCanDelete); an editor adds.
const role = await this.collections.roleOf(userId, listId);
read.push({ ...detail, canDelete: role === 'owner' || role === 'admin' });
}
const wantedPlaces = new Set(placeIds);
const wantedLabels = new Set(labelIds);
return {
lists: read.map(({ collection, canDelete }) => ({
id: collection.id,
name: collection.name,
color: collection.color ?? null,
place_count: collection.place_count,
is_owner: collection.is_owner,
can_delete: canDelete,
labels: collection.labels ?? [],
members: (collection.members ?? []).map(({ user_id, username, status, role, is_owner }) => ({
user_id,
username,
status,
role,
is_owner,
})),
})),
places: read.flatMap(({ places }) => places.filter((place) => wantedPlaces.has(place.id))),
labels: read.flatMap(({ collection }) => (collection.labels ?? []).filter((label) => wantedLabels.has(label.id))),
};
}
/** One list as its members read it; null for one the user may not see or that is gone. */
private async readable(userId: number, listId: number): Promise<CollectionDetailResponse | null> {
try {
return await this.collections.getCollection(userId, listId);
} catch (err) {
if (err instanceof DomainError) return null;
throw err;
}
}
}
@@ -14,6 +14,7 @@ import {
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { CollectionsItemsService } from './collections-items.service';
import { CollectionsService } from './collections.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import {
@@ -27,6 +28,7 @@ import {
collectionLabelAssignRequestSchema,
collectionInviteRequestSchema,
collectionSetStatusFromTripRequestSchema,
collectionItemsRequestSchema,
collectionLinkSchema,
COLLECTION_STATUSES,
COLLECTION_ROLES,
@@ -43,6 +45,7 @@ import type {
CollectionLabelUpdateRequest,
CollectionLabelAssignRequest,
CollectionSetStatusFromTripRequest,
CollectionItemsRequest,
CollectionStatus,
CollectionRole,
} from '@trek/shared';
@@ -86,6 +89,7 @@ export class CollectionsMcp {
@InjectRepository(Users) private readonly users: UsersRepository,
readonly addons: AddonsService,
private readonly guards: McpToolGuardsService,
private readonly items: CollectionsItemsService,
) {}
// ── Read ──────────────────────────────────────────────────────────────
@@ -182,6 +186,23 @@ export class CollectionsMcp {
}
}
@Tool({
name: 'get_collection_items',
description:
'Read saved places and labels by their own ids across every list the user can see, without loading whole lists: each place with its status, labels, rating and notes, and the lists they sit in with their colour, size, labels and members. Saved place ids are collection place ids, never trip place ids. Ids the user cannot see are left out.',
inputSchema: collectionItemsRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY,
when: collectionsAddonOn,
access: { group: 'collections', mode: 'read' },
})
async getCollectionItems(request: CollectionItemsRequest, ctx: McpContext) {
try {
return ok(await this.items.getItems(ctx.userId, request));
} catch (err) {
return fail(err);
}
}
// ── Collections CRUD ─────────────────────────────────────────────────
@Tool({
@@ -21,6 +21,7 @@ import { PlacesModule } from '../places/places.module';
import { buildStorageUploadOptions } from '../storage/storage-upload.factory';
import { StorageModule } from '../storage/storage.module';
import { StorageService } from '../storage/storage.service';
import { CollectionsItemsService } from './collections-items.service';
import { CollectionsController } from './collections.controller';
import { MAX_COVER_SIZE } from './collections.controller';
import { CollectionsMcp } from './collections.mcp';
@@ -76,7 +77,7 @@ import { MulterModule } from '@nestjs/platform-express';
]),
],
controllers: [CollectionsController],
providers: [CollectionsService, CollectionsMcp, CollectionsRpc],
providers: [CollectionsService, CollectionsItemsService, CollectionsMcp, CollectionsRpc],
exports: [CollectionsService],
})
export class CollectionsModule {}
@@ -0,0 +1,213 @@
/**
* The collections in the assistant chat, end to end: the whole app (buildApp,
* the real MCP registry with its collections addon gate, the real services
* on a migrated temp SQLite) and an instance connection pointing at a fake
* OpenAI-compatible server on loopback, scripted per case. A lookup of saved
* places by id streams its card beside the answer and leaves out what the
* user cannot see; a held save streams its change card, and once approved the
* card of the saved place under the same tool call, with a receipt naming the
* saved place, which an undo takes back.
*/
import { buildApp } from '../../src/bootstrap';
import { CollectionPlaces } from '../../src/db/entities/CollectionPlaces.entity';
import { addCollectionMember, makeCollection, makeCollectionPlace } from '../helpers/factories/collections';
import { countRows, findRow } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { resetRateLimits } from '../helpers/test-db';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import type { CollectionPlacesCard, UiStreamChunk } from '@trek/shared';
import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
interface ChatBody {
messages: Array<{ role: string; content: string | null; tool_call_id?: string }>;
}
type Step = () => { chunks: object[] };
const provider = { requests: [] as ChatBody[], script: [] as Step[] };
const say =
(text: string): Step =>
() => ({
chunks: [
{ choices: [{ index: 0, delta: { content: text } }] },
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
],
});
const callTool =
(id: string, name: string, args: Record<string, unknown>): Step =>
() => ({
chunks: [
{
choices: [
{
index: 0,
delta: {
tool_calls: [{ index: 0, id, type: 'function', function: { name, arguments: JSON.stringify(args) } }],
},
},
],
},
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] },
],
});
async function answer(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.method === 'GET' && req.url?.startsWith('/api/tags')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ models: [{ name: 'fake-model', size: 1 }] }));
return;
}
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
res.writeHead(404).end();
return;
}
let text = '';
for await (const chunk of req) text += String(chunk);
provider.requests.push(JSON.parse(text) as ChatBody);
const reply = (provider.script.shift() ?? say('Nothing more.'))();
res.writeHead(200, { 'content-type': 'text/event-stream' });
for (const chunk of reply.chunks) res.write(`data: ${JSON.stringify(chunk)}\n\n`);
res.end('data: [DONE]\n\n');
}
let app: INestApplication;
let orm: MikroORM;
let fake: HttpServer;
const CTX = { locale: 'en', timeZone: 'Europe/Berlin' };
beforeAll(async () => {
fake = createServer((req, res) => {
answer(req, res).catch(() => res.destroy());
});
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve));
app = await buildApp();
orm = app.get(MikroORM);
await setAddonEnabled(orm, 'ai_assistant', true);
await setAddonEnabled(orm, 'collections', true);
const admin = (await makeUser(orm, { role: 'admin' })).user.id;
const { port } = fake.address() as AddressInfo;
const res = await request(app.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(admin))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
expect(res.status).toBe(200);
});
beforeEach(async () => {
await resetRateLimits(app);
provider.requests = [];
provider.script = [];
});
afterAll(async () => {
await app.close();
await new Promise<void>((resolve) => fake.close(() => resolve()));
});
/** A request with its whole stream read, as the chunks it sent. */
async function post(path: string, userId: number, body: object): Promise<{ status: number; chunks: UiStreamChunk[] }> {
const res = await request(app.getHttpServer())
.post(path)
.set('Cookie', sessionCookie(userId))
.send(body)
.buffer(true)
.parse((stream, done) => {
let text = '';
stream.setEncoding('utf8');
stream.on('data', (chunk: string) => (text += chunk));
stream.on('end', () => done(null, text));
});
const events = String(res.body)
.split('\n\n')
.filter((event) => event.startsWith('data: ') && event !== 'data: [DONE]')
.map((event) => JSON.parse(event.slice('data: '.length)) as UiStreamChunk);
return { status: res.status, chunks: events };
}
const ofType = <T extends UiStreamChunk['type']>(chunks: UiStreamChunk[], type: T) =>
chunks.filter((chunk): chunk is Extract<UiStreamChunk, { type: T }> => chunk.type === type);
/** A traveller with a trip and a list of saved places, a list shared with them, and one of a stranger. */
async function travellerWithLists() {
const { user } = await makeUser(orm);
const { user: friend } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Paris' });
const list = await makeCollection(orm, user.id, { name: 'Paris food', color: '#e11d48' });
const septime = await makeCollectionPlace(orm, list.id, user.id, { name: 'Septime', status: 'want' });
const shared = await makeCollection(orm, friend.id, { name: 'Bistros' });
await addCollectionMember(orm, shared.id, user.id, { role: 'viewer' });
const comptoir = await makeCollectionPlace(orm, shared.id, friend.id, { name: 'Le Comptoir' });
const stranger = await makeCollection(orm, friend.id, { name: 'Private' });
const secret = await makeCollectionPlace(orm, stranger.id, friend.id, { name: 'Secret spot' });
return { userId: user.id, base: `/api/trips/${trip.id}/assistant`, list, septime, comptoir, secret };
}
describe('assistant: collections', () => {
it('ASTE2E-COL-001: saved places read by id stream as a card and reach the model, without what the user cannot see', async () => {
const t = await travellerWithLists();
provider.script = [
callTool('call_items', 'get_collection_items', { placeIds: [t.septime.id, t.comptoir.id, t.secret.id] }),
say('Septime is on your list.'),
];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'What did I save?' });
expect(turn.status).toBe(200);
const [shown] = ofType(turn.chunks, 'data-trek-card');
expect(shown?.data.toolCallId).toBe('call_items');
const card = shown?.data.card as CollectionPlacesCard;
expect(card.kind).toBe('collectionPlaces');
expect(card.list).toBeNull();
expect(card.places.map((place) => place.name).sort()).toEqual(['Le Comptoir', 'Septime']);
const told = provider.requests.at(-1)?.messages.find((message) => message.tool_call_id === 'call_items');
expect(told?.content).toContain('Septime');
expect(told?.content).not.toContain('Secret spot');
});
it('ASTE2E-COL-002: a held save shows its list, is saved once approved, shows the saved place and is undone', async () => {
const t = await travellerWithLists();
const args = { collection_id: t.list.id, name: 'Musée Rodin', lat: 48.855, lng: 2.3158, status: 'want' };
provider.script = [callTool('call_save', 'save_place_to_collection', args)];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'Save the Rodin museum' });
const toolCallId = ofType(turn.chunks, 'tool-approval-request')[0]?.toolCallId ?? '';
const [change] = ofType(turn.chunks, 'data-trek-change');
expect(change?.data).toMatchObject({ toolCallId, subject: 'Musée Rodin', tool: 'save_place_to_collection' });
const pending = change?.data.card as CollectionPlacesCard;
expect(pending.list).toMatchObject({ id: t.list.id, name: 'Paris food', placeCount: 1 });
expect(pending.places[0]).toMatchObject({ id: null, name: 'Musée Rodin', status: 'want' });
expect(await countRows(orm, CollectionPlaces, { name: 'Musée Rodin' })).toBe(0);
const threadId = ofType(turn.chunks, 'data-trek-thread')[0]?.data.threadId;
provider.script = [say('Saved it.')];
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId, approved: true }],
});
const saved = await findRow(orm, CollectionPlaces, { name: 'Musée Rodin' });
expect(saved).not.toBeNull();
const [applied] = ofType(decided.chunks, 'data-trek-applied');
expect(applied?.data).toMatchObject({ toolCallId, entity: 'collection_place', ids: [saved?.id] });
expect(applied?.data.undoUntil).toEqual(expect.any(String));
const [receipt] = ofType(decided.chunks, 'data-trek-card');
expect(receipt?.data.toolCallId).toBe(toolCallId);
const savedCard = receipt?.data.card as CollectionPlacesCard;
expect(savedCard.places[0]).toMatchObject({ id: saved?.id, name: 'Musée Rodin' });
const undone = await request(app.getHttpServer())
.post(`${t.base}/tool-calls/${toolCallId}/undo`)
.set('Cookie', sessionCookie(t.userId))
.send({});
expect(undone.status).toBe(200);
expect(undone.body).toEqual({ undone: true });
expect(await countRows(orm, CollectionPlaces, { name: 'Musée Rodin' })).toBe(0);
});
});
@@ -2,7 +2,8 @@
* Unit tests for the collections MCP surface (CollectionsMcp, DI-discovered):
* the 25 tools ported 1:1 from the legacy src/mcp/tools/collections.ts
* registrar, plus the two later additions that closed the gap to REST
* (find_place_in_collections, set_collection_place_status_from_trip).
* (find_place_in_collections, set_collection_place_status_from_trip) and the
* assistant's lookup of saved places and labels by id (get_collection_items).
* New with the DI fold: the legacy registrar had no tool-level
* suite — so this is a characterization of the ported behavior: payload
* shapes, error texts (the service's thrown httpError messages surfaced via
@@ -294,6 +295,66 @@ describe('Tool: find_place_in_collections', () => {
});
});
describe('Tool: get_collection_items', () => {
it('MCP-COLL-ITEMS-001: reads saved places and labels by their ids with their lists, leaving out what the user cannot see', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const mine = await seedCollection(user.id, 'Paris');
const shared = await seedCollection(other.id, 'Shared with me');
const hidden = await seedCollection(other.id, 'Not shared');
await addMember(shared, user.id, 'viewer');
const { user: invited } = createUser(testDb);
await addMember(shared, invited.id, 'editor', 'pending');
const louvre = await seedPlace(mine, user.id, 'Louvre', { status: 'want' });
await seedPlace(mine, user.id, 'Not asked for');
const orsay = await seedPlace(shared, other.id, 'Orsay');
const secret = await seedPlace(hidden, other.id, 'Secret');
const dinner = await insertRow(orm, CollectionLabels, { collection: mine, name: 'Dinner', color: '#2563eb' });
const theirs = await insertRow(orm, CollectionLabels, { collection: hidden, name: 'Theirs' });
await withHarness(user.id, async (h) => {
const data = parseToolResult(
await h.client.callTool({
name: 'get_collection_items',
arguments: { placeIds: [louvre, orsay, secret, 999999], labelIds: [dinner, theirs] },
}),
) as {
lists: {
id: number;
name: string;
can_delete: boolean;
members: Record<string, unknown>[];
labels: { id: number }[];
}[];
places: { id: number; name: string; status: string }[];
labels: { id: number; name: string }[];
};
expect(data.lists.map((l) => l.name).sort()).toEqual(['Paris', 'Shared with me']);
expect(data.places.map((p) => p.name).sort()).toEqual(['Louvre', 'Orsay']);
expect(data.places.find((p) => p.id === louvre)?.status).toBe('want');
expect(data.labels.map((l) => l.name)).toEqual(['Dinner']);
// The owner may delete places from a list; a viewer of a shared one may not.
expect(data.lists.find((l) => l.id === mine)?.can_delete).toBe(true);
expect(data.lists.find((l) => l.id === shared)?.can_delete).toBe(false);
// People by name and role only: no addresses travel with a lookup.
for (const list of data.lists) for (const member of list.members) expect(member).not.toHaveProperty('email');
});
});
it('MCP-COLL-ITEMS-002: an empty or unknown lookup answers empty lists; more than 50 places is refused', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const empty = parseToolResult(await h.client.callTool({ name: 'get_collection_items', arguments: {} }));
expect(empty).toEqual({ lists: [], places: [], labels: [] });
const tooMany = await h.client.callTool({
name: 'get_collection_items',
arguments: { placeIds: Array.from({ length: 51 }, (_, i) => i + 1) },
});
expect(tooMany.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// Collections CRUD
// ---------------------------------------------------------------------------
@@ -883,7 +944,13 @@ describe('Sharing tools', () => {
// Scope gating (collections read/write, registration-time)
// ---------------------------------------------------------------------------
const READ_TOOLS = ['list_collections', 'get_collection', 'available_collection_users', 'find_place_in_collections'];
const READ_TOOLS = [
'list_collections',
'get_collection',
'get_collection_items',
'available_collection_users',
'find_place_in_collections',
];
const WRITE_TOOLS = [
'create_collection',
'update_collection',
@@ -920,7 +987,7 @@ describe('Collection tools — scope gating', () => {
}
}
it('registers all 27 tools with null scopes (full access)', async () => {
it('registers all 28 tools with null scopes (full access)', async () => {
const { user } = createUser(testDb);
const names = await listToolNames(user.id, null);
for (const tool of [...READ_TOOLS, ...WRITE_TOOLS]) expect(names).toContain(tool);
@@ -0,0 +1,187 @@
/**
* ASTCOL-001 to ASTCOL-012: the cards the collections' reads draw beside the
* answer, built from the tool's raw answer through the guarded registry, so
* every card here also passed the card union and the size cap.
*/
import { answerCardOf, FOREIGN_ID_GROUPS } from '../../../../../../src/nest/assistant/cards/registry';
import { answerInput, card, collectionValue, itemsValue, savedPlace, TRIP_ID } from './collection-fixtures';
import type { CollectionListsCard, CollectionPlacesCard } 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 places = (tool: string, args: Record<string, unknown>, value: unknown) =>
answerCardOf(tool, answerInput(args, value)) as CollectionPlacesCard | null;
const lists = (tool: string, args: Record<string, unknown>, value: unknown) =>
answerCardOf(tool, answerInput(args, value)) as CollectionListsCard | null;
describe('collection answer cards', () => {
it('ASTCOL-001: a list read whole heads its card with its size, its people and its places per status', () => {
const shown = places('get_collection', { collectionId: 12 }, collectionValue());
expect(shown?.list).toEqual({
id: 12,
name: 'Paris food',
color: '#e11d48',
placeCount: 10,
memberCount: 1,
counts: { idea: 4, want: 1, visited: 5 },
});
});
it('ASTCOL-002: eight places are shown and the rest counted, in the order the list keeps them', () => {
const shown = places('get_collection', { collectionId: 12 }, collectionValue());
expect(shown?.places).toHaveLength(8);
expect(shown?.more).toBe(2);
expect(shown?.places.map((place) => place.name).slice(0, 3)).toEqual(['Septime', 'Spot 1', 'Spot 2']);
});
it('ASTCOL-003: a saved place carries its status, labels, rating, own photo and the trip place it came from', () => {
const [septime] = places('get_collection', { collectionId: 12 }, collectionValue())?.places ?? [];
expect(septime).toEqual({
id: 34,
listId: 12,
name: 'Septime',
address: '80 Rue de Charonne',
lat: 48.853,
lng: 2.381,
category: { name: 'Restaurant', icon: 'Utensils', color: '#f97316' },
status: 'want',
labelIds: [51, 52],
rating: 4.5,
ratingCount: 3,
googlePlaceId: null,
osmId: null,
imageKey: 'septime.jpg',
inTripPlaceId: 6,
savedIn: [],
delta: null,
});
});
it('ASTCOL-004: only the labels the shown places carry are named, counted over the whole list', () => {
const shown = places('get_collection', { collectionId: 12 }, collectionValue());
expect(shown?.labels).toEqual([
{ id: 51, name: 'Dinner', color: '#2563eb', places: 2, change: 'none', before: null },
{ id: 52, name: 'Late', color: null, places: 1, change: 'none', before: null },
]);
});
it('ASTCOL-005: notes, phone numbers, links and the members addresses never reach the card; e-mails in names are masked', () => {
const value = collectionValue(1);
value.places[0] = savedPlace({ name: 'Dinner with anna@example.com' });
const json = JSON.stringify(places('get_collection', { collectionId: 12 }, value));
for (const secret of ['Ask for the counter', '+33 1', 'septime.example', 'maya@example.com', 'anna@example.com']) {
expect(json).not.toContain(secret);
}
expect(json).toContain('Dinner with [hidden]');
});
it('ASTCOL-006: a photo on another host, a data address or a bad colour is left out; a place without a position has none', () => {
const value = collectionValue(1);
value.places[0] = savedPlace({
image_url: 'https://evil.example/x.jpg',
lat: null,
lng: 200,
category: { id: 9, name: 'Bar', icon: '<svg>', color: 'red' },
});
const [place] = places('get_collection', { collectionId: 12 }, value)?.places ?? [];
const category = { name: 'Bar', icon: null, color: null };
expect(place).toMatchObject({ imageKey: null, lat: null, lng: null, category });
value.places[0] = savedPlace({ image_url: 'data:image/png;base64,AAAA' });
expect(places('get_collection', { collectionId: 12 }, value)?.places[0]?.imageKey).toBeNull();
});
it('ASTCOL-007: a saved place is the trip place with the same Google id, or the one it was saved from in this trip', () => {
const value = collectionValue(1);
value.places[0] = savedPlace({ google_place_id: 'ChIJlouvre', source_place_id: null });
expect(places('get_collection', { collectionId: 12 }, value)?.places[0]?.inTripPlaceId).toBe(5);
value.places[0] = savedPlace({ source_trip_id: TRIP_ID + 1 });
expect(places('get_collection', { collectionId: 12 }, value)?.places[0]?.inTripPlaceId).toBeNull();
expect(card.places.has(6)).toBe(true);
});
it('ASTCOL-008: saved places asked for by id sit under their list when they share one, else under none', () => {
const value = itemsValue([savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir', label_ids: [] })]);
const shown = places('get_collection_items', { placeIds: [34, 35] }, value);
expect(shown?.list).toMatchObject({ id: 12, name: 'Paris food', counts: null });
expect(shown?.labels.map((label) => [label.name, label.places])).toEqual([
['Dinner', null],
['Late', null],
]);
const spread = itemsValue([savedPlace(), savedPlace({ id: 77, collection_id: 13, name: 'Elsewhere' })]);
expect(places('get_collection_items', { placeIds: [34, 77] }, spread)?.list).toBeNull();
expect(places('get_collection_items', { placeIds: [1] }, itemsValue([]))).toBeNull();
});
it('ASTCOL-009: the lists card holds the lists in order, then each invite with who sent it', () => {
const value = {
collections: [{ id: 12, name: 'Paris food', color: '#e11d48', place_count: 3, members: [] }],
incomingInvites: [{ collection_id: 40, name: 'Rome', from: { id: 4, username: 'maya' } }],
};
expect(lists('list_collections', {}, value)?.lists).toEqual([
{
id: 12,
name: 'Paris food',
color: '#e11d48',
placeCount: 3,
memberCount: 0,
counts: null,
status: null,
person: null,
invite: false,
change: 'none',
before: null,
},
{
id: 40,
name: 'Rome',
color: null,
placeCount: null,
memberCount: 0,
counts: null,
status: null,
person: { id: 4, name: 'maya', avatarKey: null },
invite: true,
change: 'none',
before: null,
},
]);
const many = { collections: Array.from({ length: 14 }, (_, i) => ({ id: i + 1, name: `List ${i}` })) };
expect(lists('list_collections', {}, many)).toMatchObject({ more: 2 });
expect(lists('list_collections', {}, { collections: [], incomingInvites: [] })).toBeNull();
});
it('ASTCOL-010: where a place is saved reads as its lists with its status in each; saved nowhere draws nothing', () => {
const value = {
saved: true,
lists: [
{ collection_id: 12, name: 'Paris food', color: '#e11d48', place_id: 34, status: 'visited', can_edit: true },
{ collection_id: 12, name: 'Paris food', color: '#e11d48', place_id: 35, status: 'idea', can_edit: true },
{ collection_id: 13, name: 'Later', place_id: 36, status: 'want', can_edit: false },
],
};
const shown = lists('find_place_in_collections', { name: 'Septime', lat: 48.8, lng: 2.3 }, value);
expect(shown?.place).toBe('Septime');
expect(shown?.lists.map((row) => [row.id, row.status, row.color])).toEqual([
[12, 'visited', '#e11d48'],
[13, 'want', null],
]);
expect(lists('find_place_in_collections', { name: 'Septime' }, { saved: false, lists: [] })).toBeNull();
});
it('ASTCOL-011: who may be invited and the tools of other parts draw no collection card', () => {
const users = { users: [{ id: 2, username: 'maya' }] };
expect(answerCardOf('available_collection_users', answerInput({ collectionId: 12 }, users))).toBeNull();
expect(answerCardOf('no_such_tool', answerInput({}, collectionValue()))).toBeNull();
});
it('ASTCOL-012: the collections place ids are their own, never a trip place', () => {
expect(FOREIGN_ID_GROUPS.has('collections')).toBe(true);
});
});
@@ -0,0 +1,215 @@
/**
* ASTCOL-040 to ASTCOL-048: what an applied change of the collections shows
* in place of its preview, what its receipt names, and the way back from a
* created list or a saved place.
*/
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
import type { CollectionLookup } from '../../../../../../src/nest/assistant/cards/collection/collection-load';
import { appliedCardOf, ASSISTANT_UNDO_MAP, undoReadsOf } from '../../../../../../src/nest/assistant/cards/registry';
import { appliedChange, describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
import { collectionValue, ctx, itemsValue, savedPlace } from './collection-fixtures';
import type { CollectionListsCard, CollectionPlacesCard } 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 NONE: CollectionLookup = { collection: null, target: null, items: null, membership: null, lists: null };
function previewOf(tool: string, args: Record<string, unknown>, loaded: Partial<CollectionLookup>) {
return describeToolCall(tool, 'call_9', args, ctx, { ...NONE, ...loaded });
}
const SAVE = { collection_id: 12, name: 'Musée Rodin', google_place_id: 'ChIJlouvre', lat: 48.86, lng: 2.33 };
const MEMBERSHIP = { saved: true, lists: [{ collection_id: 13, name: 'Later', place_id: 9, status: 'idea' }] };
describe('applied collection cards', () => {
it('ASTCOL-040: a saved place shows as saved, with its own id, keeping the trip place and the lists from the preview', () => {
const preview = previewOf('save_place_to_collection', SAVE, {
collection: collectionValue(),
membership: MEMBERSHIP,
});
const rodin = { id: 77, name: 'Musée Rodin', status: 'idea', label_ids: [], source_place_id: null };
const result = { place: savedPlace(rodin) };
const card = appliedCardOf('save_place_to_collection', { args: SAVE, result, preview }) as CollectionPlacesCard;
expect(card.list).toMatchObject({ id: 12, name: 'Paris food' });
expect(card.places[0]).toMatchObject({
id: 77,
listId: 12,
name: 'Musée Rodin',
inTripPlaceId: 5,
savedIn: [{ listId: 13, name: 'Later', status: 'idea' }],
});
expect(card.outcome).toBeNull();
});
it('ASTCOL-041: a save the service took for a duplicate keeps the preview and names the place it matched', () => {
const preview = previewOf('save_place_to_collection', SAVE, { collection: collectionValue() });
const result = { duplicate: true, duplicateOf: { id: 9, name: 'Rodin museum' } };
const card = appliedCardOf('save_place_to_collection', { args: SAVE, result, preview }) as CollectionPlacesCard;
expect(card.outcome).toEqual({ type: 'duplicate', name: 'Rodin museum' });
expect(card.places[0]?.id).toBeNull();
});
it('ASTCOL-042: a changed place shows as it is now and keeps what the change did to it', () => {
const args = { placeId: 34, status: 'visited' };
const preview = previewOf('set_collection_place_status', args, { items: itemsValue([savedPlace()]) });
const result = { place: savedPlace({ status: 'visited', updated_at: '2026-06-01 09:00:00' }) };
const card = appliedCardOf('set_collection_place_status', { args, result, preview }) as CollectionPlacesCard;
const delta = { status: { from: 'want', to: 'visited' } };
expect(card.places[0]).toMatchObject({ id: 34, status: 'visited', delta });
expect(appliedCardOf('set_collection_place_status', { args, result: { error: 'x' }, preview })).toBeNull();
});
it('ASTCOL-043: a copy says how many went in and which were there already; a status set across lists how many changed', () => {
const args = { trip_id: 7, place_ids: [34, 35] };
const items = itemsValue([savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir', source_place_id: null })]);
const preview = previewOf('copy_collection_places_to_trip', args, { items });
const result = { copied: 1, skipped: [{ id: 34, name: 'Septime' }], created: [{ id: 900, source_id: 35 }] };
const copied = appliedCardOf('copy_collection_places_to_trip', { args, result, preview }) as CollectionPlacesCard;
expect(copied.outcome).toEqual({ type: 'copied', copied: 1, skipped: 1, skippedNames: ['Septime'] });
// A copy into another trip keeps no mark of the chat's trip on its places.
const elsewhere = { trip_id: 8, place_ids: [34] };
const otherPreview = previewOf('copy_collection_places_to_trip', elsewhere, { items });
const other = appliedCardOf('copy_collection_places_to_trip', {
args: elsewhere,
result: { copied: 1, skipped: [], created: [{ id: 950, source_id: 34 }] },
preview: otherPreview,
}) as CollectionPlacesCard;
expect(other).toMatchObject({ otherTrip: true, outcome: { type: 'copied', copied: 1 } });
expect(other.places.map((place) => place.inTripPlaceId)).toEqual([null]);
const marked = { trip_id: 7, place_ids: [5], status: 'visited' };
const statusPreview = previewOf('set_collection_place_status_from_trip', marked, {});
const set = appliedCardOf('set_collection_place_status_from_trip', {
args: marked,
result: { updated: 3, places: 1 },
preview: statusPreview,
}) as CollectionPlacesCard;
expect(set.outcome).toEqual({ type: 'statusSet', updated: 3, found: 1 });
});
it('ASTCOL-044: a created or renamed list and label show as the tool answered', () => {
const created = appliedCardOf('create_collection', {
args: { name: 'Rome' },
result: { collection: { id: 44, name: 'Rome', color: '#6366f1', place_count: 0, members: [] } },
preview: null,
}) as CollectionListsCard;
expect(created.lists[0]).toMatchObject({ id: 44, name: 'Rome', change: 'add', before: null });
const rename = { collectionId: 12, name: 'Paris eats' };
const preview = previewOf('update_collection', rename, { collection: collectionValue() });
const renamed = appliedCardOf('update_collection', {
args: rename,
result: { collection: { id: 12, name: 'Paris eats', color: '#e11d48' } },
preview,
}) as CollectionListsCard;
expect(renamed.lists[0]).toMatchObject({ name: 'Paris eats', change: 'update', before: { name: 'Paris food' } });
const label = { collection_id: 12, name: 'Brunch' };
const labelPreview = previewOf('create_collection_label', label, { collection: collectionValue() });
const made = appliedCardOf('create_collection_label', {
args: label,
result: { label: { id: 60, collection_id: 12, name: 'Brunch', color: '#16a34a' } },
preview: labelPreview,
}) as CollectionPlacesCard;
expect(made.labels[0]).toMatchObject({ id: 60, name: 'Brunch', color: '#16a34a', change: 'add' });
});
it('ASTCOL-045: a receipt names a list, a saved place, or the new trip places of a copy', () => {
const receipt = (tool: string, args: Record<string, unknown>, result: unknown) =>
appliedChange(tool, 'c', args, result, null, null);
const made = receipt('create_collection', { name: 'Rome' }, { collection: { id: 44 } });
expect(made).toMatchObject({ entity: 'collection', ids: [44] });
expect(receipt('save_place_to_collection', SAVE, { place: { id: 77 } })).toMatchObject({
entity: 'collection_place',
ids: [77],
});
expect(receipt('delete_collection_place', { placeId: 34 }, { success: true })).toMatchObject({
entity: 'collection_place',
ids: [34],
});
const created = [
{ id: 900, source_id: 34 },
{ id: 901, source_id: 35 },
];
const copy = receipt('copy_collection_places_to_trip', { trip_id: 7, place_ids: [34, 35] }, { copied: 2, created });
expect(copy).toMatchObject({ entity: 'place', ids: [900, 901] });
const kept = receipt('save_trip_places_to_collection', { collectionId: 12 }, { copied: 1 });
expect(kept).toMatchObject({ entity: 'collection', ids: [12] });
});
it('ASTCOL-046: a created list is taken back while it is empty, unshared and unchanged', () => {
const result = { collection: { id: 44, name: 'Rome', color: '#6366f1', updated_at: '2026-06-01 10:00:00' } };
expect(ASSISTANT_UNDO_MAP.create_collection?.tool).toBe('delete_collection');
expect(ASSISTANT_UNDO_MAP.create_collection?.args({}, result, null)).toEqual({ collectionId: 44 });
expect(undoReadsOf('create_collection', {}, result)).toEqual([
{ tool: 'get_collection', args: { collectionId: 44 } },
]);
const owner = { user_id: 1, username: 'me', status: 'accepted', is_owner: true };
const read = (over: Record<string, unknown>) => ({
summary: {},
places: {},
reads: new Map([[readKey('get_collection', { collectionId: 44 }), { places: [], ...over }]]),
});
const same = { collection: { ...result.collection, members: [owner], labels: [] } };
expect(unchangedSinceApplied('create_collection', result, read(same))).toBe(true);
expect(unchangedSinceApplied('create_collection', result, read({ ...same, places: [savedPlace()] }))).toBe(false);
const shared = { collection: { ...same.collection, members: [owner, { user_id: 2, is_owner: false }] } };
expect(unchangedSinceApplied('create_collection', result, read(shared))).toBe(false);
const renamed = { collection: { ...same.collection, name: 'Roma' } };
expect(unchangedSinceApplied('create_collection', result, read(renamed))).toBe(false);
});
it('ASTCOL-047: a saved place is taken back while nobody changed, rated or labelled it; a duplicate has no way back', () => {
const created = savedPlace({ id: 77, label_ids: [], ratings: [] });
const result = { place: created };
expect(ASSISTANT_UNDO_MAP.save_place_to_collection?.args(SAVE, result, null)).toEqual({ placeId: 77 });
expect(ASSISTANT_UNDO_MAP.save_place_to_collection?.args(SAVE, { duplicate: true }, null)).toBeNull();
const lists = [{ id: 12, can_delete: true }];
const read = (place: Record<string, unknown>) => ({
summary: {},
places: {},
reads: new Map([[readKey('get_collection_items', { placeIds: [77] }), { places: [place], lists }]]),
});
expect(unchangedSinceApplied('save_place_to_collection', result, read(created))).toBe(true);
const visited = { ...created, status: 'visited' };
expect(unchangedSinceApplied('save_place_to_collection', result, read(visited))).toBe(false);
const rated = { ...created, ratings: [{ user_id: 2, rating: 5 }] };
expect(unchangedSinceApplied('save_place_to_collection', result, read(rated))).toBe(false);
const labelled = { ...created, label_ids: [51] };
expect(unchangedSinceApplied('save_place_to_collection', result, read(labelled))).toBe(false);
// One label swapped for another leaves the count as it was, and still counts as a change.
const tagged = { place: { ...created, label_ids: [51] } };
const swapped = { ...created, label_ids: [52] };
expect(unchangedSinceApplied('save_place_to_collection', tagged, read(swapped))).toBe(false);
const full = { place: { ...created, description: null, category_id: 9 } };
expect(unchangedSinceApplied('save_place_to_collection', full, read(full.place))).toBe(true);
for (const edit of [{ description: 'Sculptures' }, { category_id: 4 }, { image_url: '/uploads/places/x.jpg' }]) {
expect(unchangedSinceApplied('save_place_to_collection', full, read({ ...full.place, ...edit }))).toBe(false);
}
});
it('ASTCOL-048: an editor who saved a place to a list somebody else owns gets no way back, since only an admin removes', () => {
const editorsSave = { place: savedPlace({ id: 78, owner_id: 2, saved_by: 1, label_ids: [], ratings: [] }) };
expect(ASSISTANT_UNDO_MAP.save_place_to_collection?.args(SAVE, editorsSave, null)).toBeNull();
const ownSave = { place: savedPlace({ id: 79, owner_id: 1, saved_by: 1, label_ids: [], ratings: [] }) };
expect(ASSISTANT_UNDO_MAP.save_place_to_collection?.args(SAVE, ownSave, null)).toEqual({ placeId: 79 });
// Should the right to remove have gone since, the check refuses before the delete would.
const read = (canDelete: boolean) => ({
summary: {},
places: {},
reads: new Map([
[
readKey('get_collection_items', { placeIds: [79] }),
{ places: [ownSave.place], lists: [{ id: 12, can_delete: canDelete }] },
],
]),
});
expect(unchangedSinceApplied('save_place_to_collection', ownSave, read(true))).toBe(true);
expect(unchangedSinceApplied('save_place_to_collection', ownSave, read(false))).toBe(false);
});
});
@@ -0,0 +1,345 @@
/**
* ASTCOL-020 to ASTCOL-035: the change cards of the collections' writes, as
* describeToolCall writes them (the card through the guarded registry, so it
* passed the union), and the lookups they read first.
*/
import { loadFor, memoisedReader } from '../../../../../../src/nest/assistant/cards/card-context';
import { COLLECTION_CARDS } from '../../../../../../src/nest/assistant/cards/collection';
import type { CollectionLookup } from '../../../../../../src/nest/assistant/cards/collection/collection-load';
import { ASSISTANT_WRITE_TOOLS, TOOL_CARDS } from '../../../../../../src/nest/assistant/cards/registry';
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
import { collectionValue, ctx, itemsValue, LABELS, savedPlace } from './collection-fixtures';
import {
assistantChangePreviewSchema,
type CollectionListsCard,
type CollectionPlacesCard,
type TrekCard,
} 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 NONE: CollectionLookup = { collection: null, target: null, items: null, membership: null, lists: null };
function preview(tool: string, args: Record<string, unknown>, loaded: Partial<CollectionLookup> | null = null) {
return describeToolCall(tool, 'call_9', args, ctx, loaded ? { ...NONE, ...loaded } : null);
}
const placesOf = (card: TrekCard | null | undefined) => card as CollectionPlacesCard;
const listsOf = (card: TrekCard | null | undefined) => card as CollectionListsCard;
const COLLECTION_WRITES = Object.keys(COLLECTION_CARDS.tools).filter((tool) => COLLECTION_CARDS.tools[tool]?.write);
describe('collection change cards', () => {
it('ASTCOL-020: every write of the collections describes empty arguments and a failed lookup in its own words', () => {
const some = ['save_place_to_collection', 'copy_collection_places_to_trip', 'cancel_collection_invite'];
expect(COLLECTION_WRITES).toEqual(expect.arrayContaining(some));
expect(COLLECTION_WRITES.length).toBeGreaterThanOrEqual(19);
for (const tool of COLLECTION_WRITES) {
for (const loaded of [null, NONE]) {
const shown = preview(tool, {}, loaded);
expect(assistantChangePreviewSchema.safeParse(shown).success, tool).toBe(true);
expect(shown).toMatchObject({ tool, domain: 'collections', placeIds: [], rows: [], dayId: null });
expect(shown.verbKey).toBe(ASSISTANT_WRITE_TOOLS[tool]?.verbKey);
}
}
});
it('ASTCOL-021: a place to save shows its list, the status it gets, its category and the lists that hold it already', () => {
const args = {
collection_id: 12,
name: 'Musée Rodin',
google_place_id: 'ChIJlouvre',
lat: 48.86,
lng: 2.33,
category_id: 3,
status: 'want',
};
const membership = { saved: true, lists: [{ collection_id: 13, name: 'Later', place_id: 9, status: 'idea' }] };
const shown = preview('save_place_to_collection', args, { collection: collectionValue(), membership });
expect(shown.subject).toBe('Musée Rodin');
const card = placesOf(shown.card);
expect(card.list).toMatchObject({ id: 12, name: 'Paris food', counts: { idea: 4, want: 1, visited: 5 } });
expect(card.places[0]).toEqual({
id: null,
listId: 12,
name: 'Musée Rodin',
address: null,
lat: 48.86,
lng: 2.33,
category: { name: 'Sights', icon: null, color: null },
status: 'want',
labelIds: [],
rating: null,
ratingCount: 0,
googlePlaceId: 'ChIJlouvre',
osmId: null,
imageKey: null,
inTripPlaceId: 5,
savedIn: [{ listId: 13, name: 'Later', status: 'idea' }],
delta: null,
});
// Without the lookup the arguments still describe the place; it is an idea by default.
const bare = placesOf(preview('save_place_to_collection', { collection_id: 12, name: 'Musée Rodin' }).card);
expect(bare).toMatchObject({ list: null, places: [{ listId: 12, status: 'idea', savedIn: [] }] });
});
it('ASTCOL-022: a status change reads from the old status to the new one; a vote names the stars or that it goes', () => {
const items = itemsValue([savedPlace()]);
const status = placesOf(preview('set_collection_place_status', { placeId: 34, status: 'visited' }, { items }).card);
expect(status.places[0]?.delta?.status).toEqual({ from: 'want', to: 'visited' });
expect(status.list).toMatchObject({ id: 12, counts: null });
const rated = placesOf(preview('rate_collection_place', { placeId: 34, rating: 4 }, { items }).card);
expect(rated.places[0]?.delta?.rating).toEqual({ to: 4 });
const cleared = placesOf(preview('rate_collection_place', { placeId: 34, rating: null }, { items }).card);
expect(cleared.places[0]?.delta?.rating).toEqual({ to: null });
const removed = preview('delete_collection_place', { placeId: 34 }, { items });
expect(removed).toMatchObject({ subject: 'Septime', verbKey: 'genericRemove' });
expect(placesOf(removed.card).places[0]?.delta?.removed).toBe(true);
});
it('ASTCOL-023: a change of a saved place names its fields, its labels as its own list allows them and the list it moves to', () => {
const items = itemsValue([savedPlace()]);
// In its own list: the labels asked for replace the ones it carries.
const relabel = { placeId: 34, label_ids: [51, 53, 99] };
const kept = placesOf(preview('update_collection_place', relabel, { items }).card);
expect(kept.places[0]?.delta).toMatchObject({ labelsAdded: [53], labelsRemoved: [52], movedTo: null });
// A move takes every label of the old list off and applies the new list's.
const args = {
placeId: 34,
name: 'Septime Cave',
notes: 'Wine bar next door',
lat: 48.85,
lng: 2.38,
status: 'visited',
label_ids: [51, 61, 99],
collection_id: 13,
};
const brunch = { id: 61, collection_id: 13, name: 'Brunch', color: '#f59e0b' };
const target = { collection: { id: 13, name: 'Later', members: [], labels: [brunch] }, places: [] };
const shown = preview('update_collection_place', args, { items, target });
expect(shown.subject).toBe('Septime Cave');
const card = placesOf(shown.card);
expect(card.places[0]?.delta).toEqual({
status: { from: 'want', to: 'visited' },
rating: null,
labelsAdded: [61],
labelsRemoved: [51, 52],
fields: ['name', 'coordinates', 'notes'],
movedTo: {
id: 13,
name: 'Later',
color: null,
placeCount: null,
memberCount: 0,
counts: { idea: 0, want: 0, visited: 0 },
},
removed: false,
});
expect(card.labels.map((label) => label.name)).toEqual(['Dinner', 'Late', 'Brunch']);
// A move without labels still takes the old ones off.
const bare = { placeId: 34, collection_id: 13 };
const moved = placesOf(preview('update_collection_place', bare, { items, target }).card);
expect(moved.places[0]?.delta).toMatchObject({ labelsAdded: [], labelsRemoved: [51, 52] });
});
it('ASTCOL-024: labels added or taken away are exactly the ones the service applies: its own list, a real change', () => {
const items = itemsValue([savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir', label_ids: [] })]);
const add = { label_ids: [51, 53, 99], place_ids: [34, 35] };
const added = placesOf(preview('assign_collection_labels', add, { items }).card);
expect(added.places.map((place) => place.delta?.labelsAdded)).toEqual([[53], [51, 53]]);
const args = { label_ids: [51, 53, 99], place_ids: [34, 35], remove: true };
const taken = placesOf(preview('assign_collection_labels', args, { items }).card);
expect(taken.places.map((place) => place.delta?.labelsRemoved)).toEqual([[51], []]);
expect(taken.list).toMatchObject({ id: 12 });
// A place with more labels than its card names: the change is worked out on all of them.
const crowded = { items: itemsValue([savedPlace({ label_ids: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 51] })]) };
const removeArgs = { label_ids: [51], place_ids: [34], remove: true };
const off = placesOf(preview('assign_collection_labels', removeArgs, crowded).card);
expect(off.places[0]?.delta?.labelsRemoved).toEqual([51]);
const on = placesOf(preview('assign_collection_labels', { label_ids: [51], place_ids: [34] }, crowded).card);
expect(on.places[0]?.delta?.labelsAdded).toEqual([]);
});
it('ASTCOL-025: a copy into the trip marks the places the trip has already and flags another trip', () => {
const items = itemsValue([savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir', source_place_id: null })]);
const shown = preview('copy_collection_places_to_trip', { trip_id: 7, place_ids: [34, 35, 36] }, { items });
expect(shown).toMatchObject({ subject: 'Septime, Le Comptoir', placeIds: [] });
const card = placesOf(shown.card);
expect(card.places.map((place) => place.inTripPlaceId)).toEqual([6, null]);
expect(card).toMatchObject({ more: 1, otherTrip: false });
// Into another trip: what the chat's trip holds says nothing about that one.
const other = { trip_id: 8, place_ids: [34] };
const elsewhere = placesOf(preview('copy_collection_places_to_trip', other, { items }).card);
expect(elsewhere.otherTrip).toBe(true);
expect(elsewhere.places.map((place) => place.inTripPlaceId)).toEqual([null]);
});
it('ASTCOL-026: a list change shows the list: new, renamed with what it was, deleted with what goes, reordered', () => {
const collection = collectionValue();
const created = listsOf(preview('create_collection', { name: 'Rome', color: '#abc' }).card);
expect(created.lists[0]).toMatchObject({ id: null, name: 'Rome', color: '#aabbcc', placeCount: 0, change: 'add' });
const rename = { collectionId: 12, name: 'Paris eats' };
const renamed = listsOf(preview('update_collection', rename, { collection }).card);
expect(renamed.lists[0]).toMatchObject({
name: 'Paris eats',
change: 'update',
before: { name: 'Paris food', color: '#e11d48' },
});
const deleted = preview('delete_collection', { collectionId: 12 }, { collection });
expect(deleted).toMatchObject({ subject: 'Paris food', verbKey: 'genericRemove' });
expect(listsOf(deleted.card).lists[0]).toMatchObject({ placeCount: 10, memberCount: 1, change: 'remove' });
const lists = { collections: [{ id: 12, name: 'A' }, { id: 13, name: 'B' }] };
const order = preview('reorder_collections', { orderedIds: [13, 12] }, { lists });
expect(order.subject).toBe('B, A');
expect(listsOf(order.card).lists.map((row) => [row.name, row.change])).toEqual([
['B', 'order'],
['A', 'order'],
]);
});
it('ASTCOL-027: an invite joined, declined or withdrawn names the list and the person', () => {
const invite = { collection_id: 40, name: 'Rome', from: { id: 4, username: 'maya' } };
const lists = { collections: [], incomingInvites: [invite] };
const joined = listsOf(preview('accept_collection_invite', { collectionId: 40 }, { lists }).card);
expect(joined.lists[0]).toMatchObject({ id: 40, invite: true, change: 'join', person: { id: 4, name: 'maya' } });
const declined = listsOf(preview('decline_collection_invite', { collectionId: 40 }, { lists }).card);
expect(declined.lists[0]?.change).toBe('decline');
const withdraw = { collectionId: 12, userId: 3 };
const withdrawn = preview('cancel_collection_invite', withdraw, { collection: collectionValue() });
const person = { id: 3, name: 'tom', avatarKey: null };
expect(listsOf(withdrawn.card).lists[0]).toMatchObject({ change: 'withdraw', person });
});
it('ASTCOL-028: trip places saved to a list or marked across lists light their pins and show as the trip has them', () => {
const args = { collectionId: 12, tripId: 7, placeIds: [5, 6, 99] };
const saved = preview('save_trip_places_to_collection', args, { collection: collectionValue() });
expect(saved).toMatchObject({ subject: 'Louvre, Septime (trip)', placeIds: [5, 6] });
const shown = placesOf(saved.card).places.map((place) => [
place.inTripPlaceId,
place.imageKey,
place.category?.name,
]);
expect(shown).toEqual([
[5, null, 'Sights'],
[6, 'septime.jpg', undefined],
]);
const marked = preview('set_collection_place_status_from_trip', { trip_id: 7, place_ids: [5], status: 'visited' });
expect(marked.placeIds).toEqual([5]);
const card = placesOf(marked.card);
expect(card).toMatchObject({ list: null, everyList: true });
expect(card.places[0]).toMatchObject({ status: 'visited', delta: { status: { from: null, to: 'visited' } } });
});
it('ASTCOL-029: a label created, renamed or deleted shows with its list and how many places carry it', () => {
const collection = collectionValue();
const items = { lists: [], places: [], labels: [LABELS[0]] };
const brunch = { collection_id: 12, name: 'Brunch', color: '#ff0000' };
const created = placesOf(preview('create_collection_label', brunch, { collection }).card);
expect(created).toMatchObject({ list: { id: 12 }, places: [] });
expect(created.labels).toEqual([
{ id: null, name: 'Brunch', color: '#ff0000', places: 0, change: 'add', before: null },
]);
const supper = { labelId: 51, name: 'Supper' };
const renamed = placesOf(preview('update_collection_label', supper, { collection, items }).card);
expect(renamed.labels[0]).toEqual({
id: 51,
name: 'Supper',
color: '#2563eb',
places: 2,
change: 'rename',
before: { name: 'Dinner', color: '#2563eb' },
});
const removed = preview('delete_collection_label', { labelId: 51 }, { collection, items });
expect(removed.subject).toBe('Dinner');
expect(placesOf(removed.card).labels[0]).toMatchObject({ change: 'remove', places: 2 });
});
it('ASTCOL-035: a short colour the forms accept reads as that colour, never as one taken away', () => {
const collection = collectionValue();
const recolour = listsOf(preview('update_collection', { collectionId: 12, color: '#e1d' }, { collection }).card);
expect(recolour.lists[0]).toMatchObject({ color: '#ee11dd', before: { color: '#e11d48' } });
const same = listsOf(preview('update_collection', { collectionId: 12, name: 'Paris food' }, { collection }).card);
expect(same.lists[0]).toMatchObject({ color: '#e11d48', before: null });
const brunch = { collection_id: 12, name: 'Brunch', color: '#0f0' };
const label = placesOf(preview('create_collection_label', brunch, { collection }).card);
expect(label.labels[0]?.color).toBe('#00ff00');
});
it('ASTCOL-030: a saved place 5 is never the trip place 5: the subject is the saved place, no pin lights up', () => {
const items = itemsValue([savedPlace({ id: 5, name: 'Septime' })]);
const shown = preview('update_collection_place', { placeId: 5, status: 'visited' }, { items });
expect(shown).toMatchObject({ subject: 'Septime', placeIds: [] });
expect(preview('set_collection_place_status', { placeId: 5, status: 'visited' })).toMatchObject({
subject: '',
placeIds: [],
});
expect(ctx.card.places.get(5)?.name).toBe('Louvre');
});
});
describe('collection lookups', () => {
function reader() {
const read = vi.fn(async (name: string, args: Record<string, unknown> = {}) => ({
ok: true,
value: { name, args },
}));
return { read, memo: memoisedReader({ read }) };
}
it('ASTCOL-031: a place to save reads its list and where it is saved; without an id or a position only its list', async () => {
const { read, memo } = reader();
const args = { collection_id: 12, name: 'Rodin', google_place_id: 'ChIJx', lat: 1, lng: 2 };
const loaded = (await loadFor('save_place_to_collection', args, memo)) as CollectionLookup;
expect(loaded.collection).toEqual({ name: 'get_collection', args: { collectionId: 12 } });
expect(loaded.membership).toEqual({
name: 'find_place_in_collections',
args: { google_place_id: 'ChIJx', lat: 1, lng: 2 },
});
await loadFor('save_place_to_collection', { collection_id: 12, name: 'Rodin' }, memo);
// The list is read once per turn, however many changes ask for it.
expect(read.mock.calls.map(([name]) => name)).toEqual(['get_collection', 'find_place_in_collections']);
});
it('ASTCOL-032: a change of saved places reads them by id, a move its target, a label its list after the label', async () => {
const { read, memo } = reader();
await loadFor('update_collection_place', { placeId: 34, collection_id: 13 }, memo);
const sixty = Array.from({ length: 60 }, (_, i) => i + 1);
await loadFor('copy_collection_places_to_trip', { trip_id: 7, place_ids: sixty }, memo);
await loadFor('reorder_collections', { orderedIds: [1] }, memo);
expect(read.mock.calls).toEqual([
['get_collection_items', { placeIds: [34] }],
['get_collection', { collectionId: 13 }],
['get_collection_items', { placeIds: sixty.slice(0, 50) }],
['list_collections', {}],
]);
const labelRead = vi.fn(async (name: string) =>
name === 'get_collection_items' ? { labels: [{ id: 51, collection_id: 12 }] } : { collection: { id: 12 } },
);
const label = (await loadFor('delete_collection_label', { labelId: 51 }, labelRead)) as CollectionLookup;
expect(labelRead.mock.calls).toEqual([
['get_collection_items', { labelIds: [51] }],
['get_collection', { collectionId: 12 }],
]);
expect(label.collection).toEqual({ collection: { id: 12 } });
});
it('ASTCOL-033: a write with nothing to look up reads nothing', async () => {
const { read, memo } = reader();
expect(await loadFor('create_collection', { name: 'Rome' }, memo)).toBeNull();
expect(await loadFor('set_collection_place_status_from_trip', { trip_id: 7, place_ids: [5] }, memo)).toBeNull();
expect(read).not.toHaveBeenCalled();
});
it('ASTCOL-034: a place photo the model chose is never sent: the image argument is taken away on save and change', () => {
for (const tool of ['save_place_to_collection', 'update_collection_place']) {
expect(TOOL_CARDS[tool]?.argPolicy?.stripped).toEqual(['image_url']);
}
});
});
@@ -0,0 +1,110 @@
/**
* What the collections' tools answer, as the cards read them: a shared list
* with its labels and places, the lists of a user with an invite, and the
* chat's trip with the Louvre (place 5) and Septime saved from it.
*/
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
import type { AnswerInput, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
export const TRIP_ID = 7;
export const card = buildTripCard(
TRIP_ID,
1,
{ trip: { title: 'Paris' }, days: [] },
{
places: [
{ id: 5, name: 'Louvre', lat: 48.861, lng: 2.336, google_place_id: 'ChIJlouvre', category_id: 3 },
{ id: 6, name: 'Septime (trip)', lat: 48.853, lng: 2.381, image_url: '/uploads/places/septime.jpg' },
],
},
null,
);
export const ctx: PreviewContext = {
card,
categories: new Map([[3, 'Sights']]),
locale: 'en',
today: '2026-06-01',
airport: () => null,
};
export const LABELS = [
{ id: 51, collection_id: 12, name: 'Dinner', color: '#2563eb' },
{ id: 52, collection_id: 12, name: 'Late', color: 'not a colour' },
{ id: 53, collection_id: 12, name: 'Unused', color: '#16a34a' },
];
export function savedPlace(over: Record<string, unknown> = {}) {
return {
id: 34,
collection_id: 12,
name: 'Septime',
address: '80 Rue de Charonne',
lat: 48.853,
lng: 2.381,
status: 'want',
label_ids: [51, 52],
rating_avg: 4.4666,
rating_count: 3,
ratings: [],
notes: 'Ask for the counter',
phone: '+33 1 43 67 38 29',
website: 'https://septime.example',
image_url: '/uploads/places/septime.jpg',
google_place_id: null,
osm_id: null,
source_trip_id: TRIP_ID,
source_place_id: 6,
category: { id: 9, name: 'Restaurant', icon: 'Utensils', color: '#f97316' },
updated_at: '2026-05-01 10:00:00',
...over,
};
}
export const COLLECTION = {
id: 12,
owner_id: 1,
name: 'Paris food',
color: '#e11d48',
place_count: 10,
is_owner: true,
members: [
{ user_id: 1, username: 'me', email: 'me@example.com', status: 'accepted', is_owner: true },
{ user_id: 2, username: 'maya', email: 'maya@example.com', status: 'accepted', is_owner: false },
{ user_id: 3, username: 'tom', email: 'tom@example.com', status: 'pending', is_owner: false },
],
labels: LABELS,
};
/** `get_collection` of list 12 with ten places, the first one Septime. */
export function collectionValue(count = 10) {
const places = Array.from({ length: count }, (_, i) =>
i === 0
? savedPlace()
: savedPlace({
id: 100 + i,
name: `Spot ${i}`,
status: i % 2 ? 'visited' : 'idea',
label_ids: i === 1 ? [51] : [],
source_place_id: null,
image_url: null,
}),
);
return { collection: COLLECTION, places };
}
/** `get_collection_items` for the given saved places, all in list 12, which the user owns. */
export function itemsValue(places: Array<Record<string, unknown>>) {
const { members, labels, ...list } = COLLECTION;
const people = members.map(({ user_id, username, status, is_owner }) => ({ user_id, username, status, is_owner }));
return {
lists: [{ ...list, can_delete: true, labels, members: people }],
places,
labels: [],
};
}
export function answerInput(args: Record<string, unknown>, value: unknown): AnswerInput {
return { toolCallId: 'call_1', args, value, card, today: '2026-06-01' };
}
@@ -1193,7 +1193,7 @@ describe('bulk status', () => {
await svc.setStatus(owner.id, saved.id, 'visited');
expect((await svc.findMembership(viewer.id, { google_place_id: 'gp-9' })).lists).toEqual([
{ collection_id: readonly.id, name: 'Shared', place_id: saved.id, status: 'visited', can_edit: false },
{ collection_id: readonly.id, name: 'Shared', color: '#6366f1', place_id: saved.id, status: 'visited', can_edit: false },
]);
expect((await svc.findMembership(owner.id, { google_place_id: 'gp-9' })).lists[0].can_edit).toBe(true);
});
@@ -1,5 +1,44 @@
import { COLLECTION_STATUSES } from '../../collection/collection.schema';
import { cardCountSchema, cardHexColorSchema, cardPersonSchema, cardText } from './card-parts';
import { collectionCardListSchema } from './collection-places.card';
import { z } from 'zod';
/** The collections a user keeps. Only its kind so far; the collections cards give it its fields. */
export const collectionListsCardSchema = z.object({ kind: z.literal('collectionLists') });
/**
* The lists a user keeps, as the chat shows them: each with its colour, size
* and people, an incoming invite with whom it came from, the lists a place is
* saved in with its status in each, and what a held change does to a list.
*/
/** What a change does to a list; `none` on an answer. */
export const COLLECTION_LIST_CHANGES = [
'none',
'add',
'update',
'remove',
'join',
'decline',
'withdraw',
'order',
] as const;
export const collectionListRowSchema = collectionCardListSchema.extend({
/** The place's status in this list (a lookup of where a place is saved). */
status: z.enum(COLLECTION_STATUSES).nullable(),
/** Who sent an incoming invite, or whom a withdrawn one was for. */
person: cardPersonSchema.nullable(),
/** An invite the user has not answered yet. */
invite: z.boolean(),
change: z.enum(COLLECTION_LIST_CHANGES),
before: z.object({ name: cardText(120), color: cardHexColorSchema.nullable() }).nullable(),
});
export type CollectionListRow = z.infer<typeof collectionListRowSchema>;
export const collectionListsCardSchema = z.object({
kind: z.literal('collectionLists'),
lists: z.array(collectionListRowSchema).max(12),
more: cardCountSchema,
/** The place a lookup asked about, as it was asked. */
place: cardText(120).nullable(),
});
export type CollectionListsCard = z.infer<typeof collectionListsCardSchema>;
@@ -1,5 +1,132 @@
import { COLLECTION_STATUSES } from '../../collection/collection.schema';
import { idSchema } from '../../common/primitives.schema';
import {
cardCountSchema,
cardFileKeySchema,
cardHexColorSchema,
cardLatSchema,
cardLngSchema,
cardPhotoKeySchema,
cardText,
} from './card-parts';
import { z } from 'zod';
/** Places of a collection. Only its kind so far; the collections cards give it its fields. */
export const collectionPlacesCardSchema = z.object({ kind: z.literal('collectionPlaces') });
/**
* Saved places of the collections as the chat shows them: a list's head with
* its colour, size and people, its places as its rows (status, labels, rating,
* whether the trip has the place already), and what a held change does to
* them. A saved place's id is its own and never a trip place's: the one link
* to the trip is `inTripPlaceId`.
*/
const statusSchema = z.enum(COLLECTION_STATUSES);
/** The fields of a saved place a change can touch, each named on the card by its label. */
export const COLLECTION_CARD_FIELDS = [
'name',
'address',
'coordinates',
'description',
'notes',
'category',
'link',
'photo',
] as const;
/** What a change does to a label of a list; `none` on an answer. */
export const COLLECTION_LABEL_CHANGES = ['none', 'add', 'rename', 'remove'] as const;
/** A list as a card names it: its colour dot, its size and how many share it. `id` null: the list a change creates. */
export const collectionCardListSchema = z.object({
id: idSchema.nullable(),
name: cardText(120),
color: cardHexColorSchema.nullable(),
placeCount: cardCountSchema.nullable(),
/** Accepted members besides the owner. */
memberCount: cardCountSchema,
/** Places per status, only where the whole list was read. */
counts: z.object({ idea: cardCountSchema, want: cardCountSchema, visited: cardCountSchema }).nullable(),
});
export type CollectionCardList = z.infer<typeof collectionCardListSchema>;
export const collectionCardLabelSchema = z.object({
id: idSchema.nullable(),
name: cardText(60),
color: cardHexColorSchema.nullable(),
/** How many places of its list carry it, where the whole list was read. */
places: cardCountSchema.nullable(),
change: z.enum(COLLECTION_LABEL_CHANGES),
before: z.object({ name: cardText(60), color: cardHexColorSchema.nullable() }).nullable(),
});
export type CollectionCardLabel = z.infer<typeof collectionCardLabelSchema>;
/** What a held change does to one saved place. */
export const collectionCardDeltaSchema = z.object({
status: z.object({ from: statusSchema.nullable(), to: statusSchema }).nullable(),
/** The user's own vote; `to: null` takes it away. */
rating: z.object({ to: z.number().int().min(1).max(5).nullable() }).nullable(),
labelsAdded: z.array(idSchema).max(10),
labelsRemoved: z.array(idSchema).max(10),
fields: z.array(z.enum(COLLECTION_CARD_FIELDS)).max(8),
movedTo: collectionCardListSchema.nullable(),
removed: z.boolean(),
});
export type CollectionCardDelta = z.infer<typeof collectionCardDeltaSchema>;
export const collectionCardPlaceSchema = z.object({
/** The saved place's own id; null before a save is applied. */
id: idSchema.nullable(),
listId: idSchema.nullable(),
name: cardText(120),
address: cardText(200).nullable(),
lat: cardLatSchema.nullable(),
lng: cardLngSchema.nullable(),
category: z
.object({ name: cardText(60), icon: cardText(40).nullable(), color: cardHexColorSchema.nullable() })
.nullable(),
status: statusSchema,
labelIds: z.array(idSchema).max(10),
rating: z.number().min(1).max(5).nullable(),
ratingCount: cardCountSchema,
/** The Google place id the photo proxy finds its picture by. */
googlePlaceId: cardPhotoKeySchema.nullable(),
osmId: cardText(40).nullable(),
/** The file name of its own uploaded photo; the client builds `/uploads/places/<key>`. */
imageKey: cardFileKeySchema.nullable(),
/** The same place in the chat's trip, by its provider id or the trip place it was saved from. */
inTripPlaceId: idSchema.nullable(),
/** The lists that hold the place already (a save's warning). */
savedIn: z.array(z.object({ listId: idSchema, name: cardText(120), status: statusSchema })).max(3),
delta: collectionCardDeltaSchema.nullable(),
});
export type CollectionCardPlace = z.infer<typeof collectionCardPlaceSchema>;
/** What an applied change of several places did, from the tool's answer. */
export const collectionCardOutcomeSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('duplicate'), name: cardText(120) }),
z.object({
type: z.literal('copied'),
copied: cardCountSchema,
skipped: cardCountSchema,
skippedNames: z.array(cardText(120)).max(5),
}),
z.object({ type: z.literal('statusSet'), updated: cardCountSchema, found: cardCountSchema }),
]);
export type CollectionCardOutcome = z.infer<typeof collectionCardOutcomeSchema>;
export const collectionPlacesCardSchema = z.object({
kind: z.literal('collectionPlaces'),
/** Null when the places sit in several lists. */
list: collectionCardListSchema.nullable(),
places: z.array(collectionCardPlaceSchema).max(8),
more: cardCountSchema,
/** Only the labels the shown places carry or the change names. */
labels: z.array(collectionCardLabelSchema).max(24),
outcome: collectionCardOutcomeSchema.nullable(),
/** A status set on a trip place in every list that holds it. */
everyList: z.boolean(),
/** A copy into a trip other than the chat's. */
otherTrip: z.boolean(),
});
export type CollectionPlacesCard = z.infer<typeof collectionPlacesCardSchema>;
@@ -0,0 +1,138 @@
import { assistantAppliedSchema } from '../assistant.schema';
import { MAX_CARD_CHARS } from './card-parts';
import { collectionListsCardSchema } from './collection-lists.card';
import { collectionPlacesCardSchema } from './collection-places.card';
import { COLLECTION_APPLIED_ENTITIES } from './collection.contract';
import { storedTrekCardSchema, trekCardSchema } from './index';
import { describe, expect, it } from 'vitest';
const list = { id: 12, name: 'Paris food', color: '#e11d48', placeCount: 12, memberCount: 2, counts: null };
const place = {
id: 34,
listId: 12,
name: 'Septime',
address: '80 Rue de Charonne',
lat: 48.853,
lng: 2.381,
category: { name: 'Restaurant', icon: 'Utensils', color: '#f97316' },
status: 'want',
labelIds: [5],
rating: 4.5,
ratingCount: 2,
googlePlaceId: 'ChIJseptime',
osmId: null,
imageKey: 'septime.jpg',
inTripPlaceId: null,
savedIn: [],
delta: null,
};
const placesCard = {
kind: 'collectionPlaces',
list,
places: [place],
more: 0,
labels: [{ id: 5, name: 'Dinner', color: '#2563eb', places: 3, change: 'none', before: null }],
outcome: null,
everyList: false,
otherTrip: false,
};
const row = {
...list,
status: null,
person: null,
invite: false,
change: 'none',
before: null,
};
describe('collection cards', () => {
it('CARDS-COLL-001: a list with its saved places and labels reads as itself through the union', () => {
expect(trekCardSchema.parse(placesCard)).toEqual(placesCard);
expect(storedTrekCardSchema.parse(placesCard)).toEqual(placesCard);
expect(JSON.stringify(placesCard).length).toBeLessThan(MAX_CARD_CHARS);
});
it('CARDS-COLL-002: a place a change has not saved yet and the list a change creates carry no id', () => {
const pending = {
...placesCard,
list: { ...list, id: null, placeCount: 0, memberCount: 0 },
places: [{ ...place, id: null, listId: null, imageKey: null, delta: deltaOf({ removed: false }) }],
};
expect(collectionPlacesCardSchema.safeParse(pending).success).toBe(true);
});
it('CARDS-COLL-003: a photo is a stored file name, never an address or a path', () => {
const refused = ['https://example.com/a.jpg', '/uploads/places/a.jpg', '../a.jpg', 'data:image/png;base64,x'];
for (const imageKey of refused) {
const card = { ...placesCard, places: [{ ...place, imageKey }] };
expect(collectionPlacesCardSchema.safeParse(card).success, imageKey).toBe(false);
}
});
it('CARDS-COLL-004: rows, labels and names stay inside their bounds; a card past them reads as null', () => {
const nine = Array.from({ length: 9 }, (_, i) => ({ ...place, id: i + 1 }));
expect(collectionPlacesCardSchema.safeParse({ ...placesCard, places: nine }).success).toBe(false);
expect(collectionPlacesCardSchema.safeParse({ ...placesCard, list: { ...list, name: '' } }).success).toBe(false);
const tooHigh = { ...placesCard, places: [{ ...place, rating: 6 }] };
expect(collectionPlacesCardSchema.safeParse(tooHigh).success).toBe(false);
expect(storedTrekCardSchema.parse({ ...placesCard, places: nine })).toBeNull();
});
it('CARDS-COLL-005: a change names the status it leaves, the vote it casts and the fields it touches', () => {
const delta = deltaOf({ status: { from: 'idea', to: 'visited' }, rating: { to: null }, fields: ['photo'] });
const card = { ...placesCard, places: [{ ...place, delta }] };
expect(collectionPlacesCardSchema.parse(card).places[0]?.delta).toEqual(delta);
const unknownField = { ...placesCard, places: [{ ...place, delta: deltaOf({ fields: ['phone'] }) }] };
expect(collectionPlacesCardSchema.safeParse(unknownField).success).toBe(false);
});
it('CARDS-COLL-006: an applied copy, a duplicate and a status set across lists each say what happened', () => {
for (const outcome of [
{ type: 'duplicate', name: 'Septime' },
{ type: 'copied', copied: 2, skipped: 1, skippedNames: ['Le Comptoir'] },
{ type: 'statusSet', updated: 3, found: 2 },
]) {
expect(collectionPlacesCardSchema.safeParse({ ...placesCard, outcome }).success, outcome.type).toBe(true);
}
});
it('CARDS-COLL-007: the lists card holds lists, invites and a lookup of where a place is saved', () => {
const card = {
kind: 'collectionLists',
lists: [
{ ...row, status: 'visited' },
{ ...row, id: 13, invite: true, person: { id: 4, name: 'Maya', avatarKey: null }, placeCount: null },
],
more: 0,
place: 'Septime',
};
expect(trekCardSchema.parse(card)).toEqual(card);
const thirteen = Array.from({ length: 13 }, (_, i) => ({ ...row, id: i + 1 }));
expect(collectionListsCardSchema.safeParse({ ...card, lists: thirteen }).success).toBe(false);
expect(collectionListsCardSchema.safeParse({ ...card, lists: [{ ...row, change: 'rename' }] }).success).toBe(false);
});
it('CARDS-COLL-008: an applied change names a list or a saved place as what it touched', () => {
for (const entity of COLLECTION_APPLIED_ENTITIES) {
const applied = { toolCallId: 'c1', entity, ids: [12], dayId: null, undoUntil: null };
expect(assistantAppliedSchema.parse(applied).entity).toBe(entity);
}
});
});
function deltaOf(over: Record<string, unknown>) {
return {
status: null,
rating: null,
labelsAdded: [],
labelsRemoved: [],
fields: [],
movedTo: null,
removed: false,
...over,
};
}
@@ -12,5 +12,7 @@ export * from './collection-places.card';
* or entity is added here and nowhere else.
*/
export const COLLECTION_CARD_SCHEMAS = [collectionPlacesCardSchema, collectionListsCardSchema] as const;
/** Their changes read as the plain add, change and remove, named by each tool's own label. */
export const COLLECTION_VERB_KEYS = [] as const;
export const COLLECTION_APPLIED_ENTITIES = [] as const;
/** A list (`collection`) and a saved place of one (`collection_place`), both opened in Collections. */
export const COLLECTION_APPLIED_ENTITIES = ['collection', 'collection_place'] as const;
@@ -1,5 +1,7 @@
import {
collectionDeleteManyRequestSchema,
collectionItemsRequestSchema,
collectionMembershipSchema,
collectionPlaceUpdateRequestSchema,
collectionReorderRequestSchema,
collectionSavePlaceRequestSchema,
@@ -63,3 +65,24 @@ describe('collectionReorderRequestSchema / collectionDeleteManyRequestSchema', (
expect(() => collectionDeleteManyRequestSchema.parse({ ids: ['a'] })).toThrow();
});
});
describe('collectionItemsRequestSchema', () => {
it('takes saved place and label ids, each optional, at most 50 places and 20 labels', () => {
expect(collectionItemsRequestSchema.parse({})).toEqual({});
const ids = (count: number) => Array.from({ length: count }, (_, i) => i + 1);
const asked = { placeIds: [1, 2], labelIds: [3] };
expect(collectionItemsRequestSchema.parse(asked)).toEqual(asked);
expect(() => collectionItemsRequestSchema.parse({ placeIds: ids(51) })).toThrow();
expect(() => collectionItemsRequestSchema.parse({ labelIds: ids(21) })).toThrow();
expect(() => collectionItemsRequestSchema.parse({ placeIds: [0] })).toThrow();
});
});
describe('collectionMembershipSchema', () => {
it("reads a list's colour where the server sends it, and a list without one from an older server", () => {
const list = { collection_id: 1, name: 'Paris', place_id: 2, status: 'want', can_edit: true };
const coloured = collectionMembershipSchema.parse({ saved: true, lists: [{ ...list, color: '#e11d48' }] });
expect(coloured.lists[0]?.color).toBe('#e11d48');
expect(collectionMembershipSchema.parse({ saved: true, lists: [list] }).lists[0]?.color).toBeUndefined();
});
});
@@ -1,3 +1,4 @@
import { idSchema } from '../common/primitives.schema';
import {
placeCategorySchema,
placeImageUrlSchema,
@@ -258,6 +259,21 @@ export const collectionCopyToTripRequestSchema = z.object({
});
export type CollectionCopyToTripRequest = z.infer<typeof collectionCopyToTripRequestSchema>;
/** What a copy into a trip did: how many went in, the ones the trip had, and each new trip place by its source. */
export const collectionCopyToTripResponseSchema = z.object({
copied: z.number(),
skipped: z.array(z.object({ id: z.number(), name: z.string() })),
created: z.array(z.object({ id: z.number(), source_id: z.number() })),
});
export type CollectionCopyToTripResponse = z.infer<typeof collectionCopyToTripResponseSchema>;
/** Saved places and labels asked for by their ids, across every list the caller can see (get_collection_items). */
export const collectionItemsRequestSchema = z.object({
placeIds: z.array(idSchema).max(50).optional(),
labelIds: z.array(idSchema).max(20).optional(),
});
export type CollectionItemsRequest = z.infer<typeof collectionItemsRequestSchema>;
// Fusion invitations. user_id is NUMERIC ONLY — the UI always sends an id from availableUsers.
export const collectionInviteRequestSchema = z.object({
collection_id: z.number(),
@@ -337,6 +353,28 @@ export const collectionDetailResponseSchema = z.object({
});
export type CollectionDetailResponse = z.infer<typeof collectionDetailResponseSchema>;
/**
* The asked-for places and labels with the lists they sit in: each list's
* size, colour, labels and people (names and roles, no addresses), and
* whether the caller may delete places from it (its owner or an admin). An id
* the caller cannot see is left out, never refused.
*/
const collectionItemsListSchema = collectionSchema
.pick({ id: true, name: true, color: true, place_count: true, is_owner: true, labels: true })
.extend({
can_delete: z.boolean(),
members: z.array(
collectionMemberSchema.pick({ user_id: true, username: true, status: true, role: true, is_owner: true }),
),
});
export const collectionItemsResponseSchema = z.object({
lists: z.array(collectionItemsListSchema),
places: z.array(collectionPlaceSchema),
labels: z.array(collectionLabelSchema),
});
export type CollectionItemsResponse = z.infer<typeof collectionItemsResponseSchema>;
/** Dedup outcome envelope reused by save + copy (matches placeService dedup UX). */
export const collectionSaveResultSchema = z.object({
place: collectionPlaceSchema.optional(),
@@ -352,6 +390,8 @@ export const collectionMembershipSchema = z.object({
z.object({
collection_id: z.number(),
name: z.string(),
/** The list's colour, for its dot; older servers leave it out. */
color: z.string().nullable().optional(),
place_id: z.number(),
/** Per-list status, so the picker can show and change it without a second round trip (#1469). */
status: collectionStatusSchema,
+88 -1
View File
@@ -1,4 +1,91 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'فتح القائمة',
'assistant.card.collection.openPlace': 'فتح في المجموعات',
'assistant.card.collection.moreInList': '{count} أخرى في هذه القائمة',
'assistant.card.collection.moreInList.zero': '{count} أخرى في هذه القائمة',
'assistant.card.collection.moreInList.one': '{count} أخرى في هذه القائمة',
'assistant.card.collection.moreInList.two': '{count} أخرى في هذه القائمة',
'assistant.card.collection.moreInList.few': '{count} أخرى في هذه القائمة',
'assistant.card.collection.moreInList.many': '{count} أخرى في هذه القائمة',
'assistant.card.collection.members': '{count} عضو',
'assistant.card.collection.members.zero': 'لا أعضاء',
'assistant.card.collection.members.one': 'عضو واحد',
'assistant.card.collection.members.two': 'عضوان',
'assistant.card.collection.members.few': '{count} أعضاء',
'assistant.card.collection.members.many': '{count} عضوًا',
'assistant.card.collection.lists': 'قوائمك',
'assistant.card.collection.savedIn': 'محفوظ في {count} قائمة',
'assistant.card.collection.savedIn.zero': 'غير محفوظ في أي قائمة',
'assistant.card.collection.savedIn.one': 'محفوظ في قائمة واحدة',
'assistant.card.collection.savedIn.two': 'محفوظ في قائمتين',
'assistant.card.collection.savedIn.few': 'محفوظ في {count} قوائم',
'assistant.card.collection.savedIn.many': 'محفوظ في {count} قائمة',
'assistant.card.collection.alreadySaved': 'موجود بالفعل في {name}',
'assistant.card.collection.invite': 'دعوة من {name}',
'assistant.card.collection.withdraw': 'دعوة إلى {name}',
'assistant.card.collection.labelUsedBy': 'على {count} مكان',
'assistant.card.collection.labelUsedBy.zero': 'لا يحمله أي مكان',
'assistant.card.collection.labelUsedBy.one': 'على مكان واحد',
'assistant.card.collection.labelUsedBy.two': 'على مكانين',
'assistant.card.collection.labelUsedBy.few': 'على {count} أماكن',
'assistant.card.collection.labelUsedBy.many': 'على {count} مكانًا',
'assistant.card.collection.copied': 'أُضيف {count}',
'assistant.card.collection.copied.zero': 'لم يُضف شيء',
'assistant.card.collection.copied.one': 'أُضيف واحد',
'assistant.card.collection.copied.two': 'أُضيف اثنان',
'assistant.card.collection.copied.few': 'أُضيفت {count}',
'assistant.card.collection.copied.many': 'أُضيف {count}',
'assistant.card.collection.skipped': '{count} موجودة بالفعل',
'assistant.card.collection.skipped.zero': 'لا شيء موجود بالفعل',
'assistant.card.collection.skipped.one': 'واحد موجود بالفعل',
'assistant.card.collection.skipped.two': 'اثنان موجودان بالفعل',
'assistant.card.collection.skipped.few': '{count} موجودة بالفعل',
'assistant.card.collection.skipped.many': '{count} موجودًا بالفعل',
'assistant.card.collection.willSkip': '{count} موجودة في الرحلة',
'assistant.card.collection.willSkip.zero': 'لا شيء في الرحلة',
'assistant.card.collection.willSkip.one': 'واحد موجود في الرحلة',
'assistant.card.collection.willSkip.two': 'اثنان موجودان في الرحلة',
'assistant.card.collection.willSkip.few': '{count} موجودة في الرحلة',
'assistant.card.collection.willSkip.many': '{count} موجودًا في الرحلة',
'assistant.card.collection.otherTrip': 'إلى رحلة أخرى',
'assistant.card.collection.everyList': 'في كل قائمة حُفظ فيها',
'assistant.card.collection.ratingCleared': 'أُزيل التقييم',
'assistant.card.collection.moveTo': 'نقل إلى {name}',
'assistant.card.collection.removed': 'أُزيل من القائمة',
'assistant.card.collection.photo': 'صورة',
'assistant.card.collection.places': 'الأماكن المحفوظة في {name}',
'assistant.card.collection.saved': 'الأماكن المحفوظة',
'assistant.card.collection.statusSet': 'تغيّر {count} مكان محفوظ',
'assistant.card.collection.statusSet.zero': 'لم يتغيّر أي مكان محفوظ',
'assistant.card.collection.statusSet.one': 'تغيّر مكان محفوظ واحد',
'assistant.card.collection.statusSet.two': 'تغيّر مكانان محفوظان',
'assistant.card.collection.statusSet.few': 'تغيّرت {count} أماكن محفوظة',
'assistant.card.collection.statusSet.many': 'تغيّر {count} مكانًا محفوظًا',
'assistant.tool.create_collection': 'إنشاء قائمة',
'assistant.tool.update_collection': 'تعديل القائمة',
'assistant.tool.delete_collection': 'حذف القائمة',
'assistant.tool.reorder_collections': 'إعادة ترتيب قوائمك',
'assistant.tool.save_place_to_collection': 'الحفظ في قائمة',
'assistant.tool.save_trip_places_to_collection': 'حفظ أماكن الرحلة في قائمة',
'assistant.tool.update_collection_place': 'تعديل المكان المحفوظ',
'assistant.tool.set_collection_place_status': 'تعيين الحالة',
'assistant.tool.set_collection_place_status_from_trip': 'تعيين الحالة في قوائمك',
'assistant.tool.rate_collection_place': 'تقييم المكان المحفوظ',
'assistant.tool.delete_collection_place': 'الإزالة من القائمة',
'assistant.tool.copy_collection_places_to_trip': 'إضافة الأماكن المحفوظة إلى الرحلة',
'assistant.tool.create_collection_label': 'إضافة وسم',
'assistant.tool.update_collection_label': 'تعديل الوسم',
'assistant.tool.delete_collection_label': 'حذف الوسم',
'assistant.tool.assign_collection_labels': 'تغيير الوسوم',
'assistant.tool.accept_collection_invite': 'الانضمام إلى القائمة',
'assistant.tool.decline_collection_invite': 'رفض الدعوة',
'assistant.tool.cancel_collection_invite': 'سحب الدعوة',
'assistant.step.readingList': 'قراءة قائمتك',
'assistant.step.lookingAtLists': 'الاطلاع على قوائمك',
'assistant.step.checkingLists': 'التحقق من قوائمك',
'assistant.followUp.collectionToTrip': 'أضف إلى هذه الرحلة الأماكن التي أريد زيارتها',
'assistant.followUp.collectionDay': 'خطّط ليوم حول هذه القائمة',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Siyahını aç',
'assistant.card.collection.openPlace': 'Kolleksiyalarda aç',
'assistant.card.collection.moreInList': 'Bu siyahıda daha {count}',
'assistant.card.collection.moreInList.one': 'Bu siyahıda daha {count}',
'assistant.card.collection.members': '{count} üzv',
'assistant.card.collection.members.one': '{count} üzv',
'assistant.card.collection.lists': 'Siyahılarınız',
'assistant.card.collection.savedIn': '{count} siyahıda saxlanılıb',
'assistant.card.collection.savedIn.one': '{count} siyahıda saxlanılıb',
'assistant.card.collection.alreadySaved': 'Artıq {name} siyahısında',
'assistant.card.collection.invite': '{name} tərəfindən dəvət',
'assistant.card.collection.withdraw': '{name} üçün dəvət',
'assistant.card.collection.labelUsedBy': '{count} məkanda',
'assistant.card.collection.labelUsedBy.one': '{count} məkanda',
'assistant.card.collection.copied': '{count} əlavə edildi',
'assistant.card.collection.copied.one': '{count} əlavə edildi',
'assistant.card.collection.skipped': '{count} artıq var idi',
'assistant.card.collection.skipped.one': '{count} artıq var idi',
'assistant.card.collection.willSkip': '{count} artıq səyahətdə',
'assistant.card.collection.willSkip.one': '{count} artıq səyahətdə',
'assistant.card.collection.otherTrip': 'Başqa səyahətə',
'assistant.card.collection.everyList': 'Saxlanıldığı hər siyahıda',
'assistant.card.collection.ratingCleared': 'Qiymət silindi',
'assistant.card.collection.moveTo': '{name} siyahısına köçür',
'assistant.card.collection.removed': 'Siyahıdan çıxarıldı',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.places': '{name} siyahısındakı saxlanılmış məkanlar',
'assistant.card.collection.saved': 'Saxlanılmış məkanlar',
'assistant.card.collection.statusSet': '{count} saxlanılmış məkan dəyişdi',
'assistant.card.collection.statusSet.one': '{count} saxlanılmış məkan dəyişdi',
'assistant.tool.create_collection': 'Siyahı yarat',
'assistant.tool.update_collection': 'Siyahını dəyiş',
'assistant.tool.delete_collection': 'Siyahını sil',
'assistant.tool.reorder_collections': 'Siyahıları yenidən sırala',
'assistant.tool.save_place_to_collection': 'Siyahıda saxla',
'assistant.tool.save_trip_places_to_collection': 'Səyahətin məkanlarını siyahıda saxla',
'assistant.tool.update_collection_place': 'Saxlanılmış məkanı dəyiş',
'assistant.tool.set_collection_place_status': 'Statusu təyin et',
'assistant.tool.set_collection_place_status_from_trip': 'Siyahılarınızda statusu təyin et',
'assistant.tool.rate_collection_place': 'Saxlanılmış məkanı qiymətləndir',
'assistant.tool.delete_collection_place': 'Siyahıdan çıxar',
'assistant.tool.copy_collection_places_to_trip': 'Saxlanılmış məkanları səyahətə əlavə et',
'assistant.tool.create_collection_label': 'Etiket əlavə et',
'assistant.tool.update_collection_label': 'Etiketi dəyiş',
'assistant.tool.delete_collection_label': 'Etiketi sil',
'assistant.tool.assign_collection_labels': 'Etiketləri dəyiş',
'assistant.tool.accept_collection_invite': 'Siyahıya qoşul',
'assistant.tool.decline_collection_invite': 'Dəvəti rədd et',
'assistant.tool.cancel_collection_invite': 'Dəvəti geri götür',
'assistant.step.readingList': 'Siyahınız oxunur',
'assistant.step.lookingAtLists': 'Siyahılarınıza baxılır',
'assistant.step.checkingLists': 'Siyahılarınız yoxlanılır',
'assistant.followUp.collectionToTrip': 'Getmək istədiyim məkanları bu səyahətə əlavə et',
'assistant.followUp.collectionDay': 'Bu siyahı ətrafında bir gün planlaşdır',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Abrir lista',
'assistant.card.collection.openPlace': 'Abrir em Coleções',
'assistant.card.collection.moreInList': 'Mais {count} nesta lista',
'assistant.card.collection.moreInList.one': 'Mais {count} nesta lista',
'assistant.card.collection.members': '{count} membros',
'assistant.card.collection.members.one': '{count} membro',
'assistant.card.collection.lists': 'Suas listas',
'assistant.card.collection.savedIn': 'Salvo em {count} listas',
'assistant.card.collection.savedIn.one': 'Salvo em {count} lista',
'assistant.card.collection.alreadySaved': 'Já em {name}',
'assistant.card.collection.invite': 'Convite de {name}',
'assistant.card.collection.withdraw': 'Convite para {name}',
'assistant.card.collection.labelUsedBy': 'Em {count} lugares',
'assistant.card.collection.labelUsedBy.one': 'Em {count} lugar',
'assistant.card.collection.copied': '{count} adicionados',
'assistant.card.collection.copied.one': '{count} adicionado',
'assistant.card.collection.skipped': '{count} já estavam',
'assistant.card.collection.skipped.one': '{count} já estava',
'assistant.card.collection.willSkip': '{count} já na viagem',
'assistant.card.collection.willSkip.one': '{count} já na viagem',
'assistant.card.collection.otherTrip': 'Para outra viagem',
'assistant.card.collection.everyList': 'Em todas as listas em que está salvo',
'assistant.card.collection.ratingCleared': 'Avaliação removida',
'assistant.card.collection.moveTo': 'Mover para {name}',
'assistant.card.collection.removed': 'Removido da lista',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.places': 'Lugares salvos em {name}',
'assistant.card.collection.saved': 'Lugares salvos',
'assistant.card.collection.statusSet': '{count} lugares salvos alterados',
'assistant.card.collection.statusSet.one': '{count} lugar salvo alterado',
'assistant.tool.create_collection': 'Criar uma lista',
'assistant.tool.update_collection': 'Alterar a lista',
'assistant.tool.delete_collection': 'Excluir a lista',
'assistant.tool.reorder_collections': 'Reordenar suas listas',
'assistant.tool.save_place_to_collection': 'Salvar em uma lista',
'assistant.tool.save_trip_places_to_collection': 'Salvar lugares da viagem em uma lista',
'assistant.tool.update_collection_place': 'Alterar o lugar salvo',
'assistant.tool.set_collection_place_status': 'Definir o status',
'assistant.tool.set_collection_place_status_from_trip': 'Definir o status nas suas listas',
'assistant.tool.rate_collection_place': 'Avaliar o lugar salvo',
'assistant.tool.delete_collection_place': 'Remover da lista',
'assistant.tool.copy_collection_places_to_trip': 'Adicionar lugares salvos à viagem',
'assistant.tool.create_collection_label': 'Adicionar uma etiqueta',
'assistant.tool.update_collection_label': 'Alterar a etiqueta',
'assistant.tool.delete_collection_label': 'Excluir a etiqueta',
'assistant.tool.assign_collection_labels': 'Alterar as etiquetas',
'assistant.tool.accept_collection_invite': 'Entrar na lista',
'assistant.tool.decline_collection_invite': 'Recusar o convite',
'assistant.tool.cancel_collection_invite': 'Retirar o convite',
'assistant.step.readingList': 'Lendo sua lista',
'assistant.step.lookingAtLists': 'Olhando suas listas',
'assistant.step.checkingLists': 'Verificando suas listas',
'assistant.followUp.collectionToTrip': 'Adicione a esta viagem os lugares aonde quero ir',
'assistant.followUp.collectionDay': 'Planeje um dia em torno desta lista',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Obre la llista',
'assistant.card.collection.openPlace': 'Obre a Col·leccions',
'assistant.card.collection.moreInList': '{count} més en aquesta llista',
'assistant.card.collection.moreInList.one': '{count} més en aquesta llista',
'assistant.card.collection.members': '{count} membres',
'assistant.card.collection.members.one': '{count} membre',
'assistant.card.collection.lists': 'Les teves llistes',
'assistant.card.collection.savedIn': 'Desat a {count} llistes',
'assistant.card.collection.savedIn.one': 'Desat a {count} llista',
'assistant.card.collection.alreadySaved': 'Ja és a {name}',
'assistant.card.collection.invite': 'Invitació de {name}',
'assistant.card.collection.withdraw': 'Invitació a {name}',
'assistant.card.collection.labelUsedBy': 'En {count} llocs',
'assistant.card.collection.labelUsedBy.one': 'En {count} lloc',
'assistant.card.collection.copied': '{count} afegits',
'assistant.card.collection.copied.one': '{count} afegit',
'assistant.card.collection.skipped': '{count} ja hi eren',
'assistant.card.collection.skipped.one': '{count} ja hi era',
'assistant.card.collection.willSkip': '{count} ja al viatge',
'assistant.card.collection.willSkip.one': '{count} ja al viatge',
'assistant.card.collection.otherTrip': 'A un altre viatge',
'assistant.card.collection.everyList': 'A cada llista on està desat',
'assistant.card.collection.ratingCleared': 'Valoració eliminada',
'assistant.card.collection.moveTo': 'Mou a {name}',
'assistant.card.collection.removed': 'Tret de la llista',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.places': 'Llocs desats a {name}',
'assistant.card.collection.saved': 'Llocs desats',
'assistant.card.collection.statusSet': '{count} llocs desats canviats',
'assistant.card.collection.statusSet.one': '{count} lloc desat canviat',
'assistant.tool.create_collection': 'Crea una llista',
'assistant.tool.update_collection': 'Canvia la llista',
'assistant.tool.delete_collection': 'Suprimeix la llista',
'assistant.tool.reorder_collections': 'Reordena les teves llistes',
'assistant.tool.save_place_to_collection': 'Desa en una llista',
'assistant.tool.save_trip_places_to_collection': 'Desa llocs del viatge en una llista',
'assistant.tool.update_collection_place': 'Canvia el lloc desat',
'assistant.tool.set_collection_place_status': "Defineix l'estat",
'assistant.tool.set_collection_place_status_from_trip': "Defineix l'estat a les teves llistes",
'assistant.tool.rate_collection_place': 'Valora el lloc desat',
'assistant.tool.delete_collection_place': 'Treu de la llista',
'assistant.tool.copy_collection_places_to_trip': 'Afegeix llocs desats al viatge',
'assistant.tool.create_collection_label': 'Afegeix una etiqueta',
'assistant.tool.update_collection_label': "Canvia l'etiqueta",
'assistant.tool.delete_collection_label': "Suprimeix l'etiqueta",
'assistant.tool.assign_collection_labels': 'Canvia les etiquetes',
'assistant.tool.accept_collection_invite': 'Uneix-te a la llista',
'assistant.tool.decline_collection_invite': 'Rebutja la invitació',
'assistant.tool.cancel_collection_invite': 'Retira la invitació',
'assistant.step.readingList': 'Llegint la teva llista',
'assistant.step.lookingAtLists': 'Mirant les teves llistes',
'assistant.step.checkingLists': 'Revisant les teves llistes',
'assistant.followUp.collectionToTrip': 'Afegeix a aquest viatge els llocs on vull anar',
'assistant.followUp.collectionDay': 'Planifica un dia al voltant d’aquesta llista',
};
export default assistantCollection;
+64 -1
View File
@@ -1,4 +1,67 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Otevřít seznam',
'assistant.card.collection.openPlace': 'Otevřít ve Sbírkách',
'assistant.card.collection.moreInList': 'Dalších {count} v tomto seznamu',
'assistant.card.collection.moreInList.one': 'Další {count} v tomto seznamu',
'assistant.card.collection.moreInList.few': 'Další {count} v tomto seznamu',
'assistant.card.collection.members': '{count} členů',
'assistant.card.collection.members.one': '{count} člen',
'assistant.card.collection.members.few': '{count} členové',
'assistant.card.collection.lists': 'Tvoje seznamy',
'assistant.card.collection.savedIn': 'Uloženo v {count} seznamech',
'assistant.card.collection.savedIn.one': 'Uloženo v {count} seznamu',
'assistant.card.collection.savedIn.few': 'Uloženo ve {count} seznamech',
'assistant.card.collection.alreadySaved': 'Už v {name}',
'assistant.card.collection.invite': 'Pozvánka od {name}',
'assistant.card.collection.withdraw': 'Pozvánka pro {name}',
'assistant.card.collection.labelUsedBy': 'Na {count} místech',
'assistant.card.collection.labelUsedBy.one': 'Na {count} místě',
'assistant.card.collection.labelUsedBy.few': 'Na {count} místech',
'assistant.card.collection.copied': '{count} přidáno',
'assistant.card.collection.copied.one': '{count} přidáno',
'assistant.card.collection.copied.few': '{count} přidána',
'assistant.card.collection.skipped': '{count} už tam bylo',
'assistant.card.collection.skipped.one': '{count} už tam bylo',
'assistant.card.collection.skipped.few': '{count} už tam byla',
'assistant.card.collection.willSkip': '{count} už ve výletu',
'assistant.card.collection.willSkip.one': '{count} už ve výletu',
'assistant.card.collection.willSkip.few': '{count} už ve výletu',
'assistant.card.collection.otherTrip': 'Do jiného výletu',
'assistant.card.collection.everyList': 'V každém seznamu, kde je uloženo',
'assistant.card.collection.ratingCleared': 'Hodnocení odebráno',
'assistant.card.collection.moveTo': 'Přesunout do {name}',
'assistant.card.collection.removed': 'Odebráno ze seznamu',
'assistant.card.collection.photo': 'Fotka',
'assistant.card.collection.places': 'Uložená místa v {name}',
'assistant.card.collection.saved': 'Uložená místa',
'assistant.card.collection.statusSet': '{count} uložených míst změněno',
'assistant.card.collection.statusSet.one': '{count} uložené místo změněno',
'assistant.card.collection.statusSet.few': '{count} uložená místa změněna',
'assistant.tool.create_collection': 'Vytvořit seznam',
'assistant.tool.update_collection': 'Změnit seznam',
'assistant.tool.delete_collection': 'Smazat seznam',
'assistant.tool.reorder_collections': 'Přeuspořádat seznamy',
'assistant.tool.save_place_to_collection': 'Uložit do seznamu',
'assistant.tool.save_trip_places_to_collection': 'Uložit místa z výletu do seznamu',
'assistant.tool.update_collection_place': 'Změnit uložené místo',
'assistant.tool.set_collection_place_status': 'Nastavit stav',
'assistant.tool.set_collection_place_status_from_trip': 'Nastavit stav ve tvých seznamech',
'assistant.tool.rate_collection_place': 'Ohodnotit uložené místo',
'assistant.tool.delete_collection_place': 'Odebrat ze seznamu',
'assistant.tool.copy_collection_places_to_trip': 'Přidat uložená místa do výletu',
'assistant.tool.create_collection_label': 'Přidat štítek',
'assistant.tool.update_collection_label': 'Změnit štítek',
'assistant.tool.delete_collection_label': 'Smazat štítek',
'assistant.tool.assign_collection_labels': 'Změnit štítky',
'assistant.tool.accept_collection_invite': 'Připojit se k seznamu',
'assistant.tool.decline_collection_invite': 'Odmítnout pozvánku',
'assistant.tool.cancel_collection_invite': 'Stáhnout pozvánku',
'assistant.step.readingList': 'Čtu tvůj seznam',
'assistant.step.lookingAtLists': 'Prohlížím tvoje seznamy',
'assistant.step.checkingLists': 'Kontroluji tvoje seznamy',
'assistant.followUp.collectionToTrip': 'Přidej do tohoto výletu místa, kam chci jet',
'assistant.followUp.collectionDay': 'Naplánuj den kolem tohoto seznamu',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Liste öffnen',
'assistant.card.collection.openPlace': 'In Sammlungen öffnen',
'assistant.card.collection.moreInList': '{count} weitere in dieser Liste',
'assistant.card.collection.moreInList.one': '{count} weiterer in dieser Liste',
'assistant.card.collection.members': '{count} Mitglieder',
'assistant.card.collection.members.one': '{count} Mitglied',
'assistant.card.collection.lists': 'Deine Listen',
'assistant.card.collection.savedIn': 'In {count} Listen gespeichert',
'assistant.card.collection.savedIn.one': 'In {count} Liste gespeichert',
'assistant.card.collection.alreadySaved': 'Schon in {name}',
'assistant.card.collection.invite': 'Einladung von {name}',
'assistant.card.collection.withdraw': 'Einladung an {name}',
'assistant.card.collection.labelUsedBy': 'An {count} Orten',
'assistant.card.collection.labelUsedBy.one': 'An {count} Ort',
'assistant.card.collection.copied': '{count} hinzugefügt',
'assistant.card.collection.copied.one': '{count} hinzugefügt',
'assistant.card.collection.skipped': '{count} schon vorhanden',
'assistant.card.collection.skipped.one': '{count} schon vorhanden',
'assistant.card.collection.willSkip': '{count} schon im Trip',
'assistant.card.collection.willSkip.one': '{count} schon im Trip',
'assistant.card.collection.otherTrip': 'In einen anderen Trip',
'assistant.card.collection.everyList': 'In jeder Liste, in der er gespeichert ist',
'assistant.card.collection.ratingCleared': 'Bewertung entfernt',
'assistant.card.collection.moveTo': 'Nach {name} verschieben',
'assistant.card.collection.removed': 'Aus der Liste entfernt',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.places': 'Gespeicherte Orte in {name}',
'assistant.card.collection.saved': 'Gespeicherte Orte',
'assistant.card.collection.statusSet': '{count} gespeicherte Orte geändert',
'assistant.card.collection.statusSet.one': '{count} gespeicherter Ort geändert',
'assistant.tool.create_collection': 'Liste anlegen',
'assistant.tool.update_collection': 'Liste ändern',
'assistant.tool.delete_collection': 'Liste löschen',
'assistant.tool.reorder_collections': 'Listen neu ordnen',
'assistant.tool.save_place_to_collection': 'In einer Liste speichern',
'assistant.tool.save_trip_places_to_collection': 'Orte des Trips in einer Liste speichern',
'assistant.tool.update_collection_place': 'Gespeicherten Ort ändern',
'assistant.tool.set_collection_place_status': 'Status setzen',
'assistant.tool.set_collection_place_status_from_trip': 'Status in deinen Listen setzen',
'assistant.tool.rate_collection_place': 'Gespeicherten Ort bewerten',
'assistant.tool.delete_collection_place': 'Aus der Liste entfernen',
'assistant.tool.copy_collection_places_to_trip': 'Gespeicherte Orte zum Trip hinzufügen',
'assistant.tool.create_collection_label': 'Label hinzufügen',
'assistant.tool.update_collection_label': 'Label ändern',
'assistant.tool.delete_collection_label': 'Label löschen',
'assistant.tool.assign_collection_labels': 'Labels ändern',
'assistant.tool.accept_collection_invite': 'Der Liste beitreten',
'assistant.tool.decline_collection_invite': 'Einladung ablehnen',
'assistant.tool.cancel_collection_invite': 'Einladung zurückziehen',
'assistant.step.readingList': 'Liest deine Liste',
'assistant.step.lookingAtLists': 'Schaut sich deine Listen an',
'assistant.step.checkingLists': 'Prüft deine Listen',
'assistant.followUp.collectionToTrip': 'Füge die Orte, zu denen ich will, diesem Trip hinzu',
'assistant.followUp.collectionDay': 'Plane einen Tag rund um diese Liste',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Open list',
'assistant.card.collection.openPlace': 'Open in Collections',
'assistant.card.collection.moreInList': '{count} more in this list',
'assistant.card.collection.moreInList.one': '{count} more in this list',
'assistant.card.collection.members': '{count} members',
'assistant.card.collection.members.one': '{count} member',
'assistant.card.collection.lists': 'Your lists',
'assistant.card.collection.savedIn': 'Saved in {count} lists',
'assistant.card.collection.savedIn.one': 'Saved in {count} list',
'assistant.card.collection.alreadySaved': 'Already in {name}',
'assistant.card.collection.invite': 'Invite from {name}',
'assistant.card.collection.withdraw': 'Invite to {name}',
'assistant.card.collection.labelUsedBy': 'On {count} places',
'assistant.card.collection.labelUsedBy.one': 'On {count} place',
'assistant.card.collection.copied': '{count} added',
'assistant.card.collection.copied.one': '{count} added',
'assistant.card.collection.skipped': '{count} already there',
'assistant.card.collection.skipped.one': '{count} already there',
'assistant.card.collection.willSkip': '{count} already in the trip',
'assistant.card.collection.willSkip.one': '{count} already in the trip',
'assistant.card.collection.otherTrip': 'Into another trip',
'assistant.card.collection.everyList': 'In every list it is saved in',
'assistant.card.collection.ratingCleared': 'Rating removed',
'assistant.card.collection.moveTo': 'Move to {name}',
'assistant.card.collection.removed': 'Removed from the list',
'assistant.card.collection.photo': 'Photo',
'assistant.card.collection.places': 'Saved places in {name}',
'assistant.card.collection.saved': 'Saved places',
'assistant.card.collection.statusSet': '{count} saved places changed',
'assistant.card.collection.statusSet.one': '{count} saved place changed',
'assistant.tool.create_collection': 'Create a list',
'assistant.tool.update_collection': 'Change the list',
'assistant.tool.delete_collection': 'Delete the list',
'assistant.tool.reorder_collections': 'Reorder your lists',
'assistant.tool.save_place_to_collection': 'Save to a list',
'assistant.tool.save_trip_places_to_collection': 'Save trip places to a list',
'assistant.tool.update_collection_place': 'Change the saved place',
'assistant.tool.set_collection_place_status': 'Set the status',
'assistant.tool.set_collection_place_status_from_trip': 'Set the status in your lists',
'assistant.tool.rate_collection_place': 'Rate the saved place',
'assistant.tool.delete_collection_place': 'Remove from the list',
'assistant.tool.copy_collection_places_to_trip': 'Add saved places to the trip',
'assistant.tool.create_collection_label': 'Add a label',
'assistant.tool.update_collection_label': 'Change the label',
'assistant.tool.delete_collection_label': 'Delete the label',
'assistant.tool.assign_collection_labels': 'Change the labels',
'assistant.tool.accept_collection_invite': 'Join the list',
'assistant.tool.decline_collection_invite': 'Decline the invite',
'assistant.tool.cancel_collection_invite': 'Withdraw the invite',
'assistant.step.readingList': 'Reading your list',
'assistant.step.lookingAtLists': 'Looking at your lists',
'assistant.step.checkingLists': 'Checking your lists',
'assistant.followUp.collectionToTrip': 'Add the places I want to go to this trip',
'assistant.followUp.collectionDay': 'Plan a day around this list',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Abrir lista',
'assistant.card.collection.openPlace': 'Abrir en Colecciones',
'assistant.card.collection.moreInList': '{count} más en esta lista',
'assistant.card.collection.moreInList.one': '{count} más en esta lista',
'assistant.card.collection.members': '{count} miembros',
'assistant.card.collection.members.one': '{count} miembro',
'assistant.card.collection.lists': 'Tus listas',
'assistant.card.collection.savedIn': 'Guardado en {count} listas',
'assistant.card.collection.savedIn.one': 'Guardado en {count} lista',
'assistant.card.collection.alreadySaved': 'Ya en {name}',
'assistant.card.collection.invite': 'Invitación de {name}',
'assistant.card.collection.withdraw': 'Invitación a {name}',
'assistant.card.collection.labelUsedBy': 'En {count} lugares',
'assistant.card.collection.labelUsedBy.one': 'En {count} lugar',
'assistant.card.collection.copied': '{count} añadidos',
'assistant.card.collection.copied.one': '{count} añadido',
'assistant.card.collection.skipped': '{count} ya estaban',
'assistant.card.collection.skipped.one': '{count} ya estaba',
'assistant.card.collection.willSkip': '{count} ya en el viaje',
'assistant.card.collection.willSkip.one': '{count} ya en el viaje',
'assistant.card.collection.otherTrip': 'A otro viaje',
'assistant.card.collection.everyList': 'En cada lista donde está guardado',
'assistant.card.collection.ratingCleared': 'Valoración quitada',
'assistant.card.collection.moveTo': 'Mover a {name}',
'assistant.card.collection.removed': 'Quitado de la lista',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.places': 'Lugares guardados en {name}',
'assistant.card.collection.saved': 'Lugares guardados',
'assistant.card.collection.statusSet': '{count} lugares guardados cambiados',
'assistant.card.collection.statusSet.one': '{count} lugar guardado cambiado',
'assistant.tool.create_collection': 'Crear una lista',
'assistant.tool.update_collection': 'Cambiar la lista',
'assistant.tool.delete_collection': 'Eliminar la lista',
'assistant.tool.reorder_collections': 'Reordenar tus listas',
'assistant.tool.save_place_to_collection': 'Guardar en una lista',
'assistant.tool.save_trip_places_to_collection': 'Guardar lugares del viaje en una lista',
'assistant.tool.update_collection_place': 'Cambiar el lugar guardado',
'assistant.tool.set_collection_place_status': 'Fijar el estado',
'assistant.tool.set_collection_place_status_from_trip': 'Fijar el estado en tus listas',
'assistant.tool.rate_collection_place': 'Valorar el lugar guardado',
'assistant.tool.delete_collection_place': 'Quitar de la lista',
'assistant.tool.copy_collection_places_to_trip': 'Añadir lugares guardados al viaje',
'assistant.tool.create_collection_label': 'Añadir una etiqueta',
'assistant.tool.update_collection_label': 'Cambiar la etiqueta',
'assistant.tool.delete_collection_label': 'Eliminar la etiqueta',
'assistant.tool.assign_collection_labels': 'Cambiar las etiquetas',
'assistant.tool.accept_collection_invite': 'Unirse a la lista',
'assistant.tool.decline_collection_invite': 'Rechazar la invitación',
'assistant.tool.cancel_collection_invite': 'Retirar la invitación',
'assistant.step.readingList': 'Leyendo tu lista',
'assistant.step.lookingAtLists': 'Mirando tus listas',
'assistant.step.checkingLists': 'Revisando tus listas',
'assistant.followUp.collectionToTrip': 'Añade a este viaje los lugares a los que quiero ir',
'assistant.followUp.collectionDay': 'Planifica un día en torno a esta lista',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Ava nimekiri',
'assistant.card.collection.openPlace': 'Ava kogumikes',
'assistant.card.collection.moreInList': 'Veel {count} selles nimekirjas',
'assistant.card.collection.moreInList.one': 'Veel {count} selles nimekirjas',
'assistant.card.collection.members': '{count} liiget',
'assistant.card.collection.members.one': '{count} liige',
'assistant.card.collection.lists': 'Sinu nimekirjad',
'assistant.card.collection.savedIn': 'Salvestatud {count} nimekirja',
'assistant.card.collection.savedIn.one': 'Salvestatud {count} nimekirja',
'assistant.card.collection.alreadySaved': 'Juba nimekirjas {name}',
'assistant.card.collection.invite': 'Kutse kasutajalt {name}',
'assistant.card.collection.withdraw': 'Kutse kasutajale {name}',
'assistant.card.collection.labelUsedBy': '{count} kohal',
'assistant.card.collection.labelUsedBy.one': '{count} kohal',
'assistant.card.collection.copied': '{count} lisatud',
'assistant.card.collection.copied.one': '{count} lisatud',
'assistant.card.collection.skipped': '{count} oli juba olemas',
'assistant.card.collection.skipped.one': '{count} oli juba olemas',
'assistant.card.collection.willSkip': '{count} juba reisil',
'assistant.card.collection.willSkip.one': '{count} juba reisil',
'assistant.card.collection.otherTrip': 'Teisele reisile',
'assistant.card.collection.everyList': 'Igas nimekirjas, kuhu see on salvestatud',
'assistant.card.collection.ratingCleared': 'Hinnang eemaldatud',
'assistant.card.collection.moveTo': 'Teisalda nimekirja {name}',
'assistant.card.collection.removed': 'Nimekirjast eemaldatud',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.places': 'Salvestatud kohad nimekirjas {name}',
'assistant.card.collection.saved': 'Salvestatud kohad',
'assistant.card.collection.statusSet': '{count} salvestatud kohta muudetud',
'assistant.card.collection.statusSet.one': '{count} salvestatud koht muudetud',
'assistant.tool.create_collection': 'Loo nimekiri',
'assistant.tool.update_collection': 'Muuda nimekirja',
'assistant.tool.delete_collection': 'Kustuta nimekiri',
'assistant.tool.reorder_collections': 'Järjesta nimekirjad ümber',
'assistant.tool.save_place_to_collection': 'Salvesta nimekirja',
'assistant.tool.save_trip_places_to_collection': 'Salvesta reisi kohad nimekirja',
'assistant.tool.update_collection_place': 'Muuda salvestatud kohta',
'assistant.tool.set_collection_place_status': 'Määra olek',
'assistant.tool.set_collection_place_status_from_trip': 'Määra olek sinu nimekirjades',
'assistant.tool.rate_collection_place': 'Hinda salvestatud kohta',
'assistant.tool.delete_collection_place': 'Eemalda nimekirjast',
'assistant.tool.copy_collection_places_to_trip': 'Lisa salvestatud kohad reisile',
'assistant.tool.create_collection_label': 'Lisa silt',
'assistant.tool.update_collection_label': 'Muuda silti',
'assistant.tool.delete_collection_label': 'Kustuta silt',
'assistant.tool.assign_collection_labels': 'Muuda silte',
'assistant.tool.accept_collection_invite': 'Liitu nimekirjaga',
'assistant.tool.decline_collection_invite': 'Keeldu kutsest',
'assistant.tool.cancel_collection_invite': 'Võta kutse tagasi',
'assistant.step.readingList': 'Loen sinu nimekirja',
'assistant.step.lookingAtLists': 'Vaatan sinu nimekirju',
'assistant.step.checkingLists': 'Kontrollin sinu nimekirju',
'assistant.followUp.collectionToTrip': 'Lisa sellele reisile kohad, kuhu ma tahan minna',
'assistant.followUp.collectionDay': 'Planeeri päev selle nimekirja ümber',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Ouvrir la liste',
'assistant.card.collection.openPlace': 'Ouvrir dans Collections',
'assistant.card.collection.moreInList': '{count} autres dans cette liste',
'assistant.card.collection.moreInList.one': '{count} autre dans cette liste',
'assistant.card.collection.members': '{count} membres',
'assistant.card.collection.members.one': '{count} membre',
'assistant.card.collection.lists': 'Vos listes',
'assistant.card.collection.savedIn': 'Enregistré dans {count} listes',
'assistant.card.collection.savedIn.one': 'Enregistré dans {count} liste',
'assistant.card.collection.alreadySaved': 'Déjà dans {name}',
'assistant.card.collection.invite': 'Invitation de {name}',
'assistant.card.collection.withdraw': 'Invitation à {name}',
'assistant.card.collection.labelUsedBy': 'Sur {count} lieux',
'assistant.card.collection.labelUsedBy.one': 'Sur {count} lieu',
'assistant.card.collection.copied': '{count} ajoutés',
'assistant.card.collection.copied.one': '{count} ajouté',
'assistant.card.collection.skipped': '{count} déjà présents',
'assistant.card.collection.skipped.one': '{count} déjà présent',
'assistant.card.collection.willSkip': '{count} déjà dans le voyage',
'assistant.card.collection.willSkip.one': '{count} déjà dans le voyage',
'assistant.card.collection.otherTrip': 'Dans un autre voyage',
'assistant.card.collection.everyList': 'Dans chaque liste où il est enregistré',
'assistant.card.collection.ratingCleared': 'Note retirée',
'assistant.card.collection.moveTo': 'Déplacer vers {name}',
'assistant.card.collection.removed': 'Retiré de la liste',
'assistant.card.collection.photo': 'Photo', // same-as-en
'assistant.card.collection.places': 'Lieux enregistrés dans {name}',
'assistant.card.collection.saved': 'Lieux enregistrés',
'assistant.card.collection.statusSet': '{count} lieux enregistrés modifiés',
'assistant.card.collection.statusSet.one': '{count} lieu enregistré modifié',
'assistant.tool.create_collection': 'Créer une liste',
'assistant.tool.update_collection': 'Modifier la liste',
'assistant.tool.delete_collection': 'Supprimer la liste',
'assistant.tool.reorder_collections': 'Réordonner vos listes',
'assistant.tool.save_place_to_collection': 'Enregistrer dans une liste',
'assistant.tool.save_trip_places_to_collection': 'Enregistrer des lieux du voyage dans une liste',
'assistant.tool.update_collection_place': 'Modifier le lieu enregistré',
'assistant.tool.set_collection_place_status': 'Définir le statut',
'assistant.tool.set_collection_place_status_from_trip': 'Définir le statut dans vos listes',
'assistant.tool.rate_collection_place': 'Noter le lieu enregistré',
'assistant.tool.delete_collection_place': 'Retirer de la liste',
'assistant.tool.copy_collection_places_to_trip': 'Ajouter des lieux enregistrés au voyage',
'assistant.tool.create_collection_label': 'Ajouter une étiquette',
'assistant.tool.update_collection_label': "Modifier l'étiquette",
'assistant.tool.delete_collection_label': "Supprimer l'étiquette",
'assistant.tool.assign_collection_labels': 'Modifier les étiquettes',
'assistant.tool.accept_collection_invite': 'Rejoindre la liste',
'assistant.tool.decline_collection_invite': "Refuser l'invitation",
'assistant.tool.cancel_collection_invite': "Retirer l'invitation",
'assistant.step.readingList': 'Lecture de votre liste',
'assistant.step.lookingAtLists': 'Consultation de vos listes',
'assistant.step.checkingLists': 'Vérification de vos listes',
'assistant.followUp.collectionToTrip': 'Ajoute à ce voyage les lieux que je veux visiter',
'assistant.followUp.collectionDay': 'Planifie une journée autour de cette liste',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Άνοιγμα λίστας',
'assistant.card.collection.openPlace': 'Άνοιγμα στις Συλλογές',
'assistant.card.collection.moreInList': 'Ακόμη {count} σε αυτή τη λίστα',
'assistant.card.collection.moreInList.one': 'Ακόμη {count} σε αυτή τη λίστα',
'assistant.card.collection.members': '{count} μέλη',
'assistant.card.collection.members.one': '{count} μέλος',
'assistant.card.collection.lists': 'Οι λίστες σας',
'assistant.card.collection.savedIn': 'Αποθηκευμένο σε {count} λίστες',
'assistant.card.collection.savedIn.one': 'Αποθηκευμένο σε {count} λίστα',
'assistant.card.collection.alreadySaved': 'Ήδη στη λίστα {name}',
'assistant.card.collection.invite': 'Πρόσκληση από {name}',
'assistant.card.collection.withdraw': 'Πρόσκληση προς {name}',
'assistant.card.collection.labelUsedBy': 'Σε {count} μέρη',
'assistant.card.collection.labelUsedBy.one': 'Σε {count} μέρος',
'assistant.card.collection.copied': '{count} προστέθηκαν',
'assistant.card.collection.copied.one': '{count} προστέθηκε',
'assistant.card.collection.skipped': '{count} υπήρχαν ήδη',
'assistant.card.collection.skipped.one': '{count} υπήρχε ήδη',
'assistant.card.collection.willSkip': '{count} ήδη στο ταξίδι',
'assistant.card.collection.willSkip.one': '{count} ήδη στο ταξίδι',
'assistant.card.collection.otherTrip': 'Σε άλλο ταξίδι',
'assistant.card.collection.everyList': 'Σε κάθε λίστα όπου είναι αποθηκευμένο',
'assistant.card.collection.ratingCleared': 'Η βαθμολογία αφαιρέθηκε',
'assistant.card.collection.moveTo': 'Μετακίνηση στη λίστα {name}',
'assistant.card.collection.removed': 'Αφαιρέθηκε από τη λίστα',
'assistant.card.collection.photo': 'Φωτογραφία',
'assistant.card.collection.places': 'Αποθηκευμένα μέρη στη λίστα {name}',
'assistant.card.collection.saved': 'Αποθηκευμένα μέρη',
'assistant.card.collection.statusSet': '{count} αποθηκευμένα μέρη άλλαξαν',
'assistant.card.collection.statusSet.one': '{count} αποθηκευμένο μέρος άλλαξε',
'assistant.tool.create_collection': 'Δημιουργία λίστας',
'assistant.tool.update_collection': 'Αλλαγή λίστας',
'assistant.tool.delete_collection': 'Διαγραφή λίστας',
'assistant.tool.reorder_collections': 'Αναδιάταξη λιστών',
'assistant.tool.save_place_to_collection': 'Αποθήκευση σε λίστα',
'assistant.tool.save_trip_places_to_collection': 'Αποθήκευση μερών του ταξιδιού σε λίστα',
'assistant.tool.update_collection_place': 'Αλλαγή αποθηκευμένου μέρους',
'assistant.tool.set_collection_place_status': 'Ορισμός κατάστασης',
'assistant.tool.set_collection_place_status_from_trip': 'Ορισμός κατάστασης στις λίστες σας',
'assistant.tool.rate_collection_place': 'Βαθμολόγηση αποθηκευμένου μέρους',
'assistant.tool.delete_collection_place': 'Αφαίρεση από τη λίστα',
'assistant.tool.copy_collection_places_to_trip': 'Προσθήκη αποθηκευμένων μερών στο ταξίδι',
'assistant.tool.create_collection_label': 'Προσθήκη ετικέτας',
'assistant.tool.update_collection_label': 'Αλλαγή ετικέτας',
'assistant.tool.delete_collection_label': 'Διαγραφή ετικέτας',
'assistant.tool.assign_collection_labels': 'Αλλαγή ετικετών',
'assistant.tool.accept_collection_invite': 'Συμμετοχή στη λίστα',
'assistant.tool.decline_collection_invite': 'Απόρριψη πρόσκλησης',
'assistant.tool.cancel_collection_invite': 'Ανάκληση πρόσκλησης',
'assistant.step.readingList': 'Διαβάζω τη λίστα σας',
'assistant.step.lookingAtLists': 'Κοιτάζω τις λίστες σας',
'assistant.step.checkingLists': 'Ελέγχω τις λίστες σας',
'assistant.followUp.collectionToTrip': 'Πρόσθεσε σε αυτό το ταξίδι τα μέρη όπου θέλω να πάω',
'assistant.followUp.collectionDay': 'Σχεδίασε μια μέρα γύρω από αυτή τη λίστα',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Lista megnyitása',
'assistant.card.collection.openPlace': 'Megnyitás a Gyűjteményekben',
'assistant.card.collection.moreInList': 'Még {count} ebben a listában',
'assistant.card.collection.moreInList.one': 'Még {count} ebben a listában',
'assistant.card.collection.members': '{count} tag',
'assistant.card.collection.members.one': '{count} tag',
'assistant.card.collection.lists': 'A listáid',
'assistant.card.collection.savedIn': '{count} listában mentve',
'assistant.card.collection.savedIn.one': '{count} listában mentve',
'assistant.card.collection.alreadySaved': 'Már itt van: {name}',
'assistant.card.collection.invite': 'Meghívó tőle: {name}',
'assistant.card.collection.withdraw': 'Meghívó neki: {name}',
'assistant.card.collection.labelUsedBy': '{count} helyen',
'assistant.card.collection.labelUsedBy.one': '{count} helyen',
'assistant.card.collection.copied': '{count} hozzáadva',
'assistant.card.collection.copied.one': '{count} hozzáadva',
'assistant.card.collection.skipped': '{count} már megvolt',
'assistant.card.collection.skipped.one': '{count} már megvolt',
'assistant.card.collection.willSkip': '{count} már az utazásban',
'assistant.card.collection.willSkip.one': '{count} már az utazásban',
'assistant.card.collection.otherTrip': 'Egy másik utazásba',
'assistant.card.collection.everyList': 'Minden listában, ahol el van mentve',
'assistant.card.collection.ratingCleared': 'Értékelés törölve',
'assistant.card.collection.moveTo': 'Áthelyezés ide: {name}',
'assistant.card.collection.removed': 'Eltávolítva a listából',
'assistant.card.collection.photo': 'Fotó',
'assistant.card.collection.places': 'Mentett helyek itt: {name}',
'assistant.card.collection.saved': 'Mentett helyek',
'assistant.card.collection.statusSet': '{count} mentett hely módosítva',
'assistant.card.collection.statusSet.one': '{count} mentett hely módosítva',
'assistant.tool.create_collection': 'Lista létrehozása',
'assistant.tool.update_collection': 'Lista módosítása',
'assistant.tool.delete_collection': 'Lista törlése',
'assistant.tool.reorder_collections': 'Listák átrendezése',
'assistant.tool.save_place_to_collection': 'Mentés egy listába',
'assistant.tool.save_trip_places_to_collection': 'Az utazás helyeinek mentése egy listába',
'assistant.tool.update_collection_place': 'Mentett hely módosítása',
'assistant.tool.set_collection_place_status': 'Állapot beállítása',
'assistant.tool.set_collection_place_status_from_trip': 'Állapot beállítása a listáidban',
'assistant.tool.rate_collection_place': 'Mentett hely értékelése',
'assistant.tool.delete_collection_place': 'Eltávolítás a listából',
'assistant.tool.copy_collection_places_to_trip': 'Mentett helyek hozzáadása az utazáshoz',
'assistant.tool.create_collection_label': 'Címke hozzáadása',
'assistant.tool.update_collection_label': 'Címke módosítása',
'assistant.tool.delete_collection_label': 'Címke törlése',
'assistant.tool.assign_collection_labels': 'Címkék módosítása',
'assistant.tool.accept_collection_invite': 'Csatlakozás a listához',
'assistant.tool.decline_collection_invite': 'Meghívó elutasítása',
'assistant.tool.cancel_collection_invite': 'Meghívó visszavonása',
'assistant.step.readingList': 'A listád olvasása',
'assistant.step.lookingAtLists': 'A listáid átnézése',
'assistant.step.checkingLists': 'A listáid ellenőrzése',
'assistant.followUp.collectionToTrip': 'Add hozzá ehhez az utazáshoz azokat a helyeket, ahová menni szeretnék',
'assistant.followUp.collectionDay': 'Tervezz egy napot e lista köré',
};
export default assistantCollection;
+48 -1
View File
@@ -1,4 +1,51 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Buka daftar',
'assistant.card.collection.openPlace': 'Buka di Koleksi',
'assistant.card.collection.moreInList': '{count} lagi di daftar ini',
'assistant.card.collection.members': '{count} anggota',
'assistant.card.collection.lists': 'Daftarmu',
'assistant.card.collection.savedIn': 'Tersimpan di {count} daftar',
'assistant.card.collection.alreadySaved': 'Sudah ada di {name}',
'assistant.card.collection.invite': 'Undangan dari {name}',
'assistant.card.collection.withdraw': 'Undangan untuk {name}',
'assistant.card.collection.labelUsedBy': 'Di {count} tempat',
'assistant.card.collection.copied': '{count} ditambahkan',
'assistant.card.collection.skipped': '{count} sudah ada',
'assistant.card.collection.willSkip': '{count} sudah ada di perjalanan',
'assistant.card.collection.otherTrip': 'Ke perjalanan lain',
'assistant.card.collection.everyList': 'Di setiap daftar tempat ia tersimpan',
'assistant.card.collection.ratingCleared': 'Penilaian dihapus',
'assistant.card.collection.moveTo': 'Pindahkan ke {name}',
'assistant.card.collection.removed': 'Dihapus dari daftar',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.places': 'Tempat tersimpan di {name}',
'assistant.card.collection.saved': 'Tempat tersimpan',
'assistant.card.collection.statusSet': '{count} tempat tersimpan diubah',
'assistant.tool.create_collection': 'Buat daftar',
'assistant.tool.update_collection': 'Ubah daftar',
'assistant.tool.delete_collection': 'Hapus daftar',
'assistant.tool.reorder_collections': 'Atur ulang daftarmu',
'assistant.tool.save_place_to_collection': 'Simpan ke daftar',
'assistant.tool.save_trip_places_to_collection': 'Simpan tempat perjalanan ke daftar',
'assistant.tool.update_collection_place': 'Ubah tempat tersimpan',
'assistant.tool.set_collection_place_status': 'Atur status',
'assistant.tool.set_collection_place_status_from_trip': 'Atur status di daftarmu',
'assistant.tool.rate_collection_place': 'Nilai tempat tersimpan',
'assistant.tool.delete_collection_place': 'Hapus dari daftar',
'assistant.tool.copy_collection_places_to_trip': 'Tambahkan tempat tersimpan ke perjalanan',
'assistant.tool.create_collection_label': 'Tambah label',
'assistant.tool.update_collection_label': 'Ubah label',
'assistant.tool.delete_collection_label': 'Hapus label',
'assistant.tool.assign_collection_labels': 'Ubah label-label',
'assistant.tool.accept_collection_invite': 'Gabung ke daftar',
'assistant.tool.decline_collection_invite': 'Tolak undangan',
'assistant.tool.cancel_collection_invite': 'Tarik undangan',
'assistant.step.readingList': 'Membaca daftarmu',
'assistant.step.lookingAtLists': 'Melihat daftar-daftarmu',
'assistant.step.checkingLists': 'Memeriksa daftar-daftarmu',
'assistant.followUp.collectionToTrip': 'Tambahkan tempat yang ingin kukunjungi ke perjalanan ini',
'assistant.followUp.collectionDay': 'Rencanakan satu hari di sekitar daftar ini',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Apri la lista',
'assistant.card.collection.openPlace': 'Apri in Raccolte',
'assistant.card.collection.moreInList': 'Altri {count} in questa lista',
'assistant.card.collection.moreInList.one': 'Altro {count} in questa lista',
'assistant.card.collection.members': '{count} membri',
'assistant.card.collection.members.one': '{count} membro',
'assistant.card.collection.lists': 'Le tue liste',
'assistant.card.collection.savedIn': 'Salvato in {count} liste',
'assistant.card.collection.savedIn.one': 'Salvato in {count} lista',
'assistant.card.collection.alreadySaved': 'Già in {name}',
'assistant.card.collection.invite': 'Invito da {name}',
'assistant.card.collection.withdraw': 'Invito a {name}',
'assistant.card.collection.labelUsedBy': 'Su {count} luoghi',
'assistant.card.collection.labelUsedBy.one': 'Su {count} luogo',
'assistant.card.collection.copied': '{count} aggiunti',
'assistant.card.collection.copied.one': '{count} aggiunto',
'assistant.card.collection.skipped': '{count} già presenti',
'assistant.card.collection.skipped.one': '{count} già presente',
'assistant.card.collection.willSkip': '{count} già nel viaggio',
'assistant.card.collection.willSkip.one': '{count} già nel viaggio',
'assistant.card.collection.otherTrip': 'In un altro viaggio',
'assistant.card.collection.everyList': 'In ogni lista in cui è salvato',
'assistant.card.collection.ratingCleared': 'Valutazione rimossa',
'assistant.card.collection.moveTo': 'Sposta in {name}',
'assistant.card.collection.removed': 'Rimosso dalla lista',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.places': 'Luoghi salvati in {name}',
'assistant.card.collection.saved': 'Luoghi salvati',
'assistant.card.collection.statusSet': '{count} luoghi salvati modificati',
'assistant.card.collection.statusSet.one': '{count} luogo salvato modificato',
'assistant.tool.create_collection': 'Crea una lista',
'assistant.tool.update_collection': 'Modifica la lista',
'assistant.tool.delete_collection': 'Elimina la lista',
'assistant.tool.reorder_collections': 'Riordina le tue liste',
'assistant.tool.save_place_to_collection': 'Salva in una lista',
'assistant.tool.save_trip_places_to_collection': 'Salva luoghi del viaggio in una lista',
'assistant.tool.update_collection_place': 'Modifica il luogo salvato',
'assistant.tool.set_collection_place_status': 'Imposta lo stato',
'assistant.tool.set_collection_place_status_from_trip': 'Imposta lo stato nelle tue liste',
'assistant.tool.rate_collection_place': 'Valuta il luogo salvato',
'assistant.tool.delete_collection_place': 'Rimuovi dalla lista',
'assistant.tool.copy_collection_places_to_trip': 'Aggiungi luoghi salvati al viaggio',
'assistant.tool.create_collection_label': "Aggiungi un'etichetta",
'assistant.tool.update_collection_label': "Modifica l'etichetta",
'assistant.tool.delete_collection_label': "Elimina l'etichetta",
'assistant.tool.assign_collection_labels': 'Modifica le etichette',
'assistant.tool.accept_collection_invite': 'Unisciti alla lista',
'assistant.tool.decline_collection_invite': "Rifiuta l'invito",
'assistant.tool.cancel_collection_invite': "Ritira l'invito",
'assistant.step.readingList': 'Lettura della tua lista',
'assistant.step.lookingAtLists': 'Consultazione delle tue liste',
'assistant.step.checkingLists': 'Controllo delle tue liste',
'assistant.followUp.collectionToTrip': 'Aggiungi a questo viaggio i luoghi che voglio visitare',
'assistant.followUp.collectionDay': 'Pianifica una giornata attorno a questa lista',
};
export default assistantCollection;
+48 -1
View File
@@ -1,4 +1,51 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'リストを開く',
'assistant.card.collection.openPlace': 'コレクションで開く',
'assistant.card.collection.moreInList': 'このリストにあと {count} 件',
'assistant.card.collection.members': 'メンバー {count} 人',
'assistant.card.collection.lists': 'あなたのリスト',
'assistant.card.collection.savedIn': '{count} 件のリストに保存済み',
'assistant.card.collection.alreadySaved': '{name} に保存済み',
'assistant.card.collection.invite': '{name} さんからの招待',
'assistant.card.collection.withdraw': '{name} さんへの招待',
'assistant.card.collection.labelUsedBy': '{count} 件の場所に付いています',
'assistant.card.collection.copied': '{count} 件追加',
'assistant.card.collection.skipped': '{count} 件は追加済み',
'assistant.card.collection.willSkip': '{count} 件はすでに旅行に含まれています',
'assistant.card.collection.otherTrip': '別の旅行へ',
'assistant.card.collection.everyList': '保存されているすべてのリストで',
'assistant.card.collection.ratingCleared': '評価を削除しました',
'assistant.card.collection.moveTo': '{name} に移動',
'assistant.card.collection.removed': 'リストから削除しました',
'assistant.card.collection.photo': '写真',
'assistant.card.collection.places': '{name} の保存した場所',
'assistant.card.collection.saved': '保存した場所',
'assistant.card.collection.statusSet': '保存した場所 {count} 件を変更',
'assistant.tool.create_collection': 'リストを作成',
'assistant.tool.update_collection': 'リストを変更',
'assistant.tool.delete_collection': 'リストを削除',
'assistant.tool.reorder_collections': 'リストを並べ替え',
'assistant.tool.save_place_to_collection': 'リストに保存',
'assistant.tool.save_trip_places_to_collection': '旅行の場所をリストに保存',
'assistant.tool.update_collection_place': '保存した場所を変更',
'assistant.tool.set_collection_place_status': 'ステータスを設定',
'assistant.tool.set_collection_place_status_from_trip': 'リストでステータスを設定',
'assistant.tool.rate_collection_place': '保存した場所を評価',
'assistant.tool.delete_collection_place': 'リストから削除',
'assistant.tool.copy_collection_places_to_trip': '保存した場所を旅行に追加',
'assistant.tool.create_collection_label': 'ラベルを追加',
'assistant.tool.update_collection_label': 'ラベルを変更',
'assistant.tool.delete_collection_label': 'ラベルを削除',
'assistant.tool.assign_collection_labels': 'ラベルを変更',
'assistant.tool.accept_collection_invite': 'リストに参加',
'assistant.tool.decline_collection_invite': '招待を断る',
'assistant.tool.cancel_collection_invite': '招待を取り消す',
'assistant.step.readingList': 'リストを読み込み中',
'assistant.step.lookingAtLists': 'リストを確認中',
'assistant.step.checkingLists': 'リストをチェック中',
'assistant.followUp.collectionToTrip': '行きたい場所をこの旅行に追加して',
'assistant.followUp.collectionDay': 'このリストを中心に一日を計画して',
};
export default assistantCollection;
+48 -1
View File
@@ -1,4 +1,51 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': '목록 열기',
'assistant.card.collection.openPlace': '컬렉션에서 열기',
'assistant.card.collection.moreInList': '이 목록에 {count}개 더',
'assistant.card.collection.members': '멤버 {count}명',
'assistant.card.collection.lists': '내 목록',
'assistant.card.collection.savedIn': '{count}개 목록에 저장됨',
'assistant.card.collection.alreadySaved': '이미 {name}에 있음',
'assistant.card.collection.invite': '{name} 님의 초대',
'assistant.card.collection.withdraw': '{name} 님에게 보낸 초대',
'assistant.card.collection.labelUsedBy': '장소 {count}곳에 사용',
'assistant.card.collection.copied': '{count}개 추가됨',
'assistant.card.collection.skipped': '{count}개는 이미 있음',
'assistant.card.collection.willSkip': '{count}개는 이미 여행에 있음',
'assistant.card.collection.otherTrip': '다른 여행으로',
'assistant.card.collection.everyList': '저장된 모든 목록에서',
'assistant.card.collection.ratingCleared': '평가 삭제됨',
'assistant.card.collection.moveTo': '{name}(으)로 이동',
'assistant.card.collection.removed': '목록에서 제거됨',
'assistant.card.collection.photo': '사진',
'assistant.card.collection.places': '{name}에 저장한 장소',
'assistant.card.collection.saved': '저장한 장소',
'assistant.card.collection.statusSet': '저장한 장소 {count}곳 변경됨',
'assistant.tool.create_collection': '목록 만들기',
'assistant.tool.update_collection': '목록 변경',
'assistant.tool.delete_collection': '목록 삭제',
'assistant.tool.reorder_collections': '목록 순서 바꾸기',
'assistant.tool.save_place_to_collection': '목록에 저장',
'assistant.tool.save_trip_places_to_collection': '여행 장소를 목록에 저장',
'assistant.tool.update_collection_place': '저장한 장소 변경',
'assistant.tool.set_collection_place_status': '상태 설정',
'assistant.tool.set_collection_place_status_from_trip': '내 목록에서 상태 설정',
'assistant.tool.rate_collection_place': '저장한 장소 평가',
'assistant.tool.delete_collection_place': '목록에서 제거',
'assistant.tool.copy_collection_places_to_trip': '저장한 장소를 여행에 추가',
'assistant.tool.create_collection_label': '라벨 추가',
'assistant.tool.update_collection_label': '라벨 변경',
'assistant.tool.delete_collection_label': '라벨 삭제',
'assistant.tool.assign_collection_labels': '라벨 변경하기',
'assistant.tool.accept_collection_invite': '목록에 참여',
'assistant.tool.decline_collection_invite': '초대 거절',
'assistant.tool.cancel_collection_invite': '초대 철회',
'assistant.step.readingList': '목록을 읽는 중',
'assistant.step.lookingAtLists': '목록을 살펴보는 중',
'assistant.step.checkingLists': '목록을 확인하는 중',
'assistant.followUp.collectionToTrip': '가고 싶은 장소를 이 여행에 추가해 줘',
'assistant.followUp.collectionDay': '이 목록을 중심으로 하루를 계획해 줘',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Lijst openen',
'assistant.card.collection.openPlace': 'Openen in Collecties',
'assistant.card.collection.moreInList': 'Nog {count} in deze lijst',
'assistant.card.collection.moreInList.one': 'Nog {count} in deze lijst',
'assistant.card.collection.members': '{count} leden',
'assistant.card.collection.members.one': '{count} lid',
'assistant.card.collection.lists': 'Je lijsten',
'assistant.card.collection.savedIn': 'Bewaard in {count} lijsten',
'assistant.card.collection.savedIn.one': 'Bewaard in {count} lijst',
'assistant.card.collection.alreadySaved': 'Al in {name}',
'assistant.card.collection.invite': 'Uitnodiging van {name}',
'assistant.card.collection.withdraw': 'Uitnodiging aan {name}',
'assistant.card.collection.labelUsedBy': 'Op {count} plaatsen',
'assistant.card.collection.labelUsedBy.one': 'Op {count} plaats',
'assistant.card.collection.copied': '{count} toegevoegd',
'assistant.card.collection.copied.one': '{count} toegevoegd',
'assistant.card.collection.skipped': '{count} waren er al',
'assistant.card.collection.skipped.one': '{count} was er al',
'assistant.card.collection.willSkip': '{count} al in de reis',
'assistant.card.collection.willSkip.one': '{count} al in de reis',
'assistant.card.collection.otherTrip': 'Naar een andere reis',
'assistant.card.collection.everyList': 'In elke lijst waarin hij bewaard is',
'assistant.card.collection.ratingCleared': 'Beoordeling verwijderd',
'assistant.card.collection.moveTo': 'Verplaatsen naar {name}',
'assistant.card.collection.removed': 'Uit de lijst verwijderd',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.places': 'Bewaarde plaatsen in {name}',
'assistant.card.collection.saved': 'Bewaarde plaatsen',
'assistant.card.collection.statusSet': '{count} bewaarde plaatsen gewijzigd',
'assistant.card.collection.statusSet.one': '{count} bewaarde plaats gewijzigd',
'assistant.tool.create_collection': 'Lijst maken',
'assistant.tool.update_collection': 'Lijst wijzigen',
'assistant.tool.delete_collection': 'Lijst verwijderen',
'assistant.tool.reorder_collections': 'Je lijsten herschikken',
'assistant.tool.save_place_to_collection': 'In een lijst bewaren',
'assistant.tool.save_trip_places_to_collection': 'Plaatsen van de reis in een lijst bewaren',
'assistant.tool.update_collection_place': 'Bewaarde plaats wijzigen',
'assistant.tool.set_collection_place_status': 'Status instellen',
'assistant.tool.set_collection_place_status_from_trip': 'Status in je lijsten instellen',
'assistant.tool.rate_collection_place': 'Bewaarde plaats beoordelen',
'assistant.tool.delete_collection_place': 'Uit de lijst verwijderen',
'assistant.tool.copy_collection_places_to_trip': 'Bewaarde plaatsen aan de reis toevoegen',
'assistant.tool.create_collection_label': 'Label toevoegen',
'assistant.tool.update_collection_label': 'Label wijzigen',
'assistant.tool.delete_collection_label': 'Label verwijderen',
'assistant.tool.assign_collection_labels': 'Labels wijzigen',
'assistant.tool.accept_collection_invite': 'Lid worden van de lijst',
'assistant.tool.decline_collection_invite': 'Uitnodiging weigeren',
'assistant.tool.cancel_collection_invite': 'Uitnodiging intrekken',
'assistant.step.readingList': 'Je lijst lezen',
'assistant.step.lookingAtLists': 'Je lijsten bekijken',
'assistant.step.checkingLists': 'Je lijsten nakijken',
'assistant.followUp.collectionToTrip': 'Voeg de plaatsen waar ik heen wil toe aan deze reis',
'assistant.followUp.collectionDay': 'Plan een dag rond deze lijst',
};
export default assistantCollection;
+72 -1
View File
@@ -1,4 +1,75 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Otwórz listę',
'assistant.card.collection.openPlace': 'Otwórz w Kolekcjach',
'assistant.card.collection.moreInList': 'Jeszcze {count} na tej liście',
'assistant.card.collection.moreInList.one': 'Jeszcze {count} na tej liście',
'assistant.card.collection.moreInList.few': 'Jeszcze {count} na tej liście',
'assistant.card.collection.moreInList.many': 'Jeszcze {count} na tej liście',
'assistant.card.collection.members': '{count} członka',
'assistant.card.collection.members.one': '{count} członek',
'assistant.card.collection.members.few': '{count} członkowie',
'assistant.card.collection.members.many': '{count} członków',
'assistant.card.collection.lists': 'Twoje listy',
'assistant.card.collection.savedIn': 'Zapisano na {count} listy',
'assistant.card.collection.savedIn.one': 'Zapisano na {count} liście',
'assistant.card.collection.savedIn.few': 'Zapisano na {count} listach',
'assistant.card.collection.savedIn.many': 'Zapisano na {count} listach',
'assistant.card.collection.alreadySaved': 'Już na liście {name}',
'assistant.card.collection.invite': 'Zaproszenie od {name}',
'assistant.card.collection.withdraw': 'Zaproszenie dla {name}',
'assistant.card.collection.labelUsedBy': 'Na {count} miejsca',
'assistant.card.collection.labelUsedBy.one': 'Na {count} miejscu',
'assistant.card.collection.labelUsedBy.few': 'Na {count} miejscach',
'assistant.card.collection.labelUsedBy.many': 'Na {count} miejscach',
'assistant.card.collection.copied': 'Dodano {count}',
'assistant.card.collection.copied.one': 'Dodano {count}',
'assistant.card.collection.copied.few': 'Dodano {count}',
'assistant.card.collection.copied.many': 'Dodano {count}',
'assistant.card.collection.skipped': '{count} już było',
'assistant.card.collection.skipped.one': '{count} już było',
'assistant.card.collection.skipped.few': '{count} już były',
'assistant.card.collection.skipped.many': '{count} już było',
'assistant.card.collection.willSkip': '{count} już w podróży',
'assistant.card.collection.willSkip.one': '{count} już w podróży',
'assistant.card.collection.willSkip.few': '{count} już w podróży',
'assistant.card.collection.willSkip.many': '{count} już w podróży',
'assistant.card.collection.otherTrip': 'Do innej podróży',
'assistant.card.collection.everyList': 'Na każdej liście, na której jest zapisane',
'assistant.card.collection.ratingCleared': 'Ocena usunięta',
'assistant.card.collection.moveTo': 'Przenieś do {name}',
'assistant.card.collection.removed': 'Usunięto z listy',
'assistant.card.collection.photo': 'Zdjęcie',
'assistant.card.collection.places': 'Zapisane miejsca na liście {name}',
'assistant.card.collection.saved': 'Zapisane miejsca',
'assistant.card.collection.statusSet': 'Zmieniono {count} zapisanego miejsca',
'assistant.card.collection.statusSet.one': 'Zmieniono {count} zapisane miejsce',
'assistant.card.collection.statusSet.few': 'Zmieniono {count} zapisane miejsca',
'assistant.card.collection.statusSet.many': 'Zmieniono {count} zapisanych miejsc',
'assistant.tool.create_collection': 'Utwórz listę',
'assistant.tool.update_collection': 'Zmień listę',
'assistant.tool.delete_collection': 'Usuń listę',
'assistant.tool.reorder_collections': 'Zmień kolejność list',
'assistant.tool.save_place_to_collection': 'Zapisz na liście',
'assistant.tool.save_trip_places_to_collection': 'Zapisz miejsca z podróży na liście',
'assistant.tool.update_collection_place': 'Zmień zapisane miejsce',
'assistant.tool.set_collection_place_status': 'Ustaw status',
'assistant.tool.set_collection_place_status_from_trip': 'Ustaw status na twoich listach',
'assistant.tool.rate_collection_place': 'Oceń zapisane miejsce',
'assistant.tool.delete_collection_place': 'Usuń z listy',
'assistant.tool.copy_collection_places_to_trip': 'Dodaj zapisane miejsca do podróży',
'assistant.tool.create_collection_label': 'Dodaj etykietę',
'assistant.tool.update_collection_label': 'Zmień etykietę',
'assistant.tool.delete_collection_label': 'Usuń etykietę',
'assistant.tool.assign_collection_labels': 'Zmień etykiety',
'assistant.tool.accept_collection_invite': 'Dołącz do listy',
'assistant.tool.decline_collection_invite': 'Odrzuć zaproszenie',
'assistant.tool.cancel_collection_invite': 'Wycofaj zaproszenie',
'assistant.step.readingList': 'Czytam twoją listę',
'assistant.step.lookingAtLists': 'Przeglądam twoje listy',
'assistant.step.checkingLists': 'Sprawdzam twoje listy',
'assistant.followUp.collectionToTrip': 'Dodaj do tej podróży miejsca, które chcę odwiedzić',
'assistant.followUp.collectionDay': 'Zaplanuj dzień wokół tej listy',
};
export default assistantCollection;
+72 -1
View File
@@ -1,4 +1,75 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Открыть список',
'assistant.card.collection.openPlace': 'Открыть в Коллекциях',
'assistant.card.collection.moreInList': 'Ещё {count} в этом списке',
'assistant.card.collection.moreInList.one': 'Ещё {count} в этом списке',
'assistant.card.collection.moreInList.few': 'Ещё {count} в этом списке',
'assistant.card.collection.moreInList.many': 'Ещё {count} в этом списке',
'assistant.card.collection.members': '{count} участника',
'assistant.card.collection.members.one': '{count} участник',
'assistant.card.collection.members.few': '{count} участника',
'assistant.card.collection.members.many': '{count} участников',
'assistant.card.collection.lists': 'Ваши списки',
'assistant.card.collection.savedIn': 'Сохранено в {count} списках',
'assistant.card.collection.savedIn.one': 'Сохранено в {count} списке',
'assistant.card.collection.savedIn.few': 'Сохранено в {count} списках',
'assistant.card.collection.savedIn.many': 'Сохранено в {count} списках',
'assistant.card.collection.alreadySaved': 'Уже в «{name}»',
'assistant.card.collection.invite': 'Приглашение от {name}',
'assistant.card.collection.withdraw': 'Приглашение для {name}',
'assistant.card.collection.labelUsedBy': 'На {count} местах',
'assistant.card.collection.labelUsedBy.one': 'На {count} месте',
'assistant.card.collection.labelUsedBy.few': 'На {count} местах',
'assistant.card.collection.labelUsedBy.many': 'На {count} местах',
'assistant.card.collection.copied': 'Добавлено: {count}',
'assistant.card.collection.copied.one': 'Добавлено: {count}',
'assistant.card.collection.copied.few': 'Добавлено: {count}',
'assistant.card.collection.copied.many': 'Добавлено: {count}',
'assistant.card.collection.skipped': 'Уже были: {count}',
'assistant.card.collection.skipped.one': 'Уже были: {count}',
'assistant.card.collection.skipped.few': 'Уже были: {count}',
'assistant.card.collection.skipped.many': 'Уже были: {count}',
'assistant.card.collection.willSkip': 'Уже в поездке: {count}',
'assistant.card.collection.willSkip.one': 'Уже в поездке: {count}',
'assistant.card.collection.willSkip.few': 'Уже в поездке: {count}',
'assistant.card.collection.willSkip.many': 'Уже в поездке: {count}',
'assistant.card.collection.otherTrip': 'В другую поездку',
'assistant.card.collection.everyList': 'Во всех списках, где оно сохранено',
'assistant.card.collection.ratingCleared': 'Оценка удалена',
'assistant.card.collection.moveTo': 'Переместить в «{name}»',
'assistant.card.collection.removed': 'Удалено из списка',
'assistant.card.collection.photo': 'Фото',
'assistant.card.collection.places': 'Сохранённые места в «{name}»',
'assistant.card.collection.saved': 'Сохранённые места',
'assistant.card.collection.statusSet': 'Изменено сохранённых мест: {count}',
'assistant.card.collection.statusSet.one': 'Изменено сохранённых мест: {count}',
'assistant.card.collection.statusSet.few': 'Изменено сохранённых мест: {count}',
'assistant.card.collection.statusSet.many': 'Изменено сохранённых мест: {count}',
'assistant.tool.create_collection': 'Создать список',
'assistant.tool.update_collection': 'Изменить список',
'assistant.tool.delete_collection': 'Удалить список',
'assistant.tool.reorder_collections': 'Изменить порядок списков',
'assistant.tool.save_place_to_collection': 'Сохранить в список',
'assistant.tool.save_trip_places_to_collection': 'Сохранить места поездки в список',
'assistant.tool.update_collection_place': 'Изменить сохранённое место',
'assistant.tool.set_collection_place_status': 'Задать статус',
'assistant.tool.set_collection_place_status_from_trip': 'Задать статус в ваших списках',
'assistant.tool.rate_collection_place': 'Оценить сохранённое место',
'assistant.tool.delete_collection_place': 'Удалить из списка',
'assistant.tool.copy_collection_places_to_trip': 'Добавить сохранённые места в поездку',
'assistant.tool.create_collection_label': 'Добавить метку',
'assistant.tool.update_collection_label': 'Изменить метку',
'assistant.tool.delete_collection_label': 'Удалить метку',
'assistant.tool.assign_collection_labels': 'Изменить метки',
'assistant.tool.accept_collection_invite': 'Присоединиться к списку',
'assistant.tool.decline_collection_invite': 'Отклонить приглашение',
'assistant.tool.cancel_collection_invite': 'Отозвать приглашение',
'assistant.step.readingList': 'Читаю ваш список',
'assistant.step.lookingAtLists': 'Смотрю ваши списки',
'assistant.step.checkingLists': 'Проверяю ваши списки',
'assistant.followUp.collectionToTrip': 'Добавь в эту поездку места, куда я хочу попасть',
'assistant.followUp.collectionDay': 'Спланируй день вокруг этого списка',
};
export default assistantCollection;
+64 -1
View File
@@ -1,4 +1,67 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Otvoriť zoznam',
'assistant.card.collection.openPlace': 'Otvoriť v Zbierkach',
'assistant.card.collection.moreInList': 'Ďalších {count} v tomto zozname',
'assistant.card.collection.moreInList.one': 'Ďalší {count} v tomto zozname',
'assistant.card.collection.moreInList.few': 'Ďalšie {count} v tomto zozname',
'assistant.card.collection.members': '{count} členov',
'assistant.card.collection.members.one': '{count} člen',
'assistant.card.collection.members.few': '{count} členovia',
'assistant.card.collection.lists': 'Tvoje zoznamy',
'assistant.card.collection.savedIn': 'Uložené v {count} zoznamoch',
'assistant.card.collection.savedIn.one': 'Uložené v {count} zozname',
'assistant.card.collection.savedIn.few': 'Uložené v {count} zoznamoch',
'assistant.card.collection.alreadySaved': 'Už v {name}',
'assistant.card.collection.invite': 'Pozvánka od {name}',
'assistant.card.collection.withdraw': 'Pozvánka pre {name}',
'assistant.card.collection.labelUsedBy': 'Na {count} miestach',
'assistant.card.collection.labelUsedBy.one': 'Na {count} mieste',
'assistant.card.collection.labelUsedBy.few': 'Na {count} miestach',
'assistant.card.collection.copied': '{count} pridaných',
'assistant.card.collection.copied.one': '{count} pridané',
'assistant.card.collection.copied.few': '{count} pridané',
'assistant.card.collection.skipped': '{count} tam už bolo',
'assistant.card.collection.skipped.one': '{count} tam už bolo',
'assistant.card.collection.skipped.few': '{count} tam už boli',
'assistant.card.collection.willSkip': '{count} už vo výlete',
'assistant.card.collection.willSkip.one': '{count} už vo výlete',
'assistant.card.collection.willSkip.few': '{count} už vo výlete',
'assistant.card.collection.otherTrip': 'Do iného výletu',
'assistant.card.collection.everyList': 'V každom zozname, kde je uložené',
'assistant.card.collection.ratingCleared': 'Hodnotenie odstránené',
'assistant.card.collection.moveTo': 'Presunúť do {name}',
'assistant.card.collection.removed': 'Odstránené zo zoznamu',
'assistant.card.collection.photo': 'Fotka',
'assistant.card.collection.places': 'Uložené miesta v {name}',
'assistant.card.collection.saved': 'Uložené miesta',
'assistant.card.collection.statusSet': '{count} uložených miest zmenených',
'assistant.card.collection.statusSet.one': '{count} uložené miesto zmenené',
'assistant.card.collection.statusSet.few': '{count} uložené miesta zmenené',
'assistant.tool.create_collection': 'Vytvoriť zoznam',
'assistant.tool.update_collection': 'Zmeniť zoznam',
'assistant.tool.delete_collection': 'Zmazať zoznam',
'assistant.tool.reorder_collections': 'Preusporiadať zoznamy',
'assistant.tool.save_place_to_collection': 'Uložiť do zoznamu',
'assistant.tool.save_trip_places_to_collection': 'Uložiť miesta z výletu do zoznamu',
'assistant.tool.update_collection_place': 'Zmeniť uložené miesto',
'assistant.tool.set_collection_place_status': 'Nastaviť stav',
'assistant.tool.set_collection_place_status_from_trip': 'Nastaviť stav v tvojich zoznamoch',
'assistant.tool.rate_collection_place': 'Ohodnotiť uložené miesto',
'assistant.tool.delete_collection_place': 'Odstrániť zo zoznamu',
'assistant.tool.copy_collection_places_to_trip': 'Pridať uložené miesta do výletu',
'assistant.tool.create_collection_label': 'Pridať štítok',
'assistant.tool.update_collection_label': 'Zmeniť štítok',
'assistant.tool.delete_collection_label': 'Zmazať štítok',
'assistant.tool.assign_collection_labels': 'Zmeniť štítky',
'assistant.tool.accept_collection_invite': 'Pridať sa k zoznamu',
'assistant.tool.decline_collection_invite': 'Odmietnuť pozvánku',
'assistant.tool.cancel_collection_invite': 'Stiahnuť pozvánku',
'assistant.step.readingList': 'Čítam tvoj zoznam',
'assistant.step.lookingAtLists': 'Prezerám tvoje zoznamy',
'assistant.step.checkingLists': 'Kontrolujem tvoje zoznamy',
'assistant.followUp.collectionToTrip': 'Pridaj do tohto výletu miesta, kam chcem ísť',
'assistant.followUp.collectionDay': 'Naplánuj deň okolo tohto zoznamu',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Öppna listan',
'assistant.card.collection.openPlace': 'Öppna i Samlingar',
'assistant.card.collection.moreInList': '{count} till i den här listan',
'assistant.card.collection.moreInList.one': '{count} till i den här listan',
'assistant.card.collection.members': '{count} medlemmar',
'assistant.card.collection.members.one': '{count} medlem',
'assistant.card.collection.lists': 'Dina listor',
'assistant.card.collection.savedIn': 'Sparad i {count} listor',
'assistant.card.collection.savedIn.one': 'Sparad i {count} lista',
'assistant.card.collection.alreadySaved': 'Redan i {name}',
'assistant.card.collection.invite': 'Inbjudan från {name}',
'assistant.card.collection.withdraw': 'Inbjudan till {name}',
'assistant.card.collection.labelUsedBy': 'På {count} platser',
'assistant.card.collection.labelUsedBy.one': 'På {count} plats',
'assistant.card.collection.copied': '{count} tillagda',
'assistant.card.collection.copied.one': '{count} tillagd',
'assistant.card.collection.skipped': '{count} fanns redan',
'assistant.card.collection.skipped.one': '{count} fanns redan',
'assistant.card.collection.willSkip': '{count} redan i resan',
'assistant.card.collection.willSkip.one': '{count} redan i resan',
'assistant.card.collection.otherTrip': 'Till en annan resa',
'assistant.card.collection.everyList': 'I varje lista där den är sparad',
'assistant.card.collection.ratingCleared': 'Betyget borttaget',
'assistant.card.collection.moveTo': 'Flytta till {name}',
'assistant.card.collection.removed': 'Borttagen från listan',
'assistant.card.collection.photo': 'Foto',
'assistant.card.collection.places': 'Sparade platser i {name}',
'assistant.card.collection.saved': 'Sparade platser',
'assistant.card.collection.statusSet': '{count} sparade platser ändrade',
'assistant.card.collection.statusSet.one': '{count} sparad plats ändrad',
'assistant.tool.create_collection': 'Skapa en lista',
'assistant.tool.update_collection': 'Ändra listan',
'assistant.tool.delete_collection': 'Ta bort listan',
'assistant.tool.reorder_collections': 'Ändra ordning på listorna',
'assistant.tool.save_place_to_collection': 'Spara i en lista',
'assistant.tool.save_trip_places_to_collection': 'Spara resans platser i en lista',
'assistant.tool.update_collection_place': 'Ändra den sparade platsen',
'assistant.tool.set_collection_place_status': 'Ange status',
'assistant.tool.set_collection_place_status_from_trip': 'Ange status i dina listor',
'assistant.tool.rate_collection_place': 'Betygsätt den sparade platsen',
'assistant.tool.delete_collection_place': 'Ta bort från listan',
'assistant.tool.copy_collection_places_to_trip': 'Lägg till sparade platser i resan',
'assistant.tool.create_collection_label': 'Lägg till en etikett',
'assistant.tool.update_collection_label': 'Ändra etiketten',
'assistant.tool.delete_collection_label': 'Ta bort etiketten',
'assistant.tool.assign_collection_labels': 'Ändra etiketterna',
'assistant.tool.accept_collection_invite': 'Gå med i listan',
'assistant.tool.decline_collection_invite': 'Avböj inbjudan',
'assistant.tool.cancel_collection_invite': 'Dra tillbaka inbjudan',
'assistant.step.readingList': 'Läser din lista',
'assistant.step.lookingAtLists': 'Tittar på dina listor',
'assistant.step.checkingLists': 'Kontrollerar dina listor',
'assistant.followUp.collectionToTrip': 'Lägg till platserna jag vill besöka i den här resan',
'assistant.followUp.collectionDay': 'Planera en dag kring den här listan',
};
export default assistantCollection;
+48 -1
View File
@@ -1,4 +1,51 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'เปิดรายการ',
'assistant.card.collection.openPlace': 'เปิดในคอลเลกชัน',
'assistant.card.collection.moreInList': 'อีก {count} แห่งในรายการนี้',
'assistant.card.collection.members': 'สมาชิก {count} คน',
'assistant.card.collection.lists': 'รายการของคุณ',
'assistant.card.collection.savedIn': 'บันทึกไว้ใน {count} รายการ',
'assistant.card.collection.alreadySaved': 'อยู่ใน {name} แล้ว',
'assistant.card.collection.invite': 'คำเชิญจาก {name}',
'assistant.card.collection.withdraw': 'คำเชิญถึง {name}',
'assistant.card.collection.labelUsedBy': 'ใช้กับ {count} สถานที่',
'assistant.card.collection.copied': 'เพิ่มแล้ว {count} แห่ง',
'assistant.card.collection.skipped': 'มีอยู่แล้ว {count} แห่ง',
'assistant.card.collection.willSkip': 'อยู่ในทริปแล้ว {count} แห่ง',
'assistant.card.collection.otherTrip': 'ไปยังทริปอื่น',
'assistant.card.collection.everyList': 'ในทุกรายการที่บันทึกไว้',
'assistant.card.collection.ratingCleared': 'ลบคะแนนแล้ว',
'assistant.card.collection.moveTo': 'ย้ายไปที่ {name}',
'assistant.card.collection.removed': 'นำออกจากรายการแล้ว',
'assistant.card.collection.photo': 'รูปภาพ',
'assistant.card.collection.places': 'สถานที่ที่บันทึกไว้ใน {name}',
'assistant.card.collection.saved': 'สถานที่ที่บันทึกไว้',
'assistant.card.collection.statusSet': 'เปลี่ยนสถานที่ที่บันทึกไว้ {count} แห่ง',
'assistant.tool.create_collection': 'สร้างรายการ',
'assistant.tool.update_collection': 'เปลี่ยนรายการ',
'assistant.tool.delete_collection': 'ลบรายการ',
'assistant.tool.reorder_collections': 'จัดลำดับรายการใหม่',
'assistant.tool.save_place_to_collection': 'บันทึกลงรายการ',
'assistant.tool.save_trip_places_to_collection': 'บันทึกสถานที่ในทริปลงรายการ',
'assistant.tool.update_collection_place': 'เปลี่ยนสถานที่ที่บันทึกไว้',
'assistant.tool.set_collection_place_status': 'ตั้งสถานะ',
'assistant.tool.set_collection_place_status_from_trip': 'ตั้งสถานะในรายการของคุณ',
'assistant.tool.rate_collection_place': 'ให้คะแนนสถานที่ที่บันทึกไว้',
'assistant.tool.delete_collection_place': 'นำออกจากรายการ',
'assistant.tool.copy_collection_places_to_trip': 'เพิ่มสถานที่ที่บันทึกไว้ลงในทริป',
'assistant.tool.create_collection_label': 'เพิ่มป้ายกำกับ',
'assistant.tool.update_collection_label': 'เปลี่ยนป้ายกำกับ',
'assistant.tool.delete_collection_label': 'ลบป้ายกำกับ',
'assistant.tool.assign_collection_labels': 'เปลี่ยนป้ายกำกับหลายรายการ',
'assistant.tool.accept_collection_invite': 'เข้าร่วมรายการ',
'assistant.tool.decline_collection_invite': 'ปฏิเสธคำเชิญ',
'assistant.tool.cancel_collection_invite': 'ถอนคำเชิญ',
'assistant.step.readingList': 'กำลังอ่านรายการของคุณ',
'assistant.step.lookingAtLists': 'กำลังดูรายการของคุณ',
'assistant.step.checkingLists': 'กำลังตรวจสอบรายการของคุณ',
'assistant.followUp.collectionToTrip': 'เพิ่มสถานที่ที่ฉันอยากไปลงในทริปนี้',
'assistant.followUp.collectionDay': 'วางแผนหนึ่งวันรอบรายการนี้',
};
export default assistantCollection;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Listeyi aç',
'assistant.card.collection.openPlace': 'Koleksiyonlarda aç',
'assistant.card.collection.moreInList': 'Bu listede {count} tane daha',
'assistant.card.collection.moreInList.one': 'Bu listede {count} tane daha',
'assistant.card.collection.members': '{count} üye',
'assistant.card.collection.members.one': '{count} üye',
'assistant.card.collection.lists': 'Listelerin',
'assistant.card.collection.savedIn': '{count} listede kayıtlı',
'assistant.card.collection.savedIn.one': '{count} listede kayıtlı',
'assistant.card.collection.alreadySaved': 'Zaten {name} listesinde',
'assistant.card.collection.invite': '{name} kişisinden davet',
'assistant.card.collection.withdraw': '{name} kişisine davet',
'assistant.card.collection.labelUsedBy': '{count} yerde',
'assistant.card.collection.labelUsedBy.one': '{count} yerde',
'assistant.card.collection.copied': '{count} eklendi',
'assistant.card.collection.copied.one': '{count} eklendi',
'assistant.card.collection.skipped': '{count} zaten vardı',
'assistant.card.collection.skipped.one': '{count} zaten vardı',
'assistant.card.collection.willSkip': '{count} zaten seyahatte',
'assistant.card.collection.willSkip.one': '{count} zaten seyahatte',
'assistant.card.collection.otherTrip': 'Başka bir seyahate',
'assistant.card.collection.everyList': 'Kayıtlı olduğu her listede',
'assistant.card.collection.ratingCleared': 'Puan kaldırıldı',
'assistant.card.collection.moveTo': '{name} listesine taşı',
'assistant.card.collection.removed': 'Listeden çıkarıldı',
'assistant.card.collection.photo': 'Fotoğraf',
'assistant.card.collection.places': '{name} listesindeki kayıtlı yerler',
'assistant.card.collection.saved': 'Kayıtlı yerler',
'assistant.card.collection.statusSet': '{count} kayıtlı yer değişti',
'assistant.card.collection.statusSet.one': '{count} kayıtlı yer değişti',
'assistant.tool.create_collection': 'Liste oluştur',
'assistant.tool.update_collection': 'Listeyi değiştir',
'assistant.tool.delete_collection': 'Listeyi sil',
'assistant.tool.reorder_collections': 'Listeleri yeniden sırala',
'assistant.tool.save_place_to_collection': 'Bir listeye kaydet',
'assistant.tool.save_trip_places_to_collection': 'Seyahatin yerlerini bir listeye kaydet',
'assistant.tool.update_collection_place': 'Kayıtlı yeri değiştir',
'assistant.tool.set_collection_place_status': 'Durumu ayarla',
'assistant.tool.set_collection_place_status_from_trip': 'Listelerindeki durumu ayarla',
'assistant.tool.rate_collection_place': 'Kayıtlı yeri puanla',
'assistant.tool.delete_collection_place': 'Listeden çıkar',
'assistant.tool.copy_collection_places_to_trip': 'Kayıtlı yerleri seyahate ekle',
'assistant.tool.create_collection_label': 'Etiket ekle',
'assistant.tool.update_collection_label': 'Etiketi değiştir',
'assistant.tool.delete_collection_label': 'Etiketi sil',
'assistant.tool.assign_collection_labels': 'Etiketleri değiştir',
'assistant.tool.accept_collection_invite': 'Listeye katıl',
'assistant.tool.decline_collection_invite': 'Daveti reddet',
'assistant.tool.cancel_collection_invite': 'Daveti geri çek',
'assistant.step.readingList': 'Listen okunuyor',
'assistant.step.lookingAtLists': 'Listelerine bakılıyor',
'assistant.step.checkingLists': 'Listelerin kontrol ediliyor',
'assistant.followUp.collectionToTrip': 'Gitmek istediğim yerleri bu seyahate ekle',
'assistant.followUp.collectionDay': 'Bu listenin etrafında bir gün planla',
};
export default assistantCollection;
+72 -1
View File
@@ -1,4 +1,75 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Відкрити список',
'assistant.card.collection.openPlace': 'Відкрити в Колекціях',
'assistant.card.collection.moreInList': 'Ще {count} у цьому списку',
'assistant.card.collection.moreInList.one': 'Ще {count} у цьому списку',
'assistant.card.collection.moreInList.few': 'Ще {count} у цьому списку',
'assistant.card.collection.moreInList.many': 'Ще {count} у цьому списку',
'assistant.card.collection.members': '{count} учасника',
'assistant.card.collection.members.one': '{count} учасник',
'assistant.card.collection.members.few': '{count} учасники',
'assistant.card.collection.members.many': '{count} учасників',
'assistant.card.collection.lists': 'Ваші списки',
'assistant.card.collection.savedIn': 'Збережено в {count} списках',
'assistant.card.collection.savedIn.one': 'Збережено в {count} списку',
'assistant.card.collection.savedIn.few': 'Збережено в {count} списках',
'assistant.card.collection.savedIn.many': 'Збережено в {count} списках',
'assistant.card.collection.alreadySaved': 'Уже в «{name}»',
'assistant.card.collection.invite': 'Запрошення від {name}',
'assistant.card.collection.withdraw': 'Запрошення для {name}',
'assistant.card.collection.labelUsedBy': 'На {count} місцях',
'assistant.card.collection.labelUsedBy.one': 'На {count} місці',
'assistant.card.collection.labelUsedBy.few': 'На {count} місцях',
'assistant.card.collection.labelUsedBy.many': 'На {count} місцях',
'assistant.card.collection.copied': 'Додано: {count}',
'assistant.card.collection.copied.one': 'Додано: {count}',
'assistant.card.collection.copied.few': 'Додано: {count}',
'assistant.card.collection.copied.many': 'Додано: {count}',
'assistant.card.collection.skipped': 'Уже були: {count}',
'assistant.card.collection.skipped.one': 'Уже були: {count}',
'assistant.card.collection.skipped.few': 'Уже були: {count}',
'assistant.card.collection.skipped.many': 'Уже були: {count}',
'assistant.card.collection.willSkip': 'Уже в подорожі: {count}',
'assistant.card.collection.willSkip.one': 'Уже в подорожі: {count}',
'assistant.card.collection.willSkip.few': 'Уже в подорожі: {count}',
'assistant.card.collection.willSkip.many': 'Уже в подорожі: {count}',
'assistant.card.collection.otherTrip': 'В іншу подорож',
'assistant.card.collection.everyList': 'У всіх списках, де воно збережене',
'assistant.card.collection.ratingCleared': 'Оцінку прибрано',
'assistant.card.collection.moveTo': 'Перемістити в «{name}»',
'assistant.card.collection.removed': 'Прибрано зі списку',
'assistant.card.collection.photo': 'Фото',
'assistant.card.collection.places': 'Збережені місця в «{name}»',
'assistant.card.collection.saved': 'Збережені місця',
'assistant.card.collection.statusSet': 'Змінено збережених місць: {count}',
'assistant.card.collection.statusSet.one': 'Змінено збережених місць: {count}',
'assistant.card.collection.statusSet.few': 'Змінено збережених місць: {count}',
'assistant.card.collection.statusSet.many': 'Змінено збережених місць: {count}',
'assistant.tool.create_collection': 'Створити список',
'assistant.tool.update_collection': 'Змінити список',
'assistant.tool.delete_collection': 'Видалити список',
'assistant.tool.reorder_collections': 'Змінити порядок списків',
'assistant.tool.save_place_to_collection': 'Зберегти в список',
'assistant.tool.save_trip_places_to_collection': 'Зберегти місця подорожі в список',
'assistant.tool.update_collection_place': 'Змінити збережене місце',
'assistant.tool.set_collection_place_status': 'Задати статус',
'assistant.tool.set_collection_place_status_from_trip': 'Задати статус у ваших списках',
'assistant.tool.rate_collection_place': 'Оцінити збережене місце',
'assistant.tool.delete_collection_place': 'Прибрати зі списку',
'assistant.tool.copy_collection_places_to_trip': 'Додати збережені місця до подорожі',
'assistant.tool.create_collection_label': 'Додати мітку',
'assistant.tool.update_collection_label': 'Змінити мітку',
'assistant.tool.delete_collection_label': 'Видалити мітку',
'assistant.tool.assign_collection_labels': 'Змінити мітки',
'assistant.tool.accept_collection_invite': 'Приєднатися до списку',
'assistant.tool.decline_collection_invite': 'Відхилити запрошення',
'assistant.tool.cancel_collection_invite': 'Відкликати запрошення',
'assistant.step.readingList': 'Читаю ваш список',
'assistant.step.lookingAtLists': 'Переглядаю ваші списки',
'assistant.step.checkingLists': 'Перевіряю ваші списки',
'assistant.followUp.collectionToTrip': 'Додай до цієї подорожі місця, куди я хочу потрапити',
'assistant.followUp.collectionDay': 'Сплануй день навколо цього списку',
};
export default assistantCollection;
+48 -1
View File
@@ -1,4 +1,51 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': 'Mở danh sách',
'assistant.card.collection.openPlace': 'Mở trong Bộ sưu tập',
'assistant.card.collection.moreInList': 'Còn {count} trong danh sách này',
'assistant.card.collection.members': '{count} thành viên',
'assistant.card.collection.lists': 'Danh sách của bạn',
'assistant.card.collection.savedIn': 'Đã lưu trong {count} danh sách',
'assistant.card.collection.alreadySaved': 'Đã có trong {name}',
'assistant.card.collection.invite': 'Lời mời từ {name}',
'assistant.card.collection.withdraw': 'Lời mời gửi {name}',
'assistant.card.collection.labelUsedBy': 'Trên {count} địa điểm',
'assistant.card.collection.copied': 'Đã thêm {count}',
'assistant.card.collection.skipped': '{count} đã có sẵn',
'assistant.card.collection.willSkip': '{count} đã có trong chuyến đi',
'assistant.card.collection.otherTrip': 'Vào chuyến đi khác',
'assistant.card.collection.everyList': 'Trong mọi danh sách đã lưu nó',
'assistant.card.collection.ratingCleared': 'Đã xoá đánh giá',
'assistant.card.collection.moveTo': 'Chuyển sang {name}',
'assistant.card.collection.removed': 'Đã xoá khỏi danh sách',
'assistant.card.collection.photo': 'Ảnh',
'assistant.card.collection.places': 'Địa điểm đã lưu trong {name}',
'assistant.card.collection.saved': 'Địa điểm đã lưu',
'assistant.card.collection.statusSet': 'Đã thay đổi {count} địa điểm đã lưu',
'assistant.tool.create_collection': 'Tạo danh sách',
'assistant.tool.update_collection': 'Thay đổi danh sách',
'assistant.tool.delete_collection': 'Xoá danh sách',
'assistant.tool.reorder_collections': 'Sắp xếp lại danh sách',
'assistant.tool.save_place_to_collection': 'Lưu vào danh sách',
'assistant.tool.save_trip_places_to_collection': 'Lưu địa điểm của chuyến đi vào danh sách',
'assistant.tool.update_collection_place': 'Thay đổi địa điểm đã lưu',
'assistant.tool.set_collection_place_status': 'Đặt trạng thái',
'assistant.tool.set_collection_place_status_from_trip': 'Đặt trạng thái trong danh sách của bạn',
'assistant.tool.rate_collection_place': 'Đánh giá địa điểm đã lưu',
'assistant.tool.delete_collection_place': 'Xoá khỏi danh sách',
'assistant.tool.copy_collection_places_to_trip': 'Thêm địa điểm đã lưu vào chuyến đi',
'assistant.tool.create_collection_label': 'Thêm nhãn',
'assistant.tool.update_collection_label': 'Thay đổi nhãn',
'assistant.tool.delete_collection_label': 'Xoá nhãn',
'assistant.tool.assign_collection_labels': 'Thay đổi các nhãn',
'assistant.tool.accept_collection_invite': 'Tham gia danh sách',
'assistant.tool.decline_collection_invite': 'Từ chối lời mời',
'assistant.tool.cancel_collection_invite': 'Rút lại lời mời',
'assistant.step.readingList': 'Đang đọc danh sách của bạn',
'assistant.step.lookingAtLists': 'Đang xem các danh sách của bạn',
'assistant.step.checkingLists': 'Đang kiểm tra các danh sách của bạn',
'assistant.followUp.collectionToTrip': 'Thêm những nơi tôi muốn đến vào chuyến đi này',
'assistant.followUp.collectionDay': 'Lên kế hoạch một ngày quanh danh sách này',
};
export default assistantCollection;
+48 -1
View File
@@ -1,4 +1,51 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': '開啟清單',
'assistant.card.collection.openPlace': '在收藏中開啟',
'assistant.card.collection.moreInList': '此清單中還有 {count} 個',
'assistant.card.collection.members': '{count} 位成員',
'assistant.card.collection.lists': '你的清單',
'assistant.card.collection.savedIn': '已儲存在 {count} 個清單中',
'assistant.card.collection.alreadySaved': '已在 {name} 中',
'assistant.card.collection.invite': '來自 {name} 的邀請',
'assistant.card.collection.withdraw': '傳給 {name} 的邀請',
'assistant.card.collection.labelUsedBy': '用於 {count} 個地點',
'assistant.card.collection.copied': '已新增 {count} 個',
'assistant.card.collection.skipped': '{count} 個已存在',
'assistant.card.collection.willSkip': '{count} 個已在行程中',
'assistant.card.collection.otherTrip': '新增到另一個行程',
'assistant.card.collection.everyList': '在儲存它的所有清單中',
'assistant.card.collection.ratingCleared': '已移除評分',
'assistant.card.collection.moveTo': '移動到 {name}',
'assistant.card.collection.removed': '已從清單中移除',
'assistant.card.collection.photo': '照片',
'assistant.card.collection.places': '{name} 中儲存的地點',
'assistant.card.collection.saved': '儲存的地點',
'assistant.card.collection.statusSet': '已變更 {count} 個儲存的地點',
'assistant.tool.create_collection': '建立清單',
'assistant.tool.update_collection': '修改清單',
'assistant.tool.delete_collection': '刪除清單',
'assistant.tool.reorder_collections': '重新排列清單',
'assistant.tool.save_place_to_collection': '儲存到清單',
'assistant.tool.save_trip_places_to_collection': '將行程地點儲存到清單',
'assistant.tool.update_collection_place': '修改儲存的地點',
'assistant.tool.set_collection_place_status': '設定狀態',
'assistant.tool.set_collection_place_status_from_trip': '在你的清單中設定狀態',
'assistant.tool.rate_collection_place': '為儲存的地點評分',
'assistant.tool.delete_collection_place': '從清單中移除',
'assistant.tool.copy_collection_places_to_trip': '將儲存的地點新增到行程',
'assistant.tool.create_collection_label': '新增標籤',
'assistant.tool.update_collection_label': '修改標籤',
'assistant.tool.delete_collection_label': '刪除標籤',
'assistant.tool.assign_collection_labels': '變更標籤',
'assistant.tool.accept_collection_invite': '加入清單',
'assistant.tool.decline_collection_invite': '拒絕邀請',
'assistant.tool.cancel_collection_invite': '撤回邀請',
'assistant.step.readingList': '正在讀取你的清單',
'assistant.step.lookingAtLists': '正在查看你的清單',
'assistant.step.checkingLists': '正在檢查你的清單',
'assistant.followUp.collectionToTrip': '把我想去的地點新增到這個行程',
'assistant.followUp.collectionDay': '圍繞這個清單規劃一天',
};
export default assistantCollection;
+48 -1
View File
@@ -1,4 +1,51 @@
import type { TranslationStrings } from '../types';
const assistantCollection: TranslationStrings = {};
const assistantCollection: TranslationStrings = {
'assistant.card.collection.openList': '打开列表',
'assistant.card.collection.openPlace': '在收藏中打开',
'assistant.card.collection.moreInList': '此列表中还有 {count} 个',
'assistant.card.collection.members': '{count} 位成员',
'assistant.card.collection.lists': '你的列表',
'assistant.card.collection.savedIn': '已保存在 {count} 个列表中',
'assistant.card.collection.alreadySaved': '已在 {name} 中',
'assistant.card.collection.invite': '来自 {name} 的邀请',
'assistant.card.collection.withdraw': '发给 {name} 的邀请',
'assistant.card.collection.labelUsedBy': '用于 {count} 个地点',
'assistant.card.collection.copied': '已添加 {count} 个',
'assistant.card.collection.skipped': '{count} 个已存在',
'assistant.card.collection.willSkip': '{count} 个已在行程中',
'assistant.card.collection.otherTrip': '添加到另一个行程',
'assistant.card.collection.everyList': '在保存它的所有列表中',
'assistant.card.collection.ratingCleared': '已移除评分',
'assistant.card.collection.moveTo': '移动到 {name}',
'assistant.card.collection.removed': '已从列表中移除',
'assistant.card.collection.photo': '照片',
'assistant.card.collection.places': '{name} 中保存的地点',
'assistant.card.collection.saved': '保存的地点',
'assistant.card.collection.statusSet': '已更改 {count} 个保存的地点',
'assistant.tool.create_collection': '创建列表',
'assistant.tool.update_collection': '修改列表',
'assistant.tool.delete_collection': '删除列表',
'assistant.tool.reorder_collections': '重新排列列表',
'assistant.tool.save_place_to_collection': '保存到列表',
'assistant.tool.save_trip_places_to_collection': '将行程地点保存到列表',
'assistant.tool.update_collection_place': '修改保存的地点',
'assistant.tool.set_collection_place_status': '设置状态',
'assistant.tool.set_collection_place_status_from_trip': '在你的列表中设置状态',
'assistant.tool.rate_collection_place': '为保存的地点评分',
'assistant.tool.delete_collection_place': '从列表中移除',
'assistant.tool.copy_collection_places_to_trip': '将保存的地点添加到行程',
'assistant.tool.create_collection_label': '添加标签',
'assistant.tool.update_collection_label': '修改标签',
'assistant.tool.delete_collection_label': '删除标签',
'assistant.tool.assign_collection_labels': '更改标签',
'assistant.tool.accept_collection_invite': '加入列表',
'assistant.tool.decline_collection_invite': '拒绝邀请',
'assistant.tool.cancel_collection_invite': '撤回邀请',
'assistant.step.readingList': '正在读取你的列表',
'assistant.step.lookingAtLists': '正在查看你的列表',
'assistant.step.checkingLists': '正在检查你的列表',
'assistant.followUp.collectionToTrip': '把我想去的地点添加到这个行程',
'assistant.followUp.collectionDay': '围绕这个列表规划一天',
};
export default assistantCollection;