From 5452f72087739586c16d6df145fd661046aa5be8 Mon Sep 17 00:00:00 2001 From: huaqo Date: Sat, 3 Oct 2026 23:11:51 +0200 Subject: [PATCH] feat: add packer --- .env.example | 1 + README.md | 69 +++- app/components/SavePdfButton.vue | 11 + app/composables/usePdfExport.ts | 35 ++ layers/cv/app/pages/cv.vue | 27 +- layers/packer/app/app.config.ts | 54 ++++ .../packer/app/components/PackerCategory.vue | 140 ++++++++ .../app/components/PackerSettingsBar.vue | 133 ++++++++ layers/packer/app/composables/usePacker.ts | 300 ++++++++++++++++++ layers/packer/app/pages/packer.vue | 125 ++++++++ layers/packer/nuxt.config.ts | 5 + layers/packer/server/api/packer/index.get.ts | 11 + layers/packer/server/utils/packer.ts | 62 ++++ layers/packer/shared/types/packer.ts | 28 ++ 14 files changed, 976 insertions(+), 25 deletions(-) create mode 100644 app/components/SavePdfButton.vue create mode 100644 app/composables/usePdfExport.ts create mode 100644 layers/packer/app/app.config.ts create mode 100644 layers/packer/app/components/PackerCategory.vue create mode 100644 layers/packer/app/components/PackerSettingsBar.vue create mode 100644 layers/packer/app/composables/usePacker.ts create mode 100644 layers/packer/app/pages/packer.vue create mode 100644 layers/packer/nuxt.config.ts create mode 100644 layers/packer/server/api/packer/index.get.ts create mode 100644 layers/packer/server/utils/packer.ts create mode 100644 layers/packer/shared/types/packer.ts 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 @@ + + + diff --git a/layers/packer/app/components/PackerSettingsBar.vue b/layers/packer/app/components/PackerSettingsBar.vue new file mode 100644 index 0000000..d2ee75a --- /dev/null +++ b/layers/packer/app/components/PackerSettingsBar.vue @@ -0,0 +1,133 @@ + + + diff --git a/layers/packer/app/composables/usePacker.ts b/layers/packer/app/composables/usePacker.ts new file mode 100644 index 0000000..ccca130 --- /dev/null +++ b/layers/packer/app/composables/usePacker.ts @@ -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, 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` +} diff --git a/layers/packer/app/pages/packer.vue b/layers/packer/app/pages/packer.vue new file mode 100644 index 0000000..1e95d55 --- /dev/null +++ b/layers/packer/app/pages/packer.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/layers/packer/nuxt.config.ts b/layers/packer/nuxt.config.ts new file mode 100644 index 0000000..eb12abd --- /dev/null +++ b/layers/packer/nuxt.config.ts @@ -0,0 +1,5 @@ +export default defineNuxtConfig({ + runtimeConfig: { + baserowPackerTableId: '' + } +}) diff --git a/layers/packer/server/api/packer/index.get.ts b/layers/packer/server/api/packer/index.get.ts new file mode 100644 index 0000000..afb942b --- /dev/null +++ b/layers/packer/server/api/packer/index.get.ts @@ -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 => { + const [rows, options] = await Promise.all([ + listRows(event, packerTable(event)), + listPackerOptions(event) + ]) + return { + items: rows.map(toPackerItem).filter((item): item is PackerItem => !!item), + ...options + } +}) diff --git a/layers/packer/server/utils/packer.ts b/layers/packer/server/utils/packer.ts new file mode 100644 index 0000000..cbdd583 --- /dev/null +++ b/layers/packer/server/utils/packer.ts @@ -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> { + const fields = await listFields(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') } +} diff --git a/layers/packer/shared/types/packer.ts b/layers/packer/shared/types/packer.ts new file mode 100644 index 0000000..c44151c --- /dev/null +++ b/layers/packer/shared/types/packer.ts @@ -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[] +}