Compare commits

..

30 Commits

Author SHA1 Message Date
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
91cb119b7d Add trainservice details (currently raw JSON) 2026-05-03 01:16:30 +01:00
26e40c5bf6 Add loading state (initial)
Add rollover actions on train service
Add some additional toc styles
2026-04-30 01:26:29 +01:00
a746a1eac2 Add train service boxes... not yet expanding! 2026-04-28 20:28:29 +01:00
68af07b9bd Adjust error page & the error handling of the trains load function. Intead of throwing error on no-results, an empty array is passed to the page. A 'no-results' component will roughly echo the error pages not-found. 2026-04-28 18:13:05 +01:00
16d929fad1 Prepare changes to error code handling 2026-04-28 17:45:59 +01:00
5bbffcecb8 Add 'no-results' image to make the error page less 'abrupt' for a simple no-results. 2026-04-28 17:24:04 +01:00
5ead4f8296 Add no-date image for error page 2026-04-28 14:16:19 +01:00
abb8663766 Add train headcode search 2026-04-27 23:57:04 +01:00
3225b60140 Update API Client version & run npm audit fix 2026-04-27 22:33:53 +01:00
f7b1b7fe0d Update geohash (location services) logic to improve time to first hit 2026-04-21 20:13:53 +01:00
8c0d385772 Add QuickLinks based on 'frecency' pattern 2026-04-21 19:54:00 +01:00
a07315cec2 Fix search priority:
1. Match to exact CRS
 2. Match to exact Name
 3. Match to 'Name startsWith'
 4. Match any with valid CRS
 5. Match alphabetically
2026-04-20 23:23:50 +01:00
f3393f3c07 Add timezone warning to top of +layout.svelte conditionally displayed when the users device is not in the Europe/London timezone 2026-04-05 00:22:36 +01:00
b649af1925 Adjust button styling to improve appearance of text 2026-03-31 20:01:20 +01:00
aa1a989139 Remove fade-out 2026-03-31 00:11:09 +01:00
304b523127 Update button style, to improve looks while adding a large enough touch-target 2026-03-31 00:05:17 +01:00
777519ff5d Add NearestStations Card & Location monitor 2026-03-30 23:34:12 +01:00
4a969e626c Ensure dependencies are listed properly 2026-03-25 10:54:21 +00:00
53 changed files with 4551 additions and 986 deletions

2945
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,6 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.2", "@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@owlboard/owlboard-ts": "^3.0.0-dev.20260325T1023",
"@playwright/test": "^1.58.1", "@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
@@ -42,6 +41,7 @@
"vitest-browser-svelte": "^2.0.2" "vitest-browser-svelte": "^2.0.2"
}, },
"dependencies": { "dependencies": {
"@owlboard/owlboard-ts": "^3.0.0-dev.20260509t2101",
"@tabler/icons-svelte": "^3.40.0" "@tabler/icons-svelte": "^3.40.0"
} }
} }

5
src/app.d.ts vendored
View File

@@ -2,7 +2,10 @@
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} interface Error {
message: string;
owlCode?: string;
}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"><path fill="#fff" d="M0 0h120v120H0z"/><g fill="#00f"><path d="M60 0h60v60H60zM0 60h60v60H0z"/><path d="M12.72 32.97h1.08l1.22-4.9 1.2 4.9h1.08l2.16-7.4h-1.4l-1.3 5.17-1.25-5.16h-1l-1.26 5.16-1.3-5.16h-1.4zm12.6-5.54H24.1v.74a2 2 0 0 0-1.8-.87c-1.65 0-2.83 1.23-2.83 2.92 0 1.67 1.17 2.88 2.8 2.88a2.2 2.2 0 0 0 1.83-.85v.72h1.22zm-2.89 1.09c.95 0 1.63.7 1.63 1.72 0 .4-.16.86-.4 1.14-.27.33-.7.5-1.2.5-.98 0-1.65-.66-1.65-1.63 0-1.01.67-1.73 1.62-1.73m3.88 4.45h1.33v-3.08c-.03-.82.4-1.28 1.2-1.31V27.3h-.1c-.58 0-.86.16-1.2.67v-.54H26.3zm3.17 0h1.33v-2.61c0-.74.05-1.06.22-1.33q.33-.5.98-.51.53 0 .8.35c.18.24.26.65.26 1.39v2.7h1.33V30c0-1-.1-1.47-.4-1.9q-.56-.79-1.73-.8a2 2 0 0 0-1.56.66v-.53h-1.23zm6 0h1.33v-5.54h-1.33zm0-6.14h1.33v-1.25h-1.33zm2.4 6.14h1.33v-2.61c0-.74.05-1.06.22-1.33q.33-.5.98-.51.53 0 .8.35c.18.24.26.65.26 1.39v2.7h1.33V30c0-1-.1-1.47-.4-1.9q-.56-.79-1.73-.8a2 2 0 0 0-1.56.66v-.53h-1.23zm10.3-5.54v.7q-.62-.83-1.8-.83c-1.58 0-2.74 1.22-2.74 2.86 0 1.7 1.14 2.94 2.72 2.94.78 0 1.3-.25 1.82-.86q-.07 1.78-1.55 1.77c-.62 0-1-.16-1.39-.6h-1.5c.44 1.12 1.48 1.77 2.84 1.77 1 0 1.8-.35 2.31-1.01.4-.51.56-1.2.56-2.25v-4.5zm-1.66 1.09c.95 0 1.6.68 1.6 1.7 0 1-.62 1.66-1.58 1.66-.95 0-1.56-.65-1.56-1.66 0-1.02.61-1.7 1.54-1.7" aria-label="Warning" font-family="URW Gothic" font-size="10" font-weight="600" style="-inkscape-font-specification:&quot;URW Gothic Semi-Bold&quot;"/><g stroke-width="0" aria-label="No Data" font-family="URW Gothic" font-size="10" font-weight="600" style="-inkscape-font-specification:&quot;URW Gothic Oblique&quot;;text-align:center" text-anchor="middle"><path d="M83.98 87.39h1.37v-5.37l3.1 5.37h1.52V80H88.6v5.38L85.54 80h-1.56zm9.95-5.67a2.93 2.93 0 0 0-2.93 2.9c0 1.6 1.32 2.9 2.94 2.9s2.95-1.3 2.95-2.86a2.9 2.9 0 0 0-2.96-2.94m.01 1.22c.9 0 1.61.75 1.61 1.68s-.72 1.68-1.6 1.68c-.9 0-1.6-.75-1.6-1.7 0-.91.71-1.66 1.6-1.66M79.24 99.89h1.72c1.12 0 1.79-.11 2.39-.41 1.14-.56 1.8-1.78 1.8-3.27 0-1.41-.57-2.56-1.6-3.18a5 5 0 0 0-2.62-.53h-1.7zm1.37-1.31v-4.77h.27c.88 0 1.34.08 1.77.3.7.36 1.13 1.15 1.13 2.1 0 .89-.41 1.68-1.07 2.04-.41.22-.98.33-1.8.33zm11.1-4.23h-1.22v.74a2 2 0 0 0-1.81-.87c-1.64 0-2.82 1.23-2.82 2.92 0 1.67 1.17 2.88 2.79 2.88a2.2 2.2 0 0 0 1.84-.85v.72h1.22zm-2.9 1.09c.96 0 1.64.71 1.64 1.72 0 .4-.16.86-.4 1.14-.27.33-.7.5-1.21.5-.97 0-1.64-.66-1.64-1.63 0-1.01.67-1.73 1.62-1.73m4.26 4.45h1.33v-4.33h.8v-1.21h-.8V92.5h-1.33v1.85h-.65v1.21h.65zm8.33-5.54h-1.22v.74a2 2 0 0 0-1.81-.87c-1.64 0-2.82 1.23-2.82 2.92 0 1.67 1.17 2.88 2.79 2.88a2.2 2.2 0 0 0 1.84-.85v.72h1.22zm-2.9 1.09c.96 0 1.64.71 1.64 1.72 0 .4-.16.86-.4 1.14q-.42.5-1.21.5c-.97 0-1.64-.66-1.64-1.63 0-1.01.67-1.73 1.62-1.73" style="-inkscape-font-specification:&quot;URW Gothic Semi-Bold&quot;"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -19,47 +19,60 @@
{#if isLink} {#if isLink}
<a <a
{href} {href}
class="btn {color}" class="hitbox-wrapper"
target={isExternal ? '_blank' : undefined} target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined} rel={isExternal ? 'noopener noreferrer' : undefined}
{...rest} {...rest}
> ><span class="btn {color}">
{@render children?.()} {@render children?.()}
</span>
</a> </a>
{:else} {:else}
<button class="btn {color}" {onclick} {...rest}> <button class="hitbox-wrapper" {onclick} {...rest}>
{@render children?.()} <span class="btn {color}">{@render children?.()}</span>
</button> </button>
{/if} {/if}
<style> <style>
.btn { .hitbox-wrapper {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.4rem 1.2rem;
width: fit-content;
min-width: 90px;
min-height: 48px; min-height: 48px;
min-width: 48px;
appearance: none;
background: transparent;
border: none; border: none;
border-radius: 20px; padding: 0 4px;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
font-family: 'URW Gothic', sans-serif;
letter-spacing: 0.05ch;
font-size: 1rem;
font-weight: 600;
transition: all 0.2s;
box-shadow: var(--shadow-std);
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
touch-action: manipulation; }
.btn {
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
flex-shrink: 0;
padding: 0 1.2rem;
min-width: 40px;
height: 36px;
border-radius: 20px;
border: none;
box-shadow: var(--shadow-small);
font-family: 'URW Gothic', sans-serif;
font-size: 0.93rem;
font-weight: 400;
letter-spacing: 0.05ch;
transition:
all 0.1s ease,
box-shadow 0.2s;
} }
.accent { .accent {
background-color: var(--color-accent); background-color: var(--color-accent);
color: var(--color-title); color: var(--color-title);
font-weight: 600;
} }
.brand { .brand {
@@ -72,11 +85,11 @@
color: var(--color-title); color: var(--color-title);
} }
.btn:hover { .hitbox-wrapper:hover .btn {
filter: brightness(1.5); filter: brightness(1.5);
} }
.btn.active { .hitbox-wrapper:active .btn {
transform: scale(0.98); transform: scale(0.98);
} }
</style> </style>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
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>
<div class="loading-state">
<div class="track">
<div class="shuttle"></div>
</div>
<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>
<style>
.loading-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 4rem 2rem;
width: 75%;
margin: auto;
}
.track {
width: 160px;
height: 3px;
background-color: var(--color-title);
border-radius: 4px;
position: relative;
overflow: hidden;
}
.shuttle {
position: absolute;
width: 50%;
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, #1abc9c 0%, #3498db 100%);
animation: data-travel 1.6s infinite ease-in-out;
}
.message-container {
display: grid;
place-items: center;
height: 2rem;
margin-top: 1rem;
}
p {
grid-area: 1 / 1;
font-family: 'URW Gothic', sans-serif;
letter-spacing: 0.15em;
color: var(--color-title);
}
@keyframes data-travel {
0% {
left: -50%;
}
100% {
left: 100%;
}
}
</style>

