Compare commits

...

17 Commits

Author SHA1 Message Date
0ef9e5b56f Adjust dynamic styles on the departure board component 2026-05-12 20:35:12 +01:00
b88f4f3b35 Dynamically adjust font size based on vw value 2026-05-12 19:06:25 +01:00
a2e6f3b99a Switch departure board to use Inconsolata monospaced font (varying widths, weights, etc.) 2026-05-11 20:28:22 +01:00
7edccf294b Update table display on board page, in preparation for offering different 'table' styles. 2026-05-11 12:04:40 +01:00
13d22d7b73 Add initial departure board table 'skeleton', very basic at present 2026-05-10 20:57:50 +01:00
59e8a77d3d Change 'Fetched' to 'Updated' on the Board page fetch time 2026-05-10 00:27:45 +01:00
1f1e215c0c Fix layout shifts with the temporary <pre> element on the board page 2026-05-10 00:26:21 +01:00
909e36ebc2 Add parsing for StationAlerts, and fetch function for Boards. 2026-05-10 00:15:53 +01:00
f3d633e719 Fix PIS Display 2026-05-05 20:41:27 +01:00
05a04ec922 Add PIS Data formatting to TrainService expander 2026-05-05 19:24:31 +01:00
3b91fad590 Implement 'internal submit button' to the 'Textbox' component. Remove separate submit to the Headcode search card.
Adjust the hover and active styles of the 'TrainService' expandable cards.
2026-05-05 15:55:51 +01:00
6a857c2d64 Extend data in the schedule box 2026-05-04 21:49:59 +01:00
1c4c7ccabc Add JetBrains Mono font and adjust styling of the schedule. 2026-05-04 20:14:00 +01:00
9ca3662ada Adjust button minimum sizing to improve presentation of quick-links, while ensuring adequate touch target. Number of displayed quicklinks reduced from 9 to six, for improved presentation. 2026-05-03 20:59:03 +01:00
c524fe3c2e Reorganise colouring of schedule times, add 'delay' column (E, RT, D). Including reusable function to assist. 2026-05-03 20:58:22 +01:00
5486795711 Tidy up train service component, add loading state, convert tiploc to name... etc. 2026-05-03 11:45:07 +01:00
24960707e2 Add train details to train endpoint. Formatting and styling incomplete 2026-05-03 09:03:45 +01:00
43 changed files with 2511 additions and 964 deletions

1208
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,8 @@
"vitest-browser-svelte": "^2.0.2" "vitest-browser-svelte": "^2.0.2"
}, },
"dependencies": { "dependencies": {
"@owlboard/owlboard-ts": "^3.0.0-dev.20260503T0051", "@owlboard/api-schema-types": "^3.0.3-alpha18",
"@owlboard/owlboard-ts": "^3.0.0-dev.20260509t2101",
"@tabler/icons-svelte": "^3.40.0" "@tabler/icons-svelte": "^3.40.0"
} }
} }

View File

@@ -39,7 +39,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 48px; min-height: 48px;
min-width: 98px; min-width: 48px;
appearance: none; appearance: none;
background: transparent; background: transparent;
border: none; border: none;
@@ -55,7 +55,7 @@
width: fit-content; width: fit-content;
flex-shrink: 0; flex-shrink: 0;
padding: 0 1.2rem; padding: 0 1.2rem;
min-width: 90px; min-width: 40px;
height: 36px; height: 36px;
border-radius: 20px; border-radius: 20px;
border: none; border: none;

View File

@@ -1,14 +1,36 @@
<script lang="ts"> <script lang="ts">
let { import { onMount } from 'svelte';
message = "Loading..." import { fade } from 'svelte/transition';
} = $props(); let { message = 'Loading...' } = $props();
let messageIndex = $state(0);
onMount(() => {
const interval = setInterval(() => {
if (messageIndex === 0) {
messageIndex = 1;
} else {
messageIndex = messageIndex === 1 ? 2 : 1;
}
}, 1500);
return () => clearInterval(interval);
});
</script> </script>
<div class="loading-state"> <div class="loading-state">
<div class="track"> <div class="track">
<div class="shuttle"></div> <div class="shuttle"></div>
</div> </div>
<p>{message}</p> <div class="message-container">
{#if messageIndex === 0}
<p in:fade={{ delay: 300, duration: 250 }} out:fade={{ duration: 250 }}>{message}</p>
{:else if messageIndex === 1}
<p in:fade={{ delay: 300, duration: 250 }} out:fade={{ duration: 250 }}>Slow connection...</p>
{:else}
<p in:fade={{ delay: 300, duration: 250 }} out:fade={{ duration: 250 }}>Still trying...</p>
{/if}
</div>
</div> </div>
<style> <style>
@@ -40,20 +62,26 @@
animation: data-travel 1.6s infinite ease-in-out; animation: data-travel 1.6s infinite ease-in-out;
} }
.message-container {
display: grid;
place-items: center;
height: 2rem;
margin-top: 1rem;
}
p { p {
grid-area: 1 / 1;
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
letter-spacing: 0.15em; letter-spacing: 0.15em;
color: var(--color-title); color: var(--color-title);
animation: pulse 2s infinite ease-in-out;
} }
@keyframes data-travel { @keyframes data-travel {
0% { left: -50%; } 0% {
100% { left: 100%; } left: -50%;
}
100% {
left: 100%;
} }
@keyframes pulse {
0%, 100% {opacity: 1;}
50% {opacity: 0.4;}
} }
</style> </style>

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import noResult from '$lib/assets/img/no-data.svg'; import noResult from '$lib/assets/img/no-data.svg';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
let { title = "No results", message = "Try checking your search term"} = $props(); let { title = 'No results', message = 'Try checking your search term' } = $props();
</script> </script>
<div class="no-results-state"> <div class="no-results-state">
<img src={noResult} class="image" height="200" width="200" alt="" role="presentation"> <img src={noResult} class="image" height="200" width="200" alt="" role="presentation" />
<h3>{title}</h3> <h3>{title}</h3>
<p>{message}</p> <p>{message}</p>
<div class="btn-container"> <div class="btn-container">
<Button <Button
type="button" type="button"
onclick={() => history.length > 1 ? history.back() : window.location.href = '/'} onclick={() => (history.length > 1 ? history.back() : (window.location.href = '/'))}
color="accent" color="accent"
> >
Go back Go back

View File

