mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(assistant): packing list and to-do cards
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* FE-AI-PACKRUN-001 to FE-AI-PACKRUN-003
|
||||
*
|
||||
* A run of single-row packing changes reads as one checklist inside the
|
||||
* change card: each category's head once, a row per change with its own
|
||||
* include choice and the tile of what it does, and the facts of the change
|
||||
* with old values struck through. A change switched off fades.
|
||||
*/
|
||||
import type { PackingCardItem, PackingListCard } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import { PackingChangeRun } from './PackingListCard';
|
||||
|
||||
const row = (name: string, over: Partial<PackingCardItem> = {}): PackingCardItem => ({
|
||||
id: null,
|
||||
name,
|
||||
quantity: 1,
|
||||
packed: 0,
|
||||
checked: false,
|
||||
weightGrams: null,
|
||||
bagKey: null,
|
||||
tier: 'common',
|
||||
sharedWith: [],
|
||||
change: 'add',
|
||||
changed: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const card = (category: string, item: PackingCardItem): PackingListCard => ({
|
||||
kind: 'packingList',
|
||||
source: { type: 'trip' },
|
||||
groupBy: 'category',
|
||||
progress: null,
|
||||
bags: [],
|
||||
groups: [
|
||||
{
|
||||
key: { by: 'category', name: category, slot: 0 },
|
||||
assignees: [],
|
||||
assigneesRemoved: [],
|
||||
items: [item],
|
||||
count: 1,
|
||||
packed: item.checked ? 1 : 0,
|
||||
},
|
||||
],
|
||||
moreGroups: 0,
|
||||
});
|
||||
|
||||
const pending = (id: string, packing: PackingListCard): ChangeItem => ({
|
||||
toolCallId: id,
|
||||
preview: null,
|
||||
applied: null,
|
||||
state: 'pending',
|
||||
card: packing,
|
||||
});
|
||||
|
||||
const QUANTITY: PackingCardItem['changed'] = [{ field: 'quantity', before: '2' }];
|
||||
|
||||
const ITEMS = [
|
||||
pending('c1', card('Toiletries', row('Sunscreen', { quantity: 2 }))),
|
||||
pending('c2', card('Toiletries', row('Toothbrush'))),
|
||||
pending('c3', card('Clothes', row('Rain jacket', { change: 'check', checked: true, packed: 1 }))),
|
||||
pending('c4', card('Clothes', row('Hiking socks', { change: 'update', quantity: 3, changed: QUANTITY }))),
|
||||
pending('c5', card('Clothes', row('Old charger', { change: 'remove' }))),
|
||||
];
|
||||
|
||||
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
|
||||
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
|
||||
}
|
||||
|
||||
describe('PackingChangeRun', () => {
|
||||
it("FE-AI-PACKRUN-001: each category's head shows once, a row per change with what it does", () => {
|
||||
inView(<PackingChangeRun items={ITEMS} excluded={new Set()} onToggle={vi.fn()} />);
|
||||
expect(screen.getAllByText('Toiletries')).toHaveLength(1);
|
||||
expect(screen.getAllByText('Clothes')).toHaveLength(1);
|
||||
expect(screen.getAllByText('New')).toHaveLength(2);
|
||||
expect(screen.getByText('×2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Packed')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Old charger')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Removed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PACKRUN-002: every row has its own include choice, and a switched-off one fades', () => {
|
||||
const onToggle = vi.fn();
|
||||
inView(<PackingChangeRun items={ITEMS} excluded={new Set(['c5'])} onToggle={onToggle} />);
|
||||
const choices = screen.getAllByRole('button', { name: 'Include this change' });
|
||||
expect(choices).toHaveLength(5);
|
||||
expect(choices[4]).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(choices[4]?.parentElement).toHaveClass('opacity-50');
|
||||
fireEvent.click(choices[1] as HTMLElement);
|
||||
expect(onToggle).toHaveBeenCalledWith('c2');
|
||||
});
|
||||
|
||||
it('FE-AI-PACKRUN-003: on the phone the choice gets a larger hit area; a change without a card is left out', () => {
|
||||
const plain: ChangeItem = { toolCallId: 'c9', preview: null, applied: null, state: 'pending', card: null };
|
||||
inView(<PackingChangeRun items={[...ITEMS.slice(0, 2), plain]} excluded={new Set()} onToggle={vi.fn()} />, {
|
||||
variant: 'fullscreen',
|
||||
});
|
||||
const choices = screen.getAllByRole('button', { name: 'Include this change' });
|
||||
expect(choices).toHaveLength(2);
|
||||
expect(choices[0]).toHaveClass('h-8', 'w-8');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* FE-AI-PACK-001 to FE-AI-PACK-009
|
||||
*
|
||||
* The packing list in the chat: an answer's groups with their colour,
|
||||
* count, people and rows (how many, how many packed, weight, tier, bag), the
|
||||
* lines for what it left out and the way into the list; bag heads with their
|
||||
* weight against the limit; a change's facts on its line and the groups,
|
||||
* bags or template under it.
|
||||
*/
|
||||
import type { PackingCardBag, PackingCardItem, PackingListCard } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { KAT_COLORS } from '../../../Packing/packingListPanel.constants';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import { PackingChangeBody, PackingChangeFacts, PackingListBlock } from './PackingListCard';
|
||||
|
||||
const ANA = { id: 2, name: 'Ana', avatarKey: null };
|
||||
|
||||
const row = (id: number | null, name: string, over: Partial<PackingCardItem> = {}): PackingCardItem => ({
|
||||
id,
|
||||
name,
|
||||
quantity: 1,
|
||||
packed: 0,
|
||||
checked: false,
|
||||
weightGrams: null,
|
||||
bagKey: null,
|
||||
tier: 'common',
|
||||
sharedWith: [],
|
||||
change: 'none',
|
||||
changed: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const bag = (over: Partial<PackingCardBag> = {}): PackingCardBag => ({
|
||||
key: 'id:5',
|
||||
id: 5,
|
||||
name: 'Carry-on',
|
||||
color: '#6366f1',
|
||||
weightGrams: 6200,
|
||||
limitGrams: 8000,
|
||||
owner: null,
|
||||
members: [],
|
||||
membersRemoved: [],
|
||||
change: 'none',
|
||||
changed: [],
|
||||
movesOut: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
const ANSWER: PackingListCard = {
|
||||
kind: 'packingList',
|
||||
source: { type: 'trip' },
|
||||
groupBy: 'category',
|
||||
progress: { packed: 1, total: 4 },
|
||||
bags: [bag()],
|
||||
groups: [
|
||||
{
|
||||
key: { by: 'category', name: 'Clothes', slot: 1 },
|
||||
assignees: [ANA],
|
||||
assigneesRemoved: [],
|
||||
items: [
|
||||
row(11, 'Rain jacket', { checked: true, packed: 1, weightGrams: 480, bagKey: 'id:5' }),
|
||||
row(12, 'Hiking socks', { quantity: 3, packed: 1 }),
|
||||
row(15, 'Sun hat', { tier: 'personal' }),
|
||||
],
|
||||
count: 5,
|
||||
packed: 1,
|
||||
},
|
||||
{
|
||||
key: { by: 'category', name: null, slot: null },
|
||||
assignees: [],
|
||||
assigneesRemoved: [],
|
||||
items: [row(14, 'Sunscreen', { tier: 'shared', sharedWith: [ANA] })],
|
||||
count: 1,
|
||||
packed: 0,
|
||||
},
|
||||
],
|
||||
moreGroups: 2,
|
||||
};
|
||||
|
||||
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
|
||||
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
|
||||
}
|
||||
|
||||
const entries = (...cards: PackingListCard[]): CardEntry[] =>
|
||||
cards.map((card, i) => ({ toolCallId: `call_${i}`, card }));
|
||||
|
||||
const changeItem = (card: PackingListCard): ChangeItem => ({
|
||||
toolCallId: 'call_c',
|
||||
preview: null,
|
||||
applied: null,
|
||||
state: 'pending',
|
||||
card,
|
||||
});
|
||||
|
||||
describe('PackingListCard', () => {
|
||||
it('FE-AI-PACK-001: an answer shows the list, how much is packed and its groups in their colours', () => {
|
||||
inView(<PackingListBlock cards={entries(ANSWER)} />);
|
||||
expect(screen.getByRole('region', { name: 'Packing list' })).toBeInTheDocument();
|
||||
expect(screen.getByText('1 of 4 packed')).toBeInTheDocument();
|
||||
const clothes = screen.getByText('Clothes');
|
||||
expect(clothes.previousElementSibling).toHaveStyle({ background: KAT_COLORS[1] });
|
||||
expect(screen.getByText('Other')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('img', { name: 'Ana' }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-AI-PACK-002: a row says how many, how many are packed, its weight, its tier and its bag', () => {
|
||||
inView(<PackingListBlock cards={entries(ANSWER)} />);
|
||||
expect(screen.getByText('Rain jacket')).toHaveClass('line-through');
|
||||
expect(screen.getByText('480 g')).toBeInTheDocument();
|
||||
expect(screen.getByText('×3')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/3')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Just for me' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Carry-on' })).toHaveStyle({ background: '#6366f1' });
|
||||
});
|
||||
|
||||
it('FE-AI-PACK-003: what it left out leads into the list, as does its open button', () => {
|
||||
const onOpen = vi.fn();
|
||||
inView(<PackingListBlock cards={entries(ANSWER)} />, { onOpen });
|
||||
expect(screen.getByText('+2 more')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 more categories' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open packing list/ }));
|
||||
expect(onOpen).toHaveBeenCalledTimes(2);
|
||||
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'listen', sub: 'packing' });
|
||||
});
|
||||
|
||||
it('FE-AI-PACK-004: bag heads show their weight against the limit, past it in red, the pile in no bag last', () => {
|
||||
const heavy = bag({ weightGrams: 9000 });
|
||||
const card: PackingListCard = {
|
||||
...ANSWER,
|
||||
groupBy: 'bag',
|
||||
progress: null,
|
||||
bags: [heavy],
|
||||
groups: [
|
||||
{ ...ANSWER.groups[0], key: { by: 'bag', bagKey: 'id:5' }, items: [], count: 1, assignees: [] },
|
||||
{ ...ANSWER.groups[0], key: { by: 'bag', bagKey: null }, items: [], count: 3, assignees: [] },
|
||||
],
|
||||
moreGroups: 0,
|
||||
};
|
||||
inView(<PackingListBlock cards={entries(card)} />);
|
||||
expect(screen.getByRole('region', { name: 'Bags' })).toBeInTheDocument();
|
||||
expect(screen.getByText('9.0 kg of 8.0 kg')).toBeInTheDocument();
|
||||
expect(screen.getByText('Over the limit')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unassigned')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PACK-005: two answers of one list read as one card, another source stands apart', () => {
|
||||
const more: PackingListCard = { ...ANSWER, groups: [{ ...ANSWER.groups[0], items: [row(16, 'Gloves')] }] };
|
||||
const source = { type: 'tour' as const, placeId: 70, name: 'Ridge walk', category: null };
|
||||
const tour: PackingListCard = { ...ANSWER, source };
|
||||
inView(<PackingListBlock cards={entries(ANSWER, more, tour)} />);
|
||||
expect(screen.getAllByRole('region')).toHaveLength(2);
|
||||
expect(screen.getByRole('region', { name: 'Ridge walk' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Packing for this tour')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Gloves')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-AI-PACK-006: a change of one row states its facts on its line, old values struck through', () => {
|
||||
const card = (item: PackingCardItem): PackingListCard => ({
|
||||
...ANSWER,
|
||||
progress: null,
|
||||
groups: [{ ...ANSWER.groups[0], items: [item], count: 1 }],
|
||||
moreGroups: 0,
|
||||
});
|
||||
const ticked = card(row(12, 'Hiking socks', { change: 'check', checked: true }));
|
||||
const { unmount } = inView(<PackingChangeFacts card={ticked} item={changeItem(ticked)} />);
|
||||
expect(screen.getByText('Packed')).toBeInTheDocument();
|
||||
unmount();
|
||||
const quantity = [{ field: 'quantity' as const, before: '2' }];
|
||||
const changed = card(row(12, 'Hiking socks', { change: 'update', quantity: 3, changed: quantity }));
|
||||
inView(<PackingChangeFacts card={changed} item={changeItem(changed)} />);
|
||||
expect(screen.getByText('2')).toHaveClass('line-through');
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
const { container } = inView(<PackingChangeBody card={changed} item={changeItem(changed)} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-AI-PACK-007: under a bigger change: the template it adds, a bag about to go, the people a category drops', () => {
|
||||
const template: PackingListCard = {
|
||||
...ANSWER,
|
||||
source: { type: 'template', templateId: 4, name: 'Beach week', itemCount: 24 },
|
||||
progress: null,
|
||||
bags: [],
|
||||
groups: [],
|
||||
moreGroups: 0,
|
||||
};
|
||||
const { unmount } = inView(<PackingChangeBody card={template} item={changeItem(template)} />);
|
||||
expect(screen.getByText('Adds 24 items from the template')).toBeInTheDocument();
|
||||
unmount();
|
||||
const gone: PackingListCard = {
|
||||
...ANSWER,
|
||||
groupBy: 'bag',
|
||||
progress: null,
|
||||
bags: [bag({ change: 'remove', movesOut: 3 })],
|
||||
groups: [{ ...ANSWER.groups[0], key: { by: 'bag', bagKey: 'id:5' }, items: [], assignees: [] }],
|
||||
moreGroups: 0,
|
||||
};
|
||||
const second = inView(<PackingChangeBody card={gone} item={changeItem(gone)} />);
|
||||
expect(screen.getByText('Removed')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 items move to Unassigned')).toBeInTheDocument();
|
||||
second.unmount();
|
||||
const people: PackingListCard = {
|
||||
...ANSWER,
|
||||
progress: null,
|
||||
groups: [{ ...ANSWER.groups[0], items: [], assignees: [], assigneesRemoved: [ANA] }],
|
||||
moreGroups: 0,
|
||||
};
|
||||
inView(<PackingChangeBody card={people} item={changeItem(people)} />);
|
||||
expect(screen.getByText('No longer: Ana')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PACK-008: an import of twenty into one category says how many more it adds than it shows', () => {
|
||||
const rows = Array.from({ length: 8 }, (_, i) => row(null, `Item ${i}`, { change: 'add' }));
|
||||
const imported: PackingListCard = {
|
||||
...ANSWER,
|
||||
progress: null,
|
||||
bags: [],
|
||||
groups: [{ ...ANSWER.groups[0], assignees: [], items: rows, count: 20 }],
|
||||
moreGroups: 0,
|
||||
};
|
||||
inView(<PackingChangeBody card={imported} item={changeItem(imported)} />);
|
||||
expect(screen.getByText('+12 more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PACK-009: a row whose bag the card does not carry shows no swatch, never "Unassigned"', () => {
|
||||
const card: PackingListCard = {
|
||||
...ANSWER,
|
||||
bags: [],
|
||||
groups: [{ ...ANSWER.groups[0], items: [row(11, 'Rain jacket', { bagKey: 'id:5' })], count: 1 }],
|
||||
moreGroups: 0,
|
||||
};
|
||||
inView(<PackingListBlock cards={entries(card)} />);
|
||||
expect(screen.queryByRole('img', { name: 'Unassigned' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('img', { name: 'Carry-on' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { PackingCardGroup, PackingListCard as PackingCard } from '@trek/shared';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { PackedBar, PackTile } from '../../../Tours/packing/packingLinkParts';
|
||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import { isRunnable, mergePackingCards, packingColorOf, rowCount } from './checklistModel';
|
||||
import { ChecklistFoot, ChecklistRun, type RunEntry, type RunProps } from './checklistParts';
|
||||
import { PackingGroup, PackingRowAside, PackingRowFacts } from './packingParts';
|
||||
|
||||
/**
|
||||
* The packing list in the chat, drawn like the list a tour shows: the
|
||||
* backpack tile with its count, how much is packed, then the groups (by
|
||||
* category with their colour and people, or by bag with weight and fill) and
|
||||
* their rows. An answer sits in its own card with the way into the list; a
|
||||
* change draws its rows inside the change card: one row's facts on its line,
|
||||
* several rows (an import, a template) or a bag or category under it, and a
|
||||
* run of single-row changes as one checklist.
|
||||
*/
|
||||
|
||||
const PACKING_TARGET: EntityTarget = { kind: 'tab', tab: 'listen', sub: 'packing' };
|
||||
|
||||
/** How the groups a card leaves out are counted, by what it groups by. */
|
||||
const MORE_GROUPS = {
|
||||
bag: { labelKey: 'assistant.card.packing.moreBags' },
|
||||
category: { labelKey: 'assistant.card.packing.moreCategories' },
|
||||
} as const;
|
||||
|
||||
/** The bags of a card by key, and the heaviest one, the scale for bags without a limit. */
|
||||
function bagsOf(card: PackingCard) {
|
||||
const bags = new Map(card.bags.map((bag) => [bag.key, bag]));
|
||||
const heaviest = Math.max(1, ...card.bags.map((bag) => bag.weightGrams ?? 0));
|
||||
return { bags, heaviest };
|
||||
}
|
||||
|
||||
const groupKeyOf = (group: PackingCardGroup) =>
|
||||
group.key.by === 'bag' ? `bag:${group.key.bagKey ?? ''}` : `category:${group.key.name ?? ''}`;
|
||||
|
||||
/** The groups of a card, as the list shows them; the heads of a bag overview sit closer. */
|
||||
function PackingGroups({ card, inChange }: { card: PackingCard; inChange: boolean }) {
|
||||
const { bags, heaviest } = bagsOf(card);
|
||||
const overview = rowCount(card) === 0;
|
||||
return (
|
||||
<div className={`flex min-w-0 flex-col ${overview ? 'gap-2' : 'gap-1.5'}`}>
|
||||
{card.groups.map((group) => (
|
||||
<PackingGroup key={groupKeyOf(group)} group={group} bags={bags} heaviest={heaviest} inChange={inChange} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's title: the list, the bags, or the tour or template it is about. */
|
||||
function useTitle(card: PackingCard): string {
|
||||
const { t } = useTranslation();
|
||||
if (card.source.type !== 'trip') return card.source.name;
|
||||
return card.groupBy === 'bag' && rowCount(card) === 0
|
||||
? t('assistant.card.packing.bagsTitle')
|
||||
: t('assistant.card.packing.title');
|
||||
}
|
||||
|
||||
/** The head of an answer: the tile with the count, the title, how much is packed, and "All packed" once it is. */
|
||||
function PackingHead({ card }: { card: PackingCard }) {
|
||||
const { t } = useTranslation();
|
||||
const title = useTitle(card);
|
||||
const source = card.source;
|
||||
const total = card.progress?.total ?? card.groups.reduce((sum, group) => sum + group.count, 0);
|
||||
const packed = card.progress?.packed ?? 0;
|
||||
let caption: string | null = null;
|
||||
if (source.type === 'tour') caption = t('assistant.card.packing.forTour');
|
||||
else if (source.type === 'template') caption = t('assistant.card.packing.templateAdds', { count: source.itemCount });
|
||||
else if (card.progress && total > 0) caption = t('tours.packing.packed', { packed, count: total });
|
||||
const first = card.groups[0]?.key;
|
||||
const color =
|
||||
source.type === 'tour' && source.category !== null && first?.by === 'category'
|
||||
? packingColorOf(first.slot, source.category)
|
||||
: null;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<PackTile count={source.type === 'template' ? source.itemCount : total} color={color} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p title={title} className="m-0 line-clamp-2 text-body font-semibold leading-tight text-content">
|
||||
{title}
|
||||
</p>
|
||||
{caption && (
|
||||
<p title={caption} className="m-0 truncate text-caption text-content-faint">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{card.progress && total > 0 && packed === total && (
|
||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||
{t('tours.packing.allPacked')}
|
||||
</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One answer card. */
|
||||
function PackingAnswer({ card }: { card: PackingCard }) {
|
||||
const title = useTitle(card);
|
||||
const progress = card.progress;
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<PackingHead card={card} />
|
||||
{progress && progress.total > 0 && <PackedBar packed={progress.packed} total={progress.total} />}
|
||||
<PackingGroups card={card} inChange={false} />
|
||||
<ChecklistFoot
|
||||
more={card.moreGroups}
|
||||
moreKey={MORE_GROUPS[card.groupBy].labelKey}
|
||||
target={PACKING_TARGET}
|
||||
openKey="tours.packing.open"
|
||||
/>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** The packing answers of one group: consecutive cards of one list read as one, the others stacked. */
|
||||
export function PackingListBlock({ cards }: { cards: readonly CardEntry[] }) {
|
||||
const lists = mergePackingCards(cards.flatMap((entry) => (entry.card.kind === 'packingList' ? [entry.card] : [])));
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{lists.map((card, i) => (
|
||||
<PackingAnswer key={`${card.source.type}-${i}`} card={card} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The facts of a change of one row on its line; any other change keeps the fields of its preview. */
|
||||
export function PackingChangeFacts({ card, item }: { card: PackingCard; item: ChangeItem }) {
|
||||
const group = card.groups[0];
|
||||
const row = group?.items[0];
|
||||
if (!isRunnable(card) || !group || !row) return <AssistantChangeFields rows={item.preview?.rows ?? []} />;
|
||||
const { bags } = bagsOf(card);
|
||||
const category = group.key.by === 'category' ? (group.key.name ?? '') : '';
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
<PackingRowAside row={row} bag={row.bagKey ? bags.get(row.bagKey) : undefined} />
|
||||
<PackingRowFacts row={row} bags={bags} category={category} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Under a change that is more than one row: the template it adds, or its groups, bags and people. */
|
||||
export function PackingChangeBody({ card }: { card: PackingCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
if (isRunnable(card)) return null;
|
||||
const source = card.source;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{source.type === 'template' && card.groups.length === 0 && (
|
||||
<p className="m-0 text-caption text-content-muted">
|
||||
{t('assistant.card.packing.templateAdds', { count: source.itemCount })}
|
||||
</p>
|
||||
)}
|
||||
{source.type === 'tour' && (
|
||||
<p className="m-0 text-caption text-content-muted">{t('assistant.card.packing.forTour')}</p>
|
||||
)}
|
||||
<PackingGroups card={card} inChange />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A waiting change of one row as a row of a run: the category's head, the row's name, end and facts. */
|
||||
function packingEntry(item: ChangeItem): RunEntry | null {
|
||||
const card = item.card?.kind === 'packingList' ? item.card : null;
|
||||
const group = card?.groups[0];
|
||||
const row = group?.items[0];
|
||||
if (!card || !group || !row) return null;
|
||||
const { bags } = bagsOf(card);
|
||||
const category = group.key.by === 'category' ? (group.key.name ?? '') : '';
|
||||
return {
|
||||
item,
|
||||
change: row.change,
|
||||
headKey: groupKeyOf(group),
|
||||
head: <PackingGroup group={{ ...group, items: [] }} bags={bags} heaviest={1} inChange />,
|
||||
name: row.name,
|
||||
facts: (
|
||||
<>
|
||||
<PackingRowAside row={row} bag={row.bagKey ? bags.get(row.bagKey) : undefined} />
|
||||
<PackingRowFacts row={row} bags={bags} category={category} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two or more waiting changes of one row each, as one checklist: the
|
||||
* category's head once, a row per change with its name, what it says at its
|
||||
* end and what the change does.
|
||||
*/
|
||||
export function PackingChangeRun(props: RunProps) {
|
||||
return <ChecklistRun {...props} entryOf={packingEntry} />;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* FE-AI-TODO-001 to FE-AI-TODO-007
|
||||
*
|
||||
* The to-dos in the chat: what is open and overdue, lists with their colour
|
||||
* and open count, tasks with their note, priority in the panel's tones, a due
|
||||
* date that turns red once passed by the reader's own day and shows its day
|
||||
* in the plan, who a task is for; a change's facts on its line, a list's
|
||||
* people under it, and a run of task changes as one checklist.
|
||||
*/
|
||||
import type { TodoCardItem, TodoListCard } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import { TodoChangeBody, TodoChangeLine, TodoChangeRun, TodoListBlock } from './TodoListCard';
|
||||
|
||||
vi.mock('../../../Planner/today', () => ({ localToday: () => '2026-06-11' }));
|
||||
|
||||
const ANA = { id: 2, name: 'Ana', avatarKey: null };
|
||||
|
||||
const task = (id: number | null, name: string, over: Partial<TodoCardItem> = {}): TodoCardItem => ({
|
||||
id,
|
||||
name,
|
||||
note: null,
|
||||
checked: false,
|
||||
dueDate: null,
|
||||
dueDayId: null,
|
||||
priority: 0,
|
||||
assignee: null,
|
||||
change: 'none',
|
||||
changed: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const ANSWER: TodoListCard = {
|
||||
kind: 'todoList',
|
||||
progress: { open: 2, done: 1, overdue: 1 },
|
||||
groups: [
|
||||
{
|
||||
category: 'Bookings',
|
||||
slot: 0,
|
||||
assignees: [],
|
||||
assigneesRemoved: [],
|
||||
items: [
|
||||
task(21, 'Book the ferry', {
|
||||
note: 'Check the early sailing first',
|
||||
dueDate: '2026-06-10',
|
||||
dueDayId: 31,
|
||||
priority: 1,
|
||||
assignee: ANA,
|
||||
}),
|
||||
task(24, 'Car seat', { dueDate: '2026-06-14', priority: 2 }),
|
||||
],
|
||||
count: 4,
|
||||
open: 2,
|
||||
},
|
||||
{
|
||||
category: null,
|
||||
slot: null,
|
||||
assignees: [],
|
||||
assigneesRemoved: [],
|
||||
items: [task(23, 'Passport copies', { checked: true })],
|
||||
count: 1,
|
||||
open: 0,
|
||||
},
|
||||
],
|
||||
moreGroups: 1,
|
||||
};
|
||||
|
||||
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
|
||||
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
|
||||
}
|
||||
|
||||
const one = (item: TodoCardItem, category: string | null = 'Bookings'): TodoListCard => ({
|
||||
kind: 'todoList',
|
||||
progress: null,
|
||||
groups: [{ category, slot: 0, assignees: [], assigneesRemoved: [], items: [item], count: 1, open: 1 }],
|
||||
moreGroups: 0,
|
||||
});
|
||||
|
||||
const pending = (id: string, card: TodoListCard): ChangeItem => ({
|
||||
toolCallId: id,
|
||||
preview: null,
|
||||
applied: null,
|
||||
state: 'pending',
|
||||
card,
|
||||
});
|
||||
|
||||
describe('TodoListCard', () => {
|
||||
it('FE-AI-TODO-001: an answer counts what is open and overdue and shows its lists with their tasks', () => {
|
||||
inView(<TodoListBlock cards={[{ toolCallId: 'call_1', card: ANSWER }]} />);
|
||||
expect(screen.getByRole('region', { name: 'To-dos' })).toBeInTheDocument();
|
||||
expect(screen.getByText('2 open')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 overdue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bookings')).toBeInTheDocument();
|
||||
expect(screen.getByText('No list')).toBeInTheDocument();
|
||||
expect(screen.getByText('Check the early sailing first')).toHaveAttribute('title', 'Check the early sailing first');
|
||||
expect(screen.getByText('Passport copies')).toHaveClass('line-through');
|
||||
expect(screen.getByText('2 more tasks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FE-AI-TODO-002: priorities take the panel tones; a passed due date turns red by the reader's own day", () => {
|
||||
inView(<TodoListBlock cards={[{ toolCallId: 'call_1', card: ANSWER }]} />);
|
||||
expect(screen.getByText('P1').closest('span')).toHaveClass('text-danger');
|
||||
expect(screen.getByText('P2').closest('span')).toHaveClass('text-warning');
|
||||
const due = screen.getAllByRole('button', { name: /Show this day in the plan/ });
|
||||
expect(due).toHaveLength(1);
|
||||
expect(due[0]?.firstElementChild).toHaveClass('text-danger');
|
||||
expect(screen.getByRole('img', { name: 'Ana' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TODO-003: a due date shows its day in the plan; the foot leads into the to-do list', () => {
|
||||
const onOpen = vi.fn();
|
||||
inView(<TodoListBlock cards={[{ toolCallId: 'call_1', card: ANSWER }]} />, { onOpen });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Show this day in the plan/ }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: null, dayId: 31 });
|
||||
fireEvent.click(screen.getByRole('button', { name: '1 more list' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open the to-do list/ }));
|
||||
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'listen', sub: 'todo' });
|
||||
});
|
||||
|
||||
it('FE-AI-TODO-004: a change of one task states its facts, old values struck through', () => {
|
||||
const update = one(
|
||||
task(21, 'Book the ferry', {
|
||||
change: 'update',
|
||||
priority: 2,
|
||||
changed: [
|
||||
{ field: 'priority', before: '1' },
|
||||
{ field: 'assignee', before: 'Ana' },
|
||||
],
|
||||
})
|
||||
);
|
||||
inView(<TodoChangeLine card={update} item={pending('c1', update)} />);
|
||||
expect(screen.getByText('P1')).toHaveClass('line-through');
|
||||
expect(screen.getAllByText('P2').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Ana')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Unassigned')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TODO-007: an update that moves the due date or the priority states it once, old to new', () => {
|
||||
const moved = one(
|
||||
task(24, 'Car seat', {
|
||||
change: 'update',
|
||||
dueDate: '2026-06-14',
|
||||
dueDayId: 32,
|
||||
priority: 2,
|
||||
changed: [
|
||||
{ field: 'dueDate', before: '2026-06-12' },
|
||||
{ field: 'priority', before: '1' },
|
||||
],
|
||||
})
|
||||
);
|
||||
inView(<TodoChangeLine card={moved} item={pending('c1', moved)} />, { onOpen: vi.fn() });
|
||||
expect(screen.queryByRole('button', { name: /Show this day in the plan/ })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('P2')).toHaveLength(1);
|
||||
expect(screen.getByText('P1')).toHaveClass('line-through');
|
||||
});
|
||||
|
||||
it("FE-AI-TODO-005: a list's people show under the change, the ones it drops named", () => {
|
||||
const people: TodoListCard = {
|
||||
kind: 'todoList',
|
||||
progress: null,
|
||||
groups: [
|
||||
{
|
||||
category: 'Bookings',
|
||||
slot: 0,
|
||||
assignees: [ANA],
|
||||
assigneesRemoved: [{ ...ANA, id: 3, name: 'Ben' }],
|
||||
items: [],
|
||||
count: 2,
|
||||
open: 2,
|
||||
},
|
||||
],
|
||||
moreGroups: 0,
|
||||
};
|
||||
inView(<TodoChangeBody card={people} item={pending('c1', people)} />);
|
||||
expect(screen.getByRole('img', { name: 'Ana' })).toBeInTheDocument();
|
||||
expect(screen.getByText('No longer: Ben')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Ben' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TODO-006: a run of task changes reads as one checklist under its list', () => {
|
||||
const onToggle = vi.fn();
|
||||
const items = [
|
||||
pending('c1', one(task(null, 'Book the train', { change: 'add' }))),
|
||||
pending('c2', one(task(22, 'Travel insurance', { change: 'check', checked: true }))),
|
||||
];
|
||||
inView(<TodoChangeRun items={items} excluded={new Set()} onToggle={onToggle} />);
|
||||
expect(screen.getAllByText('Bookings')).toHaveLength(1);
|
||||
expect(screen.getByText('New')).toBeInTheDocument();
|
||||
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Include this change' })[0] as HTMLElement);
|
||||
expect(onToggle).toHaveBeenCalledWith('c1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { TodoCardGroup, TodoCardItem, TodoListCard as TodoCard } from '@trek/shared';
|
||||
import { CalendarDays, Flag, ListTodo, PencilLine, StickyNote, Tag, User } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { localToday } from '../../../Planner/today';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { PRIO_CONFIG } from '../../../Todo/todoListModel';
|
||||
import { PackTile } from '../../../Tours/packing/packingLinkParts';
|
||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import { PersonFace } from '../parts/CardPeople';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { isRunnable, mergeTodoCards, todoColorOf } from './checklistModel';
|
||||
import {
|
||||
ChecklistFoot,
|
||||
ChecklistHead,
|
||||
ChecklistRow,
|
||||
ChecklistRun,
|
||||
MoreLine,
|
||||
type RunEntry,
|
||||
type RunProps,
|
||||
} from './checklistParts';
|
||||
|
||||
/**
|
||||
* The to-dos in the chat, drawn like the to-do panel: lists with their colour
|
||||
* dot and default people, tasks with their round check, a note, a priority in
|
||||
* the panel's red, amber and blue, a due date that turns red once it has
|
||||
* passed (by the reader's own calendar, so a card read tomorrow still marks
|
||||
* it) and shows its day in the plan, and who it is for. A change draws its
|
||||
* task inside the change card; a run of them reads as one checklist.
|
||||
*/
|
||||
|
||||
const TODO_TARGET: EntityTarget = { kind: 'tab', tab: 'listen', sub: 'todo' };
|
||||
|
||||
/** The panel's priority colours as tones. */
|
||||
const PRIORITY_TONE: Readonly<Record<number, PillTone>> = { 1: 'danger', 2: 'warning', 3: 'info' };
|
||||
|
||||
/** How a priority reads: "P1" as the panel writes it, or none. */
|
||||
function usePriorityLabel(): (priority: number) => string {
|
||||
const { t } = useTranslation();
|
||||
return (priority) => PRIO_CONFIG[priority]?.label ?? t('todo.detail.noPriority');
|
||||
}
|
||||
|
||||
/** A due date as a pill: red once it has passed, a way to its day in the plan where the trip has one. */
|
||||
function DuePill({ row }: { row: TodoCardItem }) {
|
||||
const { t } = useTranslation();
|
||||
const { onOpen } = useAssistantView();
|
||||
const format = useCardFormat();
|
||||
if (!row.dueDate) return null;
|
||||
const overdue = !row.checked && row.dueDate < localToday();
|
||||
const pill = (
|
||||
<SoftPill
|
||||
tone={overdue ? 'danger' : 'neutral'}
|
||||
icon={<CalendarDays size={10} strokeWidth={2.2} aria-hidden="true" />}
|
||||
>
|
||||
{format.date(row.dueDate)}
|
||||
</SoftPill>
|
||||
);
|
||||
const dayId = row.dueDayId;
|
||||
if (!onOpen || dayId === null) return pill;
|
||||
const hint = t('assistant.card.common.showDay');
|
||||
return (
|
||||
<Tooltip label={hint}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${format.date(row.dueDate)}: ${hint}`}
|
||||
onClick={() => onOpen({ kind: 'map', placeId: null, dayId })}
|
||||
className="flex rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{pill}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a task states under its name: its priority and its due date. A field a
|
||||
* change moves is left to the change's own old-to-new fact, so it never shows twice.
|
||||
*/
|
||||
function TodoFacts({ row }: { row: TodoCardItem }) {
|
||||
const priority = usePriorityLabel();
|
||||
const moved = new Set(row.changed.map((entry) => entry.field));
|
||||
const tone = moved.has('priority') ? undefined : PRIORITY_TONE[row.priority];
|
||||
return (
|
||||
<>
|
||||
{tone && (
|
||||
<SoftPill tone={tone} icon={<Flag size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{priority(row.priority)}
|
||||
</SoftPill>
|
||||
)}
|
||||
{!moved.has('dueDate') && <DuePill row={row} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a change does to a task: new, done, open again, gone, or each field it changes, old to new. */
|
||||
function TodoChangeFacts({ row, category }: { row: TodoCardItem; category: string }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const priority = usePriorityLabel();
|
||||
if (row.change === 'add') return <ChangeMark change="new" />;
|
||||
if (row.change === 'remove') return <ChangeMark change="removed" />;
|
||||
if (row.change === 'check') return <SoftPill tone="success">{t('todo.filter.done')}</SoftPill>;
|
||||
if (row.change === 'uncheck') return <SoftPill>{t('assistant.card.todo.notDone')}</SoftPill>;
|
||||
const noDate = t('assistant.card.todo.noDueDate');
|
||||
const date = (value: string | null) => (value ? format.date(value) : noDate);
|
||||
// `done` is kept for the way back only, never drawn.
|
||||
const facts = row.changed
|
||||
.filter((entry) => entry.field !== 'done')
|
||||
.map(({ field, before }) => {
|
||||
if (field === 'name') return { field, icon: PencilLine, before, after: row.name };
|
||||
if (field === 'note') return { field, icon: StickyNote, before, after: row.note ?? '' };
|
||||
if (field === 'category') return { field, icon: Tag, before: before ?? t('todo.uncategorized'), after: category };
|
||||
if (field === 'dueDate') return { field, icon: CalendarDays, before: date(before), after: date(row.dueDate) };
|
||||
if (field === 'priority') {
|
||||
return { field, icon: Flag, before: priority(Number(before ?? 0)), after: priority(row.priority) };
|
||||
}
|
||||
const after = row.assignee?.name ?? t('todo.unassigned');
|
||||
return { field, icon: User, before: before ?? t('todo.unassigned'), after };
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{facts.map((fact) => (
|
||||
<FactChange key={fact.field} icon={fact.icon} before={fact.before} after={fact.after} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One task: its check, its name, who it is for, its note, its priority and due date, and what a change does. */
|
||||
function TodoRow({ row, category, inChange }: { row: TodoCardItem; category: string; inChange: boolean }) {
|
||||
const under = (
|
||||
<>
|
||||
{row.note && (
|
||||
<p title={row.note} className="m-0 line-clamp-1 text-caption text-content-faint">
|
||||
{row.note}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<TodoFacts row={row} />
|
||||
{inChange && row.change !== 'none' && row.change !== 'add' && <TodoChangeFacts row={row} category={category} />}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<ChecklistRow
|
||||
done={row.checked}
|
||||
pendingTick={inChange && row.change === 'check'}
|
||||
struck={row.change === 'remove'}
|
||||
name={row.name}
|
||||
aside={row.assignee ? <PersonFace person={row.assignee} size={20} /> : undefined}
|
||||
under={under}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A list of tasks: its head with its open count and default people, its tasks, and how many more there are. */
|
||||
function TodoGroup({ group, inChange }: { group: TodoCardGroup; inChange: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const name = group.category ?? t('todo.uncategorized');
|
||||
const hidden = group.count - group.items.length;
|
||||
const badge = (
|
||||
<span aria-label={t('assistant.card.todo.openCount', { count: group.open })} className="inline-flex">
|
||||
<WhiteBadge>{group.open}</WhiteBadge>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<section className="flex min-w-0 flex-col">
|
||||
<ChecklistHead
|
||||
color={todoColorOf(group.slot, name)}
|
||||
name={name}
|
||||
badge={inChange ? undefined : badge}
|
||||
people={group.assignees}
|
||||
removed={group.assigneesRemoved}
|
||||
/>
|
||||
{inChange && group.assigneesRemoved.length > 0 && (
|
||||
<p className="m-0 ps-[18px]">
|
||||
<NoLonger names={group.assigneesRemoved.map((person) => person.name)} />
|
||||
</p>
|
||||
)}
|
||||
{group.items.length > 0 && (
|
||||
<ul className="m-0 list-none p-0 ps-1">
|
||||
{group.items.map((row, i) => (
|
||||
<TodoRow key={`${row.id ?? 'new'}-${i}`} row={row} category={name} inChange={inChange} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{group.items.length > 0 && (
|
||||
<MoreLine label={hidden > 0 ? t('assistant.card.todo.moreTasks', { count: hidden }) : null} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One answer card: the tile, what is open and overdue, the lists, and the way into the to-dos. */
|
||||
function TodoAnswer({ card }: { card: TodoCard }) {
|
||||
const { t } = useTranslation();
|
||||
const progress = card.progress;
|
||||
const title = t('assistant.card.todo.title');
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<PackTile count={progress?.open ?? 0} color={null} icon={ListTodo} />
|
||||
<p className="m-0 text-body font-semibold leading-tight text-content">{title}</p>
|
||||
{progress && <SoftPill>{t('assistant.card.todo.openCount', { count: progress.open })}</SoftPill>}
|
||||
{progress && progress.overdue > 0 && (
|
||||
<SoftPill tone="danger">{t('assistant.card.todo.overdueCount', { count: progress.overdue })}</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{card.groups.map((group) => (
|
||||
<TodoGroup key={group.category ?? ''} group={group} inChange={false} />
|
||||
))}
|
||||
</div>
|
||||
<ChecklistFoot
|
||||
more={card.moreGroups}
|
||||
moreKey="assistant.card.todo.moreLists"
|
||||
target={TODO_TARGET}
|
||||
openKey="assistant.card.todo.open"
|
||||
/>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** The to-do answers of one group, read as one list. */
|
||||
export function TodoListBlock({ cards }: { cards: readonly CardEntry[] }) {
|
||||
const lists = mergeTodoCards(cards.flatMap((entry) => (entry.card.kind === 'todoList' ? [entry.card] : [])));
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{lists.map((card, i) => (
|
||||
<TodoAnswer key={i} card={card} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The facts of a change of one task on its line; any other change keeps the fields of its preview. */
|
||||
export function TodoChangeLine({ card, item }: { card: TodoCard; item: ChangeItem }) {
|
||||
const group = card.groups[0];
|
||||
const row = group?.items[0];
|
||||
if (!isRunnable(card) || !group || !row) return <AssistantChangeFields rows={item.preview?.rows ?? []} />;
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
<TodoFacts row={row} />
|
||||
<TodoChangeFacts row={row} category={group.category ?? ''} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Under a change of a list's default people: the list's head with the people it will have and the ones it drops. */
|
||||
export function TodoChangeBody({ card }: { card: TodoCard; item: ChangeItem }) {
|
||||
if (isRunnable(card)) return null;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{card.groups.map((group) => (
|
||||
<TodoGroup key={group.category ?? ''} group={group} inChange />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A waiting change of one task as a row of a run: its list's head, its name, person, facts and change. */
|
||||
function todoEntry(item: ChangeItem): RunEntry | null {
|
||||
const card = item.card?.kind === 'todoList' ? item.card : null;
|
||||
const group = card?.groups[0];
|
||||
const row = group?.items[0];
|
||||
if (!group || !row) return null;
|
||||
const category = group.category ?? '';
|
||||
return {
|
||||
item,
|
||||
change: row.change,
|
||||
headKey: `todo:${category}`,
|
||||
head: <TodoGroup group={{ ...group, items: [] }} inChange />,
|
||||
name: row.name,
|
||||
facts: (
|
||||
<>
|
||||
{row.assignee && <PersonFace person={row.assignee} size={20} />}
|
||||
<TodoFacts row={row} />
|
||||
<TodoChangeFacts row={row} category={category} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Two or more waiting changes of one task each, as one checklist under their lists' heads. */
|
||||
export function TodoChangeRun(props: RunProps) {
|
||||
return <ChecklistRun {...props} entryOf={todoEntry} />;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* FE-AI-CHECK-001 to FE-AI-CHECK-007
|
||||
*
|
||||
* The pure side of the packing and to-do cards: a group's colour by its slot
|
||||
* or the list's own fallback, which change cards join a run, consecutive
|
||||
* answers of one list folded into one, and how the look names and groups
|
||||
* the kinds.
|
||||
*/
|
||||
import type { PackingCardItem, PackingListCard, TodoCardItem, TodoListCard } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { KAT_COLORS } from '../../../Packing/packingListPanel.constants';
|
||||
import { katColor } from '../../../Packing/packingListPanel.helpers';
|
||||
import { katColor as todoKatColor } from '../../../Todo/todoListModel';
|
||||
import {
|
||||
isRunnable,
|
||||
mergePackingCards,
|
||||
mergeTodoCards,
|
||||
packingColorOf,
|
||||
ROW_GLYPH,
|
||||
todoColorOf,
|
||||
} from './checklistModel';
|
||||
import { PACKING_LOOK } from './packingLook';
|
||||
|
||||
const row = (id: number | null, name: string, over: Partial<PackingCardItem> = {}): PackingCardItem => ({
|
||||
id,
|
||||
name,
|
||||
quantity: 1,
|
||||
packed: 0,
|
||||
checked: false,
|
||||
weightGrams: null,
|
||||
bagKey: null,
|
||||
tier: 'common',
|
||||
sharedWith: [],
|
||||
change: 'none',
|
||||
changed: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const packing = (
|
||||
groups: Array<[string, PackingCardItem[]]>,
|
||||
over: Partial<PackingListCard> = {}
|
||||
): PackingListCard => ({
|
||||
kind: 'packingList',
|
||||
source: { type: 'trip' },
|
||||
groupBy: 'category',
|
||||
progress: null,
|
||||
bags: [],
|
||||
groups: groups.map(([name, items]) => ({
|
||||
key: { by: 'category', name, slot: 0 },
|
||||
assignees: [],
|
||||
assigneesRemoved: [],
|
||||
items,
|
||||
count: items.length,
|
||||
packed: 0,
|
||||
})),
|
||||
moreGroups: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
const task = (id: number, name: string): TodoCardItem => ({
|
||||
id,
|
||||
name,
|
||||
note: null,
|
||||
checked: false,
|
||||
dueDate: null,
|
||||
dueDayId: null,
|
||||
priority: 0,
|
||||
assignee: null,
|
||||
change: 'none',
|
||||
changed: [],
|
||||
});
|
||||
|
||||
const todos = (groups: Array<[string | null, TodoCardItem[]]>, open = 1): TodoListCard => ({
|
||||
kind: 'todoList',
|
||||
progress: { open, done: 0, overdue: 0 },
|
||||
groups: groups.map(([category, items]) => ({
|
||||
category,
|
||||
slot: null,
|
||||
assignees: [],
|
||||
assigneesRemoved: [],
|
||||
items,
|
||||
count: items.length,
|
||||
open: items.length,
|
||||
})),
|
||||
moreGroups: 0,
|
||||
});
|
||||
|
||||
describe('checklistModel', () => {
|
||||
it("FE-AI-CHECK-001: a group's colour is the list's colour at its slot, or the list's own fallback by name", () => {
|
||||
expect(packingColorOf(1, 'Clothes')).toBe(KAT_COLORS[1]);
|
||||
expect(packingColorOf(KAT_COLORS.length + 2, 'Clothes')).toBe(KAT_COLORS[2]);
|
||||
expect(packingColorOf(null, 'Clothes')).toBe(katColor('Clothes'));
|
||||
expect(todoColorOf(null, 'Bookings')).toBe(todoKatColor('Bookings', []));
|
||||
expect(todoColorOf(0, 'Bookings')).toBe(KAT_COLORS[0]);
|
||||
});
|
||||
|
||||
it('FE-AI-CHECK-002: only a change of one row of the trip list joins a run', () => {
|
||||
expect(isRunnable(packing([['Clothes', [row(null, 'Hat')]]]))).toBe(true);
|
||||
expect(isRunnable(packing([['Clothes', [row(null, 'Hat'), row(null, 'Cap')]]]))).toBe(false);
|
||||
expect(isRunnable(packing([['Clothes', []]]))).toBe(false);
|
||||
const template = packing([], { source: { type: 'template', templateId: 4, name: 'Beach', itemCount: 3 } });
|
||||
expect(isRunnable(template)).toBe(false);
|
||||
expect(isRunnable(todos([['Bookings', [task(1, 'Ferry')]]]))).toBe(true);
|
||||
expect(isRunnable(todos([['Bookings', []]]))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-CHECK-003: answers of one list fold into one, rows deduped by id with the later one kept', () => {
|
||||
const first = packing([['Clothes', [row(1, 'Hat'), row(2, 'Socks')]]]);
|
||||
const second = packing(
|
||||
[
|
||||
['Clothes', [row(2, 'Socks', { checked: true })]],
|
||||
['Tech', [row(3, 'Charger')]],
|
||||
],
|
||||
{ progress: { packed: 1, total: 3 } }
|
||||
);
|
||||
const [merged, ...rest] = mergePackingCards([first, second]);
|
||||
expect(rest).toEqual([]);
|
||||
expect(merged?.groups.map((group) => group.items.map((item) => [item.id, item.checked]))).toEqual([
|
||||
[
|
||||
[1, false],
|
||||
[2, true],
|
||||
],
|
||||
[[3, false]],
|
||||
]);
|
||||
expect(merged?.progress).toEqual({ packed: 1, total: 3 });
|
||||
});
|
||||
|
||||
it('FE-AI-CHECK-007: complete reads joined are counted again; partial reads that disagree stay apart', () => {
|
||||
const open = packing([['Clothes', [row(1, 'Hat')]]], { progress: { packed: 0, total: 1 } });
|
||||
const done = packing([['Clothes', [row(2, 'Socks', { checked: true, packed: 1 })]]], {
|
||||
progress: { packed: 1, total: 1 },
|
||||
});
|
||||
const [merged, ...rest] = mergePackingCards([open, done]);
|
||||
expect(rest).toEqual([]);
|
||||
expect(merged?.progress).toEqual({ packed: 1, total: 2 });
|
||||
expect(merged?.groups[0]).toMatchObject({ count: 2, packed: 1 });
|
||||
const partly = (card: PackingListCard): PackingListCard => ({
|
||||
...card,
|
||||
groups: card.groups.map((group) => ({ ...group, count: 9 })),
|
||||
});
|
||||
expect(mergePackingCards([partly(open), partly(done)])).toHaveLength(2);
|
||||
expect(mergePackingCards([partly(done), partly(done)])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-AI-CHECK-004: a card of another source or grouping stays a card of its own', () => {
|
||||
const trip = packing([['Clothes', [row(1, 'Hat')]]]);
|
||||
const byBag = packing([], { groupBy: 'bag' });
|
||||
const tour = packing([['Clothes', [row(1, 'Hat')]]], {
|
||||
source: { type: 'tour', placeId: 70, name: 'Ridge', category: null },
|
||||
});
|
||||
expect(mergePackingCards([trip, byBag, tour])).toHaveLength(3);
|
||||
expect(mergePackingCards([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-CHECK-005: to-do answers read as one list, a list joined by its name', () => {
|
||||
const first = todos([['Bookings', [task(1, 'Ferry')]]]);
|
||||
const second = todos(
|
||||
[
|
||||
['Bookings', [task(2, 'Car')]],
|
||||
[null, []],
|
||||
],
|
||||
2
|
||||
);
|
||||
const merged = mergeTodoCards([first, second]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]?.groups.map((group) => [group.category, group.items.length])).toEqual([
|
||||
['Bookings', 2],
|
||||
[null, 0],
|
||||
]);
|
||||
expect(merged[0]?.progress?.open).toBe(2);
|
||||
expect(mergeTodoCards([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-CHECK-006: the look opens the Lists tab, names bag overviews apart and groups by source', () => {
|
||||
const applied = { toolCallId: 'c', ids: [], dayId: null, undoUntil: null };
|
||||
const target = PACKING_LOOK.entities.packing.open?.({ ...applied, entity: 'packing' }, null);
|
||||
expect(target).toEqual({ kind: 'tab', tab: 'listen', sub: 'packing' });
|
||||
const todoTarget = PACKING_LOOK.entities.todo.open?.({ ...applied, entity: 'todo' }, null);
|
||||
expect(todoTarget).toEqual({ kind: 'tab', tab: 'listen', sub: 'todo' });
|
||||
const look = PACKING_LOOK.cards.packingList;
|
||||
expect(look.entity(packing([['Clothes', [row(1, 'Hat')]]]))).toBe('packing');
|
||||
expect(look.entity(packing([], { groupBy: 'bag' }))).toBe('packing_bag');
|
||||
expect(look.group(packing([]))).toBe('packingList:trip:category');
|
||||
const tour = packing([], { source: { type: 'tour', placeId: 70, name: 'Ridge', category: null } });
|
||||
expect(look.group(tour)).toBe('packingList:tour');
|
||||
expect(ROW_GLYPH.remove.tone).toBe('danger');
|
||||
expect(ROW_GLYPH.add.tone).toBe('success');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
PackingCardGroup,
|
||||
PackingCardItem,
|
||||
PackingListCard,
|
||||
TodoCardGroup,
|
||||
TodoCardItem,
|
||||
TodoListCard,
|
||||
} from '@trek/shared';
|
||||
import { Check, Circle, CircleDashed, type LucideIcon, PencilLine, Plus, Trash2, Users } from 'lucide-react';
|
||||
|
||||
import { KAT_COLORS as PACKING_COLORS } from '../../../Packing/packingListPanel.constants';
|
||||
import { katColor as packingHashColor } from '../../../Packing/packingListPanel.helpers';
|
||||
import { katColor as todoHashColor, KAT_COLORS as TODO_COLORS } from '../../../Todo/todoListModel';
|
||||
import type { Tone } from '../parts/assistantTones';
|
||||
|
||||
/**
|
||||
* The pure side of the packing and to-do cards: the colour a group's dot
|
||||
* takes (the list's own, by the slot the server read), consecutive answer
|
||||
* cards of one list folded into one, and which change cards a run of
|
||||
* checklist rows can take. No React here, so the look and the views read the
|
||||
* same rules.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A category's colour as its list shows it: the list's colour at its slot,
|
||||
* or the list's own fallback by name for a category the card had no slot for.
|
||||
*/
|
||||
function colorOf(
|
||||
slot: number | null,
|
||||
name: string,
|
||||
palette: readonly string[],
|
||||
fallback: (name: string) => string
|
||||
): string {
|
||||
return slot === null ? fallback(name) : (palette[slot % palette.length] ?? fallback(name));
|
||||
}
|
||||
|
||||
/** A packing category's colour, as the packing list draws it. */
|
||||
export const packingColorOf = (slot: number | null, name: string) =>
|
||||
colorOf(slot, name, PACKING_COLORS, (n) => packingHashColor(n));
|
||||
|
||||
/** A to-do list's colour, as the to-do panel draws it. */
|
||||
export const todoColorOf = (slot: number | null, name: string) =>
|
||||
colorOf(slot, name, TODO_COLORS, (n) => todoHashColor(n, []));
|
||||
|
||||
/** Rows of a card, across its groups. */
|
||||
export function rowCount(card: PackingListCard | TodoListCard): number {
|
||||
return card.groups.reduce((sum, group) => sum + group.items.length, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a change card can join a run of checklist rows: it holds exactly
|
||||
* one row of the trip's list. An import, a template, a bag or a category's
|
||||
* people keep their own row with the card under it.
|
||||
*/
|
||||
export function isRunnable(card: PackingListCard | TodoListCard): boolean {
|
||||
if (card.kind === 'packingList' && card.source.type !== 'trip') return false;
|
||||
return card.groups.length === 1 && rowCount(card) === 1;
|
||||
}
|
||||
|
||||
/** A group's identity across cards: its category or its bag. */
|
||||
function packingGroupKey(group: PackingCardGroup): string {
|
||||
return group.key.by === 'bag' ? `bag:${group.key.bagKey ?? ''}` : `category:${group.key.name ?? ''}`;
|
||||
}
|
||||
|
||||
/** Rows joined by id, a later card's row replacing an earlier one; rows without an id are all kept. */
|
||||
function joinRows<T extends { id: number | null }>(earlier: readonly T[], later: readonly T[]): T[] {
|
||||
const ids = new Set(later.flatMap((row) => (row.id === null ? [] : [row.id])));
|
||||
return [...earlier.filter((row) => row.id === null || !ids.has(row.id)), ...later];
|
||||
}
|
||||
|
||||
/** Whether a card shows every row it counts, so its figures can be counted again from its rows. */
|
||||
function isComplete(card: PackingListCard): boolean {
|
||||
return card.moreGroups === 0 && card.groups.every((group) => group.items.length === group.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two cards of one source and grouping read as one. Where both show every
|
||||
* row, the joined rows are counted again; otherwise only cards that agree on
|
||||
* how much is packed join (two reads of the same list), so an "open" read
|
||||
* and a "packed" read never show one of their figures for both.
|
||||
*/
|
||||
function canMerge(a: PackingListCard, b: PackingListCard): boolean {
|
||||
if (a.groupBy !== b.groupBy || JSON.stringify(a.source) !== JSON.stringify(b.source)) return false;
|
||||
return (isComplete(a) && isComplete(b)) || JSON.stringify(a.progress) === JSON.stringify(b.progress);
|
||||
}
|
||||
|
||||
function mergeTwoPacking(a: PackingListCard, b: PackingListCard): PackingListCard {
|
||||
const recount = isComplete(a) && isComplete(b);
|
||||
const groups = new Map<string, PackingCardGroup>();
|
||||
for (const group of [...a.groups, ...b.groups]) {
|
||||
const key = packingGroupKey(group);
|
||||
const before = groups.get(key);
|
||||
const items = before ? joinRows<PackingCardItem>(before.items, group.items) : group.items;
|
||||
const counts = recount ? { count: items.length, packed: items.filter((row) => row.checked).length } : {};
|
||||
groups.set(key, { ...group, ...counts, items });
|
||||
}
|
||||
const merged = [...groups.values()];
|
||||
const counted = {
|
||||
packed: merged.reduce((sum, group) => sum + group.packed, 0),
|
||||
total: merged.reduce((sum, group) => sum + group.count, 0),
|
||||
};
|
||||
const bags = new Map([...a.bags, ...b.bags].map((bag) => [bag.key, bag]));
|
||||
return {
|
||||
...b,
|
||||
progress: recount && (a.progress || b.progress) ? counted : (b.progress ?? a.progress),
|
||||
bags: [...bags.values()],
|
||||
groups: merged,
|
||||
moreGroups: Math.max(a.moreGroups, b.moreGroups),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Consecutive answer cards of a packing list folded where they read as one
|
||||
* list (the same source and grouping); any other card stays a card of its own.
|
||||
*/
|
||||
export function mergePackingCards(cards: readonly PackingListCard[]): PackingListCard[] {
|
||||
const out: PackingListCard[] = [];
|
||||
for (const card of cards) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && canMerge(last, card)) out[out.length - 1] = mergeTwoPacking(last, card);
|
||||
else out.push(card);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Consecutive to-do answer cards folded into one list. */
|
||||
export function mergeTodoCards(cards: readonly TodoListCard[]): TodoListCard[] {
|
||||
const first = cards[0];
|
||||
if (!first) return [];
|
||||
const groups = new Map<string, TodoCardGroup>();
|
||||
for (const group of cards.flatMap((card) => card.groups)) {
|
||||
const key = group.category ?? '';
|
||||
const before = groups.get(key);
|
||||
groups.set(key, before ? { ...group, items: joinRows<TodoCardItem>(before.items, group.items) } : group);
|
||||
}
|
||||
const last = cards[cards.length - 1] ?? first;
|
||||
return [
|
||||
{
|
||||
...last,
|
||||
progress: last.progress ?? first.progress,
|
||||
groups: [...groups.values()],
|
||||
moreGroups: Math.max(...cards.map((card) => card.moreGroups)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** What a change does to a checklist row: a row of a packing list or of the to-dos. */
|
||||
export type RowChange = PackingCardItem['change'] | TodoCardItem['change'];
|
||||
|
||||
/** The tile in front of a row of a change run: what the change does, in its tone. */
|
||||
export const ROW_GLYPH: Readonly<Record<RowChange, { Icon: LucideIcon; tone: Tone }>> = {
|
||||
none: { Icon: Circle, tone: 'info' },
|
||||
add: { Icon: Plus, tone: 'success' },
|
||||
update: { Icon: PencilLine, tone: 'info' },
|
||||
check: { Icon: Check, tone: 'success' },
|
||||
uncheck: { Icon: CircleDashed, tone: 'info' },
|
||||
remove: { Icon: Trash2, tone: 'danger' },
|
||||
share: { Icon: Users, tone: 'info' },
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { CardPerson } from '@trek/shared';
|
||||
import { Check } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { CategoryDot, CheckCircle } from '../../../Tours/packing/packingLinkParts';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { EntityTarget } from '../cardTypes';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { PeopleStack } from '../parts/CardPeople';
|
||||
import { CardMore } from '../parts/CardRows';
|
||||
import KindTile from '../parts/KindTile';
|
||||
import { ROW_GLYPH, type RowChange } from './checklistModel';
|
||||
|
||||
/**
|
||||
* The pieces the packing and to-do cards share, drawn the way the lists draw
|
||||
* themselves: a group's head with its colour dot and its people, a row with
|
||||
* its round check, the quiet "more" line, a card's foot with the way into the
|
||||
* list, and the rows of a run of changes with their include choice.
|
||||
*/
|
||||
|
||||
/** A group's head: its colour, its name, a badge, and who looks after it (the ones a change drops faded). */
|
||||
export function ChecklistHead({
|
||||
color,
|
||||
name,
|
||||
badge,
|
||||
people = [],
|
||||
removed = [],
|
||||
lead,
|
||||
}: {
|
||||
color: string;
|
||||
name: string;
|
||||
badge?: ReactNode;
|
||||
people?: readonly CardPerson[];
|
||||
removed?: readonly CardPerson[];
|
||||
/** A bag's swatch in place of the dot. */
|
||||
lead?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 pt-1">
|
||||
{lead ?? <CategoryDot color={color} />}
|
||||
<bdi title={name} className="min-w-0 truncate text-caption font-semibold text-content">
|
||||
{name}
|
||||
</bdi>
|
||||
{badge}
|
||||
{(people.length > 0 || removed.length > 0) && (
|
||||
<span className="ms-auto flex flex-none items-center gap-1.5">
|
||||
<PeopleStack people={people} />
|
||||
{/* The people a change takes off, faded; the card says "No longer" in words under the head. */}
|
||||
{removed.length > 0 && (
|
||||
<span className="flex opacity-40">
|
||||
<PeopleStack people={removed} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row of a list: its round check, its name (wrapping, never cut mid word), its end, and what goes under it. */
|
||||
export function ChecklistRow({
|
||||
done,
|
||||
pendingTick = false,
|
||||
struck = false,
|
||||
name,
|
||||
aside,
|
||||
under,
|
||||
}: {
|
||||
done: boolean;
|
||||
/** A tick a change has not made yet. */
|
||||
pendingTick?: boolean;
|
||||
struck?: boolean;
|
||||
name: string;
|
||||
aside?: ReactNode;
|
||||
under?: ReactNode;
|
||||
}) {
|
||||
const { variant } = useAssistantView();
|
||||
const faint = done || struck;
|
||||
return (
|
||||
<li className={`flex min-w-0 items-start gap-2 ${variant === 'fullscreen' ? 'py-1.5' : 'py-1'}`}>
|
||||
<span className="flex h-5 flex-none items-center">
|
||||
<CheckCircle done={done} pending={pendingTick} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<bdi
|
||||
className={`line-clamp-2 min-w-0 flex-1 break-words text-body ${faint ? 'text-content-faint line-through' : 'text-content-secondary'}`}
|
||||
>
|
||||
{name}
|
||||
</bdi>
|
||||
{aside && <span className="flex flex-none flex-wrap items-center justify-end gap-1">{aside}</span>}
|
||||
</div>
|
||||
{under}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** "+4 more" under a group: quiet words, the list itself is one tap away at the card's foot. */
|
||||
export function MoreLine({ label }: { label: string | null }) {
|
||||
if (!label) return null;
|
||||
return <p className="m-0 ps-6 text-caption text-content-faint">{label}</p>;
|
||||
}
|
||||
|
||||
/** A card's foot: how many groups it left out, and the way into the list. */
|
||||
export function ChecklistFoot({
|
||||
more,
|
||||
moreKey,
|
||||
target,
|
||||
openKey,
|
||||
}: {
|
||||
more: number;
|
||||
moreKey: string;
|
||||
target: EntityTarget;
|
||||
openKey: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-1">
|
||||
<CardMore count={more} labelKey={moreKey} target={target} />
|
||||
<span className="ms-auto">
|
||||
<CardOpenButton target={target} labelKey={openKey} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row of a run of changes, with the group head it opens (if it opens one). */
|
||||
export interface RunEntry {
|
||||
item: ChangeItem;
|
||||
change: RowChange;
|
||||
/** Rows with one key sit under one head. */
|
||||
headKey: string;
|
||||
head: ReactNode;
|
||||
name: string;
|
||||
/** What the row says at its end and what the change does to it. */
|
||||
facts: ReactNode;
|
||||
}
|
||||
|
||||
/** A change in a run: the round include choice, the tile of what it does, and the row. */
|
||||
function RunRow({
|
||||
entry,
|
||||
excluded,
|
||||
onToggle,
|
||||
}: {
|
||||
entry: RunEntry;
|
||||
excluded: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const glyph = ROW_GLYPH[entry.change];
|
||||
return (
|
||||
<div
|
||||
className={`-mx-1.5 flex items-center gap-2.5 rounded-xl px-1.5 transition-[opacity,background-color] hover:bg-surface-hover ${
|
||||
variant === 'fullscreen' ? 'py-1.5' : 'py-1'
|
||||
} ${excluded ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(entry.item.toolCallId)}
|
||||
aria-pressed={!excluded}
|
||||
aria-label={t('assistant.change.include')}
|
||||
className={`grid flex-none place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
variant === 'fullscreen' ? 'h-8 w-8' : 'h-[18px] w-[18px]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`grid h-[18px] w-[18px] place-items-center rounded-full ${
|
||||
excluded ? 'border-[1.5px] border-edge bg-surface-card' : 'bg-accent'
|
||||
}`}
|
||||
>
|
||||
{!excluded && <Check size={11} strokeWidth={3} aria-hidden="true" className="text-accent-text" />}
|
||||
</span>
|
||||
</button>
|
||||
<KindTile icon={glyph.Icon} tone={glyph.tone} size="sm" dimmed={excluded} />
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<RunName name={entry.name} removed={entry.change === 'remove'} />
|
||||
{entry.facts}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a run of changes gets from the change card. */
|
||||
export interface RunProps {
|
||||
items: readonly ChangeItem[];
|
||||
excluded: ReadonlySet<string>;
|
||||
onToggle(id: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two or more waiting changes of a list drawn as one checklist: each group's
|
||||
* head once, a row per change with its own include choice. Applying,
|
||||
* discarding and undoing stay with each change. `entryOf` reads a change's
|
||||
* card; one it cannot read is left out of the run.
|
||||
*/
|
||||
export function ChecklistRun({
|
||||
items,
|
||||
excluded,
|
||||
onToggle,
|
||||
entryOf,
|
||||
}: RunProps & { entryOf: (item: ChangeItem) => RunEntry | null }) {
|
||||
const entries = items.flatMap((item) => entryOf(item) ?? []);
|
||||
return (
|
||||
<div className="py-1">
|
||||
{entries.map((entry, i) => (
|
||||
<div key={entry.item.toolCallId}>
|
||||
{entries[i - 1]?.headKey !== entry.headKey && entry.head}
|
||||
<RunRow entry={entry} excluded={excluded.has(entry.item.toolCallId)} onToggle={onToggle} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row's name in a run: struck through for one a change removes. */
|
||||
function RunName({ name, removed }: { name: string; removed: boolean }) {
|
||||
return (
|
||||
<bdi className={`min-w-0 break-words text-body ${removed ? 'text-content-faint line-through' : 'text-content'}`}>
|
||||
{name}
|
||||
</bdi>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,58 @@
|
||||
import { ListTodo, Luggage } from 'lucide-react';
|
||||
|
||||
import type { PackLook } from '../cardTypes';
|
||||
import type { EntityLook, PackLook } from '../cardTypes';
|
||||
|
||||
/**
|
||||
* What the packing lists and to-dos 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. Applied items have no way to open yet, as
|
||||
* before. 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 packing lists and to-dos bring to the chat's pure look: the names
|
||||
* of their tools and reading steps, where an applied change of theirs opens
|
||||
* (the Lists tab, on its packing or to-do list), and how their answers group:
|
||||
* cards of one list and grouping fold into one, a tour's list or a template
|
||||
* stands on its own. A look stays pure, icons and literals only: the chat's
|
||||
* main chunk reads every look, so whatever a look imports ships with it.
|
||||
*/
|
||||
|
||||
const OPEN_PACKING: EntityLook['open'] = () => ({ kind: 'tab', tab: 'listen', sub: 'packing' });
|
||||
const OPEN_TODOS: EntityLook['open'] = () => ({ kind: 'tab', tab: 'listen', sub: 'todo' });
|
||||
|
||||
export const PACKING_LOOK = {
|
||||
verbs: {},
|
||||
tools: {},
|
||||
steps: {},
|
||||
tools: {
|
||||
bulk_import_packing: { labelKey: 'assistant.tool.bulk_import_packing' },
|
||||
apply_packing_template: { labelKey: 'assistant.tool.apply_packing_template' },
|
||||
set_packing_item_sharing: { labelKey: 'assistant.tool.set_packing_item_sharing' },
|
||||
set_packing_category_assignees: { labelKey: 'assistant.tool.set_packing_category_assignees' },
|
||||
reorder_packing_items: { labelKey: 'assistant.tool.reorder_packing_items' },
|
||||
create_packing_bag: { labelKey: 'assistant.tool.create_packing_bag' },
|
||||
update_packing_bag: { labelKey: 'assistant.tool.update_packing_bag' },
|
||||
delete_packing_bag: { labelKey: 'assistant.tool.delete_packing_bag' },
|
||||
set_bag_members: { labelKey: 'assistant.tool.set_bag_members' },
|
||||
save_packing_template: { labelKey: 'assistant.tool.save_packing_template' },
|
||||
delete_packing_template: { labelKey: 'assistant.tool.delete_packing_template' },
|
||||
set_todo_category_assignees: { labelKey: 'assistant.tool.set_todo_category_assignees' },
|
||||
reorder_todos: { labelKey: 'assistant.tool.reorder_todos' },
|
||||
link_tour_packing: { labelKey: 'assistant.tool.link_tour_packing' },
|
||||
unlink_tour_packing: { labelKey: 'assistant.tool.unlink_tour_packing' },
|
||||
},
|
||||
steps: {
|
||||
list_packing_items: { stepKey: 'assistant.step.packingItems' },
|
||||
list_packing_bags: { stepKey: 'assistant.step.packingBags' },
|
||||
get_packing_category_assignees: { stepKey: 'assistant.step.packingAssignees' },
|
||||
list_packing_templates: { stepKey: 'assistant.step.packingTemplates' },
|
||||
get_todo_category_assignees: { stepKey: 'assistant.step.todoAssignees' },
|
||||
get_tour_packing: { stepKey: 'assistant.step.tourPacking' },
|
||||
},
|
||||
entities: {
|
||||
packing: { Icon: Luggage },
|
||||
todo: { Icon: ListTodo },
|
||||
packing: { Icon: Luggage, openLabelKey: 'tours.packing.open', open: OPEN_PACKING },
|
||||
packing_bag: { Icon: Luggage, openLabelKey: 'tours.packing.open', open: OPEN_PACKING },
|
||||
todo: { Icon: ListTodo, openLabelKey: 'assistant.card.todo.open', open: OPEN_TODOS },
|
||||
},
|
||||
cards: {
|
||||
packingList: {},
|
||||
todoList: {},
|
||||
packingList: {
|
||||
entity: (card) =>
|
||||
card.groupBy === 'bag' && card.groups.every((group) => group.items.length === 0) ? 'packing_bag' : 'packing',
|
||||
group: (card) =>
|
||||
card.source.type === 'trip' ? `packingList:trip:${card.groupBy}` : `packingList:${card.source.type}`,
|
||||
},
|
||||
todoList: { entity: () => 'todo' },
|
||||
},
|
||||
} satisfies PackLook<'packingList' | 'todoList'>;
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import type { PackingCardBag, PackingCardGroup, PackingCardItem } from '@trek/shared';
|
||||
import { Hash, Lock, Luggage, PackageCheck, PencilLine, Scale, Tag, Users } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { safeHexColor } from '../../../../utils/safeColor';
|
||||
import { formatWeight } from '../../../Packing/packingListModel';
|
||||
import { bagFillPct } from '../../../Packing/packingListPanel.helpers';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { CountBadge } from '../../../Tours/packing/packingLinkParts';
|
||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||
import { PeopleStack, PersonFace } from '../parts/CardPeople';
|
||||
import { packingColorOf } from './checklistModel';
|
||||
import { ChecklistHead, ChecklistRow, MoreLine } from './checklistParts';
|
||||
|
||||
/**
|
||||
* The packing list's own pieces in the chat: a category's head with its
|
||||
* colour and people, a bag's head with its swatch, weight and fill, a row
|
||||
* with how many, how much it weighs, its bag and its tier, and the facts a
|
||||
* change states about a row, old values struck through.
|
||||
*/
|
||||
|
||||
/** A bag's colour, the accent where the bag has none yet. */
|
||||
const bagColor = (bag: PackingCardBag | undefined) => safeHexColor(bag?.color, 'var(--accent)');
|
||||
|
||||
/** The words for a tier, the people a shared item covers named. */
|
||||
function useTierLabel(): (tier: string, people?: readonly { name: string }[]) => string {
|
||||
const { t } = useTranslation();
|
||||
return (tier, people = []) => {
|
||||
if (tier === 'personal') return t('assistant.card.packing.tierPersonal');
|
||||
if (tier === 'shared' && people.length > 0) {
|
||||
return t('assistant.card.packing.sharedWith', { names: people.map((person) => person.name).join(', ') });
|
||||
}
|
||||
return tier === 'shared' ? t('packing.tierShared') : t('assistant.card.packing.tierCommon');
|
||||
};
|
||||
}
|
||||
|
||||
/** A bag's small square swatch beside a row; dashed for one a change creates. */
|
||||
function BagSwatch({ bag, size = 'row' }: { bag: PackingCardBag | undefined; size?: 'row' | 'head' }) {
|
||||
const { t } = useTranslation();
|
||||
const box = size === 'head' ? 'h-2.5 w-2.5 rounded-[3px]' : 'h-2 w-2 rounded-[2px]';
|
||||
const name = bag?.name ?? t('packing.noBag');
|
||||
if (!bag || bag.id === null) {
|
||||
return (
|
||||
<span role="img" aria-label={name} title={name} className={`${box} flex-none border border-dashed border-edge`} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip label={name}>
|
||||
<span role="img" aria-label={name} className={`${box} flex-none`} style={{ background: bagColor(bag) }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row says at its end: how many, how many packed, its weight, its tier
|
||||
* and its bag. A bag the card does not carry draws nothing rather than "Unassigned".
|
||||
*/
|
||||
export function PackingRowAside({ row, bag }: { row: PackingCardItem; bag: PackingCardBag | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const personal = t('assistant.card.packing.tierPersonal');
|
||||
const partial = !row.checked && row.packed > 0 && row.packed < row.quantity;
|
||||
return (
|
||||
<>
|
||||
{row.quantity > 1 && <WhiteBadge>×{row.quantity}</WhiteBadge>}
|
||||
{partial && <WhiteBadge>{`${row.packed}/${row.quantity}`}</WhiteBadge>}
|
||||
{row.weightGrams !== null && <WhiteBadge>{formatWeight(row.weightGrams)}</WhiteBadge>}
|
||||
{row.tier === 'personal' && (
|
||||
<Tooltip label={personal}>
|
||||
<span role="img" aria-label={personal} className="flex text-content-faint">
|
||||
<Lock size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.tier === 'shared' && <PeopleStack people={row.sharedWith} max={2} />}
|
||||
{bag && <BagSwatch bag={bag} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The facts a change states about a row: what it does, and every field it changes, old to new. */
|
||||
export function PackingRowFacts({
|
||||
row,
|
||||
bags,
|
||||
category,
|
||||
quiet = false,
|
||||
}: {
|
||||
row: PackingCardItem;
|
||||
bags: ReadonlyMap<string, PackingCardBag>;
|
||||
category: string;
|
||||
/** An import's rows carry no "New" each. */
|
||||
quiet?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tierLabel = useTierLabel();
|
||||
if (row.change === 'add') return quiet ? null : <ChangeMark change="new" />;
|
||||
if (row.change === 'remove') return <ChangeMark change="removed" />;
|
||||
if (row.change === 'check') return <SoftPill tone="success">{t('assistant.card.packing.packed')}</SoftPill>;
|
||||
if (row.change === 'uncheck') return <SoftPill>{t('assistant.card.packing.notPacked')}</SoftPill>;
|
||||
const bagName = (key: string | null, name?: string | null) =>
|
||||
(key ? bags.get(key)?.name : name) ?? t('packing.noBag');
|
||||
const noWeight = t('assistant.card.packing.noWeight');
|
||||
const grams = (value: string | number | null) => (value === null ? noWeight : formatWeight(Number(value)));
|
||||
const facts = row.changed.map(({ field, before }) => {
|
||||
if (field === 'name') return { field, icon: PencilLine, before, after: row.name };
|
||||
if (field === 'category') return { field, icon: Tag, before, after: category };
|
||||
if (field === 'quantity') return { field, icon: Hash, before, after: String(row.quantity) };
|
||||
if (field === 'packed') return { field, icon: PackageCheck, before, after: String(row.packed) };
|
||||
if (field === 'weight') return { field, icon: Scale, before: grams(before), after: grams(row.weightGrams) };
|
||||
if (field === 'bag') return { field, icon: Luggage, before: bagName(null, before), after: bagName(row.bagKey) };
|
||||
const icon = row.tier === 'common' ? Users : Lock;
|
||||
return { field, icon, before: before ? tierLabel(before) : null, after: tierLabel(row.tier, row.sharedWith) };
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{facts.map((fact) => (
|
||||
<FactChange key={fact.field} icon={fact.icon} before={fact.before} after={fact.after} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row of a packing card: an answer's as the list shows it, a change's with the facts of the change. */
|
||||
function PackingRow({
|
||||
row,
|
||||
bags,
|
||||
category,
|
||||
inChange,
|
||||
}: {
|
||||
row: PackingCardItem;
|
||||
bags: ReadonlyMap<string, PackingCardBag>;
|
||||
category: string;
|
||||
inChange: boolean;
|
||||
}) {
|
||||
const bag = row.bagKey ? bags.get(row.bagKey) : undefined;
|
||||
const facts =
|
||||
inChange && row.change !== 'none' ? (
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<PackingRowFacts row={row} bags={bags} category={category} quiet />
|
||||
</span>
|
||||
) : null;
|
||||
return (
|
||||
<ChecklistRow
|
||||
done={row.checked}
|
||||
pendingTick={inChange && row.change === 'check'}
|
||||
struck={row.change === 'remove'}
|
||||
name={row.name}
|
||||
aside={<PackingRowAside row={row} bag={bag} />}
|
||||
under={facts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** How full a bag is: a thin bar in its colour against its limit (or the heaviest bag), red past the limit. */
|
||||
function BagFill({ bag, heaviest }: { bag: PackingCardBag; heaviest: number }) {
|
||||
if (bag.weightGrams === null) return null;
|
||||
const over = bag.limitGrams !== null && bag.weightGrams > bag.limitGrams;
|
||||
const pct = bagFillPct(bag.weightGrams, bag.limitGrams, heaviest);
|
||||
return (
|
||||
<div className="mt-1 h-[3px] overflow-hidden rounded-full bg-surface-secondary" aria-hidden="true">
|
||||
<div
|
||||
className={`h-full rounded-full ${over ? 'bg-danger' : ''}`}
|
||||
style={over ? { width: `${pct}%` } : { width: `${pct}%`, background: bagColor(bag) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a change does to a bag: new, gone with where its items land, or its fields old to new. */
|
||||
function BagChange({ bag }: { bag: PackingCardBag }) {
|
||||
const { t } = useTranslation();
|
||||
if (bag.change === 'add') return <SoftPill tone="success">{t('assistant.card.packing.newBag')}</SoftPill>;
|
||||
if (bag.change === 'remove') {
|
||||
return (
|
||||
<>
|
||||
<ChangeMark change="removed" />
|
||||
{bag.movesOut > 0 && (
|
||||
<span className="text-caption text-content-muted">
|
||||
{t('assistant.card.packing.movesOut', { count: bag.movesOut })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{bag.changed.map(({ field, before }) => {
|
||||
if (field === 'name') return <FactChange key={field} icon={PencilLine} before={before} after={bag.name} />;
|
||||
if (field === 'limit') {
|
||||
const noLimit = t('assistant.card.packing.noLimit');
|
||||
const after = bag.limitGrams === null ? noLimit : formatWeight(bag.limitGrams);
|
||||
const old = before === null ? noLimit : formatWeight(Number(before));
|
||||
return <FactChange key={field} icon={Scale} before={old} after={after} />;
|
||||
}
|
||||
if (field === 'owner') {
|
||||
const owner = bag.owner?.name ?? t('todo.unassigned');
|
||||
return <FactChange key={field} icon={Users} before={before ?? t('todo.unassigned')} after={owner} />;
|
||||
}
|
||||
return <FactChange key={field} icon={Luggage} before={before} after={bag.color ?? ''} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A bag's head: swatch, name, weight against its limit, who it belongs to and who packs it, and its fill. */
|
||||
function BagHead({
|
||||
group,
|
||||
bag,
|
||||
heaviest,
|
||||
inChange,
|
||||
}: {
|
||||
group: PackingCardGroup;
|
||||
bag: PackingCardBag | undefined;
|
||||
heaviest: number;
|
||||
inChange: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!bag) {
|
||||
const badge = inChange ? undefined : <CountBadge count={group.count} />;
|
||||
const lead = <BagSwatch bag={undefined} size="head" />;
|
||||
return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />;
|
||||
}
|
||||
const over = bag.limitGrams !== null && bag.weightGrams !== null && bag.weightGrams > bag.limitGrams;
|
||||
const weight =
|
||||
bag.weightGrams === null
|
||||
? null
|
||||
: bag.limitGrams === null
|
||||
? formatWeight(bag.weightGrams)
|
||||
: t('assistant.card.packing.ofLimit', {
|
||||
weight: formatWeight(bag.weightGrams),
|
||||
limit: formatWeight(bag.limitGrams),
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<ChecklistHead
|
||||
color=""
|
||||
name={bag.name}
|
||||
lead={<BagSwatch bag={bag} size="head" />}
|
||||
badge={
|
||||
<>
|
||||
{weight && <WhiteBadge>{weight}</WhiteBadge>}
|
||||
{over && <SoftPill tone="danger">{t('assistant.card.packing.overLimit')}</SoftPill>}
|
||||
{bag.owner && <PersonFace person={bag.owner} size={20} />}
|
||||
</>
|
||||
}
|
||||
people={bag.members}
|
||||
removed={bag.membersRemoved}
|
||||
/>
|
||||
{inChange && bag.change !== 'none' && (
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1 ps-[18px]">
|
||||
<BagChange bag={bag} />
|
||||
</span>
|
||||
)}
|
||||
<BagFill bag={bag} heaviest={heaviest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of a packing card: its head and its rows, with how many more it
|
||||
* left out (on a change too, so an import of twenty never reads as eight),
|
||||
* and the people a change takes off it.
|
||||
*/
|
||||
export function PackingGroup({
|
||||
group,
|
||||
bags,
|
||||
heaviest,
|
||||
inChange,
|
||||
}: {
|
||||
group: PackingCardGroup;
|
||||
bags: ReadonlyMap<string, PackingCardBag>;
|
||||
heaviest: number;
|
||||
inChange: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const key = group.key;
|
||||
const name = key.by === 'category' ? (key.name ?? t('packing.defaultCategory')) : '';
|
||||
const bag = key.by === 'bag' && key.bagKey ? bags.get(key.bagKey) : undefined;
|
||||
const head =
|
||||
key.by === 'bag' ? (
|
||||
<BagHead group={group} bag={bag} heaviest={heaviest} inChange={inChange} />
|
||||
) : (
|
||||
<ChecklistHead
|
||||
color={packingColorOf(key.slot, name)}
|
||||
name={name}
|
||||
badge={inChange ? undefined : <CountBadge count={group.count} />}
|
||||
people={group.assignees}
|
||||
removed={group.assigneesRemoved}
|
||||
/>
|
||||
);
|
||||
const hidden = group.count - group.items.length;
|
||||
const gone = key.by === 'bag' ? (bag?.membersRemoved ?? []) : group.assigneesRemoved;
|
||||
return (
|
||||
<section className="flex min-w-0 flex-col">
|
||||
{head}
|
||||
{inChange && gone.length > 0 && (
|
||||
<p className="m-0 ps-[18px]">
|
||||
<NoLonger names={gone.map((person) => person.name)} />
|
||||
</p>
|
||||
)}
|
||||
{group.items.length > 0 && (
|
||||
<ul className="m-0 list-none p-0 ps-1">
|
||||
{group.items.map((row, i) => (
|
||||
<PackingRow key={`${row.id ?? 'new'}-${i}`} row={row} bags={bags} category={name} inChange={inChange} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{group.items.length > 0 && (
|
||||
<MoreLine label={hidden > 0 ? t('tours.packing.more', { count: hidden }) : null} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,31 @@
|
||||
import { type CardView, PENDING_VIEW } from '../cardTypes';
|
||||
import type { CardView } from '../cardTypes';
|
||||
import { isRunnable } from './checklistModel';
|
||||
import { PackingChangeBody, PackingChangeFacts, PackingChangeRun, PackingListBlock } from './PackingListCard';
|
||||
import { TodoChangeBody, TodoChangeLine, TodoChangeRun, TodoListBlock } from './TodoListCard';
|
||||
|
||||
/**
|
||||
* How the kinds of the packing lists and to-dos 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 packing lists and to-dos draw in the chat. An answer
|
||||
* draws its list in a card of its own; a change of one row states its facts
|
||||
* on its line and joins a run of such changes as one checklist; any other
|
||||
* change draws its groups, bags or people under its line. 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 PACKING_VIEWS = {
|
||||
packingList: PENDING_VIEW,
|
||||
todoList: PENDING_VIEW,
|
||||
packingList: {
|
||||
Block: PackingListBlock,
|
||||
Facts: PackingChangeFacts,
|
||||
Body: PackingChangeBody,
|
||||
Run: PackingChangeRun,
|
||||
runnable: isRunnable,
|
||||
},
|
||||
todoList: {
|
||||
Block: TodoListBlock,
|
||||
Facts: TodoChangeLine,
|
||||
Body: TodoChangeBody,
|
||||
Run: TodoChangeRun,
|
||||
runnable: isRunnable,
|
||||
},
|
||||
} satisfies { [K in 'packingList' | 'todoList']: CardView<K> };
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
import { PACKING_TOOLS } from './packing-tools';
|
||||
import { TODO_TOOLS } from './todo-cards';
|
||||
|
||||
/** The cards of packing lists and to-dos. None yet: this package fills them in. */
|
||||
export const PACKING_CARDS: DomainCards = { id: 'packing', tools: {} };
|
||||
/**
|
||||
* The packing lists and to-dos in the assistant chat: the lists a read shows
|
||||
* as cards, the card every change to an item, a bag, a category's people or a
|
||||
* to-do shows inside its change card, and the receipts that say which part of
|
||||
* the list a change touched.
|
||||
*/
|
||||
export const PACKING_CARDS: DomainCards = {
|
||||
id: 'packing',
|
||||
tools: { ...PACKING_TOOLS, ...TODO_TOOLS },
|
||||
prompt: [
|
||||
'Packing lists, bags and to-dos you read show as cards under your answer: do not list their items again.',
|
||||
'To add several packing items, make one bulk_import_packing call instead of many create_packing_item calls.',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { AnswerInput } from '../card-types';
|
||||
import { clipped, recordOf } from '../card-values';
|
||||
import {
|
||||
assigneesOf,
|
||||
bagsNamed,
|
||||
bagsOf,
|
||||
categoryOf,
|
||||
itemRow,
|
||||
listItemsOf,
|
||||
MAX_CARD_GROUPS,
|
||||
packingCard,
|
||||
packingGroups,
|
||||
positiveId,
|
||||
quantityOf,
|
||||
rowsOf,
|
||||
tripPacking,
|
||||
type ListItem,
|
||||
} from './packing-rows';
|
||||
import { PACKING_PLACEHOLDER_NAME, type PackingCardGroup, type PackingListCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The cards a packing read shows beside its answer, built from the read's
|
||||
* raw value: the list grouped the way the app groups it, the bags with what
|
||||
* they weigh, and the part of the list a tour points at. An empty list, no
|
||||
* bags and a tour that points at nothing (or at a category nobody can see an
|
||||
* item of any more) show no card; the answer says so in words.
|
||||
*/
|
||||
|
||||
/** `list_packing_items`: the items the user may see, grouped by category or by bag. */
|
||||
export function listItemsAnswer({ args, value, card }: AnswerInput): PackingListCard | null {
|
||||
const root = recordOf(value);
|
||||
const items = listItemsOf(root.items).filter((item) => !item.placeholder);
|
||||
if (items.length === 0) return null;
|
||||
const groupBy = args.group_by === 'bag' ? 'bag' : 'category';
|
||||
const bags = bagsOf(card, root);
|
||||
const assignees = recordOf(root.category_assignees);
|
||||
const { groups, moreGroups } = packingGroups(card, items, {
|
||||
groupBy,
|
||||
bagIds: bags.flatMap((bag) => bag.id ?? []),
|
||||
assigneesOf: (category) => (category === null ? [] : assigneesOf(card, assignees[category])),
|
||||
rowOf: (item) => itemRow(card, item),
|
||||
});
|
||||
const progress = { packed: items.filter((item) => item.checked).length, total: items.length };
|
||||
return packingCard({ type: 'trip' }, groupBy, bagsNamed(bags, groups), groups, { moreGroups, progress });
|
||||
}
|
||||
|
||||
/**
|
||||
* `list_packing_bags`: one head per bag with what it weighs and who packs it,
|
||||
* its items counted from the trip's list, and the pile in no bag when there is
|
||||
* one. No rows: a bag overview is its heads.
|
||||
*/
|
||||
export function listBagsAnswer({ value, card }: AnswerInput): PackingListCard | null {
|
||||
const bags = bagsOf(card, value);
|
||||
if (bags.length === 0) return null;
|
||||
const items = tripPacking(card).filter((item) => !item.placeholder);
|
||||
const group = (bagId: number | null): PackingCardGroup => {
|
||||
const inside = items.filter((item) => item.bagId === bagId);
|
||||
return {
|
||||
key: { by: 'bag', bagKey: bagId === null ? null : `id:${bagId}` },
|
||||
assignees: [],
|
||||
assigneesRemoved: [],
|
||||
items: [],
|
||||
count: inside.length,
|
||||
packed: inside.filter((item) => item.checked).length,
|
||||
};
|
||||
};
|
||||
const shown = bags.slice(0, MAX_CARD_GROUPS);
|
||||
const groups = shown.flatMap((bag) => (bag.id === null ? [] : [group(bag.id)]));
|
||||
const loose = group(null);
|
||||
if (loose.count > 0 && groups.length < MAX_CARD_GROUPS) groups.push(loose);
|
||||
return packingCard({ type: 'trip' }, 'bag', shown, groups, { moreGroups: bags.length - shown.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* `get_tour_packing`: the part of the list a tour points at, as the tour
|
||||
* shows it. What the trip's list knows of an item (its weight, tier)
|
||||
* fills in what the tour's answer leaves out; its bag is not named, since the
|
||||
* card reads no bags.
|
||||
*/
|
||||
export function tourPackingAnswer({ args, value, card }: AnswerInput): PackingListCard | null {
|
||||
const root = recordOf(value);
|
||||
const placeId = positiveId(args.placeId);
|
||||
if (placeId === null || !root.packing_link || root.state === 'missing') return null;
|
||||
const name = clipped(recordOf(root.tour).name, 200) ?? clipped(card.places.get(placeId)?.name, 200);
|
||||
if (name === null) return null;
|
||||
const known = new Map(tripPacking(card).map((item): [number, ListItem] => [item.id, item]));
|
||||
const items = rowsOf(root.items).flatMap((raw): ListItem[] => {
|
||||
const id = positiveId(raw.id);
|
||||
// The `...` stand-in of an empty category is never a row.
|
||||
const rawName = typeof raw.name === 'string' ? raw.name : null;
|
||||
if (rawName === null || rawName === PACKING_PLACEHOLDER_NAME) return [];
|
||||
const shown = clipped(rawName, 200);
|
||||
if (id === null || shown === null) return [];
|
||||
const base = known.get(id);
|
||||
return [
|
||||
{
|
||||
id,
|
||||
name: shown,
|
||||
rawName,
|
||||
category: categoryOf(raw.category),
|
||||
quantity: quantityOf(raw.quantity),
|
||||
packedQuantity: base?.packedQuantity ?? null,
|
||||
checked: raw.packed === true,
|
||||
unitGrams: base?.unitGrams ?? null,
|
||||
bagId: base?.bagId ?? null,
|
||||
isPrivate: base?.isPrivate ?? false,
|
||||
ownerId: base?.ownerId ?? null,
|
||||
recipientIds: base?.recipientIds ?? [],
|
||||
placeholder: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
const { groups, moreGroups } = packingGroups(card, items, {
|
||||
groupBy: 'category',
|
||||
bagIds: [],
|
||||
rowOf: (item) => itemRow(card, item),
|
||||
});
|
||||
const category = clipped(recordOf(root.packing_link).category, 200);
|
||||
const progress = { packed: items.filter((item) => item.checked).length, total: items.length };
|
||||
return packingCard({ type: 'tour', placeId, name, category }, 'category', [], groups, { moreGroups, progress });
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import type { TripCard } from '../../assistant-context';
|
||||
import type { CardReader, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, clipped, hexOrNull, recordOf, type Row } from '../card-values';
|
||||
import {
|
||||
argText,
|
||||
bagOf,
|
||||
bagsOf,
|
||||
categoryCard,
|
||||
categoryOf,
|
||||
gramsOf,
|
||||
idsOf,
|
||||
itemRow,
|
||||
MAX_PACKING_ROWS,
|
||||
packingCard,
|
||||
packingGroups,
|
||||
peopleOf,
|
||||
positiveId,
|
||||
quantityOf,
|
||||
rowsOf,
|
||||
slotOf,
|
||||
tierOf,
|
||||
tripItem,
|
||||
tripPacking,
|
||||
type ListItem,
|
||||
type PackingTier,
|
||||
} from './packing-rows';
|
||||
import {
|
||||
resolvePackedState,
|
||||
type PackingCardBag,
|
||||
type PackingCardItem,
|
||||
type PackingListCard,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The card each packing change shows inside its change card, from the call's
|
||||
* arguments, the trip's list as the turn read it, and what the change's
|
||||
* lookup found (the bags, the templates, who looks after a category). The
|
||||
* defaults are the server's own: a new item goes under "General", an imported
|
||||
* one under "Other", a quantity is 1 to 999, a new bag is indigo. A change to
|
||||
* an item, bag or template the turn cannot find shows no card; the server
|
||||
* refuses it anyway.
|
||||
*/
|
||||
|
||||
/** The colour `create_packing_bag` gives a bag without one. */
|
||||
const DEFAULT_BAG_COLOR = '#6366f1';
|
||||
/** New bags one import card names. */
|
||||
const MAX_NEW_BAGS = 20;
|
||||
|
||||
type Changed = PackingCardItem['changed'];
|
||||
|
||||
/** The bags as the lookup read them, raw. */
|
||||
function rawBags(loaded: unknown): Row[] {
|
||||
return rowsOf(recordOf(loaded).bags);
|
||||
}
|
||||
|
||||
/** Reads the trip's bags, for a change that names one. */
|
||||
export function loadBags(_args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return read('list_packing_bags');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the bags only when the call names one (`bag_id`), also to take an
|
||||
* item out of its bag (`null`), so the card can name the bag it leaves.
|
||||
*/
|
||||
export function loadBagsWhenNamed(args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return args.bag_id === undefined ? Promise.resolve(null) : read('list_packing_bags');
|
||||
}
|
||||
|
||||
/** Reads the bags only when an imported item names one by its name. */
|
||||
export function loadBagsForImport(args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
const named = rowsOf(args.items).some((item) => typeof item.bag === 'string' && item.bag.trim() !== '');
|
||||
return named ? read('list_packing_bags') : Promise.resolve(null);
|
||||
}
|
||||
|
||||
export function loadTemplates(_args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return read('list_packing_templates');
|
||||
}
|
||||
|
||||
export function loadPackingAssignees(_args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return read('get_packing_category_assignees');
|
||||
}
|
||||
|
||||
/** The tier a write's arguments put an item in, the server's way (`visibility` before `is_private`). */
|
||||
function visibilityOf(args: ToolArgs): PackingTier {
|
||||
const visibility = args.visibility;
|
||||
if (visibility === 'common' || visibility === 'personal' || visibility === 'shared') return visibility;
|
||||
return args.is_private === true ? 'personal' : 'common';
|
||||
}
|
||||
|
||||
/** The people a shared item covers: trip members, never its owner (the server drops both). */
|
||||
function recipientsOf(card: TripCard, value: unknown): number[] {
|
||||
return idsOf(value).filter((id) => id !== card.viewerId && card.members.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* One item in its category, with the bag it goes into. The category's name is
|
||||
* masked when it comes from the trip, kept as written from arguments.
|
||||
*/
|
||||
function oneItemCard(
|
||||
card: TripCard,
|
||||
item: ListItem,
|
||||
row: PackingCardItem,
|
||||
bags: PackingCardBag[],
|
||||
mask = true,
|
||||
): PackingListCard {
|
||||
const bag = bags.find((b) => b.id !== null && b.id === item.bagId);
|
||||
const name = mask ? clipped(item.category, 100) : argText(item.category, 100);
|
||||
return categoryCard(card, { name, raw: item.category, personal: item.isPrivate }, [row], bag ? [bag] : []);
|
||||
}
|
||||
|
||||
/** `create_packing_item`: the new row under its category. */
|
||||
export function createItemCard(ctx: PreviewContext, args: ToolArgs, loaded: unknown): PackingListCard | null {
|
||||
const name = argText(args.name, 200);
|
||||
if (name === null) return null;
|
||||
const tier = visibilityOf(args);
|
||||
const bags = bagsOf(ctx.card, loaded);
|
||||
const bagId = positiveId(args.bag_id);
|
||||
const item: ListItem = {
|
||||
id: 0,
|
||||
name,
|
||||
rawName: name,
|
||||
category: argText(args.category, 100) ?? 'General',
|
||||
quantity: quantityOf(args.quantity),
|
||||
packedQuantity: null,
|
||||
checked: !!args.checked,
|
||||
unitGrams: gramsOf(args.weight_grams),
|
||||
bagId: bags.some((bag) => bag.id === bagId) ? bagId : null,
|
||||
isPrivate: tier !== 'common',
|
||||
ownerId: ctx.card.viewerId,
|
||||
recipientIds: tier === 'shared' ? recipientsOf(ctx.card, args.recipient_ids) : [],
|
||||
placeholder: false,
|
||||
};
|
||||
return oneItemCard(ctx.card, item, itemRow(ctx.card, item, 'add'), bags, false);
|
||||
}
|
||||
|
||||
/** An imported weight as the server keeps it: whole grams cut (`parseInt`), none for 0. */
|
||||
function importGrams(value: unknown): number | null {
|
||||
return gramsOf(typeof value === 'number' ? Math.trunc(value) : value) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `bulk_import_packing`: every imported item under its category ("Other"
|
||||
* without one), a bag named by an existing bag's exact name going into that
|
||||
* bag and any other name making a new one, as the server does.
|
||||
*/
|
||||
export function bulkImportCard(ctx: PreviewContext, args: ToolArgs, loaded: unknown): PackingListCard | null {
|
||||
const existing = rawBags(loaded);
|
||||
const known = bagsOf(ctx.card, loaded);
|
||||
const created: PackingCardBag[] = [];
|
||||
const keys = new Map<number, string>();
|
||||
const keyOf = (bagName: string): string | null => {
|
||||
const found = positiveId(existing.find((bag) => bag.name === bagName)?.id);
|
||||
if (found !== null) return `id:${found}`;
|
||||
const shown = argText(bagName, 100);
|
||||
const already = created.find((bag) => bag.name === shown);
|
||||
if (already) return already.key;
|
||||
if (shown === null || created.length >= MAX_NEW_BAGS) return null;
|
||||
const bag = newBag(`new:${created.length}`, shown, null, null);
|
||||
created.push(bag);
|
||||
return bag.key;
|
||||
};
|
||||
const items = rowsOf(args.items).flatMap((raw, index): ListItem[] => {
|
||||
const name = argText(raw.name, 200);
|
||||
if (name === null) return [];
|
||||
const bagName = typeof raw.bag === 'string' ? raw.bag.trim() : '';
|
||||
const key = bagName === '' ? null : keyOf(bagName);
|
||||
if (key) keys.set(-1 - index, key);
|
||||
return [
|
||||
{
|
||||
id: -1 - index,
|
||||
name,
|
||||
rawName: name,
|
||||
category: argText(raw.category, 100) ?? 'Other',
|
||||
quantity: quantityOf(raw.quantity),
|
||||
packedQuantity: null,
|
||||
checked: raw.checked === true,
|
||||
unitGrams: importGrams(raw.weight_grams),
|
||||
bagId: null,
|
||||
isPrivate: false,
|
||||
ownerId: ctx.card.viewerId,
|
||||
recipientIds: [],
|
||||
placeholder: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
if (items.length === 0) return null;
|
||||
const { groups, moreGroups } = packingGroups(ctx.card, items, {
|
||||
groupBy: 'category',
|
||||
bagIds: [],
|
||||
categoryName: (category) => argText(category, 100),
|
||||
rowOf: (item) => ({ ...itemRow(ctx.card, item, 'add'), bagKey: keys.get(item.id) ?? null }),
|
||||
maxRows: MAX_PACKING_ROWS,
|
||||
fillSingleGroup: true,
|
||||
});
|
||||
const named = new Set(groups.flatMap((group) => group.items.flatMap((row) => row.bagKey ?? [])));
|
||||
const bags = [...known, ...created].filter((bag) => named.has(bag.key));
|
||||
return packingCard({ type: 'trip' }, 'category', bags, groups, { moreGroups });
|
||||
}
|
||||
|
||||
/** A bag a change creates, before the server has given it an id. */
|
||||
function newBag(key: string, name: string, color: string | null, limitGrams: number | null): PackingCardBag {
|
||||
return {
|
||||
key,
|
||||
id: null,
|
||||
name,
|
||||
color,
|
||||
weightGrams: null,
|
||||
limitGrams,
|
||||
owner: null,
|
||||
members: [],
|
||||
membersRemoved: [],
|
||||
change: 'add',
|
||||
changed: [],
|
||||
movesOut: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** `apply_packing_template`: the template and how many items it adds; its items cannot be read. */
|
||||
export function applyTemplateCard(_ctx: PreviewContext, args: ToolArgs, loaded: unknown): PackingListCard | null {
|
||||
const templateId = argId(args, 'templateId');
|
||||
const template = rowsOf(recordOf(loaded).templates).find((row) => row.id === templateId);
|
||||
const name = clipped(template?.name, 100);
|
||||
if (templateId === null || !template || name === null) return null;
|
||||
const itemCount = Math.max(0, Math.min(5_000, Math.floor(Number(template.item_count) || 0)));
|
||||
return packingCard({ type: 'template', templateId, name, itemCount }, 'category', [], []);
|
||||
}
|
||||
|
||||
/**
|
||||
* `toggle_packing_item`: the row ticked off or not, its packed count with it.
|
||||
* The row keeps how many were packed before, so the tick is only taken back
|
||||
* when that brings the item back to where it was.
|
||||
*/
|
||||
export function toggleItemCard(ctx: PreviewContext, args: ToolArgs): PackingListCard | null {
|
||||
const before = tripItem(ctx.card, argId(args, 'itemId'));
|
||||
if (!before) return null;
|
||||
const checked = args.checked === true;
|
||||
const after = { ...before, checked, packedQuantity: null };
|
||||
const changed: Changed = [{ field: 'packed', before: String(itemRow(ctx.card, before).packed) }];
|
||||
return oneItemCard(ctx.card, after, itemRow(ctx.card, after, checked ? 'check' : 'uncheck', changed), []);
|
||||
}
|
||||
|
||||
/** `delete_packing_item`: the row as it is, about to go. */
|
||||
export function deleteItemCard(ctx: PreviewContext, args: ToolArgs): PackingListCard | null {
|
||||
const before = tripItem(ctx.card, argId(args, 'itemId'));
|
||||
return before ? oneItemCard(ctx.card, before, itemRow(ctx.card, before, 'remove'), []) : null;
|
||||
}
|
||||
|
||||
function bagName(bags: readonly PackingCardBag[], bagId: number | null): string | null {
|
||||
return bagId === null ? null : (bags.find((bag) => bag.id === bagId)?.name ?? null);
|
||||
}
|
||||
|
||||
/** What a field read before a change, as stored. */
|
||||
function grams(value: number | null): string | null {
|
||||
return value === null ? null : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_packing_item`: the row with the arguments merged in, under its new
|
||||
* category. Taking it out of its bag is `bag_id: null`; a packed count that
|
||||
* reaches the quantity ticks it off, 0 or null clears the count, as the
|
||||
* server settles them (`resolvePackedState`). Every field that changes keeps
|
||||
* what it held before.
|
||||
*/
|
||||
export function updateItemCard(ctx: PreviewContext, args: ToolArgs, loaded: unknown): PackingListCard | null {
|
||||
const before = tripItem(ctx.card, argId(args, 'itemId'));
|
||||
if (!before) return null;
|
||||
const bags = bagsOf(ctx.card, loaded);
|
||||
const quantity = args.quantity === undefined ? before.quantity : quantityOf(args.quantity);
|
||||
const bodyKeys = args.packed_quantity === undefined ? [] : ['packed_quantity'];
|
||||
const packed = resolvePackedState(
|
||||
{ checked: before.checked ? 1 : 0, packed_quantity: before.packedQuantity },
|
||||
{ bodyKeys, packed_quantity: typeof args.packed_quantity === 'number' ? args.packed_quantity : null, quantity },
|
||||
);
|
||||
// A name sent again as it is stays the one shown, masked as the trip read it.
|
||||
const named = argText(args.name, 200);
|
||||
const after: ListItem = {
|
||||
...before,
|
||||
name: named === null || named === argText(before.rawName, 200) ? before.name : named,
|
||||
rawName: named ?? before.rawName,
|
||||
category: args.category === undefined ? before.category : (argText(args.category, 100) ?? before.category),
|
||||
quantity,
|
||||
checked: packed.checked === 1,
|
||||
packedQuantity: packed.packed_quantity,
|
||||
unitGrams: args.weight_grams === undefined ? before.unitGrams : gramsOf(args.weight_grams),
|
||||
bagId: args.bag_id === undefined ? before.bagId : positiveId(args.bag_id),
|
||||
isPrivate: typeof args.is_private === 'boolean' ? args.is_private : before.isPrivate,
|
||||
};
|
||||
const changed: Changed = [];
|
||||
const note = (field: Changed[number]['field'], old: string | null, now: string | null) => {
|
||||
if (old !== now) changed.push({ field, before: old === null ? null : old.slice(0, 120) });
|
||||
};
|
||||
note('name', before.name, after.name);
|
||||
if (before.category !== after.category) changed.push({ field: 'category', before: clipped(before.category, 100) });
|
||||
note('quantity', String(before.quantity), String(after.quantity));
|
||||
note('packed', String(itemRow(ctx.card, before).packed), String(itemRow(ctx.card, after).packed));
|
||||
// Weights compare as the row shows them, every piece together.
|
||||
if (before.unitGrams !== after.unitGrams) {
|
||||
const total = (item: ListItem) => itemRow(ctx.card, item).weightGrams;
|
||||
changed.push({ field: 'weight', before: grams(total(before)) });
|
||||
}
|
||||
note('bag', bagName(bags, before.bagId), bagName(bags, after.bagId));
|
||||
note('tier', tierOf(before), tierOf(after));
|
||||
// A bag the lookup does not know stays unnamed: the row shows no swatch rather than a wrong one.
|
||||
return oneItemCard(ctx.card, after, itemRow(ctx.card, after, 'update', changed), bags, args.category === undefined);
|
||||
}
|
||||
|
||||
/** `set_packing_item_sharing`: the row in the tier it moves to, with the people it covers. */
|
||||
export function sharingCard(ctx: PreviewContext, args: ToolArgs): PackingListCard | null {
|
||||
const before = tripItem(ctx.card, argId(args, 'itemId'));
|
||||
if (!before) return null;
|
||||
const tier = visibilityOf(args);
|
||||
const after: ListItem = {
|
||||
...before,
|
||||
isPrivate: tier !== 'common',
|
||||
recipientIds: tier === 'shared' ? recipientsOf(ctx.card, args.recipient_ids) : [],
|
||||
};
|
||||
const row = itemRow(ctx.card, after, 'share', [{ field: 'tier', before: tierOf(before) }]);
|
||||
return oneItemCard(ctx.card, after, row, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* `set_packing_category_assignees`: the category's head with the people it
|
||||
* will have, new ones marked, and the ones it drops; its items are counted.
|
||||
*/
|
||||
export function packingAssigneesCard(ctx: PreviewContext, args: ToolArgs, loaded: unknown): PackingListCard | null {
|
||||
const raw = typeof args.categoryName === 'string' ? args.categoryName : null;
|
||||
const name = argText(raw, 100);
|
||||
if (raw === null || name === null) return null;
|
||||
const chosen = idsOf(args.userIds);
|
||||
const before = rowsOf(recordOf(recordOf(loaded).assignees)[raw]).flatMap((row) => positiveId(row.user_id) ?? []);
|
||||
const inside = tripPacking(ctx.card).filter((item) => !item.placeholder && item.category === raw);
|
||||
const personal = inside.length > 0 && inside.every((item) => item.isPrivate);
|
||||
return packingCard({ type: 'trip' }, 'category', [], [
|
||||
{
|
||||
key: { by: 'category', name, slot: slotOf(ctx.card, raw, personal) },
|
||||
assignees: peopleOf(ctx.card, chosen, 6),
|
||||
assigneesRemoved: peopleOf(ctx.card, before.filter((id) => !chosen.includes(id)), 6),
|
||||
items: [],
|
||||
count: inside.length,
|
||||
packed: inside.filter((item) => item.checked).length,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** A bag head on a card of its own: what a change to one bag shows. */
|
||||
function bagCard(card: TripCard, bag: PackingCardBag): PackingListCard {
|
||||
const inside = tripPacking(card).filter((item) => !item.placeholder && bag.id !== null && item.bagId === bag.id);
|
||||
return packingCard({ type: 'trip' }, 'bag', [bag], [
|
||||
{
|
||||
key: { by: 'bag', bagKey: bag.key },
|
||||
assignees: [],
|
||||
assigneesRemoved: [],
|
||||
items: [],
|
||||
count: inside.length,
|
||||
packed: inside.filter((item) => item.checked).length,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** The bag a call names (`bagId`), as the lookup read it. */
|
||||
function namedBag(card: TripCard, args: ToolArgs, loaded: unknown): PackingCardBag | null {
|
||||
const bagId = argId(args, 'bagId');
|
||||
return bagId === null ? null : bagOf(card, rawBags(loaded).find((bag) => bag.id === bagId));
|
||||
}
|
||||
|
||||
/** `create_packing_bag`: the new bag's head, in its colour (indigo without one) and with its limit. */
|
||||
export function createBagCard(ctx: PreviewContext, args: ToolArgs): PackingListCard | null {
|
||||
const name = argText(args.name, 100);
|
||||
if (name === null) return null;
|
||||
const bag = newBag('new:0', name, hexOrNull(args.color) ?? DEFAULT_BAG_COLOR, gramsOf(args.weight_limit_grams));
|
||||
return bagCard(ctx.card, bag);
|
||||
}
|
||||
|
||||
/** `update_packing_bag`: the bag with the arguments merged in, each changed field with its old value. */
|
||||
export function updateBagCard(ctx: PreviewContext, args: ToolArgs, loaded: unknown): PackingListCard | null {
|
||||
const before = namedBag(ctx.card, args, loaded);
|
||||
if (!before) return null;
|
||||
const ownerId = args.user_id === undefined ? (before.owner?.id ?? null) : positiveId(args.user_id);
|
||||
const after: PackingCardBag = {
|
||||
...before,
|
||||
name: argText(args.name, 100) ?? before.name,
|
||||
color: args.color === undefined ? before.color : (hexOrNull(args.color) ?? before.color),
|
||||
limitGrams: args.weight_limit_grams === undefined ? before.limitGrams : gramsOf(args.weight_limit_grams),
|
||||
owner: ownerId === null ? null : (peopleOf(ctx.card, [ownerId], 1)[0] ?? null),
|
||||
change: 'update',
|
||||
};
|
||||
const changed: PackingCardBag['changed'] = [];
|
||||
if (after.name !== before.name) changed.push({ field: 'name', before: before.name.slice(0, 120) });
|
||||
if (after.color !== before.color) changed.push({ field: 'color', before: before.color });
|
||||
if (after.limitGrams !== before.limitGrams) changed.push({ field: 'limit', before: grams(before.limitGrams) });
|
||||
if (after.owner?.id !== before.owner?.id) changed.push({ field: 'owner', before: before.owner?.name ?? null });
|
||||
return bagCard(ctx.card, { ...after, changed });
|
||||
}
|
||||
|
||||
/** `set_bag_members`: the bag with the people who will pack it, and the ones it drops. */
|
||||
export function bagMembersCard(ctx: PreviewContext, args: ToolArgs, loaded: unknown): PackingListCard | null {
|
||||
const before = namedBag(ctx.card, args, loaded);
|
||||
if (!before) return null;
|
||||
const chosen = idsOf(args.userIds);
|
||||
const members = peopleOf(ctx.card, chosen, 6);
|
||||
const membersRemoved = before.members.filter((person) => !chosen.includes(person.id));
|
||||
return bagCard(ctx.card, { ...before, members, membersRemoved, change: 'members' });
|
||||
}
|
||||
|
||||
/** `delete_packing_bag`: the bag about to go, and how many items land in "Unassigned". */
|
||||
export function deleteBagCard(ctx: PreviewContext, args: ToolArgs, loaded: unknown): PackingListCard | null {
|
||||
const before = namedBag(ctx.card, args, loaded);
|
||||
if (!before) return null;
|
||||
const movesOut = tripPacking(ctx.card).filter((item) => !item.placeholder && item.bagId === before.id).length;
|
||||
return bagCard(ctx.card, { ...before, change: 'remove', movesOut: Math.min(movesOut, 5_000) });
|
||||
}
|
||||
|
||||
/**
|
||||
* `link_tour_packing`: the part of the list the tour will point at, as the
|
||||
* tour will show it (the whole list, or one category of it).
|
||||
*/
|
||||
export function linkTourCard(ctx: PreviewContext, args: ToolArgs): PackingListCard | null {
|
||||
const placeId = argId(args, 'placeId');
|
||||
const name = clipped(placeId === null ? null : ctx.card.places.get(placeId)?.name, 200);
|
||||
if (placeId === null || name === null) return null;
|
||||
const category = categoryOf(args.category);
|
||||
const items = tripPacking(ctx.card).filter((item) => category === null || item.category === category);
|
||||
const { groups, moreGroups } = packingGroups(ctx.card, items, {
|
||||
groupBy: 'category',
|
||||
bagIds: [],
|
||||
rowOf: (item) => itemRow(ctx.card, item),
|
||||
});
|
||||
const shown = category === null ? null : argText(category, 200);
|
||||
return packingCard({ type: 'tour', placeId, name, category: shown }, 'category', [], groups, { moreGroups });
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import type { TripCard } from '../../assistant-context';
|
||||
import { clipped, finite, hexOrNull, personOf, recordOf, type Row } from '../card-values';
|
||||
import {
|
||||
PACKING_PLACEHOLDER_NAME,
|
||||
type CardPerson,
|
||||
type PackingCardBag,
|
||||
type PackingCardGroup,
|
||||
type PackingCardItem,
|
||||
type PackingListCard,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The pieces every packing card is built from: an item as the packing tools
|
||||
* answer it, its row on a card, its group (a category or a bag) and the bags.
|
||||
* Text from a tool's answer is masked like a provider answer (`clipped`), text
|
||||
* from a call's arguments only cut (`argText`), since it is what will be
|
||||
* stored. The `...` stand-in of an empty category is never a row and never
|
||||
* counted, but it still holds its category's place in the list's order.
|
||||
*/
|
||||
|
||||
/** Rows one packing card shows, and at most per group. */
|
||||
export const MAX_PACKING_ROWS = 24;
|
||||
const ROWS_PER_GROUP = 8;
|
||||
export const MAX_CARD_GROUPS = 12;
|
||||
const MAX_CARD_BAGS = 20;
|
||||
const MAX_GRAMS = 1_000_000;
|
||||
|
||||
export type PackingTier = PackingCardItem['tier'];
|
||||
type Source = PackingListCard['source'];
|
||||
type GroupBy = PackingListCard['groupBy'];
|
||||
|
||||
/** A packing item as `get_trip_summary`, `list_packing_items` and the writes answer it. */
|
||||
export interface ListItem {
|
||||
id: number;
|
||||
/** As shown: masked when it comes from a tool's answer. */
|
||||
name: string;
|
||||
/** As stored, to tell whether a write really renames it; never shown. */
|
||||
rawName: string;
|
||||
category: string | null;
|
||||
quantity: number;
|
||||
packedQuantity: number | null;
|
||||
checked: boolean;
|
||||
/** The weight of one piece. */
|
||||
unitGrams: number | null;
|
||||
bagId: number | null;
|
||||
isPrivate: boolean;
|
||||
ownerId: number | null;
|
||||
recipientIds: number[];
|
||||
/** The `...` stand-in of an empty category. */
|
||||
placeholder: boolean;
|
||||
}
|
||||
|
||||
export function rowsOf(value: unknown): Row[] {
|
||||
return Array.isArray(value) ? value.map(recordOf) : [];
|
||||
}
|
||||
|
||||
export function positiveId(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
function cut(text: string, max: number): string {
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||
}
|
||||
|
||||
/** A text from a call's arguments as it will be stored: on one line and cut to `max`, never masked. */
|
||||
export function argText(value: unknown, max: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const line = value.replace(/\s+/g, ' ').trim();
|
||||
return line === '' ? null : cut(line, max);
|
||||
}
|
||||
|
||||
/** How many of an item: what the server keeps, 1 to 999. */
|
||||
export function quantityOf(value: unknown): number {
|
||||
const n = finite(value);
|
||||
return n === null ? 1 : Math.min(999, Math.max(1, Math.floor(n)));
|
||||
}
|
||||
|
||||
export function gramsOf(value: unknown): number | null {
|
||||
const n = finite(value);
|
||||
return n === null || n < 0 ? null : Math.min(MAX_GRAMS, Math.round(n));
|
||||
}
|
||||
|
||||
/** A category as stored: an empty one is none. */
|
||||
export function categoryOf(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() !== '' ? value : null;
|
||||
}
|
||||
|
||||
/** One packing item from a tool's answer; null for a row without an id or a name. */
|
||||
function listItemOf(raw: unknown): ListItem | null {
|
||||
const row = recordOf(raw);
|
||||
const id = positiveId(row.id);
|
||||
const name = typeof row.name === 'string' ? row.name : null;
|
||||
if (id === null || name === null) return null;
|
||||
const placeholder = name === PACKING_PLACEHOLDER_NAME;
|
||||
const shown = placeholder ? name : clipped(name, 200);
|
||||
if (shown === null) return null;
|
||||
return {
|
||||
id,
|
||||
name: shown,
|
||||
rawName: name,
|
||||
category: categoryOf(row.category),
|
||||
quantity: quantityOf(row.quantity),
|
||||
packedQuantity: finite(row.packed_quantity),
|
||||
checked: !!row.checked,
|
||||
unitGrams: gramsOf(row.weight_grams),
|
||||
bagId: positiveId(row.bag_id),
|
||||
isPrivate: !!row.is_private,
|
||||
ownerId: positiveId(row.owner_id),
|
||||
recipientIds: rowsOf(row.recipients).flatMap((r) => positiveId(r.user_id) ?? []),
|
||||
placeholder,
|
||||
};
|
||||
}
|
||||
|
||||
export function listItemsOf(value: unknown): ListItem[] {
|
||||
return rowsOf(value).flatMap((row) => listItemOf(row) ?? []);
|
||||
}
|
||||
|
||||
const TRIP_PACKING = new WeakMap<TripCard, ListItem[]>();
|
||||
|
||||
/** The trip's packing list as the turn read it, the stand-ins included; read once per card. */
|
||||
export function tripPacking(card: TripCard): ListItem[] {
|
||||
let items = TRIP_PACKING.get(card);
|
||||
if (!items) {
|
||||
items = listItemsOf(recordOf(recordOf(card.sources.summary).packing).items);
|
||||
TRIP_PACKING.set(card, items);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** The trip's item `id` as the turn read it. */
|
||||
export function tripItem(card: TripCard, id: number | null): ListItem | null {
|
||||
return id === null ? null : (tripPacking(card).find((item) => item.id === id && !item.placeholder) ?? null);
|
||||
}
|
||||
|
||||
/** The tier an item sits in (#858): the common list, the owner's own, or shared with named people. */
|
||||
export function tierOf(item: Pick<ListItem, 'isPrivate' | 'recipientIds'>): PackingTier {
|
||||
if (!item.isPrivate) return 'common';
|
||||
return item.recipientIds.length > 0 ? 'shared' : 'personal';
|
||||
}
|
||||
|
||||
/** The trip members among `ids`, in order, at most `max`; anyone outside the trip is left out. */
|
||||
export function peopleOf(card: TripCard, ids: readonly number[], max: number): CardPerson[] {
|
||||
return [...new Set(ids)].flatMap((id) => personOf(card, id) ?? []).slice(0, max);
|
||||
}
|
||||
|
||||
/** The ids of an id list argument. */
|
||||
export function idsOf(value: unknown): number[] {
|
||||
return Array.isArray(value) ? value.flatMap((v: unknown) => positiveId(v) ?? []) : [];
|
||||
}
|
||||
|
||||
/** How many of an item are packed: all of it once ticked, the partial count before (#2296). */
|
||||
function packedOf(item: Pick<ListItem, 'checked' | 'quantity' | 'packedQuantity'>): number {
|
||||
return item.checked ? item.quantity : Math.max(0, Math.min(item.packedQuantity ?? 0, item.quantity));
|
||||
}
|
||||
|
||||
const bagKeyOf = (bagId: number | null): string | null => (bagId === null ? null : `id:${bagId}`);
|
||||
|
||||
/** An item's row on a card; without the trip card (an applied change) nobody it is shared with is named. */
|
||||
export function itemRow(
|
||||
card: TripCard | null,
|
||||
item: ListItem,
|
||||
change: PackingCardItem['change'] = 'none',
|
||||
changed: PackingCardItem['changed'] = [],
|
||||
): PackingCardItem {
|
||||
const tier = tierOf(item);
|
||||
return {
|
||||
id: item.id > 0 ? item.id : null,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
packed: packedOf(item),
|
||||
checked: item.checked,
|
||||
weightGrams: item.unitGrams === null ? null : Math.min(MAX_GRAMS, item.unitGrams * item.quantity),
|
||||
bagKey: bagKeyOf(item.bagId),
|
||||
tier,
|
||||
sharedWith: card && tier === 'shared' ? peopleOf(card, item.recipientIds, 4) : [],
|
||||
change,
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The categories of a view of the list in the order it first shows them,
|
||||
* the way the packing panel colours them (`packingCategoryOrder`): the common
|
||||
* list, or the personal one.
|
||||
*/
|
||||
function categoryOrder(items: readonly ListItem[], personal: boolean): Array<string | null> {
|
||||
const order: Array<string | null> = [];
|
||||
for (const item of items) {
|
||||
if (item.isPrivate === personal && !order.includes(item.category)) order.push(item.category);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour slot of a category: its place in the order of the view its items
|
||||
* sit in (the personal one when every one of them is restricted), null for a
|
||||
* category the trip's list does not hold yet, and for every one without the trip card.
|
||||
*/
|
||||
export function slotOf(card: TripCard | null, category: string | null, personal: boolean): number | null {
|
||||
if (!card) return null;
|
||||
const index = categoryOrder(tripPacking(card), personal).indexOf(category);
|
||||
return index < 0 ? null : index;
|
||||
}
|
||||
|
||||
/** The people of an assignee list (`[{ user_id }]`), as trip members. */
|
||||
export function assigneesOf(card: TripCard, raw: unknown): CardPerson[] {
|
||||
return peopleOf(card, rowsOf(raw).flatMap((row) => positiveId(row.user_id) ?? []), 6);
|
||||
}
|
||||
|
||||
/** One bag as `list_packing_bags` answers it. */
|
||||
export function bagOf(card: TripCard, raw: unknown): PackingCardBag | null {
|
||||
const row = recordOf(raw);
|
||||
const id = positiveId(row.id);
|
||||
const name = clipped(row.name, 100);
|
||||
if (id === null || name === null) return null;
|
||||
const ownerId = positiveId(row.user_id);
|
||||
return {
|
||||
key: `id:${id}`,
|
||||
id,
|
||||
name,
|
||||
color: hexOrNull(row.color),
|
||||
weightGrams: gramsOf(row.total_weight_grams),
|
||||
limitGrams: gramsOf(row.weight_limit_grams),
|
||||
owner: ownerId === null ? null : personOf(card, ownerId),
|
||||
members: assigneesOf(card, row.members),
|
||||
membersRemoved: [],
|
||||
change: 'none',
|
||||
changed: [],
|
||||
movesOut: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** The bags of a `list_packing_bags` answer, in its order. */
|
||||
export function bagsOf(card: TripCard, value: unknown): PackingCardBag[] {
|
||||
return rowsOf(recordOf(value).bags).flatMap((row) => bagOf(card, row) ?? []);
|
||||
}
|
||||
|
||||
/** A group of items on its way to the card. */
|
||||
interface Bucket {
|
||||
key: PackingCardGroup['key'];
|
||||
items: ListItem[];
|
||||
assignees: CardPerson[];
|
||||
}
|
||||
|
||||
interface GroupOptions {
|
||||
groupBy: GroupBy;
|
||||
/** The bags in the list's order; a bag group follows it, the pile in no bag comes last. */
|
||||
bagIds: readonly number[];
|
||||
/** Who looks after a category. */
|
||||
assigneesOf?: (category: string | null) => CardPerson[];
|
||||
/** How a category from the answer is shown. */
|
||||
categoryName?: (category: string) => string | null;
|
||||
rowOf: (item: ListItem) => PackingCardItem;
|
||||
maxRows?: number;
|
||||
/** A card of one group shows up to `maxRows` of it, not eight (a change that adds many rows to one category). */
|
||||
fillSingleGroup?: boolean;
|
||||
}
|
||||
|
||||
function bucketsOf(card: TripCard | null, items: readonly ListItem[], options: GroupOptions): Bucket[] {
|
||||
const buckets = new Map<string, Bucket>();
|
||||
const name = options.categoryName ?? ((category: string) => clipped(category, 100));
|
||||
for (const item of items) {
|
||||
const key = options.groupBy === 'bag' ? `b${item.bagId ?? ''}` : `c${item.category ?? ''}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
key:
|
||||
options.groupBy === 'bag'
|
||||
? { by: 'bag', bagKey: bagKeyOf(item.bagId) }
|
||||
: { by: 'category', name: item.category === null ? null : name(item.category), slot: null },
|
||||
items: [],
|
||||
assignees: options.groupBy === 'category' ? (options.assigneesOf?.(item.category) ?? []) : [],
|
||||
};
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
bucket.items.push(item);
|
||||
}
|
||||
const list = [...buckets.values()];
|
||||
for (const bucket of list) {
|
||||
if (bucket.key.by !== 'category') continue;
|
||||
const personal = bucket.items.every((item) => item.isPrivate);
|
||||
bucket.key = { ...bucket.key, slot: slotOf(card, bucket.items[0]?.category ?? null, personal) };
|
||||
}
|
||||
if (options.groupBy !== 'bag') return list;
|
||||
const rank = (bucket: Bucket) => {
|
||||
const bagKey = bucket.key.by === 'bag' ? bucket.key.bagKey : null;
|
||||
if (bagKey === null) return Number.MAX_SAFE_INTEGER;
|
||||
const index = options.bagIds.indexOf(Number(bagKey.slice(3)));
|
||||
return index < 0 ? options.bagIds.length : index;
|
||||
};
|
||||
return list.sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* The groups of a card: by category in the order the items first show them,
|
||||
* or by bag in the bags' order with the pile in no bag last. At most twelve
|
||||
* groups and 24 rows (eight per group); every group still counts all of its
|
||||
* items, and the groups left out are counted on the card.
|
||||
*/
|
||||
export function packingGroups(
|
||||
card: TripCard | null,
|
||||
items: readonly ListItem[],
|
||||
options: GroupOptions,
|
||||
): { groups: PackingCardGroup[]; moreGroups: number } {
|
||||
const buckets = bucketsOf(card, items.filter((item) => !item.placeholder), options);
|
||||
let left = options.maxRows ?? MAX_PACKING_ROWS;
|
||||
const perGroup = options.fillSingleGroup && buckets.length === 1 ? left : ROWS_PER_GROUP;
|
||||
const groups = buckets.slice(0, MAX_CARD_GROUPS).map((bucket): PackingCardGroup => {
|
||||
const shown = bucket.items.slice(0, Math.min(perGroup, left));
|
||||
left -= shown.length;
|
||||
return {
|
||||
key: bucket.key,
|
||||
assignees: bucket.assignees,
|
||||
assigneesRemoved: [],
|
||||
items: shown.map(options.rowOf),
|
||||
count: bucket.items.length,
|
||||
packed: bucket.items.filter((item) => item.checked).length,
|
||||
};
|
||||
});
|
||||
return { groups, moreGroups: Math.max(0, buckets.length - MAX_CARD_GROUPS) };
|
||||
}
|
||||
|
||||
/** The bags a card names: those its groups or rows point at, in the list's order. */
|
||||
export function bagsNamed(bags: readonly PackingCardBag[], groups: readonly PackingCardGroup[]): PackingCardBag[] {
|
||||
const keys = new Set<string>();
|
||||
for (const group of groups) {
|
||||
if (group.key.by === 'bag' && group.key.bagKey) keys.add(group.key.bagKey);
|
||||
for (const item of group.items) if (item.bagKey) keys.add(item.bagKey);
|
||||
}
|
||||
return bags.filter((bag) => keys.has(bag.key)).slice(0, MAX_CARD_BAGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows with a bag the card does not carry, taken out of it: a card that
|
||||
* read no bags (a tick, a tour) cannot name or colour the bag, and a row
|
||||
* pointing at a bag the reader cannot see would read as "Unassigned".
|
||||
*/
|
||||
function withKnownBags(groups: PackingCardGroup[], bags: readonly PackingCardBag[]): PackingCardGroup[] {
|
||||
const keys = new Set(bags.map((bag) => bag.key));
|
||||
const unknown = (row: PackingCardItem) => row.bagKey !== null && !keys.has(row.bagKey);
|
||||
return groups.map((group) =>
|
||||
group.items.some(unknown)
|
||||
? { ...group, items: group.items.map((row) => (unknown(row) ? { ...row, bagKey: null } : row)) }
|
||||
: group,
|
||||
);
|
||||
}
|
||||
|
||||
/** A packing card; a row's bag is always one of the card's bags. */
|
||||
export function packingCard(
|
||||
source: Source,
|
||||
groupBy: GroupBy,
|
||||
bags: PackingCardBag[],
|
||||
groups: PackingCardGroup[],
|
||||
extra: { moreGroups?: number; progress?: PackingListCard['progress'] } = {},
|
||||
): PackingListCard {
|
||||
const shown = bags.slice(0, MAX_CARD_BAGS);
|
||||
return {
|
||||
kind: 'packingList',
|
||||
source,
|
||||
groupBy,
|
||||
progress: extra.progress ?? null,
|
||||
bags: shown,
|
||||
groups: withKnownBags(groups, shown),
|
||||
moreGroups: extra.moreGroups ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** A card of one category group holding `rows`: what a change to one item shows. */
|
||||
export function categoryCard(
|
||||
card: TripCard,
|
||||
category: { name: string | null; raw: string | null; personal: boolean },
|
||||
rows: PackingCardItem[],
|
||||
bags: PackingCardBag[] = [],
|
||||
): PackingListCard {
|
||||
const group: PackingCardGroup = {
|
||||
key: { by: 'category', name: category.name, slot: slotOf(card, category.raw, category.personal) },
|
||||
assignees: [],
|
||||
assigneesRemoved: [],
|
||||
items: rows,
|
||||
count: rows.length,
|
||||
packed: rows.filter((row) => row.checked).length,
|
||||
};
|
||||
return packingCard({ type: 'trip' }, 'category', bags, [group]);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { Described, PreviewContext, ToolArgs, ToolCards } from '../card-types';
|
||||
import { argId, idIn, readKey, recordOf } from '../card-values';
|
||||
import { memberNames, Rows, text } from '../preview-format';
|
||||
import { byId, same, STABLE_FIELDS } from '../undo-snapshot';
|
||||
import { listBagsAnswer, listItemsAnswer, tourPackingAnswer } from './packing-answers';
|
||||
import {
|
||||
applyTemplateCard,
|
||||
bagMembersCard,
|
||||
bulkImportCard,
|
||||
createBagCard,
|
||||
createItemCard,
|
||||
deleteBagCard,
|
||||
deleteItemCard,
|
||||
linkTourCard,
|
||||
loadBags,
|
||||
loadBagsForImport,
|
||||
loadBagsWhenNamed,
|
||||
loadPackingAssignees,
|
||||
loadTemplates,
|
||||
packingAssigneesCard,
|
||||
sharingCard,
|
||||
toggleItemCard,
|
||||
updateBagCard,
|
||||
updateItemCard,
|
||||
} from './packing-changes';
|
||||
import { idsOf, itemRow, listItemsOf, packingCard, packingGroups, positiveId, rowsOf, tripItem } from './packing-rows';
|
||||
import type { AssistantChangePreview, PackingListCard, TrekCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Every packing tool's entry: the cards of the reads, the change card of
|
||||
* every write, the words of the writes the core has no card for (named by
|
||||
* their own label, so the receipt says the bag or the list it touched), the
|
||||
* ways back from a tick and a new bag, and the ids a receipt names.
|
||||
*/
|
||||
|
||||
/** Ids one receipt names at most. */
|
||||
const MAX_APPLIED_IDS = 50;
|
||||
|
||||
/** The words of a change with its tool's own label. */
|
||||
function words(tool: string, subject: string, rows: Rows = new Rows()): Described {
|
||||
return { subject, rows, dayId: null, placeIds: [], tool };
|
||||
}
|
||||
|
||||
/** The item a call works on, named from the trip's list. */
|
||||
function itemName(ctx: PreviewContext, args: ToolArgs): string {
|
||||
const itemId = argId(args, 'itemId');
|
||||
return tripItem(ctx.card, itemId)?.name ?? (itemId === null ? '' : `#${itemId}`);
|
||||
}
|
||||
|
||||
/** A row the lookup read, by its id argument (`bagId` in `bags`, `templateId` in `templates`). */
|
||||
function lookedUp(loaded: unknown, list: string, args: ToolArgs, key: string): Record<string, unknown> | null {
|
||||
const wanted = argId(args, key);
|
||||
return wanted === null ? null : (rowsOf(recordOf(loaded)[list]).find((row) => row.id === wanted) ?? null);
|
||||
}
|
||||
|
||||
function lookedUpName(loaded: unknown, list: string, args: ToolArgs, key: string): string {
|
||||
const row = lookedUp(loaded, list, args, key);
|
||||
const wanted = argId(args, key);
|
||||
return row ? text(row.name) : wanted === null ? '' : `#${wanted}`;
|
||||
}
|
||||
|
||||
const grams = (value: unknown) => (typeof value === 'number' ? `${value} g` : '');
|
||||
|
||||
/** The ids a receipt names from an answer's `items` (an import, a template). */
|
||||
function itemIds(_args: ToolArgs, result: unknown): number[] {
|
||||
return rowsOf(recordOf(result).items)
|
||||
.flatMap((row) => positiveId(row.id) ?? [])
|
||||
.slice(0, MAX_APPLIED_IDS);
|
||||
}
|
||||
|
||||
const bagIdArg = (args: ToolArgs): number[] => {
|
||||
const bagId = argId(args, 'bagId');
|
||||
return bagId === null ? [] : [bagId];
|
||||
};
|
||||
|
||||
/** The applied card of a single new row: the preview's, with the id the server gave it. */
|
||||
function withCreatedId(result: unknown, preview: AssistantChangePreview | null): TrekCard | null {
|
||||
const id = positiveId(recordOf(recordOf(result).item).id);
|
||||
const card = preview?.card;
|
||||
if (id === null || card?.kind !== 'packingList') return null;
|
||||
const groups = card.groups.map((group) => ({
|
||||
...group,
|
||||
items: group.items.map((row) => (row.id === null ? { ...row, id } : row)),
|
||||
}));
|
||||
return { ...card, groups };
|
||||
}
|
||||
|
||||
/** The applied card of a template: the items it added, by category, under the template's name. */
|
||||
function templateApplied(result: unknown, preview: AssistantChangePreview | null): PackingListCard | null {
|
||||
const items = listItemsOf(recordOf(result).items).filter((item) => !item.placeholder);
|
||||
const card = preview?.card;
|
||||
if (items.length === 0 || card?.kind !== 'packingList') return null;
|
||||
const { groups, moreGroups } = packingGroups(null, items, {
|
||||
groupBy: 'category',
|
||||
bagIds: [],
|
||||
rowOf: (item) => ({ ...itemRow(null, item, 'add'), bagKey: null }),
|
||||
});
|
||||
return packingCard(card.source, 'category', [], groups, { moreGroups });
|
||||
}
|
||||
|
||||
/** How many of a ticked row were packed before the tick, as its change card kept it, and of how many. */
|
||||
function packedBefore(preview: AssistantChangePreview | null): { packed: number; quantity: number } | null {
|
||||
const card = preview?.card;
|
||||
const row = card?.kind === 'packingList' ? card.groups[0]?.items[0] : undefined;
|
||||
const kept = row?.changed.find((entry) => entry.field === 'packed')?.before;
|
||||
return row && typeof kept === 'string' ? { packed: Number(kept), quantity: row.quantity } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ticked item taken back with the opposite tick, while it is still as the
|
||||
* tick left it, and only where that is where it was: a tick of an item with
|
||||
* nothing packed, an untick of one packed in full. A tick that changed
|
||||
* nothing, or one that wiped a partial count, has no way back.
|
||||
*/
|
||||
const UNTOGGLE_ITEM: NonNullable<ToolCards['undo']> = {
|
||||
tool: 'toggle_packing_item',
|
||||
args: (args, result, preview) => {
|
||||
const itemId = idIn(result, 'item') ?? argId(args, 'itemId');
|
||||
if (itemId === null || typeof args.checked !== 'boolean') return null;
|
||||
const was = packedBefore(preview);
|
||||
if (!was || was.packed !== (args.checked ? 0 : was.quantity)) return null;
|
||||
return { itemId, checked: !args.checked };
|
||||
},
|
||||
unchanged: (result, snapshot) => {
|
||||
const item = recordOf(recordOf(result).item);
|
||||
const items = rowsOf(recordOf(recordOf(snapshot.summary).packing).items);
|
||||
return same(item, byId(items, item.id), STABLE_FIELDS.packing);
|
||||
},
|
||||
};
|
||||
|
||||
const BAG_READ = { tool: 'list_packing_bags', args: {} };
|
||||
|
||||
/**
|
||||
* A new bag taken back while nobody has used it: it still has the name,
|
||||
* colour, limit and owner it was made with, nobody packs it, no item the user
|
||||
* can see sits in it and it weighs nothing (its weight counts every member's
|
||||
* items, so a hidden item with a weight shows there). Deleting a bag only
|
||||
* unpacks its items, so an item another member keeps to themselves would only
|
||||
* land in "Unassigned".
|
||||
*/
|
||||
const DELETE_CREATED_BAG: NonNullable<ToolCards['undo']> = {
|
||||
tool: 'delete_packing_bag',
|
||||
args: (_args, result) => {
|
||||
const bagId = idIn(result, 'bag');
|
||||
return bagId ? { bagId } : null;
|
||||
},
|
||||
reads: () => [BAG_READ],
|
||||
unchanged: (result, snapshot) => {
|
||||
const bag = recordOf(recordOf(result).bag);
|
||||
const bags = rowsOf(recordOf(snapshot.reads.get(readKey(BAG_READ.tool, BAG_READ.args))).bags);
|
||||
const current = byId(bags, bag.id);
|
||||
if (!current || !same(bag, current, ['name', 'color', 'weight_limit_grams', 'user_id'])) return false;
|
||||
const items = rowsOf(recordOf(recordOf(snapshot.summary).packing).items);
|
||||
const empty = !Number(current.total_weight_grams ?? 0);
|
||||
return empty && rowsOf(current.members).length === 0 && !items.some((item) => item.bag_id === bag.id);
|
||||
},
|
||||
};
|
||||
|
||||
const ITEM_UPDATE = { verbKey: 'genericUpdate', entity: 'packing' } as const;
|
||||
const BAG_UPDATE = { verbKey: 'genericUpdate', entity: 'packing_bag' } as const;
|
||||
|
||||
export const PACKING_TOOLS: Readonly<Record<string, ToolCards>> = {
|
||||
list_packing_items: { answer: listItemsAnswer },
|
||||
list_packing_bags: { answer: listBagsAnswer },
|
||||
get_tour_packing: { answer: tourPackingAnswer },
|
||||
link_tour_packing: { change: linkTourCard },
|
||||
create_packing_item: {
|
||||
load: loadBagsWhenNamed,
|
||||
change: createItemCard,
|
||||
applied: ({ result, preview }) => withCreatedId(result, preview),
|
||||
},
|
||||
update_packing_item: { load: loadBagsWhenNamed, change: updateItemCard },
|
||||
toggle_packing_item: {
|
||||
write: ITEM_UPDATE,
|
||||
describe: (ctx, args) => {
|
||||
const before = tripItem(ctx.card, argId(args, 'itemId'));
|
||||
const rows = new Rows();
|
||||
if (typeof args.checked === 'boolean') {
|
||||
rows.change('packed', before ? text(before.checked) : null, text(args.checked));
|
||||
}
|
||||
return words('toggle_packing_item', itemName(ctx, args), rows);
|
||||
},
|
||||
change: toggleItemCard,
|
||||
undo: UNTOGGLE_ITEM,
|
||||
},
|
||||
delete_packing_item: {
|
||||
write: { verbKey: 'genericRemove', entity: 'packing' },
|
||||
describe: (ctx, args) => words('delete_packing_item', itemName(ctx, args)),
|
||||
change: deleteItemCard,
|
||||
},
|
||||
set_packing_item_sharing: {
|
||||
write: ITEM_UPDATE,
|
||||
describe: (ctx, args) =>
|
||||
words(
|
||||
'set_packing_item_sharing',
|
||||
itemName(ctx, args),
|
||||
new Rows().add('type', text(args.visibility)).add('members', memberNames(ctx, args.recipient_ids)),
|
||||
),
|
||||
change: sharingCard,
|
||||
},
|
||||
bulk_import_packing: {
|
||||
write: { verbKey: 'genericCreate', entity: 'packing' },
|
||||
load: loadBagsForImport,
|
||||
describe: (_ctx, args) => {
|
||||
const names = rowsOf(args.items).flatMap((row) => (typeof row.name === 'string' ? row.name : []));
|
||||
return words('bulk_import_packing', text(names.join(', ')), new Rows().add('quantity', text(names.length || '')));
|
||||
},
|
||||
change: bulkImportCard,
|
||||
appliedIds: itemIds,
|
||||
},
|
||||
apply_packing_template: {
|
||||
write: { verbKey: 'genericCreate', entity: 'packing' },
|
||||
load: loadTemplates,
|
||||
describe: (_ctx, args, loaded) =>
|
||||
words(
|
||||
'apply_packing_template',
|
||||
lookedUpName(loaded, 'templates', args, 'templateId'),
|
||||
new Rows().add('type', text(args.visibility)),
|
||||
),
|
||||
change: applyTemplateCard,
|
||||
applied: ({ result, preview }) => templateApplied(result, preview),
|
||||
appliedIds: itemIds,
|
||||
},
|
||||
reorder_packing_items: {
|
||||
write: ITEM_UPDATE,
|
||||
describe: (ctx, args) => {
|
||||
const names = idsOf(args.orderedIds).map((id) => tripItem(ctx.card, id)?.name ?? `#${id}`);
|
||||
return words('reorder_packing_items', text(names.join(', ')));
|
||||
},
|
||||
},
|
||||
set_packing_category_assignees: {
|
||||
write: ITEM_UPDATE,
|
||||
load: loadPackingAssignees,
|
||||
describe: (ctx, args) =>
|
||||
words(
|
||||
'set_packing_category_assignees',
|
||||
text(args.categoryName),
|
||||
new Rows().add('members', memberNames(ctx, args.userIds)),
|
||||
),
|
||||
change: packingAssigneesCard,
|
||||
},
|
||||
create_packing_bag: {
|
||||
write: { verbKey: 'genericCreate', entity: 'packing_bag' },
|
||||
describe: (_ctx, args) =>
|
||||
words(
|
||||
'create_packing_bag',
|
||||
text(args.name),
|
||||
new Rows().add('color', text(args.color)).add('weight', grams(args.weight_limit_grams)),
|
||||
),
|
||||
change: createBagCard,
|
||||
appliedIds: (_args, result) => {
|
||||
const bagId = idIn(result, 'bag');
|
||||
return bagId === null ? [] : [bagId];
|
||||
},
|
||||
undo: DELETE_CREATED_BAG,
|
||||
},
|
||||
update_packing_bag: {
|
||||
write: BAG_UPDATE,
|
||||
load: loadBags,
|
||||
describe: (ctx, args, loaded) => {
|
||||
const bag = lookedUp(loaded, 'bags', args, 'bagId');
|
||||
const rows = new Rows();
|
||||
if (args.name !== undefined) rows.change('name', bag ? text(bag.name) : null, text(args.name));
|
||||
if (args.color !== undefined) rows.change('color', bag ? text(bag.color) : null, text(args.color));
|
||||
if (args.weight_limit_grams !== undefined) {
|
||||
rows.change('weight', bag ? grams(bag.weight_limit_grams) : null, grams(args.weight_limit_grams));
|
||||
}
|
||||
if (args.user_id !== undefined) {
|
||||
const owner = (id: unknown) => memberNames(ctx, typeof id === 'number' ? [id] : null);
|
||||
rows.change('assignee', bag ? owner(bag.user_id) : null, owner(args.user_id));
|
||||
}
|
||||
return words('update_packing_bag', lookedUpName(loaded, 'bags', args, 'bagId'), rows);
|
||||
},
|
||||
change: updateBagCard,
|
||||
appliedIds: bagIdArg,
|
||||
},
|
||||
delete_packing_bag: {
|
||||
write: { verbKey: 'genericRemove', entity: 'packing_bag' },
|
||||
load: loadBags,
|
||||
describe: (_ctx, args, loaded) => words('delete_packing_bag', lookedUpName(loaded, 'bags', args, 'bagId')),
|
||||
change: deleteBagCard,
|
||||
appliedIds: bagIdArg,
|
||||
},
|
||||
set_bag_members: {
|
||||
write: BAG_UPDATE,
|
||||
load: loadBags,
|
||||
describe: (ctx, args, loaded) =>
|
||||
words(
|
||||
'set_bag_members',
|
||||
lookedUpName(loaded, 'bags', args, 'bagId'),
|
||||
new Rows().add('members', memberNames(ctx, args.userIds)),
|
||||
),
|
||||
change: bagMembersCard,
|
||||
appliedIds: bagIdArg,
|
||||
},
|
||||
save_packing_template: {
|
||||
// Templates are global, not part of the trip, like the one `delete_packing_template` drops.
|
||||
write: { verbKey: 'genericCreate', entity: 'other' },
|
||||
describe: (_ctx, args) => words('save_packing_template', text(args.templateName)),
|
||||
},
|
||||
delete_packing_template: {
|
||||
write: { verbKey: 'genericRemove', entity: 'other' },
|
||||
load: loadTemplates,
|
||||
describe: (_ctx, args, loaded) =>
|
||||
words('delete_packing_template', lookedUpName(loaded, 'templates', args, 'templateId')),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { AnswerInput, PreviewContext, ToolArgs, ToolCards } from '../card-types';
|
||||
import { argId, clipped, recordOf } from '../card-values';
|
||||
import { formatDate, memberNames, Rows, text } from '../preview-format';
|
||||
import { byId, same, STABLE_FIELDS } from '../undo-snapshot';
|
||||
import { argText, idsOf, peopleOf, positiveId, rowsOf } from './packing-rows';
|
||||
import {
|
||||
dateOf,
|
||||
listTodosOf,
|
||||
priorityOf,
|
||||
todoCard,
|
||||
todoGroups,
|
||||
todoRow,
|
||||
todoSlot,
|
||||
tripTodo,
|
||||
tripTodos,
|
||||
type ListTodo,
|
||||
} from './todo-rows';
|
||||
import type { AssistantChangePreview, TodoCardItem, TodoListCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The to-dos in the assistant chat: the list a read shows, grouped as the
|
||||
* to-do panel groups it, and the card each change to a to-do or to a list's
|
||||
* people shows inside its change card. A change names the to-do from the
|
||||
* trip as the turn read it; one it cannot find shows no card.
|
||||
*/
|
||||
|
||||
/** `list_todos`: the trip's to-dos by list, with what is open, done and overdue. */
|
||||
function listTodosAnswer({ value, card, today }: AnswerInput): TodoListCard | null {
|
||||
const todos = listTodosOf(recordOf(value).items);
|
||||
if (todos.length === 0) return null;
|
||||
const { groups, moreGroups } = todoGroups(card, todos);
|
||||
const open = todos.filter((todo) => !todo.checked);
|
||||
const progress = {
|
||||
open: open.length,
|
||||
done: todos.length - open.length,
|
||||
overdue: open.filter((todo) => todo.dueDate !== null && todo.dueDate < today).length,
|
||||
};
|
||||
return todoCard(groups, { moreGroups, progress });
|
||||
}
|
||||
|
||||
/**
|
||||
* One to-do in its list: what a change to a single to-do shows. The list's
|
||||
* name is masked when it comes from the trip, kept as written from arguments.
|
||||
*/
|
||||
function oneTodoCard(ctx: PreviewContext, row: TodoCardItem, category: string | null, mask = true): TodoListCard {
|
||||
const name = mask ? clipped(category, 100) : argText(category, 100);
|
||||
return todoCard([
|
||||
{
|
||||
category: name,
|
||||
slot: todoSlot(ctx.card, category),
|
||||
assignees: [],
|
||||
assigneesRemoved: [],
|
||||
items: [row],
|
||||
count: 1,
|
||||
open: row.checked ? 0 : 1,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function has(args: ToolArgs, key: string): boolean {
|
||||
return args[key] !== undefined;
|
||||
}
|
||||
|
||||
function descriptionOf(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
/** A to-do with the fields a write names merged in; `null` clears a field that may be empty. */
|
||||
function mergedTodo(before: ListTodo, args: ToolArgs): ListTodo {
|
||||
// A name sent again as it is stays the one shown, masked as the trip read it.
|
||||
const named = has(args, 'name') ? argText(args.name, 300) : null;
|
||||
return {
|
||||
...before,
|
||||
name: named === null || named === argText(before.rawName, 300) ? before.name : named,
|
||||
rawName: named ?? before.rawName,
|
||||
category: has(args, 'category') ? argText(args.category, 100) : before.category,
|
||||
dueDate: has(args, 'due_date') ? dateOf(args.due_date) : before.dueDate,
|
||||
description: has(args, 'description') ? descriptionOf(args.description) : before.description,
|
||||
assigneeId: has(args, 'assigned_user_id') ? positiveId(args.assigned_user_id) : before.assigneeId,
|
||||
priority: has(args, 'priority') ? priorityOf(args.priority) : before.priority,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields a write changes, each with what it held before. The notes are
|
||||
* the rows' own (the old one masked), the list names the old one masked.
|
||||
*/
|
||||
function todoChanges(
|
||||
ctx: PreviewContext,
|
||||
before: ListTodo,
|
||||
after: ListTodo,
|
||||
notes: [string | null, string | null],
|
||||
): TodoCardItem['changed'] {
|
||||
const name = (id: number | null) => (id === null ? null : (ctx.card.members.get(id)?.name ?? null));
|
||||
// Each field with its old value as shown, and whether the write changes it.
|
||||
const fields: Array<[TodoCardItem['changed'][number]['field'], string | null, boolean]> = [
|
||||
['name', before.name, before.name !== after.name],
|
||||
['note', notes[0], notes[0] !== notes[1]],
|
||||
['category', clipped(before.category, 100), before.category !== after.category],
|
||||
['dueDate', before.dueDate, before.dueDate !== after.dueDate],
|
||||
['priority', String(before.priority), before.priority !== after.priority],
|
||||
['assignee', name(before.assigneeId), before.assigneeId !== after.assigneeId],
|
||||
];
|
||||
return fields
|
||||
.filter(([, , changes]) => changes)
|
||||
.map(([field, old]) => ({ field, before: old === null ? null : old.slice(0, 120) }));
|
||||
}
|
||||
|
||||
function createTodoCard(ctx: PreviewContext, args: ToolArgs): TodoListCard | null {
|
||||
const name = argText(args.name, 300);
|
||||
if (name === null) return null;
|
||||
const category = argText(args.category, 100);
|
||||
const todo: ListTodo = {
|
||||
id: 0,
|
||||
name,
|
||||
rawName: name,
|
||||
category,
|
||||
checked: false,
|
||||
dueDate: dateOf(args.due_date),
|
||||
priority: priorityOf(args.priority),
|
||||
assigneeId: positiveId(args.assigned_user_id),
|
||||
description: descriptionOf(args.description),
|
||||
};
|
||||
return oneTodoCard(ctx, todoRow(ctx.card, todo, { mask: false, change: 'add' }), category, false);
|
||||
}
|
||||
|
||||
function updateTodoCard(ctx: PreviewContext, args: ToolArgs): TodoListCard | null {
|
||||
const before = tripTodo(ctx.card, argId(args, 'itemId'));
|
||||
if (!before) return null;
|
||||
const after = mergedTodo(before, args);
|
||||
// The note comes from the arguments only when the write sets it.
|
||||
const row = todoRow(ctx.card, after, { mask: !has(args, 'description'), change: 'update' });
|
||||
const oldNote = todoRow(ctx.card, before, { mask: true }).note;
|
||||
const changed = todoChanges(ctx, before, after, [oldNote, row.note]);
|
||||
return oneTodoCard(ctx, { ...row, changed }, after.category, !has(args, 'category'));
|
||||
}
|
||||
|
||||
/** A tick, keeping whether the to-do was done before, so the tick is only taken back when it changed that. */
|
||||
function toggleTodoCard(ctx: PreviewContext, args: ToolArgs): TodoListCard | null {
|
||||
const before = tripTodo(ctx.card, argId(args, 'itemId'));
|
||||
if (!before) return null;
|
||||
const checked = args.checked === true;
|
||||
const changed: TodoCardItem['changed'] = [{ field: 'done', before: before.checked ? '1' : '0' }];
|
||||
const change = checked ? 'check' : 'uncheck';
|
||||
const row = todoRow(ctx.card, { ...before, checked }, { mask: true, change, changed });
|
||||
return oneTodoCard(ctx, row, before.category);
|
||||
}
|
||||
|
||||
function deleteTodoCard(ctx: PreviewContext, args: ToolArgs): TodoListCard | null {
|
||||
const before = tripTodo(ctx.card, argId(args, 'itemId'));
|
||||
return before ? oneTodoCard(ctx, todoRow(ctx.card, before, { mask: true, change: 'remove' }), before.category) : null;
|
||||
}
|
||||
|
||||
/** The default assignees a list has now, by list name (`get_todo_category_assignees`). */
|
||||
type TodoAssignees = Record<string, unknown>;
|
||||
|
||||
async function loadTodoAssignees(_args: ToolArgs, read: (tool: string) => Promise<unknown>): Promise<TodoAssignees> {
|
||||
return recordOf(recordOf(await read('get_todo_category_assignees')).assignees);
|
||||
}
|
||||
|
||||
/** `set_todo_category_assignees`: the list's head with the people it will have and the ones it drops. */
|
||||
function todoAssigneesCard(ctx: PreviewContext, args: ToolArgs, loaded: TodoAssignees): TodoListCard | null {
|
||||
const category = argText(args.categoryName, 100);
|
||||
if (category === null || typeof args.categoryName !== 'string') return null;
|
||||
const chosen = idsOf(args.userIds);
|
||||
const before = rowsOf(loaded[args.categoryName]).flatMap((row) => positiveId(row.user_id) ?? []);
|
||||
const inList = tripTodos(ctx.card).filter((todo) => todo.category === args.categoryName);
|
||||
return todoCard([
|
||||
{
|
||||
category,
|
||||
slot: todoSlot(ctx.card, args.categoryName),
|
||||
assignees: peopleOf(ctx.card, chosen, 6),
|
||||
assigneesRemoved: peopleOf(ctx.card, before.filter((id) => !chosen.includes(id)), 6),
|
||||
items: [],
|
||||
count: inList.length,
|
||||
open: inList.filter((todo) => !todo.checked).length,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** The name of the to-do a call works on, from the trip as the turn read it. */
|
||||
function todoName(ctx: PreviewContext, args: ToolArgs): string {
|
||||
const itemId = argId(args, 'itemId');
|
||||
return tripTodo(ctx.card, itemId)?.name ?? (itemId === null ? '' : `#${itemId}`);
|
||||
}
|
||||
|
||||
function updateTodoRows(ctx: PreviewContext, args: ToolArgs): Rows {
|
||||
const before = tripTodo(ctx.card, argId(args, 'itemId'));
|
||||
const rows = new Rows();
|
||||
const date = (value: unknown) => (typeof value === 'string' ? formatDate(ctx, value) : '');
|
||||
const priority = (value: unknown) => (typeof value === 'number' && value > 0 ? `P${value}` : '');
|
||||
const member = (id: unknown) => memberNames(ctx, id === null || id === undefined ? null : [id]);
|
||||
if (has(args, 'name')) rows.change('name', before?.name ?? null, text(args.name));
|
||||
// The old values are the trip's, so they are masked like an answer; the new ones are the call's own.
|
||||
if (has(args, 'description')) {
|
||||
rows.change('description', before ? clipped(before.description, 160) : null, text(args.description));
|
||||
}
|
||||
if (has(args, 'category')) {
|
||||
rows.change('category', before ? clipped(before.category, 100) : null, text(args.category));
|
||||
}
|
||||
if (has(args, 'due_date')) rows.change('dueDate', before ? date(before.dueDate) : null, date(args.due_date));
|
||||
if (has(args, 'priority')) {
|
||||
rows.change('priority', before ? priority(before.priority) : null, priority(args.priority));
|
||||
}
|
||||
if (has(args, 'assigned_user_id')) {
|
||||
rows.change('assignee', before ? member(before.assigneeId) : null, member(args.assigned_user_id));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Whether the to-do was done before its tick, as the tick's change card kept it. */
|
||||
function doneBefore(preview: AssistantChangePreview | null): string | null {
|
||||
const card = preview?.card;
|
||||
const row = card?.kind === 'todoList' ? card.groups[0]?.items[0] : undefined;
|
||||
return row?.changed.find((entry) => entry.field === 'done')?.before ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A toggled to-do taken back with the opposite toggle, while it is still as
|
||||
* the toggle left it, and only when the toggle changed it: ticking one that
|
||||
* was already done has no way back.
|
||||
*/
|
||||
const UNTOGGLE_TODO: NonNullable<ToolCards['undo']> = {
|
||||
tool: 'toggle_todo',
|
||||
args: (args, result, preview) => {
|
||||
const itemId = positiveId(recordOf(recordOf(result).item).id) ?? argId(args, 'itemId');
|
||||
if (itemId === null || typeof args.checked !== 'boolean') return null;
|
||||
if (doneBefore(preview) !== (args.checked ? '0' : '1')) return null;
|
||||
return { itemId, checked: !args.checked };
|
||||
},
|
||||
unchanged: (result, snapshot) => {
|
||||
const item = recordOf(recordOf(result).item);
|
||||
return same(item, byId(rowsOf(recordOf(snapshot.summary).todos), item.id), STABLE_FIELDS.todo);
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE = { verbKey: 'genericUpdate', entity: 'todo' } as const;
|
||||
|
||||
export const TODO_TOOLS: Readonly<Record<string, ToolCards>> = {
|
||||
list_todos: { answer: listTodosAnswer },
|
||||
create_todo: {
|
||||
change: createTodoCard,
|
||||
applied: ({ result, preview }) => {
|
||||
const id = positiveId(recordOf(recordOf(result).item).id);
|
||||
const card = preview?.card;
|
||||
if (id === null || card?.kind !== 'todoList') return null;
|
||||
return { ...card, groups: card.groups.map((g) => ({ ...g, items: g.items.map((row) => ({ ...row, id })) })) };
|
||||
},
|
||||
},
|
||||
update_todo: {
|
||||
write: UPDATE,
|
||||
describe: (ctx, args) => ({
|
||||
subject: todoName(ctx, args),
|
||||
rows: updateTodoRows(ctx, args),
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
tool: 'update_todo',
|
||||
}),
|
||||
change: updateTodoCard,
|
||||
},
|
||||
toggle_todo: {
|
||||
write: UPDATE,
|
||||
describe: (ctx, args) => ({
|
||||
subject: todoName(ctx, args),
|
||||
rows: new Rows(),
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
tool: 'toggle_todo',
|
||||
}),
|
||||
change: toggleTodoCard,
|
||||
undo: UNTOGGLE_TODO,
|
||||
},
|
||||
delete_todo: {
|
||||
write: { verbKey: 'genericRemove', entity: 'todo' },
|
||||
describe: (ctx, args) => ({
|
||||
subject: todoName(ctx, args),
|
||||
rows: new Rows(),
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
tool: 'delete_todo',
|
||||
}),
|
||||
change: deleteTodoCard,
|
||||
},
|
||||
reorder_todos: {
|
||||
write: UPDATE,
|
||||
describe: (ctx, args) => {
|
||||
const names = idsOf(args.orderedIds).map((id) => tripTodo(ctx.card, id)?.name ?? `#${id}`);
|
||||
return { subject: text(names.join(', ')), rows: new Rows(), dayId: null, placeIds: [], tool: 'reorder_todos' };
|
||||
},
|
||||
},
|
||||
set_todo_category_assignees: {
|
||||
write: UPDATE,
|
||||
load: loadTodoAssignees,
|
||||
describe: (ctx, args) => ({
|
||||
subject: text(args.categoryName),
|
||||
rows: new Rows().add('members', memberNames(ctx, args.userIds)),
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
tool: 'set_todo_category_assignees',
|
||||
}),
|
||||
change: (ctx, args, loaded) => todoAssigneesCard(ctx, args, recordOf(loaded)),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { TripCard } from '../../assistant-context';
|
||||
import { clipped, finite, personOf, recordOf } from '../card-values';
|
||||
import { argText, categoryOf, positiveId, rowsOf } from './packing-rows';
|
||||
import type { TodoCardGroup, TodoCardItem, TodoListCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The pieces every to-do card is built from: a to-do as the to-do tools
|
||||
* answer it, its row, its group (its list) and the list's colour slot. Text
|
||||
* from a tool's answer is masked like a provider answer, text from a call's
|
||||
* arguments only cut.
|
||||
*/
|
||||
|
||||
const MAX_TODO_ROWS = 20;
|
||||
/** Rows a group shows at least, done ones filling up after the open ones. */
|
||||
const TODO_FILL = 5;
|
||||
const MAX_TODO_GROUPS = 12;
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** A to-do as `get_trip_summary`, `list_todos` and the writes answer it. */
|
||||
export interface ListTodo {
|
||||
id: number;
|
||||
/** As shown: masked when it comes from a tool's answer. */
|
||||
name: string;
|
||||
/** As stored, to tell whether a write really renames it; never shown. */
|
||||
rawName: string;
|
||||
category: string | null;
|
||||
checked: boolean;
|
||||
dueDate: string | null;
|
||||
priority: number;
|
||||
assigneeId: number | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export function dateOf(value: unknown): string | null {
|
||||
return typeof value === 'string' && ISO_DATE.test(value) ? value : null;
|
||||
}
|
||||
|
||||
export function priorityOf(value: unknown): number {
|
||||
const n = finite(value);
|
||||
return n !== null && Number.isInteger(n) && n >= 1 && n <= 3 ? n : 0;
|
||||
}
|
||||
|
||||
/** One to-do from a tool's answer; null for a row without an id or a name. */
|
||||
function listTodoOf(raw: unknown): ListTodo | null {
|
||||
const row = recordOf(raw);
|
||||
const id = positiveId(row.id);
|
||||
const rawName = typeof row.name === 'string' ? row.name : null;
|
||||
const name = clipped(rawName, 300);
|
||||
if (id === null || rawName === null || name === null) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
rawName,
|
||||
category: categoryOf(row.category),
|
||||
checked: !!row.checked,
|
||||
dueDate: dateOf(row.due_date),
|
||||
priority: priorityOf(row.priority),
|
||||
assigneeId: positiveId(row.assigned_user_id),
|
||||
description: typeof row.description === 'string' ? row.description : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function listTodosOf(value: unknown): ListTodo[] {
|
||||
return rowsOf(value).flatMap((row) => listTodoOf(row) ?? []);
|
||||
}
|
||||
|
||||
const TRIP_TODOS = new WeakMap<TripCard, ListTodo[]>();
|
||||
|
||||
/** The trip's to-dos as the turn read them; read once per card. */
|
||||
export function tripTodos(card: TripCard): ListTodo[] {
|
||||
let todos = TRIP_TODOS.get(card);
|
||||
if (!todos) {
|
||||
todos = listTodosOf(recordOf(card.sources.summary).todos);
|
||||
TRIP_TODOS.set(card, todos);
|
||||
}
|
||||
return todos;
|
||||
}
|
||||
|
||||
export function tripTodo(card: TripCard, id: number | null): ListTodo | null {
|
||||
return id === null ? null : (tripTodos(card).find((todo) => todo.id === id) ?? null);
|
||||
}
|
||||
|
||||
/** The trip day on a date, for the date to show it in the plan. */
|
||||
function dayOn(card: TripCard, date: string | null): number | null {
|
||||
return date === null ? null : (card.days.find((day) => day.date === date)?.id ?? null);
|
||||
}
|
||||
|
||||
/** The first line of a description with text on it: masked from an answer, only cut from arguments. */
|
||||
function noteOf(description: string | null, mask: boolean): string | null {
|
||||
const line = description?.split(/\r?\n/).find((part) => part.trim() !== '') ?? null;
|
||||
return mask ? clipped(line, 160) : argText(line, 160);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lists in the order the to-do panel sorts them (`todoCategories`): by
|
||||
* name in the reader's language, the same `localeCompare` the panel calls.
|
||||
*/
|
||||
function todoCategories(todos: readonly ListTodo[]): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const todo of todos) if (todo.category) names.add(todo.category);
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/** A list's colour slot among the trip's lists; null for a list the trip does not hold yet. */
|
||||
export function todoSlot(card: TripCard, category: string | null): number | null {
|
||||
if (category === null) return null;
|
||||
const index = todoCategories(tripTodos(card)).indexOf(category);
|
||||
return index < 0 ? null : index;
|
||||
}
|
||||
|
||||
/** A to-do's row on a card; the note is masked unless it comes from the call's own arguments. */
|
||||
export function todoRow(
|
||||
card: TripCard,
|
||||
todo: ListTodo,
|
||||
options: { mask: boolean; change?: TodoCardItem['change']; changed?: TodoCardItem['changed'] },
|
||||
): TodoCardItem {
|
||||
return {
|
||||
id: todo.id > 0 ? todo.id : null,
|
||||
name: todo.name,
|
||||
note: noteOf(todo.description, options.mask),
|
||||
checked: todo.checked,
|
||||
dueDate: todo.dueDate,
|
||||
dueDayId: dayOn(card, todo.dueDate),
|
||||
priority: todo.priority,
|
||||
assignee: todo.assigneeId === null ? null : personOf(card, todo.assigneeId),
|
||||
change: options.change ?? 'none',
|
||||
changed: options.changed ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Nearest due date first, undated ones after every dated one; ties keep their order (`compareTodoDue`). */
|
||||
function compareDue(a: ListTodo, b: ListTodo): number {
|
||||
if (!a.dueDate) return b.dueDate ? 1 : 0;
|
||||
if (!b.dueDate) return -1;
|
||||
return a.dueDate < b.dueDate ? -1 : a.dueDate > b.dueDate ? 1 : 0;
|
||||
}
|
||||
|
||||
/** A to-do list card. */
|
||||
export function todoCard(
|
||||
groups: TodoCardGroup[],
|
||||
extra: { moreGroups?: number; progress?: TodoListCard['progress'] } = {},
|
||||
): TodoListCard {
|
||||
return { kind: 'todoList', progress: extra.progress ?? null, groups, moreGroups: extra.moreGroups ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The to-dos of an answer by list, in the panel's order with the ones on no
|
||||
* list last. A list shows its open to-dos first, overdue and soonest due on
|
||||
* top, and done ones only to fill up to five rows; 20 rows in all. Every
|
||||
* group counts all of its to-dos.
|
||||
*/
|
||||
export function todoGroups(
|
||||
card: TripCard,
|
||||
todos: readonly ListTodo[],
|
||||
): { groups: TodoCardGroup[]; moreGroups: number } {
|
||||
const lists: Array<string | null> = todoCategories(todos);
|
||||
if (todos.some((todo) => todo.category === null)) lists.push(null);
|
||||
let left = MAX_TODO_ROWS;
|
||||
const groups = lists.slice(0, MAX_TODO_GROUPS).map((category, slot): TodoCardGroup => {
|
||||
const all = todos.filter((todo) => todo.category === category);
|
||||
const open = all.filter((todo) => !todo.checked).sort(compareDue);
|
||||
const shown = open.slice(0, left);
|
||||
for (const done of all.filter((todo) => todo.checked)) {
|
||||
if (shown.length >= Math.min(TODO_FILL, left)) break;
|
||||
shown.push(done);
|
||||
}
|
||||
left -= shown.length;
|
||||
return {
|
||||
category: category === null ? null : clipped(category, 100),
|
||||
slot: category === null ? null : slot,
|
||||
assignees: [],
|
||||
assigneesRemoved: [],
|
||||
items: shown.map((todo) => todoRow(card, todo, { mask: true })),
|
||||
count: all.length,
|
||||
open: open.length,
|
||||
};
|
||||
});
|
||||
return { groups, moreGroups: Math.max(0, lists.length - MAX_TODO_GROUPS) };
|
||||
}
|
||||
@@ -21,15 +21,20 @@ import {
|
||||
packingApplyTemplateRequestSchema,
|
||||
packingCreateBagRequestSchema,
|
||||
packingCreateItemRequestSchema,
|
||||
packingListItemsRequestSchema,
|
||||
packingSetSharingRequestSchema,
|
||||
packingUpdateBagRequestSchema,
|
||||
packingUpdateItemRequestSchema,
|
||||
type PackingListItemsRequest,
|
||||
type PackingVisibility,
|
||||
idSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/** What `list_packing_items` filters a packing item by. */
|
||||
type PackingListRow = { category?: string | null; bag_id?: number | null; checked?: number | boolean | null };
|
||||
|
||||
/** Legacy registrar gate: the whole packing surface rides the packing addon. */
|
||||
const packingAddonOn = addonGate(ADDON_IDS.PACKING);
|
||||
|
||||
@@ -50,9 +55,10 @@ function parseId(value: string | string[]): number | null {
|
||||
* by trekMcpAccessPolicy). The two template-management tools keep their inline
|
||||
* admin gate (isAdminUser), matching the REST routes.
|
||||
*
|
||||
* set_packing_item_sharing is the one tool with no legacy ancestor: PUT
|
||||
* /:id/sharing had no counterpart here, so the whole three-tier sharing model
|
||||
* (#858) was invisible to an assistant.
|
||||
* set_packing_item_sharing and list_packing_items have no legacy ancestor.
|
||||
* PUT /:id/sharing had no counterpart here, so the whole three-tier sharing
|
||||
* model (#858) was invisible to an assistant; and the items themselves could
|
||||
* only be read inside get_trip_summary, unfiltered and without their bags.
|
||||
*/
|
||||
@McpController()
|
||||
export class PackingMcp {
|
||||
@@ -308,6 +314,47 @@ export class PackingMcp {
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_packing_items',
|
||||
description:
|
||||
"List the packing items of a trip the user may see (another member's personal items stay hidden), with the trip's bags and who looks after each category. Narrow it to one category, one bag or what is still open; group_by says how the list is meant to be shown, by category or by bag, as the packing list groups it.",
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
category: packingListItemsRequestSchema.shape.category.describe(
|
||||
'Only the items of this category, named exactly as the items carry it',
|
||||
),
|
||||
bag_id: packingListItemsRequestSchema.shape.bag_id.describe(
|
||||
'Only the items in this bag (ids come from list_packing_bags); null for the items in no bag',
|
||||
),
|
||||
status: packingListItemsRequestSchema.shape.status.describe(
|
||||
"'open' for what is still to pack, 'packed' for what is ticked off, 'all' (the default) for both",
|
||||
),
|
||||
group_by: packingListItemsRequestSchema.shape.group_by.describe(
|
||||
"How the list is shown: by 'category' (the default) or by 'bag'. It filters nothing",
|
||||
),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: packingAddonOn,
|
||||
access: { group: 'packing', mode: 'read' },
|
||||
})
|
||||
async listPackingItems(
|
||||
{ tripId, category, bag_id, status }: { tripId: number } & PackingListItemsRequest,
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
// The viewer's own list (#858): another member's personal items never come back.
|
||||
const visible = (await this.packing.listItems(tripId, ctx.userId)) as PackingListRow[];
|
||||
const items = visible.filter(
|
||||
(item) =>
|
||||
(category === undefined || item.category === category) &&
|
||||
(bag_id === undefined || (item.bag_id ?? null) === bag_id) &&
|
||||
(status === 'open' ? !item.checked : status === 'packed' ? !!item.checked : true),
|
||||
);
|
||||
const bags = await this.packing.listBags(tripId);
|
||||
const category_assignees = await this.packing.getCategoryAssignees(tripId);
|
||||
return ok({ items, bags, category_assignees });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_packing_bags',
|
||||
description: 'List all packing bags for a trip.',
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Packing e2e for the list as an MCP client reads it: list_packing_items
|
||||
* called through a real MCP session on the whole app (buildApp, the real
|
||||
* registry with its addon gate, scopes and error mapper, the real
|
||||
* PackingService and a migrated temp SQLite), the way the assistant's
|
||||
* packing cards read it.
|
||||
*/
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { addPackingBagMembers, makePackingBag, makePackingItem } from '../helpers/factories/packing';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { addTripMember, makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
let app: INestApplication;
|
||||
let orm: MikroORM;
|
||||
|
||||
type Answer = {
|
||||
items: Array<Record<string, unknown>>;
|
||||
bags: Array<Record<string, unknown>>;
|
||||
category_assignees: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** The tools `userId` is offered with `scopes`, and one call of list_packing_items: its JSON, or its error. */
|
||||
async function listItems(userId: number, args: Record<string, unknown>, scopes: string[] | null = null) {
|
||||
const server = createLocalMcpServer('TREK packing e2e');
|
||||
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
|
||||
await withRequestContext(orm, () =>
|
||||
app
|
||||
.get(McpRegistryService)
|
||||
.attach(server, { userId, scopes, isStaticToken: false }, { include: (kind) => kind === 'tool', around }),
|
||||
);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: 'packing-e2e', version: '1.0.0' });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
const offered = (await client.listTools()).tools.map((tool) => tool.name);
|
||||
if (!offered.includes('list_packing_items')) return { offered, answer: null, error: null };
|
||||
const result = await client.callTool({ name: 'list_packing_items', arguments: args });
|
||||
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
|
||||
const text = content[0]?.text ?? '';
|
||||
if (result.isError === true) return { offered, answer: null, error: text };
|
||||
return { offered, answer: JSON.parse(text) as Answer, error: null };
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
const names = (answer: Answer | null) => (answer?.items ?? []).map((item) => item.name);
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
orm = app.get(MikroORM);
|
||||
await setAddonEnabled(orm, 'packing', true);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
describe('the packing list over MCP', () => {
|
||||
it('PACKMCP-E2E-001: lists the items the user may see with the bags and who looks after each category', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const { user: member } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { title: 'Lisbon' });
|
||||
await addTripMember(orm, trip.id, member.id, user.id);
|
||||
const bag = await makePackingBag(orm, trip.id, { name: 'Carry-on', color: '#6366f1' });
|
||||
await addPackingBagMembers(orm, bag.id, [user.id]);
|
||||
await makePackingItem(orm, trip.id, { name: 'Rain jacket', category: 'Clothes', bag: bag.id, sort_order: 1 });
|
||||
await makePackingItem(orm, trip.id, { name: 'Sunscreen', category: 'Toiletries', checked: 1, sort_order: 2 });
|
||||
await makePackingItem(orm, trip.id, {
|
||||
name: 'Diary',
|
||||
category: 'Clothes',
|
||||
is_private: 1,
|
||||
owner: member.id,
|
||||
sort_order: 3,
|
||||
});
|
||||
const { answer } = await listItems(user.id, { tripId: trip.id });
|
||||
// Another member's personal item stays with them (#858).
|
||||
expect(names(answer)).toEqual(['Rain jacket', 'Sunscreen']);
|
||||
expect(answer?.items[0]).toMatchObject({ category: 'Clothes', bag_id: bag.id, quantity: 1 });
|
||||
expect(answer?.bags).toEqual([expect.objectContaining({ id: bag.id, name: 'Carry-on', color: '#6366f1' })]);
|
||||
expect(answer?.category_assignees).toEqual({});
|
||||
const own = await listItems(member.id, { tripId: trip.id });
|
||||
expect(names(own.answer)).toContain('Diary');
|
||||
});
|
||||
|
||||
it('PACKMCP-E2E-002: narrows to one category, one bag, the items in no bag, or what is open or packed', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id);
|
||||
const bag = await makePackingBag(orm, trip.id, { name: 'Daypack' });
|
||||
await makePackingItem(orm, trip.id, { name: 'Boots', category: 'Hiking', bag: bag.id, sort_order: 1 });
|
||||
await makePackingItem(orm, trip.id, { name: 'Map', category: 'Hiking', checked: 1, sort_order: 2 });
|
||||
await makePackingItem(orm, trip.id, { name: 'Charger', category: 'Tech', sort_order: 3 });
|
||||
expect(names((await listItems(user.id, { tripId: trip.id, category: 'Hiking' })).answer)).toEqual(['Boots', 'Map']);
|
||||
expect(names((await listItems(user.id, { tripId: trip.id, bag_id: bag.id })).answer)).toEqual(['Boots']);
|
||||
expect(names((await listItems(user.id, { tripId: trip.id, bag_id: null })).answer)).toEqual(['Map', 'Charger']);
|
||||
expect(names((await listItems(user.id, { tripId: trip.id, status: 'open' })).answer)).toEqual(['Boots', 'Charger']);
|
||||
expect(names((await listItems(user.id, { tripId: trip.id, status: 'packed' })).answer)).toEqual(['Map']);
|
||||
// The grouping only says how the list is shown: it filters nothing.
|
||||
expect(names((await listItems(user.id, { tripId: trip.id, group_by: 'bag' })).answer)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('PACKMCP-E2E-003: refuses a trip the user is not part of', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const { user: stranger } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id);
|
||||
await makePackingItem(orm, trip.id, { name: 'Passport' });
|
||||
const { answer, error } = await listItems(stranger.id, { tripId: trip.id });
|
||||
expect(answer).toBeNull();
|
||||
expect(error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('PACKMCP-E2E-004: needs the packing read scope, and is gone while the Lists addon is off', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
expect((await listItems(user.id, { tripId: 1 }, ['trips:read'])).offered).not.toContain('list_packing_items');
|
||||
expect((await listItems(user.id, { tripId: 1 }, ['packing:read'])).offered).toContain('list_packing_items');
|
||||
await setAddonEnabled(orm, 'packing', false);
|
||||
try {
|
||||
expect((await listItems(user.id, { tripId: 1 })).offered).not.toContain('list_packing_items');
|
||||
} finally {
|
||||
await setAddonEnabled(orm, 'packing', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Unit tests for the packing MCP surface (PackingMcp, DI-discovered):
|
||||
* create_packing_item, update_packing_item, toggle_packing_item,
|
||||
* delete_packing_item, set_packing_item_sharing, plus the two bag fields that
|
||||
* delete_packing_item, set_packing_item_sharing, list_packing_items, plus the two bag fields that
|
||||
* drive the fill bar, the registration-time scope/addon gating and the
|
||||
* trek://trips/{tripId}/packing + .../packing/bags resources (moved from the
|
||||
* legacy registerResources — see resources.test.ts). The advanced tools live
|
||||
@@ -891,12 +891,61 @@ describe('Tool: delete_packing_item', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_packing_items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_packing_items', () => {
|
||||
it("PACKMCP-001: hides another member's own items; narrows to a category, a bag, no bag or a status", async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: mate } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, mate.id);
|
||||
const { id: bagId } = await makePackingBag(orm, trip.id, { name: 'Carry-On' });
|
||||
const jacket = createPackingItem(testDb, trip.id, { name: 'Jacket', category: 'Clothes' });
|
||||
createPackingItem(testDb, trip.id, { name: 'Charger', category: 'Tech' });
|
||||
const diary = createPackingItem(testDb, trip.id, { name: 'Diary', category: 'Clothes' });
|
||||
await updateRows(orm, PackingItems, { id: jacket.id }, { bag: bagId, checked: 1 });
|
||||
await updateRows(orm, PackingItems, { id: diary.id }, { is_private: 1, owner: mate.id });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const names = async (args: Record<string, unknown>) => {
|
||||
const result = await h.client.callTool({ name: 'list_packing_items', arguments: { tripId: trip.id, ...args } });
|
||||
const data = parseToolResult(result) as { items: Array<{ name: string }>; bags: Array<{ id: number }> };
|
||||
return { names: data.items.map((item) => item.name).sort(), bags: data.bags.map((bag) => bag.id) };
|
||||
};
|
||||
expect(await names({})).toEqual({ names: ['Charger', 'Jacket'], bags: [bagId] });
|
||||
expect((await names({ category: 'Clothes' })).names).toEqual(['Jacket']);
|
||||
expect((await names({ bag_id: bagId })).names).toEqual(['Jacket']);
|
||||
expect((await names({ bag_id: null })).names).toEqual(['Charger']);
|
||||
expect((await names({ status: 'open' })).names).toEqual(['Charger']);
|
||||
expect((await names({ status: 'packed' })).names).toEqual(['Jacket']);
|
||||
});
|
||||
});
|
||||
|
||||
it('PACKMCP-002: answers a trip the user is not part of with the access-denied payload', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Jacket' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_packing_items', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope gating (packing read/write, registration-time)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Packing tools — scope gating', () => {
|
||||
const READ_TOOLS = ['list_packing_bags', 'get_packing_category_assignees', 'list_packing_templates'];
|
||||
const READ_TOOLS = [
|
||||
'list_packing_items',
|
||||
'list_packing_bags',
|
||||
'get_packing_category_assignees',
|
||||
'list_packing_templates',
|
||||
];
|
||||
const WRITE_TOOLS = [
|
||||
'create_packing_item',
|
||||
'toggle_packing_item',
|
||||
@@ -924,7 +973,7 @@ describe('Packing tools — scope gating', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('registers all eighteen tools with null scopes (full access)', async () => {
|
||||
it('registers all nineteen 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,446 @@
|
||||
/**
|
||||
* ASTPACK-001 to ASTPACK-023: the packing cards of the assistant chat. The
|
||||
* lists a read shows (by category in the list's order or by bag, the bag
|
||||
* heads, a tour's part of the list), the card each packing change shows,
|
||||
* the words of the writes the core had no card for, the ways back from a
|
||||
* tick and a new bag, and the receipts. Every card goes through the
|
||||
* registry's guard, so a card that would not fit its kind comes back null.
|
||||
*/
|
||||
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
|
||||
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
|
||||
import {
|
||||
answerCardOf,
|
||||
appliedCardOf,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
CARD_PROMPT_LINES,
|
||||
changeCardOf,
|
||||
undoReadsOf,
|
||||
} from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { appliedChange, describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import { ANA, answerInput, BAGS, BEN, ctx, ctxWith, MAYA, PACKING_ITEMS } from './packing-fixture';
|
||||
import type { PackingListCard, TrekCard } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const BAG_VALUE = { bags: BAGS };
|
||||
|
||||
function packing(card: TrekCard | null): PackingListCard {
|
||||
if (card?.kind !== 'packingList') throw new Error(`expected a packing card, got ${JSON.stringify(card)}`);
|
||||
return card;
|
||||
}
|
||||
|
||||
const answer = (tool: string, args: Record<string, unknown>, value: unknown) =>
|
||||
answerCardOf(tool, answerInput(args, value));
|
||||
const change = (tool: string, args: Record<string, unknown>, loaded: unknown = null) =>
|
||||
changeCardOf(tool, ctx, args, loaded);
|
||||
const rowNames = (card: PackingListCard) => card.groups.map((group) => group.items.map((item) => item.name));
|
||||
/** The bag keys a card's rows name that are not among its bags: always none. */
|
||||
const strayBags = (card: PackingListCard) => {
|
||||
const keys = new Set(card.bags.map((bag) => bag.key));
|
||||
return card.groups.flatMap((group) => group.items.flatMap((item) => item.bagKey ?? [])).filter((k) => !keys.has(k));
|
||||
};
|
||||
|
||||
describe('the packing answers', () => {
|
||||
const listValue = { items: PACKING_ITEMS, bags: BAGS, category_assignees: { Clothes: [{ user_id: 2 }] } };
|
||||
|
||||
it('ASTPACK-001: a list read groups by category in first-appearance order, the empty stand-in left out', () => {
|
||||
const card = packing(answer('list_packing_items', {}, listValue));
|
||||
expect(card.source).toEqual({ type: 'trip' });
|
||||
expect(card.groupBy).toBe('category');
|
||||
expect(card.groups.map((group) => group.key)).toEqual([
|
||||
{ by: 'category', name: 'Clothes', slot: 0 },
|
||||
{ by: 'category', name: 'Toiletries', slot: 0 },
|
||||
{ by: 'category', name: 'Personal', slot: 2 },
|
||||
]);
|
||||
expect(rowNames(card)).toEqual([['Rain jacket', 'Hiking socks', 'Sun hat'], ['Sunscreen'], ['Diary']]);
|
||||
expect(card.groups[0]).toMatchObject({ count: 3, packed: 1, assignees: [ANA] });
|
||||
expect(card.progress).toEqual({ packed: 1, total: 5 });
|
||||
});
|
||||
|
||||
it('ASTPACK-002: a row carries its packed count, its weight times quantity, its bag and its tier', () => {
|
||||
const card = packing(answer('list_packing_items', {}, listValue));
|
||||
const [jacket, socks, hat] = card.groups[0]?.items ?? [];
|
||||
expect(jacket).toMatchObject({ id: 11, packed: 1, checked: true, weightGrams: 480, bagKey: 'id:5' });
|
||||
expect(socks).toMatchObject({ quantity: 3, packed: 1, checked: false, weightGrams: 210, bagKey: 'id:6' });
|
||||
expect(hat).toMatchObject({ tier: 'personal', weightGrams: null, bagKey: null, sharedWith: [], change: 'none' });
|
||||
expect(card.groups[1]?.items[0]).toMatchObject({ tier: 'shared', sharedWith: [ANA] });
|
||||
expect(card.bags.map((bag) => bag.key)).toEqual(['id:5', 'id:6']);
|
||||
expect(card.bags[0]).toMatchObject({
|
||||
name: 'Carry-on',
|
||||
color: '#6366f1',
|
||||
weightGrams: 6200,
|
||||
limitGrams: 8000,
|
||||
owner: MAYA,
|
||||
members: [MAYA, ANA],
|
||||
});
|
||||
expect(card.bags[1]).toMatchObject({ owner: null, members: [], limitGrams: null });
|
||||
});
|
||||
|
||||
it('ASTPACK-003: grouped by bag, the bags follow the list and the pile in no bag comes last', () => {
|
||||
const card = packing(answer('list_packing_items', { group_by: 'bag' }, listValue));
|
||||
expect(card.groupBy).toBe('bag');
|
||||
expect(card.groups.map((group) => group.key)).toEqual([
|
||||
{ by: 'bag', bagKey: 'id:5' },
|
||||
{ by: 'bag', bagKey: 'id:6' },
|
||||
{ by: 'bag', bagKey: null },
|
||||
]);
|
||||
expect(card.groups[2]?.count).toBe(3);
|
||||
});
|
||||
|
||||
it('ASTPACK-004: at most 24 rows, eight a group, twelve groups; every group still counts all of its items', () => {
|
||||
const items = ['A', 'B', 'C', 'D'].flatMap((category, c) =>
|
||||
Array.from({ length: 10 }, (_, i) => ({ id: 100 + c * 10 + i, name: `${category}${i}`, category })),
|
||||
);
|
||||
for (let i = 0; i < 11; i++) items.push({ id: 200 + i, name: `E${i}`, category: `E${i}` });
|
||||
const card = packing(answer('list_packing_items', {}, { items, bags: [] }));
|
||||
expect(card.groups).toHaveLength(12);
|
||||
expect(card.moreGroups).toBe(3);
|
||||
expect(card.groups.map((group) => group.items.length).slice(0, 5)).toEqual([8, 8, 8, 0, 0]);
|
||||
expect(card.groups[3]).toMatchObject({ count: 10, key: { by: 'category', name: 'D', slot: null } });
|
||||
expect(card.groups.reduce((sum, group) => sum + group.items.length, 0)).toBe(24);
|
||||
});
|
||||
|
||||
it('ASTPACK-005: an empty list and a trip without bags show no card', () => {
|
||||
expect(answer('list_packing_items', {}, { items: [PACKING_ITEMS[2]], bags: [] })).toBeNull();
|
||||
expect(answer('list_packing_bags', {}, { bags: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTPACK-006: a bag read shows one head per bag with its items counted, and the pile in no bag', () => {
|
||||
const card = packing(answer('list_packing_bags', {}, BAG_VALUE));
|
||||
expect(card.groupBy).toBe('bag');
|
||||
expect(card.progress).toBeNull();
|
||||
expect(card.groups.map((group) => [group.key, group.count, group.packed, group.items.length])).toEqual([
|
||||
[{ by: 'bag', bagKey: 'id:5' }, 1, 1, 0],
|
||||
[{ by: 'bag', bagKey: 'id:6' }, 1, 0, 0],
|
||||
[{ by: 'bag', bagKey: null }, 3, 0, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("ASTPACK-007: a tour's part of the list shows as the tour shows it, filled in from the trip's list", () => {
|
||||
const value = {
|
||||
packing_link: { category: 'Clothes', linked_at: '2026-06-01 10:00:00' },
|
||||
state: 'ready',
|
||||
items: [
|
||||
{ id: 11, name: 'Rain jacket', category: 'Clothes', quantity: 1, packed: true },
|
||||
{ id: 13, name: '...', category: 'Clothes', quantity: 1, packed: false },
|
||||
{ id: 12, name: 'Hiking socks', category: 'Clothes', quantity: 3, packed: false },
|
||||
],
|
||||
packed: 1,
|
||||
total: 2,
|
||||
tour: { name: 'Ridge walk' },
|
||||
};
|
||||
const card = packing(answer('get_tour_packing', { placeId: 70 }, value));
|
||||
expect(card.source).toEqual({ type: 'tour', placeId: 70, name: 'Ridge walk', category: 'Clothes' });
|
||||
expect(card.progress).toEqual({ packed: 1, total: 2 });
|
||||
// The stand-in is no row, and a bag the card read nothing of is not named.
|
||||
expect(card.groups[0]?.items.map((item) => [item.name, item.packed, item.weightGrams, item.bagKey])).toEqual([
|
||||
['Rain jacket', 1, 480, null],
|
||||
['Hiking socks', 1, 210, null],
|
||||
]);
|
||||
expect(card.bags).toEqual([]);
|
||||
expect(answer('get_tour_packing', { placeId: 70 }, { ...value, state: 'missing' })).toBeNull();
|
||||
expect(answer('get_tour_packing', { placeId: 70 }, { ...value, packing_link: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the packing change cards', () => {
|
||||
it('ASTPACK-008: a new item goes under General, its quantity held to 999, shared with members only, in its bag', () => {
|
||||
const args = {
|
||||
name: 'Toothbrush',
|
||||
quantity: 1500,
|
||||
visibility: 'shared',
|
||||
recipient_ids: [2, 1, 99],
|
||||
bag_id: 6,
|
||||
weight_grams: 20,
|
||||
};
|
||||
const card = packing(change('create_packing_item', args, BAG_VALUE));
|
||||
expect(card.groups[0]?.key).toEqual({ by: 'category', name: 'General', slot: null });
|
||||
expect(card.groups[0]?.items[0]).toMatchObject({
|
||||
id: null,
|
||||
quantity: 999,
|
||||
packed: 0,
|
||||
weightGrams: 19_980,
|
||||
bagKey: 'id:6',
|
||||
tier: 'shared',
|
||||
sharedWith: [ANA],
|
||||
change: 'add',
|
||||
});
|
||||
expect(card.bags.map((bag) => bag.key)).toEqual(['id:6']);
|
||||
const unknownBag = packing(change('create_packing_item', { name: 'Hat', bag_id: 77 }, BAG_VALUE));
|
||||
expect(unknownBag.groups[0]?.items[0]).toMatchObject({ bagKey: null, tier: 'common', quantity: 1 });
|
||||
expect(unknownBag.bags).toEqual([]);
|
||||
expect(change('create_packing_item', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTPACK-009: an import files under Other, matches a bag by its exact name and makes a new one otherwise', () => {
|
||||
const args = {
|
||||
items: [
|
||||
{ name: 'Towel' },
|
||||
{ name: 'Charger', category: 'Tech', bag: 'Carry-on' },
|
||||
{ name: 'Snorkel', bag: 'carry-on', quantity: 2, weight_grams: 300 },
|
||||
{ name: 'Fins', bag: 'carry-on' },
|
||||
{ name: ' ' },
|
||||
],
|
||||
};
|
||||
const card = packing(change('bulk_import_packing', args, BAG_VALUE));
|
||||
expect(rowNames(card)).toEqual([['Towel', 'Snorkel', 'Fins'], ['Charger']]);
|
||||
expect(card.groups[0]?.key).toMatchObject({ name: 'Other' });
|
||||
expect(card.groups[0]?.items.map((item) => item.bagKey)).toEqual([null, 'new:0', 'new:0']);
|
||||
expect(card.groups[0]?.items[1]).toMatchObject({ quantity: 2, weightGrams: 600, change: 'add' });
|
||||
expect(card.groups[1]?.items[0]?.bagKey).toBe('id:5');
|
||||
expect(card.bags.map((bag) => [bag.key, bag.name, bag.change, bag.color])).toEqual([
|
||||
['id:5', 'Carry-on', 'none', '#6366f1'],
|
||||
['new:0', 'carry-on', 'add', null],
|
||||
]);
|
||||
expect(change('bulk_import_packing', { items: [{ name: ' ' }] })).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTPACK-010: a tick and an untick show the row with its packed count; a removal the row as it is', () => {
|
||||
const ticked = packing(change('toggle_packing_item', { itemId: 12, checked: true }));
|
||||
expect(ticked.groups[0]?.key).toEqual({ by: 'category', name: 'Clothes', slot: 0 });
|
||||
expect(ticked.groups[0]?.items[0]).toMatchObject({ id: 12, checked: true, packed: 3, change: 'check' });
|
||||
// The tick keeps how many were packed before, for the way back.
|
||||
expect(ticked.groups[0]?.items[0]?.changed).toEqual([{ field: 'packed', before: '1' }]);
|
||||
const unticked = packing(change('toggle_packing_item', { itemId: 11, checked: false }));
|
||||
expect(unticked.groups[0]?.items[0]).toMatchObject({ checked: false, packed: 0, change: 'uncheck' });
|
||||
// Rain jacket is in Carry-on, but a tick reads no bags: the row names none rather than "Unassigned".
|
||||
expect(unticked.groups[0]?.items[0]?.bagKey).toBeNull();
|
||||
const removed = packing(change('delete_packing_item', { itemId: 14 }));
|
||||
expect(removed.groups[0]).toMatchObject({ key: { name: 'Toiletries', slot: 0 }, items: [{ change: 'remove' }] });
|
||||
for (const itemId of [99, 13]) {
|
||||
expect(change('toggle_packing_item', { itemId, checked: true }), String(itemId)).toBeNull();
|
||||
expect(change('delete_packing_item', { itemId }), String(itemId)).toBeNull();
|
||||
}
|
||||
const shared = packing(change('set_packing_item_sharing', { itemId: 12, visibility: 'common' }));
|
||||
for (const one of [ticked, unticked, removed, shared]) expect(strayBags(one)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ASTPACK-011: an update lists only the fields it changes with their old values, under the new category', () => {
|
||||
const args = { itemId: 12, name: 'Hiking socks', category: 'Hiking', packed_quantity: 3, bag_id: null };
|
||||
const card = packing(change('update_packing_item', args, BAG_VALUE));
|
||||
expect(card.groups[0]?.key).toEqual({ by: 'category', name: 'Hiking', slot: null });
|
||||
expect(card.groups[0]?.items[0]).toMatchObject({ checked: true, packed: 3, bagKey: null, change: 'update' });
|
||||
expect(card.groups[0]?.items[0]?.changed).toEqual([
|
||||
{ field: 'category', before: 'Clothes' },
|
||||
{ field: 'packed', before: '1' },
|
||||
{ field: 'bag', before: 'Daypack' },
|
||||
]);
|
||||
const cleared = packing(change('update_packing_item', { itemId: 11, weight_grams: null }));
|
||||
const weight = [{ field: 'weight', before: '480' }];
|
||||
expect(cleared.groups[0]?.items[0]).toMatchObject({ weightGrams: null, changed: weight });
|
||||
const counted = packing(change('update_packing_item', { itemId: 12, packed_quantity: 0 }));
|
||||
expect(counted.groups[0]?.items[0]).toMatchObject({ checked: false, packed: 0 });
|
||||
expect(change('update_packing_item', { itemId: 99, name: 'x' })).toBeNull();
|
||||
// A weight compares as the row shows it: three socks at 70 g weighed 210 g, at 100 g they weigh 300 g.
|
||||
const heavier = packing(change('update_packing_item', { itemId: 12, weight_grams: 100 }));
|
||||
const weighed = [{ field: 'weight', before: '210' }];
|
||||
expect(heavier.groups[0]?.items[0]).toMatchObject({ weightGrams: 300, changed: weighed });
|
||||
// A quantity alone moves no weight fact.
|
||||
const more = packing(change('update_packing_item', { itemId: 12, quantity: 4 }));
|
||||
expect(more.groups[0]?.items[0]?.changed).toEqual([{ field: 'quantity', before: '3' }]);
|
||||
});
|
||||
|
||||
it('ASTPACK-012: a sharing change shows the tier after, with trip members only, and the tier before', () => {
|
||||
const args = { itemId: 15, visibility: 'shared', recipient_ids: [3, 2, 1, 99] };
|
||||
const shared = packing(change('set_packing_item_sharing', args));
|
||||
expect(shared.groups[0]?.key).toEqual({ by: 'category', name: 'Clothes', slot: 1 });
|
||||
expect(shared.groups[0]?.items[0]).toMatchObject({
|
||||
tier: 'shared',
|
||||
sharedWith: [BEN, ANA],
|
||||
change: 'share',
|
||||
changed: [{ field: 'tier', before: 'personal' }],
|
||||
});
|
||||
const common = packing(change('set_packing_item_sharing', { itemId: 14, visibility: 'common' }));
|
||||
const before = [{ before: 'shared' }];
|
||||
expect(common.groups[0]?.items[0]).toMatchObject({ tier: 'common', sharedWith: [], changed: before });
|
||||
});
|
||||
|
||||
it("ASTPACK-013: a category's people show the ones it will have and the ones it drops, its items counted", () => {
|
||||
const loaded = { assignees: { Clothes: [{ user_id: 2 }, { user_id: 3 }] } };
|
||||
const card = packing(change('set_packing_category_assignees', { categoryName: 'Clothes', userIds: [3] }, loaded));
|
||||
expect(card.groups).toEqual([
|
||||
{
|
||||
key: { by: 'category', name: 'Clothes', slot: 0 },
|
||||
assignees: [BEN],
|
||||
assigneesRemoved: [ANA],
|
||||
items: [],
|
||||
count: 3,
|
||||
packed: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ASTPACK-014: bag changes show the head: a new one indigo, a change with its old values, people, a removal', () => {
|
||||
const created = packing(change('create_packing_bag', { name: 'Beach bag', weight_limit_grams: 5000 }));
|
||||
expect(created.bags[0]).toMatchObject({ key: 'new:0', id: null, color: '#6366f1', limitGrams: 5000 });
|
||||
expect(created.groups[0]).toMatchObject({ key: { by: 'bag', bagKey: 'new:0' }, count: 0 });
|
||||
const updates = { bagId: 5, name: 'Cabin', color: '#10b981', weight_limit_grams: null, user_id: 3 };
|
||||
const updated = packing(change('update_packing_bag', updates, BAG_VALUE));
|
||||
expect(updated.bags[0]).toMatchObject({ name: 'Cabin', color: '#10b981', limitGrams: null, owner: BEN });
|
||||
expect(updated.bags[0]?.changed).toEqual([
|
||||
{ field: 'name', before: 'Carry-on' },
|
||||
{ field: 'color', before: '#6366f1' },
|
||||
{ field: 'limit', before: '8000' },
|
||||
{ field: 'owner', before: 'Maya' },
|
||||
]);
|
||||
const members = packing(change('set_bag_members', { bagId: 5, userIds: [2, 3] }, BAG_VALUE));
|
||||
expect(members.bags[0]).toMatchObject({ members: [ANA, BEN], membersRemoved: [MAYA], change: 'members' });
|
||||
const removed = packing(change('delete_packing_bag', { bagId: 5 }, BAG_VALUE));
|
||||
expect(removed.bags[0]).toMatchObject({ change: 'remove', movesOut: 1 });
|
||||
expect(change('delete_packing_bag', { bagId: 9 }, BAG_VALUE)).toBeNull();
|
||||
expect(change('create_packing_bag', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTPACK-015: a template shows its name and how many items it adds, and once applied the items it added', () => {
|
||||
const loaded = { templates: [{ id: 4, name: 'Beach week', item_count: 24 }] };
|
||||
const card = packing(change('apply_packing_template', { templateId: 4 }, loaded));
|
||||
expect(card.source).toEqual({ type: 'template', templateId: 4, name: 'Beach week', itemCount: 24 });
|
||||
expect(card.groups).toEqual([]);
|
||||
expect(change('apply_packing_template', { templateId: 5 }, loaded)).toBeNull();
|
||||
const preview = describeToolCall('apply_packing_template', 'call_t', { templateId: 4 }, ctx, loaded);
|
||||
const result = {
|
||||
items: [
|
||||
{ id: 81, name: 'Towel', category: 'Beach', quantity: 1, checked: 0, bag_id: 5 },
|
||||
{ id: 82, name: 'Flip-flops', category: 'Beach', quantity: 1, checked: 0 },
|
||||
],
|
||||
count: 2,
|
||||
};
|
||||
const applied = packing(appliedCardOf('apply_packing_template', { args: { templateId: 4 }, result, preview }));
|
||||
expect(applied.source).toEqual(card.source);
|
||||
expect(applied.groups[0]?.items.map((item) => [item.id, item.bagKey, item.change])).toEqual([
|
||||
[81, null, 'add'],
|
||||
[82, null, 'add'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ASTPACK-016: linking a tour shows the part of the list it will point at; a new item gets its id once applied', () => {
|
||||
const linked = packing(change('link_tour_packing', { placeId: 70, category: 'Clothes' }));
|
||||
expect(linked.source).toEqual({ type: 'tour', placeId: 70, name: 'Ridge walk', category: 'Clothes' });
|
||||
expect(rowNames(linked)).toEqual([['Rain jacket', 'Hiking socks', 'Sun hat']]);
|
||||
expect(change('link_tour_packing', { placeId: 71 })).toBeNull();
|
||||
const preview = describeToolCall('create_packing_item', 'call_c', { name: 'Hat' }, ctx);
|
||||
const applied = packing(
|
||||
appliedCardOf('create_packing_item', { args: { name: 'Hat' }, result: { item: { id: 90 } }, preview }),
|
||||
);
|
||||
expect(applied.groups[0]?.items[0]?.id).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
/** The way back from a tick, with the change card the tick showed. */
|
||||
function tickBack(args: { itemId: number; checked: boolean }) {
|
||||
const preview = describeToolCall('toggle_packing_item', 'call_t', args, ctx);
|
||||
return ASSISTANT_UNDO_MAP.toggle_packing_item?.args(args, { item: { id: args.itemId } }, preview);
|
||||
}
|
||||
|
||||
describe('the packing writes, receipts and undo', () => {
|
||||
it('ASTPACK-017: the writes without a core card name their own action, the bag or list they touch, and a card', () => {
|
||||
expect(ASSISTANT_WRITE_TOOLS.create_packing_bag).toEqual({ verbKey: 'genericCreate', entity: 'packing_bag' });
|
||||
expect(ASSISTANT_WRITE_TOOLS.toggle_packing_item).toEqual({ verbKey: 'genericUpdate', entity: 'packing' });
|
||||
expect(ASSISTANT_WRITE_TOOLS.delete_packing_template).toEqual({ verbKey: 'genericRemove', entity: 'other' });
|
||||
expect(ASSISTANT_WRITE_TOOLS.save_packing_template).toEqual({ verbKey: 'genericCreate', entity: 'other' });
|
||||
const toggle = describeToolCall('toggle_packing_item', 'call_1', { itemId: 12, checked: true }, ctx);
|
||||
expect(toggle).toMatchObject({ subject: 'Hiking socks', tool: 'toggle_packing_item', undoable: true });
|
||||
expect(toggle.rows).toEqual([{ op: 'change', labelKey: 'packed', before: '✗', after: '✓' }]);
|
||||
expect(toggle.card?.kind).toBe('packingList');
|
||||
const bag = describeToolCall('update_packing_bag', 'call_2', { bagId: 5, name: 'Cabin' }, ctx, BAG_VALUE);
|
||||
expect(bag).toMatchObject({ subject: 'Carry-on', verbKey: 'genericUpdate', tool: 'update_packing_bag' });
|
||||
const template = { templates: [{ id: 4, name: 'Beach week', item_count: 24 }] };
|
||||
const dropped = describeToolCall('delete_packing_template', 'call_3', { templateId: 4 }, ctx, template);
|
||||
expect(dropped.subject).toBe('Beach week');
|
||||
const order = describeToolCall('reorder_packing_items', 'call_4', { orderedIds: [12, 11, 99] }, ctx);
|
||||
expect(order.subject).toBe('Hiking socks, Rain jacket, #99');
|
||||
const two = { items: [{ name: 'A' }, { name: 'B' }] };
|
||||
const imported = describeToolCall('bulk_import_packing', 'call_5', two, ctx);
|
||||
expect(imported).toMatchObject({ subject: 'A, B', verbKey: 'genericCreate' });
|
||||
});
|
||||
|
||||
it('ASTPACK-018: a receipt names the bag, the bag a change works on and the items an import made', () => {
|
||||
expect(appliedChange('create_packing_bag', 'c1', {}, { bag: { id: 9 } }, null, null)).toMatchObject({
|
||||
entity: 'packing_bag',
|
||||
ids: [9],
|
||||
});
|
||||
expect(appliedChange('set_bag_members', 'c2', { bagId: 5 }, { members: [] }, null, null).ids).toEqual([5]);
|
||||
const items = { items: [{ id: 81 }, { id: 82 }], count: 2 };
|
||||
expect(appliedChange('bulk_import_packing', 'c3', {}, items, null, null)).toMatchObject({
|
||||
entity: 'packing',
|
||||
ids: [81, 82],
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTPACK-019: a tick is taken back with the opposite tick, a new bag while nobody has used it', () => {
|
||||
const untick = ASSISTANT_UNDO_MAP.toggle_packing_item;
|
||||
expect(tickBack({ itemId: 15, checked: true })).toEqual({ itemId: 15, checked: false });
|
||||
expect(tickBack({ itemId: 11, checked: false })).toEqual({ itemId: 11, checked: true });
|
||||
expect(untick?.args({ itemId: 12 }, {}, null)).toBeNull();
|
||||
const row = { id: 12, name: 'Hiking socks', checked: 1, updated_at: 'b' };
|
||||
const summary = (items: unknown[]) => ({ summary: { packing: { items } }, places: { places: [] } });
|
||||
expect(unchangedSinceApplied('toggle_packing_item', { item: row }, summary([row]))).toBe(true);
|
||||
expect(unchangedSinceApplied('toggle_packing_item', { item: row }, summary([{ ...row, checked: 0 }]))).toBe(false);
|
||||
|
||||
expect(ASSISTANT_UNDO_MAP.create_packing_bag?.args({}, { bag: { id: 9 } }, null)).toEqual({ bagId: 9 });
|
||||
expect(undoReadsOf('create_packing_bag', {}, {})).toEqual([{ tool: 'list_packing_bags', args: {} }]);
|
||||
const bag = { id: 9, name: 'Beach bag', color: '#6366f1', weight_limit_grams: null, user_id: null };
|
||||
const reads = (bags: unknown[]) => new Map([[readKey('list_packing_bags', {}), { bags }]]);
|
||||
const snapshot = (bags: unknown[], items: unknown[] = []) => ({ ...summary(items), reads: reads(bags) });
|
||||
expect(unchangedSinceApplied('create_packing_bag', { bag }, snapshot([{ ...bag, members: [] }]))).toBe(true);
|
||||
const packed = snapshot([{ ...bag, members: [{ user_id: 2 }] }]);
|
||||
expect(unchangedSinceApplied('create_packing_bag', { bag }, packed)).toBe(false);
|
||||
expect(unchangedSinceApplied('create_packing_bag', { bag }, snapshot([bag], [{ id: 1, bag_id: 9 }]))).toBe(false);
|
||||
expect(unchangedSinceApplied('create_packing_bag', { bag }, snapshot([{ ...bag, name: 'Other' }]))).toBe(false);
|
||||
// Another member's hidden item still weighs in the bag's total.
|
||||
const weighed = snapshot([{ ...bag, members: [], total_weight_grams: 300 }]);
|
||||
expect(unchangedSinceApplied('create_packing_bag', { bag }, weighed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the packing cards let out', () => {
|
||||
it('ASTPACK-020: text from an answer is masked, from the arguments kept; no card carries an address', () => {
|
||||
const value = { items: [{ id: 11, name: 'Ask me@example.com', category: 'Clothes' }], bags: BAGS };
|
||||
const masked = packing(answer('list_packing_items', {}, value));
|
||||
expect(JSON.stringify(masked)).not.toContain('me@example.com');
|
||||
const kept = packing(change('create_packing_item', { name: 'Ask me@example.com' }));
|
||||
expect(kept.groups[0]?.items[0]?.name).toBe('Ask me@example.com');
|
||||
const cards = [
|
||||
masked,
|
||||
answer('list_packing_items', {}, { items: PACKING_ITEMS, bags: BAGS }),
|
||||
answer('list_packing_bags', {}, BAG_VALUE),
|
||||
];
|
||||
for (const card of cards) {
|
||||
expect(JSON.stringify(card)).not.toMatch(/http|\/uploads/);
|
||||
}
|
||||
// A name sent again as it is shows no rename, and stays masked as the trip read it.
|
||||
const mail = ctxWith([{ id: 41, name: 'Ask me@example.com', category: 'Docs', quantity: 1 }]);
|
||||
const args = { itemId: 41, name: 'Ask me@example.com' };
|
||||
const again = packing(changeCardOf('update_packing_item', mail, args, null));
|
||||
expect(again.groups[0]?.items[0]?.changed).toEqual([]);
|
||||
expect(JSON.stringify(again)).not.toContain('me@example.com');
|
||||
});
|
||||
|
||||
it('ASTPACK-022: a tick that changed nothing, or wiped a partial count, has no way back', () => {
|
||||
// Rain jacket was ticked already, Sun hat was not ticked: undoing would change what the user had.
|
||||
expect(tickBack({ itemId: 11, checked: true })).toBeNull();
|
||||
expect(tickBack({ itemId: 15, checked: false })).toBeNull();
|
||||
// One of three socks was packed: unticking after the tick would lose that count.
|
||||
expect(tickBack({ itemId: 12, checked: true })).toBeNull();
|
||||
// Without the change card it kept, there is nothing to tell where it was.
|
||||
const untick = ASSISTANT_UNDO_MAP.toggle_packing_item;
|
||||
expect(untick?.args({ itemId: 15, checked: true }, { item: { id: 15 } }, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTPACK-023: an import into one category shows up to 24 rows, weights cut to whole grams as stored', () => {
|
||||
const items = (n: number) => Array.from({ length: n }, (_, i) => ({ name: `Item ${i}`, weight_grams: 12.7 }));
|
||||
const twenty = packing(change('bulk_import_packing', { items: items(20) }));
|
||||
expect(twenty.groups[0]).toMatchObject({ count: 20 });
|
||||
expect(twenty.groups[0]?.items).toHaveLength(20);
|
||||
expect(twenty.groups[0]?.items[0]?.weightGrams).toBe(12);
|
||||
const thirty = packing(change('bulk_import_packing', { items: items(30) }));
|
||||
expect(thirty.groups[0]).toMatchObject({ count: 30 });
|
||||
expect(thirty.groups[0]?.items).toHaveLength(24);
|
||||
});
|
||||
|
||||
it('ASTPACK-021: the package tells the model once that its lists are cards and that an import is one call', () => {
|
||||
expect(CARD_PROMPT_LINES.some((line) => line.includes('do not list their items again'))).toBe(true);
|
||||
expect(CARD_PROMPT_LINES.some((line) => line.includes('bulk_import_packing'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import type { AnswerInput, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
|
||||
/**
|
||||
* A trip for the packing and to-do card tests. Maya (1) is the user; Ana (2)
|
||||
* and Ben (3) travel with her, Ben with a picture on another host. The list:
|
||||
* Clothes and an empty category on the common list, Toiletries, Clothes and
|
||||
* Personal on Maya's own, two bags; the to-dos: Bookings, Documents and one
|
||||
* on no list, one of them overdue on 2026-06-11.
|
||||
*/
|
||||
|
||||
export const TODAY = '2026-06-11';
|
||||
|
||||
export const PACKING_ITEMS = [
|
||||
{ id: 11, name: 'Rain jacket', category: 'Clothes', quantity: 1, checked: 1, weight_grams: 480, bag_id: 5 },
|
||||
{
|
||||
id: 12,
|
||||
name: 'Hiking socks',
|
||||
category: 'Clothes',
|
||||
quantity: 3,
|
||||
checked: 0,
|
||||
packed_quantity: 1,
|
||||
weight_grams: 70,
|
||||
bag_id: 6,
|
||||
},
|
||||
{ id: 13, name: '...', category: 'Empty', quantity: 1, checked: 0 },
|
||||
{
|
||||
id: 14,
|
||||
name: 'Sunscreen',
|
||||
category: 'Toiletries',
|
||||
quantity: 1,
|
||||
checked: 0,
|
||||
is_private: 1,
|
||||
owner_id: 1,
|
||||
recipients: [{ user_id: 2, username: 'Ana' }],
|
||||
},
|
||||
{ id: 15, name: 'Sun hat', category: 'Clothes', quantity: 1, checked: 0, is_private: 1, owner_id: 1, recipients: [] },
|
||||
{ id: 16, name: 'Diary', category: 'Personal', quantity: 1, checked: 0, is_private: 1, owner_id: 1 },
|
||||
];
|
||||
|
||||
export const BAGS = [
|
||||
{
|
||||
id: 5,
|
||||
name: 'Carry-on',
|
||||
color: '#6366f1',
|
||||
total_weight_grams: 6200,
|
||||
weight_limit_grams: 8000,
|
||||
user_id: 1,
|
||||
members: [{ user_id: 1 }, { user_id: 2 }],
|
||||
},
|
||||
{ id: 6, name: 'Daypack', color: '#ec4899', total_weight_grams: 900, weight_limit_grams: null, members: [] },
|
||||
];
|
||||
|
||||
export const TODOS = [
|
||||
{ id: 21, name: 'Book the ferry', category: 'Bookings', due_date: '2026-06-12', priority: 1, assigned_user_id: 2 },
|
||||
{ id: 22, name: 'Travel insurance', category: 'Documents', due_date: '2026-06-10', checked: 0, priority: 0 },
|
||||
{
|
||||
id: 23,
|
||||
name: 'Passport copies',
|
||||
category: null,
|
||||
checked: 1,
|
||||
description: '\n Scan both pages\nand mail them to me@example.com',
|
||||
},
|
||||
{ id: 24, name: 'Car seat', category: 'Bookings', checked: 0 },
|
||||
];
|
||||
|
||||
const SUMMARY = {
|
||||
trip: { title: 'Lisbon', start_date: '2026-06-12', end_date: '2026-06-14' },
|
||||
members: {
|
||||
owner: { id: 1, username: 'Maya', avatar_url: '/uploads/avatars/maya.png' },
|
||||
collaborators: [
|
||||
{ id: 2, username: 'Ana', avatar_url: null },
|
||||
{ id: 3, username: 'Ben', avatar_url: 'https://pics.example.com/ben.png' },
|
||||
],
|
||||
},
|
||||
days: [
|
||||
{ id: 31, day_number: 1, date: '2026-06-12', assignments: [], notes: [] },
|
||||
{ id: 32, day_number: 2, date: '2026-06-13', assignments: [], notes: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const PLACES = { places: [{ id: 70, name: 'Ridge walk' }] };
|
||||
|
||||
export const card = buildTripCard(4, 1, { ...SUMMARY, packing: { items: PACKING_ITEMS }, todos: TODOS }, PLACES, null);
|
||||
|
||||
export const ctx: PreviewContext = { card, categories: new Map(), locale: 'en', today: TODAY, airport: () => null };
|
||||
|
||||
/** The same trip holding other packing items and to-dos, for a case the lists above do not show. */
|
||||
export function ctxWith(items: unknown[], todos: unknown[] = []): PreviewContext {
|
||||
return { ...ctx, card: buildTripCard(4, 1, { ...SUMMARY, packing: { items }, todos }, PLACES, null) };
|
||||
}
|
||||
|
||||
export function answerInput(args: Record<string, unknown>, value: unknown): AnswerInput {
|
||||
return { toolCallId: 'call_1', args, value, card, today: TODAY };
|
||||
}
|
||||
|
||||
export const MAYA = { id: 1, name: 'Maya', avatarKey: 'maya.png' };
|
||||
export const ANA = { id: 2, name: 'Ana', avatarKey: null };
|
||||
export const BEN = { id: 3, name: 'Ben', avatarKey: null };
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* ASTTODO-001 to ASTTODO-012: the to-do cards of the assistant chat. The
|
||||
* list a read shows (by list in the panel's order, open ones first, overdue
|
||||
* against the day of the read), the card each to-do change shows, the words
|
||||
* of the to-do writes, the way back from a tick and the receipt of a new
|
||||
* to-do. Every card goes through the registry's guard.
|
||||
*/
|
||||
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
|
||||
import {
|
||||
answerCardOf,
|
||||
appliedCardOf,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
changeCardOf,
|
||||
} from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import { ANA, answerInput, BEN, ctx, ctxWith, MAYA, TODOS } from './packing-fixture';
|
||||
import type { TodoListCard, TrekCard } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function todos(card: TrekCard | null): TodoListCard {
|
||||
if (card?.kind !== 'todoList') throw new Error(`expected a to-do card, got ${JSON.stringify(card)}`);
|
||||
return card;
|
||||
}
|
||||
|
||||
const answer = (value: unknown) => answerCardOf('list_todos', answerInput({}, value));
|
||||
const change = (tool: string, args: Record<string, unknown>, loaded: unknown = null) =>
|
||||
changeCardOf(tool, ctx, args, loaded);
|
||||
|
||||
describe('the to-do answer', () => {
|
||||
it('ASTTODO-001: groups by list in name order with the ones on no list last, and counts open, done and overdue', () => {
|
||||
const card = todos(answer({ items: TODOS }));
|
||||
expect(card.groups.map((group) => [group.category, group.slot, group.count, group.open])).toEqual([
|
||||
['Bookings', 0, 2, 2],
|
||||
['Documents', 1, 1, 1],
|
||||
[null, null, 1, 0],
|
||||
]);
|
||||
expect(card.progress).toEqual({ open: 3, done: 1, overdue: 1 });
|
||||
expect(card.groups[0]?.items.map((item) => item.name)).toEqual(['Book the ferry', 'Car seat']);
|
||||
expect(card.groups[0]?.items[0]).toMatchObject({
|
||||
id: 21,
|
||||
dueDate: '2026-06-12',
|
||||
dueDayId: 31,
|
||||
priority: 1,
|
||||
assignee: ANA,
|
||||
note: null,
|
||||
change: 'none',
|
||||
});
|
||||
expect(card.groups[1]?.items[0]).toMatchObject({ dueDate: '2026-06-10', dueDayId: null });
|
||||
});
|
||||
|
||||
it('ASTTODO-002: a note is the first line with text, masked; an empty list shows no card', () => {
|
||||
const card = todos(answer({ items: TODOS }));
|
||||
expect(card.groups[2]?.items[0]).toMatchObject({ name: 'Passport copies', checked: true, note: 'Scan both pages' });
|
||||
const mail = todos(answer({ items: [{ id: 30, name: 'Ask', description: 'Mail me@example.com first' }] }));
|
||||
expect(JSON.stringify(mail)).not.toContain('me@example.com');
|
||||
expect(answer({ items: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTTODO-003: open ones come first by due date; done ones only fill a list up to five; 20 rows in all', () => {
|
||||
const items = [
|
||||
{ id: 1, name: 'Undated', category: 'Trip' },
|
||||
...Array.from({ length: 6 }, (_, i) => ({ id: 10 + i, name: `Done ${i}`, category: 'Trip', checked: 1 })),
|
||||
{ id: 2, name: 'Soon', category: 'Trip', due_date: '2026-06-01' },
|
||||
];
|
||||
const card = todos(answer({ items }));
|
||||
expect(card.groups[0]?.items.map((item) => item.name)).toEqual(['Soon', 'Undated', 'Done 0', 'Done 1', 'Done 2']);
|
||||
expect(card.groups[0]).toMatchObject({ count: 8, open: 2 });
|
||||
const many = Array.from({ length: 25 }, (_, i) => ({ id: 100 + i, name: `Task ${i}`, category: 'A' }));
|
||||
const capped = todos(answer({ items: [...many, { id: 200, name: 'Later', category: 'B' }] }));
|
||||
expect(capped.groups.map((group) => [group.items.length, group.count])).toEqual([
|
||||
[20, 25],
|
||||
[0, 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the to-do change cards', () => {
|
||||
it('ASTTODO-004: a new to-do shows in its list with its day, priority and person; its note is kept as written', () => {
|
||||
const args = {
|
||||
name: 'Book the train',
|
||||
category: 'Bookings',
|
||||
due_date: '2026-06-13',
|
||||
priority: 2,
|
||||
assigned_user_id: 3,
|
||||
description: 'Mail me@example.com',
|
||||
};
|
||||
const card = todos(change('create_todo', args));
|
||||
expect(card.groups[0]).toMatchObject({ category: 'Bookings', slot: 0, count: 1, open: 1 });
|
||||
expect(card.groups[0]?.items[0]).toMatchObject({
|
||||
id: null,
|
||||
note: 'Mail me@example.com',
|
||||
dueDayId: 32,
|
||||
priority: 2,
|
||||
assignee: BEN,
|
||||
change: 'add',
|
||||
});
|
||||
const odd = todos(change('create_todo', { name: 'Pack', due_date: '13.06.2026', priority: 7 }));
|
||||
expect(odd.groups[0]).toMatchObject({ category: null, slot: null, items: [{ dueDate: null, priority: 0 }] });
|
||||
expect(change('create_todo', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTTODO-005: an update merges its fields, null clearing them, and lists what it changes with the old values', () => {
|
||||
const args = { itemId: 21, category: 'Transport', due_date: null, priority: null, assigned_user_id: null };
|
||||
const card = todos(change('update_todo', args));
|
||||
expect(card.groups[0]).toMatchObject({ category: 'Transport', slot: null });
|
||||
expect(card.groups[0]?.items[0]).toMatchObject({ dueDate: null, dueDayId: null, priority: 0, assignee: null });
|
||||
expect(card.groups[0]?.items[0]?.changed).toEqual([
|
||||
{ field: 'category', before: 'Bookings' },
|
||||
{ field: 'dueDate', before: '2026-06-12' },
|
||||
{ field: 'priority', before: '1' },
|
||||
{ field: 'assignee', before: 'Ana' },
|
||||
]);
|
||||
const noted = todos(change('update_todo', { itemId: 21, description: 'Call me@example.com' }));
|
||||
expect(noted.groups[0]?.items[0]).toMatchObject({ note: 'Call me@example.com', changed: [{ field: 'note' }] });
|
||||
expect(change('update_todo', { itemId: 99, name: 'x' })).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTTODO-006: a tick and a removal show the to-do in its list; one the trip does not hold shows none', () => {
|
||||
const ticked = todos(change('toggle_todo', { itemId: 22, checked: true }));
|
||||
expect(ticked.groups[0]).toMatchObject({ category: 'Documents', slot: 1, open: 0 });
|
||||
expect(ticked.groups[0]?.items[0]).toMatchObject({ checked: true, change: 'check' });
|
||||
const reopened = todos(change('toggle_todo', { itemId: 23, checked: false }));
|
||||
expect(reopened.groups[0]?.items[0]).toMatchObject({ checked: false, change: 'uncheck' });
|
||||
expect(todos(change('delete_todo', { itemId: 24 })).groups[0]?.items[0]?.change).toBe('remove');
|
||||
expect(change('toggle_todo', { itemId: 99, checked: true })).toBeNull();
|
||||
expect(change('delete_todo', { itemId: 99 })).toBeNull();
|
||||
});
|
||||
|
||||
it("ASTTODO-007: a list's default people show the ones it will have and the ones it drops", () => {
|
||||
const loaded = { assignees: { Bookings: [{ user_id: 2 }] } };
|
||||
const card = todos(change('set_todo_category_assignees', { categoryName: 'Bookings', userIds: [1] }, loaded));
|
||||
expect(card.groups).toEqual([
|
||||
{
|
||||
category: 'Bookings',
|
||||
slot: 0,
|
||||
assignees: [MAYA],
|
||||
assigneesRemoved: [ANA],
|
||||
items: [],
|
||||
count: 2,
|
||||
open: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/** The way back from a to-do tick, with the change card the tick showed. */
|
||||
function todoBack(args: { itemId: number; checked: boolean }) {
|
||||
const preview = describeToolCall('toggle_todo', 'call_t', args, ctx);
|
||||
return ASSISTANT_UNDO_MAP.toggle_todo?.args(args, { item: { id: args.itemId } }, preview);
|
||||
}
|
||||
|
||||
describe('the to-do writes, undo and receipts', () => {
|
||||
it('ASTTODO-008: the to-do writes name the to-do from the trip, their own action and a card', () => {
|
||||
expect(ASSISTANT_WRITE_TOOLS.delete_todo).toEqual({ verbKey: 'genericRemove', entity: 'todo' });
|
||||
expect(ASSISTANT_WRITE_TOOLS.set_todo_category_assignees).toEqual({ verbKey: 'genericUpdate', entity: 'todo' });
|
||||
const update = describeToolCall('update_todo', 'call_1', { itemId: 21, name: 'Ferry', due_date: null }, ctx);
|
||||
expect(update).toMatchObject({ subject: 'Book the ferry', tool: 'update_todo', verbKey: 'genericUpdate' });
|
||||
expect(update.rows.map((row) => [row.labelKey, row.before, row.after])).toEqual([
|
||||
['name', 'Book the ferry', 'Ferry'],
|
||||
['dueDate', expect.any(String), ''],
|
||||
]);
|
||||
expect(update.card?.kind).toBe('todoList');
|
||||
const toggle = describeToolCall('toggle_todo', 'call_2', { itemId: 22, checked: true }, ctx);
|
||||
expect(toggle).toMatchObject({ subject: 'Travel insurance', tool: 'toggle_todo', undoable: true });
|
||||
const order = describeToolCall('reorder_todos', 'call_3', { orderedIds: [24, 21] }, ctx);
|
||||
expect(order.subject).toBe('Car seat, Book the ferry');
|
||||
});
|
||||
|
||||
it('ASTTODO-009: a tick is taken back with the opposite tick while the to-do is as the tick left it', () => {
|
||||
expect(todoBack({ itemId: 22, checked: true })).toEqual({ itemId: 22, checked: false });
|
||||
expect(todoBack({ itemId: 23, checked: false })).toEqual({ itemId: 23, checked: true });
|
||||
const row = { id: 22, name: 'Travel insurance', checked: 1, due_date: '2026-06-10' };
|
||||
const snapshot = (rows: unknown[]) => ({ summary: { todos: rows }, places: { places: [] } });
|
||||
expect(unchangedSinceApplied('toggle_todo', { item: row }, snapshot([row]))).toBe(true);
|
||||
expect(unchangedSinceApplied('toggle_todo', { item: row }, snapshot([{ ...row, name: 'Insurance' }]))).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTTODO-011: a tick that changed nothing has no way back, nor one without its change card', () => {
|
||||
// Passport copies were done already, Travel insurance was still open.
|
||||
expect(todoBack({ itemId: 23, checked: true })).toBeNull();
|
||||
expect(todoBack({ itemId: 22, checked: false })).toBeNull();
|
||||
const ticked = todos(change('toggle_todo', { itemId: 22, checked: true }));
|
||||
expect(ticked.groups[0]?.items[0]?.changed).toEqual([{ field: 'done', before: '0' }]);
|
||||
const bare = ASSISTANT_UNDO_MAP.toggle_todo?.args({ itemId: 22, checked: true }, { item: { id: 22 } }, null);
|
||||
expect(bare).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTTODO-012: the old note and list on an update are masked; a name sent again shows no rename', () => {
|
||||
const mail = ctxWith(
|
||||
[],
|
||||
[{ id: 51, name: 'Mail me@example.com', category: 'Ask you@example.com', description: 'Write me@example.com' }],
|
||||
);
|
||||
const fields = { itemId: 51, description: 'Call', category: 'Calls' };
|
||||
const update = describeToolCall('update_todo', 'call_m', fields, mail);
|
||||
expect(JSON.stringify(update.rows)).not.toMatch(/me@example\.com|you@example\.com/);
|
||||
const args = { ...fields, name: 'Mail me@example.com' };
|
||||
const card = todos(changeCardOf('update_todo', mail, args, null));
|
||||
expect(card.groups[0]?.items[0]?.changed.map((entry) => entry.field)).toEqual(['note', 'category']);
|
||||
expect(JSON.stringify(card)).not.toContain('me@example.com');
|
||||
});
|
||||
|
||||
it('ASTTODO-010: a new to-do gets the id the server gave it once applied', () => {
|
||||
const preview = describeToolCall('create_todo', 'call_4', { name: 'Pack' }, ctx);
|
||||
const input = { args: { name: 'Pack' }, result: { item: { id: 77 } }, preview };
|
||||
const applied = todos(appliedCardOf('create_todo', input));
|
||||
expect(applied.groups[0]?.items[0]?.id).toBe(77);
|
||||
expect(appliedCardOf('create_todo', { args: {}, result: 'To-do item not found.', preview })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,121 @@
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { cardHexColorSchema, cardPersonSchema, cardText } from './card-parts';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* A packing list: the trip's, a tour's or a template's. Only its kind so far; the packing and to-do
|
||||
* cards give it its fields.
|
||||
* A packing list in the assistant chat: the trip's, the part a tour points
|
||||
* at, or a template about to be added. It is grouped the way the packing
|
||||
* list shows it, by category or by bag; a bag overview is the same card with
|
||||
* the bags as groups and no item rows. On a change card the rows carry what
|
||||
* the change does to them (`change`, and the old value of every field it
|
||||
* touches), on an answer `change` is `none`.
|
||||
*/
|
||||
export const packingListCardSchema = z.object({ kind: z.literal('packingList') });
|
||||
|
||||
const gramsSchema = z.number().int().min(0).max(1_000_000);
|
||||
const checklistCountSchema = z.number().int().min(0).max(5_000);
|
||||
|
||||
/**
|
||||
* The index of a category in the order its list first shows it; the client
|
||||
* maps it onto the list's own colours, so the chat's dot matches the panel's.
|
||||
* Null where the card does not know the category yet (the hash colour then).
|
||||
*/
|
||||
export const checklistColorSlotSchema = z.number().int().min(0).max(999);
|
||||
|
||||
/** The value a field held before a change, as stored (an ISO date, a colour, grams, a name). */
|
||||
export const checklistBeforeSchema = z.string().max(120).nullable();
|
||||
|
||||
export const PACKING_ROW_CHANGES = ['none', 'add', 'update', 'check', 'uncheck', 'remove', 'share'] as const;
|
||||
export const PACKING_ITEM_FIELDS = ['name', 'category', 'quantity', 'packed', 'weight', 'bag', 'tier'] as const;
|
||||
export const PACKING_BAG_FIELDS = ['name', 'color', 'limit', 'owner'] as const;
|
||||
export const PACKING_TIERS = ['common', 'personal', 'shared'] as const;
|
||||
|
||||
/** A bag on the list (`id:12`) or one a pending import creates (`new:0`). */
|
||||
export const packingBagKeySchema = z.string().regex(/^(id:\d{1,12}|new:\d{1,2})$/);
|
||||
|
||||
export const packingCardBagSchema = z.object({
|
||||
key: packingBagKeySchema,
|
||||
id: idSchema.nullable(),
|
||||
name: cardText(100),
|
||||
/** Null while a create waits for the server to pick it. */
|
||||
color: cardHexColorSchema.nullable(),
|
||||
/** What the bag weighs with every member's items in it (#2191). */
|
||||
weightGrams: gramsSchema.nullable(),
|
||||
limitGrams: gramsSchema.nullable(),
|
||||
owner: cardPersonSchema.nullable(),
|
||||
members: z.array(cardPersonSchema).max(6),
|
||||
membersRemoved: z.array(cardPersonSchema).max(6),
|
||||
change: z.enum(['none', 'add', 'update', 'remove', 'members']),
|
||||
changed: z.array(z.object({ field: z.enum(PACKING_BAG_FIELDS), before: checklistBeforeSchema })).max(4),
|
||||
/** For a bag about to be deleted: the items that land in "Unassigned". */
|
||||
movesOut: checklistCountSchema,
|
||||
});
|
||||
export type PackingCardBag = z.infer<typeof packingCardBagSchema>;
|
||||
|
||||
export const packingCardItemSchema = z.object({
|
||||
/** Null while a create waits. */
|
||||
id: idSchema.nullable(),
|
||||
name: cardText(200),
|
||||
quantity: z.number().int().min(1).max(999),
|
||||
/** All of it once ticked, the partial count before that (#2296). */
|
||||
packed: z.number().int().min(0).max(999),
|
||||
checked: z.boolean(),
|
||||
/** Unit weight times quantity; null where none is recorded. */
|
||||
weightGrams: gramsSchema.nullable(),
|
||||
/** One of the card's `bags`; null for an item in no bag. */
|
||||
bagKey: packingBagKeySchema.nullable(),
|
||||
tier: z.enum(PACKING_TIERS),
|
||||
sharedWith: z.array(cardPersonSchema).max(4),
|
||||
change: z.enum(PACKING_ROW_CHANGES),
|
||||
changed: z.array(z.object({ field: z.enum(PACKING_ITEM_FIELDS), before: checklistBeforeSchema })).max(7),
|
||||
});
|
||||
export type PackingCardItem = z.infer<typeof packingCardItemSchema>;
|
||||
|
||||
export const packingCardGroupSchema = z.object({
|
||||
key: z.discriminatedUnion('by', [
|
||||
z.object({
|
||||
by: z.literal('category'),
|
||||
/** Null: the items without a category, which the list files under its default one. */
|
||||
name: cardText(100).nullable(),
|
||||
slot: checklistColorSlotSchema.nullable(),
|
||||
}),
|
||||
/** Null: the pile in no bag. */
|
||||
z.object({ by: z.literal('bag'), bagKey: packingBagKeySchema.nullable() }),
|
||||
]),
|
||||
/** Who looks after the category. */
|
||||
assignees: z.array(cardPersonSchema).max(6),
|
||||
/** Who a change of the category's people takes off it. */
|
||||
assigneesRemoved: z.array(cardPersonSchema).max(6),
|
||||
items: z.array(packingCardItemSchema).max(40),
|
||||
/** The group's items on the list, shown or not. */
|
||||
count: checklistCountSchema,
|
||||
packed: checklistCountSchema,
|
||||
});
|
||||
export type PackingCardGroup = z.infer<typeof packingCardGroupSchema>;
|
||||
|
||||
export const packingListCardSchema = z.object({
|
||||
kind: z.literal('packingList'),
|
||||
source: z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('trip') }),
|
||||
z.object({
|
||||
type: z.literal('tour'),
|
||||
placeId: idSchema,
|
||||
name: cardText(200),
|
||||
category: cardText(200).nullable(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('template'),
|
||||
templateId: idSchema,
|
||||
name: cardText(100),
|
||||
itemCount: checklistCountSchema,
|
||||
}),
|
||||
]),
|
||||
groupBy: z.enum(['category', 'bag']),
|
||||
/** How much of the list is packed; answers only. */
|
||||
progress: z.object({ packed: checklistCountSchema, total: checklistCountSchema }).nullable(),
|
||||
bags: z.array(packingCardBagSchema).max(20),
|
||||
groups: z.array(packingCardGroupSchema).max(12),
|
||||
/** Groups past the twelve shown. */
|
||||
moreGroups: checklistCountSchema,
|
||||
});
|
||||
export type PackingListCard = z.infer<typeof packingListCardSchema>;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { packingListItemsRequestSchema } from '../../packing/packing.schema';
|
||||
import { storedTrekCardSchema, trekCardSchema } from './index';
|
||||
import { packingListCardSchema } from './packing-list.card';
|
||||
import { todoListCardSchema } from './todo-list.card';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* PKCARD-001 to PKCARD-008: the packing and to-do cards of the assistant
|
||||
* chat. Both kinds join the card union; every field is bounded; a bag is
|
||||
* named by a key, a colour only as `#rrggbb`, a person never with an address,
|
||||
* a date only as `YYYY-MM-DD`; and the list read they come from takes its
|
||||
* filters in a closed shape.
|
||||
*/
|
||||
|
||||
const ANA = { id: 2, name: 'Ana', avatarKey: 'ana.png' };
|
||||
|
||||
const item = (over: Record<string, unknown> = {}) => ({
|
||||
id: 7,
|
||||
name: 'Rain jacket',
|
||||
quantity: 1,
|
||||
packed: 0,
|
||||
checked: false,
|
||||
weightGrams: 480,
|
||||
bagKey: 'id:3',
|
||||
tier: 'common',
|
||||
sharedWith: [],
|
||||
change: 'none',
|
||||
changed: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const bag = (over: Record<string, unknown> = {}) => ({
|
||||
key: 'id:3',
|
||||
id: 3,
|
||||
name: 'Carry-on',
|
||||
color: '#6366f1',
|
||||
weightGrams: 6200,
|
||||
limitGrams: 8000,
|
||||
owner: ANA,
|
||||
members: [ANA],
|
||||
membersRemoved: [],
|
||||
change: 'none',
|
||||
changed: [],
|
||||
movesOut: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
const packing = (over: Record<string, unknown> = {}) => ({
|
||||
kind: 'packingList',
|
||||
source: { type: 'trip' },
|
||||
groupBy: 'category',
|
||||
progress: { packed: 1, total: 3 },
|
||||
bags: [bag()],
|
||||
groups: [
|
||||
{
|
||||
key: { by: 'category', name: 'Clothes', slot: 0 },
|
||||
assignees: [ANA],
|
||||
assigneesRemoved: [],
|
||||
items: [item()],
|
||||
count: 3,
|
||||
packed: 1,
|
||||
},
|
||||
],
|
||||
moreGroups: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
/** The packing card with one row in its one group. */
|
||||
const withRow = (row: unknown) => packing({ groups: [{ ...packing().groups[0], items: [row] }] });
|
||||
|
||||
const todo = (over: Record<string, unknown> = {}) => ({
|
||||
id: 9,
|
||||
name: 'Book the ferry',
|
||||
note: 'Check the early sailing first',
|
||||
checked: false,
|
||||
dueDate: '2026-06-12',
|
||||
dueDayId: 31,
|
||||
priority: 1,
|
||||
assignee: ANA,
|
||||
change: 'none',
|
||||
changed: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const todos = (items: unknown[] = [todo()]) => ({
|
||||
kind: 'todoList',
|
||||
progress: { open: 2, done: 1, overdue: 1 },
|
||||
groups: [{ category: 'Bookings', slot: 0, assignees: [], assigneesRemoved: [], items, count: 2, open: 2 }],
|
||||
moreGroups: 0,
|
||||
});
|
||||
|
||||
describe('packing and to-do cards', () => {
|
||||
it('PKCARD-001: a packing list, a bag overview, a tour and a template source all read as cards of the union', () => {
|
||||
expect(trekCardSchema.parse(packing()).kind).toBe('packingList');
|
||||
const bags = packing({ groupBy: 'bag', progress: null, groups: [] });
|
||||
expect(packingListCardSchema.safeParse(bags).success).toBe(true);
|
||||
const tour = { type: 'tour', placeId: 11, name: 'Ridge', category: 'Hiking gear' };
|
||||
expect(packingListCardSchema.safeParse(packing({ source: tour })).success).toBe(true);
|
||||
const template = { type: 'template', templateId: 4, name: 'Beach week', itemCount: 24 };
|
||||
expect(packingListCardSchema.safeParse(packing({ source: template, groups: [] })).success).toBe(true);
|
||||
const pile = { key: { by: 'bag', bagKey: null }, assignees: [], assigneesRemoved: [], items: [], count: 2 };
|
||||
expect(packingListCardSchema.safeParse(packing({ groups: [{ ...pile, packed: 0 }] })).success).toBe(true);
|
||||
});
|
||||
|
||||
it('PKCARD-002: bags are named by key, a waiting one as new:N; any other key is refused', () => {
|
||||
const waiting = bag({ key: 'new:0', id: null, color: null });
|
||||
expect(packingListCardSchema.safeParse(packing({ bags: [waiting] })).success).toBe(true);
|
||||
for (const key of ['3', 'id:', 'new:123', 'bag:3']) {
|
||||
expect(packingListCardSchema.safeParse(packing({ bags: [bag({ key })] })).success, key).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('PKCARD-003: a colour is only #rrggbb and a person never carries an address', () => {
|
||||
expect(packingListCardSchema.safeParse(packing({ bags: [bag({ color: '#abc' })] })).success).toBe(false);
|
||||
const url = { ...ANA, avatarKey: 'https://example.com/a.png' };
|
||||
expect(packingListCardSchema.safeParse(packing({ bags: [bag({ owner: url })] })).success).toBe(false);
|
||||
expect(todoListCardSchema.safeParse(todos([todo({ assignee: url })])).success).toBe(false);
|
||||
});
|
||||
|
||||
it('PKCARD-004: counts stay in bounds: no more than 999 packed, no item without a name', () => {
|
||||
for (const row of [item({ packed: 1000 }), item({ name: '' }), item({ quantity: 0 })]) {
|
||||
expect(packingListCardSchema.safeParse(withRow(row)).success).toBe(false);
|
||||
}
|
||||
const many = packing({ groups: Array(13).fill(packing().groups[0]) });
|
||||
expect(packingListCardSchema.safeParse(many).success).toBe(false);
|
||||
});
|
||||
|
||||
it('PKCARD-005: a change row says what it does and what each touched field held before', () => {
|
||||
const changed = item({ change: 'update', changed: [{ field: 'bag', before: 'Daypack' }] });
|
||||
expect(packingListCardSchema.safeParse(withRow(changed)).success).toBe(true);
|
||||
const odd = item({ change: 'update', changed: [{ field: 'colour', before: null }] });
|
||||
expect(packingListCardSchema.safeParse(withRow(odd)).success).toBe(false);
|
||||
});
|
||||
|
||||
it('PKCARD-006: a to-do list reads as a card, a tick keeping whether it was done; a local date is refused', () => {
|
||||
expect(trekCardSchema.parse(todos()).kind).toBe('todoList');
|
||||
expect(todoListCardSchema.safeParse(todos([todo({ dueDate: '12.06.2026' })])).success).toBe(false);
|
||||
expect(todoListCardSchema.safeParse(todos([todo({ priority: 4 })])).success).toBe(false);
|
||||
const plain = todo({ dueDate: null, dueDayId: null, note: null });
|
||||
expect(todoListCardSchema.safeParse(todos([plain])).success).toBe(true);
|
||||
const ticked = todo({ change: 'check', checked: true, changed: [{ field: 'done', before: '0' }] });
|
||||
expect(todoListCardSchema.safeParse(todos([ticked])).success).toBe(true);
|
||||
});
|
||||
|
||||
it('PKCARD-007: a broken stored card reads as null instead of breaking the message', () => {
|
||||
expect(storedTrekCardSchema.parse(packing({ groupBy: 'weight' }))).toBeNull();
|
||||
expect(storedTrekCardSchema.parse(todos([todo({ change: 'archive' })]))).toBeNull();
|
||||
});
|
||||
|
||||
it('PKCARD-008: the list read takes a category, a bag or none, a packed state and a grouping, nothing else', () => {
|
||||
expect(packingListItemsRequestSchema.parse({ bag_id: null, status: 'open', group_by: 'bag' })).toEqual({
|
||||
bag_id: null,
|
||||
status: 'open',
|
||||
group_by: 'bag',
|
||||
});
|
||||
expect(packingListItemsRequestSchema.safeParse({ status: 'done' }).success).toBe(false);
|
||||
expect(packingListItemsRequestSchema.safeParse({ bag_id: 0 }).success).toBe(false);
|
||||
expect(packingListItemsRequestSchema.safeParse({ category: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,10 @@ export * from './todo-list.card';
|
||||
* the verbs of their change cards and what an applied change of theirs
|
||||
* touches, beyond the core ones. cards/index.ts and assistant.schema.ts
|
||||
* spread these into the card union and the shared enums, so a new kind, verb
|
||||
* or entity is added here and nowhere else.
|
||||
* or entity is added here and nowhere else. Their changes name the generic
|
||||
* verbs with the tool's own label; a bag is the one thing they touch that the
|
||||
* core has no name for.
|
||||
*/
|
||||
export const PACKING_CARD_SCHEMAS = [packingListCardSchema, todoListCardSchema] as const;
|
||||
export const PACKING_VERB_KEYS = [] as const;
|
||||
export const PACKING_APPLIED_ENTITIES = [] as const;
|
||||
export const PACKING_APPLIED_ENTITIES = ['packing_bag'] as const;
|
||||
|
||||
@@ -1,5 +1,59 @@
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { cardDateSchema, cardPersonSchema, cardText } from './card-parts';
|
||||
import { checklistBeforeSchema, checklistColorSlotSchema } from './packing-list.card';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/** To-dos of the trip. Only its kind so far; the packing and to-do cards give it its fields. */
|
||||
export const todoListCardSchema = z.object({ kind: z.literal('todoList') });
|
||||
/**
|
||||
* The trip's to-dos in the assistant chat, grouped by their list (category)
|
||||
* the way the to-do panel shows them. On a change card the rows carry what
|
||||
* the change does to them and the old value of every field it touches; on an
|
||||
* answer `change` is `none`.
|
||||
*/
|
||||
|
||||
const todoCountSchema = z.number().int().min(0).max(5_000);
|
||||
|
||||
export const TODO_ROW_CHANGES = ['none', 'add', 'update', 'check', 'uncheck', 'remove'] as const;
|
||||
/** `done` is kept by a tick only (`'1'` or `'0'`), for the way back; it is never drawn as a fact. */
|
||||
export const TODO_FIELDS = ['name', 'note', 'category', 'dueDate', 'priority', 'assignee', 'done'] as const;
|
||||
|
||||
export const todoCardItemSchema = z.object({
|
||||
/** Null while a create waits. */
|
||||
id: idSchema.nullable(),
|
||||
name: cardText(300),
|
||||
/** The first line of the description. */
|
||||
note: cardText(160).nullable(),
|
||||
checked: z.boolean(),
|
||||
dueDate: cardDateSchema.nullable(),
|
||||
/** The trip day on the due date, so the date can show that day in the plan. */
|
||||
dueDayId: idSchema.nullable(),
|
||||
/** 0 for none, 1 the highest. */
|
||||
priority: z.number().int().min(0).max(3),
|
||||
assignee: cardPersonSchema.nullable(),
|
||||
change: z.enum(TODO_ROW_CHANGES),
|
||||
changed: z.array(z.object({ field: z.enum(TODO_FIELDS), before: checklistBeforeSchema })).max(7),
|
||||
});
|
||||
export type TodoCardItem = z.infer<typeof todoCardItemSchema>;
|
||||
|
||||
export const todoCardGroupSchema = z.object({
|
||||
/** Null: the to-dos on no list. */
|
||||
category: cardText(100).nullable(),
|
||||
slot: checklistColorSlotSchema.nullable(),
|
||||
/** Who a list's new to-dos go to by default. */
|
||||
assignees: z.array(cardPersonSchema).max(6),
|
||||
assigneesRemoved: z.array(cardPersonSchema).max(6),
|
||||
items: z.array(todoCardItemSchema).max(40),
|
||||
/** The group's to-dos, shown or not. */
|
||||
count: todoCountSchema,
|
||||
open: todoCountSchema,
|
||||
});
|
||||
export type TodoCardGroup = z.infer<typeof todoCardGroupSchema>;
|
||||
|
||||
export const todoListCardSchema = z.object({
|
||||
kind: z.literal('todoList'),
|
||||
/** Overdue against the day of the read; answers only. */
|
||||
progress: z.object({ open: todoCountSchema, done: todoCountSchema, overdue: todoCountSchema }).nullable(),
|
||||
groups: z.array(todoCardGroupSchema).max(12),
|
||||
moreGroups: todoCountSchema,
|
||||
});
|
||||
export type TodoListCard = z.infer<typeof todoListCardSchema>;
|
||||
|
||||
@@ -1,4 +1,91 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'قائمة التجهيز',
|
||||
'assistant.card.packing.bagsTitle': 'الحقائب',
|
||||
'assistant.card.packing.forTour': 'التجهيز لهذه الجولة',
|
||||
'assistant.card.packing.templateAdds': 'يضيف {count} غرض من القالب',
|
||||
'assistant.card.packing.templateAdds.zero': 'يضيف {count} غرض من القالب',
|
||||
'assistant.card.packing.templateAdds.one': 'يضيف {count} غرض من القالب',
|
||||
'assistant.card.packing.templateAdds.two': 'يضيف {count} غرض من القالب',
|
||||
'assistant.card.packing.templateAdds.few': 'يضيف {count} أغراض من القالب',
|
||||
'assistant.card.packing.templateAdds.many': 'يضيف {count} غرض من القالب',
|
||||
'assistant.card.packing.moreCategories': '{count} فئة أخرى',
|
||||
'assistant.card.packing.moreCategories.zero': '{count} فئة أخرى',
|
||||
'assistant.card.packing.moreCategories.one': '{count} فئة أخرى',
|
||||
'assistant.card.packing.moreCategories.two': '{count} فئة أخرى',
|
||||
'assistant.card.packing.moreCategories.few': '{count} فئات أخرى',
|
||||
'assistant.card.packing.moreCategories.many': '{count} فئة أخرى',
|
||||
'assistant.card.packing.moreBags': '{count} حقيبة أخرى',
|
||||
'assistant.card.packing.moreBags.zero': '{count} حقيبة أخرى',
|
||||
'assistant.card.packing.moreBags.one': '{count} حقيبة أخرى',
|
||||
'assistant.card.packing.moreBags.two': '{count} حقيبة أخرى',
|
||||
'assistant.card.packing.moreBags.few': '{count} حقائب أخرى',
|
||||
'assistant.card.packing.moreBags.many': '{count} حقيبة أخرى',
|
||||
'assistant.card.packing.ofLimit': '{weight} من {limit}',
|
||||
'assistant.card.packing.overLimit': 'فوق الحد',
|
||||
'assistant.card.packing.movesOut': 'ينتقل {count} غرض إلى غير معيّن',
|
||||
'assistant.card.packing.movesOut.zero': 'ينتقل {count} غرض إلى غير معيّن',
|
||||
'assistant.card.packing.movesOut.one': 'ينتقل {count} غرض إلى غير معيّن',
|
||||
'assistant.card.packing.movesOut.two': 'ينتقل {count} غرض إلى غير معيّن',
|
||||
'assistant.card.packing.movesOut.few': 'تنتقل {count} أغراض إلى غير معيّن',
|
||||
'assistant.card.packing.movesOut.many': 'ينتقل {count} غرض إلى غير معيّن',
|
||||
'assistant.card.packing.newBag': 'حقيبة جديدة',
|
||||
'assistant.card.packing.packed': 'تم التجهيز',
|
||||
'assistant.card.packing.notPacked': 'لم يُجهَّز',
|
||||
'assistant.card.packing.sharedWith': 'مشترك مع {names}',
|
||||
'assistant.card.packing.tierCommon': 'القائمة المشتركة',
|
||||
'assistant.card.packing.tierPersonal': 'لي وحدي',
|
||||
'assistant.card.packing.noWeight': 'بلا وزن',
|
||||
'assistant.card.packing.noLimit': 'بلا حد',
|
||||
'assistant.card.todo.title': 'المهام',
|
||||
'assistant.card.todo.open': 'فتح قائمة المهام',
|
||||
'assistant.card.todo.openCount': '{count} مفتوحة',
|
||||
'assistant.card.todo.openCount.zero': '{count} مفتوحة',
|
||||
'assistant.card.todo.openCount.one': '{count} مفتوحة',
|
||||
'assistant.card.todo.openCount.two': '{count} مفتوحة',
|
||||
'assistant.card.todo.openCount.few': '{count} مفتوحة',
|
||||
'assistant.card.todo.openCount.many': '{count} مفتوحة',
|
||||
'assistant.card.todo.overdueCount': '{count} متأخرة',
|
||||
'assistant.card.todo.overdueCount.zero': '{count} متأخرة',
|
||||
'assistant.card.todo.overdueCount.one': '{count} متأخرة',
|
||||
'assistant.card.todo.overdueCount.two': '{count} متأخرة',
|
||||
'assistant.card.todo.overdueCount.few': '{count} متأخرة',
|
||||
'assistant.card.todo.overdueCount.many': '{count} متأخرة',
|
||||
'assistant.card.todo.moreTasks': '{count} مهمة أخرى',
|
||||
'assistant.card.todo.moreTasks.zero': '{count} مهمة أخرى',
|
||||
'assistant.card.todo.moreTasks.one': '{count} مهمة أخرى',
|
||||
'assistant.card.todo.moreTasks.two': '{count} مهمة أخرى',
|
||||
'assistant.card.todo.moreTasks.few': '{count} مهام أخرى',
|
||||
'assistant.card.todo.moreTasks.many': '{count} مهمة أخرى',
|
||||
'assistant.card.todo.moreLists': '{count} قائمة أخرى',
|
||||
'assistant.card.todo.moreLists.zero': '{count} قائمة أخرى',
|
||||
'assistant.card.todo.moreLists.one': '{count} قائمة أخرى',
|
||||
'assistant.card.todo.moreLists.two': '{count} قائمة أخرى',
|
||||
'assistant.card.todo.moreLists.few': '{count} قوائم أخرى',
|
||||
'assistant.card.todo.moreLists.many': '{count} قائمة أخرى',
|
||||
'assistant.card.todo.noDueDate': 'بلا موعد نهائي',
|
||||
'assistant.card.todo.notDone': 'لم تُنجز',
|
||||
'assistant.step.packingItems': 'جارٍ قراءة قائمة التجهيز',
|
||||
'assistant.step.packingBags': 'جارٍ قراءة الحقائب',
|
||||
'assistant.step.packingAssignees': 'جارٍ قراءة من يحمل ماذا',
|
||||
'assistant.step.packingTemplates': 'جارٍ قراءة قوالب التجهيز',
|
||||
'assistant.step.todoAssignees': 'جارٍ قراءة من يتولى كل قائمة',
|
||||
'assistant.step.tourPacking': 'جارٍ قراءة قائمة تجهيز الجولة',
|
||||
'assistant.tool.bulk_import_packing': 'إضافة عدة أغراض إلى قائمة التجهيز',
|
||||
'assistant.tool.apply_packing_template': 'إضافة قالب تجهيز',
|
||||
'assistant.tool.set_packing_item_sharing': 'تغيير لمن هذا الغرض',
|
||||
'assistant.tool.set_packing_category_assignees': 'اختيار من يجهّز هذه الفئة',
|
||||
'assistant.tool.reorder_packing_items': 'إعادة ترتيب قائمة التجهيز',
|
||||
'assistant.tool.create_packing_bag': 'إضافة حقيبة',
|
||||
'assistant.tool.update_packing_bag': 'تعديل الحقيبة',
|
||||
'assistant.tool.delete_packing_bag': 'حذف الحقيبة',
|
||||
'assistant.tool.set_bag_members': 'اختيار من يجهّز الحقيبة',
|
||||
'assistant.tool.save_packing_template': 'حفظ كقالب تجهيز',
|
||||
'assistant.tool.delete_packing_template': 'حذف قالب التجهيز',
|
||||
'assistant.tool.set_todo_category_assignees': 'اختيار من يتولى القائمة',
|
||||
'assistant.tool.reorder_todos': 'إعادة ترتيب المهام',
|
||||
'assistant.tool.link_tour_packing': 'ربط قائمة تجهيز بالجولة',
|
||||
'assistant.tool.unlink_tour_packing': 'فك ربط قائمة تجهيز الجولة',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Baqaj siyahısı',
|
||||
'assistant.card.packing.bagsTitle': 'Çantalar',
|
||||
'assistant.card.packing.forTour': 'Bu tur üçün baqaj',
|
||||
'assistant.card.packing.templateAdds': 'Şablondan {count} əşya əlavə edir',
|
||||
'assistant.card.packing.templateAdds.one': 'Şablondan {count} əşya əlavə edir',
|
||||
'assistant.card.packing.moreCategories': 'Daha {count} kateqoriya',
|
||||
'assistant.card.packing.moreCategories.one': 'Daha {count} kateqoriya',
|
||||
'assistant.card.packing.moreBags': 'Daha {count} çanta',
|
||||
'assistant.card.packing.moreBags.one': 'Daha {count} çanta',
|
||||
'assistant.card.packing.ofLimit': '{weight} / {limit}',
|
||||
'assistant.card.packing.overLimit': 'Limitdən artıq',
|
||||
'assistant.card.packing.movesOut': '{count} əşya Təyin edilməyib bölməsinə keçir',
|
||||
'assistant.card.packing.movesOut.one': '{count} əşya Təyin edilməyib bölməsinə keçir',
|
||||
'assistant.card.packing.newBag': 'Yeni çanta',
|
||||
'assistant.card.packing.packed': 'Yığılıb',
|
||||
'assistant.card.packing.notPacked': 'Yığılmayıb',
|
||||
'assistant.card.packing.sharedWith': '{names} ilə paylaşılıb',
|
||||
'assistant.card.packing.tierCommon': 'Ümumi siyahı',
|
||||
'assistant.card.packing.tierPersonal': 'Yalnız mənim üçün',
|
||||
'assistant.card.packing.noWeight': 'Çəki yoxdur',
|
||||
'assistant.card.packing.noLimit': 'Limit yoxdur',
|
||||
'assistant.card.todo.title': 'Tapşırıqlar',
|
||||
'assistant.card.todo.open': 'Tapşırıqlar siyahısını aç',
|
||||
'assistant.card.todo.openCount': '{count} açıq',
|
||||
'assistant.card.todo.openCount.one': '{count} açıq',
|
||||
'assistant.card.todo.overdueCount': '{count} gecikmiş',
|
||||
'assistant.card.todo.overdueCount.one': '{count} gecikmiş',
|
||||
'assistant.card.todo.moreTasks': 'Daha {count} tapşırıq',
|
||||
'assistant.card.todo.moreTasks.one': 'Daha {count} tapşırıq',
|
||||
'assistant.card.todo.moreLists': 'Daha {count} siyahı',
|
||||
'assistant.card.todo.moreLists.one': 'Daha {count} siyahı',
|
||||
'assistant.card.todo.noDueDate': 'Son tarix yoxdur',
|
||||
'assistant.card.todo.notDone': 'Edilməyib',
|
||||
'assistant.step.packingItems': 'Baqaj siyahısı oxunur',
|
||||
'assistant.step.packingBags': 'Çantalar oxunur',
|
||||
'assistant.step.packingAssignees': 'Kimin nəyi yığdığı oxunur',
|
||||
'assistant.step.packingTemplates': 'Baqaj şablonları oxunur',
|
||||
'assistant.step.todoAssignees': 'Hansı siyahıya kimin baxdığı oxunur',
|
||||
'assistant.step.tourPacking': 'Turun baqaj siyahısı oxunur',
|
||||
'assistant.tool.bulk_import_packing': 'Baqaj siyahısına bir neçə əşya əlavə et',
|
||||
'assistant.tool.apply_packing_template': 'Baqaj şablonu əlavə et',
|
||||
'assistant.tool.set_packing_item_sharing': 'Əşyanın kimin üçün olduğunu dəyiş',
|
||||
'assistant.tool.set_packing_category_assignees': 'Bu kateqoriyanı kimin yığacağını seç',
|
||||
'assistant.tool.reorder_packing_items': 'Baqaj siyahısını yenidən sırala',
|
||||
'assistant.tool.create_packing_bag': 'Çanta əlavə et',
|
||||
'assistant.tool.update_packing_bag': 'Çantanı dəyiş',
|
||||
'assistant.tool.delete_packing_bag': 'Çantanı sil',
|
||||
'assistant.tool.set_bag_members': 'Çantanı kimin yığacağını seç',
|
||||
'assistant.tool.save_packing_template': 'Baqaj şablonu kimi saxla',
|
||||
'assistant.tool.delete_packing_template': 'Baqaj şablonunu sil',
|
||||
'assistant.tool.set_todo_category_assignees': 'Siyahıya kimin baxacağını seç',
|
||||
'assistant.tool.reorder_todos': 'Tapşırıqları yenidən sırala',
|
||||
'assistant.tool.link_tour_packing': 'Tura baqaj siyahısı bağla',
|
||||
'assistant.tool.unlink_tour_packing': 'Turun baqaj siyahısını ayır',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Lista de mala',
|
||||
'assistant.card.packing.bagsTitle': 'Malas',
|
||||
'assistant.card.packing.forTour': 'Mala para este passeio',
|
||||
'assistant.card.packing.templateAdds': 'Adiciona {count} itens do modelo',
|
||||
'assistant.card.packing.templateAdds.one': 'Adiciona {count} item do modelo',
|
||||
'assistant.card.packing.moreCategories': 'Mais {count} categorias',
|
||||
'assistant.card.packing.moreCategories.one': 'Mais {count} categoria',
|
||||
'assistant.card.packing.moreBags': 'Mais {count} malas',
|
||||
'assistant.card.packing.moreBags.one': 'Mais {count} mala',
|
||||
'assistant.card.packing.ofLimit': '{weight} de {limit}',
|
||||
'assistant.card.packing.overLimit': 'Acima do limite',
|
||||
'assistant.card.packing.movesOut': '{count} itens vão para Sem mala',
|
||||
'assistant.card.packing.movesOut.one': '{count} item vai para Sem mala',
|
||||
'assistant.card.packing.newBag': 'Mala nova',
|
||||
'assistant.card.packing.packed': 'Na mala',
|
||||
'assistant.card.packing.notPacked': 'Fora da mala',
|
||||
'assistant.card.packing.sharedWith': 'Compartilhado com {names}',
|
||||
'assistant.card.packing.tierCommon': 'Lista comum',
|
||||
'assistant.card.packing.tierPersonal': 'Só para mim',
|
||||
'assistant.card.packing.noWeight': 'Sem peso',
|
||||
'assistant.card.packing.noLimit': 'Sem limite',
|
||||
'assistant.card.todo.title': 'Tarefas',
|
||||
'assistant.card.todo.open': 'Abrir a lista de tarefas',
|
||||
'assistant.card.todo.openCount': '{count} abertas',
|
||||
'assistant.card.todo.openCount.one': '{count} abertas',
|
||||
'assistant.card.todo.overdueCount': '{count} atrasadas',
|
||||
'assistant.card.todo.overdueCount.one': '{count} atrasadas',
|
||||
'assistant.card.todo.moreTasks': 'Mais {count} tarefas',
|
||||
'assistant.card.todo.moreTasks.one': 'Mais {count} tarefa',
|
||||
'assistant.card.todo.moreLists': 'Mais {count} listas',
|
||||
'assistant.card.todo.moreLists.one': 'Mais {count} lista',
|
||||
'assistant.card.todo.noDueDate': 'Sem prazo',
|
||||
'assistant.card.todo.notDone': 'Não concluída',
|
||||
'assistant.step.packingItems': 'Lendo a lista de mala',
|
||||
'assistant.step.packingBags': 'Lendo as malas',
|
||||
'assistant.step.packingAssignees': 'Lendo quem leva o quê',
|
||||
'assistant.step.packingTemplates': 'Lendo os modelos de mala',
|
||||
'assistant.step.todoAssignees': 'Lendo quem cuida de cada lista',
|
||||
'assistant.step.tourPacking': 'Lendo a lista de mala do passeio',
|
||||
'assistant.tool.bulk_import_packing': 'Adicionar vários itens à lista de mala',
|
||||
'assistant.tool.apply_packing_template': 'Adicionar um modelo de mala',
|
||||
'assistant.tool.set_packing_item_sharing': 'Mudar para quem é o item',
|
||||
'assistant.tool.set_packing_category_assignees': 'Escolher quem leva esta categoria',
|
||||
'assistant.tool.reorder_packing_items': 'Reordenar a lista de mala',
|
||||
'assistant.tool.create_packing_bag': 'Adicionar uma mala',
|
||||
'assistant.tool.update_packing_bag': 'Alterar a mala',
|
||||
'assistant.tool.delete_packing_bag': 'Excluir a mala',
|
||||
'assistant.tool.set_bag_members': 'Escolher quem arruma a mala',
|
||||
'assistant.tool.save_packing_template': 'Salvar como modelo de mala',
|
||||
'assistant.tool.delete_packing_template': 'Excluir o modelo de mala',
|
||||
'assistant.tool.set_todo_category_assignees': 'Escolher quem cuida da lista',
|
||||
'assistant.tool.reorder_todos': 'Reordenar as tarefas',
|
||||
'assistant.tool.link_tour_packing': 'Vincular uma lista de mala ao passeio',
|
||||
'assistant.tool.unlink_tour_packing': 'Desvincular a lista de mala do passeio',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': "Llista d'equipatge",
|
||||
'assistant.card.packing.bagsTitle': 'Bosses',
|
||||
'assistant.card.packing.forTour': 'Equipatge per a aquesta sortida',
|
||||
'assistant.card.packing.templateAdds': 'Afegeix {count} coses de la plantilla',
|
||||
'assistant.card.packing.templateAdds.one': 'Afegeix {count} cosa de la plantilla',
|
||||
'assistant.card.packing.moreCategories': '{count} categories més',
|
||||
'assistant.card.packing.moreCategories.one': '{count} categoria més',
|
||||
'assistant.card.packing.moreBags': '{count} bosses més',
|
||||
'assistant.card.packing.moreBags.one': '{count} bossa més',
|
||||
'assistant.card.packing.ofLimit': '{weight} de {limit}',
|
||||
'assistant.card.packing.overLimit': 'Per sobre del límit',
|
||||
'assistant.card.packing.movesOut': '{count} coses passen a Sense assignar',
|
||||
'assistant.card.packing.movesOut.one': '{count} cosa passa a Sense assignar',
|
||||
'assistant.card.packing.newBag': 'Bossa nova',
|
||||
'assistant.card.packing.packed': 'Empaquetat',
|
||||
'assistant.card.packing.notPacked': 'Sense empaquetar',
|
||||
'assistant.card.packing.sharedWith': 'Compartit amb {names}',
|
||||
'assistant.card.packing.tierCommon': 'Llista comuna',
|
||||
'assistant.card.packing.tierPersonal': 'Només per a mi',
|
||||
'assistant.card.packing.noWeight': 'Sense pes',
|
||||
'assistant.card.packing.noLimit': 'Sense límit',
|
||||
'assistant.card.todo.title': 'Tasques',
|
||||
'assistant.card.todo.open': 'Obre la llista de tasques',
|
||||
'assistant.card.todo.openCount': '{count} pendents',
|
||||
'assistant.card.todo.openCount.one': '{count} pendents',
|
||||
'assistant.card.todo.overdueCount': '{count} endarrerides',
|
||||
'assistant.card.todo.overdueCount.one': '{count} endarrerides',
|
||||
'assistant.card.todo.moreTasks': '{count} tasques més',
|
||||
'assistant.card.todo.moreTasks.one': '{count} tasca més',
|
||||
'assistant.card.todo.moreLists': '{count} llistes més',
|
||||
'assistant.card.todo.moreLists.one': '{count} llista més',
|
||||
'assistant.card.todo.noDueDate': 'Sense data límit',
|
||||
'assistant.card.todo.notDone': 'Sense fer',
|
||||
'assistant.step.packingItems': "Llegint la llista d'equipatge",
|
||||
'assistant.step.packingBags': 'Llegint les bosses',
|
||||
'assistant.step.packingAssignees': 'Llegint qui porta què',
|
||||
'assistant.step.packingTemplates': "Llegint les plantilles d'equipatge",
|
||||
'assistant.step.todoAssignees': "Llegint qui s'ocupa de cada llista",
|
||||
'assistant.step.tourPacking': "Llegint la llista d'equipatge de la sortida",
|
||||
'assistant.tool.bulk_import_packing': "Afegir diverses coses a la llista d'equipatge",
|
||||
'assistant.tool.apply_packing_template': "Afegir una plantilla d'equipatge",
|
||||
'assistant.tool.set_packing_item_sharing': 'Canviar per a qui és la cosa',
|
||||
'assistant.tool.set_packing_category_assignees': 'Triar qui porta aquesta categoria',
|
||||
'assistant.tool.reorder_packing_items': "Reordenar la llista d'equipatge",
|
||||
'assistant.tool.create_packing_bag': 'Afegir una bossa',
|
||||
'assistant.tool.update_packing_bag': 'Canviar la bossa',
|
||||
'assistant.tool.delete_packing_bag': 'Eliminar la bossa',
|
||||
'assistant.tool.set_bag_members': 'Triar qui omple la bossa',
|
||||
'assistant.tool.save_packing_template': "Desar com a plantilla d'equipatge",
|
||||
'assistant.tool.delete_packing_template': "Eliminar la plantilla d'equipatge",
|
||||
'assistant.tool.set_todo_category_assignees': "Triar qui s'ocupa de la llista",
|
||||
'assistant.tool.reorder_todos': 'Reordenar les tasques',
|
||||
'assistant.tool.link_tour_packing': "Vincular una llista d'equipatge a la sortida",
|
||||
'assistant.tool.unlink_tour_packing': "Desvincular la llista d'equipatge de la sortida",
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,67 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Seznam věcí',
|
||||
'assistant.card.packing.bagsTitle': 'Zavazadla',
|
||||
'assistant.card.packing.forTour': 'Věci na tuto túru',
|
||||
'assistant.card.packing.templateAdds': 'Přidá {count} věcí ze šablony',
|
||||
'assistant.card.packing.templateAdds.one': 'Přidá {count} věc ze šablony',
|
||||
'assistant.card.packing.templateAdds.few': 'Přidá {count} věci ze šablony',
|
||||
'assistant.card.packing.moreCategories': 'Dalších {count} kategorií',
|
||||
'assistant.card.packing.moreCategories.one': 'Další {count} kategorie',
|
||||
'assistant.card.packing.moreCategories.few': 'Další {count} kategorie',
|
||||
'assistant.card.packing.moreBags': 'Dalších {count} zavazadel',
|
||||
'assistant.card.packing.moreBags.one': 'Další {count} zavazadlo',
|
||||
'assistant.card.packing.moreBags.few': 'Další {count} zavazadla',
|
||||
'assistant.card.packing.ofLimit': '{weight} z {limit}',
|
||||
'assistant.card.packing.overLimit': 'Nad limitem',
|
||||
'assistant.card.packing.movesOut': '{count} věcí přejde do Nepřiřazeno',
|
||||
'assistant.card.packing.movesOut.one': '{count} věc přejde do Nepřiřazeno',
|
||||
'assistant.card.packing.movesOut.few': '{count} věci přejdou do Nepřiřazeno',
|
||||
'assistant.card.packing.newBag': 'Nové zavazadlo',
|
||||
'assistant.card.packing.packed': 'Zabaleno',
|
||||
'assistant.card.packing.notPacked': 'Nezabaleno',
|
||||
'assistant.card.packing.sharedWith': 'Sdíleno s: {names}',
|
||||
'assistant.card.packing.tierCommon': 'Společný seznam',
|
||||
'assistant.card.packing.tierPersonal': 'Jen pro mě',
|
||||
'assistant.card.packing.noWeight': 'Bez hmotnosti',
|
||||
'assistant.card.packing.noLimit': 'Bez limitu',
|
||||
'assistant.card.todo.title': 'Úkoly',
|
||||
'assistant.card.todo.open': 'Otevřít seznam úkolů',
|
||||
'assistant.card.todo.openCount': '{count} otevřených',
|
||||
'assistant.card.todo.openCount.one': '{count} otevřený',
|
||||
'assistant.card.todo.openCount.few': '{count} otevřené',
|
||||
'assistant.card.todo.overdueCount': '{count} po termínu',
|
||||
'assistant.card.todo.overdueCount.one': '{count} po termínu',
|
||||
'assistant.card.todo.overdueCount.few': '{count} po termínu',
|
||||
'assistant.card.todo.moreTasks': 'Dalších {count} úkolů',
|
||||
'assistant.card.todo.moreTasks.one': 'Další {count} úkol',
|
||||
'assistant.card.todo.moreTasks.few': 'Další {count} úkoly',
|
||||
'assistant.card.todo.moreLists': 'Dalších {count} seznamů',
|
||||
'assistant.card.todo.moreLists.one': 'Další {count} seznam',
|
||||
'assistant.card.todo.moreLists.few': 'Další {count} seznamy',
|
||||
'assistant.card.todo.noDueDate': 'Bez termínu',
|
||||
'assistant.card.todo.notDone': 'Nehotovo',
|
||||
'assistant.step.packingItems': 'Čtu seznam věcí',
|
||||
'assistant.step.packingBags': 'Čtu zavazadla',
|
||||
'assistant.step.packingAssignees': 'Čtu, kdo co balí',
|
||||
'assistant.step.packingTemplates': 'Čtu šablony balení',
|
||||
'assistant.step.todoAssignees': 'Čtu, kdo se stará o který seznam',
|
||||
'assistant.step.tourPacking': 'Čtu seznam věcí túry',
|
||||
'assistant.tool.bulk_import_packing': 'Přidat několik věcí do seznamu',
|
||||
'assistant.tool.apply_packing_template': 'Přidat šablonu balení',
|
||||
'assistant.tool.set_packing_item_sharing': 'Změnit, pro koho věc je',
|
||||
'assistant.tool.set_packing_category_assignees': 'Vybrat, kdo balí tuto kategorii',
|
||||
'assistant.tool.reorder_packing_items': 'Přeřadit seznam věcí',
|
||||
'assistant.tool.create_packing_bag': 'Přidat zavazadlo',
|
||||
'assistant.tool.update_packing_bag': 'Změnit zavazadlo',
|
||||
'assistant.tool.delete_packing_bag': 'Smazat zavazadlo',
|
||||
'assistant.tool.set_bag_members': 'Vybrat, kdo balí zavazadlo',
|
||||
'assistant.tool.save_packing_template': 'Uložit jako šablonu balení',
|
||||
'assistant.tool.delete_packing_template': 'Smazat šablonu balení',
|
||||
'assistant.tool.set_todo_category_assignees': 'Vybrat, kdo se stará o seznam',
|
||||
'assistant.tool.reorder_todos': 'Přeřadit úkoly',
|
||||
'assistant.tool.link_tour_packing': 'Propojit seznam věcí s túrou',
|
||||
'assistant.tool.unlink_tour_packing': 'Odpojit seznam věcí od túry',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Packliste',
|
||||
'assistant.card.packing.bagsTitle': 'Taschen',
|
||||
'assistant.card.packing.forTour': 'Packliste für diese Tour',
|
||||
'assistant.card.packing.templateAdds': 'Fügt {count} Dinge aus der Vorlage hinzu',
|
||||
'assistant.card.packing.templateAdds.one': 'Fügt {count} Ding aus der Vorlage hinzu',
|
||||
'assistant.card.packing.moreCategories': '{count} weitere Kategorien',
|
||||
'assistant.card.packing.moreCategories.one': '{count} weitere Kategorie',
|
||||
'assistant.card.packing.moreBags': '{count} weitere Taschen',
|
||||
'assistant.card.packing.moreBags.one': '{count} weitere Tasche',
|
||||
'assistant.card.packing.ofLimit': '{weight} von {limit}',
|
||||
'assistant.card.packing.overLimit': 'Über dem Limit',
|
||||
'assistant.card.packing.movesOut': '{count} Dinge landen bei Nicht zugeordnet',
|
||||
'assistant.card.packing.movesOut.one': '{count} Ding landet bei Nicht zugeordnet',
|
||||
'assistant.card.packing.newBag': 'Neue Tasche',
|
||||
'assistant.card.packing.packed': 'Gepackt',
|
||||
'assistant.card.packing.notPacked': 'Nicht gepackt',
|
||||
'assistant.card.packing.sharedWith': 'Geteilt mit {names}',
|
||||
'assistant.card.packing.tierCommon': 'Gemeinsame Liste',
|
||||
'assistant.card.packing.tierPersonal': 'Nur für mich',
|
||||
'assistant.card.packing.noWeight': 'Kein Gewicht',
|
||||
'assistant.card.packing.noLimit': 'Kein Limit',
|
||||
'assistant.card.todo.title': 'Aufgaben',
|
||||
'assistant.card.todo.open': 'Aufgabenliste öffnen',
|
||||
'assistant.card.todo.openCount': '{count} offen',
|
||||
'assistant.card.todo.openCount.one': '{count} offen',
|
||||
'assistant.card.todo.overdueCount': '{count} überfällig',
|
||||
'assistant.card.todo.overdueCount.one': '{count} überfällig',
|
||||
'assistant.card.todo.moreTasks': '{count} weitere Aufgaben',
|
||||
'assistant.card.todo.moreTasks.one': '{count} weitere Aufgabe',
|
||||
'assistant.card.todo.moreLists': '{count} weitere Listen',
|
||||
'assistant.card.todo.moreLists.one': '{count} weitere Liste',
|
||||
'assistant.card.todo.noDueDate': 'Kein Fälligkeitsdatum',
|
||||
'assistant.card.todo.notDone': 'Nicht erledigt',
|
||||
'assistant.step.packingItems': 'Lese die Packliste',
|
||||
'assistant.step.packingBags': 'Lese die Taschen',
|
||||
'assistant.step.packingAssignees': 'Lese, wer was packt',
|
||||
'assistant.step.packingTemplates': 'Lese die Packvorlagen',
|
||||
'assistant.step.todoAssignees': 'Lese, wer sich um welche Liste kümmert',
|
||||
'assistant.step.tourPacking': 'Lese die Packliste der Tour',
|
||||
'assistant.tool.bulk_import_packing': 'Mehrere Dinge zur Packliste hinzufügen',
|
||||
'assistant.tool.apply_packing_template': 'Packvorlage hinzufügen',
|
||||
'assistant.tool.set_packing_item_sharing': 'Ändern, für wen das Ding ist',
|
||||
'assistant.tool.set_packing_category_assignees': 'Festlegen, wer diese Kategorie packt',
|
||||
'assistant.tool.reorder_packing_items': 'Packliste neu ordnen',
|
||||
'assistant.tool.create_packing_bag': 'Tasche hinzufügen',
|
||||
'assistant.tool.update_packing_bag': 'Tasche ändern',
|
||||
'assistant.tool.delete_packing_bag': 'Tasche löschen',
|
||||
'assistant.tool.set_bag_members': 'Festlegen, wer die Tasche packt',
|
||||
'assistant.tool.save_packing_template': 'Als Packvorlage speichern',
|
||||
'assistant.tool.delete_packing_template': 'Packvorlage löschen',
|
||||
'assistant.tool.set_todo_category_assignees': 'Festlegen, wer sich um die Liste kümmert',
|
||||
'assistant.tool.reorder_todos': 'Aufgaben neu ordnen',
|
||||
'assistant.tool.link_tour_packing': 'Packliste mit der Tour verknüpfen',
|
||||
'assistant.tool.unlink_tour_packing': 'Packliste von der Tour lösen',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Packing list',
|
||||
'assistant.card.packing.bagsTitle': 'Bags',
|
||||
'assistant.card.packing.forTour': 'Packing for this tour',
|
||||
'assistant.card.packing.templateAdds': 'Adds {count} items from the template',
|
||||
'assistant.card.packing.templateAdds.one': 'Adds {count} item from the template',
|
||||
'assistant.card.packing.moreCategories': '{count} more categories',
|
||||
'assistant.card.packing.moreCategories.one': '{count} more category',
|
||||
'assistant.card.packing.moreBags': '{count} more bags',
|
||||
'assistant.card.packing.moreBags.one': '{count} more bag',
|
||||
'assistant.card.packing.ofLimit': '{weight} of {limit}',
|
||||
'assistant.card.packing.overLimit': 'Over the limit',
|
||||
'assistant.card.packing.movesOut': '{count} items move to Unassigned',
|
||||
'assistant.card.packing.movesOut.one': '{count} item moves to Unassigned',
|
||||
'assistant.card.packing.newBag': 'New bag',
|
||||
'assistant.card.packing.packed': 'Packed',
|
||||
'assistant.card.packing.notPacked': 'Not packed',
|
||||
'assistant.card.packing.sharedWith': 'Shared with {names}',
|
||||
'assistant.card.packing.tierCommon': 'Common list',
|
||||
'assistant.card.packing.tierPersonal': 'Just for me',
|
||||
'assistant.card.packing.noWeight': 'No weight',
|
||||
'assistant.card.packing.noLimit': 'No limit',
|
||||
'assistant.card.todo.title': 'To-dos',
|
||||
'assistant.card.todo.open': 'Open the to-do list',
|
||||
'assistant.card.todo.openCount': '{count} open',
|
||||
'assistant.card.todo.openCount.one': '{count} open',
|
||||
'assistant.card.todo.overdueCount': '{count} overdue',
|
||||
'assistant.card.todo.overdueCount.one': '{count} overdue',
|
||||
'assistant.card.todo.moreTasks': '{count} more tasks',
|
||||
'assistant.card.todo.moreTasks.one': '{count} more task',
|
||||
'assistant.card.todo.moreLists': '{count} more lists',
|
||||
'assistant.card.todo.moreLists.one': '{count} more list',
|
||||
'assistant.card.todo.noDueDate': 'No due date',
|
||||
'assistant.card.todo.notDone': 'Not done',
|
||||
'assistant.step.packingItems': 'Reading the packing list',
|
||||
'assistant.step.packingBags': 'Reading the bags',
|
||||
'assistant.step.packingAssignees': 'Reading who packs what',
|
||||
'assistant.step.packingTemplates': 'Reading the packing templates',
|
||||
'assistant.step.todoAssignees': 'Reading who looks after which list',
|
||||
'assistant.step.tourPacking': "Reading the tour's packing list",
|
||||
'assistant.tool.bulk_import_packing': 'Add several items to the packing list',
|
||||
'assistant.tool.apply_packing_template': 'Add a packing template',
|
||||
'assistant.tool.set_packing_item_sharing': 'Change who the item is for',
|
||||
'assistant.tool.set_packing_category_assignees': 'Choose who packs this category',
|
||||
'assistant.tool.reorder_packing_items': 'Reorder the packing list',
|
||||
'assistant.tool.create_packing_bag': 'Add a bag',
|
||||
'assistant.tool.update_packing_bag': 'Change the bag',
|
||||
'assistant.tool.delete_packing_bag': 'Delete the bag',
|
||||
'assistant.tool.set_bag_members': 'Choose who packs the bag',
|
||||
'assistant.tool.save_packing_template': 'Save as a packing template',
|
||||
'assistant.tool.delete_packing_template': 'Delete the packing template',
|
||||
'assistant.tool.set_todo_category_assignees': 'Choose who looks after the list',
|
||||
'assistant.tool.reorder_todos': 'Reorder the to-dos',
|
||||
'assistant.tool.link_tour_packing': 'Link a packing list to the tour',
|
||||
'assistant.tool.unlink_tour_packing': "Unlink the tour's packing list",
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Lista de equipaje',
|
||||
'assistant.card.packing.bagsTitle': 'Bolsas',
|
||||
'assistant.card.packing.forTour': 'Equipaje para esta ruta',
|
||||
'assistant.card.packing.templateAdds': 'Añade {count} cosas de la plantilla',
|
||||
'assistant.card.packing.templateAdds.one': 'Añade {count} cosa de la plantilla',
|
||||
'assistant.card.packing.moreCategories': '{count} categorías más',
|
||||
'assistant.card.packing.moreCategories.one': '{count} categoría más',
|
||||
'assistant.card.packing.moreBags': '{count} bolsas más',
|
||||
'assistant.card.packing.moreBags.one': '{count} bolsa más',
|
||||
'assistant.card.packing.ofLimit': '{weight} de {limit}',
|
||||
'assistant.card.packing.overLimit': 'Por encima del límite',
|
||||
'assistant.card.packing.movesOut': '{count} cosas pasan a Sin asignar',
|
||||
'assistant.card.packing.movesOut.one': '{count} cosa pasa a Sin asignar',
|
||||
'assistant.card.packing.newBag': 'Bolsa nueva',
|
||||
'assistant.card.packing.packed': 'Empacado',
|
||||
'assistant.card.packing.notPacked': 'Sin empacar',
|
||||
'assistant.card.packing.sharedWith': 'Compartido con {names}',
|
||||
'assistant.card.packing.tierCommon': 'Lista común',
|
||||
'assistant.card.packing.tierPersonal': 'Solo para mí',
|
||||
'assistant.card.packing.noWeight': 'Sin peso',
|
||||
'assistant.card.packing.noLimit': 'Sin límite',
|
||||
'assistant.card.todo.title': 'Tareas',
|
||||
'assistant.card.todo.open': 'Abrir la lista de tareas',
|
||||
'assistant.card.todo.openCount': '{count} pendientes',
|
||||
'assistant.card.todo.openCount.one': '{count} pendientes',
|
||||
'assistant.card.todo.overdueCount': '{count} vencidas',
|
||||
'assistant.card.todo.overdueCount.one': '{count} vencidas',
|
||||
'assistant.card.todo.moreTasks': '{count} tareas más',
|
||||
'assistant.card.todo.moreTasks.one': '{count} tarea más',
|
||||
'assistant.card.todo.moreLists': '{count} listas más',
|
||||
'assistant.card.todo.moreLists.one': '{count} lista más',
|
||||
'assistant.card.todo.noDueDate': 'Sin fecha límite',
|
||||
'assistant.card.todo.notDone': 'Sin hacer',
|
||||
'assistant.step.packingItems': 'Leyendo la lista de equipaje',
|
||||
'assistant.step.packingBags': 'Leyendo las bolsas',
|
||||
'assistant.step.packingAssignees': 'Leyendo quién lleva qué',
|
||||
'assistant.step.packingTemplates': 'Leyendo las plantillas de equipaje',
|
||||
'assistant.step.todoAssignees': 'Leyendo quién se ocupa de cada lista',
|
||||
'assistant.step.tourPacking': 'Leyendo la lista de equipaje de la ruta',
|
||||
'assistant.tool.bulk_import_packing': 'Añadir varias cosas a la lista de equipaje',
|
||||
'assistant.tool.apply_packing_template': 'Añadir una plantilla de equipaje',
|
||||
'assistant.tool.set_packing_item_sharing': 'Cambiar para quién es la cosa',
|
||||
'assistant.tool.set_packing_category_assignees': 'Elegir quién lleva esta categoría',
|
||||
'assistant.tool.reorder_packing_items': 'Reordenar la lista de equipaje',
|
||||
'assistant.tool.create_packing_bag': 'Añadir una bolsa',
|
||||
'assistant.tool.update_packing_bag': 'Cambiar la bolsa',
|
||||
'assistant.tool.delete_packing_bag': 'Eliminar la bolsa',
|
||||
'assistant.tool.set_bag_members': 'Elegir quién llena la bolsa',
|
||||
'assistant.tool.save_packing_template': 'Guardar como plantilla de equipaje',
|
||||
'assistant.tool.delete_packing_template': 'Eliminar la plantilla de equipaje',
|
||||
'assistant.tool.set_todo_category_assignees': 'Elegir quién se ocupa de la lista',
|
||||
'assistant.tool.reorder_todos': 'Reordenar las tareas',
|
||||
'assistant.tool.link_tour_packing': 'Vincular una lista de equipaje a la ruta',
|
||||
'assistant.tool.unlink_tour_packing': 'Desvincular la lista de equipaje de la ruta',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Pakkimisnimekiri',
|
||||
'assistant.card.packing.bagsTitle': 'Kotid',
|
||||
'assistant.card.packing.forTour': 'Pakkimine selle matka jaoks',
|
||||
'assistant.card.packing.templateAdds': 'Lisab mallist {count} asja',
|
||||
'assistant.card.packing.templateAdds.one': 'Lisab mallist {count} asja',
|
||||
'assistant.card.packing.moreCategories': 'Veel {count} kategooriat',
|
||||
'assistant.card.packing.moreCategories.one': 'Veel {count} kategooria',
|
||||
'assistant.card.packing.moreBags': 'Veel {count} kotti',
|
||||
'assistant.card.packing.moreBags.one': 'Veel {count} kott',
|
||||
'assistant.card.packing.ofLimit': '{weight} / {limit}',
|
||||
'assistant.card.packing.overLimit': 'Üle piiri',
|
||||
'assistant.card.packing.movesOut': '{count} asja läheb alla Määramata',
|
||||
'assistant.card.packing.movesOut.one': '{count} asi läheb alla Määramata',
|
||||
'assistant.card.packing.newBag': 'Uus kott',
|
||||
'assistant.card.packing.packed': 'Pakitud',
|
||||
'assistant.card.packing.notPacked': 'Pakkimata',
|
||||
'assistant.card.packing.sharedWith': 'Jagatud: {names}',
|
||||
'assistant.card.packing.tierCommon': 'Ühine nimekiri',
|
||||
'assistant.card.packing.tierPersonal': 'Ainult mulle',
|
||||
'assistant.card.packing.noWeight': 'Kaal puudub',
|
||||
'assistant.card.packing.noLimit': 'Piir puudub',
|
||||
'assistant.card.todo.title': 'Ülesanded',
|
||||
'assistant.card.todo.open': 'Ava ülesannete nimekiri',
|
||||
'assistant.card.todo.openCount': '{count} avatud',
|
||||
'assistant.card.todo.openCount.one': '{count} avatud',
|
||||
'assistant.card.todo.overdueCount': '{count} hilinenud',
|
||||
'assistant.card.todo.overdueCount.one': '{count} hilinenud',
|
||||
'assistant.card.todo.moreTasks': 'Veel {count} ülesannet',
|
||||
'assistant.card.todo.moreTasks.one': 'Veel {count} ülesanne',
|
||||
'assistant.card.todo.moreLists': 'Veel {count} nimekirja',
|
||||
'assistant.card.todo.moreLists.one': 'Veel {count} nimekiri',
|
||||
'assistant.card.todo.noDueDate': 'Tähtaeg puudub',
|
||||
'assistant.card.todo.notDone': 'Tegemata',
|
||||
'assistant.step.packingItems': 'Loen pakkimisnimekirja',
|
||||
'assistant.step.packingBags': 'Loen kotte',
|
||||
'assistant.step.packingAssignees': 'Loen, kes mida pakib',
|
||||
'assistant.step.packingTemplates': 'Loen pakkimismalle',
|
||||
'assistant.step.todoAssignees': 'Loen, kes millise nimekirja eest hoolitseb',
|
||||
'assistant.step.tourPacking': 'Loen matka pakkimisnimekirja',
|
||||
'assistant.tool.bulk_import_packing': 'Lisa pakkimisnimekirja mitu asja',
|
||||
'assistant.tool.apply_packing_template': 'Lisa pakkimismall',
|
||||
'assistant.tool.set_packing_item_sharing': 'Muuda, kellele asi on',
|
||||
'assistant.tool.set_packing_category_assignees': 'Vali, kes seda kategooriat pakib',
|
||||
'assistant.tool.reorder_packing_items': 'Järjesta pakkimisnimekiri ümber',
|
||||
'assistant.tool.create_packing_bag': 'Lisa kott',
|
||||
'assistant.tool.update_packing_bag': 'Muuda kotti',
|
||||
'assistant.tool.delete_packing_bag': 'Kustuta kott',
|
||||
'assistant.tool.set_bag_members': 'Vali, kes kotti pakib',
|
||||
'assistant.tool.save_packing_template': 'Salvesta pakkimismallina',
|
||||
'assistant.tool.delete_packing_template': 'Kustuta pakkimismall',
|
||||
'assistant.tool.set_todo_category_assignees': 'Vali, kes nimekirja eest hoolitseb',
|
||||
'assistant.tool.reorder_todos': 'Järjesta ülesanded ümber',
|
||||
'assistant.tool.link_tour_packing': 'Seo pakkimisnimekiri matkaga',
|
||||
'assistant.tool.unlink_tour_packing': 'Eemalda matka pakkimisnimekiri',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Liste de bagages',
|
||||
'assistant.card.packing.bagsTitle': 'Sacs',
|
||||
'assistant.card.packing.forTour': 'Bagages pour cette sortie',
|
||||
'assistant.card.packing.templateAdds': 'Ajoute {count} éléments du modèle',
|
||||
'assistant.card.packing.templateAdds.one': 'Ajoute {count} élément du modèle',
|
||||
'assistant.card.packing.moreCategories': '{count} autres catégories',
|
||||
'assistant.card.packing.moreCategories.one': '{count} autre catégorie',
|
||||
'assistant.card.packing.moreBags': '{count} autres sacs',
|
||||
'assistant.card.packing.moreBags.one': '{count} autre sac',
|
||||
'assistant.card.packing.ofLimit': '{weight} sur {limit}',
|
||||
'assistant.card.packing.overLimit': 'Au-delà de la limite',
|
||||
'assistant.card.packing.movesOut': '{count} éléments passent dans Non assigné',
|
||||
'assistant.card.packing.movesOut.one': '{count} élément passe dans Non assigné',
|
||||
'assistant.card.packing.newBag': 'Nouveau sac',
|
||||
'assistant.card.packing.packed': 'Emballé',
|
||||
'assistant.card.packing.notPacked': 'Pas emballé',
|
||||
'assistant.card.packing.sharedWith': 'Partagé avec {names}',
|
||||
'assistant.card.packing.tierCommon': 'Liste commune',
|
||||
'assistant.card.packing.tierPersonal': 'Rien que pour moi',
|
||||
'assistant.card.packing.noWeight': 'Sans poids',
|
||||
'assistant.card.packing.noLimit': 'Sans limite',
|
||||
'assistant.card.todo.title': 'Tâches',
|
||||
'assistant.card.todo.open': 'Ouvrir la liste des tâches',
|
||||
'assistant.card.todo.openCount': '{count} ouvertes',
|
||||
'assistant.card.todo.openCount.one': '{count} ouvertes',
|
||||
'assistant.card.todo.overdueCount': '{count} en retard',
|
||||
'assistant.card.todo.overdueCount.one': '{count} en retard',
|
||||
'assistant.card.todo.moreTasks': '{count} autres tâches',
|
||||
'assistant.card.todo.moreTasks.one': '{count} autre tâche',
|
||||
'assistant.card.todo.moreLists': '{count} autres listes',
|
||||
'assistant.card.todo.moreLists.one': '{count} autre liste',
|
||||
'assistant.card.todo.noDueDate': "Pas d'échéance",
|
||||
'assistant.card.todo.notDone': 'Pas terminée',
|
||||
'assistant.step.packingItems': 'Lecture de la liste de bagages',
|
||||
'assistant.step.packingBags': 'Lecture des sacs',
|
||||
'assistant.step.packingAssignees': 'Lecture de qui emporte quoi',
|
||||
'assistant.step.packingTemplates': 'Lecture des modèles de bagages',
|
||||
'assistant.step.todoAssignees': "Lecture de qui s'occupe de quelle liste",
|
||||
'assistant.step.tourPacking': 'Lecture de la liste de bagages de la sortie',
|
||||
'assistant.tool.bulk_import_packing': 'Ajouter plusieurs éléments à la liste de bagages',
|
||||
'assistant.tool.apply_packing_template': 'Ajouter un modèle de bagages',
|
||||
'assistant.tool.set_packing_item_sharing': "Changer à qui revient l'élément",
|
||||
'assistant.tool.set_packing_category_assignees': 'Choisir qui emporte cette catégorie',
|
||||
'assistant.tool.reorder_packing_items': 'Réordonner la liste de bagages',
|
||||
'assistant.tool.create_packing_bag': 'Ajouter un sac',
|
||||
'assistant.tool.update_packing_bag': 'Modifier le sac',
|
||||
'assistant.tool.delete_packing_bag': 'Supprimer le sac',
|
||||
'assistant.tool.set_bag_members': 'Choisir qui remplit le sac',
|
||||
'assistant.tool.save_packing_template': 'Enregistrer comme modèle de bagages',
|
||||
'assistant.tool.delete_packing_template': 'Supprimer le modèle de bagages',
|
||||
'assistant.tool.set_todo_category_assignees': "Choisir qui s'occupe de la liste",
|
||||
'assistant.tool.reorder_todos': 'Réordonner les tâches',
|
||||
'assistant.tool.link_tour_packing': 'Lier une liste de bagages à la sortie',
|
||||
'assistant.tool.unlink_tour_packing': 'Délier la liste de bagages de la sortie',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Λίστα αποσκευών',
|
||||
'assistant.card.packing.bagsTitle': 'Τσάντες',
|
||||
'assistant.card.packing.forTour': 'Αποσκευές για αυτή τη διαδρομή',
|
||||
'assistant.card.packing.templateAdds': 'Προσθέτει {count} αντικείμενα από το πρότυπο',
|
||||
'assistant.card.packing.templateAdds.one': 'Προσθέτει {count} αντικείμενο από το πρότυπο',
|
||||
'assistant.card.packing.moreCategories': '{count} ακόμη κατηγορίες',
|
||||
'assistant.card.packing.moreCategories.one': '{count} ακόμη κατηγορία',
|
||||
'assistant.card.packing.moreBags': '{count} ακόμη τσάντες',
|
||||
'assistant.card.packing.moreBags.one': '{count} ακόμη τσάντα',
|
||||
'assistant.card.packing.ofLimit': '{weight} από {limit}',
|
||||
'assistant.card.packing.overLimit': 'Πάνω από το όριο',
|
||||
'assistant.card.packing.movesOut': '{count} αντικείμενα πηγαίνουν στα Χωρίς ανάθεση',
|
||||
'assistant.card.packing.movesOut.one': '{count} αντικείμενο πηγαίνει στα Χωρίς ανάθεση',
|
||||
'assistant.card.packing.newBag': 'Νέα τσάντα',
|
||||
'assistant.card.packing.packed': 'Πακεταρισμένο',
|
||||
'assistant.card.packing.notPacked': 'Απακετάριστο',
|
||||
'assistant.card.packing.sharedWith': 'Κοινό με: {names}',
|
||||
'assistant.card.packing.tierCommon': 'Κοινή λίστα',
|
||||
'assistant.card.packing.tierPersonal': 'Μόνο για μένα',
|
||||
'assistant.card.packing.noWeight': 'Χωρίς βάρος',
|
||||
'assistant.card.packing.noLimit': 'Χωρίς όριο',
|
||||
'assistant.card.todo.title': 'Εργασίες',
|
||||
'assistant.card.todo.open': 'Άνοιγμα λίστας εργασιών',
|
||||
'assistant.card.todo.openCount': '{count} ανοιχτές',
|
||||
'assistant.card.todo.openCount.one': '{count} ανοιχτή',
|
||||
'assistant.card.todo.overdueCount': '{count} εκπρόθεσμες',
|
||||
'assistant.card.todo.overdueCount.one': '{count} εκπρόθεσμη',
|
||||
'assistant.card.todo.moreTasks': '{count} ακόμη εργασίες',
|
||||
'assistant.card.todo.moreTasks.one': '{count} ακόμη εργασία',
|
||||
'assistant.card.todo.moreLists': '{count} ακόμη λίστες',
|
||||
'assistant.card.todo.moreLists.one': '{count} ακόμη λίστα',
|
||||
'assistant.card.todo.noDueDate': 'Χωρίς προθεσμία',
|
||||
'assistant.card.todo.notDone': 'Δεν έγινε',
|
||||
'assistant.step.packingItems': 'Διαβάζω τη λίστα αποσκευών',
|
||||
'assistant.step.packingBags': 'Διαβάζω τις τσάντες',
|
||||
'assistant.step.packingAssignees': 'Διαβάζω ποιος παίρνει τι',
|
||||
'assistant.step.packingTemplates': 'Διαβάζω τα πρότυπα αποσκευών',
|
||||
'assistant.step.todoAssignees': 'Διαβάζω ποιος φροντίζει ποια λίστα',
|
||||
'assistant.step.tourPacking': 'Διαβάζω τη λίστα αποσκευών της διαδρομής',
|
||||
'assistant.tool.bulk_import_packing': 'Προσθήκη πολλών αντικειμένων στη λίστα',
|
||||
'assistant.tool.apply_packing_template': 'Προσθήκη προτύπου αποσκευών',
|
||||
'assistant.tool.set_packing_item_sharing': 'Αλλαγή του για ποιον είναι το αντικείμενο',
|
||||
'assistant.tool.set_packing_category_assignees': 'Επιλογή του ποιος πακετάρει αυτή την κατηγορία',
|
||||
'assistant.tool.reorder_packing_items': 'Αναδιάταξη της λίστας αποσκευών',
|
||||
'assistant.tool.create_packing_bag': 'Προσθήκη τσάντας',
|
||||
'assistant.tool.update_packing_bag': 'Αλλαγή της τσάντας',
|
||||
'assistant.tool.delete_packing_bag': 'Διαγραφή της τσάντας',
|
||||
'assistant.tool.set_bag_members': 'Επιλογή του ποιος γεμίζει την τσάντα',
|
||||
'assistant.tool.save_packing_template': 'Αποθήκευση ως πρότυπο αποσκευών',
|
||||
'assistant.tool.delete_packing_template': 'Διαγραφή του προτύπου αποσκευών',
|
||||
'assistant.tool.set_todo_category_assignees': 'Επιλογή του ποιος φροντίζει τη λίστα',
|
||||
'assistant.tool.reorder_todos': 'Αναδιάταξη των εργασιών',
|
||||
'assistant.tool.link_tour_packing': 'Σύνδεση λίστας αποσκευών με τη διαδρομή',
|
||||
'assistant.tool.unlink_tour_packing': 'Αποσύνδεση της λίστας αποσκευών από τη διαδρομή',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Csomagolási lista',
|
||||
'assistant.card.packing.bagsTitle': 'Táskák',
|
||||
'assistant.card.packing.forTour': 'Csomagolás ehhez a túrához',
|
||||
'assistant.card.packing.templateAdds': '{count} tételt ad hozzá a sablonból',
|
||||
'assistant.card.packing.templateAdds.one': '{count} tételt ad hozzá a sablonból',
|
||||
'assistant.card.packing.moreCategories': 'Még {count} kategória',
|
||||
'assistant.card.packing.moreCategories.one': 'Még {count} kategória',
|
||||
'assistant.card.packing.moreBags': 'Még {count} táska',
|
||||
'assistant.card.packing.moreBags.one': 'Még {count} táska',
|
||||
'assistant.card.packing.ofLimit': '{weight} / {limit}',
|
||||
'assistant.card.packing.overLimit': 'A limit felett',
|
||||
'assistant.card.packing.movesOut': '{count} tétel a Nincs hozzárendelve közé kerül',
|
||||
'assistant.card.packing.movesOut.one': '{count} tétel a Nincs hozzárendelve közé kerül',
|
||||
'assistant.card.packing.newBag': 'Új táska',
|
||||
'assistant.card.packing.packed': 'Bepakolva',
|
||||
'assistant.card.packing.notPacked': 'Nincs bepakolva',
|
||||
'assistant.card.packing.sharedWith': 'Megosztva vele: {names}',
|
||||
'assistant.card.packing.tierCommon': 'Közös lista',
|
||||
'assistant.card.packing.tierPersonal': 'Csak nekem',
|
||||
'assistant.card.packing.noWeight': 'Nincs súly',
|
||||
'assistant.card.packing.noLimit': 'Nincs limit',
|
||||
'assistant.card.todo.title': 'Teendők',
|
||||
'assistant.card.todo.open': 'Teendőlista megnyitása',
|
||||
'assistant.card.todo.openCount': '{count} nyitott',
|
||||
'assistant.card.todo.openCount.one': '{count} nyitott',
|
||||
'assistant.card.todo.overdueCount': '{count} lejárt',
|
||||
'assistant.card.todo.overdueCount.one': '{count} lejárt',
|
||||
'assistant.card.todo.moreTasks': 'Még {count} feladat',
|
||||
'assistant.card.todo.moreTasks.one': 'Még {count} feladat',
|
||||
'assistant.card.todo.moreLists': 'Még {count} lista',
|
||||
'assistant.card.todo.moreLists.one': 'Még {count} lista',
|
||||
'assistant.card.todo.noDueDate': 'Nincs határidő',
|
||||
'assistant.card.todo.notDone': 'Nincs kész',
|
||||
'assistant.step.packingItems': 'A csomagolási lista olvasása',
|
||||
'assistant.step.packingBags': 'A táskák olvasása',
|
||||
'assistant.step.packingAssignees': 'Annak olvasása, ki mit pakol',
|
||||
'assistant.step.packingTemplates': 'A csomagolási sablonok olvasása',
|
||||
'assistant.step.todoAssignees': 'Annak olvasása, ki melyik listáért felel',
|
||||
'assistant.step.tourPacking': 'A túra csomagolási listájának olvasása',
|
||||
'assistant.tool.bulk_import_packing': 'Több tétel hozzáadása a csomagolási listához',
|
||||
'assistant.tool.apply_packing_template': 'Csomagolási sablon hozzáadása',
|
||||
'assistant.tool.set_packing_item_sharing': 'Annak módosítása, kinek szól a tétel',
|
||||
'assistant.tool.set_packing_category_assignees': 'Ki pakolja ezt a kategóriát',
|
||||
'assistant.tool.reorder_packing_items': 'A csomagolási lista átrendezése',
|
||||
'assistant.tool.create_packing_bag': 'Táska hozzáadása',
|
||||
'assistant.tool.update_packing_bag': 'A táska módosítása',
|
||||
'assistant.tool.delete_packing_bag': 'A táska törlése',
|
||||
'assistant.tool.set_bag_members': 'Ki pakolja a táskát',
|
||||
'assistant.tool.save_packing_template': 'Mentés csomagolási sablonként',
|
||||
'assistant.tool.delete_packing_template': 'A csomagolási sablon törlése',
|
||||
'assistant.tool.set_todo_category_assignees': 'Ki felel a listáért',
|
||||
'assistant.tool.reorder_todos': 'A teendők átrendezése',
|
||||
'assistant.tool.link_tour_packing': 'Csomagolási lista csatolása a túrához',
|
||||
'assistant.tool.unlink_tour_packing': 'A túra csomagolási listájának leválasztása',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Daftar bawaan',
|
||||
'assistant.card.packing.bagsTitle': 'Tas',
|
||||
'assistant.card.packing.forTour': 'Bawaan untuk tur ini',
|
||||
'assistant.card.packing.templateAdds': 'Menambah {count} barang dari templat',
|
||||
'assistant.card.packing.moreCategories': '{count} kategori lagi',
|
||||
'assistant.card.packing.moreBags': '{count} tas lagi',
|
||||
'assistant.card.packing.ofLimit': '{weight} dari {limit}',
|
||||
'assistant.card.packing.overLimit': 'Melebihi batas',
|
||||
'assistant.card.packing.movesOut': '{count} barang pindah ke Belum ditugaskan',
|
||||
'assistant.card.packing.newBag': 'Tas baru',
|
||||
'assistant.card.packing.packed': 'Sudah dikemas',
|
||||
'assistant.card.packing.notPacked': 'Belum dikemas',
|
||||
'assistant.card.packing.sharedWith': 'Dibagikan dengan {names}',
|
||||
'assistant.card.packing.tierCommon': 'Daftar bersama',
|
||||
'assistant.card.packing.tierPersonal': 'Hanya untukku',
|
||||
'assistant.card.packing.noWeight': 'Tanpa berat',
|
||||
'assistant.card.packing.noLimit': 'Tanpa batas',
|
||||
'assistant.card.todo.title': 'Tugas',
|
||||
'assistant.card.todo.open': 'Buka daftar tugas',
|
||||
'assistant.card.todo.openCount': '{count} terbuka',
|
||||
'assistant.card.todo.overdueCount': '{count} terlambat',
|
||||
'assistant.card.todo.moreTasks': '{count} tugas lagi',
|
||||
'assistant.card.todo.moreLists': '{count} daftar lagi',
|
||||
'assistant.card.todo.noDueDate': 'Tanpa tenggat',
|
||||
'assistant.card.todo.notDone': 'Belum selesai',
|
||||
'assistant.step.packingItems': 'Membaca daftar bawaan',
|
||||
'assistant.step.packingBags': 'Membaca tas',
|
||||
'assistant.step.packingAssignees': 'Membaca siapa membawa apa',
|
||||
'assistant.step.packingTemplates': 'Membaca templat bawaan',
|
||||
'assistant.step.todoAssignees': 'Membaca siapa mengurus daftar mana',
|
||||
'assistant.step.tourPacking': 'Membaca daftar bawaan tur',
|
||||
'assistant.tool.bulk_import_packing': 'Tambahkan beberapa barang ke daftar bawaan',
|
||||
'assistant.tool.apply_packing_template': 'Tambahkan templat bawaan',
|
||||
'assistant.tool.set_packing_item_sharing': 'Ubah untuk siapa barang ini',
|
||||
'assistant.tool.set_packing_category_assignees': 'Pilih siapa yang mengemas kategori ini',
|
||||
'assistant.tool.reorder_packing_items': 'Urutkan ulang daftar bawaan',
|
||||
'assistant.tool.create_packing_bag': 'Tambahkan tas',
|
||||
'assistant.tool.update_packing_bag': 'Ubah tas',
|
||||
'assistant.tool.delete_packing_bag': 'Hapus tas',
|
||||
'assistant.tool.set_bag_members': 'Pilih siapa yang mengemas tas',
|
||||
'assistant.tool.save_packing_template': 'Simpan sebagai templat bawaan',
|
||||
'assistant.tool.delete_packing_template': 'Hapus templat bawaan',
|
||||
'assistant.tool.set_todo_category_assignees': 'Pilih siapa yang mengurus daftar',
|
||||
'assistant.tool.reorder_todos': 'Urutkan ulang tugas',
|
||||
'assistant.tool.link_tour_packing': 'Tautkan daftar bawaan ke tur',
|
||||
'assistant.tool.unlink_tour_packing': 'Lepaskan daftar bawaan dari tur',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Lista valigia',
|
||||
'assistant.card.packing.bagsTitle': 'Borse',
|
||||
'assistant.card.packing.forTour': 'Valigia per questo giro',
|
||||
'assistant.card.packing.templateAdds': 'Aggiunge {count} oggetti dal modello',
|
||||
'assistant.card.packing.templateAdds.one': 'Aggiunge {count} oggetto dal modello',
|
||||
'assistant.card.packing.moreCategories': 'Altre {count} categorie',
|
||||
'assistant.card.packing.moreCategories.one': 'Altra {count} categoria',
|
||||
'assistant.card.packing.moreBags': 'Altre {count} borse',
|
||||
'assistant.card.packing.moreBags.one': 'Altra {count} borsa',
|
||||
'assistant.card.packing.ofLimit': '{weight} su {limit}',
|
||||
'assistant.card.packing.overLimit': 'Oltre il limite',
|
||||
'assistant.card.packing.movesOut': '{count} oggetti passano a Non assegnato',
|
||||
'assistant.card.packing.movesOut.one': '{count} oggetto passa a Non assegnato',
|
||||
'assistant.card.packing.newBag': 'Nuova borsa',
|
||||
'assistant.card.packing.packed': 'In valigia',
|
||||
'assistant.card.packing.notPacked': 'Non in valigia',
|
||||
'assistant.card.packing.sharedWith': 'Condiviso con {names}',
|
||||
'assistant.card.packing.tierCommon': 'Lista comune',
|
||||
'assistant.card.packing.tierPersonal': 'Solo per me',
|
||||
'assistant.card.packing.noWeight': 'Senza peso',
|
||||
'assistant.card.packing.noLimit': 'Senza limite',
|
||||
'assistant.card.todo.title': 'Attività',
|
||||
'assistant.card.todo.open': 'Apri la lista delle attività',
|
||||
'assistant.card.todo.openCount': '{count} aperte',
|
||||
'assistant.card.todo.openCount.one': '{count} aperte',
|
||||
'assistant.card.todo.overdueCount': '{count} in ritardo',
|
||||
'assistant.card.todo.overdueCount.one': '{count} in ritardo',
|
||||
'assistant.card.todo.moreTasks': 'Altre {count} attività',
|
||||
'assistant.card.todo.moreTasks.one': 'Altra {count} attività',
|
||||
'assistant.card.todo.moreLists': 'Altre {count} liste',
|
||||
'assistant.card.todo.moreLists.one': 'Altra {count} lista',
|
||||
'assistant.card.todo.noDueDate': 'Nessuna scadenza',
|
||||
'assistant.card.todo.notDone': 'Non fatta',
|
||||
'assistant.step.packingItems': 'Leggo la lista valigia',
|
||||
'assistant.step.packingBags': 'Leggo le borse',
|
||||
'assistant.step.packingAssignees': 'Leggo chi porta cosa',
|
||||
'assistant.step.packingTemplates': 'Leggo i modelli di valigia',
|
||||
'assistant.step.todoAssignees': 'Leggo chi si occupa di quale lista',
|
||||
'assistant.step.tourPacking': 'Leggo la lista valigia del giro',
|
||||
'assistant.tool.bulk_import_packing': 'Aggiungi più oggetti alla lista valigia',
|
||||
'assistant.tool.apply_packing_template': 'Aggiungi un modello di valigia',
|
||||
'assistant.tool.set_packing_item_sharing': "Cambia per chi è l'oggetto",
|
||||
'assistant.tool.set_packing_category_assignees': 'Scegli chi porta questa categoria',
|
||||
'assistant.tool.reorder_packing_items': 'Riordina la lista valigia',
|
||||
'assistant.tool.create_packing_bag': 'Aggiungi una borsa',
|
||||
'assistant.tool.update_packing_bag': 'Modifica la borsa',
|
||||
'assistant.tool.delete_packing_bag': 'Elimina la borsa',
|
||||
'assistant.tool.set_bag_members': 'Scegli chi riempie la borsa',
|
||||
'assistant.tool.save_packing_template': 'Salva come modello di valigia',
|
||||
'assistant.tool.delete_packing_template': 'Elimina il modello di valigia',
|
||||
'assistant.tool.set_todo_category_assignees': 'Scegli chi si occupa della lista',
|
||||
'assistant.tool.reorder_todos': 'Riordina le attività',
|
||||
'assistant.tool.link_tour_packing': 'Collega una lista valigia al giro',
|
||||
'assistant.tool.unlink_tour_packing': 'Scollega la lista valigia dal giro',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': '持ち物リスト',
|
||||
'assistant.card.packing.bagsTitle': 'バッグ',
|
||||
'assistant.card.packing.forTour': 'このツアーの持ち物',
|
||||
'assistant.card.packing.templateAdds': 'テンプレートから{count}個を追加',
|
||||
'assistant.card.packing.moreCategories': 'ほか{count}カテゴリ',
|
||||
'assistant.card.packing.moreBags': 'ほか{count}個のバッグ',
|
||||
'assistant.card.packing.ofLimit': '{weight} / {limit}',
|
||||
'assistant.card.packing.overLimit': '上限超過',
|
||||
'assistant.card.packing.movesOut': '{count}個が未割り当てに移ります',
|
||||
'assistant.card.packing.newBag': '新しいバッグ',
|
||||
'assistant.card.packing.packed': '詰めた',
|
||||
'assistant.card.packing.notPacked': '未パッキング',
|
||||
'assistant.card.packing.sharedWith': '{names}と共有',
|
||||
'assistant.card.packing.tierCommon': '共有リスト',
|
||||
'assistant.card.packing.tierPersonal': '自分だけ',
|
||||
'assistant.card.packing.noWeight': '重さなし',
|
||||
'assistant.card.packing.noLimit': '上限なし',
|
||||
'assistant.card.todo.title': 'やること',
|
||||
'assistant.card.todo.open': 'やることリストを開く',
|
||||
'assistant.card.todo.openCount': '未完了 {count}件',
|
||||
'assistant.card.todo.overdueCount': '期限切れ {count}件',
|
||||
'assistant.card.todo.moreTasks': 'ほか{count}件のタスク',
|
||||
'assistant.card.todo.moreLists': 'ほか{count}個のリスト',
|
||||
'assistant.card.todo.noDueDate': '期限なし',
|
||||
'assistant.card.todo.notDone': '未完了',
|
||||
'assistant.step.packingItems': '持ち物リストを読み込み中',
|
||||
'assistant.step.packingBags': 'バッグを読み込み中',
|
||||
'assistant.step.packingAssignees': '誰が何を持つかを読み込み中',
|
||||
'assistant.step.packingTemplates': '持ち物テンプレートを読み込み中',
|
||||
'assistant.step.todoAssignees': '誰がどのリストを担当するかを読み込み中',
|
||||
'assistant.step.tourPacking': 'ツアーの持ち物リストを読み込み中',
|
||||
'assistant.tool.bulk_import_packing': '持ち物リストに複数追加',
|
||||
'assistant.tool.apply_packing_template': '持ち物テンプレートを追加',
|
||||
'assistant.tool.set_packing_item_sharing': 'アイテムの対象者を変更',
|
||||
'assistant.tool.set_packing_category_assignees': 'このカテゴリの担当者を選ぶ',
|
||||
'assistant.tool.reorder_packing_items': '持ち物リストを並べ替え',
|
||||
'assistant.tool.create_packing_bag': 'バッグを追加',
|
||||
'assistant.tool.update_packing_bag': 'バッグを変更',
|
||||
'assistant.tool.delete_packing_bag': 'バッグを削除',
|
||||
'assistant.tool.set_bag_members': 'バッグの担当者を選ぶ',
|
||||
'assistant.tool.save_packing_template': '持ち物テンプレートとして保存',
|
||||
'assistant.tool.delete_packing_template': '持ち物テンプレートを削除',
|
||||
'assistant.tool.set_todo_category_assignees': 'リストの担当者を選ぶ',
|
||||
'assistant.tool.reorder_todos': 'やることを並べ替え',
|
||||
'assistant.tool.link_tour_packing': '持ち物リストをツアーにリンク',
|
||||
'assistant.tool.unlink_tour_packing': 'ツアーの持ち物リストのリンクを解除',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': '짐 목록',
|
||||
'assistant.card.packing.bagsTitle': '가방',
|
||||
'assistant.card.packing.forTour': '이 투어의 짐',
|
||||
'assistant.card.packing.templateAdds': '템플릿에서 {count}개 추가',
|
||||
'assistant.card.packing.moreCategories': '카테고리 {count}개 더',
|
||||
'assistant.card.packing.moreBags': '가방 {count}개 더',
|
||||
'assistant.card.packing.ofLimit': '{weight} / {limit}',
|
||||
'assistant.card.packing.overLimit': '한도 초과',
|
||||
'assistant.card.packing.movesOut': '{count}개가 미배정으로 이동합니다',
|
||||
'assistant.card.packing.newBag': '새 가방',
|
||||
'assistant.card.packing.packed': '챙김',
|
||||
'assistant.card.packing.notPacked': '안 챙김',
|
||||
'assistant.card.packing.sharedWith': '{names}님과 공유',
|
||||
'assistant.card.packing.tierCommon': '공용 목록',
|
||||
'assistant.card.packing.tierPersonal': '나만 보기',
|
||||
'assistant.card.packing.noWeight': '무게 없음',
|
||||
'assistant.card.packing.noLimit': '한도 없음',
|
||||
'assistant.card.todo.title': '할 일',
|
||||
'assistant.card.todo.open': '할 일 목록 열기',
|
||||
'assistant.card.todo.openCount': '열린 항목 {count}개',
|
||||
'assistant.card.todo.overdueCount': '기한 지남 {count}개',
|
||||
'assistant.card.todo.moreTasks': '할 일 {count}개 더',
|
||||
'assistant.card.todo.moreLists': '목록 {count}개 더',
|
||||
'assistant.card.todo.noDueDate': '기한 없음',
|
||||
'assistant.card.todo.notDone': '미완료',
|
||||
'assistant.step.packingItems': '짐 목록 읽는 중',
|
||||
'assistant.step.packingBags': '가방 읽는 중',
|
||||
'assistant.step.packingAssignees': '누가 무엇을 챙기는지 읽는 중',
|
||||
'assistant.step.packingTemplates': '짐 템플릿 읽는 중',
|
||||
'assistant.step.todoAssignees': '누가 어떤 목록을 맡는지 읽는 중',
|
||||
'assistant.step.tourPacking': '투어의 짐 목록 읽는 중',
|
||||
'assistant.tool.bulk_import_packing': '짐 목록에 여러 개 추가',
|
||||
'assistant.tool.apply_packing_template': '짐 템플릿 추가',
|
||||
'assistant.tool.set_packing_item_sharing': '항목의 대상자 변경',
|
||||
'assistant.tool.set_packing_category_assignees': '이 카테고리를 챙길 사람 선택',
|
||||
'assistant.tool.reorder_packing_items': '짐 목록 순서 변경',
|
||||
'assistant.tool.create_packing_bag': '가방 추가',
|
||||
'assistant.tool.update_packing_bag': '가방 변경',
|
||||
'assistant.tool.delete_packing_bag': '가방 삭제',
|
||||
'assistant.tool.set_bag_members': '가방을 챙길 사람 선택',
|
||||
'assistant.tool.save_packing_template': '짐 템플릿으로 저장',
|
||||
'assistant.tool.delete_packing_template': '짐 템플릿 삭제',
|
||||
'assistant.tool.set_todo_category_assignees': '목록 담당자 선택',
|
||||
'assistant.tool.reorder_todos': '할 일 순서 변경',
|
||||
'assistant.tool.link_tour_packing': '투어에 짐 목록 연결',
|
||||
'assistant.tool.unlink_tour_packing': '투어의 짐 목록 연결 해제',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Inpaklijst',
|
||||
'assistant.card.packing.bagsTitle': 'Tassen',
|
||||
'assistant.card.packing.forTour': 'Inpakken voor deze tocht',
|
||||
'assistant.card.packing.templateAdds': 'Voegt {count} spullen uit de sjabloon toe',
|
||||
'assistant.card.packing.templateAdds.one': 'Voegt {count} ding uit de sjabloon toe',
|
||||
'assistant.card.packing.moreCategories': 'Nog {count} categorieën',
|
||||
'assistant.card.packing.moreCategories.one': 'Nog {count} categorie',
|
||||
'assistant.card.packing.moreBags': 'Nog {count} tassen',
|
||||
'assistant.card.packing.moreBags.one': 'Nog {count} tas',
|
||||
'assistant.card.packing.ofLimit': '{weight} van {limit}',
|
||||
'assistant.card.packing.overLimit': 'Boven de limiet',
|
||||
'assistant.card.packing.movesOut': '{count} spullen gaan naar Niet toegewezen',
|
||||
'assistant.card.packing.movesOut.one': '{count} ding gaat naar Niet toegewezen',
|
||||
'assistant.card.packing.newBag': 'Nieuwe tas',
|
||||
'assistant.card.packing.packed': 'Ingepakt',
|
||||
'assistant.card.packing.notPacked': 'Niet ingepakt',
|
||||
'assistant.card.packing.sharedWith': 'Gedeeld met {names}',
|
||||
'assistant.card.packing.tierCommon': 'Gezamenlijke lijst',
|
||||
'assistant.card.packing.tierPersonal': 'Alleen voor mij',
|
||||
'assistant.card.packing.noWeight': 'Geen gewicht',
|
||||
'assistant.card.packing.noLimit': 'Geen limiet',
|
||||
'assistant.card.todo.title': 'Taken',
|
||||
'assistant.card.todo.open': 'Takenlijst openen',
|
||||
'assistant.card.todo.openCount': '{count} openstaand',
|
||||
'assistant.card.todo.openCount.one': '{count} openstaand',
|
||||
'assistant.card.todo.overdueCount': '{count} te laat',
|
||||
'assistant.card.todo.overdueCount.one': '{count} te laat',
|
||||
'assistant.card.todo.moreTasks': 'Nog {count} taken',
|
||||
'assistant.card.todo.moreTasks.one': 'Nog {count} taak',
|
||||
'assistant.card.todo.moreLists': 'Nog {count} lijsten',
|
||||
'assistant.card.todo.moreLists.one': 'Nog {count} lijst',
|
||||
'assistant.card.todo.noDueDate': 'Geen vervaldatum',
|
||||
'assistant.card.todo.notDone': 'Niet gedaan',
|
||||
'assistant.step.packingItems': 'Inpaklijst lezen',
|
||||
'assistant.step.packingBags': 'Tassen lezen',
|
||||
'assistant.step.packingAssignees': 'Lezen wie wat inpakt',
|
||||
'assistant.step.packingTemplates': 'Inpaksjablonen lezen',
|
||||
'assistant.step.todoAssignees': 'Lezen wie welke lijst beheert',
|
||||
'assistant.step.tourPacking': 'Inpaklijst van de tocht lezen',
|
||||
'assistant.tool.bulk_import_packing': 'Meerdere spullen aan de inpaklijst toevoegen',
|
||||
'assistant.tool.apply_packing_template': 'Inpaksjabloon toevoegen',
|
||||
'assistant.tool.set_packing_item_sharing': 'Wijzigen voor wie het ding is',
|
||||
'assistant.tool.set_packing_category_assignees': 'Kiezen wie deze categorie inpakt',
|
||||
'assistant.tool.reorder_packing_items': 'Inpaklijst herschikken',
|
||||
'assistant.tool.create_packing_bag': 'Tas toevoegen',
|
||||
'assistant.tool.update_packing_bag': 'Tas wijzigen',
|
||||
'assistant.tool.delete_packing_bag': 'Tas verwijderen',
|
||||
'assistant.tool.set_bag_members': 'Kiezen wie de tas inpakt',
|
||||
'assistant.tool.save_packing_template': 'Opslaan als inpaksjabloon',
|
||||
'assistant.tool.delete_packing_template': 'Inpaksjabloon verwijderen',
|
||||
'assistant.tool.set_todo_category_assignees': 'Kiezen wie de lijst beheert',
|
||||
'assistant.tool.reorder_todos': 'Taken herschikken',
|
||||
'assistant.tool.link_tour_packing': 'Inpaklijst aan de tocht koppelen',
|
||||
'assistant.tool.unlink_tour_packing': 'Inpaklijst van de tocht loskoppelen',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,75 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Lista pakowania',
|
||||
'assistant.card.packing.bagsTitle': 'Torby',
|
||||
'assistant.card.packing.forTour': 'Pakowanie na tę trasę',
|
||||
'assistant.card.packing.templateAdds': 'Dodaje {count} rzeczy z szablonu',
|
||||
'assistant.card.packing.templateAdds.one': 'Dodaje {count} rzecz z szablonu',
|
||||
'assistant.card.packing.templateAdds.few': 'Dodaje {count} rzeczy z szablonu',
|
||||
'assistant.card.packing.templateAdds.many': 'Dodaje {count} rzeczy z szablonu',
|
||||
'assistant.card.packing.moreCategories': 'Jeszcze {count} kategorii',
|
||||
'assistant.card.packing.moreCategories.one': 'Jeszcze {count} kategoria',
|
||||
'assistant.card.packing.moreCategories.few': 'Jeszcze {count} kategorie',
|
||||
'assistant.card.packing.moreCategories.many': 'Jeszcze {count} kategorii',
|
||||
'assistant.card.packing.moreBags': 'Jeszcze {count} torby',
|
||||
'assistant.card.packing.moreBags.one': 'Jeszcze {count} torba',
|
||||
'assistant.card.packing.moreBags.few': 'Jeszcze {count} torby',
|
||||
'assistant.card.packing.moreBags.many': 'Jeszcze {count} toreb',
|
||||
'assistant.card.packing.ofLimit': '{weight} z {limit}',
|
||||
'assistant.card.packing.overLimit': 'Ponad limit',
|
||||
'assistant.card.packing.movesOut': '{count} rzeczy trafi do Nieprzypisane',
|
||||
'assistant.card.packing.movesOut.one': '{count} rzecz trafi do Nieprzypisane',
|
||||
'assistant.card.packing.movesOut.few': '{count} rzeczy trafią do Nieprzypisane',
|
||||
'assistant.card.packing.movesOut.many': '{count} rzeczy trafi do Nieprzypisane',
|
||||
'assistant.card.packing.newBag': 'Nowa torba',
|
||||
'assistant.card.packing.packed': 'Spakowane',
|
||||
'assistant.card.packing.notPacked': 'Niespakowane',
|
||||
'assistant.card.packing.sharedWith': 'Udostępnione: {names}',
|
||||
'assistant.card.packing.tierCommon': 'Wspólna lista',
|
||||
'assistant.card.packing.tierPersonal': 'Tylko dla mnie',
|
||||
'assistant.card.packing.noWeight': 'Bez wagi',
|
||||
'assistant.card.packing.noLimit': 'Bez limitu',
|
||||
'assistant.card.todo.title': 'Zadania',
|
||||
'assistant.card.todo.open': 'Otwórz listę zadań',
|
||||
'assistant.card.todo.openCount': '{count} otwartych',
|
||||
'assistant.card.todo.openCount.one': '{count} otwarte',
|
||||
'assistant.card.todo.openCount.few': '{count} otwarte',
|
||||
'assistant.card.todo.openCount.many': '{count} otwartych',
|
||||
'assistant.card.todo.overdueCount': '{count} zaległych',
|
||||
'assistant.card.todo.overdueCount.one': '{count} zaległe',
|
||||
'assistant.card.todo.overdueCount.few': '{count} zaległe',
|
||||
'assistant.card.todo.overdueCount.many': '{count} zaległych',
|
||||
'assistant.card.todo.moreTasks': 'Jeszcze {count} zadania',
|
||||
'assistant.card.todo.moreTasks.one': 'Jeszcze {count} zadanie',
|
||||
'assistant.card.todo.moreTasks.few': 'Jeszcze {count} zadania',
|
||||
'assistant.card.todo.moreTasks.many': 'Jeszcze {count} zadań',
|
||||
'assistant.card.todo.moreLists': 'Jeszcze {count} listy',
|
||||
'assistant.card.todo.moreLists.one': 'Jeszcze {count} lista',
|
||||
'assistant.card.todo.moreLists.few': 'Jeszcze {count} listy',
|
||||
'assistant.card.todo.moreLists.many': 'Jeszcze {count} list',
|
||||
'assistant.card.todo.noDueDate': 'Bez terminu',
|
||||
'assistant.card.todo.notDone': 'Niewykonane',
|
||||
'assistant.step.packingItems': 'Czytam listę pakowania',
|
||||
'assistant.step.packingBags': 'Czytam torby',
|
||||
'assistant.step.packingAssignees': 'Czytam, kto co pakuje',
|
||||
'assistant.step.packingTemplates': 'Czytam szablony pakowania',
|
||||
'assistant.step.todoAssignees': 'Czytam, kto zajmuje się którą listą',
|
||||
'assistant.step.tourPacking': 'Czytam listę pakowania trasy',
|
||||
'assistant.tool.bulk_import_packing': 'Dodaj kilka rzeczy do listy pakowania',
|
||||
'assistant.tool.apply_packing_template': 'Dodaj szablon pakowania',
|
||||
'assistant.tool.set_packing_item_sharing': 'Zmień, dla kogo jest rzecz',
|
||||
'assistant.tool.set_packing_category_assignees': 'Wybierz, kto pakuje tę kategorię',
|
||||
'assistant.tool.reorder_packing_items': 'Zmień kolejność listy pakowania',
|
||||
'assistant.tool.create_packing_bag': 'Dodaj torbę',
|
||||
'assistant.tool.update_packing_bag': 'Zmień torbę',
|
||||
'assistant.tool.delete_packing_bag': 'Usuń torbę',
|
||||
'assistant.tool.set_bag_members': 'Wybierz, kto pakuje torbę',
|
||||
'assistant.tool.save_packing_template': 'Zapisz jako szablon pakowania',
|
||||
'assistant.tool.delete_packing_template': 'Usuń szablon pakowania',
|
||||
'assistant.tool.set_todo_category_assignees': 'Wybierz, kto zajmuje się listą',
|
||||
'assistant.tool.reorder_todos': 'Zmień kolejność zadań',
|
||||
'assistant.tool.link_tour_packing': 'Połącz listę pakowania z trasą',
|
||||
'assistant.tool.unlink_tour_packing': 'Odłącz listę pakowania od trasy',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,75 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Список вещей',
|
||||
'assistant.card.packing.bagsTitle': 'Сумки',
|
||||
'assistant.card.packing.forTour': 'Вещи для этого маршрута',
|
||||
'assistant.card.packing.templateAdds': 'Добавит {count} вещи из шаблона',
|
||||
'assistant.card.packing.templateAdds.one': 'Добавит {count} вещь из шаблона',
|
||||
'assistant.card.packing.templateAdds.few': 'Добавит {count} вещи из шаблона',
|
||||
'assistant.card.packing.templateAdds.many': 'Добавит {count} вещей из шаблона',
|
||||
'assistant.card.packing.moreCategories': 'Ещё {count} категории',
|
||||
'assistant.card.packing.moreCategories.one': 'Ещё {count} категория',
|
||||
'assistant.card.packing.moreCategories.few': 'Ещё {count} категории',
|
||||
'assistant.card.packing.moreCategories.many': 'Ещё {count} категорий',
|
||||
'assistant.card.packing.moreBags': 'Ещё {count} сумки',
|
||||
'assistant.card.packing.moreBags.one': 'Ещё {count} сумка',
|
||||
'assistant.card.packing.moreBags.few': 'Ещё {count} сумки',
|
||||
'assistant.card.packing.moreBags.many': 'Ещё {count} сумок',
|
||||
'assistant.card.packing.ofLimit': '{weight} из {limit}',
|
||||
'assistant.card.packing.overLimit': 'Сверх лимита',
|
||||
'assistant.card.packing.movesOut': '{count} вещи перейдут в «Не назначено»',
|
||||
'assistant.card.packing.movesOut.one': '{count} вещь перейдёт в «Не назначено»',
|
||||
'assistant.card.packing.movesOut.few': '{count} вещи перейдут в «Не назначено»',
|
||||
'assistant.card.packing.movesOut.many': '{count} вещей перейдут в «Не назначено»',
|
||||
'assistant.card.packing.newBag': 'Новая сумка',
|
||||
'assistant.card.packing.packed': 'Упаковано',
|
||||
'assistant.card.packing.notPacked': 'Не упаковано',
|
||||
'assistant.card.packing.sharedWith': 'Общее с: {names}',
|
||||
'assistant.card.packing.tierCommon': 'Общий список',
|
||||
'assistant.card.packing.tierPersonal': 'Только для меня',
|
||||
'assistant.card.packing.noWeight': 'Без веса',
|
||||
'assistant.card.packing.noLimit': 'Без лимита',
|
||||
'assistant.card.todo.title': 'Задачи',
|
||||
'assistant.card.todo.open': 'Открыть список задач',
|
||||
'assistant.card.todo.openCount': '{count} открытой',
|
||||
'assistant.card.todo.openCount.one': '{count} открытая',
|
||||
'assistant.card.todo.openCount.few': '{count} открытые',
|
||||
'assistant.card.todo.openCount.many': '{count} открытых',
|
||||
'assistant.card.todo.overdueCount': '{count} просроченной',
|
||||
'assistant.card.todo.overdueCount.one': '{count} просроченная',
|
||||
'assistant.card.todo.overdueCount.few': '{count} просроченные',
|
||||
'assistant.card.todo.overdueCount.many': '{count} просроченных',
|
||||
'assistant.card.todo.moreTasks': 'Ещё {count} задачи',
|
||||
'assistant.card.todo.moreTasks.one': 'Ещё {count} задача',
|
||||
'assistant.card.todo.moreTasks.few': 'Ещё {count} задачи',
|
||||
'assistant.card.todo.moreTasks.many': 'Ещё {count} задач',
|
||||
'assistant.card.todo.moreLists': 'Ещё {count} списка',
|
||||
'assistant.card.todo.moreLists.one': 'Ещё {count} список',
|
||||
'assistant.card.todo.moreLists.few': 'Ещё {count} списка',
|
||||
'assistant.card.todo.moreLists.many': 'Ещё {count} списков',
|
||||
'assistant.card.todo.noDueDate': 'Без срока',
|
||||
'assistant.card.todo.notDone': 'Не выполнено',
|
||||
'assistant.step.packingItems': 'Читаю список вещей',
|
||||
'assistant.step.packingBags': 'Читаю сумки',
|
||||
'assistant.step.packingAssignees': 'Читаю, кто что берёт',
|
||||
'assistant.step.packingTemplates': 'Читаю шаблоны сборов',
|
||||
'assistant.step.todoAssignees': 'Читаю, кто отвечает за какой список',
|
||||
'assistant.step.tourPacking': 'Читаю список вещей маршрута',
|
||||
'assistant.tool.bulk_import_packing': 'Добавить несколько вещей в список',
|
||||
'assistant.tool.apply_packing_template': 'Добавить шаблон сборов',
|
||||
'assistant.tool.set_packing_item_sharing': 'Изменить, для кого вещь',
|
||||
'assistant.tool.set_packing_category_assignees': 'Выбрать, кто собирает эту категорию',
|
||||
'assistant.tool.reorder_packing_items': 'Изменить порядок списка вещей',
|
||||
'assistant.tool.create_packing_bag': 'Добавить сумку',
|
||||
'assistant.tool.update_packing_bag': 'Изменить сумку',
|
||||
'assistant.tool.delete_packing_bag': 'Удалить сумку',
|
||||
'assistant.tool.set_bag_members': 'Выбрать, кто собирает сумку',
|
||||
'assistant.tool.save_packing_template': 'Сохранить как шаблон сборов',
|
||||
'assistant.tool.delete_packing_template': 'Удалить шаблон сборов',
|
||||
'assistant.tool.set_todo_category_assignees': 'Выбрать, кто отвечает за список',
|
||||
'assistant.tool.reorder_todos': 'Изменить порядок задач',
|
||||
'assistant.tool.link_tour_packing': 'Привязать список вещей к маршруту',
|
||||
'assistant.tool.unlink_tour_packing': 'Отвязать список вещей от маршрута',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,67 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Zoznam vecí',
|
||||
'assistant.card.packing.bagsTitle': 'Batožina',
|
||||
'assistant.card.packing.forTour': 'Veci na túto túru',
|
||||
'assistant.card.packing.templateAdds': 'Pridá {count} vecí zo šablóny',
|
||||
'assistant.card.packing.templateAdds.one': 'Pridá {count} vec zo šablóny',
|
||||
'assistant.card.packing.templateAdds.few': 'Pridá {count} veci zo šablóny',
|
||||
'assistant.card.packing.moreCategories': 'Ďalších {count} kategórií',
|
||||
'assistant.card.packing.moreCategories.one': 'Ďalšia {count} kategória',
|
||||
'assistant.card.packing.moreCategories.few': 'Ďalšie {count} kategórie',
|
||||
'assistant.card.packing.moreBags': 'Ďalších {count} batožín',
|
||||
'assistant.card.packing.moreBags.one': 'Ďalšia {count} batožina',
|
||||
'assistant.card.packing.moreBags.few': 'Ďalšie {count} batožiny',
|
||||
'assistant.card.packing.ofLimit': '{weight} z {limit}',
|
||||
'assistant.card.packing.overLimit': 'Nad limitom',
|
||||
'assistant.card.packing.movesOut': '{count} vecí prejde do Nepriradené',
|
||||
'assistant.card.packing.movesOut.one': '{count} vec prejde do Nepriradené',
|
||||
'assistant.card.packing.movesOut.few': '{count} veci prejdú do Nepriradené',
|
||||
'assistant.card.packing.newBag': 'Nová batožina',
|
||||
'assistant.card.packing.packed': 'Zabalené',
|
||||
'assistant.card.packing.notPacked': 'Nezabalené',
|
||||
'assistant.card.packing.sharedWith': 'Zdieľané s: {names}',
|
||||
'assistant.card.packing.tierCommon': 'Spoločný zoznam',
|
||||
'assistant.card.packing.tierPersonal': 'Len pre mňa',
|
||||
'assistant.card.packing.noWeight': 'Bez hmotnosti',
|
||||
'assistant.card.packing.noLimit': 'Bez limitu',
|
||||
'assistant.card.todo.title': 'Úlohy',
|
||||
'assistant.card.todo.open': 'Otvoriť zoznam úloh',
|
||||
'assistant.card.todo.openCount': '{count} otvorených',
|
||||
'assistant.card.todo.openCount.one': '{count} otvorená',
|
||||
'assistant.card.todo.openCount.few': '{count} otvorené',
|
||||
'assistant.card.todo.overdueCount': '{count} po termíne',
|
||||
'assistant.card.todo.overdueCount.one': '{count} po termíne',
|
||||
'assistant.card.todo.overdueCount.few': '{count} po termíne',
|
||||
'assistant.card.todo.moreTasks': 'Ďalších {count} úloh',
|
||||
'assistant.card.todo.moreTasks.one': 'Ďalšia {count} úloha',
|
||||
'assistant.card.todo.moreTasks.few': 'Ďalšie {count} úlohy',
|
||||
'assistant.card.todo.moreLists': 'Ďalších {count} zoznamov',
|
||||
'assistant.card.todo.moreLists.one': 'Ďalší {count} zoznam',
|
||||
'assistant.card.todo.moreLists.few': 'Ďalšie {count} zoznamy',
|
||||
'assistant.card.todo.noDueDate': 'Bez termínu',
|
||||
'assistant.card.todo.notDone': 'Nehotové',
|
||||
'assistant.step.packingItems': 'Čítam zoznam vecí',
|
||||
'assistant.step.packingBags': 'Čítam batožinu',
|
||||
'assistant.step.packingAssignees': 'Čítam, kto čo balí',
|
||||
'assistant.step.packingTemplates': 'Čítam šablóny balenia',
|
||||
'assistant.step.todoAssignees': 'Čítam, kto sa stará o ktorý zoznam',
|
||||
'assistant.step.tourPacking': 'Čítam zoznam vecí túry',
|
||||
'assistant.tool.bulk_import_packing': 'Pridať viac vecí do zoznamu',
|
||||
'assistant.tool.apply_packing_template': 'Pridať šablónu balenia',
|
||||
'assistant.tool.set_packing_item_sharing': 'Zmeniť, pre koho je vec',
|
||||
'assistant.tool.set_packing_category_assignees': 'Vybrať, kto balí túto kategóriu',
|
||||
'assistant.tool.reorder_packing_items': 'Preusporiadať zoznam vecí',
|
||||
'assistant.tool.create_packing_bag': 'Pridať batožinu',
|
||||
'assistant.tool.update_packing_bag': 'Zmeniť batožinu',
|
||||
'assistant.tool.delete_packing_bag': 'Odstrániť batožinu',
|
||||
'assistant.tool.set_bag_members': 'Vybrať, kto balí batožinu',
|
||||
'assistant.tool.save_packing_template': 'Uložiť ako šablónu balenia',
|
||||
'assistant.tool.delete_packing_template': 'Odstrániť šablónu balenia',
|
||||
'assistant.tool.set_todo_category_assignees': 'Vybrať, kto sa stará o zoznam',
|
||||
'assistant.tool.reorder_todos': 'Preusporiadať úlohy',
|
||||
'assistant.tool.link_tour_packing': 'Prepojiť zoznam vecí s túrou',
|
||||
'assistant.tool.unlink_tour_packing': 'Odpojiť zoznam vecí od túry',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Packlista',
|
||||
'assistant.card.packing.bagsTitle': 'Väskor',
|
||||
'assistant.card.packing.forTour': 'Packning för den här turen',
|
||||
'assistant.card.packing.templateAdds': 'Lägger till {count} saker från mallen',
|
||||
'assistant.card.packing.templateAdds.one': 'Lägger till {count} sak från mallen',
|
||||
'assistant.card.packing.moreCategories': '{count} kategorier till',
|
||||
'assistant.card.packing.moreCategories.one': '{count} kategori till',
|
||||
'assistant.card.packing.moreBags': '{count} väskor till',
|
||||
'assistant.card.packing.moreBags.one': '{count} väska till',
|
||||
'assistant.card.packing.ofLimit': '{weight} av {limit}',
|
||||
'assistant.card.packing.overLimit': 'Över gränsen',
|
||||
'assistant.card.packing.movesOut': '{count} saker hamnar under Ej tilldelad',
|
||||
'assistant.card.packing.movesOut.one': '{count} sak hamnar under Ej tilldelad',
|
||||
'assistant.card.packing.newBag': 'Ny väska',
|
||||
'assistant.card.packing.packed': 'Packad',
|
||||
'assistant.card.packing.notPacked': 'Inte packad',
|
||||
'assistant.card.packing.sharedWith': 'Delad med {names}',
|
||||
'assistant.card.packing.tierCommon': 'Gemensam lista',
|
||||
'assistant.card.packing.tierPersonal': 'Bara för mig',
|
||||
'assistant.card.packing.noWeight': 'Ingen vikt',
|
||||
'assistant.card.packing.noLimit': 'Ingen gräns',
|
||||
'assistant.card.todo.title': 'Uppgifter',
|
||||
'assistant.card.todo.open': 'Öppna uppgiftslistan',
|
||||
'assistant.card.todo.openCount': '{count} öppna',
|
||||
'assistant.card.todo.openCount.one': '{count} öppna',
|
||||
'assistant.card.todo.overdueCount': '{count} försenade',
|
||||
'assistant.card.todo.overdueCount.one': '{count} försenade',
|
||||
'assistant.card.todo.moreTasks': '{count} uppgifter till',
|
||||
'assistant.card.todo.moreTasks.one': '{count} uppgift till',
|
||||
'assistant.card.todo.moreLists': '{count} listor till',
|
||||
'assistant.card.todo.moreLists.one': '{count} lista till',
|
||||
'assistant.card.todo.noDueDate': 'Inget förfallodatum',
|
||||
'assistant.card.todo.notDone': 'Inte klar',
|
||||
'assistant.step.packingItems': 'Läser packlistan',
|
||||
'assistant.step.packingBags': 'Läser väskorna',
|
||||
'assistant.step.packingAssignees': 'Läser vem som packar vad',
|
||||
'assistant.step.packingTemplates': 'Läser packmallarna',
|
||||
'assistant.step.todoAssignees': 'Läser vem som tar hand om vilken lista',
|
||||
'assistant.step.tourPacking': 'Läser turens packlista',
|
||||
'assistant.tool.bulk_import_packing': 'Lägg till flera saker i packlistan',
|
||||
'assistant.tool.apply_packing_template': 'Lägg till en packmall',
|
||||
'assistant.tool.set_packing_item_sharing': 'Ändra vem saken är till',
|
||||
'assistant.tool.set_packing_category_assignees': 'Välj vem som packar den här kategorin',
|
||||
'assistant.tool.reorder_packing_items': 'Ordna om packlistan',
|
||||
'assistant.tool.create_packing_bag': 'Lägg till en väska',
|
||||
'assistant.tool.update_packing_bag': 'Ändra väskan',
|
||||
'assistant.tool.delete_packing_bag': 'Ta bort väskan',
|
||||
'assistant.tool.set_bag_members': 'Välj vem som packar väskan',
|
||||
'assistant.tool.save_packing_template': 'Spara som packmall',
|
||||
'assistant.tool.delete_packing_template': 'Ta bort packmallen',
|
||||
'assistant.tool.set_todo_category_assignees': 'Välj vem som tar hand om listan',
|
||||
'assistant.tool.reorder_todos': 'Ordna om uppgifterna',
|
||||
'assistant.tool.link_tour_packing': 'Koppla en packlista till turen',
|
||||
'assistant.tool.unlink_tour_packing': 'Koppla bort turens packlista',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'รายการจัดกระเป๋า',
|
||||
'assistant.card.packing.bagsTitle': 'กระเป๋า',
|
||||
'assistant.card.packing.forTour': 'ของสำหรับทริปนี้',
|
||||
'assistant.card.packing.templateAdds': 'เพิ่ม {count} ชิ้นจากเทมเพลต',
|
||||
'assistant.card.packing.moreCategories': 'อีก {count} หมวดหมู่',
|
||||
'assistant.card.packing.moreBags': 'อีก {count} ใบ',
|
||||
'assistant.card.packing.ofLimit': '{weight} จาก {limit}',
|
||||
'assistant.card.packing.overLimit': 'เกินขีดจำกัด',
|
||||
'assistant.card.packing.movesOut': '{count} ชิ้นย้ายไปที่ยังไม่ได้มอบหมาย',
|
||||
'assistant.card.packing.newBag': 'กระเป๋าใบใหม่',
|
||||
'assistant.card.packing.packed': 'จัดแล้ว',
|
||||
'assistant.card.packing.notPacked': 'ยังไม่ได้จัด',
|
||||
'assistant.card.packing.sharedWith': 'แชร์กับ {names}',
|
||||
'assistant.card.packing.tierCommon': 'รายการรวม',
|
||||
'assistant.card.packing.tierPersonal': 'เฉพาะฉัน',
|
||||
'assistant.card.packing.noWeight': 'ไม่มีน้ำหนัก',
|
||||
'assistant.card.packing.noLimit': 'ไม่มีขีดจำกัด',
|
||||
'assistant.card.todo.title': 'สิ่งที่ต้องทำ',
|
||||
'assistant.card.todo.open': 'เปิดรายการสิ่งที่ต้องทำ',
|
||||
'assistant.card.todo.openCount': 'ค้างอยู่ {count}',
|
||||
'assistant.card.todo.overdueCount': 'เลยกำหนด {count}',
|
||||
'assistant.card.todo.moreTasks': 'อีก {count} งาน',
|
||||
'assistant.card.todo.moreLists': 'อีก {count} รายการ',
|
||||
'assistant.card.todo.noDueDate': 'ไม่มีกำหนดส่ง',
|
||||
'assistant.card.todo.notDone': 'ยังไม่เสร็จ',
|
||||
'assistant.step.packingItems': 'กำลังอ่านรายการจัดกระเป๋า',
|
||||
'assistant.step.packingBags': 'กำลังอ่านกระเป๋า',
|
||||
'assistant.step.packingAssignees': 'กำลังอ่านว่าใครเอาอะไรไป',
|
||||
'assistant.step.packingTemplates': 'กำลังอ่านเทมเพลตการจัดกระเป๋า',
|
||||
'assistant.step.todoAssignees': 'กำลังอ่านว่าใครดูแลรายการไหน',
|
||||
'assistant.step.tourPacking': 'กำลังอ่านรายการจัดกระเป๋าของทริป',
|
||||
'assistant.tool.bulk_import_packing': 'เพิ่มหลายชิ้นลงรายการจัดกระเป๋า',
|
||||
'assistant.tool.apply_packing_template': 'เพิ่มเทมเพลตการจัดกระเป๋า',
|
||||
'assistant.tool.set_packing_item_sharing': 'เปลี่ยนว่าชิ้นนี้เป็นของใคร',
|
||||
'assistant.tool.set_packing_category_assignees': 'เลือกคนจัดหมวดหมู่นี้',
|
||||
'assistant.tool.reorder_packing_items': 'เรียงรายการจัดกระเป๋าใหม่',
|
||||
'assistant.tool.create_packing_bag': 'เพิ่มกระเป๋า',
|
||||
'assistant.tool.update_packing_bag': 'แก้ไขกระเป๋า',
|
||||
'assistant.tool.delete_packing_bag': 'ลบกระเป๋า',
|
||||
'assistant.tool.set_bag_members': 'เลือกคนจัดกระเป๋าใบนี้',
|
||||
'assistant.tool.save_packing_template': 'บันทึกเป็นเทมเพลตการจัดกระเป๋า',
|
||||
'assistant.tool.delete_packing_template': 'ลบเทมเพลตการจัดกระเป๋า',
|
||||
'assistant.tool.set_todo_category_assignees': 'เลือกคนดูแลรายการ',
|
||||
'assistant.tool.reorder_todos': 'เรียงสิ่งที่ต้องทำใหม่',
|
||||
'assistant.tool.link_tour_packing': 'ลิงก์รายการจัดกระเป๋ากับทริป',
|
||||
'assistant.tool.unlink_tour_packing': 'ยกเลิกลิงก์รายการจัดกระเป๋าของทริป',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Paket listesi',
|
||||
'assistant.card.packing.bagsTitle': 'Çantalar',
|
||||
'assistant.card.packing.forTour': 'Bu tur için paket',
|
||||
'assistant.card.packing.templateAdds': 'Şablondan {count} eşya ekler',
|
||||
'assistant.card.packing.templateAdds.one': 'Şablondan {count} eşya ekler',
|
||||
'assistant.card.packing.moreCategories': '{count} kategori daha',
|
||||
'assistant.card.packing.moreCategories.one': '{count} kategori daha',
|
||||
'assistant.card.packing.moreBags': '{count} çanta daha',
|
||||
'assistant.card.packing.moreBags.one': '{count} çanta daha',
|
||||
'assistant.card.packing.ofLimit': '{weight} / {limit}',
|
||||
'assistant.card.packing.overLimit': 'Sınırın üstünde',
|
||||
'assistant.card.packing.movesOut': '{count} eşya Atanmamış altına geçer',
|
||||
'assistant.card.packing.movesOut.one': '{count} eşya Atanmamış altına geçer',
|
||||
'assistant.card.packing.newBag': 'Yeni çanta',
|
||||
'assistant.card.packing.packed': 'Paketlendi',
|
||||
'assistant.card.packing.notPacked': 'Paketlenmedi',
|
||||
'assistant.card.packing.sharedWith': '{names} ile paylaşıldı',
|
||||
'assistant.card.packing.tierCommon': 'Ortak liste',
|
||||
'assistant.card.packing.tierPersonal': 'Sadece benim için',
|
||||
'assistant.card.packing.noWeight': 'Ağırlık yok',
|
||||
'assistant.card.packing.noLimit': 'Sınır yok',
|
||||
'assistant.card.todo.title': 'Yapılacaklar',
|
||||
'assistant.card.todo.open': 'Yapılacaklar listesini aç',
|
||||
'assistant.card.todo.openCount': '{count} açık',
|
||||
'assistant.card.todo.openCount.one': '{count} açık',
|
||||
'assistant.card.todo.overdueCount': '{count} gecikmiş',
|
||||
'assistant.card.todo.overdueCount.one': '{count} gecikmiş',
|
||||
'assistant.card.todo.moreTasks': '{count} görev daha',
|
||||
'assistant.card.todo.moreTasks.one': '{count} görev daha',
|
||||
'assistant.card.todo.moreLists': '{count} liste daha',
|
||||
'assistant.card.todo.moreLists.one': '{count} liste daha',
|
||||
'assistant.card.todo.noDueDate': 'Son tarih yok',
|
||||
'assistant.card.todo.notDone': 'Yapılmadı',
|
||||
'assistant.step.packingItems': 'Paket listesi okunuyor',
|
||||
'assistant.step.packingBags': 'Çantalar okunuyor',
|
||||
'assistant.step.packingAssignees': 'Kimin neyi paketlediği okunuyor',
|
||||
'assistant.step.packingTemplates': 'Paket şablonları okunuyor',
|
||||
'assistant.step.todoAssignees': 'Hangi listeyle kimin ilgilendiği okunuyor',
|
||||
'assistant.step.tourPacking': 'Turun paket listesi okunuyor',
|
||||
'assistant.tool.bulk_import_packing': 'Paket listesine birkaç eşya ekle',
|
||||
'assistant.tool.apply_packing_template': 'Paket şablonu ekle',
|
||||
'assistant.tool.set_packing_item_sharing': 'Eşyanın kimin için olduğunu değiştir',
|
||||
'assistant.tool.set_packing_category_assignees': 'Bu kategoriyi kimin paketleyeceğini seç',
|
||||
'assistant.tool.reorder_packing_items': 'Paket listesini yeniden sırala',
|
||||
'assistant.tool.create_packing_bag': 'Çanta ekle',
|
||||
'assistant.tool.update_packing_bag': 'Çantayı değiştir',
|
||||
'assistant.tool.delete_packing_bag': 'Çantayı sil',
|
||||
'assistant.tool.set_bag_members': 'Çantayı kimin paketleyeceğini seç',
|
||||
'assistant.tool.save_packing_template': 'Paket şablonu olarak kaydet',
|
||||
'assistant.tool.delete_packing_template': 'Paket şablonunu sil',
|
||||
'assistant.tool.set_todo_category_assignees': 'Listeyle kimin ilgileneceğini seç',
|
||||
'assistant.tool.reorder_todos': 'Yapılacakları yeniden sırala',
|
||||
'assistant.tool.link_tour_packing': 'Tura bir paket listesi bağla',
|
||||
'assistant.tool.unlink_tour_packing': 'Turun paket listesi bağlantısını kaldır',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,75 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Список речей',
|
||||
'assistant.card.packing.bagsTitle': 'Сумки',
|
||||
'assistant.card.packing.forTour': 'Речі для цього маршруту',
|
||||
'assistant.card.packing.templateAdds': 'Додасть {count} речі з шаблону',
|
||||
'assistant.card.packing.templateAdds.one': 'Додасть {count} річ із шаблону',
|
||||
'assistant.card.packing.templateAdds.few': 'Додасть {count} речі з шаблону',
|
||||
'assistant.card.packing.templateAdds.many': 'Додасть {count} речей із шаблону',
|
||||
'assistant.card.packing.moreCategories': 'Ще {count} категорії',
|
||||
'assistant.card.packing.moreCategories.one': 'Ще {count} категорія',
|
||||
'assistant.card.packing.moreCategories.few': 'Ще {count} категорії',
|
||||
'assistant.card.packing.moreCategories.many': 'Ще {count} категорій',
|
||||
'assistant.card.packing.moreBags': 'Ще {count} сумки',
|
||||
'assistant.card.packing.moreBags.one': 'Ще {count} сумка',
|
||||
'assistant.card.packing.moreBags.few': 'Ще {count} сумки',
|
||||
'assistant.card.packing.moreBags.many': 'Ще {count} сумок',
|
||||
'assistant.card.packing.ofLimit': '{weight} з {limit}',
|
||||
'assistant.card.packing.overLimit': 'Понад ліміт',
|
||||
'assistant.card.packing.movesOut': '{count} речі перейдуть у «Не призначено»',
|
||||
'assistant.card.packing.movesOut.one': '{count} річ перейде у «Не призначено»',
|
||||
'assistant.card.packing.movesOut.few': '{count} речі перейдуть у «Не призначено»',
|
||||
'assistant.card.packing.movesOut.many': '{count} речей перейдуть у «Не призначено»',
|
||||
'assistant.card.packing.newBag': 'Нова сумка',
|
||||
'assistant.card.packing.packed': 'Спаковано',
|
||||
'assistant.card.packing.notPacked': 'Не спаковано',
|
||||
'assistant.card.packing.sharedWith': 'Спільне з: {names}',
|
||||
'assistant.card.packing.tierCommon': 'Спільний список',
|
||||
'assistant.card.packing.tierPersonal': 'Лише для мене',
|
||||
'assistant.card.packing.noWeight': 'Без ваги',
|
||||
'assistant.card.packing.noLimit': 'Без ліміту',
|
||||
'assistant.card.todo.title': 'Завдання',
|
||||
'assistant.card.todo.open': 'Відкрити список завдань',
|
||||
'assistant.card.todo.openCount': '{count} відкритого',
|
||||
'assistant.card.todo.openCount.one': '{count} відкрите',
|
||||
'assistant.card.todo.openCount.few': '{count} відкриті',
|
||||
'assistant.card.todo.openCount.many': '{count} відкритих',
|
||||
'assistant.card.todo.overdueCount': '{count} простроченого',
|
||||
'assistant.card.todo.overdueCount.one': '{count} прострочене',
|
||||
'assistant.card.todo.overdueCount.few': '{count} прострочені',
|
||||
'assistant.card.todo.overdueCount.many': '{count} прострочених',
|
||||
'assistant.card.todo.moreTasks': 'Ще {count} завдання',
|
||||
'assistant.card.todo.moreTasks.one': 'Ще {count} завдання',
|
||||
'assistant.card.todo.moreTasks.few': 'Ще {count} завдання',
|
||||
'assistant.card.todo.moreTasks.many': 'Ще {count} завдань',
|
||||
'assistant.card.todo.moreLists': 'Ще {count} списку',
|
||||
'assistant.card.todo.moreLists.one': 'Ще {count} список',
|
||||
'assistant.card.todo.moreLists.few': 'Ще {count} списки',
|
||||
'assistant.card.todo.moreLists.many': 'Ще {count} списків',
|
||||
'assistant.card.todo.noDueDate': 'Без терміну',
|
||||
'assistant.card.todo.notDone': 'Не виконано',
|
||||
'assistant.step.packingItems': 'Читаю список речей',
|
||||
'assistant.step.packingBags': 'Читаю сумки',
|
||||
'assistant.step.packingAssignees': 'Читаю, хто що бере',
|
||||
'assistant.step.packingTemplates': 'Читаю шаблони збирання',
|
||||
'assistant.step.todoAssignees': 'Читаю, хто відповідає за який список',
|
||||
'assistant.step.tourPacking': 'Читаю список речей маршруту',
|
||||
'assistant.tool.bulk_import_packing': 'Додати кілька речей до списку',
|
||||
'assistant.tool.apply_packing_template': 'Додати шаблон збирання',
|
||||
'assistant.tool.set_packing_item_sharing': 'Змінити, для кого річ',
|
||||
'assistant.tool.set_packing_category_assignees': 'Вибрати, хто збирає цю категорію',
|
||||
'assistant.tool.reorder_packing_items': 'Змінити порядок списку речей',
|
||||
'assistant.tool.create_packing_bag': 'Додати сумку',
|
||||
'assistant.tool.update_packing_bag': 'Змінити сумку',
|
||||
'assistant.tool.delete_packing_bag': 'Видалити сумку',
|
||||
'assistant.tool.set_bag_members': 'Вибрати, хто збирає сумку',
|
||||
'assistant.tool.save_packing_template': 'Зберегти як шаблон збирання',
|
||||
'assistant.tool.delete_packing_template': 'Видалити шаблон збирання',
|
||||
'assistant.tool.set_todo_category_assignees': 'Вибрати, хто відповідає за список',
|
||||
'assistant.tool.reorder_todos': 'Змінити порядок завдань',
|
||||
'assistant.tool.link_tour_packing': "Прив'язати список речей до маршруту",
|
||||
'assistant.tool.unlink_tour_packing': "Відв'язати список речей від маршруту",
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': 'Danh sách đóng gói',
|
||||
'assistant.card.packing.bagsTitle': 'Túi',
|
||||
'assistant.card.packing.forTour': 'Đồ cho chuyến đi này',
|
||||
'assistant.card.packing.templateAdds': 'Thêm {count} món từ mẫu',
|
||||
'assistant.card.packing.moreCategories': 'Thêm {count} danh mục',
|
||||
'assistant.card.packing.moreBags': 'Thêm {count} túi',
|
||||
'assistant.card.packing.ofLimit': '{weight} trên {limit}',
|
||||
'assistant.card.packing.overLimit': 'Vượt giới hạn',
|
||||
'assistant.card.packing.movesOut': '{count} món chuyển sang Chưa được chỉ định',
|
||||
'assistant.card.packing.newBag': 'Túi mới',
|
||||
'assistant.card.packing.packed': 'Đã đóng gói',
|
||||
'assistant.card.packing.notPacked': 'Chưa đóng gói',
|
||||
'assistant.card.packing.sharedWith': 'Chia sẻ với {names}',
|
||||
'assistant.card.packing.tierCommon': 'Danh sách chung',
|
||||
'assistant.card.packing.tierPersonal': 'Chỉ cho tôi',
|
||||
'assistant.card.packing.noWeight': 'Không có cân nặng',
|
||||
'assistant.card.packing.noLimit': 'Không giới hạn',
|
||||
'assistant.card.todo.title': 'Việc cần làm',
|
||||
'assistant.card.todo.open': 'Mở danh sách việc cần làm',
|
||||
'assistant.card.todo.openCount': '{count} đang mở',
|
||||
'assistant.card.todo.overdueCount': '{count} quá hạn',
|
||||
'assistant.card.todo.moreTasks': 'Thêm {count} việc',
|
||||
'assistant.card.todo.moreLists': 'Thêm {count} danh sách',
|
||||
'assistant.card.todo.noDueDate': 'Không có hạn',
|
||||
'assistant.card.todo.notDone': 'Chưa xong',
|
||||
'assistant.step.packingItems': 'Đang đọc danh sách đóng gói',
|
||||
'assistant.step.packingBags': 'Đang đọc các túi',
|
||||
'assistant.step.packingAssignees': 'Đang đọc ai mang gì',
|
||||
'assistant.step.packingTemplates': 'Đang đọc các mẫu đóng gói',
|
||||
'assistant.step.todoAssignees': 'Đang đọc ai phụ trách danh sách nào',
|
||||
'assistant.step.tourPacking': 'Đang đọc danh sách đóng gói của chuyến đi',
|
||||
'assistant.tool.bulk_import_packing': 'Thêm nhiều món vào danh sách đóng gói',
|
||||
'assistant.tool.apply_packing_template': 'Thêm mẫu đóng gói',
|
||||
'assistant.tool.set_packing_item_sharing': 'Đổi người nhận món đồ',
|
||||
'assistant.tool.set_packing_category_assignees': 'Chọn ai đóng gói danh mục này',
|
||||
'assistant.tool.reorder_packing_items': 'Sắp xếp lại danh sách đóng gói',
|
||||
'assistant.tool.create_packing_bag': 'Thêm túi',
|
||||
'assistant.tool.update_packing_bag': 'Sửa túi',
|
||||
'assistant.tool.delete_packing_bag': 'Xóa túi',
|
||||
'assistant.tool.set_bag_members': 'Chọn ai đóng túi',
|
||||
'assistant.tool.save_packing_template': 'Lưu thành mẫu đóng gói',
|
||||
'assistant.tool.delete_packing_template': 'Xóa mẫu đóng gói',
|
||||
'assistant.tool.set_todo_category_assignees': 'Chọn ai phụ trách danh sách',
|
||||
'assistant.tool.reorder_todos': 'Sắp xếp lại việc cần làm',
|
||||
'assistant.tool.link_tour_packing': 'Liên kết danh sách đóng gói với chuyến đi',
|
||||
'assistant.tool.unlink_tour_packing': 'Bỏ liên kết danh sách đóng gói của chuyến đi',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': '行李清單',
|
||||
'assistant.card.packing.bagsTitle': '行李袋',
|
||||
'assistant.card.packing.forTour': '這次路線的行李',
|
||||
'assistant.card.packing.templateAdds': '從範本新增 {count} 件',
|
||||
'assistant.card.packing.moreCategories': '另外 {count} 個類別',
|
||||
'assistant.card.packing.moreBags': '另外 {count} 個行李袋',
|
||||
'assistant.card.packing.ofLimit': '{weight} / {limit}',
|
||||
'assistant.card.packing.overLimit': '超出限額',
|
||||
'assistant.card.packing.movesOut': '{count} 件將移到未分配',
|
||||
'assistant.card.packing.newBag': '新行李袋',
|
||||
'assistant.card.packing.packed': '已打包',
|
||||
'assistant.card.packing.notPacked': '未打包',
|
||||
'assistant.card.packing.sharedWith': '與 {names} 共用',
|
||||
'assistant.card.packing.tierCommon': '共用清單',
|
||||
'assistant.card.packing.tierPersonal': '僅限自己',
|
||||
'assistant.card.packing.noWeight': '無重量',
|
||||
'assistant.card.packing.noLimit': '無限額',
|
||||
'assistant.card.todo.title': '待辦事項',
|
||||
'assistant.card.todo.open': '開啟待辦清單',
|
||||
'assistant.card.todo.openCount': '{count} 項未完成',
|
||||
'assistant.card.todo.overdueCount': '{count} 項已逾期',
|
||||
'assistant.card.todo.moreTasks': '另外 {count} 項任務',
|
||||
'assistant.card.todo.moreLists': '另外 {count} 個清單',
|
||||
'assistant.card.todo.noDueDate': '無截止日期',
|
||||
'assistant.card.todo.notDone': '未完成',
|
||||
'assistant.step.packingItems': '正在讀取行李清單',
|
||||
'assistant.step.packingBags': '正在讀取行李袋',
|
||||
'assistant.step.packingAssignees': '正在讀取誰帶什麼',
|
||||
'assistant.step.packingTemplates': '正在讀取行李範本',
|
||||
'assistant.step.todoAssignees': '正在讀取誰負責哪個清單',
|
||||
'assistant.step.tourPacking': '正在讀取路線的行李清單',
|
||||
'assistant.tool.bulk_import_packing': '在行李清單新增多件物品',
|
||||
'assistant.tool.apply_packing_template': '新增行李範本',
|
||||
'assistant.tool.set_packing_item_sharing': '變更物品歸屬',
|
||||
'assistant.tool.set_packing_category_assignees': '選擇誰負責打包此類別',
|
||||
'assistant.tool.reorder_packing_items': '重新排列行李清單',
|
||||
'assistant.tool.create_packing_bag': '新增行李袋',
|
||||
'assistant.tool.update_packing_bag': '修改行李袋',
|
||||
'assistant.tool.delete_packing_bag': '刪除行李袋',
|
||||
'assistant.tool.set_bag_members': '選擇誰負責打包此行李袋',
|
||||
'assistant.tool.save_packing_template': '另存為行李範本',
|
||||
'assistant.tool.delete_packing_template': '刪除行李範本',
|
||||
'assistant.tool.set_todo_category_assignees': '選擇誰負責此清單',
|
||||
'assistant.tool.reorder_todos': '重新排列待辦事項',
|
||||
'assistant.tool.link_tour_packing': '將行李清單連結到路線',
|
||||
'assistant.tool.unlink_tour_packing': '取消路線與行李清單的連結',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantPacking: TranslationStrings = {};
|
||||
const assistantPacking: TranslationStrings = {
|
||||
'assistant.card.packing.title': '行李清单',
|
||||
'assistant.card.packing.bagsTitle': '行李袋',
|
||||
'assistant.card.packing.forTour': '这次路线的行李',
|
||||
'assistant.card.packing.templateAdds': '从模板添加 {count} 件',
|
||||
'assistant.card.packing.moreCategories': '另外 {count} 个类别',
|
||||
'assistant.card.packing.moreBags': '另外 {count} 个行李袋',
|
||||
'assistant.card.packing.ofLimit': '{weight} / {limit}',
|
||||
'assistant.card.packing.overLimit': '超出限额',
|
||||
'assistant.card.packing.movesOut': '{count} 件将移到未分配',
|
||||
'assistant.card.packing.newBag': '新行李袋',
|
||||
'assistant.card.packing.packed': '已打包',
|
||||
'assistant.card.packing.notPacked': '未打包',
|
||||
'assistant.card.packing.sharedWith': '与 {names} 共享',
|
||||
'assistant.card.packing.tierCommon': '公共清单',
|
||||
'assistant.card.packing.tierPersonal': '仅限自己',
|
||||
'assistant.card.packing.noWeight': '无重量',
|
||||
'assistant.card.packing.noLimit': '无限额',
|
||||
'assistant.card.todo.title': '待办事项',
|
||||
'assistant.card.todo.open': '打开待办清单',
|
||||
'assistant.card.todo.openCount': '{count} 项未完成',
|
||||
'assistant.card.todo.overdueCount': '{count} 项已逾期',
|
||||
'assistant.card.todo.moreTasks': '另外 {count} 项任务',
|
||||
'assistant.card.todo.moreLists': '另外 {count} 个清单',
|
||||
'assistant.card.todo.noDueDate': '无截止日期',
|
||||
'assistant.card.todo.notDone': '未完成',
|
||||
'assistant.step.packingItems': '正在读取行李清单',
|
||||
'assistant.step.packingBags': '正在读取行李袋',
|
||||
'assistant.step.packingAssignees': '正在读取谁带什么',
|
||||
'assistant.step.packingTemplates': '正在读取行李模板',
|
||||
'assistant.step.todoAssignees': '正在读取谁负责哪个清单',
|
||||
'assistant.step.tourPacking': '正在读取路线的行李清单',
|
||||
'assistant.tool.bulk_import_packing': '向行李清单添加多件物品',
|
||||
'assistant.tool.apply_packing_template': '添加行李模板',
|
||||
'assistant.tool.set_packing_item_sharing': '更改物品归属',
|
||||
'assistant.tool.set_packing_category_assignees': '选择谁负责打包此类别',
|
||||
'assistant.tool.reorder_packing_items': '重新排列行李清单',
|
||||
'assistant.tool.create_packing_bag': '添加行李袋',
|
||||
'assistant.tool.update_packing_bag': '修改行李袋',
|
||||
'assistant.tool.delete_packing_bag': '删除行李袋',
|
||||
'assistant.tool.set_bag_members': '选择谁负责打包此行李袋',
|
||||
'assistant.tool.save_packing_template': '另存为行李模板',
|
||||
'assistant.tool.delete_packing_template': '删除行李模板',
|
||||
'assistant.tool.set_todo_category_assignees': '选择谁负责此清单',
|
||||
'assistant.tool.reorder_todos': '重新排列待办事项',
|
||||
'assistant.tool.link_tour_packing': '将行李清单关联到路线',
|
||||
'assistant.tool.unlink_tour_packing': '取消路线与行李清单的关联',
|
||||
};
|
||||
export default assistantPacking;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { idSchema } from '../common/primitives.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
@@ -196,3 +198,17 @@ export const packingCategoryAssigneesRequestSchema = z.object({
|
||||
user_ids: z.array(z.number()),
|
||||
});
|
||||
export type PackingCategoryAssigneesRequest = z.infer<typeof packingCategoryAssigneesRequestSchema>;
|
||||
|
||||
/**
|
||||
* What the assistant's `list_packing_items` reads: the items of the trip's
|
||||
* packing list the caller may see, narrowed to one category, one bag (null:
|
||||
* the items in no bag) or a packed state. `group_by` filters nothing; it says
|
||||
* how the answer is meant to be shown, the way the packing list groups it.
|
||||
*/
|
||||
export const packingListItemsRequestSchema = z.object({
|
||||
category: z.string().min(1).max(100).optional(),
|
||||
bag_id: idSchema.nullable().optional(),
|
||||
status: z.enum(['all', 'open', 'packed']).optional(),
|
||||
group_by: z.enum(['category', 'bag']).optional(),
|
||||
});
|
||||
export type PackingListItemsRequest = z.infer<typeof packingListItemsRequestSchema>;
|
||||
|
||||
Reference in New Issue
Block a user