301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
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`
|
|
}
|