feat: add packer
This commit is contained in:
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user