Add date selector to train search
This commit is contained in:
1 parent
f65f6dc422
commit
1921e74450
5 files changed
+413
-12
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user