View File

@@ -27,16 +27,27 @@
return LOCATIONS.data return LOCATIONS.data
.filter((r) => tokens.every((t) => r.s.includes(t))) .filter((r) => tokens.every((t) => r.s.includes(t)))
.sort((a, b) => { .sort((a, b) => {
// Priority One - Exact CRS Match // Priority One - Exact CRS Match
const aExactCrs = a.c?.toLowerCase() === lowerQuery; const aExactCrs = a.c?.toLowerCase() === lowerQuery;
const bExactCrs = b.c?.toLowerCase() === lowerQuery; const bExactCrs = b.c?.toLowerCase() === lowerQuery;
if (aExactCrs && !bExactCrs) return -1; if (aExactCrs && !bExactCrs) return -1;
if (!aExactCrs && bExactCrs) return 1; if (!aExactCrs && bExactCrs) return 1;
// Priority Two - 'Stations' with CRS // Priority Two - Exact Name Match
const aNameLow = a.n.toLowerCase();
const bNameLow = b.n.toLowerCase();
const aExactName = aNameLow === lowerQuery;
const bExactName = bNameLow === lowerQuery;
if (aExactName !== bExactName) return aExactName ? -1 : 1;
// Priority Three - Name starts with Query
const aStarts = aNameLow.startsWith(lowerQuery);
const bStarts = bNameLow.startsWith(lowerQuery);
if (aStarts !== bStarts) return aStarts ? -1 : 1;
// Priority Four - 'Stations' with CRS
if (!!a.c && !b.c) return -1; if (!!a.c && !b.c) return -1;
if (!a.c & !! b.c) return 1; if (!a.c && !!b.c) return 1;
// Alphabetical Sort // Alphabetical Sort
return a.n.localeCompare(b.n); return a.n.localeCompare(b.n);

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import noResult from '$lib/assets/img/no-data.svg';
import Button from '$lib/components/ui/Button.svelte';
let { title = 'No results', message = 'Try checking your search term' } = $props();
</script>
<div class="no-results-state">
<img src={noResult} class="image" height="200" width="200" alt="" role="presentation" />
<h3>{title}</h3>
<p>{message}</p>
<div class="btn-container">
<Button
type="button"
onclick={() => (history.length > 1 ? history.back() : (window.location.href = '/'))}
color="accent"
>
Go back
</Button>
</div>
</div>
<style>
.no-results-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 400px;
padding: 5rem;
text-align: center;
box-sizing: border-box;
font-family: 'URW-Gothic', sans-serif;
}
.image {
margin-bottom: 1.95rem;
max-width: 90%;
height: auto;
}
h3 {
margin: 0 0 0.5rem 0;
}
p {
margin: 0;
opacity: 0.8;
}
.btn-container {
margin-top: 20px;
}
</style>

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,16 +41,30 @@
<label for="adaptive-input">{label}</label> <label for="adaptive-input">{label}</label>
{/if} {/if}
<input <form onsubmit={handleSubmit} class="input-container">
id="adaptive-input" <input
class:all-caps={uppercase} id="adaptive-input"
{type} class:all-caps={uppercase}
{placeholder} {type}
bind:value {placeholder}
onfocus={() => (isFocussed = true)} bind:value
onblur={() => (isFocussed = false)} onfocus={() => (isFocussed = true)}
{...rest} onblur={() => (isFocussed = false)}
/> {...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>
@@ -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,44 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
let isNotLondon = $state(false);
let londonZone = $state('Greenwich Mean Time');
onMount(() => {
const userTZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
isNotLondon = userTZ !== 'Europe/London';
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/London',
timeZoneName: 'long'
}).formatToParts(new Date());
londonZone = parts.find((p) => p.type === 'timeZoneName')?.value || 'UK Time';
});
</script>
{#if isNotLondon}
<div transition:slide={{ duration: 300 }} class="tzWarn">
<p class="tzText">
All times are shown in <strong>{londonZone}</strong>
</p>
</div>
{/if}
<style>
.tzWarn {
display: flex;
justify-content: center;
width: 100%;
padding: 1rem 0 0 0;
}
.tzText {
width: 80%;
text-align: center;
margin: auto;
font-family: 'URW Gothic', sans-serif;
font-size: 1.2rem;
}
</style>

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

@@ -1,53 +1,77 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
toc: string; toc: string;
} }
let { let { toc }: Props = $props();
toc
}: Props = $props();
let code = $derived(toc.toUpperCase()); let code = $derived(toc.toUpperCase());
</script> </script>
<!-- SPACE MONO for the font? -->
<div class="toc-container {code}"> <div class="toc-container {code}">
{code} {code}
</div> </div>
<style> <style>
.toc-container { .toc-container {
border-radius: 10px; border-radius: 10px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 2px 8px; padding: 2px 8px;
font-weight: 800; font-weight: 800;
background-color: #333; font-size: 1.1rem;
color: #fff; background-color: #333;
} color: #fff;
font-family: 'Courier New', Courier, monospace;
}
.GW { /* Great Western Railway */ .AW {
background: #004225; /* Transport for Wales */
color: #E2E2E2; background: red;
} color: white;
}
.GR { /* LNER */ .LM {
background-color: #C00000; /* West Midlands Trains */
color: #FFFFFF; background: rgb(176, 115, 1);
} color: white;
}
.VT { /* Avanti West Coast */ .GW {
background-color: #004354; /* Great Western Railway */
color: #FFFFFF; background: #004225;
} color: #e2e2e2;
}
.SW { /* South Western Railway */ .GR {
background-color: #2A3389; /* LNER */
color: #FFFFFF; background-color: #c00000;
} color: #ffffff;
}
.XC { /* CrossCountry */ .VT {
background-color: #660000; /* Avanti West Coast */
color: #E4D5B1; background-color: #004354;
} color: #ffffff;
}
.GN {
/* Great Northern */
background-color: fuchsia;
color: rgb(229, 229, 229);
}
.SW {
/* South Western Railway */
background-color: #2a3389;
color: #ffffff;
}
.XC {
/* CrossCountry */
background-color: #660000;
color: #e4d5b1;
}
</style> </style>

View File

@@ -0,0 +1,571 @@
<script lang="ts">
import type { ApiPisObject, ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import { slide } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { formatUkTime, calculateDelay } from '$lib/utils/time';
import TocStyle from '$lib/components/ui/TocStyle.svelte';
import TiplocConverter from '$lib/components/ui/TiplocConverter.svelte';
import { IconChevronDownFilled } from '@tabler/icons-svelte';
let { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse } = $props();
let isExpanded = $state(false);
let loadingDetails = $state(false);
let loadingDetailsError = $state(false);
let loadingDetailsErrorMsg = $state('');
let details = $state(null);
const toggleExpand = async (rid: string) => {
if (isExpanded) {
isExpanded = false;
return;
}
loadingDetails = true;
try {
const result = await OwlClient.trains.getByRid(service.r);
details = result.data;
isExpanded = true;
} catch (e) {
console.error('Failed to load train details');
loadingDetailsError = true;
loadingDetailsErrorMsg = e.message;
} finally {
loadingDetails = false;
}
};
let OriginDepartureSummary = $derived(formatUkTime(service.od));
async function loadDetails(rid: string) {
if (details) return;
loadingDetails = true;
const result = await OwlClient.trains.getByRid(service.r);
details = result.data;
loadingDetails = false;
}
const estClass = (act, est) => (!act && est ? 'est' : 'act');
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>
<div class="train-service" class: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">
<TocStyle toc={service.o} />
</div>
<div class="main-text-summary">
<div class="time-summary">
{OriginDepartureSummary}
</div>
<div class="location-summary" class:can-all={service.ct}>
{service.ot}
</div>
<div class="location-summary to-summary" class:can-all={service.ct}>to</div>
<div class="location-summary" class:can-all={service.ct}>
{service.dt}
</div>
<div class="arrow" class:expanded={isExpanded}>
<IconChevronDownFilled color={'var(--color-title)'} size={25} />
</div>
</div>
</button>
{#if isExpanded && details}
<div class="box-ext" transition:slide={{ duration: 800, easing: quintOut }}>
<!-- Here goes the data formatting! -->
<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>
{/if}
</div>
<style>
/*
Main container
*/
.train-service {
background-color: var(--color-accent);
width: 100%;
max-width: 460px;
border-radius: 12px;
box-shadow: var(--shadow-std);
overflow: visible;
font-family: 'URW Gothic', sans-serif;
transition: 0.2s all;
filter: brightness(1.1);
-webkit-tap-highlight-color: transparent;
}
.train-service:active {
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 {
position: relative;
display: flex;
align-items: center;
width: 100%;
padding: 0 1rem;
min-height: 48px;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
gap: 0.5rem;
}
.operator-summary {
flex-shrink: 0;
}
.main-text-summary {
display: flex;
flex-grow: 1;
align-items: center;
gap: 0.5rem;
}
.time-summary {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-brand);
}
.location-summary {
text-transform: uppercase;
font-weight: 500;
font-size: 0.75rem;
letter-spacing: 0.02em;
color: var(--color-title);
}
.to-summary {
font-size: 0.8rem;
font-style: oblique;
text-transform: lowercase;
}
.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

@@ -0,0 +1,40 @@
<script lang="ts">
import { goto } from '$app/navigation';
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
let headcode = $state('');
function handleSearch(headcode: string) {
if (!headcode.trim()) return;
const searchParams = new URLSearchParams();
searchParams.append('h', headcode.trim().toUpperCase());
goto(`/trains?${searchParams.toString()}`);
}
</script>
<BaseCard header={'Search Train & PIS'}>
<div class="card-content">
<Textbox
placeholder="Enter Headcode"
bind:value={headcode}
maxLength={4}
onsubmit={handleSearch}
/>
</div>
</BaseCard>
<style>
.card-content {
text-align: center;
width: 90%;
margin: auto;
padding: 10px 0 0.5rem 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { nearestStationsState } from '$lib/geohash.svelte';
const flipDuration = 300;
</script>
<BaseCard header={'Nearby Stations'}>
<div class="card-content">
{#if nearestStationsState.error && nearestStationsState.list.length === 0}
<p class="msg">{nearestStationsState.error}</p>
{:else if nearestStationsState.loading && nearestStationsState.list.length === 0}
<p class="msg">Locating stations...</p>
{:else}
<div class="stations-flex">
{#each nearestStationsState.list as station (station.c)}
<div
class="btn-container"
animate:flip={{ duration: flipDuration }}
in:fade|global={{ duration: 200 }}
>
<Button href={`/board?loc=${station.c}`}
><span class="stn-name">{station.n}</span></Button
>
</div>
{/each}
</div>
{/if}
</div>
</BaseCard>
<style>
.card-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
width: 90%;
min-height: 98px;
margin: auto;
padding: 10px 0 0 0;
}
.stations-flex {
display: flex;
flex-wrap: wrap;
gap: 0.1rem 0.5rem;
justify-content: center;
align-items: flex-start;
}
.btn-container {
display: block;
width: fit-content;
will-change: transform;
}
.msg {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-title);
}
.stn-name {
text-transform: capitalize;
}
</style>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { quickLinks } from '$lib/quick-links.svelte';
const flipDuration = 300;
</script>
<BaseCard header={'Quick Links'}>
<div class="card-content">
{#if quickLinks.list.length === 0}
<p class="msg">Your most viewed stations will appear here</p>
{:else}
<div class="stations-flex">
{#each quickLinks.list as station (station.id)}
<div
class="btn-container"
animate:flip={{ duration: flipDuration }}
in:fade|global={{ duration: 200 }}
>
<Button href={`/board?loc=${station.id}`}
><span class="stn-name">{station.id}</span></Button
>
</div>
{/each}
</div>
{/if}
</div>
</BaseCard>
<style>
.card-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
width: 90%;
min-height: 98px;
margin: auto;
padding: 10px 0 10px 0;
}
.stations-flex {
display: flex;
flex-wrap: wrap;
gap: 0.1rem 0.5rem;
justify-content: center;
align-items: flex-start;
}
.btn-container {
display: block;
width: fit-content;
will-change: transform;
}
.msg {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-title);
}
.stn-name {
text-transform: capitalize;
}
</style>

View File

@@ -0,0 +1,138 @@
<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: 1rem 0;
border-radius: 8px;
background: transparent;
position: relative;
z-index: 10;
margin-bottom: 0;
width: 95%;
max-width: 750px;
}
.trigger {
width: 100%;
display: flex;
border-radius: 8px;
align-items: center;
padding: 0.4rem 1rem;
background: var(--alert-orange);
color: white;
border: none;
cursor: pointer;
text-align: left;
position: relative;
z-index: 2;
height: 46px;
transition: all 0.65s 0.2s;
}
.trigger.active {
border-radius: 8px 8px 0 0;
transition: all 0.1s 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: 46px;
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

@@ -1,28 +1,25 @@
<script lang="ts"> <script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte'; import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte'; import Textbox from '$lib/components/ui/Textbox.svelte';
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'}>
<div class="card-content"> <div class="card-content">
<div class="textbox-container"> <div class="textbox-container">
<div class="textbox-item-wrapper"> <div class="textbox-item-wrapper">
<Textbox placeholder={"Code"} uppercase={true} type={'number'} max={9999} bind:value={codeValue} /> <Textbox
</div> placeholder={'Code'}
</div> uppercase={true}
<div class="button-wrapper"> type={'text'}
<Button onclick={() => onsearch(codeValue.toString())}>Search</Button> onsubmit={onsearch}
<Button onclick={resetValues}>Reset</Button> maxLength={4}
</div> inputmode={'numeric'}
/>
</div>
</div>
</div> </div>
</BaseCard> </BaseCard>
@@ -34,21 +31,21 @@ function resetValues(): void {
padding: 10px 0 10px 0; padding: 10px 0 10px 0;
} }
.textbox-container { .textbox-container {
display: flex; display: flex;
width: 100%; width: 100%;
justify-content: center; justify-content: center;
gap: 4rem; gap: 4rem;
} }
.textbox-item-wrapper { .textbox-item-wrapper {
width: 30%; width: 60%;
} }
.button-wrapper { .button-wrapper {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 1rem; gap: 1rem;
margin-top: 15px; margin-top: 15px;
} }
</style> </style>

View File

@@ -1,34 +1,33 @@
<script lang="ts"> <script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte'; import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte'; import Textbox from '$lib/components/ui/Textbox.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
let { onsearch }: { onsearch: (s: string, e: string) => void } = $props(); let { onsearch }: { onsearch: (s: string, e: string) => void } = $props();
let startValue = $state('');
let endValue = $state('');
let startValue = $state(''); function resetValues(): void {
let endValue = $state(''); startValue = '';
endValue = '';
function resetValues(): void { }
startValue = '';
endValue = '';
}
</script> </script>
<BaseCard header={'Find by Start/End CRS'}> <BaseCard header={'Find by Start/End CRS'}>
<div class="card-content"> <div class="card-content">
<div class="textbox-container"> <div class="textbox-container">
<div class="textbox-item-wrapper"> <div class="textbox-item-wrapper">
<Textbox placeholder={"Start"} uppercase={true} maxLength={3} bind:value={startValue} /> <Textbox placeholder={'Start'} uppercase={true} maxLength={3} bind:value={startValue} />
</div> </div>
<div class="textbox-item-wrapper"> <div class="textbox-item-wrapper">
<Textbox placeholder={"End"} uppercase={true} maxLength={3} bind:value={endValue} /> <Textbox placeholder={'End'} uppercase={true} maxLength={3} bind:value={endValue} />
</div> </div>
</div> </div>
<div class="button-wrapper"> <div class="button-wrapper">
<Button onclick={() => onsearch(startValue, endValue)}>Search</Button> <Button onclick={() => onsearch(startValue, endValue)}>Search</Button>
<Button onclick={resetValues}>Reset</Button> <Button onclick={resetValues}>Reset</Button>
</div> </div>
</div> </div>
</BaseCard> </BaseCard>
@@ -40,21 +39,21 @@ function resetValues(): void {
padding: 10px 0 10px 0; padding: 10px 0 10px 0;
} }
.textbox-container { .textbox-container {
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 {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 1rem; gap: 1rem;
margin-top: 15px; margin-top: 15px;
} }
</style> </style>

96
src/lib/geohash.svelte.ts Normal file
View File

@@ -0,0 +1,96 @@
import { OwlClient, ValidationError, ApiError } from './owlClient';
import type { ApiStationsNearestStations } from '@owlboard/owlboard-ts';
class NearestStationsState {
list = $state<ApiStationsNearestStations.StationsNearestStations[]>([]);
currentHash = $state('');
loading = $state(true);
error = $state<string | null>(null);
private initGeoConfig: PositionOptions = {
enableHighAccuracy: false,
timeout: 500,
maximumAge: Infinity
};
private geoConfig: PositionOptions = {
enableHighAccuracy: false,
timeout: 20000,
maximumAge: 30000
};
constructor() {
if (typeof window !== 'undefined' && 'geolocation' in navigator) {
this.jumpstart();
this.initWatcher();
}
}
private jumpstart() {
navigator.geolocation.getCurrentPosition(
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
(err) => this.handleError(err),
this.initGeoConfig
);
}
private initWatcher() {
navigator.geolocation.watchPosition(
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
(err) => this.handleError(err),
this.geoConfig
);
}
private async handleUpdate(lat: number, lon: number) {
const newHash = OwlClient.stationData.generateGeohash(lat, lon);
if (newHash !== this.currentHash) {
this.loading = true;
try {
const result = await OwlClient.stationData.getNearestStations(newHash);
this.list = result.data;
this.error = null;
this.currentHash = newHash;
} catch (e) {
this.handleApiError(e);
} finally {
this.loading = false;
}
}
}
private handleError(err: GeolocationPositionError) {
if (err.code === 1) {
this.error = 'Location access denied by device';
} else {
this.error = 'Waiting for GPS signal...';
}
}
private handleApiError(e: unknown) {
if (e instanceof ValidationError) {
this.error = `Request Error: ${e.reason} (Field: ${e.field})`;
} else if (e instanceof ApiError) {
switch (e.status) {
case 404:
this.error = 'No stations found nearby';
break;
case 429:
this.error = 'Too many requests, will retry';
break;
case 500:
this.error = 'Server Error, will retry';
break;
default:
this.error = `Service error: ${e.code}`;
}
} else {
this.error = 'Connection lost, waiting for signal';
}
console.error('OwlBoard API Error:', e);
}
}
export const nearestStationsState = new NearestStationsState();

View File

@@ -44,6 +44,91 @@
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;
}
:root { :root {
/* Brand Colours */ /* Brand Colours */
--color-brand: #4fd1d1; --color-brand: #4fd1d1;
@@ -55,8 +140,39 @@
/* Shadows */ /* Shadows */
--color-shadow: hsla(210, 20%, 5%, 0.35); --color-shadow: hsla(210, 20%, 5%, 0.35);
--shadow-std: 0 4px 12px var(--color-shadow); --shadow-std: 0 4px 12px 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.3;
}
}
@keyframes fast-pulse {
0%,
50%,
100% {
opacity: 1;
}
25%,
75% {
opacity: 0;
}
} }
body { body {

View File

@@ -1,5 +1,5 @@
import { OwlClient } from "./owlClient"; import { OwlClient } from './owlClient';
import type { ApiLocationFilter } from '@owlboard/owlboard-ts' import type { ApiLocationFilter } from '@owlboard/owlboard-ts';
class LocationStore { class LocationStore {
data = $state<ApiLocationFilter.LocationFilterObject[]>([]); data = $state<ApiLocationFilter.LocationFilterObject[]>([]);
@@ -9,7 +9,7 @@ class LocationStore {
if (this.loaded) return; if (this.loaded) return;
try { try {
const fetch = await OwlClient.locationFilter.getLocationFilterData() const fetch = await OwlClient.locationFilter.getLocationFilterData();
this.data = fetch.data; this.data = fetch.data;
this.loaded = true; this.loaded = true;
} catch (err) { } catch (err) {
@@ -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

@@ -1,21 +1,21 @@
import { OwlBoardClient, ValidationError, ApiError } from "@owlboard/owlboard-ts"; import { OwlBoardClient, ValidationError, ApiError } from '@owlboard/owlboard-ts';
import { browser, dev } from "$app/environment"; import { browser, dev } from '$app/environment';
// Import the runes containing the API Key config Here... // Import the runes containing the API Key config Here...
const baseUrl: string = browser ? window.location.origin : ''; const baseUrl: string = browser ? window.location.origin : '';
const getBaseUrl = () => { const getBaseUrl = () => {
if (!browser) return ''; if (!browser) return '';
if (dev) return 'https://test.owlboard.info'; if (dev) return 'https://test.owlboard.info';
return window.location.origin; return window.location.origin;
} };
export const OwlClient = new OwlBoardClient( export const OwlClient = new OwlBoardClient(
getBaseUrl(), getBaseUrl()
// API Key Here when ready!!! // API Key Here when ready!!!
) );
export { ValidationError, ApiError }; export { ValidationError, ApiError };

View File

@@ -0,0 +1,60 @@
export interface QuickLink {
id: string;
score: number;
lastAccessed: number;
}
const RETURNED_LENGTH: number = 6;
const MAX_SCORE: number = 50;
const MAX_ENTRIES: number = RETURNED_LENGTH * 4;
class QuickLinksService {
#links = $state<QuickLink[]>([]);
constructor() {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('ql');
if (saved) {
this.#links = JSON.parse(saved);
}
}
}
get list(): QuickLink[] {
return this.#links.slice(0, RETURNED_LENGTH);
}
recordVisit(id: string) {
if (id == '') return;
const existing = this.#links.find((l) => l.id === id);
if (existing) {
existing.score += 1;
existing.lastAccessed = Date.now();
} else {
this.#links.push({
id: id,
score: 1,
lastAccessed: Date.now()
});
}
// Score decay - if MAX_SCORE reached, divide all by two
if (this.#links.some((l) => l.score > MAX_SCORE)) {
this.#links.forEach((l) => {
l.score = Math.max(1, Math.floor(l.score / 2));
});
}
// Sort & Prune
const sorted = [...this.#links].sort(
(a, b) => b.score - a.score || b.lastAccessed - a.lastAccessed
);
this.#links = sorted.slice(0, MAX_ENTRIES);
localStorage.setItem('ql', JSON.stringify(this.#links));
}
}
export const quickLinks = new QuickLinksService();

82
src/lib/utils/time.ts Normal file
View File

@@ -0,0 +1,82 @@
import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts';
/**
* Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds
* Ensures Europe/London timezone irrespective of browser timezone.
*/
export function formatUkTime(
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.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
...(includeSeconds && { second: '2-digit' }),
hour12: false,
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(',', '');
}
/**
* Calculates a 'delay' value, in the formats:
* RT, 1E, 7L, etc.
* @param 'Schedule Location' object
* @returns Delay string for departure boards
*/
export function calculateDelay(loc: ApiTrainsTrainDetails.ServiceLocation): {
val: string;
type: string;
} {
const pairs = [
{ actual: loc.atd, sched: loc.ptd ?? loc.wtd },
{ actual: loc.ata, sched: loc.pta ?? loc.wta },
{ actual: loc.atp, 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' };
}
}

View File

@@ -2,12 +2,21 @@
import { page } from '$app/state'; import { page } from '$app/state';
import stopErr from '$lib/assets/img/stop-error.svg'; import stopErr from '$lib/assets/img/stop-error.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';
</script> </script>
<div class="error-wrapper"> <div class="error-wrapper">
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" /> {#if page.status == 404}
<!-- Warning no data image -->
<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}
<!-- STOP Error image -->
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
{/if}
<h2 class="label">{page.status}</h2> <h2 class="label">{page.status}</h2>
<p class="error-message"> <p class="error-message">
@@ -20,7 +29,11 @@
</div> </div>
{/if} {/if}
<Button href={'/'} color={'accent'}>Return to Home</Button> {#if $page.error?.owlCode === 'NETWORK_DISCONNECTED'}
<Button onclick={() => window.location.reload()} color={'accent'}>Retry</Button>
{:else}
<Button href={'/'} color={'accent'}>Return to Home</Button>
{/if}
</div> </div>
<style> <style>

View File

@@ -2,8 +2,12 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { slide, fade } from 'svelte/transition'; import { slide, fade } from 'svelte/transition';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { navigating } from '$app/state';
import { LOCATIONS } from '$lib/locations-object.svelte'; import { LOCATIONS } from '$lib/locations-object.svelte';
import { nearestStationsState } from '$lib/geohash.svelte';
import Loading from '$lib/components/ui/Loading.svelte';
import TimezoneWarning from '$lib/components/ui/TimezoneWarning.svelte';
import '$lib/global.css'; import '$lib/global.css';
@@ -77,7 +81,13 @@
</header> </header>
<main> <main>
{@render children()} <TimezoneWarning />
{#if navigating && navigating.to}
<Loading />
{:else}
{@render children()}
{/if}
</main> </main>
<nav bind:clientWidth={navWidth}> <nav bind:clientWidth={navWidth}>

View File

@@ -1,9 +1,15 @@
<script lang="ts"> <script lang="ts">
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte'; import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
import HeadcodeSearchCard from '$lib/components/ui/cards/HeadcodeSearchCard.svelte';
import NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte';
import QuickLinksCard from '$lib/components/ui/cards/QuickLinksCard.svelte';
</script> </script>
<div class="card-container"> <div class="card-container">
<LocationBoardCard /> <LocationBoardCard />
<HeadcodeSearchCard />
<NearbyStationsCard />
<QuickLinksCard />
</div> </div>
<style> <style>

View File

@@ -30,9 +30,9 @@
</p> </p>
<p class="amble"> <p class="amble">
Why OwlBoard? The name was chosen as an evolution of its predecessor, 'Athena'; owls are Why OwlBoard? The name was chosen as an evolution of its predecessor, 'Athena'; owls are
associated with the equivalent Roman Goddess - Minerva - as well as with wisdom. This also links to Bath, where the associated with the equivalent Roman Goddess - Minerva - as well as with wisdom. This also links
app has been built and is run, relating to the 'Minerva Owl' sculpture trail in the city, with to Bath, where the app has been built and is run, relating to the 'Minerva Owl' sculpture trail
many of the sculptures still in the area. in the city, with many of the sculptures still in the area.
</p> </p>
<p class="opensource"> <p class="opensource">
Some components that combine to form OwlBoard are open-source, see the <a Some components that combine to form OwlBoard are open-source, see the <a

View File

@@ -1,13 +1,98 @@
<section>Live board are not yet implemented on the server</section> <script lang="ts">
import { onMount, untrack } from 'svelte';
import { quickLinks } from '$lib/quick-links.svelte';
import StationAlertCard from '$lib/components/ui/cards/StationAlertCard.svelte';
import { formatUkDateTime, formatUkTime } from '$lib/utils/time';
let { data } = $props();
let now = $state(new Date());
onMount(() => {
const interval = setInterval(() => {
now = new Date();
}, 1000);
return () => clearInterval(interval);
});
// Update 'QuickLinks'
$effect(() => {
if (data.BoardLocation) {
const id = data.BoardLocation?.c ?? data.BoardLocation?.t;
if (id) {
// Untrack, as we do not need to handle changes to quickLinks - this is WRITE_ONLY
untrack(() => {
quickLinks.recordVisit(id);
console.log(`QuickLink visit recorded: ${JSON.stringify(data.BoardLocation)}`);
});
}
}
});
// Wake Lock API Handling
// Load Data Invalidation Handling
// Refresh countdown logic
</script>
<section class="board-wrapper">
{#if data.boardData.data.m?.length}
<StationAlertCard messages={data.boardData.data.m} />
{/if}
<div class="time-data">
<span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span>
<span class="time-now">{formatUkTime(now, true)}</span>
</div>
<pre class="json-dump">{JSON.stringify(data.boardData.data.s, null, 2)}</pre>
</section>
<style> <style>
section { .board-wrapper {
font-family: 'URW Gothic', sans-serif; display: flex;
text-align: center; flex-direction: column;
font-size: 2rem; align-items: center;
width: 100%;
margin: 0 auto;
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;
}
pre {
max-width: 100%;
white-space: pre-wrap;
word-wrap: break-word;
box-sizing: border-box;
overflow-x: auto;
}
.json-dump {
background: #222;
color: #0f0;
padding: 1rem;
border-radius: 4px;
font-size: 0.9rem;
max-height: 500px;
width: 95%;
margin: 1rem auto;
} }
</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,20 +16,49 @@ export const load: PageLoad = async ({ url }) => {
}); });
} }
if (locId) { const BoardLocation = LOCATIONS.find(locId);
const location = LOCATIONS.find(locId);
if (location) { if (!BoardLocation) {
title = location.n || location.t; error(404, {
} else { message: `Location (${locId.toUpperCase()}) not found`,
error(404, { owlCode: 'INVALID_LOCATION_CODE'
message: `Location (${locId.toUpperCase()}) not found`, });
owlCode: 'INVALID_LOCATION_CODE' }
const title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
try {
const boardData = await OwlClient.board.getByLocation(locId, fetch);
return {
title,
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' });
} }
return {
title,
location
};
}; };

View File

@@ -1,98 +1,104 @@
<script lang="ts"> <script lang="ts">
import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte'; import HeadcodeSearchCard from '$lib/components/ui/cards/HeadcodeSearchCard.svelte';
import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte'; import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte';
import Button from '$lib/components/ui/Button.svelte'; import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte';
import type { ApiPisObject } from '@owlboard/owlboard-ts'; import Button from '$lib/components/ui/Button.svelte';
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient'; import type { ApiPisObject } from '@owlboard/owlboard-ts';
import TocStyle from '$lib/components/ui/TocStyle.svelte'; import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import TocStyle from '$lib/components/ui/TocStyle.svelte';
let results = $state<ApiPisObject.PisObjects[]>([]); let results = $state<ApiPisObject.PisObjects[]>([]);
let resultsLoaded = $state<boolean>(false); let resultsLoaded = $state<boolean>(false);
let errorState = $state<{status: number, message: string} | null>(null); let errorState = $state<{ status: number; message: string } | null>(null);
async function handleStartEndSearch(start: string, end: string): Promise<void> { async function handleStartEndSearch(start: string, end: string): Promise<void> {
console.log(`PIS Search: ${start}-${end}`); console.log(`PIS Search: ${start}-${end}`);
errorState = null; errorState = null;
try { try {
const response = await OwlClient.pis.getByStartEndCrs(start, end); const response = await OwlClient.pis.getByStartEndCrs(start, end);
results = response.data || []; results = response.data || [];
} catch (e) { } catch (e) {
if (e instanceof ValidationError) { if (e instanceof ValidationError) {
errorState = { status: 400, message: e.message }; errorState = { status: 400, message: e.message };
} else if (e instanceof ApiError) { } else if (e instanceof ApiError) {
console.log(e) console.log(e);
errorState = { status: 20, message: e.message }; errorState = { status: 20, message: e.message };
} else { } else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` }; errorState = { status: 0, message: `Unknown Error: ${e.message}` };
} }
} finally { } finally {
resultsLoaded = true; resultsLoaded = true;
} }
} }
async function handleCodeSearch(code: string) { async function handleCodeSearch(code: string) {
console.log(`PIS Search: ${code}`); console.log(`PIS Search: ${code}`);
errorState = null; errorState = null;
try { try {
const response = await OwlClient.pis.getByCode(code); const response = await OwlClient.pis.getByCode(code);
results = response.data || [] results = response.data || [];
} catch (e) { } catch (e) {
if (e instanceof ValidationError) { if (e instanceof ValidationError) {
errorState = { status: 400, message: e.message }; errorState = { status: 400, message: e.message };
} else if (e instanceof ApiError) { } else if (e instanceof ApiError) {
console.log(e) console.log(e);
errorState = { status: 20, message: e.message }; errorState = { status: 20, message: e.message };
} else { } else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` }; errorState = { status: 0, message: `Unknown Error: ${e.message}` };
} }
} finally { } finally {
resultsLoaded = true; resultsLoaded = true;
} }
} }
function clearResults() { function clearResults() {
console.log('Clearing Results'); console.log('Clearing Results');
resultsLoaded = false; resultsLoaded = false;
results = []; results = [];
} }
</script> </script>
{#if !resultsLoaded} {#if !resultsLoaded}
<div class="card-container"> <div class="card-container">
<PisStartEndCard onsearch={handleStartEndSearch} /> <HeadcodeSearchCard />
<PisCode onsearch={handleCodeSearch} /> <PisStartEndCard onsearch={handleStartEndSearch} />
</div> <PisCode onsearch={handleCodeSearch} />
</div>
{:else} {:else}
<div class="result-container"> <div class="result-container">
{#if errorState} {#if errorState}
<span class="errCode">Error: {errorState.status}</span> <span class="errCode">Error: {errorState.status}</span>
<span class="errMsg">{errorState.message}</span> <span class="errMsg">{errorState.message}</span>
{:else} {:else if results.length}
{#if results.length} <h2 class="result-title">
<h2 class="result-title">{results.length} Result{#if results.length > 1}s{/if} found</h2> {results.length} Result{#if results.length > 1}s{/if} found
<table class="result-table"> </h2>
<thead> <table class="result-table">
<tr> <thead>
<th style="width:16%">TOC</th> <tr>
<th style="width:14%">Code</th> <th style="width:16%">TOC</th>
<th style="width:70%">Locations</th> <th style="width:14%">Code</th>
</tr></thead> <th style="width:70%">Locations</th>
{#each results as result} </tr></thead
<tbody><tr> >
<td><TocStyle toc={result.toc || ""} /></td> {#each results as result}
<td>{result.code}</td> <tbody
<td class="locations-row">{result.crsStops?.join(' ') || ''}</td> ><tr>
</tr></tbody> <td><TocStyle toc={result.toc || ''} /></td>
{/each} <td>{result.code}</td>
</table> <td class="locations-row">{result.crsStops?.join(' ') || ''}</td>
{:else} </tr></tbody
<p class="no-results">No matching results</p> >
{/if} {/each}
{/if} </table>
<div class="reset-button-container"> {:else}
<Button onclick={clearResults}>Reset</Button> <p class="no-results">No matching results</p>
</div> </div> {/if}
<div class="reset-button-container">
<Button onclick={clearResults}>Reset</Button>
</div>
</div>
{/if} {/if}
<style> <style>
@@ -105,54 +111,54 @@
padding: 20px 10px; padding: 20px 10px;
} }
.result-container { .result-container {
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
gap: 5px; gap: 5px;
justify-content: center; justify-content: center;
background: var(--color-accent); background: var(--color-accent);
border-radius: 15px; border-radius: 15px;
padding: 20px 0 20px 0; padding: 20px 0 20px 0;
margin: auto; margin: auto;
margin-top: 25px; margin-top: 25px;
margin-bottom: 25px; margin-bottom: 25px;
width: 90%; width: 90%;
max-width: 500px; max-width: 500px;
box-shadow: var(--shadow-std); box-shadow: var(--shadow-std);
} }
.result-title { .result-title {
color: var(--color-brand); color: var(--color-brand);
} }
.result-table { .result-table {
width: 90%; width: 90%;
max-width: 350px; max-width: 350px;
margin: auto; margin: auto;
text-align: center; text-align: center;
table-layout: fixed; table-layout: fixed;
border-collapse: separate; border-collapse: separate;
border-spacing: 0 20px; border-spacing: 0 20px;
font-weight: 400; font-weight: 400;
} }
.locations-row { .locations-row {
font-family:'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
text-align: left; text-align: left;
padding-left: 20px; padding-left: 20px;
} }
.errCode { .errCode {
color: rgb(255, 54, 54); color: rgb(255, 54, 54);
font-weight: 600; font-weight: 600;
font-size: 2rem; font-size: 2rem;
} }
.reset-button-container { .reset-button-container {
padding: 20px 0 3px 0; padding: 20px 0 3px 0;
} }
</style> </style>

View File

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

View File

@@ -0,0 +1,62 @@
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
import type { PageLoad } from './$types';
import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ fetch, url }) => {
const headcode = url.searchParams.get('h');
let dateParam = url.searchParams.get('d');
const toc = url.searchParams.get('t') || '';
const date: string | Date = dateParam === '' || dateParam === null ? new Date() : dateParam;
if (!headcode) {
throw error(400, {
message: 'Headcode not provided',
owlCode: 'INVALID_DATA'
});
}
// 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);
results = response.data;
return {
title: headcode.toUpperCase(),
results: results
};
} catch (e: unknown) {
if (e instanceof ValidationError) {
throw error(400, {
message: e.message,
owlCode: 'VALIDATION_ERROR'
});
} else if (e instanceof ApiError) {
// Check if NO_RESULTS error, and return empty array if that is the case
if (e.code === 'NOT_FOUND') {
return {
title: headcode.toUpperCase(),
results: []
};
} else {
throw error(e.status, {
message: e.message,
owlCode: 'API_ERROR'
});
}
} else if (e instanceof Error) {
throw error(500, {
message: e.message,
owlCode: 'GEN_ERROR'
});
} else {
throw error(500, {
message: 'Unexpected error',
owlCode: 'UNKNOWN_ERR'
});
}
}
};

View File

@@ -1,106 +0,0 @@
[
{"n":"Manchester Piccadilly","t":"MANPICD","c":"MAN","s":"manchester piccadilly man manpicd"},
{"n":"Manchester Victoria","t":"MCV","c":"MCV","s":"manchester victoria mcv"},
{"n":"Manchester Oxford Road","t":"MCOR","c":"MCO","s":"manchester oxford road mco mcor"},
{"n":"Manchester Airport","t":"MANAPTL","c":"MIA","s":"manchester airport mia manaptl"},
{"n":"London Euston","t":"EUSTON","c":"EUS","s":"london euston eus euston"},
{"n":"London Kings Cross","t":"KGX","c":"KGX","s":"london kings cross kgx kingscross"},
{"n":"London St Pancras International","t":"STPANCR","c":"STP","s":"london st pancras international stp stpancr"},
{"n":"London Paddington","t":"PADTON","c":"PAD","s":"london paddington pad padton"},
{"n":"London Victoria","t":"VIC","c":"VIC","s":"london victoria vic"},
{"n":"London Liverpool Street","t":"LIVST","c":"LST","s":"london liverpool street lst livst"},
{"n":"London Bridge","t":"LONGBR","c":"LBG","s":"london bridge lbg longbr"},
{"n":"Birmingham New Street","t":"BHMNEWST","c":"BHM","s":"birmingham new street bhm bhmnewst bham"},
{"n":"Birmingham Moor Street","t":"BHMMRS","c":"BMO","s":"birmingham moor street bmo bhmmrs"},
{"n":"Birmingham Snow Hill","t":"BHMSH","c":"BSW","s":"birmingham snow hill bsw bhmsh"},
{"n":"Leeds","t":"LEEDS","c":"LDS","s":"leeds lds"},
{"n":"York","t":"YORK","c":"YRK","s":"york yrk"},
{"n":"Liverpool Lime Street","t":"LIVLST","c":"LIV","s":"liverpool lime street liv livlst"},
{"n":"Liverpool Central","t":"LIVCEN","c":"LVC","s":"liverpool central lvc livcen"},
{"n":"Sheffield","t":"SHEFFLD","c":"SHF","s":"sheffield shf sheffld"},
{"n":"Nottingham","t":"NOTTM","c":"NOT","s":"nottingham not nottm"},
{"n":"Derby","t":"DERBY","c":"DBY","s":"derby dby"},
{"n":"Leicester","t":"LEICEST","c":"LEI","s":"leicester lei leicest"},
{"n":"Bristol Temple Meads","t":"BRSTLTM","c":"BRI","s":"bristol temple meads bri brstltm"},
{"n":"Cardiff Central","t":"CDFCEN","c":"CDF","s":"cardiff central cdf cdfcen"},
{"n":"Newcastle","t":"NEWCAST","c":"NCL","s":"newcastle ncl newcast"},
{"n":"Edinburgh Waverley","t":"EDINBUR","c":"EDB","s":"edinburgh waverley edb edinbur"},
{"n":"Glasgow Central","t":"GLASCEN","c":"GLC","s":"glasgow central glc glascen"},
{"n":"Glasgow Queen Street","t":"GLAQS","c":"GLQ","s":"glasgow queen street glq glaqs"},
{"n":"Reading","t":"READING","c":"RDG","s":"reading rdg"},
{"n":"Oxford","t":"OXFORD","c":"OXF","s":"oxford oxf"},
{"n":"Cambridge","t":"CAMBRDG","c":"CBG","s":"cambridge cbg cambrdg"},
{"n":"Peterborough","t":"PBOUGH","c":"PBO","s":"peterborough pbo pbough"},
{"n":"Doncaster","t":"DONCAST","c":"DON","s":"doncaster don doncast"},
{"n":"Crewe","t":"CREWE","c":"CRE","s":"crewe cre"},
{"n":"Preston","t":"PRESTON","c":"PRE","s":"preston pre"},
{"n":"Blackpool North","t":"BPLNOR","c":"BPN","s":"blackpool north bpn bplnor"},
{"n":"Bolton","t":"BOLTON","c":"BON","s":"bolton bon"},
{"n":"Huddersfield","t":"HUDDSFD","c":"HUD","s":"huddersfield hud huddsfd"},
{"n":"Stockport","t":"STOCKPT","c":"SPT","s":"stockport spt stockpt"},
{"n":"Wigan North Western","t":"WIGNW","c":"WGN","s":"wigan north western wgn wignw"},
{"n":"Bath Spa","t":"BATHSPA","c":"BTH","s":"bath spa bth bathspa"},
{"n":"Exeter St Davids","t":"EXD","c":"EXD","s":"exeter st davids exd"},
{"n":"Plymouth","t":"PLYMTH","c":"PLY","s":"plymouth ply plymth"},
{"n":"Truro","t":"TRURO","c":"TRU","s":"truro tru"},
{"n":"Aberdeen","t":"ABERDN","c":"ABD","s":"aberdeen abd aberdn"},
{"n":"Inverness","t":"INVNESS","c":"INV","s":"inverness inv invness"},
{"n":"Perth","t":"PERTH","c":"PTH","s":"perth pth"},
{"n":"Dundee","t":"DUNDEE","c":"DEE","s":"dundee dee"},
{"n":"Stirling","t":"STIRLNG","c":"STG","s":"stirling stg stirlng"},
{"n":"Falkirk Grahamston","t":"FLKGRA","c":"FKG","s":"falkirk grahamston fkg flkgra"},
{"n":"Motherwell","t":"MOTHRWL","c":"MTH","s":"motherwell mth mothrwl"},
{"n":"Paisley Gilmour Street","t":"PAISGL","c":"PYG","s":"paisley gilmour street pyg paisgl"},
{"n":"Greenock Central","t":"GRNOCK","c":"GKC","s":"greenock central gkc grnock"},
{"n":"Ayr","t":"AYR","c":"AYR","s":"ayr"},
{"n":"Carlisle","t":"CARLISL","c":"CAR","s":"carlisle car carlisl"},
{"n":"Penrith North Lakes","t":"PNRITH","c":"PNR","s":"penrith north lakes pnr pnrith"},
{"n":"Kendal","t":"KENDAL","c":"KEN","s":"kendal ken"},
{"n":"Windermere","t":"WNDRMRE","c":"WDM","s":"windermere wdm wndrme"},
{"n":"Lancaster","t":"LANCAST","c":"LAN","s":"lancaster lan lancast"},
{"n":"Chester","t":"CHESTER","c":"CTR","s":"chester ctr"},
{"n":"Warrington Bank Quay","t":"WRRGBQ","c":"WBQ","s":"warrington bank quay wbq wrrgbq"},
{"n":"Warrington Central","t":"WRRGCN","c":"WAC","s":"warrington central wac wrrgcn"},
{"n":"Runcorn","t":"RUNCORN","c":"RUN","s":"runcorn run"},
{"n":"Widnes","t":"WIDNES","c":"WID","s":"widnes wid"},
{"n":"Southport","t":"STHPORT","c":"SOP","s":"southport sop sthport"},
{"n":"Ormskirk","t":"ORMSKRK","c":"OMS","s":"ormskirk oms ormskrk"},
{"n":"Blackburn","t":"BLKBRN","c":"BBN","s":"blackburn bbn blkbrn"},
{"n":"Burnley Manchester Road","t":"BURNMR","c":"BYM","s":"burnley manchester road bym burnmr"},
{"n":"Rochdale","t":"ROCHDAL","c":"RCD","s":"rochdale rcd rochdal"},
{"n":"Oldham Mumps","t":"OLDMUM","c":"OMM","s":"oldham mumps omm oldmum"},
{"n":"Ashton-under-Lyne","t":"ASHTON","c":"AHN","s":"ashton under lyne ahn ashton"},
{"n":"Stalybridge","t":"STALYBG","c":"SYB","s":"stalybridge syb stalybg"},
{"n":"Macclesfield","t":"MACCLFD","c":"MAC","s":"macclesfield mac macclfd"},
{"n":"Congleton","t":"CONGLTN","c":"CNG","s":"congleton cng conglt"},
{"n":"Stoke-on-Trent","t":"STOKETR","c":"SOT","s":"stoke on trent sot stoketr"},
{"n":"Stafford","t":"STAFFRD","c":"STA","s":"stafford sta staffrd"},
{"n":"Tamworth","t":"TAMWTH","c":"TAM","s":"tamworth tam tamwth"},
{"n":"Nuneaton","t":"NUNEATN","c":"NUN","s":"nuneaton nun nuneatn"},
{"n":"Coventry","t":"COVNTRY","c":"COV","s":"coventry cov covntry"},
{"n":"Rugby","t":"RUGBY","c":"RUG","s":"rugby rug"},
{"n":"Milton Keynes Central","t":"MKCEN","c":"MKC","s":"milton keynes central mkc mkcen"},
{"n":"Birmingham Washwood Heath Junction","t":"BWHJCT","c":"","s":"birmingham washwood heath junction bwhjct"},
{"n":"Manchester Trafford Park Yard","t":"MTRYD","c":"","s":"manchester trafford park yard mtryd"},
{"n":"London Willesden Junction","t":"WLSDJCT","c":"","s":"london willesden junction wlsdjct"},
{"n":"Leeds Neville Hill Depot","t":"NVHLDP","c":"","s":"leeds neville hill depot nvhldp"},
{"n":"York Holgate Junction","t":"YHGJCT","c":"","s":"york holgate junction yhgjct"},
{"n":"Crewe Basford Hall Junction","t":"CBHJCT","c":"","s":"crewe basford hall junction cbhjct"},
{"n":"Doncaster Decoy Sidings","t":"DCDSID","c":"","s":"doncaster decoy sidings dcdsid"},
{"n":"Liverpool Edge Hill Yard","t":"LEHYD","c":"","s":"liverpool edge hill yard lehyd"},
{"n":"Bristol East Junction","t":"BREJCT","c":"","s":"bristol east junction brejct"},
{"n":"Glasgow Polmadie Depot","t":"GLPDEP","c":"","s":"glasgow polmadie depot glpdep"},
{"n":"Newcastle Manors Junction","t":"NCMJCT","c":"","s":"newcastle manors junction ncmjct"},
{"n":"Edinburgh Haymarket Sidings","t":"EHSID","c":"","s":"edinburgh haymarket sidings ehsid"},
{"n":"Reading South Junction","t":"RDSJCT","c":"","s":"reading south junction rdsjct"},
{"n":"Oxford Rewley Road Depot","t":"OXRDEP","c":"","s":"oxford rewley road depot oxrdep"},
{"n":"Cambridge Coldham Lane Junction","t":"CCLJCT","c":"","s":"cambridge coldham lane junction ccljct"},
{"n":"Watford North Junction","t":"WFNJCT","c":"","s":"watford north junction wfnjct"},
{"n":"Luton Airport Sidings","t":"LUTSID","c":"","s":"luton airport sidings lutsid"},
{"n":"Stevenage Hitchin Junction","t":"STHJC","c":"","s":"stevenage hitchin junction sthjc"},
{"n":"Chelmsford New Hall Junction","t":"CHNJCT","c":"","s":"chelmsford new hall junction chnjct"},
{"n":"","t":"BPWY532","c":"","s":"bpwy532"},
{"n":"Ipswich Derby Road Depot","t":"IPDRDP","c":"","s":"ipswich derby road depot ipdrdp"},
{"n":"Rhoose Cardiff International Airport","c":"RIA","t":"RHOOSE","s":"rhoose cardiff international airport ria"},
{"n":"Southampton Airport Parkway","c":"SOA","t":"SOTAPT","s":"southampton airport parkway soa sotapt"}
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.