Compare commits

...

72 Commits

Author SHA1 Message Date
Spythere 83444f64d0 Merge pull request #155 from Spythere/development
v1.32.0
2026-02-26 21:15:04 +01:00
Spythere a5f9f8901b chore(profile): redirecting to main site when player is not found 2026-02-26 14:26:31 +01:00
Spythere 0276e0754b fix(profile): improper image loading when switching between users 2026-02-25 21:38:43 +01:00
Spythere 0d495ede2d fix(profile): filtering online trains and dispatches 2026-02-25 21:16:44 +01:00
Spythere 48c0a32017 fix(profile): player avatar loading 2026-02-25 21:16:19 +01:00
Spythere 26f2ced266 chore(app): improved API refresh times at about 31-32s 2026-02-24 22:21:02 +01:00
Spythere 4f17b1a704 refactor(profile): moved fetching data to view root to ensure proper loading on activating 2026-02-24 22:13:52 +01:00
Spythere 50068a239c bump(version): v1.32.0 2026-02-23 22:10:02 +01:00
Spythere 662748f705 Merge pull request #154 from Spythere/feature/user-profile
Feature: Player Profile
2026-02-23 22:06:54 +01:00
Spythere 65c1ab809f chore(views): adjusted viewport heights and scrolls 2026-02-23 22:03:49 +01:00
Spythere e7c8ba62d7 fix(app): region dropdown z-index 2026-02-23 21:36:51 +01:00
Spythere 38a9f1987f chore(profile): added activity region 2026-02-23 21:35:41 +01:00
Spythere f90dfd3cc8 chore(css): enabled dark mode and restored default scrollbar design 2026-02-23 21:25:46 +01:00
Spythere 9b765c7fdd chore(profile): added periodic player info & history updates 2026-02-20 02:13:52 +01:00
Spythere 0f7e3e8820 chore(profile): minor design fixes 2026-02-20 02:01:13 +01:00
Spythere 1735444176 refactor(profile): data loading indicators 2026-02-20 01:58:29 +01:00
Spythere 1d95b26e9c chore(app): moved discord link from footer to stations view 2026-02-20 01:38:13 +01:00
Spythere 86fbaa2510 chore(profile): moved player avatar and its logic to separate component 2026-02-20 01:27:35 +01:00
Spythere b7db3edd9b chore(app): removed migration card rendering 2026-02-18 23:56:32 +01:00
Spythere 72fa9523e8 refactor(stats): moved fetching daily stats data to store 2026-02-18 02:51:33 +01:00
Spythere 7b07a43715 fix(profile): wrapping journal links 2026-02-18 02:32:28 +01:00
Spythere 448c6e387e chore(profile): added information about no recent history 2026-02-18 02:31:47 +01:00
Spythere 527c929b53 chore(profile): added TD2 forum profile link button 2026-02-18 02:23:08 +01:00
Spythere b622df19f6 chore(profile): added stacjownik donator indicator 2026-02-18 02:20:47 +01:00
Spythere 03e69b315c chore(profile): design & layout adjustments 2026-02-18 02:14:30 +01:00
Spythere f2c11bf2cf chore(profile): date formatting from utils 2026-02-18 01:55:59 +01:00
Spythere 92c73b9ed9 chore(profile): added missing translations 2026-02-18 01:55:34 +01:00
Spythere acc15619a9 chore(profile): improved responsiveness & design 2026-02-17 23:00:53 +01:00
Spythere 3705325a9a chore(profile): added links to player journals; merged player activity into main info 2026-02-17 22:37:16 +01:00
Spythere 1655aa2c94 chore(profile): moved fetching history journal to separate component 2026-02-17 22:19:54 +01:00
Spythere f38ad8fa81 chore(journal): added dispatcher filtering by duty id 2026-02-17 21:56:40 +01:00
Spythere 1a7801259f chore(profile): added router links for history list 2026-02-17 17:06:14 +01:00
Spythere abd1c8b684 fix(journal): timetable entries minor fixes 2026-02-17 16:47:06 +01:00
Spythere 7f315b549e chore(journal): added synching detailed timetable data with basic 2026-02-17 02:17:32 +01:00
Spythere 329c85b858 refactor(journal): fetching heavy timetable details separately on demand 2026-02-16 02:16:22 +01:00
Spythere dcef8cdac8 chore(profile): improved history list design 2026-02-15 17:50:52 +01:00
Spythere 298f8a5f23 chore(driver): changed button to navigate to player's profile 2026-02-15 17:22:12 +01:00
Spythere 51d952ffee chore(profile): improved profile player stats header 2026-02-15 17:18:17 +01:00
Spythere 83b22e5978 chore(profile): player activity section design 2026-02-15 17:13:42 +01:00
Spythere 87ad7b8ede chore(stations): disabled opening donation modal on clicking nickname 2026-02-15 16:28:58 +01:00
Spythere 440e11bdd9 fix(profile): i18n keys 2026-02-15 16:27:16 +01:00
Spythere 84ecd3c175 chore(profile): added i18n translation bindings & pl locale keys 2026-02-14 02:27:03 +01:00
Spythere 72b3aef045 refactor(profile): moved view sections and their logic to separate components 2026-02-14 00:53:45 +01:00
Spythere 36ae24fdaf refactor(profile): moved view sections and their logic to separate components 2026-02-14 00:53:19 +01:00
Spythere 41e3d018e6 chore: profile 2026-02-13 00:52:02 +01:00
Spythere d9faa486d2 chore(app): improved scrolling into view for main tabs 2026-02-12 03:32:01 +01:00
Spythere 89dc265e1b hotfix(app): caching CSS files; reusing global styles in App.vue 2026-02-11 23:24:23 +01:00
Spythere 200e994ae6 chore(profile): change appearance of activity history entries 2026-02-10 00:28:57 +01:00
Spythere 150b7749ae chore(profile): added level badges for player summary 2026-02-09 00:52:11 +01:00
Spythere 0f8932b53c refactor(journal): removed seperate driver & dispatcher stats dropdowns; added button leading to player profile view 2026-02-08 22:00:15 +01:00
Spythere 1365140802 chore(profile): added loading status 2026-02-08 01:34:59 +01:00
Spythere ce8bbe4c67 chore(profile): updated api objects; replaced mock data with api results 2026-02-07 20:54:03 +01:00
Spythere 1d49de1c6b chore(profile): organized fetching data; added link to profile in scenery view 2026-02-07 01:18:28 +01:00
Spythere b8574f9ea1 chore(profile): translation setup 2026-02-06 17:15:29 +01:00
Spythere ecced14cca chore(profile): generating menu buttons from object 2026-02-06 17:12:58 +01:00
Spythere 212a87126d chore(profile): changed routing from params to query 2026-02-06 17:05:21 +01:00
Spythere 41e50b8207 chore(profile): view container responsiveness 2026-02-06 17:00:12 +01:00
Spythere 565b0dfd8c chore(profile): journal history list design 2026-02-06 03:20:25 +01:00
Spythere 40a0b47984 chore(profile): added combined journal with timetables and dispatchers; added journal filters 2026-02-06 01:49:18 +01:00
Spythere ccca1c8752 chore(profile): added grid layout & cleaned up styles 2026-02-06 01:16:32 +01:00
Spythere cf51045343 chore(player profile): added typings for player info response object 2026-02-04 01:02:57 +01:00
Spythere 23a8b9e8d4 feature: player profile view 2026-02-02 03:12:40 +01:00
Spythere c2f7eef146 Merge pull request #153 from Spythere/development
v1.31.1
2026-01-16 22:27:16 +01:00
Spythere b34f8229cc bump(version): v1.31.1 2026-01-15 17:32:53 +01:00
Spythere f1eee97d46 chore(stations): added sorting by dispatcher language id 2026-01-15 17:30:35 +01:00
Spythere d93be0b9be fix(stations): fixed displaying dispatcher flag for inactive sceneries 2026-01-15 17:27:11 +01:00
Spythere 5190eed7ee fix(journal): restored bold font for journal dispatcher entry 2026-01-14 21:22:53 +01:00
Spythere a6f284270e chore(flags): adjusted flags styles 2026-01-14 21:21:09 +01:00
Spythere 08422caa96 chore(journal): added language flags to journal entries 2026-01-14 20:57:22 +01:00
Spythere 3a70d8f6a6 chore(index): changed some images from preloads to prefetches 2026-01-14 20:38:18 +01:00
Spythere e3e5eb3460 refactor: added language flag component 2026-01-14 20:29:02 +01:00
Spythere 1819569234 feat: user communication flags 2026-01-14 00:14:35 +01:00
65 changed files with 2150 additions and 1009 deletions
+6 -6
View File
@@ -62,24 +62,24 @@
crossorigin crossorigin
/> />
<link rel="preload" as="image" href="/images/icon-pl.svg" />
<link rel="preload" as="image" href="/images/stacjownik-header-logo.svg" /> <link rel="preload" as="image" href="/images/stacjownik-header-logo.svg" />
<link rel="preload" as="image" href="/images/icon-dispatcher.svg" /> <link rel="preload" as="image" href="/images/icon-dispatcher.svg" />
<link rel="preload" as="image" href="/images/icon-train.svg" /> <link rel="preload" as="image" href="/images/icon-train.svg" />
<link rel="preload" as="image" href="/images/icon-arrow-asc.svg" />
<link rel="preload" as="image" href="/images/icon-arrow-desc.svg" /> <link rel="preload" as="image" href="/images/icon-arrow-desc.svg" />
<link rel="preload" as="image" href="/images/icon-filter2.svg" />
<link rel="preload" as="image" href="/images/icon-stats.svg" />
<link rel="preload" as="image" href="/images/icon-gnr.svg" />
<link rel="preload" as="image" href="/images/icon-pojazdownik.svg" /> <link rel="preload" as="image" href="/images/icon-pojazdownik.svg" />
<link rel="preload" as="image" href="/images/icon-diamond.svg" /> <link rel="preload" as="image" href="/images/icon-stats.svg" />
<link rel="preload" as="image" href="/images/icon-filter2.svg" />
<link rel="preload" as="image" href="/images/icon-user.svg" /> <link rel="preload" as="image" href="/images/icon-user.svg" />
<link rel="preload" as="image" href="/images/icon-like.svg" /> <link rel="preload" as="image" href="/images/icon-like.svg" />
<link rel="preload" as="image" href="/images/icon-gnr.svg" />
<link rel="preload" as="image" href="/images/icon-spawn.svg" /> <link rel="preload" as="image" href="/images/icon-spawn.svg" />
<link rel="preload" as="image" href="/images/icon-timetableAll.svg" /> <link rel="preload" as="image" href="/images/icon-timetableAll.svg" />
<link rel="preload" as="image" href="/images/icon-timetableUnconfirmed.svg" /> <link rel="preload" as="image" href="/images/icon-timetableUnconfirmed.svg" />
<link rel="preload" as="image" href="/images/icon-timetableConfirmed.svg" /> <link rel="preload" as="image" href="/images/icon-timetableConfirmed.svg" />
<link rel="preload" as="image" href="/images/icon-discord.png" /> <link rel="preload" as="image" href="/images/icon-discord.png" />
<link rel="prefetch" as="image" href="/images/icon-arrow-asc.svg" />
<link rel="prefetch" as="image" href="/images/icon-diamond.svg" />
<!-- Static OpenGraph meta --> <!-- Static OpenGraph meta -->
<meta name="description" content="Pomocnik maszynisty i dyżurnego symulatora Train Driver 2" /> <meta name="description" content="Pomocnik maszynisty i dyżurnego symulatora Train Driver 2" />
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "stacjownik", "name": "stacjownik",
"version": "1.31.0", "version": "1.32.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-cz" viewBox="0 0 640 480">
<path fill="#fff" d="M0 0h640v240H0z"/>
<path fill="#d7141a" d="M0 240h640v240H0z"/>
<path fill="#11457e" d="M360 240 0 0v480z"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-de" viewBox="0 0 640 480">
<path fill="#fc0" d="M0 320h640v160H0z"/>
<path fill="#000001" d="M0 0h640v160H0z"/>
<path fill="red" d="M0 160h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 221 B

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 504 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-it" viewBox="0 0 640 480">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="#fff" d="M0 0h640v480H0z"/>
<path fill="#009246" d="M0 0h213.3v480H0z"/>
<path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 289 B

Before

Width:  |  Height:  |  Size: 219 B

After

Width:  |  Height:  |  Size: 219 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ru" viewBox="0 0 640 480">
<path fill="#fff" d="M0 0h640v160H0z"/>
<path fill="#0039a6" d="M0 160h640v160H0z"/>
<path fill="#d52b1e" d="M0 320h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-se" viewBox="0 0 640 480">
<path fill="#005293" d="M0 0h640v480H0z"/>
<path fill="#fecb00" d="M176 0v192H0v96h176v192h96V288h368v-96H272V0z"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-sk" viewBox="0 0 640 480">
<path fill="#ee1c25" d="M0 0h640v480H0z"/>
<path fill="#0b4ea2" d="M0 0h640v320H0z"/>
<path fill="#fff" d="M0 0h640v160H0z"/>
<path fill="#fff" d="M233 370.8c-43-20.7-104.6-61.9-104.6-143.2 0-81.4 4-118.4 4-118.4h201.3s3.9 37 3.9 118.4S276 350 233 370.8"/>
<path fill="#ee1c25" d="M233 360c-39.5-19-96-56.8-96-131.4s3.6-108.6 3.6-108.6h184.8s3.5 34 3.5 108.6C329 303.3 272.5 341 233 360"/>
<path fill="#fff" d="M241.4 209c10.7.2 31.6.6 50.1-5.6 0 0-.4 6.7-.4 14.4s.5 14.4.5 14.4c-17-5.7-38.1-5.8-50.2-5.7v41.2h-16.8v-41.2c-12-.1-33.1 0-50.1 5.7 0 0 .5-6.7.5-14.4s-.5-14.4-.5-14.4c18.5 6.2 39.4 5.8 50 5.6v-25.9c-9.7 0-23.7.4-39.6 5.7 0 0 .5-6.6.5-14.4 0-7.7-.5-14.4-.5-14.4 15.9 5.3 29.9 5.8 39.6 5.7-.5-16.4-5.3-37-5.3-37s9.9.7 13.8.7 13.8-.7 13.8-.7-4.8 20.6-5.3 37c9.7.1 23.7-.4 39.6-5.7 0 0-.5 6.7-.5 14.4s.5 14.4.5 14.4a119 119 0 0 0-39.7-5.7v26z"/>
<path fill="#0b4ea2" d="M233 263.3c-19.9 0-30.5 27.5-30.5 27.5s-6-13-22.2-13c-11 0-19 9.7-24.2 18.8 20 31.7 51.9 51.3 76.9 63.4 25-12 57-31.7 76.9-63.4-5.2-9-13.2-18.8-24.2-18.8-16.2 0-22.2 13-22.2 13S253 263.3 233 263.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ua" viewBox="0 0 640 480">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="gold" d="M0 0h640v480H0z"/>
<path fill="#0057b8" d="M0 0h640v240H0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

