Compare commits

..

18 Commits

Author SHA1 Message Date
22aee0b915 Improve live indications (Smaller, but maybe marginally less user friendly?) Shorten poll interval for boards due to backend optimizations.
ensure data is reloaded when tab/page becomes visible.
2026-05-21 20:06:33 +01:00
f30a2745d4 Add initial staff grid view 2026-05-18 20:44:05 +01:00
9e0f53b7fe Adjust live flasher animated saturation 2026-05-18 20:36:47 +01:00
03495764e0 Adjust live indicator location 2026-05-18 20:30:22 +01:00
9657a77c41 Adjust poll frequency 2026-05-18 20:20:41 +01:00
437b0b8cf1 Add live indicator to board page 2026-05-18 20:20:01 +01:00
40e926bf11 Features:
- Add preferences for WakeLock and AutoRefresh
 - Add WakeLock and AutoRefresh to Board
 - Attempt adding transitions to Board elements

UI:
 - Add '3D' styling to preference toggles
2026-05-18 11:25:18 +01:00
631d324cba Add preferences and privacy policy 2026-05-16 21:06:29 +01:00
07579310b2 Improve +error.svelte handling 2026-05-16 19:19:31 +01:00
b51845528f 'Genericise' errors ready for +error.svelte upgrade to handle known errors 2026-05-16 18:19:47 +01:00
808b576df1 Improve error handling 2026-05-16 15:10:49 +01:00
6ed262ce59 Bugfix:
- Update to @tabler/icons-svelte-runes for a more 'Svente 5' experience.

 - Adjust error handling in load functions, plain error thrown for handling in the error handler.

  - Fix the warning regarding a click handler attached to an <img> on the About page

  - Re-organise component directory

  - Remove unused font declarations from global.css

Features:
 - Add service-worker caching of FilterData API response to allow for filtering to work fully offline

  -
2026-05-15 21:19:12 +01:00
6faa620bdb Adjust styling of 'estimated times' 2026-05-15 18:58:45 +01:00
1105a9f7c5 Add extra message to +error.svelte to handle, and clarify 'OFFLINE' error. 2026-05-14 19:43:22 +01:00
25b64edabd Add features:
- manifest.json with PNG icon set.
 - reset option against QuickLinks storage
 - 'Preferences' store, ready for preferences page - including location boolean... Important!
 - Minor service-worker improvements for better offline fallbacks
2026-05-14 19:39:05 +01:00
bc56e66178 Add initlal service-worker with basic offline handling. Manifest is not yet present. 2026-05-13 20:47:01 +01:00
0ef9e5b56f Adjust dynamic styles on the departure board component 2026-05-12 20:35:12 +01:00
b88f4f3b35 Dynamically adjust font size based on vw value 2026-05-12 19:06:25 +01:00
45 changed files with 1714 additions and 218 deletions

66
package-lock.json generated
View File

@@ -8,9 +8,8 @@
"name": "web-pwa",
"version": "0.0.1",
"dependencies": {
"@owlboard/api-schema-types": "^3.0.3-alpha18",
"@owlboard/owlboard-ts": "^3.0.0-dev.20260509t2101",
"@tabler/icons-svelte": "^3.40.0"
"@owlboard/api-schema-types": "^3.0.3-alpha19",
"@owlboard/owlboard-ts": "^3.0.0-dev.20260509t2101"
},
"devDependencies": {
"@eslint/compat": "^2.0.2",
@@ -19,6 +18,7 @@
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tabler/icons-svelte-runes": "^3.44.0",
"@types/node": "^22",
"@vitest/browser-playwright": "^4.0.18",
"eslint": "^9.39.2",
@@ -760,6 +760,7 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -770,6 +771,7 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -780,6 +782,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -789,12 +792,14 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -802,9 +807,9 @@
}
},
"node_modules/@owlboard/api-schema-types": {
"version": "3.0.3-alpha18",
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fapi-schema-types/-/3.0.3-alpha18/api-schema-types-3.0.3-alpha18.tgz",
"integrity": "sha512-k9X83i8ljatuiKQWNxgixid8tneM0i0G5NP1Wo1N2O0SF0PBMs3gxkEMWwKMuc+htiGCeRLqvIZqrm//Bgeo/w==",
"version": "3.0.3-alpha19",
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fapi-schema-types/-/3.0.3-alpha19/api-schema-types-3.0.3-alpha19.tgz",
"integrity": "sha512-dVtJAw1p1SLsv9nEojUaku1BPPJnypoX5ZOoONboN8fh81kqGM16OVR69GEHhwuPgJDaPLDUSBxtYYAKZ6ZTcQ==",
"license": "MIT"
},
"node_modules/@owlboard/owlboard-ts": {
@@ -1203,6 +1208,7 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -1219,9 +1225,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.59.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz",
"integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==",
"version": "2.60.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz",
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1230,7 +1236,7 @@
"@types/cookie": "^0.6.0",
"acorn": "^8.14.1",
"cookie": "^0.6.0",
"devalue": "^5.6.4",
"devalue": "^5.8.1",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
@@ -1303,16 +1309,18 @@
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz",
"integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-svelte": {
"node_modules/@tabler/icons-svelte-runes": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-svelte/-/icons-svelte-3.44.0.tgz",
"integrity": "sha512-ZJJMCHoqpvb9hLVn9dU+pn8LCdX/e+mJ/fC+EUaJT5nHEm0+IW4aKKYkYQI+rFMzN8ivj36MnMC5TGFi4H6zew==",
"resolved": "https://registry.npmjs.org/@tabler/icons-svelte-runes/-/icons-svelte-runes-3.44.0.tgz",
"integrity": "sha512-kncwVrQByKnhquybudKYQ1m9OT2Pjl2MEyGhAJmySPjm3uG5CjmcE66jag7A9Hz/D+fuRXYNcHtq99ontyi4aA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.44.0"
@@ -1322,7 +1330,7 @@
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"svelte": ">=3 <6 || >=5.0.0-next.0"
"svelte": "^5.0.0"
}
},
"node_modules/@testing-library/svelte-core": {
@@ -1367,6 +1375,7 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@@ -1390,6 +1399,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
@@ -1542,7 +1552,7 @@
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz",
"integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1838,6 +1848,7 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -1900,6 +1911,7 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1919,6 +1931,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1999,6 +2012,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -2112,9 +2126,10 @@
}
},
"node_modules/devalue": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz",
"integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==",
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-module-lexer": {
@@ -2349,6 +2364,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/espree": {
@@ -2386,6 +2402,7 @@
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.6.tgz",
"integrity": "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -2666,6 +2683,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -2773,6 +2791,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@@ -2802,6 +2821,7 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -5188,9 +5208,10 @@
}
},
"node_modules/svelte": {
"version": "5.55.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
"integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
"version": "5.55.7",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
"integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -5202,7 +5223,7 @@
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.6.4",
"devalue": "^5.8.1",
"esm-env": "^1.2.1",
"esrap": "^2.2.4",
"is-reference": "^3.0.3",
@@ -5728,6 +5749,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
}
}

