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
@@ -5,7 +5,16 @@ import { useTranslation } from '../../i18n'
import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
interface TemplateItem { id: number; category_id: number; name: string; sort_order: number }
interface TemplateItem { id: number; category_id: number; name: string; sort_order: number; weight_grams?: number | null; quantity?: number | null; bag_name?: string | null }
/** "2× · 300 g · Backpack": what a template item brings along besides its name (#1131). */
function templateItemMeta(item: TemplateItem): string {
const parts: string[] = []
if ((item.quantity ?? 1) > 1) parts.push(`${item.quantity}×`)
if (item.weight_grams) parts.push(item.weight_grams >= 1000 ? `${(item.weight_grams / 1000).toFixed(1)} kg` : `${item.weight_grams} g`)
if (item.bag_name) parts.push(item.bag_name)
return parts.join(' · ')
}
interface Template { id: number; name: string; item_count: number; category_count: number; created_by_name: string }
export default function PackingTemplateManager() {
@@ -252,6 +261,9 @@ export default function PackingTemplateManager() {
) : (
<>
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
{templateItemMeta(item) && (
<span className="text-caption text-content-faint tabular-nums truncate max-w-[45%]">{templateItemMeta(item)}</span>
)}
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
<button type="button" onClick={() => handleDeleteItem(item.id)}
@@ -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)
}
+12
View File
@@ -5409,6 +5409,18 @@ function runMigrations(db: Database.Database): void {
() => {
db.exec('CREATE TABLE IF NOT EXISTS google_api_usage (day TEXT PRIMARY KEY, calls INTEGER NOT NULL DEFAULT 0)');
},
/**
* Packing templates remember what an item weighs, how many of it go along
* and which bag it lives in (#1131), so applying one does not mean typing
* all of that in again. The bag is kept by name: bags belong to a trip.
*/
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('packing_template_items')").all() as { name: string }[];
const has = (c: string) => cols.some(col => col.name === c);
if (!has('weight_grams')) db.exec('ALTER TABLE packing_template_items ADD COLUMN weight_grams INTEGER');
if (!has('quantity')) db.exec('ALTER TABLE packing_template_items ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1');
if (!has('bag_name')) db.exec('ALTER TABLE packing_template_items ADD COLUMN bag_name TEXT');
},
];
if (currentVersion < migrations.length) {
+30 -20
View File
@@ -474,6 +474,20 @@ export class PackingService {
return item;
}
/**
* The trip's bag called `name`, created with the next colour when there is
* none yet. Shared by the bulk import and template apply (#1131), which both
* carry bags by name. Runs inside the caller's transaction.
*/
private bagIdByName(tripId: string | number, name: string | null | undefined): number | bigint | null {
const bagName = name?.trim();
if (!bagName) return null;
const existing = this.db.get<{ id: number }>('SELECT id FROM packing_bags WHERE trip_id = ? AND name = ?', tripId, bagName);
if (existing) return existing.id;
const bagCount = this.db.get<{ c: number }>('SELECT COUNT(*) as c FROM packing_bags WHERE trip_id = ?', tripId)!.c;
return this.db.run('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)', tripId, bagName, BAG_COLORS[bagCount % BAG_COLORS.length]).lastInsertRowid;
}
// ── Bulk Import ────────────────────────────────────────────────────────────
bulkImport(tripId: string | number, items: ImportItem[], ownerId?: number) {
@@ -489,19 +503,7 @@ export class PackingService {
const checked = item.checked ? 1 : 0;
const weight = item.weight_grams ? Number.parseInt(String(item.weight_grams)) || null : null;
// Resolve bag by name if provided
let bagId = null;
if (item.bag?.trim()) {
const bagName = item.bag.trim();
const existing = this.db.get<{ id: number }>('SELECT id FROM packing_bags WHERE trip_id = ? AND name = ?', tripId, bagName);
if (existing) {
bagId = existing.id;
} else {
const bagCount = this.db.get<{ c: number }>('SELECT COUNT(*) as c FROM packing_bags WHERE trip_id = ?', tripId)!.c;
const newBag = this.db.run('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)', tripId, bagName, BAG_COLORS[bagCount % BAG_COLORS.length]);
bagId = newBag.lastInsertRowid;
}
}
const bagId = this.bagIdByName(tripId, item.bag);
const qty = Math.max(1, Math.min(999, Number(item.quantity) || 1));
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++, qty, item.is_private ? 1 : 0, ownerId ?? null);
@@ -683,8 +685,8 @@ export class PackingService {
visibility: 'common' | 'personal' = 'common',
ownerId?: number,
) {
const templateItems = this.db.all<{ name: string; category: string }>(`
SELECT ti.name, tc.name as category
const templateItems = this.db.all<{ name: string; category: string; weight_grams: number | null; quantity: number | null; bag_name: string | null }>(`
SELECT ti.name, tc.name as category, ti.weight_grams, ti.quantity, ti.bag_name
FROM packing_template_items ti
JOIN packing_template_categories tc ON ti.category_id = tc.id
WHERE tc.template_id = ?
@@ -698,11 +700,14 @@ export class PackingService {
const isPrivate = ownerId != null ? this.visibilityToPrivate(visibility) : 0;
const owner = isPrivate ? ownerId! : null;
const insert = this.db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, is_private, owner_id, updated_at) VALUES (?, ?, 0, ?, ?, ?, ?, CURRENT_TIMESTAMP)');
const insert = this.db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, is_private, owner_id, weight_grams, quantity, bag_id, updated_at) VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)');
const added: any[] = [];
this.db.transaction(() => {
for (const ti of templateItems) {
const result = insert.run(tripId, ti.name, ti.category, sortOrder++, isPrivate, owner);
// Weight, count and bag ride along since #1131; a bag the trip lacks is
// created, the way the import does it.
const bagId = this.bagIdByName(tripId, ti.bag_name);
const result = insert.run(tripId, ti.name, ti.category, sortOrder++, isPrivate, owner, ti.weight_grams ?? null, Math.max(1, ti.quantity ?? 1), bagId);
const item = this.db.get('SELECT * FROM packing_items WHERE id = ?', result.lastInsertRowid);
added.push(item);
}
@@ -717,8 +722,10 @@ export class PackingService {
// A template is a durable, shareable artifact, so it may only capture what is
// the actor's to publish: the Common list plus their own items. It used to
// take every row in the trip, restricted ones included.
const items = this.db.all<{ name: string; category: string }>(
'SELECT name, category FROM packing_items WHERE trip_id = ? AND (is_private = 0 OR owner_id = ?) ORDER BY sort_order ASC',
const items = this.db.all<{ name: string; category: string; weight_grams: number | null; quantity: number | null; bag_name: string | null }>(
`SELECT i.name, i.category, i.weight_grams, i.quantity, b.name AS bag_name
FROM packing_items i LEFT JOIN packing_bags b ON b.id = i.bag_id
WHERE i.trip_id = ? AND (i.is_private = 0 OR i.owner_id = ?) ORDER BY i.sort_order ASC`,
tripId, userId,
);
@@ -740,7 +747,10 @@ export class PackingService {
for (const item of items) {
const catId = catIdMap.get(item.category || 'Other')!;
const order = itemsByCategory.get(item.category || 'Other') || 0;
this.db.run('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)', catId, item.name, order);
this.db.run(
'INSERT INTO packing_template_items (category_id, name, sort_order, weight_grams, quantity, bag_name) VALUES (?, ?, ?, ?, ?, ?)',
catId, item.name, order, item.weight_grams ?? null, Math.max(1, item.quantity ?? 1), item.bag_name ?? null,
);
itemsByCategory.set(item.category || 'Other', order + 1);
}
return id;
@@ -121,6 +121,47 @@ describe('saveAsTemplate', () => {
});
});
describe('templates carry weight, quantity and bag (#1131)', () => {
it('PACK-SVC-1131-1: a saved template keeps each item\'s weight, count and bag name, and applying it restores them', () => {
const { user } = createUser(testDb);
const source = createTrip(testDb, user.id);
const bagId = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(source.id, 'Backpack', '#111111').lastInsertRowid;
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order, weight_grams, quantity, bag_id) VALUES (?, ?, ?, 1, 0, ?, ?, ?)')
.run(source.id, 'Socks', 'Clothes', 60, 4, bagId);
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, 1)').run(source.id, 'Charger', 'Tech');
const saved = svc.saveAsTemplate(source.id, user.id, 'Hiking')!;
const rows = testDb.prepare(`SELECT ti.name, ti.weight_grams, ti.quantity, ti.bag_name FROM packing_template_items ti
JOIN packing_template_categories tc ON tc.id = ti.category_id WHERE tc.template_id = ? ORDER BY ti.name`).all(saved.id);
expect(rows).toEqual([
{ name: 'Charger', weight_grams: null, quantity: 1, bag_name: null },
{ name: 'Socks', weight_grams: 60, quantity: 4, bag_name: 'Backpack' },
]);
// A fresh trip has no bag of that name, so applying creates one.
const target = createTrip(testDb, user.id);
const added = svc.applyTemplate(target.id, saved.id) as any[];
const socks = added.find(i => i.name === 'Socks');
expect(socks).toMatchObject({ weight_grams: 60, quantity: 4, checked: 0 });
const bag = testDb.prepare('SELECT id, name FROM packing_bags WHERE trip_id = ?').get(target.id) as { id: number; name: string };
expect(bag.name).toBe('Backpack');
expect(socks.bag_id).toBe(bag.id);
expect(added.find(i => i.name === 'Charger')).toMatchObject({ weight_grams: null, quantity: 1, bag_id: null });
});
it('PACK-SVC-1131-2: applying reuses a bag the trip already has under that name', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const existing = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Duffel', '#222222').lastInsertRowid;
const templateId = seedTemplate(user.id, ['Towel']);
testDb.prepare("UPDATE packing_template_items SET bag_name = 'Duffel', weight_grams = 400 WHERE name = 'Towel'").run();
const [towel] = svc.applyTemplate(trip.id, templateId) as any[];
expect(towel).toMatchObject({ bag_id: Number(existing), weight_grams: 400 });
expect(testDb.prepare('SELECT COUNT(*) AS n FROM packing_bags WHERE trip_id = ?').get(trip.id)).toEqual({ n: 1 });
});
});
// ── listTemplates ───────────────────────────────────────────────────────────────
describe('listTemplates', () => {
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'أمتعة',
'packing.noBag': 'غير معيّن',
'packing.totalWeight': 'الوزن الإجمالي',
'packing.packedWeight': 'تم حزمه',
'packing.perPerson': 'لكل شخص',
'packing.perPersonSharedHint': 'يشمل حصة من حقيبة يحملها عدة أشخاص، مقسمة بالتساوي',
'packing.quantity': 'الكمية',
'packing.bagName': 'الاسم...',
'packing.addBag': 'إضافة أمتعة',
+3
View File
@@ -81,6 +81,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Çantalar',
'packing.noBag': 'Təyin edilməyib',
'packing.totalWeight': 'Ümumi çəki',
'packing.packedWeight': 'Yığılıb',
'packing.perPerson': 'Hər nəfərə',
'packing.perPersonSharedHint': 'Bir neçə nəfərin daşıdığı çantadan bərabər bölünmüş payı daxildir',
'packing.quantity': 'Say',
'packing.bagName': 'Çantanın adı...',
'packing.addBag': 'Çanta əlavə et',
+3
View File
@@ -65,6 +65,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Malas',
'packing.noBag': 'Sem mala',
'packing.totalWeight': 'Peso total',
'packing.packedWeight': 'Embalado',
'packing.perPerson': 'Por pessoa',
'packing.perPersonSharedHint': 'Inclui a parte de uma mala carregada por várias pessoas, dividida igualmente',
'packing.quantity': 'Qtd.',
'packing.bagName': 'Nome da mala...',
'packing.addBag': 'Adicionar mala',
+3
View File
@@ -54,6 +54,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Equipatge',
'packing.noBag': 'Sense assignar',
'packing.totalWeight': 'Pes total',
'packing.packedWeight': 'Empaquetat',
'packing.perPerson': 'Per persona',
'packing.perPersonSharedHint': "Inclou la part d'una bossa que porten diverses persones, repartida a parts iguals",
'packing.bagName': 'Nom...',
'packing.addBag': 'Afegeix equipatge',
'packing.changeCategory': 'Canvia la categoria',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Zavazadla',
'packing.noBag': 'Nepřiřazeno',
'packing.totalWeight': 'Celková váha',
'packing.packedWeight': 'Zabaleno',
'packing.perPerson': 'Na osobu',
'packing.perPersonSharedHint': 'Zahrnuje podíl na zavazadle, které nese více lidí, rozdělený rovným dílem',
'packing.quantity': 'Počet',
'packing.bagName': 'Název zavazadla...',
'packing.addBag': 'Přidat zavazadlo',
+3
View File
@@ -67,6 +67,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Gepäck',
'packing.noBag': 'Nicht zugeordnet',
'packing.totalWeight': 'Gesamtgewicht',
'packing.packedWeight': 'Gepackt',
'packing.perPerson': 'Pro Person',
'packing.perPersonSharedHint': 'Enthält den Anteil an einer Tasche, die mehrere tragen, gleichmäßig aufgeteilt',
'packing.quantity': 'Menge',
'packing.bagName': 'Name...',
'packing.addBag': 'Gepäck hinzufügen',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Bags',
'packing.noBag': 'Unassigned',
'packing.totalWeight': 'Total weight',
'packing.packedWeight': 'Packed',
'packing.perPerson': 'Per person',
'packing.perPersonSharedHint': 'Includes a share of a bag carried by several people, split evenly',
'packing.quantity': 'Qty',
'packing.bagName': 'Bag name...',
'packing.addBag': 'Add bag',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Equipaje',
'packing.noBag': 'Sin asignar',
'packing.totalWeight': 'Peso total',
'packing.packedWeight': 'Empacado',
'packing.perPerson': 'Por persona',
'packing.perPersonSharedHint': 'Incluye la parte de una bolsa que llevan varias personas, repartida a partes iguales',
'packing.quantity': 'Cant.',
'packing.bagName': 'Nombre...',
'packing.addBag': 'Añadir equipaje',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Kotid',
'packing.noBag': 'Määramata',
'packing.totalWeight': 'Kogukaal',
'packing.packedWeight': 'Pakitud',
'packing.perPerson': 'Inimese kohta',
'packing.perPersonSharedHint': 'Sisaldab osa kotist, mida kannab mitu inimest, jagatuna võrdselt',
'packing.quantity': 'Kogus',
'packing.bagName': 'Koti nimi...',
'packing.addBag': 'Lisa kott',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Bagages',
'packing.noBag': 'Non assigné',
'packing.totalWeight': 'Poids total',
'packing.packedWeight': 'Emballé',
'packing.perPerson': 'Par personne',
'packing.perPersonSharedHint': "Inclut la part d'un sac porté par plusieurs personnes, répartie à parts égales",
'packing.quantity': 'Qté',
'packing.bagName': 'Nom...',
'packing.addBag': 'Ajouter un bagage',
+3
View File
@@ -67,6 +67,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Τσάντες',
'packing.noBag': 'Χωρίς ανάθεση',
'packing.totalWeight': 'Συνολικό βάρος',
'packing.packedWeight': 'Πακεταρισμένα',
'packing.perPerson': 'Ανά άτομο',
'packing.perPersonSharedHint': 'Περιλαμβάνει μερίδιο από τσάντα που κουβαλούν πολλοί, μοιρασμένο εξίσου',
'packing.quantity': 'Ποσ.',
'packing.bagName': 'Όνομα τσάντας...',
'packing.addBag': 'Προσθήκη τσάντας',
+3
View File
@@ -67,6 +67,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Táskák',
'packing.noBag': 'Nincs hozzárendelve',
'packing.totalWeight': 'Összsúly',
'packing.packedWeight': 'Bepakolva',
'packing.perPerson': 'Személyenként',
'packing.perPersonSharedHint': 'Tartalmazza egy több ember által vitt táska egyenlően elosztott részét',
'packing.quantity': 'Menny.',
'packing.bagName': 'Táska neve...',
'packing.addBag': 'Táska hozzáadása',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Tas',
'packing.noBag': 'Belum ditugaskan',
'packing.totalWeight': 'Total berat',
'packing.packedWeight': 'Sudah dikemas',
'packing.perPerson': 'Per orang',
'packing.perPersonSharedHint': 'Termasuk bagian dari tas yang dibawa beberapa orang, dibagi rata',
'packing.quantity': 'Jml',
'packing.bagName': 'Nama tas...',
'packing.addBag': 'Tambah tas',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Valigie',
'packing.noBag': 'Non assegnato',
'packing.totalWeight': 'Peso totale',
'packing.packedWeight': 'Impacchettato',
'packing.perPerson': 'Per persona',
'packing.perPersonSharedHint': 'Include la quota di una borsa portata da più persone, divisa in parti uguali',
'packing.quantity': 'Qtà',
'packing.bagName': 'Nome valigia...',
'packing.addBag': 'Aggiungi valigia',
+3
View File
@@ -65,6 +65,9 @@ const packing: TranslationStrings = {
'packing.bags': 'バッグ',
'packing.noBag': '未割り当て',
'packing.totalWeight': '総重量',
'packing.packedWeight': 'パッキング済み',
'packing.perPerson': '1人あたり',
'packing.perPersonSharedHint': '複数人で持つバッグの均等な分担分を含みます',
'packing.quantity': '数量',
'packing.bagName': 'バッグ名...',
'packing.addBag': 'バッグを追加',
+3
View File
@@ -65,6 +65,9 @@ const packing: TranslationStrings = {
'packing.bags': '가방',
'packing.noBag': '미배정',
'packing.totalWeight': '총 무게',
'packing.packedWeight': '챙김',
'packing.perPerson': '1인당',
'packing.perPersonSharedHint': '여러 명이 함께 드는 가방의 몫이 균등하게 나뉘어 포함됩니다',
'packing.quantity': '수량',
'packing.bagName': '가방 이름...',
'packing.addBag': '가방 추가',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Bagage',
'packing.noBag': 'Niet toegewezen',
'packing.totalWeight': 'Totaalgewicht',
'packing.packedWeight': 'Ingepakt',
'packing.perPerson': 'Per persoon',
'packing.perPersonSharedHint': 'Bevat een gelijk deel van een tas die door meerdere mensen wordt gedragen',
'packing.quantity': 'Aantal',
'packing.bagName': 'Naam...',
'packing.addBag': 'Bagage toevoegen',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Torby',
'packing.noBag': 'Nieprzypisane',
'packing.totalWeight': 'Waga całkowita',
'packing.packedWeight': 'Spakowane',
'packing.perPerson': 'Na osobę',
'packing.perPersonSharedHint': 'Zawiera równy udział w torbie niesionej przez kilka osób',
'packing.quantity': 'Ilość',
'packing.bagName': 'Nazwa torby...',
'packing.addBag': 'Dodaj torbę',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Багаж',
'packing.noBag': 'Не назначено',
'packing.totalWeight': 'Общий вес',
'packing.packedWeight': 'Упаковано',
'packing.perPerson': 'На человека',
'packing.perPersonSharedHint': 'Включает долю сумки, которую несут несколько человек, поровну',
'packing.quantity': 'Кол-во',
'packing.bagName': 'Название...',
'packing.addBag': 'Добавить багаж',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Zavazadlá',
'packing.noBag': 'Nepriradené',
'packing.totalWeight': 'Celková hmotnosť',
'packing.packedWeight': 'Zbalené',
'packing.perPerson': 'Na osobu',
'packing.perPersonSharedHint': 'Zahŕňa podiel na batožine, ktorú nesie viac ľudí, rozdelený rovnakým dielom',
'packing.quantity': 'Počet',
'packing.bagName': 'Názov zavazadla...',
'packing.addBag': 'Pridať zavazadlo',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Väskor',
'packing.noBag': 'Ej tilldelad',
'packing.totalWeight': 'Totalvikt',
'packing.packedWeight': 'Packat',
'packing.perPerson': 'Per person',
'packing.perPersonSharedHint': 'Inkluderar en jämnt fördelad andel av en väska som flera bär',
'packing.quantity': 'Antal',
'packing.bagName': 'Väskans namn...',
'packing.addBag': 'Lägg till väska',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': "กระเป๋า",
'packing.noBag': "ยังไม่ได้มอบหมาย",
'packing.totalWeight': "น้ำหนักรวม",
'packing.packedWeight': 'แพ็กแล้ว',
'packing.perPerson': 'ต่อคน',
'packing.perPersonSharedHint': 'รวมส่วนแบ่งของกระเป๋าที่หลายคนช่วยกันถือ แบ่งเท่า ๆ กัน',
'packing.quantity': "จำนวน",
'packing.bagName': "ชื่อกระเป๋า...",
'packing.addBag': "เพิ่มกระเป๋า",
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Çantalar',
'packing.noBag': 'Atanmamış',
'packing.totalWeight': 'Toplam ağırlık',
'packing.packedWeight': 'Paketlendi',
'packing.perPerson': 'Kişi başı',
'packing.perPersonSharedHint': 'Birkaç kişinin taşıdığı bir çantadan eşit bölünmüş payı içerir',
'packing.quantity': 'Adet',
'packing.bagName': 'Çanta adı...',
'packing.addBag': 'Çanta ekle',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Багаж',
'packing.noBag': 'Не призначено',
'packing.totalWeight': 'Загальна вага',
'packing.packedWeight': 'Спаковано',
'packing.perPerson': 'На особу',
'packing.perPersonSharedHint': 'Містить частку сумки, яку несуть кілька людей, порівну',
'packing.quantity': 'К-сть',
'packing.bagName': 'Назва...',
'packing.addBag': 'Додати багаж',
+3
View File
@@ -66,6 +66,9 @@ const packing: TranslationStrings = {
'packing.bags': 'Túi xách',
'packing.noBag': 'Chưa được chỉ định',
'packing.totalWeight': 'Tổng trọng lượng',
'packing.packedWeight': 'Đã xếp',
'packing.perPerson': 'Mỗi người',
'packing.perPersonSharedHint': 'Bao gồm phần chia đều của một túi do nhiều người mang',
'packing.quantity': 'SL',
'packing.bagName': 'Tên túi...',
'packing.addBag': 'Thêm túi',
+3
View File
@@ -65,6 +65,9 @@ const packing: TranslationStrings = {
'packing.bags': '行李',
'packing.noBag': '未分配',
'packing.totalWeight': '總重量',
'packing.packedWeight': '已打包',
'packing.perPerson': '每人',
'packing.perPersonSharedHint': '包含多人共同攜帶的行李中平均分攤的部分',
'packing.quantity': '數量',
'packing.bagName': '名稱...',
'packing.addBag': '新增行李',
+3
View File
@@ -65,6 +65,9 @@ const packing: TranslationStrings = {
'packing.bags': '行李',
'packing.noBag': '未分配',
'packing.totalWeight': '总重量',
'packing.packedWeight': '已打包',
'packing.perPerson': '每人',
'packing.perPersonSharedHint': '包含多人共同携带的行李中平均分摊的部分',
'packing.quantity': '数量',
'packing.bagName': '名称...',
'packing.addBag': '添加行李',