Compare commits

..

29 Commits

Author SHA1 Message Date
1105a9f7c5 Add extra message to +error.svelte to handle, and clarify 'OFFLINE' error. 2026-05-14 19:43:22 +01:00
25b64edabd Add features:
- manifest.json with PNG icon set.
 - reset option against QuickLinks storage
 - 'Preferences' store, ready for preferences page - including location boolean... Important!
 - Minor service-worker improvements for better offline fallbacks
2026-05-14 19:39:05 +01:00
bc56e66178 Add initlal service-worker with basic offline handling. Manifest is not yet present. 2026-05-13 20:47:01 +01:00
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
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
58 changed files with 4602 additions and 625 deletions

2929
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.202603302258",
"@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,8 @@
"vitest-browser-svelte": "^2.0.2" "vitest-browser-svelte": "^2.0.2"
}, },
"dependencies": { "dependencies": {
"@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"
} }
} }

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

@@ -3,12 +3,14 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="title" content="OwlBoard | Your fastest route to live and reference data" /> <meta name="title" content="OwlBoard | Your fastest route to live and reference data" />
<meta <meta
name="description" name="description"
content="Live station departures, Live train tracking, PIS Codes & more" content="Live station departures, Live train tracking, PIS Codes & more"
/> />
<!-- PWA Requirements -->
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" />
<meta name="theme-color" content="#4fd1d1" /> <meta name="theme-color" content="#4fd1d1" />
<link rel="canonical" href="https://owlboard.info" /> <link rel="canonical" href="https://owlboard.info" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" viewBox="0 0 562.1 562.1"><defs><radialGradient xlink:href="#a" id="b" cx="256" cy="256" r="254" fx="256" fy="256" gradientTransform="translate(27 27)" gradientUnits="userSpaceOnUse"/><linearGradient id="a"><stop offset="0" stop-color="#2b343c"/><stop offset="1" stop-color="#404c55"/></linearGradient></defs><path fill="url(#b)" d="M694.3 283a411.3 394 0 0 1-410.7 394 411.3 394 0 0 1-412-392.8 411.3 394 0 0 1 409.5-395.1 411.3 394 0 0 1 413.2 391.4" transform="translate(-2 -2)"/><path fill="#4fd1d1" d="M281 81a200 200 0 0 0-153.4 71.8 120 120 0 0 0 50.5 25.8c9 2.3 8.9 2.3 15.6-.6 50-21.6 124.4-21.6 175.2-.1 13.3 5.6 44.6-6.5 65.9-24.8a200 200 0 0 0-153.8-72m-161.9 82.7a200 200 0 0 0-38 117.3 200 200 0 0 0 200 200 200 200 0 0 0 200-200 200 200 0 0 0-37.8-116.8 96 96 0 0 1-17.7 46.6l-2.8 4 1.3 1.8c73.3 99.2-14.3 222.2-116.7 163.6-2.5-1.4-4.7-2.4-5-2.1s-10.5 19.6-18 34c-3.4 6.5-2.7 6.6-6.3-.3-9.2-17.5-18-34-18.3-34-.1 0-2.3 1-4.7 2.5-101.3 58-190.7-65.7-117.4-162.4q2.3-3 1.8-3.6a102 102 0 0 1-20.4-50.6m86.5 57q-14.6 0-29.8 7.5C108 261.8 143.1 366.4 217.5 352a68 68 0 0 0 43.3-28.4c2.4-3.4 3.3-4.2 3.7-3.7l8.5 14.6c9.4 16.3 8 15 11.4 9 5.1-9 13.7-23.5 14-24 .3-.2 1.9 1.8 3.6 4.3 31 45.5 101.6 35.6 118.3-16.5a66.4 66.4 0 0 0-83.9-83c-23.3 7.7-38.7 24.4-53.8 58.4l-1.3 3-2.7-6c-17-38.5-43.7-59.5-73-59.2m147.2 34.7a32 32 0 0 1 23.7 9.5c22.8 22.8.7 60.8-30.9 53.1a32 32 0 0 1-23.2-37.3 31.5 31.5 0 0 1 30.4-25.3m-145.8 0c16.4-.8 32.7 11 33.9 30.1a32.2 32.2 0 0 1-40.1 32.5 31.5 31.5 0 0 1-15-52.9 32 32 0 0 1 21.2-9.6m12 9.6q-3.3 0-5.8 3c-6.3 7.4 2.5 17.7 11 13a9 9 0 0 0 4.2-7.5c0-5.4-4.8-8.9-9.4-8.5m123.1 0c-6 .5-9.6 6.5-7 12.3 2 4.5 8.3 6.3 12.2 3.6 7.2-4.9 4.7-15.3-4-15.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -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

