feat(packing): templates keep weight, quantity and bag; packed and per-person weights (#1131)

This commit is contained in:
Maurice
2026-10-01 12:47:58 +02:00
parent 88afdb986c
commit a416d41cda
37 changed files with 361 additions and 40 deletions
@@ -1,7 +1,8 @@
import { X, Plus } from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from './packingListPanel.helpers'
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, packedWeight, perPersonLoads, unassignedTotalWeight } from './packingListPanel.helpers'
import { BagCard } from './PackingListPanelBagCard'
import { PackingWeightSummary } from './PackingWeightSummary'
export function BagModal(S: PackingState) {
const {
@@ -63,18 +64,14 @@ export function BagModal(S: PackingState) {
)
})()}
{/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => {
// Same rule as the rows above it: a grand total mixing true bag
// weights with a per-viewer remainder would be worse than either.
const w = bags.reduce((s, b) => s + bagWeightOf(b), 0)
+ unassignedTotalWeight(unassignedWeightGrams, myItems.filter(i => !i.bag_id), serverWeightsFresh)
return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g`
})()}</span>
</div>
{/* Total, packed share and who carries what (#1131) */}
<div style={{ marginTop: 8, border: '1px solid var(--border-secondary)', borderRadius: 16, overflow: 'hidden' }}>
<PackingWeightSummary t={t} topRule={false}
// Same rule as the rows above it: a grand total mixing true bag
// weights with a per-viewer remainder would be worse than either.
total={bags.reduce((s, b) => s + bagWeightOf(b), 0) + unassignedTotalWeight(unassignedWeightGrams, myItems.filter(i => !i.bag_id), serverWeightsFresh)}
packed={packedWeight(myItems)}
people={perPersonLoads(bags, bagWeightOf)} />
</div>
{/* Add bag */}
@@ -1,8 +1,9 @@
import { useCallback, useRef } from 'react'
import { Briefcase, Plus, X } from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from './packingListPanel.helpers'
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, packedWeight, perPersonLoads, unassignedTotalWeight } from './packingListPanel.helpers'
import { BagCard } from './PackingListPanelBagCard'
import { PackingWeightSummary } from './PackingWeightSummary'
import { COMPOSER, composerConfirm, useDismissOnOutside } from './packingPopoverStyles'
const kgOrG = (grams: number) => (grams >= 1000 ? `${(grams / 1000).toFixed(1)} kg` : `${grams} g`)
@@ -91,10 +92,7 @@ export function BagSidebar(S: PackingState) {
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg-tertiary)', borderRadius: '0 0 15px 15px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{kgOrG(totalWeight)}</span>
</div>
<PackingWeightSummary t={t} total={totalWeight} packed={packedWeight(myItems)} people={perPersonLoads(bags, bagWeightOf)} />
</div>
</div>
)
@@ -89,3 +89,33 @@ describe('bag weights on the packing panels (#2191)', () => {
expect(screen.getByText('400 g')).toBeInTheDocument()
})
})
describe('packed weight and per-person loads (#1131)', () => {
it('FE-COMP-BAGWEIGHT-1131-1: the foot shows what of the total is already packed', () => {
const items = [{ ...ITEMS[0], checked: 1 }, { ...ITEMS[1], quantity: 2, packed_quantity: 1 }]
const { container } = render(<BagSidebar {...buildState({ items: items as never })} />)
// 300 g ticked + one of two 150 g sandwiches.
const packed = within(container).getByText('packing.packedWeight').parentElement as HTMLElement
expect(packed).toHaveTextContent('450 g')
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '30')
})
it('FE-COMP-BAGWEIGHT-1131-2: each bag member carries an even share of the bag', () => {
const bags = [
{ id: 10, trip_id: 1, name: 'Duffel', color: '#6366f1', sort_order: 0, members: [{ user_id: 1, username: 'Ana' }, { user_id: 2, username: 'Ben' }], total_weight_grams: 1000 },
{ id: 11, trip_id: 1, name: 'Daypack', color: '#10b981', sort_order: 1, members: [{ user_id: 2, username: 'Ben' }], total_weight_grams: 800 },
]
render(<BagModal {...buildState({ bags: bags as never })} />)
expect(screen.getByText('packing.perPerson')).toBeInTheDocument()
// Ben: half the duffel plus the daypack. Ana: the other half of the duffel.
const row = (name: string) => screen.getAllByText(name).map(el => el.parentElement?.textContent ?? '').find(text => text.endsWith(' g') || text.endsWith(' kg'))
expect(row('Ben')).toBe('BBen1.3 kg')
expect(row('Ana')).toBe('AAna500 g')
})
it('FE-COMP-BAGWEIGHT-1131-3: no per-person block while no bag has anyone on it', () => {
render(<BagSidebar {...buildState()} />)
expect(screen.queryByText('packing.perPerson')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,81 @@
import { Users } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import type { PersonLoad } from './packingListPanel.helpers'
const kgOrG = (grams: number) => (grams >= 1000 ? `${(grams / 1000).toFixed(1)} kg` : `${grams} g`)
interface Props {
t: (key: string, params?: Record<string, string | number>) => string
total: number
packed: number
people: PersonLoad[]
/** Hairline above the people block; off where the summary sits in its own frame. */
topRule?: boolean
}
/**
* The weight foot under the bags (#1131): who carries what, then the total with
* the packed part of it as a thin bar. Shared by the sidebar and the bag modal so
* both read the same.
*/
export function PackingWeightSummary({ t, total, packed, people, topRule = true }: Props) {
const pct = total > 0 ? Math.min(100, Math.round((packed / total) * 100)) : 0
return (
<div>
{people.length > 0 && (
<div style={{ padding: '10px 14px 8px', borderTop: topRule ? '1px solid var(--border-faint)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<Users size={12} style={{ color: 'var(--text-faint)' }} />
<span style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{t('packing.perPerson')}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{people.map(p => (
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
{p.avatar ? (
<img src={p.avatar} alt="" style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
) : (
<span aria-hidden style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)', color: 'var(--text-muted)', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, display: 'grid', placeItems: 'center', flexShrink: 0 }}>
{p.username[0]?.toUpperCase()}
</span>
)}
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)' }}>
{p.username}
</span>
{p.shared ? (
<Tooltip label={t('packing.perPersonSharedHint')}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums', borderBottom: '1px dotted var(--text-faint)', cursor: 'help' }}>
{kgOrG(p.grams)}
</span>
</Tooltip>
) : (
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{kgOrG(p.grams)}</span>
)}
</div>
))}
</div>
</div>
)}
<div style={{ padding: '10px 14px', background: 'var(--bg-tertiary)', borderRadius: '0 0 15px 15px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{kgOrG(total)}</span>
</div>
{total > 0 && (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 4, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)' }}>
<span>{t('packing.packedWeight')}</span>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{kgOrG(packed)}</span>
</div>
<div role="progressbar" aria-label={t('packing.packedWeight')} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}
style={{ height: 3, borderRadius: 99, background: 'var(--border-faint)', marginTop: 5, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', borderRadius: 99, background: 'var(--accent)', transition: 'width 0.3s ease' }} />
</div>
</>
)}
</div>
</div>
)
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { katColor, itemWeight, bagFillPct, bagTotalWeight, countsTowardsMyLoad, isMarkdownList, newItemSharing, parseCsvLine, parseImportLines, sortItemsByName, unassignedTotalWeight } from './packingListPanel.helpers'
import { katColor, itemWeight, bagFillPct, bagTotalWeight, countsTowardsMyLoad, isMarkdownList, newItemSharing, packedWeight, parseCsvLine, perPersonLoads, parseImportLines, sortItemsByName, unassignedTotalWeight } from './packingListPanel.helpers'
import { KAT_COLORS } from './packingListPanel.constants'
describe('packingListPanel.helpers', () => {
@@ -264,3 +264,31 @@ describe('newItemSharing (#2241)', () => {
expect(newItemSharing([mine(1, 'Beach', [8])], 'Beach', 'common', 7)).toEqual({ visibility: 'common' })
})
})
describe('packedWeight / perPersonLoads (#1131)', () => {
it('counts ticked items in full and partly packed ones by their packed count', () => {
expect(packedWeight([
{ weight_grams: 100, quantity: 3, checked: 1 },
{ weight_grams: 50, quantity: 4, packed_quantity: 2 },
{ weight_grams: 999, quantity: 1, checked: 0 },
{ weight_grams: null, quantity: 1, checked: 1 },
])).toBe(400)
})
it('splits a shared bag evenly, skips bags with nobody, and sorts heaviest first', () => {
const bags = [
{ id: 1, w: 900, members: [{ user_id: 1, username: 'A' }, { user_id: 2, username: 'B' }, { user_id: 3, username: 'C' }] },
{ id: 2, w: 1000, members: [{ user_id: 3, username: 'C' }] },
{ id: 3, w: 5000, members: [] },
]
expect(perPersonLoads(bags, b => b.w)).toEqual([
{ user_id: 3, username: 'C', avatar: undefined, grams: 1300, shared: true },
{ user_id: 1, username: 'A', avatar: undefined, grams: 300, shared: true },
{ user_id: 2, username: 'B', avatar: undefined, grams: 300, shared: true },
])
expect(perPersonLoads([{ members: [{ user_id: 9, username: 'Solo' }] }], () => 250)).toEqual([
{ user_id: 9, username: 'Solo', avatar: undefined, grams: 250, shared: false },
])
})
})
@@ -223,3 +223,34 @@ export function newItemSharing(
if (!own.length || !sets[0] || sets.some(set => set !== sets[0])) return { visibility: 'personal' }
return { visibility: 'shared', recipient_ids: sets[0].split(',').map(Number) }
}
/** What the packed part of these items weighs: unit weight times the packed count (#1131). */
export const packedWeight = (items: { weight_grams?: number | null; quantity?: number; checked?: number | boolean; packed_quantity?: number | null }[]): number =>
items.reduce((sum, i) => sum + (i.weight_grams || 0) * packedOf(i), 0)
export interface PersonLoad { user_id: number; username: string; avatar?: string | null; grams: number; shared: boolean }
/**
* What each person carries, from the bags they are a member of (#1131). A bag
* with several members is split evenly between them, so the rows add up to the
* weight of the bags that have anyone at all; `shared` marks a person with such
* a split share. Heaviest first.
*/
export function perPersonLoads<B extends { members?: { user_id: number; username: string; avatar?: string | null }[] }>(
bags: B[],
weightOf: (bag: B) => number,
): PersonLoad[] {
const byUser = new Map<number, PersonLoad>()
for (const bag of bags) {
const members = bag.members ?? []
if (members.length === 0) continue
const share = weightOf(bag) / members.length
for (const m of members) {
const row = byUser.get(m.user_id) ?? { user_id: m.user_id, username: m.username, avatar: m.avatar, grams: 0, shared: false }
row.grams += share
if (members.length > 1) row.shared = true
byUser.set(m.user_id, row)
}
}
return [...byUser.values()].map(r => ({ ...r, grams: Math.round(r.grams) })).sort((a, b) => b.grams - a.grams)
}