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, 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 { 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) => 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 } 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('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) { const { packer } = useAppConfig() const { data, error, status } = useFetch('/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() for (const item of data.value.items) if (!ids.has(item.name)) ids.set(item.name, item.id) return ids }) const categories = computed(() => { const removed = new Set(session.value.removed) const listed = new Set() const groups = new Map() 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` }