diff --git a/.env.example b/.env.example
index 68f883a..e1073d2 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
diff --git a/README.md b/README.md
index b79df35..3b897c6 100644
--- a/README.md
+++ b/README.md
@@ -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 `` 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 `` 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__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`) |
diff --git a/app/components/SavePdfButton.vue b/app/components/SavePdfButton.vue
new file mode 100644
index 0000000..8d36e11
--- /dev/null
+++ b/app/components/SavePdfButton.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/app/composables/usePdfExport.ts b/app/composables/usePdfExport.ts
new file mode 100644
index 0000000..7a39187
--- /dev/null
+++ b/app/composables/usePdfExport.ts
@@ -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, 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() }
+}
diff --git a/layers/cv/app/pages/cv.vue b/layers/cv/app/pages/cv.vue
index 4052108..4050802 100644
--- a/layers/cv/app/pages/cv.vue
+++ b/layers/cv/app/pages/cv.vue
@@ -14,14 +14,8 @@ const EDUCATION: (CvType | null)[] = ['Student', 'Pupil']
const { data: entries, error, status } = useFetch('/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 }}
-
-
-
+
diff --git a/layers/packer/app/app.config.ts b/layers/packer/app/app.config.ts
new file mode 100644
index 0000000..30dcd15
--- /dev/null
+++ b/layers/packer/app/app.config.ts
@@ -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,
+ /** 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,
+ 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 }[]
+ }
+})
diff --git a/layers/packer/app/components/PackerCategory.vue b/layers/packer/app/components/PackerCategory.vue
new file mode 100644
index 0000000..ec2d75f
--- /dev/null
+++ b/layers/packer/app/components/PackerCategory.vue
@@ -0,0 +1,140 @@
+
+
+
+
+