View File

@@ -23,6 +23,7 @@
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tabler/icons-svelte-runes": "^3.44.0",
"@types/node": "^22",
"@vitest/browser-playwright": "^4.0.18",
"eslint": "^9.39.2",
@@ -41,8 +42,7 @@
"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"
"@owlboard/api-schema-types": "^3.0.3-alpha19",
"@owlboard/owlboard-ts": "^3.0.0-dev.20260509t2101"
}
}

4
src/app.d.ts vendored
View File

@@ -4,7 +4,9 @@ declare global {
namespace App {
interface Error {
message: string;
owlCode?: string;
code?: string;
name?: string;
msg?: string;
}
// interface Locals {}
// interface PageData {}

View File

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

11
src/hooks.client.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { HandleClientError } from '@sveltejs/kit';
export const handleError: HandleClientError = ({ error }) => {
const err = error as any;
return {
message: err?.message || 'An unexpected application error occurred.',
code: err?.code || 'CRITICAL',
name: err?.name || 'UNHANDLED_EXCEPTION'
};
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import noResult from '$lib/assets/img/no-data.svg';
import Button from '$lib/components/ui/Button.svelte';
import Button from '$lib/components/ui/form-elements/Button.svelte';
let { title = 'No results', message = 'Try checking your search term' } = $props();
</script>

View File

@@ -6,7 +6,7 @@
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 { IconChevronDownFilled } from '@tabler/icons-svelte-runes';
import { estClass } from '$lib/utils/time';
let { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse } = $props();
let isExpanded = $state(false);

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { IconHelpCircle } from '@tabler/icons-svelte';
import { IconHelpCircle } from '@tabler/icons-svelte-runes';
import { slide } from 'svelte/transition';
interface Props {

View File

@@ -1,8 +1,7 @@
<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';
import Textbox from '$lib/components/ui/form-elements/Textbox.svelte';
let headcode = $state('');

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import LocationSearchBox from '$lib/components/ui/LocationSearchBox.svelte';
import LocationSearchBox from '$lib/components/ui/form-elements/LocationSearchBox.svelte';
let locationValue = $state('');

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Button from '$lib/components/ui/form-elements/Button.svelte';
import { prefs } from '$lib/preferences.svelte';
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
@@ -12,7 +14,9 @@
<BaseCard header={'Nearby Stations'}>
<div class="card-content">
{#if nearestStationsState.error && nearestStationsState.list.length === 0}
{#if !prefs.current.NearToMe}
<Button onclick={() => prefs.toggleNearToMe(true)}>Enable Location</Button>
{:else 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>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Button from '$lib/components/ui/form-elements/Button.svelte';
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate';

View File

@@ -1,7 +1,6 @@
<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 Textbox from '$lib/components/ui/form-elements/Textbox.svelte';
let { onsearch }: { onsearch: (c: string) => void } = $props();
</script>

View File

@@ -1,7 +1,7 @@
<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 Textbox from '$lib/components/ui/form-elements/Textbox.svelte';
import Button from '$lib/components/ui/form-elements/Button.svelte';
let { onsearch }: { onsearch: (s: string, e: string) => void } = $props();
@@ -15,7 +15,13 @@
</script>
<BaseCard header={'Find by Start/End CRS'}>
<div class="card-content">
<form
class="card-content"
onsubmit={(e) => {
e.preventDefault();
onsearch(startValue, endValue);
}}
>
<div class="textbox-container">
<div class="textbox-item-wrapper">
<Textbox placeholder={'Start'} uppercase={true} maxLength={3} bind:value={startValue} />
@@ -25,10 +31,10 @@
</div>
</div>
<div class="button-wrapper">
<Button onclick={() => onsearch(startValue, endValue)}>Search</Button>
<Button type={'submit'}>Search</Button>
<Button onclick={resetValues}>Reset</Button>
</div>
</div>
</form>
</BaseCard>
<style>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import Textbox from '$lib/components/ui/Textbox.svelte';
import Textbox from '$lib/components/ui/form-elements/Textbox.svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { goto } from '$app/navigation';

View File

@@ -2,7 +2,7 @@
import { fade } from 'svelte/transition';
import type { HTMLInputAttributes } from 'svelte/elements';
import { IconChevronRightFilled } from '@tabler/icons-svelte';
import { IconChevronRightFilled } from '@tabler/icons-svelte-runes';
interface Props extends HTMLInputAttributes {
value?: string;

View File

@@ -0,0 +1,113 @@
<script lang="ts">
interface Props {
checked: boolean;
id: string;
label: string;
disabled?: boolean;
onchange?: (checked: boolean) => void;
}
let { checked = $bindable(false), id, label, disabled = false, onchange }: Props = $props();
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
checked = target.checked;
onchange?.(checked);
}
</script>
<div class="toggle-wrapper" class:is-disabled={disabled}>
<!-- 1. Interactive Switch Mechanism (Sits Left) -->
<div class="toggle-control">
<input type="checkbox" {id} {disabled} {checked} onchange={handleInput} class="toggle-input" />
<label for={id} class="toggle-track">
<span class="toggle-thumb"></span>
</label>
</div>
<!-- 2. Visible Text Label (Sits Right) -->
<label for={id} class="toggle-label-text">
{label}
</label>
</div>
<style>
.toggle-wrapper {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 1.75rem;
width: 100%;
}
.toggle-control {
position: relative;
display: inline-flex;
align-items: center;
}
.toggle-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.toggle-track {
position: relative;
display: inline-block;
width: 3.75rem;
height: 2rem;
background-color: rgb(93, 45, 45);
border-radius: 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
}
.toggle-thumb {
position: absolute;
top: 0.25rem;
left: 0.25rem;
width: 1.5rem;
height: 1.5rem;
background-color: var(--color-accent);
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
filter: brightness(1.45);
}
.toggle-input:checked + .toggle-track {
background-color: rgb(11, 70, 11);
}
.toggle-input:checked + .toggle-track .toggle-thumb {
transform: translateX(1.75rem);
}
.toggle-input:focus-visible + .toggle-track {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.toggle-label-text {
font-weight: 600;
cursor: pointer;
user-select: none;
color: var(--color-title);
}
.is-disabled {
opacity: 0.5;
}
.is-disabled .toggle-track,
.is-disabled .toggle-label-text {
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,431 @@
<script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time';
import { fade } from 'svelte/transition';
let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
const getRowKey = (s: ApiStationsBoard.BoardService) =>
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
</script>
<section class="departure-board">
<div class="header">
<div class="header-row"></div>
<div class="header-row"></div>
</div>
<div class="services">
<!-- Keyed EACH Here -->
</div>
</section>
<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
in:fade={{duration: 150, delay:150}}
out:fade={{duration:150}}
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" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="8">
{service.o}
</td>
</tr>
{/if}
{#if service.c && service.cr?.r}
<tr class="cancel-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<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" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="9">
{service.dr.r}
{#if service.dr.l}
{service.dr.n ? 'near' : 'at'}
{service.dr.l}
{/if}
</td>
</tr>
{/if}
</tbody>
{/each}
</table>
<style>
.departure-board {
width: 100%;
margin: 5px auto;
border-collapse: collapse;
table-layout: fixed;
}
.departure-board td {
font-family: 'Inconsolata Variable', monospace;
font-size: clamp(1rem, 0.475rem + 2.8vw, 1.35rem);
font-variant-ligatures: additional-ligatures;
overflow: hidden;
}
thead,
.cancel-row td,
.delay-row td {
letter-spacing: -0.15ch;
}
thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-bg-dark);
}
abbr {
text-decoration: none;
border-bottom: none;
cursor: help;
}
tbody tr:first-child td {
border-top: 5px solid transparent;
}
/* Row Logic */
.serviceCancelled {
color: rgb(255, 131, 131);
}
.servicePass td {
opacity: 0.75;
font-weight: 200;
}
.serviceNonPassenger td {
opacity: 0.5;
font-weight: 290;
font-style: italic;
}
/* Special Row Styles */
.cancel-row td,
.delay-row td {
font-size: 0.88rem;
text-align: left;
}
.cancel-row td[colspan],
.delay-row td[colspan] {
padding-left: 0ch;
}
.cancel-row td {
color: rgb(255, 131, 131);
font-weight: 400;
}
.delay-row td {
color: var(--delay-orange);
font-style: italic;
}
@media (min-width: 375px) {
.cancel-row td,
.delay-row td {
font-size: 1.08rem;
}
}
@media (min-width: 420px) {
.cancel-row td,
.delay-row td {
font-size: 1.12rem;
}
}
@media (min-width: 550px) {
.cancel-row td,
.delay-row td {
font-size: 1.18rem;
}
}
@media (min-width: 620px) {
.cancel-row td,
.delay-row td {
font-size: 1.2rem;
}
}
.toc-coach-row td {
font-family: 'URW Gothic', sans-serif;
font-size: 0.7rem;
padding: 0;
color: rgb(187, 187, 255);
}
@media (min-width: 400px) {
.toc-coach-row td {
font-size: 0.8rem;
}
}
@media (min-width: 550px) {
.toc-coach-row td {
font-size: 0.85rem;
}
}
/* Column Specifics */
.id-cell {
text-align: left;
font-weight: 400;
font-stretch: 85%;
filter: brightness(0.75);
}
@media (min-width: 400px) {
.id-cell {
font-stretch: 90%;
}
}
.orig-cell,
.dest-cell {
font-stretch: 80%;
font-weight: 400;
}
.orig-cell {
text-align: left;
color: var(--location-yellow);
}
.dest-cell {
text-align: right;
color: var(--location-yellow);
}
@media (min-width: 350px) {
.orig-cell,
.dest-cell {
font-stretch: 85%;
}
@media (min-width: 400px) {
.orig-cell,
.dest-cell {
font-stretch: 90%;
}
}
@media (min-width: 490px) {
.orig-cell,
.dest-cell {
font-stretch: 95%;
}
}
@media (min-width: 520px) {
.orig-cell,
.dest-cell {
font-stretch: 100%;
}
.orig-cell {
text-align: right;
padding-right: 7px;
}
.dest-cell {
text-align: left;
padding-left: 7px;
}
}
@media (min-width: 600px) {
.orig-cell,
.dest-cell {
letter-spacing: 0.05ch;
}
}
}
.plt-cell {
text-align: center;
font-weight: 410;
font-stretch: 100%;
}
@media (min-width: 525px) {
.plt-cell {
font-stretch: 110%;
}
}
.plt-cell.platSup {
font-weight: 200;
opacity: 0.3;
}
.plt-cell.platChange {
animation: fast-pulse 2s ease-out infinite;
}
.service-row.serviceCancelled .plt-cell {
text-decoration: line-through;
}
.pass-cell {
text-align: center;
font-stretch: 100%;
}
/* Colour orig and dest values when cancelled */
.service-row.serviceCancelled .orig-cell,
.service-row.serviceCancelled .dest-cell {
color: rgb(255, 131, 131);
}
.time-cell {
text-align: center;
font-stretch: 72%;
}
@media (min-width: 350px) {
.time-cell {
font-stretch: 77%;
}
}
@media (min-width: 400px) {
.time-cell {
font-stretch: 82%;
}
}
@media (min-width: 525px) {
.time-cell {
font-stretch: 90%;
}
}
@media (min-width: 600px) {
.time-cell {
font-stretch: 100%;
}
}
/* RT Logic */
.time-cell.delay-rt span {
display: none;
}
.time-cell.delay-rt::after {
content: 'RT';
}
.time-cell.delay-early {
color: var(--early-blue);
}
.time-cell.delay-late {
color: var(--delay-orange);
}
/* Time Types */
.est {
font-style: italic;
opacity: 0.75;
font-weight: 350;
}
.act {
font-weight: 600;
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time';
import { fade } from 'svelte/transition';
let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
@@ -9,6 +10,24 @@
</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>
@@ -29,10 +48,21 @@
{#each services as service (getRowKey(service))}
<tbody>
<tr class="service-row" class:serviceCancelled={service.c} class:servicePass={service.wtp} class:serviceNonPassenger={!service.ip}>
<tr
in:fade={{duration: 150, delay:150}}
out:fade={{duration:150}}
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="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
>
@@ -95,9 +125,16 @@
{/if}
</tr>
{#if service.o}
<tr class="toc-coach-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="8">
{service.o}
</td>
</tr>
{/if}
{#if service.c && service.cr?.r}
<tr class="cancel-row">
<td colspan="9">
<tr class="cancel-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="8">
{service.cr.r}
{#if service.cr.l}
{service.cr.n ? 'near' : 'at'}
@@ -107,7 +144,7 @@
</tr>
{/if}
{#if service.dr?.r}
<tr class="delay-row">
<tr class="delay-row" in:fade={{ duration: 150, delay: 150 }} out:fade={{ duration: 150 }}>
<td colspan="9">
{service.dr.r}
{#if service.dr.l}
@@ -126,20 +163,20 @@
width: 100%;
margin: 5px auto;
border-collapse: collapse;
table-layout: fixed;
}
.departure-board td {
font-family: 'Inconsolate Variable', monospace;
font-size: 1rem;
font-variant-numeric: tabular-nums;
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 {
font-family: 'URW Gothic', sans-serif;
letter-spacing: 0.02ch;
letter-spacing: -0.15ch;
}
thead {
@@ -152,8 +189,12 @@
abbr {
text-decoration: none;
border-bottom: none;
cursor:help;
}
cursor: help;
}
tbody tr:first-child td {
border-top: 5px solid transparent;
}
/* Row Logic */
.serviceCancelled {
@@ -167,7 +208,7 @@
.serviceNonPassenger td {
opacity: 0.5;
font-weight: 200;
font-weight: 290;
font-style: italic;
}
@@ -175,12 +216,12 @@
.cancel-row td,
.delay-row td {
font-size: 0.88rem;
padding-bottom: 8px;
text-align: left;
}
.cancel-row td[colspan],
.delay-row td[colspan] {
padding-left: 0.75ch;
padding-left: 0ch;
}
.cancel-row td {
@@ -192,17 +233,63 @@
color: var(--delay-orange);
font-style: italic;
}
@media (min-width: 375px) {
.cancel-row td,
.delay-row td {
font-size: 1.08rem;
}
}
@media (min-width: 420px) {
.cancel-row td,
.delay-row td {
font-size: 1.12rem;
}
}
@media (min-width: 550px) {
.cancel-row td,
.delay-row td {
font-size: 1.18rem;
}
}
@media (min-width: 620px) {
.cancel-row td,
.delay-row td {
font-size: 1.2rem;
}
}
.toc-coach-row td {
font-family: 'URW Gothic', sans-serif;
font-size: 0.7rem;
padding: 0;
color: rgb(187, 187, 255);
}
@media (min-width: 400px) {
.toc-coach-row td {
font-size: 0.8rem;
}
}
@media (min-width: 550px) {
.toc-coach-row td {
font-size: 0.85rem;
}
}
/* Column Specifics */
.id-cell {
text-align: center;
text-align: left;
font-weight: 400;
font-stretch: 80%;
font-stretch: 85%;
filter: brightness(0.75);
}
@media (min-width: 400px) {
.id-cell {
font-stretch: 90%;
}
}
.orig-cell,
.dest-cell {
font-stretch: 90%;
font-stretch: 80%;
font-weight: 400;
}
.orig-cell {
@@ -213,10 +300,53 @@
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: 405;
font-stretch: 70%;
font-weight: 410;
font-stretch: 100%;
}
@media (min-width: 525px) {
.plt-cell {
font-stretch: 110%;
}
}
.plt-cell.platSup {
font-weight: 200;
@@ -230,6 +360,7 @@
}
.pass-cell {
text-align: center;
font-stretch: 100%;
}
/* Colour orig and dest values when cancelled */
.service-row.serviceCancelled .orig-cell,
@@ -239,7 +370,27 @@
.time-cell {
text-align: center;
font-stretch: 70%;
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 */
@@ -262,7 +413,7 @@
.est {
font-style: italic;
opacity: 0.75;
font-weight: 200;
font-weight: 350;
}
.act {
font-weight: 600;

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { IconAlertOctagonFilled, IconChevronDownFilled } from '@tabler/icons-svelte';
import { IconAlertOctagonFilled, IconChevronDownFilled } from '@tabler/icons-svelte-runes';
let { messages = [] }: { messages: ApiStationsBoard.BoardMsgs[] } = $props();
let isOpen = $state(false);

View File

@@ -1,12 +1,15 @@
import { OwlClient, ValidationError, ApiError } from './owlClient';
import type { ApiStationsNearestStations } from '@owlboard/owlboard-ts';
import { prefs } from './preferences.svelte';
class NearestStationsState {
list = $state<ApiStationsNearestStations.StationsNearestStations[]>([]);
currentHash = $state('');
loading = $state(true);
loading = $state(false);
error = $state<string | null>(null);
private watcherId: number | null = null;
private initGeoConfig: PositionOptions = {
enableHighAccuracy: false,
timeout: 500,
@@ -20,10 +23,35 @@ class NearestStationsState {
};
constructor() {
if (typeof window !== 'undefined' && 'geolocation' in navigator) {
if (typeof window === 'undefined' || !('geolocation' in navigator)) return;
$effect.root(() => {
$effect(() => {
if (prefs.current.NearToMe) {
this.startTracking();
} else {
this.stopTracking();
}
});
});
}
private startTracking() {
if (this.watcherId !== null) return; // Already tracking
this.loading = true;
this.jumpstart();
this.initWatcher();
}
private stopTracking() {
if (this.watcherId !== null) {
navigator.geolocation.clearWatch(this.watcherId);
this.watcherId = null;
}
this.list = [];
this.currentHash = '';
this.loading = false;
this.error = null;
}
private jumpstart() {
@@ -35,7 +63,7 @@ class NearestStationsState {
}
private initWatcher() {
navigator.geolocation.watchPosition(
this.watcherId = navigator.geolocation.watchPosition(
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
(err) => this.handleError(err),
this.geoConfig

View File

@@ -130,22 +130,6 @@
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';
@@ -153,7 +137,7 @@
font-display: swap;
font-weight: 200 900;
font-stretch: 50% 200%;
src: url(type/Inconsolata/latin-wdth-normal.woff2) format('woff2-variations');
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;

View File

@@ -1,5 +1,6 @@
import { OwlBoardClient, ValidationError, ApiError } from '@owlboard/owlboard-ts';
import { browser, dev } from '$app/environment';
import { error } from '@sveltejs/kit';
// Import the runes containing the API Key config Here...
@@ -18,4 +19,48 @@ export const OwlClient = new OwlBoardClient(
// API Key Here when ready!!!
);
export function ThrowApiError(e: unknown): never {
// Handle Request failure
if (e instanceof TypeError && e.message === 'Failed to fetch') {
throw error(503, {
message: 'Unable to connect to the OwlBoard server',
name: 'NETWORK_ERROR',
code: 'NETWORK_UNREACHABLE'
});
}
// Map ApiError 'code' values
if (e instanceof ApiError) {
console.error(JSON.stringify(e), e.code, 'ERRCODE');
let status = 500;
if (e.code === 'AUTH') {
status = 401;
} else if (e.code === 'NOT_FOUND') {
status = 404;
} else if (e.code === 'RATE_LIMIT') {
status = 421;
} else if (e.code === 'NETWORK_DISCONNECTED') {
status = 503;
} else if (e.code === 'VALIDATION') {
status = 400;
}
throw error(status, {
message: e.message || 'An operational error has occurred',
name: e.name || 'API_ERROR',
code: e.code || 'UNKNOWN_API_ERROR'
});
}
// Fallback for other error kind
const genericMessage = e instanceof Error ? e.message : 'An unexpected error occurred.';
console.log(e);
throw error(500, {
message: genericMessage,
name: 'CRITICAL_ERROR',
code: 'INTERNAL_ERROR',
msg: 'UNHANDLED_EXCEPTION'
});
}
export { ValidationError, ApiError };

View File

@@ -0,0 +1,67 @@
import { browser } from '$app/environment';
export interface Preferences {
NearToMe: boolean;
ShowPassingTrains: boolean;
BoardWakeLock: boolean;
AutoRefresh: boolean;
}
const STORAGE_KEY = 'ob_pref';
const DEFAULT_PREFS: Preferences = {
NearToMe: false,
ShowPassingTrains: true,
BoardWakeLock: true,
AutoRefresh: true,
};
function loadPrefs(): Preferences {
if (!browser) return DEFAULT_PREFS;
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return DEFAULT_PREFS;
try {
const parsed = JSON.parse(stored);
const cleaned = {} as Preferences;
for (const key of Object.keys(DEFAULT_PREFS) as Array<keyof Preferences>) {
cleaned[key] = key in parsed ? parsed[key] : DEFAULT_PREFS[key];
}
return cleaned;
} catch {
return DEFAULT_PREFS;
}
}
class PreferencesStore {
current = $state<Preferences>(loadPrefs());
constructor() {
$effect.root(() => {
$effect(() => {
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.current));
}
});
});
}
toggleNearToMe(enabled: boolean) {
this.current.NearToMe = enabled;
}
toggleShowPassingTrains(enabled: boolean) {
this.current.ShowPassingTrains = enabled;
}
toggleBoardWakeLock(enabled: boolean) {
this.current.BoardWakeLock = enabled;
}
toggleAutoRefresh(enabled: boolean) {
this.current.AutoRefresh = enabled;
}
}
export const prefs = new PreferencesStore();

View File

@@ -15,7 +15,11 @@ class QuickLinksService {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('ql');
if (saved) {
try {
this.#links = JSON.parse(saved);
} catch {
this.#links = [];
}
}
}
}
@@ -53,8 +57,17 @@ class QuickLinksService {
this.#links = sorted.slice(0, MAX_ENTRIES);
if (typeof window !== 'undefined') {
localStorage.setItem('ql', JSON.stringify(this.#links));
}
}
reset() {
this.#links = [];
if (typeof window !== 'undefined') {
localStorage.removeItem('ql');
}
}
}
export const quickLinks = new QuickLinksService();

View File

@@ -3,16 +3,36 @@
import stopErr from '$lib/assets/img/stop-error.svg';
import noResult from '$lib/assets/img/no-data.svg';
import Button from '$lib/components/ui/Button.svelte';
import Button from '$lib/components/ui/form-elements/Button.svelte';
import { IconAntennaBarsOff } from '@tabler/icons-svelte-runes';
</script>
<!-- Will need to check error type, using the upstream code combined with response code -->
<!-- {#if page.error}
{JSON.stringify(page.error)}
STATUS: {page.status}
{/if} -->
<div class="error-wrapper">
{#if page.status == 404}
<!-- Warning no data image -->
{#if page.error?.code}
{#if page.error.code === 'NOT_FOUND'}
<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?? -->
{:else if page.error.code === 'RATE_LIMIT'}
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
{:else if page.error.code === 'VALIDATION'}
<img class="err-img" src={noResult} alt="" role="presentation" width="150" height="210" />
{:else if page.error.code === 'AUTH'}
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
{:else if page.error.code === 'SERVER'}
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
{:else if page.error.code === 'NETWORK_DISCONNECTED'}
<div class="err-img gentle-flash">
<IconAntennaBarsOff size={120} />
</div>
{:else}
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
{/if}
{:else}
<!-- STOP Error image -->
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
@@ -23,14 +43,16 @@
{page.error?.message ?? 'An unexpected derailment occurred.'}
</p>
{#if page.error?.owlCode}
{#if page.error?.code}
<div class="debug-info">
<code>Ref: {page.error.owlCode}</code>
<code>Ref: {page.error.code}</code>
</div>
{/if}
{#if $page.error?.owlCode === 'NETWORK_DISCONNECTED'}
{#if page.error?.code === 'NETWORK_DISCONNECTED'}
<Button onclick={() => window.location.reload()} color={'accent'}>Retry</Button>
{:else if page.error?.code === 'NOT_FOUND'}
<Button onclick={() => history.back()} color={'accent'}>Go Back</Button>
{:else}
<Button href={'/'} color={'accent'}>Return to Home</Button>
{/if}
@@ -61,6 +83,19 @@
}
}
@keyframes gentle-flash {
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
.gentle-flash {
animation: gentle-flash ease-in-out 2s infinite;
}
.label {
color: #ff4444;
letter-spacing: 0.2rem;

View File

@@ -2,9 +2,9 @@
import { page } from '$app/state';
import { slide, fade } from 'svelte/transition';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
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';
@@ -15,9 +15,28 @@
import logoPlain from '$lib/assets/round-logo.svg';
import favicon from '$lib/assets/round-logo.svg';
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
import {
IconHome,
IconDialpad,
IconSettings,
IconHelp,
IconDots,
IconSpy,
} from '@tabler/icons-svelte-runes';
onMount(() => LOCATIONS.init());
onMount(async () => {
LOCATIONS.init();
if (browser && 'serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js', {
type: 'module'
});
console.info('OwlBoard Service Worker registered', registration.scope);
} catch (error) {
console.error('Service Worker installation failed: ', error);
}
}
});
let { children } = $props();
@@ -29,13 +48,14 @@
{ label: 'Home', path: '/', icon: IconHome },
{ label: 'PIS', path: '/pis/', icon: IconDialpad },
{ label: 'Options', path: '/preferences/', icon: IconSettings },
{ label: 'About', path: '/about/', icon: IconHelp }
{ label: 'About', path: '/about/', icon: IconHelp },
{ label: 'Privacy Policy', path: '/privacy/', icon: IconSpy },
];
let navWidth = $state(0);
let menuOpen = $state(false);
const ITEM_WIDTH = 70;
const MORE_BUTTON_WIDTH = 70;
const ITEM_WIDTH = 110;
const MORE_BUTTON_WIDTH = 110;
const activePath = $derived(page.url?.pathname ?? '');
@@ -94,6 +114,7 @@
<!-- Dynamic Nav Elements Here! -->
{#each visibleItems as item}
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
{@const Icon = item.icon}
<a
href={item.path}
class="nav-item"
@@ -101,7 +122,7 @@
aria-current={isActive ? 'page' : undefined}
onclick={() => (menuOpen = false)}
>
<item.icon size={24} stroke={isActive ? 2 : 1.5} class="nav-icon" />
<Icon size={24} stroke={isActive ? 2 : 1.5} class="nav-icon" />
<span class="label">{item.label}</span>
</a>
@@ -126,6 +147,7 @@
<div class="menu-popover" transition:slide={{ axis: 'y', duration: 250 }}>
{#each hiddenItems as item}
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
{@const Icon = item.icon}
<a
href={item.path}
class="menu-popover-item"
@@ -133,7 +155,7 @@
class:active={isActive}
aria-current={isActive ? 'page' : undefined}
>
<item.icon size={20} />
<Icon size={20} />
<span>{item.label}</span>
</a>
{/each}
@@ -147,7 +169,7 @@
<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>
@@ -319,7 +341,7 @@
display: none;
}
@media (max-width: 299px) {
@media (max-width: 319px) {
.viewport-guard {
display: block;
position: fixed;

View File

@@ -11,13 +11,15 @@
</script>
<div class="logo-container">
<img
class="logo"
src={logo}
alt="OwlBoard Logo"
<button
type="button"
class="logo-btn"
onclick={handleLogoTap}
class:animate={isSpinning}
/>
aria-label="Spin the OwlBoard logo"
>
<img class="logo-img" src={logo} alt="OwlBoard Logo" />
</button>
</div>
<section class="about">
@@ -59,6 +61,17 @@
text-align: center;
}
.logo-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: inline-block;
padding-top: 25px;
margin: auto;
width: clamp(80px, 20vw, 200px);
}
@keyframes owl-spin {
0% {
transform: rotate(0deg);
@@ -71,14 +84,13 @@
}
}
.logo {
padding-top: 25px;
margin: auto;
.logo-img {
width: 100%;
height: auto;
width: clamp(80px, 20vw, 200px);
display: block;
}
.logo.animate {
.logo-btn.animate {
animation: owl-spin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}

View File

@@ -1,12 +1,91 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { quickLinks } from '$lib/quick-links.svelte';
import { invalidateAll } from '$app/navigation';
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';
import StaffServicesGrid from '$lib/components/ui/station-board/StaffServicesGrid.svelte';
let { data } = $props();
let now = $state(new Date());
let dataAgeInSeconds = $derived(
Math.floor((now.getTime() - new Date(data.boardData.producedAt).getTime()) / 1000)
);
let live = $derived(dataAgeInSeconds < 45);
// Handle auto-refreshing & Wake-lock
$effect(() => {
let intervalId: number | undefined;
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
if ('wakeLock' in navigator && !wakeLock) {
try {
wakeLock = await navigator.wakeLock.request('screen');
console.log("Wake lock obtained");
} catch (error) {
console.warn("Wake lock request rejected: ", error);
}
}
};
const releaseWakeLock = async () => {
if (wakeLock) {
try {
await wakeLock.release();
console.log("Wake lock released");
} catch (error) {
console.error("Wake lock release failed: ", error);
}
wakeLock = null;
}
};
const startPolling = async () => {
await requestWakeLock();
if (intervalId) return;
intervalId = window.setInterval(async () => {
try {
console.log("Invalidating data")
await invalidateAll();
} catch (error) {
console.error("Background data fetch failed: ", error)
}
}, 20000);
console.log("Polling started")
};
const stopPolling = async () => {
if (intervalId) {
window.clearInterval(intervalId);
intervalId = undefined;
console.log("Polling ended")
}
await releaseWakeLock();
};
const handleVisibilityChange = async () => {
if (document.hidden) {
stopPolling();
} else {
startPolling();
await invalidateAll();
}
};
if (!document.hidden) {
startPolling();
}
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
})
onMount(() => {
const interval = setInterval(() => {
@@ -16,28 +95,31 @@
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
const currentBoardId = $derived(
data.BoardLocation ? (data.BoardLocation.c ?? data.BoardLocation.t) : null
);
// Update 'QuickLinks' when currentBoardId changes
$effect(() => {
if (currentBoardId) {
// Keep the execution and logs inside untrack to isolate dependencies completely
untrack(() => {
quickLinks.recordVisit(id);
quickLinks.recordVisit(currentBoardId);
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">
<div class="loadedlive">
<div class="liveflash {live ? 'isLive' : 'notLive'}"></div>
<span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span>
</div>
<span class="time-now">{formatUkTime(now, true)}</span>
</div>
{#if data.boardData.data.m?.length}
@@ -45,7 +127,7 @@
{/if}
{#if data.boardData.data.s?.length}
<div class="service-list-wrapper">
<StaffServicesTable services={data.boardData.data.s} />
<StaffServicesGrid services={data.boardData.data.s} />
</div>
{/if}
</section>
@@ -72,6 +154,42 @@
font-family: 'URW Gothic', sans-serif;
}
.loadedlive {
display: inline-flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
.liveflash {
width: 12px;
height: 12px;
border-radius: 50%;
transition: all 0.3s ease;
}
div.notLive {
background-color: #ef4444;
animation:
led-blink 1s steps(1, start) infinite,
led-halo-expand 1s steps(1, start) infinite;
}
div.isLive {
background-color: #10b981;
animation:
led-pulse 2.5s infinite ease-in-out;
}
@keyframes led-blink {
0%,100%{opacity:1;}
50%{opacity:0.2;filter:saturate(0.2)}
}
@keyframes led-pulse{
0%,100%{opacity: 1;filter:saturate(1);box-shadow: 0 0 6px 1px rgba(16,185,129,0.4)}
50%{opacity: 0.2;filter:saturate(0.2);box-shadow:0 0 2px 0px rgba(16,185,129,0.1)}
}
.time-loaded,
.time-now {
font-variant-numeric: tabular-nums;
@@ -86,11 +204,16 @@
font-size: 1rem;
}
.live-indicator {
margin: 0.25rem auto;
}
.service-list-wrapper {
flex: 1;
overflow-y: auto;
height: calc(100dvh - 60px);
width: 100%;
width: 97%;
max-width: 700px;
margin-bottom: 5px;
}
</style>

View File

@@ -12,7 +12,7 @@ export const load: PageLoad = async ({ url, fetch }) => {
if (!locId) {
error(400, {
message: 'Location not provided',
owlCode: 'NO_LOCATION_IN_PATH'
code: 'VALIDATION'
});
}
@@ -21,7 +21,7 @@ export const load: PageLoad = async ({ url, fetch }) => {
if (!BoardLocation) {
error(404, {
message: `Location (${locId.toUpperCase()}) not found`,
owlCode: 'INVALID_LOCATION_CODE'
code: 'VALIDATION'
});
}
const title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
@@ -35,30 +35,6 @@ export const load: PageLoad = async ({ url, fetch }) => {
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' });
throw e;
}
};

View File

@@ -2,7 +2,7 @@
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 Button from '$lib/components/ui/form-elements/Button.svelte';
import type { ApiPisObject } from '@owlboard/owlboard-ts';
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import TocStyle from '$lib/components/ui/TocStyle.svelte';
@@ -18,14 +18,17 @@
try {
const response = await OwlClient.pis.getByStartEndCrs(start, end);
results = response.data || [];
} catch (e) {
} catch (e: unknown) {
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}` };
errorState = {
status: 0,
message: `Unknown Error: ${e instanceof Error ? e.message : String(e)}`
};
}
} finally {
resultsLoaded = true;
@@ -45,7 +48,10 @@
console.log(e);
errorState = { status: 20, message: e.message };
} else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` };
errorState = {
status: 0,
message: `Unknown Error: ${e instanceof Error ? e.message : String(e)}`
};
}
} finally {
resultsLoaded = true;

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Button from '$lib/components/ui/form-elements/Button.svelte';
import Toggle from '$lib/components/ui/form-elements/Toggle.svelte';
import { quickLinks } from '$lib/quick-links.svelte';
import { prefs } from '$lib/preferences.svelte';
</script>
<section class="page-body">
<BaseCard header={'Display Settings'}>
<section class="card-content">
<Button onclick={() => quickLinks.reset()}>Reset QuickLinks</Button>
<div class="toggle-container">
<Toggle
label={'Show stations near to you'}
id={'toggle-nearby-stations'}
checked={prefs.current.NearToMe}
onchange={(checked) => prefs.toggleNearToMe(checked)}
/>
<Toggle
label={'Show passsing trains on boards'}
id={'toggle-passing-trains'}
checked={prefs.current.ShowPassingTrains}
onchange={(checked) => prefs.toggleShowPassingTrains(checked)}
/>
<Toggle
label={'Keep screen awake on live pages'}
id={'toggle-board-wake-lock'}
checked={prefs.current.BoardWakeLock}
onchange={(checked) => prefs.toggleBoardWakeLock(checked)}
/>
<Toggle
label={'Auto-refresh data'}
id={'toggle-auto-refresh'}
checked={prefs.current.AutoRefresh}
onchange={(checked) => prefs.toggleAutoRefresh(checked)}
/>
</div>
</section>
</BaseCard>
</section>
<style>
.page-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem 0 1rem 0;
}
.card-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 5px 0 5px 0;
}
.toggle-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,5 @@
export const load = () => {
return {
title: 'Preferences'
};
};

View File

@@ -0,0 +1,218 @@
<script lang="ts">
</script>
<section class="policy-block-container">
<p class="policy-meta"><em>Last updated: May 2026</em></p>
<p class="policy-intro">
The OwlBoard project is built to help you track trains, not track you. Here is exactly what data
is used, how it is used, and why.
</p>
<hr class="policy-divider" />
<section class="policy-section">
<h3 class="section-heading">1. The Data Processed (And What Is Discarded)</h3>
<ul class="policy-list">
<li class="list-item">
<strong class="highlight-text">Email Addresses:</strong> During sign-up, an email address is
requested solely to send a verification code. This needs to happen, as some data is
restricted to rail staff only due to licensing restrictions.
<strong
>The moment the verification code is sent, the email address is permanently deleted
from the system.</strong
> It is not stored, it is not logged, and it cannot be looked up later.
</li>
<li class="list-item">
<strong class="highlight-text">Platform Identity:</strong> Instead of an email, an account
is identified by two things:
<ol class="policy-nested-list">
<li>
A unique alphanumeric <strong class="code-text">"key"</strong> generated randomly for the
device upon verification.
</li>
<li>The <strong class="code-text">domain portion</strong> of the registration email.</li>
</ol>
<blockquote class="policy-example">
<strong>Example:</strong> If signing up as <code>user@owlboard.info</code>, the email is
wiped, and the account identifier becomes <code>[Your-Secret-Key] @ owlboard.info</code>.
This allows OwlBoard to manage access without knowing who you are.
</blockquote>
</li>
<li class="list-item">
<strong class="highlight-text">Fuzzy Location Data:</strong> If the "Near to Me" toggle is
enabled, exact GPS coordinates are calculated, but never leave your device. Your device compresses the position
into a <strong class="highlight-text">"fuzzy location block"</strong> (a geohash) roughly 1.2km
by 0.6km in size before contacting the server to request nearby stations. OwlBoard only processes
that neighborhood block—never an exact location.
</li>
<li class="list-item">
<strong class="highlight-text">Server Logs &amp; IP Addresses:</strong> Like any web application,
self-hosted security tools and the Web Application Firewall (WAF) temporarily process network
IP addresses. This is strictly for system health, infrastructure monitoring, and blocking malicious or
automated attacks.
</li>
</ul>
</section>
<hr class="policy-divider" />
<section class="policy-section">
<h3 class="section-heading">2. What Is Considered "Personal Data"?</h3>
<p class="policy-text">
Under the GDPR, "Personal Data" is anything that can directly or indirectly pinpoint a living
individual.
</p>
<p class="policy-text">
Because email addresses are discarded instantly and location hardware telemetry is fuzzed
directly on the device, <strong
>station boards, preferences, and account keys are completely pseudonymized.</strong
> However, because network IP addresses are technically classified as personal data under European
law, transient security logs are treated with the highest level of guardrail protection.
</p>
</section>
<hr class="policy-divider" />
<section class="policy-section">
<h3 class="section-heading">
3. Subject Access Requests (SAR) &amp; The Right to be Forgotten
</h3>
<p class="policy-text">
Absolute sovereignty over your digital footprint should be standard, without jumping through
bureaucratic hoops. There is no need to send an email to exercise GDPR rights:
</p>
<ul class="policy-list">
<li class="list-item">
<strong class="highlight-text">Instant SAR (Right of Access):</strong> The
<strong>Account</strong> dashboard contains a dedicated section displaying
every single line of data which can be identified with the account key block. It can be viewed or copied instantly.
</li>
<li class="list-item">
<strong class="highlight-text">The Right to be Forgotten (Instant Erasure):</strong> A
prominent <strong>"Delete Account"</strong> button is available on that same page. Clicking this
instantly wipes the account key. You will be able to re-register at any time, and all of your preferences remain only on your device.
</li>
</ul>
<p class="policy-text">
While the pseudonymized settings left behind are not technically personal data once
disconnected from an identity, the OwlBoard project operates on a simple philosophy.
When the delete button is pressed, the data is erased.
</p>
</section>
<hr class="policy-divider" />
<section class="policy-section">
<h3 class="section-heading">4. No Third-Party Conglomerates</h3>
<p class="policy-text">
All data is hosted transparently on isolated, self-hosted server infrastructure in Somerset. OwlBoard does
not use third-party analytics trackers, tracking cookies, or advertising networks.
</p>
</section>
</section>
<style>
.policy-block-container {
margin: 1rem auto;
width: 90%;
max-width: 650px;
font-family: 'URW Gothic', sans-serif;
text-align: center;
display: flex;
flex-direction: column;
gap: 0;
}
.policy-block-container:last-child {
padding-bottom: 15px;
}
.policy-meta {
font-size: 0.875rem;
color: var(--color-text-muted, #64748b);
margin-bottom: 2rem;
}
.policy-intro {
font-size: 1.125rem;
line-height: 1.6;
margin-bottom: 2rem;
}
.policy-divider {
border: 0;
height: 1px;
background: var(--color-border, #e2e8f0);
margin: 2rem 0;
}
/* --- Section Blocks --- */
.policy-section {
margin-bottom: 2.5rem;
}
.section-heading {
font-size: 1.5rem;
font-weight: 600;
margin-top: 0;
margin-bottom: 1.25rem;
}
.policy-text {
line-height: 1.6;
margin-top: 0;
margin-bottom: 1rem;
}
/* --- Lists & Typography Elements --- */
.policy-list {
list-style-type: disc;
padding-left: 1.5rem;
margin: 0 0 1.5rem 0;
}
.list-item {
line-height: 1.6;
margin-bottom: 1.25rem;
}
.list-item:last-child {
margin-bottom: 0;
}
.policy-nested-list {
list-style-type: decimal;
padding-left: 1.5rem;
margin: 0.5rem 0;
}
.policy-nested-list li {
margin-bottom: 0.5rem;
}
.policy-example {
background-color: var(--color-bg-light);
border-left: 4px solid var(--color-accent);
margin: 1rem 0;
padding: 1rem;
font-style: normal;
font-size: 0.95rem;
line-height: 1.5;
border-radius: 0 4px 4px 0;
}
.code-text,
.policy-example code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875rem;
background-color: var(--color-accent);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
</style>

View File

@@ -0,0 +1,5 @@
export const load = () => {
return {
title: 'Privacy'
};
};

View File

@@ -1,7 +1,6 @@
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import { OwlClient, ApiError, ValidationError, ThrowApiError } 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');
@@ -10,53 +9,21 @@ export const load: PageLoad = async ({ fetch, url }) => {
const date: string | Date = dateParam === '' || dateParam === null ? new Date() : dateParam;
if (!headcode) {
throw error(400, {
message: 'Headcode not provided',
owlCode: 'INVALID_DATA'
});
}
// Validation removed, handled by the API Client Lib
// 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);
const response = await OwlClient.trains.getByHeadcode(headcode || '', date, toc, fetch);
let title = !!headcode ? headcode.toUpperCase() : 'Error';
results = response.data;
return {
title: headcode.toUpperCase(),
title: title,
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'
});
}
ThrowApiError(e);
}
};

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

@@ -0,0 +1,121 @@
/// <reference types="@sveltejs/kit" />
/// <reference lib="webworker" />
import type { ApiEnvelope } from '@owlboard/api-schema-types';
import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope;
const CACHE_NAME = `owlboard-cache-${version}`;
const ASSETS = [
...build,
...files.filter((file) => !file.endsWith('manifest.json') && !file.startsWith('/icons/')),
'/'
];
sw.addEventListener('install', (event) => {
sw.skipWaiting();
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
});
sw.addEventListener('activate', (event) => {
event.waitUntil(
Promise.all([
caches.keys().then((keys) => {
return Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
);
}),
sw.clients.claim()
])
);
});
sw.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
// Uncached static assets
if (url.pathname.endsWith('manifest.json') || url.pathname.includes('/icons/')) {
event.respondWith(fetch(request));
return;
}
// Static Assets (Cache-First)
if (ASSETS.includes(url.pathname) || url.pathname.startsWith('/_app/')) {
event.respondWith(caches.match(request).then((res) => res || fetch(request)));
return;
}
// Cachable API Responses - stale-while-revalidate
if (url.pathname === '/api/v3/locationFilter/data') {
event.respondWith(
caches.open('ob-dynamic-cache').then(async (cache) => {
const cachedResponse = await cache.match(request);
// Ensure fallback response exists
const makeOfflineFallback = () => {
const errorObject: ApiEnvelope.Envelope = {
e: {
code: 'NETWORK_DISCONNECTED',
msg: 'Cannot connect to the OwlBoard server'
},
t: Math.floor(Date.now() / 1000)
};
return new Response(JSON.stringify(errorObject), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
};
const networkFetch = fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
})
.catch(() => {
// If offline & cache empty, return fallback
return makeOfflineFallback();
});
// If cachedResponse is undefined, networkFetch safely resolves to a Response
return cachedResponse || networkFetch;
})
);
return;
}
// Offline API Fallback (Network-First)
if (url.pathname.startsWith('/api') || url.hostname.includes('api')) {
event.respondWith(
fetch(request).catch(() => {
const errorObject: ApiEnvelope.Envelope = {
e: {
code: 'NETWORK_DISCONNECTED',
msg: 'Cannot connect to the OwlBoard server'
},
t: Math.floor(Date.now() / 1000)
};
return new Response(JSON.stringify(errorObject), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
})
);
return;
}
// Nav fallback - return fallback page, if offline and page requested
if (request.mode === 'navigate' || request.headers.get('accept')?.includes('text/html')) {
event.respondWith(
fetch(request).catch(() => {
// Serve svelte fallback page
return caches.match('/') as Promise<Response>;
})
);
return;
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

62
static/manifest.json Normal file
View File

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