Files
TREK/client/src/components/Packing/packingListPanel.helpers.ts
T
Maurice 8fd46c4c18 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.
2026-09-30 23:00:12 +02:00

226 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { KAT_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
// Stable color assignment: category name → index via simple hash
export function katColor(kat: string, allCategories?: string[]): string {
const idx = allCategories ? allCategories.indexOf(kat) : -1
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
// Fallback: hash-based
let h = 0
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + (kat.codePointAt(i) ?? 0)) | 0
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
}
/** Weight an item contributes to a total: unit weight times quantity (defaults: 0 g, qty 1). */
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
}
/**
* What a bag weighs, as shown (#2191).
*
* The server sums every member's items — including the private ones this viewer
* may not see — and sends the figure on the bag itself. Adding it up locally
* could only ever produce the part of the bag the viewer is allowed to look at,
* which is the wrong number to measure against an airline's weight limit.
*
* The local sum is the fallback in two cases: a bag that reached the client
* without the field (a row cached before #2191), and `serverFresh === false`,
* which the surfaces pass while offline — bags have no repo and no Dexie table,
* so their totals are frozen at the last online read and blind to whatever the
* mutation queue is holding. A stale absolute number measured against an
* airline limit is worse than an honest partial one.
*
* Note the explicit null check rather than `??` on a falsy value: an empty bag
* legitimately weighs 0 and must not fall through to the local sum.
*/
export const bagTotalWeight = (
bag: { total_weight_grams?: number | null },
visibleItems: { weight_grams?: number | null; quantity?: number | null }[],
serverFresh = true,
): number =>
serverFresh && bag.total_weight_grams != null
? bag.total_weight_grams
: visibleItems.reduce((sum, i) => sum + itemWeight(i), 0)
/** The same rule for the pile that is in no bag (#2191). */
export const unassignedTotalWeight = (
serverTotal: number | null | undefined,
visibleItems: { weight_grams?: number | null; quantity?: number | null }[],
serverFresh = true,
): number =>
serverFresh && serverTotal != null
? serverTotal
: visibleItems.reduce((sum, i) => sum + itemWeight(i), 0)
/**
* 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
* measure against, so bags are shown relative to the heaviest one and stay comparable.
* Lives here because three surfaces draw this bar and one of them used to forget the limit.
*/
export const bagFillPct = (bagWeight: number, limitGrams: number | null | undefined, heaviestBagWeight: number): number =>
Math.min(100, Math.round((bagWeight / (limitGrams || Math.max(heaviestBagWeight, 1))) * 100))
// Parse CSV line respecting quoted values (e.g. "Shirt, blue" stays as one field).
// A doubled quote inside a quoted field is a literal one, which is how the CSV
// export writes a name that contains a quote.
export const parseCsvLine = (line: string): string[] => {
const parts: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (ch === '"' && inQuotes && line[i + 1] === '"') { current += '"'; i++; continue }
if (ch === '"') { inQuotes = !inQuotes; continue }
if (!inQuotes && (ch === ',' || ch === ';' || ch === '\t')) { parts.push(current.trim()); current = ''; continue }
current += ch
}
parts.push(current.trim())
return parts
}
export interface ParsedImportItem {
name: string
category: string | undefined
weight_grams: string | undefined
bag: string | undefined
checked: boolean
/** From a leading "3x" or "3 ×" on the name. Absent means one. */
quantity?: number
}
// "3x Socks", "3 x Socks", "3 × Socks". The space after the x is required, so
// "4x4 adapter" stays a name.
const QUANTITY_PREFIX = /^(\d{1,3})\s*[x×]\s+(\S.*)$/i
/** A name with its quantity prefix split off, the same way for CSV rows and Markdown items. */
export const splitQuantity = (raw: string): { name: string; quantity?: number } => {
const match = QUANTITY_PREFIX.exec(raw.trim())
const quantity = match ? Number(match[1]) : 0
return match && quantity >= 1 ? { name: match[2].trim(), quantity } : { name: raw.trim() }
}
const MD_HEADING = /^#{1,6}\s+(\S.*)$/
const MD_ITEM = /^(?:[-*+]|\d+[.)])\s+(?:\[([ xX])\]\s+)?(\S.*)$/
// The weight the Markdown export writes after a name: "(200 g)" or "(1.2 kg)".
// Only a parenthesis that holds nothing but a weight counts, so "Charger (USB-C)"
// keeps its name.
const MD_WEIGHT = /\((\d+(?:[.,]\d+)?)\s?(g|kg)\)$/i
/** Links, emphasis and code marks as plain text: a list copied from a notes app is full of them. */
const plainMarkdown = (text: string): string =>
text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\*\*|__|~~|`/g, '').trim()
/**
* Whether pasted text is a Markdown list rather than CSV rows (#875): any heading or
* list item decides it, the way Obsidian, Notion, GitHub and most notes apps export.
*/
export const isMarkdownList = (text: string): boolean =>
text.split('\n').some(line => MD_HEADING.test(line.trim()) || MD_ITEM.test(line.trim()))
/**
* A Markdown list as import rows. Every heading names the category of the items
* under it, "- [x]" marks an item packed, and anything that is neither heading nor
* list item (a note, a blank line, a rule) is left out.
*/
const parseMarkdownLines = (text: string): ParsedImportItem[] => {
const out: ParsedImportItem[] = []
let category: string | undefined
for (const line of text.split('\n').map(l => l.trim())) {
const heading = MD_HEADING.exec(line)
if (heading) {
// A closing run of hashes ("## Clothing ##") is decoration, not part of the name.
category = plainMarkdown(heading[1].replace(/\s#+$/, '')) || undefined
continue
}
const item = MD_ITEM.exec(line)
// "- [ ]" with nothing after it is an empty checkbox, not an item called "[ ]".
if (!item || /^\[[ xX]\]$/.test(item[2])) continue
let label = plainMarkdown(item[2])
let weight_grams: string | undefined
const weight = MD_WEIGHT.exec(label)
if (weight) {
const grams = Number(weight[1].replace(',', '.')) * (weight[2].toLowerCase() === 'kg' ? 1000 : 1)
weight_grams = String(Math.round(grams))
label = label.slice(0, weight.index).trim()
}
out.push({ ...splitQuantity(label), category, weight_grams, bag: undefined, checked: item[1]?.toLowerCase() === 'x' })
}
return out.filter(i => i.name)
}
export const parseImportLines = (text: string): ParsedImportItem[] => {
if (isMarkdownList(text)) return parseMarkdownLines(text)
return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
// Format: Category, Name, Weight (optional), Bag (optional), checked/unchecked (optional)
const parts = parseCsvLine(line)
if (parts.length >= 2) {
const category = parts[0]
const weight_grams = parts[2] || undefined
const bag = parts[3] || undefined
const checked = parts[4]?.toLowerCase() === 'checked' || parts[4] === '1'
return { ...splitQuantity(parts[1]), category, weight_grams, bag, checked }
}
// Single value = just a name
return { ...splitQuantity(parts[0]), category: undefined, weight_grams: undefined, bag: undefined, checked: false }
}).filter(i => i.name)
}
/**
* One list's items in name order, for the A-Z view. Compared the way the reader's
* language sorts (accents next to their letter, "Shirt 2" before "Shirt 10"), and
* the '...' stand-in of an empty list stays at the bottom where it always sits.
* Returns a new array; the stored manual order is left alone.
*/
export function sortItemsByName<T extends { name: string }>(items: T[], locale?: string): T[] {
const collator = new Intl.Collator(locale, { sensitivity: 'base', numeric: true })
return [...items].sort((a, b) => {
const pa = a.name === PACKING_PLACEHOLDER_NAME
const pb = b.name === PACKING_PLACEHOLDER_NAME
if (pa !== pb) return pa ? 1 : -1
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) }
}