diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..46c8155 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Specify the clientside visible variables which are used in the frontend. If using in a docker-compose, let the 2nd variable be the container_name of the backend service. +# Do not add a "/" at the end, as the frontend will add the path to the API endpoint itself. +PUBLIC_BACKEND_BASE_URL=http://localhost:8000 + +# Specify the serverside visible variables which are used in the frontend. +BACKEND_DOCKER_BASE_URL=http://navigator-backend:8000 + +# Redis configuration +REDIS_HOST=navigator-redis +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_USER=navigator-stations +REDIS_PASSWORD=password diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index ce815dd..a8f7475 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -1,8 +1,6 @@ name: Linting Checks on: - push: - branches: ["*"] pull_request: branches: ["*"] workflow_dispatch: diff --git a/backend/Dockerfile b/backend/Dockerfile index ea43c34..bdd3544 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,21 +1,18 @@ -# Use the official Bun image as the base image FROM oven/bun:latest - -# Set the working directory inside the container WORKDIR /app -# Copy dependency files first to leverage Docker layer caching COPY package.json bun.lockb* ./ -# Install dependencies RUN bun install - -# Copy the rest of your application code (all files and folders) COPY . . + +ENV REDIS_HOST=navigator-redis +ENV REDIS_PORT=6379 +ENV REDIS_DB=0 +ENV REDIS_USER=navigator-stations +ENV REDIS_PASSWORD=password + RUN bun tsoa -# Expose port 8000 (the port your app listens on) EXPOSE 8000 - -# Start the application (adjust the entry point file if necessary) CMD ["bun", "run", "server.ts"] diff --git a/backend/controllers/StationsController.ts b/backend/controllers/StationsController.ts index 502059e..ecadccd 100644 --- a/backend/controllers/StationsController.ts +++ b/backend/controllers/StationsController.ts @@ -1,9 +1,10 @@ import { type Station } from "../models/station.ts"; import { mapToEnum, Products } from "../models/products.ts"; -import { Controller, Get, Path, Query, Res, Route, type TsoaResponse } from "tsoa"; +import { Controller, Get, Path, Query, Res, Route, Tags, type TsoaResponse } from "tsoa"; import { getRedisClient } from "../lib/redis.ts"; @Route("stations") +@Tags("Stations") export class StationController extends Controller { @Get() async queryStations( @@ -19,11 +20,11 @@ export class StationController extends Controller { @Get("/{evaNumber}") async getStationByEvaNumber( - @Path() evaNumber: number, + @Path() evaNumber: string, @Res() badRequestResponse: TsoaResponse<400, { reason: string }> ): Promise { - if (!Number.isInteger(evaNumber)) { - return badRequestResponse(400, { reason: "EvaNumber is not an integer" }); + if (!/^\d+$/.test(evaNumber)) { + return badRequestResponse(400, { reason: "evaNumber is not an integer" }); } const cachedStation = await getCachedStation(evaNumber.toString()); @@ -52,12 +53,12 @@ const fetchStation = async (searchTerm: string): Promise => { }); if (!request.ok) { - throw new Error("HTTP Request error occurred"); + throw new Error(`Failed to fetch stations for ${searchTerm}`); } const response = await request.json(); if (!response || !Array.isArray(response)) { - throw new Error("Invalid response format"); + throw new Error(`Response was expected to be an array, but got ${typeof response}`); } return response.map((data: any) => ({ diff --git a/backend/controllers/journey/SequenceController.ts b/backend/controllers/journey/SequenceController.ts new file mode 100644 index 0000000..2b798e9 --- /dev/null +++ b/backend/controllers/journey/SequenceController.ts @@ -0,0 +1,53 @@ +import { Controller, Get, Queries, Res, Route, Tags, type TsoaResponse } from "tsoa"; +import { DateTime } from "luxon"; +import { mapCoachSequence } from "../../lib/mapping.ts"; + +class SequenceQuery { + lineDetails!: string; + evaNumber!: string; + date!: string; +} + +@Route("journey/sequence") +@Tags("Coach Sequences") +export class SequenceController extends Controller { + @Get() + async getSequenceById( + @Queries() query: SequenceQuery, + @Res() badRequestResponse: TsoaResponse<400, { reason: string }> + ): Promise { + if (query.lineDetails === "") { + return badRequestResponse(400, { reason: "lineDetails must not be empty" }); + } + if (!/^\d+$/.test(query.evaNumber)) { + return badRequestResponse(400, { reason: "evaNumber is not an integer" }); + } + + const dateValidation = DateTime.fromFormat(query.date, "yyyyMMdd"); + if (!dateValidation.isValid) { + return badRequestResponse(400, { reason: `${dateValidation.invalidExplanation}` }); + } + + return fetchCoachSequence(query); + } +} + +const fetchCoachSequence = async (query: SequenceQuery): Promise => { + const request = await fetch( + `https://app.vendo.noncd.db.de/mob/zuglaeufe/${query.lineDetails}/halte/by-abfahrt/${query.evaNumber}_${query.date}/wagenreihung`, + { + method: "GET", + headers: { + Accept: "application/x.db.vendo.mob.wagenreihung.v3+json", + "Content-Type": "application/x.db.vendo.mob.wagenreihung.v3+json", + "X-Correlation-ID": crypto.randomUUID() + "_" + crypto.randomUUID() + } + } + ); + + if (!request.ok) { + throw new Error("Failed to fetch coach sequence"); + } + + return mapCoachSequence(await request.json()); +}; diff --git a/backend/controllers/timetable/BahnhofController.ts b/backend/controllers/timetable/BahnhofController.ts index b50d6a7..9587b4c 100644 --- a/backend/controllers/timetable/BahnhofController.ts +++ b/backend/controllers/timetable/BahnhofController.ts @@ -1,5 +1,5 @@ import { type RequestType, retrieveBahnhofJourneys } from "./requests.ts"; -import { Controller, Get, Queries, Route } from "tsoa"; +import { Controller, Get, Queries, Route, Tags } from "tsoa"; import type { Journey } from "../../models/connection.ts"; class BahnhofQuery { @@ -10,6 +10,7 @@ class BahnhofQuery { } @Route("timetable/bahnhof") +@Tags("Bahnhof") export class BahnhofController extends Controller { @Get() async getBahnhofJourneys(@Queries() query: BahnhofQuery): Promise { diff --git a/backend/controllers/timetable/CombinedController.ts b/backend/controllers/timetable/CombinedController.ts index 9f3cfe0..3eaa6b3 100644 --- a/backend/controllers/timetable/CombinedController.ts +++ b/backend/controllers/timetable/CombinedController.ts @@ -3,7 +3,7 @@ import { RequestType, retrieveBahnhofJourneys, retrieveCombinedConnections } fro import type { Connection, Journey } from "../../models/connection.ts"; import { isMatching } from "../../lib/merge.ts"; import calculateDuration from "../../lib/time.ts"; -import { Controller, Get, Queries, Res, Route, type TsoaResponse } from "tsoa"; +import { Controller, Get, Queries, Res, Route, Tags, type TsoaResponse } from "tsoa"; class CombinedQuery { evaNumber!: string; @@ -15,6 +15,7 @@ class CombinedQuery { } @Route("timetable/combined") +@Tags("Combined") export class CombinedController extends Controller { @Get() async getCombinedJourneys( diff --git a/backend/controllers/timetable/VendoController.ts b/backend/controllers/timetable/VendoController.ts index 1ee16c5..7df607b 100644 --- a/backend/controllers/timetable/VendoController.ts +++ b/backend/controllers/timetable/VendoController.ts @@ -1,6 +1,6 @@ import { DateTime } from "luxon"; import { Profile, RequestType, retrieveCombinedConnections, retrieveConnections } from "./requests.ts"; -import { Controller, Get, Queries, Res, Route, type TsoaResponse } from "tsoa"; +import { Controller, Get, Queries, Res, Route, Tags, type TsoaResponse } from "tsoa"; import type { Journey } from "../../models/connection.ts"; class VendoQuery { @@ -13,6 +13,7 @@ class VendoQuery { } @Route("timetable/vendo") +@Tags("Vendo") export class VendoController extends Controller { @Get() async getVendoJourneys( diff --git a/backend/controllers/timetable/requests.ts b/backend/controllers/timetable/requests.ts index 62d2307..7b47ac6 100644 --- a/backend/controllers/timetable/requests.ts +++ b/backend/controllers/timetable/requests.ts @@ -1,7 +1,7 @@ import type { Connection, Journey } from "../../models/connection.ts"; -import mapConnection from "../../lib/mapping.ts"; import { DateTime } from "luxon"; import { mergeConnections } from "../../lib/merge.ts"; +import { mapConnection } from "../../lib/mapping.ts"; export enum RequestType { DEPARTURES = "departures", diff --git a/backend/lib/mapping.ts b/backend/lib/mapping.ts index dc8b6a6..a9887a7 100644 --- a/backend/lib/mapping.ts +++ b/backend/lib/mapping.ts @@ -4,6 +4,7 @@ import { DateTime } from "luxon"; import type { Stop } from "../models/station.ts"; import type { Message } from "../models/message.ts"; import { mapToProduct } from "../models/products.ts"; +import type { Sequence } from "../models/sequence.ts"; const mapConnection = (entry: any, type: "departures" | "arrivals", profile: "db" | "dbnav"): Connection => { const delay: number = calculateDuration( @@ -96,4 +97,54 @@ const mapMessages = ( }; }; -export default mapConnection; +const mapCoachSequence = (entry: any): Sequence => { + return { + track: { + start: { position: entry?.gleis?.start?.position }, + end: { position: entry?.gleis?.ende?.position }, + sections: entry?.gleis?.sektoren?.map((rawSection: any) => ({ + name: rawSection?.bezeichnung, + start: { position: rawSection?.start?.position }, + end: { position: rawSection?.ende?.position }, + gleisabschnittswuerfelPosition: rawSection?.gleisabschnittswuerfelPosition, + firstClass: rawSection?.ersteKlasse + })), + name: entry?.gleis?.bezeichnung + }, + vehicleGroup: entry?.fahrzeuggruppen?.map((rawGroup: any) => ({ + vehicles: rawGroup?.fahrzeuge?.map((rawVehicle: any) => ({ + vehicleType: { + category: rawVehicle?.fahrzeugtyp?.fahrzeugkategorie, + model: rawVehicle?.fahrzeugtyp?.baureihe, + firstClass: rawVehicle?.fahrzeugtyp?.ersteKlasse, + secondClass: rawVehicle?.fahrzeugtyp?.zweiteKlasse + }, + status: rawVehicle?.status, + orientation: rawVehicle?.orientierung, + positionOnTrack: { + start: { position: rawVehicle?.positionAmGleis?.start?.position }, + end: { position: rawVehicle?.positionAmGleis?.ende?.position }, + section: rawVehicle?.positionAmGleis?.sektor + }, + equipment: rawVehicle?.ausstattungsmerkmale?.map((rawEquipment: any) => ({ + type: rawEquipment?.art, + status: rawEquipment?.status + })), + orderNumber: rawVehicle?.ordnungsnummer + })), + tripReference: { + type: rawGroup?.fahrtreferenz?.typ, + line: rawGroup?.fahrtreferenz?.linie, + destination: { name: rawGroup?.fahrtreferenz?.ziel.bezeichnung }, + category: rawGroup?.fahrtreferenz?.gattung, + fahrtNr: rawGroup?.fahrtreferenz?.fahrtnummer + }, + designation: rawGroup?.bezeichnung + })), + direction: entry?.fahrtrichtung === "RECHTS" ? "RIGHT" : "LEFT", + plannedTrack: entry?.gleisSoll, + actualTrack: entry?.gleisVorschau + }; +}; + +export { mapConnection, mapCoachSequence }; diff --git a/backend/models/sequence.ts b/backend/models/sequence.ts new file mode 100644 index 0000000..7a2fba2 --- /dev/null +++ b/backend/models/sequence.ts @@ -0,0 +1,85 @@ +export interface Sequence { + track: Track; + vehicleGroup?: VehicleGroup[]; + direction?: "RIGHT" | "LEFT"; + plannedTrack?: string; + actualTrack?: string; +} + +export interface Track { + start: { position: number }; + end: { position: number }; + sections: Section[] | []; + name: string; // name of the track +} + +export interface Section { + name: string; + start: { position: number }; + end: { position: number }; + gleisabschnittswuerfelPosition: number; // ?????????????????????? + firstClass: boolean; +} + +export interface VehicleGroup { + vehicles: Vehicle[]; + tripReference: TripReference; + designation: string; +} + +export interface TripReference { + type: string; + line: string; + destination: { name: string }; + category: string; + fahrtNr: number; +} + +export interface Vehicle { + vehicleType: VehicleType; + status: string; + orientation: string; + positionOnTrack: PositionOnTrack; + equipment: Equipment[]; + orderNumber?: number; // coach number +} + +/** + * category might be: + * + * POWERCAR + * LOCOMOTIVE + * DOUBLEDECK_* + * PASSENGERCARRIAGE_* + */ +export interface VehicleType { + category: string; + model: string; + firstClass: boolean; + secondClass: boolean; +} + +export interface PositionOnTrack { + start: { position: number }; + end: { position: number }; + section: string; +} + +/** + * SEATS_SEVERELY_DISABLED + * ZONE_FAMILY + * ZONE_QUIET + * INFO + * CABIN_INFANT + * AIR_CONDITION + * TOILET_WHEELCHAIR + * WHEELCHAIR_SPACE + * SEATS_BAHN_COMFORT + * BIKE_SPACE + * ZONE_MULTI_PURPOSE + * BISTRO + */ +export interface Equipment { + type: string; + status: string; +} diff --git a/backend/package.json b/backend/package.json index 0a1508f..afdc68a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,5 +1,5 @@ { - "name": "backend", + "name": "Navigator Backend", "module": "server.ts", "type": "module", "scripts": { diff --git a/backend/server.ts b/backend/server.ts index ca05f44..5e3509b 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -10,6 +10,8 @@ app.use(express.json()); app.use(cors({ origin: "*" })); app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); +app.get("/api-spec", (req, res) => res.download("./build/swagger.json")); +app.get("/api", (req, res) => res.redirect("/api-docs")); RegisterRoutes(app); diff --git a/backend/tsoa.json b/backend/tsoa.json index 0f1ec75..5e42c9c 100644 --- a/backend/tsoa.json +++ b/backend/tsoa.json @@ -6,7 +6,29 @@ "outputDirectory": "build", "specVersion": 3, "version": "1.0.0", - "basePath": "/api/v1/" + "basePath": "/api/v1/", + "tags": [ + { + "name": "Bahnhof", + "description": "Returns journeys from the DeutscheBahn Bahnhof API. All journeys are returned with a RIS ID" + }, + { + "name": "Vendo", + "description": "Returns journeys from the db-vendo-client@6.3.4. It is possible to select the profile which returns the journeys with the depending ID of the profile", + "externalDocs": { + "description": "See more in the API Documentation", + "url": "https://github.com/public-transport/db-vendo-client" + } + }, + { + "name": "Combined", + "description": "Combines both Bahnhof & Vendo journeys into a single response for better convenience" + }, + { + "name": "Stations", + "description": "Returns all stations from the DeutscheBahn API" + } + ] }, "routes": { "routesDir": "build", diff --git a/docker-compose.yml b/docker-compose.yml index 5eb74af..7e741aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,9 @@ services: restart: always ports: [ "3000:3000" ] networks: [ "navigator-network" ] + environment: + PUBLIC_BACKEND_BASE_URL: ${PUBLIC_BACKEND_BASE_URL} + BACKEND_DOCKER_BASE_URL: ${BACKEND_DOCKER_BASE_URL} navigator-backend: container_name: navigator-backend @@ -13,11 +16,11 @@ services: ports: [ "8000:8000" ] networks: [ "navigator-network" ] environment: - REDIS_HOST: navigator-redis - REDIS_PORT: 6379 - REDIS_DB: 0 - REDIS_USER: navigator-stations - REDIS_PASSWORD: pass-your-pw-here + REDIS_HOST: ${REDIS_HOST} + REDIS_PORT: ${REDIS_PORT} + REDIS_DB: ${REDIS_DB} + REDIS_USER: ${REDIS_USER} + REDIS_PASSWORD: ${REDIS_PASSWORD} navigator-redis: container_name: navigator-redis @@ -28,11 +31,11 @@ services: volumes: - ./redis:/usr/local/etc/redis - navigator-stations:/data - command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + command: [ "redis-server", "/usr/local/etc/redis/redis.conf" ] networks: navigator-network: driver: bridge volumes: - navigator-stations: \ No newline at end of file + navigator-stations: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 8fcd689..1464b11 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -4,6 +4,10 @@ WORKDIR /app COPY . . RUN npm install + +ENV PUBLIC_BACKEND_BASE_URL=http://localhost:8000 +ENV BACKEND_DOCKER_BASE_URL=http://navigator-backend:8000 + RUN npm run build RUN npm prune --production # Remove dev dependencies diff --git a/frontend/src/components/StationSearch.svelte b/frontend/src/components/StationSearch.svelte index 3e1da32..c86a645 100644 --- a/frontend/src/components/StationSearch.svelte +++ b/frontend/src/components/StationSearch.svelte @@ -2,6 +2,7 @@ import type { Station } from "$models/station"; import Search from "$components/svg/Search.svelte"; import { onMount } from "svelte"; + import { env } from "$env/dynamic/public"; let { station = $bindable(undefined) }: { station?: Station } = $props(); let open = $state(false); @@ -18,7 +19,8 @@ const searchStations = async (query: string) => { const searchParams = new URLSearchParams({ query }).toString(); - const response = await fetch(`/api/v1/stations?${searchParams}`, { method: "GET" }); + + const response = await fetch(`${env.PUBLIC_BACKEND_BASE_URL}/api/v1/stations?${searchParams}`, { method: "GET" }); if (!response.ok) return; const jsonData = await response.json(); diff --git a/frontend/src/components/sequence/Sequence.svelte b/frontend/src/components/sequence/Sequence.svelte new file mode 100644 index 0000000..0c1b48d --- /dev/null +++ b/frontend/src/components/sequence/Sequence.svelte @@ -0,0 +1,121 @@ + + +
+ + + +
+ {#each sequence.track.sections as section} + {@const style = calculateLength(section.start.position, section.end.position)} +
+ {section.name} +
+ {/each} +
+ + + {#if sequence.vehicleGroup} +
+ {#each sequence.vehicleGroup as group, groupIndex (group)} + + {#each group.vehicles as vehicle, vehicleIndex (vehicle)} + {@const style = calculateLength( + vehicle.positionOnTrack.start.position, + vehicle.positionOnTrack.end.position + )} + {@const vehicleType = vehicle.vehicleType} + {@const isSelected = + selectedVehicle.groupIndex === groupIndex && selectedVehicle.vehicleIndex === vehicleIndex} + + {/each} + {/each} +
+ {/if} + +
+ {tripReference().referenceId} + {tripReference().destination} +
+
diff --git a/frontend/src/components/sequence/VehicleInfo.svelte b/frontend/src/components/sequence/VehicleInfo.svelte new file mode 100644 index 0000000..f78447a --- /dev/null +++ b/frontend/src/components/sequence/VehicleInfo.svelte @@ -0,0 +1,101 @@ + + +{#if !vehicle}{:else} +
+ {#if vehicle?.orderNumber} +
+
+ {#if vehicle?.vehicleType?.category?.includes("LOCOMOTIVE") || vehicle?.vehicleType?.category?.includes("POWERCAR")} + + {:else if vehicle?.vehicleType?.category?.includes("DOUBLEDECK")} + + {:else} + + {/if} + {vehicle?.orderNumber} +
+ +
+ {#if vehicle?.vehicleType?.category?.includes("LOCOMOTIVE")} + Locomotive + {:else if vehicle?.vehicleType?.category?.includes("POWERCAR")} + Powercar + {:else if vehicle?.vehicleType?.firstClass && vehicle?.vehicleType?.secondClass} +
+ 1. + / + 2. Class +
+ {:else if vehicle?.vehicleType?.firstClass} + 1. Class + {:else} + 2. Class + {/if} + {sectionInfo()} +
+
+ {:else} +
+
+ {#if vehicle?.vehicleType?.category?.includes("LOCOMOTIVE") || vehicle?.vehicleType?.category?.includes("POWERCAR")} + + {:else if vehicle?.vehicleType?.category?.includes("DOUBLEDECK")} + + {:else} + + {/if} +
+ +
+ {#if vehicle?.vehicleType?.category?.includes("LOCOMOTIVE")} + Locomotive + {:else if vehicle?.vehicleType?.category?.includes("POWERCAR")} + Powercar + {:else if vehicle?.vehicleType?.firstClass && vehicle?.vehicleType?.secondClass} +
+ 1. + / + 2. Class +
+ {:else if vehicle?.vehicleType?.firstClass} + 1. Class + {:else} + 2. Class + {/if} + {sectionInfo()} +
+
+ {/if} +
+{/if} diff --git a/frontend/src/components/sequence/coaches/big/DoubleDeck.svelte b/frontend/src/components/sequence/coaches/big/DoubleDeck.svelte new file mode 100644 index 0000000..fe710e2 --- /dev/null +++ b/frontend/src/components/sequence/coaches/big/DoubleDeck.svelte @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/frontend/src/components/sequence/coaches/big/Locomotive.svelte b/frontend/src/components/sequence/coaches/big/Locomotive.svelte new file mode 100644 index 0000000..a5e8d4d --- /dev/null +++ b/frontend/src/components/sequence/coaches/big/Locomotive.svelte @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/frontend/src/components/sequence/coaches/big/SingleFloor.svelte b/frontend/src/components/sequence/coaches/big/SingleFloor.svelte new file mode 100644 index 0000000..15b6d0d --- /dev/null +++ b/frontend/src/components/sequence/coaches/big/SingleFloor.svelte @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/frontend/src/components/sequence/coaches/small/CoachEnd.svelte b/frontend/src/components/sequence/coaches/small/CoachEnd.svelte new file mode 100644 index 0000000..a7701e6 --- /dev/null +++ b/frontend/src/components/sequence/coaches/small/CoachEnd.svelte @@ -0,0 +1,38 @@ + + + + + + + {#if firstClass} + + {/if} + + + + + diff --git a/frontend/src/components/sequence/coaches/small/CoachMiddle.svelte b/frontend/src/components/sequence/coaches/small/CoachMiddle.svelte new file mode 100644 index 0000000..3237f69 --- /dev/null +++ b/frontend/src/components/sequence/coaches/small/CoachMiddle.svelte @@ -0,0 +1,39 @@ + + + + + + + + {#if firstClass} + + {/if} + + + + + diff --git a/frontend/src/components/sequence/coaches/small/CoachStart.svelte b/frontend/src/components/sequence/coaches/small/CoachStart.svelte new file mode 100644 index 0000000..0be5dd1 --- /dev/null +++ b/frontend/src/components/sequence/coaches/small/CoachStart.svelte @@ -0,0 +1,38 @@ + + + + + + + {#if firstClass} + + {/if} + + + + + diff --git a/frontend/src/components/sequence/coaches/small/ControlcarEnd.svelte b/frontend/src/components/sequence/coaches/small/ControlcarEnd.svelte new file mode 100644 index 0000000..e1eae89 --- /dev/null +++ b/frontend/src/components/sequence/coaches/small/ControlcarEnd.svelte @@ -0,0 +1,38 @@ + + + + + + + {#if firstClass} + + {/if} + + + + + diff --git a/frontend/src/components/sequence/coaches/small/ControlcarStart.svelte b/frontend/src/components/sequence/coaches/small/ControlcarStart.svelte new file mode 100644 index 0000000..be18b5b --- /dev/null +++ b/frontend/src/components/sequence/coaches/small/ControlcarStart.svelte @@ -0,0 +1,38 @@ + + + + + + + {#if firstClass} + + {/if} + + + + + diff --git a/frontend/src/components/sequence/coaches/small/Locomotive.svelte b/frontend/src/components/sequence/coaches/small/Locomotive.svelte new file mode 100644 index 0000000..fd57d96 --- /dev/null +++ b/frontend/src/components/sequence/coaches/small/Locomotive.svelte @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/frontend/src/components/timetable/TimetableNavbar.svelte b/frontend/src/components/timetable/TimetableNavbar.svelte index 2153c1f..d338cdc 100644 --- a/frontend/src/components/timetable/TimetableNavbar.svelte +++ b/frontend/src/components/timetable/TimetableNavbar.svelte @@ -10,9 +10,9 @@ { + onclick={async () => { type = "departures"; - gotoTimetable( + await gotoTimetable( page.params.evaNumber, type, page.url.searchParams.get("startDate") ?? DateTime.now().set({ second: 0, millisecond: 0 }).toISO() @@ -23,9 +23,9 @@ { + onclick={async () => { type = "arrivals"; - gotoTimetable( + await gotoTimetable( page.params.evaNumber, type, page.url.searchParams.get("startDate") ?? DateTime.now().set({ second: 0, millisecond: 0 }).toISO() diff --git a/frontend/src/components/timetable/TimetableSearch.svelte b/frontend/src/components/timetable/TimetableSearch.svelte index db91a0e..aec8c2d 100644 --- a/frontend/src/components/timetable/TimetableSearch.svelte +++ b/frontend/src/components/timetable/TimetableSearch.svelte @@ -61,9 +61,9 @@ class="{stationSelected && dateSelected ? 'bg-accent text-black' : 'bg-primary text-text hover:bg-secondary'} flex items-center justify-center rounded-3xl px-4 font-bold text-background md:text-2xl" - onclick={() => { + onclick={async () => { if (!stationSelected || !dateSelected) return; - gotoTimetable(stationSelected?.evaNumber, typeSelected, dateSelected.toISO()); + await gotoTimetable(stationSelected?.evaNumber, typeSelected, dateSelected.toISO()); }} > Search diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index e1f8273..f91283e 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -2,8 +2,8 @@ import { goto } from "$app/navigation"; import type { Stop } from "$models/station"; -const gotoTimetable = (evaNumber: string, type: "departures" | "arrivals", startDate: string) => { - goto(`/${evaNumber}/${type}?startDate=${encodeURIComponent(startDate)}`); +const gotoTimetable = async (evaNumber: string, type: "departures" | "arrivals", startDate: string) => { + await goto(`/${evaNumber}/${type}?startDate=${encodeURIComponent(startDate)}`); }; const writeStop = (stop?: Stop, fallbackName: string = ""): string => { diff --git a/frontend/src/routes/[evaNumber]/[type=type]/+page.server.ts b/frontend/src/routes/[evaNumber]/[type=type]/+page.server.ts index 7a9d16e..e40e784 100644 --- a/frontend/src/routes/[evaNumber]/[type=type]/+page.server.ts +++ b/frontend/src/routes/[evaNumber]/[type=type]/+page.server.ts @@ -3,6 +3,7 @@ import type { Station } from "$models/station"; import { error } from "@sveltejs/kit"; import type { Journey } from "$models/connection"; import { DateTime } from "luxon"; +import { env } from "$env/dynamic/private"; export const load: PageServerLoad = async ({ params, url }): Promise<{ station: Station; journeys: Journey[] }> => { const [station, journeys] = await Promise.all([ @@ -17,7 +18,7 @@ export const load: PageServerLoad = async ({ params, url }): Promise<{ station: }; const loadStation = async (evaNumber: string): Promise => { - const response = await fetch(`http://navigator-backend:8000/api/v1/stations/${evaNumber}`, { + const response = await fetch(`${env.BACKEND_DOCKER_BASE_URL}/api/v1/stations/${evaNumber}`, { method: "GET" }); if (!response.ok) { @@ -38,7 +39,7 @@ const loadJourneys = async (evaNumber: string, type: string, startDate: string): when: startDate }).toString(); - const response = await fetch(`http://navigator-backend:8000/api/v1/timetable/combined?${queryString}`, { + const response = await fetch(`${env.BACKEND_DOCKER_BASE_URL}/api/v1/timetable/combined?${queryString}`, { method: "GET" }); if (!response.ok) { diff --git a/frontend/src/routes/[evaNumber]/[type=type]/+page.svelte b/frontend/src/routes/[evaNumber]/[type=type]/+page.svelte index b57a05d..5abe6d1 100644 --- a/frontend/src/routes/[evaNumber]/[type=type]/+page.svelte +++ b/frontend/src/routes/[evaNumber]/[type=type]/+page.svelte @@ -44,13 +44,13 @@ }} /> -
+
{data.station.name}
-
+
{#each data.journeys as journey} {#if !matchesFilter(journey)}{:else if journey.connections.length > 1} @@ -60,7 +60,7 @@ {/each}
-
+
+ import { page } from "$app/state"; + + +
+
+

+ {page.status} +

+

Whoosh! It seems that our provider did not find any coach sequence for this

+
+
diff --git a/frontend/src/routes/journey/coach-sequence/+layout.svelte b/frontend/src/routes/journey/coach-sequence/+layout.svelte new file mode 100644 index 0000000..ff9a859 --- /dev/null +++ b/frontend/src/routes/journey/coach-sequence/+layout.svelte @@ -0,0 +1,20 @@ + + +
+ + + + + {@render children()} +
diff --git a/frontend/src/routes/journey/coach-sequence/+page.server.ts b/frontend/src/routes/journey/coach-sequence/+page.server.ts new file mode 100644 index 0000000..5631b77 --- /dev/null +++ b/frontend/src/routes/journey/coach-sequence/+page.server.ts @@ -0,0 +1,35 @@ +import type { Sequence } from "$models/sequence"; +import { error } from "@sveltejs/kit"; +import { DateTime } from "luxon"; +import { env } from "$env/dynamic/private"; + +export const load = async ({ url }): Promise<{ sequence: Sequence }> => { + const lineDetails = url.searchParams.get("lineDetails"); + const evaNumber = url.searchParams.get("evaNumber"); + const date = url.searchParams.get("date"); + + if (!lineDetails || !evaNumber || !date) { + throw error(400, "Missing required parameters"); + } + + if (!/^\d+$/.test(evaNumber)) { + throw error(400, "evaNumber is not an integer"); + } + + const dateValidation = DateTime.fromFormat(date, "yyyyMMdd"); + if (!dateValidation.isValid) { + return error(400, `${dateValidation.invalidExplanation}`); + } + + const request = await fetch( + `${env.BACKEND_DOCKER_BASE_URL}/api/v1/journey/sequence?lineDetails=${lineDetails}&evaNumber=${evaNumber}&date=${date}`, + { + method: "GET" + } + ); + if (!request.ok) { + throw error(400, "Failed to fetch coach sequence"); + } + + return { sequence: (await request.json()) as Sequence }; +}; diff --git a/frontend/src/routes/journey/coach-sequence/+page.svelte b/frontend/src/routes/journey/coach-sequence/+page.svelte new file mode 100644 index 0000000..38a3e9f --- /dev/null +++ b/frontend/src/routes/journey/coach-sequence/+page.svelte @@ -0,0 +1,45 @@ + + + + +
+ +
diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..c8a7ae1 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,43 @@ +server { + listen 80; + listen [::]:80; + + server_name example.com; # Adjust your domain here + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + + server_name example.com; # Adjust your domain here + + # Place here your SSL keys + ssl_certificate /etc/nginx/certs/.pem; + ssl_certificate_key /etc/nginx/certs/.key; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + # Backend base URL + location /api/ { + proxy_pass http://navigator-backend:8000; # container_name of the backend + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # Frontend base URL + location / { + proxy_pass http://navigator-frontend:3000; # container_name of the frontend + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } +} \ No newline at end of file