@@ -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

@@ -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,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,6 +8,7 @@
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>
@@ -20,8 +21,22 @@
justify-content: center; justify-content: center;
padding: 2px 8px; padding: 2px 8px;
font-weight: 800; font-weight: 800;
font-size: 1.1rem;
background-color: #333; background-color: #333;
color: #fff; color: #fff;
font-family: 'Courier New', Courier, monospace;
}
.AW {
/* Transport for Wales */
background: red;
color: white;
}
.LM {
/* West Midlands Trains */
background: rgb(176, 115, 1);
color: white;
} }
.GW { .GW {
@@ -42,6 +57,12 @@
color: #ffffff; color: #ffffff;
} }
.GN {
/* Great Northern */
background-color: fuchsia;
color: rgb(229, 229, 229);
}
.SW { .SW {
/* South Western Railway */ /* South Western Railway */
background-color: #2a3389; background-color: #2a3389;

View File

@@ -0,0 +1,570 @@
<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';
import { estClass } from '$lib/utils/time';
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 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

@@ -44,7 +44,7 @@
width: 90%; width: 90%;
min-height: 98px; min-height: 98px;
margin: auto; margin: auto;
padding: 10px 0 10px 0; padding: 10px 0 0 0;
} }
.stations-flex { .stations-flex {

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.2rem;
}
}
.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

@@ -0,0 +1,43 @@
import { browser } from '$app/environment';
export interface Preferences {
locationEnabled: boolean;
}
const STORAGE_KEY = 'ob_pref';
const DEFAULT_PREFS: Preferences = {
locationEnabled: false
};
function loadPrefs(): Preferences {
if (!browser) return DEFAULT_PREFS;
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return DEFAULT_PREFS;
try {
return { ...DEFAULT_PREFS, ...JSON.parse(stored) };
} catch {
return DEFAULT_PREFS;
}
}
class PreferencesStore {
current = $state<Preferences>(loadPrefs());
constructor() {
$effect.root(() => {
$effect(() => {
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.current));
}
});
});
}
toggleLocation(enabled: boolean) {
this.current.locationEnabled = enabled;
}
}
export const prefs = new PreferencesStore();

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;
@@ -15,7 +15,11 @@ class QuickLinksService {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const saved = localStorage.getItem('ql'); const saved = localStorage.getItem('ql');
if (saved) { if (saved) {
this.#links = JSON.parse(saved); try {
this.#links = JSON.parse(saved);
} catch {
this.#links = [];
}
} }
} }
} }
@@ -53,7 +57,16 @@ class QuickLinksService {
this.#links = sorted.slice(0, MAX_ENTRIES); this.#links = sorted.slice(0, MAX_ENTRIES);
localStorage.setItem('ql', JSON.stringify(this.#links)); if (typeof window !== 'undefined') {
localStorage.setItem('ql', JSON.stringify(this.#links));
}
}
reset() {
this.#links = [];
if (typeof window !== 'undefined') {
localStorage.removeItem('ql');
}
} }
} }

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

