Add date selector to train search

This commit is contained in:
fred.boniface committed 2026-11-12 10:31:19 +00:00
1 parent f65f6dc422
commit 1921e74450
5 files changed
+413 -12

No files matched your search

+26
View File
@@ -1,7 +1,33 @@
import { describe, it, expect } from 'vitest';
import { getUkDateString, shiftDateString, formatDateDisplay } from '$lib/utils/time';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
describe('time date helpers', () => {
it('formats UK date string to YYYY-MM-DD', () => {
const testDate = new Date('2026-11-12T12:00:00Z');
expect(getUkDateString(testDate)).toBe('2026-11-12');
});
it('shifts dates forward and backward across month boundaries', () => {
expect(shiftDateString('2026-11-12', 1)).toBe('2026-11-13');
expect(shiftDateString('2026-11-12', -1)).toBe('2026-11-11');
expect(shiftDateString('2026-11-01', -1)).toBe('2026-10-31');
expect(shiftDateString('2026-11-12', -14)).toBe('2026-10-29');
expect(shiftDateString('2026-11-12', 2)).toBe('2026-11-14');
});
it('formats relative date display correctly', () => {
const today = '2026-11-12';
expect(formatDateDisplay('2026-11-12', today)).toBe('Today • Thu 12 Nov');
expect(formatDateDisplay('2026-11-11', today)).toBe('Yesterday • Wed 11 Nov');
expect(formatDateDisplay('2026-11-13', today)).toBe('Tomorrow • Fri 13 Nov');
expect(formatDateDisplay('2026-11-14', today)).toBe('Sat, 14 Nov 2026');
expect(formatDateDisplay('2026-10-29', today)).toBe('Thu, 29 Oct 2026');
});
});
+274
View File
@@ -0,0 +1,274 @@
<script lang="ts">
import { IconChevronLeft, IconChevronRight, IconCalendar } from '@tabler/icons-svelte-runes';
import { shiftDateString, formatDateDisplay } from '$lib/utils/time';
interface Props {
selectedDate: string;
minDate?: string;
maxDate?: string;
todayDate?: string;
onchange?: (newDate: string) => void;
}
let { selectedDate, minDate, maxDate, todayDate, onchange }: Props = $props();
let inputEl: HTMLInputElement | null = $state(null);
const canGoPrev = $derived(!minDate || selectedDate > minDate);
const canGoNext = $derived(!maxDate || selectedDate < maxDate);
const displayLabel = $derived(formatDateDisplay(selectedDate, todayDate));
const isNotToday = $derived(!!todayDate && selectedDate !== todayDate);
function triggerChange(newDate: string) {
if (newDate && newDate !== selectedDate && onchange) {
onchange(newDate);
}
}
function handlePrev() {
if (!canGoPrev) return;
const prev = shiftDateString(selectedDate, -1);
if (minDate && prev < minDate) return;
triggerChange(prev);
}
function handleNext() {
if (!canGoNext) return;
const next = shiftDateString(selectedDate, 1);
if (maxDate && next > maxDate) return;
triggerChange(next);
}
function handleInputChange(e: Event) {
const target = e.currentTarget as HTMLInputElement;
const val = target.value;
if (!val || val === selectedDate) return;
let clamped = val;
if (minDate && clamped < minDate) clamped = minDate;
if (maxDate && clamped > maxDate) clamped = maxDate;
triggerChange(clamped);
}
function handleToday() {
if (todayDate && selectedDate !== todayDate) {
triggerChange(todayDate);
}
}
function handlePillClick() {
if (inputEl && 'showPicker' in HTMLInputElement.prototype) {
try {
inputEl.showPicker();
} catch {
// showPicker can throw if already active or user dismissed
}
}
}
</script>
<div class="date-selector-container">
<div class="date-selector">
<button
type="button"
class="arrow-btn"
onclick={handlePrev}
disabled={!canGoPrev}
aria-label="Previous day"
title="Previous day"
>
<IconChevronLeft size={22} stroke={2.5} />
</button>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<label class="date-display-label" onclick={handlePillClick} title="Select date">
<span class="cal-icon-wrapper">
<IconCalendar size={18} stroke={2} />
</span>
<span class="date-text">{displayLabel}</span>
<input
bind:this={inputEl}
type="date"
class="hidden-date-input"
value={selectedDate}
min={minDate}
max={maxDate}
onchange={handleInputChange}
aria-label="Select date"
/>
</label>
<button
type="button"
class="arrow-btn"
onclick={handleNext}
disabled={!canGoNext}
aria-label="Next day"
title="Next day"
>
<IconChevronRight size={22} stroke={2.5} />
</button>
</div>
{#if isNotToday}
<button type="button" class="today-chip" onclick={handleToday} aria-label="Jump to today">
Jump to Today
</button>
{/if}
</div>
<style>
.date-selector-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
width: 100%;
margin: 0.75rem auto 0.25rem auto;
}
.date-selector {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 95%;
max-width: 420px;
}
.arrow-btn {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
min-width: 42px;
min-height: 42px;
border-radius: 50%;
background-color: var(--color-bg-light);
color: var(--color-title);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: var(--shadow-small);
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease,
transform 0.15s ease,
box-shadow 0.15s ease;
-webkit-tap-highlight-color: transparent;
padding: 0;
}
.arrow-btn:hover:not(:disabled) {
background-color: var(--color-brand);
color: var(--color-bg-dark);
transform: scale(1.05);
box-shadow: 0 4px 10px rgba(79, 209, 209, 0.35);
}
.arrow-btn:active:not(:disabled) {
transform: scale(0.96);
}
.arrow-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
box-shadow: none;
}
.date-display-label {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
flex: 1;
max-width: 320px;
height: 42px;
padding: 0 1rem;
background-color: var(--color-bg-dark);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 24px;
box-shadow: var(--shadow-small);
color: var(--color-title);
font-family: 'URW Gothic', sans-serif;
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.03ch;
cursor: pointer;
user-select: none;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.date-display-label:hover {
border-color: var(--color-brand);
background-color: #323d46;
}
.date-display-label:focus-within {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
border-color: var(--color-brand);
}
.cal-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-brand);
flex-shrink: 0;
}
.date-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hidden-date-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.hidden-date-input::-webkit-calendar-picker-indicator {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.today-chip {
background: none;
border: none;
color: var(--color-brand);
font-family: 'URW Gothic', sans-serif;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
padding: 2px 8px;
border-radius: 10px;
text-decoration: underline;
text-underline-offset: 3px;
opacity: 0.9;
transition:
opacity 0.15s ease,
color 0.15s ease;
}
.today-chip:hover {
opacity: 1;
color: var(--color-title);
}
</style>
+59
View File
@@ -152,3 +152,62 @@ export function isRightTime(
return Math.abs(a - s) < 60000;
}
/**
* Returns a YYYY-MM-DD date string in Europe/London timezone.
*/
export function getUkDateString(d: Date = new Date()): string {
const parts = new Intl.DateTimeFormat('en-GB', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'Europe/London'
}).formatToParts(d);
const year = parts.find((p) => p.type === 'year')?.value;
const month = parts.find((p) => p.type === 'month')?.value;
const day = parts.find((p) => p.type === 'day')?.value;
return `${year}-${month}-${day}`;
}
/**
* Shifts a YYYY-MM-DD string by a given number of calendar days.
*/
export function shiftDateString(dateStr: string, days: number): string {
const [y, m, d] = dateStr.split('-').map(Number);
const utc = new Date(Date.UTC(y, m - 1, d + days));
const yy = utc.getUTCFullYear();
const mm = String(utc.getUTCMonth() + 1).padStart(2, '0');
const dd = String(utc.getUTCDate()).padStart(2, '0');
return `${yy}-${mm}-${dd}`;
}
/**
* Formats a YYYY-MM-DD date into a user-friendly string (e.g. "Today • Thu 12 Nov" or "Thu, 29 Oct 2026").
*/
export function formatDateDisplay(dateStr: string, todayStr?: string): string {
if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr || '';
const [y, m, d] = dateStr.split('-').map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
const weekday = date.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
const dayMonth = date.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
timeZone: 'UTC'
});
const year = date.getUTCFullYear();
const refToday = todayStr || getUkDateString();
if (refToday && /^\d{4}-\d{2}-\d{2}$/.test(refToday)) {
const [ty, tm, td] = refToday.split('-').map(Number);
const todayDate = new Date(Date.UTC(ty, tm - 1, td));
const diffDays = Math.round((date.getTime() - todayDate.getTime()) / 86400000);
if (diffDays === 0) return `Today • ${weekday} ${dayMonth}`;
if (diffDays === -1) return `Yesterday • ${weekday} ${dayMonth}`;
if (diffDays === 1) return `Tomorrow • ${weekday} ${dayMonth}`;
}
return `${weekday}, ${dayMonth} ${year}`;
}
+19 -4
View File
@@ -1,12 +1,27 @@
<script lang="ts">
import NoResults from '$lib/components/ui/NoResults.svelte';
import TrainService from '$lib/components/ui/TrainService.svelte';
import DateSelector from '$lib/components/ui/DateSelector.svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
let { data } = $props();
function handleDateChange(newDate: string) {
const url = new URL(page.url);
url.searchParams.set('d', newDate);
goto(url.toString(), { keepFocus: true, noScroll: true });
}
</script>
<h6 style="text-align:center;width=100%;margin:auto;padding-top:1rem;font-size:1rem;">
DateSelector
</h6>
<DateSelector
selectedDate={data.selectedDate}
minDate={data.minDate}
maxDate={data.maxDate}
todayDate={data.todayDate}
onchange={handleDateChange}
/>
{#if data.results.length === 0}
<NoResults message={'No trains found on this date with this headcode.'} />
{:else}
@@ -21,7 +36,7 @@
.result-boxes {
width: 95%;
margin: auto;
padding-top: 1rem;
padding-top: 0.5rem;
padding-bottom: 1rem;
display: flex;
align-items: center;
+35 -8
View File
@@ -1,29 +1,56 @@
import { OwlClient, ApiError, ValidationError, ThrowApiError } from '$lib/owlClient';
import { getUkDateString, shiftDateString } from '$lib/utils/time';
import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ fetch, url }) => {
const headcode = url.searchParams.get('h');
let dateParam = url.searchParams.get('d');
const dateParam = url.searchParams.get('d');
const toc = url.searchParams.get('t') || '';
const date: string | Date = dateParam === '' || dateParam === null ? new Date() : dateParam;
const todayDate = getUkDateString();
const minDate = shiftDateString(todayDate, -14);
const maxDate = shiftDateString(todayDate, 2);
// Validation removed, handled by the API Client Lib
let selectedDate = dateParam;
if (!selectedDate || !/^\d{4}-\d{2}-\d{2}$/.test(selectedDate)) {
selectedDate = todayDate;
} else {
if (selectedDate < minDate) selectedDate = minDate;
if (selectedDate > maxDate) selectedDate = maxDate;
}
// Declared outside of the try so that it can be used in both the try and catch blocks
let results: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[];
try {
const response = await OwlClient.trains.getByHeadcode(headcode || '', date, toc, fetch);
const response = await OwlClient.trains.getByHeadcode(headcode || '', selectedDate, toc, fetch);
let title = !!headcode ? headcode.toUpperCase() : 'Error';
const title = headcode ? headcode.toUpperCase() : 'Error';
results = response.data;
return {
title: title,
results: results
title,
headcode: headcode ? headcode.toUpperCase() : '',
toc,
selectedDate,
minDate,
maxDate,
todayDate,
results
};
} catch (e: unknown) {
if (e instanceof ApiError && e.code === 'NOT_FOUND') {
const title = headcode ? headcode.toUpperCase() : 'Not Found';
return {
title,
headcode: headcode ? headcode.toUpperCase() : '',
toc,
selectedDate,
minDate,
maxDate,
todayDate,
results: []
};
}
ThrowApiError(e);
}
};