@@ -2,6 +2,8 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { HTMLInputAttributes } from 'svelte/elements'; import type { HTMLInputAttributes } from 'svelte/elements';
import { IconChevronRightFilled } from '@tabler/icons-svelte';
interface Props extends HTMLInputAttributes { interface Props extends HTMLInputAttributes {
value?: string; value?: string;
label?: string; label?: string;
@@ -9,6 +11,7 @@
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url'; type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
error?: string; error?: string;
uppercase?: boolean; uppercase?: boolean;
onsubmit?: (val: string) => void | Promise<void>;
[key: string]: any; [key: string]: any;
} }
@@ -19,10 +22,18 @@
type = 'text', type = 'text',
error = '', error = '',
uppercase = false, uppercase = false,
onsubmit,
...rest ...rest
}: Props = $props(); }: Props = $props();
let isFocussed = $state(false); let isFocussed = $state(false);
const handleSubmit = (e: Event) => {
e.preventDefault();
if (onsubmit && value) {
onsubmit(value);
}
};
</script> </script>
<div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}> <div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}>
@@ -30,6 +41,7 @@
<label for="adaptive-input">{label}</label> <label for="adaptive-input">{label}</label>
{/if} {/if}
<form onsubmit={handleSubmit} class="input-container">
<input <input
id="adaptive-input" id="adaptive-input"
class:all-caps={uppercase} class:all-caps={uppercase}
@@ -41,6 +53,19 @@
{...rest} {...rest}
/> />
{#if onsubmit}
<button
type="submit"
class="submit-icon"
transition:fade
disabled={!value}
aria-label="Submit"
>
<IconChevronRightFilled />
</button>
{/if}
</form>
{#if error} {#if error}
<span class="error-message" transition:fade>{error}</span> <span class="error-message" transition:fade>{error}</span>
{/if} {/if}
@@ -50,29 +75,80 @@
.input-wrapper { .input-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 0.4rem;
width: 100%; width: 100%;
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
} }
.input-container {
position: relative;
display: flex;
align-items: center;
width: 100%;
background-color: var(--color-title);
border-radius: 5000px;
transition: all 0.2s;
overflow: hidden;
}
label { label {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 400; font-weight: 400;
color: var(--color-title); color: var(--color-title);
} }
input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
input { input {
width: 100%;
background: transparent;
min-height: 40px; min-height: 40px;
padding: 0 16px; height: 100%;
background-color: var(--color-title); line-height: normal;
border: 2px solid transparent; padding: 0 48px;
border-radius: 20px; border: none;
color: var(--color-bg-dark); color: var(--color-bg-dark);
font-size: 1.2rem; font-size: 1.2rem;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
outline: none; outline: none;
text-align: center; text-align: center;
box-shadow: var(--shadow-std); }
.submit-icon {
position: absolute;
background: transparent;
right: 0;
border: none;
color: var(--color-bg-dark);
cursor: pointer;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.75;
transition:
opacity 0.2s,
transform 0.2s;
}
.submit-icon:hover:not(:disabled) {
opacity: 1;
transform: translateX(2px);
}
.submit-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
.all-caps { .all-caps {

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import { LOCATIONS } from '$lib/locations-object.svelte';
let { code } = $props<{ code: string | null | undefined }>();
const location = $derived(LOCATIONS.getName(code));
const displayName = $derived(location?.n ?? code ?? '');
</script>
{displayName}

View File

@@ -8,7 +8,6 @@
let code = $derived(toc.toUpperCase()); let code = $derived(toc.toUpperCase());
</script> </script>
<!-- SPACE MONO for the font? --> <!-- SPACE MONO for the font? -->
<div class="toc-container {code}"> <div class="toc-container {code}">
{code} {code}
@@ -25,7 +24,7 @@
font-size: 1.1rem; font-size: 1.1rem;
background-color: #333; background-color: #333;
color: #fff; color: #fff;
font-family:'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
} }
.AW { .AW {

View File

@@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts'; import type { ApiPisObject, ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient'; import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { quintOut} from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import { formatUkTime } from '$lib/utils/time'; import { formatUkTime, calculateDelay } from '$lib/utils/time';
import TocStyle from '$lib/components/ui/TocStyle.svelte'; import TocStyle from '$lib/components/ui/TocStyle.svelte';
import TiplocConverter from '$lib/components/ui/TiplocConverter.svelte';
let { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse} = $props(); import { IconChevronDownFilled } from '@tabler/icons-svelte';
import { estClass } from '$lib/utils/time';
let { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse } = $props();
let isExpanded = $state(false); let isExpanded = $state(false);
let loadingDetails = $state(false) let loadingDetails = $state(false);
let loadingDetailsError = $state(false);
let loadingDetailsErrorMsg = $state('');
let details = $state(null); let details = $state(null);
const toggleExpand = async (rid: string) => { const toggleExpand = async (rid: string) => {
@@ -17,24 +21,19 @@ import { formatUkTime } from '$lib/utils/time';
return; return;
} }
if (details) {
isExpanded = true;
return;
}
loadingDetails = true; loadingDetails = true;
try { try {
const result = await OwlClient.trains.getByRid(service.r) const result = await OwlClient.trains.getByRid(service.r);
details = result.data; details = result.data;
isExpanded = true; isExpanded = true;
} catch (e) { } catch (e) {
console.Error("Failde to load train details") console.error('Failed to load train details');
loadingDetailsError = true;
loadingDetailsErrorMsg = e.message;
} finally { } finally {
loading = false; loadingDetails = false;
} }
};
}
let OriginDepartureSummary = $derived(formatUkTime(service.od)); let OriginDepartureSummary = $derived(formatUkTime(service.od));
@@ -42,14 +41,62 @@ import { formatUkTime } from '$lib/utils/time';
if (details) return; if (details) return;
loadingDetails = true; loadingDetails = true;
const result = await OwlClient.trains.getByRid(service.r) const result = await OwlClient.trains.getByRid(service.r);
details = result.data; details = result.data;
loadingDetails = false; loadingDetails = false;
} }
const activityMap: Record<string, string> = {
'-D': 'Vehicles detatched',
'-T': 'Vehicles attached & detached',
'-U': 'Vehicles attached',
AE: 'Assist locomotive attached',
C: 'Traincrew change only',
D: 'Set down only',
E: 'Stops for examination',
K: 'Passenger count point',
KC: 'Ticket collection point',
KE: 'Ticket examination point',
KF: 'First class ticket examination point',
KS: 'Selective ticket examination point',
L: 'Locomotive changed',
N: 'Unadvertised stop',
OP: 'Operational stop only',
OR: 'Locomotive attached on rear',
R: 'Request stop only',
RM: 'Train changes direction',
RR: 'Locomotive run round',
S: 'Staff only stop',
TW: 'Stops for token/staff/tablet only',
U: 'Pick up only',
W: 'Watering coaches',
X: 'Passes another train'
};
const activityRegex = new RegExp(
Object.keys(activityMap)
.sort((a, b) => b.length - a.length) // Longest codes first
.map((key) => key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')) // Escape special chars
.join('|'),
'g'
);
const getRelevantActivities = (act: string) => {
if (!act) return [];
// Find all matches in the string
const matches = act.match(activityRegex) || [];
// Map to labels and remove duplicates
return [...new Set(matches)].map((code) => activityMap[code]);
};
</script> </script>
<div class="train-service"> <div class="train-service" class:isExpanded>
<button class="summary" onclick={toggleExpand} type="button" aria-expanded={isExpanded}> <button class="summary" onclick={toggleExpand} type="button" aria-expanded={isExpanded}>
{#if loadingDetails}
<div class="loading-state"><div class="loading-spinner"></div></div>
{/if}
<div class="operator-summary"> <div class="operator-summary">
<TocStyle toc={service.o} /> <TocStyle toc={service.o} />
</div> </div>
@@ -57,48 +104,171 @@ import { formatUkTime } from '$lib/utils/time';
<div class="time-summary"> <div class="time-summary">
{OriginDepartureSummary} {OriginDepartureSummary}
</div> </div>
<div class="location-summary"> <div class="location-summary" class:can-all={service.ct}>
{service.ot} {service.ot}
</div> </div>
<div class="location-summary to-summary"> <div class="location-summary to-summary" class:can-all={service.ct}>to</div>
to <div class="location-summary" class:can-all={service.ct}>
</div>
<div class="location-summary">
{service.dt} {service.dt}
</div> </div>
<!-- Add arrow icon to signify drop-down --> <div class="arrow" class:expanded={isExpanded}>
<!-- ADD LOADING STATE --> <IconChevronDownFilled color={'var(--color-title)'} size={25} />
</div>
</div> </div>
</button> </button>
{#if isExpanded && details} {#if isExpanded && details}
<div transition:slide={{duration: 800, easing: quintOut}} class="detauls-content"> <div class="box-ext" transition:slide={{ duration: 800, easing: quintOut }}>
<!-- Here goes the data formatting! --> <!-- Here goes the data formatting! -->
{JSON.stringify(details)} <div class="detail-head">
<!-- Cancellation Section -->
{#if service.ct}
<span class="cancel-reason"> Cancelled throughout </span>
{/if}
{#if details.header.cr}
<span class="cancel-reason">
{details.header.cr}
{#if details.header.cl}
{details.header.cn ? ' near ' : ' at '}
<TiplocConverter code={details.header.cl} />
{/if}
</span>
{/if}
<!-- Delay Section -->
{#if details.header.dr}
<span class="delay-reason">
{details.header.dr}
{#if details.header.dl}
{details.header.dn ? ' near ' : ' at '}
{details.header.dl}
{/if}
</span>
{/if}
{#if details.pis}
<div class="pis-detail">
<span class="pis-code">PIS: {details.pis.code}</span>
{#if details.pis.skip?.skip > 0}
<span class="pis-skip">
(skip {details.pis.skip.position === 'head' ? 'first' : 'last'}
{details.pis.skip.skip}
{details.pis.skip.skip === 1 ? 'stop' : 'stops'}
)
</span>
{/if}
</div>
{/if}
</div>
<div class="color-key">
<p class="tpl-stop">Times in yellow are estimates</p>
</div>
<div class="schedule-table-container">
<table class="schedule-table">
<thead>
<tr>
<th colspan="2"></th>
<th colspan="2">Arr</th>
<th colspan="2">Dep</th>
</tr>
<tr>
<th>Location</th>
<th>Plat</th>
<th>Sch</th>
<th>Act</th>
<th>Sch</th>
<th>Act</th>
<th></th>
</tr>
</thead>
{#each details.locations as loc}
<tbody class="location-group">
<tr class:pass-loc={loc.r === 'PASS'} class:can-loc={loc.can}>
<td class="tpl-cell" class:tpl-stop={loc.r != 'PASS'}>
{loc.t}
</td>
<td class="plat-cell cell-divider-right" class:plat-change={loc.pc}>{loc.p}</td>
{#if loc.r == 'PASS'}
<td class="time-cell cell-divider-right" colspan="2">Pass</td>
<td class="time-cell">{formatUkTime(loc.wtp)}</td>
<td class="time-cell {estClass(loc.atp, loc.etp)}"
>{formatUkTime(loc.atp || loc.etp || '--')}</td
>
{:else}
<td class="time-cell">{formatUkTime(loc.pta || loc.wta || '--')}</td>
<td class="time-cell cell-divider-right {estClass(loc.ata, loc.eta)}"
>{formatUkTime(loc.ata || loc.eta || '--')}</td
>
<td class="time-cell">{formatUkTime(loc.ptd || loc.wtd || '--')}</td>
<td class="time-cell cell-divider-right {estClass(loc.atd, loc.etd)}"
>{formatUkTime(loc.atd || loc.etd || '--')}</td
>
{/if}
{#if loc}
{@const delay = calculateDelay(loc)}
<td class="delay-{delay.type}">{delay.val}</td>
{/if}
</tr>
{#if loc.act && getRelevantActivities(loc.act).length > 0}
<tr class="activity-row">
<td colspan="7">
<div class="activity-container">
{#each getRelevantActivities(loc.act) as note}
<span class="activity-tag">{note}</span>
{/each}
</div>
</td>
</tr>
{/if}
</tbody>{/each}
</table>
</div>
</div> </div>
{/if} {/if}
</div> </div>
<style> <style>
/*
Main container
*/
.train-service { .train-service {
background-color: var(--color-accent); background-color: var(--color-accent);
width: 100%; width: 100%;
border-radius: 4px; max-width: 460px;
border-radius: 12px;
box-shadow: var(--shadow-std); box-shadow: var(--shadow-std);
overflow: hidden; overflow: visible;
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
transition: 0.2s all; transition: 0.2s all;
filter: brightness(1.1); filter: brightness(1.1);
-webkit-tap-highlight-color: transparent;
} }
.train-service:hover { .train-service:active {
filter:brightness(1.5); filter: brightness(1.2);
} }
.train-service:active .arrow {
filter: brightness(0.2);
}
@media (hover: hover) {
.train-service:not(.isExpanded):hover {
filter: brightness(1.2);
}
.summary:hover .arrow {
filter: brightness(0.2);
}
}
/*
Summary Header
*/
.summary { .summary {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
padding: 0.75rem; padding: 0 1rem;
min-height: 48px; min-height: 48px;
border: none; border: none;
background: transparent; background: transparent;
@@ -119,7 +289,7 @@ import { formatUkTime } from '$lib/utils/time';
} }
.time-summary { .time-summary {
font-size: 1.1rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
color: var(--color-brand); color: var(--color-brand);
} }
@@ -127,7 +297,7 @@ import { formatUkTime } from '$lib/utils/time';
.location-summary { .location-summary {
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
font-size: 1.1rem; font-size: 0.75rem;
letter-spacing: 0.02em; letter-spacing: 0.02em;
color: var(--color-title); color: var(--color-title);
} }
@@ -137,4 +307,264 @@ import { formatUkTime } from '$lib/utils/time';
font-style: oblique; font-style: oblique;
text-transform: lowercase; text-transform: lowercase;
} }
</style>
.arrow {
padding: 0;
margin: 0 0 0 auto;
height: 25px;
transition: all 0.3s;
}
.expanded {
transform: rotateX(180deg);
}
.can-all {
color: red;
}
/*
PIS Data
*/
.pis-detail {
display: flex;
align-items: center;
flex-direction: column;
gap: 0.2rem;
padding: 8px 0;
width: 100%;
}
.pis-code {
font-weight: 600;
font-size: 1.1rem;
letter-spacing: 0.05em;
}
.pis-skip {
font-size: 0.85rem;
opacity: 0.75;
font-style: oblique;
}
/*
Box Extention
*/
.box-ext {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.detail-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
width: 100%;
}
.cancel-reason,
.delay-reason {
display: block;
padding: 4px 8px;
width: 95%;
font-size: 1rem;
font-weight: 500;
animation: cancel-pulse 2s ease-in-out infinite;
}
.cancel-reason {
color: var(--cancel-red);
}
.delay-reason {
color: rgb(255, 119, 0);
}
/*
Schedule Table
*/
.color-key,
.color-key p {
text-align: center;
width: 100%;
padding: 0 0 0.2rem 0;
margin: 0;
}
.schedule-table-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding-bottom: 0.1rem;
}
.schedule-table {
width: 95%;
border-collapse: collapse;
font-family: 'URW Gothic', 'Inter', sans-serif;
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
letter-spacing: -0.02ch;
transition: all 0.5s;
font-weight: 500;
}
.location-group:nth-of-type(even) {
background-color: rgba(255, 255, 255, 0.04);
}
.cell-divider-right {
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
th,
td {
text-align: center;
padding: 6px 4px;
}
.activity-row td {
padding: 0 0 10px 0;
text-align: left;
font-size: 0.75rem;
letter-spacing: 0.01em;
}
.activity-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 12px;
}
.activity-tag {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 2px 6px;
border-radius: 4px;
}
.tpl-cell {
color: var(--color-title);
text-align: left;
}
.tpl-stop {
color: var(--location-yellow);
}
.plat-change {
animation: fast-pulse 2s ease-out infinite;
}
.pass-loc td {
color: var(--color-title);
font-style: oblique;
opacity: 0.5;
}
.can-loc {
text-decoration: line-through;
}
.est {
color: var(--location-yellow);
font-style: oblique;
}
td.delay-late {
color: var(--delay-orange);
font-weight: 600;
animation: pulse 2s ease-out infinite;
}
td.delay-early {
color: var(--early-blue);
font-weight: 600;
animation: pulse 2s ease-out infinite;
}
/*
Loading State
*/
.loading-state {
position: absolute;
top: 0;
left: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(5px);
width: 100%;
height: 100%;
font-family: 'URW Gothic', sans-serif;
font-size: 1rem;
color: var(--color-title);
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-top-color: #fff;
border-radius: 50%;
animation: load-spin 0.8s linear infinite;
z-index: 3;
}
/*
Responsivity
*/
@media (min-width: 330px) {
.time-summary,
.location-summary {
font-size: 0.9rem;
}
.activity-row td {
font-size: 0.8rem;
}
}
@media (min-width: 340px) {
.schedule-table {
font-size: 0.9rem;
}
}
@media (min-width: 360px) {
.time-summary {
font-size: 1.1rem;
}
.location-summary {
font-size: 1rem;
}
.schedule-table {
font-size: 0.99rem;
}
.activity-row td {
font-size: 0.9rem;
}
}
@media (min-width: 420px) {
.schedule-table {
font-size: 1.1rem;
}
}
/*
KEYFRAMES
*/
@keyframes load-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -52,7 +52,7 @@
.card { .card {
background: var(--color-accent); background: var(--color-accent);
position: relative; position: relative;
border-radius: 20px; border-radius: 12px;
overflow: visible; overflow: visible;
width: 95%; width: 95%;
max-width: 600px; max-width: 600px;

View File

@@ -6,30 +6,25 @@
let headcode = $state(''); let headcode = $state('');
function handleSearch(e: SubmitEvent) { function handleSearch(headcode: string) {
e.preventDefault();
if (!headcode.trim()) return; if (!headcode.trim()) return;
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
searchParams.append('h', headcode.trim().toUpperCase()); searchParams.append('h', headcode.trim().toUpperCase());
goto(`/trains?${searchParams.toString()}`) goto(`/trains?${searchParams.toString()}`);
} }
</script> </script>
<BaseCard header={'Search Train & PIS'}> <BaseCard header={'Search Train & PIS'}>
<div class="card-content">
<form onsubmit={handleSearch} class="card-content">
<Textbox <Textbox
placeholder="Enter Headcode" placeholder="Enter Headcode"
bind:value={headcode} bind:value={headcode}
maxLength={4} maxLength={4}
onsubmit={handleSearch}
/> />
<Button </div>
type="submit"
disabled={!headcode}
>Search</Button>
</form>
</BaseCard> </BaseCard>
<style> <style>
@@ -37,7 +32,7 @@
text-align: center; text-align: center;
width: 90%; width: 90%;
margin: auto; margin: auto;
padding: 10px 0 0 0; padding: 10px 0 0.5rem 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;

View File

@@ -4,12 +4,6 @@
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
let { onsearch }: { onsearch: (c: string) => void } = $props(); let { onsearch }: { onsearch: (c: string) => void } = $props();
let codeValue = $state('');
function resetValues(): void {
codeValue = '';
}
</script> </script>
<BaseCard header={'Find by Code'}> <BaseCard header={'Find by Code'}>
@@ -19,16 +13,13 @@
<Textbox <Textbox
placeholder={'Code'} placeholder={'Code'}
uppercase={true} uppercase={true}
type={'number'} type={'text'}
max={9999} onsubmit={onsearch}
bind:value={codeValue} maxLength={4}
inputmode={'numeric'}
/> />
</div> </div>
</div> </div>
<div class="button-wrapper">
<Button onclick={() => onsearch(codeValue.toString())}>Search</Button>
<Button onclick={resetValues}>Reset</Button>
</div>
</div> </div>
</BaseCard> </BaseCard>
@@ -48,7 +39,7 @@
} }
.textbox-item-wrapper { .textbox-item-wrapper {
width: 30%; width: 60%;
} }
.button-wrapper { .button-wrapper {

View File

@@ -43,11 +43,11 @@
display: flex; display: flex;
width: 100%; width: 100%;
justify-content: center; justify-content: center;
gap: 4rem; gap: 1.1rem;
} }
.textbox-item-wrapper { .textbox-item-wrapper {
width: 30%; width: 50%;
} }
.button-wrapper { .button-wrapper {

View File

@@ -0,0 +1,418 @@
<script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time';
let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
const getRowKey = (s: ApiStationsBoard.BoardService) =>
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
</script>
<table class="departure-board">
<colgroup>
<col style="width:10%;" />
<!-- ID (Headcode) -->
<col style="width:17%;" />
<!-- ORIG (Origin) -->
<col style="width:17%;" />
<!-- DEST (Destination) -->
<col style="width:9%;" />
<!-- PLT (Platform) -->
<col style="width: 12%;" />
<!-- STA -->
<col style="width: 12%;" />
<!-- ETA/ATA -->
<col style="width: 12%;" />
<!-- STD -->
<col style="width: 11%;" />
<!-- ETD/ATD -->
</colgroup>
<thead>
<tr>
<th colspan="4" aria-hidden="true"></th>
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Arrival">Arr</abbr></th>
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Departure">Dep</abbr></th>
</tr>
<tr>
<th scope="col"><abbr title="Headcode">ID</abbr></th>
<th scope="col"><abbr title="Origin">Orig</abbr></th>
<th scope="col"><abbr title="Destination">Dest</abbr></th>
<th scope="col"><abbr title="Platform">Plt</abbr></th>
<th scope="col"><abbr title="Scheduled">Sch</abbr></th>
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
<th scope="col"><abbr title="Scheduled">Sch</abbr></th>
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
</tr>
</thead>
{#each services as service (getRowKey(service))}
<tbody>
<tr
class="service-row"
class:serviceCancelled={service.c}
class:servicePass={service.wtp}
class:serviceNonPassenger={!service.ip}
>
<td class="id-cell">{service.h}</td>
<td class="orig-cell"
><abbr title={service.og.n.toLocaleUpperCase()}>{service.og.t}</abbr></td
>
<td class="dest-cell"
><abbr title={service.dt.n.toLocaleUpperCase()}>{service.dt.t}</abbr></td
>
<td class="plt-cell" class:platSup={service.ps} class:platChange={service.pc}
>{service.p || '-'}</td
>
<!-- Handle different display for a passing train -->
{#if service.wtp}
<td class="pass-cell" colspan="2">Pass</td>
<td class="time-cell">{formatUkTime(service.wtp)}</td>
<!-- If cancelled, show '-', otherwise check for RT or show time -->
<td
class="time-cell {estClass(service.atp, service.etp)} {delayClassFromTimePair(
service.wtp,
service.atp || service.etp
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.wtp, service.atp || service.etp) === 'delay-rt'
? 'RT'
: formatUkTime(service.atp || service.etp)}
</span>
</td>
{:else}
<td class="time-cell">{formatUkTime(service.sta)}</td>
<!-- Arrival Actual/Expected -->
<td
class="time-cell {estClass(service.ata, service.eta)} {delayClassFromTimePair(
service.sta,
service.ata || service.eta
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.sta, service.ata || service.eta) === 'delay-rt'
? 'RT'
: formatUkTime(service.ata || service.eta)}
</span>
</td>
<td class="time-cell">{formatUkTime(service.std)}</td>
<!-- Departure Actual/Expected -->
<td
class="time-cell {estClass(service.atd, service.etd)} {delayClassFromTimePair(
service.std,
service.atd || service.etd
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.std, service.atd || service.etd) === 'delay-rt'
? 'RT'
: formatUkTime(service.atd || service.etd)}
</span>
</td>
{/if}
</tr>
{#if service.o}
<tr class="toc-coach-row">
<td colspan="8">
{service.o}
</td>
</tr>
{/if}
{#if service.c && service.cr?.r}
<tr class="cancel-row">
<td colspan="8">
{service.cr.r}
{#if service.cr.l}
{service.cr.n ? 'near' : 'at'}
{service.cr.l}
{/if}
</td>
</tr>
{/if}
{#if service.dr?.r}
<tr class="delay-row">
<td colspan="9">
{service.dr.r}
{#if service.dr.l}
{service.dr.n ? 'near' : 'at'}
{service.dr.l}
{/if}
</td>
</tr>
{/if}
</tbody>
{/each}
</table>
<style>
.departure-board {
width: 100%;
margin: 5px auto;
border-collapse: collapse;
table-layout: fixed;
}
.departure-board td {
font-family: 'Inconsolata Variable', monospace;
font-size: clamp(1rem, 0.475rem + 2.8vw, 1.35rem);
font-variant-ligatures: additional-ligatures;
overflow: hidden;
}
thead,
.cancel-row td,
.delay-row td {
letter-spacing: -0.15ch;
}
thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-bg-dark);
}
abbr {
text-decoration: none;
border-bottom: none;
cursor: help;
}
tbody tr:first-child td {
border-top: 5px solid transparent;
}
/* Row Logic */
.serviceCancelled {
color: rgb(255, 131, 131);
}
.servicePass td {
opacity: 0.75;
font-weight: 200;
}
.serviceNonPassenger td {
opacity: 0.5;
font-weight: 290;
font-style: italic;
}
/* Special Row Styles */
.cancel-row td,
.delay-row td {
font-size: 0.88rem;
text-align: left;
}
.cancel-row td[colspan],
.delay-row td[colspan] {
padding-left: 0ch;
}
.cancel-row td {
color: rgb(255, 131, 131);
font-weight: 400;
}
.delay-row td {
color: var(--delay-orange);
font-style: italic;
}
@media (min-width: 375px) {
.cancel-row td,
.delay-row td {
font-size: 1.08rem;
}
}
@media (min-width: 420px) {
.cancel-row td,
.delay-row td {
font-size: 1.12rem;
}
}
@media (min-width: 550px) {
.cancel-row td,
.delay-row td {
font-size: 1.18rem;
}
}
@media (min-width: 620px) {
.cancel-row td,
.delay-row td {
font-size: 1.20rem;
}
}
.toc-coach-row td {
font-family: 'URW Gothic', sans-serif;
font-size: 0.7rem;
padding: 0;
color: rgb(187, 187, 255);
}
@media(min-width: 400px) {
.toc-coach-row td {
font-size: 0.8rem;
}
}
@media(min-width: 550px) {
.toc-coach-row td {
font-size: 0.85rem;
}
}
/* Column Specifics */
.id-cell {
text-align: left;
font-weight: 400;
font-stretch: 85%;
filter: brightness(0.75);
}
@media (min-width: 400px) {
.id-cell {
font-stretch: 90%;
}
}
.orig-cell,
.dest-cell {
font-stretch: 80%;
font-weight: 400;
}
.orig-cell {
text-align: left;
color: var(--location-yellow);
}
.dest-cell {
text-align: right;
color: var(--location-yellow);
}
@media (min-width: 350px) {
.orig-cell,
.dest-cell {
font-stretch: 85%;
}
@media (min-width: 400px) {
.orig-cell,
.dest-cell {
font-stretch: 90%;
}
}
@media (min-width: 490px) {
.orig-cell,
.dest-cell {
font-stretch: 95%;
}
}
@media (min-width: 520px) {
.orig-cell,
.dest-cell {
font-stretch: 100%;
}
.orig-cell {
text-align: right;
padding-right: 7px;
}
.dest-cell {
text-align: left;
padding-left: 7px;
}
}
@media (min-width: 600px) {
.orig-cell,
.dest-cell {
letter-spacing: 0.05ch;
}
}
}
.plt-cell {
text-align: center;
font-weight: 410;
font-stretch: 100%;
}
@media (min-width: 525px) {
.plt-cell {
font-stretch: 110%;
}
}
.plt-cell.platSup {
font-weight: 200;
opacity: 0.3;
}
.plt-cell.platChange {
animation: fast-pulse 2s ease-out infinite;
}
.service-row.serviceCancelled .plt-cell {
text-decoration: line-through;
}
.pass-cell {
text-align: center;
font-stretch: 100%;
}
/* Colour orig and dest values when cancelled */
.service-row.serviceCancelled .orig-cell,
.service-row.serviceCancelled .dest-cell {
color: rgb(255, 131, 131);
}
.time-cell {
text-align: center;
font-stretch: 72%;
}
@media (min-width: 350px) {
.time-cell {
font-stretch: 77%;
}
}
@media (min-width: 400px) {
.time-cell {
font-stretch: 82%;
}
}
@media (min-width: 525px) {
.time-cell {
font-stretch: 90%;
}
}
@media (min-width: 600px) {
.time-cell {
font-stretch: 100%;
}
}
/* RT Logic */
.time-cell.delay-rt span {
display: none;
}
.time-cell.delay-rt::after {
content: 'RT';
}
.time-cell.delay-early {
color: var(--early-blue);
}
.time-cell.delay-late {
color: var(--delay-orange);
}
/* Time Types */
.est {
font-style: italic;
opacity: 0.75;
font-weight: 200;
}
.act {
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,143 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { IconAlertOctagonFilled, IconChevronDownFilled } from '@tabler/icons-svelte';
let { messages = [] }: { messages: ApiStationsBoard.BoardMsgs[] } = $props();
let isOpen = $state(false);
</script>
{#if messages.length > 0}
<aside class="alert-card">
<button onclick={() => (isOpen = !isOpen)} class="trigger" class:active={isOpen}>
<span class="warning-icon">
<IconAlertOctagonFilled />
</span>
<span class="summary">
{messages.length}
{messages.length === 1 ? 'active alert' : 'active alerts'}
</span>
<span class="chevron" class:rotated={isOpen}><IconChevronDownFilled /></span>
</button>
{#if isOpen}
<div transition:slide={{ duration: 450 }} class="content">
{#each messages as msg}
<div class="message-item">
<p class="message-text">
{msg.t}
{#if msg.l && msg.lt}
<a href={msg.l} target="_blank" rel="noopener noreferrer" class="alert-link">
{msg.lt}
</a>
{/if}
</p>
</div>
{/each}
</div>
{/if}
</aside>
{/if}
<style>
.alert-card {
margin: 0;
border-radius: 8px;
background: transparent;
position: relative;
z-index: 10;
margin-bottom: 0;
width: 95%;
max-width: 750px;
-webkit-tap-highlight-color: transparent;
}
.alert-card:active {
filter: brightness(1.2);
}
.trigger {
width: 100%;
display: flex;
border-radius: 8px;
align-items: center;
padding: 0.1rem 1.05rem;
background: var(--alert-orange);
color: white;
border: none;
cursor: pointer;
text-align: left;
position: relative;
z-index: 2;
height: 40px;
transition: all 0.65s 0.2s;
}
.trigger.active {
border-radius: 8px 8px 0 0;
transition: all 0.01s 0s;
}
.warning-icon {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 0;
margin-top: 0;
margin-bottom: 0;
margin-right: 1rem;
padding: 0;
}
.summary {
flex: 1;
font-family: 'URW Gothic', sans-serif;
letter-spacing: 0.05em;
font-weight: 600;
font-size: 1rem;
}
.chevron {
transition: transform 0.5s ease;
display: inline-flex;
justify-content: center;
}
.chevron.rotated {
transform: rotateX(180deg);
}
.content {
padding: 1rem;
position: absolute;
top: 40px;
border-radius: 0 0 8px 8px;
left: 0;
right: 0;
background: var(--alert-orange);
filter: brightness(1.2);
color: var(--color-title);
font-family: 'URW Gothic', sans-serif;
font-size: 1rem;
font-weight: 600;
line-height: 1.2;
z-index: 1;
}
.message-item {
margin: 10px;
padding-bottom: 0.75rem;
}
.message-item p {
margin: 0;
}
.message-item a {
color: #f2ff00;
}
.message-item:last-child {
padding-bottom: 0;
}
</style>

View File

@@ -10,13 +10,13 @@ class NearestStationsState {
private initGeoConfig: PositionOptions = { private initGeoConfig: PositionOptions = {
enableHighAccuracy: false, enableHighAccuracy: false,
timeout: 500, timeout: 500,
maximumAge: Infinity, maximumAge: Infinity
} };
private geoConfig: PositionOptions = { private geoConfig: PositionOptions = {
enableHighAccuracy: false, enableHighAccuracy: false,
timeout: 20000, timeout: 20000,
maximumAge: 30000, maximumAge: 30000
}; };
constructor() { constructor() {

View File

@@ -44,7 +44,126 @@
font-display: swap; font-display: swap;
} }
/* 100: Thin */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Thin.woff2') format('woff2');
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-ThinItalic.woff2') format('woff2');
font-weight: 100;
font-style: italic;
font-display: swap;
}
/* 200: ExtraLight */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-ExtraLight.woff2') format('woff2');
font-weight: 200;
font-style: normal;
font-display: swap;
}
/* 300: Light */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
/* 400: Regular / Italic */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* 500: Medium */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
/* 600: SemiBold */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
/* 700: Bold */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* 800: ExtraBold */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-ExtraBold.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}
/* Fira Code - Variable with fallback */
@font-face {
font-family: 'Fira Code';
src: url('/type/fira-code/FiraCode-VF.woff2') format('woff2-variations');
font-weight: 300 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Fira Code';
src: url('/type/fira-code/FiraCode-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Inconsolata Variable */
@font-face {
font-family: 'Inconsolata Variable';
font-style: normal;
font-display: swap;
font-weight: 200 900;
font-stretch: 50% 200%;
src: url(/type/inconsolata/latin-wdth-normal.woff2) format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root { :root {
/* Fixed Heights */
--header-height: 80px;
--nav-height: 60px;
/* Brand Colours */ /* Brand Colours */
--color-brand: #4fd1d1; --color-brand: #4fd1d1;
--color-accent: #3c6f79; --color-accent: #3c6f79;
@@ -58,6 +177,36 @@
--shadow-small: 0 4px 6px var(--color-shadow); --shadow-small: 0 4px 6px var(--color-shadow);
--shadow-up: 0 -4px 12px var(--color-shadow); --shadow-up: 0 -4px 12px var(--color-shadow);
--shadow-right: 4px 0 12px var(--color-shadow); --shadow-right: 4px 0 12px var(--color-shadow);
/* Functional Colours */
--location-yellow: #edff22;
--delay-orange: #ff914d;
--alert-orange: #f87728;
--cancel-red: #c60000;
--early-blue: #5ec1ff;
}
/* Pulse Animations */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes fast-pulse {
0%,
50%,
100% {
opacity: 1;
}
25%,
75% {
opacity: 0.25;
}
} }
body { body {

View File

@@ -28,6 +28,20 @@ class LocationStore {
return loc.t === query || loc.c === query; return loc.t === query || loc.c === query;
}); });
} }
getName(code: string | number | null | undefined): ApiLocationFilter.LocationFilterObject | null {
try {
if (!code) return null;
const query = String(code).toUpperCase().trim();
const match = this.data.find((loc) => loc.t === query || loc.c === query);
return match ?? null;
} catch (e) {
console.error('Error finding location object: ', e);
return null;
}
}
} }
export const LOCATIONS = new LocationStore(); export const LOCATIONS = new LocationStore();

View File

@@ -4,7 +4,7 @@ export interface QuickLink {
lastAccessed: number; lastAccessed: number;
} }
const RETURNED_LENGTH: number = 9; const RETURNED_LENGTH: number = 6;
const MAX_SCORE: number = 50; const MAX_SCORE: number = 50;
const MAX_ENTRIES: number = RETURNED_LENGTH * 4; const MAX_ENTRIES: number = RETURNED_LENGTH * 4;

View File

@@ -1,17 +1,134 @@
import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts';
export const estClass = (act: any, est: any) => (act ? 'act' : 'est');
/** /**
* Converts ISO/JSON time to UK-formatted HH:MM string. * Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds
* Ensures Europe/London timezone irrespective of browser timezone. * Ensures Europe/London timezone irrespective of browser timezone.
*/ */
export function formatUkTime(dateStr: string | Date | undefined): string { export function formatUkTime(
if (!dateStr) return '--:--'; dateStr: string | Date | undefined,
const date = typeof dateStr === 'string' ? new Date(dateStr): dateStr; includeSeconds: boolean = false
): string {
if (!dateStr) return '-';
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
if (isNaN(date.getTime())) return '--:--'; if (isNaN(date.getTime())) return '-';
return date.toLocaleTimeString('en-GB', { return date.toLocaleTimeString('en-GB', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
...(includeSeconds && { second: '2-digit' }),
hour12: false, hour12: false,
timeZone: 'Europe/London', timeZone: 'Europe/London'
}); });
} }
/**
* Converts ISO/JSON time to UK-formatted DD/MM/YY HH:MM:SS string.
* Ensures Europe/London timezone irrespective of browser timezone.
*/
export function formatUkDateTime(
dateStr: string | Date | undefined,
includeSeconds: boolean = false
): string {
if (!dateStr) return '--/--/-- --:--:--';
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
if (isNaN(date.getTime())) return '--/--/-- --:--:--';
return date
.toLocaleString('en-GB', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
...(includeSeconds && { second: '2-digit' }),
hour12: false,
timeZone: 'Europe/London'
})
.replace(',', '');
}
/**
* Specific type that can handle the TrainDetails.ServiceLocation and the ApiStationsBoard.BoardService types for delay calculation
*/
interface DelayInput {
// Board types
sta?: string | null;
std?: string | null;
eta?: string | null;
etd?: string | null;
ata?: string | null;
atd?: string | null;
// Journey types
pta?: string | null;
ptd?: string | null;
wta?: string | null;
wtd?: string | null;
atp?: string | null;
wtp?: string | null;
etp?: string | null;
}
/**
* Calculates a 'delay' value, in the formats:
* RT, 1E, 7L, etc.
* @param 'Schedule Location' or 'Board Service' object
* @returns Delay string for departure boards
*/
export function calculateDelay(loc: DelayInput): {
val: string;
type: string;
} {
const pairs = [
// Departure check (Board: atd/etd vs std | Journey: atd vs ptd/wtd)
{ actual: loc.atd || loc.etd, sched: loc.std || loc.ptd || loc.wtd },
// Arrival check (Board: ata/eta vs sta | Journey: ata vs pta/wta)
{ actual: loc.ata || loc.eta, sched: loc.sta || loc.pta || loc.wta },
// Passing check
{ actual: loc.atp || loc.etp, sched: loc.wtp }
];
const match = pairs.find((p) => p.actual && p.sched);
if (!match || !match.actual || !match.sched) return { val: '', type: 'none' };
const diffMinutes = Math.round((Date.parse(match.actual) - Date.parse(match.sched)) / 60000);
if (diffMinutes === 0) return { val: 'RT', type: 'ontime' };
const absDiff = Math.abs(diffMinutes);
if (diffMinutes > 0) {
return { val: `${absDiff}L`, type: 'late' };
} else {
return { val: `${absDiff}E`, type: 'early' };
}
}
/**
* Accepts a pair of times (string or Date) and outputs a delay class 'delay-early', 'delay-late' or 'delay-rt'
* @param sched Scheduled Time (string, Date)
* @param act Actual Time (string, Date)
*/
export function delayClassFromTimePair(sched: any, act: any): string {
if (!sched || !act) return '';
const s = new Date(sched).getTime();
const a = new Date(act).getTime();
if (isNaN(s) || isNaN(a)) return '';
const diff = a - s;
const oneMinute = 60000;
// on-time if within one minute
if (Math.abs(diff) < oneMinute) {
return 'delay-rt';
}
return diff > 0 ? 'delay-late' : 'delay-early';
}

View File

@@ -10,10 +10,13 @@
{#if page.status == 404} {#if page.status == 404}
<!-- Warning no data image --> <!-- Warning no data image -->
<img class="err-img" src={noResult} alt="" role="presentation" width="200" height="200" /> <img class="err-img" src={noResult} alt="" role="presentation" width="200" height="200" />
{:else if page.status == 499}
<!-- Change to a GSM-R X Sign?? -->
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
{:else} {:else}
<!-- STOP Error image --> <!-- STOP Error image -->
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" /> <img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
{/if} {/if}
<h2 class="label">{page.status}</h2> <h2 class="label">{page.status}</h2>
<p class="error-message"> <p class="error-message">
@@ -26,7 +29,11 @@
</div> </div>
{/if} {/if}
{#if $page.error?.owlCode === 'NETWORK_DISCONNECTED'}
<Button onclick={() => window.location.reload()} color={'accent'}>Retry</Button>
{:else}
<Button href={'/'} color={'accent'}>Return to Home</Button> <Button href={'/'} color={'accent'}>Return to Home</Button>
{/if}
</div> </div>
<style> <style>

View File

@@ -147,14 +147,14 @@
<h1 class="viewport-guard-title">Narrow Gauge Detected</h1> <h1 class="viewport-guard-title">Narrow Gauge Detected</h1>
<p> <p>
Just as trains need the right track width, our data needs a bit more room to stay on the rails. Just as trains need the right track width, our data needs a bit more room to stay on the rails.
Please expand your view to at least 300px to view the app. Please expand your view to at least 320px to view the app.
</p> </p>
</div> </div>
<style> <style>
header { header {
top: 0; top: 0;
height: 80px; height: var(--header-height);
box-shadow: var(--shadow-std); box-shadow: var(--shadow-std);
} }
.logo-link { .logo-link {
@@ -201,13 +201,12 @@
min-height: 100dvh; min-height: 100dvh;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--color-bg-dark); background-color: var(--color-bg-dark);
background-image: radial-gradient(var(--color-bg-dark), var(--color-bg-light));
} }
nav { nav {
display: flex; display: flex;
bottom: 0; bottom: 0;
height: 60px; height: var(--nav-height);
box-shadow: var(--shadow-up); box-shadow: var(--shadow-up);
} }
@@ -320,7 +319,7 @@
display: none; display: none;
} }
@media (max-width: 299px) { @media (max-width: 319px) {
.viewport-guard { .viewport-guard {
display: block; display: block;
position: fixed; position: fixed;

View File

@@ -1,8 +1,20 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { quickLinks } from '$lib/quick-links.svelte'; import { quickLinks } from '$lib/quick-links.svelte';
import StationAlertCard from '$lib/components/ui/station-board/StationAlertCard.svelte';
import { formatUkDateTime, formatUkTime } from '$lib/utils/time';
import StaffServicesTable from '$lib/components/ui/station-board/StaffServicesTable.svelte';
let { data } = $props(); let { data } = $props();
let now = $state(new Date());
onMount(() => {
const interval = setInterval(() => {
now = new Date();
}, 1000);
return () => clearInterval(interval);
});
// Update 'QuickLinks' // Update 'QuickLinks'
$effect(() => { $effect(() => {
@@ -17,18 +29,69 @@
} }
} }
}); });
// Wake Lock API Handling
// Load Data Invalidation Handling
// Refresh countdown logic
</script> </script>
<section>Live board are not yet implemented on the server</section> <section class="board-wrapper">
<div class="time-data">
<span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span>
<span class="time-now">{formatUkTime(now, true)}</span>
</div>
{#if data.boardData.data.m?.length}
<StationAlertCard messages={data.boardData.data.m} />
{/if}
{#if data.boardData.data.s?.length}
<div class="service-list-wrapper">
<StaffServicesTable services={data.boardData.data.s} />
</div>
{/if}
</section>
<style> <style>
section { .board-wrapper {
font-family: 'URW Gothic', sans-serif; display: flex;
text-align: center; height: calc(100dvh - var(--nav-height) - var(--header-height));
font-size: 2rem; flex-direction: column;
align-items: center;
width: 100%;
margin: 0 auto;
overflow: hidden;
gap: 0;
}
.time-data {
display: flex;
justify-content: space-between;
align-items: center;
width: 90%; width: 90%;
margin: auto; max-width: 700px;
padding-top: 25px; margin: 10px auto;
max-width: 500px; font-family: 'URW Gothic', sans-serif;
}
.time-loaded,
.time-now {
font-variant-numeric: tabular-nums;
}
.time-loaded {
font-size: 0.9rem;
}
.time-now {
font-weight: 600;
font-size: 1rem;
}
.service-list-wrapper {
flex: 1;
overflow-y: auto;
height: calc(100dvh - 60px);
width: 97%;
max-width: 700px;
margin-bottom: 5px;
} }
</style> </style>

View File

@@ -1,16 +1,14 @@
import { LOCATIONS } from '$lib/locations-object.svelte'; import { LOCATIONS } from '$lib/locations-object.svelte';
import { ApiError, OwlClient, ValidationError } from '$lib/owlClient';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ url }) => { export const load: PageLoad = async ({ url, fetch }) => {
const locId = url.searchParams.get('loc'); const locId = url.searchParams.get('loc');
if (!LOCATIONS.loaded) { if (!LOCATIONS.loaded) {
await LOCATIONS.init(); await LOCATIONS.init();
} }
let title: string = '';
if (!locId) { if (!locId) {
error(400, { error(400, {
message: 'Location not provided', message: 'Location not provided',
@@ -18,22 +16,49 @@ export const load: PageLoad = async ({ url }) => {
}); });
} }
let BoardLocation; const BoardLocation = LOCATIONS.find(locId);
if (locId) { if (!BoardLocation) {
BoardLocation = LOCATIONS.find(locId);
if (BoardLocation) {
title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
} else {
error(404, { error(404, {
message: `Location (${locId.toUpperCase()}) not found`, message: `Location (${locId.toUpperCase()}) not found`,
owlCode: 'INVALID_LOCATION_CODE' owlCode: 'INVALID_LOCATION_CODE'
}); });
} }
} const title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
try {
const boardData = await OwlClient.board.getByLocation(locId, fetch);
return { return {
title, title,
BoardLocation BoardLocation,
boardData
}; };
} catch (e: unknown) {
if (
e instanceof TypeError &&
(e.message == 'Failed to fetch' || e.message.includes('network'))
) {
throw error(503, {
message: 'Network error: Please check your connection',
owlCode: 'NETWORK_DISCONNECTED'
});
}
if (e instanceof ValidationError) {
throw error(400, { message: e.message, owlCode: 'VALIDATION_ERROR' });
} else if (e instanceof ApiError) {
// If the API returns 404, it means the backend doesn't recognize this CRS/TIPLOC
if (e.code === 'NOT_FOUND') {
throw error(404, {
message: `Location (${locId.toUpperCase()}) is not recognized by the server.`,
owlCode: 'LOCATION_NOT_IN_BACKEND'
});
}
throw error(e.status, { message: e.message, owlCode: 'API_ERROR' });
} else if (e instanceof Error) {
throw error(500, { message: e.message, owlCode: 'GEN_ERROR' });
}
throw error(500, { message: 'Unexpected error', owlCode: 'UNKNOWN_ERR' });
}
}; };

View File

@@ -2,17 +2,15 @@
import NoResults from '$lib/components/ui/NoResults.svelte'; import NoResults from '$lib/components/ui/NoResults.svelte';
import TrainService from '$lib/components/ui/TrainService.svelte'; import TrainService from '$lib/components/ui/TrainService.svelte';
let { data } = $props(); let { data } = $props();
</script> </script>
<h6 style="text-align:center;width=100%;margin:auto;padding-top:1rem;font-size:1rem;">
DateSelector
</h6>
{#if data.results.length === 0} {#if data.results.length === 0}
<NoResults <NoResults message={'No trains found on this date with this headcode.'} />
message={"No trains found on this date with this headcode."}
/>
{:else} {:else}
<div class="result-boxes"> <div class="result-boxes">
{#each data.results as service (service.r)} {#each data.results as service (service.r)}
<TrainService {service} /> <TrainService {service} />
{/each} {/each}

View File

@@ -3,14 +3,12 @@ import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ url }) => { export const load: PageLoad = async ({ fetch, url }) => {
const headcode = url.searchParams.get('h'); const headcode = url.searchParams.get('h');
let dateParam = url.searchParams.get('d'); let dateParam = url.searchParams.get('d');
const toc = url.searchParams.get('t') || ""; const toc = url.searchParams.get('t') || '';
const date: string | Date = (dateParam === "" || dateParam === null) const date: string | Date = dateParam === '' || dateParam === null ? new Date() : dateParam;
? new Date()
: dateParam;
if (!headcode) { if (!headcode) {
throw error(400, { throw error(400, {
@@ -20,45 +18,45 @@ export const load: PageLoad = async ({ url }) => {
} }
// Declared outside of the try so that it can be used in both the try and catch blocks // Declared outside of the try so that it can be used in both the try and catch blocks
let results: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[] let results: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[];
try { try {
const response = await OwlClient.trains.getByHeadcode(headcode, date, toc); const response = await OwlClient.trains.getByHeadcode(headcode, date, toc, fetch);
results = (response.data); results = response.data;
return { return {
title: headcode.toUpperCase(), title: headcode.toUpperCase(),
results: results, results: results
} };
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof ValidationError) { if (e instanceof ValidationError) {
throw error(400, { throw error(400, {
message: e.message, message: e.message,
owlCode: 'VALIDATION_ERROR', owlCode: 'VALIDATION_ERROR'
}); });
} else if (e instanceof ApiError) { } else if (e instanceof ApiError) {
// Check if NO_RESULTS error, and return empty array if that is the case // Check if NO_RESULTS error, and return empty array if that is the case
if (e.code === "NOT_FOUND") { if (e.code === 'NOT_FOUND') {
return { return {
title: headcode.toUpperCase(), title: headcode.toUpperCase(),
results: [] results: []
} };
} else { } else {
throw error(e.status, { throw error(e.status, {
message: e.message, message: e.message,
owlCode: 'API_ERROR', owlCode: 'API_ERROR'
}); });
} }
} else if (e instanceof Error) { } else if (e instanceof Error) {
throw error(500, { throw error(500, {
message: e.message, message: e.message,
owlCode: 'GEN_ERROR', owlCode: 'GEN_ERROR'
}) });
} else { } else {
throw error(500, { throw error(500, {
message: "Unexpected error", message: 'Unexpected error',
owlCode: "UNKNOWN_ERR", owlCode: 'UNKNOWN_ERR'
}) });
} }
} }
} };

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.