diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx index ad4fb7e44..9f18e9da3 100644 --- a/client/src/components/Packing/PackingListPanel.test.tsx +++ b/client/src/components/Packing/PackingListPanel.test.tsx @@ -1320,6 +1320,60 @@ describe('PackingListPanel', () => { await waitFor(() => expect(updateBody).toMatchObject({ name: 'Luggage' })); }); + // #207: the column, the contract and the API have existed since v2.9.0 — there was + // simply no way to type a limit in, so users encoded it in the bag name instead. + it('FE-COMP-PACKING-065b: a bag without a limit offers to set one, in kg', async () => { + const user = userEvent.setup(); + let updateBody: Record | null = null; + server.use( + http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), + http.get('/api/trips/:id/packing/bags', () => + HttpResponse.json({ bags: [{ id: 12, name: 'Cabin bag', color: '#10b981', weight_limit_grams: null, members: [] }] }) + ), + http.put('/api/trips/1/packing/bags/12', async ({ request }) => { + updateBody = await request.json() as Record; + return HttpResponse.json({ bag: { id: 12, name: 'Cabin bag', color: '#10b981', weight_limit_grams: 8000, members: [] } }); + }) + ); + render(); + + await waitFor(() => expect(screen.getAllByText('Set limit').length).toBeGreaterThan(0)); + await user.click(screen.getAllByText('Set limit')[0]); + + const limitInput = await screen.findByLabelText('Weight limit'); + await user.type(limitInput, '8'); + await user.keyboard('{Enter}'); + + // Entered in kilograms, stored in grams. + await waitFor(() => expect(updateBody).toMatchObject({ weight_limit_grams: 8000 })); + }); + + it('FE-COMP-PACKING-065c: an existing limit is shown next to the packed weight and can be cleared', async () => { + const user = userEvent.setup(); + let updateBody: Record | null = null; + server.use( + http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), + http.get('/api/trips/:id/packing/bags', () => + HttpResponse.json({ bags: [{ id: 13, name: 'Hold bag', color: '#6366f1', weight_limit_grams: 20000, members: [] }] }) + ), + http.put('/api/trips/1/packing/bags/13', async ({ request }) => { + updateBody = await request.json() as Record; + return HttpResponse.json({ bag: { id: 13, name: 'Hold bag', color: '#6366f1', weight_limit_grams: null, members: [] } }); + }) + ); + render(); + + await waitFor(() => expect(screen.getAllByText(/\/ 20\.0 kg/).length).toBeGreaterThan(0)); + + await user.click(screen.getAllByText(/\/ 20\.0 kg/)[0]); + const limitInput = await screen.findByLabelText('Weight limit'); + await user.clear(limitInput); + await user.keyboard('{Enter}'); + + // An emptied field clears the limit rather than writing 0. + await waitFor(() => expect(updateBody).toMatchObject({ weight_limit_grams: null })); + }); + it('FE-COMP-PACKING-066: BagCard Plus button opens user picker with trip members', async () => { const user = userEvent.setup(); server.use( diff --git a/client/src/components/Packing/PackingListPanelBagCard.tsx b/client/src/components/Packing/PackingListPanelBagCard.tsx index f4f188429..bd3f63a82 100644 --- a/client/src/components/Packing/PackingListPanelBagCard.tsx +++ b/client/src/components/Packing/PackingListPanelBagCard.tsx @@ -21,6 +21,27 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers, setEditingName(false) } + // Limits are entered in kg — that is how airlines state them — and stored in grams. + const limitToInput = (grams?: number | null) => (grams ? String(grams / 1000) : '') + const [editingLimit, setEditingLimit] = useState(false) + const [limitVal, setLimitVal] = useState(limitToInput(bag.weight_limit_grams)) + useEffect(() => setLimitVal(limitToInput(bag.weight_limit_grams)), [bag.weight_limit_grams]) + + const saveLimit = () => { + setEditingLimit(false) + const raw = limitVal.trim().replace(',', '.') + if (raw === '') { + // Clearing the field removes the limit and puts the bar back on relative scaling. + if (bag.weight_limit_grams != null) onUpdate(bag.id, { weight_limit_grams: null }) + return + } + const kg = Number(raw) + // Anything unparseable or negative leaves the stored limit alone rather than wiping it. + if (!Number.isFinite(kg) || kg <= 0) { setLimitVal(limitToInput(bag.weight_limit_grams)); return } + const grams = Math.round(kg * 1000) + if (grams !== bag.weight_limit_grams) onUpdate(bag.id, { weight_limit_grams: grams }) + } + const memberIds = (bag.members || []).map(m => m.user_id) const toggleMember = (userId: number) => { const next = memberIds.includes(userId) ? memberIds.filter(id => id !== userId) : [...memberIds, userId] @@ -40,8 +61,29 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers, ) : ( canEdit && setEditingName(true)} style={{ flex: 1, fontSize: sz.name, fontWeight: 600, color: compact ? 'var(--text-secondary)' : 'var(--text-primary)', cursor: canEdit ? 'text' : 'default' }}>{bag.name} )} - + {totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`} + {editingLimit && canEdit ? ( + <> + / + setLimitVal(e.target.value)} + onBlur={saveLimit} + onKeyDown={e => { if (e.key === 'Enter') saveLimit(); if (e.key === 'Escape') { setLimitVal(limitToInput(bag.weight_limit_grams)); setEditingLimit(false) } }} + inputMode="decimal" aria-label={t('packing.bagLimit')} placeholder={t('packing.bagLimit')} + style={{ width: 42, fontSize: sz.weight, padding: '1px 3px', borderRadius: 4, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', color: 'var(--text-primary)', background: 'transparent' }} /> + kg + + ) : bag.weight_limit_grams ? ( + canEdit && setEditingLimit(true)} title={t('packing.bagLimit')} + style={{ cursor: canEdit ? 'text' : 'default' }}> + / {(bag.weight_limit_grams / 1000).toFixed(1)} kg + + ) : canEdit ? ( + + ) : null} {canEdit && } diff --git a/client/src/components/Packing/PackingListPanelBagModal.tsx b/client/src/components/Packing/PackingListPanelBagModal.tsx index 995c6bcc3..fb638eb74 100644 --- a/client/src/components/Packing/PackingListPanelBagModal.tsx +++ b/client/src/components/Packing/PackingListPanelBagModal.tsx @@ -1,6 +1,6 @@ import { X, Plus } from 'lucide-react' import type { PackingState } from './usePackingListPanel' -import { itemWeight } from './packingListPanel.helpers' +import { bagFillPct, itemWeight } from './packingListPanel.helpers' import { BagCard } from './PackingListPanelBagCard' export function BagModal(S: PackingState) { @@ -8,6 +8,8 @@ export function BagModal(S: PackingState) { setShowBagModal, t, bags, items, tripId, tripMembers, canEdit, handleDeleteBag, handleUpdateBag, handleSetBagMembers, showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, } = S + // Reference for bags without a limit of their own — computed once instead of per bag. + const heaviestBagWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1) return (
setShowBagModal(false)}> @@ -21,8 +23,7 @@ export function BagModal(S: PackingState) { {bags.map(bag => { const bagItems = items.filter(i => i.bag_id === bag.id) const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0) - const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1) - const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100)) + const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight) return ( handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} /> ) diff --git a/client/src/components/Packing/PackingListPanelBagSidebar.tsx b/client/src/components/Packing/PackingListPanelBagSidebar.tsx index 03e4e1dcf..7c54d266b 100644 --- a/client/src/components/Packing/PackingListPanelBagSidebar.tsx +++ b/client/src/components/Packing/PackingListPanelBagSidebar.tsx @@ -1,6 +1,6 @@ import { Plus } from 'lucide-react' import type { PackingState } from './usePackingListPanel' -import { itemWeight } from './packingListPanel.helpers' +import { bagFillPct, itemWeight } from './packingListPanel.helpers' import { BagCard } from './PackingListPanelBagCard' export function BagSidebar(S: PackingState) { @@ -8,6 +8,8 @@ export function BagSidebar(S: PackingState) { t, bags, items, tripId, tripMembers, canEdit, handleDeleteBag, handleUpdateBag, handleSetBagMembers, showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, } = S + // Reference for bags without a limit of their own — computed once instead of per bag. + const heaviestBagWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1) return (
@@ -17,8 +19,7 @@ export function BagSidebar(S: PackingState) { {bags.map(bag => { const bagItems = items.filter(i => i.bag_id === bag.id) const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0) - const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1) - const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100)) + const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight) return ( handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact /> ) diff --git a/client/src/components/Packing/packingListPanel.helpers.test.ts b/client/src/components/Packing/packingListPanel.helpers.test.ts index 696c3cf9c..99786e95d 100644 --- a/client/src/components/Packing/packingListPanel.helpers.test.ts +++ b/client/src/components/Packing/packingListPanel.helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { katColor, itemWeight, parseCsvLine, parseImportLines } from './packingListPanel.helpers' +import { katColor, itemWeight, bagFillPct, parseCsvLine, parseImportLines } from './packingListPanel.helpers' import { KAT_COLORS } from './packingListPanel.constants' describe('packingListPanel.helpers', () => { @@ -46,6 +46,27 @@ describe('packingListPanel.helpers', () => { }) }) + describe('bagFillPct', () => { + it('measures against the bag limit when there is one', () => { + expect(bagFillPct(5000, 20000, 99999)).toBe(25) + expect(bagFillPct(20000, 20000, 1)).toBe(100) + }) + + it('never reports more than full', () => { + expect(bagFillPct(30000, 20000, 1)).toBe(100) + }) + + it('falls back to the heaviest bag when no limit is set', () => { + expect(bagFillPct(2500, null, 5000)).toBe(50) + expect(bagFillPct(2500, undefined, 5000)).toBe(50) + expect(bagFillPct(0, 0, 5000)).toBe(0) + }) + + it('does not divide by zero on an empty trip', () => { + expect(bagFillPct(0, null, 0)).toBe(0) + }) + }) + describe('parseCsvLine', () => { it('splits on comma, semicolon and tab and trims fields', () => { expect(parseCsvLine('a, b ;c\td')).toEqual(['a', 'b', 'c', 'd']) diff --git a/client/src/components/Packing/packingListPanel.helpers.ts b/client/src/components/Packing/packingListPanel.helpers.ts index 023924f8a..6f030875c 100644 --- a/client/src/components/Packing/packingListPanel.helpers.ts +++ b/client/src/components/Packing/packingListPanel.helpers.ts @@ -14,6 +14,15 @@ export function katColor(kat: string, allCategories?: string[]): string { export const itemWeight = (i: { weight_grams?: number | null; quantity?: number | null }): number => (i.weight_grams || 0) * (i.quantity || 1) +/** + * How full a bag's bar reads. A bag with a weight limit is measured against that limit — + * that is the number an airline cares about. Without one there is nothing absolute to + * measure against, so bags are shown relative to the heaviest one and stay comparable. + * Lives here because three surfaces draw this bar and one of them used to forget the limit. + */ +export const bagFillPct = (bagWeight: number, limitGrams: number | null | undefined, heaviestBagWeight: number): number => + Math.min(100, Math.round((bagWeight / (limitGrams || Math.max(heaviestBagWeight, 1))) * 100)) + // Parse CSV line respecting quoted values (e.g. "Shirt, blue" stays as one field) export const parseCsvLine = (line: string): string[] => { const parts: string[] = [] diff --git a/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx b/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx index b63e60e91..f7a863149 100644 --- a/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx @@ -7,6 +7,7 @@ import { avatarSrc } from '../../../../utils/avatarSrc' import type { PackingBag, PackingItem, TripMember } from '../../../../types' import type { TripPlanner } from '../MTripShell' import { formatWeight, packingItemWeight } from './listsModel' +import { bagFillPct } from '../../../../components/Packing/packingListPanel.helpers' export interface MBagsSheetProps { planner: TripPlanner @@ -37,6 +38,8 @@ export default function MBagsSheet({ const unassigned = items.filter(i => !i.bag_id) const unassignedWeight = unassigned.reduce((s, i) => s + packingItemWeight(i), 0) const totalWeight = items.reduce((s, i) => s + packingItemWeight(i), 0) + // Reference for bags without a limit of their own — computed once instead of per bag. + const heaviestBagWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + packingItemWeight(i), 0)), 1) const submitNewBag = () => { if (!newBagName.trim()) return @@ -53,9 +56,7 @@ export default function MBagsSheet({ {bags.map(bag => { const bagItems = items.filter(i => i.bag_id === bag.id) const bagWeight = bagItems.reduce((s, i) => s + packingItemWeight(i), 0) - const maxWeight = bag.weight_limit_grams - || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + packingItemWeight(i), 0)), 1) - const pct = Math.min(100, Math.round((bagWeight / maxWeight) * 100)) + const pct = bagFillPct(bagWeight, bag.weight_limit_grams, heaviestBagWeight) return ( (grams ? String(grams / 1000) : '') + const [editingLimit, setEditingLimit] = useState(false) + const [limitVal, setLimitVal] = useState(limitToInput(bag.weight_limit_grams)) + + const saveLimit = () => { + setEditingLimit(false) + const raw = limitVal.trim().replace(',', '.') + if (raw === '') { + if (bag.weight_limit_grams != null) onUpdate({ weight_limit_grams: null }) + return + } + const kg = Number(raw) + if (!Number.isFinite(kg) || kg <= 0) { setLimitVal(limitToInput(bag.weight_limit_grams)); return } + const grams = Math.round(kg * 1000) + if (grams !== bag.weight_limit_grams) onUpdate({ weight_limit_grams: grams }) + } + return (
@@ -178,7 +197,34 @@ function BagRow({ planner, bag, itemCount, weight, pct, tripMembers, canEdit, on {bag.name} )} - {formatWeight(weight)} + + {formatWeight(weight)} + {editingLimit && canEdit ? ( + <> + / + setLimitVal(e.target.value)} + onBlur={saveLimit} + onKeyDown={e => { if (e.key === 'Enter') saveLimit(); if (e.key === 'Escape') { setLimitVal(limitToInput(bag.weight_limit_grams)); setEditingLimit(false) } }} + className="w-9 border-b border-[color:var(--m-rowbr)] bg-transparent text-right text-m-ink outline-none" + /> + kg + + ) : bag.weight_limit_grams ? ( + + ) : canEdit ? ( + + ) : null} + {canEdit && (