feat(hikes): in-season toggle + unified canton/country filter

Add two filters to the /hikes filter panel:

- "Nur Touren in der aktuellen Saison" toggle — keeps only hikes whose
  recommended season window covers the current month (year-wrap aware;
  hikes without a window count as year-round).
- "Kanton / Land" — a typeahead that abstracts the hike's area over the
  border: Swiss hikes group by canton (coat-of-arms), hikes abroad by
  country (flag). Generalised the tag typeahead into ChipTypeahead
  (optional icon + label mapping) and reused it for both tags and areas.

Supporting bits: countries.ts (ISO/name → flag), hikeArea.ts (the
canton-or-country resolver, namespaced so codes can't collide), prepared
flag SVGs for CH/DE/IT/AT/FR, and an optional `country` field on the hike
manifest type (populated by the build script; the app falls back to canton
for Swiss hikes until a rebuild).
This commit is contained in:
2026-05-22 13:06:47 +02:00
parent 48d971c216
commit 53695b8244
12 changed files with 265 additions and 40 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.79.1",
"version": "1.80.0",
"private": true,
"type": "module",
"scripts": {
@@ -1,42 +1,63 @@
<script lang="ts">
// Tag selection by typeahead — same interaction as the recipe TagFilter:
// a text field that opens a dropdown of matching tags, with the picked
// tags shown below as removable chips. Themed with the semantic variables
// (the recipe original hardcodes Nord values) so it fits the filter panel
// in both colour schemes.
// Typeahead chip selector — a text field that opens a dropdown of matching
// options, with the picked ones shown below as removable chips. Generic over
// the value: used for free-text tags (with a leading "#") and for cantons
// (with the coat-of-arms emblem rendered before the name). Themed with the
// semantic variables so it fits the filter panel in both colour schemes.
import type { SvelteSet } from 'svelte/reactivity';
import X from '@lucide/svelte/icons/x';
interface Props {
/** All selectable tags, in display order (frequency-sorted upstream). */
tags: string[];
/** Currently-selected tags. Mutated via {@link onToggle}. */
/** All selectable values, in display order. */
options: string[];
/** Currently-selected values. Mutated via {@link onToggle}. */
selected: SvelteSet<string>;
onToggle: (tag: string) => void;
onToggle: (value: string) => void;
placeholder?: string;
/** Prefix each value with a "#" (tag style). */
hash?: boolean;
/** Optional icon URL rendered before each value (e.g. canton emblem). */
iconFor?: (value: string) => string | undefined;
/** Display label for a value (defaults to the value itself). */
labelFor?: (value: string) => string;
}
const { tags, selected, onToggle }: Props = $props();
const {
options,
selected,
onToggle,
placeholder = 'Eingeben oder auswählen…',
hash = false,
iconFor,
labelFor = (v) => v
}: Props = $props();
// Unique per instance — two of these live in the panel at once (tags +
// cantons), so a shared id would be a duplicate.
const dropdownId = `tt-dd-${Math.random().toString(36).slice(2, 9)}`;
let inputValue = $state('');
let open = $state(false);
let wrapper = $state<HTMLElement>();
let inputEl = $state<HTMLInputElement>();
const unselected = $derived(tags.filter((t) => !selected.has(t)));
const unselected = $derived(options.filter((v) => !selected.has(v)));
const filtered = $derived(
inputValue.trim() === ''
? unselected
: unselected.filter((t) => t.toLowerCase().includes(inputValue.trim().toLowerCase()))
const filtered = $derived.by(() => {
const q = inputValue.trim().toLowerCase();
if (q === '') return unselected;
return unselected.filter(
(v) => labelFor(v).toLowerCase().includes(q) || v.toLowerCase().includes(q)
);
});
// Selected tags kept in the canonical display order rather than click order.
const selectedList = $derived(tags.filter((t) => selected.has(t)));
// Selected values kept in the canonical display order rather than click order.
const selectedList = $derived(options.filter((v) => selected.has(v)));
function pick(tag: string) {
onToggle(tag);
function pick(value: string) {
onToggle(value);
inputValue = '';
// Keep the field focused so several tags can be added in a row.
// Keep the field focused so several can be added in a row.
inputEl?.focus();
open = true;
}
@@ -44,8 +65,10 @@
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
const value = inputValue.trim().toLowerCase();
const match = filtered.find((t) => t.toLowerCase() === value) ?? filtered[0];
const q = inputValue.trim().toLowerCase();
const match =
filtered.find((v) => labelFor(v).toLowerCase() === q || v.toLowerCase() === q) ??
filtered[0];
if (match) pick(match);
} else if (e.key === 'Escape') {
if (inputValue) {
@@ -74,18 +97,21 @@
bind:value={inputValue}
onfocus={() => (open = true)}
onkeydown={onKey}
placeholder="Schlagwort eingeben oder auswählen…"
{placeholder}
autocomplete="off"
role="combobox"
aria-expanded={open}
aria-controls="tt-dropdown"
aria-controls={dropdownId}
/>
{#if open && filtered.length > 0}
<div class="tt-dropdown" id="tt-dropdown">
{#each filtered as tag (tag)}
<button type="button" class="tt-option" onclick={() => pick(tag)}>
<span class="tt-hash" aria-hidden="true">#</span>{tag}
<div class="tt-dropdown" id={dropdownId}>
{#each filtered as value (value)}
{@const icon = iconFor?.(value)}
<button type="button" class="tt-option" onclick={() => pick(value)}>
{#if icon}<img class="tt-emblem" src={icon} alt="" aria-hidden="true" />{/if}
{#if hash}<span class="tt-hash" aria-hidden="true">#</span>{/if}
{labelFor(value)}
</button>
{/each}
</div>
@@ -94,14 +120,17 @@
{#if selectedList.length > 0}
<div class="tt-selected">
{#each selectedList as tag (tag)}
{#each selectedList as value (value)}
{@const icon = iconFor?.(value)}
<button
type="button"
class="tt-chip"
onclick={() => onToggle(tag)}
aria-label="{tag} entfernen"
onclick={() => onToggle(value)}
aria-label={`${labelFor(value)} entfernen`}
>
<span class="tt-hash" aria-hidden="true">#</span>{tag}
{#if icon}<img class="tt-emblem" src={icon} alt="" aria-hidden="true" />{/if}
{#if hash}<span class="tt-hash" aria-hidden="true">#</span>{/if}
{labelFor(value)}
<X size={13} strokeWidth={2} aria-hidden="true" />
</button>
{/each}
@@ -161,6 +190,9 @@
}
.tt-option {
display: inline-flex;
align-items: center;
gap: 0.3rem;
appearance: none;
font: inherit;
font-size: 0.8rem;
@@ -189,7 +221,7 @@
.tt-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
gap: 0.3rem;
appearance: none;
font: inherit;
font-size: 0.8rem;
@@ -212,6 +244,15 @@
opacity: 0.85;
}
/* Canton coat-of-arms — tall shield, kept proportional in a fixed slot. */
.tt-emblem {
width: 13px;
height: 16px;
object-fit: contain;
flex: 0 0 auto;
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.18));
}
.tt-hash {
opacity: 0.6;
font-weight: 600;
+81 -4
View File
@@ -5,7 +5,9 @@
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import X from '@lucide/svelte/icons/x';
import RangeSlider from './RangeSlider.svelte';
import TagTypeahead from './TagTypeahead.svelte';
import ChipTypeahead from './ChipTypeahead.svelte';
import Toggle from '$lib/components/Toggle.svelte';
import { resolveHikeArea, type HikeArea } from '$lib/hikes/hikeArea';
import {
hikeFilterBounds,
DISTANCE_STEP,
@@ -25,7 +27,12 @@
maxLossM: number;
difficulties: SvelteSet<Difficulty>;
regions: SvelteSet<string>;
/** Namespaced area values — canton (CH) or country (abroad). See
* {@link resolveHikeArea}. */
areas: SvelteSet<string>;
tags: SvelteSet<string>;
/** Show only hikes whose recommended season covers the current month. */
inSeasonOnly: boolean;
};
interface Props {
@@ -66,6 +73,22 @@
return out.sort((a, b) => a.localeCompare(b));
});
// Geographic areas present in the data: a Swiss hike contributes its canton,
// a hike abroad its country. Deduped by namespaced value; cantons listed
// first (alphabetical), then countries (alphabetical).
const areaList = $derived.by(() => {
const map = new Map<string, HikeArea>();
for (const h of hikes) {
const a = resolveHikeArea(h.canton, h.country);
if (a && !map.has(a.value)) map.set(a.value, a);
}
return [...map.values()].sort((a, b) =>
a.kind === b.kind ? a.label.localeCompare(b.label) : a.kind === 'canton' ? -1 : 1
);
});
const areaValues = $derived(areaList.map((a) => a.value));
const areaByValue = $derived(new Map(areaList.map((a) => [a.value, a])));
// Tags sorted by usage frequency (most-used first), alphabetical for
// ties. Frequency ordering surfaces broadly-applicable filters like
// "winter" or "easy" at the head of the list, where they're most
@@ -114,10 +137,12 @@
// Active filters, flattened into removable chips for the collapsed bar.
// A range counts as "active" only when narrowed below its data ceiling.
type Chip = { key: string; label: string; clear: () => void };
type Chip = { key: string; label: string; icon?: string; clear: () => void };
const chips = $derived.by<Chip[]>(() => {
const out: Chip[] = [];
const { distance, duration, gain, loss } = bounds;
if (filter.inSeasonOnly)
out.push({ key: 'season', label: 'In Saison', clear: () => (filter.inSeasonOnly = false) });
if (filter.minDistanceKm > distance.min || filter.maxDistanceKm < distance.max)
out.push({
key: 'dist',
@@ -159,6 +184,15 @@
out.push({ key: `d-${d}`, label: d, clear: () => filter.difficulties.delete(d) });
for (const r of filter.regions)
out.push({ key: `r-${r}`, label: r, clear: () => filter.regions.delete(r) });
for (const value of filter.areas) {
const a = areaByValue.get(value);
out.push({
key: `a-${value}`,
label: a?.label ?? value,
icon: a?.iconUrl,
clear: () => filter.areas.delete(value)
});
}
for (const t of filter.tags)
out.push({ key: `t-${t}`, label: `#${t}`, clear: () => filter.tags.delete(t) });
return out;
@@ -176,6 +210,11 @@
else filter.regions.add(r);
}
function toggleArea(value: string) {
if (filter.areas.has(value)) filter.areas.delete(value);
else filter.areas.add(value);
}
function toggleTag(t: string) {
if (filter.tags.has(t)) filter.tags.delete(t);
else filter.tags.add(t);
@@ -192,7 +231,9 @@
filter.maxLossM = bounds.loss.max;
filter.difficulties.clear();
filter.regions.clear();
filter.areas.clear();
filter.tags.clear();
filter.inSeasonOnly = false;
}
// Light-dismiss: close the panel on outside click or Escape. Only wired
@@ -229,7 +270,8 @@
<div class="active-chips" aria-label="Aktive Filter">
{#each chips as chip (chip.key)}
<button type="button" class="chip" onclick={chip.clear}>
<span class="chip-label">{chip.label}</span>
{#if chip.icon}<img class="chip-emblem" src={chip.icon} alt="" aria-hidden="true" />{/if}<span
class="chip-label">{chip.label}</span>
<X size={13} strokeWidth={2} aria-label="entfernen" />
</button>
{/each}
@@ -253,6 +295,12 @@
{#if open}
<div id="filter-panel" class="panel" transition:slide={{ duration: 200 }}>
<div class="season-row">
<Toggle bind:checked={filter.inSeasonOnly} label="Nur Touren in der aktuellen Saison" />
</div>
<hr class="divider" />
<div class="ranges">
<RangeSlider
label="Distanz"
@@ -328,10 +376,30 @@
</fieldset>
{/if}
{#if areaList.length > 0}
<fieldset>
<legend>Kanton / Land</legend>
<ChipTypeahead
options={areaValues}
selected={filter.areas}
onToggle={toggleArea}
placeholder="Kanton oder Land eingeben oder auswählen…"
iconFor={(value) => areaByValue.get(value)?.iconUrl}
labelFor={(value) => areaByValue.get(value)?.label ?? value}
/>
</fieldset>
{/if}
{#if tags.length > 0}
<fieldset>
<legend>Schlagwörter</legend>
<TagTypeahead {tags} selected={filter.tags} onToggle={toggleTag} />
<ChipTypeahead
options={tags}
selected={filter.tags}
onToggle={toggleTag}
hash
placeholder="Schlagwort eingeben oder auswählen…"
/>
</fieldset>
{/if}
@@ -428,6 +496,15 @@
font-variant-numeric: tabular-nums;
}
/* Canton coat-of-arms inside an active-filter chip. */
.chip-emblem {
width: 12px;
height: 15px;
object-fit: contain;
flex: 0 0 auto;
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.18));
}
.clear-all {
appearance: none;
background: transparent;
+52
View File
@@ -0,0 +1,52 @@
/**
* Country lookup for hikes outside Switzerland. `resolveCountry(name)` takes
* an ISO 3166-1 alpha-2 code (what the build-time geocoder emits) or a
* free-form country name (any common language) and resolves it to a stable
* record with the code, a German display name, and the flag SVG URL.
*
* The flags live in `static/countries/<code>.svg` (lowercase ISO code).
* Swiss hikes are grouped by canton instead — see `resolveHikeArea`.
*/
export type Country = {
/** ISO 3166-1 alpha-2 code, uppercase (e.g. 'DE'). */
code: string;
/** German display name. */
name: string;
/** Absolute URL of the flag SVG. */
flagUrl: string;
};
// [code, German name, ...alternate names / spellings the geocoder may return].
const COUNTRY_TABLE: ReadonlyArray<readonly [string, string, ...string[]]> = [
['CH', 'Schweiz', 'Switzerland', 'Suisse', 'Svizzera', 'Svizra'],
['DE', 'Deutschland', 'Germany', 'Allemagne', 'Germania'],
['IT', 'Italien', 'Italy', 'Italia', 'Italie'],
['AT', 'Österreich', 'Austria', 'Autriche', 'Oesterreich'],
['FR', 'Frankreich', 'France', 'Francia'],
['LI', 'Liechtenstein']
];
function normalise(s: string): string {
return s
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/gi, ' ')
.trim()
.toLowerCase();
}
const BY_NAME = new Map<string, Country>();
for (const [code, name, ...alts] of COUNTRY_TABLE) {
const country: Country = { code, name, flagUrl: `/countries/${code.toLowerCase()}.svg` };
BY_NAME.set(normalise(name), country);
BY_NAME.set(normalise(code), country);
for (const alt of alts) BY_NAME.set(normalise(alt), country);
}
/** Resolve an ISO code or free-form country name to a Country, or null if
* unknown (only the prepared Alpine-region countries are listed). */
export function resolveCountry(name: string | null | undefined): Country | null {
if (!name) return null;
return BY_NAME.get(normalise(name)) ?? null;
}
+27
View File
@@ -0,0 +1,27 @@
import { resolveCanton } from '$lib/data/cantons';
import { resolveCountry } from '$lib/data/countries';
/**
* Geographic grouping for a hike, abstracted over the border: a Swiss hike is
* grouped by its canton (with the coat-of-arms), a hike abroad by its country
* (with the flag). Canton wins when present, so existing Swiss manifest
* entries keep working even before the build adds a `country`.
*/
export type HikeArea = {
/** Namespaced so canton and country codes can't collide in a filter set. */
value: string;
label: string;
iconUrl: string;
kind: 'canton' | 'country';
};
export function resolveHikeArea(
canton: string | null | undefined,
country: string | null | undefined
): HikeArea | null {
const c = resolveCanton(canton);
if (c) return { value: `canton:${c.code}`, label: c.name, iconUrl: c.emblemUrl, kind: 'canton' };
const k = resolveCountry(country);
if (k) return { value: `country:${k.code}`, label: k.name, iconUrl: k.flagUrl, kind: 'country' };
return null;
}
+19 -1
View File
@@ -8,7 +8,17 @@
import Seo from '$lib/components/Seo.svelte';
import { HIKES_OVERVIEW } from '$lib/data/hikes.generated';
import { hikeFilterBounds } from '$lib/hikes/filterBounds';
import { resolveHikeArea } from '$lib/hikes/hikeArea';
import type { Difficulty } from '$types/hikes';
// True when the current month falls inside the hike's recommended season
// window. Windows can wrap the new year (start > end, e.g. 113 for winter);
// a missing/invalid window counts as year-round (always in season).
function isInSeason(start: number | null | undefined, end: number | null | undefined, month: number): boolean {
if (start == null || end == null) return true;
if (start < 1 || start > 12 || end < 1 || end > 12) return true;
return start <= end ? month >= start && month <= end : month >= start || month <= end;
}
import type { PageProps } from './$types';
const { data }: PageProps = $props();
@@ -62,7 +72,9 @@
maxLossM: Number.POSITIVE_INFINITY,
difficulties: new SvelteSet<Difficulty>(),
regions: new SvelteSet<string>(),
tags: new SvelteSet<string>()
areas: new SvelteSet<string>(),
tags: new SvelteSet<string>(),
inSeasonOnly: false
});
// Tag deep-link: arrival from a detail-page tag chip (`/hikes?tag=winter`)
@@ -121,6 +133,7 @@
const visible = $derived.by(() => {
const out = [];
const currentMonth = new Date().getMonth() + 1;
for (const h of data.hikes) {
if (h.distanceKm < filter.minDistanceKm || h.distanceKm > filter.maxDistanceKm) continue;
const dur = h.durationMin ?? 0;
@@ -129,6 +142,11 @@
if (h.elevationLossM < filter.minLossM || h.elevationLossM > filter.maxLossM) continue;
if (filter.difficulties.size > 0 && !filter.difficulties.has(h.difficulty)) continue;
if (filter.regions.size > 0 && (!h.region || !filter.regions.has(h.region))) continue;
if (filter.areas.size > 0) {
const area = resolveHikeArea(h.canton, h.country);
if (!area || !filter.areas.has(area.value)) continue;
}
if (filter.inSeasonOnly && !isInSeason(h.seasonStart, h.seasonEnd, currentMonth)) continue;
// Multi-tag = OR (a hike matching ANY selected tag is shown). AND
// would shrink the listing to ~zero quickly given how few tags
// most hikes have; OR matches how detail-page chips feel like
+5
View File
@@ -54,6 +54,11 @@ export type HikeManifestEntry = {
region: string | null;
canton: string | null;
municipality: string | null;
/** ISO 3166-1 alpha-2 country code (e.g. 'CH', 'DE'), detected at build
* time from the centroid. Swiss hikes are grouped by `canton`; hikes
* abroad fall back to this. Optional so pre-existing manifest entries
* (built before the field existed) still type-check. */
country?: string | null;
// Recommended hiking-season window, 1-12 (Jan-Dec). When start > end the
// window wraps the new year (e.g. 113 for a winter route). Absent /
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="#ed2939"/><rect width="3" height="0.6667" y="0.6667" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 164 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#d52b1e"/><rect x="13" y="6" width="6" height="20" fill="#fff"/><rect x="6" y="13" width="20" height="6" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 220 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3"><rect width="5" height="3" fill="#ffce00"/><rect width="5" height="2" fill="#d00"/><rect width="5" height="1" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 188 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="#ef4135"/><rect width="2" height="2" fill="#fff"/><rect width="1" height="2" fill="#0055a4"/></svg>

After

Width:  |  Height:  |  Size: 191 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="#ce2b37"/><rect width="2" height="2" fill="#fff"/><rect width="1" height="2" fill="#009246"/></svg>

After

Width:  |  Height:  |  Size: 191 B