feat: quality of life sweep from the feature discussions

Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
This commit is contained in:
Maurice
2026-09-30 23:00:12 +02:00
parent 91dfab3f4b
commit 8fd46c4c18
691 changed files with 9491 additions and 927 deletions
@@ -9,9 +9,10 @@ import {
MoreHorizontal, ChevronDown, ChevronRight,
} from 'lucide-react'
import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers'
import { katColor, packedOf } from './packingListPanel.helpers'
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { QuantityInput } from './PackingListPanelQuantityInput'
import { PackedCounter } from './PackingPackedCounter'
import PackingShareControl from './PackingShareControl'
import { PopoverItem } from './PackingPopover'
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
@@ -65,7 +66,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
const [bagInlineCreate, setBagInlineCreate] = useState(false)
const [bagInlineName, setBagInlineName] = useState('')
const itemMenuBtnRef = useRef<HTMLButtonElement>(null)
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const { togglePackingItem, updatePackingItem, deletePackingItem, setPackedCount } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
@@ -104,6 +105,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
const canDrag = canEdit && !isPlaceholder && !!drag
const selectedBag = bags.find(b => b.id === item.bag_id)
const quantity = item.quantity || 1
// A multi-piece item counts its pieces into the bag (#2296).
const packed = packedOf(item)
const partlyPacked = !item.checked && packed > 0
const owner = tripMembers.find(m => m.id === item.owner_id)
const ownerAvatar = owner?.avatar_url ?? owner?.avatar ?? null
@@ -167,7 +171,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
<button type="button" onClick={handleToggle} className="packing-check" aria-pressed={!!item.checked} style={{
flexShrink: 0, cursor: 'pointer', padding: 0, width: 20, height: 20, borderRadius: 6,
display: 'grid', placeItems: 'center',
border: `1.5px solid ${item.checked ? 'var(--accent)' : 'var(--text-faint)'}`,
border: `1.5px solid ${item.checked || partlyPacked ? 'var(--accent)' : 'var(--text-faint)'}`,
background: item.checked ? 'var(--accent)' : 'transparent',
color: 'var(--accent-text)',
transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), border-color 180ms cubic-bezier(0.23,1,0.32,1)',
@@ -233,6 +237,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
)}
<div className="packing-row-inline-actions" style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{canEdit && !isPlaceholder && quantity > 1 && (
<PackedCounter packed={packed} quantity={quantity} onChange={count => setPackedCount(tripId, item.id, count)} style={reveal(active || partlyPacked)} />
)}
{/* Quantity */}
{canEdit && <QuantityInput value={quantity} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} style={badge(active || quantity > 1)} />}
@@ -0,0 +1,31 @@
// FE-COMP-PACKEDCOUNTER-001 to FE-COMP-PACKEDCOUNTER-003 (#2296)
import { vi } from 'vitest';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { PackedCounter } from './PackingPackedCounter';
import { packedOf } from './packingListPanel.helpers';
describe('PackedCounter', () => {
it('FE-COMP-PACKEDCOUNTER-001: shows the count and steps it both ways', () => {
const onChange = vi.fn();
render(<PackedCounter packed={3} quantity={10} onChange={onChange} />);
expect(screen.getByText('3/10')).toBeInTheDocument();
expect(screen.getByRole('group', { name: '3 of 10 packed' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Pack one more' }));
fireEvent.click(screen.getByRole('button', { name: 'Pack one less' }));
expect(onChange.mock.calls).toEqual([[4], [2]]);
});
it('FE-COMP-PACKEDCOUNTER-002: stops at nothing and at all of it', () => {
const { rerender } = render(<PackedCounter packed={0} quantity={2} onChange={() => {}} />);
expect(screen.getByRole('button', { name: 'Pack one less' })).toBeDisabled();
rerender(<PackedCounter packed={2} quantity={2} onChange={() => {}} />);
expect(screen.getByRole('button', { name: 'Pack one more' })).toBeDisabled();
});
it('FE-COMP-PACKEDCOUNTER-003: a ticked item reads as fully packed', () => {
expect(packedOf({ checked: 1, quantity: 4, packed_quantity: null })).toBe(4);
expect(packedOf({ checked: 0, quantity: 4, packed_quantity: 3 })).toBe(3);
expect(packedOf({ checked: 0 })).toBe(0);
});
});
@@ -0,0 +1,55 @@
import type { CSSProperties } from 'react'
import { Minus, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
interface PackedCounterProps {
packed: number
quantity: number
onChange: (count: number) => void
style?: CSSProperties
}
const stepStyle: CSSProperties = {
width: 18, height: 18, display: 'grid', placeItems: 'center', padding: 0, border: 'none',
borderRadius: 99, background: 'transparent', color: 'var(--text-muted)', cursor: 'pointer',
}
/**
* How many pieces of a multi-piece item are already in the bag (#2296): "7/10"
* between a minus and a plus. Reaching the quantity ticks the item off.
*/
export function PackedCounter({ packed, quantity, onChange, style }: PackedCounterProps) {
const { t } = useTranslation()
const partial = packed > 0 && packed < quantity
return (
<Tooltip label={t('packing.packedCount', { packed, total: quantity })}>
<div
role="group"
aria-label={t('packing.packedCount', { packed, total: quantity })}
style={{
display: 'inline-flex', alignItems: 'center', gap: 2, flexShrink: 0, height: 24, padding: '0 3px',
borderRadius: 99, border: '1px solid transparent',
background: partial ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--bg-tertiary)',
...style,
}}
>
<button type="button" aria-label={t('packing.packedLess')} disabled={packed <= 0} onClick={() => onChange(packed - 1)}
style={{ ...stepStyle, opacity: packed <= 0 ? 0.35 : 1, cursor: packed <= 0 ? 'default' : 'pointer' }}>
<Minus size={11} strokeWidth={2.5} />
</button>
<span style={{
minWidth: 30, textAlign: 'center', fontVariantNumeric: 'tabular-nums', fontWeight: 600,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
color: partial ? 'var(--accent)' : 'var(--text-secondary)',
}}>
{packed}/{quantity}
</span>
<button type="button" aria-label={t('packing.packedMore')} disabled={packed >= quantity} onClick={() => onChange(packed + 1)}
style={{ ...stepStyle, opacity: packed >= quantity ? 0.35 : 1, cursor: packed >= quantity ? 'default' : 'pointer' }}>
<Plus size={11} strokeWidth={2.5} />
</button>
</div>
</Tooltip>
)
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { katColor, itemWeight, bagFillPct, bagTotalWeight, countsTowardsMyLoad, isMarkdownList, parseCsvLine, parseImportLines, sortItemsByName, unassignedTotalWeight } from './packingListPanel.helpers'
import { katColor, itemWeight, bagFillPct, bagTotalWeight, countsTowardsMyLoad, isMarkdownList, newItemSharing, parseCsvLine, parseImportLines, sortItemsByName, unassignedTotalWeight } from './packingListPanel.helpers'
import { KAT_COLORS } from './packingListPanel.constants'
describe('packingListPanel.helpers', () => {
@@ -242,3 +242,25 @@ describe('packingListPanel.helpers', () => {
})
})
})
describe('newItemSharing (#2241)', () => {
const mine = (id: number, category: string, recipients: number[]) =>
({ id, name: `item ${id}`, category, is_private: 1, owner_id: 7, recipients: recipients.map(user_id => ({ user_id, username: `u${user_id}` })) })
it('shares a new item the way every own item of the category is shared', () => {
const items = [mine(1, 'Beach', [9, 8]), mine(2, 'Beach', [8, 9]), mine(3, 'Other', [])]
expect(newItemSharing(items, 'Beach', 'personal', 7)).toEqual({ visibility: 'shared', recipient_ids: [8, 9] })
})
it('keeps it to me when the category disagrees, keeps something private or is new', () => {
expect(newItemSharing([mine(1, 'Beach', [8]), mine(2, 'Beach', [9])], 'Beach', 'personal', 7)).toEqual({ visibility: 'personal' })
expect(newItemSharing([mine(1, 'Beach', [8]), mine(2, 'Beach', [])], 'Beach', 'personal', 7)).toEqual({ visibility: 'personal' })
expect(newItemSharing([], 'Beach', 'personal', 7)).toEqual({ visibility: 'personal' })
// Items shared to me by somebody else say nothing about how I share mine.
expect(newItemSharing([{ ...mine(1, 'Beach', [7]), owner_id: 8 }], 'Beach', 'personal', 7)).toEqual({ visibility: 'personal' })
})
it('the shared list stays common', () => {
expect(newItemSharing([mine(1, 'Beach', [8])], 'Beach', 'common', 7)).toEqual({ visibility: 'common' })
})
})
@@ -196,3 +196,30 @@ export function sortItemsByName<T extends { name: string }>(items: T[], locale?:
return collator.compare(a.name.trim(), b.name.trim())
})
}
/** The packed count an item shows (#2296): all of it once ticked, its partial count before that. */
export function packedOf(item: { checked?: number | boolean; packed_quantity?: number | null; quantity?: number }): number {
return item.checked ? (item.quantity || 1) : (item.packed_quantity ?? 0)
}
type SharingItem = { category?: string | null; name: string; is_private?: number; owner_id?: number | null; recipients?: { user_id: number }[] }
/**
* The tier a new item takes in the given category (#2241). In "my list" a category
* whose own items are all shared with the very same people shares the next one with
* them too, so a list kept for two does not need every new line shared by hand. Any
* difference between the items, or one kept to myself, and the new item stays mine
* alone: sharing is only ever carried over when the category is unanimous about it.
*/
export function newItemSharing(
items: SharingItem[],
category: string,
view: 'common' | 'personal',
userId: number | null | undefined,
): { visibility: 'common' | 'personal' | 'shared'; recipient_ids?: number[] } {
if (view !== 'personal') return { visibility: 'common' }
const own = items.filter(i => i.category === category && i.name !== PACKING_PLACEHOLDER_NAME && !!i.is_private && i.owner_id === userId)
const sets = own.map(i => (i.recipients || []).map(r => r.user_id).sort((a, b) => a - b).join(','))
if (!own.length || !sets[0] || sets.some(set => set !== sets[0])) return { visibility: 'personal' }
return { visibility: 'shared', recipient_ids: sets[0].split(',').map(Number) }
}
@@ -11,7 +11,7 @@ import { useNetworkMode } from '../../hooks/useNetworkMode'
import { useBagTotalsPing } from './useBagTotalsPing'
import type { PackingItem, PackingBag } from '../../types'
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
import { newItemSharing, parseImportLines, sortItemsByName } from './packingListPanel.helpers'
const PACKING_SORT_KEY = 'trek:packing-sort'
@@ -153,8 +153,9 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
if (placeholder) {
await updatePackingItem(tripId, placeholder.id, { name })
} else {
// New items inherit the active view's tier: Personal in "my list", Common otherwise.
await addPackingItem(tripId, { name, category, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters<typeof addPackingItem>[1])
// New items inherit the active view's tier, and in "my list" the sharing the
// category's own items agree on (#2241).
await addPackingItem(tripId, { name, category, ...newItemSharing(items, category, view, currentUserId) } as Parameters<typeof addPackingItem>[1])
}
} catch { toast.error(t('packing.toast.addError')) }
}