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(); +} 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());