Compare commits

...

32 Commits

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

2929
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

5
src/app.d.ts vendored
View File

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

@@ -55,14 +55,14 @@
width: fit-content;
flex-shrink: 0;
padding: 0 1.2rem;
min-width: 90px;
min-width: 40px;
height: 36px;
border-radius: 20px;
border: none;
box-shadow: var(--shadow-std);
box-shadow: var(--shadow-small);
font-family: 'URW Gothic', sans-serif;
font-size: 0.93rem;
font-weight: 600;
font-weight: 400;
letter-spacing: 0.05ch;
transition:
all 0.1s ease,
@@ -72,6 +72,7 @@
.accent {
background-color: var(--color-accent);
color: var(--color-title);
font-weight: 600;
}
.brand {

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

@@ -33,9 +33,21 @@
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;
// Alphabetical Sort
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 type { HTMLInputAttributes } from 'svelte/elements';
import { IconChevronRightFilled } from '@tabler/icons-svelte';
interface Props extends HTMLInputAttributes {
value?: string;
label?: string;
@@ -9,6 +11,7 @@
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
error?: string;
uppercase?: boolean;
onsubmit?: (val: string) => void | Promise<void>;
[key: string]: any;
}
@@ -19,10 +22,18 @@
type = 'text',
error = '',
uppercase = false,
onsubmit,
...rest
}: Props = $props();
let isFocussed = $state(false);
const handleSubmit = (e: Event) => {
e.preventDefault();
if (onsubmit && value) {
onsubmit(value);
}
};
</script>
<div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}>
@@ -30,6 +41,7 @@
<label for="adaptive-input">{label}</label>
{/if}
<form onsubmit={handleSubmit} class="input-container">
<input
id="adaptive-input"
class:all-caps={uppercase}
@@ -41,6 +53,19 @@
{...rest}
/>
{#if onsubmit}
<button
type="submit"
class="submit-icon"
transition:fade
disabled={!value}
aria-label="Submit"
>
<IconChevronRightFilled />
</button>
{/if}
</form>
{#if error}
<span class="error-message" transition:fade>{error}</span>
{/if}
@@ -50,29 +75,80 @@
.input-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
gap: 0.4rem;
width: 100%;
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 {
font-size: 0.9rem;
font-weight: 400;
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 {
width: 100%;
background: transparent;
min-height: 40px;
padding: 0 16px;
background-color: var(--color-title);
border: 2px solid transparent;
border-radius: 20px;
height: 100%;
line-height: normal;
padding: 0 48px;
border: none;
color: var(--color-bg-dark);
font-size: 1.2rem;
transition: all 0.2s ease-in-out;
outline: none;
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 {

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

@@ -8,6 +8,7 @@
let code = $derived(toc.toUpperCase());
</script>
<!-- SPACE MONO for the font? -->
<div class="toc-container {code}">
{code}
</div>
@@ -20,8 +21,22 @@
justify-content: center;
padding: 2px 8px;
font-weight: 800;
font-size: 1.1rem;
background-color: #333;
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 {
@@ -42,6 +57,12 @@
color: #ffffff;
}
.GN {
/* Great Northern */
background-color: fuchsia;
color: rgb(229, 229, 229);
}
.SW {
/* South Western Railway */
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 {
background: var(--color-accent);
position: relative;
border-radius: 20px;
border-radius: 12px;
overflow: visible;
width: 95%;
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

@@ -22,7 +22,7 @@
<div
class="btn-container"
animate:flip={{ duration: flipDuration }}
transition:fade|global={{ duration: 300 }}
in:fade|global={{ duration: 200 }}
>
<Button href={`/board?loc=${station.c}`}
><span class="stn-name">{station.n}</span></Button
@@ -44,7 +44,7 @@
width: 90%;
min-height: 98px;
margin: auto;
padding: 10px 0 10px 0;
padding: 10px 0 0 0;
}
.stations-flex {

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

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

View File

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

View File

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

View File

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

View File

@@ -7,10 +7,16 @@ class NearestStationsState {
loading = $state(true);
error = $state<string | null>(null);
private initGeoConfig: PositionOptions = {
enableHighAccuracy: false,
timeout: 500,
maximumAge: Infinity
};
private geoConfig: PositionOptions = {
enableHighAccuracy: false,
timeout: 30000,
maximumAge: 120000
timeout: 20000,
maximumAge: 30000
};
constructor() {
@@ -24,7 +30,7 @@ class NearestStationsState {
navigator.geolocation.getCurrentPosition(
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
(err) => this.handleError(err),
this.geoConfig
this.initGeoConfig
);
}

View File

@@ -44,7 +44,126 @@
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 {
/* Fixed Heights */
--header-height: 80px;
--nav-height: 60px;
/* Brand Colours */
--color-brand: #4fd1d1;
--color-accent: #3c6f79;
@@ -55,8 +174,39 @@
/* Shadows */
--color-shadow: hsla(210, 20%, 5%, 0.35);
--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-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 {

View File

@@ -28,6 +28,20 @@ class LocationStore {
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();

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();

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,21 @@
import { page } from '$app/state';
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';
</script>
<div class="error-wrapper">
{#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>
<p class="error-message">
@@ -20,7 +29,11 @@
</div>
{/if}
{#if $page.error?.owlCode === 'NETWORK_DISCONNECTED'}
<Button onclick={() => window.location.reload()} color={'accent'}>Retry</Button>
{:else}
<Button href={'/'} color={'accent'}>Return to Home</Button>
{/if}
</div>
<style>

View File

@@ -2,10 +2,13 @@
import { page } from '$app/state';
import { slide, fade } from 'svelte/transition';
import { onMount } from 'svelte';
import { navigating } from '$app/state';
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 logoText from '$lib/assets/round-logo-text.svg';
@@ -78,7 +81,13 @@
</header>
<main>
<TimezoneWarning />
{#if navigating && navigating.to}
<Loading />
{:else}
{@render children()}
{/if}
</main>
<nav bind:clientWidth={navWidth}>
@@ -138,14 +147,14 @@
<h1 class="viewport-guard-title">Narrow Gauge Detected</h1>
<p>
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>
</div>
<style>
header {
top: 0;
height: 80px;
height: var(--header-height);
box-shadow: var(--shadow-std);
}
.logo-link {
@@ -192,13 +201,12 @@
min-height: 100dvh;
box-sizing: border-box;
background-color: var(--color-bg-dark);
background-image: radial-gradient(var(--color-bg-dark), var(--color-bg-light));
}
nav {
display: flex;
bottom: 0;
height: 60px;
height: var(--nav-height);
box-shadow: var(--shadow-up);
}
@@ -311,7 +319,7 @@
display: none;
}
@media (max-width: 299px) {
@media (max-width: 319px) {
.viewport-guard {
display: block;
position: fixed;

View File

@@ -1,11 +1,15 @@
<script lang="ts">
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>
<div class="card-container">
<LocationBoardCard />
<HeadcodeSearchCard />
<NearbyStationsCard />
<QuickLinksCard />
</div>
<style>

View File

@@ -1,13 +1,97 @@
<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/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 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">
<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>
section {
font-family: 'URW Gothic', sans-serif;
text-align: center;
font-size: 2rem;
.board-wrapper {
display: flex;
height: calc(100dvh - var(--nav-height) - var(--header-height));
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%;
margin: auto;
padding-top: 25px;
max-width: 500px;
max-width: 700px;
margin: 10px auto;
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>

View File

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

View File

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

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.