feat: add packer
This commit is contained in:
@@ -5,5 +5,6 @@ NUXT_BASEROW_NOTES_TABLE_ID=864
|
|||||||
NUXT_BASEROW_WISHLIST_TABLE_ID=870
|
NUXT_BASEROW_WISHLIST_TABLE_ID=870
|
||||||
NUXT_BASEROW_NOTES_APP_TABLE_ID=
|
NUXT_BASEROW_NOTES_APP_TABLE_ID=
|
||||||
NUXT_BASEROW_CV_TABLE_ID=833
|
NUXT_BASEROW_CV_TABLE_ID=833
|
||||||
|
NUXT_BASEROW_PACKER_TABLE_ID=873
|
||||||
NUXT_DIARY_PASSWORD=
|
NUXT_DIARY_PASSWORD=
|
||||||
NUXT_SESSION_SECRET=
|
NUXT_SESSION_SECRET=
|
||||||
|
|||||||
@@ -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
|
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
|
(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.
|
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 |
|
| Path | App |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -20,6 +21,7 @@ picked up automatically. The root only holds what apps share:
|
|||||||
| `/wishlist` | [Wishlist](#wishlist) — `layers/wishlist/` |
|
| `/wishlist` | [Wishlist](#wishlist) — `layers/wishlist/` |
|
||||||
| `/notes` | [Notes](#notes) — `layers/notes/` (password protected) |
|
| `/notes` | [Notes](#notes) — `layers/notes/` (password protected) |
|
||||||
| `/cv` | [CV](#cv) — `layers/cv/` |
|
| `/cv` | [CV](#cv) — `layers/cv/` |
|
||||||
|
| `/packer` | [Packer](#packer) — `layers/packer/` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
@@ -36,6 +38,70 @@ pnpm dev
|
|||||||
names that won't clash with another app's (e.g. `DiaryEntry`, not `Entry`).
|
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.
|
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
|
# CV
|
||||||
|
|
||||||
A public CV built from the Baserow CV table (`NUXT_BASEROW_CV_TABLE_ID`). Only rows with `relevant` checked are shown,
|
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_NOTES_TABLE_ID` | Notes table |
|
||||||
| `NUXT_BASEROW_WISHLIST_TABLE_ID` | Wishlist table (see [Wishlist](#wishlist)) |
|
| `NUXT_BASEROW_WISHLIST_TABLE_ID` | Wishlist table (see [Wishlist](#wishlist)) |
|
||||||
| `NUXT_BASEROW_CV_TABLE_ID` | CV table (see [CV](#cv)) |
|
| `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_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_DIARY_PASSWORD` | Password that unlocks the diary and notes |
|
||||||
| `NUXT_SESSION_SECRET` | Signs the login cookie, at least 32 characters (`openssl rand -hex 32`) |
|
| `NUXT_SESSION_SECRET` | Signs the login cookie, at least 32 characters (`openssl rand -hex 32`) |
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -14,14 +14,8 @@ const EDUCATION: (CvType | null)[] = ['Student', 'Pupil']
|
|||||||
|
|
||||||
const { data: entries, error, status } = useFetch<CvEntry[]>('/api/cv', { key: 'cv-entries', default: () => [] })
|
const { data: entries, error, status } = useFetch<CvEntry[]>('/api/cv', { key: 'cv-entries', default: () => [] })
|
||||||
|
|
||||||
// Opens the browser's print dialog, which offers "Save as PDF"
|
// The PDF is named "joaquin_gottlebe_cv"
|
||||||
function savePdf() {
|
|
||||||
window.print()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browsers name the PDF after the page title, so it is "joaquin_gottlebe_cv" while printing (also with ⌘ P)
|
|
||||||
const pdfName = `${(profile.name || 'cv').toLowerCase().trim().replace(/\s+/g, '_')}${profile.name ? '_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
|
// The date under the CV is the day it is printed
|
||||||
const printDate = ref(new Date())
|
const printDate = ref(new Date())
|
||||||
const signedOn = computed(() => {
|
const signedOn = computed(() => {
|
||||||
@@ -30,21 +24,8 @@ const signedOn = computed(() => {
|
|||||||
: printDate.value.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
|
: printDate.value.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
return [profile.city, date].filter(Boolean).join(', ')
|
return [profile.city, date].filter(Boolean).join(', ')
|
||||||
})
|
})
|
||||||
function onBeforePrint() {
|
const { savePdf } = usePdfExport(pdfName, () => {
|
||||||
printDate.value = new Date()
|
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)))
|
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 }}
|
{{ signedOn }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="entries.length" class="mt-16 flex justify-center print:hidden">
|
<SavePdfButton v-if="entries.length" :label="t.savePdf" @click="savePdf" />
|
||||||
<UButton :label="t.savePdf" icon="i-lucide-download" color="primary" variant="outline" @click="savePdf" />
|
|
||||||
</div>
|
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</UMain>
|
</UMain>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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`
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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') }
|
||||||
|
}
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user