refactor(packing): share the bag editor and the bag weight totals between desktop and phone

useBagCardEditor holds the editing of a bag's name and weight limit for
the desktop bag card and MBagsSheet, and bagLoadSummary computes the
weight totals for the bag sidebar, the bag modal and the phone sheet.
The desktop card follows the bag when it changes elsewhere, the phone
row resets a name it did not save.
This commit is contained in:
Maurice
2026-10-08 21:08:31 +00:00
parent 4d4ef17de8
commit ad220d9608
8 changed files with 347 additions and 111 deletions
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useState, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { X, Plus } from 'lucide-react'
import type { PackingItem, PackingBag } from '../../types'
@@ -6,6 +6,7 @@ import type { TripMember } from './usePackingListPanel'
import { PopoverItem } from './PackingPopover'
import { POPOVER, POPOVER_CAPTION, useDismissOnOutside } from './packingPopoverStyles'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
import { useBagCardEditor } from './useBagCardEditor'
interface BagCardProps {
bag: PackingBag; bagItems: PackingItem[]; totalWeight: number; pct: number; tripId: number
@@ -15,8 +16,6 @@ interface BagCardProps {
}
export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers, canEdit, onDelete, onUpdate, onSetMembers, t, compact }: BagCardProps) {
const [editingName, setEditingName] = useState(false)
const [nameVal, setNameVal] = useState(bag.name)
const [showUserPicker, setShowUserPicker] = useState(false)
const membersRef = useRef<HTMLDivElement>(null)
const pickerRef = useRef<HTMLDivElement>(null)
@@ -24,39 +23,10 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
useDismissOnOutside(membersRef, showUserPicker, closePicker, pickerRef)
// The bag sidebar scrolls and would clip the list, so it hangs off the body under the faces.
const pickerBox = useAnchoredPosition(membersRef, showUserPicker, { estimatedHeight: 260, offset: 6 })
useEffect(() => setNameVal(bag.name), [bag.name])
const saveName = () => {
if (nameVal.trim() && nameVal.trim() !== bag.name) onUpdate(bag.id, { name: nameVal.trim() })
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]
onSetMembers(bag.id, next)
}
const {
editingName, setEditingName, nameVal, setNameVal, saveName, cancelName,
editingLimit, setEditingLimit, limitVal, setLimitVal, saveLimit, cancelLimit, memberIds, toggleMember,
} = useBagCardEditor({ bag, onUpdate: data => onUpdate(bag.id, data), onSetMembers: ids => onSetMembers(bag.id, ids), followBag: true })
const sz = compact ? { dot: 10, name: 12, weight: 11, bar: 6, count: 10, gap: 6, mb: 14, icon: 11, avatar: 18 } : { dot: 12, name: 14, weight: 13, bar: 8, count: 11, gap: 8, mb: 16, icon: 13, avatar: 22 }
@@ -68,7 +38,7 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
<span style={{ width: sz.dot, height: sz.dot, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
{editingName && canEdit ? (
<input autoFocus value={nameVal} onChange={e => setNameVal(e.target.value)}
onBlur={saveName} onKeyDown={e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') { setEditingName(false); setNameVal(bag.name) } }}
onBlur={saveName} onKeyDown={e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') cancelName() }}
style={{ flex: 1, fontSize: sz.name, fontWeight: 600, padding: '1px 4px', borderRadius: 4, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', color: 'var(--text-primary)', background: 'transparent' }} />
) : (
<button type="button" disabled={!canEdit} onClick={() => setEditingName(true)}
@@ -133,7 +103,7 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
<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) } }}
onKeyDown={e => { if (e.key === 'Enter') saveLimit(); if (e.key === 'Escape') cancelLimit() }}
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>
@@ -1,6 +1,6 @@
import { X, Plus } from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, packedWeight, perPersonLoads, unassignedTotalWeight } from './packingListPanel.helpers'
import { bagFillPct, bagLoadSummary, packedWeight, perPersonLoads } from './packingListPanel.helpers'
import { BagCard } from './PackingListPanelBagCard'
import { PackingWeightSummary } from './PackingWeightSummary'
@@ -10,14 +10,11 @@ export function BagModal(S: PackingState) {
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, unassignedWeightGrams, serverWeightsFresh,
} = S
// The ITEM LISTS still describe what you are carrying — an item someone shared
// with you stays in your list, but they are the one bringing it (#1767).
const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId))
// The WEIGHTS no longer do. A bag's load is the bag's, whoever packed it and
// with you stays in your list, but they are the one bringing it (#1767). The
// WEIGHTS no longer do. A bag's load is the bag's, whoever packed it and
// whether or not you may see the items, so it comes from the server (#2191).
const bagWeightOf = (bag: typeof bags[number]) =>
bagTotalWeight(bag, myItems.filter(i => i.bag_id === bag.id), serverWeightsFresh)
// Reference for bags without a limit of their own — computed once instead of per bag.
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
const { myItems, bagItemsOf, bagWeightOf, heaviestBagWeight, unassigned, unassignedWeight, totalWeight } =
bagLoadSummary(bags, items, currentUserId, unassignedWeightGrams, serverWeightsFresh)
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' }}
role="button" tabIndex={0} aria-label={t('common.close')}
@@ -34,18 +31,16 @@ export function BagModal(S: PackingState) {
</div>
{bags.map(bag => {
const bagItems = myItems.filter(i => i.bag_id === bag.id)
const totalWeight = bagWeightOf(bag)
const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight)
const bagItems = bagItemsOf(bag)
const bagWeight = bagWeightOf(bag)
const pct = bagFillPct(bagWeight, 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} />
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={bagWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
)
})}
{/* Unassigned */}
{(() => {
const unassigned = myItems.filter(i => !i.bag_id)
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
// Shown whenever there is weight to account for, even with no visible
// items: the grand total counts it, and a total nothing adds up to is
// the confusion this issue was about (#2191).
@@ -69,7 +64,7 @@ export function BagModal(S: PackingState) {
<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)}
total={totalWeight}
packed={packedWeight(myItems)}
people={perPersonLoads(bags, bagWeightOf)} />
</div>
@@ -1,7 +1,7 @@
import { useCallback, useRef } from 'react'
import { Briefcase, Plus, X } from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, packedWeight, perPersonLoads, unassignedTotalWeight } from './packingListPanel.helpers'
import { bagFillPct, bagLoadSummary, packedWeight, perPersonLoads } from './packingListPanel.helpers'
import { BagCard } from './PackingListPanelBagCard'
import { PackingWeightSummary } from './PackingWeightSummary'
import { COMPOSER, composerConfirm, useDismissOnOutside } from './packingPopoverStyles'
@@ -19,17 +19,10 @@ export function BagSidebar(S: PackingState) {
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, unassignedWeightGrams, serverWeightsFresh,
} = S
// The ITEM LISTS still describe what you are carrying — an item someone shared
// with you stays in your list, but they are the one bringing it (#1767).
const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId))
// The WEIGHTS no longer do: a bag's load is the bag's, whoever packed it (#2191).
const bagWeightOf = (bag: typeof bags[number]) =>
bagTotalWeight(bag, myItems.filter(i => i.bag_id === bag.id), serverWeightsFresh)
// Reference for bags without a limit of their own — computed once instead of per bag.
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
const unassigned = myItems.filter(i => !i.bag_id)
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
// Same rule as the rows above it (#2191).
const totalWeight = bags.reduce((s, b) => s + bagWeightOf(b), 0) + unassignedWeight
// with you stays in your list, but they are the one bringing it (#1767). The
// WEIGHTS no longer do: a bag's load is the bag's, whoever packed it (#2191).
const { myItems, bagItemsOf, bagWeightOf, heaviestBagWeight, unassigned, unassignedWeight, totalWeight } =
bagLoadSummary(bags, items, currentUserId, unassignedWeightGrams, serverWeightsFresh)
const hairline = (first: boolean): React.CSSProperties => ({ borderTop: first ? 'none' : '1px solid var(--border-faint)' })
const composerRef = useRef<HTMLDivElement>(null)
const closeComposer = useCallback(() => { setShowAddBag(false); setNewBagName('') }, [setShowAddBag, setNewBagName])
@@ -69,7 +62,7 @@ export function BagSidebar(S: PackingState) {
<div style={{ padding: '0 14px' }}>
{bags.map((bag, index) => {
const bagItems = myItems.filter(i => i.bag_id === bag.id)
const bagItems = bagItemsOf(bag)
const weight = bagWeightOf(bag)
const pct = bagFillPct(weight, bag.weight_limit_grams, heaviestBagWeight)
return (
@@ -1,5 +1,7 @@
import { describe, it, expect } from 'vitest'
import { katColor, itemWeight, bagFillPct, bagTotalWeight, countsTowardsMyLoad, isMarkdownList, newItemSharing, packedWeight, parseCsvLine, perPersonLoads, parseImportLines, sortItemsByName, unassignedTotalWeight } from './packingListPanel.helpers'
import { katColor, itemWeight, bagFillPct, bagLoadSummary, bagTotalWeight, countsTowardsMyLoad, isMarkdownList, newItemSharing, packedWeight, parseCsvLine, perPersonLoads, parseImportLines, sortItemsByName, unassignedTotalWeight } from './packingListPanel.helpers'
import { buildPackingItem } from '../../../tests/helpers/factories'
import type { PackingBag } from '../../types'
import { KAT_COLORS } from './packingListPanel.constants'
describe('packingListPanel.helpers', () => {
@@ -292,3 +294,43 @@ describe('packedWeight / perPersonLoads (#1131)', () => {
})
})
describe('bagLoadSummary: what every bag surface adds up (#1767, #2191)', () => {
const bag = (over: Partial<PackingBag>): PackingBag => ({ id: 1, trip_id: 1, name: 'Bag', color: '#000', sort_order: 0, ...over })
const backpack = bag({ id: 1, total_weight_grams: 4000, weight_limit_grams: 7000 })
const duffel = bag({ id: 2 })
const items = [
buildPackingItem({ id: 1, bag_id: 1, weight_grams: 500 }),
buildPackingItem({ id: 2, bag_id: 2, weight_grams: 300, quantity: 2 }),
buildPackingItem({ id: 3, bag_id: null, weight_grams: 200 }),
// Shared with me, but Ada brings it: not part of my load.
buildPackingItem({ id: 4, bag_id: 2, weight_grams: 900, is_private: 1, owner_id: 2 }),
buildPackingItem({ id: 5, bag_id: null, weight_grams: 50, is_private: 1, owner_id: 9 }),
]
it('lists only what I carry, and weighs bags by the server figure while it is fresh', () => {
const s = bagLoadSummary([backpack, duffel], items, 9, 1200, true)
expect(s.myItems.map(i => i.id)).toEqual([1, 2, 3, 5])
expect(s.bagItemsOf(duffel).map(i => i.id)).toEqual([2])
expect(s.bagWeightOf(backpack)).toBe(4000)
// No server figure on the bag: the visible items count.
expect(s.bagWeightOf(duffel)).toBe(600)
expect(s.heaviestBagWeight).toBe(4000)
expect(s.unassigned.map(i => i.id)).toEqual([3, 5])
expect(s.unassignedWeight).toBe(1200)
expect(s.totalWeight).toBe(4000 + 600 + 1200)
})
it('sums what it can see while offline', () => {
const s = bagLoadSummary([backpack, duffel], items, 9, 1200, false)
expect(s.bagWeightOf(backpack)).toBe(500)
expect(s.unassignedWeight).toBe(250)
expect(s.totalWeight).toBe(500 + 600 + 250)
expect(s.heaviestBagWeight).toBe(600)
})
it('scales against at least one gram when there are no bags or they weigh nothing', () => {
expect(bagLoadSummary([], [], 9, null, true)).toMatchObject({ heaviestBagWeight: 1, unassignedWeight: 0, totalWeight: 0 })
expect(bagLoadSummary([duffel], [], null, undefined, true).heaviestBagWeight).toBe(1)
})
})
@@ -1,3 +1,4 @@
import type { PackingBag, PackingItem } from '../../types'
import { KAT_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
// Stable color assignment: category name → index via simple hash
@@ -67,6 +68,29 @@ export const unassignedTotalWeight = (
? serverTotal
: visibleItems.reduce((sum, i) => sum + itemWeight(i), 0)
/**
* What every bag surface adds up: the bag sidebar and the bag dialog on the desktop
* and the bag sheet on the phone. The item lists describe what you carry (#1767),
* the weights what the bags hold, whoever packed them (#2191). The heaviest bag is
* the scale for bags without a limit of their own, worked out once for all of them.
*/
export function bagLoadSummary(
bags: PackingBag[],
items: PackingItem[],
currentUserId: number | null | undefined,
unassignedWeightGrams: number | null | undefined,
serverWeightsFresh: boolean,
) {
const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId))
const bagItemsOf = (bag: PackingBag) => myItems.filter(i => i.bag_id === bag.id)
const bagWeightOf = (bag: PackingBag) => bagTotalWeight(bag, bagItemsOf(bag), serverWeightsFresh)
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
const unassigned = myItems.filter(i => !i.bag_id)
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
const totalWeight = bags.reduce((s, b) => s + bagWeightOf(b), 0) + unassignedWeight
return { myItems, bagItemsOf, bagWeightOf, heaviestBagWeight, unassigned, unassignedWeight, totalWeight }
}
/**
* 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
@@ -0,0 +1,147 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { PackingBag } from '../../types';
import { bagLimitInput, useBagCardEditor } from './useBagCardEditor';
// FE-PACK-BAGEDIT-001 to FE-PACK-BAGEDIT-009
const BAG: PackingBag = {
id: 3,
trip_id: 7,
name: 'Backpack',
color: '#6366f1',
sort_order: 0,
weight_limit_grams: 7000,
members: [{ user_id: 1, username: 'me' }],
};
function setup(options: { followBag?: boolean; resetUnsavedName?: boolean } = {}, bag: PackingBag = BAG) {
const onUpdate = vi.fn();
const onSetMembers = vi.fn();
const rendered = renderHook(
(props: { bag: PackingBag }) => useBagCardEditor({ bag: props.bag, onUpdate, onSetMembers, ...options }),
{
initialProps: { bag },
}
);
return { ...rendered, onUpdate, onSetMembers };
}
describe('useBagCardEditor', () => {
it('FE-PACK-BAGEDIT-001: a limit reads in kilos, and no limit as an empty field', () => {
expect(bagLimitInput(7000)).toBe('7');
expect(bagLimitInput(23500)).toBe('23.5');
expect(bagLimitInput(null)).toBe('');
expect(bagLimitInput(undefined)).toBe('');
expect(bagLimitInput(0)).toBe('');
const { result } = setup();
expect(result.current.nameVal).toBe('Backpack');
expect(result.current.limitVal).toBe('7');
expect(result.current.editingName).toBe(false);
expect(result.current.editingLimit).toBe(false);
});
it('FE-PACK-BAGEDIT-002: a changed name is saved trimmed and the field closes', () => {
const { result, onUpdate } = setup();
act(() => {
result.current.setEditingName(true);
result.current.setNameVal(' Daypack ');
});
act(() => result.current.saveName());
expect(onUpdate).toHaveBeenCalledWith({ name: 'Daypack' });
expect(result.current.editingName).toBe(false);
});
it('FE-PACK-BAGEDIT-003: an unsaved name stays as typed on the desktop and snaps back on the phone', () => {
const desktop = setup({ followBag: true });
act(() => desktop.result.current.setNameVal(' '));
act(() => desktop.result.current.saveName());
expect(desktop.onUpdate).not.toHaveBeenCalled();
expect(desktop.result.current.nameVal).toBe(' ');
const phone = setup({ resetUnsavedName: true });
act(() => phone.result.current.setNameVal('Backpack '));
act(() => phone.result.current.saveName());
expect(phone.onUpdate).not.toHaveBeenCalled();
expect(phone.result.current.nameVal).toBe('Backpack');
});
it('FE-PACK-BAGEDIT-004: cancelling a name puts the bag name back and closes the field', () => {
const { result } = setup();
act(() => {
result.current.setEditingName(true);
result.current.setNameVal('Other');
});
act(() => result.current.cancelName());
expect(result.current.nameVal).toBe('Backpack');
expect(result.current.editingName).toBe(false);
});
it('FE-PACK-BAGEDIT-005: a limit typed in kilos, with a comma or a point, is saved in grams', () => {
const { result, onUpdate } = setup();
act(() => {
result.current.setEditingLimit(true);
result.current.setLimitVal(' 8,25 ');
});
act(() => result.current.saveLimit());
expect(onUpdate).toHaveBeenCalledWith({ weight_limit_grams: 8250 });
expect(result.current.editingLimit).toBe(false);
onUpdate.mockClear();
act(() => result.current.setLimitVal('7.0'));
act(() => result.current.saveLimit());
// The same limit as stored: nothing to send.
expect(onUpdate).not.toHaveBeenCalled();
});
it('FE-PACK-BAGEDIT-006: an emptied limit removes it, but only when there was one', () => {
const withLimit = setup();
act(() => withLimit.result.current.setLimitVal(''));
act(() => withLimit.result.current.saveLimit());
expect(withLimit.onUpdate).toHaveBeenCalledWith({ weight_limit_grams: null });
const without = setup({}, { ...BAG, weight_limit_grams: null });
act(() => without.result.current.saveLimit());
expect(without.onUpdate).not.toHaveBeenCalled();
});
it('FE-PACK-BAGEDIT-007: an unreadable or non-positive limit is dropped for the stored one', () => {
const { result, onUpdate } = setup();
for (const typed of ['heavy', '-3', '0']) {
act(() => result.current.setLimitVal(typed));
act(() => result.current.saveLimit());
expect(result.current.limitVal).toBe('7');
}
act(() => {
result.current.setEditingLimit(true);
result.current.setLimitVal('12');
});
act(() => result.current.cancelLimit());
expect(result.current.limitVal).toBe('7');
expect(result.current.editingLimit).toBe(false);
expect(onUpdate).not.toHaveBeenCalled();
});
it('FE-PACK-BAGEDIT-008: the desktop fields follow the bag as it changes elsewhere, the phone ones do not', () => {
const renamed = { ...BAG, name: 'Daypack', weight_limit_grams: 9000 };
const desktop = setup({ followBag: true });
desktop.rerender({ bag: renamed });
expect(desktop.result.current.nameVal).toBe('Daypack');
expect(desktop.result.current.limitVal).toBe('9');
const phone = setup({ resetUnsavedName: true });
phone.rerender({ bag: renamed });
expect(phone.result.current.nameVal).toBe('Backpack');
expect(phone.result.current.limitVal).toBe('7');
});
it('FE-PACK-BAGEDIT-009: tapping a member takes them off the bag, anyone else is added', () => {
const { result, onSetMembers } = setup();
expect(result.current.memberIds).toEqual([1]);
act(() => result.current.toggleMember(1));
expect(onSetMembers).toHaveBeenLastCalledWith([]);
act(() => result.current.toggleMember(4));
expect(onSetMembers).toHaveBeenLastCalledWith([1, 4]);
expect(setup({}, { ...BAG, members: undefined }).result.current.memberIds).toEqual([]);
});
});
@@ -0,0 +1,96 @@
import type { PackingUpdateBagRequest } from '@trek/shared';
import { useEffect, useState } from 'react';
import type { PackingBag } from '../../types';
/** A stored limit as the field shows it: in kilos, that is how airlines state them. */
export const bagLimitInput = (grams?: number | null) => (grams ? String(grams / 1000) : '');
/**
* Editing one bag in place, behind the desktop bag card and the phone bag row:
* its name, its weight limit (typed in kilos, stored in grams) and who carries it.
*
* The desktop card keeps its fields on the bag as it changes elsewhere
* (`followBag`) and leaves a name it did not save as it was typed; the phone row
* starts its fields from the bag and puts a name it did not save back
* (`resetUnsavedName`).
*/
export function useBagCardEditor({
bag,
onUpdate,
onSetMembers,
followBag = false,
resetUnsavedName = false,
}: {
bag: PackingBag;
onUpdate: (data: PackingUpdateBagRequest) => void;
onSetMembers: (userIds: number[]) => void;
followBag?: boolean;
resetUnsavedName?: boolean;
}) {
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState(bag.name);
const [editingLimit, setEditingLimit] = useState(false);
const [limitVal, setLimitVal] = useState(bagLimitInput(bag.weight_limit_grams));
useEffect(() => {
if (followBag) setNameVal(bag.name);
}, [bag.name, followBag]);
useEffect(() => {
if (followBag) setLimitVal(bagLimitInput(bag.weight_limit_grams));
}, [bag.weight_limit_grams, followBag]);
const saveName = () => {
const trimmed = nameVal.trim();
if (trimmed && trimmed !== bag.name) onUpdate({ name: trimmed });
else if (resetUnsavedName) setNameVal(bag.name);
setEditingName(false);
};
const cancelName = () => {
setEditingName(false);
setNameVal(bag.name);
};
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({ 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(bagLimitInput(bag.weight_limit_grams));
return;
}
const grams = Math.round(kg * 1000);
if (grams !== bag.weight_limit_grams) onUpdate({ weight_limit_grams: grams });
};
const cancelLimit = () => {
setLimitVal(bagLimitInput(bag.weight_limit_grams));
setEditingLimit(false);
};
const memberIds = (bag.members || []).map((m) => m.user_id);
const toggleMember = (userId: number) =>
onSetMembers(memberIds.includes(userId) ? memberIds.filter((id) => id !== userId) : [...memberIds, userId]);
return {
editingName,
setEditingName,
nameVal,
setNameVal,
saveName,
cancelName,
editingLimit,
setEditingLimit,
limitVal,
setLimitVal,
saveLimit,
cancelLimit,
memberIds,
toggleMember,
};
}
@@ -7,7 +7,8 @@ import { avatarSrc } from '../../../../utils/avatarSrc'
import type { PackingBag, PackingItem, TripMember } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
import { formatWeight } from '../../../../components/Packing/packingListModel'
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from '../../../../components/Packing/packingListPanel.helpers'
import { bagFillPct, bagLoadSummary } from '../../../../components/Packing/packingListPanel.helpers'
import { useBagCardEditor } from '../../../../components/Packing/useBagCardEditor'
export interface MBagsSheetProps {
planner: TripPlanner
@@ -42,16 +43,10 @@ export default function MBagsSheet({
const [newBagName, setNewBagName] = useState('')
// The ITEM LISTS still describe what you are carrying — an item someone shared
// with you stays in your list, but they are the one bringing it (#1767).
const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId))
// The WEIGHTS no longer do: a bag's load is the bag's, whoever packed it (#2191).
const bagWeightOf = (bag: PackingBag) =>
bagTotalWeight(bag, myItems.filter(i => i.bag_id === bag.id), serverWeightsFresh)
const unassigned = myItems.filter(i => !i.bag_id)
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
const totalWeight = bags.reduce((s, b) => s + bagWeightOf(b), 0) + unassignedWeight
// Reference for bags without a limit of their own — computed once instead of per bag.
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
// with you stays in your list, but they are the one bringing it (#1767). The
// WEIGHTS no longer do: a bag's load is the bag's, whoever packed it (#2191).
const { bagItemsOf, bagWeightOf, heaviestBagWeight, unassigned, unassignedWeight, totalWeight } =
bagLoadSummary(bags, items, currentUserId, unassignedWeightGrams, serverWeightsFresh)
const submitNewBag = () => {
if (!newBagName.trim()) return
@@ -66,7 +61,7 @@ export default function MBagsSheet({
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
{bags.map(bag => {
const bagItems = myItems.filter(i => i.bag_id === bag.id)
const bagItems = bagItemsOf(bag)
const bagWeight = bagWeightOf(bag)
const pct = bagFillPct(bagWeight, bag.weight_limit_grams, heaviestBagWeight)
return (
@@ -159,37 +154,11 @@ function BagRow({ planner, bag, itemCount, weight, pct, tripMembers, canEdit, on
onSetMembers: (userIds: number[]) => void
}) {
const { t } = planner
const [editingName, setEditingName] = useState(false)
const [nameVal, setNameVal] = useState(bag.name)
const [showPicker, setShowPicker] = useState(false)
const memberIds = (bag.members || []).map(m => m.user_id)
const toggleMember = (userId: number) => onSetMembers(memberIds.includes(userId) ? memberIds.filter(id => id !== userId) : [...memberIds, userId])
const saveName = () => {
const trimmed = nameVal.trim()
if (trimmed && trimmed !== bag.name) onUpdate({ name: trimmed })
else setNameVal(bag.name)
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 })
}
const {
editingName, setEditingName, nameVal, setNameVal, saveName, cancelName,
editingLimit, setEditingLimit, limitVal, setLimitVal, saveLimit, cancelLimit, memberIds, toggleMember,
} = useBagCardEditor({ bag, onUpdate, onSetMembers, resetUnsavedName: true })
return (
<div className="mb-4">
@@ -202,7 +171,7 @@ function BagRow({ planner, bag, itemCount, weight, pct, tripMembers, canEdit, on
value={nameVal}
onChange={e => setNameVal(e.target.value)}
onBlur={saveName}
onKeyDown={e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') { setEditingName(false); setNameVal(bag.name) } }}
onKeyDown={e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') cancelName() }}
className="min-w-0 flex-1 border-b border-[color:var(--m-rowbr)] bg-transparent text-[0.8125rem] font-semibold text-m-ink outline-none"
/>
) : (
@@ -223,7 +192,7 @@ function BagRow({ planner, bag, itemCount, weight, pct, tripMembers, canEdit, on
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) } }}
onKeyDown={e => { if (e.key === 'Enter') saveLimit(); if (e.key === 'Escape') cancelLimit() }}
className="w-9 border-b border-[color:var(--m-rowbr)] bg-transparent text-end text-m-ink outline-none"
/>
<span>kg</span>