feat(client): let people actually type a bag weight limit

The column, the contract, the API route and its tests have all been there
since v2.9.0 — there was simply no field to enter a limit in, so the only
way to record one was to put it in the bag name, where nothing can act on
it. Both the desktop bag card and the mobile bag row now take a limit in
kilograms (how airlines state them) and store it in grams. Emptying the
field clears the limit instead of writing a zero.

While adding it: the fill-bar formula existed in three places, and the one
in the bag modal had been copied without the limit, so a limit set on a
window narrower than xl did nothing. It now lives once in
packingListPanel.helpers as bagFillPct, next to itemWeight.
This commit is contained in:
Maurice
2026-08-03 20:52:59 +02:00
committed by Maurice
parent 628f4e78cd
commit e25a1276dc
8 changed files with 215 additions and 12 deletions
@@ -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<string, unknown> | 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<string, unknown>;
return HttpResponse.json({ bag: { id: 12, name: 'Cabin bag', color: '#10b981', weight_limit_grams: 8000, members: [] } });
})
);
render(<PackingListPanel tripId={1} items={[buildPackingItem({ name: 'Jacket', category: 'Clothing' })]} />);
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<string, unknown> | 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<string, unknown>;
return HttpResponse.json({ bag: { id: 13, name: 'Hold bag', color: '#6366f1', weight_limit_grams: null, members: [] } });
})
);
render(<PackingListPanel tripId={1} items={[buildPackingItem({ name: 'Boots', category: 'Clothing' })]} />);
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(
@@ -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,
) : (
<span onClick={() => 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}</span>
)}
<span style={{ fontSize: sz.weight, color: 'var(--text-faint)', fontWeight: 500 }}>
<span style={{ fontSize: sz.weight, color: 'var(--text-faint)', fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 3 }}>
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
{editingLimit && canEdit ? (
<>
<span>/</span>
<input autoFocus value={limitVal} onChange={e => 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' }} />
<span>kg</span>
</>
) : bag.weight_limit_grams ? (
<span onClick={() => canEdit && setEditingLimit(true)} title={t('packing.bagLimit')}
style={{ cursor: canEdit ? 'text' : 'default' }}>
/ {(bag.weight_limit_grams / 1000).toFixed(1)} kg
</span>
) : canEdit ? (
<button onClick={() => setEditingLimit(true)} title={t('packing.bagLimit')}
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', color: 'var(--text-faint)', fontSize: sz.count, fontFamily: 'inherit', textDecoration: 'underline dotted' }}>
{t('packing.setBagLimit')}
</button>
) : null}
</span>
{canEdit && <button onClick={onDelete} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}><X size={sz.icon} /></button>}
</div>
@@ -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 (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
onClick={() => 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 (
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
)
@@ -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 (
<div className="hidden xl:block" style={{ width: 260, marginLeft: 16, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
@@ -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 (
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
)
@@ -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'])
@@ -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[] = []
@@ -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 (
<BagRow
key={bag.id}
@@ -159,6 +160,24 @@ function BagRow({ planner, bag, itemCount, weight, pct, tripMembers, canEdit, on
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))
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 (
<div className="mb-4">
<div className="mb-1 flex items-center gap-[8px]">
@@ -178,7 +197,34 @@ function BagRow({ planner, bag, itemCount, weight, pct, tripMembers, canEdit, on
{bag.name}
</button>
)}
<span className="font-geist text-[0.71875rem] font-medium text-m-faint">{formatWeight(weight)}</span>
<span className="flex flex-none items-center gap-1 font-geist text-[0.71875rem] font-medium text-m-faint">
{formatWeight(weight)}
{editingLimit && canEdit ? (
<>
<span>/</span>
<input
type="text"
autoFocus
inputMode="decimal"
value={limitVal}
aria-label={t('packing.bagLimit')}
onChange={e => 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"
/>
<span>kg</span>
</>
) : bag.weight_limit_grams ? (
<button type="button" onClick={() => canEdit && setEditingLimit(true)} aria-label={t('packing.bagLimit')}>
/ {(bag.weight_limit_grams / 1000).toFixed(1)} kg
</button>
) : canEdit ? (
<button type="button" onClick={() => setEditingLimit(true)} className="underline decoration-dotted">
{t('packing.setBagLimit')}
</button>
) : null}
</span>
{canEdit && (
<button type="button" onClick={onDelete} aria-label={t('common.delete')} className="flex flex-none items-center text-m-faint">
<X size={14} strokeWidth={2} />
@@ -247,4 +247,33 @@ describe('MBagsSheet', () => {
expect(onClose).toHaveBeenCalled()
})
// #207: the limit was storable and readable but had nowhere to be typed in.
it('FE-MOB-BAGS-020: a limit typed in kg is stored in grams', () => {
const { onUpdateBag } = setup({ bags: [bag({ id: 1, name: 'Backpack' })] })
fireEvent.click(screen.getByRole('button', { name: 'packing.setBagLimit' }))
const input = screen.getByLabelText('packing.bagLimit')
fireEvent.change(input, { target: { value: '7.5' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateBag).toHaveBeenCalledWith(1, { weight_limit_grams: 7500 })
})
it('FE-MOB-BAGS-021: emptying the field clears the limit instead of storing zero', () => {
const { onUpdateBag } = setup({ bags: [bag({ id: 1, name: 'Backpack', weight_limit_grams: 20000 })] })
fireEvent.click(screen.getByRole('button', { name: 'packing.bagLimit' }))
const input = screen.getByLabelText('packing.bagLimit')
fireEvent.change(input, { target: { value: '' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateBag).toHaveBeenCalledWith(1, { weight_limit_grams: null })
})
it('FE-MOB-BAGS-022: read-only mode offers no way to set a limit', () => {
setup({ canEdit: false, bags: [bag({ id: 1, name: 'Backpack' })] })
expect(screen.queryByRole('button', { name: 'packing.setBagLimit' })).toBeNull()
})
})