@@ -0,0 +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, 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(',', '');
}
/**
* 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

@@ -2,12 +2,22 @@
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?? -->
<span>OFFLINE!</span>
<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 +30,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,10 +2,11 @@
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 { browser } from '$app/environment';
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 TimezoneWarning from '$lib/components/ui/TimezoneWarning.svelte';
import '$lib/global.css'; import '$lib/global.css';
@@ -16,7 +17,19 @@
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte'; import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
onMount(() => LOCATIONS.init()); onMount(async () => {
LOCATIONS.init();
if (browser && 'serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js', {
type: 'module'
});
console.info('OwlBoard Service Worker registered', registration.scope);
} catch (error) {
console.error('Service Worker installation failed: ', error);
}
}
});
let { children } = $props(); let { children } = $props();
@@ -81,7 +94,12 @@
<main> <main>
<TimezoneWarning /> <TimezoneWarning />
{@render children()}
{#if navigating && navigating.to}
<Loading />
{:else}
{@render children()}
{/if}
</main> </main>
<nav bind:clientWidth={navWidth}> <nav bind:clientWidth={navWidth}>
@@ -141,14 +159,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 {
@@ -195,13 +213,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);
} }
@@ -314,7 +331,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,11 +1,13 @@
<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 NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte';
import QuickLinksCard from '$lib/components/ui/cards/QuickLinksCard.svelte'; import QuickLinksCard from '$lib/components/ui/cards/QuickLinksCard.svelte';
</script> </script>
<div class="card-container"> <div class="card-container">
<LocationBoardCard /> <LocationBoardCard />
<HeadcodeSearchCard />
<NearbyStationsCard /> <NearbyStationsCard />
<QuickLinksCard /> <QuickLinksCard />
</div> </div>

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); error(404, {
message: `Location (${locId.toUpperCase()}) not found`,
owlCode: 'INVALID_LOCATION_CODE'
});
}
const title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
if (BoardLocation) { try {
title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep'; const boardData = await OwlClient.board.getByLocation(locId, fetch);
} else {
error(404, { return {
message: `Location (${locId.toUpperCase()}) not found`, title,
owlCode: 'INVALID_LOCATION_CODE' 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,
BoardLocation
};
}; };

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import HeadcodeSearchCard from '$lib/components/ui/cards/HeadcodeSearchCard.svelte';
import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte'; import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte';
import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte'; import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
@@ -60,6 +61,7 @@
{#if !resultsLoaded} {#if !resultsLoaded}
<div class="card-container"> <div class="card-container">
<HeadcodeSearchCard />
<PisStartEndCard onsearch={handleStartEndSearch} /> <PisStartEndCard onsearch={handleStartEndSearch} />
<PisCode onsearch={handleCodeSearch} /> <PisCode onsearch={handleCodeSearch} />
</div> </div>

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'
});
}
}
};

83
src/service-worker.ts Normal file
View File

@@ -0,0 +1,83 @@
/// <reference types="@sveltejs/kit" />
/// <reference lib="webworker" />
import type { ApiEnvelope } from '@owlboard/api-schema-types';
import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope;
const CACHE_NAME = `owlboard-cache-${version}`;
const ASSETS = [
...build,
...files.filter((file) => !file.endsWith('manifest.json') && !file.startsWith('/icons/')),
'/'
];
sw.addEventListener('install', (event) => {
sw.skipWaiting();
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
});
sw.addEventListener('activate', (event) => {
event.waitUntil(
Promise.all([
caches.keys().then((keys) => {
return Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
);
}),
sw.clients.claim()
])
);
});
sw.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
// Uncached static assets
if (url.pathname.endsWith('manifest.json') || url.pathname.includes('/icons/')) {
event.respondWith(fetch(request));
return;
}
// 1. Static Assets (Cache-First)
if (ASSETS.includes(url.pathname) || url.pathname.startsWith('/_app/')) {
event.respondWith(caches.match(request).then((res) => res || fetch(request)));
return;
}
// 2. Offline API Fallback (Network-First)
if (url.pathname.startsWith('/api') || url.hostname.includes('api')) {
event.respondWith(
fetch(request).catch(() => {
const errorObject: ApiEnvelope.Envelope = {
e: {
code: 'NETWORK_DISCONNECTED',
msg: 'Cannot connect to the OwlBoard server'
},
t: Math.floor(Date.now() / 1000)
};
return new Response(JSON.stringify(errorObject), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
})
);
return;
}
// 3. Navigation Fallback (The Offline 404 Catch-All)
// This catches top-level page navigations and hard refreshes
if (request.mode === 'navigate' || request.headers.get('accept')?.includes('text/html')) {
event.respondWith(
fetch(request).catch(() => {
// The network request failed entirely (offline).
// Serve the cached root shell so SvelteKit can boot.
return caches.match('/') as Promise<Response>;
})
);
return;
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
static/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
static/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

62
static/manifest.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "OwlBoard",
"short_name": "OwlBoard",
"description": "Real-time UK rail data and operational tools for staff and enthusiasts.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": [
"window-controls-overlay",
"standalone",
"minimal-ui"
],
"background_color": "#404c55",
"theme_color": "#4fd1d1",
"orientation": "portrait",
"categories": [
"transportation",
"utilities"
],
"dir": "ltr",
"lang": "en-GB",
"prefer_related_applications": false,
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Find PIS",
"short_name": "PIS",
"description": "Find PIS Codes fast",
"url": "/pis",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192"
}
]
}
],
"handle_links": "preferred",
"launch_handler": {
"client_mode": "focus-existing"
}
}

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.