75 Commits

Author SHA1 Message Date
Spythere ba4a068df7 Merge pull request #10 from Spythere/development
v1.1.3 hotfix
2026-04-07 01:50:47 +02:00
Spythere 9afbd3b1eb hotfix(timetable): incorrect display for departure lines; removed doubling 0.000km and excessive top border 2026-04-06 20:24:41 +02:00
Spythere d27b66cfd9 Merge pull request #9 from Spythere/development
v1.1.3
2026-04-06 15:47:02 +02:00
Spythere 8fea27f5a6 chore(release): fixed footer title 2026-04-06 15:44:27 +02:00
Spythere 1d6838ef53 chore(navbar): added link to version's github release changelog 2026-04-06 15:23:24 +02:00
Spythere ce3546745b chore(timetable): added 0.000 km at the top of timetable 2026-04-06 15:02:33 +02:00
Spythere 06e70afc29 fix(timetable): VP & VL columns width 2026-04-06 14:53:37 +02:00
Spythere cd8c29f327 chore(api): improved fetching data from api; changed to new vehicles data endpoint 2026-04-05 01:28:23 +02:00
Spythere cd22a23aef fix(timetable): light mode directional arrow style 2026-04-05 01:02:04 +02:00
Spythere 5c56c0d63f refactor(http): replaced axios to native fetch api 2026-04-05 00:59:53 +02:00
Spythere eaa7771ca7 chore(tailwind): changed purge to content 2026-04-05 00:58:28 +02:00
Spythere 99fca9f65d chore(timetable): improved directional arrow design 2026-04-05 00:54:57 +02:00
Spythere eb3c42de8f chore(http): added error throw on bad response 2026-04-04 23:41:47 +02:00
Spythere 345c5764f9 bump(version): v1.1.3 2026-04-04 23:41:14 +02:00
Spythere 4c8afe5018 chore(timetable): added vertical directional arrow 2026-04-04 23:40:53 +02:00
Spythere c15939d5d0 chore(workflows): detached old domain from updating on main branch changes 2026-03-10 00:08:27 +01:00
Spythere fa71587ca7 Merge pull request #8 from Spythere/development
Added information bar about migration to a new domain
2026-03-10 00:06:08 +01:00
Spythere f76a5f8603 chore(packages): updated package.json dependencies 2026-03-10 00:03:53 +01:00
Spythere 1a377ddfd8 chore(app): added info bar about migration to a new domain 2026-03-09 23:53:57 +01:00
Spythere a164918d3a chore(workflows): added deploying files to VPS 2025-12-15 13:33:03 +01:00
Spythere 96a14778c8 Merge pull request #7 from Spythere/development
v1.1.2
2025-07-06 16:18:35 +02:00
Spythere 712813aba7 bump: v1.1.2 2025-07-06 16:02:02 +02:00
Spythere 874568d3e4 fix: loco load for multiple units 2025-07-06 16:01:37 +02:00
Spythere 0fae903884 chore: departure real line number (wip) 2025-07-06 15:50:30 +02:00
Spythere 022e937a17 fix: table border alignment 2025-07-06 15:21:45 +02:00
Spythere 812c34ae31 fix: table borders for left & right track speed limits separation 2025-07-06 02:43:59 +02:00
Spythere 0c3e9e7ae2 chore: added support of seconds in schedule dates 2025-07-06 02:33:52 +02:00
Spythere 3cd7201883 fix: 0 load for loco only 2025-07-06 02:32:20 +02:00
Spythere 3c8fad0759 chore: added support for left & right track vmax 2025-07-01 21:56:51 +02:00
Spythere 7397e3fd87 chore: packages upgrade 2025-06-28 16:19:54 +02:00
Spythere c767ee6d2b Merge pull request #6 from Spythere/development
v1.1.1
2025-06-22 14:13:27 +02:00
Spythere 63262ccf4a chore: added timetable bottom warnings info 2025-06-22 14:10:17 +02:00
Spythere 62736d4b04 bump: v1.1.1 2025-06-22 13:42:42 +02:00
Spythere 59d694b233 chore: subtracting loco weight for "obc. lok." cell 2025-06-22 13:39:29 +02:00
Spythere 1df3510df1 Merge pull request #5 from Spythere/development
hotfix: PWA installation settings
2025-05-03 14:19:16 +02:00
Spythere 87f173a645 hotfix: PWA installation settings 2025-05-03 14:18:34 +02:00
Spythere ceb0a49932 Merge pull request #4 from Spythere/development
v1.1.0
2025-05-03 14:13:51 +02:00
Spythere 4c9a560a4a fix: accessibility; minor improvements 2025-05-03 14:04:55 +02:00
Spythere b5edfb8d3e fix: views render logic 2025-05-03 13:49:01 +02:00
Spythere 7aaf620d6a fix: head units regex 2025-05-03 13:47:51 +02:00
Spythere c296ef6dcd fix: build files 2025-05-03 13:38:19 +02:00
Spythere 696d196b05 fix: translations 2025-05-03 13:36:27 +02:00
Spythere 05c04e4aa1 feat: fullscreen mode 2025-05-03 13:35:39 +02:00
Spythere 93acfdb780 fix: names corrections for multiple units 2025-05-02 19:30:19 +02:00
Spythere 4a96ed3852 chore: added icons to storage items 2025-05-02 17:56:02 +02:00
Spythere 35ded92a64 chore: build fixes, missing translations, PWA caching 2025-05-02 17:18:00 +02:00
Spythere 2f946a37b4 chore: print styles; Czech timetable (concept) 2025-05-02 17:08:25 +02:00
Spythere 86185a8f98 chore: added Czech variation of working timetable 2025-04-30 20:31:45 +02:00
Spythere c4473673a7 restruct: timetable view 2025-04-30 18:51:39 +02:00
Spythere 2c660b556e chore: updated gitignore 2025-04-29 22:11:07 +02:00
Spythere 5969b4202c chore: updated translations 2025-04-29 20:33:15 +02:00
Spythere 2d1b573101 chore: improved inputs & warnings styles 2025-04-29 20:30:39 +02:00
Spythere 24875d674f chore: changed icons pack 2025-04-29 20:00:44 +02:00
Spythere b3ee8bd119 chore: fetching data 2025-04-29 01:55:09 +02:00
Spythere 26e348b0be chore: update prompt hover 2025-04-28 13:56:01 +02:00
Spythere 4e8aabe05e feat: offline mode; PWA 2025-04-28 00:10:44 +02:00
Spythere f4aa0b28a1 fix: unable to use print mode when loading timetable using 'id' query parameter 2025-04-27 18:00:55 +02:00
Spythere dc7d0a7ccc fix: doubling warnings 2025-04-25 02:23:22 +02:00
Spythere 4d3d2c68de hotfix: build 2025-04-25 02:16:54 +02:00
Spythere 9515c77620 bump: v1.1.0 2025-04-25 02:15:01 +02:00
Spythere 17d5574d0c feat: journal timetable view mode 2025-04-25 02:14:45 +02:00
Spythere 3b68056acc chore: added storage switching between local and api modes 2025-04-18 02:32:02 +02:00
Spythere ca393057e4 Merge pull request #3 from Spythere/development
v1.0.5
2025-04-15 20:45:48 +02:00
Spythere be64e4b61a bump: 1.0.5 2025-04-15 20:44:52 +02:00
Spythere fab96589c1 chore: limiting speed in VP and VL columns to the Vmax column 2025-04-15 20:44:40 +02:00
Spythere e028905048 Merge pull request #2 from Spythere/development
v1.0.4
2025-04-14 15:34:08 +02:00
Spythere 0c21a8cb65 chore: code comments, print display 2025-04-14 15:33:43 +02:00
Spythere 0a41aa2828 bump: v1.0.4 2025-04-14 14:28:28 +02:00
Spythere bf7d00e29c chore: removed obsolete console logs 2025-04-14 14:27:25 +02:00
Spythere 6bd928d18e chore: removed external site scrollbar 2025-04-14 14:26:45 +02:00
Spythere 5c46209fd3 Merge pull request #1 from Spythere/development
v1.0.3
2025-02-11 19:16:48 +01:00
Spythere 4de4991ff6 fix: generating arrival km 2025-02-10 21:23:36 +01:00
Spythere 0431153326 hotfix: border colors & rendering 2025-02-10 17:32:24 +01:00
Spythere 6a23821f9c bump: v1.0.3 2025-02-10 17:10:09 +01:00
Spythere a34c8807de chore: improved data within Km column 2025-02-10 17:09:49 +01:00
53 changed files with 5910 additions and 1419 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ name: Deploy to Firebase Hosting on merge
on:
push:
branches:
- main
- main-old
jobs:
build_and_deploy:
runs-on: ubuntu-latest
@@ -0,0 +1,17 @@
on:
release:
types: [published]
jobs:
github-releases-to-discord:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Github Releases To Discord
uses: SethCohen/github-releases-to-discord@v1.13.1
with:
webhook_url: '${{ secrets.WEBHOOK_URL }}'
color: '10038562'
footer_title: 'Changelog - Rozkładownik SRJP'
footer_timestamp: true
+23
View File
@@ -0,0 +1,23 @@
name: Build & Deploy to VPS
on:
push:
branches:
- main
env:
PROJECT_NAME: srjp-td2
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build the app
run: yarn && yarn build
- name: Setup SSH key for connection with the server
run: |
mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa
- name: Send new files
run: rsync -avP -e "ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa -p 2022" ./dist/ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/$PROJECT_NAME --delete
+1
View File
@@ -13,6 +13,7 @@ dist-ssr
*.local
.env
.env.*
/dev-dist
# Editor directories and files
.vscode/*
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}
+1 -1
View File
@@ -19,7 +19,7 @@ async function main() {
if (!existsSync('endpoints')) await mkdir('endpoints');
Promise.all(
['getActiveData', 'getDonators', 'getSceneries', 'getVehicles'].map((endpointName) =>
['getActiveData', 'getDonators', 'getSceneries', 'getVehiclesData'].map((endpointName) =>
fetchJSONEndpointData(
`https://stacjownik.spythere.eu/api/${endpointName}`,
`${endpointName}.json`
+2 -2
View File
@@ -15,8 +15,8 @@ app.get('/api/getSceneries', (_, res) => {
res.sendFile(path.join(cwd(), 'endpoints', 'getSceneries.json'));
});
app.get('/api/getVehicles', (_, res) => {
res.sendFile(path.join(cwd(), 'endpoints', 'getVehicles.json'));
app.get('/api/getVehiclesData', (_, res) => {
res.sendFile(path.join(cwd(), 'endpoints', 'getVehiclesData.json'));
});
app.get('/api/getDonators', (_, res) => {
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
# Logger Function
log() {
local message="$1"
local type="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
local color
local endcolor="\033[0m"
case "$type" in
"info") color="\033[38;5;79m" ;;
"success") color="\033[1;32m" ;;
"error") color="\033[1;31m" ;;
*) color="\033[1;34m" ;;
esac
echo -e "${color}${timestamp} - ${message}${endcolor}"
}
# Error handler function
handle_error() {
local exit_code=$1
local error_message="$2"
log "Error: $error_message (Exit Code: $exit_code)" "error"
exit $exit_code
}
# Function to check for command availability
command_exists() {
command -v "$1" &> /dev/null
}
check_os() {
if ! [ -f "/etc/debian_version" ]; then
echo "Error: This script is only supported on Debian-based systems."
exit 1
fi
}
# Function to Install the script pre-requisites
install_pre_reqs() {
log "Installing pre-requisites" "info"
# Run 'apt-get update'
if ! apt-get update -y; then
handle_error "$?" "Failed to run 'apt-get update'"
fi
# Run 'apt-get install'
if ! apt-get install -y apt-transport-https ca-certificates curl gnupg; then
handle_error "$?" "Failed to install packages"
fi
if ! mkdir -p /usr/share/keyrings; then
handle_error "$?" "Makes sure the path /usr/share/keyrings exist or run ' mkdir -p /usr/share/keyrings' with sudo"
fi
rm -f /usr/share/keyrings/nodesource.gpg || true
rm -f /etc/apt/sources.list.d/nodesource.list || true
# Run 'curl' and 'gpg' to download and import the NodeSource signing key
if ! curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg; then
handle_error "$?" "Failed to download and import the NodeSource signing key"
fi
# Explicitly set the permissions to ensure the file is readable by all
if ! chmod 644 /usr/share/keyrings/nodesource.gpg; then
handle_error "$?" "Failed to set correct permissions on /usr/share/keyrings/nodesource.gpg"
fi
}
# Function to configure the Repo
configure_repo() {
local node_version=$1
arch=$(dpkg --print-architecture)
if [ "$arch" != "amd64" ] && [ "$arch" != "arm64" ] && [ "$arch" != "armhf" ]; then
handle_error "1" "Unsupported architecture: $arch. Only amd64, arm64, and armhf are supported."
fi
echo "deb [arch=$arch signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$node_version nodistro main" | tee /etc/apt/sources.list.d/nodesource.list > /dev/null
# N|solid Config
echo "Package: nsolid" | tee /etc/apt/preferences.d/nsolid > /dev/null
echo "Pin: origin deb.nodesource.com" | tee -a /etc/apt/preferences.d/nsolid > /dev/null
echo "Pin-Priority: 600" | tee -a /etc/apt/preferences.d/nsolid > /dev/null
# Nodejs Config
echo "Package: nodejs" | tee /etc/apt/preferences.d/nodejs > /dev/null
echo "Pin: origin deb.nodesource.com" | tee -a /etc/apt/preferences.d/nodejs > /dev/null
echo "Pin-Priority: 600" | tee -a /etc/apt/preferences.d/nodejs > /dev/null
# Run 'apt-get update'
if ! apt-get update -y; then
handle_error "$?" "Failed to run 'apt-get update'"
else
log "Repository configured successfully."
log "To install Node.js, run: apt-get install nodejs -y" "info"
log "You can use N|solid Runtime as a node.js alternative" "info"
log "To install N|solid Runtime, run: apt-get install nsolid -y \n" "success"
fi
}
# Define Node.js version
NODE_VERSION="23.x"
# Check OS
check_os
# Main execution
install_pre_reqs || handle_error $? "Failed installing pre-requisites"
configure_repo "$NODE_VERSION" || handle_error $? "Failed configuring repository"
+11 -11
View File
@@ -1,7 +1,7 @@
{
"name": "srjp-td2",
"private": true,
"version": "1.0.2",
"version": "1.1.3",
"type": "module",
"scripts": {
"dev": "vite --mode staging",
@@ -11,20 +11,20 @@
"preview": "vite preview"
},
"dependencies": {
"@heroicons/vue": "^2.2.0",
"axios": "^1.7.9",
"pinia": "^2.3.1",
"vue": "^3.5.13",
"vue-i18n": "10"
"lucide-vue-next": "^0.577.0",
"pinia": "^3.0.4",
"vue": "^3.5.30",
"vue-i18n": "11.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/tsconfig": "^0.9.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vue-tsc": "^3.2.5"
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg width="50" height="30" viewBox="0 0 50 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25 30L0 0H50L25 30Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 149 B

+5 -6
View File
@@ -5,17 +5,16 @@
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
"type": "image/png"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#151414",
"display": "standalone"
}
"display": "standalone",
"start_url": "."
}
+79 -16
View File
@@ -1,17 +1,34 @@
<template>
<div class="text-white min-h-screen bg-zinc-950">
<Navbar />
<div class="text-white min-h-screen bg-zinc-950 print:bg-white">
<!-- PWA update prompt -->
<transition name="slide-anim">
<UpdatePrompt v-if="needRefresh" @onUpdateClick="updateApp()" />
</transition>
<!-- Content -->
<Navbar v-if="!globalStore.fullscreenMode" />
<MainContainer />
<!-- Migrate Info -->
<transition name="slide-anim">
<MigrateInfo v-if="globalStore.isMigrationInfoOpen" />
</transition>
</div>
</template>
<script lang="ts" setup>
import Navbar from './components/App/Navbar.vue';
import MainContainer from './components/App/MainContainer.vue';
import UpdatePrompt from './components/App/UpdatePrompt.vue';
import { onMounted } from 'vue';
import { useApiStore } from './stores/api.store';
import { useGlobalStore } from './stores/global.store';
import { useI18n } from 'vue-i18n';
import { useRegisterSW } from 'virtual:pwa-register/vue';
import { DataStatus } from './types/api.types';
import MigrateInfo from './components/App/MigrateInfo.vue';
const originalDocumentTitle = document.title;
@@ -19,28 +36,25 @@ const apiStore = useApiStore();
const globalStore = useGlobalStore();
const i18n = useI18n();
const { needRefresh, updateServiceWorker } = useRegisterSW({ immediate: true });
onMounted(async () => {
setupLocale();
setupDarkMode();
handleMigrationInfo();
setupOfflineMode();
loadStorageTimetables();
setupAfterPrintClose();
await apiStore.setupAPIData();
const query = new URLSearchParams(window.location.search);
if (query.has('id')) {
const id = query.get('id')!;
const queryTrain = apiStore.activeData?.trains.find((train) => train.id == id);
if (queryTrain) {
globalStore.selectedTrainId = id;
globalStore.selectedActiveTrain = queryTrain;
}
}
handleQueries();
});
function updateApp() {
updateServiceWorker(true);
needRefresh.value = false;
}
function loadStorageTimetables() {
if (!window.localStorage.getItem('savedTimetables')) return;
@@ -53,7 +67,9 @@ function loadStorageTimetables() {
function setupDarkMode() {
globalStore.darkMode =
localStorage.currentTheme === 'dark' || (!('currentTheme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
localStorage.currentTheme === 'dark' ||
(!('currentTheme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
}
function setupAfterPrintClose() {
@@ -71,4 +87,51 @@ function setupLocale() {
i18n.locale.value = window.localStorage.getItem('locale')!;
}
}
function setupOfflineMode() {
apiStore.connectionMode = !navigator.onLine ? 'offline' : 'online';
window.addEventListener('offline', () => {
apiStore.connectionMode = 'offline';
apiStore.journalTimetablesData = null;
apiStore.activeData = null;
});
window.addEventListener('online', () => {
apiStore.connectionMode = 'online';
apiStore.journalDataStatus = DataStatus.SUCCESS;
apiStore.setupAPIData();
});
}
function handleQueries() {
const query = new URLSearchParams(window.location.search);
if (query.has('id')) {
const id = query.get('id')!;
const queryTrain = apiStore.activeData?.trains.find((train) => train.id == id);
if (queryTrain) {
globalStore.selectedTrainId = id;
globalStore.selectedActiveTrain = queryTrain;
}
}
}
function handleMigrationInfo() {
// Show only on old domain
if (location.hostname !== 'srjp-td2.web.app' && location.hostname != 'localhost') return;
const showInfo = localStorage.getItem('showMigrationInfo');
// Do not show if already acknowledged
if (showInfo === 'false') return;
setTimeout(() => {
globalStore.isMigrationInfoOpen = true;
}, 2000);
}
</script>
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

+16 -9
View File
@@ -1,15 +1,22 @@
<template>
<main class="grid print:block p-3 mx-auto max-w-[800px] h-screen grid-rows-[auto_auto_1fr] gap-1">
<TimetableSelect />
<TimetableWarnings />
<TrainTimetable />
<!-- <MainBottom /> -->
<main
class="grid print:block print:bg-white p-3 mx-auto max-w-[800px] min-h-[300px] gap-1 relative"
:class="{
'grid-rows-[auto_auto_1fr] h-[calc(100vh-40px)]': !globalStore.fullscreenMode,
'grid-rows-[1fr] h-screen': globalStore.fullscreenMode
}"
>
<SearchContainer v-if="!globalStore.fullscreenMode" />
<TimetableWarnings v-if="!globalStore.fullscreenMode" />
<TimetableContainer />
</main>
</template>
<script setup lang="ts">
import TimetableSelect from '../Timetable/TimetableSelect.vue';
import TimetableWarnings from "../Timetable/TimetableWarnings.vue";
import TrainTimetable from '../Timetable/TrainTimetable.vue';
// import MainBottom from './MainBottom.vue';
import { useGlobalStore } from '../../stores/global.store';
import TimetableContainer from '../Timetable/TimetableContainer.vue';
import TimetableWarnings from '../Timetable/TimetableWarnings.vue';
import SearchContainer from '../TimetableSearch/SearchContainer.vue';
const globalStore = useGlobalStore();
</script>
+30
View File
@@ -0,0 +1,30 @@
<template>
<div class="fixed z-10 bottom-0 left-0 p-1 text-center w-full bg-yellow-400 text-black font-bold">
<div class="flex justify-center items-center flex-wrap gap-2">
<i18n-t keypath="migrate-info.line-1" for="migrate-info" tag="div">
<a href="https://srjp-td2.spythere.eu/" target="_blank" class="underline">
https://srjp-td2.spythere.eu/
</a>
</i18n-t>
<button
class="p-1 bg-zinc-700 text-white rounded-md hover:bg-zinc-500 focus-visible:bg-zinc-500"
@click="onAcceptButtonClick"
>
{{ t('migrate-info.accept-btn') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useGlobalStore } from '../../stores/global.store';
const store = useGlobalStore();
const { t } = useI18n();
function onAcceptButtonClick() {
store.isMigrationInfoOpen = false;
localStorage.setItem('showMigrationInfo', 'false');
}
</script>
+17 -8
View File
@@ -1,15 +1,21 @@
<template>
<nav class="bg-zinc-900 w-full p-1 print:hidden flex justify-between items-center relative">
<div class="flex items-center">
<img src="/favicon.svg" class="size-8 inline" />
<b class="ml-2 text-lg"
>Rozkładownik TD2 <sup class="font-semibold text-zinc-300">{{ version }}</sup></b
>
<img src="/favicon.svg" class="size-8 inline" alt="SRJP logo" />
<b class="ml-2 text-lg">
Rozkładownik TD2
<sup class="font-semibold text-zinc-300">
<a :href="releaseHref" target="_blank">v{{ version }}</a>
</sup>
</b>
</div>
<div>
<button class="bg-slate-600 p-1 px-2 rounded-md hover:bg-slate-500 flex items-center" @click="changeLang()">
<LanguageIcon class="size-5 inline-block align-middle mr-2" /> {{ i18n.locale.value == 'pl' ? 'POL' : 'ENG' }}
<button
class="bg-slate-600 p-1 px-2 rounded-md hover:bg-slate-500 flex items-center"
@click="changeLang()"
>
<GlobeIcon :size="18" class="mr-2" /> {{ i18n.locale.value == 'pl' ? 'POL' : 'ENG' }}
</button>
</div>
<!-- <div v-if="apiMode == 'mocking'"><ExclamationTriangleIcon class="size-6 inline mr-1 text-yellow-400" /> API mocking</div> -->
@@ -17,15 +23,18 @@
</template>
<script setup lang="ts">
import { LanguageIcon } from '@heroicons/vue/16/solid';
import { GlobeIcon } from 'lucide-vue-next';
import { version } from '../../../package.json';
import { useI18n } from 'vue-i18n';
import { computed } from 'vue';
const i18n = useI18n();
function changeLang(locale?: string) {
i18n.locale.value = locale ?? i18n.locale.value == 'pl' ? 'en' : 'pl';
i18n.locale.value = (locale ?? i18n.locale.value == 'pl') ? 'en' : 'pl';
window.localStorage.setItem('locale', i18n.locale.value);
}
const releaseHref = computed(() => `https://github.com/Spythere/srjp-td2/releases/tag/v${version}`);
// const apiMode = import.meta.env.VITE_API_MODE;
</script>
+28
View File
@@ -0,0 +1,28 @@
<template>
<div class="fixed z-50 bottom-0 right-0">
<button
@click="onUpdateClick"
class="p-3 m-3 bg-cyan-600 rounded-md text-xl hover:scale-105 transition-transform"
ref="updateBtnEl"
>
<div>{{ $t('update-prompt.line1') }}</div>
<u>{{ $t('update-prompt.line2') }}</u>
</button>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { ref } from 'vue';
const emit = defineEmits(['onUpdateClick']);
const updateBtnEl = ref<HTMLElement | null>(null);
function onUpdateClick() {
emit('onUpdateClick');
}
onMounted(() => {
updateBtnEl.value?.focus();
});
</script>
-188
View File
@@ -1,188 +0,0 @@
<template>
<tbody>
<tr v-for="(row, i) in computedTimetable">
<td
class="text-center align-top border-l border-l-black dark:border-l-white"
:class="{
'border-t border-t-black dark:border-t-white': i != 0 && computedTimetable[i - 1].realLine != row.realLine,
'border-b border-b-black dark:border-b-white': i == computedTimetable.length - 1,
}"
>
{{ i == 0 || computedTimetable[i - 1].realLine != row.realLine ? row.realLine : '&nbsp;' }}
</td>
<td class="border border-black dark:border-white relative">
<div class="absolute top-0 left-0 w-full h-full p-0.5">
<table class="h-full w-full border-collapse">
<tbody>
<tr>
<td class="align-top">{{ row.arrivalKm == '0.000' ? '' : row.arrivalKm }}</td>
</tr>
<tr>
<td class="align-bottom">{{ row.departureKm == '0.000' ? '' : row.departureKm }}</td>
</tr>
</tbody>
</table>
</div>
</td>
<td
class="text-center align-top p-0 border-l-black dark:border-l-white relative"
:class="{
'border-t border-t-black dark:border-t-white': i != 0 && computedTimetable[i - 1].departureSpeed != row.arrivalSpeed,
'border-b border-b-black dark:border-b-white': i == computedTimetable.length - 1,
}"
colspan="2"
>
<div class="absolute top-0 left-0 w-full h-full">
<table class="h-full w-full border-collapse">
<tbody>
<tr class="align-top">
<td :colspan="row.arrivalTracks == 2 ? '1' : '2'" class="font-bold" width="35">
{{
i == 0 ||
computedTimetable[i - 1].departureSpeed != row.arrivalSpeed ||
computedTimetable[i - 1].departureTracks != row.arrivalTracks
? row.arrivalSpeed
: '&nbsp; '
}}
</td>
<td v-if="row.arrivalTracks == 2" class="border-l border-l-black dark:border-l-white" width="35">
{{
i == 0 ||
computedTimetable[i - 1].departureSpeed != row.arrivalSpeed ||
computedTimetable[i - 1].departureTracks != row.arrivalTracks
? row.arrivalSpeed
: '&nbsp; '
}}
</td>
</tr>
<tr
:class="{
'border-t border-t-black dark:border-t-white align-top':
row.arrivalTracks != row.departureTracks || row.departureSpeed != row.arrivalSpeed,
}"
>
<td :colspan="row.departureTracks == 2 ? '1' : '2'" class="font-bold" width="35">
{{ row.departureSpeed != row.arrivalSpeed || row.departureTracks != row.arrivalTracks ? row.departureSpeed : '&nbsp; ' }}
</td>
<td v-if="row.departureTracks == 2" class="border-l border-l-black dark:border-l-white" width="35">
{{ row.departureSpeed != row.arrivalSpeed || row.departureTracks != row.arrivalTracks ? row.departureSpeed : '&nbsp; ' }}
</td>
</tr>
</tbody>
</table>
</div>
</td>
<td class="border border-black dark:border-white relative">
<div class="absolute top-0 left-0 w-full h-full">
<div class="flex flex-col h-full justify-between p-1">
<div :class="{ 'font-bold': row.isMain }">
{{ row.pointName }}
<span v-if="row.stopTime"> ; {{ row.stopType || 'pt' }}</span>
</div>
<div class="flex justify-between">
<span>{{ row.pointKm }}</span>
<span>{{ row.abbrevs.join(', ') }}</span>
</div>
</div>
</div>
</td>
<td class="p-0 border border-black dark:border-white relative">
<div class="absolute top-0 left-0 w-full h-full">
<table class="h-full w-full border-collapse">
<tbody>
<tr class="text-center align-top h-full">
<td class="border-r-[1px] border-r-black dark:border-r-white" :class="{ 'font-bold': row.stopTime > 0 }">
{{
(row.scheduledArrivalDate?.getTime() || 0) != (row.scheduledDepartureDate?.getTime() || 0)
? row.scheduledArrivalDate?.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' })
: '|'
}}
</td>
<td width="30">{{ row.driveTime ? Math.floor(row.driveTime / 60000) : '' }}</td>
</tr>
<tr class="text-center align-bottom h-full">
<td class="border-r-[1px] border-r-black dark:border-r-white" :class="{ 'font-bold': row.stopTime > 0 }">
{{ row.scheduledDepartureDate?.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' }) }}
</td>
<td width="30" class="font-bold">{{ row.stopTime || '' }}</td>
</tr>
</tbody>
</table>
</div>
</td>
<td class="p-0 text-center border border-black dark:border-white relative h-24 text-sm" :class="{ 'text-stone-400 ': i > 0 }">
<table class="h-full w-full border-collapse">
<tbody>
<tr class="border-b-[1px] border-b-black dark:border-b-white">
<td>{{ row.headUnits[0] }}</td>
</tr>
<tr class="border-b-[1px] border-b-black dark:border-b-white">
<td>{{ row.headUnits[1] ?? '&nbsp;' }}</td>
</tr>
<tr>
<td>{{ row.headUnits[2] ?? '&nbsp;' }}</td>
</tr>
</tbody>
</table>
</td>
<td class="p-0 text-center border border-black dark:border-white relative" :class="{ 'text-stone-400 ': i > 0 }">
<div class="absolute top-0 left-0 w-full h-full">
<table class="h-full w-full border-collapse">
<tbody>
<tr class="border-b-[1px] border-b-black dark:border-b-white">
<td>{{ row.stockMass }}</td>
</tr>
<tr>
<td>{{ row.stockLength }}</td>
</tr>
</tbody>
</table>
</div>
</td>
<td class="text-center border border-black dark:border-white" :class="{ 'text-stone-400 ': i > 0 }">{{ row.stockVmax }}</td>
</tr>
</tbody>
</template>
<script setup lang="ts">
import type { PropType } from 'vue';
import type { StopRow } from '../../types/common.types';
defineProps({
computedTimetable: {
type: Object as PropType<StopRow[]>,
required: true,
},
});
</script>
<style scoped>
@media print {
table {
page-break-after: auto;
}
tr {
page-break-inside: avoid;
page-break-after: auto;
}
thead {
display: table-header-group;
}
tr,
td {
border-color: theme('colors.black');
}
}
</style>
@@ -0,0 +1,55 @@
<template>
<!-- Button closing fullscreen mode, relative to MainContainer -->
<button
v-if="globalStore.fullscreenMode"
class="absolute right-6 top-3 p-1 rounded-md bg-green-600 hover:bg-green-500 print:hidden z-50"
@click="() => (globalStore.fullscreenMode = false)"
>
<Minimize2Icon :size="22" />
</button>
<!-- If there is no timetable chosen -->
<div
class="overflow-auto text-center font-bold text-zinc-400 p-2 min-h-full"
v-if="globalStore.currentTimetableData == null"
>
<component :is="viewModes[globalStore.viewMode]" />
</div>
<div
class="overflow-auto text-center font-bold text-white p-2 min-h-full"
v-else-if="apiStore.apiDataStatus == DataStatus.LOADING"
>
Pobieranie danych...
</div>
<div
class="overflow-auto text-center font-bold text-red-500 p-2 min-h-full"
v-else-if="apiStore.apiDataStatus == DataStatus.ERROR"
>
Ups! Coś poszło nie tak przy pobieraniu danych! :/
</div>
<!-- Timetable render based on current view mode -->
<CurrentTimetableView v-else />
</template>
<script setup lang="ts">
import { useGlobalStore } from '../../stores/global.store';
import LocalStorageView from '../TimetableViews/LocalStorageView.vue';
import JournalStorageView from '../TimetableViews/JournalStorageView.vue';
import ActiveDataView from '../TimetableViews/ActiveDataView.vue';
import CurrentTimetableView from '../TimetableViews/CurrentTimetableView.vue';
import { Minimize2Icon } from 'lucide-vue-next';
import { useApiStore } from '../../stores/api.store';
import { DataStatus } from '../../types/api.types';
const globalStore = useGlobalStore();
const apiStore = useApiStore();
const viewModes: Record<typeof globalStore.viewMode, any> = {
active: ActiveDataView,
storage: LocalStorageView,
journal: JournalStorageView
};
</script>
@@ -0,0 +1,771 @@
<template>
<div>
<h2 class="p-1 font-bold w-max">
{{ globalStore.currentTimetableData!.category }}
{{ globalStore.currentTimetableData!.trainNo }} {{ $t('headers.relation') }}
{{ globalStore.currentTimetableData!.route.replace('|', ' - ') }}
</h2>
<table
class="table-fixed mt-2 w-full border-collapse overflow-hidden"
v-if="computedTimetableRows.length > 0"
>
<thead>
<tr>
<th width="40" class="border border-black dark:border-white">
{{ $t('headers.line_no') }}
</th>
<th width="100" class="border border-black dark:border-white">
{{ $t('headers.line_km') }}
</th>
<th width="42" class="border border-black dark:border-white">V<sub>P</sub></th>
<th width="40" class="border border-black dark:border-white">V<sub>L</sub></th>
<th width="200" class="border border-black dark:border-white">
{{ $t('headers.station') }}
</th>
<th width="100" class="border border-black dark:border-white">
{{ $t('headers.time') }}
</th>
<th width="50" class="border border-black dark:border-white text-xs p-0">
<table class="h-full w-full border-collapse">
<tbody>
<tr class="border-b border-b-black dark:border-b-white">
<td class="">{{ $t('headers.loco_1') }}</td>
</tr>
<tr class="border-b border-b-black dark:border-b-white">
<td>{{ $t('headers.loco_2') }}</td>
</tr>
<tr>
<td>{{ $t('headers.loco_3') }}</td>
</tr>
</tbody>
</table>
</th>
<th width="55" class="border border-black dark:border-white text-xs relative">
<div class="absolute top-0 left-0 w-full h-full">
<table class="h-full w-full border-collapse">
<tbody>
<tr class="border-b border-b-black dark:border-b-white">
<td>{{ $t('headers.mass') }}</td>
</tr>
<tr>
<td>{{ $t('headers.length') }}</td>
</tr>
</tbody>
</table>
</div>
</th>
<th width="50" class="border border-black dark:border-white">{{ $t('headers.vmax') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in computedTimetableRows">
<!-- Line no. -->
<td
class="text-center align-top border-l border-l-black dark:border-l-white relative"
:class="{
'border-t border-t-black dark:border-t-white':
row.lastRowRef != null && row.lastRowRef.departureLineNumber != row.arrivalLineNumber,
'border-b border-b-black dark:border-b-white': i == computedTimetableRows.length - 1
}"
>
<div class="absolute -top-[0.5px] left-0 w-full h-full">
<table class="h-full w-full border-collapse">
<tbody>
<!-- Arrival Line -->
<tr class="align-top">
<td>
{{
row.lastRowRef == null ||
row.lastRowRef.departureLineNumber != row.arrivalLineNumber
? row.arrivalLineNumber
: '&nbsp;'
}}
</td>
</tr>
<!-- Departure Line -->
<tr class="align-top">
<td v-if="row.departureLineNumber != row.arrivalLineNumber" class="border-t">
{{ row.departureLineNumber }}
</td>
<td v-else>&nbsp;</td>
</tr>
</tbody>
</table>
</div>
</td>
<!-- Km -->
<td
class="border border-black dark:border-white border-t-0 border-b-1 relative p-0"
:class="{
'border-b-0': i != computedTimetableRows.length - 1
}"
>
<div class="absolute -top-[0.5px] left-0 w-full h-full">
<table class="h-full w-full border-collapse">
<tbody>
<!-- Arrival Km -->
<tr>
<td
class="align-top border-t"
v-if="
row.lastRowRef &&
(row.lastRowRef.departureSpeedL != row.arrivalSpeedL ||
row.lastRowRef.departureSpeedP != row.arrivalSpeedP ||
row.lastRowRef.departureTracks != row.arrivalTracks ||
row.lastRowRef.departureLineNumber != row.arrivalLineNumber)
"
>
&nbsp;{{ row.arrivalKm }}
</td>
<td class="align-top" v-else-if="row.lastRowRef == null">
&nbsp;{{ row.arrivalKm }}
</td>
<td v-else>&nbsp;</td>
</tr>
<!-- Departure Km -->
<tr
:class="{
'border-black dark:border-white border-t align-top':
row.arrivalTracks != row.departureTracks ||
row.departureSpeedL != row.arrivalSpeedL ||
row.departureSpeedP != row.arrivalSpeedP ||
row.departureLineNumber != row.arrivalLineNumber,
hidden:
row.arrivalTracks == row.departureTracks &&
row.departureSpeedL == row.arrivalSpeedL &&
row.departureSpeedP == row.arrivalSpeedP &&
row.departureLineNumber == row.arrivalLineNumber
}"
>
<td>&nbsp;{{ row.departureKm }}</td>
</tr>
</tbody>
</table>
</div>
</td>
<!-- Vp, Vl -->
<td
class="text-center align-top p-0 border-l-black dark:border-l-white relative"
:class="{
'border-b border-b-black dark:border-b-white': i == computedTimetableRows.length - 1
}"
colspan="2"
>
<!-- Direction line arrow -->
<div
class="absolute h-0 w-0 border-x-transparent border-x-[6px] -left-[4px] border-t-[25px] -bottom-[12px] z-30 border-t-black dark:border-t-white print:border-t-black"
v-if="i == computedTimetableRows.length - 1"
></div>
<div class="absolute -top-[0.5px] left-0 w-full h-full">
<table class="h-full w-full border-collapse">
<tbody>
<tr class="align-top">
<td
class="font-bold border-l-4 border-l-black dark:border-l-white"
:class="{
'border-t border-t-black dark:border-t-white':
row.lastRowRef != null &&
row.lastRowRef.departureSpeedP != row.arrivalSpeedP
}"
:colspan="row.arrivalTracks == 2 ? '1' : '2'"
width="35"
>
{{
row.lastRowRef == null ||
row.lastRowRef.departureSpeedP != row.arrivalSpeedP ||
row.lastRowRef.departureTracks != row.arrivalTracks
? row.arrivalSpeedP
: '&nbsp; '
}}
</td>
<td
v-if="row.arrivalTracks == 2"
class="border-l border-l-black dark:border-l-white"
:class="{
'border-t border-t-black dark:border-t-white':
row.lastRowRef != null &&
row.lastRowRef.departureSpeedL != row.arrivalSpeedL
}"
width="35"
>
{{
row.lastRowRef == null ||
row.lastRowRef.departureSpeedL != row.arrivalSpeedL ||
row.lastRowRef.departureTracks != row.arrivalTracks
? row.arrivalSpeedL
: '&nbsp; '
}}
</td>
</tr>
<tr
class="border-l-4 border-l-black dark:border-l-white"
:class="{
'border-t border-t-black dark:border-t-white align-top':
row.arrivalTracks != row.departureTracks ||
row.departureSpeedL != row.arrivalSpeedL ||
row.departureSpeedP != row.arrivalSpeedP
}"
>
<td
:colspan="row.departureTracks == 2 ? '1' : '2'"
class="font-bold"
width="35"
>
{{
row.departureSpeedP != row.arrivalSpeedP ||
row.departureTracks != row.arrivalTracks
? row.departureSpeedP
: '&nbsp; '
}}
</td>
<td
v-if="row.departureTracks == 2"
class="border-l border-l-black dark:border-l-white"
width="35"
>
{{
row.departureSpeedL != row.arrivalSpeedL ||
row.departureTracks != row.arrivalTracks
? row.departureSpeedL
: '&nbsp; '
}}
</td>
</tr>
</tbody>
</table>
</div>
</td>
<!-- Station -->
<td class="border border-black dark:border-white relative">
<div class="absolute top-0 left-0 w-full h-full">
<div class="flex flex-col h-full justify-between p-1">
<div :class="{ 'font-bold': row.isMain }">
{{ row.pointName }}
<span v-if="row.stopTime"> ; {{ row.stopType || 'pt' }}</span>
</div>
<div class="flex justify-between">
<span>{{ row.pointKm }}</span>
<span>{{ row.abbrevs.join(', ') }}</span>
</div>
</div>
</div>
</td>
<!-- Time -->
<td class="p-0 border border-black dark:border-white relative">
<div class="absolute top-0 left-0 w-full h-full">
<table class="h-full w-full border-collapse">
<tbody>
<tr class="text-center align-top h-full">
<td
class="border-r-[1px] border-r-black dark:border-r-white"
:class="{ 'font-bold': row.stopTime > 0 }"
>
<span
v-if="
(row.scheduledArrivalDate?.getTime() || 0) !=
(row.scheduledDepartureDate?.getTime() || 0)
"
>
{{
row.scheduledArrivalDate?.toLocaleTimeString('pl-PL', {
hour: '2-digit',
minute: '2-digit'
})
}}<sup
v-if="
row.scheduledArrivalDate && row.scheduledArrivalDate.getSeconds() != 0
"
>{{ Math.floor((row.scheduledArrivalDate.getSeconds() / 60) * 10) }}</sup
>
</span>
<span v-else> | </span>
</td>
<td width="30">{{ row.driveTime > 0 ? row.driveTime / 60000 : '' }}</td>
</tr>
<tr class="text-center align-bottom h-full">
<td
class="border-r-[1px] border-r-black dark:border-r-white"
:class="{ 'font-bold': row.stopTime > 0 }"
>
<span>{{
row.scheduledDepartureDate?.toLocaleTimeString('pl-PL', {
hour: '2-digit',
minute: '2-digit'
})
}}</span>
<sup
v-if="
row.scheduledDepartureDate && row.scheduledDepartureDate.getSeconds() != 0
"
>{{ Math.floor((row.scheduledDepartureDate.getSeconds() / 60) * 10) }}</sup
>
</td>
<td width="30" class="font-bold">{{ row.stopTime || '' }}</td>
</tr>
</tbody>
</table>
</div>
</td>
<!-- Locos -->
<td
class="p-0 text-center border border-black dark:border-white relative h-24 text-sm"
:class="{ 'text-stone-400 ': i > 0 }"
>
<table class="h-full w-full border-collapse">
<tbody>
<tr class="border-b-[1px] border-b-black dark:border-b-white">
<td>{{ row.headUnits[0] }}</td>
</tr>
<tr class="border-b-[1px] border-b-black dark:border-b-white">
<td>{{ row.headUnits[1] ?? '&nbsp;' }}</td>
</tr>
<tr>
<td>{{ row.headUnits[2] ?? '&nbsp;' }}</td>
</tr>
</tbody>
</table>
</td>
<!-- Load / Length -->
<td
class="p-0 text-center border border-black dark:border-white relative"
:class="{ 'text-stone-400 ': i > 0 }"
>
<div class="absolute top-0 left-0 w-full h-full">
<table class="h-full w-full border-collapse">
<tbody>
<tr class="border-b-[1px] border-b-black dark:border-b-white">
<td>{{ Math.floor(row.stockMass / 1000) }}</td>
</tr>
<tr>
<td>{{ row.stockLength }}</td>
</tr>
</tbody>
</table>
</div>
</td>
<!-- Vmax-->
<td
class="text-center border border-black dark:border-white"
:class="{ 'text-stone-400 ': i > 0 }"
>
{{ row.stockVmax }}
</td>
</tr>
</tbody>
</table>
<div class="mt-2">
<b><u>Kursuje:</u></b>
<div>
- {{ parseTimetableRunDate(computedTimetableRows[0].scheduledDepartureDate!) }}
<span
v-if="
computedTimetableRows[
computedTimetableRows.length - 1
].scheduledArrivalDate!.getDate() !=
computedTimetableRows[0].scheduledDepartureDate!.getDate()
"
>
-
{{
parseTimetableRunDate(
computedTimetableRows[computedTimetableRows.length - 1].scheduledArrivalDate!
)
}}
</span>
</div>
<div v-if="timetableWarnings.length != 0">
<b><u>Uwagi do rozkładu:</u></b>
<div>- {{ timetableWarnings }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useApiStore } from '../../stores/api.store';
import { useGlobalStore } from '../../stores/global.store';
import type { SceneryRoute, StopRow, TimetablePathData } from '../../types/common.types';
import { parseTimetableRunDate } from '../../utils/dateUtils';
const globalStore = useGlobalStore();
const apiStore = useApiStore();
// Tymczasowa tabelka z posterunkami APO
const apoNames = ['Stary Kisielin, pe', 'Czerwony Dwór, pe', 'Szczejkowice, pe'];
const timetableWarnings = computed(() => {
const timetableData = globalStore.currentTimetableData;
if (!timetableData) return '';
return timetableData.warningNotes;
});
const computedTimetableRows = computed(() => {
const timetableData = globalStore.currentTimetableData;
if (!timetableData) return [];
let timeFrom = Date.now();
const stockVmax = timetableData.trainMaxSpeed,
stockMass = timetableData.mass,
stockLength = timetableData.length;
const timetablePath = parseTimetablePath(timetableData.path);
const stopRows: StopRow[] = [];
let lastRowRef: StopRow | null = null;
let currentPathIndex = 0;
let currentPath = timetablePath[0];
let lastDepartureTimestamp = 0;
let arrivalSpeedL = 0,
arrivalSpeedP = 0;
let departureSpeedL = 0,
departureSpeedP = 0;
let arrivalKm = 0,
arrivalTracks = 0,
departureTracks = 2,
arrivalLineNumber = 0,
departureLineNumber = 0,
abbrevs = [] as string[];
if (currentPath.departureLineData) {
departureSpeedL = Math.min(currentPath.departureLineData.routeSpeed, stockVmax);
departureSpeedP = currentPath.departureLineData.routeSpeedExit
? Math.min(currentPath.departureLineData.routeSpeedExit, stockVmax)
: departureSpeedL;
departureTracks = currentPath.departureLineData.routeTracks;
arrivalSpeedL = departureSpeedL;
arrivalSpeedP = departureSpeedP;
arrivalTracks = departureTracks;
departureLineNumber = currentPath.departureLineData?.realLineNo ?? 0;
arrivalLineNumber = departureLineNumber;
abbrevs = getAbbrevs(currentPath.departureLineData);
}
// console.debug('=========== ' + timetableData.trainNo + ' ===========');
const stopList = parseStopListString(timetableData.stopListString);
for (const stop of stopList) {
if (stop.arrivalLine && stop.arrivalLine == currentPath.arrivalLine) {
if (arrivalKm >= stop.stopDistance)
arrivalKm =
(Number(stopRows[stopRows.length - 1].departureKm ?? '0') + stop.stopDistance) / 2;
if (currentPath.arrivalLineData) {
arrivalSpeedP = Math.min(currentPath.arrivalLineData.routeSpeed, stockVmax);
arrivalSpeedL = currentPath.arrivalLineData.routeSpeedExit
? Math.min(currentPath.arrivalLineData.routeSpeedExit, stockVmax)
: arrivalSpeedP;
arrivalTracks = currentPath.arrivalLineData.routeTracks;
arrivalLineNumber = currentPath.arrivalLineData.realLineNo ?? 0;
abbrevs = getAbbrevs(currentPath.arrivalLineData);
}
departureSpeedL = arrivalSpeedL;
departureSpeedP = arrivalSpeedP;
departureLineNumber = arrivalLineNumber;
departureTracks = arrivalTracks;
}
if (
stop.mainStop ||
(/^podg|po|pe$/.test(stop.stopNameRAW) && !/^sbl/i.test(stop.stopNameRAW))
) {
let correctedDepartureSpeedL = 0,
correctedDepartureSpeedP = 0,
correctedDepartureTracks = 0;
const internalRouteInfo = stop.departureLine
? currentPath.sceneryData?.routesInfo.find(
(route) => route.isInternal && route.routeName == stop.departureLine
)
: undefined;
if (internalRouteInfo) {
correctedDepartureSpeedL = Math.min(internalRouteInfo.routeSpeed, stockVmax);
correctedDepartureSpeedP = internalRouteInfo.routeSpeedExit
? Math.min(internalRouteInfo.routeSpeedExit, stockVmax)
: correctedDepartureSpeedL;
departureSpeedL = correctedDepartureSpeedL;
departureSpeedP = correctedDepartureSpeedP;
arrivalLineNumber = internalRouteInfo.realLineNo ?? arrivalLineNumber;
departureLineNumber = internalRouteInfo.realLineNo ?? departureLineNumber;
abbrevs = getAbbrevs(internalRouteInfo);
correctedDepartureTracks = internalRouteInfo.routeTracks;
departureTracks = internalRouteInfo.routeTracks;
if (stopRows.length == 0) {
arrivalSpeedL = departureSpeedL;
arrivalSpeedP = departureSpeedP;
arrivalTracks = departureTracks;
arrivalLineNumber = departureLineNumber;
}
}
let pointAbbrevs = [];
if (apoNames.includes(stop.stopNameRAW))
pointAbbrevs.unshift(`APO ${currentPath.sceneryData?.abbr}`);
let rowData: StopRow = {
isMain: stop.mainStop,
pointKm: stop.stopDistance.toFixed(3),
pointName: stop.stopNameRAW,
scheduledArrivalDate: stop.arrivalTimestamp ? new Date(stop.arrivalTimestamp) : null,
scheduledDepartureDate: stop.departureTimestamp ? new Date(stop.departureTimestamp) : null,
stopTime: stop.stopTime ? (stop.departureTimestamp - stop.arrivalTimestamp) / 60000 : 0,
stopType: stop.stopType,
sceneryName: currentPath.sceneryName,
arrivalLineNumber: arrivalLineNumber == 0 ? '' : arrivalLineNumber.toString(),
departureLineNumber: departureLineNumber == 0 ? '' : departureLineNumber.toString(),
driveTime: lastDepartureTimestamp ? stop.arrivalTimestamp - lastDepartureTimestamp : 0,
abbrevs: [...pointAbbrevs, ...abbrevs],
arrivalKm: arrivalKm.toFixed(3),
departureKm: stop.stopDistance.toFixed(3),
arrivalSpeedL,
arrivalSpeedP,
arrivalTracks: arrivalTracks,
departureSpeedL,
departureSpeedP,
departureTracks: departureTracks,
headUnits: timetableData.headUnits,
stockVmax,
stockLength,
stockMass,
lastRowRef
};
// console.debug(stop.stopNameRAW, stop.departureLine);
arrivalKm = stop.stopDistance;
arrivalSpeedL = correctedDepartureSpeedL || arrivalSpeedL;
arrivalSpeedP = correctedDepartureSpeedP || arrivalSpeedP;
arrivalTracks = correctedDepartureTracks || arrivalTracks;
if (stop.departureTimestamp) lastDepartureTimestamp = stop.departureTimestamp;
lastRowRef = rowData;
stopRows.push(rowData);
}
if (stop.departureLine && stop.departureLine == currentPath.departureLine) {
arrivalKm = stop.stopDistance;
// Reverse search for last scenery checkpoint
if (currentPath.departureLineData) {
if (
currentPath.departureLineData.routeLength != 0 &&
!currentPath.departureLineData.isRouteSBL
)
arrivalKm = stop.stopDistance + currentPath.departureLineData.routeLength / 1000;
if (
stopRows[stopRows.length - 1].isMain &&
currentPath.departureLineData.isRouteSBL &&
stop.departureLine == currentPath.departureLine
)
arrivalKm = stop.stopDistance + currentPath.departureLineData.routeLength / 1000;
for (let i = stopRows.length - 1; i >= 0; i--) {
stopRows[i].departureTracks = currentPath.departureLineData.routeTracks;
stopRows[i].departureSpeedL = Math.min(
currentPath.departureLineData.routeSpeed,
stockVmax
);
stopRows[i].departureSpeedP = currentPath.departureLineData.routeSpeedExit
? Math.min(currentPath.departureLineData.routeSpeedExit, stockVmax)
: stopRows[i].departureSpeedL;
// stopRows[i].arrivalLineNumber =
// currentPath.departureLineData.realLineNo?.toString() ?? '';
stopRows[i].departureLineNumber =
currentPath.departureLineData.realLineNo?.toString() ?? '';
if (stopRows[i].isMain || stopRows[i].pointName.endsWith(', podg')) {
stopRows[i].departureSpeedL = Math.min(
currentPath.departureLineData.routeSpeed,
stockVmax
);
stopRows[i].departureSpeedP = currentPath.departureLineData.routeSpeedExit
? Math.min(currentPath.departureLineData.routeSpeedExit, stockVmax)
: stopRows[i].departureSpeedL;
stopRows[i].departureTracks = currentPath.departureLineData.routeTracks;
// console.log(
// stop.departureLine,
// currentPath.sceneryName,
// stop.stopDistance,
// currentPath.departureLineData.routeLength,
// currentPath.departureLineData.isRouteSBL,
// currentPath.departureLineData.realLineNo
// );
abbrevs = getAbbrevs(currentPath.departureLineData);
stopRows[i].abbrevs = abbrevs;
break;
}
stopRows[i].arrivalSpeedP = Math.min(currentPath.departureLineData.routeSpeed, stockVmax);
stopRows[i].arrivalSpeedL = currentPath.departureLineData.routeSpeedExit
? Math.min(currentPath.departureLineData.routeSpeedExit, stockVmax)
: stopRows[i].arrivalSpeedP;
stopRows[i].arrivalTracks = currentPath.departureLineData.routeTracks;
stopRows[i].arrivalLineNumber =
currentPath.departureLineData.realLineNo?.toString() ?? '';
}
}
currentPath = timetablePath[++currentPathIndex];
}
}
let timeTo = Date.now();
globalStore.generatedMs = timeTo - timeFrom;
return stopRows;
});
function parseTimetablePath(path: string): TimetablePathData[] {
return path.split(';').map((pathEl) => {
const [arrivalLine, scenery, departureLine] = pathEl.split(',');
const sceneryName = scenery.split(' ').slice(0, -1).join(' ');
const sceneryData = apiStore.sceneryData?.find((sc) => sc.name == sceneryName) ?? null;
const arrivalLineData = arrivalLine
? (sceneryData?.routesInfo.find((rt) => rt.routeName == arrivalLine) ?? null)
: null;
const departureLineData = departureLine
? (sceneryData?.routesInfo.find((rt) => rt.routeName == departureLine) ?? null)
: null;
return {
sceneryName,
sceneryData: sceneryData ?? null,
arrivalLine: arrivalLine ?? '',
departureLine: departureLine ?? '',
arrivalLineData,
departureLineData
};
});
}
function parseStopListString(stopsString: string) {
//${stop.arrivalLine ?? ''};${stop.arrivalTimestamp};${stop.stopNameRAW};${stop.stopTime ? stop.stopTime + '_' + stop.stopType : ''};${stop.mainStop};${stop.stopDistance};${stop.departureTimestamp};${stop.departureLine ?? ''}
return stopsString.split('~~').map((stop) => {
const [
arrivalLine,
arrivalTimestamp,
stopNameRAW,
stopDetails,
isMainStop,
stopDistance,
departureTimestamp,
departureLine
] = stop.split(';');
const [stopTime, stopType] = stopDetails.split('_');
return {
arrivalLine,
arrivalTimestamp: parseInt(arrivalTimestamp),
stopNameRAW,
stopTime: stopTime ?? 0,
stopType: stopType ?? null,
mainStop: isMainStop == 'true',
stopDistance: parseFloat(stopDistance),
departureTimestamp: parseInt(departureTimestamp),
departureLine
};
});
}
function getAbbrevs(routeData: SceneryRoute) {
const abbrevs = [];
if (routeData.isRouteSBL == true)
abbrevs.push(
`${routeData.routeSpeed > 130 ? '4' : ''}S${routeData.routeTracks == 2 ? 'S' : ''}`
);
else if (routeData.routeTracks == 2) abbrevs.push('PP');
return abbrevs;
}
</script>
<style scoped>
@media print {
th,
tr,
td {
border-color: theme('colors.black');
}
table {
page-break-after: auto;
}
tr {
page-break-inside: avoid;
page-break-after: auto;
}
thead {
display: table-header-group;
}
}
</style>
@@ -0,0 +1,394 @@
<template>
<div>
<h2 class="font-semibold text-center text-3xl">
{{ globalStore.currentTimetableData!.category }}
{{ globalStore.currentTimetableData!.trainNo }}
</h2>
<h3 class="font-medium text-center">
{{ globalStore.currentTimetableData!.route.replace('|', ' - ') }}
</h3>
<p class="mt-2 text-center">
Kursuje: {{ timetableDate.toLocaleDateString('pl-PL', { day: '2-digit' }) }}.{{
romanMonthDigits[timetableDate.getMonth()]
}}.{{ timetableDate.toLocaleDateString('pl-PL', { year: 'numeric' }) }}
</p>
<p class="mt-2">
Lokomotywa elektryczna {{ globalStore.currentTimetableData!.headUnits[0] }}, waga:
{{ (globalStore.currentTimetableData!.mass / 1000).toFixed(1) }} t, długość:
{{ globalStore.currentTimetableData!.length }} m
</p>
<p></p>
<table class="table-fixed w-full border-collapse h-full">
<thead>
<tr>
<!-- Name -->
<th
width="250"
class="font-normal border border-black dark:border-white border-l-transparent"
>
<MapPinIcon :size="20" class="mx-auto" />
</th>
<!-- Info -->
<th width="50" class="font-normal border border-black dark:border-white">
<CircleAlertIcon :size="20" class="mx-auto" />
</th>
<!-- Drive time -->
<th width="30" class="font-normal border border-black dark:border-white">
<TimerIcon :size="20" class="mx-auto" />
</th>
<!-- Arrival -->
<th width="70" class="font-normal border border-black dark:border-white">
<CalendarArrowUpIcon :size="20" class="mx-auto" />
</th>
<!-- Stop time -->
<th width="40" class="font-normal border border-black dark:border-white">
<HandIcon :size="20" class="mx-auto" />
</th>
<!-- Departure -->
<th width="70" class="font-normal border border-black dark:border-white">
<CalendarArrowDownIcon :size="20" class="mx-auto" />
</th>
<!-- vMax -->
<th
width="80"
class="font-normal border border-black dark:border-white border-r-transparent"
>
<CircleGaugeIcon :size="20" class="mx-auto" />
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, i) in computedTimetableRows"
:class="{ 'bg-slate-100 dark:bg-zinc-900 print:bg-gray-300': i % 2 == 0 }"
class="leading-none"
>
<td class="px-2 font-thin text-nowrap overflow-hidden overflow-ellipsis">
<span :class="{ 'font-semibold': row.isMain, 'font-normal': !row.isMain }">
{{ row.pointName }}</span
><span
>.............................................................................................
</span>
</td>
<td
class="border border-black dark:border-white border-t-transparent border-b-transparent"
></td>
<td
class="border border-black dark:border-white border-t-transparent border-b-transparent text-center font-bold"
>
{{ row.driveTime ? Math.floor(row.driveTime / 60000) : '' }}
</td>
<td
class="border border-black dark:border-white border-t-transparent border-b-transparent text-right font-bold px-2"
>
<span v-if="row.stopType == 'pt'">+ </span>
<span> {{ row.arrivalDateStr }} </span>
</td>
<td
class="border border-black dark:border-white border-t-transparent border-b-transparent text-center font-semibold"
>
{{ row.stopTime || '' }}
</td>
<td
class="border border-black dark:border-white border-t-transparent border-b-transparent text-right font-bold px-2 relative"
>
<span
class="absolute right-[-3px] border-r-[5px] border-black"
:class="{
'top-0 h-[calc(100%+1px)]':
row.arrivalTracks == row.departureTracks && row.arrivalTracks == 2,
'top-0 h-[calc(50%+1px)]': row.arrivalTracks > row.departureTracks,
'top-1/2 h-[calc(50%+1px)]': row.arrivalTracks < row.departureTracks
}"
></span>
{{ row.departureDateStr }}
</td>
<!-- v-if="
i == 0 || (i > 0 && computedTimetableRows[i - 1].departureSpeed != row.arrivalSpeed)
" -->
<td class="text-center font-bold">
<span v-if="i == 0 || computedTimetableRows[i - 1].departureSpeed != row.arrivalSpeed">
{{ i == 0 ? row.departureSpeed : row.arrivalSpeed }}
<span v-if="row.arrivalSpeed != row.departureSpeed">
/
{{ row.departureSpeed }}
</span>
</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useGlobalStore } from '../../stores/global.store';
import type { StopRowCZ, TimetablePathData } from '../../types/common.types';
import { useApiStore } from '../../stores/api.store';
import {
CalendarArrowDownIcon,
CalendarArrowUpIcon,
CircleAlertIcon,
CircleGaugeIcon,
HandIcon,
MapPinIcon,
TimerIcon
} from 'lucide-vue-next';
const globalStore = useGlobalStore();
const apiStore = useApiStore();
const timetableDate = ref(new Date());
const romanMonthDigits = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII'];
const computedTimetableRows = computed(() => {
const timetableData = globalStore.currentTimetableData;
if (!timetableData) return [];
let timeFrom = Date.now();
const stockVmax = timetableData.trainMaxSpeed,
stockMass = Math.floor(timetableData.mass / 1000),
stockLength = timetableData.length;
const timetablePath = parseTimetablePath(timetableData.path);
const stopRows: StopRowCZ[] = [];
let currentPathIndex = 0;
let currentPath = timetablePath[0];
let lastDepartureTimestamp = 0;
let arrivalSpeed = 0,
departureSpeed = 0,
arrivalTracks = 0,
departureTracks = 2;
if (currentPath.departureLineData) {
departureSpeed = Math.min(currentPath.departureLineData.routeSpeed, stockVmax);
arrivalSpeed = Math.min(currentPath.departureLineData.routeSpeed, stockVmax);
departureTracks = currentPath.departureLineData.routeTracks;
arrivalTracks = currentPath.departureLineData.routeTracks;
}
const stopList = parseStopListString(timetableData.stopListString);
timetableDate.value = new Date(stopList[0].departureTimestamp);
stopList.forEach((stop) => {
if (stop.arrivalLine && stop.arrivalLine == currentPath.arrivalLine) {
if (currentPath.arrivalLineData) {
arrivalSpeed = Math.min(currentPath.arrivalLineData.routeSpeed, stockVmax);
arrivalTracks = currentPath.arrivalLineData.routeTracks;
}
departureSpeed = arrivalSpeed;
departureTracks = arrivalTracks;
}
if (
stop.mainStop ||
(/^podg|po|pe$/.test(stop.stopNameRAW) && !/^sbl/i.test(stop.stopNameRAW))
) {
let correctedDepartureSpeed = 0,
correctedDepartureTracks = 0;
const internalRouteInfo = stop.departureLine
? currentPath.sceneryData?.routesInfo.find(
(route) => route.isInternal && route.routeName == stop.departureLine
)
: undefined;
if (internalRouteInfo) {
correctedDepartureSpeed = Math.min(internalRouteInfo.routeSpeed, stockVmax);
departureSpeed = Math.min(internalRouteInfo.routeSpeed, stockVmax);
correctedDepartureTracks = internalRouteInfo.routeTracks;
departureTracks = internalRouteInfo.routeTracks;
if (stopRows.length == 0) {
arrivalSpeed = departureSpeed;
arrivalTracks = departureTracks;
}
}
const scheduledArrivalDate = stop.arrivalTimestamp ? new Date(stop.arrivalTimestamp) : null;
const scheduledDepartureDate = stop.departureTimestamp
? new Date(stop.departureTimestamp)
: null;
let arrivalDateStr =
scheduledArrivalDate?.toLocaleTimeString('pl-PL', {
hour: 'numeric',
minute: '2-digit'
}) ?? '';
let departureDateStr =
scheduledDepartureDate?.toLocaleTimeString('pl-PL', {
hour: 'numeric',
minute: '2-digit'
}) ?? '';
if (
stopRows.length > 0 &&
stopRows[stopRows.length - 1]?.scheduledArrivalDate?.getHours() ==
scheduledArrivalDate?.getHours()
) {
arrivalDateStr = arrivalDateStr.split(':').slice(1).join(' ');
}
if (
stopRows[stopRows.length - 1]?.scheduledDepartureDate?.getHours() ==
scheduledDepartureDate?.getHours()
) {
departureDateStr = departureDateStr.split(':').slice(1).join(' ');
}
let rowData: StopRowCZ = {
isMain: stop.mainStop,
pointKm: stop.stopDistance.toFixed(3),
pointName: stop.stopNameRAW,
scheduledArrivalDate,
scheduledDepartureDate,
stopTime: stop.stopTime ? (stop.departureTimestamp - stop.arrivalTimestamp) / 60000 : 0,
stopType: stop.stopType,
sceneryName: currentPath.sceneryName,
driveTime: lastDepartureTimestamp ? stop.arrivalTimestamp - lastDepartureTimestamp : 0,
arrivalSpeed: arrivalSpeed,
departureSpeed: departureSpeed,
arrivalTracks,
departureTracks,
headUnits: timetableData.headUnits,
stockVmax,
stockLength,
stockMass,
arrivalDateStr,
departureDateStr
};
arrivalSpeed = correctedDepartureSpeed || arrivalSpeed;
arrivalTracks = correctedDepartureTracks || arrivalTracks;
if (stop.departureTimestamp) lastDepartureTimestamp = stop.departureTimestamp;
stopRows.push(rowData);
}
if (stop.departureLine && stop.departureLine == currentPath.departureLine) {
// Reverse search for last scenery checkpoint
if (currentPath.departureLineData) {
for (let i = stopRows.length - 1; i >= 0; i--) {
stopRows[i].departureTracks = currentPath.departureLineData.routeTracks;
stopRows[i].departureSpeed = Math.min(
currentPath.departureLineData.routeSpeed,
stockVmax
);
if (stopRows[i].isMain || stopRows[i].pointName.endsWith(', podg')) {
stopRows[i].departureSpeed = Math.min(
currentPath.departureLineData.routeSpeed,
stockVmax
);
stopRows[i].departureTracks = currentPath.departureLineData.routeTracks;
break;
}
stopRows[i].arrivalSpeed = Math.min(currentPath.departureLineData.routeSpeed, stockVmax);
stopRows[i].arrivalTracks = currentPath.departureLineData.routeTracks;
}
}
currentPath = timetablePath[++currentPathIndex];
}
});
let timeTo = Date.now();
globalStore.generatedMs = timeTo - timeFrom;
return stopRows;
});
function parseTimetablePath(path: string): TimetablePathData[] {
return path.split(';').map((pathEl) => {
const [arrivalLine, scenery, departureLine] = pathEl.split(',');
const sceneryName = scenery.split(' ').slice(0, -1).join(' ');
const sceneryData = apiStore.sceneryData?.find((sc) => sc.name == sceneryName) ?? null;
const arrivalLineData = arrivalLine
? sceneryData?.routesInfo.find((rt) => rt.routeName == arrivalLine) ?? null
: null;
const departureLineData = departureLine
? sceneryData?.routesInfo.find((rt) => rt.routeName == departureLine) ?? null
: null;
return {
sceneryName,
sceneryData: sceneryData ?? null,
arrivalLine: arrivalLine ?? '',
departureLine: departureLine ?? '',
arrivalLineData,
departureLineData
};
});
}
function parseStopListString(stopsString: string) {
//${stop.arrivalLine ?? ''};${stop.arrivalTimestamp};${stop.stopNameRAW};${stop.stopTime ? stop.stopTime + '_' + stop.stopType : ''};${stop.mainStop};${stop.stopDistance};${stop.departureTimestamp};${stop.departureLine ?? ''}
return stopsString.split('~~').map((stop) => {
const [
arrivalLine,
arrivalTimestamp,
stopNameRAW,
stopDetails,
isMainStop,
stopDistance,
departureTimestamp,
departureLine
] = stop.split(';');
const [stopTime, stopType] = stopDetails.split('_');
return {
arrivalLine,
arrivalTimestamp: parseInt(arrivalTimestamp),
stopNameRAW,
stopTime: Number(stopTime ?? 0),
stopType: stopType ?? null,
mainStop: isMainStop == 'true',
stopDistance: parseFloat(stopDistance),
departureTimestamp: parseInt(departureTimestamp),
departureLine
};
});
}
</script>
@@ -1,50 +0,0 @@
<template>
<thead>
<tr>
<th width="40" class="border border-black dark:border-white">{{ $t('headers.line_no') }}</th>
<th width="100" class="border border-black dark:border-white">{{ $t('headers.line_km') }}</th>
<th width="35" class="border border-black dark:border-white">V<sub>P</sub></th>
<th width="35" class="border border-black dark:border-white">V<sub>L</sub></th>
<th width="200" class="border border-black dark:border-white">{{ $t('headers.station') }}</th>
<th width="100" class="border border-black dark:border-white">{{ $t('headers.time') }}</th>
<th width="50" class="border border-black dark:border-white text-xs p-0">
<table class="h-full w-full border-collapse">
<tbody>
<tr class="border-b border-b-black dark:border-b-white">
<td class="">{{ $t('headers.loco_1') }}</td>
</tr>
<tr class="border-b border-b-black dark:border-b-white">
<td>{{ $t('headers.loco_2') }}</td>
</tr>
<tr>
<td>{{ $t('headers.loco_3') }}</td>
</tr>
</tbody>
</table>
</th>
<th width="55" class="border border-black dark:border-white text-xs relative">
<div class="absolute top-0 left-0 w-full h-full">
<table class="h-full w-full border-collapse">
<tbody>
<tr class="border-b border-b-black dark:border-b-white">
<td>{{ $t('headers.mass') }}</td>
</tr>
<tr>
<td>{{ $t('headers.length') }}</td>
</tr>
</tbody>
</table>
</div>
</th>
<th width="50" class="border border-black dark:border-white">{{ $t('headers.vmax') }}</th>
</tr>
</thead>
</template>
<style scoped>
@media print {
th, tr {
border-color: theme('colors.black');
}
}
</style>
@@ -1,147 +0,0 @@
<template>
<div class="flex gap-2 mb-2">
<button
class="p-1 rounded-md"
:class="{
'bg-zinc-800 hover:bg-zinc-700': globalStore.viewMode == 'active',
'bg-green-600 hover:bg-green-500': globalStore.viewMode == 'storage',
}"
@click="toggleViewMode"
>
<ArchiveBoxArrowDownIcon class="size-6" />
</button>
<select
name="trains"
id="trains-select"
class="bg-zinc-800 p-1 rounded-md print:hidden w-full"
:disabled="apiStore.activeDataStatus != DataStatus.SUCCESS"
v-model="globalStore.selectedTrainId"
v-if="globalStore.viewMode == 'active'"
@change="selectTrain"
>
<option :value="null" disabled>
{{ apiStore.activeDataStatus == DataStatus.LOADING ? $t('data-loading-text') : $t('train-select-placeholder') }}
</option>
<option :value="train.id" v-for="train in globalStore.activeTimetableTrains">
{{ train.driverName }} | {{ train.timetable?.category }} {{ train.trainNo }} [{{ getRegionNameById(train.region) }}]
</option>
</select>
<input
type="text"
v-if="globalStore.viewMode == 'storage'"
v-model="globalStore.timetableSearch"
class="bg-zinc-800 p-1 rounded-md print:hidden w-full"
:placeholder="$t('train-search-placeholder')"
/>
<button class="bg-zinc-800 p-1 rounded-md hover:bg-zinc-700" @click="toggleDarkMode">
<MoonIcon v-if="globalStore.darkMode" class="text-white size-6" />
<SunIcon v-else class="text-white size-6" />
</button>
<button
class="bg-zinc-800 p-1 rounded-md hover:bg-zinc-700 disabled:opacity-60 disabled:hover:bg-zinc-800"
:disabled="globalStore.currentTimetableData == null"
@click="openPrintingWindow"
>
<PrinterIcon class="text-white size-6" />
</button>
<button
class="p-1 rounded-md disabled:opacity-60 disabled:hover:bg-zinc-800"
:disabled="globalStore.currentTimetableData == null"
:class="{
'bg-green-600 hover:bg-green-700': isTimetableSaved,
'bg-zinc-800 hover:bg-zinc-700': !isTimetableSaved,
}"
@click="saveToStorage"
>
<ArrowDownTrayIcon class="text-white size-6" />
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useApiStore } from '../../stores/api.store';
import { DataStatus } from '../../types/api.types';
import { useGlobalStore } from '../../stores/global.store';
import { PrinterIcon, MoonIcon, SunIcon, ArchiveBoxArrowDownIcon, ArrowDownTrayIcon } from '@heroicons/vue/16/solid';
import { getRegionNameById } from '../../utils/trainUtils';
import type { TimetableData } from '../../types/common.types';
// Stores
const apiStore = useApiStore();
const globalStore = useGlobalStore();
// Computed
const isTimetableSaved = computed(() => {
if (!globalStore.currentTimetableData) return false;
return Object.keys(globalStore.storageTimetables).includes(`${globalStore.currentTimetableData.timetableId}`);
});
// Methods
function selectTrain() {
if (!apiStore.activeData) return;
globalStore.selectedActiveTrain = globalStore.activeTimetableTrains.find((train) => train.id == globalStore.selectedTrainId) ?? null;
if (globalStore.selectedActiveTrain != null) {
globalStore.generatedDate = new Date();
}
}
function toggleViewMode() {
globalStore.viewMode = globalStore.viewMode == 'active' ? 'storage' : 'active';
}
function toggleDarkMode() {
globalStore.darkMode = !globalStore.darkMode;
window.localStorage.setItem('currentTheme', globalStore.darkMode ? 'dark' : 'light');
}
function saveToStorage() {
if (globalStore.currentTimetableData == null) return;
try {
const savedTimetablesStorage = localStorage.getItem('savedTimetables');
let savedTimetablesJSON: Record<number, TimetableData> = savedTimetablesStorage ? JSON.parse(savedTimetablesStorage) : {};
if (savedTimetablesJSON[globalStore.currentTimetableData.timetableId] !== undefined) {
globalStore.selectedStorageTimetable = savedTimetablesJSON[globalStore.currentTimetableData.timetableId];
globalStore.viewMode = 'storage';
return;
}
savedTimetablesJSON[globalStore.currentTimetableData.timetableId] = { ...globalStore.currentTimetableData, savedTimestamp: Date.now() };
localStorage.setItem('savedTimetables', JSON.stringify(savedTimetablesJSON));
globalStore.storageTimetables = savedTimetablesJSON;
} catch (error) {}
}
function openPrintingWindow() {
if (globalStore.selectedActiveTrain != null) {
const date = `${globalStore.generatedDate!.toLocaleDateString('pl-PL').replace(/\./g, '-')}--${globalStore
.generatedDate!.toLocaleTimeString('pl-PL')
.replace(/:/g, '-')}`;
document.title = `${globalStore.selectedActiveTrain.driverName} ; ${globalStore.selectedActiveTrain.timetable!.category} ${
globalStore.selectedActiveTrain.trainNo
}
${globalStore.selectedActiveTrain.timetable?.route.replace('|', ' - ')} ; ${date}`;
}
window.print();
}
// function refreshData() {
// apiStore.fetchActiveData();
// selectTrain();
// }
</script>
@@ -1,73 +0,0 @@
<template>
<div class="text-white">
<div v-if="globalStore.selectedStorageTimetable == null && Object.keys(globalStore.storageTimetables).length == 0">
<div class="font-bold text-xl">{{ $t('storage-empty-header') }}</div>
<div>{{ $t('storage-empty-info') }}</div>
</div>
<div v-else>
<div class="font-bold text-xl p-2 bg-zinc-700 mb-3">{{ $t('storage-preview-title') }}</div>
<div class="font-bold p-2 bg-zinc-800 mb-3" v-if="filteredTimetables.length == 0">{{ $t('storage-preview-empty') }}</div>
<li v-for="timetable in filteredTimetables" class="flex gap-1 w-full my-2">
<button class="bg-zinc-900 p-2 w-full cursor-pointer hover:bg-zinc-800 text-left" @click="selectTimetable(timetable)">
<div class="text-zinc-300">#{{ timetable.timetableId }} &bull; {{ new Date(timetable.savedTimestamp!).toLocaleString() }}</div>
<b>{{ timetable.driverName }} | {{ timetable.category }} {{ timetable.trainNo }}</b> {{ timetable.route.replace('|', ' > ') }}
</button>
<button class="bg-zinc-900 p-2 hover:bg-zinc-800" @click="removeTimetable(timetable.timetableId)">
<TrashIcon class="size-5 text-white" />
</button>
</li>
</div>
</div>
</template>
<script setup lang="ts">
import { TrashIcon } from '@heroicons/vue/16/solid';
import { useGlobalStore } from '../../stores/global.store';
import type { TimetableData } from '../../types/common.types';
import { useI18n } from 'vue-i18n';
import { computed } from 'vue';
const globalStore = useGlobalStore();
const i18n = useI18n();
const filteredTimetables = computed(() => {
let timetables = Object.values(globalStore.storageTimetables);
if (globalStore.timetableSearch.length != 0)
timetables = timetables.filter((st) =>
`${st.timetableId} ${st.driverName} ${st.route} ${st.category} ${st.trainNo}`
.toLocaleLowerCase()
.includes(globalStore.timetableSearch.toLocaleLowerCase())
);
timetables.sort((a, b) => {
return (b.savedTimestamp ?? 0) - (a.savedTimestamp ?? 0);
});
return timetables;
});
function selectTimetable(timetable: TimetableData) {
globalStore.selectedStorageTimetable = timetable;
}
function removeTimetable(timetableId: number) {
const isConfirmed = confirm(i18n.t('delete-timetable-confirm'));
if (!isConfirmed) return;
try {
const savedTimetablesStorage = localStorage.getItem('savedTimetables');
let savedTimetablesJSON: Record<number, TimetableData> = savedTimetablesStorage ? JSON.parse(savedTimetablesStorage) : {};
delete savedTimetablesJSON[timetableId];
localStorage.setItem('savedTimetables', JSON.stringify(savedTimetablesJSON));
globalStore.storageTimetables = savedTimetablesJSON;
} catch (error) {}
}
</script>
<style scoped></style>
+57 -11
View File
@@ -1,9 +1,10 @@
<template>
<div class="my-2 print:hidden" v-if="globalStore.currentTimetableData?.savedTimestamp">
<div class="flex gap-2">
<!-- Local -->
<div class="print:hidden" v-if="globalStore.currentTimetableData?.savedTimestamp">
<div class="flex gap-2 mt-1">
<div class="flex items-center gap-2 bg-zinc-900 p-1 w-full">
<div>
<InformationCircleIcon class="size-5" />
<InfoIcon :size="20" />
</div>
<i18n-t keypath="storage-preview-info" tag="span">
<template v-slot:id>
@@ -13,27 +14,70 @@
<b>{{ globalStore.currentTimetableData.driverName }}</b>
</template>
<template v-slot:date>
<b>{{ new Date(globalStore.currentTimetableData.savedTimestamp).toLocaleString() }}</b>
<b>{{
new Date(
globalStore.currentTimetableData?.journalCreatedAt ??
globalStore.currentTimetableData.savedTimestamp
).toLocaleString()
}}</b>
</template>
</i18n-t>
</div>
<button class="font-bold bg-zinc-900 p-1 hover:bg-zinc-800" @click="removeTimetable(globalStore.currentTimetableData.timetableId)">
<TrashIcon class="text-white size-6" />
<button
class="font-bold bg-zinc-900 p-1 hover:bg-zinc-800 rounded-md"
@click="removeTimetable(globalStore.currentTimetableData.timetableId)"
>
<Trash2Icon />
</button>
<button class="font-bold bg-zinc-900 p-1 hover:bg-zinc-800" @click="globalStore.selectedStorageTimetable = null">
<ArrowUturnLeftIcon class="text-white size-6" />
<button
class="font-bold bg-zinc-900 p-1 hover:bg-zinc-800 rounded-md"
@click="globalStore.selectedStorageTimetable = null"
>
<Undo2Icon />
</button>
</div>
</div>
<!-- Journal -->
<div class="print:hidden" v-else-if="globalStore.currentTimetableData?.journalCreatedAt">
<div class="flex gap-2 mt-1">
<div class="flex items-center gap-2 bg-zinc-900 p-1 w-full">
<div>
<InfoIcon :size="20" />
</div>
<i18n-t keypath="journal-preview-info" tag="span">
<template v-slot:id>
<b>#{{ globalStore.currentTimetableData.timetableId }}</b>
</template>
<template v-slot:driverName>
<b>{{ globalStore.currentTimetableData.driverName }}</b>
</template>
<template v-slot:date>
<b>{{
new Date(globalStore.currentTimetableData.journalCreatedAt).toLocaleString()
}}</b>
</template>
</i18n-t>
</div>
<button
class="font-bold bg-zinc-900 p-1 hover:bg-zinc-800 rounded-md"
@click="globalStore.selectedJournalTimetable = null"
>
<Undo2Icon />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowUturnLeftIcon, InformationCircleIcon, TrashIcon } from '@heroicons/vue/16/solid';
import { useGlobalStore } from '../../stores/global.store';
import { useI18n } from 'vue-i18n';
import type { TimetableData } from '../../types/common.types';
import { InfoIcon, Trash2Icon, Undo2Icon } from 'lucide-vue-next';
const globalStore = useGlobalStore();
const i18n = useI18n();
@@ -45,12 +89,14 @@ function removeTimetable(timetableId: number) {
try {
const savedTimetablesStorage = localStorage.getItem('savedTimetables');
let savedTimetablesJSON: Record<number, TimetableData> = savedTimetablesStorage ? JSON.parse(savedTimetablesStorage) : {};
let savedTimetablesJSON: Record<number, TimetableData> = savedTimetablesStorage
? JSON.parse(savedTimetablesStorage)
: {};
delete savedTimetablesJSON[timetableId];
localStorage.setItem('savedTimetables', JSON.stringify(savedTimetablesJSON));
globalStore.storageTimetables = savedTimetablesJSON;
globalStore.selectedStorageTimetable = null;
} catch (error) {}
}
-252
View File
@@ -1,252 +0,0 @@
<template>
<div
:class="{ dark: globalStore.darkMode }"
v-if="globalStore.currentTimetableData != null"
class="overflow-auto p-1 bg-white print:bg-white dark:bg-zinc-950 print:text-black text-black dark:text-white min-h-full"
>
<div>
<div class="p-1 font-bold w-max">
{{ globalStore.currentTimetableData.category }} {{ globalStore.currentTimetableData.trainNo }} {{ $t('headers.relation') }}
{{ globalStore.currentTimetableData.route.replace('|', ' - ') }}
</div>
<table class="table-fixed mt-2 w-full border-collapse" v-if="computedTimetableRows.length > 0">
<TimetableHeader />
<TimetableBody :computed-timetable="computedTimetableRows" />
</table>
</div>
</div>
<div class="overflow-auto text-center font-bold text-zinc-400 p-1 min-h-full" v-else>
<div v-if="globalStore.viewMode == 'active'">
<div>{{ $t('train-select-info') }}</div>
</div>
<div v-else>
<TimetableStorage />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
``;
import { useApiStore } from '../../stores/api.store';
import { useGlobalStore } from '../../stores/global.store';
import TimetableBody from './TimetableBody.vue';
import TimetableHeader from './TimetableHeader.vue';
import type { SceneryRoute, StopRow, TimetablePathData } from '../../types/common.types';
import TimetableStorage from './TimetableStorage.vue';
const globalStore = useGlobalStore();
const apiStore = useApiStore();
// Tymczasowa tabelka z posterunkami APO
const apoNames = ['Stary Kisielin, pe', 'Czerwony Dwór, pe', 'Szczejkowice, pe'];
const computedTimetableRows = computed(() => {
const timetableData = globalStore.currentTimetableData;
if (!timetableData) return [];
let timeFrom = Date.now();
const stockVmax = timetableData.trainMaxSpeed,
stockMass = Math.floor(timetableData.mass / 1000),
stockLength = timetableData.length;
const timetablePath = parseTimetablePath(timetableData.path);
const stopRows: StopRow[] = [];
let currentPathIndex = 0;
let currentPath = timetablePath[0];
let lastDepartureTimestamp = 0;
let arrivalKm = 0,
arrivalSpeed = 0,
arrivalTracks = 0,
departureSpeed = 0,
departureTracks = 2,
realLineNo = 0,
abbrevs = [] as string[];
if (currentPath.departureLineData) {
departureSpeed = currentPath.departureLineData.routeSpeed;
departureTracks = currentPath.departureLineData.routeTracks;
arrivalSpeed = currentPath.departureLineData.routeSpeed;
arrivalTracks = currentPath.departureLineData.routeTracks;
realLineNo = currentPath.departureLineData?.realLineNo ?? 0;
abbrevs = getAbbrevs(currentPath.departureLineData);
}
// console.debug('=========== ' + timetableData.trainNo + ' ===========');
const stopList = parseStopListString(timetableData.stopListString);
for (const stop of stopList) {
if (stop.arrivalLine && stop.arrivalLine == currentPath.arrivalLine) {
arrivalKm = stop.stopDistance;
if (currentPath.arrivalLineData) {
arrivalSpeed = currentPath.arrivalLineData.routeSpeed;
arrivalTracks = currentPath.arrivalLineData.routeTracks;
realLineNo = currentPath.arrivalLineData.realLineNo ?? 0;
abbrevs = getAbbrevs(currentPath.arrivalLineData);
}
departureSpeed = arrivalSpeed;
departureTracks = arrivalTracks;
}
if (stop.mainStop || (/^podg|po|pe$/.test(stop.stopNameRAW) && !/^sbl/i.test(stop.stopNameRAW))) {
let correctedDepartureSpeed = 0,
correctedDepartureTracks = 0;
const internalRouteInfo = stop.departureLine
? currentPath.sceneryData?.routesInfo.find((route) => route.isInternal && route.routeName == stop.departureLine)
: undefined;
if (internalRouteInfo) {
correctedDepartureSpeed = internalRouteInfo.routeSpeed;
departureSpeed = internalRouteInfo.routeSpeed;
realLineNo = internalRouteInfo.realLineNo ?? realLineNo;
abbrevs = getAbbrevs(internalRouteInfo);
correctedDepartureTracks = internalRouteInfo.routeTracks;
departureTracks = internalRouteInfo.routeTracks;
if (stopRows.length == 0) {
arrivalSpeed = departureSpeed;
arrivalTracks = departureTracks;
}
}
let pointAbbrevs = [];
if (apoNames.includes(stop.stopNameRAW)) pointAbbrevs.unshift(`APO ${currentPath.sceneryData?.abbr}`);
let rowData: StopRow = {
isMain: stop.mainStop,
pointKm: stop.stopDistance.toFixed(3),
pointName: stop.stopNameRAW,
scheduledArrivalDate: stop.arrivalTimestamp ? new Date(stop.arrivalTimestamp) : null,
scheduledDepartureDate: stop.departureTimestamp ? new Date(stop.departureTimestamp) : null,
stopTime: stop.stopTime ? (stop.departureTimestamp - stop.arrivalTimestamp) / 60000 : 0,
stopType: stop.stopType,
sceneryName: currentPath.sceneryName,
realLine: realLineNo == 0 ? '' : realLineNo.toString(),
driveTime: lastDepartureTimestamp ? stop.arrivalTimestamp - lastDepartureTimestamp : 0,
abbrevs: [...pointAbbrevs, ...abbrevs],
arrivalKm: arrivalKm.toFixed(3),
departureKm: stop.stopDistance.toFixed(3),
arrivalSpeed: arrivalSpeed,
arrivalTracks: arrivalTracks,
departureSpeed: departureSpeed,
departureTracks: departureTracks,
headUnits: timetableData.headUnits,
stockVmax,
stockLength,
stockMass,
};
// console.debug(stop.stopNameRAW, stop.departureLine);
arrivalKm = stop.stopDistance;
arrivalSpeed = correctedDepartureSpeed || arrivalSpeed;
arrivalTracks = correctedDepartureTracks || arrivalTracks;
if (stop.departureTimestamp) lastDepartureTimestamp = stop.departureTimestamp;
stopRows.push(rowData);
}
if (stop.departureLine && stop.departureLine == currentPath.departureLine) {
// Reverse search for last scenery checkpoint
for (let i = stopRows.length - 1; i > 0; i--) {
if (currentPath.departureLineData) {
stopRows[i].departureTracks = currentPath.departureLineData.routeTracks;
stopRows[i].departureSpeed = currentPath.departureLineData.routeSpeed;
stopRows[i].realLine = currentPath.departureLineData.realLineNo?.toString() ?? '';
if (stopRows[i].isMain || stopRows[i].pointName.endsWith(', podg')) {
stopRows[i].departureSpeed = currentPath.departureLineData.routeSpeed;
stopRows[i].departureTracks = currentPath.departureLineData.routeTracks;
abbrevs = getAbbrevs(currentPath.departureLineData);
stopRows[i].abbrevs = abbrevs;
break;
}
stopRows[i].arrivalSpeed = currentPath.departureLineData.routeSpeed;
stopRows[i].arrivalTracks = currentPath.departureLineData.routeTracks;
}
}
currentPath = timetablePath[++currentPathIndex];
}
}
let timeTo = Date.now();
globalStore.generatedMs = timeTo - timeFrom;
return stopRows;
});
function parseTimetablePath(path: string): TimetablePathData[] {
return path.split(';').map((pathEl) => {
const [arrivalLine, scenery, departureLine] = pathEl.split(',');
const sceneryName = scenery.split(' ').slice(0, -1).join(' ');
const sceneryData = apiStore.sceneryData?.find((sc) => sc.name == sceneryName) ?? null;
const arrivalLineData = arrivalLine ? sceneryData?.routesInfo.find((rt) => rt.routeName == arrivalLine) ?? null : null;
const departureLineData = departureLine ? sceneryData?.routesInfo.find((rt) => rt.routeName == departureLine) ?? null : null;
return {
sceneryName,
sceneryData: sceneryData ?? null,
arrivalLine: arrivalLine ?? '',
departureLine: departureLine ?? '',
arrivalLineData,
departureLineData,
};
});
}
function parseStopListString(stopsString: string) {
//${stop.arrivalLine ?? ''};${stop.arrivalTimestamp};${stop.stopNameRAW};${stop.stopTime ? stop.stopTime + '_' + stop.stopType : ''};${stop.mainStop};${stop.stopDistance};${stop.departureTimestamp};${stop.departureLine ?? ''}
return stopsString.split('~~').map((stop) => {
const [arrivalLine, arrivalTimestamp, stopNameRAW, stopDetails, isMainStop, stopDistance, departureTimestamp, departureLine] = stop.split(';');
const [stopTime, stopType] = stopDetails.split('_');
return {
arrivalLine,
arrivalTimestamp: parseInt(arrivalTimestamp),
stopNameRAW,
stopTime: stopTime ?? 0,
stopType: stopType ?? null,
mainStop: isMainStop == 'true',
stopDistance: parseFloat(stopDistance),
departureTimestamp: parseInt(departureTimestamp),
departureLine,
};
});
}
function getAbbrevs(routeData: SceneryRoute) {
const abbrevs = [];
if (routeData.isRouteSBL == true) abbrevs.push(`${routeData.routeSpeed > 130 ? '4' : ''}S${routeData.routeTracks == 2 ? 'S' : ''}`);
else if (routeData.routeTracks == 2) abbrevs.push('PP');
return abbrevs;
}
</script>
@@ -0,0 +1,42 @@
<template>
<select
v-model="globalStore.selectedTrainId"
@change="selectTrain"
name="trains"
id="trains-select"
class="bg-zinc-800 p-1 rounded-md print:hidden w-full"
:disabled="apiStore.apiDataStatus != DataStatus.SUCCESS"
aria-label="Active train select"
>
<option :value="null" disabled>
{{
apiStore.apiDataStatus == DataStatus.LOADING
? $t('data-loading-text')
: $t('train-select-placeholder')
}}
</option>
<option :value="train.id" v-for="train in globalStore.activeTimetableTrains">
{{ train.driverName }} | {{ train.timetable?.category }} {{ train.trainNo }} [{{
getRegionNameById(train.region)
}}]
</option>
</select>
</template>
<script setup lang="ts">
import { useApiStore } from '../../stores/api.store';
import { useGlobalStore } from '../../stores/global.store';
import { DataStatus } from '../../types/api.types';
import { getRegionNameById } from '../../utils/trainUtils';
const apiStore = useApiStore();
const globalStore = useGlobalStore();
function selectTrain() {
if (!apiStore.activeData) return;
globalStore.selectedActiveTrain =
globalStore.activeTimetableTrains.find((train) => train.id == globalStore.selectedTrainId) ??
null;
}
</script>
@@ -0,0 +1,129 @@
<template>
<div class="flex gap-2 items-center" v-if="globalStore.currentTimetableData == null">
<div class="flex gap-2 w-full">
<input
v-model="globalStore.journalTimetableSearch.driverName"
type="text"
@keydown.enter="fetchJournalTimetables"
:class="`bg-zinc-800 p-1 rounded-md print:hidden w-full ${
apiStore.connectionMode == 'offline' ? 'opacity-35' : ''
}`"
:disabled="
apiStore.journalDataStatus == DataStatus.LOADING || apiStore.connectionMode == 'offline'
"
:placeholder="$t('journal-driver-search-placeholder')"
/>
<input
v-model="globalStore.journalTimetableSearch.route"
type="text"
@keydown.enter="fetchJournalTimetables"
:class="`bg-zinc-800 p-1 rounded-md print:hidden w-full ${
apiStore.connectionMode == 'offline' ? 'opacity-35' : ''
}`"
:disabled="
apiStore.journalDataStatus == DataStatus.LOADING || apiStore.connectionMode == 'offline'
"
:placeholder="$t('journal-route-search-placeholder')"
/>
<input
v-model="globalStore.journalTimetableSearch.date"
type="date"
@keydown.enter="fetchJournalTimetables"
:class="`bg-zinc-800 p-1 rounded-md print:hidden w-full ${
apiStore.connectionMode == 'offline' ? 'opacity-35' : ''
}`"
:disabled="
apiStore.journalDataStatus == DataStatus.LOADING || apiStore.connectionMode == 'offline'
"
:placeholder="$t('journal-date-search-placeholder')"
/>
</div>
<button
class="bg-zinc-800 hover:bg-zinc-700 p-1 rounded-md"
v-if="globalStore.viewMode == 'journal'"
@click="clearSearch"
>
<Trash2Icon />
</button>
<button
class="bg-zinc-800 hover:bg-zinc-700 p-1 rounded-md"
v-if="globalStore.viewMode == 'journal'"
@click="fetchJournalTimetables"
>
<SearchIcon />
</button>
</div>
</template>
<script setup lang="ts">
import { useApiStore } from '../../stores/api.store';
import { useGlobalStore } from '../../stores/global.store';
import { DataStatus, type JournalTimetablesShortResponse } from '../../types/api.types';
import { SearchIcon, Trash2Icon } from 'lucide-vue-next';
const globalStore = useGlobalStore();
const apiStore = useApiStore();
async function fetchJournalTimetables() {
const searchValues = globalStore.journalTimetableSearch;
let fetchParams: Record<string, any> = {};
if (searchValues['driverName']) {
fetchParams['driverName'] = searchValues['driverName'].trim();
}
if (searchValues['date']) {
let dateFromStr = new Date(searchValues['date']).toISOString();
let dateTo = new Date(dateFromStr);
dateTo.setDate(dateTo.getDate() + 1);
fetchParams['dateFrom'] = dateFromStr;
fetchParams['dateTo'] = dateTo.toISOString();
}
if (searchValues['route']) {
const [routeFrom, routeTo] = searchValues['route'].split('-');
if (routeFrom) {
fetchParams['issuedFrom'] = routeFrom.trim();
}
if (routeTo) {
fetchParams['terminatingAt'] = routeTo.trim();
}
}
fetchParams['hasStopsDetails'] = 1;
fetchParams['returnType'] = 'short';
try {
apiStore.journalDataStatus = DataStatus.LOADING;
const response = await apiStore.client.get<JournalTimetablesShortResponse>(
'api/getTimetables',
fetchParams
);
apiStore.journalDataStatus = DataStatus.SUCCESS;
apiStore.journalTimetablesData = response;
} catch (error) {
apiStore.journalDataStatus = DataStatus.ERROR;
apiStore.journalTimetablesData = null;
console.error(error);
}
}
function clearSearch() {
Object.keys(globalStore.journalTimetableSearch).forEach(
(k) => ((globalStore.journalTimetableSearch as any)[k] = '')
);
apiStore.journalTimetablesData = null;
}
</script>
@@ -0,0 +1,33 @@
<template>
<div class="flex gap-2" v-if="globalStore.currentTimetableData == null">
<div class="w-full">
<input
v-model="globalStore.localTimetableSearch"
type="text"
class="bg-zinc-800 p-1 rounded-md print:hidden w-full"
:placeholder="$t('train-search-placeholder')"
/>
</div>
<div>
<button
class="bg-zinc-800 hover:bg-zinc-700 p-1 rounded-md"
v-if="globalStore.viewMode == 'storage'"
@click="clearSearch"
>
<Trash2Icon />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { Trash2Icon } from 'lucide-vue-next';
import { useGlobalStore } from '../../stores/global.store';
const globalStore = useGlobalStore();
function clearSearch() {
globalStore.localTimetableSearch = '';
}
</script>
@@ -0,0 +1,26 @@
<template>
<div class="flex gap-2 flex-col print:hidden">
<!-- Top Actions & Modes -->
<SearchModeActions />
<!-- Active Data Search -->
<ActiveSearchInput v-if="globalStore.viewMode == 'active'" />
<!-- Local Storage Search -->
<LocalSearchInput v-else-if="globalStore.viewMode == 'storage'" />
<!-- Journal Serach -->
<JournalSearchInput v-else />
</div>
</template>
<script setup lang="ts">
import { useGlobalStore } from '../../stores/global.store';
import ActiveSearchInput from './ActiveSearchInput.vue';
import JournalSearchInput from './JournalSearchInput.vue';
import LocalSearchInput from './LocalSearchInput.vue';
import SearchModeActions from './SearchModeActions.vue';
const globalStore = useGlobalStore();
</script>
@@ -0,0 +1,179 @@
<template>
<div class="flex justify-between gap-2">
<div class="flex gap-2">
<button
:class="`p-1 rounded-md ${
globalStore.viewMode == 'active'
? 'bg-green-600 hover:bg-green-500'
: 'bg-zinc-800 hover:bg-zinc-700'
}`"
@click="toggleViewMode('active')"
aria-label="Active data view mode"
>
<WifiIcon />
</button>
<button
:class="`p-1 rounded-md ${
globalStore.viewMode == 'storage'
? 'bg-green-600 hover:bg-green-500'
: 'bg-zinc-800 hover:bg-zinc-700'
}`"
@click="toggleViewMode('storage')"
aria-label="Storage view mode"
>
<ArchiveIcon />
</button>
<button
:class="`p-1 rounded-md ${
globalStore.viewMode == 'journal'
? 'bg-green-600 hover:bg-green-500'
: 'bg-zinc-800 hover:bg-zinc-700'
}`"
@click="toggleViewMode('journal')"
aria-label="Journal view mode"
>
<HistoryIcon />
</button>
</div>
<div class="flex gap-2">
<button
class="bg-zinc-800 p-1 rounded-md hover:bg-zinc-700"
@click="toggleDarkMode"
aria-label="Dark mode toggle"
>
<MoonIcon v-if="globalStore.darkMode" />
<SunIcon v-else />
</button>
<button
class="bg-zinc-800 p-1 rounded-md hover:bg-zinc-700 disabled:opacity-60 disabled:hover:bg-zinc-800"
@click="toggleFullscreenMode()"
:disabled="globalStore.currentTimetableData == null"
aria-label="Full screen toggle"
>
<FullscreenIcon :size="24" />
</button>
<button
class="bg-zinc-800 p-1 rounded-md hover:bg-zinc-700 disabled:opacity-60 disabled:hover:bg-zinc-800"
:disabled="globalStore.currentTimetableData == null"
@click="openPrintingWindow"
aria-label="Print mode"
>
<PrinterIcon />
</button>
<button
class="p-1 rounded-md disabled:opacity-60 disabled:hover:bg-zinc-800"
:disabled="globalStore.currentTimetableData == null"
:class="{
'bg-green-600 hover:bg-green-700': isTimetableSaved,
'bg-zinc-800 hover:bg-zinc-700': !isTimetableSaved
}"
@click="saveToStorage"
aria-label="Save timetable to storage"
>
<FolderDownIcon />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue';
import { useGlobalStore } from '../../stores/global.store';
import type { ViewMode, TimetableData } from '../../types/common.types';
import {
ArchiveIcon,
FolderDownIcon,
FullscreenIcon,
HistoryIcon,
MoonIcon,
PrinterIcon,
SunIcon,
WifiIcon
} from 'lucide-vue-next';
const globalStore = useGlobalStore();
// Computed
const isTimetableSaved = computed(() => {
if (!globalStore.currentTimetableData) return false;
return Object.keys(globalStore.storageTimetables).includes(
`${globalStore.currentTimetableData.timetableId}`
);
});
// Watchers
watch(
() => globalStore.selectedActiveTrain,
(curr) => {
if (curr != null) {
globalStore.generatedDate = new Date();
}
}
);
// Methods
function toggleViewMode(viewMode: ViewMode) {
globalStore.viewMode = viewMode;
}
function toggleDarkMode() {
globalStore.darkMode = !globalStore.darkMode;
window.localStorage.setItem('currentTheme', globalStore.darkMode ? 'dark' : 'light');
}
function saveToStorage() {
if (globalStore.currentTimetableData == null) return;
try {
const savedTimetablesStorage = localStorage.getItem('savedTimetables');
let savedTimetablesJSON: Record<number, TimetableData> = savedTimetablesStorage
? JSON.parse(savedTimetablesStorage)
: {};
if (savedTimetablesJSON[globalStore.currentTimetableData.timetableId] !== undefined) {
globalStore.selectedStorageTimetable =
savedTimetablesJSON[globalStore.currentTimetableData.timetableId];
globalStore.viewMode = 'storage';
return;
}
savedTimetablesJSON[globalStore.currentTimetableData.timetableId] = {
...globalStore.currentTimetableData,
savedTimestamp: Date.now()
};
localStorage.setItem('savedTimetables', JSON.stringify(savedTimetablesJSON));
globalStore.storageTimetables = savedTimetablesJSON;
} catch (error) {}
}
function openPrintingWindow() {
if (globalStore.selectedActiveTrain != null) {
const date = `${globalStore
.generatedDate!.toLocaleDateString('pl-PL')
.replace(/\./g, '-')}--${globalStore
.generatedDate!.toLocaleTimeString('pl-PL')
.replace(/:/g, '-')}`;
document.title = `${globalStore.selectedActiveTrain.driverName} ; ${
globalStore.selectedActiveTrain.timetable!.category
} ${globalStore.selectedActiveTrain.trainNo}
${globalStore.selectedActiveTrain.timetable?.route.replace('|', ' - ')} ; ${date}`;
}
window.print();
}
function toggleFullscreenMode() {
globalStore.fullscreenMode = !globalStore.fullscreenMode;
}
</script>
@@ -0,0 +1,12 @@
<template>
<div>
<div v-if="apiStore.connectionMode == 'online'">{{ $t('train-select-info') }}</div>
<div v-else class="bg-red-500 text-white p-2">{{ $t('data-offline-mode') }}</div>
</div>
</template>
<script setup lang="ts">
import { useApiStore } from '../../stores/api.store';
const apiStore = useApiStore();
</script>
@@ -0,0 +1,23 @@
<template>
<div
class="overflow-auto p-1 bg-white print:bg-white dark:bg-zinc-950 print:text-black text-black dark:text-white min-h-full relative"
:class="{ dark: globalStore.darkMode }"
>
<!-- <button
v-if="globalStore.fullscreenMode"
class="fixed right-0 top-0 bg-green-600 p-1 m-1 rounded-md hover:bg-green-500 print:hidden"
@click="() => (globalStore.fullscreenMode = false)"
>
<Minimize2Icon :size="22" />
</button> -->
<TimetableContent />
</div>
</template>
<script setup lang="ts">
import { useGlobalStore } from '../../stores/global.store';
import TimetableContent from '../Timetable/TimetableContent.vue';
const globalStore = useGlobalStore();
</script>
@@ -0,0 +1,93 @@
<template>
<div class="text-white">
<h2
class="font-bold p-2 bg-zinc-700 mb-3 text-2xl flex items-center gap-2 justify-center flex-wrap"
>
<HistoryIcon :size="25" />
{{ $t('journal-preview-title') }}
</h2>
<div v-if="apiStore.connectionMode == 'offline'" class="bg-red-500 p-2">
{{ $t('data-offline-mode') }}
</div>
<div v-else-if="apiStore.journalDataStatus == DataStatus.LOADING" class="bg-zinc-900 p-2">
{{ $t('data-loading-text') }}
</div>
<div v-else-if="apiStore.journalDataStatus == DataStatus.ERROR" class="bg-red-500 p-2">
{{ $t('data-loading-error-text') }}
</div>
<div
v-else-if="!apiStore.journalTimetablesData"
class="text-zinc-400 mt-2"
v-html="$t('journal-empty-info')"
></div>
<div v-else-if="apiStore.journalTimetablesData.length == 0">
<p class="text-zinc-300 mb-2">
{{ $t('journal-no-data') }}
</p>
<b class="text-red-300">
{{ $t('journal-reminder-text') }}
</b>
</div>
<div v-else>
<ul>
<li
v-for="timetable in apiStore.journalTimetablesData"
class="flex gap-1 w-full my-2"
@click="fetchTimetableDetails(timetable.id)"
>
<button class="bg-zinc-900 p-2 w-full cursor-pointer hover:bg-zinc-800 text-left">
<div class="text-zinc-300">
#{{ timetable.id }} &bull;
{{ new Date(timetable.createdAt!).toLocaleString() }}
</div>
<b>
{{ timetable.driverName }} | {{ timetable.trainCategoryCode }}
{{ timetable.trainNo }}
</b>
{{ timetable.route.replace('|', ' > ') }}
</button>
</li>
</ul>
<div v-if="apiStore.journalTimetablesData.length > 0">
<hr class="border-t-2 border-t-gray-500" />
<p class="text-zinc-400 text-sm">
{{ $t('journal-footer-text') }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { HistoryIcon } from 'lucide-vue-next';
import { useApiStore } from '../../stores/api.store';
import { useGlobalStore } from '../../stores/global.store';
import { DataStatus } from '../../types/api.types';
import type { JournalTimetableDetailed } from '../../types/common.types';
const apiStore = useApiStore();
const globalStore = useGlobalStore();
async function fetchTimetableDetails(id: number) {
try {
const response = await apiStore.client.get<JournalTimetableDetailed[]>('api/getTimetables', {
timetableId: id,
returnType: 'detailed'
});
if (response.length > 0) globalStore.selectedJournalTimetable = response[0];
} catch (error) {
globalStore.selectedJournalTimetable = null;
console.error(error);
}
}
</script>
@@ -0,0 +1,113 @@
<template>
<div class="text-white">
<div class="font-bold p-2 bg-zinc-700 mb-3">
<div class="text-2xl flex items-center gap-2 justify-center flex-wrap">
<ArchiveIcon :size="25" />
<span>{{ $t('storage-preview-title') }}</span>
</div>
</div>
<div
v-if="
globalStore.selectedStorageTimetable == null &&
Object.keys(globalStore.storageTimetables).length == 0
"
class="text-zinc-400"
>
{{ $t('storage-empty-info') }}
</div>
<div class="font-bold p-2 bg-zinc-800 mb-3" v-else-if="filteredTimetables.length == 0">
{{ $t('storage-preview-empty') }}
</div>
<ul v-else>
<li v-for="timetable in filteredTimetables" class="flex gap-1 w-full my-2">
<button
class="bg-zinc-900 p-2 w-full cursor-pointer hover:bg-zinc-800 text-left"
@click="selectTimetable(timetable)"
>
<div class="text-zinc-300 flex gap-x-2 items-center flex-wrap">
<span>#{{ timetable.timetableId }}</span>
<i class="flex items-center gap-1"><ArchiveIcon :size="18" :stroke-width="3" /> {{ new Date(timetable.savedTimestamp!).toLocaleString() }}</i>
<i
v-if="timetable.journalCreatedAt"
class="flex items-center gap-0.5"
:title="
$t('storage-journal-timetable-placeholder', {
date: new Date(timetable.journalCreatedAt).toLocaleDateString('pl-PL')
})
"
>
<HistoryIcon :size="18" :stroke-width="3" />
{{ new Date(timetable.journalCreatedAt).toLocaleDateString('pl-PL') }}
</i>
</div>
<b>{{ timetable.driverName }} | {{ timetable.category }} {{ timetable.trainNo }}</b>
{{ timetable.route.replace('|', ' > ') }}
</button>
<button
class="bg-zinc-900 p-2 hover:bg-zinc-800"
@click="removeTimetable(timetable.timetableId)"
>
<Trash2Icon />
</button>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useGlobalStore } from '../../stores/global.store';
import type { TimetableData } from '../../types/common.types';
import { ArchiveIcon, HistoryIcon, Trash2Icon } from 'lucide-vue-next';
const globalStore = useGlobalStore();
const i18n = useI18n();
const filteredTimetables = computed(() => {
let timetables = Object.values(globalStore.storageTimetables);
if (globalStore.localTimetableSearch.length != 0)
timetables = timetables.filter((st) =>
`${st.timetableId} ${st.driverName} ${st.route} ${st.category} ${st.trainNo}`
.toLocaleLowerCase()
.includes(globalStore.localTimetableSearch.toLocaleLowerCase())
);
timetables.sort((a, b) => {
return (b.savedTimestamp ?? 0) - (a.savedTimestamp ?? 0);
});
return timetables;
});
function selectTimetable(timetable: TimetableData) {
globalStore.selectedStorageTimetable = timetable;
}
function removeTimetable(timetableId: number) {
const isConfirmed = confirm(i18n.t('delete-timetable-confirm'));
if (!isConfirmed) return;
try {
const savedTimetablesStorage = localStorage.getItem('savedTimetables');
let savedTimetablesJSON: Record<number, TimetableData> = savedTimetablesStorage
? JSON.parse(savedTimetablesStorage)
: {};
delete savedTimetablesJSON[timetableId];
localStorage.setItem('savedTimetables', JSON.stringify(savedTimetablesJSON));
globalStore.storageTimetables = savedTimetablesJSON;
} catch (error) {
console.error(error);
}
}
</script>
+23
View File
@@ -0,0 +1,23 @@
export class HttpClient {
constructor(private readonly baseURL: string) {}
async get<T>(url: string, params?: Record<string, any>): Promise<T> {
const absoluteURL = new URL(this.baseURL + '/' + url);
if (params) {
Object.keys(params).forEach((key) => {
if (params[key] === undefined) return;
absoluteURL.searchParams.append(key, params[key]);
});
}
const data = await fetch(absoluteURL);
if (!data.ok) {
throw new Error(`Cannot fetch ${absoluteURL}: ${data.status}`);
}
return data.json();
}
}
+52 -25
View File
@@ -1,26 +1,53 @@
{
"data-loading-text": "Loading data...",
"train-select-placeholder": "Choose active train from the list",
"train-select-info": "Choose active train to generate SRJP timetable",
"train-search-placeholder": "Enter TT details (number, route, user)",
"headers": {
"line_no": "Line\nno.",
"line_km": "Km",
"station": "Station",
"time": "Time",
"loco_1": "Loco I",
"loco_2": "Loco II",
"loco_3": "Loco III",
"mass": "Loco load",
"length": "Train len.",
"vmax": "Vmax",
"relation": "Route"
},
"storage-empty-header": "ARCHIVED TIMETABLES SEARCH MODE",
"storage-empty-info": "Timetables will be shown here after their archiving.",
"storage-preview-title": "ARCHIVED TIMETABLES",
"storage-preview-empty": "No entries found for given parameters",
"storage-preview-info": "Archived timetable {id} for user {driverName} from: {date}",
"storage-preview-button-text": "Return",
"delete-timetable-confirm": "Are you sure that you want to delete this timetable?"
}
"data-loading-text": "Loading data...",
"data-loading-error-text": "Oops! An error occurent while loading data from the server!",
"train-select-placeholder": "Choose active train from the list",
"train-select-info": "Choose active train to generate SRJP timetable",
"train-search-placeholder": "Enter TT details (number, route, user)",
"update-prompt": {
"line1": "New version of SRJP is available!",
"line2": "Click here to update the app!"
},
"data-offline-mode": "You're currently using the offline mode of the SRJP app - server data is unavailable!",
"headers": {
"line_no": "Line\nno.",
"line_km": "Km",
"station": "Station",
"time": "Time",
"loco_1": "Loco I",
"loco_2": "Loco II",
"loco_3": "Loco III",
"mass": "Loco load",
"length": "Train len.",
"vmax": "Vmax",
"relation": "Route"
},
"migrate-info": {
"line-1": "Rozkładownik is being moved to a new domain - {0}! You can still use the current website, but it will no longer be updated and will be shut down in the nearest future!",
"accept-btn": "ROGER THAT!"
},
"storage-empty-header": "ARCHIVED TIMETABLES SEARCH MODE",
"storage-empty-info": "Timetables will be shown here after their archiving.",
"storage-preview-title": "ARCHIVED TIMETABLES",
"storage-preview-empty": "No entries found for given parameters",
"storage-preview-info": "Archived timetable {id} for user {driverName} from: {date}",
"storage-preview-button-text": "Return",
"storage-journal-timetable-placeholder": "Saved historical timetable from day {date}",
"delete-timetable-confirm": "Are you sure that you want to delete this timetable?",
"journal-preview-title": "TIMETABLES JOURNAL",
"journal-empty-info": "Enter timetable details in the text fields above (use at least one field).<br>Up to 15 newest timetables will be shown.",
"journal-driver-search-placeholder": "Driver",
"journal-date-search-placeholder": "Date",
"journal-route-search-placeholder": "Route",
"journal-preview-info": "Historical timetable {id} for user {driverName} from: {date}",
"journal-no-data": "No data for the current search! Check if the data you entered is correct.",
"journal-reminder-text": "Warning: detailed timetables data for SRJP purpose are collected since 1st February 2025 and only for users who support Stacjownik project!",
"journal-footer-text": "Detailed timetables data for SRJP purpose are collected since 1st February 2025 and only for users who support Stacjownik project!"
}
+52 -25
View File
@@ -1,26 +1,53 @@
{
"data-loading-text": "Ładowanie danych...",
"train-select-placeholder": "Wybierz pociąg z listy",
"train-select-info": "Wybierz aktywny pociąg, aby wygenerować SRJP",
"train-search-placeholder": "Wpisz szczegóły RJ (nr, relacja, gracz)",
"headers": {
"line_no": "Nr\nlinii",
"line_km": "Km",
"station": "Stacja",
"time": "Godzina",
"loco_1": "Lok I",
"loco_2": "Lok II",
"loco_3": "Lok III",
"mass": "Obc. lok.",
"length": "Dł. poc.",
"vmax": "Vmax",
"relation": "Relacja"
},
"storage-empty-header": "TRYB WYSZUKIWANA ZAPISANYCH ROZKŁADÓW JAZDY",
"storage-empty-info": "Użyj funkcji zapisu rozkładu jazdy, aby go tutaj wyświetlić.",
"storage-preview-title": "ZAPISANE ROZKŁADY JAZDY",
"storage-preview-empty": "Nie znaleziono żadnych wpisów dla podanych parametrów",
"storage-preview-info": "Rozkład archiwalny {id} maszynisty {driverName} z dnia {date}",
"storage-preview-button-text": "Powróć",
"delete-timetable-confirm": "Czy na pewno chcesz usunąć ten rozkład jazdy z archiwum?"
}
"data-loading-text": "Ładowanie danych...",
"data-loading-error-text": "Ups! Wystąpił błąd podczas pobierania danych z serwera!",
"train-select-placeholder": "Wybierz pociąg z listy",
"train-select-info": "Wybierz aktywny pociąg, aby wygenerować SRJP",
"train-search-placeholder": "Wpisz szczegóły RJ (nr, relacja, gracz)",
"update-prompt": {
"line1": "Nowa wersja SRJP jest dostępna!",
"line2": "Kliknij, aby zaktualizować aplikację!"
},
"data-offline-mode": "Korzystasz z trybu offline aplikacji SRJP - dane serwerowe są niedostępne!",
"headers": {
"line_no": "Nr\nlinii",
"line_km": "Km",
"station": "Stacja",
"time": "Godzina",
"loco_1": "Lok I",
"loco_2": "Lok II",
"loco_3": "Lok III",
"mass": "Obc. lok.",
"length": "Dł. poc.",
"vmax": "Vmax",
"relation": "Relacja"
},
"migrate-info": {
"line-1": "Rozkładownik zostaje przeniesiony na nową domenę - {0}! Możesz korzystać z obecnej strony, jednak nie będzie ona otrzymywać już aktualizacji i w przyszłości zostanie wyłączona!",
"accept-btn": "PRZYJĄŁEM!"
},
"storage-empty-header": "TRYB WYSZUKIWANA ZAPISANYCH ROZKŁADÓW JAZDY",
"storage-empty-info": "Użyj funkcji zapisu rozkładu jazdy, aby go tutaj wyświetlić.",
"storage-preview-title": "ZAPISANE ROZKŁADY JAZDY",
"storage-preview-empty": "Nie znaleziono żadnych wpisów dla podanych parametrów",
"storage-preview-info": "Rozkład archiwalny {id} maszynisty {driverName} z dnia {date}",
"storage-preview-button-text": "Powróć",
"storage-journal-timetable-placeholder": "Zapisany historyczny rozkład jazdy z dnia {date}",
"delete-timetable-confirm": "Czy na pewno chcesz usunąć ten rozkład jazdy z archiwum?",
"journal-preview-title": "DZIENNIK ROZKŁADÓW JAZDY",
"journal-empty-info": "Wpisz dane rozkładu korzystając z pól tekstowych powyżej (co najmniej jednego).<br>W przypadku wielu rozkładów jazdy wyświetli się maks. 15 najnowszych.",
"journal-driver-search-placeholder": "Maszynista",
"journal-date-search-placeholder": "Data",
"journal-route-search-placeholder": "Relacja",
"journal-preview-info": "Rozkład historyczny {id} maszynisty {driverName} z dnia {date}",
"journal-no-data": "Brak wyników dla obecnego wyszukiwania! Sprawdź czy wpisałeś poprawnie dane.",
"journal-reminder-text": "Uwaga: szczegółowe rozkłady jazdy są zapisywane od 1 lutego 2025r. wyłącznie dla osób wspierających projekt Stacjownika!",
"journal-footer-text": "Szczegółowe dane o rozkładach jazdy do wygenerowania SRJP są zbierane od 1 lutego 2025r. wyłącznie dla maszynistów wspierających projekt Stacjownika!"
}
+27
View File
@@ -0,0 +1,27 @@
import { useApiStore } from '../stores/api.store';
export function useVehicleMixin() {
const apiStore = useApiStore();
/**
* Gets loco load (obc. lok.) in tons - effectively train mass without locomotive or lone locomotive / unit mass
*/
function getLocoLoad(trainMass: number, stockString: string) {
if (!apiStore.vehiclesData) return trainMass;
const stockArray = stockString.split(';');
const headUnitsNames = stockArray.slice(0, 3).filter((v) => /-\d{3,}$/.test(v));
const headVehicleData = apiStore.vehiclesData.find((v) => v.name == headUnitsNames[0]);
if (!headVehicleData) return trainMass;
// 0t load for loco only
if (headVehicleData.type.startsWith("loco") && stockArray.length == 1) return 0;
else if (headVehicleData.type.startsWith("unit")) return trainMass;
return Math.min(trainMass, trainMass - headVehicleData.group.weight);
}
return { getLocoLoad };
}
+68 -32
View File
@@ -1,60 +1,83 @@
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import { defineStore } from 'pinia';
import { DataStatus, type ActiveDataResponse, type SceneriesDataResponse } from '../types/api.types';
import type { ActiveData, SceneryData } from '../types/common.types';
import {
DataStatus,
type ActiveDataResponse,
type SceneriesDataResponse,
type VehiclesDataResponse
} from '../types/api.types';
import type {
ActiveData,
JournalTimetableShort,
SceneryData,
VehicleData
} from '../types/common.types';
import { HttpClient } from '../http';
let activeDataInterval = -1;
// Base API URL
let baseURL = 'https://stacjownik.spythere.eu';
switch (import.meta.env.VITE_API_MODE) {
case 'development':
baseURL = 'http://localhost:3001';
break;
case 'mocking':
baseURL = 'http://localhost:3123';
break;
default:
break;
}
export const useApiStore = defineStore('api', {
state() {
return {
client: null as AxiosInstance | null,
client: new HttpClient(baseURL),
activeData: null as ActiveData | null,
sceneryData: null as SceneryData[] | null,
vehiclesData: null as VehicleData[] | null,
journalTimetablesData: null as JournalTimetableShort[] | null,
outdatedTimerId: -1,
isActiveDataOutdated: false,
activeDataStatus: DataStatus.LOADING,
apiDataStatus: DataStatus.LOADING,
journalDataStatus: DataStatus.SUCCESS,
connectionMode: 'online' as 'online' | 'offline'
};
},
actions: {
async setupAPIData() {
if (this.client != null) return;
clearInterval(activeDataInterval);
let baseURL = 'https://stacjownik.spythere.eu';
try {
this.apiDataStatus = DataStatus.LOADING;
switch (import.meta.env.VITE_API_MODE) {
case 'development':
baseURL = 'http://localhost:3001';
break;
case 'mocking':
baseURL = 'http://localhost:3123';
break;
default:
break;
await Promise.all([
this.fetchSceneriesData(),
this.fetchVehiclesData(),
this.fetchActiveData()
]);
this.apiDataStatus = DataStatus.SUCCESS;
} catch (error) {
this.apiDataStatus = DataStatus.ERROR;
console.log('Data fetching error: ', error);
}
this.client = axios.create({
baseURL,
});
this.fetchSceneriesData();
await this.fetchActiveData();
setInterval(() => {
activeDataInterval = setInterval(() => {
this.fetchActiveData();
}, 25000);
},
async fetchActiveData() {
try {
const response = (await this.client!.get<ActiveDataResponse>('/api/getActiveData')).data;
const response = await this.client.get<ActiveDataResponse>('api/getActiveData');
this.activeData = response;
this.activeDataStatus = DataStatus.SUCCESS;
this.isActiveDataOutdated = false;
if (this.outdatedTimerId != -1) clearTimeout(this.outdatedTimerId);
@@ -63,18 +86,31 @@ export const useApiStore = defineStore('api', {
this.isActiveDataOutdated = true;
}, 60000);
} catch (error) {
console.error(error);
throw error;
}
},
async fetchSceneriesData() {
try {
const response = (await this.client!.get<SceneriesDataResponse>('/api/getSceneries')).data;
const response = await this.client.get<SceneriesDataResponse>('api/getSceneries');
this.sceneryData = response;
} catch (error) {
console.error(error);
throw error;
}
},
},
async fetchVehiclesData() {
try {
const response = await this.client.get<VehiclesDataResponse>('api/getVehiclesData');
this.vehiclesData = response.vehicles.map((v) => ({
...v,
group: response.vehicleGroups.find((g) => g.id == v.vehicleGroupsId)!
}));
} catch (error) {
throw error;
}
}
}
});
+60 -21
View File
@@ -1,16 +1,25 @@
import { defineStore } from 'pinia';
import { useApiStore } from './api.store';
import type { ActiveTrain, TimetableData, ViewMode } from '../types/common.types';
import { unitNameCorrections } from '../utils/trainUtils';
import type {
ActiveTrain,
JournalTimetableDetailed,
TimetableData,
ViewMode
} from '../types/common.types';
import { getHeadUnits } from '../utils/trainUtils';
import { useVehicleMixin } from '../mixins/useVehicleMixin';
export const useGlobalStore = defineStore('global', {
state: () => ({
darkMode: false,
fullscreenMode: false,
viewMode: 'active' as ViewMode,
selectedTrainId: null as string | null,
selectedActiveTrain: null as ActiveTrain | null,
selectedStorageTimetable: null as TimetableData | null,
selectedJournalTimetable: null as JournalTimetableDetailed | null,
storageTimetables: {} as Record<number, TimetableData>,
timetableWarnings: [] as string[],
@@ -18,9 +27,16 @@ export const useGlobalStore = defineStore('global', {
generatedDate: null as Date | null,
generatedMs: 0,
timetableSearch: '',
localTimetableSearch: '',
journalTimetableSearch: {
driverName: '',
date: '',
route: ''
},
showSettings: false,
isMigrationInfoOpen: false
}),
getters: {
activeTimetableTrains() {
@@ -28,10 +44,14 @@ export const useGlobalStore = defineStore('global', {
if (!apiStore.activeData) return [];
return apiStore.activeData.trains.filter((train) => train.timetable).sort((t1, t2) => t1.driverName.localeCompare(t2.driverName, 'pl-PL'));
return apiStore.activeData.trains
.filter((train) => train.timetable)
.sort((t1, t2) => t1.driverName.localeCompare(t2.driverName, 'pl-PL'));
},
currentTimetableData(): TimetableData | null {
const vehicleUtils = useVehicleMixin();
if (this.viewMode == 'active') {
const selectedTrain = this.selectedActiveTrain;
@@ -39,7 +59,7 @@ export const useGlobalStore = defineStore('global', {
return {
trainNo: selectedTrain.trainNo,
mass: selectedTrain.mass,
mass: vehicleUtils.getLocoLoad(selectedTrain.mass, selectedTrain.stockString),
length: selectedTrain.length,
driverId: selectedTrain.driverId,
driverName: selectedTrain.driverName,
@@ -52,29 +72,48 @@ export const useGlobalStore = defineStore('global', {
trainMaxSpeed: selectedTrain.timetable.trainMaxSpeed,
timetableId: selectedTrain.timetable.timetableId,
stopListString: selectedTrain.timetable.stopList
.filter((stop) => stop.mainStop || (/^podg|po|pe$/.test(stop.stopNameRAW)))
.filter((stop) => stop.mainStop || /^podg|po|pe$/.test(stop.stopNameRAW))
.map(
(stop) =>
`${stop.arrivalLine ?? ''};${stop.arrivalTimestamp};${stop.stopNameRAW};${stop.stopTime ? stop.stopTime + '_' + stop.stopType : ''};${
stop.mainStop
};${stop.stopDistance};${stop.departureTimestamp};${stop.departureLine ?? ''}`
`${stop.arrivalLine ?? ''};${stop.arrivalTimestamp};${stop.stopNameRAW};${
stop.stopTime ? stop.stopTime + '_' + stop.stopType : ''
};${stop.mainStop};${stop.stopDistance};${stop.departureTimestamp};${
stop.departureLine ?? ''
}`
)
.join('~~'),
headUnits: selectedTrain.stockString
.split(';')
.slice(0, 3)
.filter((s, i) => i == 0 || /-\d+$/.test(s))
.map((s) => {
const unitName = s.slice(0, s.indexOf('-'));
headUnits: getHeadUnits(selectedTrain.stockString)
};
} else if (this.viewMode == 'journal') {
const selectedTimetable = this.selectedJournalTimetable;
return unitNameCorrections[unitName] ?? unitName;
}),
if (!selectedTimetable || !selectedTimetable.stopListString) return null;
return {
journalCreatedAt: new Date(selectedTimetable.createdAt).getTime(),
trainNo: selectedTimetable.trainNo,
mass: vehicleUtils.getLocoLoad(
selectedTimetable.stockMass,
selectedTimetable.stockString
),
length: selectedTimetable.stockLength,
driverId: selectedTimetable.driverId,
driverName: selectedTimetable.driverName,
category: selectedTimetable.trainCategoryCode,
hasDangerousCargo: selectedTimetable.hasDangerousCargo,
hasExtraDeliveries: selectedTimetable.hasExtraDeliveries,
warningNotes: selectedTimetable.warningNotes,
path: selectedTimetable.path,
route: selectedTimetable.route,
trainMaxSpeed: selectedTimetable.trainMaxSpeed,
timetableId: selectedTimetable.id,
stopListString: selectedTimetable.stopListString,
headUnits: getHeadUnits(selectedTimetable.stockString)
};
} else {
const selectedStorageTimetable = this.selectedStorageTimetable;
return selectedStorageTimetable;
return this.selectedStorageTimetable;
}
},
}
},
actions: {},
actions: {}
});
+12 -1
View File
@@ -32,7 +32,6 @@ body {
::-webkit-scrollbar-corner {
background: theme('colors.stone.900');
border-radius: 0 0 theme('borderRadius.md') 0;
}
/* Tooltips */
@@ -86,3 +85,15 @@ body {
color-scheme: light;
}
}
/* Animations */
.slide-anim-enter-active,
.slide-anim-leave-active {
transition: all 250ms ease-in-out;
transform: translateY(0);
}
.slide-anim-enter-from,
.slide-anim-leave-to {
transform: translateY(100%);
}
+51 -5
View File
@@ -1,11 +1,57 @@
import type { ActiveData, SceneryData } from './common.types';
import type {
ActiveData,
JournalTimetableShort,
SceneryData,
VehicleGroup,
VehicleRestrictions
} from './common.types';
/***
* API Data Status
* */
export enum DataStatus {
'INIT' = -1,
'LOADING' = 0,
'SUCCESS' = 1,
'ERROR' = 2
}
/***
* Active Data API
* */
export type ActiveDataResponse = ActiveData;
/***
* Sceneries API
* */
export type SceneriesDataResponse = SceneryData[];
export enum DataStatus {
'LOADING' = 0,
'SUCCESS' = 1,
'ERROR' = 2,
/***
* Journal API
* */
export type JournalTimetablesShortResponse = JournalTimetableShort[];
/***
* Vehicles API
* */
export interface VehiclesDataResponse {
vehicles: VehicleDataAPI[];
vehicleGroups: VehicleGroupAPI[];
}
export interface VehicleDataAPI {
id: number;
name: string;
type: string;
cabinName?: string;
restrictions?: VehicleRestrictions;
vehicleGroupsId: number;
}
export interface VehicleGroupAPI extends VehicleGroup {}
+78 -6
View File
@@ -1,4 +1,4 @@
export type ViewMode = 'active' | 'storage';
export type ViewMode = 'active' | 'storage' | 'journal';
export interface ActiveData {
trains: ActiveTrain[];
@@ -119,6 +119,7 @@ export interface SceneryRoute {
isInternal: boolean;
isRouteSBL: boolean;
routeSpeed: number;
routeSpeedExit?: number;
routeLength: number;
routeTracks: number;
hidden?: boolean;
@@ -133,20 +134,48 @@ export interface StopRow {
stopType: string;
scheduledArrivalDate: Date | null;
scheduledDepartureDate: Date | null;
realLine: string;
arrivalLineNumber: string;
departureLineNumber: string;
driveTime: number;
abbrevs: string[];
sceneryName: string;
arrivalKm: string;
arrivalSpeed: number;
arrivalSpeedL: number;
arrivalSpeedP: number;
arrivalTracks: number;
departureKm: string;
departureSpeed: number;
departureSpeedL: number;
departureSpeedP: number;
departureTracks: number;
headUnits: string[];
stockVmax: number;
stockLength: number;
stockMass: number;
lastRowRef: StopRow | null;
}
export interface StopRowCZ {
pointName: string;
pointKm: string;
isMain: boolean;
stopTime: number;
stopType: string;
scheduledArrivalDate: Date | null;
scheduledDepartureDate: Date | null;
driveTime: number;
sceneryName: string;
arrivalSpeed: number;
departureSpeed: number;
arrivalTracks: number;
departureTracks: number;
headUnits: string[];
stockVmax: number;
stockLength: number;
stockMass: number;
arrivalDateStr: string;
departureDateStr: string;
}
export interface TimetablePathData {
@@ -223,7 +252,6 @@ export interface JournalTimetableDetailed extends JournalTimetableShort {
createdAt: string;
updatedAt: string;
stockHistory: string[];
hidden: boolean;
routeSceneries: string;
checkpointArrivals: any[];
checkpointDepartures: any[];
@@ -240,7 +268,7 @@ export interface JournalTimetableDetailed extends JournalTimetableShort {
warningNotes: string;
hasDangerousCargo: boolean;
hasExtraDeliveries: boolean;
stopListString: any;
stopListString?: string;
}
export interface TimetableData {
@@ -260,4 +288,48 @@ export interface TimetableData {
stopListString: string;
headUnits: string[];
savedTimestamp?: number;
journalCreatedAt?: number;
}
export interface VehicleData {
id: number;
name: string;
type: string;
group: VehicleGroup;
cabinName?: string;
restrictions?: VehicleRestrictions;
}
export interface VehicleRestrictions {
sponsorOnly?: number;
teamOnly?: boolean;
}
export interface VehicleGroup {
id: number;
name: string;
speed: number;
speedLoaded?: number;
speedLoco?: number;
length: number;
weight: number;
cargoTypes?: VehicleCargoType[];
locoProps?: VehicleLocoProps;
massSpeeds?: VehicleMassSpeeds;
}
export interface VehicleCargoType {
id: string;
weight: number;
}
export interface VehicleLocoProps {
coldStart: boolean;
doubleManned: boolean;
}
export interface VehicleMassSpeeds {
none: number;
cargo?: Record<string, number>;
passenger?: Record<string, number>;
}
+8
View File
@@ -0,0 +1,8 @@
const romanMonthDigits = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII'];
export function parseTimetableRunDate(date: Date) {
return `${date.getDate()}.${romanMonthDigits[date.getMonth()]}.${date
.getFullYear()
.toString()
.slice(2)}`;
}
+23 -7
View File
@@ -1,3 +1,11 @@
const unitNameCorrections: Record<string, string[]> = {
'2EN57': ['EN57', 'EN57'],
'201E': ['ET22'],
'4E': ['EU07'],
M62: ['ST44'],
CTLR4C: ['ST44']
};
export const getRegionNameById = (id: string) => {
switch (id) {
case 'eu':
@@ -20,10 +28,18 @@ export const getRegionNameById = (id: string) => {
}
};
export const unitNameCorrections: Record<string, string> = {
'2EN57': 'EN57',
'201E': 'ET22',
'4E': 'EU07',
M62: 'ST44',
CTLR4C: 'ST44',
};
export function getHeadUnits(stockString: string) {
const stockList = stockString.split(';').slice(0, 3);
return stockList.reduce((acc, unitType, i) => {
if (i != 0 && !/-\d{3,}$/.test(unitType)) return acc;
const unitName = unitType.slice(0, unitType.indexOf('-'));
const correctedNames = unitNameCorrections[unitName] ?? [unitName];
acc.push(...correctedNames);
return acc;
}, [] as string[]);
}
+1 -1
View File
@@ -6,6 +6,6 @@ export default {
},
darkMode: 'selector',
plugins: [],
purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
}
+2 -3
View File
@@ -1,14 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"types": ["vite/client", "vite-plugin-pwa/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/http.ts","./src/i18n.ts","./src/main.ts","./src/vite-env.d.ts","./src/mixins/useVehicleMixin.ts","./src/stores/api.store.ts","./src/stores/global.store.ts","./src/types/api.types.ts","./src/types/common.types.ts","./src/utils/dateUtils.ts","./src/utils/trainUtils.ts","./src/App.vue","./src/components/App/MainBottom.vue","./src/components/App/MainContainer.vue","./src/components/App/MigrateInfo.vue","./src/components/App/Navbar.vue","./src/components/App/SettingsCard.vue","./src/components/App/UpdatePrompt.vue","./src/components/Timetable/TimetableContainer.vue","./src/components/Timetable/TimetableContent.vue","./src/components/Timetable/TimetableContentCZ.vue","./src/components/Timetable/TimetableWarnings.vue","./src/components/TimetableSearch/ActiveSearchInput.vue","./src/components/TimetableSearch/JournalSearchInput.vue","./src/components/TimetableSearch/LocalSearchInput.vue","./src/components/TimetableSearch/SearchContainer.vue","./src/components/TimetableSearch/SearchModeActions.vue","./src/components/TimetableViews/ActiveDataView.vue","./src/components/TimetableViews/CurrentTimetableView.vue","./src/components/TimetableViews/JournalStorageView.vue","./src/components/TimetableViews/LocalStorageView.vue"],"version":"5.9.3"}
+1 -3
View File
@@ -1,6 +1,5 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
@@ -17,8 +16,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}
+29 -4
View File
@@ -1,10 +1,35 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { VitePWA } from 'vite-plugin-pwa';
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
VitePWA({
registerType: 'prompt',
workbox: {
disableDevLogs: true,
globPatterns: ['**/*.{js,css,html,png,svg,jpg,ico}'],
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/stacjownik.spythere.eu\/api\/(getSceneries|getVehiclesData)/i,
handler: 'StaleWhileRevalidate',
options: {
expiration: {
maxAgeSeconds: 3600
},
cacheName: 'stacjownik-api-cache',
cacheableResponse: { statuses: [0, 200] }
}
}
]
},
devOptions: { enabled: false, suppressWarnings: true }
})
],
server: {
port: 5345
}
})
});
+3140 -510
View File
File diff suppressed because it is too large Load Diff