diff --git a/client/scripts/layers-baseline.json b/client/scripts/layers-baseline.json index 9d91b2ba3..9da60c45b 100644 --- a/client/scripts/layers-baseline.json +++ b/client/scripts/layers-baseline.json @@ -12,7 +12,6 @@ "src/components/Collections/CollectionPlaceDetail.tsx": 1, "src/components/Collections/ListEditorModal.tsx": 1, "src/components/Collections/MSaveToCollectionSheet.tsx": 3, - "src/components/Collections/StatusBadge.tsx": 1, "src/components/Dawarich/DawarichAcceptDialog.tsx": 3, "src/components/Dawarich/DawarichAtlasDialog.tsx": 3, "src/components/Dawarich/DawarichSuggestionsPanel.tsx": 1, diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json index 974ea3fcd..6a4511b5b 100644 --- a/client/scripts/theme-baseline.json +++ b/client/scripts/theme-baseline.json @@ -312,7 +312,6 @@ "pages/TripPlannerPage.tsx": 7, "pages/VacayPage.tsx": 13, "pages/atlas/useAtlas.ts": 2, - "pages/collections/collectionsModel.ts": 1, "pages/collections/useCollections.ts": 1, "pages/sharedTrip/SharedChrome.tsx": 2, "pages/sharedTrip/SharedMap.tsx": 1, diff --git a/client/src/components/Assistant/cards/collection/CollectionCardBlocks.test.tsx b/client/src/components/Assistant/cards/collection/CollectionCardBlocks.test.tsx new file mode 100644 index 000000000..ca0e3fb5c --- /dev/null +++ b/client/src/components/Assistant/cards/collection/CollectionCardBlocks.test.tsx @@ -0,0 +1,147 @@ +/** + * FE-AI-COLL-010 to FE-AI-COLL-017 + * + * The answer cards of the collections as the chat draws them: a list's head + * with its size, people and places per status, its saved places as rows + * (status, labels, rating, the trip having them), the rest as a way to the + * list, the map pin a row lights and where a click takes it, the phone + * without hover, and the lists card with invites and where a place is saved. + */ +import type { AssistantMessage, TrekCard } from '@trek/shared'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { listRow, listsCard, placesCard, savedPlace } from '../../../../../tests/helpers/collectionCards'; +import { fireEvent, render, screen, within } from '../../../../../tests/helpers/render'; +import { useAssistantStore } from '../../../../store/assistantStore'; +import type { CardsBlock } from '../../assistantParts'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; +import AssistantCardBlock from '../AssistantCardBlock'; + +const INITIAL = useAssistantStore.getState(); + +afterEach(() => { + useAssistantStore.setState(INITIAL, true); + vi.restoreAllMocks(); +}); + +const spots = (count: number) => + Array.from({ length: count }, (_, i) => savedPlace({ id: 100 + i, name: `Spot ${i}`, labelIds: [] })); + +function draw(cards: TrekCard[], view: Partial = {}) { + const value: AssistantView = { tripId: 4, variant: 'window', density: 'compact', onOpen: vi.fn(), ...view }; + const block: CardsBlock = { + kind: 'cards', + key: 'cards-1', + group: cards[0]?.kind ?? 'none', + entries: cards.map((card, index) => ({ toolCallId: `call_${index}`, card })), + }; + const message = { id: 'msg-1', role: 'assistant', parts: [] } as unknown as AssistantMessage; + render( + + + + ); + return value; +} + +describe('collection answer cards', () => { + it('FE-AI-COLL-010: a list heads its card with its size, its people, its places per status and the way to it', () => { + const view = draw([placesCard()]); + const card = screen.getByRole('region', { name: 'Saved places in Paris food' }); + expect(within(card).getByText('Paris food')).toBeInTheDocument(); + expect(within(card).getByText('12 places')).toBeInTheDocument(); + expect(within(card).getByText('1 member')).toBeInTheDocument(); + expect(within(card).getByText('Idea')).toBeInTheDocument(); + expect(within(card).getByText('3')).toBeInTheDocument(); + fireEvent.click(within(card).getByRole('button', { name: /Open list/ })); + expect(view.onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/collections/12' }); + }); + + it('FE-AI-COLL-011: a saved place shows its status, two labels and the rest counted, its rating and the trip having it', () => { + draw([placesCard({ places: [savedPlace({ inTripPlaceId: 6 })] })]); + expect(screen.getByText('Septime')).toBeInTheDocument(); + expect(screen.getByText('80 Rue de Charonne')).toBeInTheDocument(); + expect(screen.getAllByText('Want to go').length).toBeGreaterThan(0); + expect(screen.getByText('Dinner')).toBeInTheDocument(); + expect(screen.getByText('Late')).toBeInTheDocument(); + expect(screen.queryByText('Terrace')).toBeNull(); + expect(screen.getByText('+1')).toBeInTheDocument(); + expect(screen.getByText('4.5')).toBeInTheDocument(); + expect(screen.getByText('Already in your trip')).toBeInTheDocument(); + }); + + it('FE-AI-COLL-012: a narrow chat shows five rows and counts the rest as a way to the list; a roomy one eight', () => { + const view = draw([placesCard({ places: spots(8), more: 4 })]); + expect(screen.getByText('Spot 4')).toBeInTheDocument(); + expect(screen.queryByText('Spot 5')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '7 more in this list' })); + expect(view.onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/collections/12' }); + }); + + it('FE-AI-COLL-013: a roomy chat shows eight rows', () => { + draw([placesCard({ places: spots(8) })], { density: 'roomy' }); + expect(screen.getByText('Spot 7')).toBeInTheDocument(); + expect(screen.queryByText(/more in this list/)).toBeNull(); + }); + + it('FE-AI-COLL-014: resting on a row pins it on the map; a click selects the trip place or moves the map to the saved one', () => { + const requestCamera = vi.fn(); + useAssistantStore.setState({ requestCamera }); + const onRevealMap = vi.fn(); + const inTrip = savedPlace({ id: 34, name: 'Septime', inTripPlaceId: 6, labelIds: [] }); + const elsewhere = savedPlace({ id: 35, name: 'Le Comptoir', lat: 48.85, lng: 2.33, labelIds: [] }); + const view = draw([placesCard({ places: [inTrip, elsewhere] })], { onRevealMap }); + const row = screen.getByRole('button', { name: /Septime/ }); + fireEvent.mouseEnter(row); + expect(useAssistantStore.getState().preview).toMatchObject({ ref: 'col:34', placeId: 6, lat: 48.853 }); + fireEvent.mouseLeave(row); + expect(useAssistantStore.getState().preview).toBeNull(); + fireEvent.click(row); + expect(view.onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 6, dayId: null }); + fireEvent.click(screen.getByRole('button', { name: /Le Comptoir/ })); + expect(onRevealMap).toHaveBeenCalled(); + expect(requestCamera).toHaveBeenCalledWith(4, [[48.85, 2.33]], 'pan', 'user'); + }); + + it('FE-AI-COLL-015: on the phone a row does not pin on hover, and a place without a position is no button', () => { + draw([placesCard({ places: [savedPlace({ lat: null, lng: null, labelIds: [] })] })], { variant: 'fullscreen' }); + expect(screen.queryByRole('button', { name: /Septime/ })).toBeNull(); + fireEvent.mouseEnter(screen.getByText('Septime')); + expect(useAssistantStore.getState().preview).toBeNull(); + }); + + it('FE-AI-COLL-016: two lists read in one answer share one card, each under its head; one read twice shows once', () => { + const list = { id: 13, name: 'Rome', color: null, placeCount: 2, memberCount: 0, counts: null }; + const rome = placesCard({ list }); + const again = placesCard({ places: [savedPlace({ name: 'Septime again', labelIds: [] })] }); + draw([placesCard(), rome, again]); + const card = screen.getByRole('region', { name: 'Saved places' }); + expect(within(card).getByText('Paris food')).toBeInTheDocument(); + expect(within(card).getByText('Rome')).toBeInTheDocument(); + expect(within(card).getByText('Septime again')).toBeInTheDocument(); + expect(within(card).getAllByText('Paris food')).toHaveLength(1); + }); + + it('FE-AI-COLL-017: the lists card names each list with its size, an invite with who sent it, and where a place is saved', () => { + const person = { id: 4, name: 'Maya', avatarKey: null }; + const view = draw([ + listsCard({ lists: [listRow(), listRow({ id: 40, name: 'Rome', invite: true, person })] }), + listsCard({ lists: [listRow({ name: 'Paris eats' })] }), + ]); + expect(screen.getByText('Your lists')).toBeInTheDocument(); + expect(screen.getByText('Paris eats')).toBeInTheDocument(); + expect(screen.queryByText('Paris food')).toBeNull(); + expect(screen.getByText('Invite from Maya')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Rome/ })); + expect(view.onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/collections/40' }); + }); + + it('FE-AI-COLL-018: where a place is saved reads as its lists with its status in each', () => { + const lists = [listRow({ status: 'visited' }), listRow({ id: 13, name: 'Later', status: 'idea' })]; + draw([listsCard({ place: 'Septime', lists })]); + expect(screen.getByText('Septime')).toBeInTheDocument(); + expect(screen.getByText('Saved in 2 lists')).toBeInTheDocument(); + expect(screen.getByText('Visited')).toBeInTheDocument(); + expect(screen.getByText('Idea')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Assistant/cards/collection/CollectionCardBlocks.tsx b/client/src/components/Assistant/cards/collection/CollectionCardBlocks.tsx new file mode 100644 index 000000000..2e92dc3fe --- /dev/null +++ b/client/src/components/Assistant/cards/collection/CollectionCardBlocks.tsx @@ -0,0 +1,117 @@ +import type { CollectionListsCard, CollectionPlacesCard } from '@trek/shared'; +import { Bookmark } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { SoftPill, WhiteBadge } from '../../../Planner/planParts'; +import { useAssistantView } from '../../assistantViewContext'; +import type { CardEntry } from '../cardTypes'; +import { CardFacts } from '../parts/CardFacts'; +import CardFrame from '../parts/CardFrame'; +import CardHeader from '../parts/CardHeader'; +import { CardMore, CardRow, CardRows } from '../parts/CardRows'; +import { labelsById, listTarget, mergeListRows, mergePlacesCards, rowsShown } from './collectionCardModel'; +import { ListHead, ListLead, SavedPlaceRow, StatusPill } from './collectionCardParts'; + +/** + * The answer cards of the collections: saved places under their list's head + * (two lists read in one answer sit in one card, one head each), and the + * user's lists with the invites waiting or the lists a place is saved in. + * Window and phone draw the same; the phone has taller rows and no hover. + */ + +function PlacesSection({ card, first }: { card: CollectionPlacesCard; first: boolean }) { + const { density } = useAssistantView(); + const shown = card.places.slice(0, rowsShown(density)); + const labels = labelsById(card.labels); + const more = card.more + card.places.length - shown.length; + return ( +
+ {card.list && } + + {shown.map((place, index) => ( + labels.get(id) ?? [])} + /> + ))} + + +
+ ); +} + +export function CollectionPlacesBlock({ cards }: { cards: readonly CardEntry[] }) { + const { t } = useTranslation(); + const placesCards = mergePlacesCards( + cards.flatMap((entry) => (entry.card.kind === 'collectionPlaces' ? [entry.card] : [])) + ); + const [only] = placesCards; + if (!only) return null; + const label = + placesCards.length === 1 && only.list + ? t('assistant.card.collection.places', { name: only.list.name }) + : t('assistant.card.collection.saved'); + return ( + + {placesCards.map((card, index) => ( + + ))} + + ); +} + +export function CollectionListsBlock({ cards }: { cards: readonly CardEntry[] }) { + const { t } = useTranslation(); + const { onOpen } = useAssistantView(); + const listsCards = cards.flatMap((entry) => (entry.card.kind === 'collectionLists' ? [entry.card] : [])); + const { rows, more } = mergeListRows(listsCards); + if (rows.length === 0) return null; + const place = listsCards.reduce((found, card) => card.place ?? found, null); + const title = place ?? t('assistant.card.collection.lists'); + return ( + + + + {rows.map((row, index) => ( + } + title={ + + {row.name} + + } + aside={row.status ? : undefined} + onActivate={row.id !== null && onOpen ? () => onOpen(listTarget(row.id)) : undefined} + facts={ + + {row.invite && row.person && ( + {t('assistant.card.collection.invite', { name: row.person.name })} + )} + {row.placeCount !== null && ( + {t('collections.placeCount', { count: row.placeCount })} + )} + {row.memberCount > 0 && ( + {t('assistant.card.collection.members', { count: row.memberCount })} + )} + + } + /> + ))} + + + + ); +} diff --git a/client/src/components/Assistant/cards/collection/CollectionChangeSlots.test.tsx b/client/src/components/Assistant/cards/collection/CollectionChangeSlots.test.tsx new file mode 100644 index 000000000..ec4698630 --- /dev/null +++ b/client/src/components/Assistant/cards/collection/CollectionChangeSlots.test.tsx @@ -0,0 +1,183 @@ +/** + * FE-AI-COLL-020 to FE-AI-COLL-031 + * + * The collections inside the change card: a held save with its list, its + * status and the lists that hold the place already; a change of status, of + * labels and of fields; an applied save opened in Collections, a duplicate + * named, a copy into the trip with its map pin and its count; a list deleted + * with what goes; a label renamed; the facts past four counted; trip places + * saved to a list without a warning; and a new order of the lists. + */ +import type { AssistantApplied, AssistantChangePreview, TrekCard } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { emptyDelta, listRow, listsCard, placesCard, savedPlace } from '../../../../../tests/helpers/collectionCards'; +import { fireEvent, render, screen } from '../../../../../tests/helpers/render'; +import { chatOf, useAssistantStore } from '../../../../store/assistantStore'; +import AssistantChangeCard from '../../AssistantChangeCard'; +import type { ChangeItem } from '../../assistantParts'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; + +const TRIP = 4; +const INITIAL = useAssistantStore.getState(); + +type Verb = AssistantChangePreview['verbKey']; + +function preview(tool: string, subject: string, verbKey: Verb = 'genericCreate'): AssistantChangePreview { + return { toolCallId: 'c1', verbKey, subject, rows: [], dayId: null, placeIds: [], undoable: false, tool }; +} + +function pending(tool: string, subject: string, card: TrekCard, verbKey?: Verb): ChangeItem { + return { toolCallId: 'c1', preview: preview(tool, subject, verbKey), applied: null, state: 'pending', card }; +} + +function applied(item: ChangeItem, entity: AssistantApplied['entity'], ids: number[]): ChangeItem { + return { ...item, state: 'applied', applied: { toolCallId: 'c1', entity, ids, dayId: null, undoUntil: null } }; +} + +function draw(items: ChangeItem[], view: Partial = {}) { + const value: AssistantView = { tripId: TRIP, variant: 'window', onOpen: vi.fn(), ...view }; + render( + + + + ); + return value; +} + +beforeEach(() => { + useAssistantStore.setState({ + decide: vi.fn(async () => {}), + byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 1 } }, + }); +}); + +afterEach(() => { + useAssistantStore.setState(INITIAL, true); + vi.clearAllMocks(); +}); + +const SAVE = placesCard({ + places: [savedPlace({ id: null, labelIds: [], savedIn: [{ listId: 13, name: 'Later', status: 'idea' }] })], + labels: [], +}); + +describe('collection change cards', () => { + it('FE-AI-COLL-020: a held save names its list, the status it gets and the list that holds the place already', () => { + draw([pending('save_place_to_collection', 'Septime', SAVE)]); + expect(screen.getByText('Septime')).toBeInTheDocument(); + expect(screen.getByText('Save to a list')).toBeInTheDocument(); + expect(screen.getByText('Paris food')).toBeInTheDocument(); + expect(screen.getByText('Want to go')).toBeInTheDocument(); + expect(screen.getByText('Already in Later')).toBeInTheDocument(); + }); + + it('FE-AI-COLL-021: a change reads its status from old to new, labels added and taken away, and the fields it touches', () => { + const delta = emptyDelta({ + status: { from: 'idea', to: 'visited' }, + labelsAdded: [53], + labelsRemoved: [52], + fields: ['photo'], + }); + const card = placesCard({ list: null, places: [savedPlace({ labelIds: [51, 52], delta })] }); + draw([pending('update_collection_place', 'Septime', card, 'genericUpdate')]); + expect(screen.getByText('Change the saved place')).toBeInTheDocument(); + expect(screen.getByText('Idea')).toHaveClass('line-through'); + expect(screen.getByText('Visited')).toBeInTheDocument(); + expect(screen.getByText('Terrace')).not.toHaveClass('line-through'); + expect(screen.getByText('Late')).toHaveClass('line-through'); + expect(screen.getByText('Photo')).toBeInTheDocument(); + }); + + it('FE-AI-COLL-022: an applied save opens the saved place in its list', () => { + const card = placesCard({ places: [savedPlace({ id: 77 })] }); + const view = draw([applied(pending('save_place_to_collection', 'Septime', card), 'collection_place', [77])]); + expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Open in Collections' })); + expect(view.onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/collections/12?place=77' }); + }); + + it('FE-AI-COLL-023: a save the place matched already says where it is saved', () => { + const card = { ...SAVE, outcome: { type: 'duplicate' as const, name: 'Rodin museum' } }; + draw([applied(pending('save_place_to_collection', 'Septime', card), 'collection_place', [])]); + expect(screen.getByText('Already in Rodin museum')).toBeInTheDocument(); + }); + + it('FE-AI-COLL-024: a copy into the trip shows its new trip place on the map and says how many went in', () => { + const card = placesCard({ + places: [savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir', inTripPlaceId: 6 })], + outcome: { type: 'copied', copied: 1, skipped: 1, skippedNames: ['Le Comptoir'] }, + }); + const copy = pending('copy_collection_places_to_trip', 'Septime, Le Comptoir', card); + const view = draw([applied(copy, 'place', [900])]); + expect(screen.getByText('1 added')).toBeInTheDocument(); + expect(screen.getByText('1 already there')).toBeInTheDocument(); + // The held copy's warning gives way to what the copy did. + expect(screen.queryByText('1 already in the trip')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Show on map' })); + expect(view.onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 900, dayId: null }); + }); + + it('FE-AI-COLL-025: a held copy warns of the places the trip has, and a copy into another trip says so', () => { + const card = placesCard({ + places: [savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir', inTripPlaceId: 6 })], + }); + draw([pending('copy_collection_places_to_trip', 'Septime, Le Comptoir', card)]); + expect(screen.getByText('2 places')).toBeInTheDocument(); + expect(screen.getByText('1 already in the trip')).toBeInTheDocument(); + expect(screen.queryByText('Into another trip')).toBeNull(); + }); + + it('FE-AI-COLL-031: a copy into another trip carries no mark of this trip, only the flag', () => { + const card = placesCard({ places: [savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir' })], otherTrip: true }); + draw([pending('copy_collection_places_to_trip', 'Septime, Le Comptoir', card)]); + expect(screen.getByText('Into another trip')).toBeInTheDocument(); + expect(screen.queryByText(/already in the trip/)).toBeNull(); + }); + + it('FE-AI-COLL-026: a list to delete shows what goes; a renamed label reads from its old name to its new one', () => { + const removal = listsCard({ lists: [listRow({ placeCount: 12, memberCount: 2, change: 'remove' })] }); + draw([pending('delete_collection', 'Paris food', removal, 'genericRemove')]); + expect(screen.getByText('Will be deleted')).toBeInTheDocument(); + expect(screen.getByText('12 places')).toBeInTheDocument(); + expect(screen.getByText('2 members')).toBeInTheDocument(); + }); + + it('FE-AI-COLL-027: a renamed label reads from its old name to its new one with the places that carry it', () => { + const label = { + id: 51, + name: 'Supper', + color: '#2563eb', + places: 3, + change: 'rename' as const, + before: { name: 'Dinner', color: '#2563eb' }, + }; + draw([pending('update_collection_label', 'Supper', placesCard({ places: [], labels: [label] }), 'genericUpdate')]); + expect(screen.getByText('Dinner')).toHaveClass('line-through'); + expect(screen.getAllByText('Supper').length).toBeGreaterThan(0); + expect(screen.getByText('On 3 places')).toBeInTheDocument(); + }); + + it('FE-AI-COLL-028: past four facts the rest is counted', () => { + const delta = emptyDelta({ fields: ['name', 'address', 'notes', 'link', 'photo'] }); + const card = placesCard({ places: [savedPlace({ delta })] }); + draw([pending('update_collection_place', 'Septime', card, 'genericUpdate')]); + expect(screen.getByText('+2')).toBeInTheDocument(); + }); + + it('FE-AI-COLL-029: trip places saved to a list are trip places by nature, so no warning is given', () => { + const louvre = savedPlace({ id: null, name: 'Louvre', inTripPlaceId: 6 }); + const card = placesCard({ places: [savedPlace({ id: null, inTripPlaceId: 5 }), louvre] }); + draw([pending('save_trip_places_to_collection', 'Septime, Louvre', card)]); + expect(screen.getByText('2 places')).toBeInTheDocument(); + expect(screen.queryByText(/already in the trip/)).toBeNull(); + }); + + it('FE-AI-COLL-030: a new order of the lists numbers them in turn', () => { + const rome = listRow({ id: 13, name: 'Rome', change: 'order' }); + const order = listsCard({ lists: [rome, listRow({ id: 12, name: 'Paris food', change: 'order' })] }); + draw([pending('reorder_collections', 'Rome, Paris food', order, 'genericUpdate')]); + expect(screen.getByText('1 Rome')).toBeInTheDocument(); + expect(screen.getByText('2 Paris food')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Assistant/cards/collection/CollectionChangeSlots.tsx b/client/src/components/Assistant/cards/collection/CollectionChangeSlots.tsx new file mode 100644 index 000000000..df309dab7 --- /dev/null +++ b/client/src/components/Assistant/cards/collection/CollectionChangeSlots.tsx @@ -0,0 +1,285 @@ +import type { + CollectionCardDelta, + CollectionCardLabel, + CollectionCardList, + CollectionCardPlace, + CollectionListsCard, + CollectionPlacesCard, +} from '@trek/shared'; +import { Bookmark, Info, Tag } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import { useTranslation } from '../../../../i18n'; +import { STATUS_META } from '../../../Collections/collectionStatus'; +import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts'; +import PlaceAvatar from '../../../shared/PlaceAvatar'; +import type { ChangeItem } from '../../assistantParts'; +import { verbLookOf } from '../cardLooks'; +import { ChangeMark, FactChange } from '../parts/CardFacts'; +import KindTile from '../parts/KindTile'; +import { avatarCategory, avatarPlace, labelsById, MAX_FACTS } from './collectionCardModel'; +import { LabelChip, ListDot, RatingPill, StatusPill } from './collectionCardParts'; + +/** + * The collections inside a change card: the saved place in front, with the + * tile of what happens on its corner (an overlapping stack for several), and + * on the faint line what the change does in small pills (its list, the + * status from and to, labels added or taken away, a vote, the fields it + * touches, a move), with warnings where a place is saved or in the trip + * already. An applied change says what it did instead: saved, a duplicate, + * how many went in. + */ + +type T = (key: string, params?: Record) => string; + +const COPY_TOOL = 'copy_collection_places_to_trip'; + +/** The fields a change touches, each by the label the other change cards name it with. */ +const FIELD_LOOK: Record = { + name: { labelKey: 'assistant.change.label.name' }, + address: { labelKey: 'assistant.change.label.address' }, + coordinates: { labelKey: 'assistant.change.label.coordinates' }, + description: { labelKey: 'assistant.change.label.description' }, + notes: { labelKey: 'assistant.change.label.notes' }, + category: { labelKey: 'assistant.change.label.category' }, + link: { labelKey: 'assistant.change.label.link' }, + photo: { labelKey: 'assistant.card.collection.photo' }, +}; + +/** The verb's tile, as the row shows it without a place. */ +function VerbTile({ item, size }: { item: ChangeItem; size: 'xs' | 'sm' }) { + const look = item.preview ? verbLookOf(item.preview.verbKey) : null; + return ; +} + +export function CollectionPlaceLead({ card, item }: { card: CollectionPlacesCard; item: ChangeItem }) { + const [single] = card.places; + if (!single) return ; + if (card.places.length === 1) { + return ( + + + + + + + ); + } + const stacked = card.places.slice(0, 4); + const extra = card.places.length - stacked.length + card.more; + return ( + + {stacked.map((place, index) => ( + + + + ))} + {extra > 0 && +{extra}} + + ); +} + +/** One small fact in words. */ +function Pill({ tone, text }: { tone?: PillTone; text: string }) { + return ( + + {text} + + ); +} + +/** At most MAX_FACTS pills, the rest counted. */ +function Capped({ pills }: { pills: ReactNode[] }) { + const rest = pills.length - MAX_FACTS; + return ( + <> + {pills.slice(0, MAX_FACTS)} + {rest > 0 && +{rest}} + + ); +} + +function ListChip({ list }: { list: CollectionCardList }) { + return ( + }> + {list.name} + + ); +} + +function statusChange(status: NonNullable, t: T): ReactNode { + const to = STATUS_META[status.to]; + const before = status.from ? t(STATUS_META[status.from].labelKey) : null; + const tone = status.to === 'visited' ? 'success' : 'neutral'; + return ; +} + +/** What an applied change did, from the tool's answer. */ +function outcomePills(card: CollectionPlacesCard, t: T): ReactNode[] { + const outcome = card.outcome; + if (!outcome) return []; + if (outcome.type === 'duplicate') { + const text = t('assistant.card.collection.alreadySaved', { name: outcome.name }); + return []; + } + if (outcome.type === 'statusSet') { + const text = t('assistant.card.collection.statusSet', { count: outcome.updated }); + return []; + } + const copied = t('assistant.card.collection.copied', { count: outcome.copied }); + const pills: ReactNode[] = []; + if (outcome.skipped > 0) { + const skipped = t('assistant.card.collection.skipped', { count: outcome.skipped }); + pills.push(); + } + return pills; +} + +/** Labels a change adds and takes away, as chips in their colours. */ +function labelPills(added: number[], removed: number[], labels: Map): ReactNode[] { + const chip = (id: number, mark: 'add' | 'remove') => { + const label = labels.get(id); + return label ? [] : []; + }; + return [...added.flatMap((id) => chip(id, 'add')), ...removed.flatMap((id) => chip(id, 'remove'))]; +} + +/** What a held change does to a saved place: its status, labels, vote, fields, list, removal. */ +function deltaPills(delta: CollectionCardDelta, card: CollectionPlacesCard, t: T): ReactNode[] { + const pills: ReactNode[] = []; + if (delta.status) pills.push(statusChange(delta.status, t)); + pills.push(...labelPills(delta.labelsAdded, delta.labelsRemoved, labelsById(card.labels))); + if (delta.rating) { + const to = delta.rating.to; + const cleared = t('assistant.card.collection.ratingCleared'); + pills.push(to === null ? : ); + } + for (const field of delta.fields) pills.push(); + if (delta.movedTo) { + const text = t('assistant.card.collection.moveTo', { name: delta.movedTo.name }); + pills.push(); + } + if (delta.removed) pills.push(); + return pills; +} + +/** What a change does to its one saved place; a place to save shows the status it gets. */ +function placePills(place: CollectionCardPlace, card: CollectionPlacesCard, tool: string | null, t: T): ReactNode[] { + const pills: ReactNode[] = place.delta + ? deltaPills(place.delta, card, t) + : []; + for (const saved of place.savedIn) { + const text = t('assistant.card.collection.alreadySaved', { name: saved.name }); + pills.push(); + } + // Once a copy ran, its outcome names what it skipped; the warning is for the held copy. + if (place.inTripPlaceId !== null && !(tool === COPY_TOOL && card.outcome)) { + const skipped = tool === COPY_TOOL; + const text = skipped ? t('assistant.card.collection.willSkip', { count: 1 }) : t('assistant.place.inTrip'); + pills.push(); + } + return pills; +} + +/** + * What a change does to several saved places at once. Only a held copy into + * the trip warns of the places the trip has: a save from the trip names trip + * places by nature, and a copy that ran says what it skipped. + */ +function manyPills(card: CollectionPlacesCard, tool: string | null, t: T): ReactNode[] { + const status = card.places.find((place) => place.delta?.status)?.delta?.status ?? null; + const added = [...new Set(card.places.flatMap((place) => place.delta?.labelsAdded ?? []))]; + const removed = [...new Set(card.places.flatMap((place) => place.delta?.labelsRemoved ?? []))]; + const warns = tool === COPY_TOOL && !card.outcome; + const inTrip = warns ? card.places.filter((place) => place.inTripPlaceId !== null).length : 0; + const pills: ReactNode[] = [ + , + ]; + if (status) pills.push(statusChange(status, t)); + if (card.everyList) pills.push(); + pills.push(...labelPills(added, removed, labelsById(card.labels))); + if (inTrip > 0) { + const text = t('assistant.card.collection.willSkip', { count: inTrip }); + pills.push(); + } + return pills; +} + +/** A label a change makes, renames or deletes, with how many places carry it. */ +function labelChangePills(card: CollectionPlacesCard, t: T): ReactNode[] { + return card.labels.flatMap((label) => { + const mark = label.change === 'add' ? 'add' : label.change === 'remove' ? 'remove' : undefined; + const renamed = label.before !== null && label.before.name !== label.name; + const pills: ReactNode[] = [ + renamed ? ( + + ) : ( + + ), + ]; + if (label.places !== null && label.change !== 'add') { + const text = t('assistant.card.collection.labelUsedBy', { count: label.places }); + pills.push(); + } + return pills; + }); +} + +export function CollectionPlaceFacts({ card, item }: { card: CollectionPlacesCard; item: ChangeItem }) { + const { t } = useTranslation(); + const [single] = card.places; + const tool = item.preview?.tool ?? null; + const pills = outcomePills(card, t); + if (card.list) pills.push(); + if (!single) pills.push(...labelChangePills(card, t)); + else if (card.places.length === 1) pills.push(...placePills(single, card, tool, t)); + else pills.push(...manyPills(card, tool, t)); + if (card.otherTrip) { + pills.push(); + } + return ; +} + +/** What a change does to a list: new, renamed, deleted with what goes, an invite answered or withdrawn, a new order. */ +function listPills(card: CollectionListsCard, t: T, locale: string): ReactNode[] { + const [row] = card.lists; + if (!row) return []; + const person = row.person?.name ?? ''; + switch (row.change) { + case 'add': + return []; + case 'update': + return row.before && row.before.name !== row.name + ? [] + : []; + case 'remove': { + const pills: ReactNode[] = []; + if (row.placeCount !== null) { + pills.push(); + } + if (row.memberCount > 0) { + const members = t('assistant.card.collection.members', { count: row.memberCount }); + pills.push(); + } + return pills; + } + case 'join': + case 'decline': { + const tone = row.change === 'join' ? 'info' : 'neutral'; + return []; + } + case 'withdraw': + return []; + case 'order': + return card.lists.map((list, index) => ( + + )); + default: + return []; + } +} + +export function CollectionListFacts({ card }: { card: CollectionListsCard }) { + const { t, locale } = useTranslation(); + return ; +} diff --git a/client/src/components/Assistant/cards/collection/collectionCardModel.ts b/client/src/components/Assistant/cards/collection/collectionCardModel.ts new file mode 100644 index 000000000..919a6eb49 --- /dev/null +++ b/client/src/components/Assistant/cards/collection/collectionCardModel.ts @@ -0,0 +1,130 @@ +import type { + AssistantApplied, + CollectionCardLabel, + CollectionCardPlace, + CollectionListRow, + CollectionListsCard, + CollectionPlacesCard, + TrekCard, +} from '@trek/shared'; + +import type { EntityTarget } from '../cardTypes'; + +/** + * The pure side of the collections' cards: where a list and a saved place + * open, the saved place a card is about, how cards of several reads lay out + * as one, and how many rows a card shows. No components here, so the look + * (and the main chunk with it) reads it without pulling in a card. + */ + +/** Rows a places card shows in a narrow chat and in a roomy one; the server sends at most the second. */ +const ROWS_COMPACT = 5; +const ROWS_ROOMY = 8; + +/** Pills a change row shows before it counts the rest. */ +export const MAX_FACTS = 4; + +/** Where a list opens in Collections. */ +export function listTarget(listId: number | null): EntityTarget { + return { kind: 'page', path: listId === null ? '/collections' : `/collections/${listId}` }; +} + +/** Where a saved place opens: its list, with the place selected. */ +function placeTarget(listId: number, placeId: number | null): EntityTarget { + const path = `/collections/${listId}`; + return { kind: 'page', path: placeId === null ? path : `${path}?place=${placeId}` }; +} + +function placesCardOf(card: TrekCard | null): CollectionPlacesCard | null { + return card?.kind === 'collectionPlaces' ? card : null; +} + +/** The saved place a change card is about: its only one. */ +export function firstPlace(card: TrekCard | null): CollectionCardPlace | null { + const places = placesCardOf(card)?.places ?? []; + return places.length === 1 ? (places[0] ?? null) : null; +} + +/** The list an applied change of a list opens; none for a list it deleted or an invite it declined. */ +export function appliedListTarget(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null { + const row = card?.kind === 'collectionLists' ? card.lists[0] : undefined; + if (row && (row.change === 'remove' || row.change === 'decline')) return null; + const id = applied.ids[0] ?? row?.id ?? placesCardOf(card)?.list?.id ?? null; + return id === null ? null : listTarget(id); +} + +/** The saved place an applied change opens; its list once the change removed it. */ +export function appliedPlaceTarget(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null { + const place = firstPlace(card); + const listId = place?.listId ?? placesCardOf(card)?.list?.id ?? null; + if (listId === null) return null; + if (place?.delta?.removed) return listTarget(listId); + return placeTarget(listId, applied.ids[0] ?? place?.id ?? null); +} + +/** The trip place an applied change of a saved place is, for "Show on map". */ +export function appliedPlaceOnMap(card: TrekCard | null): { placeId: number; dayId: null } | null { + const placeId = firstPlace(card)?.inTripPlaceId ?? null; + return placeId === null ? null : { placeId, dayId: null }; +} + +/** How many rows a places card shows at this density. */ +export function rowsShown(density: 'compact' | 'roomy' | undefined): number { + return density === 'roomy' ? ROWS_ROOMY : ROWS_COMPACT; +} + +/** + * The places cards of one answer as one: a list read twice shows once (the + * later read wins, in the place of the first), places of several lists each + * under their own head. + */ +export function mergePlacesCards(cards: readonly CollectionPlacesCard[]): CollectionPlacesCard[] { + const merged: CollectionPlacesCard[] = []; + for (const card of cards) { + const id = card.list?.id ?? null; + const at = id === null ? -1 : merged.findIndex((seen) => seen.list?.id === id); + if (at >= 0) merged[at] = card; + else merged.push(card); + } + return merged; +} + +/** The rows of several lists cards as one: a list twice shows once, as the later card has it. */ +export function mergeListRows(cards: readonly CollectionListsCard[]): { rows: CollectionListRow[]; more: number } { + const rows: CollectionListRow[] = []; + let more = 0; + for (const card of cards) { + more += card.more; + for (const row of card.lists) { + const at = row.id === null ? -1 : rows.findIndex((seen) => seen.id === row.id); + if (at >= 0) rows[at] = row; + else rows.push(row); + } + } + return { rows, more }; +} + +/** The labels a card names, by id. */ +export function labelsById(labels: readonly CollectionCardLabel[]): Map { + return new Map(labels.flatMap((label) => (label.id === null ? [] : [[label.id, label] as const]))); +} + +/** A saved place as the shared place avatar takes it: its own photo by TREK's address, else its provider ids. */ +export function avatarPlace(place: CollectionCardPlace, fallbackId: number) { + return { + id: place.id ?? fallbackId, + name: place.name, + image_url: place.imageKey ? `/uploads/places/${place.imageKey}` : null, + google_place_id: place.googlePlaceId, + osm_id: place.osmId, + lat: place.lat, + lng: place.lng, + }; +} + +/** The category colour and icon the avatar falls back to without a photo. */ +export function avatarCategory(place: CollectionCardPlace): { color?: string; icon?: string } | null { + const category = place.category; + if (!category) return null; + return { color: category.color ?? undefined, icon: category.icon ?? undefined }; +} diff --git a/client/src/components/Assistant/cards/collection/collectionCardParts.tsx b/client/src/components/Assistant/cards/collection/collectionCardParts.tsx new file mode 100644 index 000000000..55f1c4ccf --- /dev/null +++ b/client/src/components/Assistant/cards/collection/collectionCardParts.tsx @@ -0,0 +1,211 @@ +import type { CollectionCardLabel, CollectionCardList, CollectionCardPlace, CollectionStatus } from '@trek/shared'; +import { Star } from 'lucide-react'; +import type { CSSProperties } from 'react'; + +import { useTranslation } from '../../../../i18n'; +import { safeHexColor } from '../../../../utils/safeColor'; +import { STATUS_META } from '../../../Collections/collectionStatus'; +import { SoftPill, tintOf, WhiteBadge } from '../../../Planner/planParts'; +import PlaceAvatar from '../../../shared/PlaceAvatar'; +import { Tooltip } from '../../../shared/Tooltip'; +import { useAssistantView } from '../../assistantViewContext'; +import { CardFacts } from '../parts/CardFacts'; +import { CardOpenButton } from '../parts/CardOpen'; +import { CardRow } from '../parts/CardRows'; +import { useCardPoint } from '../parts/useCardPoint'; +import { avatarCategory, avatarPlace, listTarget } from './collectionCardModel'; + +/** + * The pieces the collections' cards are drawn with, the way the Collections + * page draws them: a list's colour dot and head, a saved place as a row with + * its photo, status, labels and rating, and a label as a chip in its colour. + * Every fact is a small pill, as on the planner's rows. + */ + +/** A list's colour as a dot; the accent where the list has none. */ +export function ListDot({ color, size = 10 }: { color: string | null; size?: 8 | 10 }) { + return ( +