From f833a05461b8a4bfe3ebcc86a9a504a7208ad5d9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 13:56:42 +0200 Subject: [PATCH] feat(tours): point a tour at its trip's packing list A tour can now point at the packing list of its trip, as a whole or at one of its categories (say "Hiking gear" for a hike), and shows it in its detail. The new Packing list section reads the list live as the user may see it: the backpack tile with the item count on its corner and the category's own colour, how many are packed with a thin progress bar, the first items ticked off or not with their quantity, how many more there are, and a button that opens the trip's Lists tab. Who may change the tour links one, changes it or lets go through a dialog that lists the whole list and every category with its colour and item count, and writes only on its confirm. It is a reference, never a copy: the items stay in Packing, are ticked off and edited there, and each traveller sees in the tour only the items Packing shows them, so an item kept to oneself never shows through a tour. A category that is renamed, cleared or only holds items of others reads as missing, an empty one as empty, a link that could not be read says so with a retry, and while Lists are switched off the link stays, dimmed, and can still be let go. Removing the link, or the tour, leaves the list and every item as they are. Nothing suggests or changes an item by itself. On the server the link is a row of its own in the tours domain (tour_packing_links, migration 046110), one per tour and gone with it. GET, PUT and DELETE /api/trips/:tripId/tours/:placeId/packing-link read it with the trip and change it with place_edit; linking needs the Lists addon and, for a category, an item of it the user can see. The writes answer the tour with its link and tell the trip through tours:changed, so the offline client queues them as writes of the tour and replays them like one. A copied trip keeps its tours' links, a tour saved as new or taken over does not. The MCP tools get_tour_packing, link_tour_packing and unlink_tour_packing run over the same service and checks; the read also hands the tour's facts as context for suggesting what to pack, which only becomes an item through the packing tools. The empty category placeholder now lives in @trek/shared so both sides leave it out alike. All 27 locales carry the new strings, and the wiki describes the link and the tools. --- client/src/api/tourPackingLink.ts | 20 ++ .../Packing/packingListPanel.constants.ts | 5 +- .../src/components/Tours/TourDetailDialog.tsx | 11 + .../Tours/packing/TourPackingLinkDialog.tsx | 151 ++++++++++ .../Tours/packing/TourPackingSection.tsx | 260 ++++++++++++++++++ .../Tours/packing/packingLinkParts.tsx | 26 ++ .../Tours/packing/tourPacking.test.tsx | 215 +++++++++++++++ .../Tours/packing/useTourPackingLink.ts | 137 +++++++++ client/src/db/cacheTour.test.ts | 9 + client/src/db/cacheTour.ts | 11 +- client/src/db/offlineDb.ts | 6 +- .../tripPlanner/useTourDetailActions.test.ts | 17 ++ .../pages/tripPlanner/useTourDetailActions.ts | 5 + client/src/repo/tourPackingLinkRepo.test.ts | 154 +++++++++++ client/src/repo/tourPackingLinkRepo.ts | 100 +++++++ client/src/repo/tourRepo.ts | 2 +- client/tests/helpers/msw/handlers/tours.ts | 6 +- server/scripts/repository-owners.json | 1 + .../db/entities/TourPackingLinks.entity.ts | 29 ++ server/src/db/entities/Tours.entity.ts | 3 + server/src/db/entities/Users.entity.ts | 3 + server/src/db/entities/index.ts | 2 + .../src/db/kysely/TourPackingLinks.table.ts | 10 + server/src/db/kysely/db.ts | 2 + ...46110_a_tour_points_at_its_packing_list.ts | 25 ++ .../TourPackingLinks.repository.ts | 55 ++++ .../src/db/repositories/Tours.repository.ts | 30 ++ .../nest/tours/dto/tour-packing-link.dto.ts | 5 + .../tours/tour-packing-link.controller.ts | 56 ++++ .../nest/tours/tour-packing-link.service.ts | 193 +++++++++++++ server/src/nest/tours/tour-packing.mcp.ts | 104 +++++++ server/src/nest/tours/tours.module.ts | 15 +- server/src/nest/trips/trips.service.ts | 8 +- .../tests/e2e/tour-packing-link.e2e.test.ts | 230 ++++++++++++++++ server/tests/helpers/factories/tours.ts | 11 + server/tests/unit/db/entities-index.test.ts | 7 +- server/tests/unit/db/legacy-baseline.test.ts | 1 + .../TourPackingLinks.repository.test.ts | 105 +++++++ .../db/repositories/Tours.repository.test.ts | 27 +- .../nest/tour-packing-link.controller.test.ts | 68 +++++ .../nest/tour-packing-link.service.test.ts | 231 ++++++++++++++++ .../tests/unit/nest/tour-packing.mcp.test.ts | 151 ++++++++++ .../unit/nest/trips.copy-tour-packing.test.ts | 64 +++++ shared/src/i18n/ar/index.ts | 2 + shared/src/i18n/ar/tourPacking.ts | 43 +++ shared/src/i18n/az/index.ts | 2 + shared/src/i18n/az/tourPacking.ts | 31 +++ shared/src/i18n/br/index.ts | 2 + shared/src/i18n/br/tourPacking.ts | 31 +++ shared/src/i18n/ca/index.ts | 2 + shared/src/i18n/ca/tourPacking.ts | 31 +++ shared/src/i18n/cs/index.ts | 2 + shared/src/i18n/cs/tourPacking.ts | 34 +++ shared/src/i18n/de/index.ts | 2 + shared/src/i18n/de/tourPacking.ts | 31 +++ shared/src/i18n/en/index.ts | 2 + shared/src/i18n/en/tourPacking.ts | 31 +++ shared/src/i18n/es/index.ts | 2 + shared/src/i18n/es/tourPacking.ts | 31 +++ shared/src/i18n/et/index.ts | 2 + shared/src/i18n/et/tourPacking.ts | 31 +++ shared/src/i18n/fr/index.ts | 2 + shared/src/i18n/fr/tourPacking.ts | 31 +++ shared/src/i18n/gr/index.ts | 2 + shared/src/i18n/gr/tourPacking.ts | 31 +++ shared/src/i18n/hu/index.ts | 2 + shared/src/i18n/hu/tourPacking.ts | 31 +++ shared/src/i18n/id/index.ts | 2 + shared/src/i18n/id/tourPacking.ts | 28 ++ shared/src/i18n/it/index.ts | 2 + shared/src/i18n/it/tourPacking.ts | 31 +++ shared/src/i18n/ja/index.ts | 2 + shared/src/i18n/ja/tourPacking.ts | 28 ++ shared/src/i18n/ko/index.ts | 2 + shared/src/i18n/ko/tourPacking.ts | 28 ++ shared/src/i18n/nl/index.ts | 2 + shared/src/i18n/nl/tourPacking.ts | 31 +++ shared/src/i18n/pl/index.ts | 2 + shared/src/i18n/pl/tourPacking.ts | 37 +++ shared/src/i18n/ru/index.ts | 2 + shared/src/i18n/ru/tourPacking.ts | 37 +++ shared/src/i18n/sk/index.ts | 2 + shared/src/i18n/sk/tourPacking.ts | 34 +++ shared/src/i18n/sv/index.ts | 2 + shared/src/i18n/sv/tourPacking.ts | 31 +++ shared/src/i18n/th/index.ts | 2 + shared/src/i18n/th/tourPacking.ts | 28 ++ shared/src/i18n/tours-locales.spec.ts | 11 +- shared/src/i18n/tr/index.ts | 2 + shared/src/i18n/tr/tourPacking.ts | 31 +++ shared/src/i18n/uk/index.ts | 2 + shared/src/i18n/uk/tourPacking.ts | 37 +++ shared/src/i18n/vi/index.ts | 2 + shared/src/i18n/vi/tourPacking.ts | 28 ++ shared/src/i18n/zh-TW/index.ts | 2 + shared/src/i18n/zh-TW/tourPacking.ts | 28 ++ shared/src/i18n/zh/index.ts | 2 + shared/src/i18n/zh/tourPacking.ts | 28 ++ shared/src/index.ts | 3 + shared/src/packing/packing-placeholder.ts | 6 + shared/src/tours/tour-packing-link.schema.ts | 30 ++ shared/src/tours/tour-packing-link.spec.ts | 76 +++++ shared/src/tours/tour-packing-link.ts | 64 +++++ shared/src/tours/tours.schema.ts | 7 + wiki/MCP-Addon-Tools.md | 5 +- wiki/Packing-Lists.md | 4 + 106 files changed, 3661 insertions(+), 22 deletions(-) create mode 100644 client/src/api/tourPackingLink.ts create mode 100644 client/src/components/Tours/packing/TourPackingLinkDialog.tsx create mode 100644 client/src/components/Tours/packing/TourPackingSection.tsx create mode 100644 client/src/components/Tours/packing/packingLinkParts.tsx create mode 100644 client/src/components/Tours/packing/tourPacking.test.tsx create mode 100644 client/src/components/Tours/packing/useTourPackingLink.ts create mode 100644 client/src/pages/tripPlanner/useTourDetailActions.test.ts create mode 100644 client/src/repo/tourPackingLinkRepo.test.ts create mode 100644 client/src/repo/tourPackingLinkRepo.ts create mode 100644 server/src/db/entities/TourPackingLinks.entity.ts create mode 100644 server/src/db/kysely/TourPackingLinks.table.ts create mode 100644 server/src/db/migrations/Migration20200101046110_a_tour_points_at_its_packing_list.ts create mode 100644 server/src/db/repositories/TourPackingLinks.repository.ts create mode 100644 server/src/nest/tours/dto/tour-packing-link.dto.ts create mode 100644 server/src/nest/tours/tour-packing-link.controller.ts create mode 100644 server/src/nest/tours/tour-packing-link.service.ts create mode 100644 server/src/nest/tours/tour-packing.mcp.ts create mode 100644 server/tests/e2e/tour-packing-link.e2e.test.ts create mode 100644 server/tests/unit/db/repositories/TourPackingLinks.repository.test.ts create mode 100644 server/tests/unit/nest/tour-packing-link.controller.test.ts create mode 100644 server/tests/unit/nest/tour-packing-link.service.test.ts create mode 100644 server/tests/unit/nest/tour-packing.mcp.test.ts create mode 100644 server/tests/unit/nest/trips.copy-tour-packing.test.ts create mode 100644 shared/src/i18n/ar/tourPacking.ts create mode 100644 shared/src/i18n/az/tourPacking.ts create mode 100644 shared/src/i18n/br/tourPacking.ts create mode 100644 shared/src/i18n/ca/tourPacking.ts create mode 100644 shared/src/i18n/cs/tourPacking.ts create mode 100644 shared/src/i18n/de/tourPacking.ts create mode 100644 shared/src/i18n/en/tourPacking.ts create mode 100644 shared/src/i18n/es/tourPacking.ts create mode 100644 shared/src/i18n/et/tourPacking.ts create mode 100644 shared/src/i18n/fr/tourPacking.ts create mode 100644 shared/src/i18n/gr/tourPacking.ts create mode 100644 shared/src/i18n/hu/tourPacking.ts create mode 100644 shared/src/i18n/id/tourPacking.ts create mode 100644 shared/src/i18n/it/tourPacking.ts create mode 100644 shared/src/i18n/ja/tourPacking.ts create mode 100644 shared/src/i18n/ko/tourPacking.ts create mode 100644 shared/src/i18n/nl/tourPacking.ts create mode 100644 shared/src/i18n/pl/tourPacking.ts create mode 100644 shared/src/i18n/ru/tourPacking.ts create mode 100644 shared/src/i18n/sk/tourPacking.ts create mode 100644 shared/src/i18n/sv/tourPacking.ts create mode 100644 shared/src/i18n/th/tourPacking.ts create mode 100644 shared/src/i18n/tr/tourPacking.ts create mode 100644 shared/src/i18n/uk/tourPacking.ts create mode 100644 shared/src/i18n/vi/tourPacking.ts create mode 100644 shared/src/i18n/zh-TW/tourPacking.ts create mode 100644 shared/src/i18n/zh/tourPacking.ts create mode 100644 shared/src/packing/packing-placeholder.ts create mode 100644 shared/src/tours/tour-packing-link.schema.ts create mode 100644 shared/src/tours/tour-packing-link.spec.ts create mode 100644 shared/src/tours/tour-packing-link.ts diff --git a/client/src/api/tourPackingLink.ts b/client/src/api/tourPackingLink.ts new file mode 100644 index 000000000..53766911f --- /dev/null +++ b/client/src/api/tourPackingLink.ts @@ -0,0 +1,20 @@ +import type { TourDetailResponse, TourPackingLinkRequest, TourPackingLinkResponse } from '@trek/shared'; +import apiClient from './client'; + +/** + * Where a tour's packing link lives; a write queued offline replays to the + * same path, `{id}` standing for a tour that has not synced yet. + */ +export function tourPackingLinkPath(tripId: number | string, placeId: number | string): string { + return `/trips/${tripId}/tours/${placeId}/packing-link`; +} + +/** P2D-XD-01: the packing list, or the category of it, a tour points at. */ +export const tourPackingLinkApi = { + read: (tripId: number | string, placeId: number, signal?: AbortSignal): Promise => + apiClient.get(tourPackingLinkPath(tripId, placeId), { signal }).then((r) => r.data), + link: (tripId: number | string, placeId: number, body: TourPackingLinkRequest): Promise => + apiClient.put(tourPackingLinkPath(tripId, placeId), body).then((r) => r.data), + unlink: (tripId: number | string, placeId: number): Promise => + apiClient.delete(tourPackingLinkPath(tripId, placeId)).then((r) => r.data), +}; diff --git a/client/src/components/Packing/packingListPanel.constants.ts b/client/src/components/Packing/packingListPanel.constants.ts index 796606ee5..690bda73a 100644 --- a/client/src/components/Packing/packingListPanel.constants.ts +++ b/client/src/components/Packing/packingListPanel.constants.ts @@ -15,8 +15,9 @@ export const KAT_COLORS = [ export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b'] // A category's first item is seeded with this sentinel because the server -// rejects empty names. Treat it as a placeholder in the UI. -export const PACKING_PLACEHOLDER_NAME = '...' +// rejects empty names. Treat it as a placeholder in the UI. Shared, because a +// tour's packing link (P2D-XD-01) leaves it out the same way. +export { PACKING_PLACEHOLDER_NAME } from '@trek/shared' /** What the import's file picker offers: CSV rows, plain text, or a Markdown list (#875). */ export const PACKING_IMPORT_ACCEPT = '.csv,.txt,.md,.markdown,text/markdown' diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index ccb3ce285..2e3d9c3e3 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -53,6 +53,7 @@ import TourActivitySection from './activity/TourActivitySection'; import type { PlanValues } from './activity/tourActivityCompare'; import { TourSaveToCollectionButton } from './collection/TourSaveToCollectionButton'; import { PaddleCheckSection } from './paddle/PaddleCheckSection'; +import { TourPackingSection } from './packing/TourPackingSection'; import { PaddleWindSection } from './paddle/PaddleWindCard'; import { TourProvenanceBlock } from './provenance/TourProvenanceSection'; import { RunHeadPills } from './run/RunHeadPills'; @@ -94,6 +95,8 @@ interface TourDetailDialogProps extends TourPermissionProps { onSaveAsNew?: (tour: TourListItem) => Promise | void; /** ACT-01: a recorded track was assigned, taken off or imported; the lists take in these tours. */ onToursChanged?: (placeIds: number[]) => Promise | void; + /** P2D-XD-01: opens the trip's packing list the tour points at. */ + onOpenPacking?: () => void; onAssignToDay?: (placeId: number, dayId?: number) => void; onRemoveAssignment?: (dayId: number, assignmentId: number) => void; onDelete?: () => void; @@ -502,6 +505,7 @@ export default function TourDetailDialog({ onSourceReimported, onSaveAsNew, onToursChanged, + onOpenPacking, onAssignToDay, onRemoveAssignment, onDelete, @@ -756,6 +760,13 @@ export default function TourDetailDialog({ onToursChanged={onToursChanged} /> )} + {description && ( diff --git a/client/src/components/Tours/packing/TourPackingLinkDialog.tsx b/client/src/components/Tours/packing/TourPackingLinkDialog.tsx new file mode 100644 index 000000000..8f42aaaa5 --- /dev/null +++ b/client/src/components/Tours/packing/TourPackingLinkDialog.tsx @@ -0,0 +1,151 @@ +import { Backpack, Link2, ListChecks, Loader2, Unlink } from 'lucide-react'; +import { useId, type ReactNode } from 'react'; +import { useTranslation } from '../../../i18n'; +import { + DialogButton, + DialogFooter, + DialogHeader, + DialogSection, + DialogShell, + DialogTile, + FooterSpacer, + fs, + NEUTRAL_TINT, +} from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { ChoiceList, ChoiceRow } from '../collection/choiceParts'; +import { CategoryDot, CountBadge } from './packingLinkParts'; +import type { TourPackingLinkController } from './useTourPackingLink'; + +/** One thing to point at: its mark, its name and how many items it holds. */ +function Choice({ + selected, + onChoose, + testId, + mark, + name, + count, +}: { + selected: boolean; + onChoose: () => void; + testId: string; + mark: ReactNode; + name: string; + count: number; +}) { + return ( + + {mark} + + {name} + + + + ); +} + +/** + * P2D-XD-01: the dialog that points a tour at its trip's packing list, as a + * whole or at one of its categories, each with its colour and how many items + * it holds. It writes only through the button in its foot; letting go of a + * link is offered there too, and neither touches the list itself. + */ +export function TourPackingLinkDialog({ packing, tourName }: { packing: TourPackingLinkController; tourName: string }) { + const { t } = useTranslation(); + const labelId = useId(); + if (!packing.open) return null; + return ( + + + + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={packing.close} + eyebrow={t('tours.packing.link')} + title={tourName} + sub={t('tours.packing.dialogSub')} + subWraps + /> + } + footer={ + + {packing.link && ( + + + void packing.unlink()} + disabled={packing.saving} + data-testid="tour-packing-unlink" + icon={} + > + {t('tours.packing.unlink')} + + + + )} + + + {t('common.cancel')} + + void packing.confirm()} + icon={ + packing.saving ? ( + + ) : ( + + ) + } + > + {t('tours.packing.confirm')} + + + } + > + + + packing.choose(null)} + testId="tour-packing-choice-whole" + mark={ + + + + } + name={t('tours.packing.whole')} + count={packing.wholeCount} + /> + {packing.categories.map((category) => ( + packing.choose(category.name)} + testId={`tour-packing-choice-${category.name}`} + mark={ + + + + } + name={category.name} + count={category.count} + /> + ))} + + + + ); +} diff --git a/client/src/components/Tours/packing/TourPackingSection.tsx b/client/src/components/Tours/packing/TourPackingSection.tsx new file mode 100644 index 000000000..51c5b2142 --- /dev/null +++ b/client/src/components/Tours/packing/TourPackingSection.tsx @@ -0,0 +1,260 @@ +import type { TourListItem, TourPackingLink } from '@trek/shared'; +import { AlertTriangle, ArrowUpRight, Backpack, Check, Link2, PackageOpen, PowerOff, Unlink } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useTranslation } from '../../../i18n'; +import type { PackingItem } from '../../../types'; +import { BOX } from '../../Planner/bookings/bookingParts'; +import { SoftPill, WhiteBadge } from '../../Planner/planParts'; +import { DialogSection, fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { CardAction, TourNotice } from '../tourParts'; +import { CategoryDot } from './packingLinkParts'; +import { TourPackingLinkDialog } from './TourPackingLinkDialog'; +import { useTourPackingLink, type TourPackingLinkController } from './useTourPackingLink'; + +/** How many items of the list the card shows before it says how many more there are. */ +const SHOWN_ITEMS = 5; + +/** The quiet pill beside the section's label, like the upload of a tour's files. */ +function SectionPill({ onClick, icon, children }: { onClick: () => void; icon: ReactNode; children: ReactNode }) { + return ( + + ); +} + +/** The backpack tile with the item count on its corner, the way a waypoint's number carries its marks. */ +function PackTile({ count, color }: { count: number; color: string | null }) { + return ( + + + ); +} + +/** One item as Packing keeps it, read only: ticked off or not, its name and how many. */ +function ItemRow({ item }: { item: PackingItem }) { + return ( +
  • + + {!!item.checked && + + {item.name} + + {(item.quantity ?? 1) > 1 && ×{item.quantity}} +
  • + ); +} + +/** The name of what the tour points at: a category by its own name, otherwise the whole list. */ +function linkName(link: TourPackingLink, t: (key: string) => string): string { + return link.category ?? t('tours.packing.whole'); +} + +/** The linked list as it reads now: its items with how many are packed, or why there are none to show. */ +function LinkedCard({ packing, onOpenPacking }: { packing: TourPackingLinkController; onOpenPacking?: () => void }) { + const { t } = useTranslation(); + const { link, linked } = packing; + if (!link || !linked) return null; + const name = linkName(link, t); + if (linked.state === 'missing') { + return ( + }> + {t('tours.packing.missing', { name })} + + ); + } + const total = linked.items.length; + const allPacked = total > 0 && linked.packed === total; + const more = total - SHOWN_ITEMS; + return ( +
    +
    + +
    +

    + {name} +

    + {total > 0 && ( +

    + {t('tours.packing.packed', { packed: linked.packed, count: total })} +

    + )} +
    + {allPacked && ( + }> + {t('tours.packing.allPacked')} + + )} +
    + {total > 0 ? ( + <> + + ); +} + +/** No link yet: a quiet dashed card, with the way to make one for who may change the tour. */ +function UnlinkedCard({ onLink }: { onLink?: () => void }) { + const { t } = useTranslation(); + return ( +
    + + + + {t('tours.packing.none')} + + {onLink && ( + } onClick={onLink}> + {t('tours.packing.link')} + + )} +
    + ); +} + +/** The section's body for whatever the link reads as now. */ +function SectionBody({ + packing, + canEdit, + onOpenPacking, +}: { + packing: TourPackingLinkController; + canEdit: boolean; + onOpenPacking?: () => void; +}) { + const { t } = useTranslation(); + if (packing.status === 'error') { + return ( + }> + + {t('tours.packing.loadFailed')} + + + + ); + } + if (!packing.link) return ; + if (!packing.packingEnabled) { + // Dimmed, not hidden: the tour still points at the list, it just cannot be shown. + return ( +
    + }> + {linkName(packing.link, t)} + {t('tours.packing.addonOff')} + +
    + ); + } + return ; +} + +/** + * P2D-XD-01 in a tour's detail: the packing list, or the category of it, the + * tour points at, read live from the trip's packing list as this user may see + * it. Who may change the tour links one, changes it or lets go through the + * dialog; the items themselves are only ever changed in Packing, which the + * card opens. Without Packing and without a link there is nothing to show. + */ +export function TourPackingSection({ + tripId, + tour, + tourName, + canEdit, + onOpenPacking, +}: { + tripId: number; + tour: TourListItem; + tourName: string; + canEdit: boolean; + /** Opens the trip's packing list; absent where the detail cannot leave for it. */ + onOpenPacking?: () => void; +}) { + const { t } = useTranslation(); + const packing = useTourPackingLink({ tripId, tour }); + if (packing.status === 'loading' || (!packing.packingEnabled && !packing.link)) return null; + const editable = canEdit && packing.status === 'ready'; + let action: ReactNode = null; + if (editable && packing.link && packing.packingEnabled) { + action = ( + }> + {t('tours.packing.change')} + + ); + } else if (editable && packing.link) { + action = ( + + + void packing.unlink()} icon={}> + {t('tours.packing.unlink')} + + + + ); + } + return ( + +
    + +
    + +
    + ); +} diff --git a/client/src/components/Tours/packing/packingLinkParts.tsx b/client/src/components/Tours/packing/packingLinkParts.tsx new file mode 100644 index 000000000..9ad51e9cb --- /dev/null +++ b/client/src/components/Tours/packing/packingLinkParts.tsx @@ -0,0 +1,26 @@ +import { Package } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { tintOf, WhiteBadge } from '../../Planner/planParts'; + +/** A packing category's own colour, as the packing list gives it, with a soft halo of the same colour. */ +export function CategoryDot({ color }: { color: string }) { + return ( +