From 2bbe9b77747d401f466ab9c4fb8300c7ab08d328 Mon Sep 17 00:00:00 2001
From: Pascal Syma
Date: Mon, 24 Nov 2025 17:37:04 +0100
Subject: [PATCH] Add export/import
---
bun.lock | 3 ++
package.json | 3 +-
src/data/specials.ts | 14 ++++++
src/pages/StatsView.vue | 94 ++++++++++++++++++++++++++++++++++++++++-
src/stores/tram.ts | 50 ++++++++++++++++++----
src/utils/zod.ts | 14 ++++++
6 files changed, 168 insertions(+), 10 deletions(-)
create mode 100644 src/utils/zod.ts
diff --git a/bun.lock b/bun.lock
index 563cdd3..85357c3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -15,6 +15,7 @@
"vue": "^3.5.18",
"vue-router": "^4.5.1",
"vuestic-ui": "^1.10.3",
+ "zod": "^4.1.13",
},
"devDependencies": {
"@tsconfig/node22": "^22.0.2",
@@ -1627,6 +1628,8 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+ "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
+
"@apideck/better-ajv-errors/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
diff --git a/package.json b/package.json
index bbdf903..8f5f853 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,8 @@
"tailwind": "^4.0.0",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
- "vuestic-ui": "^1.10.3"
+ "vuestic-ui": "^1.10.3",
+ "zod": "^4.1.13"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.2",
diff --git a/src/data/specials.ts b/src/data/specials.ts
index c552783..516f40d 100644
--- a/src/data/specials.ts
+++ b/src/data/specials.ts
@@ -1,3 +1,5 @@
+import { z } from 'zod';
+
export type SpecialCar = [string, string, number] | [string, string, number, string];
export const specialCars = {
crash: [
@@ -30,3 +32,15 @@ export const specialCars = {
} as const;
export const allSpecials = [...specialCars.crash, ...specialCars.work, ...specialCars.help];
+
+export const specialsSchema = z.object({
+ bsag: z
+ .string()
+ .trim()
+ .refine((id) =>
+ allSpecials.some((a) => a[0] === id, {
+ error: (v: z.core.$ZodIssue) => `Unknown id '${v.input}'!`
+ })
+ )
+ .array()
+});
diff --git a/src/pages/StatsView.vue b/src/pages/StatsView.vue
index bfbd3ab..35473bc 100644
--- a/src/pages/StatsView.vue
+++ b/src/pages/StatsView.vue
@@ -5,9 +5,82 @@ import { specialCars } from '@/data/specials.ts';
import { type, useTramStore } from '@/stores/tram.ts';
import { groupBy } from '@/utils/array.ts';
import { idsOf } from '@/utils/typeGroup.ts';
-import { VaCard, VaCardContent, VaCardTitle } from 'vuestic-ui';
+import {
+ useModal,
+ useToast,
+ VaButton,
+ VaCard,
+ VaCardContent,
+ VaCardTitle,
+ type VaFile,
+ VaFileUpload
+} from 'vuestic-ui';
+import { z } from 'zod';
+const { init } = useToast();
+const { confirm } = useModal();
const tramStore = useTramStore();
+
+function download() {
+ const fileType = 'application/json';
+ const blob = new Blob([tramStore.exportData()], { type: fileType });
+
+ const a = document.createElement('a');
+ a.download = `tramtrack-export-${Date.now()}.json`;
+ a.href = URL.createObjectURL(blob);
+ a.dataset.downloadurl = [fileType, a.download, a.href].join(':');
+ a.style.display = 'none';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(() => URL.revokeObjectURL(a.href), 1500);
+
+ init({
+ title: 'Export erfolgreich',
+ message: 'Datenbestand wurde erfolgreich exportiert!',
+ color: 'success'
+ });
+}
+
+async function upload(file: VaFile) {
+ const data = (await file?.text?.()?.catch(() => '')) || '';
+
+ const error = tramStore.importData(data);
+ if (error) {
+ console.error(error);
+ console.warn(z.prettifyError(error));
+ init({
+ title: 'Import fehlgeschlagen',
+ message: `Die hochgeladene Datei ist im falschen Format!`,
+ color: 'danger'
+ });
+ return;
+ }
+ init({
+ title: 'Import erfolgreich',
+ message: 'Datenbestand wurde erfolgreich importiert!',
+ color: 'success'
+ });
+}
+
+async function reset() {
+ const yes = await confirm({
+ title: 'Bist du dir sicher?',
+ message:
+ 'Willst du wirklich deinen Fortschritt zurücksetzen? Dies ist nicht rückgängig zu machen!',
+ okText: 'Ja zurücksetzen!',
+ cancelText: 'Abbrechen',
+ 'child:okButton': {
+ color: 'danger'
+ }
+ });
+
+ if (!yes) {
+ return;
+ }
+
+ tramStore.$reset();
+}
@@ -79,6 +152,25 @@ const tramStore = useTramStore();
+
+ Export/Import
+
+
+ Export
+ Import
+ Reset
+
+
+
diff --git a/src/stores/tram.ts b/src/stores/tram.ts
index 9029647..5ec2801 100644
--- a/src/stores/tram.ts
+++ b/src/stores/tram.ts
@@ -1,6 +1,8 @@
-import { allSpecials } from '@/data/specials.ts';
+import { allSpecials, specialsSchema } from '@/data/specials.ts';
+import { stringToJsonSchema } from '@/utils/zod.ts';
import { defineStore } from 'pinia';
import { computed, reactive } from 'vue';
+import { z } from 'zod';
export type CarType = [number, number, boolean, string];
export const type: CarType[] = [
@@ -49,18 +51,23 @@ export function getType(id: number | string): null | { id: number; isTram: boole
type: foundType[3]
};
}
+export const tramsSchema = z.object({
+ bsag: z
+ .number()
+ .int()
+ .refine((id) => getType(id) !== null, {
+ error: (v) => `Unknown id '${v.input}'!`
+ })
+ .array()
+});
export const useTramStore = defineStore(
'tram',
() => {
- const trams = reactive<{
- bsag: number[];
- }>({
+ const trams = reactive>({
bsag: []
});
- const specials = reactive<{
- bsag: string[];
- }>({
+ const specials = reactive>({
bsag: []
});
@@ -93,9 +100,36 @@ export const useTramStore = defineStore(
};
});
- return { trams, addNumber, shortStats, specials };
+ function exportData(): string {
+ return JSON.stringify({ trams, specials });
+ }
+
+ function importData(data: string | null | undefined) {
+ const result = importSchema.safeParse(data);
+
+ if (!result.success) {
+ return result.error;
+ }
+
+ trams.bsag = result.data.trams.bsag;
+ specials.bsag = result.data.specials.bsag;
+ }
+
+ function $reset() {
+ trams.bsag = [];
+ specials.bsag = [];
+ }
+
+ return { trams, addNumber, shortStats, specials, exportData, importData, $reset };
},
{
persist: true
}
);
+
+export const importSchema = stringToJsonSchema.pipe(
+ z.object({
+ trams: tramsSchema,
+ specials: specialsSchema
+ })
+);
diff --git a/src/utils/zod.ts b/src/utils/zod.ts
new file mode 100644
index 0000000..0c7bf4b
--- /dev/null
+++ b/src/utils/zod.ts
@@ -0,0 +1,14 @@
+import { z } from 'zod';
+
+export const stringToJsonSchema = z
+ .string()
+ .transform((str, ctx) => {
+ try {
+ return JSON.parse(str);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ } catch (_) {
+ ctx.addIssue({ code: 'invalid_type', message: 'Invalid JSON', expected: 'string' });
+ return z.NEVER;
+ }
+ })
+ .pipe(z.json());