Compare commits

...

22 Commits

Author SHA1 Message Date
26e40c5bf6 Add loading state (initial)
Add rollover actions on train service
Add some additional toc styles
2026-04-30 01:26:29 +01:00
a746a1eac2 Add train service boxes... not yet expanding! 2026-04-28 20:28:29 +01:00
68af07b9bd Adjust error page & the error handling of the trains load function. Intead of throwing error on no-results, an empty array is passed to the page. A 'no-results' component will roughly echo the error pages not-found. 2026-04-28 18:13:05 +01:00
16d929fad1 Prepare changes to error code handling 2026-04-28 17:45:59 +01:00
5bbffcecb8 Add 'no-results' image to make the error page less 'abrupt' for a simple no-results. 2026-04-28 17:24:04 +01:00
5ead4f8296 Add no-date image for error page 2026-04-28 14:16:19 +01:00
abb8663766 Add train headcode search 2026-04-27 23:57:04 +01:00
3225b60140 Update API Client version & run npm audit fix 2026-04-27 22:33:53 +01:00
f7b1b7fe0d Update geohash (location services) logic to improve time to first hit 2026-04-21 20:13:53 +01:00
8c0d385772 Add QuickLinks based on 'frecency' pattern 2026-04-21 19:54:00 +01:00
a07315cec2 Fix search priority:
1. Match to exact CRS
 2. Match to exact Name
 3. Match to 'Name startsWith'
 4. Match any with valid CRS
 5. Match alphabetically
2026-04-20 23:23:50 +01:00
f3393f3c07 Add timezone warning to top of +layout.svelte conditionally displayed when the users device is not in the Europe/London timezone 2026-04-05 00:22:36 +01:00
b649af1925 Adjust button styling to improve appearance of text 2026-03-31 20:01:20 +01:00
aa1a989139 Remove fade-out 2026-03-31 00:11:09 +01:00
304b523127 Update button style, to improve looks while adding a large enough touch-target 2026-03-31 00:05:17 +01:00
777519ff5d Add NearestStations Card & Location monitor 2026-03-30 23:34:12 +01:00
4a969e626c Ensure dependencies are listed properly 2026-03-25 10:54:21 +00:00
3e1b7ea5d5 Disable source-maps for output code 2026-03-25 10:51:37 +00:00
fd213d6340 Refine filtering logic
Remove api-types package as the types are re-exported by the TS Client package
2026-03-25 10:50:26 +00:00
3eceddf20a Bump OwlBoard-TS 2026-03-25 10:24:58 +00:00
1d461780ab Add proper capitalization to page titles and location names. 2026-03-24 15:44:50 +00:00
ec4dd5dd3b Move 'LocationFilter' fetching to API Lib package 2026-03-24 12:47:39 +00:00
34 changed files with 1174 additions and 449 deletions

75
package-lock.json generated
View File

