13 Commits

Author SHA1 Message Date
fd213d6340 Refine filtering logic
Remove api-types package as the types are re-exported by the TS Client package
2026-03-25 10:50:26 +00:00
3eceddf20a Bump OwlBoard-TS 2026-03-25 10:24:58 +00:00
1d461780ab Add proper capitalization to page titles and location names. 2026-03-24 15:44:50 +00:00
ec4dd5dd3b Move 'LocationFilter' fetching to API Lib package 2026-03-24 12:47:39 +00:00
a7c244171c Add display options to PIS 2026-03-20 19:41:36 +00:00
3467f97889 Add PIS fetch logic 2026-03-19 23:41:12 +00:00
b1d8eea518 Add PIS Logic 2026-03-19 20:46:12 +00:00
deb151075a Add OwlBoard API Library
Add styling to UI Components
2026-03-19 10:59:25 +00:00
d9b60daa8b Add PIS code search components 2026-03-18 20:03:02 +00:00
2f0a6b9646 Add PIS Page 2026-03-18 19:20:14 +00:00
1165c02e26 Add OwlBoard npm repo 2026-03-18 19:07:31 +00:00
45dd5a1cf5 ALL_CAPS the locId in the error message for location_not_found error 2026-03-17 20:27:34 +00:00
e47bebe7d4 Add dynamic title sizing for better display on smaller screens 2026-03-17 20:26:04 +00:00
17 changed files with 408 additions and 29 deletions

1
.npmrc
View File

@@ -1 +1,2 @@
engine-strict=true
@owlboard:registry=https://git.fjla.uk/api/packages/OwlBoard/npm/

25
package-lock.json generated
View File

@@ -13,6 +13,8 @@
"devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2",
"@owlboard/api-schema-types": "^3.0.2-alpha1",
"@owlboard/owlboard-ts": "^3.0.0-dev.20260324T1240",
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
@@ -778,6 +780,23 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@owlboard/api-schema-types": {
"version": "3.0.2-alpha1",
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fapi-schema-types/-/3.0.2-alpha1/api-schema-types-3.0.2-alpha1.tgz",
"integrity": "sha512-3yqWw28y2DZQmNXgAz8emCN5avX/upBXrTOXj9XLuay3gdVcdELd7BiYODBWfgtwZnSbT0fCgVXgKeTzbhHoSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@owlboard/owlboard-ts": {
"version": "3.0.0-dev.20260324T1240",
"resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fowlboard-ts/-/3.0.0-dev.20260324T1240/owlboard-ts-3.0.0-dev.20260324t1240.tgz",
"integrity": "sha512-s528RtkKLZmx6jZPdj159eKOBEmDHAjKDV0dSEU8/55JMt+7cSXYEqdXC3Cqs6t39wDxsOaPe8P0Q2z6P+d0jg==",
"dev": true,
"license": "GPL-3.0",
"dependencies": {
"@owlboard/api-schema-types": "^3.0.2-alpha1"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -2452,9 +2471,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},

View File

@@ -19,6 +19,7 @@
"devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2",
"@owlboard/owlboard-ts": "^3.0.0-dev.20260325T1023",
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",

View File

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

View File

@@ -72,6 +72,7 @@
transition: all 0.2s ease-in-out;
outline: none;
text-align: center;
box-shadow: var(--shadow-std);
}
.all-caps {

View File

@@ -0,0 +1,53 @@
<script lang="ts">
interface Props {
toc: string;
}
let {
toc
}: Props = $props();
let code = $derived(toc.toUpperCase());
</script>
<div class="toc-container {code}">
{code}
</div>
<style>
.toc-container {
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
font-weight: 800;
background-color: #333;
color: #fff;
}
.GW { /* Great Western Railway */
background: #004225;
color: #E2E2E2;
}
.GR { /* LNER */
background-color: #C00000;
color: #FFFFFF;
}
.VT { /* Avanti West Coast */
background-color: #004354;
color: #FFFFFF;
}
.SW { /* South Western Railway */
background-color: #2A3389;
color: #FFFFFF;
}
.XC { /* CrossCountry */
background-color: #660000;
color: #E4D5B1;
}
</style>

View File

@@ -60,6 +60,7 @@
font-family: 'URW Gothic', sans-serif;
color: var(--color-brand);
padding: 10px 0;
box-shadow: var(--shadow-std);
}
.header-content {

View File

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

View File

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

View File

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

21
src/lib/owlClient.ts Normal file
View File

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

View File

@@ -3,7 +3,7 @@
import { slide, fade } from 'svelte/transition';
import { onMount } from 'svelte';
import { LOCATIONS } from '$lib/locations-object.svelte.ts';
import { LOCATIONS } from '$lib/locations-object.svelte';
import '$lib/global.css';
@@ -13,7 +13,7 @@
import { IconHome, IconDialpad, IconSettings, IconHelp, IconDots } from '@tabler/icons-svelte';
onMount(() => LOCATIONS.init(fetch));
onMount(() => LOCATIONS.init());
let { children } = $props();
@@ -162,10 +162,12 @@
.page-title {
font-family: 'URW Gothic', sans-serif;
font-weight: 600;
font-size: clamp(0.9rem, 2.5vw + 0.8rem, 2rem);
font-style: normal;
margin-left: 5px;
padding-bottom: 2px;
color: var(--color-title);
text-transform: capitalize;
}
header,
nav {

View File

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

View File

@@ -6,7 +6,7 @@ export const load: PageLoad = async ({ url }) => {
const locId = url.searchParams.get('loc');
if (!LOCATIONS.loaded) {
await LOCATIONS.init(fetch);
await LOCATIONS.init();
}
let title: string = '';
@@ -25,7 +25,7 @@ export const load: PageLoad = async ({ url }) => {
title = location.n || location.t;
} else {
error(404, {
message: `Location (${locId}) not found`,
message: `Location (${locId.toUpperCase()}) not found`,
owlCode: 'INVALID_LOCATION_CODE'
});
}

158
src/routes/pis/+page.svelte Normal file
View File

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

5
src/routes/pis/+page.ts Normal file
View File

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

View File

@@ -100,5 +100,7 @@
{"n":"Stevenage Hitchin Junction","t":"STHJC","c":"","s":"stevenage hitchin junction sthjc"},
{"n":"Chelmsford New Hall Junction","t":"CHNJCT","c":"","s":"chelmsford new hall junction chnjct"},
{"n":"","t":"BPWY532","c":"","s":"bpwy532"},
{"n":"Ipswich Derby Road Depot","t":"IPDRDP","c":"","s":"ipswich derby road depot ipdrdp"}
{"n":"Ipswich Derby Road Depot","t":"IPDRDP","c":"","s":"ipswich derby road depot ipdrdp"},
{"n":"Rhoose Cardiff International Airport","c":"RIA","t":"RHOOSE","s":"rhoose cardiff international airport ria"},
{"n":"Southampton Airport Parkway","c":"SOA","t":"SOTAPT","s":"southampton airport parkway soa sotapt"}
]