feat(hikes): SBB-style public-transport journey planner for hike prose

Add JourneyPlanner.svelte: a "Von / Nach" connections widget to drop into a
hike's prose (e.g. with a fixed trailhead destination) so readers can plan
the trip there by public transport. Backed by the free Swiss transport API
(transport.opendata.ch — the same one sbb-tui uses).

- From/To fields: prefillable + per-field lockable (fromFixed / toFixed),
  swap when both editable, and "Aktueller Standort" via geolocation
  (coarse accuracy → fast on laptops; resolves to the nearest stop).
- Station typeahead on both fields via /locations?type=station, so routes
  start/end at canonical stops instead of fuzzy-geocoded points (which was
  yielding roundabout connections); plus resolve-on-search fallback. The
  dropdown grows flush from the field as a route-line spur (matching dots),
  typed text highlighted.
- Separate date + time controls: date defaults to the next weekend day
  (Sat on weekdays, Sun on a Saturday); time + departure/arrival target are
  author-settable props (time="08:00" target="arrival") and reader-editable.
- Results: per-leg transport-type icons (bus/train/tram/ship/cable) and an
  expandable full itinerary (stations, times, platforms, line + direction,
  coloured rails, walk connectors); link to the full search.ch timetable.

Add TimePicker.svelte: a shared pill time picker (HH:MM string, chevron
nudges, hour/minute dropdown) in the same language as DatePicker /
DateTimePicker.
This commit is contained in:
2026-05-22 18:33:23 +02:00
parent 2347a02fcb
commit 35872d731a
3 changed files with 1516 additions and 1 deletions
+326
View File
@@ -0,0 +1,326 @@
<script lang="ts">
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Clock from '@lucide/svelte/icons/clock';
import { m } from '$lib/js/commonI18n';
import type { CommonLang } from '$lib/js/commonI18n';
/**
* Pill-styled time picker, sibling to `DatePicker.svelte` (date) and
* `DateTimePicker.svelte` (combined). Operates on a plain `"HH:MM"` string so
* it drops straight into 24-hour API params and `<input type="time">`-shaped
* stores.
*
* - Chevron arrows nudge by `step` minutes (wrapping across the hour).
* - The display opens a two-column hour / minute dropdown.
* - Optional `min` / `max` (also `"HH:MM"`) disable out-of-range cells.
*/
interface Props {
value?: string;
/** Minute granularity for the dropdown + chevron nudges. */
step?: number;
min?: string;
max?: string;
lang?: CommonLang;
/** Optional extra CSS class on the outer wrapper. */
class?: string;
}
let {
value = $bindable(''),
step = 5,
min = '',
max = '',
lang = 'de',
class: extraClass = ''
}: Props = $props();
const t = $derived(m[lang]);
let open = $state(false);
let pickerRef = $state<HTMLDivElement | null>(null);
let hourCol = $state<HTMLDivElement | null>(null);
let minCol = $state<HTMLDivElement | null>(null);
function pad(n: number): string {
return n.toString().padStart(2, '0');
}
function parse(v: string): { h: number; m: number } | null {
const mt = /^(\d{1,2}):(\d{2})$/.exec(v ?? '');
if (!mt) return null;
const h = Number(mt[1]);
const mm = Number(mt[2]);
if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
return { h, m: mm };
}
const current = $derived(parse(value));
const label = $derived(current ? `${pad(current.h)}:${pad(current.m)}` : t.select_time);
const hours = Array.from({ length: 24 }, (_, i) => i);
const minutes = $derived(
Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step).filter((mm) => mm < 60)
);
function outOfRange(time: string): boolean {
if (min && time < min) return true;
if (max && time > max) return true;
return false;
}
function hourDisabled(h: number): boolean {
for (let mm = 0; mm < 60; mm += step) {
if (!outOfRange(`${pad(h)}:${pad(mm)}`)) return false;
}
return true;
}
function minuteDisabled(mm: number): boolean {
const h = current?.h ?? -1;
if (h < 0) return false;
return outOfRange(`${pad(h)}:${pad(mm)}`);
}
function commit(h: number, mm: number) {
const next = `${pad(h)}:${pad(mm)}`;
if (outOfRange(next)) return;
value = next;
}
function selectHour(h: number) {
commit(h, current?.m ?? 0);
}
function selectMinute(mm: number) {
commit(current?.h ?? new Date().getHours(), mm);
}
function nudge(delta: number) {
const base = current ?? { h: new Date().getHours(), m: 0 };
let total = (base.h * 60 + base.m + delta) % (24 * 60);
if (total < 0) total += 24 * 60;
commit(Math.floor(total / 60), total % 60);
}
function setNow() {
const d = new Date();
let mm = Math.round(d.getMinutes() / step) * step;
let h = d.getHours();
if (mm >= 60) {
mm = 0;
h = (h + 1) % 24;
}
commit(h, mm);
open = false;
}
// Centre the selected cells when the dropdown opens.
function centreCol(col: HTMLDivElement | null) {
if (!col) return;
const sel = col.querySelector<HTMLElement>('.tp-cell.selected');
if (sel) col.scrollTop = sel.offsetTop - col.clientHeight / 2 + sel.clientHeight / 2;
}
$effect(() => {
if (open) {
centreCol(hourCol);
centreCol(minCol);
}
});
function handleClickOutside(e: MouseEvent) {
if (pickerRef && e.target instanceof Node && !pickerRef.contains(e.target)) {
open = false;
}
}
$effect(() => {
if (open) {
document.addEventListener('pointerdown', handleClickOutside);
return () => document.removeEventListener('pointerdown', handleClickOutside);
}
});
</script>
<div class="tp {extraClass}" bind:this={pickerRef}>
<div class="tp-pill" class:empty={current == null}>
<button type="button" class="tp-arrow" onclick={() => nudge(-step)} aria-label="{step} min">
<ChevronLeft size={16} />
</button>
<button type="button" class="tp-display" onclick={() => (open = !open)} aria-label={t.select_time}>
<Clock size={14} aria-hidden="true" />
<span class="tp-label">{label}</span>
</button>
<button type="button" class="tp-arrow" onclick={() => nudge(step)} aria-label="+{step} min">
<ChevronRight size={16} />
</button>
</div>
{#if open}
<div class="tp-dropdown" role="dialog" aria-label={t.select_time}>
<div class="tp-cols">
<div class="tp-col" bind:this={hourCol} role="listbox" aria-label="Stunde">
{#each hours as h (h)}
<button
type="button"
class="tp-cell"
class:selected={current?.h === h}
disabled={hourDisabled(h)}
onclick={() => selectHour(h)}
>
{pad(h)}
</button>
{/each}
</div>
<div class="tp-col" bind:this={minCol} role="listbox" aria-label="Minute">
{#each minutes as mm (mm)}
<button
type="button"
class="tp-cell"
class:selected={current?.m === mm}
disabled={minuteDisabled(mm)}
onclick={() => selectMinute(mm)}
>
{pad(mm)}
</button>
{/each}
</div>
</div>
<button type="button" class="tp-now" onclick={setNow}>{t.now}</button>
</div>
{/if}
</div>
<style>
.tp {
position: relative;
display: inline-flex;
align-items: center;
}
.tp-pill {
display: flex;
align-items: stretch;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
overflow: hidden;
font-size: 0.8rem;
}
.tp-arrow {
display: flex;
align-items: center;
justify-content: center;
padding: 0.35rem 0.4rem;
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-normal), background var(--transition-normal);
}
.tp-arrow:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.tp-display {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
background: none;
border: none;
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
color: var(--color-text-secondary);
font: inherit;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color var(--transition-normal), background var(--transition-normal);
}
.tp-display:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.tp-label {
font-variant-numeric: tabular-nums;
}
.tp-pill.empty .tp-label {
color: var(--color-text-tertiary);
}
/* Dropdown — mirrors DatePicker / DateTimePicker chrome. */
.tp-dropdown {
position: absolute;
top: calc(100% + 0.4rem);
left: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 0.5rem;
z-index: 200;
}
.tp-cols {
display: flex;
gap: 0.3rem;
}
.tp-col {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 11rem;
overflow-y: auto;
padding-right: 0.15rem;
scrollbar-width: thin;
}
.tp-cell {
display: flex;
align-items: center;
justify-content: center;
min-width: 2.6rem;
padding: 0.3rem 0.5rem;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-primary);
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-normal), color var(--transition-normal);
}
.tp-cell:hover:not(:disabled) {
background: var(--color-bg-elevated);
}
.tp-cell:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.tp-cell.selected {
background: var(--color-primary);
color: var(--color-text-on-primary);
font-weight: 700;
}
.tp-cell.selected:hover {
background: var(--color-primary-hover);
}
.tp-now {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.3rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-primary);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition-normal);
}
.tp-now:hover {
background: var(--color-bg-elevated);
}
</style>