diff --git a/client/src/components/Packing/PackingListPanelBagModal.tsx b/client/src/components/Packing/PackingListPanelBagModal.tsx index fb638eb74..650ce8632 100644 --- a/client/src/components/Packing/PackingListPanelBagModal.tsx +++ b/client/src/components/Packing/PackingListPanelBagModal.tsx @@ -1,15 +1,18 @@ import { X, Plus } from 'lucide-react' import type { PackingState } from './usePackingListPanel' -import { bagFillPct, itemWeight } from './packingListPanel.helpers' +import { bagFillPct, countsTowardsMyLoad, itemWeight } from './packingListPanel.helpers' import { BagCard } from './PackingListPanelBagCard' export function BagModal(S: PackingState) { const { - setShowBagModal, t, bags, items, tripId, tripMembers, canEdit, handleDeleteBag, handleUpdateBag, handleSetBagMembers, + setShowBagModal, t, bags, items, tripId, tripMembers, canEdit, currentUserId, handleDeleteBag, handleUpdateBag, handleSetBagMembers, showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, } = S + // These numbers describe what you are carrying. An item someone shared with you stays + // in your list, but they are the one bringing it, so it is not your weight (#1767). + const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId)) // 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) + const heaviestBagWeight = Math.max(...bags.map(b => myItems.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1) return (
setShowBagModal(false)}> @@ -21,7 +24,7 @@ export function BagModal(S: PackingState) {
{bags.map(bag => { - const bagItems = items.filter(i => i.bag_id === bag.id) + const bagItems = myItems.filter(i => i.bag_id === bag.id) const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0) const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight) return ( @@ -31,7 +34,7 @@ export function BagModal(S: PackingState) { {/* Unassigned */} {(() => { - const unassigned = items.filter(i => !i.bag_id) + const unassigned = myItems.filter(i => !i.bag_id) const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0) if (unassigned.length === 0) return null return ( @@ -52,7 +55,7 @@ export function BagModal(S: PackingState) {
{t('packing.totalWeight')} - {(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()} + {(() => { const w = myItems.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}
diff --git a/client/src/components/Packing/PackingListPanelBagSidebar.tsx b/client/src/components/Packing/PackingListPanelBagSidebar.tsx index 7c54d266b..f3c994021 100644 --- a/client/src/components/Packing/PackingListPanelBagSidebar.tsx +++ b/client/src/components/Packing/PackingListPanelBagSidebar.tsx @@ -1,15 +1,18 @@ import { Plus } from 'lucide-react' import type { PackingState } from './usePackingListPanel' -import { bagFillPct, itemWeight } from './packingListPanel.helpers' +import { bagFillPct, countsTowardsMyLoad, itemWeight } from './packingListPanel.helpers' import { BagCard } from './PackingListPanelBagCard' export function BagSidebar(S: PackingState) { const { - t, bags, items, tripId, tripMembers, canEdit, handleDeleteBag, handleUpdateBag, handleSetBagMembers, + t, bags, items, tripId, tripMembers, canEdit, currentUserId, handleDeleteBag, handleUpdateBag, handleSetBagMembers, showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, } = S + // These numbers describe what you are carrying. An item someone shared with you stays + // in your list, but they are the one bringing it, so it is not your weight (#1767). + const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId)) // 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) + const heaviestBagWeight = Math.max(...bags.map(b => myItems.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1) return (
@@ -17,7 +20,7 @@ export function BagSidebar(S: PackingState) {
{bags.map(bag => { - const bagItems = items.filter(i => i.bag_id === bag.id) + const bagItems = myItems.filter(i => i.bag_id === bag.id) const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0) const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight) return ( @@ -27,7 +30,7 @@ export function BagSidebar(S: PackingState) { {/* Unassigned */} {(() => { - const unassigned = items.filter(i => !i.bag_id) + const unassigned = myItems.filter(i => !i.bag_id) const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0) if (unassigned.length === 0) return null return ( @@ -48,7 +51,7 @@ export function BagSidebar(S: PackingState) {
{t('packing.totalWeight')} - {(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()} + {(() => { const w = myItems.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}
diff --git a/client/src/components/Packing/packingListPanel.helpers.test.ts b/client/src/components/Packing/packingListPanel.helpers.test.ts index 99786e95d..d3d33bc5b 100644 --- a/client/src/components/Packing/packingListPanel.helpers.test.ts +++ b/client/src/components/Packing/packingListPanel.helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { katColor, itemWeight, bagFillPct, parseCsvLine, parseImportLines } from './packingListPanel.helpers' +import { katColor, itemWeight, bagFillPct, countsTowardsMyLoad, parseCsvLine, parseImportLines } from './packingListPanel.helpers' import { KAT_COLORS } from './packingListPanel.constants' describe('packingListPanel.helpers', () => { @@ -46,6 +46,31 @@ describe('packingListPanel.helpers', () => { }) }) + describe('countsTowardsMyLoad', () => { + it('counts the common pool for everyone', () => { + // owner_id is stamped on every item, common ones included — filtering by it alone + // would shrink the group total to "only what I entered myself". + expect(countsTowardsMyLoad({ is_private: 0, owner_id: 2 }, 1)).toBe(true) + }) + + it('counts my own private items', () => { + expect(countsTowardsMyLoad({ is_private: 1, owner_id: 1 }, 1)).toBe(true) + }) + + it('leaves out an item somebody else shared with me', () => { + expect(countsTowardsMyLoad({ is_private: 1, owner_id: 2 }, 1)).toBe(false) + }) + + it('counts unowned legacy rows', () => { + expect(countsTowardsMyLoad({ is_private: 1, owner_id: null }, 1)).toBe(true) + }) + + it('filters nothing when the viewer is unknown', () => { + expect(countsTowardsMyLoad({ is_private: 1, owner_id: 2 }, null)).toBe(true) + expect(countsTowardsMyLoad({ is_private: 1, owner_id: 2 }, undefined)).toBe(true) + }) + }) + describe('bagFillPct', () => { it('measures against the bag limit when there is one', () => { expect(bagFillPct(5000, 20000, 99999)).toBe(25) diff --git a/client/src/components/Packing/packingListPanel.helpers.ts b/client/src/components/Packing/packingListPanel.helpers.ts index 6f030875c..80cdf59ca 100644 --- a/client/src/components/Packing/packingListPanel.helpers.ts +++ b/client/src/components/Packing/packingListPanel.helpers.ts @@ -14,6 +14,22 @@ 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) +/** + * Whether an item's weight is part of what *you* carry. An item shared through the + * "Shared with…" tier stays visible to its recipients, but the owner is the one bringing + * it — counting it for everyone it was shared with inflated their bags (#1767). + * Common items are the group pool and always count; so does anything unowned (legacy + * rows) and everything, if we don't know who is looking. + */ +export const countsTowardsMyLoad = ( + i: { is_private?: number | boolean | null; owner_id?: number | null }, + currentUserId?: number | null, +): boolean => { + if (currentUserId == null) return true + if (!i.is_private) return true + return i.owner_id == null || i.owner_id === currentUserId +} + /** * 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 diff --git a/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx b/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx index f7a863149..451ac3eec 100644 --- a/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx @@ -7,7 +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' +import { bagFillPct, countsTowardsMyLoad } from '../../../../components/Packing/packingListPanel.helpers' export interface MBagsSheetProps { planner: TripPlanner @@ -17,6 +17,8 @@ export interface MBagsSheetProps { items: PackingItem[] tripMembers: TripMember[] canEdit: boolean + /** Who is looking — decides whose load the weights describe (#1767). */ + currentUserId?: number | null onCreateBag: (name: string) => void onUpdateBag: (bagId: number, data: PackingUpdateBagRequest) => void onDeleteBag: (bagId: number) => void @@ -29,17 +31,20 @@ export interface MBagsSheetProps { * is the caller's business — this sheet just renders whatever bags it's given. */ export default function MBagsSheet({ - planner, open, onClose, bags, items, tripMembers, canEdit, onCreateBag, onUpdateBag, onDeleteBag, onSetBagMembers, + planner, open, onClose, bags, items, tripMembers, canEdit, currentUserId, onCreateBag, onUpdateBag, onDeleteBag, onSetBagMembers, }: MBagsSheetProps) { const { t } = planner const [addingBag, setAddingBag] = useState(false) const [newBagName, setNewBagName] = useState('') - const unassigned = items.filter(i => !i.bag_id) + // These numbers describe what you are carrying. An item someone shared with you stays + // in your list, but they are the one bringing it, so it is not your weight (#1767). + const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId)) + const unassigned = myItems.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) + const totalWeight = myItems.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 heaviestBagWeight = Math.max(...bags.map(b => myItems.filter(i => i.bag_id === b.id).reduce((s, i) => s + packingItemWeight(i), 0)), 1) const submitNewBag = () => { if (!newBagName.trim()) return @@ -54,7 +59,7 @@ export default function MBagsSheet({
{bags.map(bag => { - const bagItems = items.filter(i => i.bag_id === bag.id) + const bagItems = myItems.filter(i => i.bag_id === bag.id) const bagWeight = bagItems.reduce((s, i) => s + packingItemWeight(i), 0) const pct = bagFillPct(bagWeight, bag.weight_limit_grams, heaviestBagWeight) return ( diff --git a/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx b/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx index a9d726a23..9cfcd2092 100644 --- a/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx @@ -472,6 +472,7 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) { items={items} tripMembers={tripMembers} canEdit={canEdit} + currentUserId={currentUserId} onCreateBag={createBag} onUpdateBag={updateBag} onDeleteBag={deleteBag} diff --git a/client/tests/unit/mobile/trip/MBagsSheet.test.tsx b/client/tests/unit/mobile/trip/MBagsSheet.test.tsx index b774738da..6893566b4 100644 --- a/client/tests/unit/mobile/trip/MBagsSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MBagsSheet.test.tsx @@ -276,4 +276,43 @@ describe('MBagsSheet', () => { expect(screen.queryByRole('button', { name: 'packing.setBagLimit' })).toBeNull() }) + + // #1767: an item somebody shared with me is theirs to carry, so it must not land in my + // bag totals — while the common pool stays everyone's. + it('FE-MOB-BAGS-023: an item shared with me is not added to my bag weight', () => { + setup({ + currentUserId: 1, + bags: [bag({ id: 1, name: 'Backpack' })], + items: [ + item({ id: 1, name: 'Mine', bag_id: 1, weight_grams: 200, is_private: 1, owner_id: 1 } as Partial), + item({ id: 2, name: 'Binoculars', bag_id: 1, weight_grams: 300, is_private: 1, owner_id: 2 } as Partial), + ], + }) + + expect(within(rowFor('Backpack')).getByText('200 g')).toBeInTheDocument() + expect(within(rowFor('Backpack')).getByText('1 admin.packingTemplates.items')).toBeInTheDocument() + }) + + it('FE-MOB-BAGS-024: common items still count for everyone', () => { + setup({ + currentUserId: 1, + bags: [bag({ id: 1, name: 'Backpack' })], + items: [ + item({ id: 1, name: 'First-aid kit', bag_id: 1, weight_grams: 500, is_private: 0, owner_id: 2 } as Partial), + ], + }) + + expect(within(rowFor('Backpack')).getByText('500 g')).toBeInTheDocument() + }) + + it('FE-MOB-BAGS-025: without a known viewer nothing is filtered out', () => { + setup({ + bags: [bag({ id: 1, name: 'Backpack' })], + items: [ + item({ id: 1, name: 'Binoculars', bag_id: 1, weight_grams: 300, is_private: 1, owner_id: 2 } as Partial), + ], + }) + + expect(within(rowFor('Backpack')).getByText('300 g')).toBeInTheDocument() + }) })