Compare commits

..

29 Commits

Author SHA1 Message Date
893fc727e7 Further styling to staff view grid 2026-05-24 00:37:00 +01:00
0a9fb02b9f Adjust display logic 2026-05-23 01:18:39 +01:00
6a99ffc806 Minot animation again... 2026-05-22 00:16:15 +01:00
9ea8bef99c Minor animation adjustments - still not as desired! 2026-05-22 00:15:58 +01:00
9088de9277 Minor adjustments to improve transitions... not currently working on time changes 2026-05-22 00:07:22 +01:00
0877d98bef Adjust header sizing and padding 2026-05-22 00:00:20 +01:00
0eb2f4349c Initial grid staff view layout 2026-05-21 23:56:59 +01:00
d10ca44023 Display cancellation reason ELSE delay reason... there is no need to display both if the train is cancelled! 2026-05-21 22:11:53 +01:00
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
a2e6f3b99a Switch departure board to use Inconsolata monospaced font (varying widths, weights, etc.) 2026-05-11 20:28:22 +01:00
7edccf294b Update table display on board page, in preparation for offering different 'table' styles. 2026-05-11 12:04:40 +01:00
13d22d7b73 Add initial departure board table 'skeleton', very basic at present 2026-05-10 20:57:50 +01:00
46 changed files with 1641 additions and 207 deletions

65
package-lock.json generated
View File

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

View File

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

4
src/app.d.ts vendored
View File

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

View File

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

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"> <script lang="ts">
import noResult from '$lib/assets/img/no-data.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';
let { title = 'No results', message = 'Try checking your search term' } = $props(); let { title = 'No results', message = 'Try checking your search term' } = $props();
</script> </script>

View File