@@ -8,12 +8,12 @@
"name": "web-pwa",
"version": "0.0.1",
"dependencies": {
"@owlboard/owlboard-ts": "^3.0.0-dev.20260428T2753",
"@tabler/icons-svelte": "^3.40.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2",
"@owlboard/owlboard-ts": "^3.0.0-dev.20260319T2004",
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
@@ -780,20 +780,19 @@
}
},
"node_modules/@owlboard/api-schema-types": {
"version": "3.0.1-alpha3",
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fapi-schema-types/-/3.0.1-alpha3/api-schema-types-3.0.1-alpha3.tgz",
"integrity": "sha512-5CVm1k/C++/VrtAw4NkvclDunH+RmYLnDZZMSWTM1mm+WlEVnmD+MVnTgC/FhcsAmsNHV8swm66RCqkCuhbOnA==",
"dev": true,
"version": "3.0.3-alpha1",
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fapi-schema-types/-/3.0.3-alpha1/api-schema-types-3.0.3-alpha1.tgz",
"integrity": "sha512-UWe2nbJWb2B/LuZW1UXHJ2lpqOGwugiXTa4G6X5xLiaws3ISEdciweorX8kr2/JAz5+iFYIe1xXRFAWsFtpn/w==",
"license": "MIT"
},
"node_modules/@owlboard/owlboard-ts": {
"version": "3.0.0-dev.20260319T2004",
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fowlboard-ts/-/3.0.0-dev.20260319T2004/owlboard-ts-3.0.0-dev.20260319t2004.tgz",
"integrity": "sha512-pphq1/l/8eOH4C0O7ocwBOUzt0HkCWGUlhy1itzKnQbmog7oPUEdyaxzS4Evw8onLsxZwkyqsLAyK7okYi+4XA==",
"dev": true,
"version": "3.0.0-dev.20260428T2753",
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fowlboard-ts/-/3.0.0-dev.20260428T2753/owlboard-ts-3.0.0-dev.20260428t2753.tgz",
"integrity": "sha512-8kZ6oP8MFz+1FHyYZS7lAYSk0Kamq80KAsIcef32/lZTT0GhIjyjZcabWZ0uipAYYsKvGqdm1wtxDrtgKPN/DQ==",
"license": "GPL-3.0",
"dependencies": {
"@owlboard/api-schema-types": "^3.0.1-alpha3"
"@owlboard/api-schema-types": "^3.0.3-alpha1",
"latlon-geohash": "^2.0.0"
}
},
"node_modules/@playwright/test": {
@@ -1196,9 +1195,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.55.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
"version": "2.58.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.58.0.tgz",
"integrity": "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1225,7 +1224,7 @@
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3",
"typescript": "^5.3.3 || ^6.0.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
@@ -1558,9 +1557,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1881,9 +1880,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2664,6 +2663,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/latlon-geohash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/latlon-geohash/-/latlon-geohash-2.0.0.tgz",
"integrity": "sha512-OKBswTwrvTdtenV+9C9euBmvgGuqyjJNAzpQCarRz1m8/pYD2nz9fKkXmLs2S3jeXaLi3Ry76twQplKKUlgS/g==",
"license": "MIT"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2901,9 +2906,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2969,9 +2974,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"dev": true,
"funding": [
{
@@ -3028,9 +3033,9 @@
}
},
"node_modules/postcss-load-config/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true,
"license": "ISC",
"engines": {
@@ -3573,9 +3578,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3843,9 +3848,9 @@
}
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"optional": true,

View File

@@ -19,7 +19,6 @@
"devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2",
"@owlboard/owlboard-ts": "^3.0.0-dev.20260319T2004",
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
@@ -42,6 +41,7 @@
"vitest-browser-svelte": "^2.0.2"
},
"dependencies": {
"@owlboard/owlboard-ts": "^3.0.0-dev.20260428T2753",
"@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

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

View File

@@ -0,0 +1,59 @@
<script lang="ts">
let {
message = "Loading..."
} = $props();
</script>
<div class="loading-state">
<div class="track">
<div class="shuttle"></div>
</div>
<p>{message}</p>
</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;
}
p {
font-family: 'URW Gothic', sans-serif;
letter-spacing: 0.15em;
color: var(--color-title);
animation: pulse 2s infinite ease-in-out;
}
@keyframes data-travel {
0% { left: -50%; }
100% { left: 100%; }
}
@keyframes pulse {
0%, 100% {opacity: 1;}
50% {opacity: 0.4;}
}
</style>

View File

