diff --git a/client/src/components/Assistant/cards/packing/PackingChangeRun.test.tsx b/client/src/components/Assistant/cards/packing/PackingChangeRun.test.tsx new file mode 100644 index 000000000..353879ed7 --- /dev/null +++ b/client/src/components/Assistant/cards/packing/PackingChangeRun.test.tsx @@ -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 => ({ + 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 = {}) { + const value: AssistantView = { tripId: 4, variant: 'window', ...view }; + return render({ui}); +} + +describe('PackingChangeRun', () => { + it("FE-AI-PACKRUN-001: each category's head shows once, a row per change with what it does", () => { + inView(); + 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(); + 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(, { + variant: 'fullscreen', + }); + const choices = screen.getAllByRole('button', { name: 'Include this change' }); + expect(choices).toHaveLength(2); + expect(choices[0]).toHaveClass('h-8', 'w-8'); + }); +}); diff --git a/client/src/components/Assistant/cards/packing/PackingListCard.test.tsx b/client/src/components/Assistant/cards/packing/PackingListCard.test.tsx new file mode 100644 index 000000000..0c48f29de --- /dev/null +++ b/client/src/components/Assistant/cards/packing/PackingListCard.test.tsx @@ -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 => ({ + id, + name, + quantity: 1, + packed: 0, + checked: false, + weightGrams: null, + bagKey: null, + tier: 'common', + sharedWith: [], + change: 'none', + changed: [], + ...over, +}); + +const bag = (over: Partial = {}): 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 = {}) { + const value: AssistantView = { tripId: 4, variant: 'window', ...view }; + return render({ui}); +} + +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(); + 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(); + 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(, { 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(); + 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(); + 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(); + 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(); + expect(screen.getByText('2')).toHaveClass('line-through'); + expect(screen.getByText('3')).toBeInTheDocument(); + const { container } = inView(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.queryByRole('img', { name: 'Unassigned' })).not.toBeInTheDocument(); + expect(screen.queryByRole('img', { name: 'Carry-on' })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Assistant/cards/packing/PackingListCard.tsx b/client/src/components/Assistant/cards/packing/PackingListCard.tsx new file mode 100644 index 000000000..f9d30f540 --- /dev/null +++ b/client/src/components/Assistant/cards/packing/PackingListCard.tsx @@ -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 ( +
+ {card.groups.map((group) => ( + + ))} +
+ ); +} + +/** 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 ( +
+ +
+

+ {title} +

+ {caption && ( +

+ {caption} +

+ )} +
+ {card.progress && total > 0 && packed === total && ( + }> + {t('tours.packing.allPacked')} + + )} +
+ ); +} + +/** One answer card. */ +function PackingAnswer({ card }: { card: PackingCard }) { + const title = useTitle(card); + const progress = card.progress; + return ( + +
+ + {progress && progress.total > 0 && } + + +
+
+ ); +} + +/** 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 ( +
+ {lists.map((card, i) => ( + + ))} +
+ ); +} + +/** 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 ; + const { bags } = bagsOf(card); + const category = group.key.by === 'category' ? (group.key.name ?? '') : ''; + return ( + + + + + ); +} + +/** 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 ( +
+ {source.type === 'template' && card.groups.length === 0 && ( +

+ {t('assistant.card.packing.templateAdds', { count: source.itemCount })} +

+ )} + {source.type === 'tour' && ( +

{t('assistant.card.packing.forTour')}

+ )} + +
+ ); +} + +/** 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: , + name: row.name, + facts: ( + <> + + + + ), + }; +} + +/** + * 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 ; +} diff --git a/client/src/components/Assistant/cards/packing/TodoListCard.test.tsx b/client/src/components/Assistant/cards/packing/TodoListCard.test.tsx new file mode 100644 index 000000000..48b9e7935 --- /dev/null +++ b/client/src/components/Assistant/cards/packing/TodoListCard.test.tsx @@ -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 => ({ + 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 = {}) { + const value: AssistantView = { tripId: 4, variant: 'window', ...view }; + return render({ui}); +} + +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(); + 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(); + 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(, { 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(); + 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(, { 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(); + 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(); + 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'); + }); +}); diff --git a/client/src/components/Assistant/cards/packing/TodoListCard.tsx b/client/src/components/Assistant/cards/packing/TodoListCard.tsx new file mode 100644 index 000000000..08bc58a91 --- /dev/null +++ b/client/src/components/Assistant/cards/packing/TodoListCard.tsx @@ -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> = { 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 = ( + + ); + const dayId = row.dueDayId; + if (!onOpen || dayId === null) return pill; + const hint = t('assistant.card.common.showDay'); + return ( + + + + ); +} + +/** + * 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 && ( + + )} + {!moved.has('dueDate') && } + + ); +} + +/** 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 ; + if (row.change === 'remove') return ; + if (row.change === 'check') return {t('todo.filter.done')}; + if (row.change === 'uncheck') return {t('assistant.card.todo.notDone')}; + 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) => ( + + ))} + + ); +} + +/** 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 && ( +

+ {row.note} +

+ )} + + + {inChange && row.change !== 'none' && row.change !== 'add' && } + + + ); + return ( + : 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 = ( + + {group.open} + + ); + return ( +
+ + {inChange && group.assigneesRemoved.length > 0 && ( +

+ person.name)} /> +

+ )} + {group.items.length > 0 && ( +
    + {group.items.map((row, i) => ( + + ))} +
+ )} + {group.items.length > 0 && ( + 0 ? t('assistant.card.todo.moreTasks', { count: hidden }) : null} /> + )} +
+ ); +} + +/** 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 ( + +
+
+ +

{title}

+ {progress && {t('assistant.card.todo.openCount', { count: progress.open })}} + {progress && progress.overdue > 0 && ( + {t('assistant.card.todo.overdueCount', { count: progress.overdue })} + )} +
+
+ {card.groups.map((group) => ( + + ))} +
+ +
+
+ ); +} + +/** 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 ( +
+ {lists.map((card, i) => ( + + ))} +
+ ); +} + +/** 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 ; + return ( + + + + + ); +} + +/** 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 ( +
+ {card.groups.map((group) => ( + + ))} +
+ ); +} + +/** 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: , + name: row.name, + facts: ( + <> + {row.assignee && } + + + + ), + }; +} + +/** Two or more waiting changes of one task each, as one checklist under their lists' heads. */ +export function TodoChangeRun(props: RunProps) { + return ; +} diff --git a/client/src/components/Assistant/cards/packing/checklistModel.test.ts b/client/src/components/Assistant/cards/packing/checklistModel.test.ts new file mode 100644 index 000000000..1d9292c06 --- /dev/null +++ b/client/src/components/Assistant/cards/packing/checklistModel.test.ts @@ -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 => ({ + 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 => ({ + 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'); + }); +}); diff --git a/client/src/components/Assistant/cards/packing/checklistModel.ts b/client/src/components/Assistant/cards/packing/checklistModel.ts new file mode 100644 index 000000000..1a777639c --- /dev/null +++ b/client/src/components/Assistant/cards/packing/checklistModel.ts @@ -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(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(); + for (const group of [...a.groups, ...b.groups]) { + const key = packingGroupKey(group); + const before = groups.get(key); + const items = before ? joinRows(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(); + 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(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> = { + 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' }, +}; diff --git a/client/src/components/Assistant/cards/packing/checklistParts.tsx b/client/src/components/Assistant/cards/packing/checklistParts.tsx new file mode 100644 index 000000000..8c00f27bb --- /dev/null +++ b/client/src/components/Assistant/cards/packing/checklistParts.tsx @@ -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 ( +
+ {lead ?? } + + {name} + + {badge} + {(people.length > 0 || removed.length > 0) && ( + + + {/* The people a change takes off, faded; the card says "No longer" in words under the head. */} + {removed.length > 0 && ( + + + + )} + + )} +
+ ); +} + +/** 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 ( +
  • + + + +
    +
    + + {name} + + {aside && {aside}} +
    + {under} +
    +
  • + ); +} + +/** "+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

    {label}

    ; +} + +/** 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 ( +
    + + + + +
    + ); +} + +/** 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 ( +
    + + + + + {entry.facts} + +
    + ); +} + +/** What a run of changes gets from the change card. */ +export interface RunProps { + items: readonly ChangeItem[]; + excluded: ReadonlySet; + 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 ( +
    + {entries.map((entry, i) => ( +
    + {entries[i - 1]?.headKey !== entry.headKey && entry.head} + +
    + ))} +
    + ); +} + +/** A row's name in a run: struck through for one a change removes. */ +function RunName({ name, removed }: { name: string; removed: boolean }) { + return ( + + {name} + + ); +} diff --git a/client/src/components/Assistant/cards/packing/packingLook.ts b/client/src/components/Assistant/cards/packing/packingLook.ts index b22119208..dedf79738 100644 --- a/client/src/components/Assistant/cards/packing/packingLook.ts +++ b/client/src/components/Assistant/cards/packing/packingLook.ts @@ -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'>; diff --git a/client/src/components/Assistant/cards/packing/packingParts.tsx b/client/src/components/Assistant/cards/packing/packingParts.tsx new file mode 100644 index 000000000..d6e11d40b --- /dev/null +++ b/client/src/components/Assistant/cards/packing/packingParts.tsx @@ -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 ( + + ); + } + return ( + + + + ); +} + +/** + * 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 && ×{row.quantity}} + {partial && {`${row.packed}/${row.quantity}`}} + {row.weightGrams !== null && {formatWeight(row.weightGrams)}} + {row.tier === 'personal' && ( + + + + + )} + {row.tier === 'shared' && } + {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; + 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 : ; + if (row.change === 'remove') return ; + if (row.change === 'check') return {t('assistant.card.packing.packed')}; + if (row.change === 'uncheck') return {t('assistant.card.packing.notPacked')}; + 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) => ( + + ))} + + ); +} + +/** 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; + category: string; + inChange: boolean; +}) { + const bag = row.bagKey ? bags.get(row.bagKey) : undefined; + const facts = + inChange && row.change !== 'none' ? ( + + + + ) : null; + return ( + } + 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 ( +