feat: add packer
This commit is contained in:
@@ -14,14 +14,8 @@ const EDUCATION: (CvType | null)[] = ['Student', 'Pupil']
|
||||
|
||||
const { data: entries, error, status } = useFetch<CvEntry[]>('/api/cv', { key: 'cv-entries', default: () => [] })
|
||||
|
||||
// Opens the browser's print dialog, which offers "Save as PDF"
|
||||
function savePdf() {
|
||||
window.print()
|
||||
}
|
||||
|
||||
// Browsers name the PDF after the page title, so it is "joaquin_gottlebe_cv" while printing (also with ⌘ P)
|
||||
// The PDF is named "joaquin_gottlebe_cv"
|
||||
const pdfName = `${(profile.name || 'cv').toLowerCase().trim().replace(/\s+/g, '_')}${profile.name ? '_cv' : ''}`
|
||||
let screenTitle = ''
|
||||
// The date under the CV is the day it is printed
|
||||
const printDate = ref(new Date())
|
||||
const signedOn = computed(() => {
|
||||
@@ -30,21 +24,8 @@ const signedOn = computed(() => {
|
||||
: printDate.value.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
return [profile.city, date].filter(Boolean).join(', ')
|
||||
})
|
||||
function onBeforePrint() {
|
||||
const { savePdf } = usePdfExport(pdfName, () => {
|
||||
printDate.value = new Date()
|
||||
screenTitle = document.title
|
||||
document.title = pdfName
|
||||
}
|
||||
function onAfterPrint() {
|
||||
if (screenTitle) document.title = screenTitle
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeprint', onBeforePrint)
|
||||
window.addEventListener('afterprint', onAfterPrint)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeprint', onBeforePrint)
|
||||
window.removeEventListener('afterprint', onAfterPrint)
|
||||
})
|
||||
|
||||
const education = computed(() => entries.value.filter(entry => EDUCATION.includes(entry.type)))
|
||||
@@ -122,9 +103,7 @@ const experience = computed(() => entries.value.filter(entry => !EDUCATION.inclu
|
||||
{{ signedOn }}
|
||||
</p>
|
||||
|
||||
<div v-if="entries.length" class="mt-16 flex justify-center print:hidden">
|
||||
<UButton :label="t.savePdf" icon="i-lucide-download" color="primary" variant="outline" @click="savePdf" />
|
||||
</div>
|
||||
<SavePdfButton v-if="entries.length" :label="t.savePdf" @click="savePdf" />
|
||||
</UContainer>
|
||||
</UMain>
|
||||
</template>
|
||||
|
||||
@@ -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