@@ -4,7 +4,8 @@
import { fade } from 'svelte/transition';
import { goto } from '$app/navigation';
import { LOCATIONS } from '$lib/locations-object.svelte.ts';
import { LOCATIONS } from '$lib/locations-object.svelte';
import type { ApiLocationFilter } from '@owlboard/api-schema-types';
let { value = $bindable() } = $props();
@@ -26,13 +27,27 @@
return LOCATIONS.data
.filter((r) => tokens.every((t) => r.s.includes(t)))
.sort((a, b) => {
// Check if query matches CRS
const aIsCrs = a.c?.toLowerCase() === lowerQuery;
const bIsCrs = b.c?.toLowerCase() === lowerQuery;
// Priority One - Exact CRS Match
const aExactCrs = a.c?.toLowerCase() === lowerQuery;
const bExactCrs = b.c?.toLowerCase() === lowerQuery;
if (aExactCrs && !bExactCrs) return -1;
if (!aExactCrs && bExactCrs) return 1;
// Sort matching CRS first
if (aIsCrs && !bIsCrs) return -1;
if (!aIsCrs && bIsCrs) return 1;
// 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;
// Alphabetical Sort
return a.n.localeCompare(b.n);
@@ -59,7 +74,7 @@
}
});
function choose(loc: LocationRecord) {
function choose(loc: ApiLocationFilter.LocationFilterObject) {
showResults = false;
selectedIndex = -1;
value = '';
@@ -195,6 +210,7 @@
font-size: 1.1rem;
font-weight: 700;
text-align: right;
text-transform: capitalize;
}
.tiploc {

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

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

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

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
import { slide } from 'svelte/transition';
import { formatUkTime } from '$lib/utils/time';
import TocStyle from '$lib/components/ui/TocStyle.svelte';
let { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse} = $props();
let isExpanded = $state(false);
const toggleExpand = () => isExpanded = !isExpanded;
let OriginDepartureSummary = $derived(formatUkTime(service.od));
</script>
<div class="train-service">
<button class="summary" onclick={toggleExpand} type="button" aria-expanded={isExpanded}>
<div class="operator-summary">
<TocStyle toc={service.o} />
</div>
<div class="main-text-summary">
<div class="time-summary">
{OriginDepartureSummary}
</div>
<div class="location-summary">
{service.ot}
</div>
<div class="location-summary to-summary">
to
</div>
<div class="location-summary">
{service.dt}
</div>
<!-- Add arrow icon to signify drop-down -->
</div>
</button>
</div>
<style>
.train-service {
background-color: var(--color-accent);
width: 100%;
border-radius: 4px;
box-shadow: var(--shadow-std);
overflow: hidden;
font-family: 'URW Gothic', sans-serif;
transition: 0.2s all;
filter: brightness(1.1);
}
.train-service:hover {
filter:brightness(1.5);
}
.summary {
display: flex;
align-items: center;
width: 100%;
padding: 0.75rem;
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: 1.1rem;
font-weight: 700;
color: var(--color-brand);
}
.location-summary {
text-transform: uppercase;
font-weight: 500;
font-size: 1.1rem;
letter-spacing: 0.02em;
color: var(--color-title);
}
.to-summary {
font-size: 0.8rem;
font-style: oblique;
text-transform: lowercase;
}
</style>

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,34 @@
<script lang="ts">
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';
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 { onsearch }: { onsearch: (c: string) => void } = $props();
let { onsearch }: { onsearch: (c: string) => void } = $props();
let codeValue = $state('');
let codeValue = $state('');
function resetValues(): void {
codeValue = '';
}
function resetValues(): void {
codeValue = '';
}
</script>
<BaseCard header={'Find by Code'}>
<div class="card-content">
<div class="textbox-container">
<div class="textbox-item-wrapper">
<Textbox placeholder={"Code"} uppercase={true} type={'number'} max={9999} bind:value={codeValue} />
</div>
</div>
<div class="button-wrapper">
<Button onclick={() => onsearch(codeValue.toString())}>Search</Button>
<Button onclick={resetValues}>Reset</Button>
</div>
<div class="textbox-container">
<div class="textbox-item-wrapper">
<Textbox
placeholder={'Code'}
uppercase={true}
type={'number'}
max={9999}
bind:value={codeValue}
/>
</div>
</div>
<div class="button-wrapper">
<Button onclick={() => onsearch(codeValue.toString())}>Search</Button>
<Button onclick={resetValues}>Reset</Button>
</div>
</div>
</BaseCard>
@@ -34,21 +40,21 @@ function resetValues(): void {
padding: 10px 0 10px 0;
}
.textbox-container {
display: flex;
width: 100%;
justify-content: center;
gap: 4rem;
}
.textbox-container {
display: flex;
width: 100%;
justify-content: center;
gap: 4rem;
}
.textbox-item-wrapper {
width: 30%;
}
.textbox-item-wrapper {
width: 30%;
}
.button-wrapper {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 15px;
}
</style>
.button-wrapper {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 15px;
}
</style>

View File

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

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

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

View File

@@ -55,6 +55,7 @@
/* 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);
}

View File

@@ -1,27 +1,23 @@
interface LocationRecord {
n: string; // name
t: string; // tiploc
c?: string; // crs
s: string; // search string
}
import { OwlClient } from './owlClient';
import type { ApiLocationFilter } from '@owlboard/owlboard-ts';
class LocationStore {
data = $state<LocationRecord[]>([]);
data = $state<ApiLocationFilter.LocationFilterObject[]>([]);
loaded = $state(false);
async init(fetcher = fetch) {
async init() {
if (this.loaded) return;
try {
const res = await fetcher('/api/tiplocs');
this.data = await res.json();
const fetch = await OwlClient.locationFilter.getLocationFilterData();
this.data = fetch.data;
this.loaded = true;
} catch (err) {
console.error('Failed to load locations', err);
}
}
find(id: string | null): LocationRecord | undefined {
find(id: string | null): ApiLocationFilter.LocationFilterObject | undefined {
if (!id) return undefined;
const query = id.toUpperCase().trim();

View File

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

View File

@@ -0,0 +1,60 @@
export interface QuickLink {
id: string;
score: number;
lastAccessed: number;
}
const RETURNED_LENGTH: number = 9;
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();

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

@@ -0,0 +1,17 @@
/**
* Converts ISO/JSON time to UK-formatted HH:MM string.
* Ensures Europe/London timezone irrespective of browser timezone.
*/
export function formatUkTime(dateStr: string | Date | undefined): 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',
hour12: false,
timeZone: 'Europe/London',
});
}

