feat: add packer

This commit is contained in:
2026-10-03 23:11:51 +02:00
parent e82561862a
commit 5452f72087
14 changed files with 976 additions and 25 deletions
+300
View File
@@ -0,0 +1,300 @@
export interface PackerSettings {
days: number
/** Everything that isn't sharable is packed once per person */
people: number
/** Picked activities, without the stays */
activities: string[]
/** Where to sleep on trips longer than a day, one of the app config's `packer.stays` */
stay: string
season: PackerSeason
/** One of the table's `weather` options */
weather: string
/** Picked weathers of the app config's `weatherExtras`, on top of `weather` */
weatherExtras: string[]
}
export interface PackerEntry extends PackerItem {
count: number
/** The weight was changed on this page */
weightEdited: boolean
/** Added on this page, not in the table */
added: boolean
}
export interface PackerCategory {
name: string
entries: PackerEntry[]
}
export const PACKER_MAX_DAYS = 60
export const PACKER_MAX_PEOPLE = 20
// The season of today's date. Meteorological seasons (as the DWD counts them: spring from 1 March, …), by the date in Germany. The page is first
// rendered on the server, whose clock may run in UTC, so its month would be off around midnight at a month's turn.
function currentSeason(): PackerSeason {
const month = Number(new Intl.DateTimeFormat('en', { month: 'numeric', timeZone: 'Europe/Berlin' }).format(new Date())) - 1
if (month >= 2 && month <= 4) return 'Frühling'
if (month >= 5 && month <= 7) return 'Sommer'
if (month >= 8 && month <= 10) return 'Herbst'
return 'Winter'
}
function readSettings(query: Record<string, unknown>, stays: string[], defaultWeather: string, weatherExtras: string[]): PackerSettings {
const text = (key: string) => (typeof query[key] === 'string' ? query[key] as string : undefined)
const season = text('season') as PackerSeason | undefined
const stay = text('stay')
const number = (key: string, fallback: number, max: number) => {
const value = Number.parseInt(text(key) ?? '', 10)
return Number.isFinite(value) ? Math.min(Math.max(value, 1), max) : fallback
}
return {
days: number('days', 1, PACKER_MAX_DAYS),
people: number('people', 1, PACKER_MAX_PEOPLE),
activities: (text('activities') ?? '').split(',').filter(activity => activity && !stays.includes(activity)),
stay: stay && stays.includes(stay) ? stay : stays[0] ?? '',
season: season && PACKER_SEASONS.includes(season) ? season : currentSeason(),
weather: text('weather') || defaultWeather,
weatherExtras: (text('weatherExtras') ?? '').split(',').filter(weather => weatherExtras.includes(weather))
}
}
function writeSettings(settings: PackerSettings): Record<string, string> {
return {
days: String(settings.days),
people: String(settings.people),
activities: settings.activities.join(','),
stay: settings.stay,
season: settings.season,
weather: settings.weather,
weatherExtras: settings.weatherExtras.join(',')
}
}
/** The settings as a comparable string; the order activities were picked in doesn't matter */
function settingsKey(settings: PackerSettings): string {
return JSON.stringify(writeSettings({
...settings,
activities: settings.activities.toSorted(),
weatherExtras: settings.weatherExtras.toSorted()
}))
}
/** The trip settings, kept in the URL so a list can be bookmarked or sent to the others */
export function usePackerSettings() {
const route = useRoute()
const router = useRouter()
const { packer } = useAppConfig()
// Activities that come with the trip (e.g. washing on long trips)
type Rule = typeof packer.autoActivities[number]
const applies = (rule: Rule, settings: PackerSettings) => settings.days >= (rule.minDays ?? 1)
&& (!rule.stay || (settings.days > 1 && settings.stay === rule.stay))
function withAutoActivities(settings: PackerSettings): PackerSettings {
const auto = packer.autoActivities.filter(rule => applies(rule, settings)).map(rule => rule.activity)
return { ...settings, activities: [...new Set([...settings.activities, ...auto])] }
}
/** A fresh list: today's season, the first stay and the activities that apply */
const read = (query: Record<string, unknown>) => readSettings(query, packer.stays, packer.defaultWeather, packer.weatherExtras)
const defaults = () => withAutoActivities(read({}))
// A list without activities in the URL starts with the ones that apply
const initial = read(route.query)
const settings = reactive(route.query.activities === undefined ? withAutoActivities(initial) : initial)
watch(settings, value => router.replace({ query: writeSettings(value) }), { deep: true })
// The automatic activities are switched when their condition changes, so they can be switched by hand in between
for (const rule of packer.autoActivities) {
watch(() => applies(rule, settings), (on) => {
const others = settings.activities.filter(activity => activity !== rule.activity)
settings.activities = on ? [...others, rule.activity] : others
})
}
const changed = computed(() => settingsKey(settings) !== settingsKey(defaults()))
function reset() {
Object.assign(settings, defaults())
}
return { settings, changed, reset }
}
interface PackerSessionState {
checked: number[]
/** Table items deleted on this page */
removed: number[]
/** Items added on this page; their ids are negative so they never meet a row id */
added: PackerItem[]
/** Weights of table items changed on this page, in grams by item id */
weights: Record<number, number>
}
const emptySession = (): PackerSessionState => ({ checked: [], removed: [], added: [], weights: {} })
const SESSION_KEY = 'packer-session'
/**
* Ticked, deleted and added items. They only live in this browser tab (sessionStorage), never in the table, and
* survive reloads until the tab is closed or the list is reset.
*/
export function usePackerSession() {
const state = useState<PackerSessionState>('packer-session', emptySession)
onMounted(() => {
try {
const saved = JSON.parse(sessionStorage.getItem(SESSION_KEY) ?? 'null')
if (saved) state.value = { ...emptySession(), ...saved }
} catch {}
watch(state, (value) => {
try {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(value))
} catch {}
}, { deep: true })
})
const checked = computed(() => new Set(state.value.checked))
function toggle(id: number, value: boolean) {
const others = state.value.checked.filter(item => item !== id)
state.value.checked = value ? [...others, id] : others
}
function add(category: string, name: string, weightG: number | null) {
const lowest = Math.min(0, ...state.value.added.map(item => item.id))
state.value.added.push({
id: lowest - 1,
name,
category,
sharable: false,
everyNDays: null,
weightG,
seasons: [],
activities: [],
weathers: []
})
}
/** Weight of one, in grams; null goes back to the table's weight (or none, for an added item) */
function setWeight(id: number, weightG: number | null) {
if (id < 0) {
const item = state.value.added.find(added => added.id === id)
if (item) item.weightG = weightG
} else if (weightG === null) {
const { [id]: _, ...others } = state.value.weights
state.value.weights = others
} else {
state.value.weights = { ...state.value.weights, [id]: weightG }
}
}
function remove(id: number) {
state.value.checked = state.value.checked.filter(item => item !== id)
setWeight(id, null)
if (id < 0) state.value.added = state.value.added.filter(item => item.id !== id)
else state.value.removed.push(id)
}
function reset() {
state.value = emptySession()
}
const changed = computed(() => !!(state.value.checked.length || state.value.removed.length || state.value.added.length
|| Object.keys(state.value.weights).length))
return { state, checked, changed, toggle, add, remove, setWeight, reset }
}
const collator = new Intl.Collator('de', { sensitivity: 'base' })
export function usePackerList(settings: PackerSettings, session: Ref<PackerSessionState>) {
const { packer } = useAppConfig()
const { data, error, status } = useFetch<PackerData>('/api/packer', {
key: 'packer-data',
default: () => ({ items: [], activities: [], weathers: [] })
})
// Only offer what some item uses. The stays get their own switch.
const used = computed(() => new Set(data.value.items.flatMap(item => item.activities)))
const stays = computed(() => packer.stays.filter(stay => used.value.has(stay)))
const activities = computed(() => data.value.activities
.filter(activity => used.value.has(activity) && !packer.stays.includes(activity))
.toSorted(collator.compare))
// Weathers in the order of the app config's `weatherIcons`, the rest after them in their Baserow order
const weatherOrder = (weather: string) => {
const index = Object.keys(packer.weatherIcons).indexOf(weather)
return index === -1 ? Infinity : index
}
const usedWeathers = computed(() => data.value.weathers
.filter(weather => data.value.items.some(item => item.weathers.includes(weather)))
.toSorted((a, b) => weatherOrder(a) - weatherOrder(b)))
// The switch, and the weathers toggled on top of it
const weathers = computed(() => usedWeathers.value.filter(weather => !packer.weatherExtras.includes(weather)))
const weatherExtras = computed(() => usedWeathers.value.filter(weather => packer.weatherExtras.includes(weather)))
const tripWeathers = computed(() => new Set([settings.weather, ...settings.weatherExtras]))
const hasSeasons = computed(() => data.value.items.some(item => item.seasons.length))
// What the items are matched against: the picked activities, plus where to sleep once the trip has a night
const tripActivities = computed(() => new Set([
...settings.activities,
...(settings.days > 1 && stays.value.includes(settings.stay) ? [settings.stay] : [])
]))
function count(item: PackerItem): number {
const people = item.sharable ? 1 : settings.people
if (!item.everyNDays) return people
const laundry = packer.laundry
const washes = tripActivities.value.has(laundry.activity) && laundry.categories.includes(item.category)
const days = washes ? Math.min(settings.days, laundry.days) : settings.days
return Math.ceil(days / item.everyNDays) * people
}
// Rows with the same name are alternatives: the item is listed once when any of them fits the trip (e.g. a scarf in
// winter or when it's windy). Ticks, deletions and weights belong to the name's first row, whichever row fits.
const firstIds = computed(() => {
const ids = new Map<string, number>()
for (const item of data.value.items) if (!ids.has(item.name)) ids.set(item.name, item.id)
return ids
})
const categories = computed<PackerCategory[]>(() => {
const removed = new Set(session.value.removed)
const listed = new Set<string>()
const groups = new Map<string, PackerEntry[]>()
for (const item of data.value.items) {
if (listed.has(item.name)) continue
if (item.activities.length && !item.activities.some(activity => tripActivities.value.has(activity))) continue
if (item.seasons.length && !item.seasons.includes(settings.season)) continue
if (item.weathers.length && !item.weathers.some(weather => tripWeathers.value.has(weather))) continue
listed.add(item.name)
// A category stays when all its items are deleted, so things can still be added to it
const entries = groups.get(item.category) ?? []
groups.set(item.category, entries)
const id = firstIds.value.get(item.name)!
if (removed.has(id)) continue
const weightG = session.value.weights[id]
entries.push({
...item,
id,
weightG: weightG ?? item.weightG,
weightEdited: weightG !== undefined,
count: count(item),
added: false
})
}
// Added items show up while their category is shown
for (const item of session.value.added) {
groups.get(item.category)?.push({ ...item, count: 1, added: true, weightEdited: false })
}
// Categories keep the order of the table, items are sorted alphabetically
return [...groups].map(([name, entries]) => ({ name, entries: entries.toSorted((a, b) => collator.compare(a.name, b.name)) }))
})
return { categories, activities, stays, weathers, weatherExtras, hasSeasons, error, status }
}
/** "850 g", "3,2 kg" */
export function packerWeight(grams: number): string {
if (grams < 1000) return `${Math.round(grams)} g`
return `${(grams / 1000).toLocaleString('de-DE', { maximumFractionDigits: 1 })} kg`
}