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
+3 -24
View File
@@ -14,14 +14,8 @@ const EDUCATION: (CvType | null)[] = ['Student', 'Pupil']
const { data: entries, error, status } = useFetch<CvEntry[]>('/api/cv', { key: 'cv-entries', default: () => [] })
// Opens the browser's print dialog, which offers "Save as PDF"
function savePdf() {
window.print()
}
// Browsers name the PDF after the page title, so it is "joaquin_gottlebe_cv" while printing (also with ⌘ P)
// The PDF is named "joaquin_gottlebe_cv"
const pdfName = `${(profile.name || 'cv').toLowerCase().trim().replace(/\s+/g, '_')}${profile.name ? '_cv' : ''}`
let screenTitle = ''
// The date under the CV is the day it is printed
const printDate = ref(new Date())
const signedOn = computed(() => {
@@ -30,21 +24,8 @@ const signedOn = computed(() => {
: printDate.value.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
return [profile.city, date].filter(Boolean).join(', ')
})
function onBeforePrint() {
const { savePdf } = usePdfExport(pdfName, () => {
printDate.value = new Date()
screenTitle = document.title
document.title = pdfName
}
function onAfterPrint() {
if (screenTitle) document.title = screenTitle
}
onMounted(() => {
window.addEventListener('beforeprint', onBeforePrint)
window.addEventListener('afterprint', onAfterPrint)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeprint', onBeforePrint)
window.removeEventListener('afterprint', onAfterPrint)
})
const education = computed(() => entries.value.filter(entry => EDUCATION.includes(entry.type)))
@@ -122,9 +103,7 @@ const experience = computed(() => entries.value.filter(entry => !EDUCATION.inclu
{{ signedOn }}
</p>
<div v-if="entries.length" class="mt-16 flex justify-center print:hidden">
<UButton :label="t.savePdf" icon="i-lucide-download" color="primary" variant="outline" @click="savePdf" />
</div>
<SavePdfButton v-if="entries.length" :label="t.savePdf" @click="savePdf" />
</UContainer>
</UMain>
</template>
+54
View File
@@ -0,0 +1,54 @@
export default defineAppConfig({
packer: {
/**
* Icons of the activities. The activities themselves are the options of the table's `activity` field; one without
* an icon here gets a tag.
*/
activityIcons: {
'Fahrradfahren': 'i-lucide-bike',
'Schwimmen': 'i-lucide-waves',
'Camping': 'i-lucide-tent',
'Kochen': 'i-lucide-cooking-pot',
'Erholung': 'i-lucide-sofa',
'Periode': 'i-lucide-droplet',
'Wäsche waschen': 'i-lucide-washing-machine',
'Duschen': 'i-lucide-shower-head',
'Übernachtung': 'i-lucide-bed',
'Musik': 'i-lucide-headphones',
'Auto fahren': 'i-lucide-car',
'Fotografieren': 'i-lucide-camera',
'Wandern': 'i-lucide-footprints',
'Bergsteigen': 'i-lucide-mountain-snow',
'Klettern': 'i-lucide-mountain',
'Ausland': 'i-lucide-globe',
'Arbeiten': 'i-lucide-laptop',
'Ausgehen': 'i-lucide-party-popper'
} as Record<string, string>,
/** Icons of the options of the table's `weather` field, in the order of the switch; and the one picked on a fresh list */
weatherIcons: {
Sonnig: 'i-lucide-sun',
Bewölkt: 'i-lucide-cloud',
Windig: 'i-lucide-wind',
Regnerisch: 'i-lucide-cloud-rain'
} as Record<string, string>,
defaultWeather: 'Sonnig',
/** Weathers that come on top of the picked one (toggled on their own instead of being part of the switch) */
weatherExtras: ['Windig'],
/**
* Where to sleep: activities of which exactly one is picked on trips longer than a day (the first by default) and
* none on a day trip. They are offered as a switch instead of with the other activities.
*/
stays: ['Übernachtung', 'Camping'],
/** With this activity picked, items of the laundry categories are packed for at most `days` days */
laundry: { activity: 'Wäsche waschen', days: 4, categories: ['Kleidung'] },
/**
* Activities that turn on by themselves once the trip has at least `minDays` days and/or sleeps in `stay`, and off
* again when it no longer does. In between they can be switched by hand.
*/
autoActivities: [
{ activity: 'Wäsche waschen', minDays: 5 },
{ activity: 'Duschen', minDays: 2 },
{ activity: 'Kochen', stay: 'Camping' }
] as { activity: string, minDays?: number, stay?: string }[]
}
})
@@ -0,0 +1,140 @@
<script setup lang="ts">
const props = defineProps<{ category: PackerCategory, checked: Set<number> }>()
const emit = defineEmits<{
toggle: [id: number, value: boolean]
add: [name: string, weightG: number | null]
remove: [id: number]
weight: [id: number, weightG: number | null]
}>()
/** Grams typed into a field; empty or not a positive number is no weight */
function grams(text: string | number | null | undefined): number | null {
const value = Number(String(text ?? '').replace(',', '.'))
return value > 0 ? Math.round(value) : null
}
const newItem = ref('')
const newWeight = ref('')
function add() {
const name = newItem.value.trim()
if (!name) return
emit('add', name, grams(newWeight.value))
newItem.value = ''
newWeight.value = ''
}
// Weight of one piece, edited in place; an empty field goes back to the table's weight
const editing = ref<number | null>(null)
const draft = ref('')
function startEdit(entry: PackerEntry) {
editing.value = entry.id
draft.value = entry.weightG ? String(entry.weightG) : ''
}
function commit() {
if (editing.value === null) return
emit('weight', editing.value, grams(draft.value))
editing.value = null
}
</script>
<template>
<section class="mb-8 break-inside-avoid print:mb-5">
<h2 class="mb-2 text-sm font-semibold text-highlighted">
{{ props.category.name }}
</h2>
<ul class="flex flex-col gap-1.5">
<li v-for="entry in props.category.entries" :key="entry.id" class="group flex min-h-6 items-center gap-2">
<!-- print-color-adjust: browsers leave out the ticked box's fill when printing -->
<UCheckbox
:model-value="props.checked.has(entry.id)"
:ui="{ base: 'print:[print-color-adjust:exact]', label: props.checked.has(entry.id) ? 'text-muted line-through' : '' }"
@update:model-value="emit('toggle', entry.id, !!$event)"
>
<template #label>
<span v-if="entry.count > 1" class="tabular-nums">{{ entry.count }} × </span>{{ entry.name }}
</template>
</UCheckbox>
<UBadge v-if="entry.added" label="neu" color="neutral" variant="outline" size="sm" />
<UInput
v-if="editing === entry.id"
v-model="draft"
inputmode="numeric"
size="xs"
autofocus
:placeholder="entry.count > 1 ? 'g pro Stück' : 'g'"
:aria-label="`Gewicht von ${entry.name} in Gramm${entry.count > 1 ? ' pro Stück' : ''}`"
class="ms-auto w-24"
:ui="{ trailing: 'pe-2' }"
@keydown.enter="commit"
@keydown.esc="editing = null"
@blur="commit"
>
<template #trailing>
<span class="text-xs text-muted">g</span>
</template>
</UInput>
<!-- Click to change the weight for this session; changed weights are in the primary color -->
<button
v-else
type="button"
class="ms-auto rounded text-xs tabular-nums hover:text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
:class="[
entry.weightEdited ? 'text-primary' : 'text-muted',
{ 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100 print:hidden': !entry.weightG }
]"
:title="entry.weightEdited ? 'Gewicht geändert (nur in dieser Sitzung)' : 'Gewicht ändern'"
:aria-label="`Gewicht von ${entry.name} ändern`"
@click="startEdit(entry)"
>
{{ entry.weightG ? packerWeight(entry.weightG * entry.count) : '+ g' }}
</button>
<!-- Always visible on touch screens, on hover with a mouse -->
<UButton
icon="i-lucide-x"
color="neutral"
variant="link"
size="xs"
:aria-label="`${entry.name} entfernen`"
class="sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100 print:hidden"
@click="emit('remove', entry.id)"
/>
</li>
</ul>
<form class="mt-2 flex gap-2 print:hidden" @submit.prevent="add">
<!-- The plus lines up with the checkboxes, the text with the item names -->
<UInput
v-model="newItem"
icon="i-lucide-plus"
placeholder="Hinzufügen"
variant="ghost"
size="sm"
:aria-label="`Zu ${props.category.name} hinzufügen`"
class="min-w-0 flex-1"
:ui="{ leading: 'ps-0', base: 'ps-6' }"
/>
<!-- Only once a name is typed, to keep the list calm -->
<UInput
v-if="newItem"
v-model="newWeight"
inputmode="numeric"
placeholder="Gewicht"
variant="ghost"
size="sm"
:aria-label="`Gewicht in Gramm`"
class="w-24"
:ui="{ trailing: 'pe-2' }"
>
<template #trailing>
<span class="text-xs text-muted">g</span>
</template>
</UInput>
<!-- Enter in either field adds the item -->
<button type="submit" class="hidden" />
</form>
</section>
</template>
@@ -0,0 +1,133 @@
<script setup lang="ts">
defineProps<{ activities: string[], stays: string[], weathers: string[], weatherExtras: string[], hasSeasons: boolean }>()
const settings = defineModel<PackerSettings>({ required: true })
const { packer } = useAppConfig()
const toggled = (list: string[], label: string) => list.includes(label) ? list.filter(item => item !== label) : [...list, label]
function toggleActivity(label: string) {
settings.value.activities = toggled(settings.value.activities, label)
}
function toggleWeather(label: string) {
settings.value.weatherExtras = toggled(settings.value.weatherExtras, label)
}
const weatherIcon = (weather: string) => packer.weatherIcons[weather] ?? 'i-lucide-cloud'
const activityIcon = (activity: string) => packer.activityIcons[activity] ?? 'i-lucide-tag'
const SEASON_ICONS: Record<PackerSeason, string> = {
Frühling: 'i-lucide-flower',
Sommer: 'i-lucide-sun',
Herbst: 'i-lucide-leaf',
Winter: 'i-lucide-snowflake'
}
</script>
<template>
<div class="flex flex-col gap-6 rounded-lg p-4 ring-1 ring-default sm:p-5">
<!-- Side by side once there is room; Reise only takes the width it needs, so the four seasons fit next to it -->
<div class="grid gap-x-8 gap-y-6 md:grid-cols-[auto_1fr]">
<section class="flex flex-col gap-4">
<h2 class="text-xs font-semibold tracking-wide text-muted uppercase">
Reise
</h2>
<UFormField label="Personen">
<UInputNumber v-model="settings.people" :min="1" :max="PACKER_MAX_PEOPLE" class="w-32" />
</UFormField>
<UFormField label="Tage">
<UInputNumber v-model="settings.days" :min="1" :max="PACKER_MAX_DAYS" class="w-32" />
</UFormField>
<!-- Only once the trip has a night -->
<UFormField v-if="settings.days > 1 && stays.length > 1" label="Schlafen">
<UFieldGroup>
<UButton
v-for="stay in stays"
:key="stay"
:label="stay"
:icon="activityIcon(stay)"
:color="settings.stay === stay ? 'primary' : 'neutral'"
:variant="settings.stay === stay ? 'solid' : 'outline'"
:aria-pressed="settings.stay === stay"
@click="settings.stay = stay"
/>
</UFieldGroup>
</UFormField>
</section>
<section v-if="weathers.length > 1 || weatherExtras.length || hasSeasons" class="flex flex-col gap-4">
<h2 class="text-xs font-semibold tracking-wide text-muted uppercase">
Bedingungen
</h2>
<UFormField v-if="weathers.length > 1 || weatherExtras.length" label="Wetter">
<div class="flex flex-wrap gap-2">
<UFieldGroup v-if="weathers.length > 1">
<UButton
v-for="weather in weathers"
:key="weather"
:label="weather"
:icon="weatherIcon(weather)"
:ui="{ leadingIcon: 'max-sm:hidden' }"
:color="settings.weather === weather ? 'primary' : 'neutral'"
:variant="settings.weather === weather ? 'solid' : 'outline'"
:aria-pressed="settings.weather === weather"
@click="settings.weather = weather"
/>
</UFieldGroup>
<!-- On top of the weather above, e.g. windy -->
<UButton
v-for="weather in weatherExtras"
:key="weather"
:label="weather"
:icon="weatherIcon(weather)"
:ui="{ leadingIcon: 'max-sm:hidden' }"
:color="settings.weatherExtras.includes(weather) ? 'primary' : 'neutral'"
:variant="settings.weatherExtras.includes(weather) ? 'solid' : 'outline'"
:aria-pressed="settings.weatherExtras.includes(weather)"
@click="toggleWeather(weather)"
/>
</div>
</UFormField>
<!-- Without icons on phones, so the four fit in one row -->
<UFormField v-if="hasSeasons" label="Jahreszeit">
<UFieldGroup>
<UButton
v-for="season in PACKER_SEASONS"
:key="season"
:label="season"
:icon="SEASON_ICONS[season]"
:ui="{ leadingIcon: 'max-sm:hidden' }"
:color="settings.season === season ? 'primary' : 'neutral'"
:variant="settings.season === season ? 'solid' : 'outline'"
:aria-pressed="settings.season === season"
@click="settings.season = season"
/>
</UFieldGroup>
</UFormField>
</section>
</div>
<section v-if="activities.length" class="flex flex-col gap-4">
<h2 class="text-xs font-semibold tracking-wide text-muted uppercase">
Extras
</h2>
<div class="flex flex-wrap gap-2">
<UButton
v-for="activity in activities"
:key="activity"
:label="activity"
:icon="activityIcon(activity)"
:color="settings.activities.includes(activity) ? 'primary' : 'neutral'"
:variant="settings.activities.includes(activity) ? 'solid' : 'outline'"
:aria-pressed="settings.activities.includes(activity)"
@click="toggleActivity(activity)"
/>
</div>
</section>
</div>
</template>
+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`
}
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
useHead({ title: 'Packer', htmlAttrs: { lang: 'de' } })
const { settings, changed: settingsChanged, reset: resetSettings } = usePackerSettings()
const { state, checked, changed: sessionChanged, toggle, add, remove, setWeight, reset: resetSession } = usePackerSession()
const { categories, activities, stays, weathers, weatherExtras, hasSeasons, error, status } = usePackerList(settings, state)
// Back to a fresh list: default settings, nothing ticked, added or deleted
const changed = computed(() => settingsChanged.value || sessionChanged.value)
function reset() {
resetSettings()
resetSession()
}
const shown = computed(() => categories.value.flatMap(category => category.entries))
const packed = computed(() => shown.value.filter(entry => checked.value.has(entry.id)).length)
// Weight of what is packed and of the whole list, from the items with a weight; shown once the list has any
const weighed = computed(() => shown.value.filter(entry => entry.weightG))
const sumWeight = (entries: PackerEntry[]) => entries.reduce((sum, entry) => sum + entry.weightG! * entry.count, 0)
const weight = computed(() => sumWeight(weighed.value))
const packedWeight = computed(() => sumWeight(weighed.value.filter(entry => checked.value.has(entry.id))))
const unweighed = computed(() => shown.value.length - weighed.value.length)
// The trip in one line, printed above the list instead of the settings
const summary = computed(() => [
settings.days === 1 ? '1 Tag' : `${settings.days} Tage`,
settings.people === 1 ? '1 Person' : `${settings.people} Personen`,
...(hasSeasons.value ? [settings.season] : []),
...(weathers.value.includes(settings.weather) ? [settings.weather] : []),
...weatherExtras.value.filter(weather => settings.weatherExtras.includes(weather)),
...(settings.days > 1 && stays.value.includes(settings.stay) ? [settings.stay] : []),
...activities.value.filter(activity => settings.activities.includes(activity)),
...(weighed.value.length ? [`${unweighed.value ? '≥ ' : ''}${packerWeight(weight.value)}`] : [])
].join(' · '))
// The PDF is named after the trip, e.g. "packer_3_tage"
const { savePdf } = usePdfExport(() => `packer_${settings.days}_${settings.days === 1 ? 'tag' : 'tage'}`)
</script>
<template>
<UMain>
<UContainer class="max-w-3xl py-8 print:max-w-none print:px-[12mm] print:pt-[15mm] print:pb-0">
<div class="mb-6 flex items-center gap-3">
<div class="me-auto">
<h1 class="text-2xl font-semibold text-highlighted">
Packer
</h1>
<p class="mt-1 hidden text-sm text-muted print:block">
{{ summary }}
</p>
</div>
<UButton
v-if="changed"
label="Zurücksetzen"
icon="i-lucide-rotate-ccw"
color="neutral"
variant="ghost"
size="sm"
title="Einstellungen, Häkchen, hinzugefügte und entfernte Sachen zurücksetzen"
class="print:hidden"
@click="reset"
/>
</div>
<PackerSettingsBar
v-model="settings"
:activities="activities"
:stays="stays"
:weathers="weathers"
:weather-extras="weatherExtras"
:has-seasons="hasSeasons"
class="mb-8 print:hidden"
/>
<UAlert
v-if="error"
color="error"
variant="subtle"
icon="i-lucide-triangle-alert"
title="Die Packliste konnte nicht geladen werden"
:description="errorMessage(error)"
class="mb-6"
/>
<div v-if="shown.length" class="mb-6 print:hidden">
<UProgress :model-value="packed" :max="shown.length" />
<div class="mt-2 flex justify-between gap-4 text-sm text-muted tabular-nums">
<span>{{ packed }} / {{ shown.length }} gepackt</span>
<!-- "≥": some items have no weight yet -->
<span v-if="weighed.length" :title="unweighed ? `${unweighed} ${unweighed === 1 ? 'Sache' : 'Sachen'} ohne Gewicht` : undefined">
{{ packerWeight(packedWeight) }} / {{ unweighed ? '≥ ' : '' }}{{ packerWeight(weight) }}
</span>
</div>
</div>
<div class="columns-1 gap-8 sm:columns-2 print:columns-2">
<PackerCategory
v-for="category in categories"
:key="category.name"
:category="category"
:checked="checked"
@toggle="toggle"
@add="(name, weightG) => add(category.name, name, weightG)"
@remove="remove"
@weight="setWeight"
/>
</div>
<p v-if="!categories.length && !error && status !== 'pending'" class="py-16 text-center text-muted">
Nichts zu packen.
</p>
<SavePdfButton v-if="shown.length" @click="savePdf" />
</UContainer>
</UMain>
</template>
<style>
/* As on the CV: top and bottom of every printed page. The sides and the extra space above the first page come from the
container's print padding, which also works in browsers that ignore @page margins */
@page {
margin: 15mm 0;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
export default defineNuxtConfig({
runtimeConfig: {
baserowPackerTableId: ''
}
})
@@ -0,0 +1,11 @@
/** Every item of the packing table in table order, and the activities and weathers to pick from */
export default defineEventHandler(async (event): Promise<PackerData> => {
const [rows, options] = await Promise.all([
listRows<PackerRow>(event, packerTable(event)),
listPackerOptions(event)
])
return {
items: rows.map(toPackerItem).filter((item): item is PackerItem => !!item),
...options
}
})
+62
View File
@@ -0,0 +1,62 @@
import type { H3Event } from 'h3'
interface SelectValue {
value: string
}
export interface PackerRow {
id: number
item: string | null
category: SelectValue | null
sharable: boolean
every_n_days?: string | null
weight_g?: string | null
season?: SelectValue[] | null
activity?: SelectValue[] | null
/** A single select; a multiple select works too */
weather?: SelectValue | SelectValue[] | null
}
interface PackerField {
name: string
select_options?: SelectValue[]
}
export function packerTable(event: H3Event): string {
const { baserowPackerTableId } = useRuntimeConfig(event)
if (!baserowPackerTableId) {
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_PACKER_TABLE_ID is not set' })
}
return baserowPackerTableId
}
/** Baserow sends numbers as strings; empty, zero and negative mean "not set" */
function positive(value: string | null | undefined): number | null {
const number = Number(value)
return value && number > 0 ? number : null
}
export function toPackerItem(row: PackerRow): PackerItem | null {
const name = (row.item ?? '').trim()
if (!name) return null
return {
id: row.id,
name,
category: row.category?.value ?? 'Andere',
sharable: !!row.sharable,
everyNDays: positive(row.every_n_days),
weightG: positive(row.weight_g),
seasons: (row.season ?? [])
.map(option => option.value as PackerSeason)
.filter(season => PACKER_SEASONS.includes(season)),
activities: (row.activity ?? []).map(option => option.value),
weathers: [row.weather ?? []].flat().map(option => option.value)
}
}
/** The options of the `activity` and `weather` fields, in the order set in Baserow */
export async function listPackerOptions(event: H3Event): Promise<Pick<PackerData, 'activities' | 'weathers'>> {
const fields = await listFields<PackerField>(event, packerTable(event))
const options = (name: string) => (fields.find(field => field.name === name)?.select_options ?? []).map(option => option.value)
return { activities: options('activity'), weathers: options('weather') }
}
+28
View File
@@ -0,0 +1,28 @@
export const PACKER_SEASONS = ['Frühling', 'Sommer', 'Herbst', 'Winter'] as const
export type PackerSeason = typeof PACKER_SEASONS[number]
export interface PackerItem {
id: number
name: string
category: string
/** One per group is enough, everything else is packed by every person */
sharable: boolean
/** One every n days of the trip; null for one per trip */
everyNDays: number | null
/** Weight of one, in grams; null while unknown */
weightG: number | null
/** Only packed in these seasons; empty for every season */
seasons: PackerSeason[]
/** Only packed when one of these activities is picked; empty for every trip */
activities: string[]
/** Only packed in one of these weathers; empty for any weather */
weathers: string[]
}
export interface PackerData {
items: PackerItem[]
/** The options of the table's `activity` field, in their order */
activities: string[]
/** The options of the table's `weather` field, in their order */
weathers: string[]
}