View File

@@ -2,12 +2,18 @@
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}
<!-- 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">

View File

@@ -2,8 +2,12 @@
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 { LOCATIONS } from '$lib/locations-object.svelte.ts';
import Loading from '$lib/components/ui/Loading.svelte';
import TimezoneWarning from '$lib/components/ui/TimezoneWarning.svelte';
import '$lib/global.css';
@@ -13,7 +17,7 @@
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
onMount(() => LOCATIONS.init(fetch));
onMount(() => LOCATIONS.init());
let { children } = $props();
@@ -77,7 +81,13 @@
</header>
<main>
{@render children()}
<TimezoneWarning />
{#if navigating && navigating.to}
<Loading />
{:else}
{@render children()}
{/if}
</main>
<nav bind:clientWidth={navWidth}>
@@ -167,6 +177,7 @@
margin-left: 5px;
padding-bottom: 2px;
color: var(--color-title);
text-transform: capitalize;
}
header,
nav {

View File

@@ -1,9 +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

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

View File

@@ -1,3 +1,24 @@
<script lang="ts">
import { untrack } from 'svelte';
import { quickLinks } from '$lib/quick-links.svelte';
let { data } = $props();
// 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)}`);
});
}
}
});
</script>
<section>Live board are not yet implemented on the server</section>
<style>

View File

@@ -6,7 +6,7 @@ export const load: PageLoad = async ({ url }) => {
const locId = url.searchParams.get('loc');
if (!LOCATIONS.loaded) {
await LOCATIONS.init(fetch);
await LOCATIONS.init();
}
let title: string = '';
@@ -18,11 +18,13 @@ export const load: PageLoad = async ({ url }) => {
});
}
if (locId) {
const location = LOCATIONS.find(locId);
let BoardLocation;
if (location) {
title = location.n || location.t;
if (locId) {
BoardLocation = LOCATIONS.find(locId);
if (BoardLocation) {
title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
} else {
error(404, {
message: `Location (${locId.toUpperCase()}) not found`,
@@ -32,6 +34,6 @@ export const load: PageLoad = async ({ url }) => {
}
return {
title,
location
BoardLocation
};
};

View File

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

View File

@@ -2,4 +2,4 @@ export const load = () => {
return {
title: 'PIS Codes'
};
};
};

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import NoResults from '$lib/components/ui/NoResults.svelte';
import TrainService from '$lib/components/ui/TrainService.svelte';
let { data } = $props();
</script>
{#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;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@@ -0,0 +1,64 @@
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 ({ 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);
results = (response.data);
return {
title: headcode.toUpperCase(),
results: results,
}
} catch (e: unknown) {
if (e instanceof ValidationError) {
throw error(400, {
message: e.message,
owlCode: 'VALIDATION_ERROR',
});
} else if (e instanceof ApiError) {
// Check if NO_RESULTS error, and return empty array if that is the case
if (e.code === "NOT_FOUND") {
return {
title: headcode.toUpperCase(),
results: []
}
} else {
throw error(e.status, {
message: e.message,
owlCode: 'API_ERROR',
});
}
} else if (e instanceof Error) {
throw error(500, {
message: e.message,
owlCode: 'GEN_ERROR',
})
} else {
throw error(500, {
message: "Unexpected error",
owlCode: "UNKNOWN_ERR",
})
}
}
}

View File

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

View File

@@ -8,7 +8,7 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"sourceMap": false,
"strict": true,
"moduleResolution": "bundler"
}