mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
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:
@@ -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')) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user