@@ -6,7 +6,8 @@
import { formatUkTime, calculateDelay } from '$lib/utils/time'; import { formatUkTime, calculateDelay } from '$lib/utils/time';
import TocStyle from '$lib/components/ui/TocStyle.svelte'; import TocStyle from '$lib/components/ui/TocStyle.svelte';
import TiplocConverter from '$lib/components/ui/TiplocConverter.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 { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse } = $props();
let isExpanded = $state(false); let isExpanded = $state(false);
let loadingDetails = $state(false); let loadingDetails = $state(false);
@@ -45,8 +46,6 @@
loadingDetails = false; loadingDetails = false;
} }
const estClass = (act, est) => (!act && est ? 'est' : 'act');
const activityMap: Record<string, string> = { const activityMap: Record<string, string> = {
'-D': 'Vehicles detatched', '-D': 'Vehicles detatched',
'-T': 'Vehicles attached & detached', '-T': 'Vehicles attached & detached',

View File

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

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte'; import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte'; import Textbox from '$lib/components/ui/form-elements/Textbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
let headcode = $state(''); let headcode = $state('');

View File

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

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte'; import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import 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 { fade } from 'svelte/transition';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
@@ -12,7 +14,9 @@
<BaseCard header={'Nearby Stations'}> <BaseCard header={'Nearby Stations'}>
<div class="card-content"> <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> <p class="msg">{nearestStationsState.error}</p>
{:else if nearestStationsState.loading && nearestStationsState.list.length === 0} {:else if nearestStationsState.loading && nearestStationsState.list.length === 0}
<p class="msg">Locating stations...</p> <p class="msg">Locating stations...</p>

View File

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

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte'; import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte'; import Textbox from '$lib/components/ui/form-elements/Textbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { onsearch }: { onsearch: (c: string) => void } = $props(); let { onsearch }: { onsearch: (c: string) => void } = $props();
</script> </script>

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <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 { onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';

View File

@@ -2,7 +2,7 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { HTMLInputAttributes } from 'svelte/elements'; import type { HTMLInputAttributes } from 'svelte/elements';
import { IconChevronRightFilled } from '@tabler/icons-svelte'; import { IconChevronRightFilled } from '@tabler/icons-svelte-runes';
interface Props extends HTMLInputAttributes { interface Props extends HTMLInputAttributes {
value?: string; 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,386 @@
<script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, delayClassFromTimePair, isRightTime } from '$lib/utils/time';
import { fade, slide, fly } from 'svelte/transition';
import { flip } from 'svelte/animate';
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 container">
<div class="header row">
<div class="upper-header-blank"></div>
<div class="upper-header-text">Arr</div>
<div class="upper-header-text">Dep</div>
</div>
<div class="header row">
<div class="cell">ID</div>
<div class="cell">Orig</div>
<div class="cell">Dest</div>
<div class="cell">Plt</div>
<div class="cell">Sch</div>
<div class="cell">Act</div>
<div class="cell">Sch</div>
<div class="cell">Act</div>
</div>
</div>
<div class="services">
{#each services as service (getRowKey(service))}
{@const realArrival = formatUkTime(service.ata || service.eta)}
{@const realDeparture = formatUkTime(service.atd || service.etd)}
{@const realPass = formatUkTime(service.atp || service.etp)}
<div
class="service-block"
animate:flip={{ duration: 400 }}
transition:slide={{ duration: 300 }}
>
<div
class="service row"
class:passing={service.wtp}
class:nonPax={!service.ip}
class:cancelled={service.c}
>
<div class="cell id">{service.h}</div>
<div class="cell orig">
{#key service.og.t}
<div transition:fade={{ duration: 300 }}>
{service.og.t}
</div>
{/key}
</div>
<div class="cell dest">
{#key service.dt.t}<div transition:fade={{ duration: 300 }}>{service.dt.t}</div>{/key}
</div>
<div class="cell plt" class:platsup={service.ps} class:platchange={service.pc}>
{#key service.p}<div transition:fade={{ duration: 300 }}>{service.p || '-'}</div>{/key}
</div>
{#if service.wtp}
<div class="cell pass">PASS</div>
<div class="cell sch">
{formatUkTime(service.wtp)}
</div>
<div
class="cell act {estClass(service.atp, service.etp)} {delayClassFromTimePair(service.wtp, service.atp || service.etp)}"
>
{#if isRightTime(service.wtp, service.atp || service.etp)}
RT
{:else if service.c}
CANC
{:else}
{realPass}
{/if}
</div>
{:else}
<div class="cell sch">{formatUkTime(service.sta)}</div>
<div
class="cell act {estClass(service.ata, service.eta)} {delayClassFromTimePair(service.sta, service.ata || service.eta)}"
>
{#if isRightTime(service.sta, service.ata || service.eta)}
RT
{:else if service.sta && service.c} CANC {:else}
{realArrival}
{/if}
</div>
<div class="cell sch">{formatUkTime(service.std)}</div>
<div class="cell act {estClass(service.atd, service.etd)} {delayClassFromTimePair(service.std, service.atd || service.etd)}">
{#if isRightTime(service.std, service.atd || service.etd)}
RT
{:else if service.std && service.c} CANC {:else}
{realDeparture}
{/if}
</div>
{/if}
</div>
<div class="operator row">{service.o}</div>
{#if service?.cr?.r}
{#if service.c}
<div class="cancel-reason row" transition:slide={{ duration: 300 }}>{service.cr.r}</div>
{:else}
<div class="cancel-reason row" transition:slide={{ duration: 300 }}>
Part of this service has been cancelled
</div>
{/if}
{:else if service?.dr?.r}
<div class="delay-reason row" transition:slide={{ duration: 300 }}>{service.dr.r}</div>
{/if}
</div>
{/each}
</div>
</section>
<style>
.departure-board {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0 auto;
box-sizing: border-box;
overscroll-behavior-y: contain;
overflow-y: auto;
}
.header.container {
position: sticky;
padding-top: 7px;
top: 0;
z-index: 10;
background: var(--color-bg-dark);
}
.row.header {
font-weight: 875;
}
.row.header:last-child {
padding-bottom: 1rem;
}
.upper-header-blank {
grid-column: span 4;
}
.upper-header-text {
grid-column: span 2;
text-align: center;
}
.row {
display: grid;
grid-template-columns: 10% 17% 17% 9% 12% 12% 12% 11%;
align-items: center;
text-align: center;
gap: 0;
box-sizing: border-box;
font-family: 'Inconsolata Variable', monospace;
font-size: clamp(1rem, 0.475rem + 2.8vw, 1.35rem);
font-variant-ligatures: additional-ligatures;
}
.service.row.passing {
font-style: italic;
opacity: 0.25;
font-weight: 200;
}
.service.row.nonPax {
opacity: 0.25;
font-weight: 200;
}
.service-block {
width: 100%;
max-width: 100%;
padding-bottom: 7px;
}
/* Cancellation Logic */
.service.cancelled,
.service.cancelled .cell.orig,
.service.cancelled .cell.dest {
color: rgb(255, 131, 131);
}
.service.cancelled .cell.plt,
.service.cancelled .cell.act {
text-decoration: line-through;
opacity: 0.25;
}
.service.cancelled .cell.sch {
text-decoration: line-through;
}
/* Meta Row Styles */
.operator.row,
.delay-reason.row,
.cancel-reason.row {
grid-template-columns: 1fr;
text-align: left;
font-size: 0.88rem;
font-stretch: 80%;
}
.cancel-reason.row {
color: rgb(255, 131, 131);
font-weight: 400;
letter-spacing: -0.1ch;
}
.delay-reason.row {
color: var(--delay-orange);
font-weight: 400;
letter-spacing: -0.1ch;
}
@media (min-width: 375px) {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.08rem;
}
}
@media (min-width: 420px) {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.12rem;
}
}
@media (min-width: 550px) {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.18rem;
}
}
@media (min-width: 620px) {
.operator.row,
.delay-reason.row,
.cancel-reason.row {
font-size: 1.2rem;
}
}
.operator.row {
font-stretch: 110%;
padding: 0;
color: rgb(187, 187, 255);
}
/* Column Specifics */
.cell.id {
text-align: left;
font-weight: 400;
font-stretch: 85%;
filter: brightness(0.75);
}
@media (min-width: 400px) {
.cell.id {
font-stretch: 90%;
}
}
.cell.orig,
.cell.dest {
font-stretch: 80%;
font-weight: 400;
}
.cell.orig {
text-align: left;
color: var(--location-yellow);
}
.cell.dest {
text-align: right;
color: var(--location-yellow);
}
@media (min-width: 350px) {
.cell.orig,
.cell.dest {
font-stretch: 85%;
}
@media (min-width: 400px) {
.cell.orig,
.cell.dest {
font-stretch: 90%;
}
}
@media (min-width: 490px) {
.cell.orig,
.cell.dest {
font-stretch: 95%;
}
}
@media (min-width: 520px) {
.cell.orig,
.cell.dest {
font-stretch: 100%;
}
.cell.orig {
text-align: right;
padding-right: 7px;
}
.cell.dest {
text-align: left;
padding-left: 7px;
}
}
@media (min-width: 600px) {
.cell.orig,
.cell.dest {
letter-spacing: 0.05ch;
}
}
}
.cell.plt {
text-align: center;
font-weight: 410;
font-stretch: 100%;
}
@media (min-width: 525px) {
.cell.plt {
font-stretch: 110%;
}
}
.cell.plt.platsup {
font-weight: 150;
}
.cell.plt.platchange {
animation: fast-pulse 2s ease-out infinite;
}
.pass-cell {
text-align: center;
font-stretch: 100%;
}
.cell.pass {
grid-column: span 2;
}
.cell.sch,
.cell.act {
text-align: center;
font-stretch: 72%;
}
.cell.act.delay-late {
color: var(--delay-orange);
}
.cell.act.delay-early {
color: var(--early-blue);
}
@media (min-width: 350px) {
.cell.sch,
.cell.act {
font-stretch: 77%;
}
}
@media (min-width: 400px) {
.cell.sch,
.cell.act {
font-stretch: 82%;
}
}
@media (min-width: 525px) {
.cell.sch,
.cell.act {
font-stretch: 90%;
}
}
@media (min-width: 600px) {
.cell.sch,
.cell.act {
font-stretch: 100%;
}
}
.cell.act.est {
font-style: italic;
font-weight: 300;
opacity: 0.5;
}
/* Time Types */
.cell.act.estimate {
font-style: italic;
opacity: 0.75;
font-weight: 350;
}
.cell.act.actual {
font-weight: 600;
}
</style>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import type { ApiStationsBoard } from '@owlboard/owlboard-ts'; 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 { messages = [] }: { messages: ApiStationsBoard.BoardMsgs[] } = $props();
let isOpen = $state(false); let isOpen = $state(false);
@@ -41,14 +41,19 @@
<style> <style>
.alert-card { .alert-card {
margin: 1rem 0; margin: 0;
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
position: relative; position: relative;
z-index: 10; z-index: 12;
margin-bottom: 0; margin-bottom: 0;
width: 95%; width: 95%;
max-width: 750px; max-width: 750px;
-webkit-tap-highlight-color: transparent;
}
.alert-card:active {
filter: brightness(1.2);
} }
.trigger { .trigger {
@@ -56,7 +61,7 @@
display: flex; display: flex;
border-radius: 8px; border-radius: 8px;
align-items: center; align-items: center;
padding: 0.4rem 1rem; padding: 0.1rem 1.05rem;
background: var(--alert-orange); background: var(--alert-orange);
color: white; color: white;
border: none; border: none;
@@ -64,13 +69,13 @@
text-align: left; text-align: left;
position: relative; position: relative;
z-index: 2; z-index: 2;
height: 46px; height: 40px;
transition: all 0.65s 0.2s; transition: all 0.65s 0.2s;
} }
.trigger.active { .trigger.active {
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
transition: all 0.1s 0s; transition: all 0.01s 0s;
} }
.warning-icon { .warning-icon {
@@ -105,7 +110,7 @@
.content { .content {
padding: 1rem; padding: 1rem;
position: absolute; position: absolute;
top: 46px; top: 40px;
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;
left: 0; left: 0;
right: 0; right: 0;

View File

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

View File

@@ -129,7 +129,25 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
/* Inconsolata Variable */
@font-face {
font-family: 'Inconsolata Variable';
font-style: normal;
font-display: swap;
font-weight: 200 900;
font-stretch: 50% 200%;
src: url(/type/inconsolata/latin-wdth-normal.woff2) format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root { :root {
/* Fixed Heights */
--header-height: 80px;
--nav-height: 60px;
/* Brand Colours */ /* Brand Colours */
--color-brand: #4fd1d1; --color-brand: #4fd1d1;
--color-accent: #3c6f79; --color-accent: #3c6f79;
@@ -159,7 +177,7 @@
opacity: 1; opacity: 1;
} }
50% { 50% {
opacity: 0.3; opacity: 0.5;
} }
} }
@@ -171,7 +189,7 @@
} }
25%, 25%,
75% { 75% {
opacity: 0; opacity: 0.25;
} }
} }

View File

@@ -1,5 +1,6 @@
import { OwlBoardClient, ValidationError, ApiError } from '@owlboard/owlboard-ts'; import { OwlBoardClient, ValidationError, ApiError } from '@owlboard/owlboard-ts';
import { browser, dev } from '$app/environment'; import { browser, dev } from '$app/environment';
import { error } from '@sveltejs/kit';
// Import the runes containing the API Key config Here... // Import the runes containing the API Key config Here...
@@ -18,4 +19,48 @@ export const OwlClient = new OwlBoardClient(
// API Key Here when ready!!! // 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 }; 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') { if (typeof window !== 'undefined') {
const saved = localStorage.getItem('ql'); const saved = localStorage.getItem('ql');
if (saved) { if (saved) {
this.#links = JSON.parse(saved); try {
this.#links = JSON.parse(saved);
} catch {
this.#links = [];
}
} }
} }
} }
@@ -53,7 +57,16 @@ class QuickLinksService {
this.#links = sorted.slice(0, MAX_ENTRIES); this.#links = sorted.slice(0, MAX_ENTRIES);
localStorage.setItem('ql', JSON.stringify(this.#links)); if (typeof window !== 'undefined') {
localStorage.setItem('ql', JSON.stringify(this.#links));
}
}
reset() {
this.#links = [];
if (typeof window !== 'undefined') {
localStorage.removeItem('ql');
}
} }
} }

View File

@@ -1,4 +1,7 @@
import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts'; import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts';
export const estClass = (act: any, est: any) => (act ? 'actual' : 'estimate');
/** /**
* Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds * Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds
* Ensures Europe/London timezone irrespective of browser timezone. * Ensures Europe/London timezone irrespective of browser timezone.
@@ -7,10 +10,10 @@ export function formatUkTime(
dateStr: string | Date | undefined, dateStr: string | Date | undefined,
includeSeconds: boolean = false includeSeconds: boolean = false
): string { ): string {
if (!dateStr) return '--:--'; if (!dateStr) return '-';
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
if (isNaN(date.getTime())) return '--:--'; if (isNaN(date.getTime())) return '-';
return date.toLocaleTimeString('en-GB', { return date.toLocaleTimeString('en-GB', {
hour: '2-digit', hour: '2-digit',
@@ -49,20 +52,45 @@ export function formatUkDateTime(
.replace(',', ''); .replace(',', '');
} }
/**
* Specific type that can handle the TrainDetails.ServiceLocation and the ApiStationsBoard.BoardService types for delay calculation
*/
interface DelayInput {
// Board types
sta?: string | null;
std?: string | null;
eta?: string | null;
etd?: string | null;
ata?: string | null;
atd?: string | null;
// Journey types
pta?: string | null;
ptd?: string | null;
wta?: string | null;
wtd?: string | null;
atp?: string | null;
wtp?: string | null;
etp?: string | null;
}
/** /**
* Calculates a 'delay' value, in the formats: * Calculates a 'delay' value, in the formats:
* RT, 1E, 7L, etc. * RT, 1E, 7L, etc.
* @param 'Schedule Location' object * @param 'Schedule Location' or 'Board Service' object
* @returns Delay string for departure boards * @returns Delay string for departure boards
*/ */
export function calculateDelay(loc: ApiTrainsTrainDetails.ServiceLocation): { export function calculateDelay(loc: DelayInput): {
val: string; val: string;
type: string; type: string;
} { } {
const pairs = [ const pairs = [
{ actual: loc.atd, sched: loc.ptd ?? loc.wtd }, // Departure check (Board: atd/etd vs std | Journey: atd vs ptd/wtd)
{ actual: loc.ata, sched: loc.pta ?? loc.wta }, { actual: loc.atd || loc.etd, sched: loc.std || loc.ptd || loc.wtd },
{ actual: loc.atp, sched: loc.wtp } // Arrival check (Board: ata/eta vs sta | Journey: ata vs pta/wta)
{ actual: loc.ata || loc.eta, sched: loc.sta || loc.pta || loc.wta },
// Passing check
{ actual: loc.atp || loc.etp, sched: loc.wtp }
]; ];
const match = pairs.find((p) => p.actual && p.sched); const match = pairs.find((p) => p.actual && p.sched);
@@ -80,3 +108,47 @@ export function calculateDelay(loc: ApiTrainsTrainDetails.ServiceLocation): {
return { val: `${absDiff}E`, type: 'early' }; return { val: `${absDiff}E`, type: 'early' };
} }
} }
/**
* Accepts a pair of times (string or Date) and outputs a delay class 'delay-early', 'delay-late' or 'delay-rt'
* @param sched Scheduled Time (string, Date)
* @param act Actual Time (string, Date)
*/
export function delayClassFromTimePair(sched: any, act: any): string {
if (!sched || !act) return '';
const s = new Date(sched).getTime();
const a = new Date(act).getTime();
if (isNaN(s) || isNaN(a)) return '';
const diff = a - s;
const oneMinute = 60000;
// on-time if within one minute
if (Math.abs(diff) < oneMinute) {
return 'delay-rt';
}
return diff > 0 ? 'delay-late' : 'delay-early';
}
/**
* Accepts a pair of times (string or Date) and returns true if service considered 'on-time'
* @param sched Scheduled Time (string, Date)
* @param act Actual Time (string, Date)
*/
export function isRightTime(
sched: string | Date | undefined,
act: string | Date | undefined
): boolean {
console.log(`Checking [sched: ${sched}, act: ${act}]`);
if (!sched || !act) return false;
const s = new Date(sched).getTime();
const a = new Date(act).getTime();
if (isNaN(s) || isNaN(a)) return false;
return Math.abs(a - s) < 60000;
}

View File

@@ -3,16 +3,36 @@
import stopErr from '$lib/assets/img/stop-error.svg'; import stopErr from '$lib/assets/img/stop-error.svg';
import noResult from '$lib/assets/img/no-data.svg'; import 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> </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"> <div class="error-wrapper">
{#if page.status == 404} {#if page.error?.code}
<!-- Warning no data image --> {#if page.error.code === 'NOT_FOUND'}
<img class="err-img" src={noResult} alt="" role="presentation" width="200" height="200" /> <img class="err-img" src={noResult} alt="" role="presentation" width="200" height="200" />
{:else if page.status == 499} {:else if page.error.code === 'RATE_LIMIT'}
<!-- Change to a GSM-R X Sign?? --> <img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
<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} {:else}
<!-- STOP Error image --> <!-- STOP Error image -->
<img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" /> <img class="err-img" src={stopErr} alt="" role="presentation" width="150" height="210" />
@@ -23,14 +43,16 @@
{page.error?.message ?? 'An unexpected derailment occurred.'} {page.error?.message ?? 'An unexpected derailment occurred.'}
</p> </p>
{#if page.error?.owlCode} {#if page.error?.code}
<div class="debug-info"> <div class="debug-info">
<code>Ref: {page.error.owlCode}</code> <code>Ref: {page.error.code}</code>
</div> </div>
{/if} {/if}
{#if $page.error?.owlCode === 'NETWORK_DISCONNECTED'} {#if page.error?.code === 'NETWORK_DISCONNECTED'}
<Button onclick={() => window.location.reload()} color={'accent'}>Retry</Button> <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} {:else}
<Button href={'/'} color={'accent'}>Return to Home</Button> <Button href={'/'} color={'accent'}>Return to Home</Button>
{/if} {/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 { .label {
color: #ff4444; color: #ff4444;
letter-spacing: 0.2rem; letter-spacing: 0.2rem;

View File

@@ -2,9 +2,9 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { slide, fade } from 'svelte/transition'; import { slide, fade } from 'svelte/transition';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { navigating } from '$app/state'; import { navigating } from '$app/state';
import { LOCATIONS } from '$lib/locations-object.svelte'; import { LOCATIONS } from '$lib/locations-object.svelte';
import { nearestStationsState } from '$lib/geohash.svelte';
import Loading from '$lib/components/ui/Loading.svelte'; import Loading from '$lib/components/ui/Loading.svelte';
import TimezoneWarning from '$lib/components/ui/TimezoneWarning.svelte'; import TimezoneWarning from '$lib/components/ui/TimezoneWarning.svelte';
@@ -15,9 +15,28 @@
import logoPlain from '$lib/assets/round-logo.svg'; import logoPlain from '$lib/assets/round-logo.svg';
import favicon 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(); let { children } = $props();
@@ -29,13 +48,14 @@
{ label: 'Home', path: '/', icon: IconHome }, { label: 'Home', path: '/', icon: IconHome },
{ label: 'PIS', path: '/pis/', icon: IconDialpad }, { label: 'PIS', path: '/pis/', icon: IconDialpad },
{ label: 'Options', path: '/preferences/', icon: IconSettings }, { 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 navWidth = $state(0);
let menuOpen = $state(false); let menuOpen = $state(false);
const ITEM_WIDTH = 70; const ITEM_WIDTH = 110;
const MORE_BUTTON_WIDTH = 70; const MORE_BUTTON_WIDTH = 110;
const activePath = $derived(page.url?.pathname ?? ''); const activePath = $derived(page.url?.pathname ?? '');
@@ -94,6 +114,7 @@
<!-- Dynamic Nav Elements Here! --> <!-- Dynamic Nav Elements Here! -->
{#each visibleItems as item} {#each visibleItems as item}
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')} {@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
{@const Icon = item.icon}
<a <a
href={item.path} href={item.path}
class="nav-item" class="nav-item"
@@ -101,7 +122,7 @@
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
onclick={() => (menuOpen = false)} 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> <span class="label">{item.label}</span>
</a> </a>
@@ -126,6 +147,7 @@
<div class="menu-popover" transition:slide={{ axis: 'y', duration: 250 }}> <div class="menu-popover" transition:slide={{ axis: 'y', duration: 250 }}>
{#each hiddenItems as item} {#each hiddenItems as item}
{@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')} {@const isActive = activePath.replace(/\/$/, '') === item.path.replace(/\/$/, '')}
{@const Icon = item.icon}
<a <a
href={item.path} href={item.path}
class="menu-popover-item" class="menu-popover-item"
@@ -133,7 +155,7 @@
class:active={isActive} class:active={isActive}
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
> >
<item.icon size={20} /> <Icon size={20} />
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
{/each} {/each}
@@ -147,14 +169,14 @@
<h1 class="viewport-guard-title">Narrow Gauge Detected</h1> <h1 class="viewport-guard-title">Narrow Gauge Detected</h1>
<p> <p>
Just as trains need the right track width, our data needs a bit more room to stay on the rails. Just as trains need the right track width, our data needs a bit more room to stay on the rails.
Please expand your view to at least 300px to view the app. Please expand your view to at least 320px to view the app.
</p> </p>
</div> </div>
<style> <style>
header { header {
top: 0; top: 0;
height: 80px; height: var(--header-height);
box-shadow: var(--shadow-std); box-shadow: var(--shadow-std);
} }
.logo-link { .logo-link {
@@ -201,13 +223,12 @@
min-height: 100dvh; min-height: 100dvh;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--color-bg-dark); background-color: var(--color-bg-dark);
background-image: radial-gradient(var(--color-bg-dark), var(--color-bg-light));
} }
nav { nav {
display: flex; display: flex;
bottom: 0; bottom: 0;
height: 60px; height: var(--nav-height);
box-shadow: var(--shadow-up); box-shadow: var(--shadow-up);
} }
@@ -320,7 +341,7 @@
display: none; display: none;
} }
@media (max-width: 299px) { @media (max-width: 319px) {
.viewport-guard { .viewport-guard {
display: block; display: block;
position: fixed; position: fixed;

View File

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

View File

@@ -1,11 +1,90 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { quickLinks } from '$lib/quick-links.svelte'; import { quickLinks } from '$lib/quick-links.svelte';
import StationAlertCard from '$lib/components/ui/cards/StationAlertCard.svelte'; import { invalidateAll } from '$app/navigation';
import StationAlertCard from '$lib/components/ui/station-board/StationAlertCard.svelte';
import { formatUkDateTime, formatUkTime } from '$lib/utils/time'; import { formatUkDateTime, formatUkTime } from '$lib/utils/time';
import StaffServicesGrid from '$lib/components/ui/station-board/StaffServicesGrid.svelte';
let { data } = $props(); let { data } = $props();
let now = $state(new Date()); 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(() => { onMount(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -15,46 +94,68 @@
return () => clearInterval(interval); return () => clearInterval(interval);
}); });
// Update 'QuickLinks' const currentBoardId = $derived(
data.BoardLocation ? (data.BoardLocation.c ?? data.BoardLocation.t) : null
);
// Update 'QuickLinks' when currentBoardId changes
$effect(() => { $effect(() => {
if (data.BoardLocation) { if (currentBoardId) {
const id = data.BoardLocation?.c ?? data.BoardLocation?.t; // Keep the execution and logs inside untrack to isolate dependencies completely
if (id) { untrack(() => {
// Untrack, as we do not need to handle changes to quickLinks - this is WRITE_ONLY quickLinks.recordVisit(currentBoardId);
untrack(() => { console.log(`QuickLink visit recorded: ${JSON.stringify(data.BoardLocation)}`);
quickLinks.recordVisit(id); });
console.log(`QuickLink visit recorded: ${JSON.stringify(data.BoardLocation)}`);
});
}
} }
}); });
// Wake Lock API Handling
// Load Data Invalidation Handling
// Refresh countdown logic
</script> </script>
<section class="board-wrapper"> <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} {#if data.boardData.data.m?.length}
<StationAlertCard messages={data.boardData.data.m} /> <StationAlertCard messages={data.boardData.data.m} />
{/if} {/if}
<div class="time-data"> {#if data.boardData.data.s?.length}
<span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span> <div class="service-list-wrapper">
<span class="time-now">{formatUkTime(now, true)}</span> <StaffServicesGrid services={data.boardData.data.s} />
</div> </div>
<pre class="json-dump">{JSON.stringify(data.boardData.data.s, null, 2)}</pre> {:else}
<div class="no-service">
No services at this location in the next three hours
</div>
{/if}
</section> </section>
<style> <style>
.board-wrapper { .board-wrapper {
display: flex; display: flex;
height: calc(100dvh - var(--nav-height) - var(--header-height));
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 0;
overflow: hidden;
gap: 0; gap: 0;
} }
.no-service {
display: block;
margin: auto;
text-align: center;
font-weight: 600;
letter-spacing: 0.15ch;
font-size: 1.4rem;
max-width: 75%;
font-family: 'URW Gothic', sans-serif;
}
.time-data { .time-data {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -65,6 +166,56 @@
font-family: 'URW Gothic', sans-serif; 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-loaded,
.time-now { .time-now {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -78,21 +229,13 @@
font-weight: 600; font-weight: 600;
font-size: 1rem; font-size: 1rem;
} }
pre {
max-width: 100%; .service-list-wrapper {
white-space: pre-wrap; flex: 1;
word-wrap: break-word; overflow-y: auto;
box-sizing: border-box; height: calc(100dvh - 60px);
overflow-x: auto; width: 97%;
} max-width: 700px;
.json-dump { margin-bottom: 5px;
background: #222;
color: #0f0;
padding: 1rem;
border-radius: 4px;
font-size: 0.9rem;
max-height: 500px;
width: 95%;
margin: 1rem auto;
} }
</style> </style>

View File

@@ -12,7 +12,7 @@ export const load: PageLoad = async ({ url, fetch }) => {
if (!locId) { if (!locId) {
error(400, { error(400, {
message: 'Location not provided', message: 'Location not provided',
owlCode: 'NO_LOCATION_IN_PATH' code: 'VALIDATION'
}); });
} }
@@ -21,7 +21,7 @@ export const load: PageLoad = async ({ url, fetch }) => {
if (!BoardLocation) { if (!BoardLocation) {
error(404, { error(404, {
message: `Location (${locId.toUpperCase()}) not found`, message: `Location (${locId.toUpperCase()}) not found`,
owlCode: 'INVALID_LOCATION_CODE' code: 'VALIDATION'
}); });
} }
const title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep'; const title = BoardLocation.n || BoardLocation.t || 'Live Arr/Dep';
@@ -35,30 +35,6 @@ export const load: PageLoad = async ({ url, fetch }) => {
boardData boardData
}; };
} catch (e: unknown) { } catch (e: unknown) {
if ( throw e;
e instanceof TypeError &&
(e.message == 'Failed to fetch' || e.message.includes('network'))
) {
throw error(503, {
message: 'Network error: Please check your connection',
owlCode: 'NETWORK_DISCONNECTED'
});
}
if (e instanceof ValidationError) {
throw error(400, { message: e.message, owlCode: 'VALIDATION_ERROR' });
} else if (e instanceof ApiError) {
// If the API returns 404, it means the backend doesn't recognize this CRS/TIPLOC
if (e.code === 'NOT_FOUND') {
throw error(404, {
message: `Location (${locId.toUpperCase()}) is not recognized by the server.`,
owlCode: 'LOCATION_NOT_IN_BACKEND'
});
}
throw error(e.status, { message: e.message, owlCode: 'API_ERROR' });
} else if (e instanceof Error) {
throw error(500, { message: e.message, owlCode: 'GEN_ERROR' });
}
throw error(500, { message: 'Unexpected error', owlCode: 'UNKNOWN_ERR' });
} }
}; };

View File

@@ -2,7 +2,7 @@
import HeadcodeSearchCard from '$lib/components/ui/cards/HeadcodeSearchCard.svelte'; import HeadcodeSearchCard from '$lib/components/ui/cards/HeadcodeSearchCard.svelte';
import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte'; import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte';
import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte'; import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/form-elements/Button.svelte';
import type { ApiPisObject } from '@owlboard/owlboard-ts'; import type { ApiPisObject } from '@owlboard/owlboard-ts';
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient'; import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import TocStyle from '$lib/components/ui/TocStyle.svelte'; import TocStyle from '$lib/components/ui/TocStyle.svelte';
@@ -18,14 +18,17 @@
try { try {
const response = await OwlClient.pis.getByStartEndCrs(start, end); const response = await OwlClient.pis.getByStartEndCrs(start, end);
results = response.data || []; results = response.data || [];
} catch (e) { } catch (e: unknown) {
if (e instanceof ValidationError) { if (e instanceof ValidationError) {
errorState = { status: 400, message: e.message }; errorState = { status: 400, message: e.message };
} else if (e instanceof ApiError) { } else if (e instanceof ApiError) {
console.log(e); console.log(e);
errorState = { status: 20, message: e.message }; errorState = { status: 20, message: e.message };
} else { } else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` }; errorState = {
status: 0,
message: `Unknown Error: ${e instanceof Error ? e.message : String(e)}`
};
} }
} finally { } finally {
resultsLoaded = true; resultsLoaded = true;
@@ -45,7 +48,10 @@
console.log(e); console.log(e);
errorState = { status: 20, message: e.message }; errorState = { status: 20, message: e.message };
} else { } else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` }; errorState = {
status: 0,
message: `Unknown Error: ${e instanceof Error ? e.message : String(e)}`
};
} }
} finally { } finally {
resultsLoaded = true; 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,221 @@
<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 { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ fetch, url }) => { export const load: PageLoad = async ({ fetch, url }) => {
const headcode = url.searchParams.get('h'); 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; const date: string | Date = dateParam === '' || dateParam === null ? new Date() : dateParam;
if (!headcode) { // Validation removed, handled by the API Client Lib
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 // Declared outside of the try so that it can be used in both the try and catch blocks
let results: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[]; let results: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[];
try { 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; results = response.data;
return { return {
title: headcode.toUpperCase(), title: title,
results: results results: results
}; };
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof ValidationError) { ThrowApiError(e);
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'
});
}
} }
}; };

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"
}
}

Binary file not shown.