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
+1
View File
@@ -5,5 +5,6 @@ NUXT_BASEROW_NOTES_TABLE_ID=864
NUXT_BASEROW_WISHLIST_TABLE_ID=870
NUXT_BASEROW_NOTES_APP_TABLE_ID=
NUXT_BASEROW_CV_TABLE_ID=833
NUXT_BASEROW_PACKER_TABLE_ID=873
NUXT_DIARY_PASSWORD=
NUXT_SESSION_SECRET=
+68 -1
View File
@@ -12,7 +12,8 @@ picked up automatically. The root only holds what apps share:
401 for the API prefixes in `PROTECTED_PREFIXES` until `/api/auth/login` has set the session cookie
(signed with `NUXT_SESSION_SECRET`, http-only, 30 days), and a page wraps itself in `<PasswordGate>` to show the
password prompt. To protect a new app, add its API prefix and use the gate.
- `readValidBody(event, zodSchema)` and the client util `errorMessage(error)`
- `readValidBody(event, zodSchema)`, the client util `errorMessage(error)` and `usePdfExport(fileName)` ("Save as PDF"
through the browser's print dialog: names the PDF and prints light also in dark mode; used by CV and Packer, with the `<SavePdfButton>` under the content)
| Path | App |
|---|---|
@@ -20,6 +21,7 @@ picked up automatically. The root only holds what apps share:
| `/wishlist` | [Wishlist](#wishlist) — `layers/wishlist/` |
| `/notes` | [Notes](#notes) — `layers/notes/` (password protected) |
| `/cv` | [CV](#cv) — `layers/cv/` |
| `/packer` | [Packer](#packer) — `layers/packer/` |
```bash
pnpm install
@@ -36,6 +38,70 @@ pnpm dev
names that won't clash with another app's (e.g. `DiaryEntry`, not `Entry`).
3. Don't use `~/` inside a layer — it points to the root `app/`. Use relative imports.
# Packer
A public packing list built from the Baserow packing table (`NUXT_BASEROW_PACKER_TABLE_ID`). At the top you set the
trip: number of days and people, where you sleep, weather, season and activities. The list below is grouped by category in table order, items sorted alphabetically.
The settings are kept in the URL, so a list can be bookmarked or sent.
- **Activities** are the options of the table's `activity` field that some item uses, sorted alphabetically. Items with activities are
only listed when one of them is picked; items without are always listed. Their icons are set in
`layers/packer/app/app.config.ts` under `packer.activityIcons` (a tag icon otherwise).
- **Sleeping:** the activities in `packer.stays` (Übernachtung, Camping) aren't picked with the others. On a trip of
more than one day a switch picks exactly one of them (the first by default); a day trip has neither.
- **Counts:** an item with `every_n_days` is packed once every n days (rounded up), the rest once. With the
`packer.laundry` activity (Wäsche waschen) picked, items of the laundry categories (Kleidung) are counted for at
most 4 days.
- **Automatic activities** (`packer.autoActivities`): Wäsche waschen from 5 days, Duschen from 2 days, Kochen when
sleeping is Camping. Each turns on when its condition starts to hold and off when it stops; in between it can be
switched by hand.
- **Weather:** a switch between the options of the `weather` field that some item uses (Sonnig, Bewölkt, Regnerisch;
`packer.defaultWeather` on a fresh list), and next to it toggles for the weathers in `packer.weatherExtras` (Windig),
which come on top of the picked one. Items with weathers are listed when one of them applies, empty means any
weather. `weather` may be a single or a multiple select. Icons and the switch's order in `packer.weatherIcons`.
- **Alternatives:** rows with the same `item` name are listed once, when any of them fits the trip. That is how an item
gets "or" conditions, e.g. `Schal/Buff` once with season Winter and once with weather Windig. Ticks, deletions and
weight changes belong to the name's first row, so they stick whichever row fits.
- **Season:** items with `season` set are only listed in those seasons, empty means always. The season defaults to
today's in Germany, by meteorological seasons (spring from 1 March, summer from 1 June, autumn from
1 September, winter from 1 December).
- **People:** everything that isn't `sharable` is multiplied by the number of people; `sharable` items are packed
once for the group.
- **Weight:** once `weight_g` is filled in, each item shows its weight (times its count), and under the progress bar
the weight already packed next to the list's total; "≥" means some listed items have no weight yet (hover for how
many).
- **Session only:** you can tick items off, delete items (✕, on hover with a mouse), add items to a category (fields at
the bottom of each category: name and optional weight; added items count once) and change any item's weight (click
it; it's the weight of one piece, an empty field goes back to the table's weight, changed weights are shown in the
primary color). None of this touches the table: it is kept in the browser
tab's `sessionStorage`, survives reloads and is gone when the tab is closed.
- **Als PDF speichern** (under the list, as on the CV) prints the list as a checklist (with the browser's "Save as PDF"): the trip in one line under the title
(days, people, season, stay, activities, total weight), the categories in two columns, ticked items ticked. The
settings and the buttons are left out; the file is named `packer_<days>_tage`.
- **Zurücksetzen** (shown once anything differs from a fresh list) clears all of that and puts the settings back to
their defaults (weights changed on the page go back to the table's too): 1 day, 1 person, today's season, Übernachtung and the automatic activities.
**Table:** `item` (text), `category` (single select), `sharable` (boolean), `every_n_days` (number), `weight_g`
(number), `season` (multiple select: `Frühling`, `Sommer`, `Herbst`, `Winter`), `activity` (multiple select),
`weather` (single or multiple select). Rows
without `item` are skipped. The token needs read (rows and fields).
```
layers/packer/
nuxt.config.ts baserowPackerTableId
app/
app.config.ts packer: activityIcons, weatherIcons, defaultWeather, weatherExtras, stays, laundry,
autoActivities
pages/packer.vue settings, progress, reset, the list, PDF button
components/PackerSettingsBar.vue days, people, sleeping, weather, season, activities
components/PackerCategory.vue one category: tick, delete, add
composables/usePacker.ts settings in the URL, session changes, filtering and counts
server/
api/packer/index.get.ts GET all items, the activities and the weathers
utils/packer.ts Baserow row -> PackerItem
shared/types/packer.ts
```
# CV
A public CV built from the Baserow CV table (`NUXT_BASEROW_CV_TABLE_ID`). Only rows with `relevant` checked are shown,
@@ -160,6 +226,7 @@ self-hosted Baserow tables.
| `NUXT_BASEROW_NOTES_TABLE_ID` | Notes table |
| `NUXT_BASEROW_WISHLIST_TABLE_ID` | Wishlist table (see [Wishlist](#wishlist)) |
| `NUXT_BASEROW_CV_TABLE_ID` | CV table (see [CV](#cv)) |
| `NUXT_BASEROW_PACKER_TABLE_ID` | Packing table (see [Packer](#packer)) |
| `NUXT_BASEROW_NOTES_APP_TABLE_ID` | Table of the notes app (see [Notes](#notes)), not the diary's notes |
| `NUXT_DIARY_PASSWORD` | Password that unlocks the diary and notes |
| `NUXT_SESSION_SECRET` | Signs the login cookie, at least 32 characters (`openssl rand -hex 32`) |
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
// The "Save as PDF" button under a page's content (CV, Packer); pair it with usePdfExport
withDefaults(defineProps<{ label?: string }>(), { label: 'Als PDF speichern' })
defineEmits<{ click: [] }>()
</script>
<template>
<div class="mt-16 flex justify-center print:hidden">
<UButton :label="label" icon="i-lucide-download" color="primary" variant="outline" @click="$emit('click')" />
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
/**
* "Save as PDF" through the browser's print dialog. While printing (also with ⌘ P) the page title is `fileName`, which
* browsers name the PDF after, and dark mode is off, so the PDF is dark text on white.
*/
export function usePdfExport(fileName: MaybeRefOrGetter<string>, onBeforePrint?: () => void) {
// What to restore after printing; null while not printing. Browsers can fire beforeprint twice before afterprint.
let screen: { title: string, dark: boolean } | null = null
function beforePrint() {
onBeforePrint?.()
if (screen) return
screen = { title: document.title, dark: document.documentElement.classList.contains('dark') }
document.title = toValue(fileName)
document.documentElement.classList.remove('dark')
}
function afterPrint() {
if (!screen) return
document.title = screen.title
if (screen.dark) document.documentElement.classList.add('dark')
screen = null
}
onMounted(() => {
window.addEventListener('beforeprint', beforePrint)
window.addEventListener('afterprint', afterPrint)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeprint', beforePrint)
window.removeEventListener('afterprint', afterPrint)
})
// Opens the browser's print dialog, which offers "Save as PDF"
return { savePdf: () => window.print() }
}
+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[]
}