fix(client): stop shared items counting towards the recipient bag weight

An item shared through the sharing tier is visible to its recipients but
carried by its owner. The bag totals filtered on bag_id alone, so every
recipient saw the owner's weight added to their own load — which is exactly
the situation that tier exists for (one person brings the first-aid kit).

Filtering by owner_id, as the issue suggests, would have broken more than it
fixed: owner_id is stamped on every item including Common ones, so the group
pool would have shrunk to "only what I entered myself" even on trips where
nobody shares anything. The predicate keeps the Common pool intact and drops
only private items owned by someone else.

Closes #1767
This commit is contained in:
Maurice
2026-08-03 21:47:45 +02:00
committed by Maurice
parent d7d7fac339
commit 446bbdc38f
7 changed files with 111 additions and 19 deletions
@@ -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 (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
onClick={() => setShowBagModal(false)}>
@@ -21,7 +24,7 @@ export function BagModal(S: PackingState) {
</div>
{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) {
<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>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
<span>{(() => { const w = myItems.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div>
</div>
@@ -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 (
<div className="hidden xl:block" style={{ width: 260, marginLeft: 16, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
@@ -17,7 +20,7 @@ export function BagSidebar(S: PackingState) {
</div>
{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) {
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
<span>{(() => { const w = myItems.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div>
</div>
@@ -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)
@@ -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