+1 -7
View File
@@ -7,11 +7,6 @@
<AppWelcomeCard :is-card-open="isWelcomeCardOpen" @toggle-card="closeWelcomeCard" /> <AppWelcomeCard :is-card-open="isWelcomeCardOpen" @toggle-card="closeWelcomeCard" />
<MigrateInfoCard
:is-open="store.isMigrateInfoCardOpen"
@toggle-card="closeMigrateInfoCard"
></MigrateInfoCard>
<Tooltip /> <Tooltip />
<AppHeader /> <AppHeader />
@@ -52,7 +47,6 @@ import UpdateCard from './components/App/UpdateCard.vue';
import StorageManager from './managers/storageManager'; import StorageManager from './managers/storageManager';
import AppFooter from './components/App/AppFooter.vue'; import AppFooter from './components/App/AppFooter.vue';
import AppWelcomeCard from './components/App/AppWelcomeCard.vue'; import AppWelcomeCard from './components/App/AppWelcomeCard.vue';
import MigrateInfoCard from './components/App/MigrateInfoCard.vue';
const STORAGE_VERSION_KEY = 'app_version'; const STORAGE_VERSION_KEY = 'app_version';
const WELCOME_CARD_SEEN_KEY = 'welcome_card_seen'; const WELCOME_CARD_SEEN_KEY = 'welcome_card_seen';
@@ -66,7 +60,6 @@ export default defineComponent({
AppFooter, AppFooter,
UpdateCard, UpdateCard,
AppWelcomeCard, AppWelcomeCard,
MigrateInfoCard,
Tooltip Tooltip
}, },
@@ -212,6 +205,7 @@ export default defineComponent({
<style lang="scss"> <style lang="scss">
@use './styles/animations'; @use './styles/animations';
@use './styles/global';
// APP // APP
#app { #app {
-7
View File
@@ -7,13 +7,6 @@
v{{ version }}{{ isOnProductionHost ? '' : 'dev' }} v{{ version }}{{ isOnProductionHost ? '' : 'dev' }}
</button> </button>
<br />
<a href="https://discord.gg/x2mpNN3svk">
<img src="/images/icon-discord.png" alt="discord logo icon" />&nbsp;<b class="text--discord">
{{ $t('footer.discord') }}
</b>
</a>
<div style="display: none">&int; ukryta taktyczna całka do programowania w HTMLu</div> <div style="display: none">&int; ukryta taktyczna całka do programowania w HTMLu</div>
</footer> </footer>
</template> </template>
+4 -3
View File
@@ -5,11 +5,11 @@
<div class="language-select"> <div class="language-select">
<button :data-active="$i18n.locale == 'pl'" @click="store.changeLocale('pl')"> <button :data-active="$i18n.locale == 'pl'" @click="store.changeLocale('pl')">
<img src="/images/icon-pl.svg" alt="" width="45" /> <FlagIcon :language-id="0" width="2.5em" />
</button> </button>
<button :data-active="$i18n.locale == 'en'" @click="store.changeLocale('en')"> <button :data-active="$i18n.locale == 'en'" @click="store.changeLocale('en')">
<img src="/images/icon-en.svg" alt="" width="45" /> <FlagIcon :language-id="1" width="2.5em" />
</button> </button>
</div> </div>
@@ -116,6 +116,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Card from '../Global/Card.vue'; import Card from '../Global/Card.vue';
import { useMainStore } from '../../store/mainStore'; import { useMainStore } from '../../store/mainStore';
import FlagIcon from '../Global/FlagIcon.vue';
const store = useMainStore(); const store = useMainStore();
@@ -157,7 +158,7 @@ a.link {
justify-content: center; justify-content: center;
margin: 0.5em 0; margin: 0.5em 0;
button[data-active='false'] img { button[data-active='false'] ::v-deep(img) {
opacity: 0.5; opacity: 0.5;
} }
} }
+50 -48
View File
@@ -1,37 +1,41 @@
<template> <template>
<div class="driver-top-actions"> <div class="driver-top-actions">
<div class="actions-container"> <div class="actions-container">
<div class="actions actions-left"> <div class="actions actions-left">
<button class="a-button btn--filled btn--image" @click="routerReturn"> <button class="a-button btn--filled btn--image" @click="routerReturn">
<img src="/images/icon-back.svg" alt="train icon" /> <img src="/images/icon-back.svg" alt="train icon" />
<span> <span>
{{ t('trains.driver-return-link') }} {{ t('trains.driver-return-link') }}
</span> </span>
</button> </button>
</div> </div>
<div class="actions actions-right"> <div class="actions actions-right">
<a class="a-button btn--filled btn--image" :href="`https://srjp-td2.web.app/?id=${chosenTrain.id}`" <a
target="_blank"> class="a-button btn--filled btn--image"
<span class="hidable"> :href="`https://srjp-td2.web.app/?id=${chosenTrain.id}`"
{{ t('trains.driver-srjp-link') }} target="_blank"
</span> >
<span class="hidable">
{{ t('trains.driver-srjp-link') }}
</span>
<img src="/images/icon-srjp.svg" alt="srjp icon" /> <img src="/images/icon-srjp.svg" alt="srjp icon" />
</a> </a>
<router-link :to="`/journal/timetables?search-driver=${chosenTrain.driverName}`" <router-link
class="a-button btn--filled btn--image"> :to="`/profile?playerId=${chosenTrain.driverId}`"
<span class="hidable"> class="a-button btn--filled btn--image"
{{ t('trains.driver-journal-link') }} >
</span> <span class="hidable">
{{ t('trains.driver-profile-link') }}
<img src="/images/icon-train.svg" alt="train icon" /> </span>
</router-link>
</div>
</div>
<img src="/images/icon-user.svg" alt="user icon" />
</router-link>
</div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -44,42 +48,40 @@ const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
defineProps({ defineProps({
chosenTrain: { chosenTrain: {
type: Object as PropType<Train>, type: Object as PropType<Train>,
required: true required: true
} }
}); });
function routerReturn() { function routerReturn() {
router.back(); router.back();
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use '../../styles/responsive'; @use '../../styles/responsive';
.actions-container { .actions-container {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: space-between; justify-content: space-between;
gap: 0.5em; gap: 0.5em;
} }
.actions { .actions {
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;
} }
.actions-container>.actions>.a-button { .actions-container > .actions > .a-button {
padding: 0.5em; padding: 0.5em;
border-radius: 0.5em 0.5em 0 0; border-radius: 0.5em 0.5em 0 0;
} }
@include responsive.smallScreen { @include responsive.smallScreen {
span.hidable { span.hidable {
display: none; display: none;
} }
} }
</style> </style>
+43
View File
@@ -0,0 +1,43 @@
<template>
<div class="flag-icon">
<img
:src="languageFlagSrc"
alt="language flag"
:style="{
width: width
}"
/>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { getLanguageNameById } from '../../utils/languageUtils';
const props = defineProps({
languageId: {
type: Number,
required: true
},
width: {
type: String
}
});
const languageFlagSrc = computed(
() => `/images/flags/${getLanguageNameById(props.languageId)}.svg`
);
</script>
<style scoped>
.flag-icon {
display: flex;
justify-content: center;
align-items: center;
}
.flag-icon img {
vertical-align: middle;
}
</style>
+1 -1
View File
@@ -160,7 +160,7 @@ ul.options {
height: auto; height: auto;
z-index: 100; z-index: 150;
width: 100%; width: 100%;
font-size: 0.9em; font-size: 0.9em;
@@ -1,6 +1,6 @@
<template> <template>
<section class="daily-stats"> <section class="daily-stats">
<span :data-active="statsStatus"> <span :data-active="apiStore.dataStatuses.dailyStatsData">
<h3> <h3>
{{ $t('journal.daily-stats.title') }} {{ $t('journal.daily-stats.title') }}
<b class="text--primary">{{ new Date().toLocaleDateString($i18n.locale) }}</b> <b class="text--primary">{{ new Date().toLocaleDateString($i18n.locale) }}</b>
@@ -8,11 +8,11 @@
<hr class="header-separator" /> <hr class="header-separator" />
<b v-if="statsStatus == Status.Data.Loading"> <b v-if="apiStore.dataStatuses.dailyStatsData == Status.Data.Loading">
{{ $t('app.loading') }} {{ $t('app.loading') }}
</b> </b>
<b class="text--error" v-else-if="statsStatus == Status.Data.Error"> <b class="text--error" v-else-if="apiStore.dataStatuses.dailyStatsData == Status.Data.Error">
{{ $t('journal.stats-error') }} {{ $t('journal.stats-error') }}
</b> </b>
@@ -20,42 +20,48 @@
{{ $t('journal.daily-stats.info') }} {{ $t('journal.daily-stats.info') }}
</b> </b>
<div v-else> <div v-else-if="apiStore.dailyStatsData">
<ul class="stats-list"> <ul class="stats-list">
<li v-if="stats.totalTimetables"> <li v-if="apiStore.dailyStatsData.totalTimetables">
<i18n-t keypath="journal.daily-stats.total"> <i18n-t keypath="journal.daily-stats.total">
<template #count> <template #count>
<b class="text--primary"> <b class="text--primary">
{{ stats.totalTimetables }} {{ apiStore.dailyStatsData.totalTimetables }}
{{ $t('journal.daily-stats.count', stats.totalTimetables) }} {{ $t('journal.daily-stats.count', apiStore.dailyStatsData.totalTimetables) }}
</b> </b>
</template> </template>
<template #distance> <template #distance>
<b class="text--primary"> {{ stats.distanceSum?.toFixed(2) }} km</b> <b class="text--primary">
{{ apiStore.dailyStatsData.distanceSum?.toFixed(2) }} km</b
>
</template> </template>
</i18n-t> </i18n-t>
</li> </li>
<li v-if="stats.maxTimetable"> <li v-if="apiStore.dailyStatsData.maxTimetable">
<i18n-t keypath="journal.daily-stats.longest"> <i18n-t keypath="journal.daily-stats.longest">
<template #id> <template #id>
<router-link :to="`/journal/timetables?search-train=%23${stats.maxTimetable.id}`"> <router-link
<b>{{ stats.maxTimetable.id }}</b> :to="`/journal/timetables?search-train=%23${apiStore.dailyStatsData.maxTimetable.id}`"
>
<b>{{ apiStore.dailyStatsData.maxTimetable.id }}</b>
</router-link> </router-link>
</template> </template>
<template #author> <template #author>
<router-link <router-link
:to="`/journal/timetables?search-dispatcher=${stats.maxTimetable.authorName}`" :to="`/journal/timetables?search-dispatcher=${apiStore.dailyStatsData.maxTimetable.authorName}`"
> >
<b>{{ stats.maxTimetable.authorName }}</b> <b>{{ apiStore.dailyStatsData.maxTimetable.authorName }}</b>
</router-link> </router-link>
</template> </template>
<template #driver> <template #driver>
<b class="text--primary">{{ stats.maxTimetable.driverName }}</b> <b class="text--primary">{{ apiStore.dailyStatsData.maxTimetable.driverName }}</b>
</template> </template>
<template #distance> <template #distance>
<b class="text--primary">{{ stats.maxTimetable.routeDistance }} km</b> <b class="text--primary"
>{{ apiStore.dailyStatsData.maxTimetable.routeDistance }} km</b
>
</template> </template>
</i18n-t> </i18n-t>
</li> </li>
@@ -101,35 +107,37 @@
</i18n-t> </i18n-t>
</li> </li>
<li v-if="stats.longestDuties.length > 0"> <li v-if="apiStore.dailyStatsData.longestDuties.length > 0">
<i18n-t keypath="journal.daily-stats.longest-duties"> <i18n-t keypath="journal.daily-stats.longest-duties">
<template #dispatcher> <template #dispatcher>
<router-link <router-link
:to="`/journal/dispatchers?search-dispatcher=${stats.longestDuties[0].name}`" :to="`/journal/dispatchers?search-dispatcher=${apiStore.dailyStatsData.longestDuties[0].name}`"
> >
<b>{{ stats.longestDuties[0].name }}</b> <b>{{ apiStore.dailyStatsData.longestDuties[0].name }}</b>
</router-link> </router-link>
</template> </template>
<template #station>{{ stats.longestDuties[0].station }}</template> <template #station>{{ apiStore.dailyStatsData.longestDuties[0].station }}</template>
<template #duration> <template #duration>
{{ calculateDuration(stats.longestDuties[0].duration) }} {{ humanizeDuration(apiStore.dailyStatsData.longestDuties[0].duration) }}
</template> </template>
</i18n-t> </i18n-t>
</li> </li>
<li v-if="stats.mostActiveDrivers.length > 0"> <li v-if="apiStore.dailyStatsData.mostActiveDrivers.length > 0">
<i18n-t keypath="journal.daily-stats.most-active-driver"> <i18n-t keypath="journal.daily-stats.most-active-driver">
<template #driver> <template #driver>
<router-link <router-link
:to="`/journal/timetables?search-driver=${stats.mostActiveDrivers[0].name}`" :to="`/journal/timetables?search-driver=${apiStore.dailyStatsData.mostActiveDrivers[0].name}`"
> >
<b>{{ stats.mostActiveDrivers[0].name }}</b> <b>{{ apiStore.dailyStatsData.mostActiveDrivers[0].name }}</b>
</router-link> </router-link>
</template> </template>
<template #distance> <template #distance>
<b class="text--primary">{{ stats.mostActiveDrivers[0].distance.toFixed(2) }} km</b> <b class="text--primary"
>{{ apiStore.dailyStatsData.mostActiveDrivers[0].distance.toFixed(2) }} km</b
>
</template> </template>
</i18n-t> </i18n-t>
</li> </li>
@@ -151,7 +159,11 @@
> >
<span>{{ $t(`journal.daily-stats.${key}`) }}</span> <span>{{ $t(`journal.daily-stats.${key}`) }}</span>
<span> <span>
{{ Object.entries(stats.globalDiff).find(([k, v]) => k == key)?.[1] || '--' }} {{
Object.entries(apiStore.dailyStatsData.globalDiff).find(
([k, v]) => k == key
)?.[1] || '--'
}}
</span> </span>
</span> </span>
</div> </div>
@@ -160,76 +172,25 @@
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { computed, onMounted } from 'vue';
import dateMixin from '../../mixins/dateMixin';
import { API } from '../../typings/api';
import { Status } from '../../typings/common';
import { useApiStore } from '../../store/apiStore'; import { useApiStore } from '../../store/apiStore';
import { Status } from '../../typings/common';
import { humanizeDuration } from '../../composables/time';
export default defineComponent({ onMounted(() => {
name: 'journal-daily-stats', apiStore.fetchDailyStats();
});
mixins: [dateMixin], const apiStore = useApiStore();
data() { const topDispatchers = computed(() => {
return { if (!apiStore.dailyStatsData || apiStore.dailyStatsData.mostActiveDispatchers.length == 0)
Status, return [];
statsStatus: Status.Data.Loading,
intervalId: -1,
stats: {} as API.DailyStats.Response, const maxCount = apiStore.dailyStatsData.mostActiveDispatchers[0].count;
apiStore: useApiStore()
};
},
activated() { return apiStore.dailyStatsData.mostActiveDispatchers.filter((disp) => disp.count === maxCount);
this.startFetchingDailyStats();
},
deactivated() {
this.stopFetchingDailyStats();
},
computed: {
topDispatchers() {
if (this.stats.mostActiveDispatchers.length == 0) return [];
const maxCount = this.stats.mostActiveDispatchers[0].count;
return this.stats.mostActiveDispatchers.filter((disp) => disp.count === maxCount);
}
},
methods: {
async fetchDailyTimetableStats() {
try {
const res: API.DailyStats.Response = await (
await this.apiStore.client!.get('api/getDailyStats')
).data;
this.stats = res;
this.statsStatus = Status.Data.Loaded;
} catch (error) {
console.error('Ups! Wystąpił błąd podczas pobierania statystyk rozkładów jazdy...');
this.statsStatus = Status.Data.Error;
}
},
startFetchingDailyStats() {
this.fetchDailyTimetableStats();
if (this.intervalId != -1) return;
this.intervalId = window.setInterval(this.fetchDailyTimetableStats, 60000);
},
stopFetchingDailyStats() {
clearInterval(this.intervalId);
this.intervalId = -1;
}
}
}); });
</script> </script>
@@ -265,7 +226,7 @@ ul.stats-list {
gap: 0.5em; gap: 0.5em;
} }
@include responsive.smallScreen{ @include responsive.smallScreen {
h3 { h3 {
text-align: center; text-align: center;
} }
@@ -1,23 +1,22 @@
<template> <template>
<li class="dispatcher-history-entry"> <li class="dispatcher-history-entry">
<div class="entry-info"> <div class="entry-info">
<span> <span class="entry-info-left">
<span> <div class="station-info">
<router-link :to="`/journal/dispatchers?search-station=${entry.stationName}`"> <router-link :to="`/journal/dispatchers?search-station=${entry.stationName}`">
<b>{{ entry.stationName }}</b> <b>{{ entry.stationName }}</b>
</router-link> </router-link>
<b class="text--grayed"> #{{ entry.stationHash }}</b> <b class="text--grayed"> #{{ entry.stationHash }}</b>
</span> &bull;
&bull; <b
<b v-if="entry.dispatcherLevel !== null"
v-if="entry.dispatcherLevel !== null" class="level-badge dispatcher"
class="level-badge dispatcher" :style="calculateExpStyle(entry.dispatcherLevel, entry.dispatcherIsSupporter)"
:style="calculateExpStyle(entry.dispatcherLevel, entry.dispatcherIsSupporter)" >
> {{ entry.dispatcherLevel >= 2 ? entry.dispatcherLevel : 'L' }}
{{ entry.dispatcherLevel >= 2 ? entry.dispatcherLevel : 'L' }} </b>
</b>
<b style="margin-left: 5px">
<span <span
v-if="apiStore.donatorsData.includes(entry.dispatcherName)" v-if="apiStore.donatorsData.includes(entry.dispatcherName)"
data-tooltip-type="DonatorTooltip" data-tooltip-type="DonatorTooltip"
@@ -37,7 +36,11 @@
> >
{{ entry.dispatcherName }} {{ entry.dispatcherName }}
</router-link> </router-link>
</b>
<span class="dispatcher-language" v-if="entry.dispatcherLanguageId != null">
<FlagIcon :language-id="entry.dispatcherLanguageId" width="1.75em" />
</span>
</div>
<div> <div>
<span v-if="entry.timestampTo"> <span v-if="entry.timestampTo">
@@ -118,6 +121,7 @@ import dateMixin from '../../../mixins/dateMixin';
import styleMixin from '../../../mixins/styleMixin'; import styleMixin from '../../../mixins/styleMixin';
import { useApiStore } from '../../../store/apiStore'; import { useApiStore } from '../../../store/apiStore';
import StationStatusBadge from '../../Global/StationStatusBadge.vue'; import StationStatusBadge from '../../Global/StationStatusBadge.vue';
import FlagIcon from '../../Global/FlagIcon.vue';
export default defineComponent({ export default defineComponent({
props: { props: {
@@ -125,7 +129,7 @@ export default defineComponent({
showExtraInfo: { type: Boolean, required: true } showExtraInfo: { type: Boolean, required: true }
}, },
components: { StationStatusBadge }, components: { StationStatusBadge, FlagIcon },
mixins: [dateMixin, styleMixin], mixins: [dateMixin, styleMixin],
emits: ['toggleShowExtraInfo'], emits: ['toggleShowExtraInfo'],
@@ -164,6 +168,11 @@ export default defineComponent({
padding: 1em; padding: 1em;
} }
.dispatcher-language {
display: inline-block;
vertical-align: middle;
}
.entry-info { .entry-info {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -185,6 +194,15 @@ export default defineComponent({
margin-top: 1em; margin-top: 1em;
} }
.station-info {
display: flex;
flex-wrap: wrap;
text-align: center;
align-items: center;
gap: 0.25em;
font-weight: bold;
}
.status-list { .status-list {
display: flex; display: flex;
overflow: auto; overflow: auto;
@@ -198,11 +216,15 @@ export default defineComponent({
border-radius: 1em; border-radius: 1em;
} }
@include responsive.smallScreen{ @include responsive.smallScreen {
.entry-info { .entry-info {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
} }
.station-info {
justify-content: center;
}
} }
</style> </style>
@@ -1,85 +0,0 @@
<template>
<div class="journal-stats dispatcher" v-if="dispatcherName && stats">
<span class="loading" v-if="!stats.issuedTimetables && !stats.services">
{{ $t('journal.dispatcher-stats.empty') }}
</span>
<span v-else>
<h3>
<i18n-t keypath="journal.dispatcher-stats.title">
<template #name>
<span class="text--primary">{{ dispatcherName.toUpperCase() }}</span>
</template>
</i18n-t>
</h3>
<hr class="header-separator" />
<div class="info-stats">
<span class="badge stat-badge" v-if="stats.services">
<span>{{ $t('journal.dispatcher-stats.services-count') }}</span>
<span>{{ stats.services.count }}</span>
</span>
<span class="badge stat-badge" v-if="stats.services">
<span>{{ $t('journal.dispatcher-stats.service-max') }}</span>
<span>{{ calculateDuration(stats.services.durationMax) }}</span>
</span>
<span class="badge stat-badge" v-if="stats.services">
<span>{{ $t('journal.dispatcher-stats.service-avg') }}</span>
<span>{{ calculateDuration(stats.services.durationAvg) }}</span>
</span>
</div>
<hr class="section-separator" v-if="stats.issuedTimetables" />
<div class="info-stats" v-if="stats.issuedTimetables">
<span class="badge stat-badge">
<span>{{ $t('journal.dispatcher-stats.timetables-count') }}</span>
<span>{{ stats.issuedTimetables.count }}</span>
</span>
<span class="badge stat-badge">
<span>{{ $t('journal.dispatcher-stats.timetables-sum') }}</span>
<span>{{ stats.issuedTimetables.distanceSum.toFixed(2) }}km</span>
</span>
<span class="badge stat-badge">
<span>{{ $t('journal.dispatcher-stats.timetables-max') }}</span>
<span>{{ stats.issuedTimetables.distanceMax.toFixed(2) }}km</span>
</span>
<span class="badge stat-badge">
<span>{{ $t('journal.dispatcher-stats.timetables-avg') }}</span>
<span>{{ stats.issuedTimetables.distanceAvg.toFixed(2) }}km</span>
</span>
</div>
</span>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import dateMixin from '../../../mixins/dateMixin';
import { useMainStore } from '../../../store/mainStore';
export default defineComponent({
name: 'journal-dispatcher-stats',
mixins: [dateMixin],
setup() {
const store = useMainStore();
return {
stats: store.dispatcherStatsData,
dispatcherName: store.dispatcherStatsName
};
}
});
</script>
<style lang="scss" scoped>
@use '../../../styles/journal-stats';
</style>
@@ -1,5 +1,5 @@
<template> <template>
<div class="filters-options dropdown" @keydown.esc="showOptions = false"> <div class="dropdown filters-options" @keydown.esc="showOptions = false">
<div class="dropdown_background" v-if="showOptions" @click="showOptions = false"></div> <div class="dropdown_background" v-if="showOptions" @click="showOptions = false"></div>
<div class="actions-bar"> <div class="actions-bar">
@@ -57,7 +57,7 @@
<label v-if="propName == 'search-date-from'" for="search-date">{{ <label v-if="propName == 'search-date-from'" for="search-date">{{
$t(`options.search-${optionsType}-date`) $t(`options.search-${optionsType}-date`)
}}</label> }}</label>
<div class="search-box"> <div class="search-box">
<input <input
class="search-input" class="search-input"
@@ -330,4 +330,9 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
@use '../../styles/dropdown'; @use '../../styles/dropdown';
@use '../../styles/dropdown-filters'; @use '../../styles/dropdown-filters';
.filters-options > .dropdown_wrapper {
height: calc(100vh - 19em);
min-height: 500px;
}
</style> </style>
+35 -52
View File
@@ -2,87 +2,70 @@
<div <div
class="journal-stats dropdown" class="journal-stats dropdown"
v-if="!mainStore.isOffline" v-if="!mainStore.isOffline"
@keydown.esc="currentStatsTab = null" @keydown.esc="isDropdownOpen = false"
> >
<div <div class="dropdown_background" v-if="isDropdownOpen" @click="isDropdownOpen = false"></div>
class="dropdown_background"
v-if="currentStatsTab !== null"
@click="currentStatsTab = null"
></div>
<div class="actions-bar"> <div class="actions-bar">
<button class="btn--filled btn--image" @click="toggleDropdown">
<img :src="`/images/icon-stats.svg`" alt="stats icon" />
{{ $t('journal.daily-stats.button') }}
</button>
<button <button
v-for="button in statsButtons"
:key="button.tab"
class="btn--filled btn--image" class="btn--filled btn--image"
:data-selected="button.tab == currentStatsTab" :data-disabled="chosenPlayerId == -1"
:data-disabled="button.disabled" @click="navigateToProfile"
:disabled="button.disabled"
@click="onTabButtonClick(button.tab)"
> >
<img <img :src="`/images/icon-user.svg`" alt="user icon" />
v-if="button.iconName" {{ $t('profile.journal-button') }}
:src="`/images/icon-${button.iconName}.svg`"
:alt="button.iconName"
/>
{{ $t(button.localeKey) }}
</button> </button>
</div> </div>
<transition name="dropdown-anim"> <transition name="dropdown-anim">
<div <div class="dropdown_wrapper" v-if="isDropdownOpen">
class="dropdown_wrapper"
:class="{ 'dropdown-align-right': true }"
v-if="currentStatsTab !== null"
>
<keep-alive> <keep-alive>
<component :is="currentStatsTab" :key="currentStatsTab"></component> <JournalDailyStats />
</keep-alive> </keep-alive>
</div> </div>
</transition> </transition>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, PropType } from 'vue'; import { ref } from 'vue';
import { useMainStore } from '../../store/mainStore'; import { useMainStore } from '../../store/mainStore';
import StorageManager from '../../managers/storageManager';
import { Journal } from './typings';
import JournalDailyStats from './JournalDailyStats.vue'; import JournalDailyStats from './JournalDailyStats.vue';
import JournalDispatcherStats from '../JournalView/JournalDispatchers/JournalDispatcherStats.vue'; import { useRouter } from 'vue-router';
import JournalDriverStats from '../JournalView/JournalTimetables/JournalDriverStats.vue';
export default defineComponent({ const router = useRouter();
components: { JournalDailyStats, JournalDriverStats, JournalDispatcherStats },
props: {
statsButtons: {
type: Array as PropType<Journal.StatsButton[]>,
required: true
}
},
data() {
return {
Journal,
mainStore: useMainStore(),
currentStatsTab: null as Journal.StatsTab | null
};
},
methods: { const props = defineProps({
onTabButtonClick(tab: Journal.StatsTab) { chosenPlayerId: {
this.currentStatsTab = tab == this.currentStatsTab ? null : tab; type: Number,
required: true
StorageManager.setStringValue('journalStatsTab', this.currentStatsTab ?? '');
}
} }
}); });
const mainStore = useMainStore();
const isDropdownOpen = ref(false);
function toggleDropdown() {
isDropdownOpen.value = !isDropdownOpen.value;
}
function navigateToProfile() {
if (props.chosenPlayerId == -1) return;
router.push(`/profile?playerId=${props.chosenPlayerId}`);
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use '../../styles/dropdown'; @use '../../styles/dropdown';
@use '../../styles/dropdown-filters'; @use '../../styles/dropdown-filters';
.dropdown_wrapper.dropdown-align-right { .dropdown_wrapper {
left: auto; left: auto;
right: 0; right: 0;
max-width: 700px; max-width: 700px;
@@ -19,209 +19,238 @@
<div class="details-body" v-if="showExtraInfo"> <div class="details-body" v-if="showExtraInfo">
<div class="g-separator"></div> <div class="g-separator"></div>
<EntryStops :timetable="timetable" /> <div v-if="timetableDetails">
<EntryStops :timetable="timetableDetails" />
<div class="g-separator"></div>
<div class="timetable-specs">
<span class="badge specs-badge" v-if="timetable.authorName">
<span>{{ $t('journal.dispatcher-name') }}</span>
<span>{{ timetable.authorName }}</span>
</span>
<span class="badge specs-badge" v-if="timetable.trainMaxSpeed">
<span>{{ $t('journal.stock-timetable-speed') }}</span>
<span> {{ timetable.trainMaxSpeed }}km/h </span>
</span>
<span class="badge specs-badge" v-if="timetable.maxSpeed">
<span>{{ $t('journal.stock-max-speed') }}</span>
<span>{{ timetable.maxSpeed }}km/h</span>
</span>
</div>
<div class="stock-dangers" v-if="timetable.warningNotes">
<div class="g-separator"></div> <div class="g-separator"></div>
<b>{{ $t('journal.stock-dangers') }}:</b> <div class="timetable-specs">
<span class="badge specs-badge" v-if="timetableDetails.authorName">
<ul> <span>{{ $t('journal.dispatcher-name') }}</span>
<li v-if="timetable.twr"> <span>{{ timetableDetails.authorName }}</span>
<b class="text--primary">{{ $t('warnings.TWR') }} (TWR)</b>
</li>
<li v-if="timetable.skr">
<b class="text--primary">{{ $t('warnings.SKR') }}</b>
</li>
<li v-if="timetable.hasDangerousCargo">
<b class="text--primary">{{ $t('warnings.TN') }}</b>
</li>
<li v-if="timetable.hasExtraDeliveries">
<b class="text--primary">{{ $t('warnings.PN') }}</b>
</li>
</ul>
<div class="dangers-notes" v-if="timetable.warningNotes">
<h4>{{ $t('warnings.header-title') }}</h4>
<p>
<i>{{ timetable.warningNotes }}</i>
</p>
</div>
</div>
<!-- Historia zmian w składzie -->
<div v-if="timetable.stockString || stockHistory.length != 0">
<div class="g-separator"></div>
<b>{{ $t('journal.stock-preview') }}:</b>
<div class="stock-specs" style="margin-top: 0.5em">
<span class="badge specs-badge" v-if="timetable.stockLength">
<span>{{ $t('journal.stock-length') }}</span>
<span>
{{
currentHistoryIndex == 0
? timetable.stockLength
: stockHistory[currentHistoryIndex].stockLength || timetable.stockLength
}}m
</span>
</span> </span>
<span class="badge specs-badge" v-if="timetable.stockMass"> <span class="badge specs-badge" v-if="timetableDetails.trainMaxSpeed">
<span>{{ $t('journal.stock-mass') }}</span> <span>{{ $t('journal.stock-timetable-speed') }}</span>
<span> <span> {{ timetableDetails.trainMaxSpeed }}km/h </span>
{{ </span>
Math.floor(
(currentHistoryIndex == 0 <span class="badge specs-badge" v-if="timetableDetails.maxSpeed">
? timetable.stockMass <span>{{ $t('journal.stock-max-speed') }}</span>
: stockHistory[currentHistoryIndex].stockMass || timetable.stockMass) / 1000 <span>{{ timetableDetails.maxSpeed }}km/h</span>
)
}}t
</span>
</span> </span>
</div> </div>
<div class="stock-history"> <div class="stock-dangers" v-if="timetableDetails.warningNotes">
<button class="btn btn--action" @click="copyStockToClipboard()"> <div class="g-separator"></div>
<i class="fa-regular fa-copy"></i> {{ $t('journal.stock-copy') }}
</button>
<button <b>{{ $t('journal.stock-dangers') }}:</b>
v-for="(sh, i) in stockHistory"
:key="i" <ul>
class="btn--action" <li v-if="timetableDetails.twr">
:data-checked="i == currentHistoryIndex" <b class="text--primary">{{ $t('warnings.TWR') }} (TWR)</b>
@click.stop="currentHistoryIndex = i" </li>
>
{{ sh.updatedAt }} <li v-if="timetableDetails.skr">
</button> <b class="text--primary">{{ $t('warnings.SKR') }}</b>
</li>
<li v-if="timetableDetails.hasDangerousCargo">
<b class="text--primary">{{ $t('warnings.TN') }}</b>
</li>
<li v-if="timetableDetails.hasExtraDeliveries">
<b class="text--primary">{{ $t('warnings.PN') }}</b>
</li>
</ul>
<div class="dangers-notes" v-if="timetableDetails.warningNotes">
<h4>{{ $t('warnings.header-title') }}</h4>
<p>
<i>{{ timetableDetails.warningNotes }}</i>
</p>
</div>
</div> </div>
<div v-if="timetable.stockString" style="margin-top: 1em"> <!-- Historia zmian w składzie -->
<StockList <div v-if="timetableDetails.stockString || stockHistory.length != 0">
:trainStockList=" <div class="g-separator"></div>
(currentHistoryIndex == 0
? timetable.stockString <b>{{ $t('journal.stock-preview') }}:</b>
: stockHistory[currentHistoryIndex].stockString
).split(';') <div class="stock-specs" style="margin-top: 0.5em">
" <span class="badge specs-badge" v-if="timetableDetails.stockLength">
/> <span>{{ $t('journal.stock-length') }}</span>
<span>
{{
currentHistoryIndex == 0
? timetableDetails.stockLength
: stockHistory[currentHistoryIndex].stockLength || timetableDetails.stockLength
}}m
</span>
</span>
<span class="badge specs-badge" v-if="timetableDetails.stockMass">
<span>{{ $t('journal.stock-mass') }}</span>
<span>
{{
Math.floor(
(currentHistoryIndex == 0
? timetableDetails.stockMass
: stockHistory[currentHistoryIndex].stockMass || timetableDetails.stockMass) /
1000
)
}}t
</span>
</span>
</div>
<div class="stock-history">
<button class="btn btn--action" @click="copyStockToClipboard()">
<i class="fa-regular fa-copy"></i> {{ $t('journal.stock-copy') }}
</button>
<button
v-for="(sh, i) in stockHistory"
:key="i"
class="btn--action"
:data-checked="i == currentHistoryIndex"
@click.stop="currentHistoryIndex = i"
>
{{ sh.updatedAt }}
</button>
</div>
<div v-if="timetableDetails.stockString" style="margin-top: 1em">
<StockList
:trainStockList="
(currentHistoryIndex == 0
? timetableDetails.stockString
: stockHistory[currentHistoryIndex].stockString
).split(';')
"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { PropType, defineComponent } from 'vue'; import { computed, PropType, ref } from 'vue';
import StockList from '../../Global/StockList.vue';
import { API } from '../../../typings/api';
import { RouteLocationRaw } from 'vue-router'; import { RouteLocationRaw } from 'vue-router';
import EntryStops from './EntryStops.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
export default defineComponent({ import StockList from '../../Global/StockList.vue';
components: { StockList, EntryStops }, import EntryStops from './EntryStops.vue';
import { API } from '../../../typings/api';
import { useApiStore } from '../../../store/apiStore';
emits: ['toggleExtraInfo'], const i18n = useI18n();
const apiStore = useApiStore();
props: { const props = defineProps({
showExtraInfo: { showExtraInfo: {
type: Boolean, type: Boolean,
required: true required: true
},
timetable: {
type: Object as PropType<API.TimetableHistory.Data>,
required: true
}
}, },
data() {
return {
currentHistoryIndex: 0,
i18n: useI18n()
};
},
computed: {
stockHistory() {
return this.timetable.stockHistory
.slice()
.reverse()
.map((h) => {
const historyData = h.split('@');
return {
updatedAt: new Date(Number(historyData[0])).toLocaleTimeString(this.$i18n.locale, {
hour: '2-digit',
minute: '2-digit'
}),
stockString: historyData[1],
stockMass: Number(historyData[2]) || undefined,
stockLength: Number(historyData[3]) || undefined
};
});
},
driverRouteLocation(): RouteLocationRaw | null { timetableEntry: {
if (this.timetable.terminated) return null; type: Object as PropType<API.TimetableHistory.DataShort>,
return { required: true
name: 'DriverView',
query: {
trainId: `${this.timetable.driverId}|${this.timetable.trainNo}|eu`
}
};
}
},
methods: {
onImageError(e: Event) {
const imageEl = e.target as HTMLImageElement;
imageEl.src = '/images/icon-unknown.png';
},
toggleExtraInfo() {
this.$emit('toggleExtraInfo', this.timetable.id);
},
copyStockToClipboard() {
const currentStockString =
this.stockHistory[this.currentHistoryIndex]?.stockString ?? this.timetable.stockString;
if (!currentStockString) {
alert(this.i18n.t('journal.stock-clipboard-failure'));
return;
}
navigator.clipboard
.writeText(currentStockString)
.then(() => {
prompt(this.i18n.t('journal.stock-clipboard-success'), currentStockString);
})
.catch(() => {
alert(this.i18n.t('journal.stock-clipboard-failure'));
});
}
} }
}); });
const emits = defineEmits(['toggleExtraInfo']);
const currentHistoryIndex = ref(0);
const timetableDetails = ref<API.TimetableHistory.Data | null>(null);
const stockHistory = computed(() => {
return (
timetableDetails.value?.stockHistory
.slice()
.reverse()
.map((h) => {
const historyData = h.split('@');
return {
updatedAt: new Date(Number(historyData[0])).toLocaleTimeString(i18n.locale.value, {
hour: '2-digit',
minute: '2-digit'
}),
stockString: historyData[1],
stockMass: Number(historyData[2]) || undefined,
stockLength: Number(historyData[3]) || undefined
};
}) ?? []
);
});
const driverRouteLocation = computed<RouteLocationRaw | null>(() => {
if (props.timetableEntry.terminated) return null;
return {
name: 'DriverView',
query: {
trainId: `${props.timetableEntry.driverId}|${props.timetableEntry.trainNo}|eu`
}
};
});
async function fetchTimetableDetails() {
try {
const responseData = await apiStore.client!.get<API.TimetableHistory.Response>(
'api/getTimetables',
{
params: {
timetableId: props.timetableEntry.id,
returnType: 'detailed'
}
}
);
if (!responseData || responseData.data.length != 1) {
timetableDetails.value = null;
return;
}
timetableDetails.value = responseData.data[0];
} catch (error) {
// this.dataStatus = Status.Data.Error;
console.error(error);
}
}
async function toggleExtraInfo() {
if (props.showExtraInfo == false) {
await fetchTimetableDetails();
}
emits('toggleExtraInfo', timetableDetails.value);
}
function copyStockToClipboard() {
if (!timetableDetails.value) return;
const currentStockString =
stockHistory.value[currentHistoryIndex.value]?.stockString ??
timetableDetails.value.stockString;
if (!currentStockString) {
alert(i18n.t('journal.stock-clipboard-failure'));
return;
}
navigator.clipboard
.writeText(currentStockString)
.then(() => {
prompt(i18n.t('journal.stock-clipboard-success'), currentStockString);
})
.catch(() => {
alert(i18n.t('journal.stock-clipboard-failure'));
});
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -299,7 +328,7 @@ hr {
} }
} }
@include responsive.smallScreen{ @include responsive.smallScreen {
.timetable-specs { .timetable-specs {
justify-content: center; justify-content: center;
} }
@@ -71,6 +71,10 @@
<router-link v-else :to="`/journal/timetables?search-driver=${timetable.driverName}`"> <router-link v-else :to="`/journal/timetables?search-driver=${timetable.driverName}`">
<strong>{{ timetable.driverName }}</strong> <strong>{{ timetable.driverName }}</strong>
</router-link> </router-link>
<div v-if="timetable.driverLanguageId != null">
<FlagIcon :language-id="timetable.driverLanguageId" width="1.75em" />
</div>
</span> </span>
<span class="general-time"> <span class="general-time">
@@ -83,7 +87,7 @@
</b> </b>
<b <b
class="info-badge" class="timetable-status-badge"
:class="{ :class="{
fulfilled: timetable.fulfilled, fulfilled: timetable.fulfilled,
terminated: timetable.terminated && !timetable.fulfilled, terminated: timetable.terminated && !timetable.fulfilled,
@@ -110,8 +114,10 @@ import dateMixin from '../../../mixins/dateMixin';
import styleMixin from '../../../mixins/styleMixin'; import styleMixin from '../../../mixins/styleMixin';
import { useApiStore } from '../../../store/apiStore'; import { useApiStore } from '../../../store/apiStore';
import trainCategoryMixin from '../../../mixins/trainCategoryMixin'; import trainCategoryMixin from '../../../mixins/trainCategoryMixin';
import FlagIcon from '../../Global/FlagIcon.vue';
export default defineComponent({ export default defineComponent({
components: { FlagIcon },
mixins: [dateMixin, styleMixin, trainCategoryMixin], mixins: [dateMixin, styleMixin, trainCategoryMixin],
data() { data() {
@@ -122,7 +128,7 @@ export default defineComponent({
props: { props: {
timetable: { timetable: {
type: Object as PropType<API.TimetableHistory.Data>, type: Object as PropType<API.TimetableHistory.DataShort>,
required: true required: true
} }
} }
@@ -165,23 +171,6 @@ export default defineComponent({
gap: 0.25em; gap: 0.25em;
} }
.info-badge {
padding: 0.05em 0.35em;
color: black;
&.terminated {
background-color: salmon;
}
&.fulfilled {
background-color: lightgreen;
}
&.active {
background-color: lightblue;
}
}
.btn-timetable { .btn-timetable {
display: flex; display: flex;
padding: 0.2em 0.5em; padding: 0.2em 0.5em;
@@ -191,7 +180,7 @@ export default defineComponent({
} }
} }
@include responsive.smallScreen{ @include responsive.smallScreen {
.item-general { .item-general {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -51,7 +51,7 @@ export default defineComponent({
components: { ProgressBar }, components: { ProgressBar },
props: { props: {
timetable: { timetable: {
type: Object as PropType<API.TimetableHistory.Data>, type: Object as PropType<API.TimetableHistory.DataShort>,
required: true required: true
} }
} }
@@ -1,105 +0,0 @@
<template>
<div class="journal-stats driver" v-if="store.driverStatsData">
<span>
<h3>
<i18n-t keypath="journal.driver-stats.title">
<template #name>
<span class="text--primary">{{ store.driverStatsName.toUpperCase() }}</span>
</template>
</i18n-t>
</h3>
<hr class="header-separator" />
<div class="info-stats">
<span class="badge stat-badge">
<span>{{ $t('journal.driver-stats.longest-timetable') }}</span>
<span> {{ store.driverStatsData._max.routeDistance.toFixed(2) }}km </span>
</span>
<span class="badge stat-badge">
<span>{{ $t('journal.driver-stats.avg-timetable') }}</span>
<span> {{ store.driverStatsData._avg.routeDistance.toFixed(2) }}km </span>
</span>
</div>
<hr class="section-separator" />
<div class="info-stats">
<span class="badge stat-badge">
<span>{{ $t('journal.driver-stats.timetables') }}</span>
<span>
{{ store.driverStatsData._count.fulfilled }} /
{{ store.driverStatsData._count._all }}
<template v-if="store.driverStatsData._count._all > 0">
({{
(
(store.driverStatsData._count.fulfilled / store.driverStatsData._count._all) *
100
).toFixed(2)
}}%)
</template>
</span>
</span>
<span class="badge stat-badge">
<span>{{ $t('journal.driver-stats.distance') }}</span>
<span>
{{ store.driverStatsData._sum.currentDistance.toFixed(2) }} /
{{ store.driverStatsData._sum.routeDistance.toFixed(2) }}km
<template v-if="store.driverStatsData._sum.routeDistance > 0">
({{
(
(store.driverStatsData._sum.currentDistance /
store.driverStatsData._sum.routeDistance) *
100
).toFixed(2)
}}%)
</template>
</span>
</span>
<span class="badge stat-badge">
<span>{{ $t('journal.driver-stats.stations') }}</span>
<span>
{{ store.driverStatsData._sum.confirmedStopsCount }} /
{{ store.driverStatsData._sum.allStopsCount }}
<template v-if="store.driverStatsData._sum.allStopsCount > 0">
({{
(
(store.driverStatsData._sum.confirmedStopsCount /
store.driverStatsData._sum.allStopsCount) *
100
).toFixed(2)
}}%)
</template>
</span>
</span>
</div>
</span>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useMainStore } from '../../../store/mainStore';
import { Status } from '../../../typings/common';
export default defineComponent({
name: 'journal-driver-stats',
data() {
return {
store: useMainStore(),
Status: Status
};
}
});
</script>
<style lang="scss" scoped>
@use '../../../styles/journal-stats';
</style>
@@ -10,14 +10,14 @@
<hr /> <hr />
<div @click="toggleExtraInfo" style="cursor: pointer"> <div style="cursor: pointer">
<!-- Status --> <!-- Status -->
<EntryStatus :timetable="timetableEntry" /> <EntryStatus :timetable="timetableEntry" />
</div> </div>
<!-- Extra --> <!-- Extra -->
<EntryDetails <EntryDetails
:timetable="timetableEntry" :timetableEntry="timetableEntry"
:show-extra-info="showExtraInfo" :show-extra-info="showExtraInfo"
@toggle-extra-info="toggleExtraInfo" @toggle-extra-info="toggleExtraInfo"
/> />
@@ -28,7 +28,6 @@
import { defineComponent, PropType } from 'vue'; import { defineComponent, PropType } from 'vue';
import { API } from '../../../typings/api'; import { API } from '../../../typings/api';
import { useApiStore } from '../../../store/apiStore'; import { useApiStore } from '../../../store/apiStore';
import { Journal } from '../typings';
import trainCategoryMixin from '../../../mixins/trainCategoryMixin'; import trainCategoryMixin from '../../../mixins/trainCategoryMixin';
import dateMixin from '../../../mixins/dateMixin'; import dateMixin from '../../../mixins/dateMixin';
@@ -41,7 +40,7 @@ import EntryDetails from './EntryDetails.vue';
export default defineComponent({ export default defineComponent({
props: { props: {
timetableEntry: { timetableEntry: {
type: Object as PropType<API.TimetableHistory.Data>, type: Object as PropType<API.TimetableHistory.DataShort>,
required: true required: true
}, },
showExtraInfo: { showExtraInfo: {
@@ -60,74 +59,9 @@ export default defineComponent({
}; };
}, },
computed: {
timetablePathDetails() {
if (!this.timetableEntry.path || this.timetableEntry.path == '') return null;
return this.timetableEntry.path.split(';').map((pathEl, i) => {
const [arrival, name, departure] = pathEl.split(',');
const sceneryName = name.split(' ').slice(0, -1).join(' ');
const sceneryHash = name.split(' ').pop()?.replace('.sc', '') ?? '';
return {
arrival,
sceneryName,
sceneryHash,
departure,
isVisited: this.timetableEntry.visitedSceneries?.includes(sceneryHash) ?? false
};
});
},
timetableStops(): Journal.TimetableStopDetails[] {
const timetableEntry = this.timetableEntry;
const stopNames = timetableEntry.sceneriesString.split('%');
return stopNames.reduce<Journal.TimetableStopDetails[]>((acc, stopName, i, arr) => {
const arrivalDate =
i == arr.length - 1
? (timetableEntry.checkpointArrivals.at(i) ?? timetableEntry.endDate)
: timetableEntry.checkpointArrivals.at(i);
const scheduledArrivalDate =
i == arr.length - 1
? (timetableEntry.checkpointArrivalsScheduled.at(i) ?? timetableEntry.scheduledEndDate)
: timetableEntry.checkpointArrivalsScheduled.at(i);
const departureDate =
i == 0
? (timetableEntry.checkpointDepartures.at(i) ?? timetableEntry.beginDate)
: timetableEntry.checkpointDepartures.at(i);
const scheduledDepartureDate =
i == 0
? (timetableEntry.checkpointDeparturesScheduled.at(i) ??
timetableEntry.scheduledBeginDate)
: timetableEntry.checkpointDeparturesScheduled.at(i);
const stopTime = Number(timetableEntry.checkpointStopTypes.at(i)?.split(',')[0]) || 0;
const stopType = timetableEntry.checkpointStopTypes.at(i)?.split(',')[1] || '';
acc.push({
stopName,
arrivalTimestamp: this.dateStringToTimestamp(arrivalDate),
scheduledArrivalTimestamp: this.dateStringToTimestamp(scheduledArrivalDate),
departureTimestamp: this.dateStringToTimestamp(departureDate),
scheduledDepartureTimestamp: this.dateStringToTimestamp(scheduledDepartureDate),
stopTime,
stopType,
isConfirmed: i < timetableEntry.confirmedStopsCount
});
return acc;
}, []);
}
},
methods: { methods: {
toggleExtraInfo() { toggleExtraInfo(data: API.TimetableHistory.Data | null) {
this.$emit('toggleShowExtraInfo'); this.$emit('toggleShowExtraInfo', data);
} }
} }
}); });
@@ -145,7 +79,7 @@ export default defineComponent({
display: flex; display: flex;
} }
@include responsive.smallScreen{ @include responsive.smallScreen {
.entry-route { .entry-route {
justify-content: center; justify-content: center;
text-align: center; text-align: center;
@@ -20,7 +20,7 @@
v-for="(timetableEntry, i) in timetableHistory" v-for="(timetableEntry, i) in timetableHistory"
:key="timetableEntry.id" :key="timetableEntry.id"
:timetableEntry="timetableEntry" :timetableEntry="timetableEntry"
:onToggleShowExtraInfo="() => toggleExtraInfo(timetableEntry.id)" :onToggleShowExtraInfo="toggleExtraInfo"
:showExtraInfo="extraInfoIndexes.includes(timetableEntry.id)" :showExtraInfo="extraInfoIndexes.includes(timetableEntry.id)"
/> />
</transition-group> </transition-group>
@@ -59,9 +59,11 @@ export default defineComponent({
JournalTimetableEntry JournalTimetableEntry
}, },
emits: ['toggleExtraInfo'],
props: { props: {
timetableHistory: { timetableHistory: {
type: Array as PropType<API.TimetableHistory.Response>, type: Array as PropType<API.TimetableHistory.ResponseShort>,
required: true required: true
}, },
scrollNoMoreData: { scrollNoMoreData: {
@@ -75,32 +77,23 @@ export default defineComponent({
}, },
dataStatus: { dataStatus: {
type: Number as PropType<Status.Data> type: Number as PropType<Status.Data>
},
extraInfoIndexes: {
type: Object as PropType<number[]>,
required: true
} }
}, },
data() { data() {
return { return {
Status, Status,
store: useMainStore(), store: useMainStore()
extraInfoIndexes: [] as number[]
}; };
}, },
watch: {
'$route.query': {
deep: true,
handler() {
this.extraInfoIndexes.length = 0;
}
}
},
methods: { methods: {
toggleExtraInfo(id: number) { toggleExtraInfo(data: API.TimetableHistory.Data | null) {
const existingIdx = this.extraInfoIndexes.indexOf(id); this.$emit('toggleExtraInfo', data);
if (existingIdx != -1) this.extraInfoIndexes.splice(existingIdx, 1);
else this.extraInfoIndexes.push(id);
} }
} }
}); });
@@ -111,7 +104,7 @@ export default defineComponent({
@use '../../../styles/journal-section'; @use '../../../styles/journal-section';
@use '../../../styles/responsive'; @use '../../../styles/responsive';
@include responsive.smallScreen{ @include responsive.smallScreen {
.journal_item-info { .journal_item-info {
text-align: center; text-align: center;
} }
+1 -13
View File
@@ -1,5 +1,6 @@
export namespace Journal { export namespace Journal {
export type DispatcherSearchKey = export type DispatcherSearchKey =
| 'search-duty-id'
| 'search-dispatcher' | 'search-dispatcher'
| 'search-station' | 'search-station'
| 'search-date-from' | 'search-date-from'
@@ -62,19 +63,6 @@ export namespace Journal {
default: boolean; default: boolean;
} }
export enum StatsTab {
DRIVER_STATS = 'journal-driver-stats',
DISPATCHER_STATS = 'journal-dispatcher-stats',
DAILY_STATS = 'journal-daily-stats'
}
export interface StatsButton {
tab: StatsTab;
localeKey: string;
iconName: string;
disabled: boolean;
}
export interface TimetableStopDetails { export interface TimetableStopDetails {
stopName: string; stopName: string;
arrivalTimestamp: number; arrivalTimestamp: number;
@@ -0,0 +1,298 @@
<template>
<section class="profile-history-list">
<div class="list-header">
<div class="history-menu">
<button
v-for="(filterState, filterKey) in activeFilterTypes"
class="menu-btn btn--option"
:data-active="filterState"
@click="toggleFilter(filterKey)"
>
{{ t(`profile.filters.${filterKey}`) }}
</button>
</div>
</div>
<div class="history-list-box">
<Loading v-if="journalStatus == Status.Data.Loading" />
<div v-else-if="combinedJournal.length == 0" class="no-recent-history">
{{ t('profile.list.no-recent-history') }}
</div>
<router-link
v-else
v-for="entry in combinedJournal"
:to="
'trainNo' in entry.value
? `/journal/timetables?search-train=%23${entry.value.id}`
: `/journal/dispatchers?search-duty-id=${entry.value.id}`
"
>
<!-- Date -->
<div class="entry-top-date">
<img
v-if="entry.type == 'Dispatcher'"
src="/images/icon-user.svg"
width="25"
alt="user icon"
/>
<img
v-else-if="entry.type == 'Timetable'"
src="/images/icon-train.svg"
width="25"
alt="train icon"
/>
<img v-else src="/images/icon-timetable.svg" width="25" alt="timetable icon" />
<b
class="timestamp-indicator"
:data-online="
'isOnline' in entry.value
? entry.value.isOnline
: !entry.value.terminated && entry.type != 'IssuedTimetable'
"
>
{{ dateToLocaleString(entry.date, { dateStyle: 'long', timeStyle: 'short' }) }}
<span v-if="'timestampTo' in entry.value && entry.value.timestampTo">
-
<span v-if="new Date(entry.value.timestampTo).getDay() == entry.date.getDay()">{{
dateToLocaleString(new Date(entry.value.timestampTo), {
timeStyle: 'short'
})
}}</span>
<span v-else>{{
dateToLocaleString(new Date(entry.value.timestampTo), {
dateStyle: 'long',
timeStyle: 'short'
})
}}</span>
</span>
</b>
</div>
<!-- Timetables -->
<div v-if="'trainNo' in entry.value">
<b class="text--primary">
{{ entry.value.trainCategoryCode }}
</b>
{{ ' ' }}
<b>{{ entry.value.trainNo }}</b>
<b class="text--grayed" v-if="entry.type == 'IssuedTimetable'">
{{ ' ' }} {{ t('profile.list.for') }}: {{ entry.value.driverName }}
</b>
{{ ' ' }}
<b>{{ entry.value.route.replace('|', ' > ') }}</b>
{{ ' ' }}
<b class="text--primary">{{ entry.value.currentDistance }} km</b>
<b> / {{ entry.value.routeDistance }} km</b>
</div>
<!-- Dispatchers -->
<div v-else>
<b class="text--primary">{{ entry.value.stationName }}</b>
{{ ' - ' }}
<b class="timestamp-indicator" :data-online="entry.value.isOnline">
<span v-if="entry.value.isOnline">{{ t('profile.list.online-since') }}: </span>
<span>{{
humanizeDuration((entry.value.timestampTo || Date.now()) - entry.value.timestampFrom)
}}</span>
</b>
</div>
</router-link>
</div>
</section>
</template>
<script lang="ts" setup>
import {
computed,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
PropType,
reactive,
ref
} from 'vue';
import { dateToLocaleString, humanizeDuration } from '../../composables/time';
import { API } from '../../typings/api';
import { useI18n } from 'vue-i18n';
import { useApiStore } from '../../store/apiStore';
import { onBeforeRouteUpdate, useRoute } from 'vue-router';
import { Status } from '../../typings/common';
import Loading from '../Global/Loading.vue';
type JournalEntryType = 'Timetable' | 'Dispatcher' | 'IssuedTimetable';
interface JournalEntry {
type: JournalEntryType;
date: Date;
value: API.TimetableHistory.DataShort | API.DispatcherHistory.Data;
}
const props = defineProps({
playerName: {
type: String
},
playerJournal: {
type: Object as PropType<API.PlayerJournal.Data>,
},
journalStatus: {
type: Number as PropType<Status.Data>,
required: true
}
});
const { t } = useI18n();
const activeFilterTypes = reactive<Record<JournalEntryType, boolean>>({
Timetable: true,
Dispatcher: true,
IssuedTimetable: true
});
const combinedJournal = computed<JournalEntry[]>(() => {
if (!props.playerJournal || !props.playerName) return [];
const list = [
...props.playerJournal.timetables,
...props.playerJournal.duties,
...props.playerJournal.issuedTimetables
]
.reduce<JournalEntry[]>((acc, v) => {
// Timetable or dispatcher type
if ('trainNo' in v) {
const isIssued = v.authorName == props.playerName;
if (!isIssued && !activeFilterTypes['Timetable']) return acc;
if (isIssued && !activeFilterTypes['IssuedTimetable']) return acc;
acc.push({
date: new Date(v.createdAt),
type: isIssued ? 'IssuedTimetable' : 'Timetable',
value: v
});
} else {
if (!activeFilterTypes['Dispatcher']) return acc;
acc.push({
date: new Date(v.timestampFrom),
type: 'Dispatcher',
value: v
});
}
return acc;
}, [])
.sort((a, b) => {
return a.date.getTime() - b.date.getTime() > 0 ? -1 : 1;
});
return list;
});
function toggleFilter(filterType: JournalEntryType) {
const toggledState = !activeFilterTypes[filterType];
// Prevent switching off all filters at the same time (at least one must be active)
if (
toggledState === false &&
Object.values(activeFilterTypes).filter((v) => v === false).length ==
Object.values(activeFilterTypes).length - 1
)
return;
activeFilterTypes[filterType] = toggledState;
}
</script>
<style lang="scss" scoped>
@use '../../styles/responsive';
.profile-history-list {
overflow-y: scroll;
height: 100%;
}
.list-header {
position: sticky;
top: 0;
z-index: 100;
& > h3 {
padding: 0.5em;
margin-bottom: 0.5em;
}
}
.history-menu {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1em;
background-color: var(--clr-tile);
padding: 0.5em;
}
.menu-btn {
padding: 0.5em;
font-weight: bold;
color: #aaa;
&[data-active='true'] {
color: var(--clr-success);
}
}
.history-list-box {
padding: 0 0.5em;
position: relative;
}
.history-list-box > a {
display: flex;
flex-direction: column;
gap: 0.25em;
background-color: var(--clr-bg-light);
padding: 0.5em;
margin-bottom: 0.5em;
text-align: initial;
&:hover {
background-color: #333;
}
}
.no-recent-history {
padding: 1em;
font-size: 1.25em;
font-weight: bold;
color: #aaa;
}
.entry-top-date {
display: flex;
align-items: center;
gap: 0.25em;
}
.timestamp-indicator {
color: #ccc;
&[data-online='true'] {
color: var(--clr-success);
}
}
@include responsive.midScreen {
.profile-history-list {
height: 100vh;
}
}
</style>
@@ -0,0 +1,80 @@
<template>
<div class="player-avatar">
<img
v-if="props.playerTD2Info && props.playerTD2Info.avatar"
:src="`https://td2.info.pl/index.php?action=dlattach;attach=${props.playerTD2Info.avatar};type=avatar`"
class="player-avatar-image"
ref="avatarImageRef"
alt="player image"
@load="onAvatarLoadSuccess"
@error="onAvatarLoadError"
/>
<img
v-if="
avatarLoadingStatus == Status.Data.Error ||
(props.playerTD2Info && !props.playerTD2Info.avatar)
"
class="img-placeholder"
height="100"
src="/images/default-avatar.jpg"
/>
<Loading v-else-if="avatarLoadingStatus == Status.Data.Loading" />
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, PropType, ref, useTemplateRef } from 'vue';
import { Status } from '../../typings/common';
import Loading from '../Global/Loading.vue';
import { Td2API } from '../../typings/api';
const props = defineProps({
playerTD2Info: {
type: Object as PropType<Td2API.UsersInfoByName.UserInfo>
}
});
onMounted(() => {
console.log(avatarImageRef.value);
});
const avatarImageRef = useTemplateRef('avatarImageRef');
const avatarLoadingStatus = ref<Status.Data>(Status.Data.Loading);
function onAvatarLoadSuccess() {
if (!avatarImageRef.value) return;
avatarLoadingStatus.value = Status.Data.Loaded;
avatarImageRef.value.style.opacity = '1';
}
function onAvatarLoadError() {
if (!avatarImageRef.value) return;
avatarLoadingStatus.value = Status.Data.Error;
avatarImageRef.value.src = '/images/default-avatar.jpg';
avatarImageRef.value.style.opacity = '1';
}
</script>
<style lang="scss" scoped>
.player-avatar {
display: flex;
justify-content: center;
align-items: center;
position: relative;
min-height: 110px;
.loading {
top: 50%;
margin: 0;
}
img.player-avatar-image {
opacity: 0;
}
}
</style>
@@ -0,0 +1,107 @@
<template>
<section class="profile-recent-stats">
<h2 class="stats-header">
<img src="/images/icon-stats.svg" width="30" alt="stats icon" />
{{ t('profile.recent-stats.header') }}
</h2>
<div class="month-stats-box">
<div class="month-stat">
<div><img src="/images/icon-train.svg" width="30" alt="train icon" /></div>
<div>
<h3 class="text--primary">{{ playerInfo.driverStatsLastMonth.countAll }}</h3>
</div>
<div>{{ t('profile.recent-stats.timetables') }}</div>
</div>
<div class="month-stat">
<div><img src="/images/icon-spawn.svg" width="30" alt="spawn icon" /></div>
<div>
<h3 class="text--primary">
{{ playerInfo.driverStatsLastMonth.currentDistanceTotal?.toFixed(2) || 0 }}
</h3>
</div>
<div>{{ t('profile.recent-stats.distance') }}</div>
</div>
<div class="month-stat">
<div><img src="/images/icon-user.svg" width="30" alt="user icon" /></div>
<div>
<h3 class="text--primary">
{{ playerInfo.dispatcherStatsLastMonth.services?.count || 0 }}
</h3>
</div>
<div>{{ t('profile.recent-stats.duties') }}</div>
</div>
<div class="month-stat">
<div><img src="/images/icon-timetable.svg" width="30" alt="timetable icon" /></div>
<div>
<h3 class="text--primary">
{{ playerInfo.dispatcherStatsLastMonth.issuedTimetables?.count || 0 }}
</h3>
</div>
<div>{{ t('profile.recent-stats.created-timetables') }}</div>
</div>
</div>
</section>
</template>
<script lang="ts" setup>
import { PropType } from 'vue';
import { API } from '../../typings/api';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
defineProps({
playerInfo: {
type: Object as PropType<API.PlayerInfo.Data>,
required: true
}
});
</script>
<style lang="scss" scoped>
@use '../../styles/responsive';
.profile-recent-stats {
overflow: hidden;
}
.stats-header {
padding: 1em;
img {
vertical-align: text-bottom;
}
}
.month-stats-box {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5em;
padding: 0.5em;
}
.month-stat {
background-color: var(--clr-bg-light);
border-radius: 0.5em;
padding: 0.5em;
h3 {
font-size: 1.3em;
}
div:nth-child(3) {
margin-top: 0.5em;
font-size: 0.9em;
}
}
@include responsive.smallScreen {
.month-stats-box {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
@@ -0,0 +1,392 @@
<template>
<section class="profile-summary">
<div class="player-info">
<div class="info-main">
<ProfilePlayerAvatar :playerTD2Info="playerTD2Info" />
<div>
<h2 class="player-name-header" :class="{ 'text--donator': isPlayerDonator }">
<a :href="`https://td2.info.pl/profile/?u=${route.query.playerId}`" target="_blank">
<img
v-if="isPlayerDonator"
src="/images/icon-diamond.svg"
width="25"
alt="diamond icon"
/>
{{ playerName }}
</a>
</h2>
<div class="player-badges">
<div class="badge-container" v-if="playerInfo.driverStats.driverLevel != null">
<span
class="level-badge driver"
:style="calculateExpStyles(playerInfo.driverStats.driverLevel)"
>
{{
playerInfo.driverStats.driverLevel > 1 ? playerInfo.driverStats.driverLevel : 'L'
}}
</span>
{{ t('profile.stats.driver') }}
</div>
<div class="badge-container" v-if="playerInfo.dispatcherStats.dispatcherLevel != null">
<span
class="level-badge dispatcher"
:style="calculateExpStyles(playerInfo.dispatcherStats.dispatcherLevel)"
>
{{
playerInfo.dispatcherStats.dispatcherLevel > 1
? playerInfo.dispatcherStats.dispatcherLevel
: 'L'
}}
</span>
{{ t('profile.stats.dispatcher') }}
</div>
</div>
<div class="player-journal-links">
<router-link
class="a-button btn--action"
:to="`/journal/timetables?search-driver=${playerInfo.driverStats.driverName}`"
>
{{ t('profile.stats.timetables-journal') }}
</router-link>
<router-link
class="a-button btn--action"
:to="`/journal/dispatchers?search-dispatcher=${playerInfo.dispatcherStats.dispatcherName}`"
>
{{ t('profile.stats.dispatchers-journal') }}
</router-link>
<a
class="a-button btn--action"
:href="`https://td2.info.pl/profile/?u=${route.query.playerId}`"
target="_blank"
>
{{ t('profile.stats.forum-profile') }}
</a>
</div>
<!-- Current activities -->
<div
class="player-activities-box"
v-if="activeDispatches.length > 0 || activeTrains.length > 0"
>
<div class="info-activity" v-if="activeDispatches.length > 0">
<router-link
v-for="d in activeDispatches"
class="dispatcher-badge"
:to="`/scenery?station=${d.stationName}&region=${d.region}`"
>
<img src="/images/icon-user.svg" width="25" alt="user icon" />
<b>{{ d.stationName }} ({{ getRegionNameById(d.region) }})</b>
<StationStatusBadge :isOnline="true" :dispatcherStatus="d.dispatcherStatus" />
</router-link>
</div>
<div class="info-activity" v-if="activeTrains.length > 0">
<router-link
v-for="t in activeTrains"
:to="`/driver?trainId=${t.id}`"
class="driver-badge"
>
<img src="/images/icon-train.svg" width="25" alt="train icon" />
<span v-if="t.timetable" class="text--primary">{{ t.timetable.category }}</span>
<span>{{ t.trainNo }}</span>
&bull;
<span>{{ t.currentStationName }} ({{ getRegionNameById(t.region) }})</span>
&bull;
<span class="text--grayed">{{ t.stockString.split(';')[0] }}</span>
</router-link>
</div>
</div>
</div>
</div>
</div>
<div class="player-stats">
<div class="stats-driver">
<h3 class="stats-header">
<img src="/images/icon-train.svg" width="30" alt="train icon" />
{{ t('profile.stats.header-driver') }}
</h3>
<hr />
<div v-if="playerInfo.driverStats.countAll > 0">
<div>
<b class="text--primary">
{{ playerInfo.driverStats.countFulfilled }} /
{{ playerInfo.driverStats.countAll }} ({{
getCountPercentage(
playerInfo.driverStats.countFulfilled,
playerInfo.driverStats.countAll,
2
)
}}%)
</b>
- {{ t('profile.stats.fulfilled-timetables') }}
</div>
<div>
<b class="text--primary">
{{ playerInfo.driverStats.currentDistanceTotal?.toFixed(2) }} /
{{ playerInfo.driverStats.routeDistanceTotal?.toFixed(2) }} ({{
getCountPercentage(
playerInfo.driverStats.currentDistanceTotal || 0,
playerInfo.driverStats.routeDistanceTotal || 0,
2
)
}}%)
</b>
- {{ t('profile.stats.route-distance') }}
</div>
<div>
<b class="text--primary">
{{ playerInfo.driverStats.confirmedStopsTotal }} /
{{ playerInfo.driverStats.allStopsTotal }} ({{
getCountPercentage(
playerInfo.driverStats.confirmedStopsTotal || 0,
playerInfo.driverStats.allStopsTotal || 0,
2
)
}}%)
</b>
- {{ t('profile.stats.confirmed-stops') }}
</div>
<div>
<b class="text--primary">{{ playerInfo.driverStats.routeDistanceMax || 0 }}km</b> -
{{ t('profile.stats.longest-timetable') }}
</div>
<div>
<b class="text--primary">
{{ playerInfo.driverStats.routeDistanceAvg?.toFixed(2) || 0 }}km
</b>
- {{ t('profile.stats.avg-timetable-length') }}
</div>
</div>
<div class="text--grayed" v-else>
{{ t('profile.stats.no-timetable-stats') }}
</div>
</div>
<div
class="stats-dispatcher"
v-if="playerInfo.dispatcherStats && playerInfo.dispatcherStats.services?.count"
>
<h3 class="stats-header">
<img src="/images/icon-user.svg" width="30" alt="user icon" />
{{ t('profile.stats.header-dispatcher') }}
</h3>
<hr />
<div>
<b class="text--primary">{{ playerInfo.dispatcherStats.services.count }}</b> -
{{ t('profile.stats.duties-count') }}
</div>
<div>
<b class="text--primary">{{
humanizeDuration(playerInfo.dispatcherStats.services.durationMax)
}}</b>
- {{ t('profile.stats.longest-duty') }}
</div>
<div v-if="playerInfo.dispatcherStats.issuedTimetables">
<div>
<b class="text--primary">{{ playerInfo.dispatcherStats.issuedTimetables.count }}</b>
- {{ t('profile.stats.created-timetables-count') }}
</div>
<div>
<b class="text--primary">
{{ playerInfo.dispatcherStats.issuedTimetables.distanceMax }}km
</b>
- {{ t('profile.stats.longest-created-timetable') }}
</div>
<div>
<b class="text--primary">
{{ playerInfo.dispatcherStats.issuedTimetables.distanceSum.toFixed(2) }}km
</b>
- {{ t('profile.stats.created-timetables-length-sum') }}
</div>
</div>
<div class="text--grayed" v-else>
{{ t('profile.stats.no-dispatcher-stats') }}
</div>
</div>
</div>
</section>
</template>
<script lang="ts" setup>
import { computed, onActivated, onMounted, PropType, ref, watch } from 'vue';
import { API, Td2API } from '../../typings/api';
import { calculateExpStyles } from '../../composables/badge';
import { getCountPercentage } from '../../utils/calcUtils';
import { humanizeDuration } from '../../composables/time';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useApiStore } from '../../store/apiStore';
import StationStatusBadge from '../Global/StationStatusBadge.vue';
import ProfilePlayerAvatar from './ProfilePlayerAvatar.vue';
import { getRegionNameById } from '../../utils/regionUtils';
const { t } = useI18n();
const route = useRoute();
const apiStore = useApiStore();
const props = defineProps({
playerInfo: {
type: Object as PropType<API.PlayerInfo.Data>,
required: true
},
playerTD2Info: {
type: Object as PropType<Td2API.UsersInfoByName.UserInfo>
},
playerName: {
type: String
}
});
const isPlayerDonator = computed(() =>
props.playerName ? apiStore.donatorsData.includes(props.playerName) : false
);
const activeDispatches = computed(() => {
if (!props.playerName) return [];
if (!apiStore.activeData || !apiStore.activeData.activeSceneries) return [];
return apiStore.activeData.activeSceneries.filter(
(sc) =>
sc.dispatcherName == props.playerName && (sc.lastSeen >= Date.now() - 60000 || sc.isOnline)
);
});
const activeTrains = computed(() => {
if (!props.playerName) return [];
if (!apiStore.activeData || !apiStore.activeData.trains) return [];
return apiStore.activeData.trains.filter(
(t) => t.driverName == props.playerName && (t.lastSeen >= Date.now() - 60000 || t.online)
);
});
</script>
<style lang="scss" scoped>
@use '../../styles/badge';
@use '../../styles/responsive';
.profile-summary {
display: flex;
flex-direction: column;
gap: 1em;
overflow: auto;
}
.player-name-header {
margin: 0.5em 0;
a {
display: flex;
justify-content: center;
align-items: center;
gap: 0.25em;
}
}
.player-badges {
display: flex;
justify-content: center;
gap: 1em;
}
.badge-container {
display: flex;
justify-content: center;
align-items: center;
gap: 0.25em;
font-weight: bold;
& > .level-badge {
font-size: 1.15em;
}
}
.player-journal-links {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.5em;
margin-top: 1em;
}
.info-activity {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 1em;
margin-top: 1em;
.dispatcher-badge {
display: flex;
align-items: center;
gap: 0.25em;
}
.driver-badge {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 0.25em;
font-weight: bold;
border-radius: 0.5em;
}
}
.player-stats {
display: flex;
flex-direction: column;
gap: 1em;
hr {
margin: 0.5em 0;
}
}
.player-info,
.player-stats > div {
background-color: var(--clr-tile);
border-radius: 0.5em;
padding: 1em;
}
.stats-header {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25em;
}
@include responsive.midScreen {
.player-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
}
}
@include responsive.smallScreen {
.player-stats {
display: grid;
grid-template-columns: 1fr;
}
}
</style>
@@ -8,10 +8,7 @@
{{ onlineScenery.dispatcherExp > 1 ? onlineScenery.dispatcherExp : 'L' }} {{ onlineScenery.dispatcherExp > 1 ? onlineScenery.dispatcherExp : 'L' }}
</span> </span>
<router-link <router-link class="dispatcher-name" :to="`/profile?playerId=${onlineScenery.dispatcherId}`">
class="dispatcher-name"
:to="`/journal/dispatchers?search-dispatcher=${onlineScenery.dispatcherName}`"
>
<span <span
class="text--donator" class="text--donator"
v-if="apiStore.donatorsData.includes(onlineScenery.dispatcherName)" v-if="apiStore.donatorsData.includes(onlineScenery.dispatcherName)"
@@ -21,6 +18,8 @@
</span> </span>
<span v-else>{{ onlineScenery.dispatcherName }}</span> <span v-else>{{ onlineScenery.dispatcherName }}</span>
</router-link> </router-link>
<FlagIcon :languageId="onlineScenery.dispatcherLanguageId" width="1.25em" />
</div> </div>
<div class="info-bottom"> <div class="info-bottom">
@@ -51,9 +50,11 @@ import styleMixin from '../../../mixins/styleMixin';
import StationStatusBadge from '../../Global/StationStatusBadge.vue'; import StationStatusBadge from '../../Global/StationStatusBadge.vue';
import { ActiveScenery } from '../../../typings/common'; import { ActiveScenery } from '../../../typings/common';
import { useApiStore } from '../../../store/apiStore'; import { useApiStore } from '../../../store/apiStore';
import FlagIcon from '../../Global/FlagIcon.vue';
export default defineComponent({ export default defineComponent({
mixins: [styleMixin, dateMixin, routerMixin], mixins: [styleMixin, dateMixin, routerMixin],
components: { StationStatusBadge, FlagIcon },
data() { data() {
return { return {
@@ -66,8 +67,7 @@ export default defineComponent({
type: Object as PropType<ActiveScenery>, type: Object as PropType<ActiveScenery>,
required: false required: false
} }
}, }
components: { StationStatusBadge }
}); });
</script> </script>
@@ -115,7 +115,7 @@ export default defineComponent({
data() { data() {
return { return {
historyList: [] as API.TimetableHistory.Response, historyList: [] as API.TimetableHistory.ResponseShort,
historyModeList, historyModeList,
apiStore: useApiStore(), apiStore: useApiStore(),
@@ -149,7 +149,7 @@ export default defineComponent({
requestFilters['returnType'] = 'short'; requestFilters['returnType'] = 'short';
try { try {
const response: API.TimetableHistory.Response = await ( const response: API.TimetableHistory.ResponseShort = await (
await this.apiStore.client!.get('api/getTimetables', { await this.apiStore.client!.get('api/getTimetables', {
params: requestFilters params: requestFilters
}) })
@@ -178,7 +178,7 @@ export default defineComponent({
}); });
}, },
parseCreatedDate(timetable: API.TimetableHistory.Data, locale: string) { parseCreatedDate(timetable: API.TimetableHistory.DataShort, locale: string) {
const createdDate = const createdDate =
timetable.createdAt > timetable.beginDate timetable.createdAt > timetable.beginDate
? new Date(timetable.beginDate) ? new Date(timetable.beginDate)
+16 -3
View File
@@ -132,7 +132,6 @@
<span v-if="station.onlineInfo?.dispatcherName"> <span v-if="station.onlineInfo?.dispatcherName">
<b <b
v-if="apiStore.donatorsData.includes(station.onlineInfo.dispatcherName)" v-if="apiStore.donatorsData.includes(station.onlineInfo.dispatcherName)"
@click.prevent="openDonationCard"
data-tooltip-type="DonatorTooltip" data-tooltip-type="DonatorTooltip"
:data-tooltip-content="$t('donations.dispatcher-message')" :data-tooltip-content="$t('donations.dispatcher-message')"
> >
@@ -146,6 +145,14 @@
</span> </span>
</td> </td>
<td class="station-dispatcher-lang">
<FlagIcon
v-if="station.onlineInfo && station.onlineInfo.dispatcherLanguageId != -1"
:language-id="station.onlineInfo.dispatcherLanguageId"
width="2.25em"
/>
</td>
<td class="station-dispatcher-exp"> <td class="station-dispatcher-exp">
<span <span
v-if="station.onlineInfo && station.onlineInfo?.dispatcherExp != -1" v-if="station.onlineInfo && station.onlineInfo?.dispatcherExp != -1"
@@ -344,11 +351,13 @@ import { useTooltipStore } from '../../store/tooltipStore';
import { getChangedFilters } from '../../managers/stationFilterManager'; import { getChangedFilters } from '../../managers/stationFilterManager';
import { ActiveSorter, HeadIdsType, headIconsIds, headIds } from './typings'; import { ActiveSorter, HeadIdsType, headIconsIds, headIds } from './typings';
import { filterStations, sortStations } from './utils'; import { filterStations, sortStations } from './utils';
import { getLanguageNameById } from '../../utils/languageUtils';
import FlagIcon from '../Global/FlagIcon.vue';
export default defineComponent({ export default defineComponent({
emits: ['toggleDonationCard'], emits: ['toggleDonationCard'],
components: { Loading, StationStatusBadge }, components: { Loading, StationStatusBadge, FlagIcon },
mixins: [styleMixin, dateMixin], mixins: [styleMixin, dateMixin],
data: () => ({ data: () => ({
@@ -436,7 +445,7 @@ export default defineComponent({
$rowCol: #424242; $rowCol: #424242;
.station_table { .station_table {
height: calc(100vh - 11em); height: calc(100vh - 17em);
max-height: 2000px; max-height: 2000px;
min-height: 500px; min-height: 500px;
overflow: auto; overflow: auto;
@@ -495,6 +504,10 @@ thead th {
width: 12em; width: 12em;
} }
&.dispatcher-lang {
width: 6em;
}
&.dispatcher-lvl { &.dispatcher-lvl {
width: 6em; width: 6em;
} }
+1
View File
@@ -10,6 +10,7 @@ export const headIds = [
'min-lvl', 'min-lvl',
'status', 'status',
'dispatcher', 'dispatcher',
'dispatcher-lang',
'dispatcher-lvl', 'dispatcher-lvl',
'routes-single', 'routes-single',
'routes-double', 'routes-double',
+5
View File
@@ -210,6 +210,11 @@ export const sortStations = (a: Station, b: Station, sorter: ActiveSorter) => {
diff = (a.onlineInfo?.dispatcherExp || 0) - (b.onlineInfo?.dispatcherExp || 0); diff = (a.onlineInfo?.dispatcherExp || 0) - (b.onlineInfo?.dispatcherExp || 0);
break; break;
case 'dispatcher-lang':
diff =
(a.onlineInfo?.dispatcherLanguageId ?? -1) - (b.onlineInfo?.dispatcherLanguageId ?? -1);
break;
case 'routes-single': case 'routes-single':
diff = diff =
(a.generalInfo?.routes.single.filter((r) => !r.hidden && !r.isInternal).length ?? -1) - (a.generalInfo?.routes.single.filter((r) => !r.hidden && !r.isInternal).length ?? -1) -
+6 -1
View File
@@ -66,6 +66,10 @@
<span v-else>{{ train.driverName }}</span> <span v-else>{{ train.driverName }}</span>
</div> </div>
<div class="train-language-flag">
<FlagIcon :language-id="train.driverLanguageId" width="1.75em" />
</div>
</div> </div>
</div> </div>
@@ -199,10 +203,11 @@ import trainInfoMixin from '../../mixins/trainInfoMixin';
import trainCategoryMixin from '../../mixins/trainCategoryMixin'; import trainCategoryMixin from '../../mixins/trainCategoryMixin';
import ProgressBar from '../Global/ProgressBar.vue'; import ProgressBar from '../Global/ProgressBar.vue';
import StockList from '../Global/StockList.vue'; import StockList from '../Global/StockList.vue';
import FlagIcon from '../Global/FlagIcon.vue';
export default defineComponent({ export default defineComponent({
mixins: [trainInfoMixin, styleMixin, trainCategoryMixin], mixins: [trainInfoMixin, styleMixin, trainCategoryMixin],
components: { ProgressBar, StockList }, components: { ProgressBar, StockList, FlagIcon },
props: { props: {
train: { train: {
+1 -1
View File
@@ -97,7 +97,7 @@ export default defineComponent({
@use '../../styles/animations'; @use '../../styles/animations';
.train-table { .train-table {
height: calc(100vh - 11em); height: calc(100vh - 17em);
min-height: 500px; min-height: 500px;
position: relative; position: relative;
+8
View File
@@ -0,0 +1,8 @@
export function calculateExpStyles(exp: number, isSupporter = false) {
const bgColor = exp > -1 ? (exp < 2 ? '#26B0D9' : `hsl(${-exp * 5 + 100}, 85%, 50%)`) : '#666';
const fontColor = exp > 14 || exp == -1 ? 'white' : 'black';
const boxShadow = isSupporter ? `0 0 6px 2px ${bgColor};` : '';
return { 'background-color': bgColor, color: fontColor, 'box-shadow': boxShadow };
}
+37
View File
@@ -0,0 +1,37 @@
import { useI18n } from 'vue-i18n';
export function calculateDuration(timestampMs: number) {
const secondsTotal = Math.floor(timestampMs / 1000);
const minsTotal = Math.round(timestampMs / 60000);
const hoursTotal = Math.floor(minsTotal / 60);
const minsInHour = minsTotal % 60;
return {
secondsTotal,
minsTotal,
hoursTotal,
minsInHour
};
}
export function humanizeDuration(timestampMs: number, showSeconds = false) {
const { t } = useI18n();
const duration = calculateDuration(timestampMs);
return duration.minsTotal >= 60
? `${t('journal.hours', { value: duration.hoursTotal }, duration.hoursTotal)} ${t(
'journal.minutes',
{ value: duration.minsInHour },
duration.minsInHour
)}`
: showSeconds && duration.secondsTotal <= 60
? t('journal.seconds', { value: duration.secondsTotal }, duration.secondsTotal)
: t('journal.minutes', { value: duration.minsTotal }, duration.minsTotal);
}
export function dateToLocaleString(date: Date, dateOptions: Intl.DateTimeFormatOptions) {
const { locale } = useI18n();
return date.toLocaleString(locale.value == 'pl' ? 'pl-PL' : 'en-GB', dateOptions);
}
+54 -9
View File
@@ -87,10 +87,8 @@
"tooltip-scenery-offline": "Scenery is offline", "tooltip-scenery-offline": "Scenery is offline",
"pojazdownik-link-content": "POJAZDOWNIK", "pojazdownik-link-content": "POJAZDOWNIK",
"language-tooltip-content": "JĘZYK / LANGUAGE", "language-tooltip-content": "JĘZYK / LANGUAGE",
"gnr-link-content": "TRAIN ORDERS <br> GENERATOR" "gnr-link-content": "TRAIN ORDERS <br> GENERATOR",
}, "discord-link-content": "STACJOWNIK <br> DISCORD SERVER"
"footer": {
"discord": "Stacjownik Discord server"
}, },
"categories": { "categories": {
"EI": "domestic express", "EI": "domestic express",
@@ -197,6 +195,7 @@
"search-train": "Train no. / #", "search-train": "Train no. / #",
"select-driver": "Choose a driver...", "select-driver": "Choose a driver...",
"search-driver": "Driver name", "search-driver": "Driver name",
"search-duty-id": "Duty ID",
"search-dispatcher": "Dispatcher name", "search-dispatcher": "Dispatcher name",
"search-station": "Scenery name / #", "search-station": "Scenery name / #",
"search-author": "Timetable author name", "search-author": "Timetable author name",
@@ -337,6 +336,7 @@
"min-lvl": "Scenery\nlevel", "min-lvl": "Scenery\nlevel",
"status": "Status", "status": "Status",
"dispatcher": "Dispatcher", "dispatcher": "Dispatcher",
"dispatcher-lang": "Language",
"dispatcher-lvl": "Dispatcher\nlevel", "dispatcher-lvl": "Dispatcher\nlevel",
"routes-single": "1-track\nroutes", "routes-single": "1-track\nroutes",
"routes-double": "2-track\nroutes", "routes-double": "2-track\nroutes",
@@ -427,7 +427,7 @@
"last-seen-ago": "since {minutes} minutes", "last-seen-ago": "since {minutes} minutes",
"scenery-offline": "Offline ride", "scenery-offline": "Offline ride",
"timeout": "An error occured while trying to refresh SWDR timetable data!", "timeout": "An error occured while trying to refresh SWDR timetable data!",
"driver-journal-link": "DRIVER JOURNAL", "driver-profile-link": "PLAYER'S PROFILE",
"driver-srjp-link": "SRJP", "driver-srjp-link": "SRJP",
"driver-return-link": "RETURN", "driver-return-link": "RETURN",
"driver-not-found-header": "Train not found! :/", "driver-not-found-header": "Train not found! :/",
@@ -618,9 +618,54 @@
"desc-end": "The train terminates here", "desc-end": "The train terminates here",
"desc-terminated": "The train has been terminated" "desc-terminated": "The train has been terminated"
}, },
"history": { "profile": {
"title": "TIMETABLE JOURNAL", "journal-button": "PLAYER'S PROFILE",
"search-train": "Train no.", "no-player-found": "Player not found! :/",
"search-driver": "Driver name" "return-to-main": "Return to the main site",
"filters": {
"Timetable": "TIMETABLES",
"Dispatcher": "DISPATCHER DUTIES",
"IssuedTimetable": "ISSUED TIMETABLES"
},
"stats": {
"timetables-journal": "TIMETABLE JOURNAL",
"dispatchers-journal": "DISPATCHER JOURNAL",
"forum-profile": "FORUM PROFILE",
"driver": "DRIVER",
"dispatcher": "DISPATCHER",
"header-driver": "DRIVER'S STATS",
"fulfilled-timetables": "fulfilled timetables",
"route-distance": "confirmed timetables distance",
"confirmed-stops": "confirmed stations in timetables",
"longest-timetable": "longest timetable",
"avg-timetable-length": "average distance of all timetables",
"no-timetable-stats": "This player does not have any registered timetables in Stacjownik!",
"header-dispatcher": "DISPATCHER'S STATS",
"duties-count": "duties as dispatcher",
"longest-duty": "longest duty",
"created-timetables-count": "issued timetables as dispatcher",
"longest-created-timetable": "longest issued timetable",
"created-timetables-length-sum": "distance sum of issued timetables",
"no-dispatcher-stats": "No registered dispatcher duties in Stacjownik!"
},
"recent-stats": {
"header": "ACTIVITY STATISTICS (30 LAST DAYS)",
"timetables": "TIMETABLES",
"distance": "MADE KILOMETERS",
"duties": "DISPATCHER DUTIES",
"created-timetables": "ISSUED TIMETABLES"
},
"list": {
"for": "for",
"online-since": "online since",
"no-recent-history": "No recent activity in the simulator :("
}
} }
} }
+54 -7
View File
@@ -83,10 +83,8 @@
"tooltip-scenery-offline": "Sceneria offline", "tooltip-scenery-offline": "Sceneria offline",
"pojazdownik-link-content": "POJAZDOWNIK", "pojazdownik-link-content": "POJAZDOWNIK",
"language-tooltip-content": "JĘZYK / LANGUAGE", "language-tooltip-content": "JĘZYK / LANGUAGE",
"gnr-link-content": "GENERATOR <br> ROZKAZÓW PISEMNYCH" "gnr-link-content": "GENERATOR <br> ROZKAZÓW PISEMNYCH",
}, "discord-link-content": "SERWER DISCORD <br> STACJOWNIKA"
"footer": {
"discord": "Serwer Discord Stacjownika"
}, },
"categories": { "categories": {
"EI": "ekspres krajowy", "EI": "ekspres krajowy",
@@ -193,6 +191,7 @@
"search-train": "Nr pociągu / #", "search-train": "Nr pociągu / #",
"search-driver": "Nick maszynisty", "search-driver": "Nick maszynisty",
"select-driver": "Wybierz maszynistę...", "select-driver": "Wybierz maszynistę...",
"search-duty-id": "ID służby",
"search-dispatcher": "Nick dyżurnego", "search-dispatcher": "Nick dyżurnego",
"search-station": "Nazwa scenerii / #", "search-station": "Nazwa scenerii / #",
"search-author": "Nick autora rozkładu jazdy", "search-author": "Nick autora rozkładu jazdy",
@@ -334,6 +333,7 @@
"min-lvl": "Poziom\nscenerii", "min-lvl": "Poziom\nscenerii",
"status": "Status", "status": "Status",
"dispatcher": "Dyżurny", "dispatcher": "Dyżurny",
"dispatcher-lang": "Język",
"dispatcher-lvl": "Poziom\ndyżurnego", "dispatcher-lvl": "Poziom\ndyżurnego",
"routes-single": "Szlaki\n1-torowe", "routes-single": "Szlaki\n1-torowe",
"routes-double": "Szlaki\n2-torowe", "routes-double": "Szlaki\n2-torowe",
@@ -413,7 +413,7 @@
"last-seen-ago": "od {minutes} minut", "last-seen-ago": "od {minutes} minut",
"scenery-offline": "Przejazd offline", "scenery-offline": "Przejazd offline",
"timeout": "Wystąpił problem z aktualizacją rozkładów jazdy z SWDR", "timeout": "Wystąpił problem z aktualizacją rozkładów jazdy z SWDR",
"driver-journal-link": "DZIENNIK MASZYNISTY", "driver-profile-link": "PROFIL GRACZA",
"driver-srjp-link": "SRJP", "driver-srjp-link": "SRJP",
"driver-return-link": "POWRÓT", "driver-return-link": "POWRÓT",
"driver-not-found-header": "Nie znaleziono pociągu! :/", "driver-not-found-header": "Nie znaleziono pociągu! :/",
@@ -603,7 +603,54 @@
"desc-end": "Pociąg kończy bieg", "desc-end": "Pociąg kończy bieg",
"desc-terminated": "Pociąg zakończył bieg" "desc-terminated": "Pociąg zakończył bieg"
}, },
"history": { "profile": {
"title": "DZIENNIK ROZKŁADÓW JAZDY" "journal-button": "PROFIL GRACZA",
"no-player-found": "Nie znaleziono gracza! :/",
"return-to-main": "Powrót do strony głównej",
"filters": {
"Timetable": "ROZKŁADY JAZDY",
"Dispatcher": "SŁUŻBY DYŻURNEGO",
"IssuedTimetable": "WYSTAWIONE RJ"
},
"stats": {
"timetables-journal": "DZIENNIK RJ",
"dispatchers-journal": "DZIENNIK DR",
"forum-profile": "PROFIL FORUM",
"driver": "MASZYNISTA",
"dispatcher": "DYŻURNY RUCHU",
"header-driver": "STATYSTYKI MASZYNISTY",
"fulfilled-timetables": "wypełnione rozkłady jazdy",
"route-distance": "zatwierdzony kilometraż w RJ",
"confirmed-stops": "potwierdzonych stacji w RJ",
"longest-timetable": "najdłuższy rozkład jazdy",
"avg-timetable-length": "średnia długość wszystkich rozkładów",
"no-timetable-stats": "Ten użytkownik nie posiada statystyk maszynisty zarejestrowanych przez Stacjownik!",
"header-dispatcher": "STATYSTYKI DYŻURNEGO RUCHU",
"duties-count": "służby jako dyżurny ruchu",
"longest-duty": "najdłuższa służba",
"created-timetables-count": "wystawione RJ jako dyżurny ruchu",
"longest-created-timetable": "najdłuższy wystawiony RJ",
"created-timetables-length-sum": "suma długości wystawionych RJ",
"no-dispatcher-stats": "Ten użytkownik nie posiada statystyk dyżurnego zarejestrowanych przez Stacjownik!"
},
"recent-stats": {
"header": "STATYSTYKI AKTYWNOŚCI (30 DNI)",
"timetables": "ROZKŁADÓW JAZDY",
"distance": "POKONANYCH KILOMETRÓW",
"duties": "SŁUŻB DYŻURNEGO",
"created-timetables": "WYSTAWIONYCH ROZKŁADÓW"
},
"list": {
"for": "dla",
"online-since": "online od",
"no-recent-history": "Brak ostatniej aktywności w symulatorze :("
}
} }
} }
+7 -2
View File
@@ -61,6 +61,11 @@ const routes: Array<RouteRecordRaw> = [
region: route.query.region region: route.query.region
}) })
}, },
{
path: '/profile',
name: 'PlayerProfileView',
component: () => import('../views/PlayerProfileView.vue')
},
{ {
path: '/:catchAll(.*)', path: '/:catchAll(.*)',
redirect: '/' redirect: '/'
@@ -70,12 +75,12 @@ const routes: Array<RouteRecordRaw> = [
const router = createRouter({ const router = createRouter({
scrollBehavior(to, from, savedPosition) { scrollBehavior(to, from, savedPosition) {
if ( if (
(to.name == 'SceneryView' || to.name == 'DriverView') && (to.name == 'SceneryView' || to.name == 'DriverView' || to.name == 'PlayerProfileView') &&
from.name !== to.name && from.name !== to.name &&
from.query['view'] === undefined && from.query['view'] === undefined &&
!savedPosition !savedPosition
) )
return { el: `.app_main`, behavior: 'instant', top: -13 }; return { el: `.app_main`, behavior: 'smooth', top: 0 };
if (savedPosition) return savedPosition; if (savedPosition) return savedPosition;
}, },
+20 -2
View File
@@ -9,7 +9,8 @@ export const useApiStore = defineStore('apiStore', {
dataStatuses: { dataStatuses: {
connection: Status.Data.Loading, connection: Status.Data.Loading,
sceneries: Status.Data.Loading, sceneries: Status.Data.Loading,
vehicles: Status.Data.Loading vehicles: Status.Data.Loading,
dailyStatsData: Status.Data.Loading
}, },
activeData: undefined as API.ActiveData.Response | undefined, activeData: undefined as API.ActiveData.Response | undefined,
@@ -18,6 +19,8 @@ export const useApiStore = defineStore('apiStore', {
donatorsData: [] as API.Donators.Response, donatorsData: [] as API.Donators.Response,
sceneryData: [] as StationJSONData[], sceneryData: [] as StationJSONData[],
dailyStatsData: null as API.DailyStats.Response | null,
nextUpdateTime: 0, nextUpdateTime: 0,
nextDataCheckTime: 0, nextDataCheckTime: 0,
@@ -65,7 +68,7 @@ export const useApiStore = defineStore('apiStore', {
// Active data fefresh // Active data fefresh
if (t >= this.nextUpdateTime) { if (t >= this.nextUpdateTime) {
this.fetchActiveData(); this.fetchActiveData();
this.nextUpdateTime = t + 20000; this.nextUpdateTime = t + 31000;
} }
window.requestAnimationFrame(this.updateTick); window.requestAnimationFrame(this.updateTick);
@@ -119,6 +122,21 @@ export const useApiStore = defineStore('apiStore', {
this.dataStatuses.vehicles = Status.Data.Error; this.dataStatuses.vehicles = Status.Data.Error;
console.error('Ups! Wystąpił błąd podczas pobierania informacji o pojazdach:', error); console.error('Ups! Wystąpił błąd podczas pobierania informacji o pojazdach:', error);
} }
},
async fetchDailyStats() {
try {
const res: API.DailyStats.Response = await (
await this.client!.get('api/getDailyStats')
).data;
this.dailyStatsData = res;
this.dataStatuses.dailyStatsData = Status.Data.Loaded;
} catch (error) {
console.error('Ups! Wystąpił błąd podczas pobierania statystyk rozkładów jazdy...');
this.dataStatuses.dailyStatsData = Status.Data.Error;
}
} }
} }
}); });
+4 -9
View File
@@ -26,20 +26,12 @@ export const useMainStore = defineStore('mainStore', {
isOffline: false, isOffline: false,
appUpdate: null, appUpdate: null,
dispatcherStatsName: '',
dispatcherStatsStatus: Status.Data.Initialized,
driverStatsName: '',
driverStatsData: undefined,
driverStatsStatus: Status.Data.Initialized,
chosenModalTrainId: undefined, chosenModalTrainId: undefined,
modalLastClickedTarget: null, modalLastClickedTarget: null,
currentLocale: 'pl', currentLocale: 'pl',
isMigrateInfoCardOpen: false, isMigrateInfoCardOpen: false
pinnedStationNames: []
}) as MainStoreState, }) as MainStoreState,
actions: { actions: {
@@ -87,6 +79,7 @@ export const useMainStore = defineStore('mainStore', {
online: Boolean(train.online), online: Boolean(train.online),
driverId: train.driverId, driverId: train.driverId,
driverName: train.driverName, driverName: train.driverName,
driverLanguageId: train.driverLanguageId,
currentStationName: train.currentStationName, currentStationName: train.currentStationName,
currentStationHash: train.currentStationHash, currentStationHash: train.currentStationHash,
connectedTrack: train.connectedTrack, connectedTrack: train.connectedTrack,
@@ -258,6 +251,7 @@ export const useMainStore = defineStore('mainStore', {
dispatcherIsSupporter: false, dispatcherIsSupporter: false,
dispatcherStatus: Status.ActiveDispatcher.FREE, dispatcherStatus: Status.ActiveDispatcher.FREE,
dispatcherTimestamp: -1, dispatcherTimestamp: -1,
dispatcherLanguageId: -1,
isOnline: false, isOnline: false,
@@ -304,6 +298,7 @@ export const useMainStore = defineStore('mainStore', {
dispatcherIsSupporter: scenery.dispatcherIsSupporter, dispatcherIsSupporter: scenery.dispatcherIsSupporter,
dispatcherStatus: scenery.dispatcherStatus, dispatcherStatus: scenery.dispatcherStatus,
dispatcherTimestamp: dispatcherTimestamp, dispatcherTimestamp: dispatcherTimestamp,
dispatcherLanguageId: scenery.dispatcherLanguageId,
isOnline: scenery.isOnline == 1, isOnline: scenery.isOnline == 1,
-5
View File
@@ -5,11 +5,6 @@ export interface MainStoreState {
region: { id: string; value: string; name: string }; region: { id: string; value: string; name: string };
isOffline: boolean; isOffline: boolean;
appUpdate: { version: string; changelog: string; releaseURL: string } | null; appUpdate: { version: string; changelog: string; releaseURL: string } | null;
dispatcherStatsName: string;
dispatcherStatsData?: API.DispatcherStats.Response;
driverStatsName: string;
driverStatsData?: API.DriverStats.Response;
driverStatsStatus: Status.Data;
chosenModalTrainId?: string; chosenModalTrainId?: string;
modalLastClickedTarget: EventTarget | null; modalLastClickedTarget: EventTarget | null;
currentLocale: string; currentLocale: string;
+17
View File
@@ -135,3 +135,20 @@
color: black; color: black;
} }
} }
.timetable-status-badge {
padding: 0.05em 0.35em;
color: black;
&.terminated {
background-color: salmon;
}
&.fulfilled {
background-color: lightgreen;
}
&.active {
background-color: lightblue;
}
}
+8 -16
View File
@@ -9,6 +9,9 @@
--clr-bg2: #1b1b1b; --clr-bg2: #1b1b1b;
--clr-bg3: #1d1d1d; --clr-bg3: #1d1d1d;
--clr-view-bg: #1a1a1a; --clr-view-bg: #1a1a1a;
--clr-bg-light: #2b2b2b;
--clr-tile: #181818;
--clr-accent: #1085b3; --clr-accent: #1085b3;
--clr-accent2: #ff3d5d; --clr-accent2: #ff3d5d;
@@ -23,6 +26,8 @@
--clr-donator: #f7a4ff; --clr-donator: #f7a4ff;
--clr-success: springgreen;
--no-scroll-padding: 17px; --no-scroll-padding: 17px;
--max-container-width: 1700px; --max-container-width: 1700px;
@@ -30,9 +35,8 @@
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: var(--no-scroll-padding); // width: var(--no-scroll-padding);
height: var(--no-scroll-padding); // height: var(--no-scroll-padding);
background-color: transparent;
&-track { &-track {
background-color: #333; background-color: #333;
@@ -49,6 +53,7 @@
body { body {
background: var(--clr-bg); background: var(--clr-bg);
color-scheme: dark;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -331,19 +336,6 @@ a.a-button {
} }
@include responsive.smallScreen { @include responsive.smallScreen {
::-webkit-scrollbar {
width: 0.5em;
height: 0.5em;
&-track {
background-color: #222;
}
&-thumb {
background-color: #777;
}
}
[data-tooltip]:hover::after, [data-tooltip]:hover::after,
[data-tooltip]:focus::after { [data-tooltip]:focus::after {
transform: translate(-50%, 2em); transform: translate(-50%, 2em);
+2 -2
View File
@@ -11,8 +11,8 @@
.list_wrapper { .list_wrapper {
overflow-y: auto; overflow-y: auto;
height: calc(100vh - 12.5em); height: calc(100vh - 21em);
min-height: 700px; min-height: 500px;
margin-top: 0.5em; margin-top: 0.5em;
position: relative; position: relative;
+175 -52
View File
@@ -1,3 +1,4 @@
import { Journal } from '../components/JournalView/typings';
import { Status, Vehicle, VehicleGroup } from './common'; import { Status, Vehicle, VehicleGroup } from './common';
export enum APIDataStatus { export enum APIDataStatus {
@@ -27,17 +28,29 @@ export namespace API {
} }
} }
export namespace PlayerActivity {
export interface Data {
dispatcher: API.ActiveSceneries.Data[];
driver: API.ActiveTrains.Data | null;
}
export type Response = Data;
}
export namespace DispatcherHistory { export namespace DispatcherHistory {
export type Response = Data[]; export type Response = Data[];
export interface Data { export interface Data {
id: number; id: number;
createdAt: string;
updatedAt: string;
currentDuration: number; currentDuration: number;
dispatcherId: number; dispatcherId: number;
dispatcherName: string; dispatcherName: string;
dispatcherLevel: number | null; dispatcherLevel: number | null;
dispatcherRate: number; dispatcherRate: number;
dispatcherIsSupporter: boolean; dispatcherIsSupporter: boolean;
dispatcherLanguageId: number | null;
dispatcherStatus?: number; dispatcherStatus?: number;
isOnline: boolean; isOnline: boolean;
lastOnlineTimestamp: number; lastOnlineTimestamp: number;
@@ -51,61 +64,64 @@ export namespace API {
} }
export namespace DispatcherStats { export namespace DispatcherStats {
export interface DistanceStat { export interface Services {
routeDistance: number | null; count: number;
durationMax: number;
durationAvg: number;
} }
export interface DurationStat { export interface IssuedTimetables {
currentDuration: number | null; count: number;
distanceMax: number;
distanceAvg: number;
distanceSum: number;
} }
export interface Count { export interface Data {
_all: number; dispatcherId: number | null;
dispatcherName: string | null;
dispatcherLevel: number | null;
services: Services | null;
issuedTimetables: IssuedTimetables | null;
} }
export interface Response { export type Response = Data;
services: {
count: number;
durationMax: number;
durationAvg: number;
} | null;
issuedTimetables: {
count: number;
distanceMax: number;
distanceAvg: number;
distanceSum: number;
} | null;
}
} }
export namespace DriverStats { export namespace DriverStats {
export interface SumStats { export interface Data {
routeDistance: number; driverName: string | null;
confirmedStopsCount: number; driverId: number | null;
allStopsCount: number; driverLevel: number | null;
currentDistance: number; countAll: number;
countTerminated: number;
countFulfilled: number;
routeDistanceTotal: number | null;
routeDistanceAvg: number | null;
routeDistanceMax: number | null;
currentDistanceTotal: number | null;
confirmedStopsTotal: number | null;
allStopsTotal: number | null;
} }
export interface CountStats { export type Response = Data;
fulfilled: number; }
terminated: number;
_all: number;
}
export interface MaxStats { export namespace PlayerInfo {
routeDistance: number; export interface Data {
currentActivity: PlayerActivity.Data;
dispatcherStats: DispatcherStats.Data;
dispatcherStatsLastMonth: DispatcherStats.Data;
driverStats: DriverStats.Data;
driverStatsLastMonth: DriverStats.Data;
} }
}
export interface AvdStats { export namespace PlayerJournal {
routeDistance: number; export interface Data {
} timetables: TimetableHistory.DataShort[];
issuedTimetables: TimetableHistory.DataShort[];
export interface Response { duties: DispatcherHistory.Data[];
_sum: SumStats;
_count: CountStats;
_max: MaxStats;
_avg: AvdStats;
} }
} }
@@ -114,6 +130,7 @@ export namespace API {
dispatcherId: number; dispatcherId: number;
dispatcherName: string; dispatcherName: string;
dispatcherIsSupporter: boolean; dispatcherIsSupporter: boolean;
dispatcherLanguageId: number;
stationName: string; stationName: string;
stationHash: string; stationHash: string;
region: string; region: string;
@@ -152,6 +169,7 @@ export namespace API {
driverId: number; driverId: number;
driverIsSupporter: boolean; driverIsSupporter: boolean;
driverLevel?: number; driverLevel?: number;
driverLanguageId: number;
currentStationName: string; currentStationName: string;
currentStationHash?: string; currentStationHash?: string;
@@ -208,24 +226,58 @@ export namespace API {
} }
export namespace TimetableHistory { export namespace TimetableHistory {
export interface Data { export interface QueryParams {
driverName?: string;
trainNo?: string;
timetableId?: string;
categoryCode?: string;
authorName?: string;
dateFrom?: string;
dateTo?: string;
issuedFrom?: string;
terminatingAt?: string;
via?: string;
includesScenery?: string;
countFrom?: number;
countLimit?: number;
fulfilled?: number;
terminated?: number;
twr?: number;
skr?: number;
pn?: number;
tn?: number;
returnType?: 'all' | 'short' | 'detailed';
sortBy?: Journal.TimetableSorter['id'];
}
export interface Data extends DataShort, DataDetailsOnly {
updatedAt: string;
}
export interface DataShort {
id: number; id: number;
createdAt: string; createdAt: string;
updatedAt: string;
timetableId: number;
trainNo: number; trainNo: number;
trainCategoryCode: string; trainCategoryCode: string;
timetableId: number;
driverId: number; driverId: number;
driverName: string; driverName: string;
driverLevel: number | null; driverLevel: number | null;
driverIsSupporter: boolean; driverIsSupporter: boolean;
driverLanguageId: number | null;
route: string; route: string;
twr: number; twr: number;
skr: number; skr: number;
sceneriesString: string;
currentLocation: string[]; currentLocation: string[];
routeDistance: number; routeDistance: number;
@@ -236,7 +288,6 @@ export namespace API {
beginDate: string; beginDate: string;
endDate: string; endDate: string;
scheduledBeginDate: string; scheduledBeginDate: string;
scheduledEndDate: string; scheduledEndDate: string;
@@ -246,15 +297,25 @@ export namespace API {
authorName?: string; authorName?: string;
authorId?: number; authorId?: number;
currentSceneryName?: string;
currentSceneryHash?: string;
hasDangerousCargo: boolean;
hasExtraDeliveries: boolean;
}
export interface DataDetailsOnly {
id: number;
timetableId: number;
sceneriesString: string;
stockString?: string; stockString?: string;
stockHistory: string[]; stockHistory: string[];
stockMass?: number; stockMass?: number;
stockLength?: number; stockLength?: number;
maxSpeed?: number; maxSpeed?: number;
trainMaxSpeed?: number;
currentSceneryName?: string;
currentSceneryHash?: string;
routeSceneries: string; routeSceneries: string;
checkpointArrivals: string[]; checkpointArrivals: string[];
checkpointDepartures: string[]; checkpointDepartures: string[];
@@ -264,14 +325,20 @@ export namespace API {
checkpointComments: string[]; checkpointComments: string[];
visitedSceneries: string[]; visitedSceneries: string[];
sceneryNames: string[]; sceneryNames: string[];
path: string; path: string;
warningNotes: string | null; warningNotes: string | null;
hasDangerousCargo: boolean;
hasExtraDeliveries: boolean; authorId?: number;
trainMaxSpeed?: number; authorName?: string;
driverId: number;
driverName: string;
driverLanguageId: number | null;
} }
export type Response = Data[]; export type Response = Data[];
export type ResponseShort = DataShort[];
export type ResponseDetailsOnly = DataDetailsOnly[];
} }
export namespace DailyStats { export namespace DailyStats {
@@ -423,6 +490,62 @@ export namespace GithubAPI {
} }
} }
export namespace Td2API {
export namespace UsersInfoByName {
export interface UserStat {
variable: string;
value: number;
position: number;
server_total: number;
server_max: number;
server_min: number;
server_avg: number;
}
export interface Levels {
driver: number;
dispatcher: number;
}
export interface UserGroup {
id_group: number;
group_name: string;
description: string;
online_color: string;
min_posts: number;
max_messages: number;
stars: string;
group_type: number;
hidden: number;
id_parent: number;
}
export interface UserInfo {
id_member: number;
id_group: number;
additional_groups: string;
member_name: string;
karma_bad: number;
karma_good: number;
date_registered: number;
last_login: number;
avatar: number;
lngfile: string;
user_stats: UserStat[];
levels: Levels;
user_groups: UserGroup[];
}
export type Message = UserInfo[];
export interface Response {
success: boolean;
respCode: number;
message: Message;
}
}
}
export namespace Websocket { export namespace Websocket {
export interface Payload { export interface Payload {
activeSceneries: API.ActiveSceneries.Response; activeSceneries: API.ActiveSceneries.Response;
+4 -1
View File
@@ -60,6 +60,7 @@ export interface Train {
distance: number; distance: number;
connectedTrack: string; connectedTrack: string;
driverId: number; driverId: number;
driverLanguageId: number;
trainNo: number; trainNo: number;
driverName: string; driverName: string;
driverLevel: number; driverLevel: number;
@@ -162,6 +163,7 @@ export interface ActiveScenery {
dispatcherIsSupporter: boolean; dispatcherIsSupporter: boolean;
dispatcherStatus: Status.ActiveDispatcher | number; dispatcherStatus: Status.ActiveDispatcher | number;
dispatcherTimestamp: number | null; dispatcherTimestamp: number | null;
dispatcherLanguageId: number;
isOnline: boolean; isOnline: boolean;
stationTrains: Train[]; stationTrains: Train[];
scheduledTrains: CheckpointTrain[]; scheduledTrains: CheckpointTrain[];
@@ -218,6 +220,7 @@ export interface CheckpointTrain {
export type Vehicle = API.VehiclesData.VehicleObject; export type Vehicle = API.VehiclesData.VehicleObject;
export type VehicleGroup = API.VehiclesData.VehicleGroupObject; export type VehicleGroup = API.VehiclesData.VehicleGroupObject;
// Train Tooltip Info
export interface TooltipUserTrain { export interface TooltipUserTrain {
driverName: string; driverName: string;
trainNo: number; trainNo: number;
@@ -238,4 +241,4 @@ export interface TooltipTrainInfo {
headVehicleName: string; headVehicleName: string;
stockCount: number; stockCount: number;
trainTimetableCategory?: string; trainTimetableCategory?: string;
} }
+5
View File
@@ -0,0 +1,5 @@
export function getCountPercentage(partCount: number, allCount: number, fixedDigits: number) {
if (allCount == 0) return 0;
return ((partCount / allCount) * 100).toFixed(fixedDigits);
}
+5
View File
@@ -0,0 +1,5 @@
export const languageFlagNames = ['pl', 'en', 'de', 'cz', 'sk', 'ru', 'se', 'ua', 'it'];
export function getLanguageNameById(languageId: number) {
return languageFlagNames[languageId] ?? 'pl';
}
+19
View File
@@ -0,0 +1,19 @@
export enum ServerRegion {
'eu' = 'PL1',
'cae' = 'PL2',
'usw' = 'DE',
'us' = 'CZE',
'ru' = 'ENG'
}
export const regions: Record<string, string> = {
eu: 'PL1',
cae: 'PL2',
usw: 'DE',
us: 'CZE',
ru: 'ENG'
};
export function getRegionNameById(id: string) {
return regions[id] ?? 'PL1';
}
+1 -1
View File
@@ -47,6 +47,6 @@ const chosenTrain = computed(() =>
margin: 0 auto; margin: 0 auto;
padding: 1em 0; padding: 1em 0;
max-width: var(--max-container-width); max-width: var(--max-container-width);
min-height: calc(100vh - 7em); min-height: 100vh;
} }
</style> </style>
+19 -57
View File
@@ -14,7 +14,7 @@
optionsType="dispatchers" optionsType="dispatchers"
/> />
<JournalStats :statsButtons="statsButtons" /> <JournalStats :chosen-player-id="chosenPlayerId" />
</div> </div>
<div class="journal_refreshed-date"> <div class="journal_refreshed-date">
@@ -50,16 +50,8 @@ import JournalHeader from '../components/JournalView/JournalHeader.vue';
import JournalStats from '../components/JournalView/JournalStats.vue'; import JournalStats from '../components/JournalView/JournalStats.vue';
import { useApiStore } from '../store/apiStore'; import { useApiStore } from '../store/apiStore';
const statsButtons: Journal.StatsButton[] = [
{
tab: Journal.StatsTab.DISPATCHER_STATS,
localeKey: 'journal.dispatcher-stats.button',
iconName: 'user',
disabled: true
}
];
interface DispatchersQueryParams { interface DispatchersQueryParams {
dutyId?: number;
dispatcherName?: string; dispatcherName?: string;
stationName?: string; stationName?: string;
stationHash?: string; stationHash?: string;
@@ -105,18 +97,15 @@ export default defineComponent({
}, },
data: () => ({ data: () => ({
statsButtons,
dataRefreshedAt: null as Date | null, dataRefreshedAt: null as Date | null,
currentQueryParams: {} as DispatchersQueryParams, currentQueryParams: {} as DispatchersQueryParams,
scrollDataLoaded: true, scrollDataLoaded: true,
scrollNoMoreData: false, scrollNoMoreData: false,
showReturnButton: false, chosenPlayerId: -1,
statsCardOpen: false,
currentOptionsActive: false,
currentOptionsActive: false,
dataStatus: Status.Data.Loading, dataStatus: Status.Data.Loading,
historyList: [] as API.DispatcherHistory.Response historyList: [] as API.DispatcherHistory.Response
@@ -126,12 +115,13 @@ export default defineComponent({
const sorterActive: Journal.DispatcherSorter = reactive({ id: 'timestampFrom', dir: -1 }); const sorterActive: Journal.DispatcherSorter = reactive({ id: 'timestampFrom', dir: -1 });
const journalFilterActive = ref({}); const journalFilterActive = ref({});
const searchersValues = reactive({ const searchersValues = reactive<Record<Journal.DispatcherSearchKey, string>>({
'search-duty-id': '',
'search-dispatcher': '', 'search-dispatcher': '',
'search-station': '', 'search-station': '',
'search-date-from': '', 'search-date-from': '',
'search-date-to': '' 'search-date-to': ''
} as Journal.DispatcherSearchType); });
provide('sorterActive', sorterActive); provide('sorterActive', sorterActive);
provide('journalFilterActive', journalFilterActive); provide('journalFilterActive', journalFilterActive);
@@ -158,15 +148,6 @@ export default defineComponent({
queryParams[k as keyof DispatchersQueryParams] != queryParams[k as keyof DispatchersQueryParams] !=
defaultQueryParams[k as keyof DispatchersQueryParams] defaultQueryParams[k as keyof DispatchersQueryParams]
); );
},
'mainStore.dispatcherStatsData'(stats) {
this.statsButtons.find((sb) => sb.tab == Journal.StatsTab.DISPATCHER_STATS)!.disabled =
stats === undefined;
},
async 'mainStore.dispatcherStatsName'() {
this.fetchDispatcherStats();
} }
}, },
@@ -192,6 +173,7 @@ export default defineComponent({
handleRouteParams() { handleRouteParams() {
this.$router.push({ this.$router.push({
query: { query: {
'search-duty-id': this.searchersValues['search-duty-id'] || undefined,
'search-date-from': this.searchersValues['search-date-from'] || undefined, 'search-date-from': this.searchersValues['search-date-from'] || undefined,
'search-date-to': this.searchersValues['search-date-to'] || undefined, 'search-date-to': this.searchersValues['search-date-to'] || undefined,
'search-station': this.searchersValues['search-station'] || undefined, 'search-station': this.searchersValues['search-station'] || undefined,
@@ -215,30 +197,8 @@ export default defineComponent({
this.setOptions(query as any); this.setOptions(query as any);
}, },
async fetchDispatcherStats() { setOptions(options: Record<string, string>) {
if (!this.mainStore.dispatcherStatsName) { this.searchersValues['search-duty-id'] = options['search-duty-id'] ?? '';
this.mainStore.dispatcherStatsData = undefined;
return;
}
try {
const statsData: API.DispatcherStats.Response = await (
await this.apiStore.client!.get('api/getDispatcherStats', {
params: {
name: this.mainStore.dispatcherStatsName
}
})
).data;
this.mainStore.dispatcherStatsData = statsData;
} catch (error) {
this.mainStore.dispatcherStatsData = undefined;
console.error('Ups! Wystąpił błąd przy próbie pobrania statystyk dyżurnego! :/');
}
},
setOptions(options: { [key: string]: string }) {
this.searchersValues['search-date-from'] = options['search-date-from'] ?? ''; this.searchersValues['search-date-from'] = options['search-date-from'] ?? '';
this.searchersValues['search-date-to'] = options['search-date-to'] ?? ''; this.searchersValues['search-date-to'] = options['search-date-to'] ?? '';
this.searchersValues['search-station'] = options['search-station'] ?? ''; this.searchersValues['search-station'] = options['search-station'] ?? '';
@@ -275,6 +235,7 @@ export default defineComponent({
async fetchHistoryData() { async fetchHistoryData() {
const queryParams: DispatchersQueryParams = {}; const queryParams: DispatchersQueryParams = {};
const dutyId = this.searchersValues['search-duty-id'].trim() || undefined;
const dispatcherName = this.searchersValues['search-dispatcher'].trim() || undefined; const dispatcherName = this.searchersValues['search-dispatcher'].trim() || undefined;
const stationName = this.searchersValues['search-station'].trim() || undefined; const stationName = this.searchersValues['search-station'].trim() || undefined;
const dateFromString = this.searchersValues['search-date-from'].trim() || undefined; const dateFromString = this.searchersValues['search-date-from'].trim() || undefined;
@@ -295,6 +256,7 @@ export default defineComponent({
dateToISO = dateTo.toISOString(); dateToISO = dateTo.toISOString();
} }
queryParams['dutyId'] = Number(dutyId) || undefined;
queryParams['dispatcherName'] = dispatcherName; queryParams['dispatcherName'] = dispatcherName;
queryParams['dateFrom'] = dateFromISO; queryParams['dateFrom'] = dateFromISO;
@@ -320,24 +282,24 @@ export default defineComponent({
if (!responseData) { if (!responseData) {
this.dataStatus = Status.Data.Error; this.dataStatus = Status.Data.Error;
this.chosenPlayerId = -1;
return; return;
} }
if (!responseData) return;
// Response data exists // Response data exists
this.historyList = responseData; this.historyList = responseData;
// Stats display this.chosenPlayerId =
this.mainStore.dispatcherStatsName = this.historyList.length > 0 && this.searchersValues['search-dispatcher'].trim() != ''
this.historyList.length > 0 && this.searchersValues['search-dispatcher'].trim() ? this.historyList[0].dispatcherId
? this.historyList[0].dispatcherName : -1;
: '';
this.dataRefreshedAt = new Date(); this.dataRefreshedAt = new Date();
this.dataStatus = Status.Data.Loaded; this.dataStatus = Status.Data.Loaded;
} catch (error) { } catch (error) {
this.dataStatus = Status.Data.Error; this.dataStatus = Status.Data.Error;
this.chosenPlayerId = -1;
} }
this.scrollNoMoreData = false; this.scrollNoMoreData = false;
+36 -95
View File
@@ -14,7 +14,7 @@
optionsType="timetables" optionsType="timetables"
/> />
<JournalStats :statsButtons="statsButtons" /> <JournalStats :chosen-player-id="chosenPlayerId" />
</div> </div>
<div class="journal_refreshed-date"> <div class="journal_refreshed-date">
@@ -29,6 +29,8 @@
:dataStatus="dataStatus" :dataStatus="dataStatus"
:scrollDataLoaded="scrollDataLoaded" :scrollDataLoaded="scrollDataLoaded"
:scrollNoMoreData="scrollNoMoreData" :scrollNoMoreData="scrollNoMoreData"
:extraInfoIndexes="extraInfoIndexes"
@toggleExtraInfo="toggleExtraInfo"
/> />
</div> </div>
</div> </div>
@@ -118,36 +120,6 @@ export const journalTimetableFilters: Journal.TimetableFilter[] = [
} }
]; ];
interface TimetablesQueryParams {
driverName?: string;
trainNo?: string;
timetableId?: string;
categoryCode?: string;
authorName?: string;
dateFrom?: string;
dateTo?: string;
issuedFrom?: string;
terminatingAt?: string;
via?: string;
includesScenery?: string;
countFrom?: number;
countLimit?: number;
fulfilled?: number;
terminated?: number;
twr?: number;
skr?: number;
pn?: number;
tn?: number;
sortBy?: Journal.TimetableSorter['id'];
}
export default defineComponent({ export default defineComponent({
components: { components: {
JournalOptions, JournalOptions,
@@ -170,35 +142,18 @@ export default defineComponent({
mainStore: useMainStore(), mainStore: useMainStore(),
apiStore: useApiStore(), apiStore: useApiStore(),
statsButtons: [ currentQueryParams: {} as API.TimetableHistory.QueryParams,
{
tab: Journal.StatsTab.DAILY_STATS,
localeKey: 'journal.daily-stats.button',
iconName: 'stats',
disabled: false
},
{
tab: Journal.StatsTab.DRIVER_STATS,
localeKey: 'journal.driver-stats.button',
iconName: 'train',
disabled: true
}
],
currentQueryParams: {} as TimetablesQueryParams,
dataRefreshedAt: null as Date | null, dataRefreshedAt: null as Date | null,
scrollDataLoaded: true, scrollDataLoaded: true,
scrollNoMoreData: false, scrollNoMoreData: false,
extraInfoIndexes: [] as number[],
showReturnButton: false, chosenPlayerId: -1,
statsCardOpen: false,
currentOptionsActive: false,
timetableHistory: [] as API.TimetableHistory.Response, timetableHistory: [] as API.TimetableHistory.ResponseShort,
dataStatus: Status.Data.Loading, dataStatus: Status.Data.Loading
dataErrorMessage: ''
}), }),
setup() { setup() {
@@ -245,18 +200,11 @@ export default defineComponent({
}; };
}, },
watch: { computed: {
currentQueryParams(q: TimetablesQueryParams) { currentOptionsActive() {
this.currentOptionsActive = Object.values(q).some((v) => v !== undefined); return Object.keys(this.currentQueryParams)
}, .filter((k) => k != 'countFrom' && k != 'returnType')
.some((k) => (this.currentQueryParams as any)[k] !== undefined);
'mainStore.driverStatsData'(driverStats) {
this.statsButtons.find((sb) => sb.tab == Journal.StatsTab.DRIVER_STATS)!.disabled =
driverStats === undefined;
},
async 'mainStore.driverStatsName'() {
this.fetchDriverStats();
} }
}, },
@@ -287,28 +235,21 @@ export default defineComponent({
this.setOptions(query as any); this.setOptions(query as any);
}, },
async fetchDriverStats() { async toggleExtraInfo(timetableDetails: API.TimetableHistory.Data | null) {
if (!this.mainStore.driverStatsName) { if (!timetableDetails) return;
this.mainStore.driverStatsData = undefined;
this.mainStore.driverStatsStatus = Status.Data.Initialized;
return;
}
try { const existingIdx = this.extraInfoIndexes.indexOf(timetableDetails.id);
this.mainStore.driverStatsStatus = Status.Data.Loading;
const statsData: API.DriverStats.Response = await ( if (existingIdx == -1) {
await this.apiStore.client!.get( this.extraInfoIndexes.push(timetableDetails.id);
`api/getDriverInfo?name=${this.mainStore.driverStatsName}`
)
).data;
this.mainStore.driverStatsData = statsData; const synchedTimetable = this.timetableHistory.find((t) => t.id == timetableDetails.id);
this.mainStore.driverStatsStatus = Status.Data.Loaded;
} catch (error) { if (synchedTimetable) {
this.mainStore.driverStatsData = undefined; Object.assign(synchedTimetable, timetableDetails);
this.mainStore.driverStatsStatus = Status.Data.Error; }
console.error('Ups! Wystąpił błąd przy próbie pobrania statystyk maszynisty! :/'); } else {
this.extraInfoIndexes.splice(existingIdx, 1);
} }
}, },
@@ -354,6 +295,8 @@ export default defineComponent({
}, },
async fetchHistoryData() { async fetchHistoryData() {
this.extraInfoIndexes.length = 0;
const driverName = this.searchersValues['search-driver'].trim() || undefined; const driverName = this.searchersValues['search-driver'].trim() || undefined;
const trainNo = this.searchersValues['search-train'].trim() || undefined; const trainNo = this.searchersValues['search-train'].trim() || undefined;
const authorName = this.searchersValues['search-dispatcher'].trim() || undefined; const authorName = this.searchersValues['search-dispatcher'].trim() || undefined;
@@ -378,7 +321,7 @@ export default defineComponent({
dateToISO = dateTo.toISOString(); dateToISO = dateTo.toISOString();
} }
const queryParams: TimetablesQueryParams = {}; const queryParams: API.TimetableHistory.QueryParams = {};
this.filterList this.filterList
.filter((f) => f.isActive) .filter((f) => f.isActive)
@@ -445,6 +388,7 @@ export default defineComponent({
queryParams['terminatingAt'] = terminatingAt; queryParams['terminatingAt'] = terminatingAt;
queryParams['via'] = via; queryParams['via'] = via;
queryParams['categoryCode'] = categoryCode; queryParams['categoryCode'] = categoryCode;
queryParams['returnType'] = 'short';
queryParams['issuedFrom'] = issuedFrom; queryParams['issuedFrom'] = issuedFrom;
queryParams['sortBy'] = queryParams['sortBy'] =
@@ -456,7 +400,7 @@ export default defineComponent({
this.currentQueryParams = queryParams; this.currentQueryParams = queryParams;
try { try {
const responseData: API.TimetableHistory.Response = await ( const responseData: API.TimetableHistory.ResponseShort = await (
await this.apiStore.client!.get('api/getTimetables', { await this.apiStore.client!.get('api/getTimetables', {
params: this.currentQueryParams params: this.currentQueryParams
}) })
@@ -464,26 +408,23 @@ export default defineComponent({
if (!responseData) { if (!responseData) {
this.dataStatus = Status.Data.Error; this.dataStatus = Status.Data.Error;
this.dataErrorMessage = 'Brak danych!'; this.chosenPlayerId = -1;
return; return;
} }
if (!responseData) return;
// Response data exists // Response data exists
this.timetableHistory = responseData; this.timetableHistory = responseData;
// Stats display this.chosenPlayerId =
this.mainStore.driverStatsName = this.timetableHistory.length > 0 && this.searchersValues['search-driver'].trim() != ''
this.timetableHistory.length > 0 && this.searchersValues['search-driver'].trim() ? this.timetableHistory[0].driverId
? this.timetableHistory[0].driverName : -1;
: '';
this.dataStatus = Status.Data.Loaded; this.dataStatus = Status.Data.Loaded;
this.dataRefreshedAt = new Date(); this.dataRefreshedAt = new Date();
} catch (error) { } catch (error) {
this.dataStatus = Status.Data.Error; this.dataStatus = Status.Data.Error;
this.dataErrorMessage = 'Ups! Coś poszło nie tak!'; this.chosenPlayerId = -1;
} }
this.scrollNoMoreData = false; this.scrollNoMoreData = false;
+221
View File
@@ -0,0 +1,221 @@
<template>
<div class="profile-view">
<div class="profile-wrapper" v-if="playerInfo && playerInfoStatus == Status.Data.Loaded">
<ProfileSummary
:playerInfo="playerInfo"
:playerTD2Info="playerTD2Info"
:playerName="playerName"
/>
<div class="profile-side">
<ProfileRecentStats :playerInfo="playerInfo" />
<ProfileHistoryList
:playerName="playerName"
:playerJournal="playerJournal"
:journalStatus="playerJournalStatus"
/>
</div>
</div>
<Loading v-else-if="playerInfoStatus == Status.Data.Loading" />
<div class="no-data-found" v-else>
<div>
<h3>{{ t('profile.no-player-found') }}</h3>
<router-link to="/" class="btn btn--text"> {{ t('profile.return-to-main') }}</router-link>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onActivated, onDeactivated, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useApiStore } from '../store/apiStore';
import { API, Td2API } from '../typings/api';
import { useI18n } from 'vue-i18n';
import { Status } from '../typings/common';
import Loading from '../components/Global/Loading.vue';
import ProfileSummary from '../components/PlayerProfileView/ProfileSummary.vue';
import ProfileRecentStats from '../components/PlayerProfileView/ProfileRecentStats.vue';
import ProfileHistoryList from '../components/PlayerProfileView/ProfileHistoryList.vue';
import axios from 'axios';
const { t } = useI18n();
const router = useRouter();
const apiStore = useApiStore();
const route = useRoute();
const playerId = ref(-1);
const playerName = ref('');
const playerInfo = ref<API.PlayerInfo.Data | undefined>(undefined);
const playerTD2Info = ref<Td2API.UsersInfoByName.UserInfo | undefined>(undefined);
const playerJournal = ref<API.PlayerJournal.Data | undefined>(undefined);
const playerInfoStatus = ref(Status.Data.Initialized);
const playerJournalStatus = ref(Status.Data.Initialized);
const intervalId = ref(-1);
onActivated(() => {
fetchPlayerData();
intervalId.value = setInterval(fetchPlayerData, 32000);
});
onDeactivated(() => {
clearInterval(intervalId.value);
intervalId.value = -1;
});
async function fetchPlayerInfo(playerId: number) {
return apiStore.client!.get<API.PlayerInfo.Data>('api/getPlayerInfo', {
params: {
playerId
}
});
}
async function fetchPlayerJournal(playerId: number) {
return apiStore.client!.get<API.PlayerJournal.Data>('api/getPlayerJournal', {
params: {
playerId,
dateScope: '30d'
}
});
}
async function fetchPlayerTd2Info(playerName: string) {
return axios.get<Td2API.UsersInfoByName.Response>('https://api.td2.info.pl', {
params: {
method: 'getUsersInfoByName',
name: playerName
}
});
}
async function fetchPlayerData() {
const queryPlayerId = Number(route.query.playerId) || -1;
if (!apiStore.client || !queryPlayerId) return;
if (queryPlayerId != playerId.value) {
playerInfoStatus.value = Status.Data.Loading;
playerJournalStatus.value = Status.Data.Loading;
playerInfo.value = undefined;
playerTD2Info.value = undefined;
playerJournal.value = undefined;
}
playerId.value = queryPlayerId;
try {
const playerInfoResp = await fetchPlayerInfo(playerId.value);
playerName.value =
playerInfoResp.data.driverStats.driverName ||
playerInfoResp.data.dispatcherStats.dispatcherName ||
'';
if (!playerName.value) {
router.push('/');
return;
}
playerInfo.value = playerName.value ? playerInfoResp.data : undefined;
playerInfoStatus.value = Status.Data.Loaded;
if (playerName.value) {
const playerTD2InfoResp = await fetchPlayerTd2Info(playerName.value);
if (playerTD2InfoResp.data.success && playerTD2InfoResp.data.message.length == 1) {
playerTD2Info.value = playerTD2InfoResp.data.message[0];
}
}
} catch (error) {
playerInfo.value = undefined;
playerTD2Info.value = undefined;
playerInfoStatus.value = Status.Data.Error;
}
try {
const playerJournalResp = await fetchPlayerJournal(playerId.value);
playerJournal.value = playerJournalResp.data;
playerJournalStatus.value = Status.Data.Loaded;
} catch (error) {
playerJournal.value = undefined;
playerJournalStatus.value = Status.Data.Error;
}
}
</script>
<style lang="scss" scoped>
@use '../styles/responsive';
.profile-view {
display: flex;
justify-content: center;
height: 100vh;
min-height: 500px;
max-height: 2000px;
}
.no-data-found {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 1.35em;
max-width: var(--max-container-width);
width: 100%;
background-color: var(--clr-tile);
padding: 1em;
margin: 1em;
a {
display: inline-block;
text-decoration: underline;
margin-top: 0.5em;
}
}
.profile-wrapper {
display: grid;
grid-template-columns: 500px 1fr;
gap: 1em;
position: relative;
max-width: var(--max-container-width);
width: 100%;
padding: 1rem 0;
text-align: center;
}
.profile-side {
display: grid;
grid-template-rows: auto 1fr;
overflow: auto;
background-color: var(--clr-tile);
border-radius: 0.5em;
}
@include responsive.midScreen {
.profile-view {
height: auto;
}
.profile-wrapper {
grid-template-columns: 1fr;
max-width: 1000px;
}
}
</style>
+8 -4
View File
@@ -135,6 +135,10 @@ function setViewMode(componentName: string) {
&-view { &-view {
display: flex; display: flex;
justify-content: center; justify-content: center;
height: 100vh;
min-height: 500px;
max-height: 2000px;
} }
&-offline { &-offline {
@@ -181,10 +185,6 @@ function setViewMode(componentName: string) {
background-color: #181818; background-color: #181818;
border-radius: 0.5em; border-radius: 0.5em;
padding: 1em 0.5em; padding: 1em 0.5em;
height: calc(100vh - 0.5em);
min-height: 500px;
max-height: 2000px;
} }
.scenery-left { .scenery-left {
@@ -236,6 +236,10 @@ function setViewMode(componentName: string) {
} }
@include responsive.midScreen { @include responsive.midScreen {
.scenery-view {
height: auto;
}
.scenery-wrapper { .scenery-wrapper {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0; gap: 0;
+17 -7
View File
@@ -29,12 +29,19 @@
data-tooltip-type="HtmlTooltip" data-tooltip-type="HtmlTooltip"
:data-tooltip-content="`<b>${$t('app.language-tooltip-content')}</b>`" :data-tooltip-content="`<b>${$t('app.language-tooltip-content')}</b>`"
> >
<img <FlagIcon :language-id="mainStore.currentLocale == 'pl' ? 0 : 1" />
:src="`/images/icon-${mainStore.currentLocale}.svg`"
alt="change language flag icon"
/>
</button> </button>
<a
class="a-button btn--image discord-link"
href="https://discord.gg/x2mpNN3svk"
target="_blank"
data-tooltip-type="HtmlTooltip"
:data-tooltip-content="`<b>${$t('app.discord-link-content')}</b>`"
>
<img src="/images/icon-discord.png" alt="discord logo icon" />
</a>
<a <a
class="a-button btn--image gnr-link" class="a-button btn--image gnr-link"
href="https://generator-td2.web.app/" href="https://generator-td2.web.app/"
@@ -85,6 +92,7 @@ import { reactive } from 'vue';
import { provide } from 'vue'; import { provide } from 'vue';
import { ActiveSorter } from '../components/StationsView/typings'; import { ActiveSorter } from '../components/StationsView/typings';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import FlagIcon from '../components/Global/FlagIcon.vue';
const filterInitStates = { ...initFilters }; const filterInitStates = { ...initFilters };
@@ -93,7 +101,8 @@ export default defineComponent({
StationTable, StationTable,
StationFilterCard, StationFilterCard,
StationStats, StationStats,
DonationCard DonationCard,
FlagIcon
}, },
data: () => ({ data: () => ({
@@ -204,11 +213,12 @@ a.pojazdownik-link {
} }
} }
a.gnr-link { a.gnr-link,
a.discord-link {
background-color: #141414; background-color: #141414;
&:hover { &:hover {
background-color: #222222; background-color: #333;
} }
} }
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineConfig({
publicDir: 'public', publicDir: 'public',
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { additionalData: `@use '@/styles/global';`, silenceDeprecations: ['legacy-js-api'] } scss: { silenceDeprecations: ['legacy-js-api'] }
} }
}, },
resolve: { resolve: {