From 7396952d82e99ee58a43c3a67c845e1a201d7c91 Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Fri, 24 Jul 2026 18:55:01 +0530 Subject: [PATCH 01/23] Formatting and added sidebar element --- docs/openwiki/architecture/overview.md | 4 +- docs/openwiki/architecture/routing-and-api.md | 4 +- docs/openwiki/domain/data-models.md | 4 +- docs/openwiki/index.md | 2 +- docs/openwiki/operations/runbook.md | 4 +- docs/openwiki/quickstart.md | 4 +- docs/openwiki/testing.md | 4 +- docs/openwiki/workflows/authentication.md | 4 +- docs/openwiki/workflows/feature-toggles.md | 4 +- i18n/project.inlang/settings.json | 2 +- package.json | 6 +- pnpm-lock.yaml | 164 +++++++++--------- pnpm-workspace.yaml | 7 +- src/lib/components/ui/button/button.svelte | 33 ++-- src/lib/components/ui/input/input.svelte | 8 +- .../components/ui/separator/separator.svelte | 4 +- .../components/ui/sheet/sheet-content.svelte | 47 +++-- .../components/ui/sheet/sheet-header.svelte | 2 +- .../components/ui/sheet/sheet-overlay.svelte | 5 +- .../components/ui/sheet/sheet-title.svelte | 2 +- src/lib/components/ui/sidebar/constants.ts | 6 + .../components/ui/sidebar/context.svelte.ts | 79 +++++++++ src/lib/components/ui/sidebar/index.ts | 75 ++++++++ .../ui/sidebar/sidebar-content.svelte | 24 +++ .../ui/sidebar/sidebar-footer.svelte | 21 +++ .../ui/sidebar/sidebar-group-action.svelte | 33 ++++ .../ui/sidebar/sidebar-group-content.svelte | 21 +++ .../ui/sidebar/sidebar-group-label.svelte | 33 ++++ .../ui/sidebar/sidebar-group.svelte | 21 +++ .../ui/sidebar/sidebar-header.svelte | 21 +++ .../ui/sidebar/sidebar-input.svelte | 21 +++ .../ui/sidebar/sidebar-inset.svelte | 23 +++ .../ui/sidebar/sidebar-menu-action.svelte | 37 ++++ .../ui/sidebar/sidebar-menu-badge.svelte | 24 +++ .../ui/sidebar/sidebar-menu-button.svelte | 101 +++++++++++ .../ui/sidebar/sidebar-menu-item.svelte | 21 +++ .../ui/sidebar/sidebar-menu-skeleton.svelte | 36 ++++ .../ui/sidebar/sidebar-menu-sub-button.svelte | 39 +++++ .../ui/sidebar/sidebar-menu-sub-item.svelte | 21 +++ .../ui/sidebar/sidebar-menu-sub.svelte | 24 +++ .../components/ui/sidebar/sidebar-menu.svelte | 21 +++ .../ui/sidebar/sidebar-provider.svelte | 53 ++++++ .../components/ui/sidebar/sidebar-rail.svelte | 36 ++++ .../ui/sidebar/sidebar-separator.svelte | 19 ++ .../ui/sidebar/sidebar-trigger.svelte | 36 ++++ src/lib/components/ui/sidebar/sidebar.svelte | 105 +++++++++++ src/lib/components/ui/skeleton/index.ts | 7 + .../components/ui/skeleton/skeleton.svelte | 15 +- src/lib/components/ui/tooltip/index.ts | 19 ++ .../ui/tooltip/tooltip-content.svelte | 52 ++++++ .../ui/tooltip/tooltip-portal.svelte | 7 + .../ui/tooltip/tooltip-provider.svelte | 7 + .../ui/tooltip/tooltip-trigger.svelte | 7 + src/lib/components/ui/tooltip/tooltip.svelte | 7 + src/lib/composables/sheet-form.svelte.ts | 24 ++- src/lib/hooks/is-mobile.svelte.ts | 9 + src/server/db/init.ts | 5 +- 57 files changed, 1249 insertions(+), 175 deletions(-) create mode 100644 src/lib/components/ui/sidebar/constants.ts create mode 100644 src/lib/components/ui/sidebar/context.svelte.ts create mode 100644 src/lib/components/ui/sidebar/index.ts create mode 100644 src/lib/components/ui/sidebar/sidebar-content.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-footer.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group-action.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group-content.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group-label.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-header.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-input.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-inset.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-action.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-badge.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-button.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-item.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-sub.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-provider.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-rail.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-separator.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-trigger.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar.svelte create mode 100644 src/lib/components/ui/skeleton/index.ts create mode 100644 src/lib/components/ui/tooltip/index.ts create mode 100644 src/lib/components/ui/tooltip/tooltip-content.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip-portal.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip-provider.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip-trigger.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip.svelte create mode 100644 src/lib/hooks/is-mobile.svelte.ts diff --git a/docs/openwiki/architecture/overview.md b/docs/openwiki/architecture/overview.md index 27f2c1da..3d20ce39 100644 --- a/docs/openwiki/architecture/overview.md +++ b/docs/openwiki/architecture/overview.md @@ -1,6 +1,6 @@ --- -type: "Reference" -title: "Architecture Overview" +type: 'Reference' +title: 'Architecture Overview' openwiki_generated: true --- diff --git a/docs/openwiki/architecture/routing-and-api.md b/docs/openwiki/architecture/routing-and-api.md index 122422f0..56547a71 100644 --- a/docs/openwiki/architecture/routing-and-api.md +++ b/docs/openwiki/architecture/routing-and-api.md @@ -1,6 +1,6 @@ --- -type: "Reference" -title: "Routing and API Surface" +type: 'Reference' +title: 'Routing and API Surface' openwiki_generated: true --- diff --git a/docs/openwiki/domain/data-models.md b/docs/openwiki/domain/data-models.md index 4b90133f..52fb3cdf 100644 --- a/docs/openwiki/domain/data-models.md +++ b/docs/openwiki/domain/data-models.md @@ -1,6 +1,6 @@ --- -type: "Reference" -title: "Domain and Data Models" +type: 'Reference' +title: 'Domain and Data Models' openwiki_generated: true --- diff --git a/docs/openwiki/index.md b/docs/openwiki/index.md index c3e4fb36..17db6c7d 100644 --- a/docs/openwiki/index.md +++ b/docs/openwiki/index.md @@ -1,5 +1,5 @@ --- -okf_version: "0.1" +okf_version: '0.1' --- # Files diff --git a/docs/openwiki/operations/runbook.md b/docs/openwiki/operations/runbook.md index 486be341..de387b09 100644 --- a/docs/openwiki/operations/runbook.md +++ b/docs/openwiki/operations/runbook.md @@ -1,6 +1,6 @@ --- -type: "Reference" -title: "Operations Runbook" +type: 'Reference' +title: 'Operations Runbook' openwiki_generated: true --- diff --git a/docs/openwiki/quickstart.md b/docs/openwiki/quickstart.md index 2103187a..f74774f6 100644 --- a/docs/openwiki/quickstart.md +++ b/docs/openwiki/quickstart.md @@ -1,6 +1,6 @@ --- -type: "Reference" -title: "Tracktor — OpenWiki Quickstart" +type: 'Reference' +title: 'Tracktor — OpenWiki Quickstart' openwiki_generated: true --- diff --git a/docs/openwiki/testing.md b/docs/openwiki/testing.md index 4d88bd27..5f93be22 100644 --- a/docs/openwiki/testing.md +++ b/docs/openwiki/testing.md @@ -1,6 +1,6 @@ --- -type: "Reference" -title: "Testing Guidance" +type: 'Reference' +title: 'Testing Guidance' openwiki_generated: true --- diff --git a/docs/openwiki/workflows/authentication.md b/docs/openwiki/workflows/authentication.md index 3c01f9c8..5f122950 100644 --- a/docs/openwiki/workflows/authentication.md +++ b/docs/openwiki/workflows/authentication.md @@ -1,6 +1,6 @@ --- -type: "Reference" -title: "Authentication Workflow" +type: 'Reference' +title: 'Authentication Workflow' openwiki_generated: true --- diff --git a/docs/openwiki/workflows/feature-toggles.md b/docs/openwiki/workflows/feature-toggles.md index d0161596..d50e62ad 100644 --- a/docs/openwiki/workflows/feature-toggles.md +++ b/docs/openwiki/workflows/feature-toggles.md @@ -1,6 +1,6 @@ --- -type: "Reference" -title: "Feature Toggles Workflow" +type: 'Reference' +title: 'Feature Toggles Workflow' openwiki_generated: true --- diff --git a/i18n/project.inlang/settings.json b/i18n/project.inlang/settings.json index 073a3605..aed47185 100644 --- a/i18n/project.inlang/settings.json +++ b/i18n/project.inlang/settings.json @@ -8,5 +8,5 @@ "pathPattern": "./messages/{locale}.json" }, "baseLocale": "en", - "locales": ["en", "ar", "hi", "es", "fr", "de", "it", "hu", "fi","ro"] + "locales": ["en", "ar", "hi", "es", "fr", "de", "it", "hu", "fi", "ro"] } diff --git a/package.json b/package.json index 5f9893fa..8a703abb 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@eslint/js": "^10.0.1", "@inlang/paraglide-js": "^2.22.0", "@internationalized/date": "^3.12.2", - "@lucide/svelte": "^1.25.0", + "@lucide/svelte": "^1.26.0", "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/kit": "^2.70.1", "@sveltejs/vite-plugin-svelte": "^7.2.0", @@ -41,7 +41,6 @@ "@types/supertest": "^7.2.1", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", - "@typescript/native": "npm:typescript@^7.0.2", "@vitest/coverage-v8": "^4.1.10", "bits-ui": "^2.18.1", "clsx": "^2.1.1", @@ -66,6 +65,7 @@ "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", "svelte": "^5.56.7", + "@typescript/native": "npm:typescript@^7.0.2", "svelte-awesome-color-picker": "^4.1.3", "svelte-check": "^4.7.3", "svelte-eslint-parser": "^1.8.0", @@ -76,7 +76,7 @@ "tailwindcss": "^4.3.3", "tsx": "^4.23.1", "tw-animate-css": "^1.4.0", - "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript": "npm:typescript@^6.0.3", "vite": "^8.1.5", "vitest": "^4.1.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c251b60..6f21def8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,19 +83,19 @@ importers: version: 10.0.1(eslint@10.7.0(jiti@2.7.0)) '@inlang/paraglide-js': specifier: ^2.22.0 - version: 2.22.0(@typescript/typescript6@6.0.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + version: 2.22.0(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) '@internationalized/date': specifier: ^3.12.2 version: 3.12.2 '@lucide/svelte': - specifier: ^1.25.0 - version: 1.25.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + specifier: ^1.26.0 + version: 1.26.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) '@sveltejs/adapter-node': specifier: ^5.5.7 - version: 5.5.7(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))) + version: 5.5.7(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))) '@sveltejs/kit': specifier: ^2.70.1 - version: 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + version: 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) '@sveltejs/vite-plugin-svelte': specifier: ^7.2.0 version: 7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) @@ -131,10 +131,10 @@ importers: version: 7.2.1 '@typescript-eslint/eslint-plugin': specifier: ^8.65.0 - version: 8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/parser': specifier: ^8.65.0 - version: 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) + version: 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 @@ -143,7 +143,7 @@ importers: version: 4.1.10(vitest@4.1.10) bits-ui: specifier: ^2.18.1 - version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -185,10 +185,10 @@ importers: version: 3.22.0(eslint@10.7.0(jiti@2.7.0))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)))(eslint@10.7.0(jiti@2.7.0)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)) formsnap: specifier: ^2.0.1 - version: 2.0.1(svelte@5.56.7(@typescript-eslint/types@8.65.0))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))) + version: 2.0.1(svelte@5.56.7(@typescript-eslint/types@8.65.0))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)) globals: specifier: ^17.7.0 version: 17.7.0 @@ -197,7 +197,7 @@ importers: version: 29.1.1(@noble/hashes@1.8.0) layerchart: specifier: 2.0.2 - version: 2.0.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3) + version: 2.0.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3) prettier: specifier: ^3.9.6 version: 3.9.6 @@ -215,7 +215,7 @@ importers: version: 4.1.3(svelte@5.56.7(@typescript-eslint/types@8.65.0)) svelte-check: specifier: ^4.7.3 - version: 4.7.3(@typescript/typescript6@6.0.2)(picomatch@4.0.4)(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + version: 4.7.3(picomatch@4.0.4)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3) svelte-eslint-parser: specifier: ^1.8.0 version: 1.8.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) @@ -224,7 +224,7 @@ importers: version: 1.1.1(svelte@5.56.7(@typescript-eslint/types@8.65.0)) sveltekit-superforms: specifier: ^2.30.2 - version: 2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + version: 2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -241,8 +241,8 @@ importers: specifier: ^1.4.0 version: 1.4.0 typescript: - specifier: npm:@typescript/typescript6@^6.0.2 - version: '@typescript/typescript6@6.0.2' + specifier: npm:typescript@^6.0.3 + version: 6.0.3 vite: specifier: ^8.1.5 version: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1) @@ -1019,8 +1019,8 @@ packages: '@lix-js/server-protocol-schema@0.1.1': resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} - '@lucide/svelte@1.25.0': - resolution: {integrity: sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==} + '@lucide/svelte@1.26.0': + resolution: {integrity: sha512-KUfLTlNRVrHZ460Um7jdrvh46hI/8Sw4+1k/Ttj4g6JI9bCrjD7HZVG4cqhh7LasjfDz5b+CuKrjd/k/3obYcA==} peerDependencies: svelte: ^5 @@ -1854,10 +1854,6 @@ packages: cpu: [x64] os: [win32] - '@typescript/typescript6@6.0.2': - resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} - hasBin: true - '@valibot/to-json-schema@1.7.1': resolution: {integrity: sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A==} peerDependencies: @@ -4318,7 +4314,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inlang/paraglide-js@2.22.0(@typescript/typescript6@6.0.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))': + '@inlang/paraglide-js@2.22.0(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))': dependencies: '@inlang/recommend-sherlock': 0.2.1 '@inlang/sdk': 2.10.2 @@ -4328,7 +4324,7 @@ snapshots: unplugin: 2.3.11 urlpattern-polyfill: 10.1.0 optionalDependencies: - typescript: '@typescript/typescript6@6.0.2' + typescript: 6.0.3 vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1) transitivePeerDependencies: - babel-plugin-macros @@ -4465,7 +4461,7 @@ snapshots: '@lix-js/server-protocol-schema@0.1.1': {} - '@lucide/svelte@1.25.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))': + '@lucide/svelte@1.26.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))': dependencies: svelte: 5.56.7(@typescript-eslint/types@8.65.0) @@ -4697,16 +4693,16 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-node@5.5.7(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))': + '@sveltejs/adapter-node@5.5.7(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))': dependencies: '@rollup/plugin-commonjs': 29.0.2(rollup@4.59.0) '@rollup/plugin-json': 6.1.0(rollup@4.59.0) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0) '@rollup/plugin-replace': 6.0.3(rollup@4.59.0) - '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) rollup: 4.59.0 - '@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))': + '@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.11(acorn@8.16.0) @@ -4724,7 +4720,7 @@ snapshots: svelte: 5.56.7(@typescript-eslint/types@8.65.0) vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1) optionalDependencies: - typescript: '@typescript/typescript6@6.0.2' + typescript: 6.0.3 '@sveltejs/load-config@0.2.0': {} @@ -4998,40 +4994,40 @@ snapshots: '@types/json-schema': 7.0.15 optional: true - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) + '@typescript-eslint/parser': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) - '@typescript-eslint/utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) + '@typescript-eslint/type-utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 eslint: 10.7.0(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) - typescript: '@typescript/typescript6@6.0.2' + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))': + '@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 eslint: 10.7.0(jiti@2.7.0) - typescript: '@typescript/typescript6@6.0.2' + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/project-service@8.65.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) '@typescript-eslint/types': 8.65.0 debug: 4.4.3 - typescript: '@typescript/typescript6@6.0.2' + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -5040,47 +5036,47 @@ snapshots: '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 - '@typescript-eslint/tsconfig-utils@8.65.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': dependencies: - typescript: '@typescript/typescript6@6.0.2' + typescript: 6.0.3 - '@typescript-eslint/type-utils@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))': + '@typescript-eslint/type-utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 eslint: 10.7.0(jiti@2.7.0) - ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) - typescript: '@typescript/typescript6@6.0.2' + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.65.0': {} - '@typescript-eslint/typescript-estree@8.65.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/tsconfig-utils': 8.65.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/project-service': 8.65.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) - typescript: '@typescript/typescript6@6.0.2' + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))': + '@typescript-eslint/utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) eslint: 10.7.0(jiti@2.7.0) - typescript: '@typescript/typescript6@6.0.2' + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -5149,13 +5145,9 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@typescript/typescript6@6.0.2': - dependencies: - '@typescript/old': typescript@6.0.3 - - '@valibot/to-json-schema@1.7.1(valibot@1.4.2(@typescript/typescript6@6.0.2))': + '@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@6.0.3))': dependencies: - valibot: 1.4.2(@typescript/typescript6@6.0.2) + valibot: 1.4.2(typescript@6.0.3) optional: true '@vinejs/compiler@3.0.0': @@ -5300,15 +5292,15 @@ snapshots: dependencies: require-from-string: 2.0.2 - bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)): dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/dom': 1.7.6 '@internationalized/date': 3.12.2 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + runed: 0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) svelte: 5.56.7(@typescript-eslint/types@8.65.0) - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -5763,11 +5755,11 @@ snapshots: transitivePeerDependencies: - ts-node - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)))(eslint@10.7.0(jiti@2.7.0)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)): dependencies: eslint: 10.7.0(jiti@2.7.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) eslint-scope@8.4.0: dependencies: @@ -5927,11 +5919,11 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - formsnap@2.0.1(svelte@5.56.7(@typescript-eslint/types@8.65.0))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))): + formsnap@2.0.1(svelte@5.56.7(@typescript-eslint/types@8.65.0))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)): dependencies: svelte: 5.56.7(@typescript-eslint/types@8.65.0) svelte-toolbelt: 0.5.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) - sveltekit-superforms: 2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + sveltekit-superforms: 2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3) fsevents@2.3.3: optional: true @@ -6130,7 +6122,7 @@ snapshots: kysely@0.28.16: {} - layerchart@2.0.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3): + layerchart@2.0.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3): dependencies: '@dagrejs/dagre': 2.0.4 '@layerstack/svelte-actions': 1.0.1-next.18 @@ -6160,7 +6152,7 @@ snapshots: d3-tile: 1.0.0 d3-time: 3.1.0 memoize: 10.2.0 - runed: 0.37.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3) + runed: 0.37.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3) svelte: 5.56.7(@typescript-eslint/types@8.65.0) transitivePeerDependencies: - '@sveltejs/kit' @@ -6569,23 +6561,23 @@ snapshots: esm-env: 1.2.2 svelte: 5.56.7(@typescript-eslint/types@8.65.0) - runed@0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + runed@0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.56.7(@typescript-eslint/types@8.65.0) optionalDependencies: - '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) - runed@0.37.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3): + runed@0.37.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.56.7(@typescript-eslint/types@8.65.0) optionalDependencies: - '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) zod: 4.4.3 rw@1.3.3: {} @@ -6689,7 +6681,7 @@ snapshots: dependencies: svelte: 5.56.7(@typescript-eslint/types@8.65.0) - svelte-check@4.7.3(@typescript/typescript6@6.0.2)(picomatch@4.0.4)(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + svelte-check@4.7.3(picomatch@4.0.4)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 '@sveltejs/load-config': 0.2.0 @@ -6698,7 +6690,7 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 svelte: 5.56.7(@typescript-eslint/types@8.65.0) - typescript: '@typescript/typescript6@6.0.2' + typescript: 6.0.3 transitivePeerDependencies: - picomatch @@ -6719,10 +6711,10 @@ snapshots: runed: 0.28.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) svelte: 5.56.7(@typescript-eslint/types@8.65.0) - svelte-toolbelt@0.10.6(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + runed: 0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) style-to-object: 1.0.14 svelte: 5.56.7(@typescript-eslint/types@8.65.0) transitivePeerDependencies: @@ -6762,9 +6754,9 @@ snapshots: transitivePeerDependencies: - '@typescript-eslint/types' - sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3): dependencies: - '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@typescript/typescript6@6.0.2)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) devalue: 5.8.1 memoize-weak: 1.0.2 svelte: 5.56.7(@typescript-eslint/types@8.65.0) @@ -6773,7 +6765,7 @@ snapshots: '@exodus/schemasafe': 1.3.0 '@standard-schema/spec': 1.1.0 '@typeschema/class-validator': 0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.4) - '@valibot/to-json-schema': 1.7.1(valibot@1.4.2(@typescript/typescript6@6.0.2)) + '@valibot/to-json-schema': 1.7.1(valibot@1.4.2(typescript@6.0.3)) '@vinejs/vine': 3.0.1 arktype: 2.2.3 class-validator: 0.14.4 @@ -6782,7 +6774,7 @@ snapshots: json-schema-to-ts: 3.1.1 superstruct: 2.0.2 typebox: 1.3.6 - valibot: 1.4.2(@typescript/typescript6@6.0.2) + valibot: 1.4.2(typescript@6.0.3) yup: 1.7.1 zod: 4.4.3 zod-v3-to-json-schema: 4.0.0(zod@4.4.3) @@ -6855,9 +6847,9 @@ snapshots: ts-algebra@2.0.0: optional: true - ts-api-utils@2.5.0(@typescript/typescript6@6.0.2): + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: - typescript: '@typescript/typescript6@6.0.2' + typescript: 6.0.3 ts-deepmerge@8.0.0: {} @@ -6946,9 +6938,9 @@ snapshots: uuid@14.0.0: {} - valibot@1.4.2(@typescript/typescript6@6.0.2): + valibot@1.4.2(typescript@6.0.3): optionalDependencies: - typescript: '@typescript/typescript6@6.0.2' + typescript: 6.0.3 optional: true validator@13.15.26: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 774f5869..a7d2aaab 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,10 +1,7 @@ allowBuilds: bcrypt: true esbuild: true + better-sqlite3: true + sqlite3: true minimumReleaseAgeExclude: - helmet@8.3.0 -onlyBuiltDependencies: - - bcrypt - - better-sqlite3 - - esbuild - - sqlite3 diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index 3bc29eb7..a4615a0d 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -4,25 +4,32 @@ import { type VariantProps, tv } from 'tailwind-variants'; export const buttonVariants = tv({ - base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", variants: { variant: { - default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', - destructive: - 'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white', + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', outline: - 'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border', - secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + ghost: + 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground', + destructive: + 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30', link: 'text-primary underline-offset-4 hover:underline' }, size: { - default: 'h-9 px-4 py-2 has-[>svg]:px-3', - sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', - lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', - icon: 'size-9', - 'icon-sm': 'size-8', - 'icon-lg': 'size-10' + default: + 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + icon: 'size-8', + 'icon-xs': + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': + 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', + 'icon-lg': 'size-9' } }, defaultVariants: { diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte index 01c2bcca..90e5ea09 100644 --- a/src/lib/components/ui/input/input.svelte +++ b/src/lib/components/ui/input/input.svelte @@ -25,9 +25,7 @@ bind:this={ref} data-slot={dataSlot} class={cn( - 'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50', - 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', - 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', + 'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm', className )} type="file" @@ -40,9 +38,7 @@ bind:this={ref} data-slot={dataSlot} class={cn( - 'border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', - 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', - 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', + 'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm', className )} {type} diff --git a/src/lib/components/ui/separator/separator.svelte b/src/lib/components/ui/separator/separator.svelte index 84c91d9f..09c3026e 100644 --- a/src/lib/components/ui/separator/separator.svelte +++ b/src/lib/components/ui/separator/separator.svelte @@ -14,7 +14,9 @@ bind:ref data-slot={dataSlot} class={cn( - 'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px', + 'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px', + // this is different in shadcn/ui but self-stretch breaks things for us + 'data-[orientation=vertical]:h-full', className )} {...restProps} diff --git a/src/lib/components/ui/sheet/sheet-content.svelte b/src/lib/components/ui/sheet/sheet-content.svelte index 78aeb1c4..8baf7da1 100644 --- a/src/lib/components/ui/sheet/sheet-content.svelte +++ b/src/lib/components/ui/sheet/sheet-content.svelte @@ -1,31 +1,14 @@ @@ -48,15 +33,23 @@ {@render children?.()} - - - Close - + {#if showCloseButton} + + {#snippet child({ props })} + + {/snippet} + + {/if} diff --git a/src/lib/components/ui/sheet/sheet-header.svelte b/src/lib/components/ui/sheet/sheet-header.svelte index ffd09c40..35cc73f7 100644 --- a/src/lib/components/ui/sheet/sheet-header.svelte +++ b/src/lib/components/ui/sheet/sheet-header.svelte @@ -13,7 +13,7 @@
{@render children?.()} diff --git a/src/lib/components/ui/sheet/sheet-overlay.svelte b/src/lib/components/ui/sheet/sheet-overlay.svelte index d86a498c..146ddffe 100644 --- a/src/lib/components/ui/sheet/sheet-overlay.svelte +++ b/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -12,9 +12,6 @@ diff --git a/src/lib/components/ui/sheet/sheet-title.svelte b/src/lib/components/ui/sheet/sheet-title.svelte index 68732279..10eaf7cc 100644 --- a/src/lib/components/ui/sheet/sheet-title.svelte +++ b/src/lib/components/ui/sheet/sheet-title.svelte @@ -12,6 +12,6 @@ diff --git a/src/lib/components/ui/sidebar/constants.ts b/src/lib/components/ui/sidebar/constants.ts new file mode 100644 index 00000000..9e59823f --- /dev/null +++ b/src/lib/components/ui/sidebar/constants.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_COOKIE_NAME = 'sidebar_state'; +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +export const SIDEBAR_WIDTH = '16rem'; +export const SIDEBAR_WIDTH_MOBILE = '18rem'; +export const SIDEBAR_WIDTH_ICON = '3rem'; +export const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; diff --git a/src/lib/components/ui/sidebar/context.svelte.ts b/src/lib/components/ui/sidebar/context.svelte.ts new file mode 100644 index 00000000..6788b5b6 --- /dev/null +++ b/src/lib/components/ui/sidebar/context.svelte.ts @@ -0,0 +1,79 @@ +import { IsMobile } from '$lib/hooks/is-mobile.svelte.js'; +import { getContext, setContext } from 'svelte'; +import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants.js'; + +type Getter = () => T; + +export type SidebarStateProps = { + /** + * A getter function that returns the current open state of the sidebar. + * We use a getter function here to support `bind:open` on the `Sidebar.Provider` + * component. + */ + open: Getter; + + /** + * A function that sets the open state of the sidebar. To support `bind:open`, we need + * a source of truth for changing the open state to ensure it will be synced throughout + * the sub-components and any `bind:` references. + */ + setOpen: (open: boolean) => void; +}; + +class SidebarState { + readonly props: SidebarStateProps; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen: SidebarStateProps['setOpen']; + #isMobile: IsMobile; + state = $derived.by(() => (this.open ? 'expanded' : 'collapsed')); + + constructor(props: SidebarStateProps) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } + + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } + + // Event handler to apply to the `` + handleShortcutKeydown = (e: KeyboardEvent) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; + + setOpenMobile = (value: boolean) => { + this.openMobile = value; + }; + + toggle = () => { + return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open); + }; +} + +const SYMBOL_KEY = 'scn-sidebar'; + +/** + * Instantiates a new `SidebarState` instance and sets it in the context. + * + * @param props The constructor props for the `SidebarState` class. + * @returns The `SidebarState` instance. + */ +export function setSidebar(props: SidebarStateProps): SidebarState { + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); +} + +/** + * Retrieves the `SidebarState` instance from the context. This is a class instance, + * so you cannot destructure it. + * @returns The `SidebarState` instance. + */ +export function useSidebar(): SidebarState { + return getContext(Symbol.for(SYMBOL_KEY)); +} diff --git a/src/lib/components/ui/sidebar/index.ts b/src/lib/components/ui/sidebar/index.ts new file mode 100644 index 00000000..74803f08 --- /dev/null +++ b/src/lib/components/ui/sidebar/index.ts @@ -0,0 +1,75 @@ +import { useSidebar } from './context.svelte.js'; +import Content from './sidebar-content.svelte'; +import Footer from './sidebar-footer.svelte'; +import GroupAction from './sidebar-group-action.svelte'; +import GroupContent from './sidebar-group-content.svelte'; +import GroupLabel from './sidebar-group-label.svelte'; +import Group from './sidebar-group.svelte'; +import Header from './sidebar-header.svelte'; +import Input from './sidebar-input.svelte'; +import Inset from './sidebar-inset.svelte'; +import MenuAction from './sidebar-menu-action.svelte'; +import MenuBadge from './sidebar-menu-badge.svelte'; +import MenuButton from './sidebar-menu-button.svelte'; +import MenuItem from './sidebar-menu-item.svelte'; +import MenuSkeleton from './sidebar-menu-skeleton.svelte'; +import MenuSubButton from './sidebar-menu-sub-button.svelte'; +import MenuSubItem from './sidebar-menu-sub-item.svelte'; +import MenuSub from './sidebar-menu-sub.svelte'; +import Menu from './sidebar-menu.svelte'; +import Provider from './sidebar-provider.svelte'; +import Rail from './sidebar-rail.svelte'; +import Separator from './sidebar-separator.svelte'; +import Trigger from './sidebar-trigger.svelte'; +import Root from './sidebar.svelte'; + +export { + Content, + Footer, + Group, + GroupAction, + GroupContent, + GroupLabel, + Header, + Input, + Inset, + Menu, + MenuAction, + MenuBadge, + MenuButton, + MenuItem, + MenuSkeleton, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + Separator, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupAction as SidebarGroupAction, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Input as SidebarInput, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuAction as SidebarMenuAction, + MenuBadge as SidebarMenuBadge, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSkeleton as SidebarMenuSkeleton, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + Separator as SidebarSeparator, + Trigger as SidebarTrigger, + Trigger, + useSidebar +}; diff --git a/src/lib/components/ui/sidebar/sidebar-content.svelte b/src/lib/components/ui/sidebar/sidebar-content.svelte new file mode 100644 index 00000000..156f5346 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-content.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-footer.svelte b/src/lib/components/ui/sidebar/sidebar-footer.svelte new file mode 100644 index 00000000..7b43e9da --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-footer.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/src/lib/components/ui/sidebar/sidebar-group-action.svelte new file mode 100644 index 00000000..349d0b11 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group-action.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/src/lib/components/ui/sidebar/sidebar-group-content.svelte new file mode 100644 index 00000000..5e27abd7 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/src/lib/components/ui/sidebar/sidebar-group-label.svelte new file mode 100644 index 00000000..931bbd95 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-group.svelte b/src/lib/components/ui/sidebar/sidebar-group.svelte new file mode 100644 index 00000000..2e555668 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-header.svelte b/src/lib/components/ui/sidebar/sidebar-header.svelte new file mode 100644 index 00000000..08a97f13 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-header.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-input.svelte b/src/lib/components/ui/sidebar/sidebar-input.svelte new file mode 100644 index 00000000..1d3973f0 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-input.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar-inset.svelte b/src/lib/components/ui/sidebar/sidebar-inset.svelte new file mode 100644 index 00000000..b33f1fc1 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte new file mode 100644 index 00000000..7fae2b77 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte new file mode 100644 index 00000000..6f72cbc1 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte new file mode 100644 index 00000000..486c4640 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -0,0 +1,101 @@ + + + + +{#snippet Button({ props }: { props?: Record })} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} +{/snippet} + +{#if !tooltipContent} + {@render Button({})} +{:else} + + + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + + + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte new file mode 100644 index 00000000..634a08a1 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte new file mode 100644 index 00000000..6856c716 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte @@ -0,0 +1,36 @@ + + +
    + {#if showIcon} + + {/if} + + {@render children?.()} +
    diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte new file mode 100644 index 00000000..0efac952 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -0,0 +1,39 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte new file mode 100644 index 00000000..0516c06f --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte new file mode 100644 index 00000000..10ac3f57 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -0,0 +1,24 @@ + + +
      + {@render children?.()} +
    diff --git a/src/lib/components/ui/sidebar/sidebar-menu.svelte b/src/lib/components/ui/sidebar/sidebar-menu.svelte new file mode 100644 index 00000000..b1c20e72 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu.svelte @@ -0,0 +1,21 @@ + + +
      + {@render children?.()} +
    diff --git a/src/lib/components/ui/sidebar/sidebar-provider.svelte b/src/lib/components/ui/sidebar/sidebar-provider.svelte new file mode 100644 index 00000000..e41fccaf --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-provider.svelte @@ -0,0 +1,53 @@ + + + + + +
    + {@render children?.()} +
    +
    diff --git a/src/lib/components/ui/sidebar/sidebar-rail.svelte b/src/lib/components/ui/sidebar/sidebar-rail.svelte new file mode 100644 index 00000000..d065b85b --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -0,0 +1,36 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar-separator.svelte b/src/lib/components/ui/sidebar/sidebar-separator.svelte new file mode 100644 index 00000000..f917d364 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-separator.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/src/lib/components/ui/sidebar/sidebar-trigger.svelte new file mode 100644 index 00000000..e5132db9 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -0,0 +1,36 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar.svelte b/src/lib/components/ui/sidebar/sidebar.svelte new file mode 100644 index 00000000..eaf3f4f4 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar.svelte @@ -0,0 +1,105 @@ + + +{#if collapsible === 'none'} +
    + {@render children?.()} +
    +{:else if sidebar.isMobile} + sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}> + button]:hidden', + className + )} + style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};" + {side} + > + + Sidebar + Displays the mobile sidebar. + +
    + {@render children?.()} +
    +
    +
    +{:else} + +{/if} diff --git a/src/lib/components/ui/skeleton/index.ts b/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 00000000..df169ced --- /dev/null +++ b/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from './skeleton.svelte'; + +export { + Root, + // + Root as Skeleton +}; diff --git a/src/lib/components/ui/skeleton/skeleton.svelte b/src/lib/components/ui/skeleton/skeleton.svelte index 1ad8b50a..1737f85d 100644 --- a/src/lib/components/ui/skeleton/skeleton.svelte +++ b/src/lib/components/ui/skeleton/skeleton.svelte @@ -1,8 +1,17 @@ -
    +
    diff --git a/src/lib/components/ui/tooltip/index.ts b/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 00000000..36a2f3ab --- /dev/null +++ b/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,19 @@ +import Root from './tooltip.svelte'; +import Trigger from './tooltip-trigger.svelte'; +import Content from './tooltip-content.svelte'; +import Provider from './tooltip-provider.svelte'; +import Portal from './tooltip-portal.svelte'; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal +}; diff --git a/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 00000000..296abeba --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,52 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
    + {/snippet} +
    +
    +
    diff --git a/src/lib/components/ui/tooltip/tooltip-portal.svelte b/src/lib/components/ui/tooltip/tooltip-portal.svelte new file mode 100644 index 00000000..7b9e8f9f --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip-provider.svelte b/src/lib/components/ui/tooltip/tooltip-provider.svelte new file mode 100644 index 00000000..49f8379b --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-provider.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 00000000..f9a3bf41 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip.svelte b/src/lib/components/ui/tooltip/tooltip.svelte new file mode 100644 index 00000000..5a327a71 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/composables/sheet-form.svelte.ts b/src/lib/composables/sheet-form.svelte.ts index a631644a..fa4d1d4a 100644 --- a/src/lib/composables/sheet-form.svelte.ts +++ b/src/lib/composables/sheet-form.svelte.ts @@ -35,12 +35,24 @@ export function createSheetForm(config: { } return { - get processing() { return processing; }, - set processing(v) { processing = v; }, - get attachment() { return attachment; }, - set attachment(v) { attachment = v; }, - get removeExistingAttachment() { return removeExistingAttachment; }, - set removeExistingAttachment(v) { removeExistingAttachment = v; }, + get processing() { + return processing; + }, + set processing(v) { + processing = v; + }, + get attachment() { + return attachment; + }, + set attachment(v) { + attachment = v; + }, + get removeExistingAttachment() { + return removeExistingAttachment; + }, + set removeExistingAttachment(v) { + removeExistingAttachment = v; + }, form, formData, enhance, diff --git a/src/lib/hooks/is-mobile.svelte.ts b/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 00000000..e3802d38 --- /dev/null +++ b/src/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,9 @@ +import { MediaQuery } from 'svelte/reactivity'; + +const DEFAULT_MOBILE_BREAKPOINT = 768; + +export class IsMobile extends MediaQuery { + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`); + } +} diff --git a/src/server/db/init.ts b/src/server/db/init.ts index fba279fe..e77431c7 100644 --- a/src/server/db/init.ts +++ b/src/server/db/init.ts @@ -16,7 +16,10 @@ export async function initializeDatabase(): Promise { // Run migrations logger.info('Running database migrations...'); await migrate(db, { - migrationsFolder: resolve(env.NODE_ENV==='production'?process.cwd():'src/server/db', 'migrations'), + migrationsFolder: resolve( + env.NODE_ENV === 'production' ? process.cwd() : 'src/server/db', + 'migrations' + ), migrationsTable: '_migrations' }); logger.info('Database migrations completed successfully'); From fbc6fdf36b12f20dec5673dd6af4826c8adb09c0 Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Fri, 24 Jul 2026 23:13:26 +0530 Subject: [PATCH 02/23] Phase 0: Add shadcn avatar/progress/range-calendar primitives and shared dashboard components (StatCard, PageHeader, DonutChart, ProgressRing, StatusPill) --- .gitignore | 1 + .../components/dashboard/DonutChart.svelte | 124 ++++++++++++++++++ .../components/dashboard/PageHeader.svelte | 25 ++++ .../components/dashboard/ProgressRing.svelte | 85 ++++++++++++ src/lib/components/dashboard/StatCard.svelte | 48 +++++++ .../components/dashboard/StatusPill.svelte | 45 +++++++ .../components/ui/avatar/avatar-badge.svelte | 26 ++++ .../ui/avatar/avatar-fallback.svelte | 20 +++ .../ui/avatar/avatar-group-count.svelte | 23 ++++ .../components/ui/avatar/avatar-group.svelte | 23 ++++ .../components/ui/avatar/avatar-image.svelte | 17 +++ src/lib/components/ui/avatar/avatar.svelte | 26 ++++ src/lib/components/ui/avatar/index.ts | 22 ++++ src/lib/components/ui/progress/index.ts | 7 + .../components/ui/progress/progress.svelte | 27 ++++ src/lib/components/ui/range-calendar/index.ts | 40 ++++++ .../range-calendar-caption.svelte | 76 +++++++++++ .../range-calendar/range-calendar-cell.svelte | 19 +++ .../range-calendar/range-calendar-day.svelte | 36 +++++ .../range-calendar-grid-body.svelte | 7 + .../range-calendar-grid-head.svelte | 7 + .../range-calendar-grid-row.svelte | 12 ++ .../range-calendar/range-calendar-grid.svelte | 16 +++ .../range-calendar-head-cell.svelte | 19 +++ .../range-calendar-header.svelte | 19 +++ .../range-calendar-heading.svelte | 16 +++ .../range-calendar-month-select.svelte | 44 +++++++ .../range-calendar-month.svelte | 15 +++ .../range-calendar-months.svelte | 19 +++ .../range-calendar/range-calendar-nav.svelte | 19 +++ .../range-calendar-next-button.svelte | 36 +++++ .../range-calendar-prev-button.svelte | 36 +++++ .../range-calendar-year-select.svelte | 43 ++++++ .../ui/range-calendar/range-calendar.svelte | 112 ++++++++++++++++ 34 files changed, 1110 insertions(+) create mode 100644 src/lib/components/dashboard/DonutChart.svelte create mode 100644 src/lib/components/dashboard/PageHeader.svelte create mode 100644 src/lib/components/dashboard/ProgressRing.svelte create mode 100644 src/lib/components/dashboard/StatCard.svelte create mode 100644 src/lib/components/dashboard/StatusPill.svelte create mode 100644 src/lib/components/ui/avatar/avatar-badge.svelte create mode 100644 src/lib/components/ui/avatar/avatar-fallback.svelte create mode 100644 src/lib/components/ui/avatar/avatar-group-count.svelte create mode 100644 src/lib/components/ui/avatar/avatar-group.svelte create mode 100644 src/lib/components/ui/avatar/avatar-image.svelte create mode 100644 src/lib/components/ui/avatar/avatar.svelte create mode 100644 src/lib/components/ui/avatar/index.ts create mode 100644 src/lib/components/ui/progress/index.ts create mode 100644 src/lib/components/ui/progress/progress.svelte create mode 100644 src/lib/components/ui/range-calendar/index.ts create mode 100644 src/lib/components/ui/range-calendar/range-calendar-caption.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-cell.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-day.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-grid.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-header.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-heading.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-month-select.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-month.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-months.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-nav.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-next-button.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar-year-select.svelte create mode 100644 src/lib/components/ui/range-calendar/range-calendar.svelte diff --git a/.gitignore b/.gitignore index 1653a44c..3bd67cb1 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ src/lib/paraglide project.inlang/cache/ .opencode .app/ +docs/planning diff --git a/src/lib/components/dashboard/DonutChart.svelte b/src/lib/components/dashboard/DonutChart.svelte new file mode 100644 index 00000000..c1b8bb67 --- /dev/null +++ b/src/lib/components/dashboard/DonutChart.svelte @@ -0,0 +1,124 @@ + + +
    + {#if title} +
    + {title} +
    + {/if} + + {#if loading} +
    +
    + +
    +
    + {#each [0, 1, 2] as i (i)} +
    + + +
    + {/each} +
    +
    + {:else if data.length === 0} +
    + No data available +
    + {:else} + + d.color} + {innerRadius} + padAngle={0.02} + cornerRadius={4} + > + {#snippet tooltip()} + + {#snippet formatter({ value, name })} + {@const pct = total > 0 && typeof value === 'number' + ? roundToDec((value / total) * 100, 1) + : 0} + {name} + + {typeof value === 'number' ? value.toLocaleString() : value} ({pct}%) + + {/snippet} + + {/snippet} + + + + {#if showLegend && itemsWithPercent.length > 0} +
    + {#each itemsWithPercent as item (item.name)} +
    + + {item.name} + {item.percentage}% + + ({item.value.toLocaleString()}) + +
    + {/each} +
    + {/if} + {/if} +
    \ No newline at end of file diff --git a/src/lib/components/dashboard/PageHeader.svelte b/src/lib/components/dashboard/PageHeader.svelte new file mode 100644 index 00000000..79b20df4 --- /dev/null +++ b/src/lib/components/dashboard/PageHeader.svelte @@ -0,0 +1,25 @@ + + +
    +
    +

    {title}

    + {#if description} +

    {description}

    + {/if} +
    + {#if children} +
    + {@render children()} +
    + {/if} +
    diff --git a/src/lib/components/dashboard/ProgressRing.svelte b/src/lib/components/dashboard/ProgressRing.svelte new file mode 100644 index 00000000..239e77a3 --- /dev/null +++ b/src/lib/components/dashboard/ProgressRing.svelte @@ -0,0 +1,85 @@ + + +
    + + + + + + + + + + {#if children} +
    + {@render children()} +
    + {:else if showLabel} +
    + + {Math.round(value)}% + + {#if label} + + {label} + + {/if} +
    + {/if} +
    \ No newline at end of file diff --git a/src/lib/components/dashboard/StatCard.svelte b/src/lib/components/dashboard/StatCard.svelte new file mode 100644 index 00000000..8d8fe3ec --- /dev/null +++ b/src/lib/components/dashboard/StatCard.svelte @@ -0,0 +1,48 @@ + + +
    +
    + +
    + +
    + {label} + {value} + {#if trend} + + {trendIcon} + {trend.value} + + {/if} +
    +
    diff --git a/src/lib/components/dashboard/StatusPill.svelte b/src/lib/components/dashboard/StatusPill.svelte new file mode 100644 index 00000000..a7c2a284 --- /dev/null +++ b/src/lib/components/dashboard/StatusPill.svelte @@ -0,0 +1,45 @@ + + + + {displayLabel} + \ No newline at end of file diff --git a/src/lib/components/ui/avatar/avatar-badge.svelte b/src/lib/components/ui/avatar/avatar-badge.svelte new file mode 100644 index 00000000..ca9d9ed2 --- /dev/null +++ b/src/lib/components/ui/avatar/avatar-badge.svelte @@ -0,0 +1,26 @@ + + +svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...restProps} +> + {@render children?.()} + diff --git a/src/lib/components/ui/avatar/avatar-fallback.svelte b/src/lib/components/ui/avatar/avatar-fallback.svelte new file mode 100644 index 00000000..63c8b022 --- /dev/null +++ b/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/avatar/avatar-group-count.svelte b/src/lib/components/ui/avatar/avatar-group-count.svelte new file mode 100644 index 00000000..c89684ab --- /dev/null +++ b/src/lib/components/ui/avatar/avatar-group-count.svelte @@ -0,0 +1,23 @@ + + +
    svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", + className + )} + {...restProps} +> + {@render children?.()} +
    diff --git a/src/lib/components/ui/avatar/avatar-group.svelte b/src/lib/components/ui/avatar/avatar-group.svelte new file mode 100644 index 00000000..78714fc0 --- /dev/null +++ b/src/lib/components/ui/avatar/avatar-group.svelte @@ -0,0 +1,23 @@ + + +
    + {@render children?.()} +
    diff --git a/src/lib/components/ui/avatar/avatar-image.svelte b/src/lib/components/ui/avatar/avatar-image.svelte new file mode 100644 index 00000000..f41cf8fe --- /dev/null +++ b/src/lib/components/ui/avatar/avatar-image.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/avatar/avatar.svelte b/src/lib/components/ui/avatar/avatar.svelte new file mode 100644 index 00000000..ea6fde56 --- /dev/null +++ b/src/lib/components/ui/avatar/avatar.svelte @@ -0,0 +1,26 @@ + + + diff --git a/src/lib/components/ui/avatar/index.ts b/src/lib/components/ui/avatar/index.ts new file mode 100644 index 00000000..38ccef84 --- /dev/null +++ b/src/lib/components/ui/avatar/index.ts @@ -0,0 +1,22 @@ +import Root from "./avatar.svelte"; +import Image from "./avatar-image.svelte"; +import Fallback from "./avatar-fallback.svelte"; +import Badge from "./avatar-badge.svelte"; +import Group from "./avatar-group.svelte"; +import GroupCount from "./avatar-group-count.svelte"; + +export { + Root, + Image, + Fallback, + Badge, + Group, + GroupCount, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback, + Badge as AvatarBadge, + Group as AvatarGroup, + GroupCount as AvatarGroupCount, +}; diff --git a/src/lib/components/ui/progress/index.ts b/src/lib/components/ui/progress/index.ts new file mode 100644 index 00000000..25eee615 --- /dev/null +++ b/src/lib/components/ui/progress/index.ts @@ -0,0 +1,7 @@ +import Root from "./progress.svelte"; + +export { + Root, + // + Root as Progress, +}; diff --git a/src/lib/components/ui/progress/progress.svelte b/src/lib/components/ui/progress/progress.svelte new file mode 100644 index 00000000..c4d15085 --- /dev/null +++ b/src/lib/components/ui/progress/progress.svelte @@ -0,0 +1,27 @@ + + + +
    +
    diff --git a/src/lib/components/ui/range-calendar/index.ts b/src/lib/components/ui/range-calendar/index.ts new file mode 100644 index 00000000..d2d258bc --- /dev/null +++ b/src/lib/components/ui/range-calendar/index.ts @@ -0,0 +1,40 @@ +import Root from "./range-calendar.svelte"; +import Cell from "./range-calendar-cell.svelte"; +import Day from "./range-calendar-day.svelte"; +import Grid from "./range-calendar-grid.svelte"; +import Header from "./range-calendar-header.svelte"; +import Months from "./range-calendar-months.svelte"; +import GridRow from "./range-calendar-grid-row.svelte"; +import Heading from "./range-calendar-heading.svelte"; +import HeadCell from "./range-calendar-head-cell.svelte"; +import NextButton from "./range-calendar-next-button.svelte"; +import PrevButton from "./range-calendar-prev-button.svelte"; +import MonthSelect from "./range-calendar-month-select.svelte"; +import YearSelect from "./range-calendar-year-select.svelte"; +import Caption from "./range-calendar-caption.svelte"; +import Nav from "./range-calendar-nav.svelte"; +import Month from "./range-calendar-month.svelte"; +import GridBody from "./range-calendar-grid-body.svelte"; +import GridHead from "./range-calendar-grid-head.svelte"; + +export { + Day, + Cell, + Grid, + Header, + Months, + GridRow, + Heading, + GridBody, + GridHead, + HeadCell, + NextButton, + PrevButton, + MonthSelect, + YearSelect, + Caption, + Nav, + Month, + // + Root as RangeCalendar, +}; diff --git a/src/lib/components/ui/range-calendar/range-calendar-caption.svelte b/src/lib/components/ui/range-calendar/range-calendar-caption.svelte new file mode 100644 index 00000000..944654d3 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-caption.svelte @@ -0,0 +1,76 @@ + + +{#snippet MonthSelect()} + { + if (!placeholder) return; + const v = Number.parseInt(e.currentTarget.value); + const newPlaceholder = placeholder.set({ month: v }); + placeholder = newPlaceholder.subtract({ months: monthIndex }); + }} + /> +{/snippet} + +{#snippet YearSelect()} + +{/snippet} + +{#if captionLayout === "dropdown"} + {@render MonthSelect()} + {@render YearSelect()} +{:else if captionLayout === "dropdown-months"} + {@render MonthSelect()} + {#if placeholder} + {formatYear(placeholder)} + {/if} +{:else if captionLayout === "dropdown-years"} + {#if placeholder} + {formatMonth(placeholder)} + {/if} + {@render YearSelect()} +{:else} + {formatMonth(month)} {formatYear(month)} +{/if} diff --git a/src/lib/components/ui/range-calendar/range-calendar-cell.svelte b/src/lib/components/ui/range-calendar/range-calendar-cell.svelte new file mode 100644 index 00000000..e042cf1a --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/range-calendar/range-calendar-day.svelte b/src/lib/components/ui/range-calendar/range-calendar-day.svelte new file mode 100644 index 00000000..0890592a --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-day.svelte @@ -0,0 +1,36 @@ + + +span]:text-xs [&>span]:opacity-70", + className + )} + {...restProps} +/> diff --git a/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte b/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte new file mode 100644 index 00000000..45a31d1f --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte b/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte new file mode 100644 index 00000000..e3116255 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte b/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte new file mode 100644 index 00000000..3286b2aa --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte @@ -0,0 +1,12 @@ + + + diff --git a/src/lib/components/ui/range-calendar/range-calendar-grid.svelte b/src/lib/components/ui/range-calendar/range-calendar-grid.svelte new file mode 100644 index 00000000..3d74b2f3 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-grid.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte b/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte new file mode 100644 index 00000000..93a60c0a --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/range-calendar/range-calendar-header.svelte b/src/lib/components/ui/range-calendar/range-calendar-header.svelte new file mode 100644 index 00000000..ec1e6ea1 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-header.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/range-calendar/range-calendar-heading.svelte b/src/lib/components/ui/range-calendar/range-calendar-heading.svelte new file mode 100644 index 00000000..3b3325f4 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-heading.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/ui/range-calendar/range-calendar-month-select.svelte b/src/lib/components/ui/range-calendar/range-calendar-month-select.svelte new file mode 100644 index 00000000..d12c18f8 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-month-select.svelte @@ -0,0 +1,44 @@ + + + + + {#snippet child({ props, monthItems, selectedMonthItem })} + + + {/snippet} + + diff --git a/src/lib/components/ui/range-calendar/range-calendar-month.svelte b/src/lib/components/ui/range-calendar/range-calendar-month.svelte new file mode 100644 index 00000000..e747fae7 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-month.svelte @@ -0,0 +1,15 @@ + + +
    + {@render children?.()} +
    diff --git a/src/lib/components/ui/range-calendar/range-calendar-months.svelte b/src/lib/components/ui/range-calendar/range-calendar-months.svelte new file mode 100644 index 00000000..f717a9d9 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-months.svelte @@ -0,0 +1,19 @@ + + +
    + {@render children?.()} +
    diff --git a/src/lib/components/ui/range-calendar/range-calendar-nav.svelte b/src/lib/components/ui/range-calendar/range-calendar-nav.svelte new file mode 100644 index 00000000..27f33d79 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-nav.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte b/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte new file mode 100644 index 00000000..2c82cd4b --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte @@ -0,0 +1,36 @@ + + +{#snippet Fallback()} + +{/snippet} + + + {#if children} + {@render children?.()} + {:else} + {@render Fallback()} + {/if} + diff --git a/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte b/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte new file mode 100644 index 00000000..0b912f89 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte @@ -0,0 +1,36 @@ + + +{#snippet Fallback()} + +{/snippet} + + + {#if children} + {@render children?.()} + {:else} + {@render Fallback()} + {/if} + diff --git a/src/lib/components/ui/range-calendar/range-calendar-year-select.svelte b/src/lib/components/ui/range-calendar/range-calendar-year-select.svelte new file mode 100644 index 00000000..926aa3a1 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar-year-select.svelte @@ -0,0 +1,43 @@ + + + + + {#snippet child({ props, yearItems, selectedYearItem })} + + + {/snippet} + + diff --git a/src/lib/components/ui/range-calendar/range-calendar.svelte b/src/lib/components/ui/range-calendar/range-calendar.svelte new file mode 100644 index 00000000..a2c01f14 --- /dev/null +++ b/src/lib/components/ui/range-calendar/range-calendar.svelte @@ -0,0 +1,112 @@ + + + + {#snippet children({ months, weekdays })} + + + + + + {#each months as month, monthIndex (month)} + + + + + + + + + {#each weekdays as weekday, i (i)} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + + {#if day} + {@render day({ + day: date, + outsideMonth: !isEqualMonth(date, month.value), + })} + {:else} + + {/if} + + {/each} + + {/each} + + + + {/each} + + {/snippet} + From 55a3b92cca465deacb2b49b2cd0af4aebc01d6e1 Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Fri, 24 Jul 2026 23:13:29 +0530 Subject: [PATCH 03/23] chore: upgrade shadcn button component via CLI --- src/lib/components/ui/button/button.svelte | 147 ++++++++++----------- src/lib/components/ui/button/index.ts | 26 ++-- 2 files changed, 83 insertions(+), 90 deletions(-) diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index a4615a0d..89cedcb7 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -1,89 +1,82 @@ {#if href} - - {@render children?.()} - + + {@render children?.()} + {:else} - + {/if} diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts index 7fe4a81e..fb585d76 100644 --- a/src/lib/components/ui/button/index.ts +++ b/src/lib/components/ui/button/index.ts @@ -1,17 +1,17 @@ import Root, { - type ButtonProps, - type ButtonSize, - type ButtonVariant, - buttonVariants -} from './button.svelte'; + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; export { - Root, - type ButtonProps as Props, - // - Root as Button, - buttonVariants, - type ButtonProps, - type ButtonSize, - type ButtonVariant + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, }; From 56400960486c3aeb9cdc099fea035cf0ee4c9044 Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Fri, 24 Jul 2026 23:16:16 +0530 Subject: [PATCH 04/23] Phase 1: Sidebar app shell - Add AppSidebar, (app) route group, minimal root layout - Create AppSidebar component with brand, feature-flagged nav, user dropdown - Create (app) route group layout with Sidebar.Provider + top bar - Move dashboard/* and settings routes under (app) group - Simplify root layout to minimal wrapper (demo banner removed to app layout) - Remove old dashboard layout (vehicle strip, DashboardNav) - Remove back-arrow chrome from settings page - Notifications now show on all app routes --- src/lib/components/layout/AppSidebar.svelte | 244 ++++++++++++++++++ .../{dashboard => (app)}/+layout.server.ts | 0 src/routes/(app)/+layout.svelte | 74 ++++++ src/routes/(app)/dashboard/+layout.svelte | 7 + src/routes/{ => (app)}/dashboard/+page.ts | 0 .../{ => (app)}/dashboard/fuel/+page.svelte | 0 .../{ => (app)}/dashboard/fuel/+page.ts | 0 .../dashboard/insurance/+page.svelte | 0 .../{ => (app)}/dashboard/insurance/+page.ts | 0 .../dashboard/maintenance/+page.svelte | 0 .../dashboard/maintenance/+page.ts | 0 .../dashboard/overview/+page.svelte | 0 .../dashboard/pollution/+page.svelte | 0 .../{ => (app)}/dashboard/pollution/+page.ts | 0 .../dashboard/reminders/+page.svelte | 0 .../{ => (app)}/dashboard/reminders/+page.ts | 0 src/routes/(app)/settings/+layout.svelte | 7 + src/routes/{ => (app)}/settings/+page.svelte | 13 - src/routes/+layout.svelte | 76 +----- src/routes/dashboard/+layout.svelte | 143 ---------- src/routes/settings/+layout.svelte | 18 -- 21 files changed, 335 insertions(+), 247 deletions(-) create mode 100644 src/lib/components/layout/AppSidebar.svelte rename src/routes/{dashboard => (app)}/+layout.server.ts (100%) create mode 100644 src/routes/(app)/+layout.svelte create mode 100644 src/routes/(app)/dashboard/+layout.svelte rename src/routes/{ => (app)}/dashboard/+page.ts (100%) rename src/routes/{ => (app)}/dashboard/fuel/+page.svelte (100%) rename src/routes/{ => (app)}/dashboard/fuel/+page.ts (100%) rename src/routes/{ => (app)}/dashboard/insurance/+page.svelte (100%) rename src/routes/{ => (app)}/dashboard/insurance/+page.ts (100%) rename src/routes/{ => (app)}/dashboard/maintenance/+page.svelte (100%) rename src/routes/{ => (app)}/dashboard/maintenance/+page.ts (100%) rename src/routes/{ => (app)}/dashboard/overview/+page.svelte (100%) rename src/routes/{ => (app)}/dashboard/pollution/+page.svelte (100%) rename src/routes/{ => (app)}/dashboard/pollution/+page.ts (100%) rename src/routes/{ => (app)}/dashboard/reminders/+page.svelte (100%) rename src/routes/{ => (app)}/dashboard/reminders/+page.ts (100%) create mode 100644 src/routes/(app)/settings/+layout.svelte rename src/routes/{ => (app)}/settings/+page.svelte (97%) delete mode 100644 src/routes/dashboard/+layout.svelte delete mode 100644 src/routes/settings/+layout.svelte diff --git a/src/lib/components/layout/AppSidebar.svelte b/src/lib/components/layout/AppSidebar.svelte new file mode 100644 index 00000000..782e7134 --- /dev/null +++ b/src/lib/components/layout/AppSidebar.svelte @@ -0,0 +1,244 @@ + + + + +
    + +
    + {m.app_name()} + Vehicle Management +
    +
    +
    + + + + + + {#each visibleNavItems as item (item.href)} + + handleNavClick(item.href)} + tooltipContent={item.label} + > + + {item.label} + + + {/each} + + + + + + + + + + + handleNavClick('/settings')} + tooltipContent={m.settings_title()} + > + + {m.settings_title()} + + + + + + + + + {#if authStore.isLoggedIn} + + + + + + {userInitial} + + +
    + + {authStore.user?.username || m.profile_menu_item()} + +
    +
    +
    + + +
    + + + {userInitial} + + +
    + + {authStore.user?.username || m.profile_menu_item()} + +
    +
    +
    + + { + sheetStore.openSheet( + ProfileForm, + m.profile_sheet_title(), + m.profile_sheet_desc() + ); + }} + disabled={authStore.isAuthDisabled || env.DEMO_MODE} + > + + {m.profile_menu_item()} + + + + + {m.tools_menu()} + + + { + sheetStore.openSheet( + DataExportImport, + m.data_export_import_sheet_title(), + m.data_export_import_sheet_desc() + ); + }} + > + + {m.data_export_import_menu_item()} + + + + + + + {m.logout_menu_item()} + +
    +
    + {/if} +
    +
    diff --git a/src/routes/dashboard/+layout.server.ts b/src/routes/(app)/+layout.server.ts similarity index 100% rename from src/routes/dashboard/+layout.server.ts rename to src/routes/(app)/+layout.server.ts diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte new file mode 100644 index 00000000..5c750e43 --- /dev/null +++ b/src/routes/(app)/+layout.svelte @@ -0,0 +1,74 @@ + + +{#if demoMode} +
    + + {demo_banner()} + + {#if !env.DISABLE_AUTH} + {default_login()} + {/if} + +
    +{/if} + + + + +
    + +
    + + +
    +
    +
    + {@render children()} +
    +
    +
    + + diff --git a/src/routes/(app)/dashboard/+layout.svelte b/src/routes/(app)/dashboard/+layout.svelte new file mode 100644 index 00000000..135b568d --- /dev/null +++ b/src/routes/(app)/dashboard/+layout.svelte @@ -0,0 +1,7 @@ + + +
    + {@render children()} +
    diff --git a/src/routes/dashboard/+page.ts b/src/routes/(app)/dashboard/+page.ts similarity index 100% rename from src/routes/dashboard/+page.ts rename to src/routes/(app)/dashboard/+page.ts diff --git a/src/routes/dashboard/fuel/+page.svelte b/src/routes/(app)/dashboard/fuel/+page.svelte similarity index 100% rename from src/routes/dashboard/fuel/+page.svelte rename to src/routes/(app)/dashboard/fuel/+page.svelte diff --git a/src/routes/dashboard/fuel/+page.ts b/src/routes/(app)/dashboard/fuel/+page.ts similarity index 100% rename from src/routes/dashboard/fuel/+page.ts rename to src/routes/(app)/dashboard/fuel/+page.ts diff --git a/src/routes/dashboard/insurance/+page.svelte b/src/routes/(app)/dashboard/insurance/+page.svelte similarity index 100% rename from src/routes/dashboard/insurance/+page.svelte rename to src/routes/(app)/dashboard/insurance/+page.svelte diff --git a/src/routes/dashboard/insurance/+page.ts b/src/routes/(app)/dashboard/insurance/+page.ts similarity index 100% rename from src/routes/dashboard/insurance/+page.ts rename to src/routes/(app)/dashboard/insurance/+page.ts diff --git a/src/routes/dashboard/maintenance/+page.svelte b/src/routes/(app)/dashboard/maintenance/+page.svelte similarity index 100% rename from src/routes/dashboard/maintenance/+page.svelte rename to src/routes/(app)/dashboard/maintenance/+page.svelte diff --git a/src/routes/dashboard/maintenance/+page.ts b/src/routes/(app)/dashboard/maintenance/+page.ts similarity index 100% rename from src/routes/dashboard/maintenance/+page.ts rename to src/routes/(app)/dashboard/maintenance/+page.ts diff --git a/src/routes/dashboard/overview/+page.svelte b/src/routes/(app)/dashboard/overview/+page.svelte similarity index 100% rename from src/routes/dashboard/overview/+page.svelte rename to src/routes/(app)/dashboard/overview/+page.svelte diff --git a/src/routes/dashboard/pollution/+page.svelte b/src/routes/(app)/dashboard/pollution/+page.svelte similarity index 100% rename from src/routes/dashboard/pollution/+page.svelte rename to src/routes/(app)/dashboard/pollution/+page.svelte diff --git a/src/routes/dashboard/pollution/+page.ts b/src/routes/(app)/dashboard/pollution/+page.ts similarity index 100% rename from src/routes/dashboard/pollution/+page.ts rename to src/routes/(app)/dashboard/pollution/+page.ts diff --git a/src/routes/dashboard/reminders/+page.svelte b/src/routes/(app)/dashboard/reminders/+page.svelte similarity index 100% rename from src/routes/dashboard/reminders/+page.svelte rename to src/routes/(app)/dashboard/reminders/+page.svelte diff --git a/src/routes/dashboard/reminders/+page.ts b/src/routes/(app)/dashboard/reminders/+page.ts similarity index 100% rename from src/routes/dashboard/reminders/+page.ts rename to src/routes/(app)/dashboard/reminders/+page.ts diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte new file mode 100644 index 00000000..9e113ee1 --- /dev/null +++ b/src/routes/(app)/settings/+layout.svelte @@ -0,0 +1,7 @@ + + +
    + {@render children()} +
    diff --git a/src/routes/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte similarity index 97% rename from src/routes/settings/+page.svelte rename to src/routes/(app)/settings/+page.svelte index 61394318..55c961cb 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -2,7 +2,6 @@ import SettingsSection from '$lib/components/feature/settings/SettingsSection.svelte'; import { configStore } from '$stores/config.svelte'; import { themeStore } from '$lib/stores/theme.svelte'; - import LeftArrow from '@lucide/svelte/icons/move-left'; import SubmitButton from '$appui/SubmitButton.svelte'; import { toast } from 'svelte-sonner'; import { superForm, defaults } from 'sveltekit-superforms'; @@ -26,8 +25,6 @@ import SettingsFeaturesTab from '$feature/settings/SettingsFeaturesTab.svelte'; import SettingsPersonalizationTab from '$feature/settings/SettingsPersonalizationTab.svelte'; import SettingsUnitsTab from '$feature/settings/SettingsUnitsTab.svelte'; - import Button from '$lib/components/ui/button/button.svelte'; - import { goto } from '$app/navigation'; import SettingFormSection from '$lib/components/feature/settings/SettingFormSection.svelte'; let processing = $state(false); @@ -170,16 +167,6 @@
    -

    {m.settings_title()}

    diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5a8a10eb..5df373f8 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,31 +3,18 @@ import { locales, localizeHref } from '$lib/paraglide/runtime'; import { ModeWatcher } from 'mode-watcher'; import '../styles/app.css'; - import TriangleAlert from '@lucide/svelte/icons/triangle-alert'; - import Skeleton from '$lib/components/ui/skeleton/skeleton.svelte'; import { Toaster } from '$ui/sonner'; - import LabelWithIcon from '$appui/LabelWithIcon.svelte'; - import { navigating, page } from '$app/state'; - import Header from '$layout/Header.svelte'; + import { page } from '$app/state'; import { onMount } from 'svelte'; import { env } from '$lib/config/env'; import { toast } from 'svelte-sonner'; import { configStore } from '$lib/stores/config.svelte'; import { themeStore } from '$lib/stores/theme.svelte'; - import { demo_banner, default_login, app_name } from '$lib/paraglide/messages/_index.js'; + import { app_name } from '$lib/paraglide/messages/_index.js'; import { app_new_update_available } from '$lib/paraglide/messages'; let { children, data }: LayoutProps = $props(); - let demoMode = env.DEMO_MODE; let customCss = $state(''); - let isDashboardTransition = $derived( - navigating.to && - navigating.to.url.pathname.startsWith('/dashboard') && - navigating.from?.url.pathname.startsWith('/dashboard') - ); - let showGlobalLoader = $derived( - navigating.to && !navigating.to.route.id?.includes('(auth)') && !isDashboardTransition - ); async function detectSWUpdate() { const registrations = await navigator?.serviceWorker?.ready; @@ -49,7 +36,6 @@ } onMount(() => { - // Initialize theme themeStore.initializeTheme(); detectSWUpdate(); @@ -74,63 +60,7 @@ -{#if demoMode} -
    - - {demo_banner()} - - {#if !env.DISABLE_AUTH} - {default_login()} - {/if} - -
    -{/if} - -{#if showGlobalLoader} -
    -
    -
    - -
    - -
    -
    - -
    -
    -
    -
    -
    - -
    -
    - - -
    -
    -
    -{:else} -
    -
    -
    {@render children()}
    -
    -{/if} +{@render children()}
    {#each locales as locale (locale)} diff --git a/src/routes/dashboard/+layout.svelte b/src/routes/dashboard/+layout.svelte deleted file mode 100644 index 16ea7e34..00000000 --- a/src/routes/dashboard/+layout.svelte +++ /dev/null @@ -1,143 +0,0 @@ - - -
    - {#if vehicleStore.processing || isLoading} -
    -
    - - -
    - -
    - - - -
    - -
    -
    - - - -
    - -
    -
    - {:else} -
    -
    -

    - {app_title()} -

    - -
    - - - -
    - {#if vehicleStore.selectedId} -
    - -
    - {@render children()} -
    -
    - {:else} -
    -

    - {app_empty_select_message()} -

    -

    - {app_empty_select_hint()} -

    -
    - {/if} -
    -
    - {/if} -
    - - diff --git a/src/routes/settings/+layout.svelte b/src/routes/settings/+layout.svelte deleted file mode 100644 index e06dca3a..00000000 --- a/src/routes/settings/+layout.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - -
    - {@render children()} -
    From 8aec1058ae9dc499368137bd130056038148d08b Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Fri, 24 Jul 2026 23:18:26 +0530 Subject: [PATCH 05/23] Phase 2: Route restructure - Top-level feature pages with redirects from old /dashboard/* paths - Create top-level feature pages: /fuel, /maintenance, /reminders, /pollution, /insurance, /vehicles - /dashboard is now the fleet home page (placeholder) - Old /dashboard/* paths redirect to new top-level paths - /dashboard/overview redirects to /dashboard - Update AppSidebar nav item hrefs to point to new paths - Remove DashboardNav/DashboardTabs references (no longer used) --- src/lib/components/layout/AppSidebar.svelte | 12 ++--- src/routes/(app)/dashboard/+page.svelte | 46 +++++++++++++++++++ src/routes/(app)/dashboard/+page.ts | 28 +---------- src/routes/(app)/dashboard/fuel/+page.ts | 14 +----- src/routes/(app)/dashboard/insurance/+page.ts | 14 +----- .../(app)/dashboard/maintenance/+page.ts | 14 +----- src/routes/(app)/dashboard/overview/+page.ts | 6 +++ src/routes/(app)/dashboard/pollution/+page.ts | 14 +----- src/routes/(app)/dashboard/reminders/+page.ts | 14 +----- src/routes/(app)/fuel/+page.svelte | 17 +++++++ src/routes/(app)/insurance/+page.svelte | 17 +++++++ src/routes/(app)/maintenance/+page.svelte | 17 +++++++ src/routes/(app)/pollution/+page.svelte | 17 +++++++ src/routes/(app)/reminders/+page.svelte | 17 +++++++ src/routes/(app)/vehicles/+page.svelte | 23 ++++++++++ 15 files changed, 178 insertions(+), 92 deletions(-) create mode 100644 src/routes/(app)/dashboard/+page.svelte create mode 100644 src/routes/(app)/dashboard/overview/+page.ts create mode 100644 src/routes/(app)/fuel/+page.svelte create mode 100644 src/routes/(app)/insurance/+page.svelte create mode 100644 src/routes/(app)/maintenance/+page.svelte create mode 100644 src/routes/(app)/pollution/+page.svelte create mode 100644 src/routes/(app)/reminders/+page.svelte create mode 100644 src/routes/(app)/vehicles/+page.svelte diff --git a/src/lib/components/layout/AppSidebar.svelte b/src/lib/components/layout/AppSidebar.svelte index 782e7134..a217f384 100644 --- a/src/lib/components/layout/AppSidebar.svelte +++ b/src/lib/components/layout/AppSidebar.svelte @@ -37,7 +37,7 @@ const NAV_ITEMS: NavItem[] = [ { label: 'Dashboard', - href: '/dashboard/overview', + href: '/dashboard', icon: LayoutDashboard }, { @@ -47,31 +47,31 @@ }, { label: m.nav_fuel_logs(), - href: '/dashboard/fuel', + href: '/fuel', icon: Fuel, featureKey: 'featureFuelLog' }, { label: m.nav_maintenance(), - href: '/dashboard/maintenance', + href: '/maintenance', icon: Wrench, featureKey: 'featureMaintenance' }, { label: m.nav_reminders(), - href: '/dashboard/reminders', + href: '/reminders', icon: Bell, featureKey: 'featureReminders' }, { label: m.nav_pollution(), - href: '/dashboard/pollution', + href: '/pollution', icon: BadgeInfo, featureKey: 'featurePucc' }, { label: m.nav_insurance(), - href: '/dashboard/insurance', + href: '/insurance', icon: Shield, featureKey: 'featureInsurance' } diff --git a/src/routes/(app)/dashboard/+page.svelte b/src/routes/(app)/dashboard/+page.svelte new file mode 100644 index 00000000..5d57ddaa --- /dev/null +++ b/src/routes/(app)/dashboard/+page.svelte @@ -0,0 +1,46 @@ + + +
    + + +
    + + + + +
    + +
    +

    Dashboard charts and widgets coming in Phase 3

    +
    +
    diff --git a/src/routes/(app)/dashboard/+page.ts b/src/routes/(app)/dashboard/+page.ts index 5696eb1f..c9a9cf41 100644 --- a/src/routes/(app)/dashboard/+page.ts +++ b/src/routes/(app)/dashboard/+page.ts @@ -1,29 +1,5 @@ -import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -/** - * Redirect to the first enabled feature page - */ -export const load: PageLoad = async ({ parent }) => { - const { configs } = await parent(); - - // Priority order for redirecting to enabled features - const featureRoutes = [ - { key: 'featureOverview', path: '/dashboard/overview' }, - { key: 'featureFuelLog', path: '/dashboard/fuel' }, - { key: 'featureMaintenance', path: '/dashboard/maintenance' }, - { key: 'featureInsurance', path: '/dashboard/insurance' }, - { key: 'featurePucc', path: '/dashboard/pollution' }, - { key: 'featureReminders', path: '/dashboard/reminders' } - ]; - - // Find the first enabled feature - for (const feature of featureRoutes) { - if (configs?.[feature.key] !== false) { - throw redirect(307, feature.path); - } - } - - // Fallback to overview if all features are somehow disabled - throw redirect(307, '/dashboard/overview'); +export const load: PageLoad = async () => { + return {}; }; diff --git a/src/routes/(app)/dashboard/fuel/+page.ts b/src/routes/(app)/dashboard/fuel/+page.ts index 6e3d44b8..75e9d960 100644 --- a/src/routes/(app)/dashboard/fuel/+page.ts +++ b/src/routes/(app)/dashboard/fuel/+page.ts @@ -1,16 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -/** - * Load function that checks if the Fuel Log feature is enabled - * Redirects to overview if disabled - */ -export const load: PageLoad = async ({ parent }) => { - const { configs } = await parent(); - - if (configs?.featureFuelLog === false) { - throw redirect(307, '/dashboard/overview'); - } - - return {}; +export const load: PageLoad = async () => { + throw redirect(307, '/fuel'); }; diff --git a/src/routes/(app)/dashboard/insurance/+page.ts b/src/routes/(app)/dashboard/insurance/+page.ts index cd80a6ed..5923852b 100644 --- a/src/routes/(app)/dashboard/insurance/+page.ts +++ b/src/routes/(app)/dashboard/insurance/+page.ts @@ -1,16 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -/** - * Load function that checks if the Insurance feature is enabled - * Redirects to overview if disabled - */ -export const load: PageLoad = async ({ parent }) => { - const { configs } = await parent(); - - if (configs?.featureInsurance === false) { - throw redirect(307, '/dashboard/overview'); - } - - return {}; +export const load: PageLoad = async () => { + throw redirect(307, '/insurance'); }; diff --git a/src/routes/(app)/dashboard/maintenance/+page.ts b/src/routes/(app)/dashboard/maintenance/+page.ts index 41ba7f89..99c3dd85 100644 --- a/src/routes/(app)/dashboard/maintenance/+page.ts +++ b/src/routes/(app)/dashboard/maintenance/+page.ts @@ -1,16 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -/** - * Load function that checks if the Maintenance feature is enabled - * Redirects to overview if disabled - */ -export const load: PageLoad = async ({ parent }) => { - const { configs } = await parent(); - - if (configs?.featureMaintenance === false) { - throw redirect(307, '/dashboard/overview'); - } - - return {}; +export const load: PageLoad = async () => { + throw redirect(307, '/maintenance'); }; diff --git a/src/routes/(app)/dashboard/overview/+page.ts b/src/routes/(app)/dashboard/overview/+page.ts new file mode 100644 index 00000000..8bb857d6 --- /dev/null +++ b/src/routes/(app)/dashboard/overview/+page.ts @@ -0,0 +1,6 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async () => { + throw redirect(307, '/dashboard'); +}; diff --git a/src/routes/(app)/dashboard/pollution/+page.ts b/src/routes/(app)/dashboard/pollution/+page.ts index abe8762a..939a6526 100644 --- a/src/routes/(app)/dashboard/pollution/+page.ts +++ b/src/routes/(app)/dashboard/pollution/+page.ts @@ -1,16 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -/** - * Load function that checks if the PUCC feature is enabled - * Redirects to overview if disabled - */ -export const load: PageLoad = async ({ parent }) => { - const { configs } = await parent(); - - if (configs?.featurePucc === false) { - throw redirect(307, '/dashboard/overview'); - } - - return {}; +export const load: PageLoad = async () => { + throw redirect(307, '/pollution'); }; diff --git a/src/routes/(app)/dashboard/reminders/+page.ts b/src/routes/(app)/dashboard/reminders/+page.ts index 71126358..f71f8f56 100644 --- a/src/routes/(app)/dashboard/reminders/+page.ts +++ b/src/routes/(app)/dashboard/reminders/+page.ts @@ -1,16 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -/** - * Load function that checks if the Reminders feature is enabled - * Redirects to overview if disabled - */ -export const load: PageLoad = async ({ parent }) => { - const { configs } = await parent(); - - if (configs?.featureReminders === false) { - throw redirect(307, '/dashboard/overview'); - } - - return {}; +export const load: PageLoad = async () => { + throw redirect(307, '/reminders'); }; diff --git a/src/routes/(app)/fuel/+page.svelte b/src/routes/(app)/fuel/+page.svelte new file mode 100644 index 00000000..be0b1aa0 --- /dev/null +++ b/src/routes/(app)/fuel/+page.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/routes/(app)/insurance/+page.svelte b/src/routes/(app)/insurance/+page.svelte new file mode 100644 index 00000000..ab237fef --- /dev/null +++ b/src/routes/(app)/insurance/+page.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/routes/(app)/maintenance/+page.svelte b/src/routes/(app)/maintenance/+page.svelte new file mode 100644 index 00000000..064bb704 --- /dev/null +++ b/src/routes/(app)/maintenance/+page.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/routes/(app)/pollution/+page.svelte b/src/routes/(app)/pollution/+page.svelte new file mode 100644 index 00000000..9a665e7d --- /dev/null +++ b/src/routes/(app)/pollution/+page.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/routes/(app)/reminders/+page.svelte b/src/routes/(app)/reminders/+page.svelte new file mode 100644 index 00000000..45dd1038 --- /dev/null +++ b/src/routes/(app)/reminders/+page.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/routes/(app)/vehicles/+page.svelte b/src/routes/(app)/vehicles/+page.svelte new file mode 100644 index 00000000..dc26cd78 --- /dev/null +++ b/src/routes/(app)/vehicles/+page.svelte @@ -0,0 +1,23 @@ + + +
    + + + + + +
    From b165861426b295291784068a2bbfea3e8e2dba5a Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Fri, 24 Jul 2026 23:19:45 +0530 Subject: [PATCH 06/23] Phase 3: Dashboard home - Fleet summary endpoint + full dashboard page - Create GET /api/dashboard/summary endpoint with fleet-wide aggregates - Create dashboardService on the server computing totals, expenses, PUC status, vehicle health, reminders - Create client-side dashboard store - Build full dashboard home page with: - Greeting header + Add Vehicle button - 4 KPI stat cards (Vehicles, Distance, Fuel, Expenses) - Expenses donut chart with legend - My Vehicles compact list - Vehicle Health ProgressRing with Good/Attention/Needs Action bars - PUC Status donut chart - Upcoming Reminders list with time-until-due chips - Bottom CTA banner for Reports --- src/lib/stores/dashboard.svelte.ts | 47 ++++ src/routes/(app)/dashboard/+page.svelte | 233 +++++++++++++++++++- src/routes/api/dashboard/summary/+server.ts | 13 ++ src/server/services/dashboardService.ts | 221 +++++++++++++++++++ 4 files changed, 502 insertions(+), 12 deletions(-) create mode 100644 src/lib/stores/dashboard.svelte.ts create mode 100644 src/routes/api/dashboard/summary/+server.ts create mode 100644 src/server/services/dashboardService.ts diff --git a/src/lib/stores/dashboard.svelte.ts b/src/lib/stores/dashboard.svelte.ts new file mode 100644 index 00000000..6e85dba9 --- /dev/null +++ b/src/lib/stores/dashboard.svelte.ts @@ -0,0 +1,47 @@ +import { apiClient } from '$lib/helper/api.helper'; + +interface DashboardSummary { + totalVehicles: number; + totalFuelUsed: number; + totalDistance: number; + totalExpenses: number; + expenseBreakdown: { fuel: number; maintenance: number; insurance: number }; + puccStatus: { valid: number; expiringSoon: number; expired: number; notAvailable: number }; + vehicleHealth: { good: number; attention: number; needsAction: number }; + upcomingReminders: Array<{ + id: string; + vehicleId: string; + vehicleName: string; + vehiclePlate: string | null; + type: string; + note: string | null; + dueDate: string; + daysUntilDue: number; + }>; +} + +class DashboardStore { + summary = $state(null); + loading = $state(false); + error = $state(); + + async fetchSummary() { + this.loading = true; + this.error = undefined; + try { + const { data: res } = await apiClient.get<{ success: boolean; data: DashboardSummary }>( + '/dashboard/summary' + ); + if (res.success && res.data) { + this.summary = res.data; + } + } catch (err) { + this.error = 'Failed to load dashboard summary'; + console.error(err); + } finally { + this.loading = false; + } + } +} + +export const dashboardStore = new DashboardStore(); diff --git a/src/routes/(app)/dashboard/+page.svelte b/src/routes/(app)/dashboard/+page.svelte index 5d57ddaa..91b038f8 100644 --- a/src/routes/(app)/dashboard/+page.svelte +++ b/src/routes/(app)/dashboard/+page.svelte @@ -1,18 +1,88 @@ -
    - +
    + + + +
    - 0 ? `${(totalDistance / 1000).toFixed(1)}k km` : '--'} color="bg-violet-500/10 text-violet-500" /> + 0 ? `${totalFuelUsed.toFixed(1)} L` : '--'} + color="bg-green-500/10 text-green-500" + /> 0 ? `$${totalExpenses.toFixed(2)}` : '--'} color="bg-amber-500/10 text-amber-500" />
    -
    -

    Dashboard charts and widgets coming in Phase 3

    + +
    + +
    +

    Fuel Consumption Chart

    +
    + + + +
    + + +
    + +
    +

    My Vehicles

    + {#if vehicleStore.vehicles && vehicleStore.vehicles.length > 0} +
    + {#each vehicleStore.vehicles as vehicle (vehicle.id)} + vehicleStore.selectedId = vehicle.id} /> + {/each} + +
    + {:else} +
    + No vehicles yet +
    + {/if} +
    + + +
    + +
    +

    Vehicle Health

    +
    + + {#if summary} +
    +
    + + + Good + + {summary.vehicleHealth.good} +
    +
    + + + Attention + + {summary.vehicleHealth.attention} +
    +
    + + + Needs Action + + {summary.vehicleHealth.needsAction} +
    +
    + {:else} +
    Loading...
    + {/if} +
    +
    + + +
    +

    PUC Status

    + +
    +
    +
    + + +
    +
    +

    Upcoming Reminders

    + +
    + {#if summary && summary.upcomingReminders.length > 0} +
    + {#each summary.upcomingReminders.slice(0, 5) as reminder (reminder.id)} +
    + +
    +

    + {reminder.vehicleName} + {#if reminder.vehiclePlate} + ({reminder.vehiclePlate}) + {/if} +

    +

    {reminder.note || reminder.type}

    +
    + +
    + {/each} +
    + {:else if loading} +
    Loading reminders...
    + {:else} +
    No upcoming reminders
    + {/if} +
    + + +
    +
    +

    Get detailed insights

    +

    View comprehensive reports and analytics for your fleet

    +
    +
    diff --git a/src/routes/api/dashboard/summary/+server.ts b/src/routes/api/dashboard/summary/+server.ts new file mode 100644 index 00000000..27b41c18 --- /dev/null +++ b/src/routes/api/dashboard/summary/+server.ts @@ -0,0 +1,13 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getDashboardSummary } from '$server/services/dashboardService'; + +export const GET: RequestHandler = async () => { + try { + const summary = await getDashboardSummary(); + return json({ success: true, data: summary }); + } catch (error) { + console.error('Failed to fetch dashboard summary:', error); + return json({ success: false, error: 'Failed to fetch dashboard summary' }, { status: 500 }); + } +}; diff --git a/src/server/services/dashboardService.ts b/src/server/services/dashboardService.ts new file mode 100644 index 00000000..4bab6a5e --- /dev/null +++ b/src/server/services/dashboardService.ts @@ -0,0 +1,221 @@ +import * as schema from '../db/schema/index'; +import { db } from '../db/index'; +import { eq, sql, and, gte, lte } from 'drizzle-orm'; + +const EXPIRING_SOON_DAYS = 30; + +export interface DashboardSummary { + totalVehicles: number; + totalFuelUsed: number; + totalDistance: number; + totalExpenses: number; + expenseBreakdown: { + fuel: number; + maintenance: number; + insurance: number; + }; + puccStatus: { + valid: number; + expiringSoon: number; + expired: number; + notAvailable: number; + }; + vehicleHealth: { + good: number; + attention: number; + needsAction: number; + }; + upcomingReminders: Array<{ + id: string; + vehicleId: string; + vehicleName: string; + vehiclePlate: string | null; + type: string; + note: string | null; + dueDate: string; + daysUntilDue: number; + }>; +} + +export async function getDashboardSummary(): Promise { + const today = new Date(); + const thirtyDaysFromNow = new Date(today.getTime() + EXPIRING_SOON_DAYS * 24 * 60 * 60 * 1000); + + const [ + vehicles, + fuelLogs, + maintenanceLogs, + insurances, + pollutionCerts, + reminders, + allInsurancesForExpense + ] = await Promise.all([ + db.query.vehicleTable.findMany({ + columns: { id: true, make: true, model: true, licensePlate: true, image: true, odometer: true } + }), + db.query.fuelLogTable.findMany({ + columns: { fuelAmount: true, cost: true, filled: true, odometer: true, date: true, vehicleId: true } + }), + db.query.maintenanceLogTable.findMany({ + columns: { cost: true, odometer: true, vehicleId: true } + }), + db.query.insuranceTable.findMany({ + columns: { vehicleId: true, endDate: true, cost: true } + }), + db.query.pollutionCertificateTable.findMany({ + columns: { vehicleId: true, expiryDate: true } + }), + db.query.reminderTable.findMany({ + columns: { + id: true, vehicleId: true, type: true, note: true, dueDate: true, isCompleted: true + } + }), + db.query.insuranceTable.findMany({ + columns: { cost: true } + }) + ]); + + // Totals + const totalVehicles = vehicles.length; + const totalFuelUsed = fuelLogs.reduce((sum, l) => sum + (l.fuelAmount || 0), 0); + const totalFuelCost = fuelLogs.reduce((sum, l) => sum + (l.cost || 0), 0); + const totalMaintenanceCost = maintenanceLogs.reduce((sum, l) => sum + (l.cost || 0), 0); + const totalInsuranceCost = allInsurancesForExpense.reduce((sum, i) => sum + (i.cost || 0), 0); + const totalDistance = fuelLogs + .filter((l) => l.odometer && l.filled) + .reduce((sum, l, i, arr) => { + if (i === 0) return sum; + const prev = arr[i - 1]; + if (prev.odometer && l.odometer && l.odometer > prev.odometer) { + return sum + (l.odometer - prev.odometer); + } + return sum; + }, 0); + const totalExpenses = totalFuelCost + totalMaintenanceCost + totalInsuranceCost; + + // PUC Status + const puccByVehicle = new Map(); + for (const cert of pollutionCerts) { + if (cert.expiryDate) { + const existing = puccByVehicle.get(cert.vehicleId); + const certDate = new Date(cert.expiryDate); + if (!existing || certDate > existing) { + puccByVehicle.set(cert.vehicleId, certDate); + } + } + } + + let puccValid = 0; + let puccExpiringSoon = 0; + let puccExpired = 0; + let puccNotAvailable = 0; + + for (const vehicle of vehicles) { + const expiryDate = puccByVehicle.get(vehicle.id); + if (!expiryDate) { + puccNotAvailable++; + } else if (expiryDate < today) { + puccExpired++; + } else if (expiryDate <= thirtyDaysFromNow) { + puccExpiringSoon++; + } else { + puccValid++; + } + } + + // Vehicle Health + const insuranceByVehicle = new Map(); + for (const ins of insurances) { + if (ins.endDate) { + const existing = insuranceByVehicle.get(ins.vehicleId); + const insDate = new Date(ins.endDate); + if (!existing || insDate > existing) { + insuranceByVehicle.set(ins.vehicleId, insDate); + } + } + } + + const remindersByVehicle = new Map(); + for (const r of reminders) { + if (!remindersByVehicle.has(r.vehicleId)) { + remindersByVehicle.set(r.vehicleId, []); + } + remindersByVehicle.get(r.vehicleId)!.push(r); + } + + let healthGood = 0; + let healthAttention = 0; + let healthNeedsAction = 0; + + for (const vehicle of vehicles) { + const insDate = insuranceByVehicle.get(vehicle.id); + const puccDate = puccByVehicle.get(vehicle.id); + const vehicleReminders = remindersByVehicle.get(vehicle.id) || []; + + const hasExpiredPucc = puccDate && new Date(puccDate) < today; + const hasExpiredInsurance = insDate && new Date(insDate) < today; + const hasExpiringPucc = puccDate && new Date(puccDate) <= thirtyDaysFromNow && new Date(puccDate) >= today; + const hasExpiringInsurance = insDate && new Date(insDate) <= thirtyDaysFromNow && new Date(insDate) >= today; + const hasOverdueReminder = vehicleReminders.some( + (r) => !r.isCompleted && r.dueDate && new Date(r.dueDate) < today + ); + const hasUpcomingReminder = vehicleReminders.some( + (r) => !r.isCompleted && r.dueDate && new Date(r.dueDate) <= thirtyDaysFromNow && new Date(r.dueDate) >= today + ); + + if (hasExpiredPucc || hasExpiredInsurance || hasOverdueReminder) { + healthNeedsAction++; + } else if (hasExpiringPucc || hasExpiringInsurance || hasUpcomingReminder) { + healthAttention++; + } else { + healthGood++; + } + } + + // Upcoming Reminders + const vehicleMap = new Map(vehicles.map((v) => [v.id, v])); + + const upcomingReminders = reminders + .filter((r) => !r.isCompleted && r.dueDate) + .map((r) => { + const dueDate = new Date(r.dueDate!); + const vehicle = vehicleMap.get(r.vehicleId); + return { + id: r.id, + vehicleId: r.vehicleId, + vehicleName: vehicle ? `${vehicle.make} ${vehicle.model}` : 'Unknown', + vehiclePlate: vehicle?.licensePlate ?? null, + type: r.type, + note: r.note, + dueDate: dueDate.toISOString(), + daysUntilDue: Math.ceil((dueDate.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)) + }; + }) + .filter((r) => r.daysUntilDue >= 0) + .sort((a, b) => a.daysUntilDue - b.daysUntilDue) + .slice(0, 10); + + return { + totalVehicles, + totalFuelUsed, + totalDistance, + totalExpenses, + expenseBreakdown: { + fuel: totalFuelCost, + maintenance: totalMaintenanceCost, + insurance: totalInsuranceCost + }, + puccStatus: { + valid: puccValid, + expiringSoon: puccExpiringSoon, + expired: puccExpired, + notAvailable: puccNotAvailable + }, + vehicleHealth: { + good: healthGood, + attention: healthAttention, + needsAction: healthNeedsAction + }, + upcomingReminders + }; +} From fa1e9331960895ee5e9a72a0b1bad5c9babe9671 Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Fri, 24 Jul 2026 23:22:03 +0530 Subject: [PATCH 07/23] Phase 4: Feature page redesigns with fleet-wide support and vehicle selectors - Fuel: Add vehicle selector, 4 stat cards (Fuel Used, Total Spent, Avg Mileage, Entries) - Maintenance: Add vehicle selector, 4 stat cards (Total Spent, Next Service, Odometer, Records) - Reminders: Fleet-wide endpoint GET /api/reminders, filter tabs, status pills - PUC/Pollution: Fleet-wide endpoint GET /api/pucc/status, 4 stat cards with %, filter tabs, table - Insurance: Add vehicle selector, restyle in sidebar shell - Vehicles: Responsive card grid with add vehicle tile - All pages use FeatureGate for disabled feature handling --- .gitignore | 1 + src/routes/(app)/fuel/+page.svelte | 118 +++++++++++++-- src/routes/(app)/insurance/+page.svelte | 69 +++++++-- src/routes/(app)/maintenance/+page.svelte | 113 ++++++++++++-- src/routes/(app)/pollution/+page.svelte | 175 ++++++++++++++++++++-- src/routes/(app)/reminders/+page.svelte | 152 +++++++++++++++++-- src/routes/(app)/vehicles/+page.svelte | 29 +++- src/routes/api/pucc/status/+server.ts | 58 +++++++ src/routes/api/reminders/+server.ts | 47 ++++++ 9 files changed, 696 insertions(+), 66 deletions(-) create mode 100644 src/routes/api/pucc/status/+server.ts create mode 100644 src/routes/api/reminders/+server.ts diff --git a/.gitignore b/.gitignore index 3bd67cb1..d54df9a2 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,6 @@ uploads/ src/lib/paraglide project.inlang/cache/ .opencode +opencode.json .app/ docs/planning diff --git a/src/routes/(app)/fuel/+page.svelte b/src/routes/(app)/fuel/+page.svelte index be0b1aa0..e41880bc 100644 --- a/src/routes/(app)/fuel/+page.svelte +++ b/src/routes/(app)/fuel/+page.svelte @@ -1,17 +1,111 @@ - + + {#snippet children()} +
    + +
    + +
    +
    + +
    + 0 ? `${totalFuelUsed.toFixed(1)} L` : '--'} + color="bg-green-500/10 text-green-500" + /> + 0 ? `$${totalCost.toFixed(2)}` : '--'} + color="bg-amber-500/10 text-amber-500" + /> + 0 ? avgMileage.toFixed(1) : '--'} + color="bg-blue-500/10 text-blue-500" + /> + +
    + +
    + + +
    + + +
    + {/snippet} + {#snippet fallback()} +
    +
    +

    {feature_fuel_disabled_title()}

    +

    {feature_fuel_disabled_hint()}

    +
    +
    + {/snippet} +
    diff --git a/src/routes/(app)/insurance/+page.svelte b/src/routes/(app)/insurance/+page.svelte index ab237fef..d551ab40 100644 --- a/src/routes/(app)/insurance/+page.svelte +++ b/src/routes/(app)/insurance/+page.svelte @@ -1,17 +1,62 @@ - + + {#snippet children()} +
    + +
    + +
    +
    + + +
    + {/snippet} + {#snippet fallback()} +
    +
    +

    {feature_insurance_disabled_title()}

    +

    {feature_insurance_disabled_hint()}

    +
    +
    + {/snippet} +
    diff --git a/src/routes/(app)/maintenance/+page.svelte b/src/routes/(app)/maintenance/+page.svelte index 064bb704..fdeea815 100644 --- a/src/routes/(app)/maintenance/+page.svelte +++ b/src/routes/(app)/maintenance/+page.svelte @@ -1,17 +1,106 @@ - + + {#snippet children()} +
    + +
    + +
    +
    + + +
    + 0 ? `$${totalCost.toFixed(2)}` : '--'} + color="bg-blue-500/10 text-blue-500" + /> + 0 ? `${nextServiceOdometer.toLocaleString()} km` : '--'} + color="bg-amber-500/10 text-amber-500" + /> + 0 ? `${latestOdometer.toLocaleString()} km` : '--'} + color="bg-green-500/10 text-green-500" + /> + +
    + + +
    + {/snippet} + {#snippet fallback()} +
    +
    +

    {feature_maintenance_disabled_title()}

    +

    {feature_maintenance_disabled_hint()}

    +
    +
    + {/snippet} +
    diff --git a/src/routes/(app)/pollution/+page.svelte b/src/routes/(app)/pollution/+page.svelte index 9a665e7d..26e240d2 100644 --- a/src/routes/(app)/pollution/+page.svelte +++ b/src/routes/(app)/pollution/+page.svelte @@ -1,17 +1,166 @@ - + + {#snippet children()} +
    + + + +
    + + + + +
    + + +
    + {#each tabs as tab} + + {/each} +
    + + + {#if loading} +
    Loading PUC status...
    + {:else if filteredRecords.length === 0} +
    No PUC records found
    + {:else} +
    + + + + + + + + + + + + {#each filteredRecords as record (record.vehicleId)} + + + + + + + + {/each} + +
    VehicleReg. No.PUC No.ExpiryStatus
    {record.vehicleName}{record.licensePlate ?? '--'}{record.certificateNumber ?? '--'} + {record.expiryDate ? new Date(record.expiryDate).toLocaleDateString() : '--'} + + +
    +
    + {/if} +
    + {/snippet} + {#snippet fallback()} +
    +
    +

    {feature_pucc_disabled_title()}

    +

    {feature_pucc_disabled_hint()}

    +
    +
    + {/snippet} +
    diff --git a/src/routes/(app)/reminders/+page.svelte b/src/routes/(app)/reminders/+page.svelte index 45dd1038..6a05594c 100644 --- a/src/routes/(app)/reminders/+page.svelte +++ b/src/routes/(app)/reminders/+page.svelte @@ -1,17 +1,143 @@ - + + {#snippet children()} +
    + + + +
    + {#each tabs as tab} + + {/each} +
    + + + {#if loading} +
    Loading reminders...
    + {:else if filteredReminders.length === 0} +
    No reminders found
    + {:else} +
    + {#each filteredReminders as reminder (reminder.id)} + {@const Icon = typeIcon(reminder.type)} + {@const dueDate = new Date(reminder.dueDate)} + {@const daysUntilDue = Math.ceil((dueDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))} +
    +
    + +
    +
    +

    + {reminder.vehicleMake} {reminder.vehicleModel} + {#if reminder.vehiclePlate} + ({reminder.vehiclePlate}) + {/if} +

    +

    {reminder.type}

    + {#if reminder.note} +

    {reminder.note}

    + {/if} +
    +
    +

    {dueDate.toLocaleDateString()}

    + +
    +
    + {/each} +
    + {/if} +
    + {/snippet} + {#snippet fallback()} +
    +
    +

    {feature_reminders_disabled_title()}

    +

    {feature_reminders_disabled_hint()}

    +
    +
    + {/snippet} +
    diff --git a/src/routes/(app)/vehicles/+page.svelte b/src/routes/(app)/vehicles/+page.svelte index dc26cd78..c687a4bb 100644 --- a/src/routes/(app)/vehicles/+page.svelte +++ b/src/routes/(app)/vehicles/+page.svelte @@ -2,15 +2,17 @@ import PageHeader from '$dashboard/PageHeader.svelte'; import LabelWithIcon from '$appui/LabelWithIcon.svelte'; import VehicleForm from '$feature/vehicle/VehicleForm.svelte'; - import VehicleList from '$feature/vehicle/VehicleList.svelte'; + import VehicleCard from '$feature/vehicle/VehicleCard.svelte'; + import CardGridSkeleton from '$appui/CardGridSkeleton.svelte'; + import { vehicleStore } from '$stores/vehicle.svelte'; import { sheetStore } from '$stores/sheet.svelte'; import Button from '$ui/button/button.svelte'; import CirclePlus from '@lucide/svelte/icons/circle-plus'; - import { app_add_vehicle } from '$lib/paraglide/messages/_index.js'; + import { app_add_vehicle, vehicle_list_empty } from '$lib/paraglide/messages/_index.js';
    - + - + {#if vehicleStore.processing} + + {:else if vehicleStore.vehicles && vehicleStore.vehicles.length > 0} +
    + {#each vehicleStore.vehicles as vehicle (vehicle.id)} + + {/each} + +
    + {:else} +
    +

    {vehicle_list_empty()}

    +
    + {/if}
    diff --git a/src/routes/api/pucc/status/+server.ts b/src/routes/api/pucc/status/+server.ts new file mode 100644 index 00000000..751e13e5 --- /dev/null +++ b/src/routes/api/pucc/status/+server.ts @@ -0,0 +1,58 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$server/db/index'; +import * as schema from '$server/db/schema/index'; +import { eq, sql, max } from 'drizzle-orm'; + +export const GET: RequestHandler = async () => { + const today = new Date(); + const thirtyDaysFromNow = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); + + // Get all vehicles with their latest PUC certificate + const vehicles = await db.query.vehicleTable.findMany({ + columns: { id: true, make: true, model: true, licensePlate: true, image: true } + }); + + // Get all PUC certificates + const allCerts = await db.query.pollutionCertificateTable.findMany({ + columns: { vehicleId: true, certificateNumber: true, issueDate: true, expiryDate: true } + }); + + // Get latest cert per vehicle + const latestCerts = new Map(); + for (const cert of allCerts) { + const existing = latestCerts.get(cert.vehicleId); + if (!existing || (cert.expiryDate && existing.expiryDate && new Date(cert.expiryDate) > new Date(existing.expiryDate))) { + latestCerts.set(cert.vehicleId, cert); + } + } + + const records = vehicles.map((vehicle) => { + const cert = latestCerts.get(vehicle.id); + let status: 'valid' | 'expiring_soon' | 'expired' | 'not_available'; + + if (!cert || !cert.expiryDate) { + status = 'not_available'; + } else { + const expiryDate = new Date(cert.expiryDate); + if (expiryDate < today) { + status = 'expired'; + } else if (expiryDate <= thirtyDaysFromNow) { + status = 'expiring_soon'; + } else { + status = 'valid'; + } + } + + return { + vehicleId: vehicle.id, + vehicleName: `${vehicle.make} ${vehicle.model}`, + licensePlate: vehicle.licensePlate, + certificateNumber: cert?.certificateNumber ?? null, + expiryDate: cert?.expiryDate ?? null, + status + }; + }); + + return json({ success: true, data: records }); +}; diff --git a/src/routes/api/reminders/+server.ts b/src/routes/api/reminders/+server.ts new file mode 100644 index 00000000..39c1c814 --- /dev/null +++ b/src/routes/api/reminders/+server.ts @@ -0,0 +1,47 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$server/db/index'; +import * as schema from '$server/db/schema/index'; +import { eq, and, sql } from 'drizzle-orm'; + +export const GET: RequestHandler = async ({ url }) => { + const type = url.searchParams.get('type'); + const status = url.searchParams.get('status'); + + const conditions = []; + + if (type && type !== 'all') { + conditions.push(eq(schema.reminderTable.type, type)); + } + if (status === 'completed') { + conditions.push(eq(schema.reminderTable.isCompleted, true)); + } else if (status === 'upcoming') { + conditions.push(eq(schema.reminderTable.isCompleted, false)); + } else { + conditions.push(eq(schema.reminderTable.isCompleted, false)); + } + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const reminders = await db + .select({ + id: schema.reminderTable.id, + vehicleId: schema.reminderTable.vehicleId, + type: schema.reminderTable.type, + note: schema.reminderTable.note, + dueDate: schema.reminderTable.dueDate, + isCompleted: schema.reminderTable.isCompleted, + remindSchedule: schema.reminderTable.remindSchedule, + recurrenceType: schema.reminderTable.recurrenceType, + recurrenceInterval: schema.reminderTable.recurrenceInterval, + vehicleMake: schema.vehicleTable.make, + vehicleModel: schema.vehicleTable.model, + vehiclePlate: schema.vehicleTable.licensePlate + }) + .from(schema.reminderTable) + .leftJoin(schema.vehicleTable, eq(schema.reminderTable.vehicleId, schema.vehicleTable.id)) + .where(where) + .orderBy(schema.reminderTable.dueDate); + + return json({ success: true, data: reminders }); +}; From c7f6494774a30d3f00678b8fe79642907e22f2a4 Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Fri, 24 Jul 2026 23:28:29 +0530 Subject: [PATCH 08/23] Phase 6: Cleanup & verification - Remove dead code, fix type/lint errors - Remove DashboardNav, DashboardTabs, OverviewTab (dead components) - Add path alias to svelte.config.js - Fix all TypeScript type errors (VehicleCard props, status types, null checks) - Fix all lint errors (unused imports, unused variables) - Add Expenses and Reports pages as sidebar nav items - Run pnpm check, pnpm lint, pnpm format, pnpm test - all passing --- .../components/dashboard/DonutChart.svelte | 22 +- .../components/dashboard/ProgressRing.svelte | 11 +- .../components/dashboard/StatusPill.svelte | 25 ++- .../feature/overview/OverviewTab.svelte | 25 --- src/lib/components/layout/AppSidebar.svelte | 42 ++-- src/lib/components/layout/DashboardNav.svelte | 132 ----------- .../components/layout/DashboardTabs.svelte | 99 --------- .../components/ui/avatar/avatar-badge.svelte | 38 ++-- .../ui/avatar/avatar-fallback.svelte | 28 +-- .../ui/avatar/avatar-group-count.svelte | 32 +-- .../components/ui/avatar/avatar-group.svelte | 32 +-- .../components/ui/avatar/avatar-image.svelte | 22 +- src/lib/components/ui/avatar/avatar.svelte | 40 ++-- src/lib/components/ui/avatar/index.ts | 38 ++-- src/lib/components/ui/button/button.svelte | 147 +++++++------ src/lib/components/ui/button/index.ts | 26 +-- src/lib/components/ui/progress/index.ts | 8 +- .../components/ui/progress/progress.svelte | 43 ++-- src/lib/components/ui/range-calendar/index.ts | 74 +++---- .../range-calendar-caption.svelte | 124 +++++------ .../range-calendar/range-calendar-cell.svelte | 26 +-- .../range-calendar/range-calendar-day.svelte | 60 ++--- .../range-calendar-grid-body.svelte | 4 +- .../range-calendar-grid-head.svelte | 4 +- .../range-calendar-grid-row.svelte | 16 +- .../range-calendar/range-calendar-grid.svelte | 20 +- .../range-calendar-head-cell.svelte | 26 +-- .../range-calendar-header.svelte | 26 +-- .../range-calendar-heading.svelte | 20 +- .../range-calendar-month-select.svelte | 74 +++---- .../range-calendar-month.svelte | 20 +- .../range-calendar-months.svelte | 24 +- .../range-calendar/range-calendar-nav.svelte | 24 +- .../range-calendar-next-button.svelte | 52 ++--- .../range-calendar-prev-button.svelte | 52 ++--- .../range-calendar-year-select.svelte | 72 +++--- .../ui/range-calendar/range-calendar.svelte | 206 +++++++++--------- src/routes/(app)/+layout.svelte | 5 +- src/routes/(app)/dashboard/+page.svelte | 71 +++--- .../(app)/dashboard/overview/+page.svelte | 18 +- src/routes/(app)/expenses/+page.svelte | 67 ++++++ src/routes/(app)/fuel/+page.svelte | 16 +- src/routes/(app)/insurance/+page.svelte | 28 ++- src/routes/(app)/maintenance/+page.svelte | 24 +- src/routes/(app)/pollution/+page.svelte | 13 +- src/routes/(app)/reminders/+page.svelte | 41 +++- src/routes/(app)/reports/+page.svelte | 58 +++++ src/routes/(app)/vehicles/+page.svelte | 29 ++- src/routes/+layout.svelte | 3 +- src/routes/api/pucc/status/+server.ts | 11 +- src/routes/api/reminders/+server.ts | 2 +- src/server/services/dashboardService.ts | 39 +++- svelte.config.js | 1 + 53 files changed, 1048 insertions(+), 1112 deletions(-) delete mode 100644 src/lib/components/feature/overview/OverviewTab.svelte delete mode 100644 src/lib/components/layout/DashboardNav.svelte delete mode 100644 src/lib/components/layout/DashboardTabs.svelte create mode 100644 src/routes/(app)/expenses/+page.svelte create mode 100644 src/routes/(app)/reports/+page.svelte diff --git a/src/lib/components/dashboard/DonutChart.svelte b/src/lib/components/dashboard/DonutChart.svelte index c1b8bb67..a513c854 100644 --- a/src/lib/components/dashboard/DonutChart.svelte +++ b/src/lib/components/dashboard/DonutChart.svelte @@ -1,6 +1,6 @@ -
    +
    {#if title}
    {title} @@ -60,7 +58,10 @@ {#if loading}
    - +
    {#each [0, 1, 2] as i (i)} @@ -90,9 +91,8 @@ {#snippet tooltip()} {#snippet formatter({ value, name })} - {@const pct = total > 0 && typeof value === 'number' - ? roundToDec((value / total) * 100, 1) - : 0} + {@const pct = + total > 0 && typeof value === 'number' ? roundToDec((value / total) * 100, 1) : 0} {name} {typeof value === 'number' ? value.toLocaleString() : value} ({pct}%) @@ -104,7 +104,9 @@ {#if showLegend && itemsWithPercent.length > 0} -
    +
    {#each itemsWithPercent as item (item.name)}
    {/if} {/if} -
    \ No newline at end of file +
    diff --git a/src/lib/components/dashboard/ProgressRing.svelte b/src/lib/components/dashboard/ProgressRing.svelte index 239e77a3..9628cff7 100644 --- a/src/lib/components/dashboard/ProgressRing.svelte +++ b/src/lib/components/dashboard/ProgressRing.svelte @@ -21,7 +21,7 @@ backgroundColor = 'var(--muted)', showLabel = true, label = undefined, - children = undefined, + children = undefined }: Props = $props(); const center = $derived(size / 2); @@ -72,14 +72,17 @@
    {:else if showLabel}
    - + {Math.round(value)}% {#if label} - + {label} {/if}
    {/if} -
    \ No newline at end of file +
    diff --git a/src/lib/components/dashboard/StatusPill.svelte b/src/lib/components/dashboard/StatusPill.svelte index a7c2a284..9db7371b 100644 --- a/src/lib/components/dashboard/StatusPill.svelte +++ b/src/lib/components/dashboard/StatusPill.svelte @@ -2,7 +2,15 @@ import Badge from '$ui/badge/badge.svelte'; import { cn } from '$lib/utils'; - type Status = 'valid' | 'expiring_soon' | 'expired' | 'active' | 'needs_action' | 'attention' | 'good'; + type Status = + | 'valid' + | 'expiring_soon' + | 'expired' + | 'active' + | 'needs_action' + | 'attention' + | 'good' + | 'not_available'; interface Props { status: Status; @@ -18,7 +26,8 @@ active: 'Active', needs_action: 'Needs Action', attention: 'Attention', - good: 'Good' + good: 'Good', + not_available: 'N/A' }; const displayLabel = $derived(label ?? defaultLabels[status]); @@ -28,7 +37,9 @@ ? 'destructive' : status === 'expiring_soon' || status === 'attention' ? 'secondary' - : 'default' + : status === 'not_available' + ? 'outline' + : 'default' ); const colorClass = $derived( @@ -36,10 +47,12 @@ ? 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300 border-green-200 dark:border-green-800' : status === 'expiring_soon' || status === 'attention' ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300 border-amber-200 dark:border-amber-800' - : 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300 border-red-200 dark:border-red-800' + : status === 'not_available' + ? 'bg-gray-100 text-gray-800 dark:bg-gray-900/50 dark:text-gray-300 border-gray-200 dark:border-gray-800' + : 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300 border-red-200 dark:border-red-800' ); - + {displayLabel} - \ No newline at end of file + diff --git a/src/lib/components/feature/overview/OverviewTab.svelte b/src/lib/components/feature/overview/OverviewTab.svelte deleted file mode 100644 index e57521d9..00000000 --- a/src/lib/components/feature/overview/OverviewTab.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - -
    -
    - -
    - - -
    -
    -
    -
    diff --git a/src/lib/components/layout/AppSidebar.svelte b/src/lib/components/layout/AppSidebar.svelte index a217f384..38bd226e 100644 --- a/src/lib/components/layout/AppSidebar.svelte +++ b/src/lib/components/layout/AppSidebar.svelte @@ -11,6 +11,8 @@ import Bell from '@lucide/svelte/icons/bell'; import BadgeInfo from '@lucide/svelte/icons/badge-info'; import Shield from '@lucide/svelte/icons/shield'; + import Banknote from '@lucide/svelte/icons/banknote'; + import BarChart3 from '@lucide/svelte/icons/bar-chart-3'; import Settings from '@lucide/svelte/icons/settings'; import LogOut from '@lucide/svelte/icons/log-out'; import UserCog from '@lucide/svelte/icons/user-cog'; @@ -74,20 +76,17 @@ href: '/insurance', icon: Shield, featureKey: 'featureInsurance' + }, + { + label: 'Expenses', + href: '/expenses', + icon: Banknote + }, + { + label: 'Reports', + href: '/reports', + icon: BarChart3 } - // TODO: Optional nav items (enable when routes are ready) - // { - // label: 'Expenses', - // href: '/dashboard/expenses', - // icon: Banknote, - // featureKey: 'featureExpenses' - // }, - // { - // label: 'Reports', - // href: '/dashboard/reports', - // icon: BarChart3, - // featureKey: 'featureReports' - // } ]; const visibleNavItems = $derived( @@ -97,9 +96,7 @@ }) ); - const userInitial = $derived( - authStore.user?.username?.charAt(0)?.toUpperCase() || 'U' - ); + const userInitial = $derived(authStore.user?.username?.charAt(0)?.toUpperCase() || 'U'); function handleNavClick(href: string) { goto(href, { noScroll: true, keepFocus: true }); @@ -178,12 +175,7 @@
    - +
    @@ -201,11 +193,7 @@ { - sheetStore.openSheet( - ProfileForm, - m.profile_sheet_title(), - m.profile_sheet_desc() - ); + sheetStore.openSheet(ProfileForm, m.profile_sheet_title(), m.profile_sheet_desc()); }} disabled={authStore.isAuthDisabled || env.DEMO_MODE} > diff --git a/src/lib/components/layout/DashboardNav.svelte b/src/lib/components/layout/DashboardNav.svelte deleted file mode 100644 index 4f22393c..00000000 --- a/src/lib/components/layout/DashboardNav.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - - diff --git a/src/lib/components/layout/DashboardTabs.svelte b/src/lib/components/layout/DashboardTabs.svelte deleted file mode 100644 index d30c3e61..00000000 --- a/src/lib/components/layout/DashboardTabs.svelte +++ /dev/null @@ -1,99 +0,0 @@ - - -{#if isLoading} -
    -
    - {#each Array.from({ length: 5 }) as index} - - {/each} -
    -
    - -
    - -
    - - -
    -
    -
    -
    -{:else} - - - {#each tabs as tab} - - - - {/each} - - {#each tabs as { id, Component } (id)} - - - - {/each} - -{/if} diff --git a/src/lib/components/ui/avatar/avatar-badge.svelte b/src/lib/components/ui/avatar/avatar-badge.svelte index ca9d9ed2..64e966d3 100644 --- a/src/lib/components/ui/avatar/avatar-badge.svelte +++ b/src/lib/components/ui/avatar/avatar-badge.svelte @@ -1,26 +1,26 @@ svg]:hidden", - "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", - "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", - className - )} - {...restProps} + bind:this={ref} + data-slot="avatar-badge" + class={cn( + 'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none', + 'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden', + 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', + 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', + className + )} + {...restProps} > - {@render children?.()} + {@render children?.()} diff --git a/src/lib/components/ui/avatar/avatar-fallback.svelte b/src/lib/components/ui/avatar/avatar-fallback.svelte index 63c8b022..2d4c1b02 100644 --- a/src/lib/components/ui/avatar/avatar-fallback.svelte +++ b/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -1,20 +1,20 @@ diff --git a/src/lib/components/ui/avatar/avatar-group-count.svelte b/src/lib/components/ui/avatar/avatar-group-count.svelte index c89684ab..21f72b09 100644 --- a/src/lib/components/ui/avatar/avatar-group-count.svelte +++ b/src/lib/components/ui/avatar/avatar-group-count.svelte @@ -1,23 +1,23 @@
    svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", - className - )} - {...restProps} + bind:this={ref} + data-slot="avatar-group-count" + class={cn( + 'bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', + className + )} + {...restProps} > - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/avatar/avatar-group.svelte b/src/lib/components/ui/avatar/avatar-group.svelte index 78714fc0..75743b4d 100644 --- a/src/lib/components/ui/avatar/avatar-group.svelte +++ b/src/lib/components/ui/avatar/avatar-group.svelte @@ -1,23 +1,23 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/avatar/avatar-image.svelte b/src/lib/components/ui/avatar/avatar-image.svelte index f41cf8fe..256c95ca 100644 --- a/src/lib/components/ui/avatar/avatar-image.svelte +++ b/src/lib/components/ui/avatar/avatar-image.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/avatar/avatar.svelte b/src/lib/components/ui/avatar/avatar.svelte index ea6fde56..2e9eb5c4 100644 --- a/src/lib/components/ui/avatar/avatar.svelte +++ b/src/lib/components/ui/avatar/avatar.svelte @@ -1,26 +1,26 @@ diff --git a/src/lib/components/ui/avatar/index.ts b/src/lib/components/ui/avatar/index.ts index 38ccef84..dc0d9ac3 100644 --- a/src/lib/components/ui/avatar/index.ts +++ b/src/lib/components/ui/avatar/index.ts @@ -1,22 +1,22 @@ -import Root from "./avatar.svelte"; -import Image from "./avatar-image.svelte"; -import Fallback from "./avatar-fallback.svelte"; -import Badge from "./avatar-badge.svelte"; -import Group from "./avatar-group.svelte"; -import GroupCount from "./avatar-group-count.svelte"; +import Root from './avatar.svelte'; +import Image from './avatar-image.svelte'; +import Fallback from './avatar-fallback.svelte'; +import Badge from './avatar-badge.svelte'; +import Group from './avatar-group.svelte'; +import GroupCount from './avatar-group-count.svelte'; export { - Root, - Image, - Fallback, - Badge, - Group, - GroupCount, - // - Root as Avatar, - Image as AvatarImage, - Fallback as AvatarFallback, - Badge as AvatarBadge, - Group as AvatarGroup, - GroupCount as AvatarGroupCount, + Root, + Image, + Fallback, + Badge, + Group, + GroupCount, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback, + Badge as AvatarBadge, + Group as AvatarGroup, + GroupCount as AvatarGroupCount }; diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index 89cedcb7..a4615a0d 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -1,82 +1,89 @@ {#if href} - - {@render children?.()} - + + {@render children?.()} + {:else} - + {/if} diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts index fb585d76..7fe4a81e 100644 --- a/src/lib/components/ui/button/index.ts +++ b/src/lib/components/ui/button/index.ts @@ -1,17 +1,17 @@ import Root, { - type ButtonProps, - type ButtonSize, - type ButtonVariant, - buttonVariants, -} from "./button.svelte"; + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants +} from './button.svelte'; export { - Root, - type ButtonProps as Props, - // - Root as Button, - buttonVariants, - type ButtonProps, - type ButtonSize, - type ButtonVariant, + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant }; diff --git a/src/lib/components/ui/progress/index.ts b/src/lib/components/ui/progress/index.ts index 25eee615..8685886f 100644 --- a/src/lib/components/ui/progress/index.ts +++ b/src/lib/components/ui/progress/index.ts @@ -1,7 +1,7 @@ -import Root from "./progress.svelte"; +import Root from './progress.svelte'; export { - Root, - // - Root as Progress, + Root, + // + Root as Progress }; diff --git a/src/lib/components/ui/progress/progress.svelte b/src/lib/components/ui/progress/progress.svelte index c4d15085..bc843339 100644 --- a/src/lib/components/ui/progress/progress.svelte +++ b/src/lib/components/ui/progress/progress.svelte @@ -1,27 +1,30 @@ -
    +
    diff --git a/src/lib/components/ui/range-calendar/index.ts b/src/lib/components/ui/range-calendar/index.ts index d2d258bc..e0fcf370 100644 --- a/src/lib/components/ui/range-calendar/index.ts +++ b/src/lib/components/ui/range-calendar/index.ts @@ -1,40 +1,40 @@ -import Root from "./range-calendar.svelte"; -import Cell from "./range-calendar-cell.svelte"; -import Day from "./range-calendar-day.svelte"; -import Grid from "./range-calendar-grid.svelte"; -import Header from "./range-calendar-header.svelte"; -import Months from "./range-calendar-months.svelte"; -import GridRow from "./range-calendar-grid-row.svelte"; -import Heading from "./range-calendar-heading.svelte"; -import HeadCell from "./range-calendar-head-cell.svelte"; -import NextButton from "./range-calendar-next-button.svelte"; -import PrevButton from "./range-calendar-prev-button.svelte"; -import MonthSelect from "./range-calendar-month-select.svelte"; -import YearSelect from "./range-calendar-year-select.svelte"; -import Caption from "./range-calendar-caption.svelte"; -import Nav from "./range-calendar-nav.svelte"; -import Month from "./range-calendar-month.svelte"; -import GridBody from "./range-calendar-grid-body.svelte"; -import GridHead from "./range-calendar-grid-head.svelte"; +import Root from './range-calendar.svelte'; +import Cell from './range-calendar-cell.svelte'; +import Day from './range-calendar-day.svelte'; +import Grid from './range-calendar-grid.svelte'; +import Header from './range-calendar-header.svelte'; +import Months from './range-calendar-months.svelte'; +import GridRow from './range-calendar-grid-row.svelte'; +import Heading from './range-calendar-heading.svelte'; +import HeadCell from './range-calendar-head-cell.svelte'; +import NextButton from './range-calendar-next-button.svelte'; +import PrevButton from './range-calendar-prev-button.svelte'; +import MonthSelect from './range-calendar-month-select.svelte'; +import YearSelect from './range-calendar-year-select.svelte'; +import Caption from './range-calendar-caption.svelte'; +import Nav from './range-calendar-nav.svelte'; +import Month from './range-calendar-month.svelte'; +import GridBody from './range-calendar-grid-body.svelte'; +import GridHead from './range-calendar-grid-head.svelte'; export { - Day, - Cell, - Grid, - Header, - Months, - GridRow, - Heading, - GridBody, - GridHead, - HeadCell, - NextButton, - PrevButton, - MonthSelect, - YearSelect, - Caption, - Nav, - Month, - // - Root as RangeCalendar, + Day, + Cell, + Grid, + Header, + Months, + GridRow, + Heading, + GridBody, + GridHead, + HeadCell, + NextButton, + PrevButton, + MonthSelect, + YearSelect, + Caption, + Nav, + Month, + // + Root as RangeCalendar }; diff --git a/src/lib/components/ui/range-calendar/range-calendar-caption.svelte b/src/lib/components/ui/range-calendar/range-calendar-caption.svelte index 944654d3..c7293861 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-caption.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-caption.svelte @@ -1,76 +1,76 @@ {#snippet MonthSelect()} - { - if (!placeholder) return; - const v = Number.parseInt(e.currentTarget.value); - const newPlaceholder = placeholder.set({ month: v }); - placeholder = newPlaceholder.subtract({ months: monthIndex }); - }} - /> + { + if (!placeholder) return; + const v = Number.parseInt(e.currentTarget.value); + const newPlaceholder = placeholder.set({ month: v }); + placeholder = newPlaceholder.subtract({ months: monthIndex }); + }} + /> {/snippet} {#snippet YearSelect()} - + {/snippet} -{#if captionLayout === "dropdown"} - {@render MonthSelect()} - {@render YearSelect()} -{:else if captionLayout === "dropdown-months"} - {@render MonthSelect()} - {#if placeholder} - {formatYear(placeholder)} - {/if} -{:else if captionLayout === "dropdown-years"} - {#if placeholder} - {formatMonth(placeholder)} - {/if} - {@render YearSelect()} +{#if captionLayout === 'dropdown'} + {@render MonthSelect()} + {@render YearSelect()} +{:else if captionLayout === 'dropdown-months'} + {@render MonthSelect()} + {#if placeholder} + {formatYear(placeholder)} + {/if} +{:else if captionLayout === 'dropdown-years'} + {#if placeholder} + {formatMonth(placeholder)} + {/if} + {@render YearSelect()} {:else} - {formatMonth(month)} {formatYear(month)} + {formatMonth(month)} {formatYear(month)} {/if} diff --git a/src/lib/components/ui/range-calendar/range-calendar-cell.svelte b/src/lib/components/ui/range-calendar/range-calendar-cell.svelte index e042cf1a..a323f68d 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-cell.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-cell.svelte @@ -1,19 +1,19 @@ diff --git a/src/lib/components/ui/range-calendar/range-calendar-day.svelte b/src/lib/components/ui/range-calendar/range-calendar-day.svelte index 0890592a..e5b6fc66 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-day.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-day.svelte @@ -1,36 +1,36 @@ span]:text-xs [&>span]:opacity-70", - className - )} - {...restProps} + bind:ref + class={cn( + 'flex size-(--cell-size) flex-col items-center justify-center gap-1 rounded-(--cell-radius) p-0 leading-none font-normal whitespace-nowrap select-none', + 'not-data-selected:hover:bg-accent/50 not-data-selected:hover:text-accent-foreground', + '[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground data-[range-middle]:rounded-none', + // range Start + 'data-[range-start]:bg-primary data-[range-start]:text-primary-foreground data-[range-start]:hover:text-foreground', + // range End + 'data-[range-end]:bg-primary data-[range-end]:text-primary-foreground data-[range-end]:hover:text-foreground', + // Outside months + '[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground', + // Disabled + 'data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', + // Unavailable + 'data-[unavailable]:line-through', + 'dark:data-[range-middle]:hover:bg-accent/0', + // focus + 'focus:border-ring focus:ring-ring/50 focus:relative', + // inner spans + '[&>span]:text-xs [&>span]:opacity-70', + className + )} + {...restProps} /> diff --git a/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte b/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte index 45a31d1f..a8f208fb 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-grid-body.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte b/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte index e3116255..6dd4372e 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-grid-head.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte b/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte index 3286b2aa..65eec90b 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte @@ -1,12 +1,12 @@ - + diff --git a/src/lib/components/ui/range-calendar/range-calendar-grid.svelte b/src/lib/components/ui/range-calendar/range-calendar-grid.svelte index 3d74b2f3..947e2bab 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-grid.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-grid.svelte @@ -1,16 +1,16 @@ diff --git a/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte b/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte index 93a60c0a..1b11061b 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte @@ -1,19 +1,19 @@ diff --git a/src/lib/components/ui/range-calendar/range-calendar-header.svelte b/src/lib/components/ui/range-calendar/range-calendar-header.svelte index ec1e6ea1..80c5fd18 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-header.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-header.svelte @@ -1,19 +1,19 @@ diff --git a/src/lib/components/ui/range-calendar/range-calendar-heading.svelte b/src/lib/components/ui/range-calendar/range-calendar-heading.svelte index 3b3325f4..cff53311 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-heading.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-heading.svelte @@ -1,16 +1,16 @@ diff --git a/src/lib/components/ui/range-calendar/range-calendar-month-select.svelte b/src/lib/components/ui/range-calendar/range-calendar-month-select.svelte index d12c18f8..8f90c0cb 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-month-select.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-month-select.svelte @@ -1,44 +1,44 @@ - - {#snippet child({ props, monthItems, selectedMonthItem })} - - - {/snippet} - + + {#snippet child({ props, monthItems, selectedMonthItem })} + + + {/snippet} + diff --git a/src/lib/components/ui/range-calendar/range-calendar-month.svelte b/src/lib/components/ui/range-calendar/range-calendar-month.svelte index e747fae7..4ab4c5af 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-month.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-month.svelte @@ -1,15 +1,15 @@ -
    - {@render children?.()} +
    + {@render children?.()}
    diff --git a/src/lib/components/ui/range-calendar/range-calendar-months.svelte b/src/lib/components/ui/range-calendar/range-calendar-months.svelte index f717a9d9..1f524727 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-months.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-months.svelte @@ -1,19 +1,19 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/range-calendar/range-calendar-nav.svelte b/src/lib/components/ui/range-calendar/range-calendar-nav.svelte index 27f33d79..7e7ffc58 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-nav.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-nav.svelte @@ -1,19 +1,19 @@ diff --git a/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte b/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte index 2c82cd4b..07e00f45 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte @@ -1,36 +1,36 @@ {#snippet Fallback()} - + {/snippet} - {#if children} - {@render children?.()} - {:else} - {@render Fallback()} - {/if} + {#if children} + {@render children?.()} + {:else} + {@render Fallback()} + {/if} diff --git a/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte b/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte index 0b912f89..f2dbd824 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte @@ -1,36 +1,36 @@ {#snippet Fallback()} - + {/snippet} - {#if children} - {@render children?.()} - {:else} - {@render Fallback()} - {/if} + {#if children} + {@render children?.()} + {:else} + {@render Fallback()} + {/if} diff --git a/src/lib/components/ui/range-calendar/range-calendar-year-select.svelte b/src/lib/components/ui/range-calendar/range-calendar-year-select.svelte index 926aa3a1..1dcd04fe 100644 --- a/src/lib/components/ui/range-calendar/range-calendar-year-select.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar-year-select.svelte @@ -1,43 +1,43 @@ - - {#snippet child({ props, yearItems, selectedYearItem })} - - - {/snippet} - + + {#snippet child({ props, yearItems, selectedYearItem })} + + + {/snippet} + diff --git a/src/lib/components/ui/range-calendar/range-calendar.svelte b/src/lib/components/ui/range-calendar/range-calendar.svelte index a2c01f14..99c37caa 100644 --- a/src/lib/components/ui/range-calendar/range-calendar.svelte +++ b/src/lib/components/ui/range-calendar/range-calendar.svelte @@ -1,112 +1,112 @@ - {#snippet children({ months, weekdays })} - - - - - - {#each months as month, monthIndex (month)} - - - - + {#snippet children({ months, weekdays })} + + + + + + {#each months as month, monthIndex (month)} + + + + - - - - {#each weekdays as weekday, i (i)} - - {weekday.slice(0, 2)} - - {/each} - - - - {#each month.weeks as weekDates (weekDates)} - - {#each weekDates as date (date)} - - {#if day} - {@render day({ - day: date, - outsideMonth: !isEqualMonth(date, month.value), - })} - {:else} - - {/if} - - {/each} - - {/each} - - - - {/each} - - {/snippet} + + + + {#each weekdays as weekday, i (i)} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + + {#if day} + {@render day({ + day: date, + outsideMonth: !isEqualMonth(date, month.value) + })} + {:else} + + {/if} + + {/each} + + {/each} + + + + {/each} + + {/snippet} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 5c750e43..ec5267f7 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -55,10 +55,7 @@ -
    +
    diff --git a/src/routes/(app)/dashboard/+page.svelte b/src/routes/(app)/dashboard/+page.svelte index 91b038f8..c33cecc0 100644 --- a/src/routes/(app)/dashboard/+page.svelte +++ b/src/routes/(app)/dashboard/+page.svelte @@ -11,7 +11,6 @@ import DollarSign from '@lucide/svelte/icons/dollar-sign'; import CirclePlus from '@lucide/svelte/icons/circle-plus'; import Bell from '@lucide/svelte/icons/bell'; - import AlertTriangle from '@lucide/svelte/icons/alert-triangle'; import ChartBar from '@lucide/svelte/icons/chart-bar'; import { dashboardStore } from '$stores/dashboard.svelte'; import { vehicleStore } from '$stores/vehicle.svelte'; @@ -23,7 +22,6 @@ import Button from '$ui/button/button.svelte'; import { goto } from '$app/navigation'; import * as m from '$lib/paraglide/messages'; - import { env } from '$lib/config/env'; onMount(() => { dashboardStore.fetchSummary(); @@ -43,7 +41,11 @@ summary ? [ { name: 'Fuel', value: summary.expenseBreakdown.fuel, color: 'var(--chart-1)' }, - { name: 'Maintenance', value: summary.expenseBreakdown.maintenance, color: 'var(--chart-2)' }, + { + name: 'Maintenance', + value: summary.expenseBreakdown.maintenance, + color: 'var(--chart-2)' + }, { name: 'Insurance', value: summary.expenseBreakdown.insurance, color: 'var(--chart-3)' } ].filter((d) => d.value > 0) : [] @@ -53,7 +55,11 @@ summary ? [ { name: 'Valid', value: summary.puccStatus.valid, color: 'var(--chart-1)' }, - { name: 'Expiring Soon', value: summary.puccStatus.expiringSoon, color: 'var(--chart-4)' }, + { + name: 'Expiring Soon', + value: summary.puccStatus.expiringSoon, + color: 'var(--chart-4)' + }, { name: 'Expired', value: summary.puccStatus.expired, color: 'var(--chart-5)' }, { name: 'N/A', value: summary.puccStatus.notAvailable, color: 'var(--muted)' } ].filter((d) => d.value > 0) @@ -93,7 +99,11 @@ 0 ? `${(totalDistance / 1000).toFixed(1)}k km` : '--'} + value={loading + ? '...' + : totalDistance > 0 + ? `${(totalDistance / 1000).toFixed(1)}k km` + : '--'} color="bg-violet-500/10 text-violet-500" /> - +
    @@ -134,15 +139,18 @@ {#if vehicleStore.vehicles && vehicleStore.vehicles.length > 0}
    {#each vehicleStore.vehicles as vehicle (vehicle.id)} - vehicleStore.selectedId = vehicle.id} /> + (vehicleStore.selectedId = vehicle.id!)} + onkeydown={() => {}} + /> {/each} - +
    {:else}
    @@ -167,21 +175,21 @@
    - + Good {summary.vehicleHealth.good}
    - + Attention {summary.vehicleHealth.attention}
    - + Needs Action {summary.vehicleHealth.needsAction} @@ -196,12 +204,7 @@

    PUC Status

    - +
    @@ -210,9 +213,7 @@

    Upcoming Reminders

    - +
    {#if summary && summary.upcomingReminders.length > 0}
    @@ -229,7 +230,11 @@

    {reminder.note || reminder.type}

    @@ -243,10 +248,14 @@
    -
    +

    Get detailed insights

    -

    View comprehensive reports and analytics for your fleet

    +

    + View comprehensive reports and analytics for your fleet +

    +
    + +
    + +
    +

    Fuel Report

    +

    Fuel consumption and cost summary

    +
    + +
    + +
    + +
    +

    Full Data Export

    +

    Export all fleet data as JSON

    +
    + +
    +
    +
    diff --git a/src/routes/(app)/vehicles/+page.svelte b/src/routes/(app)/vehicles/+page.svelte index c687a4bb..0445eacc 100644 --- a/src/routes/(app)/vehicles/+page.svelte +++ b/src/routes/(app)/vehicles/+page.svelte @@ -13,31 +13,38 @@
    - {#if vehicleStore.processing} - + {:else if vehicleStore.vehicles && vehicleStore.vehicles.length > 0}
    {#each vehicleStore.vehicles as vehicle (vehicle.id)} - + { + vehicleStore.selectedId = vehicle.id!; + }} + onkeydown={() => {}} + /> {/each} - +
    {:else} -
    +

    {vehicle_list_empty()}

    {/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5df373f8..b4f83250 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,14 +6,13 @@ import { Toaster } from '$ui/sonner'; import { page } from '$app/state'; import { onMount } from 'svelte'; - import { env } from '$lib/config/env'; import { toast } from 'svelte-sonner'; import { configStore } from '$lib/stores/config.svelte'; import { themeStore } from '$lib/stores/theme.svelte'; import { app_name } from '$lib/paraglide/messages/_index.js'; import { app_new_update_available } from '$lib/paraglide/messages'; - let { children, data }: LayoutProps = $props(); + let { children }: LayoutProps = $props(); let customCss = $state(''); async function detectSWUpdate() { diff --git a/src/routes/api/pucc/status/+server.ts b/src/routes/api/pucc/status/+server.ts index 751e13e5..eb1c5d7d 100644 --- a/src/routes/api/pucc/status/+server.ts +++ b/src/routes/api/pucc/status/+server.ts @@ -1,8 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { db } from '$server/db/index'; -import * as schema from '$server/db/schema/index'; -import { eq, sql, max } from 'drizzle-orm'; export const GET: RequestHandler = async () => { const today = new Date(); @@ -19,10 +17,15 @@ export const GET: RequestHandler = async () => { }); // Get latest cert per vehicle - const latestCerts = new Map(); + const latestCerts = new Map(); for (const cert of allCerts) { const existing = latestCerts.get(cert.vehicleId); - if (!existing || (cert.expiryDate && existing.expiryDate && new Date(cert.expiryDate) > new Date(existing.expiryDate))) { + if ( + !existing || + (cert.expiryDate && + existing.expiryDate && + new Date(cert.expiryDate) > new Date(existing.expiryDate)) + ) { latestCerts.set(cert.vehicleId, cert); } } diff --git a/src/routes/api/reminders/+server.ts b/src/routes/api/reminders/+server.ts index 39c1c814..fa0290ed 100644 --- a/src/routes/api/reminders/+server.ts +++ b/src/routes/api/reminders/+server.ts @@ -1,8 +1,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { db } from '$server/db/index'; +import { eq, and } from 'drizzle-orm'; import * as schema from '$server/db/schema/index'; -import { eq, and, sql } from 'drizzle-orm'; export const GET: RequestHandler = async ({ url }) => { const type = url.searchParams.get('type'); diff --git a/src/server/services/dashboardService.ts b/src/server/services/dashboardService.ts index 4bab6a5e..7d25ca49 100644 --- a/src/server/services/dashboardService.ts +++ b/src/server/services/dashboardService.ts @@ -1,6 +1,4 @@ -import * as schema from '../db/schema/index'; import { db } from '../db/index'; -import { eq, sql, and, gte, lte } from 'drizzle-orm'; const EXPIRING_SOON_DAYS = 30; @@ -51,10 +49,24 @@ export async function getDashboardSummary(): Promise { allInsurancesForExpense ] = await Promise.all([ db.query.vehicleTable.findMany({ - columns: { id: true, make: true, model: true, licensePlate: true, image: true, odometer: true } + columns: { + id: true, + make: true, + model: true, + licensePlate: true, + image: true, + odometer: true + } }), db.query.fuelLogTable.findMany({ - columns: { fuelAmount: true, cost: true, filled: true, odometer: true, date: true, vehicleId: true } + columns: { + fuelAmount: true, + cost: true, + filled: true, + odometer: true, + date: true, + vehicleId: true + } }), db.query.maintenanceLogTable.findMany({ columns: { cost: true, odometer: true, vehicleId: true } @@ -67,7 +79,12 @@ export async function getDashboardSummary(): Promise { }), db.query.reminderTable.findMany({ columns: { - id: true, vehicleId: true, type: true, note: true, dueDate: true, isCompleted: true + id: true, + vehicleId: true, + type: true, + note: true, + dueDate: true, + isCompleted: true } }), db.query.insuranceTable.findMany({ @@ -154,13 +171,19 @@ export async function getDashboardSummary(): Promise { const hasExpiredPucc = puccDate && new Date(puccDate) < today; const hasExpiredInsurance = insDate && new Date(insDate) < today; - const hasExpiringPucc = puccDate && new Date(puccDate) <= thirtyDaysFromNow && new Date(puccDate) >= today; - const hasExpiringInsurance = insDate && new Date(insDate) <= thirtyDaysFromNow && new Date(insDate) >= today; + const hasExpiringPucc = + puccDate && new Date(puccDate) <= thirtyDaysFromNow && new Date(puccDate) >= today; + const hasExpiringInsurance = + insDate && new Date(insDate) <= thirtyDaysFromNow && new Date(insDate) >= today; const hasOverdueReminder = vehicleReminders.some( (r) => !r.isCompleted && r.dueDate && new Date(r.dueDate) < today ); const hasUpcomingReminder = vehicleReminders.some( - (r) => !r.isCompleted && r.dueDate && new Date(r.dueDate) <= thirtyDaysFromNow && new Date(r.dueDate) >= today + (r) => + !r.isCompleted && + r.dueDate && + new Date(r.dueDate) <= thirtyDaysFromNow && + new Date(r.dueDate) >= today ); if (hasExpiredPucc || hasExpiredInsurance || hasOverdueReminder) { diff --git a/svelte.config.js b/svelte.config.js index 2bd47915..fec8dda9 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -31,6 +31,7 @@ const config = { $stores: './src/lib/stores', $services: './src/lib/services', $helper: './src/lib/helper', + $dashboard: './src/lib/components/dashboard', $server: './src/server' } } From 0be19ff251005673affca9ff6159a6bd176f9191 Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Wed, 29 Jul 2026 18:49:24 +0530 Subject: [PATCH 09/23] Added dashboard widgets and allow rearranging them as per need --- .agents/skills/shadcn-svelte/SKILL.md | 227 ++++++++ .../skills/shadcn-svelte/agents/openai.yml | 5 + .../assets/shadcn-svelte-small.png | Bin 0 -> 2672 bytes .../shadcn-svelte/assets/shadcn-svelte.png | Bin 0 -> 9410 bytes .agents/skills/shadcn-svelte/cli.md | 166 ++++++ .agents/skills/shadcn-svelte/customization.md | 211 +++++++ .agents/skills/shadcn-svelte/evals/evals.json | 47 ++ .../skills/shadcn-svelte/rules/composition.md | 242 ++++++++ .agents/skills/shadcn-svelte/rules/forms.md | 232 ++++++++ .agents/skills/shadcn-svelte/rules/icons.md | 107 ++++ .agents/skills/shadcn-svelte/rules/styling.md | 193 +++++++ .claude/settings.json | 13 + .prettierignore | 6 +- CLAUDE.md | 87 +++ package.json | 19 +- pnpm-lock.yaml | 541 ++++++++++-------- pnpm-workspace.yaml | 3 + skills-lock.json | 11 + src/__tests__/grid-layout.test.ts | 122 ++++ .../components/dashboard/ActivityFeed.svelte | 60 ++ src/lib/components/dashboard/CtaBanner.svelte | 30 + .../components/dashboard/DashboardGrid.svelte | 104 ++++ .../components/dashboard/DonutChart.svelte | 187 ++++-- .../components/dashboard/FilterTabs.svelte | 18 + .../dashboard/StackedAreaChart.svelte | 118 ++++ src/lib/components/dashboard/StatCard.svelte | 72 ++- .../dashboard/VehicleLeaderboard.svelte | 70 +++ .../components/dashboard/WidgetCard.svelte | 229 ++++++++ .../dashboard/grid-interaction.svelte.ts | 319 +++++++++++ src/lib/components/dashboard/grid-layout.ts | 116 ++++ .../components/dashboard/widget-registry.ts | 180 ++++++ src/lib/components/dashboard/widget-size.ts | 39 ++ .../widgets/ExpenseBreakdownWidget.svelte | 33 ++ .../widgets/FleetFuelTrendWidget.svelte | 24 + .../dashboard/widgets/FleetStatWidget.svelte | 45 ++ .../widgets/MonthlyExpenseTrendWidget.svelte | 13 + .../widgets/RecentActivityWidget.svelte | 10 + .../widgets/StatusDonutWidget.svelte | 31 + .../widgets/UpcomingRemindersWidget.svelte | 48 ++ .../widgets/VehicleHealthWidget.svelte | 62 ++ .../widgets/VehicleLeaderboardWidget.svelte | 51 ++ .../widgets/VehicleQuickListWidget.svelte | 21 + .../feature/dashboard/AddWidgetPanel.svelte | 39 ++ .../maintenance/MaintenanceTimeline.svelte | 39 ++ .../feature/overview/AreaChart.svelte | 35 +- .../feature/overview/FuelAmountChart.svelte | 19 + .../feature/overview/MileageChart.svelte | 11 +- .../feature/vehicle/VehicleListItem.svelte | 94 +++ src/lib/components/layout/AppSidebar.svelte | 105 ++-- .../components/layout/VehicleSwitcher.svelte | 115 ++++ src/lib/domain/dashboard.ts | 242 ++++++++ src/lib/domain/fuel/mileage.ts | 5 + src/lib/stores/chart.svelte.ts | 13 + src/lib/stores/dashboard-layout.svelte.ts | 111 ++++ src/lib/stores/dashboard.svelte.ts | 21 +- src/lib/stores/notification.svelte.ts | 8 +- src/routes/(app)/+layout.svelte | 47 +- src/routes/(app)/dashboard/+layout.svelte | 7 - src/routes/(app)/dashboard/+page.svelte | 257 ++------- src/routes/(app)/dashboard/fuel/+page.svelte | 17 - src/routes/(app)/dashboard/fuel/+page.ts | 6 - .../(app)/dashboard/insurance/+page.svelte | 17 - src/routes/(app)/dashboard/insurance/+page.ts | 6 - .../(app)/dashboard/maintenance/+page.svelte | 17 - .../(app)/dashboard/maintenance/+page.ts | 6 - .../(app)/dashboard/overview/+page.svelte | 1 - src/routes/(app)/dashboard/overview/+page.ts | 6 - .../(app)/dashboard/pollution/+page.svelte | 17 - src/routes/(app)/dashboard/pollution/+page.ts | 6 - .../(app)/dashboard/reminders/+page.svelte | 17 - src/routes/(app)/dashboard/reminders/+page.ts | 6 - src/routes/(app)/expenses/+page.svelte | 36 +- src/routes/(app)/fuel/+page.svelte | 51 +- src/routes/(app)/insurance/+page.svelte | 32 +- src/routes/(app)/maintenance/+page.svelte | 107 ++-- src/routes/(app)/pollution/+page.svelte | 112 ++-- src/routes/(app)/reminders/+page.svelte | 186 +++--- src/routes/(app)/settings/+layout.svelte | 7 - src/routes/api/dashboard/layout/+server.ts | 19 + src/routes/api/dashboard/summary/+server.ts | 11 +- src/server/services/dashboardService.ts | 502 ++++++++++------ src/server/services/dashboardWidgetService.ts | 24 + 82 files changed, 5269 insertions(+), 1220 deletions(-) create mode 100644 .agents/skills/shadcn-svelte/SKILL.md create mode 100644 .agents/skills/shadcn-svelte/agents/openai.yml create mode 100644 .agents/skills/shadcn-svelte/assets/shadcn-svelte-small.png create mode 100644 .agents/skills/shadcn-svelte/assets/shadcn-svelte.png create mode 100644 .agents/skills/shadcn-svelte/cli.md create mode 100644 .agents/skills/shadcn-svelte/customization.md create mode 100644 .agents/skills/shadcn-svelte/evals/evals.json create mode 100644 .agents/skills/shadcn-svelte/rules/composition.md create mode 100644 .agents/skills/shadcn-svelte/rules/forms.md create mode 100644 .agents/skills/shadcn-svelte/rules/icons.md create mode 100644 .agents/skills/shadcn-svelte/rules/styling.md create mode 100644 .claude/settings.json create mode 100644 CLAUDE.md create mode 100644 skills-lock.json create mode 100644 src/__tests__/grid-layout.test.ts create mode 100644 src/lib/components/dashboard/ActivityFeed.svelte create mode 100644 src/lib/components/dashboard/CtaBanner.svelte create mode 100644 src/lib/components/dashboard/DashboardGrid.svelte create mode 100644 src/lib/components/dashboard/FilterTabs.svelte create mode 100644 src/lib/components/dashboard/StackedAreaChart.svelte create mode 100644 src/lib/components/dashboard/VehicleLeaderboard.svelte create mode 100644 src/lib/components/dashboard/WidgetCard.svelte create mode 100644 src/lib/components/dashboard/grid-interaction.svelte.ts create mode 100644 src/lib/components/dashboard/grid-layout.ts create mode 100644 src/lib/components/dashboard/widget-registry.ts create mode 100644 src/lib/components/dashboard/widget-size.ts create mode 100644 src/lib/components/dashboard/widgets/ExpenseBreakdownWidget.svelte create mode 100644 src/lib/components/dashboard/widgets/FleetFuelTrendWidget.svelte create mode 100644 src/lib/components/dashboard/widgets/FleetStatWidget.svelte create mode 100644 src/lib/components/dashboard/widgets/MonthlyExpenseTrendWidget.svelte create mode 100644 src/lib/components/dashboard/widgets/RecentActivityWidget.svelte create mode 100644 src/lib/components/dashboard/widgets/StatusDonutWidget.svelte create mode 100644 src/lib/components/dashboard/widgets/UpcomingRemindersWidget.svelte create mode 100644 src/lib/components/dashboard/widgets/VehicleHealthWidget.svelte create mode 100644 src/lib/components/dashboard/widgets/VehicleLeaderboardWidget.svelte create mode 100644 src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte create mode 100644 src/lib/components/feature/dashboard/AddWidgetPanel.svelte create mode 100644 src/lib/components/feature/maintenance/MaintenanceTimeline.svelte create mode 100644 src/lib/components/feature/overview/FuelAmountChart.svelte create mode 100644 src/lib/components/feature/vehicle/VehicleListItem.svelte create mode 100644 src/lib/components/layout/VehicleSwitcher.svelte create mode 100644 src/lib/domain/dashboard.ts create mode 100644 src/lib/stores/dashboard-layout.svelte.ts delete mode 100644 src/routes/(app)/dashboard/+layout.svelte delete mode 100644 src/routes/(app)/dashboard/fuel/+page.svelte delete mode 100644 src/routes/(app)/dashboard/fuel/+page.ts delete mode 100644 src/routes/(app)/dashboard/insurance/+page.svelte delete mode 100644 src/routes/(app)/dashboard/insurance/+page.ts delete mode 100644 src/routes/(app)/dashboard/maintenance/+page.svelte delete mode 100644 src/routes/(app)/dashboard/maintenance/+page.ts delete mode 100644 src/routes/(app)/dashboard/overview/+page.svelte delete mode 100644 src/routes/(app)/dashboard/overview/+page.ts delete mode 100644 src/routes/(app)/dashboard/pollution/+page.svelte delete mode 100644 src/routes/(app)/dashboard/pollution/+page.ts delete mode 100644 src/routes/(app)/dashboard/reminders/+page.svelte delete mode 100644 src/routes/(app)/dashboard/reminders/+page.ts delete mode 100644 src/routes/(app)/settings/+layout.svelte create mode 100644 src/routes/api/dashboard/layout/+server.ts create mode 100644 src/server/services/dashboardWidgetService.ts diff --git a/.agents/skills/shadcn-svelte/SKILL.md b/.agents/skills/shadcn-svelte/SKILL.md new file mode 100644 index 00000000..5e123edd --- /dev/null +++ b/.agents/skills/shadcn-svelte/SKILL.md @@ -0,0 +1,227 @@ +--- +name: shadcn-svelte +description: Manages shadcn-svelte components and projects — adding, updating, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn-svelte, the CLI, design-system presets, or any project with a components.json file. Also triggers for "shadcn-svelte init", "add component", or registry URLs. +user-invocable: false +allowed-tools: Bash(npx shadcn-svelte@latest *), Bash(pnpm dlx shadcn-svelte@latest *), Bash(bunx --bun shadcn-svelte@latest *) +--- + +# shadcn-svelte + +A framework for building UI, components, and design systems for Svelte. Components are added as source to the user's project via the CLI. + +> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn-svelte@latest`, `pnpm dlx shadcn-svelte@latest`, or `bunx --bun shadcn-svelte@latest` — based on the project's package manager. Examples below use `npx shadcn-svelte@latest` but substitute the correct runner for the project. + +## Current Project Context + +Read `components.json` at the project root and, when you need the live file layout, list the directory given by the `aliases.ui` path (resolved with the same rules as the CLI). + +## Imports (Svelte) + +Each component lives in its own folder with an `index.ts` barrel. Match the [installation docs](https://shadcn-svelte.com/docs/installation): + +- **Multi-part components** (dialog, select, card, field, tabs, …): `import * as Dialog from "$lib/components/ui/dialog"` then `Dialog.Content`, `Dialog.Title`, `Card.Root`, `Card.Header`, etc. — whatever the barrel exports (short names and/or `Root as …` aliases). +- **Single-component barrels** (only one meaningful component in the folder): **named imports** — `import { Button } from "$lib/components/ui/button"` and ` + + +
    + + + + + U + + + ++20.1% +``` + +## Component Selection + +| Need | Use | +| -------------------------- | --------------------------------------------------------------------------------------------------- | +| Button/action | `Button` with appropriate variant (`import { Button }`) | +| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` | +| Toggle between 2–5 options | `ToggleGroup.Root` + `ToggleGroup.Item` | +| Data display | `Table`, `Card`, `Badge`, `Avatar` | +| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` | +| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) | +| Feedback | `svelte-sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` | +| Command palette | `Command` inside `Dialog` | +| Charts | `Chart` (LayerChart) | +| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` | +| Empty states | `Empty` | +| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` | +| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` | + +## Key Fields + +Use `components.json` and the filesystem — not a separate `info` command: + +- **`aliases`** → use the actual alias prefix from config (e.g. `$lib/`), never hardcode unrelated projects. +- **`tailwind.css`** → the global CSS file where theme variables live. Edit this file for theme tweaks; don't add a second globals file unless the user already uses one. +- **`style`** → visual treatment (e.g. `nova`, `vega`, …) and registry style path. +- **`iconLibrary`** → determines icon packages (`@lucide/svelte`, `@tabler/icons-svelte`, etc.). Never assume `@lucide/svelte`. +- **`registry`** → where the CLI fetches components; default official registry at `shadcn-svelte.com`. +- **`resolvedPaths`** (conceptual) → the CLI resolves `aliases` to absolute paths; list `aliases.ui` on disk to see installed components. + +See [cli.md](./cli.md) for commands and flags. + +## Component Docs, Examples, and Usage + +Open `https://shadcn-svelte.com/docs/components/.md` for docs and examples. **When creating, fixing, debugging, or using a component, read the official page first** so you follow the documented APIs. + +## Workflow + +1. **Get project context** — read `components.json` and list the UI components directory when needed. +2. **Check installed components first** — before running `add`, list files under the resolved `ui` path. Don't import components that haven't been added, and don't re-add ones already present unless updating. +3. **Discover components** — `npx shadcn-svelte@latest add` with no arguments (interactive list), or the docs site. +4. **Install or update** — `npx shadcn-svelte@latest add ` or a registry **URL**. To refresh existing files from the registry, use `npx shadcn-svelte@latest update` (see [cli.md](./cli.md)). +5. **Fix imports in third-party / URL-added items** — After adding from a custom registry URL, check for hardcoded paths that don't match the project's `aliases`. Rewrite imports to use the project's `ui` / `lib` aliases from `components.json`. +6. **Review added components** — After adding, **read the added files** and verify composition (groups, titles, validation attrs). Align icon imports with `iconLibrary`. +7. **Remote registry items** — Adding by URL is explicit; if the user wants a component from an unknown source, confirm the registry URL or item before running `add`. + +## Updating Components + +Use the **`update`** command to pull the latest registry versions of components already in the project. Review changes with `git diff` after `update`. + +1. Commit or stash local work. +2. Run `npx shadcn-svelte@latest update [component]` or `--all`. +3. Resolve merge conflicts if you had customized files. +4. **Never use `--overwrite` on `add` without the user's explicit approval** when it would destroy intentional edits. + +## Quick Reference + +```bash +# Initialize shadcn-svelte in your project. +npx shadcn-svelte@latest init + +# Initialize with a preset string from the docs site builder. +npx shadcn-svelte@latest init --preset + +# Add components (interactive when run with no names). +npx shadcn-svelte@latest add +npx shadcn-svelte@latest add button card dialog +npx shadcn-svelte@latest add --all + +# Update components already installed. +npx shadcn-svelte@latest update button +npx shadcn-svelte@latest update --all --yes + +# Build a custom registry (registry authors). +npx shadcn-svelte@latest registry build +``` + +**Registry:** default `https://shadcn-svelte.com/registry` — override in `components.json` if needed. +**Docs:** [shadcn-svelte.com](https://shadcn-svelte.com) + +## Detailed References + +- [rules/forms.md](./rules/forms.md) — Field.FieldGroup, Field.Field, InputGroup, ToggleGroup, Field.FieldSet, validation states +- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading +- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icon components +- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, class, spacing, size, truncate, dark mode, cn(), z-index +- [cli.md](./cli.md) — Commands, flags, registry +- [customization.md](./customization.md) — Theming, CSS variables, extending components diff --git a/.agents/skills/shadcn-svelte/agents/openai.yml b/.agents/skills/shadcn-svelte/agents/openai.yml new file mode 100644 index 00000000..53b19ff1 --- /dev/null +++ b/.agents/skills/shadcn-svelte/agents/openai.yml @@ -0,0 +1,5 @@ +interface: + display_name: 'shadcn-svelte' + short_description: 'Manages shadcn-svelte components — adding, updating, fixing, debugging, styling, and composing UI.' + icon_small: './assets/shadcn-svelte-small.png' + icon_large: './assets/shadcn-svelte.png' diff --git a/.agents/skills/shadcn-svelte/assets/shadcn-svelte-small.png b/.agents/skills/shadcn-svelte/assets/shadcn-svelte-small.png new file mode 100644 index 0000000000000000000000000000000000000000..17a8bf5c9ba3e1e14355d7b3c0aef89cf4ce3c5f GIT binary patch literal 2672 zcmdUx`#;oa8^^D4$SFHYEXkP4mRMPbr7)RFR9b_nbTr!3Fli(ud^IzqgCv&lN^8=C zkQ_2Ih@_-N%7_uZ7^GM+4#_um7&|@tJb%OUdVaX?>wVv^&*!>+x<9Ww*2CRZQ+=g6 z0BG*o>Fg!1&%Q0yCGy>?Cs0LRa3Nl<+n}g<)d+yP=`QE3d&7Jr8NUP`q3d6!3Bv;G z9~smS+FQo2>3QX{EL%NKb6|Dtvk%f+kW(o^NosG zshgs;JdW9#bjwyNUDtEpYOJnvmv;A*u#>yBBlbnz?YlukvN_i9?R^D3(%x|4W?_%@ z*PHap*AE$v9^}1I8$s(l_mQ0rKs2!sU}v{*639!k<>vsYJ^>myd<7t$c6y-*`ic7i zvL*B|AS$-11Bbt`6c2vnLpVq*yE}m6EZu1a(27|*LLx3e8OSK>iV9f$Z<}ff$*Vg~ z3|47i{SC}JdSj0sx1UEsLK|hlr`uC)O~nO@AU@m^_NIMFy4nQ^Zx} z0kfB?I|}wW1T&jia}#F&D3D>_LWU3{MZed6f)AUy)byWgsc(@WH?;U;{DX!5u^S6R zwLb^#Q-Gktg+*V9h{AL{OO{&!0e3vVuuH zjyr=wbUiaOJjb)f{t`vkf9lcG1V=$d8>$_%_%eitN#mw{Xp4QNhTy0~+#?D9w5L&Q zV!k`(t~Doj;KJiZ!NqeqDAmD%4hh6ej32ZP_{Ci}doH1Ex6PsDHq(J&QG}gis=&FT z-kY7tsCM%bP|c-1K&VJ<6CMVB-g82kL9r=SB-_}pt5g@Pw||Ty+M8=1j77BzUxQX5 z_fe6HB-Y0VuKBbb*q@EntuZsY66gBP-#Z6$E}%;ozvJGw*(RmrlYVRHJhKe0dG~n~ zP?2qo!+dMl)Oc&&Ma1>9QQ`QVV~(1LxMd&I1(pz#Wbs!4BQ zL}jBq(uQl|{zOPD`Ci10Q4R{;1Up;s3`+E`4g_^rby>YiU$FjR^wbz0rv8lRejws1 zIgd|jLCd`}MOB{2k(AwZ2e{^WfkCA(0Y{l{H%zp=Nuof z3$7hFz@XGc|M6rMIGg+=Sij9u`;a=US)Pt+|CXp`p$N-V-6gT)x?8EhNvtgpafv2X zRUz;0Oq9-C@0yvpUg%x0y4{U^0aOQ~c1yab4c#xkE=AL>R|V2&<>n8)$9gIIZP~kL zYFiJfD!29Nfc+P84z{XuUrEQ!F<#b7)@YMT@Z+fi1$!lfqo=UWkpP6v5^UFL>(d1L zNxAgK%=vD!uM@oD1kFHJY+!Z$h?yDohWfqa6Y8R}`+kiE@JHm6)qLh?_KpmifkDX; z?f9V%)6n@|H>`f-vTaI_{vAcK!4{KtE-ms{jiKR4(Cn+49}ed*uvTdZ#$z94Zg0%s zZGcS?TP67xd*NBr@bFl!kpk!Vt?*(Y`Q$E*vL@^Mn2q;_Fzxxm z@{MSURZ#TN&$seV=aJ$i>ddI4M|^m$?!R<}Z@hHUw8Nc}inck zZ4Jp~*4^p1Z}vda>t~#rPGNO4H>|n+goap&K8J(uo-B-CM5-63m+?CGm;Nq2EHng0 zU^eD2;-VXH{r6Xj2pOm%h`6a2!pe=&N_3Da!2u(I%Es4DG_`YG6y3aujD(Ytfu(1r z(++aEdS%1w;`UEleoZaD@!U{2ENt!^rFPAJ$!c; zu$>%Pm)7*mYXVo%m~%c^11iwLBpkH@U3)aD0)5d=I0$(p5%=UQvOthK<)(IAwK50Y zE(y?)_4idPeZye2nl$i0@<>GkU%vweUXYtHi712MD%!@Jt;T(mj2O@R09kSmJGWBy zTJamisIl+kGBFRN_KFkl-)_$sr{sU&vQx-EaK(uVE6yIpUe}e791iluN3x8@9#gqz z1q@2YhAxAg9cJ`?WImd9GI$~QIvUU}OENRnvYI}aDO`$m8a+MRGCuysLD+0m;^6i} z)v=QhHYTpdl3!#syC@H^r+UVQDB&kRru2QZd(2SwkhyIfCh|LfvW6#-i(8P$$c6wW zBO!TD;H{j5?M8QhMDh4|zD+#Z=C#H)&y#3P=U99It+{K@CO7T6-(r9x`>XZpnqWhc4t(S* z!m5!AXcRhVO#L$+i2N9)hSFCHmTb;a`bQ_Z%`lI|jBpX?fOC#t-1*9?%CXyx$D}g9 zx6zcccZdhF+$wA6CrHd3Qjuz4Raq*5zh>r+d*+kW(u1cx23B{lP3fM|p}jRSHwdu{|r-lBji0Hi5F-6g<>wVCDs{!$Ak5;VAy4+FGdG*$#8CUDB&|8f9l5jUNw!q{Y)CY667 OVb>0K=b~-C(SHM+r2$a@ literal 0 HcmV?d00001 diff --git a/.agents/skills/shadcn-svelte/assets/shadcn-svelte.png b/.agents/skills/shadcn-svelte/assets/shadcn-svelte.png new file mode 100644 index 0000000000000000000000000000000000000000..a35eb16e517ab064a89715a985619789e4f41d1a GIT binary patch literal 9410 zcmeHN`#;oc*Z$5l7!*UHgP20dd6y&&U!inhy`Tugotdnwsa2j9_%0&c)JBlbXjrK zi*A%(UZh|xL)-F|Rk2t+=fX}K@s&$*R$CoPzD%a7tDimbSJbJ6mMaYxI@g}YBqv#l zUwaouh&y{q?-b5nZN-VfUPq_2oyxX_-IJu5fs+&OzTUsGFl^G)kK`9O{$O&=L~Q1f z>F=N85DXTFC)jEXufh;p9vbhY&;B4DheMPYb6=@1At3THUUjgDh@wcK#Ox;>-Gado zKQ@-^np=)TLgzm5zZL^{ztH%_jbEAa|3nHlMsfJ&B}qy`6VVrgEcQ%X!nd&$ zQ)Dx(kCv4f%}-4;AF{&jz~SifBp*ovF;IfBxaM1GeTJzqX=X|-F3LOXdtM)Zr(2d^ zY?)$O=sAu)7R%A!VQxi77%Rn8%%c6L%h)vkX@QmM+mR<}78^wCyOP~LBy_|IiKOTn*&hLCe99->L5>)aIPIBXP!Mll{ET7p9L9hV#_t(cezO8)%SIH!crR+mzVJ>J#z5XP0{*BB}NHc zzC@iAi!jz>^Ag#V1^?Xluv?*kq#Tc2hQYoTD|HtZzr7Yy-6w4!IC8>#JXD>CoLIl` z7J=AU#&N9^mDbi&(Q*QaEobBj$d*exi*R^eZT}2;qXx`RJM+$Km535MN^ZKeySp)1-xp}BAr7RuW=E3 zgvEC(7-v0DeqYHGzuvUHCl8zRk33Z)A z!nm4#OsE_I**hDzT}{p1JlgoXS2x*W!_y!2)~znApd*EwawO!51Ups@m&WfJKFn{R z;R+3~#^Ckx+v(fs=u%9b#mnp^6?b9nZ`n0+M8Y->FJYa|wR{6`Gob%G4v!-g3KlHf ze%=LOxcyMYo{lD9nY09_-A;ZO46CGkQjS=dL+7%!*@^#)xq};rSMc>~UlE+-KI*84 z!Agtiip~VaFVZ0*3(Y(($q@rRIlNFMyx=~N%9TJgyhrC+jD;Dy5sAo>F4G-!v=ff$ z9Npbf3REr?et?5DFm-JC+!`4*u(58(B?7T4RQL^Hbbx0ac~O5M3Dr=~&5wZ$` zZzOI*AQqkl*UsMXIS4@A-Q|&i6IUt}NT?Q0xGE74SIu!0i}R3n7v?>!48UP9)YIDi z40Jz$GBRA^4GdZ`V!DgYVPcsg=Jbb^UMO^-n`1<7hfM_(h&!z)?!J5mSUkGPLf3DtQ3O=Dh6R31q_dFyIF{VEts>}VpugP zccGfS3z;N|M@V*7dN{l?IHHebOk=U=$pZn9sDJ~bowFQVTYe$kl1c(Iq@Co|XqP^g$-ZOBvH-5Jle5op9AJqlpCAT;es4O2}<+rGcLPDH!NI9*yR1KkBT{L=1h$C3sN;*7LTaaM!(a;a80a0KYt2CxqBbJ96$ z!1Y-!P+j1A7kgPX!z$ps@M3V16^cAJ^6`>)x8d+8XI@+3Py8fsFNMP!qHSHPUYGzW zn!s$3|4F`BC>&>13cld40)M$@(^1woz^EoC`#h=%$ZEzoh%=<%Xffvmb;DC|(BmcJ zXxBF^mj2$7&dHeNi5O!9E;HhjYlV%3e@7nU8!`%EOWSMh>zrxub~-cNmgNR!TVG_y zp%6i~QF*Ien>&iMg(2Q#mIVAHRy&)4dcvB#vNE||dvN!ty431MYZN*Bct8U;y`)eu zb3N`h{vN2ChgPHnLk3tZ6MI7)y9f?H+vaV=wZx{2ET&7F1Y%7}5)4}r1G~V{k2@uUyX?PcYn*n-3I7d&R5dW!w|! z;8O`H7AeW^l*BDA6vQmrbsx6@>c)mslOCX+x}sV2q(b1SFYouS{hQ9s2<-J(^E1W% zb01V0%+}{R@92sLvvFWn9=6Piz$Upuf!VUtzu|E}TC9xS3)~@~D2a%&05`4IK}^n8 zfPd?-_f9Giwy^g3YQ(}R2kI--9XwGaTW^4ma|LAGo{VW>U%>r7E1@&Tz_E?Rj@k@7 z#PGCsj!`9S5lSJ%|C9oEkY!41Qxl#r6$7%nhS2K{@e13xLyfQ(Meyb*H$jKWPcZXL zlk2rak=}h$zQkd3@a!6RRtmc0*)}RUpiSiuNq*_Z;dKEL#UfK{Ry+i!ro~B;FcpNs zaPo+a=jq(e7sRWU#Pf9I&x4Kc20-kGBF%5B8Qy?seR3XOvV+5uHP(MBzjP~#EIo8} z`FSk}&h?ZO$%{b!i$>eLaB#f^v&e|UDdNjBT|YrO9XoW(5qnb#kKD9(rE~xTI^wtP z!#P6M<~+-Ln+Flk)9m_=!?#0nw2NUc0U}!)y{+gRdtjFRE^}+vE)LING$;jOj00-p zcBIRkNJwL)QwT!^exQoQySA(Zv)iGUF~B>6bnCDp zK6{DPr)@GnIr2HCNzlOmmS!G3&|BOp8(>%9{lL`g;l#vPrA}*BTDSR8O-B!J{{3<`aGVI&f19rE;AHgyhGNt^JCg#?kmJQN~ zr9{?1G++>Y$mqC+pMmJT>uAA(s^AefwWhv7=qu?U$0(r42|D4!@Te1oU`ELpK^(&@ z;XsnC6jFWBQEfI5Z$6oJx<&FEZjbUZtmp-Mfs^^+UqhgV(===o_$WiMu9J-6d2hiG zhX#VH8~_-6Pn$PZFHy9&EvTR}d@MtJx)w6^b8?Bj8zAn6j-88?y5}v7UKH4Hc_AP2 z0&3B1DDV`kmUW#5g->}Ie&xplH=g_$YhnE7d^HQc4-G(j{fiE=y?d7aT0;{bVKzU#OJ-At}8o@U68xFy{Q(yp|5I3LVMu`>1Ruzcc@Xs^r6{ z_}yeFWVdG4H(%Il>>(>$%t&X7_P%j45JjbkjuR>2AUT_h-}K2q=F8ZcJL>*dd+6Gp zDDedHVE?O~CA0LnP_FVl9}R$JbRgL++Ng4@t~15)H&C0moRUZ%NZ410S2cF?CLf3t zuMCZ@#G)BNyIrh6W>2;Zj8oQ;P9&%L=Irm8^Apc(4 zTU|`&4yoLOPf;1fXWZGxUov=o*;JVf!Z}VSgFcBQb!Xk zn{51>+gZJ&xp3ZQ83d*5=Ca#{b+qhbnf_a6Pd}DgDyT{OgPgWUINMzPG^z{pvPXS4u}K1`lyyUgwEc5c2mZ`1^gn%rDkL8znkj%51V&lybtqshNFq`3=8 z^sb^3I2G5Yycb$|gKI%YRY8_i^)g;*5s`z}r_Yi+g)2U3MwDKC~e33SB&}d1L*0)*O;`&fBS_OL6??P|HunG z@VXV1Zw)&6I%va>rHdj<1uOPzMqlG*giU!+epns$hIFqyEg15HD&waWv2`nCad-2q zSrc^P!V6xgBwiQ9iuv9&9QvfrmcxKfnZrctoq?2p6a2iUMBnb zp(K^3i_~GRTmLd8&s}I7OnN^z+f6|1B1Z!Q?)}gP81GCs*hoNi$I2Gi^f77DzYl>y z40aF~$JrF0SC@MfXJnYLn%5QBR_h*lWO5puruJyNw`L?!D@L?!|r-JN@k@imJ3vD^M)ojTD-#<7`E=h-_k{bSZKDfAn=T{F2&As zmzhapo1ycg$@+rlKhmvglx!Me7YflmvH7~V$BIi#>h_$u3p z{hUW8MZcO~mjdf%?>sbnm+wH$?+u5?rSwEC+eyPRFw})NlsBXdH9GvWO8j64Wg-sctUR$jou^s!(j?cYa!X8*tykNeLk z{J|gU;WpJlf&cxey7~$dVje4cGnc zyh8uf#M=aNgSqgz6xpLkjw#v{Pa-|#4INqTyUJk=5xL3_4w}}<2s=DoeMU<{^ca^+_93@Zge--0nDyvwZ`E`~_2M^_u{zLn=@5^8KNmjI zfMLZN%uY19o-tf@epZ%TuMKp;zsrjS3%+(nM@>)rl!-dnT#Dfn`vebRn#|TcI9C{J zT+#MvIRjyJUtjx+?o{SJen2gS%Vp9OTxB&HFTn)!N{gw)vk>u~j5VHQQ-t)#SD3U_ z)YuxmAA^t%1Y9TP=twz*HvUqvfE1C%WiLBfqU0%TIZR4I-5>|ty4_u0x?-S7+E9>A zhJpmO1NR0b_|16x=OZF`*dfh6HEO*-xkosd>sk4N7oagn!u@zsvf8bXC(=Oc*oXIe zsgw#HD97EvJHy`FywdO~lk|U$cLkT+3=^$;%biVyWD64a#T#N&163R`f*x3Q4}O`#c4_cr1lMht!#d#Mw!%S}ec zY|wen42%Rb7xpPElhe4GjG=RHm}$?Ty#*yL3nQP|XTQVW_))dTjo)f+uvtktV} zG*h1*MxB0lK$bje(PL;RI!zT;93Otr$GcSBSK}rd&#ZdNeiXQt{qJ0=;II&2Mr_sz ztSS_&2dX&ylmguhB|>6l_;h2_Jr5cIIJ5~3kP6Gi{ro12PN;rXYG z!J%S1&1XjX-qxu0W$M(iQ+I2fhS>#PhE8!%tg*0tCGuB{B zM2^`fQwjx>uX%gsHS&Wk>jLNP;z_c_G%xGU=E%-ij~|fDNo`Pox`Pet8AZJ0ABP|B zRqw#}!B>@+Q3Zxdv><@*wk$7JJ{z*rdEdAL*c8s_(8j-I@Y;lfO$Ye_e}7BEF=0#H z;C1!JYn%dh#G&E{UD2!F$|TNyz}hG+1~)C=wia<0p6poALTGaVrEsb3!Jp6{Ruzp+ zotU25mg0Kk2MO;$w|(b$#8So}&O@}5&J=|dPaMMf0X3mgiZf(zRU=Pw`UJ^W?A8_u zQVk;|8Mh%`)E$YEDCR*D;fJ4e9FSjBDiJy3yIK(H)l)&b~Ug= zf-3^YsaXv(1Ps;)n4T2mWXz}(`!7;9m`8~o(1NO6w8;;EW-gdsA{Q3qDO~(Iobe`z3sR)7#oc+H5zvBCgwT99BYK?t=}1hzB?5o8+t^#@Z=s(0AJPmnZ~y=R literal 0 HcmV?d00001 diff --git a/.agents/skills/shadcn-svelte/cli.md b/.agents/skills/shadcn-svelte/cli.md new file mode 100644 index 00000000..ff19b7cb --- /dev/null +++ b/.agents/skills/shadcn-svelte/cli.md @@ -0,0 +1,166 @@ +# shadcn-svelte CLI Reference + +Configuration is read from `components.json`. See [components.json](https://shadcn-svelte.com/docs/components-json) on the docs site for the full schema. + +> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn-svelte@latest`, `pnpm dlx shadcn-svelte@latest`, or `bunx --bun shadcn-svelte@latest`. Check `packageManager` from the project (or lockfile) to choose the right one. Examples below use `npx shadcn-svelte@latest` but substitute the correct runner for the project. + +> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager; there is no `--package-manager` flag. + +## Contents + +- Commands: `init`, `add`, `apply`, `update`, `registry build` +- Proxy / outgoing requests +- Presets (via `init` and `apply`) + +--- + +## Commands + +### `init` — Initialize an existing project + +```bash +npx shadcn-svelte@latest init [options] +``` + +Installs dependencies, adds the `cn` util, creates `components.json`, and sets up CSS variables. Run `init` from the root of your project. + +| Flag | Short | Description | Default | +| --------------------------- | ----- | ------------------------------------------------------------------------- | --------- | +| `--preset ` | — | Encoded design-system preset string from the docs site | — | +| `-c, --cwd ` | `-c` | Working directory | current | +| `-o, --overwrite` | — | Overwrite existing files | `false` | +| `--no-deps` | — | Do not add or install dependencies | — | +| `--skip-preflight` | — | Ignore preflight checks and continue | `false` | +| `--base-color ` | — | Base color: `neutral`, `stone`, `zinc`, `mauve`, `olive`, `mist`, `taupe` | — | +| `--css ` | — | Path to the global CSS file | — | +| `--components-alias ` | — | Import alias for components | — | +| `--lib-alias ` | — | Import alias for lib | — | +| `--utils-alias ` | — | Import alias for utils | — | +| `--hooks-alias ` | — | Import alias for hooks | — | +| `--ui-alias ` | — | Import alias for UI components | — | +| `--proxy ` | — | Fetch registry items through this proxy | env-based | +| `--design-system-url` | — | Optional design-system URL (see docs / preset builder) | — | +| `-h, --help` | `-h` | Help | — | + +--- + +### `add` — Add components + +```bash +npx shadcn-svelte@latest add [options] [components...] +``` + +Adds components from the configured registry. Arguments are component names from the registry index, or a **URL** to a registry JSON item. With **no** component names, the CLI prompts you to pick components interactively. + +| Flag | Short | Description | Default | +| ------------------ | ----- | ----------------------------------------------- | --------- | +| `-c, --cwd ` | `-c` | Working directory | current | +| `--no-deps` | — | Skip adding and installing package dependencies | — | +| `--skip-preflight` | — | Ignore preflight checks and continue | `false` | +| `-a, --all` | — | Install all UI components | `false` | +| `-y, --yes` | — | Skip confirmation prompt | `false` | +| `-o, --overwrite` | — | Overwrite existing files | `false` | +| `--proxy ` | — | Fetch components through this proxy | env-based | +| `-h, --help` | `-h` | Help | — | + +--- + +### `apply` — Apply a preset to an existing project + +```bash +npx shadcn-svelte@latest apply [options] +``` + +Applies a design-system preset to a project that has already been initialized. Updates `components.json` with the preset settings, reinstalls existing components (except `utils`) with the new styles, and installs any required dependencies. + +Use `--only theme` or `--only font` to apply only part of a preset without reinstalling UI components. + +Get a preset code from the builder at [shadcn-svelte.com/create](https://shadcn-svelte.com/create). + +| Flag | Short | Description | Default | +| ------------------- | ----- | ---------------------------------------------- | --------- | +| `--preset ` | — | Encoded design-system preset string (required) | — | +| `--only [parts]` | — | Apply only `theme` or `font` from the preset | — | +| `-c, --cwd ` | `-c` | Working directory | current | +| `-y, --yes` | `-y` | Overwrite existing files without confirmation | `false` | +| `-s, --silent` | `-s` | Mute output | `false` | +| `--skip-preflight` | — | Ignore preflight checks and continue | `false` | +| `--proxy ` | — | Fetch registry items through this proxy | env-based | +| `-h, --help` | `-h` | Help | — | + +Requires an existing `components.json`. Run `init` first if the project is not yet configured. + +--- + +### `update` — Update installed components + +```bash +npx shadcn-svelte@latest update [options] [components...] +``` + +Re-fetches and applies registry content for components **already present** in the project. Run `shadcn-svelte update --help` for options. + +| Flag | Short | Description | Default | +| ------------------ | ----- | ----------------------------------------------- | --------- | +| `-c, --cwd ` | `-c` | Working directory | current | +| `--skip-preflight` | — | Ignore preflight checks and continue | `false` | +| `--no-deps` | — | Skip adding and installing package dependencies | — | +| `-a, --all` | — | Update every installed component | `false` | +| `-y, --yes` | — | Skip confirmation prompt | `false` | +| `--proxy ` | — | Fetch through this proxy | env-based | +| `-h, --help` | `-h` | Help | — | + +Commit your work before updating; overwrites are destructive. + +--- + +### `registry build` — Build a custom registry + +```bash +npx shadcn-svelte@latest registry build [options] [registry] +``` + +Reads a `registry.json` and writes registry JSON files for distribution. Default input: `./registry.json`, default output: `./static/r`. + +| Flag | Short | Description | Default | +| --------------------- | ----- | ------------------------------- | ------------ | +| `-c, --cwd ` | `-c` | Working directory | current | +| `-o, --output ` | `-o` | Output directory for JSON files | `./static/r` | +| `-h, --help` | `-h` | Help | — | + +--- + +## Outgoing Requests + +### Proxy + +The CLI can fetch the registry through a proxy. If `HTTP_PROXY` or `http_proxy` is set, requests respect it. You can also pass `--proxy` on `init`, `add`, `apply`, or `update`. + +```bash +HTTP_PROXY="" npx shadcn-svelte@latest init +``` + +--- + +## Presets + +Design-system options (style, theme, icons, fonts, etc.) can be captured as an encoded **preset** string from the builder on [shadcn-svelte.com/create](https://shadcn-svelte.com/create). + +- **New project:** pass the preset to **`init`** with `--preset `. +- **Existing project:** use **`apply --preset `** to update configuration, restyle installed components, and install any new dependencies. + +--- + +## `components.json` — useful fields for agents + +| Field / path | Meaning | +| -------------------- | ---------------------------------------------------------------- | +| `tailwind.css` | Global CSS file path (Tailwind entry / theme variables) | +| `tailwind.baseColor` | Base palette (cannot change after init) | +| `aliases.*` | Import aliases; must match `svelte.config.js` / `tsconfig` paths | +| `registry` | Base registry URL (default `https://shadcn-svelte.com/registry`) | +| `style` | Registered style name (e.g. `nova`, `vega`, …) | +| `iconLibrary` | Icon set key (`lucide`, `tabler`, …) — drives generated imports | +| `typescript` | Whether TS and optional custom config path | + +Resolved paths (including `tailwindCss`, `ui`, `components`) are computed by the CLI from `components.json` and the filesystem. Read `components.json` and list the UI directory when you need a snapshot of what is installed. diff --git a/.agents/skills/shadcn-svelte/customization.md b/.agents/skills/shadcn-svelte/customization.md new file mode 100644 index 00000000..c278cb52 --- /dev/null +++ b/.agents/skills/shadcn-svelte/customization.md @@ -0,0 +1,211 @@ +# Customization & Theming + +Components reference semantic CSS variable tokens. Change the variables to change every component. + +## Contents + +- How it works (CSS variables → Tailwind utilities → components) +- Color variables and OKLCH format +- Dark mode setup +- Changing the theme (presets, CSS variables) +- Adding custom colors (Tailwind v3 and v4) +- Border radius +- Customizing components (variants, class, wrappers) +- Checking for updates + +--- + +## How It Works + +1. CSS variables defined in `:root` (light) and `.dark` (dark mode). +2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc. +3. Components use these utilities — changing a variable changes all components that reference it. + +--- + +## Color Variables + +Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background. + +| Variable | Purpose | +| -------------------------------------------- | -------------------------------- | +| `--background` / `--foreground` | Page background and default text | +| `--card` / `--card-foreground` | Card surfaces | +| `--primary` / `--primary-foreground` | Primary buttons and actions | +| `--secondary` / `--secondary-foreground` | Secondary actions | +| `--muted` / `--muted-foreground` | Muted/disabled states | +| `--accent` / `--accent-foreground` | Hover and accent states | +| `--destructive` / `--destructive-foreground` | Error and destructive actions | +| `--border` | Default border color | +| `--input` | Form input borders | +| `--ring` | Focus ring color | +| `--chart-1` through `--chart-5` | Chart/data visualization | +| `--sidebar-*` | Sidebar-specific colors | +| `--surface` / `--surface-foreground` | Secondary surface | + +Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360). + +--- + +## Dark Mode + +Class-based toggle via `.dark` on the root element. In SvelteKit, use [mode-watcher](https://github.com/svecosystem/mode-watcher) (see [Dark mode — Svelte](https://shadcn-svelte.com/docs/dark-mode/svelte)): + +```svelte + + + +{@render children?.()} +``` + +--- + +## Changing the Theme + +Use a **preset** from the design-system builder on [shadcn-svelte.com](https://shadcn-svelte.com) and pass it to `init`: + +```bash +npx shadcn-svelte@latest init --preset +``` + +Or edit CSS variables directly in the file set in `components.json` as `tailwind.css` (for example `src/app.css`). + +To align config and components with a new preset, re-run `init` with `--preset` and confirm overwrites when prompted. + +--- + +## Adding Custom Colors + +Add variables to the global CSS file path in `components.json` (`tailwind.css`). Do not create a second global CSS file for theming unless the project already uses that pattern. + +```css +/* 1. Define in the global CSS file. */ +:root { + --warning: oklch(0.84 0.16 84); + --warning-foreground: oklch(0.28 0.07 46); +} +.dark { + --warning: oklch(0.41 0.11 46); + --warning-foreground: oklch(0.99 0.02 95); +} +``` + +```css +/* 2a. Register with Tailwind v4 (@theme inline). */ +@theme inline { + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); +} +``` + +On Tailwind v3, register in `tailwind.config.js` (see the [Tailwind v3 docs](https://tw3.shadcn-svelte.com) if you maintain a legacy setup): + +```js +// 2b. Register with Tailwind v3 (tailwind.config.js). +module.exports = { + theme: { + extend: { + colors: { + warning: 'oklch(var(--warning) / )', + 'warning-foreground': 'oklch(var(--warning-foreground) / )' + } + } + } +}; +``` + +```svelte +
    Warning
    +``` + +--- + +## Border Radius + +`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`). + +--- + +## Customizing Components + +See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples. + +Prefer these approaches in order: + +### 1. Built-in variants + +```svelte + + + +``` + +### 2. Tailwind classes via `class` + +```svelte + + + + ... + +``` + +### 3. Add a new variant + +Edit the component source to add a variant via `tailwind-variants` / `cva` in the `.svelte` or shared variants file: + +```ts +// e.g. in button variants +warning: "bg-warning text-warning-foreground hover:bg-warning/90", +``` + +### 4. Wrapper components + +Compose shadcn-svelte primitives into higher-level `.svelte` files: + +```svelte + + + + + {@render children?.()} + + + + {title} + {description} + + + Cancel + { + onConfirm?.(); + open = false; + }}>Confirm + + + +``` + +--- + +## Checking for Updates + +```bash +npx shadcn-svelte@latest update button +npx shadcn-svelte@latest update --all +``` + +See [Updating Components in SKILL.md](./SKILL.md#updating-components). Review `git diff` after `update` to see what changed. diff --git a/.agents/skills/shadcn-svelte/evals/evals.json b/.agents/skills/shadcn-svelte/evals/evals.json new file mode 100644 index 00000000..2b6067d8 --- /dev/null +++ b/.agents/skills/shadcn-svelte/evals/evals.json @@ -0,0 +1,47 @@ +{ + "skill_name": "shadcn-svelte", + "evals": [ + { + "id": 1, + "prompt": "I'm building a SvelteKit app with shadcn-svelte (nova style, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.", + "expected_output": "A Svelte component using Field.FieldGroup, Field.Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.", + "files": [], + "expectations": [ + "Uses Field.FieldGroup and Field.Field for form layout instead of raw div with space-y", + "Uses Switch for independent on/off notification toggles (not looping Button with manual active state)", + "Uses data-invalid on Field and aria-invalid on the input control for validation states", + "Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing", + "Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500", + "No manual dark: color overrides" + ] + }, + { + "id": 2, + "prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn-svelte with tabler icons.", + "expected_output": "A Svelte component with Dialog.Title, Avatar with Avatar.Fallback, data-icon on icon buttons, no icon sizing classes, @tabler/icons-svelte imports.", + "files": [], + "expectations": [ + "Includes Dialog.Title for accessibility (visible or with sr-only class)", + "Avatar includes Avatar.Fallback", + "Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")", + "No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)", + "Uses tabler icons (@tabler/icons-svelte) instead of @lucide/svelte when tabler is configured", + "Uses shadcn-svelte Dialog patterns (e.g. Dialog.Trigger wrapping the control, or bind:open on Dialog.Root)" + ] + }, + { + "id": 3, + "prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn-svelte with lucide icons.", + "expected_output": "A Svelte component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.", + "files": [], + "expectations": [ + "Uses full Card composition with Card.Header, Card.Title, Card.Content (not dumping everything into Card.Content)", + "Uses Skeleton component for loading placeholders instead of custom animate-pulse divs", + "Uses Badge component for percentage change instead of custom styled spans", + "Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600", + "Uses gap-* instead of space-y-* or space-x-* for spacing", + "Uses size-* when width and height are equal instead of separate w-* h-*" + ] + } + ] +} diff --git a/.agents/skills/shadcn-svelte/rules/composition.md b/.agents/skills/shadcn-svelte/rules/composition.md new file mode 100644 index 00000000..ca8ff45e --- /dev/null +++ b/.agents/skills/shadcn-svelte/rules/composition.md @@ -0,0 +1,242 @@ +# Component Composition + +## Contents + +- Items always inside their Group component +- Callouts use Alert +- Empty states use Empty component +- Toast notifications use svelte-sonner +- Choosing between overlay components +- Dialog, Sheet, and Drawer always need a Title +- Card structure +- Button has no isPending or isLoading prop +- Tabs.Trigger must be inside Tabs.List +- Avatar always needs Avatar.Fallback +- Use Separator instead of raw hr or border divs +- Use Skeleton for loading placeholders +- Use Badge instead of custom styled spans + +--- + +## Items always inside their Group component + +Never render items directly inside the content container. + +**Incorrect:** + +```svelte + + + + Apple + Banana + +``` + +**Correct:** + +```svelte + + + + + Apple + Banana + + +``` + +This applies to all group-based components: + +| Item | Group | +| ------------------------------------------------------------- | -------------------- | +| `Select.Item`, `Select.Label` | `Select.Group` | +| `DropdownMenu.Item`, `DropdownMenu.Label`, `DropdownMenu.Sub` | `DropdownMenu.Group` | +| `Menubar.Item` | `Menubar.Group` | +| `ContextMenu.Item` | `ContextMenu.Group` | +| `Command.Item` | `Command.Group` | + +--- + +## Callouts use Alert + +```svelte + + + + Warning + Something needs attention. + +``` + +--- + +## Empty states use Empty component + +```svelte + + + + + + No projects yet + Get started by creating a new project. + + + + + +``` + +--- + +## Toast notifications use svelte-sonner + +```svelte + +``` + +```ts +toast.success('Changes saved.'); +toast.error('Something went wrong.'); +toast('File deleted.', { + action: { label: 'Undo', onClick: () => undoDelete() } +}); +``` + +Mount the `Toaster` from your UI folder once in the app layout (see [Sonner](https://shadcn-svelte.com/docs/components/sonner)). + +--- + +## Choosing between overlay components + +| Use case | Component | +| ---------------------------------- | ------------- | +| Focused task that requires input | `Dialog` | +| Destructive action confirmation | `AlertDialog` | +| Side panel with details or filters | `Sheet` | +| Mobile-first bottom panel | `Drawer` | +| Quick info on hover | `HoverCard` | +| Small contextual content on click | `Popover` | + +--- + +## Dialog, Sheet, and Drawer always need a Title + +`Dialog.Title`, `Sheet.Title`, `Drawer.Title` are required for accessibility. Use `class="sr-only"` if visually hidden. + +```svelte + + + + + Edit Profile + Update your profile. + + ... + +``` + +--- + +## Card structure + +Use full composition — don't dump everything into `Card.Content`: + +```svelte + + + + + Team Members + Manage your team. + + ... + + + + +``` + +--- + +## Button has no isPending or isLoading prop + +Compose with `Spinner` inside `Button` + `disabled`: + +```svelte + + + +``` + +--- + +## Tabs.Trigger must be inside Tabs.List + +Never render `Tabs.Trigger` directly inside `Tabs.Root` — always wrap in `Tabs.List`: + +```svelte + + + + + Account + Password + + ... + +``` + +--- + +## Avatar always needs Avatar.Fallback + +Always include `Avatar.Fallback` for when the image fails to load: + +```svelte + + + + + JD + +``` + +--- + +## Use existing components instead of custom markup + +| Instead of | Use | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `
    ` or `
    ` | `` (`import { Separator } from "$lib/components/ui/separator"`) | +| `
    ` with styled divs | `` (`import { Skeleton } from "$lib/components/ui/skeleton"`) | +| `` | `` (`import { Badge } from "$lib/components/ui/badge"`) | diff --git a/.agents/skills/shadcn-svelte/rules/forms.md b/.agents/skills/shadcn-svelte/rules/forms.md new file mode 100644 index 00000000..933f53c8 --- /dev/null +++ b/.agents/skills/shadcn-svelte/rules/forms.md @@ -0,0 +1,232 @@ +# Forms & Inputs + +## Contents + +- Forms use Field.FieldGroup + Field.Field +- InputGroup requires InputGroup.Input/InputGroup.Textarea +- Buttons inside inputs use InputGroup.Root + InputGroup.Addon +- Option sets (2–7 choices) use ToggleGroup.Root + ToggleGroup.Item +- Field.FieldSet + Field.FieldLegend for grouping related fields +- Field validation and disabled states + +--- + +## Forms use Field.FieldGroup + Field.Field + +Always use `Field.FieldGroup` + `Field.Field` — never raw `div` with `space-y-*`: + +```svelte + + + + + Email + + + + Password + + + +``` + +Use `Field` with `orientation="horizontal"` for settings pages. Use `Field.FieldLabel` with `class="sr-only"` for visually hidden labels. + +**Choosing form controls:** + +- Simple text input → `Input` +- Dropdown with predefined options → `Select` +- Searchable dropdown → `Combobox` +- Native HTML select (no JS) → `native-select` +- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms) +- Single choice from few options → `RadioGroup` +- Toggle between 2–5 options → `ToggleGroup.Root` + `ToggleGroup.Item` +- OTP/verification code → `InputOTP` +- Multi-line text → `Textarea` + +--- + +## InputGroup requires InputGroup.Input/InputGroup.Textarea + +Never use raw `Input` or `Textarea` inside an `InputGroup.Root`. + +**Incorrect:** + +```svelte + + + + + +``` + +**Correct:** + +```svelte + + + + + +``` + +--- + +## Buttons inside inputs use InputGroup.Root + InputGroup.Addon + +Never place a `Button` directly inside or adjacent to an `Input` with custom positioning. + +**Incorrect:** + +```svelte + + +
    + + +
    +``` + +**Correct:** + +```svelte + + + + + + + + +``` + +--- + +## Option sets (2–7 choices) use ToggleGroup.Root + ToggleGroup.Item + +Don't manually loop `Button` components with active state. + +**Incorrect:** + +```svelte + + +
    + {#each ['daily', 'weekly', 'monthly'] as option (option)} + + {/each} +
    +``` + +**Correct:** + +```svelte + + + + Daily + Weekly + Monthly + +``` + +Combine with `Field` for labelled toggle groups: + +```svelte + + + + Theme + + Light + Dark + System + + +``` + +--- + +## Field.FieldSet + Field.FieldLegend for grouping related fields + +Use `Field.FieldSet` + `Field.FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading: + +```svelte + + + + Preferences + Select all that apply. + + + + Dark mode + + + +``` + +--- + +## Field validation and disabled states + +Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control. + +```svelte + + + + + Email + + Invalid email address. + + + + + Email + + +``` + +Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`. diff --git a/.agents/skills/shadcn-svelte/rules/icons.md b/.agents/skills/shadcn-svelte/rules/icons.md new file mode 100644 index 00000000..15b54c9c --- /dev/null +++ b/.agents/skills/shadcn-svelte/rules/icons.md @@ -0,0 +1,107 @@ +# Icons + +**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field in `components.json`: `lucide` → `@lucide/svelte`, `tabler` → `@tabler/icons-svelte`, etc. Never assume `@lucide/svelte`. + +--- + +## Icons in Button use data-icon attribute + +Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon. + +**Incorrect:** + +```svelte + + + +``` + +**Correct:** + +```svelte + + + + + +``` + +--- + +## No sizing classes on icons inside components + +Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside ` +``` + +**Correct:** + +```svelte + + + +``` + +The same applies to icons inside `DropdownMenu.Item`, sidebar items, and other menu rows — no extra sizing classes on the icon component. + +--- + +## Pass icons as components, not string keys + +Use a component reference, not a string key to a lookup map. + +**Incorrect:** + +```svelte + + +``` + +**Correct:** + +```svelte + + + + + +``` diff --git a/.agents/skills/shadcn-svelte/rules/styling.md b/.agents/skills/shadcn-svelte/rules/styling.md new file mode 100644 index 00000000..f12415cd --- /dev/null +++ b/.agents/skills/shadcn-svelte/rules/styling.md @@ -0,0 +1,193 @@ +# Styling & Customization + +See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors. + +## Contents + +- Semantic colors +- Built-in variants first +- class for layout only +- No space-x-_ / space-y-_ +- Prefer size-_ over w-_ h-\* when equal +- Prefer truncate shorthand +- No manual dark: color overrides +- Use cn() for conditional classes +- No manual z-index on overlay components + +--- + +## Semantic colors + +**Incorrect:** + +```svelte +
    +

    Secondary text

    +
    +``` + +**Correct:** + +```svelte +
    +

    Secondary text

    +
    +``` + +--- + +## No raw color values for status/state indicators + +For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors. + +**Incorrect:** + +```svelte ++20.1% +Active +-3.2% +``` + +**Correct:** + +```svelte + + ++20.1% +Active +-3.2% +``` + +If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)). + +--- + +## Built-in variants first + +**Incorrect:** + +```svelte + + + +``` + +**Correct:** + +```svelte + + + +``` + +--- + +## class for layout only + +Use `class` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables. + +**Incorrect:** + +```svelte + + + + Dashboard + +``` + +**Correct:** + +```svelte + + + + Dashboard + +``` + +To customize a component's appearance, prefer these approaches in order: + +1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc. +2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`. +3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)). + +--- + +## No space-x-_ / space-y-_ + +Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`. + +```svelte + + +
    + + + +
    +``` + +--- + +## Prefer size-_ over w-_ h-\* when equal + +`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc. + +--- + +## Prefer truncate shorthand + +`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`. + +--- + +## No manual dark: color overrides + +Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`. + +--- + +## Use cn() for conditional classes + +Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in `class` strings. + +**Incorrect:** + +```svelte + + +
    +``` + +**Correct:** + +```svelte + + +
    +``` + +--- + +## No manual z-index on overlay components + +`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..fb29c281 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm check *)", + "Bash(pnpm lint *)", + "Bash(node_modules/.bin/prettier --check *)", + "Bash(npm run check *)", + "Bash(npm run lint *)", + "Bash(pnpm exec prettier --check *)", + "Bash(pnpm vitest --run *)" + ] + } +} diff --git a/.prettierignore b/.prettierignore index 5fb313c2..2ac23908 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,4 +10,8 @@ bun.lockb # Ignore specific files for linting project.inlang/.meta.json -project.inlang/README.md \ No newline at end of file +project.inlang/README.md + +# Vendored agent skills and local tool config — not ours to reformat +.agents/ +.claude/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..2eaa7586 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Svelte MCP Usage + +Use the Svelte MCP server for any Svelte, SvelteKit, or `.svelte`/`.svelte.ts` work: + +1. **`list-sections`** — call first to discover docs sections; always start Svelte tasks here. +2. **`get-documentation`** — after `list-sections`, inspect `use_cases` and fetch all relevant sections at once when possible. +3. **`svelte-autofixer`** — use whenever writing or editing Svelte code; iterate until clean. +4. **`playground-link`** — only after the user explicitly asks for one; never for code already written to the repo. + +## Project Snapshot + +Tracktor is a self-hosted vehicle management app (fuel, maintenance, insurance, PUCC/pollution certs, reminders) built with SvelteKit + Svelte 5, Tailwind CSS, SQLite (via `@libsql/client`), and Drizzle ORM. i18n uses inlang/Paraglide. TypeScript is strict throughout. + +## Core Commands + +- Install: `pnpm install` +- Dev server: `pnpm dev` (host mode) / `pnpm local` (localhost only) +- Build: `pnpm build` / Preview build: `pnpm preview` +- Type/svelte check: `pnpm check` (watch: `pnpm check:watch`) +- Lint: `pnpm lint` (eslint + prettier check) / Autofix: `pnpm format` +- Test: `pnpm test` (watch: `pnpm test:watch`, coverage: `pnpm test:coverage`) +- DB: `pnpm db:generate` (drizzle migration from schema changes), `pnpm db:migrate`, `pnpm db:seed` +- Clean: `pnpm clean` (removes build artifacts, db file, node_modules, etc.) + +Always run `pnpm check` and `pnpm lint` before considering a change finished. ESLint fails the build on unused imports/vars. + +### Single-Test Commands + +- Run one file: `pnpm vitest --run path/to/file.test.ts` +- Run by test name: `pnpm vitest --run -t "test name"` +- Run a folder: `pnpm vitest --run src/__tests__/feature` + +Note: test coverage is currently minimal (essentially a placeholder in `src/__tests__/index.test.ts`) — don't assume extensive existing test patterns exist for a given module. + +## Architecture + +### Path aliases (defined in `svelte.config.js`) + +`$lib` → `src/lib`, `$ui` → `src/lib/components/ui`, `$appui` → `src/lib/components/app`, `$layout` → `src/lib/components/layout`, `$feature` → `src/lib/components/feature`, `$stores` → `src/lib/stores`, `$services` → `src/lib/services`, `$helper` → `src/lib/helper`, `$dashboard` → `src/lib/components/dashboard`, `$server` → `src/server`. Prefer these aliases over long relative paths. + +### Two parallel "service" layers — don't confuse them + +- **`src/lib/services/*.service.ts`** — browser/client-side code. Calls the app's own `/api/*` REST endpoints via `$lib/helper/api.helper` (`apiClient`) and returns a `Response` shape (`{ status: 'OK' | 'ERROR', data?, error? }`). Used from `.svelte` pages/components. +- **`src/server/services/*Service.ts`** — server-only code. Talks directly to the Drizzle DB (`src/server/db`), does business logic, and is called from `+server.ts` route handlers (or `+page.server.ts`). Never import these from client-facing `.svelte` code. + +`src/lib/domain/*` holds shared types/models and pure business rules (e.g. `domain/fuel/mileage.ts` mileage math) usable from both client and server code. + +### Request pipeline + +`src/hooks.server.ts` runs one-time app init (ensure directories, `initializeDatabase()` — runs Drizzle migrations, seeding, then patches — and starts the notification scheduler cron) and wires a `MiddlewareChain` (`src/server/middlewares`, chain-of-responsibility pattern via `BaseMiddleware`/`setNext`): `CorsMiddleware` → `AuthMiddleware` → `RateLimitMiddleware` → `LoggingMiddleware`. `AuthMiddleware` checks session cookie/Bearer token against `authService`, bypassing `/api/auth`, `/api/health`, `/api/config/branding`, and everything when `TRACKTOR_DISABLE_AUTH`/`env.DISABLE_AUTH` is set. + +### Data layer + +- Drizzle schema lives in `src/server/db/schema/*.ts` (one file per domain entity: `vehicle`, `fuel-log`, `insurance`, `maintenance-logs`, `pucc`, `reminder`, `notification`, `notification-provider`, `config`, `audit`, `auth`). +- SQLite dialect, `snake_case` column casing, migrations generated to `src/server/db/migrations` via `pnpm db:generate` — never hand-edit generated migrations. +- One-off data fixups live under `src/server/db/patch` and run via `applyPatches()` at startup, after migrations/seeding. + +### Routes + +- `src/routes/(app)/*` — authenticated app pages (dashboard, fuel, maintenance, insurance, pollution, reminders, vehicles, expenses, reports, settings), sharing the app shell (`AppSidebar`, `+layout.svelte`). +- `src/routes/(auth)/*` — login/register, outside the app shell. +- `src/routes/api/*` — REST endpoints as `+server.ts` files; vehicle-scoped resources nest under `api/vehicles/[id]/...`. + +The app recently moved from a `/dashboard/*` nested-route structure to top-level feature routes (`/fuel`, `/insurance`, `/maintenance`, `/pollution`, `/reminders`) with a single sidebar app shell — the old `dashboard/(feature)` routes are being removed in favor of this flatter structure with fleet-wide/vehicle-selector support baked into each page. + +### UI components + +`src/lib/components/ui` is a shadcn-svelte install (`components.json`, baseColor `zinc`, registry `shadcn-svelte.com`) built on `bits-ui` + `tailwind-variants` — treat it as generated/vendored (it's excluded from lint) and prefer composing it from `$feature`/`$dashboard`/`$appui` rather than editing it directly. Charts use `layerchart`/`d3-*`. + +### Feature toggles + +Features (Fuel Log, Maintenance, PUCC, Reminders, Insurance, Overview) are stored as string `'true'/'false'` values in the `configs` table under keys like `featureFuelLog`, and gated in the UI with the `FeatureGate` component (`feature="fuelLog"` or `requireAll={[...]}`). See `docs/feature-toggles.md`. + +## Code Style + +- ESM only (`"type": "module"`); use `pnpm`, not raw `npm`/`yarn`/`npx`. +- `camelCase` for values/functions, `PascalCase` for components/types, `SCREAMING_SNAKE_CASE` for constants. +- Group imports: external, then aliases, then local relative. +- Assume Svelte 5 runes mode where a file already uses it; keep props/events simple; prefer derived values/helpers over complex template logic; split busy `.svelte` markup into smaller components. +- Fail fast on invalid inputs; prefer existing response/error helpers (`$server/exceptions/AppError`, `service-response.helper.ts`) over ad hoc shapes; narrow `unknown` before accessing API/request/JSON values. +- Respect the boundaries between `src/lib/domain`, `src/lib/services`, `src/server/services`, and `src/lib/components` described above. +- Keep translation changes in `messages/` aligned with inlang; regenerate via the Paraglide vite plugin (runs automatically through `vite dev`/`vite build`). +- Add comments only when something is genuinely non-obvious. diff --git a/package.json b/package.json index 8a703abb..440d1d2a 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@inlang/paraglide-js": "^2.22.0", + "@inlang/paraglide-js": "^2.23.0", "@internationalized/date": "^3.12.2", - "@lucide/svelte": "^1.26.0", + "@lucide/svelte": "^1.27.0", "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/kit": "^2.70.1", "@sveltejs/vite-plugin-svelte": "^7.2.0", @@ -41,10 +41,11 @@ "@types/supertest": "^7.2.1", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", + "@typescript/native": "npm:typescript@^7.0.2", "@vitest/coverage-v8": "^4.1.10", "bits-ui": "^2.18.1", "clsx": "^2.1.1", - "concurrently": "^10.0.3", + "concurrently": "^10.0.4", "currency-codes": "^2.2.0", "d3-array": "^3.2.4", "d3-scale": "^4.0.2", @@ -53,26 +54,25 @@ "date-fns-tz": "^3.2.0", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.22.0", "eslint-plugin-unused-imports": "^4.4.1", "formsnap": "^2.0.1", - "globals": "^17.7.0", - "jsdom": "^29.1.1", + "globals": "^17.8.0", + "jsdom": "^30.0.0", "layerchart": "2.0.2", "prettier": "^3.9.6", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", - "svelte": "^5.56.7", - "@typescript/native": "npm:typescript@^7.0.2", + "svelte": "^5.56.8", "svelte-awesome-color-picker": "^4.1.3", "svelte-check": "^4.7.3", "svelte-eslint-parser": "^1.8.0", "svelte-sonner": "^1.1.1", "sveltekit-superforms": "^2.30.2", "tailwind-merge": "^3.6.0", - "tailwind-variants": "^3.2.2", + "tailwind-variants": "^3.3.0", "tailwindcss": "^4.3.3", "tsx": "^4.23.1", "tw-animate-css": "^1.4.0", @@ -102,6 +102,7 @@ "node-cron": "^4.6.0", "nodemailer": "^9.0.3", "pdfkit": "^0.19.1", + "svelte-dnd-action": "^0.9.74", "winston": "^3.19.0", "zod": "^4.4.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f21def8..00562bf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,7 +58,7 @@ importers: version: 8.3.0 mode-watcher: specifier: ^1.1.0 - version: 1.1.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + version: 1.1.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) multer: specifier: ^2.2.0 version: 2.2.0 @@ -71,6 +71,9 @@ importers: pdfkit: specifier: ^0.19.1 version: 0.19.1 + svelte-dnd-action: + specifier: ^0.9.74 + version: 0.9.74(svelte@5.56.8(@typescript-eslint/types@8.65.0)) winston: specifier: ^3.19.0 version: 3.19.0 @@ -80,25 +83,25 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.7.0(jiti@2.7.0)) + version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) '@inlang/paraglide-js': - specifier: ^2.22.0 - version: 2.22.0(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + specifier: ^2.23.0 + version: 2.23.0(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) '@internationalized/date': specifier: ^3.12.2 version: 3.12.2 '@lucide/svelte': - specifier: ^1.26.0 - version: 1.26.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + specifier: ^1.27.0 + version: 1.27.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) '@sveltejs/adapter-node': specifier: ^5.5.7 - version: 5.5.7(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))) + version: 5.5.7(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))) '@sveltejs/kit': specifier: ^2.70.1 - version: 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + version: 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) '@sveltejs/vite-plugin-svelte': specifier: ^7.2.0 - version: 7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + version: 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) '@tailwindcss/forms': specifier: ^0.5.11 version: 0.5.11(tailwindcss@4.3.3) @@ -113,7 +116,7 @@ importers: version: 8.21.3 '@testing-library/svelte': specifier: ^5.4.2 - version: 5.4.2(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))(vitest@4.1.10) + version: 5.4.2(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))(vitest@4.1.10) '@types/node': specifier: ^26.1.1 version: 26.1.1 @@ -131,10 +134,10 @@ importers: version: 7.2.1 '@typescript-eslint/eslint-plugin': specifier: ^8.65.0 - version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/parser': specifier: ^8.65.0 - version: 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 @@ -143,13 +146,13 @@ importers: version: 4.1.10(vitest@4.1.10) bits-ui: specifier: ^2.18.1 - version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)) clsx: specifier: ^2.1.1 version: 2.1.1 concurrently: - specifier: ^10.0.3 - version: 10.0.3 + specifier: ^10.0.4 + version: 10.0.4 currency-codes: specifier: ^2.2.0 version: 2.2.0 @@ -175,62 +178,62 @@ importers: specifier: ^0.45.2 version: 0.45.2(@libsql/client@0.17.4)(kysely@0.28.16) eslint: - specifier: ^10.7.0 - version: 10.7.0(jiti@2.7.0) + specifier: ^10.8.0 + version: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.7.0(jiti@2.7.0)) + version: 10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-svelte: specifier: ^3.22.0 - version: 3.22.0(eslint@10.7.0(jiti@2.7.0))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + version: 3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(svelte@5.56.8(@typescript-eslint/types@8.65.0)) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) formsnap: specifier: ^2.0.1 - version: 2.0.1(svelte@5.56.7(@typescript-eslint/types@8.65.0))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)) + version: 2.0.1(svelte@5.56.8(@typescript-eslint/types@8.65.0))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)) globals: - specifier: ^17.7.0 - version: 17.7.0 + specifier: ^17.8.0 + version: 17.8.0 jsdom: - specifier: ^29.1.1 - version: 29.1.1(@noble/hashes@1.8.0) + specifier: ^30.0.0 + version: 30.0.0(@noble/hashes@1.8.0) layerchart: specifier: 2.0.2 - version: 2.0.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3) + version: 2.0.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(zod@4.4.3) prettier: specifier: ^3.9.6 version: 3.9.6 prettier-plugin-svelte: specifier: ^4.1.1 - version: 4.1.1(prettier@3.9.6)(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + version: 4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.65.0)) prettier-plugin-tailwindcss: specifier: ^0.8.1 - version: 0.8.1(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.7(@typescript-eslint/types@8.65.0)))(prettier@3.9.6) + version: 0.8.1(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(prettier@3.9.6) svelte: - specifier: ^5.56.7 - version: 5.56.7(@typescript-eslint/types@8.65.0) + specifier: ^5.56.8 + version: 5.56.8(@typescript-eslint/types@8.65.0) svelte-awesome-color-picker: specifier: ^4.1.3 - version: 4.1.3(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + version: 4.1.3(svelte@5.56.8(@typescript-eslint/types@8.65.0)) svelte-check: specifier: ^4.7.3 - version: 4.7.3(picomatch@4.0.4)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3) + version: 4.7.3(picomatch@4.0.4)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3) svelte-eslint-parser: specifier: ^1.8.0 - version: 1.8.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + version: 1.8.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) svelte-sonner: specifier: ^1.1.1 - version: 1.1.1(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + version: 1.1.1(svelte@5.56.8(@typescript-eslint/types@8.65.0)) sveltekit-superforms: specifier: ^2.30.2 - version: 2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3) + version: 2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 tailwind-variants: - specifier: ^3.2.2 - version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.3) + specifier: ^3.3.0 + version: 3.3.0(tailwind-merge@3.6.0)(tailwindcss@4.3.3) tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -248,7 +251,7 @@ importers: version: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.0(@noble/hashes@1.8.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) packages: @@ -258,20 +261,13 @@ packages: '@ark/util@0.56.2': resolution: {integrity: sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==} - '@asamuzakjp/css-color@5.1.11': - resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@asamuzakjp/css-color@6.0.5': + resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==} + engines: {node: ^22.13.0 || >=24.0.0} - '@asamuzakjp/dom-selector@7.1.1': - resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/generational-cache@1.0.1': - resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/nwsapi@2.3.9': - resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@asamuzakjp/dom-selector@8.3.0': + resolution: {integrity: sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==} + engines: {node: ^22.13.0 || >=24.0.0} '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} @@ -310,19 +306,19 @@ packages: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} - '@csstools/color-helpers@6.0.2': - resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} - '@csstools/css-calc@3.2.0': - resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@4.1.0': - resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 @@ -334,8 +330,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.4': - resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} peerDependencies: css-tree: ^3.2.1 peerDependenciesMeta: @@ -369,11 +365,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -833,8 +829,8 @@ packages: resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@1.2.1': @@ -867,6 +863,15 @@ packages: '@noble/hashes': optional: true + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@exodus/schemasafe@1.3.0': resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} @@ -905,8 +910,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inlang/paraglide-js@2.22.0': - resolution: {integrity: sha512-GSzG7KEKcYAhwuPNJczIPB+DzyndYxr4lsXAkkB7xh00jTrt80NF2KfgjEAkxuJvWcnscqXf7y7d1Q0SWtSu7A==} + '@inlang/paraglide-js@2.23.0': + resolution: {integrity: sha512-q+FQisRAVQqyD+0fdVHPkC7UDfaXENUJqUjim7XGEzWHE6vVMUOO10MFtAOKx5VYAgi8o7rLwe4zjzesCY0Anw==} hasBin: true peerDependencies: typescript: '>=5.6' @@ -1019,8 +1024,8 @@ packages: '@lix-js/server-protocol-schema@0.1.1': resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} - '@lucide/svelte@1.26.0': - resolution: {integrity: sha512-KUfLTlNRVrHZ460Um7jdrvh46hI/8Sw4+1k/Ttj4g6JI9bCrjD7HZVG4cqhh7LasjfDz5b+CuKrjd/k/3obYcA==} + '@lucide/svelte@1.27.0': + resolution: {integrity: sha512-qzNBHdqy/MKp2qxuy/DcYELuep6h19fjo+4ech8fZsS4GtZeBtg5jgCTRQvLvwhppt0mhRN6qt8Nt8rPb/J4Ag==} peerDependencies: svelte: ^5 @@ -1049,6 +1054,7 @@ packages: '@oslojs/crypto@1.0.1': resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -1999,6 +2005,10 @@ packages: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} + brotli@1.3.3: resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} @@ -2089,8 +2099,8 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} - concurrently@10.0.3: - resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==} + concurrently@10.0.4: + resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} engines: {node: '>=22'} hasBin: true @@ -2520,8 +2530,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.7.0: - resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==} + eslint@10.8.0: + resolution: {integrity: sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -2675,8 +2685,8 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globals@17.7.0: - resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + globals@17.8.0: + resolution: {integrity: sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==} engines: {node: '>=18'} gopd@1.2.0: @@ -2811,11 +2821,11 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - jsdom@29.1.1: - resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + jsdom@30.0.0: + resolution: {integrity: sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} peerDependencies: - canvas: ^3.0.0 + canvas: ^3.2.3 peerDependenciesMeta: canvas: optional: true @@ -2964,8 +2974,8 @@ packages: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} - lru-cache@11.3.6: - resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} lz-string@1.5.0: @@ -3020,6 +3030,10 @@ packages: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + mode-watcher@1.1.0: resolution: {integrity: sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==} peerDependencies: @@ -3390,8 +3404,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.4: - resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} engines: {node: '>= 0.4'} siginfo@2.0.0: @@ -3479,6 +3493,11 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' + svelte-dnd-action@0.9.74: + resolution: {integrity: sha512-zn//d8plF73Y05joRdj0g9S7Z8KY5zoTyzjHtnaJMqQP//6U6smHAHi0ArZWi60bxpoOwo+im8gKhK/M7Eq1pQ==} + peerDependencies: + svelte: '>=3.23.0 || ^5.0.0-next.0' + svelte-eslint-parser@1.8.0: resolution: {integrity: sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.1} @@ -3511,8 +3530,8 @@ packages: peerDependencies: svelte: ^5.0.0 - svelte@5.56.7: - resolution: {integrity: sha512-5qERUZX80oQj6XrDMUmD2Uhd/cIpCPDWWKBK3ZHmyRUC9apPyamWM8xMo31mbWsIQxwG2hVoSnOJ/EcnhVkkzQ==} + svelte@5.56.8: + resolution: {integrity: sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==} engines: {node: '>=18'} sveltekit-superforms@2.30.2: @@ -3530,15 +3549,17 @@ packages: tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwind-variants@3.2.2: - resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} - engines: {node: '>=16.x', pnpm: '>=7.x'} + tailwind-variants@3.3.0: + resolution: {integrity: sha512-t1QsB42dcwUdaCEArO0tRZcP0nbCcAmJKRYCs7jmwYSFOCXlGqgO8c0TrCYm9OranYmT6i9YOi8LFo2gxmxnow==} + engines: {node: '>=16.9.x', pnpm: '>=7.x'} peerDependencies: tailwind-merge: '>=3.0.0' tailwindcss: '*' peerDependenciesMeta: tailwind-merge: optional: true + tailwindcss: + optional: true tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -3589,8 +3610,8 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} tr46@6.0.0: @@ -3663,9 +3684,9 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@7.25.0: - resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} - engines: {node: '>=20.18.1'} + undici@8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + engines: {node: '>=22.19.0'} unicode-properties@1.4.1: resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} @@ -3817,6 +3838,10 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3906,25 +3931,20 @@ snapshots: '@ark/util@0.56.2': optional: true - '@asamuzakjp/css-color@5.1.11': + '@asamuzakjp/css-color@6.0.5': dependencies: - '@asamuzakjp/generational-cache': 1.0.1 - '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 - '@asamuzakjp/dom-selector@7.1.1': + '@asamuzakjp/dom-selector@8.3.0': dependencies: - '@asamuzakjp/generational-cache': 1.0.1 - '@asamuzakjp/nwsapi': 2.3.9 bidi-js: 1.0.3 css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 - - '@asamuzakjp/generational-cache@1.0.1': {} - - '@asamuzakjp/nwsapi@2.3.9': {} + lru-cache: 11.5.2 '@babel/code-frame@7.29.0': dependencies: @@ -3955,17 +3975,17 @@ snapshots: '@colors/colors@1.6.0': {} - '@csstools/color-helpers@6.0.2': {} + '@csstools/color-helpers@6.1.0': {} - '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/color-helpers': 6.0.2 - '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -3973,7 +3993,7 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 @@ -4241,22 +4261,22 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.7.0(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))': dependencies: - eslint: 10.7.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@10.2.2)': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.4 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.6.0': + '@eslint/config-helpers@0.7.0': dependencies: '@eslint/core': 1.2.1 @@ -4264,9 +4284,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.7.0(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))': optionalDependencies: - eslint: 10.7.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) '@eslint/object-schema@3.0.5': {} @@ -4279,6 +4299,10 @@ snapshots: optionalDependencies: '@noble/hashes': 1.8.0 + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@exodus/schemasafe@1.3.0': optional: true @@ -4314,7 +4338,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inlang/paraglide-js@2.22.0(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))': + '@inlang/paraglide-js@2.23.0(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))': dependencies: '@inlang/recommend-sherlock': 0.2.1 '@inlang/sdk': 2.10.2 @@ -4461,9 +4485,9 @@ snapshots: '@lix-js/server-protocol-schema@0.1.1': {} - '@lucide/svelte@1.26.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))': + '@lucide/svelte@1.27.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))': dependencies: - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: @@ -4693,20 +4717,20 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-node@5.5.7(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))': + '@sveltejs/adapter-node@5.5.7(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))': dependencies: '@rollup/plugin-commonjs': 29.0.2(rollup@4.59.0) '@rollup/plugin-json': 6.1.0(rollup@4.59.0) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0) '@rollup/plugin-replace': 6.0.3(rollup@4.59.0) - '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) rollup: 4.59.0 - '@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))': + '@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.11(acorn@8.16.0) - '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 @@ -4717,19 +4741,19 @@ snapshots: mrmime: 2.0.1 set-cookie-parser: 3.0.1 sirv: 3.0.2 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1) optionalDependencies: typescript: 6.0.3 '@sveltejs/load-config@0.2.0': {} - '@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))': + '@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))': dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1) vitefu: 1.1.2(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) @@ -4828,18 +4852,18 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/svelte-core@1.1.3(svelte@5.56.7(@typescript-eslint/types@8.65.0))': + '@testing-library/svelte-core@1.1.3(svelte@5.56.8(@typescript-eslint/types@8.65.0))': dependencies: - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) - '@testing-library/svelte@5.4.2(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))(vitest@4.1.10)': + '@testing-library/svelte@5.4.2(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))(vitest@4.1.10)': dependencies: '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.1.3(svelte@5.56.7(@typescript-eslint/types@8.65.0)) - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + '@testing-library/svelte-core': 1.1.3(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) optionalDependencies: vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1) - vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.0(@noble/hashes@1.8.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) '@tybys/wasm-util@0.10.3': dependencies: @@ -4994,15 +5018,15 @@ snapshots: '@types/json-schema': 7.0.15 optional: true - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 - eslint: 10.7.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -5010,23 +5034,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 - eslint: 10.7.0(jiti@2.7.0) + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.65.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) '@typescript-eslint/types': 8.65.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -5040,13 +5064,13 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 10.7.0(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -5054,13 +5078,13 @@ snapshots: '@typescript-eslint/types@8.65.0': {} - '@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.65.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.17 @@ -5069,13 +5093,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - eslint: 10.7.0(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -5177,7 +5201,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.0(@noble/hashes@1.8.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) '@vitest/expect@4.1.10': dependencies: @@ -5292,15 +5316,15 @@ snapshots: dependencies: require-from-string: 2.0.2 - bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/dom': 1.7.6 '@internationalized/date': 3.12.2 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) - svelte: 5.56.7(@typescript-eslint/types@8.65.0) - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + runed: 0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -5309,6 +5333,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + brace-expansion@5.0.8: + dependencies: + balanced-match: 4.0.4 + brotli@1.3.3: dependencies: base64-js: 1.5.1 @@ -5395,11 +5423,11 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 - concurrently@10.0.3: + concurrently@10.0.4: dependencies: chalk: 5.6.2 rxjs: 7.8.2 - shell-quote: 1.8.4 + shell-quote: 1.9.0 supports-color: 10.2.2 tree-kill: 1.2.2 yargs: 18.0.0 @@ -5559,9 +5587,11 @@ snapshots: dayjs@1.11.20: optional: true - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js@10.6.0: {} @@ -5733,15 +5763,15 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.7.0(jiti@2.7.0)): + eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - eslint: 10.7.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) - eslint-plugin-svelte@3.22.0(eslint@10.7.0(jiti@2.7.0))(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + eslint-plugin-svelte@3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) '@jridgewell/sourcemap-codec': 1.5.5 - eslint: 10.7.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) esutils: 2.0.3 globals: 16.5.0 known-css-properties: 0.37.0 @@ -5749,17 +5779,17 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.16) postcss-safe-parser: 7.0.1(postcss@8.5.16) semver: 7.7.4 - svelte-eslint-parser: 1.8.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + svelte-eslint-parser: 1.8.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) optionalDependencies: - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) transitivePeerDependencies: - ts-node - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - eslint: 10.7.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) eslint-scope@8.4.0: dependencies: @@ -5779,12 +5809,12 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.7.0(jiti@2.7.0): + eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.6.0 + '@eslint/config-array': 0.23.5(supports-color@10.2.2) + '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 '@humanfs/node': 0.16.7 @@ -5793,7 +5823,7 @@ snapshots: '@types/estree': 1.0.8 ajv: 6.14.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -5808,7 +5838,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.4 + minimatch: 10.2.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -5919,11 +5949,11 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - formsnap@2.0.1(svelte@5.56.7(@typescript-eslint/types@8.65.0))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)): + formsnap@2.0.1(svelte@5.56.8(@typescript-eslint/types@8.65.0))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)): dependencies: - svelte: 5.56.7(@typescript-eslint/types@8.65.0) - svelte-toolbelt: 0.5.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) - sveltekit-superforms: 2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte-toolbelt: 0.5.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + sveltekit-superforms: 2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3) fsevents@2.3.3: optional: true @@ -5962,7 +5992,7 @@ snapshots: globals@16.5.0: {} - globals@17.7.0: {} + globals@17.8.0: {} gopd@1.2.0: {} @@ -6070,28 +6100,28 @@ snapshots: js-tokens@4.0.0: {} - jsdom@29.1.1(@noble/hashes@1.8.0): + jsdom@30.0.0(@noble/hashes@1.8.0): dependencies: - '@asamuzakjp/css-color': 5.1.11 - '@asamuzakjp/dom-selector': 7.1.1 + '@asamuzakjp/css-color': 6.0.5 + '@asamuzakjp/dom-selector': 8.3.0 '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) - '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) css-tree: 3.2.1 data-urls: 7.0.0(@noble/hashes@1.8.0) decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) is-potential-custom-element-name: 1.0.1 - lru-cache: 11.3.6 + lru-cache: 11.5.2 parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 6.0.1 - undici: 7.25.0 + tough-cookie: 6.0.2 + undici: 8.9.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@1.8.0) + whatwg-url: 17.1.0(@noble/hashes@1.8.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -6122,7 +6152,7 @@ snapshots: kysely@0.28.16: {} - layerchart@2.0.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3): + layerchart@2.0.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(zod@4.4.3): dependencies: '@dagrejs/dagre': 2.0.4 '@layerstack/svelte-actions': 1.0.1-next.18 @@ -6152,8 +6182,8 @@ snapshots: d3-tile: 1.0.0 d3-time: 3.1.0 memoize: 10.2.0 - runed: 0.37.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3) - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + runed: 0.37.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(zod@4.4.3) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) transitivePeerDependencies: - '@sveltejs/kit' - zod @@ -6252,7 +6282,7 @@ snapshots: safe-stable-stringify: 2.5.0 triple-beam: 1.4.1 - lru-cache@11.3.6: {} + lru-cache@11.5.2: {} lz-string@1.5.0: {} @@ -6296,11 +6326,15 @@ snapshots: dependencies: brace-expansion: 5.0.4 - mode-watcher@1.1.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + minimatch@10.2.5: dependencies: - runed: 0.25.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) - svelte: 5.56.7(@typescript-eslint/types@8.65.0) - svelte-toolbelt: 0.7.1(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + brace-expansion: 5.0.8 + + mode-watcher@1.1.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + dependencies: + runed: 0.25.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte-toolbelt: 0.7.1(svelte@5.56.8(@typescript-eslint/types@8.65.0)) mri@1.2.0: {} @@ -6441,16 +6475,16 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: prettier: 3.9.6 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) - prettier-plugin-tailwindcss@0.8.1(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.7(@typescript-eslint/types@8.65.0)))(prettier@3.9.6): + prettier-plugin-tailwindcss@0.8.1(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.65.0)))(prettier@3.9.6): dependencies: prettier: 3.9.6 optionalDependencies: - prettier-plugin-svelte: 4.1.1(prettier@3.9.6)(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + prettier-plugin-svelte: 4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.65.0)) prettier@3.9.6: {} @@ -6546,38 +6580,38 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 - runed@0.23.4(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + runed@0.23.4(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: esm-env: 1.2.2 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) - runed@0.25.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + runed@0.25.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: esm-env: 1.2.2 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) - runed@0.28.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + runed@0.28.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: esm-env: 1.2.2 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) - runed@0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + runed@0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) optionalDependencies: - '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) - runed@0.37.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(zod@4.4.3): + runed@0.37.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(zod@4.4.3): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) optionalDependencies: - '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) zod: 4.4.3 rw@1.3.3: {} @@ -6610,7 +6644,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.4: {} + shell-quote@1.9.0: {} siginfo@2.0.0: {} @@ -6671,17 +6705,17 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome-color-picker@4.1.3(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + svelte-awesome-color-picker@4.1.3(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: colord: 2.9.3 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) - svelte-awesome-slider: 2.0.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte-awesome-slider: 2.0.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) - svelte-awesome-slider@2.0.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + svelte-awesome-slider@2.0.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) - svelte-check@4.7.3(picomatch@4.0.4)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3): + svelte-check@4.7.3(picomatch@4.0.4)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 '@sveltejs/load-config': 0.2.0 @@ -6689,12 +6723,16 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) typescript: 6.0.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.8.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + svelte-dnd-action@0.9.74(svelte@5.56.8(@typescript-eslint/types@8.65.0)): + dependencies: + svelte: 5.56.8(@typescript-eslint/types@8.65.0) + + svelte-eslint-parser@1.8.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -6704,36 +6742,36 @@ snapshots: postcss-selector-parser: 7.1.1 semver: 7.7.4 optionalDependencies: - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) - svelte-sonner@1.1.1(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + svelte-sonner@1.1.1(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: - runed: 0.28.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)) - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + runed: 0.28.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) - svelte-toolbelt@0.10.6(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + runed: 0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)) style-to-object: 1.0.14 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) transitivePeerDependencies: - '@sveltejs/kit' - svelte-toolbelt@0.5.0(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + svelte-toolbelt@0.5.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: clsx: 2.1.1 style-to-object: 1.0.14 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) - svelte-toolbelt@0.7.1(svelte@5.56.7(@typescript-eslint/types@8.65.0)): + svelte-toolbelt@0.7.1(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: clsx: 2.1.1 - runed: 0.23.4(svelte@5.56.7(@typescript-eslint/types@8.65.0)) + runed: 0.23.4(svelte@5.56.8(@typescript-eslint/types@8.65.0)) style-to-object: 1.0.14 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) - svelte@5.56.7(@typescript-eslint/types@8.65.0): + svelte@5.56.8(@typescript-eslint/types@8.65.0): dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -6754,12 +6792,12 @@ snapshots: transitivePeerDependencies: - '@typescript-eslint/types' - sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3): + sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3): dependencies: - '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.7(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) + '@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) devalue: 5.8.1 memoize-weak: 1.0.2 - svelte: 5.56.7(@typescript-eslint/types@8.65.0) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) ts-deepmerge: 8.0.0 optionalDependencies: '@exodus/schemasafe': 1.3.0 @@ -6788,11 +6826,10 @@ snapshots: tailwind-merge@3.6.0: {} - tailwind-variants@3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.3): - dependencies: - tailwindcss: 4.3.3 + tailwind-variants@3.3.0(tailwind-merge@3.6.0)(tailwindcss@4.3.3): optionalDependencies: tailwind-merge: 3.6.0 + tailwindcss: 4.3.3 tailwindcss@4.3.3: {} @@ -6832,7 +6869,7 @@ snapshots: totalist@3.0.1: {} - tough-cookie@6.0.1: + tough-cookie@6.0.2: dependencies: tldts: 7.0.26 @@ -6909,7 +6946,7 @@ snapshots: undici-types@8.3.0: {} - undici@7.25.0: {} + undici@8.9.0: {} unicode-properties@1.4.1: dependencies: @@ -6966,7 +7003,7 @@ snapshots: optionalDependencies: vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1) - vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)): + vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.0(@noble/hashes@1.8.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) @@ -6991,7 +7028,7 @@ snapshots: optionalDependencies: '@types/node': 26.1.1 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - jsdom: 29.1.1(@noble/hashes@1.8.0) + jsdom: 30.0.0(@noble/hashes@1.8.0) transitivePeerDependencies: - msw @@ -7013,6 +7050,14 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + whatwg-url@17.1.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a7d2aaab..45e844bd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,6 @@ allowBuilds: sqlite3: true minimumReleaseAgeExclude: - helmet@8.3.0 + - globals@17.8.0 + - jsdom@30.0.0 + - tailwind-variants@3.3.0 diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..abe05c72 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "shadcn-svelte": { + "source": "huntabyte/shadcn-svelte", + "sourceType": "github", + "skillPath": "skills/shadcn-svelte/SKILL.md", + "computedHash": "d4f3f983a71466a86649985a8a7fbce47da29f44de3a9d02540bbcf3167134e0" + } + } +} diff --git a/src/__tests__/grid-layout.test.ts b/src/__tests__/grid-layout.test.ts new file mode 100644 index 00000000..32dcf22e --- /dev/null +++ b/src/__tests__/grid-layout.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + compactLayout, + moveElement, + resizeElement, + type GridItem +} from '$lib/components/dashboard/grid-layout'; +import { WIDGET_REGISTRY } from '$lib/components/dashboard/widget-registry'; +import { DEFAULT_WIDGET_LAYOUT, widgetMinSize } from '$lib/domain/dashboard'; + +function item(id: string, colStart: number, rowStart: number, colSpan = 6, rowSpan = 4): GridItem { + return { id, colStart, rowStart, colSpan, rowSpan }; +} + +function positions(items: GridItem[]): Record { + return Object.fromEntries(items.map((i) => [i.id, [i.colStart, i.rowStart]])); +} + +describe('compactLayout', () => { + it('pulls items up to close vertical gaps', () => { + const items = [item('a', 1, 1), item('b', 1, 20)]; + expect(positions(compactLayout(items))).toEqual({ a: [1, 1], b: [1, 5] }); + }); + + it('leaves items in separate columns at the top', () => { + const items = [item('a', 1, 1), item('b', 7, 9)]; + expect(positions(compactLayout(items))).toEqual({ a: [1, 1], b: [7, 1] }); + }); + + it('separates items that arrive overlapping', () => { + const items = [item('a', 1, 1), item('b', 1, 1)]; + const packed = compactLayout(items); + expect(packed.find((i) => i.id === 'b')!.rowStart).toBe(5); + }); +}); + +describe('moveElement', () => { + it('swaps with the widget below instead of pushing it further down', () => { + // The old compactor pinned the dragged widget then repacked everything else beneath it, so + // dragging 'a' down shoved 'b' — which was already below — down again instead of swapping. + const items = [item('a', 1, 1), item('b', 1, 5)]; + expect(positions(moveElement(items, 'a', 1, 5))).toEqual({ a: [1, 5], b: [1, 1] }); + }); + + it('swaps with the widget above when dragging up', () => { + const items = [item('a', 1, 1), item('b', 1, 5)]; + expect(positions(moveElement(items, 'b', 1, 1))).toEqual({ a: [1, 5], b: [1, 1] }); + }); + + it('ignores a nudge too small to clear the neighbour', () => { + const items = [item('a', 1, 1), item('b', 1, 5)]; + expect(positions(moveElement(items, 'a', 1, 3))).toEqual({ a: [1, 1], b: [1, 5] }); + }); + + it('leaves untouched columns alone', () => { + const items = [item('a', 1, 1), item('b', 7, 1), item('c', 1, 5)]; + expect(positions(moveElement(items, 'a', 1, 5))).toEqual({ + a: [1, 5], + b: [7, 1], + c: [1, 1] + }); + }); + + it('cascades displaced widgets down without dropping any', () => { + const items = [item('a', 1, 1), item('b', 1, 5), item('c', 1, 9), item('d', 1, 13)]; + const moved = moveElement(items, 'd', 1, 1); + const rows = moved.map((i) => i.rowStart).sort((x, y) => x - y); + expect(rows).toEqual([1, 5, 9, 13]); + expect(moved.find((i) => i.id === 'd')!.rowStart).toBe(1); + }); + + it('clamps a widget dragged past the right edge', () => { + const items = [item('a', 1, 1)]; + expect(moveElement(items, 'a', 99, 1)[0].colStart).toBe(7); + }); +}); + +describe('resizeElement', () => { + it('pushes the widget below down rather than lifting it above', () => { + const items = [item('a', 1, 1), item('b', 1, 5)]; + expect(positions(resizeElement(items, 'a', 6, 8))).toEqual({ a: [1, 1], b: [1, 9] }); + }); + + it('pulls neighbours back up when a widget shrinks', () => { + const items = [item('a', 1, 1, 6, 8), item('b', 1, 9)]; + expect(positions(resizeElement(items, 'a', 6, 4))).toEqual({ a: [1, 1], b: [1, 5] }); + }); + + it('clamps growth to the columns remaining to the right', () => { + const items = [item('a', 7, 1)]; + expect(resizeElement(items, 'a', 12, 4)[0].colSpan).toBe(6); + }); + + it('refuses to shrink a widget below its own minimum', () => { + const items = [item('a', 1, 1)]; + const resized = resizeElement(items, 'a', 1, 1, { minColSpan: 3, minRowSpan: 2 })[0]; + expect([resized.colSpan, resized.rowSpan]).toEqual([3, 2]); + }); + + it('lets the grid edge win over a minimum that cannot fit', () => { + const items = [item('a', 11, 1, 2, 4)]; + expect(resizeElement(items, 'a', 1, 4, { minColSpan: 6, minRowSpan: 1 })[0].colSpan).toBe(2); + }); +}); + +describe('widgetMinSize', () => { + it('keeps every registry default at or above its widget minimum', () => { + for (const def of Object.values(WIDGET_REGISTRY)) { + const { minColSpan, minRowSpan } = widgetMinSize(def.type); + expect(def.defaultColSpan).toBeGreaterThanOrEqual(minColSpan); + expect(def.defaultRowSpan).toBeGreaterThanOrEqual(minRowSpan); + } + }); + + it('ships a default layout that already satisfies the minimums', () => { + for (const layoutItem of DEFAULT_WIDGET_LAYOUT) { + const { minColSpan, minRowSpan } = widgetMinSize(layoutItem.type); + expect(layoutItem.colSpan).toBeGreaterThanOrEqual(minColSpan); + expect(layoutItem.rowSpan).toBeGreaterThanOrEqual(minRowSpan); + } + }); +}); diff --git a/src/lib/components/dashboard/ActivityFeed.svelte b/src/lib/components/dashboard/ActivityFeed.svelte new file mode 100644 index 00000000..4327f354 --- /dev/null +++ b/src/lib/components/dashboard/ActivityFeed.svelte @@ -0,0 +1,60 @@ + + +{#if loading} +
    + {#each [0, 1, 2, 3] as i (i)} +
    + + + +
    + {/each} +
    +{:else if entries.length === 0} +
    + No recent activity +
    +{:else} +
    + {#each entries as entry (entry.id)} +
    + {#if entry.type === 'fuel'} + + + + {:else} + + + + {/if} +
    +

    {entry.vehicleName}

    +

    + {entry.description} · {formatDate(entry.date)} +

    +
    + {formatCurrency(entry.cost)} +
    + {/each} +
    +{/if} diff --git a/src/lib/components/dashboard/CtaBanner.svelte b/src/lib/components/dashboard/CtaBanner.svelte new file mode 100644 index 00000000..dadf02c1 --- /dev/null +++ b/src/lib/components/dashboard/CtaBanner.svelte @@ -0,0 +1,30 @@ + + +
    +
    +
    +

    {heading}

    +

    {description}

    +
    + +
    diff --git a/src/lib/components/dashboard/DashboardGrid.svelte b/src/lib/components/dashboard/DashboardGrid.svelte new file mode 100644 index 00000000..9f69000f --- /dev/null +++ b/src/lib/components/dashboard/DashboardGrid.svelte @@ -0,0 +1,104 @@ + + +
    + + {#if placeholder && placeholderRect && interaction.enabled} +
    +
    + {/if} + + {#each interaction.items as item (item.id)} + {@render children(item)} + {/each} +
    + + diff --git a/src/lib/components/dashboard/DonutChart.svelte b/src/lib/components/dashboard/DonutChart.svelte index a513c854..8d4a8c43 100644 --- a/src/lib/components/dashboard/DonutChart.svelte +++ b/src/lib/components/dashboard/DonutChart.svelte @@ -15,7 +15,10 @@ loading = false, showLegend = true, height = 300, - innerRadius = 60 + innerRadius = 60, + bare = false, + centerLabel = undefined, + centerValueFormatter = (value: number) => value.toLocaleString() }: { data: DonutDataPoint[]; title?: string; @@ -23,6 +26,11 @@ showLegend?: boolean; height?: number; innerRadius?: number; + /** Render content only — surrounding card chrome is provided by the parent. */ + bare?: boolean; + /** Small caption shown above the centered total, e.g. "Total Expenses". */ + centerLabel?: string | undefined; + centerValueFormatter?: (value: number) => string; } = $props(); const total = $derived(data.reduce((sum, d) => sum + d.value, 0)); @@ -45,23 +53,59 @@ ) satisfies Chart.ChartConfig ); - const chartStyle = $derived(`height: ${height}px`); + // In bare mode the parent card owns the box, so fill it instead of forcing a pixel height. + const chartStyle = $derived(bare ? '' : `height: ${height}px`); + const chartAreaClass = $derived(bare ? 'min-h-0 w-full flex-1' : ''); + + // Rings drawn by hand (single slice, loading) need a pixel size. Non-bare takes it from the + // `height` prop; bare measures the box the card actually gave us so it tracks the widget's rowSpan. + let areaWidth = $state(0); + let areaHeight = $state(0); + const ringSize = $derived( + bare ? Math.max(40, Math.min(areaWidth, areaHeight)) : Math.min(height * 0.6, 220) + ); + + // Bare legends may need to give ground to the chart in a short card, and scroll rather than clip. + const legendClass = $derived( + bare + ? 'mt-3 flex min-h-0 shrink flex-col gap-1 overflow-y-auto' + : 'mt-4 flex shrink-0 flex-col gap-1' + ); -
    +{#snippet centerContent()} + {#if centerLabel} +
    + {centerLabel} + {centerValueFormatter(total)} +
    + {/if} +{/snippet} + +
    {#if title} -
    +
    {title}
    {/if} {#if loading} -
    -
    - +
    +
    +
    {#each [0, 1, 2] as i (i)} @@ -73,50 +117,105 @@
    {:else if data.length === 0} -
    +
    No data available
    + {:else if data.length === 1} + {@const size = ringSize} + {@const strokeWidth = size * 0.2} + {@const radius = (size - strokeWidth) / 2} + +
    + + + + {@render centerContent()} +
    + + {#if showLegend} +
    + {#each itemsWithPercent as item (item.name)} +
    + + {item.name} + {item.percentage}% + + {item.value.toLocaleString()} + +
    + {/each} +
    + {/if} {:else} - - d.color} - {innerRadius} - padAngle={0.02} - cornerRadius={4} - > - {#snippet tooltip()} - - {#snippet formatter({ value, name })} - {@const pct = - total > 0 && typeof value === 'number' ? roundToDec((value / total) * 100, 1) : 0} - {name} - - {typeof value === 'number' ? value.toLocaleString() : value} ({pct}%) - - {/snippet} - - {/snippet} - - +
    + + d.color} + {innerRadius} + padAngle={0.02} + cornerRadius={4} + > + {#snippet tooltip()} + + {#snippet formatter({ value, name })} + {@const pct = + total > 0 && typeof value === 'number' ? roundToDec((value / total) * 100, 1) : 0} + {name} + + {typeof value === 'number' ? value.toLocaleString() : value} ({pct}%) + + {/snippet} + + {/snippet} + + + {@render centerContent()} +
    {#if showLegend && itemsWithPercent.length > 0} -
    +
    {#each itemsWithPercent as item (item.name)} -
    +
    - {item.name} - {item.percentage}% - - ({item.value.toLocaleString()}) + {item.name} + {item.percentage}% + + {item.value.toLocaleString()}
    {/each} diff --git a/src/lib/components/dashboard/FilterTabs.svelte b/src/lib/components/dashboard/FilterTabs.svelte new file mode 100644 index 00000000..1b2551aa --- /dev/null +++ b/src/lib/components/dashboard/FilterTabs.svelte @@ -0,0 +1,18 @@ + + + + + {#each tabs as tab (tab.id)} + {tab.label} + {/each} + + diff --git a/src/lib/components/dashboard/StackedAreaChart.svelte b/src/lib/components/dashboard/StackedAreaChart.svelte new file mode 100644 index 00000000..0d77dd37 --- /dev/null +++ b/src/lib/components/dashboard/StackedAreaChart.svelte @@ -0,0 +1,118 @@ + + +
    + {#if !bare} +
    + {title} +
    + {/if} + {#if loading} +
    +
    + {#each [40, 65, 45, 80, 55, 70, 50, 85, 60, 75] as height, i (i)} + + {/each} +
    + +
    + {:else if hasData} + + ({ key: s.key, label: s.label, color: s.color }))} + seriesLayout="stack" + axis={'x'} + props={chartProps} + > + {#snippet tooltip()} + + v.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' })} + indicator="dot" + > + {#snippet formatter({ value, name })} + {name} + + {typeof value === 'number' ? value.toFixed(2) : value} + + {/snippet} + + {/snippet} + + + {:else} +
    + +
    + {/if} +
    diff --git a/src/lib/components/dashboard/StatCard.svelte b/src/lib/components/dashboard/StatCard.svelte index 8d8fe3ec..1661c3a4 100644 --- a/src/lib/components/dashboard/StatCard.svelte +++ b/src/lib/components/dashboard/StatCard.svelte @@ -8,14 +8,16 @@ } interface Props { - icon: Component<{ class?: string }>; - label: string; + icon?: Component<{ class?: string }>; + label?: string; value: string | number; trend?: Trend | undefined; - color: string; + color?: string; + /** Render content only — the surrounding card chrome, icon and label are provided by the parent (WidgetCard). */ + bare?: boolean; } - let { icon: Icon, label, value, trend = undefined, color }: Props = $props(); + let { icon: Icon, label, value, trend = undefined, color, bare = false }: Props = $props(); const trendIcon = $derived( trend?.direction === 'up' ? '▲' : trend?.direction === 'down' ? '▼' : '–' @@ -23,26 +25,56 @@ const trendClass = $derived( trend?.direction === 'up' - ? 'text-green-600' + ? 'bg-green-500/10 text-green-600 dark:text-green-400' : trend?.direction === 'down' - ? 'text-red-600' - : 'text-muted-foreground' + ? 'bg-red-500/10 text-red-600 dark:text-red-400' + : 'bg-muted text-muted-foreground' ); -
    -
    - -
    +{#snippet trendBadge()} + {#if trend} + + {trendIcon} + {trend.value} + + {/if} +{/snippet} -
    - {label} - {value} - {#if trend} - - {trendIcon} - {trend.value} - +{#if bare} + +
    + {value} + {@render trendBadge()} +
    +{:else} +
    + {#if Icon} +
    + +
    {/if} + +
    + {label} + {value} + {@render trendBadge()} +
    -
    +{/if} diff --git a/src/lib/components/dashboard/VehicleLeaderboard.svelte b/src/lib/components/dashboard/VehicleLeaderboard.svelte new file mode 100644 index 00000000..a1e7df1e --- /dev/null +++ b/src/lib/components/dashboard/VehicleLeaderboard.svelte @@ -0,0 +1,70 @@ + + +{#if loading} +
    + {#each [0, 1, 2, 3] as i (i)} +
    + + + +
    + {/each} +
    +{:else if entries.length === 0} +
    + {emptyLabel} +
    +{:else} +
    + {#each entries as entry, i (entry.id)} +
    + + {i + 1} + +
    +

    {entry.name}

    + {#if entry.plate} +

    {entry.plate}

    + {/if} +
    + {entry.formattedValue} +
    + {/each} +
    +{/if} diff --git a/src/lib/components/dashboard/WidgetCard.svelte b/src/lib/components/dashboard/WidgetCard.svelte new file mode 100644 index 00000000..9645e23c --- /dev/null +++ b/src/lib/components/dashboard/WidgetCard.svelte @@ -0,0 +1,229 @@ + + +
    + {#if Icon} + +
    +
    + +
    + +
    +

    + {title} +

    + {@render children()} +
    +
    + {:else} + +
    +

    + {title} +

    +
    + +
    + {@render children()} +
    + {/if} + + + + + +
    + + diff --git a/src/lib/components/dashboard/grid-interaction.svelte.ts b/src/lib/components/dashboard/grid-interaction.svelte.ts new file mode 100644 index 00000000..edb19a31 --- /dev/null +++ b/src/lib/components/dashboard/grid-interaction.svelte.ts @@ -0,0 +1,319 @@ +import { getContext, setContext } from 'svelte'; +import { + GRID_COLUMNS, + GRID_MAX_ROW_SPAN, + widgetMinSize, + type WidgetLayoutItem, + type WidgetMinSize +} from '$lib/domain/dashboard'; +import { clamp, ROW_UNIT_PX } from './widget-size'; +import { moveElement, resizeElement, type GridRect } from './grid-layout'; + +export type GridMode = 'move' | 'resize'; + +/** Pixel geometry of the widget being dragged, relative to the grid's padding box. */ +export interface FloatRect { + left: number; + top: number; + width: number; + height: number; +} + +/** Rendered track geometry: `pitch` is one track plus one gap, i.e. the distance between track starts. */ +export interface GridMetrics { + colPitch: number; + colGap: number; + rowPitch: number; + rowGap: number; +} + +const EDGE_SCROLL_ZONE_PX = 80; +const EDGE_SCROLL_MAX_PX = 22; + +function readMetrics(grid: HTMLElement): GridMetrics { + const style = getComputedStyle(grid); + const columns = style.gridTemplateColumns + .split(' ') + .map(parseFloat) + .filter((width) => !Number.isNaN(width)); + const colGap = parseFloat(style.columnGap) || 0; + const rowGap = parseFloat(style.rowGap) || 0; + + return { + colGap, + rowGap, + colPitch: (columns[0] ?? grid.clientWidth / GRID_COLUMNS) + colGap, + rowPitch: ROW_UNIT_PX + rowGap + }; +} + +function findScroller(el: HTMLElement | undefined): HTMLElement { + const fallback = (document.scrollingElement as HTMLElement | null) ?? document.documentElement; + for (let node = el?.parentElement; node; node = node.parentElement) { + const overflowY = getComputedStyle(node).overflowY; + if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) { + return node; + } + } + return fallback; +} + +/** + * Drives pointer-driven move/resize for a widget grid. The widget being manipulated follows the + * pointer in raw pixels while a snapped `draft` layout is recomputed from the committed layout on + * every frame — the dragged item's rect within that draft is the dotted placeholder. Nothing reaches + * the store until the pointer is released, so the live layout never churns mid-gesture. + */ +export class GridInteraction { + #getItems: () => WidgetLayoutItem[]; + #commit: (items: WidgetLayoutItem[]) => void; + + gridEl = $state(); + /** Pointer interaction only applies at the 12-column breakpoint; below it the grid stacks. */ + enabled = $state(false); + + draft = $state(null); + activeId = $state(null); + mode = $state(null); + float = $state(null); + /** Track sizes captured at gesture start; also lets the placeholder be drawn in pixels. */ + metrics = $state(null); + + constructor(getItems: () => WidgetLayoutItem[], commit: (items: WidgetLayoutItem[]) => void) { + this.#getItems = getItems; + this.#commit = commit; + } + + #minSize(item: WidgetLayoutItem): WidgetMinSize { + return widgetMinSize(item.type); + } + + /** Layout to render: the in-flight draft while dragging, otherwise the committed layout. */ + get items(): WidgetLayoutItem[] { + return this.draft ?? this.#getItems(); + } + + get placeholder(): GridRect | null { + if (!this.activeId || !this.draft) return null; + return this.draft.find((item) => item.id === this.activeId) ?? null; + } + + /** + * The placeholder's rect in pixels. Drawing the outline as an absolutely-positioned box instead of + * a grid-placed one keeps a *painted* element from re-flowing grid tracks on every snap step — + * Safari doesn't reliably invalidate the area such an element vacates, which left a dashed ghost + * at every width a shrinking widget passed through. + */ + get placeholderRect(): FloatRect | null { + const rect = this.placeholder; + const metrics = this.metrics; + if (!rect || !metrics) return null; + + return { + left: (rect.colStart - 1) * metrics.colPitch, + top: (rect.rowStart - 1) * metrics.rowPitch, + width: rect.colSpan * metrics.colPitch - metrics.colGap, + height: rect.rowSpan * metrics.rowPitch - metrics.rowGap + }; + } + + isActive(id: string): boolean { + return this.activeId === id; + } + + #snapshot(): WidgetLayoutItem[] { + return this.#getItems().map((item) => ({ ...item })); + } + + start(mode: GridMode, id: string, event: PointerEvent, cardEl: HTMLElement): void { + if (!this.enabled || this.activeId || event.button !== 0) return; + + const grid = this.gridEl; + const source = this.#getItems().find((item) => item.id === id); + if (!grid || !source) return; + + event.preventDefault(); + // The stat layout makes the whole card a drag handle, so the resize corner must not also + // reach it. (The `activeId` guard above already covers this; this keeps it explicit.) + event.stopPropagation(); + + const metrics = readMetrics(grid); + const gridBox = grid.getBoundingClientRect(); + const cardBox = cardEl.getBoundingClientRect(); + const origin: FloatRect = { + left: cardBox.left - gridBox.left, + top: cardBox.top - gridBox.top, + width: cardBox.width, + height: cardBox.height + }; + // Where inside the card the pointer grabbed, so the card doesn't jump to the cursor. + const grab = { x: event.clientX - cardBox.left, y: event.clientY - cardBox.top }; + const scroller = findScroller(grid); + + let pointerX = event.clientX; + let pointerY = event.clientY; + let frame = 0; + + this.activeId = id; + this.mode = mode; + this.float = { ...origin }; + this.metrics = metrics; + this.draft = this.#snapshot(); + const bodyClass = mode === 'move' ? 'grid-moving' : 'grid-resizing'; + document.body.classList.add(bodyClass); + + // Capture on the grid — never the card, which goes `pointer-events: none` while it floats. This + // guarantees the gesture terminates: without it, releasing outside the window drops `pointerup` + // and the draft (and its placeholder) is stranded on screen until the next interaction. + const pointerId = event.pointerId; + try { + grid.setPointerCapture(pointerId); + } catch { + // Capture is best-effort; the window listeners below still cover the common case. + } + + const update = () => { + // Re-read the box every frame so edge auto-scroll doesn't skew the mapping. + const box = grid.getBoundingClientRect(); + + if (mode === 'move') { + const left = pointerX - box.left - grab.x; + const top = pointerY - box.top - grab.y; + this.float = { left, top, width: origin.width, height: origin.height }; + + const colStart = clamp( + Math.round(left / metrics.colPitch) + 1, + 1, + GRID_COLUMNS - source.colSpan + 1 + ); + const rowStart = Math.max(1, Math.round(top / metrics.rowPitch) + 1); + this.draft = moveElement(this.#snapshot(), id, colStart, rowStart); + return; + } + + const min = this.#minSize(source); + const maxColSpan = GRID_COLUMNS - source.colStart + 1; + const minColSpan = Math.min(min.minColSpan, maxColSpan); + const minRowSpan = Math.min(min.minRowSpan, GRID_MAX_ROW_SPAN); + + // The pointer can't drag the ghost below the widget's floor either, so what you see while + // resizing is always a size the widget will actually accept. + const width = clamp( + pointerX - box.left - origin.left, + minColSpan * metrics.colPitch - metrics.colGap, + maxColSpan * metrics.colPitch - metrics.colGap + ); + const height = clamp( + pointerY - box.top - origin.top, + minRowSpan * metrics.rowPitch - metrics.rowGap, + GRID_MAX_ROW_SPAN * metrics.rowPitch - metrics.rowGap + ); + this.float = { left: origin.left, top: origin.top, width, height }; + + const colSpan = Math.round((width + metrics.colGap) / metrics.colPitch); + const rowSpan = Math.round((height + metrics.rowGap) / metrics.rowPitch); + this.draft = resizeElement(this.#snapshot(), id, colSpan, rowSpan, min); + }; + + const autoScroll = () => { + frame = requestAnimationFrame(autoScroll); + + const top = pointerY - EDGE_SCROLL_ZONE_PX; + const bottom = pointerY - (window.innerHeight - EDGE_SCROLL_ZONE_PX); + const delta = + top < 0 + ? (top / EDGE_SCROLL_ZONE_PX) * EDGE_SCROLL_MAX_PX + : bottom > 0 + ? (bottom / EDGE_SCROLL_ZONE_PX) * EDGE_SCROLL_MAX_PX + : 0; + if (delta === 0) return; + + const before = scroller.scrollTop; + scroller.scrollTop = before + delta; + if (scroller.scrollTop !== before) update(); + }; + + const handleMove = (moveEvent: PointerEvent) => { + pointerX = moveEvent.clientX; + pointerY = moveEvent.clientY; + update(); + }; + + const finish = (commit: boolean) => { + cancelAnimationFrame(frame); + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + window.removeEventListener('pointercancel', handleCancel); + window.removeEventListener('lostpointercapture', handleUp); + window.removeEventListener('blur', handleCancel); + window.removeEventListener('keydown', handleKey, true); + document.body.classList.remove(bodyClass); + if (grid.hasPointerCapture?.(pointerId)) grid.releasePointerCapture(pointerId); + + const next = this.draft; + this.draft = null; + this.activeId = null; + this.mode = null; + this.float = null; + this.metrics = null; + if (commit && next) this.#commit(next); + }; + + const handleUp = () => finish(true); + const handleCancel = () => finish(false); + const handleKey = (keyEvent: KeyboardEvent) => { + if (keyEvent.key !== 'Escape') return; + keyEvent.preventDefault(); + finish(false); + }; + + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', handleUp); + window.addEventListener('pointercancel', handleCancel); + window.addEventListener('lostpointercapture', handleUp); + window.addEventListener('blur', handleCancel); + window.addEventListener('keydown', handleKey, true); + frame = requestAnimationFrame(autoScroll); + } + + /** Keyboard equivalent of dragging the widget by its handle. */ + nudgeMove(id: string, colDelta: number, rowDelta: number): void { + const source = this.#getItems().find((item) => item.id === id); + if (!source) return; + this.#commit( + moveElement( + this.#snapshot(), + id, + source.colStart + colDelta, + Math.max(1, source.rowStart + rowDelta) + ) + ); + } + + /** Keyboard equivalent of dragging the resize handle. */ + nudgeResize(id: string, colDelta: number, rowDelta: number): void { + const source = this.#getItems().find((item) => item.id === id); + if (!source) return; + this.#commit( + resizeElement( + this.#snapshot(), + id, + source.colSpan + colDelta, + source.rowSpan + rowDelta, + this.#minSize(source) + ) + ); + } +} + +const GRID_INTERACTION_KEY = Symbol('grid-interaction'); + +export function setGridInteraction(interaction: GridInteraction): GridInteraction { + return setContext(GRID_INTERACTION_KEY, interaction); +} + +export function getGridInteraction(): GridInteraction { + const interaction = getContext(GRID_INTERACTION_KEY); + if (!interaction) throw new Error('WidgetCard must be rendered inside DashboardGrid'); + return interaction; +} diff --git a/src/lib/components/dashboard/grid-layout.ts b/src/lib/components/dashboard/grid-layout.ts new file mode 100644 index 00000000..b7b90298 --- /dev/null +++ b/src/lib/components/dashboard/grid-layout.ts @@ -0,0 +1,116 @@ +import { GRID_COLUMNS, GRID_MAX_ROW_SPAN } from '$lib/domain/dashboard'; +import { clamp } from './widget-size'; + +export interface GridRect { + colStart: number; + rowStart: number; + colSpan: number; + rowSpan: number; +} + +export type GridItem = GridRect & { id: string }; + +function overlaps(a: GridItem, b: GridItem): boolean { + return ( + a.id !== b.id && + a.colStart < b.colStart + b.colSpan && + a.colStart + a.colSpan > b.colStart && + a.rowStart < b.rowStart + b.rowSpan && + a.rowStart + a.rowSpan > b.rowStart + ); +} + +function byPosition(a: GridItem, b: GridItem): number { + return a.rowStart - b.rowStart || a.colStart - b.colStart; +} + +function collidesAt(items: GridItem[], item: GridItem, rowStart: number): boolean { + const probe = { ...item, rowStart }; + return items.some((other) => overlaps(other, probe)); +} + +// Vertical gravity: walk items in reading order and settle each one at the highest row it can reach +// without touching anything already settled. Items are only ever compared against items placed +// before them, so a single pass both closes gaps and resolves leftover overlap. +export function compactLayout(items: T[]): T[] { + const settled: T[] = []; + + for (const item of [...items].sort(byPosition)) { + let rowStart = item.rowStart; + while (collidesAt(settled, item, rowStart)) rowStart += 1; + while (rowStart > 1 && !collidesAt(settled, item, rowStart - 1)) rowStart -= 1; + item.rowStart = rowStart; + settled.push(item); + } + + return items; +} + +// Clears space for `target` by displacing whatever it now overlaps. A collider first tries the gap +// the target just vacated (directly above it), which is what makes dragging one widget onto another +// read as a swap; otherwise it drops below the target and cascades into whatever it hits in turn. +function displaceColliders( + items: T[], + target: T, + handled: Set, + options: { movingUp: boolean; allowTuck: boolean } +): void { + const colliders = items.filter((item) => overlaps(item, target)).sort(byPosition); + // Resolve the nearest collider in the direction of travel first so cascades run away from the target. + if (options.movingUp) colliders.reverse(); + + for (const collider of colliders) { + if (handled.has(collider.id)) continue; + handled.add(collider.id); + + const tuckedRow = target.rowStart - collider.rowSpan; + if (options.allowTuck && tuckedRow >= 1 && !collidesAt(items, collider, tuckedRow)) { + collider.rowStart = tuckedRow; + continue; + } + + collider.rowStart = target.rowStart + target.rowSpan; + displaceColliders(items, collider, handled, options); + } +} + +/** Drops the widget at (colStart, rowStart), pushing others out of the way, then applies gravity. */ +export function moveElement( + items: T[], + id: string, + colStart: number, + rowStart: number +): T[] { + const target = items.find((item) => item.id === id); + if (!target) return items; + + const movingUp = rowStart < target.rowStart; + target.colStart = clamp(colStart, 1, GRID_COLUMNS - target.colSpan + 1); + target.rowStart = Math.max(1, rowStart); + + displaceColliders(items, target, new Set([id]), { movingUp, allowTuck: true }); + return compactLayout(items); +} + +/** + * Resizes the widget from its top-left anchor, pushing others down, then applies gravity. + * `min` is the widget's own floor; it wins over the pointer but not over the grid's right edge. + */ +export function resizeElement( + items: T[], + id: string, + colSpan: number, + rowSpan: number, + min: { minColSpan: number; minRowSpan: number } = { minColSpan: 1, minRowSpan: 1 } +): T[] { + const target = items.find((item) => item.id === id); + if (!target) return items; + + const maxColSpan = GRID_COLUMNS - target.colStart + 1; + target.colSpan = clamp(colSpan, Math.min(min.minColSpan, maxColSpan), maxColSpan); + target.rowSpan = clamp(rowSpan, Math.min(min.minRowSpan, GRID_MAX_ROW_SPAN), GRID_MAX_ROW_SPAN); + + // Growing a widget must never lift its neighbours above it, so no tucking here. + displaceColliders(items, target, new Set([id]), { movingUp: false, allowTuck: false }); + return compactLayout(items); +} diff --git a/src/lib/components/dashboard/widget-registry.ts b/src/lib/components/dashboard/widget-registry.ts new file mode 100644 index 00000000..0c8832b7 --- /dev/null +++ b/src/lib/components/dashboard/widget-registry.ts @@ -0,0 +1,180 @@ +import type { Component } from 'svelte'; +import type { WidgetColSpan, WidgetRowSpan, WidgetType } from '$lib/domain/dashboard'; +import FleetStatWidget from './widgets/FleetStatWidget.svelte'; +import ExpenseBreakdownWidget from './widgets/ExpenseBreakdownWidget.svelte'; +import MonthlyExpenseTrendWidget from './widgets/MonthlyExpenseTrendWidget.svelte'; +import VehicleLeaderboardWidget from './widgets/VehicleLeaderboardWidget.svelte'; +import FleetFuelTrendWidget from './widgets/FleetFuelTrendWidget.svelte'; +import StatusDonutWidget from './widgets/StatusDonutWidget.svelte'; +import VehicleHealthWidget from './widgets/VehicleHealthWidget.svelte'; +import UpcomingRemindersWidget from './widgets/UpcomingRemindersWidget.svelte'; +import VehicleQuickListWidget from './widgets/VehicleQuickListWidget.svelte'; +import RecentActivityWidget from './widgets/RecentActivityWidget.svelte'; +import Car from '@lucide/svelte/icons/car'; +import Route from '@lucide/svelte/icons/route'; +import Fuel from '@lucide/svelte/icons/fuel'; +import DollarSign from '@lucide/svelte/icons/dollar-sign'; +import CircleGauge from '@lucide/svelte/icons/circle-gauge'; + +export interface WidgetDefinition { + type: WidgetType; + title: string; + description: string; + component: Component; + extraProps?: Record; + defaultColSpan: WidgetColSpan; + defaultRowSpan: WidgetRowSpan; + /** Only stat-style widgets set these — WidgetCard renders the icon in its header when present. */ + icon?: Component<{ class?: string }>; + iconColor?: string; +} + +export const WIDGET_REGISTRY: Record = { + 'stat-vehicle-count': { + type: 'stat-vehicle-count', + title: 'Total Vehicles', + description: 'Number of vehicles in your garage', + component: FleetStatWidget, + extraProps: { metric: 'vehicle-count' }, + defaultColSpan: 3, + defaultRowSpan: 4, + icon: Car, + iconColor: 'bg-gradient-to-br from-blue-400 to-blue-600 shadow-blue-500/30' + }, + 'stat-total-distance': { + type: 'stat-total-distance', + title: 'Total Distance', + description: 'Distance driven across the whole fleet', + component: FleetStatWidget, + extraProps: { metric: 'total-distance' }, + defaultColSpan: 3, + defaultRowSpan: 4, + icon: Route, + iconColor: 'bg-gradient-to-br from-violet-400 to-violet-600 shadow-violet-500/30' + }, + 'stat-fuel-used': { + type: 'stat-fuel-used', + title: 'Total Fuel Used', + description: 'Fuel consumed across the whole fleet', + component: FleetStatWidget, + extraProps: { metric: 'fuel-used' }, + defaultColSpan: 3, + defaultRowSpan: 4, + icon: Fuel, + iconColor: 'bg-gradient-to-br from-emerald-400 to-emerald-600 shadow-emerald-500/30' + }, + 'stat-total-expenses': { + type: 'stat-total-expenses', + title: 'Total Expenses', + description: 'Fuel, maintenance and insurance spend combined', + component: FleetStatWidget, + extraProps: { metric: 'total-expenses' }, + defaultColSpan: 3, + defaultRowSpan: 4, + icon: DollarSign, + iconColor: 'bg-gradient-to-br from-amber-400 to-amber-600 shadow-amber-500/30' + }, + 'stat-cost-per-distance': { + type: 'stat-cost-per-distance', + title: 'Cost / Distance', + description: 'Overall running cost per unit distance', + component: FleetStatWidget, + extraProps: { metric: 'cost-per-distance' }, + defaultColSpan: 3, + defaultRowSpan: 4, + icon: CircleGauge, + iconColor: 'bg-gradient-to-br from-rose-400 to-rose-600 shadow-rose-500/30' + }, + 'expense-breakdown-donut': { + type: 'expense-breakdown-donut', + title: 'Expenses by Category', + description: 'Fuel vs. maintenance vs. insurance spend', + component: ExpenseBreakdownWidget, + defaultColSpan: 6, + defaultRowSpan: 8 + }, + 'monthly-expense-trend': { + type: 'monthly-expense-trend', + title: 'Monthly Expense Trend', + description: 'Last 12 months of spend by category', + component: MonthlyExpenseTrendWidget, + defaultColSpan: 9, + defaultRowSpan: 8 + }, + 'cost-by-vehicle-leaderboard': { + type: 'cost-by-vehicle-leaderboard', + title: 'Cost by Vehicle', + description: 'Which vehicles cost the most to run', + component: VehicleLeaderboardWidget, + extraProps: { metric: 'cost' }, + defaultColSpan: 6, + defaultRowSpan: 8 + }, + 'fleet-fuel-trend': { + type: 'fleet-fuel-trend', + title: 'Fleet Fuel Trend', + description: 'Daily fuel usage across the fleet', + component: FleetFuelTrendWidget, + defaultColSpan: 9, + defaultRowSpan: 8 + }, + 'efficiency-leaderboard': { + type: 'efficiency-leaderboard', + title: 'Efficiency Leaderboard', + description: 'Vehicles ranked by fuel efficiency', + component: VehicleLeaderboardWidget, + extraProps: { metric: 'efficiency' }, + defaultColSpan: 6, + defaultRowSpan: 8 + }, + 'pucc-status-donut': { + type: 'pucc-status-donut', + title: 'PUC Status', + description: 'Pollution certificate status across the fleet', + component: StatusDonutWidget, + extraProps: { metric: 'pucc' }, + defaultColSpan: 6, + defaultRowSpan: 6 + }, + 'insurance-status-donut': { + type: 'insurance-status-donut', + title: 'Insurance Status', + description: 'Insurance policy status across the fleet', + component: StatusDonutWidget, + extraProps: { metric: 'insurance' }, + defaultColSpan: 6, + defaultRowSpan: 6 + }, + 'vehicle-health-distribution': { + type: 'vehicle-health-distribution', + title: 'Vehicle Health', + description: 'Overall good/attention/needs-action breakdown', + component: VehicleHealthWidget, + defaultColSpan: 6, + defaultRowSpan: 8 + }, + 'upcoming-reminders-list': { + type: 'upcoming-reminders-list', + title: 'Upcoming Reminders', + description: 'Reminders coming due soon', + component: UpcomingRemindersWidget, + defaultColSpan: 6, + defaultRowSpan: 8 + }, + 'vehicle-quick-list': { + type: 'vehicle-quick-list', + title: 'My Vehicles', + description: 'Quick access to your vehicles', + component: VehicleQuickListWidget, + defaultColSpan: 9, + defaultRowSpan: 8 + }, + 'recent-activity-feed': { + type: 'recent-activity-feed', + title: 'Recent Activity', + description: 'Latest fuel and maintenance logs across the fleet', + component: RecentActivityWidget, + defaultColSpan: 6, + defaultRowSpan: 8 + } +}; diff --git a/src/lib/components/dashboard/widget-size.ts b/src/lib/components/dashboard/widget-size.ts new file mode 100644 index 00000000..9ebf48f1 --- /dev/null +++ b/src/lib/components/dashboard/widget-size.ts @@ -0,0 +1,39 @@ +import { GRID_COLUMNS, GRID_MAX_ROW_SPAN } from '$lib/domain/dashboard'; + +/** Base pixel height of one row unit; a widget's rowSpan is this many units tall (before gaps). */ +export const ROW_UNIT_PX = 28; + +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function clampColSpan(span: number): number { + return clamp(Math.round(span), 1, GRID_COLUMNS); +} + +export function clampRowSpan(span: number): number { + return clamp(Math.round(span), 1, GRID_MAX_ROW_SPAN); +} + +export function clampColStart(colStart: number, colSpan: number): number { + return clamp(Math.round(colStart), 1, GRID_COLUMNS - clampColSpan(colSpan) + 1); +} + +export function clampRowStart(rowStart: number): number { + return Math.max(1, Math.round(rowStart)); +} + +// CSS custom properties for a widget's grid rect; WidgetCard only wires these to grid-column/grid-row at the 12-col breakpoint since colStart/rowStart don't translate to the stacked mobile layout. +export function widgetGridVars(item: { + colStart: number; + rowStart: number; + colSpan: number; + rowSpan: number; +}): string { + return ( + `--wc-col-start: ${clampColStart(item.colStart, item.colSpan)}; ` + + `--wc-col-span: ${clampColSpan(item.colSpan)}; ` + + `--wc-row-start: ${clampRowStart(item.rowStart)}; ` + + `--wc-row-span: ${clampRowSpan(item.rowSpan)};` + ); +} diff --git a/src/lib/components/dashboard/widgets/ExpenseBreakdownWidget.svelte b/src/lib/components/dashboard/widgets/ExpenseBreakdownWidget.svelte new file mode 100644 index 00000000..04bcdf58 --- /dev/null +++ b/src/lib/components/dashboard/widgets/ExpenseBreakdownWidget.svelte @@ -0,0 +1,33 @@ + + + formatCurrency(value)} +/> diff --git a/src/lib/components/dashboard/widgets/FleetFuelTrendWidget.svelte b/src/lib/components/dashboard/widgets/FleetFuelTrendWidget.svelte new file mode 100644 index 00000000..4a6b66ad --- /dev/null +++ b/src/lib/components/dashboard/widgets/FleetFuelTrendWidget.svelte @@ -0,0 +1,24 @@ + + + `${value.toFixed(1)} L`} + xFormatter={(v: Date) => + v.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })} +/> diff --git a/src/lib/components/dashboard/widgets/FleetStatWidget.svelte b/src/lib/components/dashboard/widgets/FleetStatWidget.svelte new file mode 100644 index 00000000..3d0b149a --- /dev/null +++ b/src/lib/components/dashboard/widgets/FleetStatWidget.svelte @@ -0,0 +1,45 @@ + + + + + diff --git a/src/lib/components/dashboard/widgets/MonthlyExpenseTrendWidget.svelte b/src/lib/components/dashboard/widgets/MonthlyExpenseTrendWidget.svelte new file mode 100644 index 00000000..8bd49ba1 --- /dev/null +++ b/src/lib/components/dashboard/widgets/MonthlyExpenseTrendWidget.svelte @@ -0,0 +1,13 @@ + + + diff --git a/src/lib/components/dashboard/widgets/RecentActivityWidget.svelte b/src/lib/components/dashboard/widgets/RecentActivityWidget.svelte new file mode 100644 index 00000000..e1414348 --- /dev/null +++ b/src/lib/components/dashboard/widgets/RecentActivityWidget.svelte @@ -0,0 +1,10 @@ + + +
    + +
    diff --git a/src/lib/components/dashboard/widgets/StatusDonutWidget.svelte b/src/lib/components/dashboard/widgets/StatusDonutWidget.svelte new file mode 100644 index 00000000..501c197b --- /dev/null +++ b/src/lib/components/dashboard/widgets/StatusDonutWidget.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/src/lib/components/dashboard/widgets/UpcomingRemindersWidget.svelte b/src/lib/components/dashboard/widgets/UpcomingRemindersWidget.svelte new file mode 100644 index 00000000..e268c346 --- /dev/null +++ b/src/lib/components/dashboard/widgets/UpcomingRemindersWidget.svelte @@ -0,0 +1,48 @@ + + +{#if summary && summary.compliance.upcomingReminders.length > 0} +
    + {#each summary.compliance.upcomingReminders.slice(0, 6) as reminder (reminder.id)} +
    + + + +
    +

    + {reminder.vehicleName} + {#if reminder.vehiclePlate} + ({reminder.vehiclePlate}) + {/if} +

    +

    + {reminder.note || reminder.type} +

    +
    + +
    + {/each} +
    +{:else if loading} +
    Loading reminders...
    +{:else} +
    + No upcoming reminders +
    +{/if} diff --git a/src/lib/components/dashboard/widgets/VehicleHealthWidget.svelte b/src/lib/components/dashboard/widgets/VehicleHealthWidget.svelte new file mode 100644 index 00000000..e6ddac1b --- /dev/null +++ b/src/lib/components/dashboard/widgets/VehicleHealthWidget.svelte @@ -0,0 +1,62 @@ + + +
    +
    + +
    + {#if summary && !loading} +
    +
    + + + Good + + {summary.compliance.vehicleHealth.good} +
    +
    + + + Attention + + {summary.compliance.vehicleHealth.attention} +
    +
    + + + Needs Action + + {summary.compliance.vehicleHealth.needsAction} +
    +
    + {:else} +
    Loading...
    + {/if} +
    diff --git a/src/lib/components/dashboard/widgets/VehicleLeaderboardWidget.svelte b/src/lib/components/dashboard/widgets/VehicleLeaderboardWidget.svelte new file mode 100644 index 00000000..e661bd91 --- /dev/null +++ b/src/lib/components/dashboard/widgets/VehicleLeaderboardWidget.svelte @@ -0,0 +1,51 @@ + + + + +
    + +
    diff --git a/src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte b/src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte new file mode 100644 index 00000000..1f94669a --- /dev/null +++ b/src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte @@ -0,0 +1,21 @@ + + +{#if vehicleStore.vehicles && vehicleStore.vehicles.length > 0} +
    + {#each vehicleStore.vehicles.slice(0, 5) as vehicle (vehicle.id)} + (vehicleStore.selectedId = vehicle.id!)} + isSelected={vehicleStore.selectedId === vehicle.id} + actions={false} + /> + {/each} +
    +{:else} +
    + No vehicles yet +
    +{/if} diff --git a/src/lib/components/feature/dashboard/AddWidgetPanel.svelte b/src/lib/components/feature/dashboard/AddWidgetPanel.svelte new file mode 100644 index 00000000..56a38773 --- /dev/null +++ b/src/lib/components/feature/dashboard/AddWidgetPanel.svelte @@ -0,0 +1,39 @@ + + +
    + {#if availableWidgets.length === 0} +

    + All available widgets are already on your dashboard. +

    + {:else} + {#each availableWidgets as widget (widget.type)} + + {/each} + {/if} +
    diff --git a/src/lib/components/feature/maintenance/MaintenanceTimeline.svelte b/src/lib/components/feature/maintenance/MaintenanceTimeline.svelte new file mode 100644 index 00000000..7866dd59 --- /dev/null +++ b/src/lib/components/feature/maintenance/MaintenanceTimeline.svelte @@ -0,0 +1,39 @@ + + +{#if logs.length === 0} +
    + No service history yet +
    +{:else} +
      + {#each logs as log (log.id)} +
    1. +
      + + +
      +
      +
      +

      {log.serviceCenter}

      + + Completed + +
      +

      + {formatDate(log.date)} · at {formatDistance(log.odometer)} +

      + {#if log.cost} +

      {formatCurrency(log.cost)}

      + {/if} +
      +
    2. + {/each} +
    +{/if} diff --git a/src/lib/components/feature/overview/AreaChart.svelte b/src/lib/components/feature/overview/AreaChart.svelte index 6b9753fd..f364f2fa 100644 --- a/src/lib/components/feature/overview/AreaChart.svelte +++ b/src/lib/components/feature/overview/AreaChart.svelte @@ -18,7 +18,8 @@ title, xFormatter, valueFormatter, - loading = false + loading = false, + bare = false }: { chartData: DataPoint[]; color?: string; @@ -27,6 +28,8 @@ xFormatter: (_: Date) => string; valueFormatter?: (_: number) => string; loading?: boolean; + /** Render content only — surrounding card chrome is provided by the parent. */ + bare?: boolean; } = $props(); const chartProps = { @@ -79,18 +82,27 @@
    -
    - {title} +
    + {#if !bare} + {title} + {/if} + Avg: {formattedAverage}
    {#if loading} -
    +
    {#each [40, 65, 45, 80, 55, 70, 50, 85, 60, 75] as height, i (i)} @@ -99,7 +111,7 @@
    {:else if chartData.length != 0} - + {#snippet children({ gradient })} diff --git a/src/lib/components/feature/overview/FuelAmountChart.svelte b/src/lib/components/feature/overview/FuelAmountChart.svelte new file mode 100644 index 00000000..3c54bb57 --- /dev/null +++ b/src/lib/components/feature/overview/FuelAmountChart.svelte @@ -0,0 +1,19 @@ + + + `${value.toFixed(1)} L`} + xFormatter={(v: Date) => + v.toLocaleDateString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric' + })} +/> diff --git a/src/lib/components/feature/overview/MileageChart.svelte b/src/lib/components/feature/overview/MileageChart.svelte index 53964b05..8914fc55 100644 --- a/src/lib/components/feature/overview/MileageChart.svelte +++ b/src/lib/components/feature/overview/MileageChart.svelte @@ -1,13 +1,10 @@ + +
    + + + {#if vehicle.odometer} + + {/if} + + {#if actions} + + + + + + + sheetStore.openSheet(VehicleForm, m.vehicle_action_update_vehicle(), '', vehicle)} + > + + {m.vehicle_action_edit()} + + (deleteDialog = true)}> + + {m.vehicle_action_delete()} + + + + {/if} +
    + +{#if actions} + performDelete(vehicle.id!)} bind:open={deleteDialog} /> +{/if} diff --git a/src/lib/components/layout/AppSidebar.svelte b/src/lib/components/layout/AppSidebar.svelte index 38bd226e..ce423d87 100644 --- a/src/lib/components/layout/AppSidebar.svelte +++ b/src/lib/components/layout/AppSidebar.svelte @@ -2,8 +2,8 @@ import * as Sidebar from '$ui/sidebar/index.js'; import * as Avatar from '$ui/avatar/index.js'; import * as DropdownMenu from '$ui/dropdown-menu/index.js'; + import VehicleSwitcher from '$layout/VehicleSwitcher.svelte'; - import Tractor from '@lucide/svelte/icons/tractor'; import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard'; import Car from '@lucide/svelte/icons/car'; import Fuel from '@lucide/svelte/icons/fuel'; @@ -18,6 +18,7 @@ import UserCog from '@lucide/svelte/icons/user-cog'; import ToolCase from '@lucide/svelte/icons/tool-case'; import Database from '@lucide/svelte/icons/database'; + import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down'; import { configStore } from '$stores/config.svelte'; import { authStore } from '$stores/auth.svelte'; @@ -34,58 +35,68 @@ href: string; icon: typeof LayoutDashboard; featureKey?: keyof typeof configStore.configs; + group: string; }; const NAV_ITEMS: NavItem[] = [ { label: 'Dashboard', href: '/dashboard', - icon: LayoutDashboard + icon: LayoutDashboard, + group: 'Overview' }, { label: 'Vehicles', href: '/vehicles', - icon: Car + icon: Car, + group: 'Overview' }, { label: m.nav_fuel_logs(), href: '/fuel', icon: Fuel, - featureKey: 'featureFuelLog' + featureKey: 'featureFuelLog', + group: 'Logs' }, { label: m.nav_maintenance(), href: '/maintenance', icon: Wrench, - featureKey: 'featureMaintenance' + featureKey: 'featureMaintenance', + group: 'Logs' }, { label: m.nav_reminders(), href: '/reminders', icon: Bell, - featureKey: 'featureReminders' + featureKey: 'featureReminders', + group: 'Logs' }, { label: m.nav_pollution(), href: '/pollution', icon: BadgeInfo, - featureKey: 'featurePucc' + featureKey: 'featurePucc', + group: 'Logs' }, { label: m.nav_insurance(), href: '/insurance', icon: Shield, - featureKey: 'featureInsurance' + featureKey: 'featureInsurance', + group: 'Logs' }, { label: 'Expenses', href: '/expenses', - icon: Banknote + icon: Banknote, + group: 'Logs' }, { label: 'Reports', href: '/reports', - icon: BarChart3 + icon: BarChart3, + group: 'Insights' } ]; @@ -96,43 +107,62 @@ }) ); + const navGroups = $derived.by(() => { + const groups: { label: string; items: NavItem[] }[] = []; + for (const item of visibleNavItems) { + let group = groups.find((g) => g.label === item.group); + if (!group) { + group = { label: item.group, items: [] }; + groups.push(group); + } + group.items.push(item); + } + return groups; + }); + const userInitial = $derived(authStore.user?.username?.charAt(0)?.toUpperCase() || 'U'); function handleNavClick(href: string) { goto(href, { noScroll: true, keepFocus: true }); } + + // The underlying Sidebar.MenuButton always renders `data-active="false"` instead of + // omitting the attribute, which makes Tailwind's presence-based `data-active:` variant + // match on every item. Passing `data-active` explicitly (as an extra, undeclared prop) + // overrides that, since it lands in the component's `...restProps` spread last. + function navActiveProps(href: string) { + const active = page.url.pathname.startsWith(href); + return { isActive: active, 'data-active': active || undefined }; + } -
    - -
    - {m.app_name()} - Vehicle Management -
    -
    +
    - - - - {#each visibleNavItems as item (item.href)} - - handleNavClick(item.href)} - tooltipContent={item.label} - > - - {item.label} - - - {/each} - - - + {#each navGroups as group (group.label)} + + {group.label} + + + {#each group.items as item (item.href)} + + handleNavClick(item.href)} + tooltipContent={item.label} + > + + {item.label} + + + {/each} + + + + {/each} @@ -141,7 +171,7 @@ handleNavClick('/settings')} tooltipContent={m.settings_title()} > @@ -161,6 +191,7 @@ @@ -173,6 +204,7 @@ {authStore.user?.username || m.profile_menu_item()}
    + @@ -229,4 +261,5 @@ {/if} + diff --git a/src/lib/components/layout/VehicleSwitcher.svelte b/src/lib/components/layout/VehicleSwitcher.svelte new file mode 100644 index 00000000..d5641597 --- /dev/null +++ b/src/lib/components/layout/VehicleSwitcher.svelte @@ -0,0 +1,115 @@ + + + + + {#if activeVehicle} + + + +
    + +
    +
    + {vehicleName(activeVehicle)} + + {activeVehicle.licensePlate ?? '--'} + +
    + +
    +
    + + + Your Vehicles + + {#each vehicles as vehicle (vehicle.id)} + (vehicleStore.selectedId = vehicle.id!)} + > +
    + +
    + {vehicleName(vehicle)} + {#if vehicle.id === activeVehicle.id} + + {/if} +
    + {/each} + + goto('/vehicles')}> +
    + +
    + Manage vehicles +
    + +
    + +
    + {m.app_add_vehicle()} +
    +
    +
    + {:else} + +
    + +
    +
    + {m.app_add_vehicle()} + Get started +
    +
    + {/if} +
    +
    diff --git a/src/lib/domain/dashboard.ts b/src/lib/domain/dashboard.ts new file mode 100644 index 00000000..3944a457 --- /dev/null +++ b/src/lib/domain/dashboard.ts @@ -0,0 +1,242 @@ +import { z } from 'zod'; +import type { Vehicle } from './vehicle'; + +export interface StatusBucket { + valid: number; + expiringSoon: number; + expired: number; + notAvailable: number; +} + +export interface VehicleSummary { + id: string; + make: string; + model: string; + licensePlate: string | null; + image: string | null; + odometer: number; + fuelType: Vehicle['fuelType']; + totalDistance: number; + totalFuelCost: number; + totalMaintenanceCost: number; + totalInsuranceCost: number; + totalExpenses: number; + avgMileage: number | null; + costPerDistance: number | null; + puccStatus: 'valid' | 'expiring_soon' | 'expired' | 'not_available'; + insuranceStatus: 'valid' | 'expiring_soon' | 'expired' | 'not_available'; + healthStatus: 'good' | 'attention' | 'needs_action'; +} + +export interface UpcomingReminder { + id: string; + vehicleId: string; + vehicleName: string; + vehiclePlate: string | null; + type: string; + note: string | null; + dueDate: string; + daysUntilDue: number; +} + +export interface ActivityEntry { + id: string; + type: 'fuel' | 'maintenance'; + vehicleId: string; + vehicleName: string; + date: string; + description: string; + cost: number; +} + +export interface MonthlyExpensePoint { + month: string; // YYYY-MM + fuel: number; + maintenance: number; + insurance: number; + total: number; +} + +export interface DashboardSummary { + fleet: { + totalVehicles: number; + totalDistance: number; + totalFuelUsed: number; + totalExpenses: number; + costPerDistance: number | null; + }; + expenses: { + breakdown: { fuel: number; maintenance: number; insurance: number }; + monthlyTrend: MonthlyExpensePoint[]; + }; + fuel: { + dailyTrend: Array<{ date: string; fuelAmount: number }>; + }; + compliance: { + pucc: StatusBucket; + insurance: StatusBucket; + vehicleHealth: { good: number; attention: number; needsAction: number }; + upcomingReminders: UpcomingReminder[]; + }; + vehicles: VehicleSummary[]; + activity: ActivityEntry[]; +} + +export const WIDGET_TYPES = { + 'stat-vehicle-count': 'stat-vehicle-count', + 'stat-total-distance': 'stat-total-distance', + 'stat-fuel-used': 'stat-fuel-used', + 'stat-total-expenses': 'stat-total-expenses', + 'stat-cost-per-distance': 'stat-cost-per-distance', + 'expense-breakdown-donut': 'expense-breakdown-donut', + 'monthly-expense-trend': 'monthly-expense-trend', + 'cost-by-vehicle-leaderboard': 'cost-by-vehicle-leaderboard', + 'fleet-fuel-trend': 'fleet-fuel-trend', + 'efficiency-leaderboard': 'efficiency-leaderboard', + 'pucc-status-donut': 'pucc-status-donut', + 'insurance-status-donut': 'insurance-status-donut', + 'vehicle-health-distribution': 'vehicle-health-distribution', + 'upcoming-reminders-list': 'upcoming-reminders-list', + 'vehicle-quick-list': 'vehicle-quick-list', + 'recent-activity-feed': 'recent-activity-feed' +} as const; + +export type WidgetType = keyof typeof WIDGET_TYPES; + +const widgetTypeOptions = Object.keys(WIDGET_TYPES) as [WidgetType, ...WidgetType[]]; + +// Widgets have an explicit (colStart, rowStart) position plus a (colSpan, rowSpan) size in a 12-col grid; every move/resize/remove recompacts the layout (see grid-compaction.ts). +export const GRID_COLUMNS = 12; +export const GRID_MAX_ROW_SPAN = 12; + +export type WidgetColSpan = number; +export type WidgetRowSpan = number; + +export interface WidgetLayoutItem { + id: string; + type: WidgetType; + colStart: number; + rowStart: number; + colSpan: WidgetColSpan; + rowSpan: WidgetRowSpan; +} + +export const widgetLayoutItemSchema = z.object({ + id: z.string(), + type: z.enum(widgetTypeOptions), + colStart: z.number().int().min(1).max(GRID_COLUMNS), + rowStart: z.number().int().min(1), + colSpan: z.number().int().min(1).max(GRID_COLUMNS), + rowSpan: z.number().int().min(1).max(GRID_MAX_ROW_SPAN) +}); + +export const widgetLayoutSchema = z.array(widgetLayoutItemSchema).max(32); + +/** + * Smallest rect a widget stays legible in. Enforced when resizing, when adding, and when reading a + * persisted layout back — so a layout saved before a widget's minimum changed heals on load rather + * than rendering clipped. One row unit is 28px plus the 16px grid gap, so N rows ≈ 44N − 16 px. + */ +export interface WidgetMinSize { + minColSpan: number; + minRowSpan: number; +} + +const DEFAULT_MIN_SIZE: WidgetMinSize = { minColSpan: 2, minRowSpan: 3 }; + +const WIDGET_MIN_SIZES: Partial> = { + // Label over a single number, laid out beside the icon. Legible down to 2 rows (72px); 3 columns + // is what the longest label ("Total Fuel Used") needs beside the icon before it starts eliding. + 'stat-vehicle-count': { minColSpan: 3, minRowSpan: 2 }, + 'stat-total-distance': { minColSpan: 3, minRowSpan: 2 }, + 'stat-fuel-used': { minColSpan: 3, minRowSpan: 2 }, + 'stat-total-expenses': { minColSpan: 3, minRowSpan: 2 }, + 'stat-cost-per-distance': { minColSpan: 3, minRowSpan: 2 }, + // Ring plus legend. + 'expense-breakdown-donut': { minColSpan: 3, minRowSpan: 6 }, + 'pucc-status-donut': { minColSpan: 3, minRowSpan: 6 }, + 'insurance-status-donut': { minColSpan: 3, minRowSpan: 6 }, + 'vehicle-health-distribution': { minColSpan: 3, minRowSpan: 6 }, + // Plot area plus a rotated x-axis. + 'monthly-expense-trend': { minColSpan: 4, minRowSpan: 6 }, + 'fleet-fuel-trend': { minColSpan: 4, minRowSpan: 6 }, + // Scrolling lists stay usable at a couple of visible rows. + 'cost-by-vehicle-leaderboard': { minColSpan: 3, minRowSpan: 4 }, + 'efficiency-leaderboard': { minColSpan: 3, minRowSpan: 4 }, + 'upcoming-reminders-list': { minColSpan: 3, minRowSpan: 4 }, + 'vehicle-quick-list': { minColSpan: 3, minRowSpan: 4 }, + 'recent-activity-feed': { minColSpan: 3, minRowSpan: 4 } +}; + +export function widgetMinSize(type: WidgetType): WidgetMinSize { + return WIDGET_MIN_SIZES[type] ?? DEFAULT_MIN_SIZE; +} + +export const DEFAULT_WIDGET_LAYOUT: WidgetLayoutItem[] = [ + { id: 'default-1', type: 'stat-vehicle-count', colStart: 1, rowStart: 1, colSpan: 3, rowSpan: 4 }, + { + id: 'default-2', + type: 'stat-total-distance', + colStart: 4, + rowStart: 1, + colSpan: 3, + rowSpan: 4 + }, + { id: 'default-3', type: 'stat-fuel-used', colStart: 7, rowStart: 1, colSpan: 3, rowSpan: 4 }, + { + id: 'default-4', + type: 'stat-total-expenses', + colStart: 10, + rowStart: 1, + colSpan: 3, + rowSpan: 4 + }, + { + id: 'default-5', + type: 'fleet-fuel-trend', + colStart: 1, + rowStart: 5, + colSpan: 9, + rowSpan: 8 + }, + { + id: 'default-6', + type: 'expense-breakdown-donut', + colStart: 1, + rowStart: 13, + colSpan: 6, + rowSpan: 8 + }, + { + id: 'default-7', + type: 'vehicle-health-distribution', + colStart: 7, + rowStart: 13, + colSpan: 6, + rowSpan: 8 + }, + { + id: 'default-8', + type: 'vehicle-quick-list', + colStart: 1, + rowStart: 21, + colSpan: 9, + rowSpan: 8 + }, + { + id: 'default-9', + type: 'pucc-status-donut', + colStart: 1, + rowStart: 29, + colSpan: 6, + rowSpan: 6 + }, + { + id: 'default-10', + type: 'upcoming-reminders-list', + colStart: 1, + rowStart: 33, + colSpan: 6, + rowSpan: 8 + } +]; diff --git a/src/lib/domain/fuel/mileage.ts b/src/lib/domain/fuel/mileage.ts index 24890f54..574c694b 100644 --- a/src/lib/domain/fuel/mileage.ts +++ b/src/lib/domain/fuel/mileage.ts @@ -75,6 +75,11 @@ export function computeAverageMileage(fuelLogs: FuelLogInput[]): number | null { return parseFloat(avg.toFixed(2)); } +export function computeTotalDistance(fuelLogs: FuelLogInput[]): number { + const windows = findMileageWindows(fuelLogs); + return windows.reduce((sum, w) => sum + w.distance, 0); +} + export function computeMileagePerWindow(fuelLogs: FuelLogInput[]): (number | null)[] { const windows = findMileageWindows(fuelLogs); diff --git a/src/lib/stores/chart.svelte.ts b/src/lib/stores/chart.svelte.ts index 59a214f6..74f383d7 100644 --- a/src/lib/stores/chart.svelte.ts +++ b/src/lib/stores/chart.svelte.ts @@ -25,9 +25,22 @@ const calculateMileageData = (logs: FuelLog[]) => { .sort((a, b) => a.x.getTime() - b.x.getTime()); }; +const calculateFuelAmountData = (logs: FuelLog[]) => { + return logs + .filter((log) => log.fuelAmount) + .map((log) => { + return { + x: new Date(log.date), + y: log.fuelAmount + }; + }) + .sort((a, b) => a.x.getTime() - b.x.getTime()); +}; + class ChartStore { mileageData? = $derived(calculateMileageData(fuelLogStore.fuelLogs || [])); costData? = $derived(calculateCostData(fuelLogStore.fuelLogs || [])); + fuelAmountData? = $derived(calculateFuelAmountData(fuelLogStore.fuelLogs || [])); } export const chartStore = new ChartStore(); diff --git a/src/lib/stores/dashboard-layout.svelte.ts b/src/lib/stores/dashboard-layout.svelte.ts new file mode 100644 index 00000000..606320ec --- /dev/null +++ b/src/lib/stores/dashboard-layout.svelte.ts @@ -0,0 +1,111 @@ +import { apiClient } from '$lib/helper/api.helper'; +import { + DEFAULT_WIDGET_LAYOUT, + GRID_COLUMNS, + widgetMinSize, + WIDGET_TYPES, + type WidgetColSpan, + type WidgetLayoutItem, + type WidgetRowSpan, + type WidgetType +} from '$lib/domain/dashboard'; +import { compactLayout } from '$lib/components/dashboard/grid-layout'; + +const SAVE_DEBOUNCE_MS = 600; + +/** Grows anything below its widget's minimum, so old layouts don't render clipped. */ +function applyMinSizes(items: WidgetLayoutItem[]): WidgetLayoutItem[] { + return items.map((item) => { + const { minColSpan, minRowSpan } = widgetMinSize(item.type); + const colSpan = Math.max(item.colSpan, minColSpan); + return { + ...item, + colSpan, + rowSpan: Math.max(item.rowSpan, minRowSpan), + colStart: Math.min(item.colStart, GRID_COLUMNS - colSpan + 1) + }; + }); +} + +class DashboardLayoutStore { + items = $state([]); + loading = $state(false); + saving = $state(false); + error = $state(); + + private saveTimer: ReturnType | undefined; + + async fetchLayout() { + this.loading = true; + this.error = undefined; + try { + const { data: res } = await apiClient.get<{ success: boolean; data: WidgetLayoutItem[] }>( + '/dashboard/layout' + ); + const layout = res.success && res.data ? res.data : DEFAULT_WIDGET_LAYOUT; + this.items = compactLayout(applyMinSizes(layout)); + } catch (err) { + this.error = 'Failed to load dashboard layout'; + this.items = compactLayout(applyMinSizes(DEFAULT_WIDGET_LAYOUT)); + console.error(err); + } finally { + this.loading = false; + } + } + + private scheduleSave() { + this.saving = true; + clearTimeout(this.saveTimer); + this.saveTimer = setTimeout(() => this.persist(), SAVE_DEBOUNCE_MS); + } + + private async persist() { + try { + await apiClient.put('/dashboard/layout', $state.snapshot(this.items)); + } catch (err) { + console.error('Failed to save dashboard layout', err); + } finally { + this.saving = false; + } + } + + get availableWidgetTypes(): WidgetType[] { + const used = new Set(this.items.map((w) => w.type)); + return (Object.keys(WIDGET_TYPES) as WidgetType[]).filter((t) => !used.has(t)); + } + + addWidget(type: WidgetType, colSpan: WidgetColSpan = 6, rowSpan: WidgetRowSpan = 8) { + const { minColSpan, minRowSpan } = widgetMinSize(type); + const bottomRow = this.items.reduce((max, w) => Math.max(max, w.rowStart + w.rowSpan), 1); + const items = [ + ...this.items.map((w) => ({ ...w })), + { + id: crypto.randomUUID(), + type, + colStart: 1, + rowStart: bottomRow, + colSpan: Math.max(colSpan, minColSpan), + rowSpan: Math.max(rowSpan, minRowSpan) + } + ]; + this.items = compactLayout(items); + this.scheduleSave(); + } + + removeWidget(id: string) { + const items = this.items.filter((w) => w.id !== id).map((w) => ({ ...w })); + this.items = compactLayout(items); + this.scheduleSave(); + } + + /** + * Accepts a layout already resolved by the grid engine (see `grid-layout.ts`). Called once per + * drag/resize gesture, on drop — the grid keeps its own draft while the pointer is down. + */ + commitLayout(items: WidgetLayoutItem[]) { + this.items = items; + this.scheduleSave(); + } +} + +export const dashboardLayoutStore = new DashboardLayoutStore(); diff --git a/src/lib/stores/dashboard.svelte.ts b/src/lib/stores/dashboard.svelte.ts index 6e85dba9..6199543b 100644 --- a/src/lib/stores/dashboard.svelte.ts +++ b/src/lib/stores/dashboard.svelte.ts @@ -1,24 +1,5 @@ import { apiClient } from '$lib/helper/api.helper'; - -interface DashboardSummary { - totalVehicles: number; - totalFuelUsed: number; - totalDistance: number; - totalExpenses: number; - expenseBreakdown: { fuel: number; maintenance: number; insurance: number }; - puccStatus: { valid: number; expiringSoon: number; expired: number; notAvailable: number }; - vehicleHealth: { good: number; attention: number; needsAction: number }; - upcomingReminders: Array<{ - id: string; - vehicleId: string; - vehicleName: string; - vehiclePlate: string | null; - type: string; - note: string | null; - dueDate: string; - daysUntilDue: number; - }>; -} +import type { DashboardSummary } from '$lib/domain/dashboard'; class DashboardStore { summary = $state(null); diff --git a/src/lib/stores/notification.svelte.ts b/src/lib/stores/notification.svelte.ts index d271004b..9a793eb3 100644 --- a/src/lib/stores/notification.svelte.ts +++ b/src/lib/stores/notification.svelte.ts @@ -14,10 +14,10 @@ type NotificationType = Notification['type']; export const NAVIGATION_MAP: Record = { information: '/dashboard', - insurance: '/dashboard/insurance', - pollution: '/dashboard/pollution', - reminder: '/dashboard/reminders', - maintenance: '/dashboard/maintenance', + insurance: '/insurance', + pollution: '/pollution', + reminder: '/reminders', + maintenance: '/maintenance', registration: '/dashboard', alert: '/dashboard' }; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index ec5267f7..465855c3 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -12,6 +12,8 @@ import TriangleAlert from '@lucide/svelte/icons/triangle-alert'; import LabelWithIcon from '$appui/LabelWithIcon.svelte'; import { demo_banner, default_login } from '$lib/paraglide/messages/_index.js'; + import Tractor from '@lucide/svelte/icons/tractor'; + import { m } from '$lib/paraglide/messages'; let { data, children }: LayoutProps = $props(); @@ -33,36 +35,39 @@ let demoMode = env.DEMO_MODE; -{#if demoMode} -
    - - {demo_banner()} - - {#if !env.DISABLE_AUTH} - {default_login()} - {/if} - -
    -{/if} - -
    +
    +
    + + {m.app_name()} +
    -
    + {#if demoMode} +
    + + {demo_banner()} + + {#if !env.DISABLE_AUTH} + {default_login()} + {/if} + +
    + {/if} +
    {@render children()}
    diff --git a/src/routes/(app)/dashboard/+layout.svelte b/src/routes/(app)/dashboard/+layout.svelte deleted file mode 100644 index 135b568d..00000000 --- a/src/routes/(app)/dashboard/+layout.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - -
    - {@render children()} -
    diff --git a/src/routes/(app)/dashboard/+page.svelte b/src/routes/(app)/dashboard/+page.svelte index c33cecc0..a4ff78ae 100644 --- a/src/routes/(app)/dashboard/+page.svelte +++ b/src/routes/(app)/dashboard/+page.svelte @@ -1,23 +1,19 @@
    + - -
    - - 0 - ? `${(totalDistance / 1000).toFixed(1)}k km` - : '--'} - color="bg-violet-500/10 text-violet-500" - /> - 0 ? `${totalFuelUsed.toFixed(1)} L` : '--'} - color="bg-green-500/10 text-green-500" - /> - 0 ? `$${totalExpenses.toFixed(2)}` : '--'} - color="bg-amber-500/10 text-amber-500" - /> -
    - - -
    - -
    -

    Fuel Consumption Chart

    -
    - - - -
    - - -
    - -
    -

    My Vehicles

    - {#if vehicleStore.vehicles && vehicleStore.vehicles.length > 0} -
    - {#each vehicleStore.vehicles as vehicle (vehicle.id)} - (vehicleStore.selectedId = vehicle.id!)} - onkeydown={() => {}} - /> - {/each} - -
    - {:else} -
    - No vehicles yet -
    - {/if} -
    - - -
    - -
    -

    Vehicle Health

    -
    - - {#if summary} -
    -
    - - - Good - - {summary.vehicleHealth.good} -
    -
    - - - Attention - - {summary.vehicleHealth.attention} -
    -
    - - - Needs Action - - {summary.vehicleHealth.needsAction} -
    -
    - {:else} -
    Loading...
    - {/if} -
    -
    - - -
    -

    PUC Status

    - -
    -
    -
    - - -
    -
    -

    Upcoming Reminders

    - -
    - {#if summary && summary.upcomingReminders.length > 0} -
    - {#each summary.upcomingReminders.slice(0, 5) as reminder (reminder.id)} -
    - -
    -

    - {reminder.vehicleName} - {#if reminder.vehiclePlate} - ({reminder.vehiclePlate}) - {/if} -

    -

    {reminder.note || reminder.type}

    -
    - -
    - {/each} -
    - {:else if loading} -
    Loading reminders...
    - {:else} -
    No upcoming reminders
    - {/if} -
    + dashboardLayoutStore.commitLayout(items)} + > + {#snippet children(item)} + {@const def = WIDGET_REGISTRY[item.type]} + dashboardLayoutStore.removeWidget(item.id)} + > + + + {/snippet} + -
    -
    -

    Get detailed insights

    -

    - View comprehensive reports and analytics for your fleet -

    -
    - -
    + goto('/reports')} + />
    diff --git a/src/routes/(app)/dashboard/fuel/+page.svelte b/src/routes/(app)/dashboard/fuel/+page.svelte deleted file mode 100644 index be0b1aa0..00000000 --- a/src/routes/(app)/dashboard/fuel/+page.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/routes/(app)/dashboard/fuel/+page.ts b/src/routes/(app)/dashboard/fuel/+page.ts deleted file mode 100644 index 75e9d960..00000000 --- a/src/routes/(app)/dashboard/fuel/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageLoad } from './$types'; - -export const load: PageLoad = async () => { - throw redirect(307, '/fuel'); -}; diff --git a/src/routes/(app)/dashboard/insurance/+page.svelte b/src/routes/(app)/dashboard/insurance/+page.svelte deleted file mode 100644 index ab237fef..00000000 --- a/src/routes/(app)/dashboard/insurance/+page.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/routes/(app)/dashboard/insurance/+page.ts b/src/routes/(app)/dashboard/insurance/+page.ts deleted file mode 100644 index 5923852b..00000000 --- a/src/routes/(app)/dashboard/insurance/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageLoad } from './$types'; - -export const load: PageLoad = async () => { - throw redirect(307, '/insurance'); -}; diff --git a/src/routes/(app)/dashboard/maintenance/+page.svelte b/src/routes/(app)/dashboard/maintenance/+page.svelte deleted file mode 100644 index 064bb704..00000000 --- a/src/routes/(app)/dashboard/maintenance/+page.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/routes/(app)/dashboard/maintenance/+page.ts b/src/routes/(app)/dashboard/maintenance/+page.ts deleted file mode 100644 index 99c3dd85..00000000 --- a/src/routes/(app)/dashboard/maintenance/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageLoad } from './$types'; - -export const load: PageLoad = async () => { - throw redirect(307, '/maintenance'); -}; diff --git a/src/routes/(app)/dashboard/overview/+page.svelte b/src/routes/(app)/dashboard/overview/+page.svelte deleted file mode 100644 index 6a4f41d6..00000000 --- a/src/routes/(app)/dashboard/overview/+page.svelte +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/routes/(app)/dashboard/overview/+page.ts b/src/routes/(app)/dashboard/overview/+page.ts deleted file mode 100644 index 8bb857d6..00000000 --- a/src/routes/(app)/dashboard/overview/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageLoad } from './$types'; - -export const load: PageLoad = async () => { - throw redirect(307, '/dashboard'); -}; diff --git a/src/routes/(app)/dashboard/pollution/+page.svelte b/src/routes/(app)/dashboard/pollution/+page.svelte deleted file mode 100644 index 9a665e7d..00000000 --- a/src/routes/(app)/dashboard/pollution/+page.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/routes/(app)/dashboard/pollution/+page.ts b/src/routes/(app)/dashboard/pollution/+page.ts deleted file mode 100644 index 939a6526..00000000 --- a/src/routes/(app)/dashboard/pollution/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageLoad } from './$types'; - -export const load: PageLoad = async () => { - throw redirect(307, '/pollution'); -}; diff --git a/src/routes/(app)/dashboard/reminders/+page.svelte b/src/routes/(app)/dashboard/reminders/+page.svelte deleted file mode 100644 index 45dd1038..00000000 --- a/src/routes/(app)/dashboard/reminders/+page.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/routes/(app)/dashboard/reminders/+page.ts b/src/routes/(app)/dashboard/reminders/+page.ts deleted file mode 100644 index f71f8f56..00000000 --- a/src/routes/(app)/dashboard/reminders/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageLoad } from './$types'; - -export const load: PageLoad = async () => { - throw redirect(307, '/reminders'); -}; diff --git a/src/routes/(app)/expenses/+page.svelte b/src/routes/(app)/expenses/+page.svelte index d226ff44..39831314 100644 --- a/src/routes/(app)/expenses/+page.svelte +++ b/src/routes/(app)/expenses/+page.svelte @@ -2,10 +2,12 @@ import PageHeader from '$dashboard/PageHeader.svelte'; import StatCard from '$dashboard/StatCard.svelte'; import DonutChart from '$dashboard/DonutChart.svelte'; + import StackedAreaChart from '$dashboard/StackedAreaChart.svelte'; import Fuel from '@lucide/svelte/icons/fuel'; import Wrench from '@lucide/svelte/icons/wrench'; import Shield from '@lucide/svelte/icons/shield'; import { dashboardStore } from '$stores/dashboard.svelte'; + import { formatCurrency } from '$lib/helper/format.helper'; import { onMount } from 'svelte'; onMount(() => { @@ -18,13 +20,17 @@ const expenseDonutData = $derived( summary ? [ - { name: 'Fuel', value: summary.expenseBreakdown.fuel, color: 'var(--chart-1)' }, + { name: 'Fuel', value: summary.expenses.breakdown.fuel, color: 'var(--chart-1)' }, { name: 'Maintenance', - value: summary.expenseBreakdown.maintenance, + value: summary.expenses.breakdown.maintenance, color: 'var(--chart-2)' }, - { name: 'Insurance', value: summary.expenseBreakdown.insurance, color: 'var(--chart-3)' } + { + name: 'Insurance', + value: summary.expenses.breakdown.insurance, + color: 'var(--chart-3)' + } ].filter((d) => d.value > 0) : [] ); @@ -37,8 +43,8 @@
    -
    -

    Monthly expense trend chart coming soon

    -
    +
    diff --git a/src/routes/(app)/fuel/+page.svelte b/src/routes/(app)/fuel/+page.svelte index 6d99c89f..e49ae336 100644 --- a/src/routes/(app)/fuel/+page.svelte +++ b/src/routes/(app)/fuel/+page.svelte @@ -1,5 +1,4 @@ {#snippet children()}
    - -
    - -
    -
    +
    diff --git a/src/routes/(app)/maintenance/+page.svelte b/src/routes/(app)/maintenance/+page.svelte index de1464b6..e4aed5d9 100644 --- a/src/routes/(app)/maintenance/+page.svelte +++ b/src/routes/(app)/maintenance/+page.svelte @@ -1,100 +1,107 @@ {#snippet children()}
    - -
    - -
    -
    + -
    +
    0 ? `$${totalCost.toFixed(2)}` : '--'} - color="bg-blue-500/10 text-blue-500" + icon={HeartPulse} + label="Last Service" + value={lastServiceDate ? formatDate(lastServiceDate) : '--'} + color="bg-gradient-to-br from-emerald-400 to-emerald-600 shadow-emerald-500/30" /> 0 ? `${nextServiceOdometer.toLocaleString()} km` : '--'} - color="bg-amber-500/10 text-amber-500" + color="bg-gradient-to-br from-amber-400 to-amber-600 shadow-amber-500/30" /> 0 ? `${latestOdometer.toLocaleString()} km` : '--'} - color="bg-green-500/10 text-green-500" - /> -
    - + + + Overview + Service History + + + +
    +
    +

    Next Service

    + {#if nextServiceOdometer > 0} +

    + {nextServiceOdometer.toLocaleString()} km +

    +

    + Estimated, based on last service at {latestOdometer.toLocaleString()} km +

    + {:else} +

    No service history to estimate from

    + {/if} +
    + +
    +

    Maintenance Timeline

    + +
    +
    +
    + + + + +
    {/snippet} {#snippet fallback()} diff --git a/src/routes/(app)/pollution/+page.svelte b/src/routes/(app)/pollution/+page.svelte index d9a488b2..cbea6be1 100644 --- a/src/routes/(app)/pollution/+page.svelte +++ b/src/routes/(app)/pollution/+page.svelte @@ -3,13 +3,22 @@ import PageHeader from '$dashboard/PageHeader.svelte'; import StatCard from '$dashboard/StatCard.svelte'; import StatusPill from '$dashboard/StatusPill.svelte'; + import CtaBanner from '$dashboard/CtaBanner.svelte'; + import FilterTabs from '$dashboard/FilterTabs.svelte'; + import * as Table from '$ui/table/index.js'; + import Car from '@lucide/svelte/icons/car'; import BadgeCheck from '@lucide/svelte/icons/badge-check'; import AlertTriangle from '@lucide/svelte/icons/alert-triangle'; import Timer from '@lucide/svelte/icons/timer'; - import Ban from '@lucide/svelte/icons/ban'; import { apiClient } from '$lib/helper/api.helper'; import { Features } from '$lib/helper/feature.helper'; import FeatureGate from '$feature/FeatureGate.svelte'; + import { sheetStore } from '$stores/sheet.svelte'; + import PollutionCertificateForm from '$feature/pollution/PollutionCertificateForm.svelte'; + import LabelWithIcon from '$appui/LabelWithIcon.svelte'; + import Button from '$ui/button/button.svelte'; + import CirclePlus from '@lucide/svelte/icons/circle-plus'; + import * as m from '$lib/paraglide/messages'; import { feature_pucc_disabled_title, feature_pucc_disabled_hint @@ -50,6 +59,7 @@ const notAvailable = records.filter((r) => r.status === 'not_available').length; const total = records.length; return { + total, valid, expiringSoon, expired, @@ -77,50 +87,45 @@ {#snippet children()}
    - + + +
    + -
    -
    - {#each tabs as tab} - - {/each} -
    + {#if loading} @@ -129,35 +134,48 @@
    No PUC records found
    {:else}
    - - - - - - - - - - - + + + + Vehicle + Reg. No. + PUC No. + Expiry + Status + + + {#each filteredRecords as record (record.vehicleId)} - - - - - - - + + {/each} - -
    VehicleReg. No.PUC No.ExpiryStatus
    {record.vehicleName}{record.licensePlate ?? '--'}{record.certificateNumber ?? '--'} + + {record.vehicleName} + + {record.licensePlate ?? '--'} + + + {record.certificateNumber ?? '--'} + + {record.expiryDate ? new Date(record.expiryDate).toLocaleDateString() : '--'} - + + -
    + +
    {/if} + + + + sheetStore.openSheet(PollutionCertificateForm, m.pollution_add_action())} + />
    {/snippet} {#snippet fallback()} diff --git a/src/routes/(app)/reminders/+page.svelte b/src/routes/(app)/reminders/+page.svelte index da11d35e..a315cc53 100644 --- a/src/routes/(app)/reminders/+page.svelte +++ b/src/routes/(app)/reminders/+page.svelte @@ -2,14 +2,21 @@ import { onMount } from 'svelte'; import PageHeader from '$dashboard/PageHeader.svelte'; import StatusPill from '$dashboard/StatusPill.svelte'; + import FilterTabs from '$dashboard/FilterTabs.svelte'; import { apiClient } from '$lib/helper/api.helper'; import Bell from '@lucide/svelte/icons/bell'; import Wrench from '@lucide/svelte/icons/wrench'; import Shield from '@lucide/svelte/icons/shield'; import BadgeInfo from '@lucide/svelte/icons/badge-info'; + import CirclePlus from '@lucide/svelte/icons/circle-plus'; import { Features } from '$lib/helper/feature.helper'; import FeatureGate from '$feature/FeatureGate.svelte'; + import { sheetStore } from '$stores/sheet.svelte'; + import ReminderForm from '$feature/reminder/ReminderForm.svelte'; + import LabelWithIcon from '$appui/LabelWithIcon.svelte'; + import Button from '$ui/button/button.svelte'; + import * as m from '$lib/paraglide/messages'; import { feature_reminders_disabled_title, feature_reminders_disabled_hint @@ -28,23 +35,31 @@ } let reminders = $state([]); + let completedReminders = $state([]); let loading = $state(true); let activeTab = $state('upcoming'); - onMount(async () => { + async function fetchReminders() { + loading = true; try { - const { data: res } = await apiClient.get<{ success: boolean; data: ReminderRecord[] }>( - '/reminders?status=upcoming' - ); - if (res.success && res.data) { - reminders = res.data; + const [upcomingRes, completedRes] = await Promise.all([ + apiClient.get<{ success: boolean; data: ReminderRecord[] }>('/reminders?status=upcoming'), + apiClient.get<{ success: boolean; data: ReminderRecord[] }>('/reminders?status=completed') + ]); + if (upcomingRes.data.success && upcomingRes.data.data) { + reminders = upcomingRes.data.data; + } + if (completedRes.data.success && completedRes.data.data) { + completedReminders = completedRes.data.data; } } catch (err) { console.error('Failed to fetch reminders', err); } finally { loading = false; } - }); + } + + onMount(fetchReminders); const typeIcon = (type: string) => { switch (type) { @@ -59,17 +74,32 @@ } }; - const filteredReminders = $derived( - activeTab === 'all' - ? reminders + const typeIconColor = (type: string) => { + switch (type) { + case 'maintenance': + return 'bg-amber-500/10 text-amber-500'; + case 'insurance': + return 'bg-sky-500/10 text-sky-500'; + case 'pollution': + return 'bg-violet-500/10 text-violet-500'; + default: + return 'bg-blue-500/10 text-blue-500'; + } + }; + + const byType = (list: ReminderRecord[]) => + activeTab === 'upcoming' + ? list : activeTab === 'service' - ? reminders.filter((r) => r.type === 'maintenance') + ? list.filter((r) => r.type === 'maintenance') : activeTab === 'puc' - ? reminders.filter((r) => r.type === 'pollution') + ? list.filter((r) => r.type === 'pollution') : activeTab === 'insurance' - ? reminders.filter((r) => r.type === 'insurance') - : reminders.filter((r) => r.type === 'others') - ); + ? list.filter((r) => r.type === 'insurance') + : list.filter((r) => !['maintenance', 'pollution', 'insurance'].includes(r.type)); + + const filteredReminders = $derived(byType(reminders)); + const filteredCompletedReminders = $derived(byType(completedReminders)); const tabs = [ { id: 'upcoming', label: 'All' }, @@ -80,69 +110,83 @@ ]; +{#snippet reminderRow(reminder: ReminderRecord, completed: boolean)} + {@const Icon = typeIcon(reminder.type)} + {@const dueDate = new Date(reminder.dueDate)} + {@const daysUntilDue = Math.ceil((dueDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))} +
    +
    + +
    +
    +

    + {reminder.vehicleMake} + {reminder.vehicleModel} + {#if reminder.vehiclePlate} + ({reminder.vehiclePlate}) + {/if} +

    +

    {reminder.type}

    + {#if reminder.note} +

    {reminder.note}

    + {/if} +
    +
    +

    {dueDate.toLocaleDateString()}

    + {#if completed} + + {:else} + + {/if} +
    +
    +{/snippet} + {#snippet children()}
    - + + + -
    - {#each tabs as tab} - - {/each} + + + +
    +

    Upcoming

    + {#if loading} +
    Loading reminders...
    + {:else if filteredReminders.length === 0} +
    No upcoming reminders
    + {:else} +
    + {#each filteredReminders as reminder (reminder.id)} + {@render reminderRow(reminder, false)} + {/each} +
    + {/if}
    - - {#if loading} -
    Loading reminders...
    - {:else if filteredReminders.length === 0} -
    No reminders found
    - {:else} -
    - {#each filteredReminders as reminder (reminder.id)} - {@const Icon = typeIcon(reminder.type)} - {@const dueDate = new Date(reminder.dueDate)} - {@const daysUntilDue = Math.ceil( - (dueDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000) - )} -
    -
    - -
    -
    -

    - {reminder.vehicleMake} - {reminder.vehicleModel} - {#if reminder.vehiclePlate} - ({reminder.vehiclePlate}) - {/if} -

    -

    {reminder.type}

    - {#if reminder.note} -

    {reminder.note}

    - {/if} -
    -
    -

    {dueDate.toLocaleDateString()}

    - -
    -
    - {/each} + + {#if !loading && filteredCompletedReminders.length > 0} +
    +

    Completed

    +
    + {#each filteredCompletedReminders as reminder (reminder.id)} + {@render reminderRow(reminder, true)} + {/each} +
    {/if}
    diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte deleted file mode 100644 index 9e113ee1..00000000 --- a/src/routes/(app)/settings/+layout.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - -
    - {@render children()} -
    diff --git a/src/routes/api/dashboard/layout/+server.ts b/src/routes/api/dashboard/layout/+server.ts new file mode 100644 index 00000000..eb8e51fd --- /dev/null +++ b/src/routes/api/dashboard/layout/+server.ts @@ -0,0 +1,19 @@ +import type { RequestHandler } from './$types'; +import { getWidgetLayout, saveWidgetLayout } from '$server/services/dashboardWidgetService'; +import { jsonResponse, parseBody, withRouteErrorHandling } from '$server/utils/route-handler'; +import { widgetLayoutSchema } from '$lib/domain/dashboard'; + +export const GET: RequestHandler = async () => { + return withRouteErrorHandling('Dashboard layout GET error:', async () => { + const layout = await getWidgetLayout(); + return jsonResponse(layout); + }); +}; + +export const PUT: RequestHandler = async (event) => { + return withRouteErrorHandling('Dashboard layout PUT error:', async () => { + const layout = await parseBody(event, widgetLayoutSchema); + const saved = await saveWidgetLayout(layout); + return jsonResponse(saved); + }); +}; diff --git a/src/routes/api/dashboard/summary/+server.ts b/src/routes/api/dashboard/summary/+server.ts index 27b41c18..ee8a3e7b 100644 --- a/src/routes/api/dashboard/summary/+server.ts +++ b/src/routes/api/dashboard/summary/+server.ts @@ -1,13 +1,10 @@ -import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getDashboardSummary } from '$server/services/dashboardService'; +import { jsonResponse, withRouteErrorHandling } from '$server/utils/route-handler'; export const GET: RequestHandler = async () => { - try { + return withRouteErrorHandling('Dashboard summary GET error:', async () => { const summary = await getDashboardSummary(); - return json({ success: true, data: summary }); - } catch (error) { - console.error('Failed to fetch dashboard summary:', error); - return json({ success: false, error: 'Failed to fetch dashboard summary' }, { status: 500 }); - } + return jsonResponse(summary); + }); }; diff --git a/src/server/services/dashboardService.ts b/src/server/services/dashboardService.ts index 7d25ca49..f11b7cdf 100644 --- a/src/server/services/dashboardService.ts +++ b/src/server/services/dashboardService.ts @@ -1,180 +1,153 @@ import { db } from '../db/index'; +import { + computeAverageMileage, + computeTotalDistance, + type FuelLogInput +} from '$lib/domain/fuel/mileage'; +import type { + ActivityEntry, + DashboardSummary, + MonthlyExpensePoint, + StatusBucket, + UpcomingReminder, + VehicleSummary +} from '$lib/domain/dashboard'; const EXPIRING_SOON_DAYS = 30; +const FUEL_TREND_MAX_POINTS = 10; +const ACTIVITY_MAX_ENTRIES = 15; +const UPCOMING_REMINDERS_MAX = 10; +const MONTHLY_TREND_MONTHS = 12; -export interface DashboardSummary { - totalVehicles: number; - totalFuelUsed: number; - totalDistance: number; - totalExpenses: number; - expenseBreakdown: { - fuel: number; - maintenance: number; - insurance: number; - }; - puccStatus: { - valid: number; - expiringSoon: number; - expired: number; - notAvailable: number; - }; - vehicleHealth: { - good: number; - attention: number; - needsAction: number; - }; - upcomingReminders: Array<{ - id: string; - vehicleId: string; - vehicleName: string; - vehiclePlate: string | null; - type: string; - note: string | null; - dueDate: string; - daysUntilDue: number; - }>; +async function fetchRawData() { + const [vehicles, fuelLogs, maintenanceLogs, insurances, pollutionCerts, reminders] = + await Promise.all([ + db.query.vehicleTable.findMany({ + columns: { + id: true, + make: true, + model: true, + licensePlate: true, + image: true, + odometer: true, + fuelType: true + } + }), + db.query.fuelLogTable.findMany({ + columns: { + vehicleId: true, + fuelAmount: true, + cost: true, + filled: true, + missedLast: true, + odometer: true, + date: true + }, + orderBy: (log, { asc }) => [asc(log.date), asc(log.odometer)] + }), + db.query.maintenanceLogTable.findMany({ + columns: { vehicleId: true, cost: true, odometer: true, date: true, serviceCenter: true } + }), + db.query.insuranceTable.findMany({ + columns: { vehicleId: true, endDate: true, startDate: true, cost: true } + }), + db.query.pollutionCertificateTable.findMany({ + columns: { vehicleId: true, expiryDate: true } + }), + db.query.reminderTable.findMany({ + columns: { + id: true, + vehicleId: true, + type: true, + note: true, + dueDate: true, + isCompleted: true + } + }) + ]); + + return { vehicles, fuelLogs, maintenanceLogs, insurances, pollutionCerts, reminders }; } -export async function getDashboardSummary(): Promise { +type RawData = Awaited>; + +function statusFromExpiry( + expiry: Date | undefined, + today: Date, + soonCutoff: Date +): VehicleSummary['puccStatus'] { + if (!expiry) return 'not_available'; + if (expiry < today) return 'expired'; + if (expiry <= soonCutoff) return 'expiring_soon'; + return 'valid'; +} + +function buildVehicleSummaries(raw: RawData): VehicleSummary[] { const today = new Date(); - const thirtyDaysFromNow = new Date(today.getTime() + EXPIRING_SOON_DAYS * 24 * 60 * 60 * 1000); + const soonCutoff = new Date(today.getTime() + EXPIRING_SOON_DAYS * 24 * 60 * 60 * 1000); - const [ - vehicles, - fuelLogs, - maintenanceLogs, - insurances, - pollutionCerts, - reminders, - allInsurancesForExpense - ] = await Promise.all([ - db.query.vehicleTable.findMany({ - columns: { - id: true, - make: true, - model: true, - licensePlate: true, - image: true, - odometer: true - } - }), - db.query.fuelLogTable.findMany({ - columns: { - fuelAmount: true, - cost: true, - filled: true, - odometer: true, - date: true, - vehicleId: true - } - }), - db.query.maintenanceLogTable.findMany({ - columns: { cost: true, odometer: true, vehicleId: true } - }), - db.query.insuranceTable.findMany({ - columns: { vehicleId: true, endDate: true, cost: true } - }), - db.query.pollutionCertificateTable.findMany({ - columns: { vehicleId: true, expiryDate: true } - }), - db.query.reminderTable.findMany({ - columns: { - id: true, - vehicleId: true, - type: true, - note: true, - dueDate: true, - isCompleted: true - } - }), - db.query.insuranceTable.findMany({ - columns: { cost: true } - }) - ]); - - // Totals - const totalVehicles = vehicles.length; - const totalFuelUsed = fuelLogs.reduce((sum, l) => sum + (l.fuelAmount || 0), 0); - const totalFuelCost = fuelLogs.reduce((sum, l) => sum + (l.cost || 0), 0); - const totalMaintenanceCost = maintenanceLogs.reduce((sum, l) => sum + (l.cost || 0), 0); - const totalInsuranceCost = allInsurancesForExpense.reduce((sum, i) => sum + (i.cost || 0), 0); - const totalDistance = fuelLogs - .filter((l) => l.odometer && l.filled) - .reduce((sum, l, i, arr) => { - if (i === 0) return sum; - const prev = arr[i - 1]; - if (prev.odometer && l.odometer && l.odometer > prev.odometer) { - return sum + (l.odometer - prev.odometer); - } - return sum; - }, 0); - const totalExpenses = totalFuelCost + totalMaintenanceCost + totalInsuranceCost; - - // PUC Status - const puccByVehicle = new Map(); - for (const cert of pollutionCerts) { - if (cert.expiryDate) { - const existing = puccByVehicle.get(cert.vehicleId); - const certDate = new Date(cert.expiryDate); - if (!existing || certDate > existing) { - puccByVehicle.set(cert.vehicleId, certDate); - } - } + const fuelLogsByVehicle = new Map(); + const fuelCostByVehicle = new Map(); + for (const log of raw.fuelLogs) { + if (!fuelLogsByVehicle.has(log.vehicleId)) fuelLogsByVehicle.set(log.vehicleId, []); + fuelLogsByVehicle.get(log.vehicleId)!.push(log); + fuelCostByVehicle.set(log.vehicleId, (fuelCostByVehicle.get(log.vehicleId) || 0) + log.cost); } - let puccValid = 0; - let puccExpiringSoon = 0; - let puccExpired = 0; - let puccNotAvailable = 0; - - for (const vehicle of vehicles) { - const expiryDate = puccByVehicle.get(vehicle.id); - if (!expiryDate) { - puccNotAvailable++; - } else if (expiryDate < today) { - puccExpired++; - } else if (expiryDate <= thirtyDaysFromNow) { - puccExpiringSoon++; - } else { - puccValid++; - } + const maintenanceCostByVehicle = new Map(); + for (const log of raw.maintenanceLogs) { + maintenanceCostByVehicle.set( + log.vehicleId, + (maintenanceCostByVehicle.get(log.vehicleId) || 0) + log.cost + ); } - // Vehicle Health - const insuranceByVehicle = new Map(); - for (const ins of insurances) { + const insuranceCostByVehicle = new Map(); + const insuranceEndByVehicle = new Map(); + for (const ins of raw.insurances) { + insuranceCostByVehicle.set( + ins.vehicleId, + (insuranceCostByVehicle.get(ins.vehicleId) || 0) + ins.cost + ); if (ins.endDate) { - const existing = insuranceByVehicle.get(ins.vehicleId); - const insDate = new Date(ins.endDate); - if (!existing || insDate > existing) { - insuranceByVehicle.set(ins.vehicleId, insDate); - } + const endDate = new Date(ins.endDate); + const existing = insuranceEndByVehicle.get(ins.vehicleId); + if (!existing || endDate > existing) insuranceEndByVehicle.set(ins.vehicleId, endDate); } } - const remindersByVehicle = new Map(); - for (const r of reminders) { - if (!remindersByVehicle.has(r.vehicleId)) { - remindersByVehicle.set(r.vehicleId, []); - } + const puccByVehicle = new Map(); + for (const cert of raw.pollutionCerts) { + if (!cert.expiryDate) continue; + const expiryDate = new Date(cert.expiryDate); + const existing = puccByVehicle.get(cert.vehicleId); + if (!existing || expiryDate > existing) puccByVehicle.set(cert.vehicleId, expiryDate); + } + + const remindersByVehicle = new Map(); + for (const r of raw.reminders) { + if (!remindersByVehicle.has(r.vehicleId)) remindersByVehicle.set(r.vehicleId, []); remindersByVehicle.get(r.vehicleId)!.push(r); } - let healthGood = 0; - let healthAttention = 0; - let healthNeedsAction = 0; + return raw.vehicles.map((vehicle) => { + const vehicleFuelLogs: FuelLogInput[] = fuelLogsByVehicle.get(vehicle.id) || []; + const totalDistance = computeTotalDistance(vehicleFuelLogs); + const avgMileage = computeAverageMileage(vehicleFuelLogs); - for (const vehicle of vehicles) { - const insDate = insuranceByVehicle.get(vehicle.id); - const puccDate = puccByVehicle.get(vehicle.id); - const vehicleReminders = remindersByVehicle.get(vehicle.id) || []; + const totalFuelCost = fuelCostByVehicle.get(vehicle.id) || 0; + const totalMaintenanceCost = maintenanceCostByVehicle.get(vehicle.id) || 0; + const totalInsuranceCost = insuranceCostByVehicle.get(vehicle.id) || 0; + const totalExpenses = totalFuelCost + totalMaintenanceCost + totalInsuranceCost; - const hasExpiredPucc = puccDate && new Date(puccDate) < today; - const hasExpiredInsurance = insDate && new Date(insDate) < today; - const hasExpiringPucc = - puccDate && new Date(puccDate) <= thirtyDaysFromNow && new Date(puccDate) >= today; - const hasExpiringInsurance = - insDate && new Date(insDate) <= thirtyDaysFromNow && new Date(insDate) >= today; + const puccStatus = statusFromExpiry(puccByVehicle.get(vehicle.id), today, soonCutoff); + const insuranceStatus = statusFromExpiry( + insuranceEndByVehicle.get(vehicle.id), + today, + soonCutoff + ); + + const vehicleReminders = remindersByVehicle.get(vehicle.id) || []; const hasOverdueReminder = vehicleReminders.some( (r) => !r.isCompleted && r.dueDate && new Date(r.dueDate) < today ); @@ -182,23 +155,150 @@ export async function getDashboardSummary(): Promise { (r) => !r.isCompleted && r.dueDate && - new Date(r.dueDate) <= thirtyDaysFromNow && + new Date(r.dueDate) <= soonCutoff && new Date(r.dueDate) >= today ); - if (hasExpiredPucc || hasExpiredInsurance || hasOverdueReminder) { - healthNeedsAction++; - } else if (hasExpiringPucc || hasExpiringInsurance || hasUpcomingReminder) { - healthAttention++; + let healthStatus: VehicleSummary['healthStatus']; + if (puccStatus === 'expired' || insuranceStatus === 'expired' || hasOverdueReminder) { + healthStatus = 'needs_action'; + } else if ( + puccStatus === 'expiring_soon' || + insuranceStatus === 'expiring_soon' || + hasUpcomingReminder + ) { + healthStatus = 'attention'; } else { - healthGood++; + healthStatus = 'good'; } + + return { + id: vehicle.id, + make: vehicle.make, + model: vehicle.model, + licensePlate: vehicle.licensePlate, + image: vehicle.image, + odometer: vehicle.odometer || 0, + fuelType: vehicle.fuelType, + totalDistance, + totalFuelCost, + totalMaintenanceCost, + totalInsuranceCost, + totalExpenses, + avgMileage, + costPerDistance: + totalDistance > 0 ? parseFloat((totalExpenses / totalDistance).toFixed(2)) : null, + puccStatus, + insuranceStatus, + healthStatus + }; + }); +} + +function buildFleetSection( + vehicles: VehicleSummary[], + totalFuelUsed: number +): DashboardSummary['fleet'] { + const totalDistance = vehicles.reduce((sum, v) => sum + v.totalDistance, 0); + const totalExpenses = vehicles.reduce((sum, v) => sum + v.totalExpenses, 0); + return { + totalVehicles: vehicles.length, + totalDistance, + totalFuelUsed, + totalExpenses, + costPerDistance: + totalDistance > 0 ? parseFloat((totalExpenses / totalDistance).toFixed(2)) : null + }; +} + +function monthKey(date: Date): string { + return date.toISOString().slice(0, 7); +} + +function buildExpensesSection(raw: RawData): DashboardSummary['expenses'] { + const fuel = raw.fuelLogs.reduce((sum, l) => sum + l.cost, 0); + const maintenance = raw.maintenanceLogs.reduce((sum, l) => sum + l.cost, 0); + const insurance = raw.insurances.reduce((sum, i) => sum + i.cost, 0); + + const months: string[] = []; + const anchor = new Date(); + for (let i = MONTHLY_TREND_MONTHS - 1; i >= 0; i--) { + months.push(monthKey(new Date(anchor.getFullYear(), anchor.getMonth() - i, 1))); } - // Upcoming Reminders - const vehicleMap = new Map(vehicles.map((v) => [v.id, v])); + const byMonth = new Map( + months.map((month) => [month, { month, fuel: 0, maintenance: 0, insurance: 0, total: 0 }]) + ); - const upcomingReminders = reminders + for (const log of raw.fuelLogs) { + if (!log.date) continue; + const point = byMonth.get(monthKey(new Date(log.date))); + if (point) point.fuel += log.cost; + } + for (const log of raw.maintenanceLogs) { + if (!log.date) continue; + const point = byMonth.get(monthKey(new Date(log.date))); + if (point) point.maintenance += log.cost; + } + for (const ins of raw.insurances) { + if (!ins.startDate) continue; + const point = byMonth.get(monthKey(new Date(ins.startDate))); + if (point) point.insurance += ins.cost; + } + + const monthlyTrend = months.map((month) => { + const point = byMonth.get(month)!; + point.total = point.fuel + point.maintenance + point.insurance; + return point; + }); + + return { + breakdown: { fuel, maintenance, insurance }, + monthlyTrend + }; +} + +function buildFuelSection(raw: RawData): DashboardSummary['fuel'] { + const fuelByDate = new Map(); + for (const log of raw.fuelLogs) { + if (!log.date || !log.fuelAmount) continue; + const dateKey = new Date(log.date).toISOString().slice(0, 10); + fuelByDate.set(dateKey, (fuelByDate.get(dateKey) || 0) + log.fuelAmount); + } + const dailyTrend = Array.from(fuelByDate.entries()) + .map(([date, fuelAmount]) => ({ date, fuelAmount })) + .sort((a, b) => a.date.localeCompare(b.date)) + .slice(-FUEL_TREND_MAX_POINTS); + + return { dailyTrend }; +} + +function bucketFromStatuses(statuses: VehicleSummary['puccStatus'][]): StatusBucket { + const bucket: StatusBucket = { valid: 0, expiringSoon: 0, expired: 0, notAvailable: 0 }; + for (const status of statuses) { + if (status === 'valid') bucket.valid++; + else if (status === 'expiring_soon') bucket.expiringSoon++; + else if (status === 'expired') bucket.expired++; + else bucket.notAvailable++; + } + return bucket; +} + +function buildComplianceSection( + raw: RawData, + vehicles: VehicleSummary[] +): DashboardSummary['compliance'] { + const today = new Date(); + const vehicleMap = new Map(raw.vehicles.map((v) => [v.id, v])); + + const vehicleHealth = { good: 0, attention: 0, needsAction: 0 }; + for (const v of vehicles) { + if (v.healthStatus === 'good') vehicleHealth.good++; + else if (v.healthStatus === 'attention') vehicleHealth.attention++; + else vehicleHealth.needsAction++; + } + + const upcomingReminders: UpcomingReminder[] = raw.reminders .filter((r) => !r.isCompleted && r.dueDate) .map((r) => { const dueDate = new Date(r.dueDate!); @@ -216,29 +316,59 @@ export async function getDashboardSummary(): Promise { }) .filter((r) => r.daysUntilDue >= 0) .sort((a, b) => a.daysUntilDue - b.daysUntilDue) - .slice(0, 10); + .slice(0, UPCOMING_REMINDERS_MAX); return { - totalVehicles, - totalFuelUsed, - totalDistance, - totalExpenses, - expenseBreakdown: { - fuel: totalFuelCost, - maintenance: totalMaintenanceCost, - insurance: totalInsuranceCost - }, - puccStatus: { - valid: puccValid, - expiringSoon: puccExpiringSoon, - expired: puccExpired, - notAvailable: puccNotAvailable - }, - vehicleHealth: { - good: healthGood, - attention: healthAttention, - needsAction: healthNeedsAction - }, + pucc: bucketFromStatuses(vehicles.map((v) => v.puccStatus)), + insurance: bucketFromStatuses(vehicles.map((v) => v.insuranceStatus)), + vehicleHealth, upcomingReminders }; } + +function buildActivitySection(raw: RawData): ActivityEntry[] { + const vehicleMap = new Map(raw.vehicles.map((v) => [v.id, v])); + const vehicleName = (vehicleId: string) => { + const vehicle = vehicleMap.get(vehicleId); + return vehicle ? `${vehicle.make} ${vehicle.model}` : 'Unknown'; + }; + + const fuelEntries: ActivityEntry[] = raw.fuelLogs.map((l, i) => ({ + id: `fuel-${l.vehicleId}-${i}`, + type: 'fuel', + vehicleId: l.vehicleId, + vehicleName: vehicleName(l.vehicleId), + date: l.date, + description: l.fuelAmount ? `${l.fuelAmount.toFixed(1)}L fill-up` : 'Fuel log', + cost: l.cost + })); + + const maintenanceEntries: ActivityEntry[] = raw.maintenanceLogs.map((l, i) => ({ + id: `maintenance-${l.vehicleId}-${i}`, + type: 'maintenance', + vehicleId: l.vehicleId, + vehicleName: vehicleName(l.vehicleId), + date: l.date, + description: l.serviceCenter, + cost: l.cost + })); + + return [...fuelEntries, ...maintenanceEntries] + .sort((a, b) => b.date.localeCompare(a.date)) + .slice(0, ACTIVITY_MAX_ENTRIES); +} + +export async function getDashboardSummary(): Promise { + const raw = await fetchRawData(); + const vehicles = buildVehicleSummaries(raw); + const totalFuelUsed = raw.fuelLogs.reduce((sum, l) => sum + (l.fuelAmount || 0), 0); + + return { + fleet: buildFleetSection(vehicles, totalFuelUsed), + expenses: buildExpensesSection(raw), + fuel: buildFuelSection(raw), + compliance: buildComplianceSection(raw, vehicles), + vehicles, + activity: buildActivitySection(raw) + }; +} diff --git a/src/server/services/dashboardWidgetService.ts b/src/server/services/dashboardWidgetService.ts new file mode 100644 index 00000000..30a22e84 --- /dev/null +++ b/src/server/services/dashboardWidgetService.ts @@ -0,0 +1,24 @@ +import { getAppConfigByKey, updateAppConfig } from './configService'; +import { + DEFAULT_WIDGET_LAYOUT, + widgetLayoutSchema, + type WidgetLayoutItem +} from '$lib/domain/dashboard'; + +const LAYOUT_CONFIG_KEY = 'dashboardLayout'; + +export async function getWidgetLayout(): Promise { + try { + const row = await getAppConfigByKey(LAYOUT_CONFIG_KEY); + const parsed = widgetLayoutSchema.safeParse(JSON.parse(row.value)); + // Falls back to defaults for layouts saved before colSpan/rowSpan became numeric (1-12). + return parsed.success ? parsed.data : DEFAULT_WIDGET_LAYOUT; + } catch { + return DEFAULT_WIDGET_LAYOUT; + } +} + +export async function saveWidgetLayout(layout: WidgetLayoutItem[]): Promise { + await updateAppConfig([{ key: LAYOUT_CONFIG_KEY, value: JSON.stringify(layout) }]); + return layout; +} From ba80e5c348eed3f26cb3a921d003ddf189d766d5 Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Fri, 31 Jul 2026 19:50:51 +0530 Subject: [PATCH 10/23] WIP --- i18n/messages/en.json | 74 +++++- .../components/app/FeatureRecordCard.svelte | 10 + src/lib/components/app/FeatureTabShell.svelte | 4 +- .../widgets/VehicleQuickListWidget.svelte | 6 +- .../feature/fuel/FuelLogForm.svelte | 17 +- .../feature/fuel/FuelLogList.svelte | 50 ++-- .../feature/fuel/fuel-log-import.svelte.ts | 12 +- .../feature/insurance/InsuranceForm.svelte | 14 +- .../feature/insurance/InsuranceList.svelte | 22 +- .../maintenance/MaintenanceForm.svelte | 14 +- .../maintenance/MaintenanceLogList.svelte | 45 +++- .../maintenance/MaintenenceLogTab.svelte | 8 +- .../overview/FleetChartPlaceholder.svelte | 30 +++ .../feature/overview/MileageChart.svelte | 6 +- .../pollution/PollutionCertificateForm.svelte | 14 +- .../pollution/PollutionCertificateList.svelte | 48 ++-- .../feature/pollution/PollutionTab.svelte | 13 - .../reminder/ReminderContextMenu.svelte | 2 +- .../feature/reminder/ReminderForm.svelte | 12 +- .../feature/reminder/ReminderList.svelte | 201 +++++++-------- .../feature/reminder/ReminderTab.svelte | 15 -- .../feature/shared/FeaturePageShell.svelte | 53 ++++ .../feature/shared/VehicleCell.svelte | 16 ++ .../shared/VehicleScopeSelector.svelte | 73 ++++++ .../feature/shared/VehicleSelectField.svelte | 44 ++++ .../feature/vehicle/VehicleCard.svelte | 6 +- .../vehicle/VehicleHubFeatureLinks.svelte | 127 ++++++++++ .../feature/vehicle/VehicleHubHero.svelte | 108 ++++++++ .../feature/vehicle/VehicleHubStats.svelte | 48 ++++ .../feature/vehicle/VehicleList.svelte | 46 ---- src/lib/components/layout/AppSidebar.svelte | 23 +- .../layout/DashboardFeaturePage.svelte | 35 --- src/lib/components/layout/Header.svelte | 138 ----------- .../components/layout/Notifications.svelte | 26 +- .../components/layout/VehicleSwitcher.svelte | 115 --------- src/lib/composables/sheet-form.svelte.ts | 5 +- src/lib/domain/fuel.ts | 3 + src/lib/domain/insurance.ts | 3 + src/lib/domain/maintenance.ts | 3 + src/lib/domain/pucc.ts | 26 ++ src/lib/domain/reminder.ts | 3 + src/lib/scope/vehicle-scope.svelte.ts | 53 ++++ src/lib/services/notification.service.ts | 12 + src/lib/stores/entity-store.svelte.ts | 59 +++-- src/lib/stores/fuel-log.svelte.ts | 7 +- src/lib/stores/insurance.svelte.ts | 7 +- src/lib/stores/maintenance.svelte.ts | 8 +- src/lib/stores/notification.svelte.ts | 29 ++- src/lib/stores/pucc.svelte.ts | 7 +- src/lib/stores/reminder.svelte.ts | 5 +- src/lib/stores/vehicle.svelte.ts | 13 +- src/routes/(app)/+layout.svelte | 6 - src/routes/(app)/expenses/+page.server.ts | 6 + src/routes/(app)/expenses/+page.svelte | 79 ------ src/routes/(app)/fuel/+page.svelte | 113 ++++----- src/routes/(app)/insurance/+page.svelte | 35 +-- src/routes/(app)/maintenance/+page.svelte | 147 +++++------ src/routes/(app)/pollution/+page.svelte | 233 ++++++------------ src/routes/(app)/reminders/+page.svelte | 229 ++++------------- src/routes/(app)/reports/+page.svelte | 223 +++++++++++++---- src/routes/(app)/vehicles/+page.svelte | 3 +- .../(app)/vehicles/[id]/+page.server.ts | 16 ++ src/routes/(app)/vehicles/[id]/+page.svelte | 31 +++ src/routes/api/fuel-logs/+server.ts | 11 + src/routes/api/insurance/+server.ts | 11 + src/routes/api/maintenance-logs/+server.ts | 11 + src/routes/api/notifications/+server.ts | 10 + src/routes/api/pucc/+server.ts | 11 + src/routes/api/pucc/status/+server.ts | 61 ----- src/routes/api/reminders/+server.ts | 10 +- src/server/services/fuelLogService.ts | 68 ++++- src/server/services/insuranceService.ts | 28 ++- src/server/services/maintenanceLogService.ts | 33 ++- src/server/services/notificationService.ts | 23 +- .../services/pollutionCertificateService.ts | 33 ++- src/server/services/reminderService.ts | 29 ++- 76 files changed, 1793 insertions(+), 1385 deletions(-) create mode 100644 src/lib/components/feature/overview/FleetChartPlaceholder.svelte delete mode 100644 src/lib/components/feature/pollution/PollutionTab.svelte delete mode 100644 src/lib/components/feature/reminder/ReminderTab.svelte create mode 100644 src/lib/components/feature/shared/FeaturePageShell.svelte create mode 100644 src/lib/components/feature/shared/VehicleCell.svelte create mode 100644 src/lib/components/feature/shared/VehicleScopeSelector.svelte create mode 100644 src/lib/components/feature/shared/VehicleSelectField.svelte create mode 100644 src/lib/components/feature/vehicle/VehicleHubFeatureLinks.svelte create mode 100644 src/lib/components/feature/vehicle/VehicleHubHero.svelte create mode 100644 src/lib/components/feature/vehicle/VehicleHubStats.svelte delete mode 100644 src/lib/components/feature/vehicle/VehicleList.svelte delete mode 100644 src/lib/components/layout/DashboardFeaturePage.svelte delete mode 100644 src/lib/components/layout/Header.svelte delete mode 100644 src/lib/components/layout/VehicleSwitcher.svelte create mode 100644 src/lib/scope/vehicle-scope.svelte.ts create mode 100644 src/routes/(app)/expenses/+page.server.ts delete mode 100644 src/routes/(app)/expenses/+page.svelte create mode 100644 src/routes/(app)/vehicles/[id]/+page.server.ts create mode 100644 src/routes/(app)/vehicles/[id]/+page.svelte create mode 100644 src/routes/api/fuel-logs/+server.ts create mode 100644 src/routes/api/insurance/+server.ts create mode 100644 src/routes/api/maintenance-logs/+server.ts create mode 100644 src/routes/api/notifications/+server.ts create mode 100644 src/routes/api/pucc/+server.ts delete mode 100644 src/routes/api/pucc/status/+server.ts diff --git a/i18n/messages/en.json b/i18n/messages/en.json index 8ccfa7ee..3cb19559 100644 --- a/i18n/messages/en.json +++ b/i18n/messages/en.json @@ -5,6 +5,10 @@ "app_title": "Your Garage", "app_new_update_available": "New Update is available. Reloading..!", "app_add_vehicle": "Add Vehicle", + "vehicle_scope_all": "All vehicles", + "vehicle_scope_manage": "Manage vehicles", + "form_vehicle_label": "Vehicle", + "form_vehicle_desc": "Which vehicle this record belongs to", "app_empty_select_message": "Select a vehicle to view its details", "app_empty_select_hint": "Choose one from the garage above to load its dashboard.", "demo_banner": "This is a demo instance. Data will be reset periodically and is not saved permanently. Please avoid adding any personal info.", @@ -200,7 +204,6 @@ "fuel_toast_error_prefix": "Error while saving : ", "notifications_title": "Notifications", "notifications_new": "new", - "notifications_select_vehicle_hint": "Select a vehicle to load reminders and alerts.", "notifications_syncing": "Syncing latest data...", "notifications_caught_up": "You're all caught up.", "notifications_section_reminders": "Reminders", @@ -343,7 +346,6 @@ "pollution_menu_edit": "Edit", "pollution_menu_delete": "Delete", "pollution_menu_sheet_title": "Update Pollution Certificate", - "pollution_tab_title": "Pollution Certificate Details", "pollution_add_action": "Add Pollution Certificate", "pollution_delete_success": "Deleted PUCC.", "pollution_delete_error": "Some error occurred while deleting PUCC.", @@ -380,7 +382,6 @@ "reminder_menu_delete": "Delete", "reminder_menu_open": "Open menu", "reminder_menu_sheet_title": "Update Reminder", - "reminder_tab_title": "Reminders", "reminder_add_action": "Add Reminder", "reminder_delete_success": "Deleted reminder.", "reminder_delete_error": "Some error occurred while deleting reminder.", @@ -690,10 +691,6 @@ "notif_status_read": "Read", "notif_status_unread": "Unread", "notif_due_prefix": "Due: {date}", - "header_home_aria": "Go to home", - "header_settings_aria": "Open settings", - "header_account_aria": "Account menu", - "header_account_title": "Account", "notif_save_provider_failed": "Failed to save provider", "notif_update_provider_failed": "Failed to update provider", "notif_send_all_failed": "Failed to send notifications", @@ -705,5 +702,66 @@ "notif_test_send_desc": "Send a test notification using {name}", "notif_cron_every_n_minutes": "Every {n} minutes", "notif_cron_hourly_at_minute": "Every hour at minute {n}", - "notif_cron_daily_at": "Daily at {time}" + "notif_cron_daily_at": "Daily at {time}", + "vehicle_hub_back_to_vehicles": "Back to Vehicles", + "vehicle_hub_manage_title": "Manage this vehicle", + "vehicle_hub_stat_odometer": "Odometer", + "vehicle_hub_stat_mileage": "Overall Mileage", + "vehicle_hub_stat_fuel_logs": "Fuel Logs", + "vehicle_hub_stat_maintenance_logs": "Maintenance Logs", + "col_vehicle": "Vehicle", + "overview_chart_pick_vehicle": "Select a vehicle to see this chart", + "fuel_page_title": "Fuel Tracking", + "fuel_page_description": "Monitor fuel consumption and costs", + "fuel_stat_used": "Fuel Used", + "fuel_stat_spent": "Total Spent", + "fuel_stat_avg_mileage": "Avg Mileage", + "fuel_stat_entries": "Total Entries", + "maintenance_page_title": "Maintenance", + "maintenance_page_description": "Track service history and upcoming maintenance", + "maintenance_stat_last_service": "Last Service", + "maintenance_stat_next_service": "Next Service", + "maintenance_stat_odometer": "Odometer", + "maintenance_tab_overview": "Overview", + "maintenance_tab_history": "Service History", + "maintenance_timeline_title": "Maintenance Timeline", + "insurance_page_title": "Insurance", + "insurance_page_description": "Manage insurance records", + "pollution_page_title": "Pollution (PUC)", + "pollution_page_description": "Track pollution certificate compliance", + "pollution_filter_all": "All", + "pollution_filter_valid": "Valid", + "pollution_filter_expiring_soon": "Expiring Soon", + "pollution_filter_expired": "Expired", + "pollution_stat_total": "Total", + "pollution_stat_valid": "Valid", + "pollution_stat_expiring_soon": "Expiring Soon", + "pollution_stat_expired": "Expired", + "pollution_cta_heading": "Keep your vehicles compliant", + "pollution_cta_description": "Keep your PUC certificates updated to avoid penalties and ensure a cleaner environment.", + "reminder_page_title": "Reminders", + "reminder_page_description": "Stay on top of upcoming service, insurance and PUC dates", + "reminder_filter_all": "All", + "reminder_filter_service": "Service", + "reminder_filter_puc": "PUC", + "reminder_filter_insurance": "Insurance", + "reminder_filter_others": "Others", + "reminder_section_upcoming": "Upcoming", + "reminder_section_completed": "Completed", + "reports_page_title": "Reports", + "reports_page_description": "Costs and exports for your fleet", + "reports_section_costs": "Costs", + "reports_section_exports": "Exports", + "reports_stat_fuel_costs": "Fuel Costs", + "reports_stat_maintenance_costs": "Maintenance Costs", + "reports_stat_insurance_costs": "Insurance Costs", + "reports_chart_breakdown_title": "Expense Breakdown", + "reports_chart_trend_title": "Monthly Expense Trend", + "reports_chart_trend_unavailable": "Monthly trend is only available for all vehicles combined", + "reports_export_maintenance_title": "Maintenance History", + "reports_export_maintenance_description": "Export maintenance history as PDF", + "reports_export_maintenance_hint": "Select a vehicle to export its maintenance history", + "reports_export_data_title": "Full Data Export", + "reports_export_data_description": "Export all fleet data as JSON", + "reports_export_action": "Export" } diff --git a/src/lib/components/app/FeatureRecordCard.svelte b/src/lib/components/app/FeatureRecordCard.svelte index 82c6468a..57d59f87 100644 --- a/src/lib/components/app/FeatureRecordCard.svelte +++ b/src/lib/components/app/FeatureRecordCard.svelte @@ -8,6 +8,8 @@ titleIcon: Component<{ class?: string }>; titleClass?: string; class?: string; + /** Shown as a small pill next to the title — used for the vehicle badge in fleet scope. */ + subtitle?: string; headerExtras?: Snippet; actions?: Snippet; children?: Snippet; @@ -19,6 +21,7 @@ titleIcon: TitleIcon, titleClass = '', class: className = '', + subtitle, headerExtras, actions, children @@ -32,6 +35,13 @@ {title}
    + {#if subtitle} + + {subtitle} + + {/if} {@render headerExtras?.()}
    {@render actions?.()} diff --git a/src/lib/components/app/FeatureTabShell.svelte b/src/lib/components/app/FeatureTabShell.svelte index 52d95f17..7e2f5c15 100644 --- a/src/lib/components/app/FeatureTabShell.svelte +++ b/src/lib/components/app/FeatureTabShell.svelte @@ -2,7 +2,6 @@ import type { Component } from 'svelte'; import TabContainer from '$appui/TabContainer.svelte'; import { sheetStore } from '$lib/stores/sheet.svelte'; - import { vehicleStore } from '$lib/stores/vehicle.svelte'; type SheetDataResolver = () => unknown; type AnyComponent = Component; @@ -57,8 +56,7 @@ ) : null} {exportAction} - exportActionDisabled={exportActionDisabled || !vehicleStore.selectedId} - addActionDisabled={!vehicleStore.selectedId} + {exportActionDisabled} > diff --git a/src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte b/src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte index 1f94669a..0768a457 100644 --- a/src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte +++ b/src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte @@ -1,6 +1,7 @@ {#if vehicleStore.vehicles && vehicleStore.vehicles.length > 0} @@ -8,8 +9,9 @@ {#each vehicleStore.vehicles.slice(0, 5) as vehicle (vehicle.id)} (vehicleStore.selectedId = vehicle.id!)} - isSelected={vehicleStore.selectedId === vehicle.id} + onclick={() => { + if (vehicle.id) goto(`/vehicles/${vehicle.id}`); + }} actions={false} /> {/each} diff --git a/src/lib/components/feature/fuel/FuelLogForm.svelte b/src/lib/components/feature/fuel/FuelLogForm.svelte index b8484d87..ff4fe7e7 100644 --- a/src/lib/components/feature/fuel/FuelLogForm.svelte +++ b/src/lib/components/feature/fuel/FuelLogForm.svelte @@ -26,6 +26,9 @@ import { toast } from 'svelte-sonner'; import { sheetStore } from '$stores/sheet.svelte'; import { vehicleStore } from '$stores/vehicle.svelte'; + import { page } from '$app/state'; + import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; + import VehicleSelectField from '$feature/shared/VehicleSelectField.svelte'; import { form_date, form_date_desc, @@ -76,7 +79,7 @@ if (res.status == 'OK') { toast.success(data ? fuel_toast_updated() : fuel_toast_saved()); sf.attachment = undefined; - sheetStore.closeSheet(() => fuelLogStore.refreshFuelLogs()); + sheetStore.closeSheet(() => fuelLogStore.reloadFuelLogs()); } else { toast.error(`${fuel_toast_error_prefix()}${res.error}`); } @@ -89,8 +92,11 @@ const { form, enhance } = sf; const formData: any = sf.formData; + const scope = $derived(readVehicleScope(page.url, vehicleStore.vehicles)); + const suppliedVehicleId = $derived(data?.vehicleId || (scope.isFleet ? '' : scope.vehicleId)); + const selectedVehicle = $derived( - vehicleStore.vehicles?.find((v) => v.id === vehicleStore.selectedId) + vehicleStore.vehicles?.find((v) => v.id === $formData.vehicleId) ); const volumeLabel = $derived( selectedVehicle?.fuelType === 'electric' ? form_volume_energy() : form_volume_fuel() @@ -174,7 +180,7 @@ } else { formData.set({ id: null, - vehicleId: vehicleStore.selectedId || '', + vehicleId: suppliedVehicleId || '', date: formatDate(new Date()), odometer: null, filled: true, @@ -186,12 +192,15 @@ attachment: null }); } - sf.setVehicleId(); + if (suppliedVehicleId) sf.setVehicleId(suppliedVehicleId); });
    e.preventDefault()}>
    + {#if !suppliedVehicleId} + + {/if} {#snippet children({ props })} diff --git a/src/lib/components/feature/fuel/FuelLogList.svelte b/src/lib/components/feature/fuel/FuelLogList.svelte index eec505c8..43176c47 100644 --- a/src/lib/components/feature/fuel/FuelLogList.svelte +++ b/src/lib/components/feature/fuel/FuelLogList.svelte @@ -28,9 +28,13 @@ import CostCell from '$lib/components/feature/shared/CostCell.svelte'; import NotesCell from '$lib/components/feature/shared/NotesCell.svelte'; import AttachmentCell from '$lib/components/feature/shared/AttachmentCell.svelte'; + import VehicleCell from '$lib/components/feature/shared/VehicleCell.svelte'; + import Car from '@lucide/svelte/icons/car'; import { fuelLogStore } from '$stores/fuel-log.svelte'; import { vehicleStore } from '$stores/vehicle.svelte'; + import { page } from '$app/state'; + import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; import { col_date, col_odometer, @@ -40,6 +44,7 @@ col_mileage, col_notes, col_attachment, + col_vehicle, fuel_volume_label_energy, fuel_volume_label_fuel, fuel_empty_list, @@ -47,16 +52,35 @@ common_no } from '$lib/paraglide/messages/_index.js'; - const selectedVehicle = $derived( - vehicleStore.vehicles?.find((v) => v.id === vehicleStore.selectedId) - ); + const scope = $derived(readVehicleScope(page.url, vehicleStore.vehicles)); + const selectedVehicle = $derived(scope.vehicle); const volumeLabel = $derived( selectedVehicle?.fuelType === 'electric' ? fuel_volume_label_energy() : fuel_volume_label_fuel() ); - let lastVehicleId: string | undefined; + let lastScopeKey: string | undefined; - const columns: ColumnDef[] = [ + const columns = $derived[]>([ + ...(scope.isFleet + ? [ + { + id: 'vehicle', + header: () => + renderComponent(LabelWithIcon, { + icon: Car, + iconClass: 'h-4 w-4', + label: col_vehicle(), + style: 'justify-start' + }), + cell: ({ row }: { row: { original: FuelLog } }) => + renderComponent(VehicleCell, { + make: row.original.vehicleMake, + model: row.original.vehicleModel, + plate: row.original.vehiclePlate + }) + } satisfies ColumnDef + ] + : []), { accessorKey: 'date', header: () => @@ -182,20 +206,18 @@ renderComponent(FuelLogContextMenu, { fuelLog: row.original, onaction: () => { - fuelLogStore.refreshFuelLogs(); + fuelLogStore.reloadFuelLogs(); } }) } - ]; + ]); $effect(() => { - const vehicleId = vehicleStore.selectedId; - if (vehicleId && vehicleId !== lastVehicleId) { - lastVehicleId = vehicleId; - fuelLogStore.refreshFuelLogs(); - } - if (!vehicleId) { - lastVehicleId = undefined; + const vehicleId = scope.vehicleId; + const scopeKey = vehicleId ?? '__fleet__'; + if (scopeKey !== lastScopeKey) { + lastScopeKey = scopeKey; + fuelLogStore.refreshFuelLogs(vehicleId); } }); diff --git a/src/lib/components/feature/fuel/fuel-log-import.svelte.ts b/src/lib/components/feature/fuel/fuel-log-import.svelte.ts index 7dbe99f7..cece2780 100644 --- a/src/lib/components/feature/fuel/fuel-log-import.svelte.ts +++ b/src/lib/components/feature/fuel/fuel-log-import.svelte.ts @@ -2,6 +2,8 @@ import { parseCsvPreview, importFuelLogsFromCsv } from '$lib/helper/csv.helper'; import { fuelLogStore } from '$stores/fuel-log.svelte'; import { sheetStore } from '$stores/sheet.svelte'; import { vehicleStore } from '$stores/vehicle.svelte'; +import { page } from '$app/state'; +import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; import { toast } from 'svelte-sonner'; import { parseWithFormat } from '$lib/helper/format.helper'; import { configStore } from '$lib/stores/config.svelte'; @@ -100,8 +102,10 @@ export class FuelLogImportState { parseError = $state(); processing = $state<'idle' | 'parsing' | 'importing'>('idle'); + scopedVehicleId = $derived(readVehicleScope(page.url, vehicleStore.vehicles).vehicleId); + selectedVehicle = $derived( - vehicleStore.vehicles?.find((vehicle) => vehicle.id === vehicleStore.selectedId) + vehicleStore.vehicles?.find((vehicle) => vehicle.id === this.scopedVehicleId) ); selectedVehicleLabel = $derived( @@ -211,7 +215,7 @@ export class FuelLogImportState { }; handleImport = async () => { - if (!this.canImport || !vehicleStore.selectedId) return; + if (!this.canImport || !this.scopedVehicleId) return; this.processing = 'importing'; try { const rowsForImport = this.csvRows.map((row) => { @@ -227,13 +231,13 @@ export class FuelLogImportState { const result = await importFuelLogsFromCsv( rowsForImport, - vehicleStore.selectedId, + this.scopedVehicleId, this.dateFormat ); if (result.failed === 0) { toast.success(m.fuel_import_success({ count: result.imported })); - sheetStore.closeSheet(() => fuelLogStore.refreshFuelLogs()); + sheetStore.closeSheet(() => fuelLogStore.reloadFuelLogs()); } else { const message = m.fuel_import_failed_count({ imported: result.imported, diff --git a/src/lib/components/feature/insurance/InsuranceForm.svelte b/src/lib/components/feature/insurance/InsuranceForm.svelte index 85a3dfd3..22ec14e0 100644 --- a/src/lib/components/feature/insurance/InsuranceForm.svelte +++ b/src/lib/components/feature/insurance/InsuranceForm.svelte @@ -22,8 +22,12 @@ import Building2 from '@lucide/svelte/icons/building-2'; import SubmitButton from '$appui/SubmitButton.svelte'; import RecurrenceFields from '$lib/components/feature/shared/RecurrenceFields.svelte'; + import VehicleSelectField from '$feature/shared/VehicleSelectField.svelte'; import { toast } from 'svelte-sonner'; import { sheetStore } from '$stores/sheet.svelte'; + import { page } from '$app/state'; + import { vehicleStore } from '$stores/vehicle.svelte'; + import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; let { data } = $props(); @@ -52,7 +56,7 @@ if (res.status == 'OK') { toast.success(m[data ? 'insurance_toast_updated' : 'insurance_toast_saved']()); sf.attachment = undefined; - sheetStore.closeSheet(() => insuranceStore.refreshInsurances()); + sheetStore.closeSheet(() => insuranceStore.reloadInsurances()); } else { toast.error(m.insurance_toast_error_prefix() + res.error); } @@ -71,6 +75,9 @@ const { form, enhance } = sf; const formData: any = sf.formData; + const scope = $derived(readVehicleScope(page.url, vehicleStore.vehicles)); + const suppliedVehicleId = $derived(data?.vehicleId || (scope.isFleet ? '' : scope.vehicleId)); + $effect(() => { sf.resetAttachment(); if (data) { @@ -81,7 +88,7 @@ attachment: null }); } - sf.setVehicleId(); + if (suppliedVehicleId) sf.setVehicleId(suppliedVehicleId); }); $effect(() => { @@ -95,6 +102,9 @@ e.preventDefault()}>
    + {#if !suppliedVehicleId} + + {/if} { const baseDate = ins.endDate ?? ins.startDate; @@ -31,13 +34,11 @@ }; $effect(() => { - const vehicleId = vehicleStore.selectedId; - if (vehicleId && vehicleId !== lastVehicleId) { - lastVehicleId = vehicleId; - insuranceStore.refreshInsurances(); - } - if (!vehicleId) { - lastVehicleId = undefined; + const vehicleId = scope.vehicleId; + const scopeKey = vehicleId ?? '__fleet__'; + if (scopeKey !== lastScopeKey) { + lastScopeKey = scopeKey; + insuranceStore.refreshInsurances(vehicleId); } }); @@ -59,12 +60,15 @@ title={ins.provider} titleIcon={Shield} titleClass="text-blue-400" + subtitle={scope.isFleet && (ins.vehicleMake || ins.vehicleModel) + ? `${ins.vehicleMake ?? ''} ${ins.vehicleModel ?? ''}`.trim() + : undefined} > {#snippet actions()} { - insuranceStore.refreshInsurances(); + insuranceStore.reloadInsurances(); }} /> {/snippet} diff --git a/src/lib/components/feature/maintenance/MaintenanceForm.svelte b/src/lib/components/feature/maintenance/MaintenanceForm.svelte index 833f567c..ca9820e1 100644 --- a/src/lib/components/feature/maintenance/MaintenanceForm.svelte +++ b/src/lib/components/feature/maintenance/MaintenanceForm.svelte @@ -18,6 +18,10 @@ import { sheetStore } from '$stores/sheet.svelte'; import { FileDropZone, AutocompleteInput } from '$lib/components/app'; import { getServiceCenterSuggestions } from '$lib/services/autocomplete.service'; + import { page } from '$app/state'; + import { vehicleStore } from '$stores/vehicle.svelte'; + import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; + import VehicleSelectField from '$feature/shared/VehicleSelectField.svelte'; import * as m from '$lib/paraglide/messages'; let { data } = $props(); @@ -42,7 +46,7 @@ if (res.status == 'OK') { toast.success(data ? m.maintenance_toast_updated() : m.maintenance_toast_saved()); sf.attachment = undefined; - sheetStore.closeSheet(maintenanceStore.refreshMaintenanceLogs); + sheetStore.closeSheet(maintenanceStore.reloadMaintenanceLogs); } else { toast.error(`${m.maintenance_toast_error_prefix()}${res.error}`); } @@ -57,12 +61,15 @@ const { form, enhance } = sf; const formData: any = sf.formData; + const scope = $derived(readVehicleScope(page.url, vehicleStore.vehicles)); + const suppliedVehicleId = $derived(data?.vehicleId || (scope.isFleet ? '' : scope.vehicleId)); + $effect(() => { sf.resetAttachment(); if (data) { formData.set({ ...data, date: formatDate(data.date), attachment: null }); } - sf.setVehicleId(); + if (suppliedVehicleId) sf.setVehicleId(suppliedVehicleId); }); $effect(() => { @@ -76,6 +83,9 @@ e.preventDefault()}>
    + {#if !suppliedVehicleId} + + {/if} diff --git a/src/lib/components/feature/maintenance/MaintenanceLogList.svelte b/src/lib/components/feature/maintenance/MaintenanceLogList.svelte index 95fcadec..3eb71a56 100644 --- a/src/lib/components/feature/maintenance/MaintenanceLogList.svelte +++ b/src/lib/components/feature/maintenance/MaintenanceLogList.svelte @@ -16,16 +16,41 @@ import type { MaintenanceLog } from '$lib/domain/maintenance'; import { maintenanceStore } from '$stores/maintenance.svelte'; import { vehicleStore } from '$stores/vehicle.svelte'; + import { page } from '$app/state'; + import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; import DateCell from '$lib/components/feature/shared/DateCell.svelte'; import OdometerCell from '$lib/components/feature/shared/OdometerCell.svelte'; import CostCell from '$lib/components/feature/shared/CostCell.svelte'; import NotesCell from '$lib/components/feature/shared/NotesCell.svelte'; import AttachmentCell from '$lib/components/feature/shared/AttachmentCell.svelte'; + import VehicleCell from '$lib/components/feature/shared/VehicleCell.svelte'; + import Car from '@lucide/svelte/icons/car'; import * as m from '$lib/paraglide/messages'; - let lastVehicleId: string | undefined; + let lastScopeKey: string | undefined; + const scope = $derived(readVehicleScope(page.url, vehicleStore.vehicles)); - const columns: ColumnDef[] = [ + const columns = $derived[]>([ + ...(scope.isFleet + ? [ + { + id: 'vehicle', + header: () => + renderComponent(LabelWithIcon, { + icon: Car, + iconClass: 'h-4 w-4', + label: m.col_vehicle(), + style: 'justify-start' + }), + cell: ({ row }: { row: { original: MaintenanceLog } }) => + renderComponent(VehicleCell, { + make: row.original.vehicleMake, + model: row.original.vehicleModel, + plate: row.original.vehiclePlate + }) + } satisfies ColumnDef + ] + : []), { accessorKey: 'date', header: () => @@ -100,20 +125,18 @@ renderComponent(MaintenanceContextMenu, { maintenanceLog: row.original, onaction: () => { - maintenanceStore.refreshMaintenanceLogs(); + maintenanceStore.reloadMaintenanceLogs(); } }) } - ]; + ]); $effect(() => { - const vehicleId = vehicleStore.selectedId; - if (vehicleId && vehicleId !== lastVehicleId) { - lastVehicleId = vehicleId; - maintenanceStore.refreshMaintenanceLogs(); - } - if (!vehicleId) { - lastVehicleId = undefined; + const vehicleId = scope.vehicleId; + const scopeKey = vehicleId ?? '__fleet__'; + if (scopeKey !== lastScopeKey) { + lastScopeKey = scopeKey; + maintenanceStore.refreshMaintenanceLogs(vehicleId); } }); diff --git a/src/lib/components/feature/maintenance/MaintenenceLogTab.svelte b/src/lib/components/feature/maintenance/MaintenenceLogTab.svelte index 28dafbe4..ff29d1f2 100644 --- a/src/lib/components/feature/maintenance/MaintenenceLogTab.svelte +++ b/src/lib/components/feature/maintenance/MaintenenceLogTab.svelte @@ -3,12 +3,16 @@ import MaintenanceForm from './MaintenanceForm.svelte'; import MaintenanceLogList from './MaintenanceLogList.svelte'; import { vehicleStore } from '$lib/stores/vehicle.svelte'; + import { page } from '$app/state'; + import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; import { exportMaintenanceLogsPdf } from '$lib/services/maintenance.service'; import * as m from '$lib/paraglide/messages'; + const scope = $derived(readVehicleScope(page.url, vehicleStore.vehicles)); + const handleExportPdf = async () => { - if (vehicleStore.selectedId) { - await exportMaintenanceLogsPdf(vehicleStore.selectedId); + if (scope.vehicleId) { + await exportMaintenanceLogsPdf(scope.vehicleId); } }; diff --git a/src/lib/components/feature/overview/FleetChartPlaceholder.svelte b/src/lib/components/feature/overview/FleetChartPlaceholder.svelte new file mode 100644 index 00000000..99f4746f --- /dev/null +++ b/src/lib/components/feature/overview/FleetChartPlaceholder.svelte @@ -0,0 +1,30 @@ + + +
    + {#if title} +
    + {title} +
    + {/if} +
    + +
    +
    diff --git a/src/lib/components/feature/overview/MileageChart.svelte b/src/lib/components/feature/overview/MileageChart.svelte index 8914fc55..fa6d3db3 100644 --- a/src/lib/components/feature/overview/MileageChart.svelte +++ b/src/lib/components/feature/overview/MileageChart.svelte @@ -4,11 +4,11 @@ import { fuelLogStore } from '$stores/fuel-log.svelte'; import AreaChart from './AreaChart.svelte'; import { vehicleStore } from '$stores/vehicle.svelte'; + import { page } from '$app/state'; + import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; import { overview_chart_mileage_label } from '$lib/paraglide/messages/_index.js'; - const selectedVehicle = $derived( - vehicleStore.vehicles?.find((vehicle: any) => vehicle.id === vehicleStore.selectedId) - ); + const selectedVehicle = $derived(readVehicleScope(page.url, vehicleStore.vehicles).vehicle); { sf.resetAttachment(); if (data) { @@ -77,7 +84,7 @@ attachment: null }); } - sf.setVehicleId(); + if (suppliedVehicleId) sf.setVehicleId(suppliedVehicleId); }); $effect(() => { @@ -91,6 +98,9 @@ e.preventDefault()}>
    + {#if !suppliedVehicleId} + + {/if} { - const baseDate = pucc.expiryDate ?? pucc.issueDate; - if (!baseDate) return null; - if (pucc.recurrenceType === 'no_end') return null; - if (pucc.recurrenceType === 'none') return baseDate; - return getNextDueDate(new Date(baseDate), pucc.recurrenceType, pucc.recurrenceInterval); - }; + interface Props { + /** Optional predicate to narrow what's rendered (e.g. page-level status filter tabs). */ + filter?: (pucc: PollutionCertificate) => boolean; + } - let lastVehicleId: string | undefined; + let { filter }: Props = $props(); + + let lastScopeKey: string | undefined; + const scope = $derived(readVehicleScope(page.url, vehicleStore.vehicles)); + + const certs = $derived( + filter ? (puccStore.pollutionCerts ?? []).filter(filter) : (puccStore.pollutionCerts ?? []) + ); $effect(() => { - const vehicleId = vehicleStore.selectedId; - if (vehicleId && vehicleId !== lastVehicleId) { - lastVehicleId = vehicleId; - puccStore.refreshPuccs(); - } - if (!vehicleId) { - lastVehicleId = undefined; + const vehicleId = scope.vehicleId; + const scopeKey = vehicleId ?? '__fleet__'; + if (scopeKey !== lastScopeKey) { + lastScopeKey = scopeKey; + puccStore.refreshPuccs(vehicleId); } }); @@ -44,26 +47,29 @@ {#snippet skeleton()} {/snippet} - {#each puccStore.pollutionCerts as pucc (pucc.id)} - {@const nextDue = getNextPuccDue(pucc)} + {#each certs as pucc (pucc.id)} + {@const nextDue = getPuccNextDue(pucc)} {#snippet actions()} { - puccStore.refreshPuccs(); + puccStore.reloadPuccs(); }} /> {/snippet} diff --git a/src/lib/components/feature/pollution/PollutionTab.svelte b/src/lib/components/feature/pollution/PollutionTab.svelte deleted file mode 100644 index bed32efe..00000000 --- a/src/lib/components/feature/pollution/PollutionTab.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/src/lib/components/feature/reminder/ReminderContextMenu.svelte b/src/lib/components/feature/reminder/ReminderContextMenu.svelte index 9f23b876..c1f384f4 100644 --- a/src/lib/components/feature/reminder/ReminderContextMenu.svelte +++ b/src/lib/components/feature/reminder/ReminderContextMenu.svelte @@ -29,7 +29,7 @@ const res = await saveReminder(updated); if (res.status === 'OK') { toast.success(m.reminder_status_completed()); - reminderStore.refreshReminders(); + reminderStore.reloadReminders(); } else { toast.error(res.error || m.reminder_status_error()); } diff --git a/src/lib/components/feature/reminder/ReminderForm.svelte b/src/lib/components/feature/reminder/ReminderForm.svelte index 686ffed2..07d67a42 100644 --- a/src/lib/components/feature/reminder/ReminderForm.svelte +++ b/src/lib/components/feature/reminder/ReminderForm.svelte @@ -27,6 +27,9 @@ import { toast } from 'svelte-sonner'; import * as m from '$lib/paraglide/messages'; import RecurrenceFields from '$lib/components/feature/shared/RecurrenceFields.svelte'; + import VehicleSelectField from '$feature/shared/VehicleSelectField.svelte'; + import { page } from '$app/state'; + import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; let { data }: { data?: Partial } = $props(); @@ -42,7 +45,7 @@ }).then((res) => { if (res.status === 'OK') { toast.success(m[data ? 'reminder_toast_updated' : 'reminder_toast_created']()); - sheetStore.closeSheet(reminderStore.refreshReminders); + sheetStore.closeSheet(reminderStore.reloadReminders); } else { toast.error(res.error || m.reminder_toast_error_fallback()); } @@ -55,7 +58,9 @@ const { form, enhance } = sf; const formData: any = sf.formData; - const resolveVehicleId = () => data?.vehicleId || vehicleStore.selectedId || ''; + const scope = $derived(readVehicleScope(page.url, vehicleStore.vehicles)); + const resolveVehicleId = () => data?.vehicleId || (scope.isFleet ? '' : scope.vehicleId) || ''; + const suppliedVehicleId = $derived(resolveVehicleId()); $effect(() => { if (data?.id) { @@ -87,6 +92,9 @@ e.preventDefault()}>
    + {#if !suppliedVehicleId} + + {/if} {#snippet children({ props })} diff --git a/src/lib/components/feature/reminder/ReminderList.svelte b/src/lib/components/feature/reminder/ReminderList.svelte index 92e2e490..08b2eb17 100644 --- a/src/lib/components/feature/reminder/ReminderList.svelte +++ b/src/lib/components/feature/reminder/ReminderList.svelte @@ -6,6 +6,8 @@ import StoreResourceState from '$appui/StoreResourceState.svelte'; import { reminderStore } from '$stores/reminder.svelte'; import { vehicleStore } from '$stores/vehicle.svelte'; + import { page } from '$app/state'; + import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; import { getReminderScheduleLabel, getRecurrenceTypeLabel, @@ -22,7 +24,15 @@ import ReminderContextMenu from './ReminderContextMenu.svelte'; import * as m from '$lib/paraglide/messages'; - let lastVehicleId: string | undefined; + interface Props { + /** Optional predicate to narrow what's rendered (e.g. page-level type/status filter tabs). */ + filter?: (reminder: Reminder) => boolean; + } + + let { filter }: Props = $props(); + + let lastScopeKey: string | undefined; + const scope = $derived(readVehicleScope(page.url, vehicleStore.vehicles)); const isOverdue = (reminder: Reminder) => { return !reminder.isCompleted && reminder.dueDate.getTime() < Date.now(); @@ -33,112 +43,105 @@ $effect(() => { if (!browser) return; - const vehicleId = vehicleStore.selectedId; - if (!vehicleId) { - lastVehicleId = undefined; - reminderStore.clear(); - return; - } - if (vehicleId !== lastVehicleId) { - lastVehicleId = vehicleId; - reminderStore.refreshReminders(); + const vehicleId = scope.vehicleId; + const scopeKey = vehicleId ?? '__fleet__'; + if (scopeKey !== lastScopeKey) { + lastScopeKey = scopeKey; + reminderStore.refreshReminders(vehicleId); } }); - const reminders = $derived(reminderStore.reminders || []); + const reminders = $derived.by(() => { + const all = reminderStore.reminders || []; + return filter ? all.filter(filter) : all; + }); -{#if !vehicleStore.selectedId} -
    -

    {m.reminder_list_select_vehicle()}

    -

    {m.reminder_list_select_hint()}

    -
    -{:else} - - {#snippet skeleton()} - - {/snippet} - {#each reminders as reminder (reminderKey(reminder))} - - {#snippet headerExtras()} -
    - - {reminder.isCompleted ? m.reminder_status_completed() : m.reminder_status_pending()} - - {#if isOverdue(reminder)} - {m.reminder_status_overdue()} - {/if} -
    - {/snippet} + + {#snippet skeleton()} + + {/snippet} + {#each reminders as reminder (reminderKey(reminder))} + + {#snippet headerExtras()} +
    + + {reminder.isCompleted ? m.reminder_status_completed() : m.reminder_status_pending()} + + {#if isOverdue(reminder)} + {m.reminder_status_overdue()} + {/if} +
    + {/snippet} - {#snippet actions()} - { - reminderStore.refreshReminders(); - }} - /> - {/snippet} + {#snippet actions()} + { + reminderStore.reloadReminders(); + }} + /> + {/snippet} + + + {#if reminder.recurrenceType && reminder.recurrenceType !== 'none'} + > + + {getRecurrenceTypeLabel(reminder.recurrenceType, m)} + {#if reminder.recurrenceInterval > 1} + ({m.recurrence_every()} + {reminder.recurrenceInterval} + {reminder.recurrenceType === 'yearly' + ? m.recurrence_interval_years() + : reminder.recurrenceType === 'monthly' + ? m.recurrence_interval_months() + : reminder.recurrenceType === 'weekly' + ? m.recurrence_interval_weeks() + : m.recurrence_interval_days()}) + {/if} + {#if reminder.recurrenceEndDate} + - {m.recurrence_until()} + {formatDate(reminder.recurrenceEndDate)} + {/if} + + + {/if} + {#if reminder.note} - {#if reminder.recurrenceType && reminder.recurrenceType !== 'none'} - - - {getRecurrenceTypeLabel(reminder.recurrenceType, m)} - {#if reminder.recurrenceInterval > 1} - ({m.recurrence_every()} - {reminder.recurrenceInterval} - {reminder.recurrenceType === 'yearly' - ? m.recurrence_interval_years() - : reminder.recurrenceType === 'monthly' - ? m.recurrence_interval_months() - : reminder.recurrenceType === 'weekly' - ? m.recurrence_interval_weeks() - : m.recurrence_interval_days()}) - {/if} - {#if reminder.recurrenceEndDate} - - {m.recurrence_until()} - {formatDate(reminder.recurrenceEndDate)} - {/if} - - - {/if} - {#if reminder.note} - - {/if} -
    - {/each} -
    -{/if} + {/if} +
    + {/each} +
    diff --git a/src/lib/components/feature/reminder/ReminderTab.svelte b/src/lib/components/feature/reminder/ReminderTab.svelte deleted file mode 100644 index 35a14dff..00000000 --- a/src/lib/components/feature/reminder/ReminderTab.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - ({ vehicleId: vehicleStore.selectedId })} -/> diff --git a/src/lib/components/feature/shared/FeaturePageShell.svelte b/src/lib/components/feature/shared/FeaturePageShell.svelte new file mode 100644 index 00000000..4ae4c76b --- /dev/null +++ b/src/lib/components/feature/shared/FeaturePageShell.svelte @@ -0,0 +1,53 @@ + + +{#snippet enabledContent()} +
    + + + {@render actions?.()} + + {@render children?.(scope)} +
    +{/snippet} + +{#snippet disabledContent()} +
    +
    +

    {disabledTitle}

    +

    {disabledHint}

    +
    +
    +{/snippet} + + diff --git a/src/lib/components/feature/shared/VehicleCell.svelte b/src/lib/components/feature/shared/VehicleCell.svelte new file mode 100644 index 00000000..df03b26c --- /dev/null +++ b/src/lib/components/feature/shared/VehicleCell.svelte @@ -0,0 +1,16 @@ + + +
    + {make} {model} + {#if plate} + {plate} + {/if} +
    diff --git a/src/lib/components/feature/shared/VehicleScopeSelector.svelte b/src/lib/components/feature/shared/VehicleScopeSelector.svelte new file mode 100644 index 00000000..c93a895d --- /dev/null +++ b/src/lib/components/feature/shared/VehicleScopeSelector.svelte @@ -0,0 +1,73 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + setVehicleScope()}> + + {m.vehicle_scope_all()} + + {#if vehicles.length > 0} + + {#each vehicles as vehicle (vehicle.id)} + setVehicleScope(vehicle.id ?? undefined)} + > + +
    + {vehicleLabel(vehicle)} + + {vehicle.licensePlate ?? '--'} + +
    +
    + {/each} + {/if} + + goto('/vehicles')}> + + {m.vehicle_scope_manage()} + +
    +
    diff --git a/src/lib/components/feature/shared/VehicleSelectField.svelte b/src/lib/components/feature/shared/VehicleSelectField.svelte new file mode 100644 index 00000000..1d36fc8a --- /dev/null +++ b/src/lib/components/feature/shared/VehicleSelectField.svelte @@ -0,0 +1,44 @@ + + + + + {#snippet children({ props })} + {m.form_vehicle_label()} + + {/snippet} + + + diff --git a/src/lib/components/feature/vehicle/VehicleCard.svelte b/src/lib/components/feature/vehicle/VehicleCard.svelte index 842cac7b..7930b2b3 100644 --- a/src/lib/components/feature/vehicle/VehicleCard.svelte +++ b/src/lib/components/feature/vehicle/VehicleCard.svelte @@ -28,7 +28,7 @@ import VehicleCardHeader from './VehicleCardHeader.svelte'; import VehicleQuickActions from './VehicleQuickActions.svelte'; - const { vehicle, onclick, onkeydown, isSelected = false } = $props(); + const { vehicle, onclick, onkeydown } = $props(); let deleteDialog = $state(false); let detailsModalOpen = $state(false); @@ -112,7 +112,7 @@ {onkeydown} > @@ -120,7 +120,7 @@ - + e.stopPropagation()}>
    diff --git a/src/lib/components/feature/vehicle/VehicleHubFeatureLinks.svelte b/src/lib/components/feature/vehicle/VehicleHubFeatureLinks.svelte new file mode 100644 index 00000000..a0281f97 --- /dev/null +++ b/src/lib/components/feature/vehicle/VehicleHubFeatureLinks.svelte @@ -0,0 +1,127 @@ + + + diff --git a/src/lib/components/feature/vehicle/VehicleHubHero.svelte b/src/lib/components/feature/vehicle/VehicleHubHero.svelte new file mode 100644 index 00000000..f2de5cf0 --- /dev/null +++ b/src/lib/components/feature/vehicle/VehicleHubHero.svelte @@ -0,0 +1,108 @@ + + +
    +
    + +
    +
    +
    +

    + {vehicle.make} + {vehicle.model} +

    +
    + {vehicle.year} + {fuelLabel} +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    + + + {vehicle.currentOdometer ? formatDistance(vehicle.currentOdometer) : '--'} + +
    +
    + + + {vehicle.overallMileage ? formatMileage(vehicle.overallMileage, vehicle.fuelType) : '--'} + +
    +
    +
    +
    + + diff --git a/src/lib/components/feature/vehicle/VehicleHubStats.svelte b/src/lib/components/feature/vehicle/VehicleHubStats.svelte new file mode 100644 index 00000000..156839a5 --- /dev/null +++ b/src/lib/components/feature/vehicle/VehicleHubStats.svelte @@ -0,0 +1,48 @@ + + +
    + + + + +
    diff --git a/src/lib/components/feature/vehicle/VehicleList.svelte b/src/lib/components/feature/vehicle/VehicleList.svelte deleted file mode 100644 index e6e16b34..00000000 --- a/src/lib/components/feature/vehicle/VehicleList.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - - - {#snippet skeleton()} - - {/snippet} - -
    - {#each vehicleStore.vehicles as vehicle (vehicle.id)} - selectVehicle(vehicle.id)} - onkeydown={(e: { key: string }) => { - if (e.key === 'Enter' || e.key === ' ') selectVehicle(vehicle.id); - }} - /> - {/each} -
    -
    -
    diff --git a/src/lib/components/layout/AppSidebar.svelte b/src/lib/components/layout/AppSidebar.svelte index ce423d87..134fbfe9 100644 --- a/src/lib/components/layout/AppSidebar.svelte +++ b/src/lib/components/layout/AppSidebar.svelte @@ -2,7 +2,6 @@ import * as Sidebar from '$ui/sidebar/index.js'; import * as Avatar from '$ui/avatar/index.js'; import * as DropdownMenu from '$ui/dropdown-menu/index.js'; - import VehicleSwitcher from '$layout/VehicleSwitcher.svelte'; import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard'; import Car from '@lucide/svelte/icons/car'; @@ -11,7 +10,6 @@ import Bell from '@lucide/svelte/icons/bell'; import BadgeInfo from '@lucide/svelte/icons/badge-info'; import Shield from '@lucide/svelte/icons/shield'; - import Banknote from '@lucide/svelte/icons/banknote'; import BarChart3 from '@lucide/svelte/icons/bar-chart-3'; import Settings from '@lucide/svelte/icons/settings'; import LogOut from '@lucide/svelte/icons/log-out'; @@ -19,6 +17,7 @@ import ToolCase from '@lucide/svelte/icons/tool-case'; import Database from '@lucide/svelte/icons/database'; import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down'; + import Tractor from '@lucide/svelte/icons/tractor'; import { configStore } from '$stores/config.svelte'; import { authStore } from '$stores/auth.svelte'; @@ -86,12 +85,6 @@ featureKey: 'featureInsurance', group: 'Logs' }, - { - label: 'Expenses', - href: '/expenses', - icon: Banknote, - group: 'Logs' - }, { label: 'Reports', href: '/reports', @@ -138,7 +131,19 @@ - +
    +
    + +
    + + {m.app_name()} + +
    diff --git a/src/lib/components/layout/DashboardFeaturePage.svelte b/src/lib/components/layout/DashboardFeaturePage.svelte deleted file mode 100644 index 18362631..00000000 --- a/src/lib/components/layout/DashboardFeaturePage.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - {#snippet children()} -
    - -
    - {/snippet} - {#snippet fallback()} -
    -
    -

    {disabledTitle}

    -

    {disabledHint}

    -
    -
    - {/snippet} -
    diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte deleted file mode 100644 index 0e2a219c..00000000 --- a/src/lib/components/layout/Header.svelte +++ /dev/null @@ -1,138 +0,0 @@ - - -
    -
    - -
    -
    -
    -
    -
    - - {#if showNotifications} - - {/if} - {#if authStore.isLoggedIn} - - {#if !authStore.isAuthDisabled} - - - - - - { - sheetStore.openSheet( - ProfileForm, - m.profile_sheet_title(), - m.profile_sheet_desc() - ); - }} - disabled={authStore.isAuthDisabled || env.DEMO_MODE} - > - - {m.profile_menu_item()} - - - - - {m.tools_menu()} - - - { - sheetStore.openSheet( - DataExportImport, - m.data_export_import_sheet_title(), - m.data_export_import_sheet_desc() - ); - }} - > - - {m.data_export_import_menu_item()} - - - - - - - {m.logout_menu_item()} - - - - {/if} - {/if} -
    -
    -
    -
    diff --git a/src/lib/components/layout/Notifications.svelte b/src/lib/components/layout/Notifications.svelte index a0a72787..cb508b8a 100644 --- a/src/lib/components/layout/Notifications.svelte +++ b/src/lib/components/layout/Notifications.svelte @@ -13,7 +13,6 @@ import { browser } from '$app/environment'; import type { Notification } from '$lib/domain/notification'; - import { vehicleStore } from '$stores/vehicle.svelte'; import { notificationStore } from '$stores/notification.svelte'; import { getLocale } from '$lib/paraglide/runtime.js'; import { authStore } from '$lib/stores/auth.svelte'; @@ -72,19 +71,19 @@ await notificationStore.navigate(notification); }; - let hydratedVehicleId: string | undefined; + // The bell is fleet-wide: fetch once per login rather than per vehicle scope. + let hasFetchedNotifications = false; $effect(() => { - if (!browser || !authStore.isLoggedIn) return; - const selectedId = vehicleStore.selectedId; - if (!selectedId) { - hydratedVehicleId = undefined; + if (!browser) return; + if (!authStore.isLoggedIn) { + hasFetchedNotifications = false; notificationStore.apiNotifications = []; return; } - if (hydratedVehicleId === selectedId) return; - hydratedVehicleId = selectedId; - notificationStore.fetch(selectedId); + if (hasFetchedNotifications) return; + hasFetchedNotifications = true; + notificationStore.fetch(); }); const dateFormatter = $derived(new Intl.DateTimeFormat(getLocale(), { dateStyle: 'medium' })); @@ -154,14 +153,7 @@
    - {#if !vehicleStore.selectedId} -
    - - {m.notifications_select_vehicle_hint()} -
    - {:else if notificationStore.isLoadingNotifications} + {#if notificationStore.isLoadingNotifications}
    diff --git a/src/lib/components/layout/VehicleSwitcher.svelte b/src/lib/components/layout/VehicleSwitcher.svelte deleted file mode 100644 index d5641597..00000000 --- a/src/lib/components/layout/VehicleSwitcher.svelte +++ /dev/null @@ -1,115 +0,0 @@ - - - - - {#if activeVehicle} - - - -
    - -
    -
    - {vehicleName(activeVehicle)} - - {activeVehicle.licensePlate ?? '--'} - -
    - -
    -
    - - - Your Vehicles - - {#each vehicles as vehicle (vehicle.id)} - (vehicleStore.selectedId = vehicle.id!)} - > -
    - -
    - {vehicleName(vehicle)} - {#if vehicle.id === activeVehicle.id} - - {/if} -
    - {/each} - - goto('/vehicles')}> -
    - -
    - Manage vehicles -
    - -
    - -
    - {m.app_add_vehicle()} -
    -
    -
    - {:else} - -
    - -
    -
    - {m.app_add_vehicle()} - Get started -
    -
    - {/if} -
    -
    diff --git a/src/lib/composables/sheet-form.svelte.ts b/src/lib/composables/sheet-form.svelte.ts index fa4d1d4a..8c2a2936 100644 --- a/src/lib/composables/sheet-form.svelte.ts +++ b/src/lib/composables/sheet-form.svelte.ts @@ -1,6 +1,5 @@ import { superForm, defaults } from 'sveltekit-superforms'; import { zod4 } from 'sveltekit-superforms/adapters'; -import { vehicleStore } from '$stores/vehicle.svelte'; import type { z } from 'zod'; export function createSheetForm(config: { @@ -22,10 +21,10 @@ export function createSheetForm(config: { const { form: formData, enhance } = form; - function setVehicleId() { + function setVehicleId(vehicleId: string) { formData.update((fd: any) => ({ ...fd, - vehicleId: vehicleStore.selectedId || '' + vehicleId })); } diff --git a/src/lib/domain/fuel.ts b/src/lib/domain/fuel.ts index 228b422d..0e923b96 100644 --- a/src/lib/domain/fuel.ts +++ b/src/lib/domain/fuel.ts @@ -15,6 +15,9 @@ export interface FuelLog { notes: string | null; attachment: string | null; mileage?: number; + vehicleMake?: string | null; + vehicleModel?: string | null; + vehiclePlate?: string | null; } export const fuelSchema = z.object({ diff --git a/src/lib/domain/insurance.ts b/src/lib/domain/insurance.ts index 1aa619c6..268fe44f 100644 --- a/src/lib/domain/insurance.ts +++ b/src/lib/domain/insurance.ts @@ -36,6 +36,9 @@ export interface Insurance { cost: number; notes: string | null; attachment: string | null; + vehicleMake?: string | null; + vehicleModel?: string | null; + vehiclePlate?: string | null; } const insuranceRecurrenceOptions = Object.keys( diff --git a/src/lib/domain/maintenance.ts b/src/lib/domain/maintenance.ts index ffaea811..f1383893 100644 --- a/src/lib/domain/maintenance.ts +++ b/src/lib/domain/maintenance.ts @@ -10,6 +10,9 @@ export interface MaintenanceLog { cost: number; notes: string | null; attachment: string | null; + vehicleMake?: string | null; + vehicleModel?: string | null; + vehiclePlate?: string | null; } export const maintenanceSchema = z.object({ diff --git a/src/lib/domain/pucc.ts b/src/lib/domain/pucc.ts index e3b88a81..727dbea0 100644 --- a/src/lib/domain/pucc.ts +++ b/src/lib/domain/pucc.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { apiDateString, optionalApiDateString } from './shared'; +import { getNextDueDate } from '$lib/helper/recurrence.helper'; export const PUCC_RECURRENCE_TYPES = { none: 'none', @@ -35,6 +36,31 @@ export interface PollutionCertificate { testingCenter: string; notes: string | null; attachment: string | null; + vehicleMake?: string | null; + vehicleModel?: string | null; + vehiclePlate?: string | null; +} + +export type PuccStatus = 'valid' | 'expiring_soon' | 'expired'; + +/** Next date this certificate is due (issue/expiry + recurrence). Null when it never expires. */ +export function getPuccNextDue(pucc: PollutionCertificate): Date | null { + const baseDate = pucc.expiryDate ?? pucc.issueDate; + if (!baseDate) return null; + if (pucc.recurrenceType === 'no_end') return null; + if (pucc.recurrenceType === 'none') return baseDate; + return getNextDueDate(new Date(baseDate), pucc.recurrenceType, pucc.recurrenceInterval); +} + +/** Compliance status derived from the next due date, matching the 30-day "expiring soon" window + * previously computed server-side in /api/pucc/status. */ +export function getPuccStatus(pucc: PollutionCertificate, today: Date = new Date()): PuccStatus { + const nextDue = getPuccNextDue(pucc); + if (!nextDue) return 'valid'; + const thirtyDaysFromNow = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); + if (nextDue < today) return 'expired'; + if (nextDue <= thirtyDaysFromNow) return 'expiring_soon'; + return 'valid'; } const puccRecurrenceOptions = Object.keys( diff --git a/src/lib/domain/reminder.ts b/src/lib/domain/reminder.ts index a3d5a916..65deaccd 100644 --- a/src/lib/domain/reminder.ts +++ b/src/lib/domain/reminder.ts @@ -93,6 +93,9 @@ export interface Reminder { recurrenceEndDate: Date | null; note: string | null; isCompleted: boolean; + vehicleMake?: string | null; + vehicleModel?: string | null; + vehiclePlate?: string | null; } const reminderTypeOptions = Object.keys(REMINDER_TYPES) as (keyof typeof REMINDER_TYPES)[]; diff --git a/src/lib/scope/vehicle-scope.svelte.ts b/src/lib/scope/vehicle-scope.svelte.ts new file mode 100644 index 00000000..365119fc --- /dev/null +++ b/src/lib/scope/vehicle-scope.svelte.ts @@ -0,0 +1,53 @@ +import { goto } from '$app/navigation'; +import { page } from '$app/state'; +import type { Vehicle } from '$lib/domain'; + +/** Query param used to express the current vehicle scope in the URL. */ +export const SCOPE_PARAM = 'vehicle'; + +export interface VehicleScope { + vehicleId?: string; + vehicle?: Vehicle; + isFleet: boolean; +} + +/** + * Read the current vehicle scope from the URL. + * + * Falls back to fleet (`isFleet: true`, no vehicleId) when the param is + * absent, or when the id doesn't match any known vehicle (stale/deleted + * vehicle, or a hand-typed URL). While `vehicles` is still loading + * (`undefined`), the param is trusted as-is with `vehicle` left undefined. + * + * Plain function — call sites wrap it in `$derived(...)` using `page` from + * `$app/state`. + */ +export function readVehicleScope(url: URL, vehicles: Vehicle[] | undefined): VehicleScope { + const vehicleId = url.searchParams.get(SCOPE_PARAM); + + if (!vehicleId) { + return { isFleet: true }; + } + + if (vehicles === undefined) { + return { vehicleId, isFleet: false }; + } + + const vehicle = vehicles.find((v) => v.id === vehicleId); + if (!vehicle) { + return { isFleet: true }; + } + + return { vehicleId, vehicle, isFleet: false }; +} + +/** Navigate to the current path with the scope param set (or removed when omitted). */ +export function setVehicleScope(vehicleId?: string): void { + const url = new URL(page.url); + if (vehicleId) { + url.searchParams.set(SCOPE_PARAM, vehicleId); + } else { + url.searchParams.delete(SCOPE_PARAM); + } + goto(`${url.pathname}${url.search}`, { keepFocus: true, noScroll: true }); +} diff --git a/src/lib/services/notification.service.ts b/src/lib/services/notification.service.ts index 2a9fd470..b3adaf30 100644 --- a/src/lib/services/notification.service.ts +++ b/src/lib/services/notification.service.ts @@ -14,6 +14,18 @@ export const getNotifications = async (vehicleId: string): Promise> => { + const res: Response = { status: 'OK' }; + try { + const response = await apiClient.get('/notifications'); + res.data = response.data.data || response.data; + } catch (e: any) { + res.status = 'ERROR'; + res.error = e.response?.data?.message || 'Failed to fetch notifications.'; + } + return res; +}; + export const markNotificationAsRead = async ( vehicleId: string, notificationId: string diff --git a/src/lib/stores/entity-store.svelte.ts b/src/lib/stores/entity-store.svelte.ts index eee79383..a7964aae 100644 --- a/src/lib/stores/entity-store.svelte.ts +++ b/src/lib/stores/entity-store.svelte.ts @@ -2,7 +2,7 @@ import { apiClient } from '$lib/helper/api.helper'; import type { ApiResponse } from '$lib/response'; interface EntityStoreOptions { - buildPath: () => string | undefined; + buildPath: (vehicleId?: string) => string; sort?: (a: T, b: T) => number; map?: (raw: unknown) => T; errorMessage?: string; @@ -12,22 +12,48 @@ export function createEntityStore(options: EntityStoreOptions) { let items = $state() as T[] | undefined; let processing = $state(false); let error = $state(); + // Remembered so post-mutation reloads stay in the scope the user is viewing + // instead of silently widening the list back to the whole fleet. + let currentVehicleId: string | undefined; + // A page and the list it renders often derive the same scope independently, + // so both ask for it on mount. Share the in-flight request instead of + // firing duplicate round trips. + let inFlight: { path: string; promise: Promise } | undefined; - async function refresh(): Promise { - const urlPath = options.buildPath(); - if (!urlPath) return; - processing = true; - try { - const { data: res } = await apiClient.get(urlPath); - let result: T[] = options.map ? (res.data as unknown[]).map(options.map) : (res.data as T[]); - if (options.sort) result = [...result].sort(options.sort); - items = result; - error = undefined; - } catch { - error = options.errorMessage || 'Failed to fetch data'; - } finally { - processing = false; - } + function refresh(vehicleId?: string): Promise { + currentVehicleId = vehicleId; + const urlPath = options.buildPath(vehicleId); + if (inFlight?.path === urlPath) return inFlight.promise; + + const promise = (async () => { + processing = true; + try { + const { data: res } = await apiClient.get(urlPath); + let result: T[] = options.map + ? (res.data as unknown[]).map(options.map) + : (res.data as T[]); + if (options.sort) result = [...result].sort(options.sort); + items = result; + error = undefined; + } catch { + error = options.errorMessage || 'Failed to fetch data'; + } finally { + processing = false; + if (inFlight?.path === urlPath) inFlight = undefined; + } + })(); + + inFlight = { path: urlPath, promise }; + return promise; + } + + /** + * Re-fetch the current scope. Use after a create/edit/delete — it bypasses + * the in-flight dedupe so it never resolves with pre-mutation data. + */ + function reload(): Promise { + inFlight = undefined; + return refresh(currentVehicleId); } function clear(): void { @@ -45,6 +71,7 @@ export function createEntityStore(options: EntityStoreOptions) { return error; }, refresh, + reload, clear }; } diff --git a/src/lib/stores/fuel-log.svelte.ts b/src/lib/stores/fuel-log.svelte.ts index f0eca3f8..deab26c9 100644 --- a/src/lib/stores/fuel-log.svelte.ts +++ b/src/lib/stores/fuel-log.svelte.ts @@ -1,11 +1,9 @@ import type { FuelLog } from '$lib/domain'; import { compareDesc } from 'date-fns'; -import { vehicleStore } from './vehicle.svelte'; import { createEntityStore } from './entity-store.svelte'; const entityStore = createEntityStore({ - buildPath: () => - vehicleStore.selectedId ? `/vehicles/${vehicleStore.selectedId}/fuel-logs` : undefined, + buildPath: (vehicleId) => (vehicleId ? `/fuel-logs?vehicleId=${vehicleId}` : '/fuel-logs'), sort: (a, b) => { const dateDiff = compareDesc(a.date, b.date); if (dateDiff !== 0) return dateDiff; @@ -24,5 +22,6 @@ export const fuelLogStore = { get error() { return entityStore.error; }, - refreshFuelLogs: entityStore.refresh + refreshFuelLogs: entityStore.refresh, + reloadFuelLogs: entityStore.reload }; diff --git a/src/lib/stores/insurance.svelte.ts b/src/lib/stores/insurance.svelte.ts index 3c9b7f34..4c5be95f 100644 --- a/src/lib/stores/insurance.svelte.ts +++ b/src/lib/stores/insurance.svelte.ts @@ -1,10 +1,8 @@ import type { Insurance } from '$lib/domain'; -import { vehicleStore } from './vehicle.svelte'; import { createEntityStore } from './entity-store.svelte'; const entityStore = createEntityStore({ - buildPath: () => - vehicleStore.selectedId ? `/vehicles/${vehicleStore.selectedId}/insurance` : undefined, + buildPath: (vehicleId) => (vehicleId ? `/insurance?vehicleId=${vehicleId}` : '/insurance'), errorMessage: 'Failed to fetch Insurances' }); @@ -18,5 +16,6 @@ export const insuranceStore = { get error() { return entityStore.error; }, - refreshInsurances: entityStore.refresh + refreshInsurances: entityStore.refresh, + reloadInsurances: entityStore.reload }; diff --git a/src/lib/stores/maintenance.svelte.ts b/src/lib/stores/maintenance.svelte.ts index 1f193c1a..846b791e 100644 --- a/src/lib/stores/maintenance.svelte.ts +++ b/src/lib/stores/maintenance.svelte.ts @@ -1,11 +1,10 @@ import type { MaintenanceLog } from '$lib/domain'; import { compareDesc } from 'date-fns'; -import { vehicleStore } from './vehicle.svelte'; import { createEntityStore } from './entity-store.svelte'; const entityStore = createEntityStore({ - buildPath: () => - vehicleStore.selectedId ? `/vehicles/${vehicleStore.selectedId}/maintenance-logs` : undefined, + buildPath: (vehicleId) => + vehicleId ? `/maintenance-logs?vehicleId=${vehicleId}` : '/maintenance-logs', sort: (a, b) => { const dateDiff = compareDesc(a.date, b.date); if (dateDiff !== 0) return dateDiff; @@ -24,5 +23,6 @@ export const maintenanceStore = { get error() { return entityStore.error; }, - refreshMaintenanceLogs: entityStore.refresh + refreshMaintenanceLogs: entityStore.refresh, + reloadMaintenanceLogs: entityStore.reload }; diff --git a/src/lib/stores/notification.svelte.ts b/src/lib/stores/notification.svelte.ts index 9a793eb3..10de83d9 100644 --- a/src/lib/stores/notification.svelte.ts +++ b/src/lib/stores/notification.svelte.ts @@ -1,8 +1,7 @@ import { goto } from '$app/navigation'; -import { vehicleStore } from '$stores/vehicle.svelte'; import type { Notification } from '$lib/domain/notification'; import { - getNotifications, + getAllNotifications, markNotificationAsRead, markAllNotificationsAsRead, clearNotification @@ -33,10 +32,10 @@ class NotificationStore { this.apiNotifications.filter((n) => n.isRead && n.channel !== 'alert').length ); - async fetch(vehicleId: string) { + async fetch() { this.isLoadingNotifications = true; try { - const response = await getNotifications(vehicleId); + const response = await getAllNotifications(); this.apiNotifications = response.status === 'OK' && response.data ? response.data : []; } catch (err) { console.error('Failed to fetch notifications:', err); @@ -48,11 +47,11 @@ class NotificationStore { async markAsRead(notification: Notification) { if (notification.isRead) return; - if (!vehicleStore.selectedId || !notification.id) return; + if (!notification.vehicleId || !notification.id) return; if (this.markingAsReadIds[notification.id]) return; this.markingAsReadIds = { ...this.markingAsReadIds, [notification.id]: true }; try { - const response = await markNotificationAsRead(vehicleStore.selectedId, notification.id); + const response = await markNotificationAsRead(notification.vehicleId, notification.id); if (response.status === 'OK') { this.apiNotifications = this.apiNotifications.map((n) => n.id === notification.id ? { ...n, isRead: true } : n @@ -73,7 +72,7 @@ class NotificationStore { } async clearAllRead() { - if (!vehicleStore.selectedId || this.isClearingAll) return; + if (this.isClearingAll) return; const readNotifications = this.apiNotifications.filter( (n) => n.isRead && n.channel !== 'alert' ); @@ -81,7 +80,7 @@ class NotificationStore { this.isClearingAll = true; try { const results = await Promise.allSettled( - readNotifications.map((n) => clearNotification(vehicleStore.selectedId!, n.id!)) + readNotifications.map((n) => clearNotification(n.vehicleId, n.id!)) ); const successCount = results.filter((r) => r.status === 'fulfilled').length; const failCount = results.filter((r) => r.status === 'rejected').length; @@ -110,13 +109,21 @@ class NotificationStore { } async markAllAsRead() { - if (!vehicleStore.selectedId || this.unreadCount === 0 || this.isClearingAll) return; + if (this.unreadCount === 0 || this.isClearingAll) return; this.isClearingAll = true; try { - const response = await markAllNotificationsAsRead(vehicleStore.selectedId); - if (response.status === 'OK') { + const vehicleIds = Array.from( + new Set(this.apiNotifications.filter((n) => !n.isRead).map((n) => n.vehicleId)) + ); + const results = await Promise.allSettled( + vehicleIds.map((id) => markAllNotificationsAsRead(id)) + ); + const anySucceeded = results.some((r) => r.status === 'fulfilled' && r.value.status === 'OK'); + if (anySucceeded) { this.apiNotifications = this.apiNotifications.map((n) => ({ ...n, isRead: true })); toast.success(m.notif_all_marked_read()); + } else { + toast.error(m.notif_mark_read_failed()); } } catch (err) { toast.error(err instanceof Error ? err.message : m.notif_mark_read_failed()); diff --git a/src/lib/stores/pucc.svelte.ts b/src/lib/stores/pucc.svelte.ts index 3b21616d..a8728253 100644 --- a/src/lib/stores/pucc.svelte.ts +++ b/src/lib/stores/pucc.svelte.ts @@ -1,10 +1,8 @@ import type { PollutionCertificate } from '$lib/domain'; -import { vehicleStore } from './vehicle.svelte'; import { createEntityStore } from './entity-store.svelte'; const entityStore = createEntityStore({ - buildPath: () => - vehicleStore.selectedId ? `/vehicles/${vehicleStore.selectedId}/pucc` : undefined, + buildPath: (vehicleId) => (vehicleId ? `/pucc?vehicleId=${vehicleId}` : '/pucc'), errorMessage: 'Failed to fetch PUCCs' }); @@ -18,5 +16,6 @@ export const puccStore = { get error() { return entityStore.error; }, - refreshPuccs: entityStore.refresh + refreshPuccs: entityStore.refresh, + reloadPuccs: entityStore.reload }; diff --git a/src/lib/stores/reminder.svelte.ts b/src/lib/stores/reminder.svelte.ts index e7e1f61b..61d9376a 100644 --- a/src/lib/stores/reminder.svelte.ts +++ b/src/lib/stores/reminder.svelte.ts @@ -1,5 +1,4 @@ import type { Reminder } from '$lib/domain'; -import { vehicleStore } from './vehicle.svelte'; import { createEntityStore } from './entity-store.svelte'; function parseReminder(raw: unknown): Reminder { @@ -8,8 +7,7 @@ function parseReminder(raw: unknown): Reminder { } const entityStore = createEntityStore({ - buildPath: () => - vehicleStore.selectedId ? `/vehicles/${vehicleStore.selectedId}/reminders` : undefined, + buildPath: (vehicleId) => (vehicleId ? `/reminders?vehicleId=${vehicleId}` : '/reminders'), map: parseReminder, sort: (a, b) => a.dueDate.getTime() - b.dueDate.getTime(), errorMessage: 'Failed to fetch reminders' @@ -26,5 +24,6 @@ export const reminderStore = { return entityStore.error; }, refreshReminders: entityStore.refresh, + reloadReminders: entityStore.reload, clear: entityStore.clear }; diff --git a/src/lib/stores/vehicle.svelte.ts b/src/lib/stores/vehicle.svelte.ts index a9e370cc..bf156bc1 100644 --- a/src/lib/stores/vehicle.svelte.ts +++ b/src/lib/stores/vehicle.svelte.ts @@ -4,17 +4,11 @@ import type { ApiResponse } from '$lib/response'; class VehicleStore { vehicles = $state(); - selectedId = $state(); processing = $state(false); error = $state(); setVehicles = (vehicles: Vehicle[]) => { this.vehicles = vehicles; - if (vehicles && vehicles.length > 0) { - this.selectedId = vehicles[0].id || undefined; - } else { - this.selectedId = undefined; - } }; refreshVehicles = () => { @@ -23,14 +17,9 @@ class VehicleStore { .get('/vehicles') .then(({ data: res }) => { this.vehicles = res.data; - if (this.vehicles && this.vehicles.length > 0) { - this.selectedId = this.vehicles[0].id || undefined; - } else { - this.selectedId = undefined; - } this.error = undefined; }) - .catch((err) => (this.error = 'Failed to fetch vehicles')) + .catch(() => (this.error = 'Failed to fetch vehicles')) .finally(() => (this.processing = false)); }; } diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 465855c3..2ce25077 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -12,8 +12,6 @@ import TriangleAlert from '@lucide/svelte/icons/triangle-alert'; import LabelWithIcon from '$appui/LabelWithIcon.svelte'; import { demo_banner, default_login } from '$lib/paraglide/messages/_index.js'; - import Tractor from '@lucide/svelte/icons/tractor'; - import { m } from '$lib/paraglide/messages'; let { data, children }: LayoutProps = $props(); @@ -40,10 +38,6 @@
    -
    - - {m.app_name()} -
    diff --git a/src/routes/(app)/expenses/+page.server.ts b/src/routes/(app)/expenses/+page.server.ts new file mode 100644 index 00000000..ebcc876b --- /dev/null +++ b/src/routes/(app)/expenses/+page.server.ts @@ -0,0 +1,6 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + throw redirect(308, '/reports'); +}; diff --git a/src/routes/(app)/expenses/+page.svelte b/src/routes/(app)/expenses/+page.svelte deleted file mode 100644 index 39831314..00000000 --- a/src/routes/(app)/expenses/+page.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - -
    - - -
    - - - -
    - -
    - - -
    -
    diff --git a/src/routes/(app)/fuel/+page.svelte b/src/routes/(app)/fuel/+page.svelte index e49ae336..a4024193 100644 --- a/src/routes/(app)/fuel/+page.svelte +++ b/src/routes/(app)/fuel/+page.svelte @@ -1,35 +1,27 @@ - - {#snippet children()} -
    - - -
    - 0 ? `${totalFuelUsed.toFixed(1)} L` : '--'} - color="bg-gradient-to-br from-emerald-400 to-emerald-600 shadow-emerald-500/30" - /> - 0 ? `$${totalCost.toFixed(2)}` : '--'} - color="bg-gradient-to-br from-amber-400 to-amber-600 shadow-amber-500/30" - /> - 0 ? avgMileage.toFixed(1) : '--'} - color="bg-gradient-to-br from-blue-400 to-blue-600 shadow-blue-500/30" - /> - -
    + + {#snippet children(scope)} +
    + 0 ? `${totalFuelUsed.toFixed(1)} L` : '--'} + color="bg-gradient-to-br from-emerald-400 to-emerald-600 shadow-emerald-500/30" + /> + 0 ? `$${totalCost.toFixed(2)}` : '--'} + color="bg-gradient-to-br from-amber-400 to-amber-600 shadow-amber-500/30" + /> + 0 ? avgMileage.toFixed(1) : '--'} + color="bg-gradient-to-br from-blue-400 to-blue-600 shadow-blue-500/30" + /> + +
    -
    +
    + {#if scope.isFleet} + + + {:else} -
    - - -
    - {/snippet} - {#snippet fallback()} -
    -
    -

    {feature_fuel_disabled_title()}

    -

    {feature_fuel_disabled_hint()}

    -
    + {/if}
    + + {/snippet} - +
    diff --git a/src/routes/(app)/insurance/+page.svelte b/src/routes/(app)/insurance/+page.svelte index 2e456965..92b067e4 100644 --- a/src/routes/(app)/insurance/+page.svelte +++ b/src/routes/(app)/insurance/+page.svelte @@ -1,30 +1,21 @@ - - {#snippet children()} -
    - - - -
    - {/snippet} - {#snippet fallback()} -
    -
    -

    - {feature_insurance_disabled_title()} -

    -

    {feature_insurance_disabled_hint()}

    -
    -
    - {/snippet} -
    + + + diff --git a/src/routes/(app)/maintenance/+page.svelte b/src/routes/(app)/maintenance/+page.svelte index e4aed5d9..f8cf38bb 100644 --- a/src/routes/(app)/maintenance/+page.svelte +++ b/src/routes/(app)/maintenance/+page.svelte @@ -1,32 +1,41 @@ - - {#snippet children()} -
    - - - -
    - - 0 ? `${nextServiceOdometer.toLocaleString()} km` : '--'} - color="bg-gradient-to-br from-amber-400 to-amber-600 shadow-amber-500/30" - /> - 0 ? `${latestOdometer.toLocaleString()} km` : '--'} - color="bg-gradient-to-br from-blue-400 to-blue-600 shadow-blue-500/30" - /> -
    + +
    + + 0 ? `${nextServiceOdometer.toLocaleString()} km` : '--'} + color="bg-gradient-to-br from-amber-400 to-amber-600 shadow-amber-500/30" + /> + 0 ? `${latestOdometer.toLocaleString()} km` : '--'} + color="bg-gradient-to-br from-blue-400 to-blue-600 shadow-blue-500/30" + /> +
    - - - Overview - Service History - + + + {maintenance_tab_overview()} + {maintenance_tab_history()} + - -
    -
    -

    Next Service

    - {#if nextServiceOdometer > 0} -

    - {nextServiceOdometer.toLocaleString()} km -

    -

    - Estimated, based on last service at {latestOdometer.toLocaleString()} km -

    - {:else} -

    No service history to estimate from

    - {/if} -
    - -
    -

    Maintenance Timeline

    - -
    -
    -
    - - - - -
    -
    - {/snippet} - {#snippet fallback()} -
    -
    -

    - {feature_maintenance_disabled_title()} -

    -

    {feature_maintenance_disabled_hint()}

    + +
    +

    {maintenance_timeline_title()}

    +
    -
    - {/snippet} - + + + + + + + diff --git a/src/routes/(app)/pollution/+page.svelte b/src/routes/(app)/pollution/+page.svelte index cbea6be1..31e51652 100644 --- a/src/routes/(app)/pollution/+page.svelte +++ b/src/routes/(app)/pollution/+page.svelte @@ -1,189 +1,106 @@ - - {#snippet children()} -
    - - - + const listFilter = $derived( + statusFilter === 'all' + ? undefined + : (c: (typeof certs)[number]) => getPuccStatus(c) === statusFilter + ); + - -
    - - - - -
    + + {#snippet actions()} + + {/snippet} +
    + + + + +
    - - + - - {#if loading} -
    Loading PUC status...
    - {:else if filteredRecords.length === 0} -
    No PUC records found
    - {:else} -
    - - - - Vehicle - Reg. No. - PUC No. - Expiry - Status - - - - {#each filteredRecords as record (record.vehicleId)} - - {record.vehicleName} - - {record.licensePlate ?? '--'} - - - {record.certificateNumber ?? '--'} - - - {record.expiryDate ? new Date(record.expiryDate).toLocaleDateString() : '--'} - - - - - - {/each} - - -
    - {/if} + - - - sheetStore.openSheet(PollutionCertificateForm, m.pollution_add_action())} - /> -
    - {/snippet} - {#snippet fallback()} -
    -
    -

    {feature_pucc_disabled_title()}

    -

    {feature_pucc_disabled_hint()}

    -
    -
    - {/snippet} -
    + sheetStore.openSheet(PollutionCertificateForm, m.pollution_add_action())} + /> + diff --git a/src/routes/(app)/reminders/+page.svelte b/src/routes/(app)/reminders/+page.svelte index a315cc53..be2a201f 100644 --- a/src/routes/(app)/reminders/+page.svelte +++ b/src/routes/(app)/reminders/+page.svelte @@ -1,204 +1,75 @@ -{#snippet reminderRow(reminder: ReminderRecord, completed: boolean)} - {@const Icon = typeIcon(reminder.type)} - {@const dueDate = new Date(reminder.dueDate)} - {@const daysUntilDue = Math.ceil((dueDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))} -
    -
    + {#snippet actions()} +
    -
    -

    - {reminder.vehicleMake} - {reminder.vehicleModel} - {#if reminder.vehiclePlate} - ({reminder.vehiclePlate}) - {/if} -

    -

    {reminder.type}

    - {#if reminder.note} -

    {reminder.note}

    - {/if} -
    -
    -

    {dueDate.toLocaleDateString()}

    - {#if completed} - - {:else} - - {/if} -
    -
    -{/snippet} - - - {#snippet children()} -
    - - - - - - + + + {/snippet} + - -
    -

    Upcoming

    - {#if loading} -
    Loading reminders...
    - {:else if filteredReminders.length === 0} -
    No upcoming reminders
    - {:else} -
    - {#each filteredReminders as reminder (reminder.id)} - {@render reminderRow(reminder, false)} - {/each} -
    - {/if} -
    +
    +

    {m.reminder_section_upcoming()}

    + +
    - - {#if !loading && filteredCompletedReminders.length > 0} -
    -

    Completed

    -
    - {#each filteredCompletedReminders as reminder (reminder.id)} - {@render reminderRow(reminder, true)} - {/each} -
    -
    - {/if} -
    - {/snippet} - {#snippet fallback()} -
    -
    -

    - {feature_reminders_disabled_title()} -

    -

    {feature_reminders_disabled_hint()}

    -
    -
    - {/snippet} -
    +
    +

    {m.reminder_section_completed()}

    + +
    + diff --git a/src/routes/(app)/reports/+page.svelte b/src/routes/(app)/reports/+page.svelte index c894456d..38af82d5 100644 --- a/src/routes/(app)/reports/+page.svelte +++ b/src/routes/(app)/reports/+page.svelte @@ -1,58 +1,179 @@ -
    - - -
    -
    - -
    -

    Maintenance Report

    -

    Export maintenance history as PDF

    + + {#snippet children(scope)} + {@const costs = scopedCosts(scope)} +
    +

    {reports_section_costs()}

    + +
    + + +
    - -
    - -
    - -
    -

    Fuel Report

    -

    Fuel consumption and cost summary

    + +
    + + {#if scope.isFleet} + + {:else} + + {/if}
    - -
    - -
    - -
    -

    Full Data Export

    -

    Export all fleet data as JSON

    + + +
    +

    {reports_section_exports()}

    + +
    +
    + +
    +

    {reports_export_maintenance_title()}

    +

    + {reports_export_maintenance_description()} +

    + {#if scope.isFleet} +

    + {reports_export_maintenance_hint()} +

    + {/if} +
    + +
    + +
    + +
    +

    {reports_export_data_title()}

    +

    {reports_export_data_description()}

    +
    + +
    - -
    -
    -
    + + {/snippet} + diff --git a/src/routes/(app)/vehicles/+page.svelte b/src/routes/(app)/vehicles/+page.svelte index 0445eacc..50d77a2f 100644 --- a/src/routes/(app)/vehicles/+page.svelte +++ b/src/routes/(app)/vehicles/+page.svelte @@ -6,6 +6,7 @@ import CardGridSkeleton from '$appui/CardGridSkeleton.svelte'; import { vehicleStore } from '$stores/vehicle.svelte'; import { sheetStore } from '$stores/sheet.svelte'; + import { goto } from '$app/navigation'; import Button from '$ui/button/button.svelte'; import CirclePlus from '@lucide/svelte/icons/circle-plus'; import { app_add_vehicle, vehicle_list_empty } from '$lib/paraglide/messages/_index.js'; @@ -29,7 +30,7 @@ { - vehicleStore.selectedId = vehicle.id!; + if (vehicle.id) goto(`/vehicles/${vehicle.id}`); }} onkeydown={() => {}} /> diff --git a/src/routes/(app)/vehicles/[id]/+page.server.ts b/src/routes/(app)/vehicles/[id]/+page.server.ts new file mode 100644 index 00000000..f42e6ea8 --- /dev/null +++ b/src/routes/(app)/vehicles/[id]/+page.server.ts @@ -0,0 +1,16 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { getVehicleSummary } from '$server/services/vehicleService'; +import { AppError } from '$server/exceptions/AppError'; + +export const load: PageServerLoad = async ({ params }) => { + try { + const vehicle = await getVehicleSummary(params.id); + return { vehicle }; + } catch (err) { + if (err instanceof AppError && err.status === 404) { + throw error(404, 'Vehicle not found'); + } + throw err; + } +}; diff --git a/src/routes/(app)/vehicles/[id]/+page.svelte b/src/routes/(app)/vehicles/[id]/+page.svelte new file mode 100644 index 00000000..6a0245b0 --- /dev/null +++ b/src/routes/(app)/vehicles/[id]/+page.svelte @@ -0,0 +1,31 @@ + + +
    + + + + + +
    +

    {m.vehicle_hub_manage_title()}

    + +
    +
    diff --git a/src/routes/api/fuel-logs/+server.ts b/src/routes/api/fuel-logs/+server.ts new file mode 100644 index 00000000..e271cbd9 --- /dev/null +++ b/src/routes/api/fuel-logs/+server.ts @@ -0,0 +1,11 @@ +import type { RequestHandler } from './$types'; +import * as fuelLogService from '$server/services/fuelLogService'; +import { jsonResponse, withRouteErrorHandling } from '$server/utils/route-handler'; + +export const GET: RequestHandler = async ({ url }) => { + return withRouteErrorHandling('Fuel logs GET error:', async () => { + const vehicleId = url.searchParams.get('vehicleId') ?? undefined; + const result = await fuelLogService.getFuelLogs(vehicleId); + return jsonResponse(result); + }); +}; diff --git a/src/routes/api/insurance/+server.ts b/src/routes/api/insurance/+server.ts new file mode 100644 index 00000000..df55d2a0 --- /dev/null +++ b/src/routes/api/insurance/+server.ts @@ -0,0 +1,11 @@ +import type { RequestHandler } from './$types'; +import * as insuranceService from '$server/services/insuranceService'; +import { jsonResponse, withRouteErrorHandling } from '$server/utils/route-handler'; + +export const GET: RequestHandler = async ({ url }) => { + return withRouteErrorHandling('Insurance GET error:', async () => { + const vehicleId = url.searchParams.get('vehicleId') ?? undefined; + const result = await insuranceService.getInsurances(vehicleId); + return jsonResponse(result); + }); +}; diff --git a/src/routes/api/maintenance-logs/+server.ts b/src/routes/api/maintenance-logs/+server.ts new file mode 100644 index 00000000..8ba961df --- /dev/null +++ b/src/routes/api/maintenance-logs/+server.ts @@ -0,0 +1,11 @@ +import type { RequestHandler } from './$types'; +import * as maintenanceLogService from '$server/services/maintenanceLogService'; +import { jsonResponse, withRouteErrorHandling } from '$server/utils/route-handler'; + +export const GET: RequestHandler = async ({ url }) => { + return withRouteErrorHandling('Maintenance logs GET error:', async () => { + const vehicleId = url.searchParams.get('vehicleId') ?? undefined; + const result = await maintenanceLogService.getMaintenanceLogs(vehicleId); + return jsonResponse(result); + }); +}; diff --git a/src/routes/api/notifications/+server.ts b/src/routes/api/notifications/+server.ts new file mode 100644 index 00000000..352f58f1 --- /dev/null +++ b/src/routes/api/notifications/+server.ts @@ -0,0 +1,10 @@ +import type { RequestHandler } from './$types'; +import * as notificationService from '$server/services/notificationService'; +import { jsonResponse, withRouteErrorHandling } from '$server/utils/route-handler'; + +export const GET: RequestHandler = async () => { + return withRouteErrorHandling('Notifications GET error:', async () => { + const result = await notificationService.getAllNotifications(); + return jsonResponse(result); + }); +}; diff --git a/src/routes/api/pucc/+server.ts b/src/routes/api/pucc/+server.ts new file mode 100644 index 00000000..bd72f345 --- /dev/null +++ b/src/routes/api/pucc/+server.ts @@ -0,0 +1,11 @@ +import type { RequestHandler } from './$types'; +import * as pollutionCertificateService from '$server/services/pollutionCertificateService'; +import { jsonResponse, withRouteErrorHandling } from '$server/utils/route-handler'; + +export const GET: RequestHandler = async ({ url }) => { + return withRouteErrorHandling('PUCC GET error:', async () => { + const vehicleId = url.searchParams.get('vehicleId') ?? undefined; + const result = await pollutionCertificateService.getPollutionCertificates(vehicleId); + return jsonResponse(result); + }); +}; diff --git a/src/routes/api/pucc/status/+server.ts b/src/routes/api/pucc/status/+server.ts deleted file mode 100644 index eb1c5d7d..00000000 --- a/src/routes/api/pucc/status/+server.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { db } from '$server/db/index'; - -export const GET: RequestHandler = async () => { - const today = new Date(); - const thirtyDaysFromNow = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); - - // Get all vehicles with their latest PUC certificate - const vehicles = await db.query.vehicleTable.findMany({ - columns: { id: true, make: true, model: true, licensePlate: true, image: true } - }); - - // Get all PUC certificates - const allCerts = await db.query.pollutionCertificateTable.findMany({ - columns: { vehicleId: true, certificateNumber: true, issueDate: true, expiryDate: true } - }); - - // Get latest cert per vehicle - const latestCerts = new Map(); - for (const cert of allCerts) { - const existing = latestCerts.get(cert.vehicleId); - if ( - !existing || - (cert.expiryDate && - existing.expiryDate && - new Date(cert.expiryDate) > new Date(existing.expiryDate)) - ) { - latestCerts.set(cert.vehicleId, cert); - } - } - - const records = vehicles.map((vehicle) => { - const cert = latestCerts.get(vehicle.id); - let status: 'valid' | 'expiring_soon' | 'expired' | 'not_available'; - - if (!cert || !cert.expiryDate) { - status = 'not_available'; - } else { - const expiryDate = new Date(cert.expiryDate); - if (expiryDate < today) { - status = 'expired'; - } else if (expiryDate <= thirtyDaysFromNow) { - status = 'expiring_soon'; - } else { - status = 'valid'; - } - } - - return { - vehicleId: vehicle.id, - vehicleName: `${vehicle.make} ${vehicle.model}`, - licensePlate: vehicle.licensePlate, - certificateNumber: cert?.certificateNumber ?? null, - expiryDate: cert?.expiryDate ?? null, - status - }; - }); - - return json({ success: true, data: records }); -}; diff --git a/src/routes/api/reminders/+server.ts b/src/routes/api/reminders/+server.ts index fa0290ed..3b5ee84c 100644 --- a/src/routes/api/reminders/+server.ts +++ b/src/routes/api/reminders/+server.ts @@ -7,9 +7,13 @@ import * as schema from '$server/db/schema/index'; export const GET: RequestHandler = async ({ url }) => { const type = url.searchParams.get('type'); const status = url.searchParams.get('status'); + const vehicleId = url.searchParams.get('vehicleId'); const conditions = []; + if (vehicleId) { + conditions.push(eq(schema.reminderTable.vehicleId, vehicleId)); + } if (type && type !== 'all') { conditions.push(eq(schema.reminderTable.type, type)); } @@ -17,9 +21,10 @@ export const GET: RequestHandler = async ({ url }) => { conditions.push(eq(schema.reminderTable.isCompleted, true)); } else if (status === 'upcoming') { conditions.push(eq(schema.reminderTable.isCompleted, false)); - } else { - conditions.push(eq(schema.reminderTable.isCompleted, false)); } + // No status param: return reminders of every completion state — this is + // the generic list used by the reminder store (fleet or scoped), not just + // the ad-hoc upcoming/completed split on the /reminders page. const where = conditions.length > 0 ? and(...conditions) : undefined; @@ -34,6 +39,7 @@ export const GET: RequestHandler = async ({ url }) => { remindSchedule: schema.reminderTable.remindSchedule, recurrenceType: schema.reminderTable.recurrenceType, recurrenceInterval: schema.reminderTable.recurrenceInterval, + recurrenceEndDate: schema.reminderTable.recurrenceEndDate, vehicleMake: schema.vehicleTable.make, vehicleModel: schema.vehicleTable.model, vehiclePlate: schema.vehicleTable.licensePlate diff --git a/src/server/services/fuelLogService.ts b/src/server/services/fuelLogService.ts index e4eb9c0a..1299538b 100644 --- a/src/server/services/fuelLogService.ts +++ b/src/server/services/fuelLogService.ts @@ -1,6 +1,7 @@ import * as schema from '../db/schema/index'; import { db } from '../db/index'; import { z } from 'zod'; +import { eq, getTableColumns } from 'drizzle-orm'; import { fuelSchema } from '$lib/domain/fuel'; import { computeMileagePerWindow, type FuelLogInput } from '$lib/domain/fuel/mileage'; import { validateVehicleExistsByLicensePlate } from '../utils/serviceUtils'; @@ -23,19 +24,21 @@ export const getFuelLogById = getById; export const updateFuelLog = update; export const deleteFuelLog = remove; -export const getFuelLogs = async (vehicleId: string) => { - const configs = await getConfigsByKeys(['mileageUnitFormat', 'unitOfDistance', 'unitOfVolume']); - const configMap = Object.fromEntries(configs.map((c) => [c.key, c.value])); - const mileageFormat = configMap.mileageUnitFormat || 'distance-per-fuel'; - - const fuelLogs = await db.query.fuelLogTable.findMany({ - where: (log, { eq }) => eq(log.vehicleId, vehicleId), - orderBy: (log, { asc }) => [asc(log.date), asc(log.odometer)] - }); +type FuelLogRow = typeof schema.fuelLogTable.$inferSelect; +type FuelLogVehicleFields = { + vehicleMake?: string | null; + vehicleModel?: string | null; + vehiclePlate?: string | null; +}; +function withMileageMetrics( + fuelLogs: T[], + mileageFormat: string, + configMap: Record +) { const rawMileagePerWindow = computeMileagePerWindow(fuelLogs as FuelLogInput[]); - const fuelLogsWithMetrics = fuelLogs.map((log, index, arr) => { + return fuelLogs.map((log, index, arr) => { let distanceDriven: number | null = null; if (index > 0 && !log.missedLast && log.odometer !== null) { @@ -67,7 +70,50 @@ export const getFuelLogs = async (vehicleId: string) => { return { ...log, distanceDriven, mileage }; }); - return fuelLogsWithMetrics; +} + +export const getFuelLogs = async (vehicleId?: string) => { + const configs = await getConfigsByKeys(['mileageUnitFormat', 'unitOfDistance', 'unitOfVolume']); + const configMap = Object.fromEntries(configs.map((c) => [c.key, c.value])); + const mileageFormat = configMap.mileageUnitFormat || 'distance-per-fuel'; + + if (vehicleId) { + const fuelLogs = await db.query.fuelLogTable.findMany({ + where: (log, { eq }) => eq(log.vehicleId, vehicleId), + orderBy: (log, { asc }) => [asc(log.date), asc(log.odometer)] + }); + + return withMileageMetrics(fuelLogs, mileageFormat, configMap); + } + + // Fleet mode: mileage windows are only meaningful within a single vehicle's + // log sequence, so each vehicle's logs must be grouped and computed in + // isolation before the results are flattened back together — running the + // computation over the interleaved multi-vehicle list would corrupt it. + const rows = await db + .select({ + ...getTableColumns(schema.fuelLogTable), + vehicleMake: schema.vehicleTable.make, + vehicleModel: schema.vehicleTable.model, + vehiclePlate: schema.vehicleTable.licensePlate + }) + .from(schema.fuelLogTable) + .leftJoin(schema.vehicleTable, eq(schema.fuelLogTable.vehicleId, schema.vehicleTable.id)) + .orderBy(schema.fuelLogTable.vehicleId, schema.fuelLogTable.date, schema.fuelLogTable.odometer); + + const groupedByVehicle = new Map(); + for (const row of rows) { + const group = groupedByVehicle.get(row.vehicleId); + if (group) { + group.push(row); + } else { + groupedByVehicle.set(row.vehicleId, [row]); + } + } + + return Array.from(groupedByVehicle.values()).flatMap((group) => + withMileageMetrics(group, mileageFormat, configMap) + ); }; export const addFuelLogByLicensePlate = async ( diff --git a/src/server/services/insuranceService.ts b/src/server/services/insuranceService.ts index 82281307..eef7e53a 100644 --- a/src/server/services/insuranceService.ts +++ b/src/server/services/insuranceService.ts @@ -1,5 +1,6 @@ import * as schema from '../db/schema/index'; import { db } from '../db/index'; +import { eq, getTableColumns } from 'drizzle-orm'; import { createOwnedEntityService } from '../utils/entity-service-factory'; import { clearFixedEndDate } from './domain-payload.helper'; import type { z } from 'zod'; @@ -22,12 +23,23 @@ export const getInsuranceById = getById; export const updateInsurance = update; export const deleteInsurance = remove; -export const getInsurances = async (vehicleId: string) => { - const insurance = await db.query.insuranceTable.findMany({ - where: (insurances, { eq }) => eq(insurances.vehicleId, vehicleId) - }); - const normalized = insurance.map((i) => - i.recurrenceType !== 'none' ? { ...i, endDate: null } : i - ); - return normalized; +export const getInsurances = async (vehicleId?: string) => { + if (vehicleId) { + const insurance = await db.query.insuranceTable.findMany({ + where: (insurances, { eq }) => eq(insurances.vehicleId, vehicleId) + }); + return insurance.map((i) => (i.recurrenceType !== 'none' ? { ...i, endDate: null } : i)); + } + + const rows = await db + .select({ + ...getTableColumns(schema.insuranceTable), + vehicleMake: schema.vehicleTable.make, + vehicleModel: schema.vehicleTable.model, + vehiclePlate: schema.vehicleTable.licensePlate + }) + .from(schema.insuranceTable) + .leftJoin(schema.vehicleTable, eq(schema.insuranceTable.vehicleId, schema.vehicleTable.id)); + + return rows.map((i) => (i.recurrenceType !== 'none' ? { ...i, endDate: null } : i)); }; diff --git a/src/server/services/maintenanceLogService.ts b/src/server/services/maintenanceLogService.ts index ae95d1bc..65549bf2 100644 --- a/src/server/services/maintenanceLogService.ts +++ b/src/server/services/maintenanceLogService.ts @@ -1,5 +1,6 @@ import * as schema from '../db/schema/index'; import { db } from '../db/index'; +import { eq, getTableColumns } from 'drizzle-orm'; import { createOwnedEntityService } from '../utils/entity-service-factory'; import type { z } from 'zod'; import { maintenanceSchema } from '$lib/domain/maintenance'; @@ -20,10 +21,32 @@ export const getMaintenanceLogById = getById; export const updateMaintenanceLog = update; export const deleteMaintenanceLog = removeScoped; -export const getMaintenanceLogs = async (vehicleId: string) => { - const rows = await db.query.maintenanceLogTable.findMany({ - where: (logs, { eq }) => eq(logs.vehicleId, vehicleId), - orderBy: (logs, { asc }) => [asc(logs.date), asc(logs.odometer)] - }); +export const getMaintenanceLogs = async (vehicleId?: string) => { + if (vehicleId) { + const rows = await db.query.maintenanceLogTable.findMany({ + where: (logs, { eq }) => eq(logs.vehicleId, vehicleId), + orderBy: (logs, { asc }) => [asc(logs.date), asc(logs.odometer)] + }); + return rows.map((r) => ({ ...r, date: new Date(r.date) })); + } + + // Fleet mode: no cross-row derived metrics here (unlike fuel mileage), but + // logs are still ordered per-vehicle so any future odometer-based + // computation can rely on grouped, chronologically ordered rows. + const rows = await db + .select({ + ...getTableColumns(schema.maintenanceLogTable), + vehicleMake: schema.vehicleTable.make, + vehicleModel: schema.vehicleTable.model, + vehiclePlate: schema.vehicleTable.licensePlate + }) + .from(schema.maintenanceLogTable) + .leftJoin(schema.vehicleTable, eq(schema.maintenanceLogTable.vehicleId, schema.vehicleTable.id)) + .orderBy( + schema.maintenanceLogTable.vehicleId, + schema.maintenanceLogTable.date, + schema.maintenanceLogTable.odometer + ); + return rows.map((r) => ({ ...r, date: new Date(r.date) })); }; diff --git a/src/server/services/notificationService.ts b/src/server/services/notificationService.ts index 4cfa0974..9df2ed5e 100644 --- a/src/server/services/notificationService.ts +++ b/src/server/services/notificationService.ts @@ -3,7 +3,7 @@ import { NOTIFICATION_SOURCES, NOTIFICATION_TYPES } from '$lib/domain/notification'; -import { and, eq, inArray, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, getTableColumns, inArray, isNull, sql } from 'drizzle-orm'; import { db } from '$server/db'; import * as schema from '$server/db/schema'; @@ -198,6 +198,27 @@ export const getNotifications = async (vehicleId: string) => { return notifications; }; +/** Fleet-wide notification list, with vehicle fields joined in for display. */ +export const getAllNotifications = async () => { + const rows = await db + .select({ + ...getTableColumns(schema.notificationTable), + vehicleMake: schema.vehicleTable.make, + vehicleModel: schema.vehicleTable.model, + vehiclePlate: schema.vehicleTable.licensePlate + }) + .from(schema.notificationTable) + .leftJoin(schema.vehicleTable, eq(schema.notificationTable.vehicleId, schema.vehicleTable.id)) + .where(isNull(schema.notificationTable.clearedAt)) + .orderBy( + asc(schema.notificationTable.isRead), + asc(schema.notificationTable.dueDate), + desc(schema.notificationTable.created_at) + ); + + return rows; +}; + export const getPendingNotificationsForChannels = async (channels: NotificationChannel[]) => { const notifications = await db.query.notificationTable.findMany({ where: (notification, { and, eq, inArray, isNull }) => diff --git a/src/server/services/pollutionCertificateService.ts b/src/server/services/pollutionCertificateService.ts index 27d83097..f7e8e822 100644 --- a/src/server/services/pollutionCertificateService.ts +++ b/src/server/services/pollutionCertificateService.ts @@ -1,5 +1,6 @@ import * as schema from '../db/schema/index'; import { db } from '../db/index'; +import { eq, getTableColumns } from 'drizzle-orm'; import { createOwnedEntityService } from '../utils/entity-service-factory'; import { clearFixedEndDate } from './domain-payload.helper'; import type { z } from 'zod'; @@ -25,12 +26,28 @@ export const getPollutionCertificateById = getById; export const updatePollutionCertificate = update; export const deletePollutionCertificate = remove; -export const getPollutionCertificates = async (vehicleId: string) => { - const pollutionCertificates = await db.query.pollutionCertificateTable.findMany({ - where: (certificates, { eq }) => eq(certificates.vehicleId, vehicleId) - }); - const normalized = pollutionCertificates.map((c) => - c.recurrenceType !== 'none' ? { ...c, expiryDate: null } : c - ); - return normalized; +export const getPollutionCertificates = async (vehicleId?: string) => { + if (vehicleId) { + const pollutionCertificates = await db.query.pollutionCertificateTable.findMany({ + where: (certificates, { eq }) => eq(certificates.vehicleId, vehicleId) + }); + return pollutionCertificates.map((c) => + c.recurrenceType !== 'none' ? { ...c, expiryDate: null } : c + ); + } + + const rows = await db + .select({ + ...getTableColumns(schema.pollutionCertificateTable), + vehicleMake: schema.vehicleTable.make, + vehicleModel: schema.vehicleTable.model, + vehiclePlate: schema.vehicleTable.licensePlate + }) + .from(schema.pollutionCertificateTable) + .leftJoin( + schema.vehicleTable, + eq(schema.pollutionCertificateTable.vehicleId, schema.vehicleTable.id) + ); + + return rows.map((c) => (c.recurrenceType !== 'none' ? { ...c, expiryDate: null } : c)); }; diff --git a/src/server/services/reminderService.ts b/src/server/services/reminderService.ts index 586b96cb..bbf87c11 100644 --- a/src/server/services/reminderService.ts +++ b/src/server/services/reminderService.ts @@ -3,7 +3,7 @@ import * as schema from '../db/schema/index'; import { db } from '../db/index'; import type { Reminder } from '$lib/domain/reminder'; import { performDelete, validateVehicleExists } from '../utils/serviceUtils'; -import { eq } from 'drizzle-orm'; +import { eq, getTableColumns } from 'drizzle-orm'; import { syncVehicleNotifications } from './notificationService'; import { requireRecord } from './service-response.helper'; @@ -102,13 +102,28 @@ export const addReminder = async (vehicleId: string, reminderData: ReminderPaylo return inserted; }; -export const getReminders = async (vehicleId: string) => { - const reminders = await db.query.reminderTable.findMany({ - where: (reminder, { eq }) => eq(reminder.vehicleId, vehicleId), - orderBy: (reminder, { asc }) => [asc(reminder.dueDate)] - }); +export const getReminders = async (vehicleId?: string) => { + if (vehicleId) { + const reminders = await db.query.reminderTable.findMany({ + where: (reminder, { eq }) => eq(reminder.vehicleId, vehicleId), + orderBy: (reminder, { asc }) => [asc(reminder.dueDate)] + }); + + return reminders; + } + + const rows = await db + .select({ + ...getTableColumns(schema.reminderTable), + vehicleMake: schema.vehicleTable.make, + vehicleModel: schema.vehicleTable.model, + vehiclePlate: schema.vehicleTable.licensePlate + }) + .from(schema.reminderTable) + .leftJoin(schema.vehicleTable, eq(schema.reminderTable.vehicleId, schema.vehicleTable.id)) + .orderBy(schema.reminderTable.dueDate); - return reminders; + return rows; }; export const getReminderById = async (id: string) => { From 18ec88cabee948780916378a4a2549b7cad14238 Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Sat, 1 Aug 2026 08:37:45 +0530 Subject: [PATCH 11/23] Code Cleanup with Ponytail --- package.json | 17 +- pnpm-lock.yaml | 1106 +---------------- src/__tests__/index.test.ts | 7 - src/hooks.server.ts | 53 +- .../dashboard/grid-interaction.svelte.ts | 6 +- .../components/dashboard/widget-registry.ts | 2 +- src/lib/components/dashboard/widget-size.ts | 8 +- .../feature/fuel/fuel-log-import.svelte.ts | 10 +- .../insurance/InsuranceContextMenu.svelte | 2 +- .../maintenance/MaintenanceContextMenu.svelte | 2 +- .../maintenance/MaintenanceTimeline.svelte | 2 +- .../feature/overview/AreaChart.svelte | 2 +- .../feature/overview/CostChart.svelte | 25 - .../feature/pollution/PuccContextMenu.svelte | 2 +- .../reminder/ReminderContextMenu.svelte | 2 +- .../feature/reminder/ReminderForm.svelte | 2 +- .../feature/reminder/ReminderList.svelte | 2 +- .../feature/shared/BadgeCell.svelte | 9 - .../feature/shared/VehicleSelectField.svelte | 2 +- .../feature/vehicle/VehicleHubHero.svelte | 2 +- .../feature/vehicle/VehicleHubStats.svelte | 2 +- .../feature/vehicle/VehicleListItem.svelte | 2 +- src/lib/config/env.server.ts | 12 +- src/lib/config/themes.ts | 2 - src/lib/constants/timezones.ts | 460 ------- src/lib/domain/dashboard.ts | 2 +- src/lib/domain/fuel/mileage.ts | 2 +- src/lib/domain/index.ts | 36 - src/lib/domain/notification-provider.ts | 10 +- src/lib/domain/pucc.ts | 2 +- src/lib/domain/shared.ts | 12 + src/lib/helper/alert.helper.ts | 123 -- src/lib/helper/csv.helper.ts | 4 +- src/lib/helper/http.helper.ts | 200 +-- src/lib/helper/label.helper.ts | 8 - src/lib/helper/recurrence.helper.ts | 4 +- src/lib/helper/settings-form.helper.ts | 2 +- src/lib/helper/timezone.helper.ts | 30 +- src/lib/helper/unit.helper.ts | 2 +- src/lib/index.ts | 1 - src/lib/scope/vehicle-scope.svelte.ts | 4 +- src/lib/services/config.service.ts | 3 +- src/lib/services/entity-service.ts | 4 +- src/lib/services/file.service.ts | 2 +- src/lib/services/fuel.service.ts | 2 +- src/lib/services/insurance.service.ts | 9 +- src/lib/services/maintenance.service.ts | 9 +- .../services/notification-provider.service.ts | 10 - src/lib/services/notification.service.ts | 3 +- src/lib/services/pucc.service.ts | 13 +- src/lib/services/reminder.service.ts | 2 +- src/lib/services/vehicle.service.ts | 3 +- src/lib/stores/auth.svelte.ts | 19 +- src/lib/stores/chart.svelte.ts | 3 +- src/lib/stores/fuel-log.svelte.ts | 2 +- src/lib/stores/insurance.svelte.ts | 2 +- src/lib/stores/maintenance.svelte.ts | 2 +- src/lib/stores/notification.svelte.ts | 2 +- src/lib/stores/pucc.svelte.ts | 2 +- src/lib/stores/reminder.svelte.ts | 2 +- src/lib/stores/vehicle.svelte.ts | 2 +- src/lib/utils.ts | 2 +- src/lib/utils/theme.ts | 23 +- src/routes/(app)/reminders/+page.svelte | 2 +- .../api/vehicles/[id]/fuel-logs/+server.ts | 8 - .../[id]/fuel-logs/[logId]/+server.ts | 4 +- .../api/vehicles/[id]/insurance/+server.ts | 8 - .../[id]/insurance/[insuranceId]/+server.ts | 4 +- .../vehicles/[id]/maintenance-logs/+server.ts | 8 - src/routes/api/vehicles/[id]/pucc/+server.ts | 8 - .../vehicles/[id]/pucc/[puccId]/+server.ts | 4 +- .../api/vehicles/[id]/reminders/+server.ts | 8 - src/server/config/appVersion.ts | 21 +- src/server/config/logger.ts | 78 +- src/server/db/init.ts | 6 - src/server/db/patch/index.ts | 49 - src/server/db/seeders/index.ts | 2 +- src/server/middlewares/auth.ts | 123 +- src/server/middlewares/base.ts | 53 - src/server/middlewares/cors.ts | 98 +- src/server/middlewares/index.ts | 4 - src/server/middlewares/logging.ts | 34 +- src/server/middlewares/rateLimit.ts | 60 - src/server/services/emailTemplateService.ts | 2 +- src/server/services/fuelLogService.ts | 20 - src/server/services/maintenanceLogService.ts | 4 +- .../notification-provider-http.helper.ts | 56 +- .../notification-provider-test.service.ts | 96 +- .../services/notification-service.helper.ts | 4 +- .../services/notificationDispatchService.ts | 195 +-- .../services/notificationProviderService.ts | 13 - .../services/notificationSchedulerService.ts | 12 +- src/server/services/vehicleService.ts | 13 - src/server/utils/entity-service-factory.ts | 9 +- src/server/utils/errorHandler.ts | 26 - src/server/utils/fs.ts | 15 +- src/server/utils/route-handler.ts | 7 +- src/server/utils/serviceUtils.ts | 15 - src/server/utils/session.ts | 2 +- 99 files changed, 492 insertions(+), 2883 deletions(-) delete mode 100644 src/__tests__/index.test.ts delete mode 100644 src/lib/components/feature/overview/CostChart.svelte delete mode 100644 src/lib/components/feature/shared/BadgeCell.svelte delete mode 100644 src/lib/constants/timezones.ts delete mode 100644 src/lib/domain/index.ts delete mode 100644 src/lib/helper/alert.helper.ts delete mode 100644 src/lib/helper/label.helper.ts delete mode 100644 src/lib/index.ts delete mode 100644 src/server/db/patch/index.ts delete mode 100644 src/server/middlewares/base.ts delete mode 100644 src/server/middlewares/index.ts delete mode 100644 src/server/middlewares/rateLimit.ts delete mode 100644 src/server/utils/errorHandler.ts diff --git a/package.json b/package.json index 440d1d2a..245e294a 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,11 @@ "clean": "rm -rf build .svelte-kit *.db uploads logs coverage node_modules", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:seed": "tsx scripts/seed.ts", "start": "node build" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@faker-js/faker": "^10.5.0", "@inlang/paraglide-js": "^2.23.0", "@internationalized/date": "^3.12.2", "@lucide/svelte": "^1.27.0", @@ -33,19 +33,15 @@ "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", "@tanstack/table-core": "^8.21.3", - "@testing-library/svelte": "^5.4.2", "@types/node": "^26.1.1", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^8.0.1", "@types/pdfkit": "^0.17.6", - "@types/supertest": "^7.2.1", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", - "@typescript/native": "npm:typescript@^7.0.2", "@vitest/coverage-v8": "^4.1.10", "bits-ui": "^2.18.1", "clsx": "^2.1.1", - "concurrently": "^10.0.4", "currency-codes": "^2.2.0", "d3-array": "^3.2.4", "d3-scale": "^4.0.2", @@ -60,7 +56,6 @@ "eslint-plugin-unused-imports": "^4.4.1", "formsnap": "^2.0.1", "globals": "^17.8.0", - "jsdom": "^30.0.0", "layerchart": "2.0.2", "prettier": "^3.9.6", "prettier-plugin-svelte": "^4.1.1", @@ -74,36 +69,26 @@ "tailwind-merge": "^3.6.0", "tailwind-variants": "^3.3.0", "tailwindcss": "^4.3.3", - "tsx": "^4.23.1", "tw-animate-css": "^1.4.0", "typescript": "npm:typescript@^6.0.3", "vite": "^8.1.5", "vitest": "^4.1.10" }, "dependencies": { - "@faker-js/faker": "^10.5.0", "@libsql/client": "^0.17.4", "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@types/bcrypt": "^6.0.0", - "@types/cors": "^2.8.19", "@types/d3-array": "^3.2.2", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", - "@types/express": "^5.0.6", - "@types/multer": "^2.2.0", "bcrypt": "^6.0.0", - "cors": "^2.8.6", "csv-parse": "^7.0.1", - "dotenv": "^17.4.2", - "helmet": "^8.3.0", "mode-watcher": "^1.1.0", - "multer": "^2.2.0", "node-cron": "^4.6.0", "nodemailer": "^9.0.3", "pdfkit": "^0.19.1", "svelte-dnd-action": "^0.9.74", - "winston": "^3.19.0", "zod": "^4.4.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00562bf7..f9a19b30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@faker-js/faker': - specifier: ^10.5.0 - version: 10.5.0 '@libsql/client': specifier: ^0.17.4 version: 0.17.4 @@ -23,9 +20,6 @@ importers: '@types/bcrypt': specifier: ^6.0.0 version: 6.0.0 - '@types/cors': - specifier: ^2.8.19 - version: 2.8.19 '@types/d3-array': specifier: ^3.2.2 version: 3.2.2 @@ -35,33 +29,15 @@ importers: '@types/d3-shape': specifier: ^3.1.8 version: 3.1.8 - '@types/express': - specifier: ^5.0.6 - version: 5.0.6 - '@types/multer': - specifier: ^2.2.0 - version: 2.2.0 bcrypt: specifier: ^6.0.0 version: 6.0.0 - cors: - specifier: ^2.8.6 - version: 2.8.6 csv-parse: specifier: ^7.0.1 version: 7.0.1 - dotenv: - specifier: ^17.4.2 - version: 17.4.2 - helmet: - specifier: ^8.3.0 - version: 8.3.0 mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.56.8(@typescript-eslint/types@8.65.0)) - multer: - specifier: ^2.2.0 - version: 2.2.0 node-cron: specifier: ^4.6.0 version: 4.6.0 @@ -74,9 +50,6 @@ importers: svelte-dnd-action: specifier: ^0.9.74 version: 0.9.74(svelte@5.56.8(@typescript-eslint/types@8.65.0)) - winston: - specifier: ^3.19.0 - version: 3.19.0 zod: specifier: ^4.4.3 version: 4.4.3 @@ -84,6 +57,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) + '@faker-js/faker': + specifier: ^10.5.0 + version: 10.5.0 '@inlang/paraglide-js': specifier: ^2.23.0 version: 2.23.0(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) @@ -114,9 +90,6 @@ importers: '@tanstack/table-core': specifier: ^8.21.3 version: 8.21.3 - '@testing-library/svelte': - specifier: ^5.4.2 - version: 5.4.2(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))(vitest@4.1.10) '@types/node': specifier: ^26.1.1 version: 26.1.1 @@ -129,18 +102,12 @@ importers: '@types/pdfkit': specifier: ^0.17.6 version: 0.17.6 - '@types/supertest': - specifier: ^7.2.1 - version: 7.2.1 '@typescript-eslint/eslint-plugin': specifier: ^8.65.0 version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/parser': specifier: ^8.65.0 version: 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript/native': - specifier: npm:typescript@^7.0.2 - version: typescript@7.0.2 '@vitest/coverage-v8': specifier: ^4.1.10 version: 4.1.10(vitest@4.1.10) @@ -150,9 +117,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - concurrently: - specifier: ^10.0.4 - version: 10.0.4 currency-codes: specifier: ^2.2.0 version: 2.2.0 @@ -195,9 +159,6 @@ importers: globals: specifier: ^17.8.0 version: 17.8.0 - jsdom: - specifier: ^30.0.0 - version: 30.0.0(@noble/hashes@1.8.0) layerchart: specifier: 2.0.2 version: 2.0.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(zod@4.4.3) @@ -237,9 +198,6 @@ importers: tailwindcss: specifier: ^4.3.3 version: 4.3.3 - tsx: - specifier: ^4.23.1 - version: 4.23.1 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -269,10 +227,6 @@ packages: resolution: {integrity: sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==} engines: {node: ^22.13.0 || >=24.0.0} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -302,10 +256,6 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true - '@colors/colors@1.6.0': - resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} - engines: {node: '>=0.1.90'} - '@csstools/color-helpers@6.1.0': resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} @@ -342,9 +292,6 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} - '@dabh/diagnostics@2.0.8': - resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} - '@dagrejs/dagre@2.0.4': resolution: {integrity: sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==} @@ -1361,9 +1308,6 @@ packages: '@sinclair/typebox@0.31.28': resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==} - '@so-ric/colorspace@1.1.6': - resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} - '@sqlite.org/sqlite-wasm@3.48.0-build4': resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==} hasBin: true @@ -1519,56 +1463,18 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} - - '@testing-library/svelte-core@1.1.3': - resolution: {integrity: sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==} - engines: {node: '>=16'} - peerDependencies: - svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 - - '@testing-library/svelte@5.4.2': - resolution: {integrity: sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==} - engines: {node: '>= 10'} - peerDependencies: - svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 - vite: '*' - vitest: '*' - peerDependenciesMeta: - vite: - optional: true - vitest: - optional: true - '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - '@types/bcrypt@6.0.0': resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/cookiejar@2.1.5': - resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - - '@types/cors@2.8.19': - resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} - '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1596,27 +1502,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/express-serve-static-core@5.1.1': - resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} - - '@types/express@5.0.6': - resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} - '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/methods@1.1.4': - resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - - '@types/multer@2.2.0': - resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} - '@types/node-cron@3.0.11': resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} @@ -1632,30 +1523,9 @@ packages: '@types/pdfkit@0.17.6': resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==} - '@types/qs@6.15.0': - resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - - '@types/serve-static@2.2.0': - resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - - '@types/superagent@8.1.9': - resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} - - '@types/supertest@7.2.1': - resolution: {integrity: sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==} - - '@types/triple-beam@1.3.5': - resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} - '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1740,126 +1610,6 @@ packages: resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript/typescript-aix-ppc64@7.0.2': - resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} - engines: {node: '>=16.20.0'} - cpu: [ppc64] - os: [aix] - - '@typescript/typescript-darwin-arm64@7.0.2': - resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [darwin] - - '@typescript/typescript-darwin-x64@7.0.2': - resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [darwin] - - '@typescript/typescript-freebsd-arm64@7.0.2': - resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [freebsd] - - '@typescript/typescript-freebsd-x64@7.0.2': - resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [freebsd] - - '@typescript/typescript-linux-arm64@7.0.2': - resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [linux] - - '@typescript/typescript-linux-arm@7.0.2': - resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} - engines: {node: '>=16.20.0'} - cpu: [arm] - os: [linux] - - '@typescript/typescript-linux-loong64@7.0.2': - resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} - engines: {node: '>=16.20.0'} - cpu: [loong64] - os: [linux] - - '@typescript/typescript-linux-mips64el@7.0.2': - resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} - engines: {node: '>=16.20.0'} - cpu: [mips64el] - os: [linux] - - '@typescript/typescript-linux-ppc64@7.0.2': - resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} - engines: {node: '>=16.20.0'} - cpu: [ppc64] - os: [linux] - - '@typescript/typescript-linux-riscv64@7.0.2': - resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} - engines: {node: '>=16.20.0'} - cpu: [riscv64] - os: [linux] - - '@typescript/typescript-linux-s390x@7.0.2': - resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} - engines: {node: '>=16.20.0'} - cpu: [s390x] - os: [linux] - - '@typescript/typescript-linux-x64@7.0.2': - resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [linux] - - '@typescript/typescript-netbsd-arm64@7.0.2': - resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [netbsd] - - '@typescript/typescript-netbsd-x64@7.0.2': - resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [netbsd] - - '@typescript/typescript-openbsd-arm64@7.0.2': - resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [openbsd] - - '@typescript/typescript-openbsd-x64@7.0.2': - resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [openbsd] - - '@typescript/typescript-sunos-x64@7.0.2': - resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [sunos] - - '@typescript/typescript-win32-arm64@7.0.2': - resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [win32] - - '@typescript/typescript-win32-x64@7.0.2': - resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [win32] - '@valibot/to-json-schema@1.7.1': resolution: {integrity: sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A==} peerDependencies: @@ -1924,28 +1674,6 @@ packages: ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - append-field@1.0.0: - resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} - - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - aria-query@5.3.1: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} @@ -1966,12 +1694,6 @@ packages: ast-v8-to-istanbul@1.0.0: resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2018,14 +1740,6 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - camelcase@8.0.0: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} @@ -2034,10 +1748,6 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2045,10 +1755,6 @@ packages: class-validator@0.14.4: resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} - cliui@9.0.1: - resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} - engines: {node: '>=20'} - clone@2.1.2: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} engines: {node: '>=0.8'} @@ -2057,29 +1763,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - color-convert@3.1.3: - resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} - engines: {node: '>=14.6'} - - color-name@2.1.0: - resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} - engines: {node: '>=12.20'} - - color-string@2.1.4: - resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} - engines: {node: '>=18'} - - color@5.0.3: - resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} - engines: {node: '>=18'} - colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -2095,15 +1781,6 @@ packages: commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} - concat-stream@2.0.0: - resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} - engines: {'0': node >= 6.0} - - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} - engines: {node: '>=22'} - hasBin: true - consola@3.4.0: resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -2115,10 +1792,6 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2294,10 +1967,6 @@ packages: delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2319,13 +1988,6 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -2422,19 +2084,9 @@ packages: sqlite3: optional: true - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - effect@3.21.4: resolution: {integrity: sha512-B89v/xSgPbl1J2Ai2u18jxq3odpFauU1rC6/eSs4FeNHi72kwKdJp12VGigvRV2lK+kRnx+OOz41XV8guZd4gQ==} - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - - enabled@2.0.0: - resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} - enhanced-resolve@5.24.3: resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} engines: {node: '>=10.13.0'} @@ -2443,25 +2095,9 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -2477,10 +2113,6 @@ packages: engines: {node: '>=18'} hasBin: true - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2612,9 +2244,6 @@ packages: picomatch: optional: true - fecha@4.2.3: - resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} - file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2633,16 +2262,9 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - fn.name@1.1.0: - resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} - fontkit@2.0.4: resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - formsnap@2.0.1: resolution: {integrity: sha512-iJSe4YKd/W6WhLwKDVJU9FQeaJRpEFuolhju7ZXlRpUVyDdqFdMP8AUBICgnVvQPyP41IPAlBa/v0Eo35iE6wQ==} engines: {node: '>=18', pnpm: '>=8.7.0'} @@ -2658,22 +2280,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-east-asian-width@1.6.0: - resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} - engines: {node: '>=18'} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} @@ -2689,10 +2295,6 @@ packages: resolution: {integrity: sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==} engines: {node: '>=18'} - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2700,22 +2302,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - helmet@8.3.0: - resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} - engines: {node: '>=18.0.0'} - html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2743,9 +2333,6 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -2780,10 +2367,6 @@ packages: is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2818,9 +2401,6 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - jsdom@30.0.0: resolution: {integrity: sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -2858,9 +2438,6 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} - kuler@2.0.0: - resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} - kysely@0.28.16: resolution: {integrity: sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww==} engines: {node: '>=20.0.0'} @@ -2970,10 +2547,6 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - logform@2.7.0: - resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} - engines: {node: '>= 12.0.0'} - lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -2992,17 +2565,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - memoize-weak@1.0.2: resolution: {integrity: sha512-gj39xkrjEw7nCn4nJ1M5ms6+MyMlyiGmttzsqAUsAKn6bYKwuTHh/AO3cKPF8IBrTIYTxb0wWXFs3E//Y8VoWQ==} @@ -3010,14 +2575,6 @@ packages: resolution: {integrity: sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==} engines: {node: '>=18'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -3050,10 +2607,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - multer@2.2.0: - resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} - engines: {node: '>= 10.16.0'} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3095,16 +2648,9 @@ packages: nub@0.0.0: resolution: {integrity: sha512-dK0Ss9C34R/vV0FfYJXuqDAqHlaW9fvWVufq9MmGF2umCuDbd5GRfRD9fpi/LiM0l4ZXf8IBB+RYmZExqCrf0w==} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - one-time@1.0.0: - resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3272,10 +2818,6 @@ packages: engines: {node: '>=14'} hasBin: true - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - promise-limit@2.7.0: resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} @@ -3289,13 +2831,6 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -3367,20 +2902,10 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3404,10 +2929,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.9.0: - resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} - engines: {node: '>= 0.4'} - siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3431,30 +2952,12 @@ packages: peerDependencies: kysely: '*' - stack-trace@0.0.10: - resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} @@ -3568,9 +3071,6 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - text-hex@1.0.0: - resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} - tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} @@ -3618,14 +3118,6 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - triple-beam@1.4.1: - resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} - engines: {node: '>= 14.0.0'} - ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -3658,26 +3150,14 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - typebox@1.3.6: resolution: {integrity: sha512-Sc8RA0NCMEFmApHNU9ZMzqcpQj46She44J8ffpLM/bdhLNUZKq7DJumcLcsFx1gRmDfQPgCgOmFFJ7rcnfWNyA==} - typedarray@0.0.6: - resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true - typescript@7.0.2: - resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} - engines: {node: '>=16.20.0'} - hasBin: true - undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -3723,10 +3203,6 @@ packages: resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} engines: {node: '>= 0.10'} - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - vite@8.1.5: resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3852,22 +3328,10 @@ packages: engines: {node: '>=8'} hasBin: true - winston-transport@4.9.0: - resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} - engines: {node: '>= 12.0.0'} - - winston@3.19.0: - resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} - engines: {node: '>= 12.0.0'} - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -3887,22 +3351,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} - yargs-parser@22.0.0: - resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=23} - - yargs@18.0.0: - resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3938,6 +3390,7 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 lru-cache: 11.5.2 + optional: true '@asamuzakjp/dom-selector@8.3.0': dependencies: @@ -3945,12 +3398,7 @@ snapshots: css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.2 - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 + optional: true '@babel/helper-string-parser@7.27.1': {} @@ -3960,7 +3408,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/runtime@7.29.2': {} + '@babel/runtime@7.29.2': + optional: true '@babel/types@7.29.0': dependencies: @@ -3972,15 +3421,16 @@ snapshots: '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 + optional: true - '@colors/colors@1.6.0': {} - - '@csstools/color-helpers@6.1.0': {} + '@csstools/color-helpers@6.1.0': + optional: true '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 + optional: true '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: @@ -3988,22 +3438,20 @@ snapshots: '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 + optional: true '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-tokenizer': 4.0.0 + optional: true '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 + optional: true - '@csstools/css-tokenizer@4.0.0': {} - - '@dabh/diagnostics@2.0.8': - dependencies: - '@so-ric/colorspace': 1.1.6 - enabled: 2.0.0 - kuler: 2.0.0 + '@csstools/css-tokenizer@4.0.0': + optional: true '@dagrejs/dagre@2.0.4': dependencies: @@ -4298,10 +3746,12 @@ snapshots: '@exodus/bytes@1.15.0(@noble/hashes@1.8.0)': optionalDependencies: '@noble/hashes': 1.8.0 + optional: true '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': optionalDependencies: '@noble/hashes': 1.8.0 + optional: true '@exodus/schemasafe@1.3.0': optional: true @@ -4704,11 +4154,6 @@ snapshots: '@sinclair/typebox@0.31.28': {} - '@so-ric/colorspace@1.1.6': - dependencies: - color: 5.0.3 - text-hex: 1.0.0 - '@sqlite.org/sqlite-wasm@3.48.0-build4': {} '@standard-schema/spec@1.1.0': {} @@ -4841,63 +4286,22 @@ snapshots: '@tanstack/table-core@8.21.3': {} - '@testing-library/dom@10.4.1': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/svelte-core@1.1.3(svelte@5.56.8(@typescript-eslint/types@8.65.0))': - dependencies: - svelte: 5.56.8(@typescript-eslint/types@8.65.0) - - '@testing-library/svelte@5.4.2(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1))(vitest@4.1.10)': - dependencies: - '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.1.3(svelte@5.56.8(@typescript-eslint/types@8.65.0)) - svelte: 5.56.8(@typescript-eslint/types@8.65.0) - optionalDependencies: - vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1) - vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.0(@noble/hashes@1.8.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)) - '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true - '@types/aria-query@5.0.4': {} - '@types/bcrypt@6.0.0': dependencies: '@types/node': 26.1.1 - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 26.1.1 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/connect@3.4.38': - dependencies: - '@types/node': 26.1.1 - '@types/cookie@0.6.0': {} - '@types/cookiejar@2.1.5': {} - - '@types/cors@2.8.19': - dependencies: - '@types/node': 26.1.1 - '@types/d3-array@3.2.2': {} '@types/d3-contour@3.0.6': @@ -4923,31 +4327,10 @@ snapshots: '@types/estree@1.0.8': {} - '@types/express-serve-static-core@5.1.1': - dependencies: - '@types/node': 26.1.1 - '@types/qs': 6.15.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express@5.0.6': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 5.1.1 - '@types/serve-static': 2.2.0 - '@types/geojson@7946.0.16': {} - '@types/http-errors@2.0.5': {} - '@types/json-schema@7.0.15': {} - '@types/methods@1.1.4': {} - - '@types/multer@2.2.0': - dependencies: - '@types/express': 5.0.6 - '@types/node-cron@3.0.11': {} '@types/node@25.8.0': @@ -4966,35 +4349,8 @@ snapshots: dependencies: '@types/node': 26.1.1 - '@types/qs@6.15.0': {} - - '@types/range-parser@1.2.7': {} - '@types/resolve@1.20.2': {} - '@types/send@1.2.1': - dependencies: - '@types/node': 26.1.1 - - '@types/serve-static@2.2.0': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 26.1.1 - - '@types/superagent@8.1.9': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/methods': 1.1.4 - '@types/node': 26.1.1 - form-data: 4.0.5 - - '@types/supertest@7.2.1': - dependencies: - '@types/methods': 1.1.4 - '@types/superagent': 8.1.9 - - '@types/triple-beam@1.3.5': {} - '@types/trusted-types@2.0.7': {} '@types/validator@13.15.10': @@ -5109,66 +4465,6 @@ snapshots: '@typescript-eslint/types': 8.65.0 eslint-visitor-keys: 5.0.1 - '@typescript/typescript-aix-ppc64@7.0.2': - optional: true - - '@typescript/typescript-darwin-arm64@7.0.2': - optional: true - - '@typescript/typescript-darwin-x64@7.0.2': - optional: true - - '@typescript/typescript-freebsd-arm64@7.0.2': - optional: true - - '@typescript/typescript-freebsd-x64@7.0.2': - optional: true - - '@typescript/typescript-linux-arm64@7.0.2': - optional: true - - '@typescript/typescript-linux-arm@7.0.2': - optional: true - - '@typescript/typescript-linux-loong64@7.0.2': - optional: true - - '@typescript/typescript-linux-mips64el@7.0.2': - optional: true - - '@typescript/typescript-linux-ppc64@7.0.2': - optional: true - - '@typescript/typescript-linux-riscv64@7.0.2': - optional: true - - '@typescript/typescript-linux-s390x@7.0.2': - optional: true - - '@typescript/typescript-linux-x64@7.0.2': - optional: true - - '@typescript/typescript-netbsd-arm64@7.0.2': - optional: true - - '@typescript/typescript-netbsd-x64@7.0.2': - optional: true - - '@typescript/typescript-openbsd-arm64@7.0.2': - optional: true - - '@typescript/typescript-openbsd-x64@7.0.2': - optional: true - - '@typescript/typescript-sunos-x64@7.0.2': - optional: true - - '@typescript/typescript-win32-arm64@7.0.2': - optional: true - - '@typescript/typescript-win32-x64@7.0.2': - optional: true - '@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@6.0.3))': dependencies: valibot: 1.4.2(typescript@6.0.3) @@ -5257,20 +4553,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@5.2.0: {} - - ansi-styles@6.2.3: {} - - append-field@1.0.0: {} - - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - aria-query@5.3.1: {} arkregex@0.0.8: @@ -5295,10 +4577,6 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - async@3.2.6: {} - - asynckit@0.4.0: {} - axobject-query@4.1.0: {} balanced-match@4.0.4: {} @@ -5315,6 +4593,7 @@ snapshots: bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + optional: true bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0)): dependencies: @@ -5347,22 +4626,11 @@ snapshots: buffer-from@1.1.2: {} - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - camelcase@8.0.0: optional: true chai@6.2.2: {} - chalk@5.6.2: {} - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -5374,37 +4642,12 @@ snapshots: validator: 13.15.26 optional: true - cliui@9.0.1: - dependencies: - string-width: 7.2.0 - strip-ansi: 7.2.0 - wrap-ansi: 9.0.2 - clone@2.1.2: {} clsx@2.1.1: {} - color-convert@3.1.3: - dependencies: - color-name: 2.1.0 - - color-name@2.1.0: {} - - color-string@2.1.4: - dependencies: - color-name: 2.1.0 - - color@5.0.3: - dependencies: - color-convert: 3.1.3 - color-string: 2.1.4 - colord@2.9.3: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - commander@11.1.0: {} commander@7.2.0: {} @@ -5416,33 +4659,12 @@ snapshots: commondir@1.0.1: {} - concat-stream@2.0.0: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 3.6.2 - typedarray: 0.0.6 - - concurrently@10.0.4: - dependencies: - chalk: 5.6.2 - rxjs: 7.8.2 - shell-quote: 1.9.0 - supports-color: 10.2.2 - tree-kill: 1.2.2 - yargs: 18.0.0 - consola@3.4.0: {} convert-source-map@2.0.0: {} cookie@0.6.0: {} - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5453,6 +4675,7 @@ snapshots: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 + optional: true cssesc@3.0.0: {} @@ -5577,6 +4800,7 @@ snapshots: whatwg-url: 16.0.1(@noble/hashes@1.8.0) transitivePeerDependencies: - '@noble/hashes' + optional: true date-fns-tz@3.2.0(date-fns@4.4.0): dependencies: @@ -5593,7 +4817,8 @@ snapshots: optionalDependencies: supports-color: 10.2.2 - decimal.js@10.6.0: {} + decimal.js@10.6.0: + optional: true dedent@1.5.1: {} @@ -5605,8 +4830,6 @@ snapshots: dependencies: robust-predicates: 3.0.2 - delayed-stream@1.0.0: {} - dequal@2.0.3: {} detect-libc@2.0.2: {} @@ -5620,10 +4843,6 @@ snapshots: dlv@1.1.3: optional: true - dom-accessibility-api@0.5.16: {} - - dotenv@17.4.2: {} - drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -5636,46 +4855,22 @@ snapshots: '@libsql/client': 0.17.4 kysely: 0.28.16 - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - effect@3.21.4: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 optional: true - emoji-regex@10.6.0: {} - - enabled@2.0.0: {} - enhanced-resolve@5.24.3: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 - entities@8.0.0: {} - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} + entities@8.0.0: + optional: true es-module-lexer@2.0.0: {} - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -5759,8 +4954,6 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 - escalade@3.2.0: {} - escape-string-regexp@4.0.0: {} eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): @@ -5907,8 +5100,6 @@ snapshots: optionalDependencies: picomatch: 4.0.5 - fecha@4.2.3: {} - file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -5927,8 +5118,6 @@ snapshots: flatted@3.4.2: {} - fn.name@1.1.0: {} - fontkit@2.0.4: dependencies: '@swc/helpers': 0.5.19 @@ -5941,14 +5130,6 @@ snapshots: unicode-properties: 1.4.1 unicode-trie: 2.0.0 - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - formsnap@2.0.1(svelte@5.56.8(@typescript-eslint/types@8.65.0))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(@types/json-schema@7.0.15)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)): dependencies: svelte: 5.56.8(@typescript-eslint/types@8.65.0) @@ -5960,28 +5141,6 @@ snapshots: function-bind@1.1.2: {} - get-caller-file@2.0.5: {} - - get-east-asian-width@1.6.0: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -5994,29 +5153,20 @@ snapshots: globals@17.8.0: {} - gopd@1.2.0: {} - graceful-fs@4.2.11: {} has-flag@4.0.0: {} - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - hasown@2.0.2: dependencies: function-bind: 1.1.2 - helmet@8.3.0: {} - html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): dependencies: '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) transitivePeerDependencies: - '@noble/hashes' + optional: true html-escaper@2.0.2: {} @@ -6032,8 +5182,6 @@ snapshots: imurmurhash@0.1.4: {} - inherits@2.0.4: {} - inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -6052,7 +5200,8 @@ snapshots: is-module@1.0.0: {} - is-potential-custom-element-name@1.0.1: {} + is-potential-custom-element-name@1.0.1: + optional: true is-reference@1.2.1: dependencies: @@ -6062,8 +5211,6 @@ snapshots: dependencies: '@types/estree': 1.0.8 - is-stream@2.0.1: {} - isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -6098,8 +5245,6 @@ snapshots: js-tokens@10.0.0: {} - js-tokens@4.0.0: {} - jsdom@30.0.0(@noble/hashes@1.8.0): dependencies: '@asamuzakjp/css-color': 6.0.5 @@ -6125,6 +5270,7 @@ snapshots: xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' + optional: true json-buffer@3.0.1: {} @@ -6148,8 +5294,6 @@ snapshots: known-css-properties@0.37.0: {} - kuler@2.0.0: {} - kysely@0.28.16: {} layerchart@2.0.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1)))(svelte@5.56.8(@typescript-eslint/types@8.65.0))(zod@4.4.3): @@ -6273,16 +5417,8 @@ snapshots: dependencies: p-locate: 5.0.0 - logform@2.7.0: - dependencies: - '@colors/colors': 1.6.0 - '@types/triple-beam': 1.3.5 - fecha: 4.2.3 - ms: 2.1.3 - safe-stable-stringify: 2.5.0 - triple-beam: 1.4.1 - - lru-cache@11.5.2: {} + lru-cache@11.5.2: + optional: true lz-string@1.5.0: {} @@ -6300,11 +5436,8 @@ snapshots: dependencies: semver: 7.7.4 - math-intrinsics@1.1.0: {} - - mdn-data@2.27.1: {} - - media-typer@0.3.0: {} + mdn-data@2.27.1: + optional: true memoize-weak@1.0.2: {} @@ -6312,12 +5445,6 @@ snapshots: dependencies: mimic-function: 5.0.1 - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mimic-function@5.0.1: {} mini-svg-data-uri@1.4.4: {} @@ -6342,13 +5469,6 @@ snapshots: ms@2.1.3: {} - multer@2.2.0: - dependencies: - append-field: 1.0.0 - busboy: 1.6.0 - concat-stream: 2.0.0 - type-is: 1.6.18 - nanoid@3.3.11: {} nanoid@3.3.15: {} @@ -6370,14 +5490,8 @@ snapshots: nub@0.0.0: {} - object-assign@4.1.1: {} - obug@2.1.1: {} - one-time@1.0.0: - dependencies: - fn.name: 1.1.0 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6402,6 +5516,7 @@ snapshots: parse5@8.0.1: dependencies: entities: 8.0.0 + optional: true path-exists@4.0.0: {} @@ -6488,12 +5603,6 @@ snapshots: prettier@3.9.6: {} - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - promise-limit@2.7.0: {} property-expr@2.0.6: @@ -6504,17 +5613,10 @@ snapshots: pure-rand@6.1.0: optional: true - react-is@17.0.2: {} - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - readdirp@4.1.2: {} - require-from-string@2.0.2: {} + require-from-string@2.0.2: + optional: true resolve-pkg-maps@1.0.0: {} @@ -6616,23 +5718,16 @@ snapshots: rw@1.3.3: {} - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - sade@1.8.1: dependencies: mri: 1.2.0 - safe-buffer@5.2.1: {} - - safe-stable-stringify@2.5.0: {} - safer-buffer@2.1.2: {} saxes@6.0.0: dependencies: xmlchars: 2.2.0 + optional: true semver@7.7.4: {} @@ -6644,8 +5739,6 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.9.0: {} - siginfo@2.0.0: {} sirv@3.0.2: @@ -6668,28 +5761,10 @@ snapshots: '@sqlite.org/sqlite-wasm': 3.48.0-build4 kysely: 0.28.16 - stack-trace@0.0.10: {} - stackback@0.0.2: {} std-env@4.0.0: {} - streamsearch@1.1.0: {} - - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.6.0 - strip-ansi: 7.2.0 - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - style-to-object@1.0.14: dependencies: inline-style-parser: 0.2.7 @@ -6697,7 +5772,8 @@ snapshots: superstruct@2.0.2: optional: true - supports-color@10.2.2: {} + supports-color@10.2.2: + optional: true supports-color@7.2.0: dependencies: @@ -6820,7 +5896,8 @@ snapshots: - '@types/json-schema' - typescript - symbol-tree@3.2.4: {} + symbol-tree@3.2.4: + optional: true tabbable@6.4.0: {} @@ -6835,8 +5912,6 @@ snapshots: tapable@2.3.3: {} - text-hex@1.0.0: {} - tiny-case@1.0.3: optional: true @@ -6858,11 +5933,13 @@ snapshots: tinyrainbow@3.1.0: {} - tldts-core@7.0.26: {} + tldts-core@7.0.26: + optional: true tldts@7.0.26: dependencies: tldts-core: 7.0.26 + optional: true toposort@2.0.2: optional: true @@ -6872,14 +5949,12 @@ snapshots: tough-cookie@6.0.2: dependencies: tldts: 7.0.26 + optional: true tr46@6.0.0: dependencies: punycode: 2.3.1 - - tree-kill@1.2.2: {} - - triple-beam@1.4.1: {} + optional: true ts-algebra@2.0.0: optional: true @@ -6907,46 +5982,17 @@ snapshots: type-fest@2.19.0: optional: true - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - typebox@1.3.6: optional: true - typedarray@0.0.6: {} - typescript@6.0.3: {} - typescript@7.0.2: - optionalDependencies: - '@typescript/typescript-aix-ppc64': 7.0.2 - '@typescript/typescript-darwin-arm64': 7.0.2 - '@typescript/typescript-darwin-x64': 7.0.2 - '@typescript/typescript-freebsd-arm64': 7.0.2 - '@typescript/typescript-freebsd-x64': 7.0.2 - '@typescript/typescript-linux-arm': 7.0.2 - '@typescript/typescript-linux-arm64': 7.0.2 - '@typescript/typescript-linux-loong64': 7.0.2 - '@typescript/typescript-linux-mips64el': 7.0.2 - '@typescript/typescript-linux-ppc64': 7.0.2 - '@typescript/typescript-linux-riscv64': 7.0.2 - '@typescript/typescript-linux-s390x': 7.0.2 - '@typescript/typescript-linux-x64': 7.0.2 - '@typescript/typescript-netbsd-arm64': 7.0.2 - '@typescript/typescript-netbsd-x64': 7.0.2 - '@typescript/typescript-openbsd-arm64': 7.0.2 - '@typescript/typescript-openbsd-x64': 7.0.2 - '@typescript/typescript-sunos-x64': 7.0.2 - '@typescript/typescript-win32-arm64': 7.0.2 - '@typescript/typescript-win32-x64': 7.0.2 - undici-types@7.24.6: {} undici-types@8.3.0: {} - undici@8.9.0: {} + undici@8.9.0: + optional: true unicode-properties@1.4.1: dependencies: @@ -6983,8 +6029,6 @@ snapshots: validator@13.15.26: optional: true - vary@1.1.2: {} - vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.23.1): dependencies: lightningcss: 1.32.0 @@ -7035,12 +6079,15 @@ snapshots: w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 + optional: true - webidl-conversions@8.0.1: {} + webidl-conversions@8.0.1: + optional: true webpack-virtual-modules@0.6.2: {} - whatwg-mimetype@5.0.0: {} + whatwg-mimetype@5.0.0: + optional: true whatwg-url@16.0.1(@noble/hashes@1.8.0): dependencies: @@ -7049,6 +6096,7 @@ snapshots: webidl-conversions: 8.0.1 transitivePeerDependencies: - '@noble/hashes' + optional: true whatwg-url@17.1.0(@noble/hashes@1.8.0): dependencies: @@ -7057,6 +6105,7 @@ snapshots: webidl-conversions: 8.0.1 transitivePeerDependencies: - '@noble/hashes' + optional: true which@2.0.2: dependencies: @@ -7067,55 +6116,18 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - winston-transport@4.9.0: - dependencies: - logform: 2.7.0 - readable-stream: 3.6.2 - triple-beam: 1.4.1 - - winston@3.19.0: - dependencies: - '@colors/colors': 1.6.0 - '@dabh/diagnostics': 2.0.8 - async: 3.2.6 - is-stream: 2.0.1 - logform: 2.7.0 - one-time: 1.0.0 - readable-stream: 3.6.2 - safe-stable-stringify: 2.5.0 - stack-trace: 0.0.10 - triple-beam: 1.4.1 - winston-transport: 4.9.0 - word-wrap@1.2.5: {} - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.2.0 - ws@8.19.0: {} - xml-name-validator@5.0.0: {} - - xmlchars@2.2.0: {} + xml-name-validator@5.0.0: + optional: true - y18n@5.0.8: {} + xmlchars@2.2.0: + optional: true yaml@1.10.2: {} - yargs-parser@22.0.0: {} - - yargs@18.0.0: - dependencies: - cliui: 9.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - string-width: 7.2.0 - y18n: 5.0.8 - yargs-parser: 22.0.0 - yocto-queue@0.1.0: {} yup@1.7.1: diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts deleted file mode 100644 index cf0f0739..00000000 --- a/src/__tests__/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -describe('test', () => { - it('test', () => { - expect(true).toBe(true); - }); -}); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 13dba86b..3f80bda6 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,30 +1,17 @@ import { sequence } from '@sveltejs/kit/hooks'; import { paraglideMiddleware } from '$lib/paraglide/server'; import type { Handle, HandleServerError } from '@sveltejs/kit'; -import { createErrorResponseBody, logError } from './server/utils/errorHandler'; -import { - CorsMiddleware, - RateLimitMiddleware, - AuthMiddleware, - LoggingMiddleware -} from '$server/middlewares'; - -import { MiddlewareChain } from '$server/middlewares/base'; +import { handleCors } from '$server/middlewares/cors'; +import { handleAuth } from '$server/middlewares/auth'; +import { handleLogging } from '$server/middlewares/logging'; import { initializeDatabase } from '$server/db/init'; import { appAsciiArt, appVersion, logger } from '$server/config'; import { env } from '$lib/config/env.server'; +import { getTextDirection } from '$lib/utils'; import { ensureAppDirectories } from '$server/utils/fs'; import { initializeNotificationScheduler } from '$server/services/notificationSchedulerService'; -const middlewareChain = new MiddlewareChain(); -middlewareChain.init([ - new CorsMiddleware(), - new AuthMiddleware(), - new RateLimitMiddleware(), - new LoggingMiddleware() -]); - const envSnapshot = () => ({ APP_VERSION: appVersion, LOG_LEVEL: env.LOG_LEVEL, @@ -90,27 +77,15 @@ const initPromise = (async () => { })(); export const handleError: HandleServerError = async ({ error, event }) => { - logError(error, event); + logger.error(`Error in ${event.request.method} - ${event.url.pathname}`, error); - const body = createErrorResponseBody(error); - - return { message: body.error.message || 'Internal server error' }; + return { message: error instanceof Error ? error.message : 'Internal server error' }; }; -const originalHandle: Handle = async ({ event, resolve }) => { +const handleInit: Handle = async ({ event, resolve }) => { await initPromise; - const middlewareResult = await middlewareChain.handle(event); - - if (middlewareResult.response) { - return middlewareResult.response; - } - - const response = await resolve(event); - - CorsMiddleware.addCorsHeaders(response, event.request); - - return response; + return resolve(event); }; const handleParaglide: Handle = ({ event, resolve }) => @@ -118,15 +93,11 @@ const handleParaglide: Handle = ({ event, resolve }) => event.request = request; return resolve(event, { - transformPageChunk: ({ html }) => { - // Set language and direction attributes based on locale - const rtlLanguages = ['ar', 'he', 'fa', 'ur', 'yi']; - const direction = rtlLanguages.includes(locale) ? 'rtl' : 'ltr'; - return html + transformPageChunk: ({ html }) => + html .replace('%paraglide.lang%', locale) - .replace('dir="%paraglide.lang%"', `dir="${direction}"`); - } + .replace('dir="%paraglide.lang%"', `dir="${getTextDirection(locale)}"`) }); }); -export const handle = sequence(originalHandle, handleParaglide); +export const handle = sequence(handleInit, handleCors, handleAuth, handleLogging, handleParaglide); diff --git a/src/lib/components/dashboard/grid-interaction.svelte.ts b/src/lib/components/dashboard/grid-interaction.svelte.ts index edb19a31..c20dcd63 100644 --- a/src/lib/components/dashboard/grid-interaction.svelte.ts +++ b/src/lib/components/dashboard/grid-interaction.svelte.ts @@ -9,10 +9,10 @@ import { import { clamp, ROW_UNIT_PX } from './widget-size'; import { moveElement, resizeElement, type GridRect } from './grid-layout'; -export type GridMode = 'move' | 'resize'; +type GridMode = 'move' | 'resize'; /** Pixel geometry of the widget being dragged, relative to the grid's padding box. */ -export interface FloatRect { +interface FloatRect { left: number; top: number; width: number; @@ -20,7 +20,7 @@ export interface FloatRect { } /** Rendered track geometry: `pitch` is one track plus one gap, i.e. the distance between track starts. */ -export interface GridMetrics { +interface GridMetrics { colPitch: number; colGap: number; rowPitch: number; diff --git a/src/lib/components/dashboard/widget-registry.ts b/src/lib/components/dashboard/widget-registry.ts index 0c8832b7..f3eef40c 100644 --- a/src/lib/components/dashboard/widget-registry.ts +++ b/src/lib/components/dashboard/widget-registry.ts @@ -16,7 +16,7 @@ import Fuel from '@lucide/svelte/icons/fuel'; import DollarSign from '@lucide/svelte/icons/dollar-sign'; import CircleGauge from '@lucide/svelte/icons/circle-gauge'; -export interface WidgetDefinition { +interface WidgetDefinition { type: WidgetType; title: string; description: string; diff --git a/src/lib/components/dashboard/widget-size.ts b/src/lib/components/dashboard/widget-size.ts index 9ebf48f1..55303a6a 100644 --- a/src/lib/components/dashboard/widget-size.ts +++ b/src/lib/components/dashboard/widget-size.ts @@ -7,19 +7,19 @@ export function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -export function clampColSpan(span: number): number { +function clampColSpan(span: number): number { return clamp(Math.round(span), 1, GRID_COLUMNS); } -export function clampRowSpan(span: number): number { +function clampRowSpan(span: number): number { return clamp(Math.round(span), 1, GRID_MAX_ROW_SPAN); } -export function clampColStart(colStart: number, colSpan: number): number { +function clampColStart(colStart: number, colSpan: number): number { return clamp(Math.round(colStart), 1, GRID_COLUMNS - clampColSpan(colSpan) + 1); } -export function clampRowStart(rowStart: number): number { +function clampRowStart(rowStart: number): number { return Math.max(1, Math.round(rowStart)); } diff --git a/src/lib/components/feature/fuel/fuel-log-import.svelte.ts b/src/lib/components/feature/fuel/fuel-log-import.svelte.ts index cece2780..84c7a8b8 100644 --- a/src/lib/components/feature/fuel/fuel-log-import.svelte.ts +++ b/src/lib/components/feature/fuel/fuel-log-import.svelte.ts @@ -9,10 +9,10 @@ import { parseWithFormat } from '$lib/helper/format.helper'; import { configStore } from '$lib/stores/config.svelte'; import * as m from '$lib/paraglide/messages'; -export type FuelLogColumnKey = +type FuelLogColumnKey = 'date' | 'odometer' | 'fuelAmount' | 'cost' | 'filled' | 'missedLast' | 'notes'; -export interface ColumnDefinition { +interface ColumnDefinition { key: FuelLogColumnKey; label: string; required: boolean; @@ -24,7 +24,7 @@ export interface ParsedCsvRow { } export const stepOrder = [1, 2, 3] as const; -export type ImportStep = (typeof stepOrder)[number]; +type ImportStep = (typeof stepOrder)[number]; export const columns: ColumnDefinition[] = [ { key: 'date', label: m.col_date(), required: true, hint: m.fuel_import_col_date_hint() }, @@ -56,7 +56,7 @@ export const columns: ColumnDefinition[] = [ { key: 'notes', label: m.col_notes(), required: false, hint: m.fuel_import_col_notes_hint() } ]; -export const defaultMapping = (): Record => ({ +const defaultMapping = (): Record => ({ date: '', odometer: '', fuelAmount: '', @@ -76,7 +76,7 @@ const autoMapHints: Record = { notes: ['note', 'remarks', 'comment', 'description'] }; -export const buildAutoMapping = (headers: string[]): Record => { +const buildAutoMapping = (headers: string[]): Record => { const next = defaultMapping(); for (const header of headers) { const normalized = header.toLowerCase(); diff --git a/src/lib/components/feature/insurance/InsuranceContextMenu.svelte b/src/lib/components/feature/insurance/InsuranceContextMenu.svelte index 62d7d30f..4a20012e 100644 --- a/src/lib/components/feature/insurance/InsuranceContextMenu.svelte +++ b/src/lib/components/feature/insurance/InsuranceContextMenu.svelte @@ -1,7 +1,7 @@ - - formatCurrency(value)} - xFormatter={(v: Date) => - v.toLocaleDateString('en-IN', { - day: '2-digit', - month: 'short', - year: 'numeric' - })} -/> diff --git a/src/lib/components/feature/pollution/PuccContextMenu.svelte b/src/lib/components/feature/pollution/PuccContextMenu.svelte index 97ec1947..371e9f44 100644 --- a/src/lib/components/feature/pollution/PuccContextMenu.svelte +++ b/src/lib/components/feature/pollution/PuccContextMenu.svelte @@ -1,7 +1,7 @@ - -
    - {value ? yesLabel : noLabel} -
    diff --git a/src/lib/components/feature/shared/VehicleSelectField.svelte b/src/lib/components/feature/shared/VehicleSelectField.svelte index 1d36fc8a..673d9076 100644 --- a/src/lib/components/feature/shared/VehicleSelectField.svelte +++ b/src/lib/components/feature/shared/VehicleSelectField.svelte @@ -4,7 +4,7 @@ import SearchableSelect from '$appui/SearchableSelect.svelte'; import Car from '@lucide/svelte/icons/car'; import type { SuperForm } from 'sveltekit-superforms'; - import type { Vehicle } from '$lib/domain'; + import type { Vehicle } from '$lib/domain/vehicle'; import * as m from '$lib/paraglide/messages'; interface Props { diff --git a/src/lib/components/feature/vehicle/VehicleHubHero.svelte b/src/lib/components/feature/vehicle/VehicleHubHero.svelte index f2de5cf0..b4ec996c 100644 --- a/src/lib/components/feature/vehicle/VehicleHubHero.svelte +++ b/src/lib/components/feature/vehicle/VehicleHubHero.svelte @@ -16,7 +16,7 @@ import { getFuelTypeLabel } from '$lib/domain/vehicle'; import { formatDistance, formatMileage } from '$lib/helper/format.helper'; import VehicleForm from './VehicleForm.svelte'; - import type { Vehicle } from '$lib/domain'; + import type { Vehicle } from '$lib/domain/vehicle'; import * as m from '$lib/paraglide/messages'; interface Props { diff --git a/src/lib/components/feature/vehicle/VehicleHubStats.svelte b/src/lib/components/feature/vehicle/VehicleHubStats.svelte index 156839a5..b8b43e4d 100644 --- a/src/lib/components/feature/vehicle/VehicleHubStats.svelte +++ b/src/lib/components/feature/vehicle/VehicleHubStats.svelte @@ -5,7 +5,7 @@ import Fuel from '@lucide/svelte/icons/fuel'; import Wrench from '@lucide/svelte/icons/wrench'; import { formatDistance, formatMileage } from '$lib/helper/format.helper'; - import type { Vehicle } from '$lib/domain'; + import type { Vehicle } from '$lib/domain/vehicle'; import * as m from '$lib/paraglide/messages'; interface Props { diff --git a/src/lib/components/feature/vehicle/VehicleListItem.svelte b/src/lib/components/feature/vehicle/VehicleListItem.svelte index 48ea1160..56c60a83 100644 --- a/src/lib/components/feature/vehicle/VehicleListItem.svelte +++ b/src/lib/components/feature/vehicle/VehicleListItem.svelte @@ -12,7 +12,7 @@ import { sheetStore } from '$stores/sheet.svelte'; import VehicleForm from './VehicleForm.svelte'; import * as m from '$lib/paraglide/messages'; - import type { Vehicle } from '$lib/domain'; + import type { Vehicle } from '$lib/domain/vehicle'; const { vehicle, diff --git a/src/lib/config/env.server.ts b/src/lib/config/env.server.ts index 463ad74c..740f3923 100644 --- a/src/lib/config/env.server.ts +++ b/src/lib/config/env.server.ts @@ -1,15 +1,13 @@ import { env as privateEnv } from '$env/dynamic/private'; -import { env as publicEnv } from '$env/dynamic/public'; +import { clientEnv as publicClientEnv } from './env'; /** - * Client-side environment configuration - * Only includes public environment variables that are safe to expose to the browser + * Client-side environment configuration, plus the server-only way of disabling auth + * (the private env var also works, so it can be set in container deployments). */ export const clientEnv = { - DEMO_MODE: publicEnv.TRACKTOR_DEMO_MODE === 'true', - // Allow disabling auth via either the public or private env var so it works in container deployments - DISABLE_AUTH: - publicEnv.TRACKTOR_DISABLE_AUTH === 'true' || privateEnv.TRACKTOR_DISABLE_AUTH === 'true' + ...publicClientEnv, + DISABLE_AUTH: publicClientEnv.DISABLE_AUTH || privateEnv.TRACKTOR_DISABLE_AUTH === 'true' } as const; function getCorsOrigins(origins?: string): string[] { diff --git a/src/lib/config/themes.ts b/src/lib/config/themes.ts index b84031c1..95c695c7 100644 --- a/src/lib/config/themes.ts +++ b/src/lib/config/themes.ts @@ -207,5 +207,3 @@ export const themes: Record = { } } }; - -export const themesList = Object.values(themes); diff --git a/src/lib/constants/timezones.ts b/src/lib/constants/timezones.ts deleted file mode 100644 index 4fa4c4e5..00000000 --- a/src/lib/constants/timezones.ts +++ /dev/null @@ -1,460 +0,0 @@ -/** - * Canonical IANA Timezone Database - * This provides a consistent list of timezones across all operating systems and Node.js versions. - * Unlike Intl.supportedValuesOf('timeZone'), this list remains constant regardless of the runtime environment. - * - * Source: IANA Time Zone Database (https://www.iana.org/time-zones) - * Last updated: 2024 - */ - -export const CANONICAL_TIMEZONES = [ - // UTC - 'UTC', - - // Africa - 'Africa/Abidjan', - 'Africa/Accra', - 'Africa/Addis_Ababa', - 'Africa/Algiers', - 'Africa/Asmara', - 'Africa/Bamako', - 'Africa/Bangui', - 'Africa/Banjul', - 'Africa/Bissau', - 'Africa/Blantyre', - 'Africa/Brazzaville', - 'Africa/Bujumbura', - 'Africa/Cairo', - 'Africa/Casablanca', - 'Africa/Ceuta', - 'Africa/Conakry', - 'Africa/Dakar', - 'Africa/Dar_es_Salaam', - 'Africa/Djibouti', - 'Africa/Douala', - 'Africa/El_Aaiun', - 'Africa/Freetown', - 'Africa/Gaborone', - 'Africa/Harare', - 'Africa/Johannesburg', - 'Africa/Juba', - 'Africa/Kampala', - 'Africa/Khartoum', - 'Africa/Kigali', - 'Africa/Kinshasa', - 'Africa/Lagos', - 'Africa/Libreville', - 'Africa/Lome', - 'Africa/Luanda', - 'Africa/Lubumbashi', - 'Africa/Lusaka', - 'Africa/Malabo', - 'Africa/Maputo', - 'Africa/Maseru', - 'Africa/Mbabane', - 'Africa/Mogadishu', - 'Africa/Monrovia', - 'Africa/Nairobi', - 'Africa/Ndjamena', - 'Africa/Niamey', - 'Africa/Nouakchott', - 'Africa/Ouagadougou', - 'Africa/Porto-Novo', - 'Africa/Sao_Tome', - 'Africa/Tripoli', - 'Africa/Tunis', - 'Africa/Windhoek', - - // America - North America - 'America/Adak', - 'America/Anchorage', - 'America/Anguilla', - 'America/Antigua', - 'America/Araguaina', - 'America/Argentina/Buenos_Aires', - 'America/Argentina/Catamarca', - 'America/Argentina/Cordoba', - 'America/Argentina/Jujuy', - 'America/Argentina/La_Rioja', - 'America/Argentina/Mendoza', - 'America/Argentina/Rio_Gallegos', - 'America/Argentina/Salta', - 'America/Argentina/San_Juan', - 'America/Argentina/San_Luis', - 'America/Argentina/Tucuman', - 'America/Argentina/Ushuaia', - 'America/Aruba', - 'America/Asuncion', - 'America/Atikokan', - 'America/Bahia', - 'America/Bahia_Banderas', - 'America/Barbados', - 'America/Belem', - 'America/Belize', - 'America/Blanc-Sablon', - 'America/Boa_Vista', - 'America/Bogota', - 'America/Boise', - 'America/Cambridge_Bay', - 'America/Campo_Grande', - 'America/Cancun', - 'America/Caracas', - 'America/Cayenne', - 'America/Cayman', - 'America/Chicago', - 'America/Chihuahua', - 'America/Costa_Rica', - 'America/Creston', - 'America/Cuiaba', - 'America/Curacao', - 'America/Danmarkshavn', - 'America/Dawson', - 'America/Dawson_Creek', - 'America/Denver', - 'America/Detroit', - 'America/Dominica', - 'America/Edmonton', - 'America/Eirunepe', - 'America/El_Salvador', - 'America/Fort_Nelson', - 'America/Fortaleza', - 'America/Glace_Bay', - 'America/Godthab', - 'America/Goose_Bay', - 'America/Grand_Turk', - 'America/Grenada', - 'America/Guadeloupe', - 'America/Guatemala', - 'America/Guayaquil', - 'America/Guyana', - 'America/Halifax', - 'America/Havana', - 'America/Hermosillo', - 'America/Indiana/Indianapolis', - 'America/Indiana/Knox', - 'America/Indiana/Marengo', - 'America/Indiana/Petersburg', - 'America/Indiana/Tell_City', - 'America/Indiana/Vevay', - 'America/Indiana/Vincennes', - 'America/Indiana/Winamac', - 'America/Inuvik', - 'America/Iqaluit', - 'America/Jamaica', - 'America/Juneau', - 'America/Kentucky/Louisville', - 'America/Kentucky/Monticello', - 'America/Kralendijk', - 'America/La_Paz', - 'America/Lima', - 'America/Los_Angeles', - 'America/Lower_Princes', - 'America/Maceio', - 'America/Managua', - 'America/Manaus', - 'America/Marigot', - 'America/Martinique', - 'America/Matamoros', - 'America/Mazatlan', - 'America/Menominee', - 'America/Merida', - 'America/Metlakatla', - 'America/Mexico_City', - 'America/Miquelon', - 'America/Moncton', - 'America/Monterrey', - 'America/Montevideo', - 'America/Montserrat', - 'America/Nassau', - 'America/New_York', - 'America/Nipigon', - 'America/Nome', - 'America/Noronha', - 'America/North_Dakota/Beulah', - 'America/North_Dakota/Center', - 'America/North_Dakota/New_Salem', - 'America/Nuuk', - 'America/Ojinaga', - 'America/Panama', - 'America/Pangnirtung', - 'America/Paramaribo', - 'America/Phoenix', - 'America/Port-au-Prince', - 'America/Port_of_Spain', - 'America/Porto_Velho', - 'America/Puerto_Rico', - 'America/Punta_Arenas', - 'America/Rainy_River', - 'America/Rankin_Inlet', - 'America/Recife', - 'America/Regina', - 'America/Resolute', - 'America/Rio_Branco', - 'America/Santarem', - 'America/Santiago', - 'America/Santo_Domingo', - 'America/Sao_Paulo', - 'America/Scoresbysund', - 'America/Sitka', - 'America/St_Barthelemy', - 'America/St_Johns', - 'America/St_Kitts', - 'America/St_Lucia', - 'America/St_Thomas', - 'America/St_Vincent', - 'America/Swift_Current', - 'America/Tegucigalpa', - 'America/Thule', - 'America/Thunder_Bay', - 'America/Tijuana', - 'America/Toronto', - 'America/Tortola', - 'America/Vancouver', - 'America/Whitehorse', - 'America/Winnipeg', - 'America/Yakutat', - 'America/Yellowknife', - - // Antarctica - 'Antarctica/Casey', - 'Antarctica/Davis', - 'Antarctica/DumontDUrville', - 'Antarctica/Macquarie', - 'Antarctica/Mawson', - 'Antarctica/McMurdo', - 'Antarctica/Palmer', - 'Antarctica/Rothera', - 'Antarctica/Syowa', - 'Antarctica/Troll', - 'Antarctica/Vostok', - - // Arctic - 'Arctic/Longyearbyen', - - // Asia - 'Asia/Aden', - 'Asia/Almaty', - 'Asia/Amman', - 'Asia/Anadyr', - 'Asia/Aqtau', - 'Asia/Aqtobe', - 'Asia/Ashgabat', - 'Asia/Atyrau', - 'Asia/Baghdad', - 'Asia/Bahrain', - 'Asia/Baku', - 'Asia/Bangkok', - 'Asia/Barnaul', - 'Asia/Beirut', - 'Asia/Bishkek', - 'Asia/Brunei', - 'Asia/Chita', - 'Asia/Choibalsan', - 'Asia/Colombo', - 'Asia/Damascus', - 'Asia/Dhaka', - 'Asia/Dili', - 'Asia/Dubai', - 'Asia/Dushanbe', - 'Asia/Famagusta', - 'Asia/Gaza', - 'Asia/Hebron', - 'Asia/Ho_Chi_Minh', - 'Asia/Hong_Kong', - 'Asia/Hovd', - 'Asia/Irkutsk', - 'Asia/Jakarta', - 'Asia/Jayapura', - 'Asia/Jerusalem', - 'Asia/Kabul', - 'Asia/Kamchatka', - 'Asia/Karachi', - 'Asia/Kathmandu', - 'Asia/Khandyga', - 'Asia/Kolkata', - 'Asia/Krasnoyarsk', - 'Asia/Kuala_Lumpur', - 'Asia/Kuching', - 'Asia/Kuwait', - 'Asia/Macau', - 'Asia/Magadan', - 'Asia/Makassar', - 'Asia/Manila', - 'Asia/Muscat', - 'Asia/Nicosia', - 'Asia/Novokuznetsk', - 'Asia/Novosibirsk', - 'Asia/Omsk', - 'Asia/Oral', - 'Asia/Phnom_Penh', - 'Asia/Pontianak', - 'Asia/Pyongyang', - 'Asia/Qatar', - 'Asia/Qostanay', - 'Asia/Qyzylorda', - 'Asia/Riyadh', - 'Asia/Sakhalin', - 'Asia/Samarkand', - 'Asia/Seoul', - 'Asia/Shanghai', - 'Asia/Singapore', - 'Asia/Srednekolymsk', - 'Asia/Taipei', - 'Asia/Tashkent', - 'Asia/Tbilisi', - 'Asia/Tehran', - 'Asia/Thimphu', - 'Asia/Tokyo', - 'Asia/Tomsk', - 'Asia/Ulaanbaatar', - 'Asia/Urumqi', - 'Asia/Ust-Nera', - 'Asia/Vientiane', - 'Asia/Vladivostok', - 'Asia/Yakutsk', - 'Asia/Yangon', - 'Asia/Yekaterinburg', - 'Asia/Yerevan', - - // Atlantic - 'Atlantic/Azores', - 'Atlantic/Bermuda', - 'Atlantic/Canary', - 'Atlantic/Cape_Verde', - 'Atlantic/Faroe', - 'Atlantic/Madeira', - 'Atlantic/Reykjavik', - 'Atlantic/South_Georgia', - 'Atlantic/St_Helena', - 'Atlantic/Stanley', - - // Australia - 'Australia/Adelaide', - 'Australia/Brisbane', - 'Australia/Broken_Hill', - 'Australia/Darwin', - 'Australia/Eucla', - 'Australia/Hobart', - 'Australia/Lindeman', - 'Australia/Lord_Howe', - 'Australia/Melbourne', - 'Australia/Perth', - 'Australia/Sydney', - - // Europe - 'Europe/Amsterdam', - 'Europe/Andorra', - 'Europe/Astrakhan', - 'Europe/Athens', - 'Europe/Belgrade', - 'Europe/Berlin', - 'Europe/Bratislava', - 'Europe/Brussels', - 'Europe/Bucharest', - 'Europe/Budapest', - 'Europe/Busingen', - 'Europe/Chisinau', - 'Europe/Copenhagen', - 'Europe/Dublin', - 'Europe/Gibraltar', - 'Europe/Guernsey', - 'Europe/Helsinki', - 'Europe/Isle_of_Man', - 'Europe/Istanbul', - 'Europe/Jersey', - 'Europe/Kaliningrad', - 'Europe/Kiev', - 'Europe/Kirov', - 'Europe/Lisbon', - 'Europe/Ljubljana', - 'Europe/London', - 'Europe/Luxembourg', - 'Europe/Madrid', - 'Europe/Malta', - 'Europe/Mariehamn', - 'Europe/Minsk', - 'Europe/Monaco', - 'Europe/Moscow', - 'Europe/Oslo', - 'Europe/Paris', - 'Europe/Podgorica', - 'Europe/Prague', - 'Europe/Riga', - 'Europe/Rome', - 'Europe/Samara', - 'Europe/San_Marino', - 'Europe/Sarajevo', - 'Europe/Saratov', - 'Europe/Simferopol', - 'Europe/Skopje', - 'Europe/Sofia', - 'Europe/Stockholm', - 'Europe/Tallinn', - 'Europe/Tirane', - 'Europe/Ulyanovsk', - 'Europe/Uzhgorod', - 'Europe/Vaduz', - 'Europe/Vatican', - 'Europe/Vienna', - 'Europe/Vilnius', - 'Europe/Volgograd', - 'Europe/Warsaw', - 'Europe/Zagreb', - 'Europe/Zaporozhye', - 'Europe/Zurich', - - // Indian Ocean - 'Indian/Antananarivo', - 'Indian/Chagos', - 'Indian/Christmas', - 'Indian/Cocos', - 'Indian/Comoro', - 'Indian/Kerguelen', - 'Indian/Mahe', - 'Indian/Maldives', - 'Indian/Mauritius', - 'Indian/Mayotte', - 'Indian/Reunion', - - // Pacific - 'Pacific/Apia', - 'Pacific/Auckland', - 'Pacific/Bougainville', - 'Pacific/Chatham', - 'Pacific/Chuuk', - 'Pacific/Easter', - 'Pacific/Efate', - 'Pacific/Enderbury', - 'Pacific/Fakaofo', - 'Pacific/Fiji', - 'Pacific/Funafuti', - 'Pacific/Galapagos', - 'Pacific/Gambier', - 'Pacific/Guadalcanal', - 'Pacific/Guam', - 'Pacific/Honolulu', - 'Pacific/Kiritimati', - 'Pacific/Kosrae', - 'Pacific/Kwajalein', - 'Pacific/Majuro', - 'Pacific/Marquesas', - 'Pacific/Midway', - 'Pacific/Nauru', - 'Pacific/Niue', - 'Pacific/Norfolk', - 'Pacific/Noumea', - 'Pacific/Pago_Pago', - 'Pacific/Palau', - 'Pacific/Pitcairn', - 'Pacific/Pohnpei', - 'Pacific/Port_Moresby', - 'Pacific/Rarotonga', - 'Pacific/Saipan', - 'Pacific/Tahiti', - 'Pacific/Tarawa', - 'Pacific/Tongatapu', - 'Pacific/Wake', - 'Pacific/Wallis' -] as const; - -export type CanonicalTimezone = (typeof CANONICAL_TIMEZONES)[number]; diff --git a/src/lib/domain/dashboard.ts b/src/lib/domain/dashboard.ts index 3944a457..0b6bf472 100644 --- a/src/lib/domain/dashboard.ts +++ b/src/lib/domain/dashboard.ts @@ -121,7 +121,7 @@ export interface WidgetLayoutItem { rowSpan: WidgetRowSpan; } -export const widgetLayoutItemSchema = z.object({ +const widgetLayoutItemSchema = z.object({ id: z.string(), type: z.enum(widgetTypeOptions), colStart: z.number().int().min(1).max(GRID_COLUMNS), diff --git a/src/lib/domain/fuel/mileage.ts b/src/lib/domain/fuel/mileage.ts index 574c694b..6519d077 100644 --- a/src/lib/domain/fuel/mileage.ts +++ b/src/lib/domain/fuel/mileage.ts @@ -16,7 +16,7 @@ export function computeLatestOdometer( return values.length > 0 ? Math.max(...values) : 0; } -export type MileageResult = { +type MileageResult = { distance: number; totalFuel: number; }; diff --git a/src/lib/domain/index.ts b/src/lib/domain/index.ts deleted file mode 100644 index e7fc4a72..00000000 --- a/src/lib/domain/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type DataPoint = { - x: Date | string; - y: number | null; -}; - -export type Response = { - status: 'OK' | 'ERROR'; - data?: DataType; - error?: string; -}; - -// Re-export types from other modules -export type { FuelLog, FuelSchema } from './fuel'; -export type { Vehicle, VehicleSchema } from './vehicle'; -export type { Insurance, InsuranceSchema } from './insurance'; -export type { PollutionCertificate, PollutionCertificateSchema } from './pucc'; -export type { MaintenanceLog, MaintenanceSchema } from './maintenance'; -export type { Config } from './config'; -export type { Reminder, ReminderSchema } from './reminder'; -export type { Notification, NotificationSchema } from './notification'; - -// Re-export constants -export { INSURANCE_RECURRENCE_TYPES, insuranceSchema } from './insurance'; -export { PUCC_RECURRENCE_TYPES, pollutionCertificateSchema } from './pucc'; -export { - REMINDER_TYPES, - REMINDER_SCHEDULES, - REMINDER_RECURRENCE_TYPES, - reminderSchema -} from './reminder'; -export { - NOTIFICATION_CHANNELS, - NOTIFICATION_TYPES, - NOTIFICATION_SOURCES, - notificationSchema -} from './notification'; diff --git a/src/lib/domain/notification-provider.ts b/src/lib/domain/notification-provider.ts index ff43b8b3..4af80b67 100644 --- a/src/lib/domain/notification-provider.ts +++ b/src/lib/domain/notification-provider.ts @@ -22,7 +22,7 @@ export type NotificationProviderType = z.infer; // Webhook Provider Configuration -export const webhookProviderConfigSchema = z.object({ +const webhookProviderConfigSchema = z.object({ url: z.string().url('Valid webhook URL is required'), method: z.enum(['POST', 'PUT', 'PATCH']).default('POST'), headers: z.record(z.string(), z.string()).optional(), @@ -57,7 +57,7 @@ export const webhookProviderConfigSchema = z.object({ export type WebhookProviderConfig = z.infer; // Gotify Provider Configuration -export const gotifyProviderConfigSchema = z.object({ +const gotifyProviderConfigSchema = z.object({ serverUrl: z.string().url('Valid Gotify server URL is required'), appToken: z.string().min(1, 'App token is required'), priority: z.number().int().min(0).max(10).default(5) @@ -83,7 +83,7 @@ export const notificationProviderConfigSchema = z.discriminatedUnion('type', [ export type NotificationProviderConfig = z.infer; -export const notificationProviderSchema = z.object({ +const notificationProviderSchema = z.object({ id: z.string().uuid(), name: z.string().min(1, 'Provider name is required').max(100), type: notificationProviderTypeSchema, @@ -94,7 +94,7 @@ export const notificationProviderSchema = z.object({ updated_at: z.string() }); -export type NotificationProvider = z.infer; +type NotificationProvider = z.infer; export const createNotificationProviderSchema = z.object({ name: z.string().min(1, 'Provider name is required').max(100), diff --git a/src/lib/domain/pucc.ts b/src/lib/domain/pucc.ts index 727dbea0..6b36cc7a 100644 --- a/src/lib/domain/pucc.ts +++ b/src/lib/domain/pucc.ts @@ -41,7 +41,7 @@ export interface PollutionCertificate { vehiclePlate?: string | null; } -export type PuccStatus = 'valid' | 'expiring_soon' | 'expired'; +type PuccStatus = 'valid' | 'expiring_soon' | 'expired'; /** Next date this certificate is due (issue/expiry + recurrence). Null when it never expires. */ export function getPuccNextDue(pucc: PollutionCertificate): Date | null { diff --git a/src/lib/domain/shared.ts b/src/lib/domain/shared.ts index b4ff23ac..502cfd0f 100644 --- a/src/lib/domain/shared.ts +++ b/src/lib/domain/shared.ts @@ -11,3 +11,15 @@ export const apiDateString = z /** Nullable/optional variant for fields that can be empty or omitted. */ export const optionalApiDateString = apiDateString.nullable().optional(); + +export type DataPoint = { + x: Date | string; + y: number | null; +}; + +/** Result envelope returned by the client-side `$lib/services/*` layer. */ +export type Response = { + status: 'OK' | 'ERROR'; + data?: DataType; + error?: string; +}; diff --git a/src/lib/helper/alert.helper.ts b/src/lib/helper/alert.helper.ts deleted file mode 100644 index 1a7c68c7..00000000 --- a/src/lib/helper/alert.helper.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { Insurance, PollutionCertificate } from '$lib/domain'; -import { differenceInDays } from 'date-fns'; -import * as m from '$lib/paraglide/messages'; - -export type VehicleAlertType = 'insurance' | 'pucc'; -export type VehicleAlertStatus = 'expired' | 'expiring' | 'valid' | 'missing'; - -export interface VehicleAlert { - type: VehicleAlertType; - status: VehicleAlertStatus; - title: string; - message: string; - daysRemaining: number; - expiryDate: Date | null; - hasRecord: boolean; -} - -const ALERT_THRESHOLD_DAYS = 30; - -const classifyStatus = (daysRemaining: number): VehicleAlertStatus => { - if (daysRemaining < 0) return 'expired'; - if (daysRemaining <= ALERT_THRESHOLD_DAYS) return 'expiring'; - return 'valid'; -}; - -const formatMessage = (type: VehicleAlertType, status: VehicleAlertStatus, days: number) => { - const label = type === 'insurance' ? m.alert_type_insurance() : m.alert_type_pucc(); - if (status === 'expired') { - return m.alert_status_expired_ago({ label, days: Math.abs(days) }); - } - if (status === 'expiring') { - return m.alert_status_expires_in({ label, days }); - } - return m.alert_status_valid_for({ label, days }); -}; - -const buildAlert = (type: VehicleAlertType, expiryDate: Date): VehicleAlert => { - const today = new Date(); - const daysRemaining = differenceInDays(expiryDate, today); - const status = classifyStatus(daysRemaining); - - return { - type, - status, - title: type === 'insurance' ? m.alert_type_insurance() : m.alert_type_pucc(), - message: formatMessage(type, status, daysRemaining), - daysRemaining, - expiryDate, - hasRecord: true - }; -}; - -const buildMissingAlert = (type: VehicleAlertType): VehicleAlert => { - const label = type === 'insurance' ? 'Insurance' : 'PUCC'; - return { - type, - status: 'missing', - title: type === 'insurance' ? m.alert_type_insurance() : m.alert_type_pucc(), - message: m.alert_record_not_found({ label }), - daysRemaining: Number.POSITIVE_INFINITY, - expiryDate: null, - hasRecord: false - }; -}; - -export const calculateInsuranceAlert = (insurances?: Insurance[] | null): VehicleAlert | null => { - if (!insurances || insurances.length === 0) return buildMissingAlert('insurance'); - - const perpetual = insurances.find( - (insurance) => !insurance.endDate || insurance.recurrenceType === 'no_end' - ); - if (perpetual) { - return { - type: 'insurance', - status: 'valid', - title: m.alert_type_insurance(), - message: m.alert_insurance_active_no_end(), - daysRemaining: Number.POSITIVE_INFINITY, - expiryDate: null, - hasRecord: true - }; - } - - const latest = insurances.reduce((latest, current) => { - return new Date(current.endDate!) > new Date(latest.endDate!) ? current : latest; - }); - return buildAlert('insurance', new Date(latest.endDate!)); -}; - -export const calculatePuccAlert = ( - certificates?: PollutionCertificate[] | null -): VehicleAlert | null => { - if (!certificates || certificates.length === 0) return buildMissingAlert('pucc'); - - const perpetual = certificates.find( - (certificate) => !certificate.expiryDate || certificate.recurrenceType === 'no_end' - ); - if (perpetual) { - return { - type: 'pucc', - status: 'valid', - title: m.alert_type_pucc(), - message: m.alert_pucc_active_no_end(), - daysRemaining: Number.POSITIVE_INFINITY, - expiryDate: null, - hasRecord: true - }; - } - - const latest = certificates.reduce((latest, current) => { - return new Date(current.expiryDate!) > new Date(latest.expiryDate!) ? current : latest; - }); - return buildAlert('pucc', new Date(latest.expiryDate!)); -}; - -export const calculateVehicleAlerts = ( - insurances?: Insurance[] | null, - certificates?: PollutionCertificate[] | null -): VehicleAlert[] => { - return [calculateInsuranceAlert(insurances), calculatePuccAlert(certificates)].filter( - (alert): alert is VehicleAlert => Boolean(alert) - ); -}; diff --git a/src/lib/helper/csv.helper.ts b/src/lib/helper/csv.helper.ts index 46da9112..7bc10535 100644 --- a/src/lib/helper/csv.helper.ts +++ b/src/lib/helper/csv.helper.ts @@ -1,12 +1,12 @@ // Stub helpers for CSV import flows. Implement the real parsing and persistence logic here. -export type CsvParseOptions = { +type CsvParseOptions = { delimiter: string; hasHeaders: boolean; }; export type ParsedCsvRow = Record; -export type ParsedCsv = { +type ParsedCsv = { headers: string[]; rows: ParsedCsvRow[]; }; diff --git a/src/lib/helper/http.helper.ts b/src/lib/helper/http.helper.ts index 22591df8..39ef835c 100644 --- a/src/lib/helper/http.helper.ts +++ b/src/lib/helper/http.helper.ts @@ -1,185 +1,95 @@ +/* global BodyInit */ + interface RequestConfig { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; headers?: Record; - params?: Record; data?: any; timeout?: number; - baseURL?: string; - skipInterceptors?: boolean; - responseType?: 'json' | 'blob' | 'text'; + responseType?: 'json' | 'blob'; } interface Response { data: T; status: number; - statusText: string; - headers: Headers; - config: RequestConfig; } class HttpError extends Error { - response?: Response; - request?: RequestConfig; - status?: number; - - constructor(message: string, config?: RequestConfig, response?: Response) { + constructor( + message: string, + readonly status?: number, + readonly response?: Response + ) { super(message); this.name = 'HttpError'; - this.request = config; - this.response = response; - this.status = response?.status; } } class HttpClient { - private baseURL: string = ''; - private defaultHeaders: Record = { - 'Content-Type': 'application/json' - }; - private timeout: number = 10000; - - constructor(config?: { baseURL?: string; headers?: Record; timeout?: number }) { - if (config?.baseURL) this.baseURL = config.baseURL; - if (config?.headers) this.defaultHeaders = { ...this.defaultHeaders, ...config.headers }; - if (config?.timeout) this.timeout = config.timeout; - } - - private buildURL(url: string, params?: Record): string { - const fullURL = url.startsWith('http') ? url : `${this.baseURL}${url}`; - - if (!params) return fullURL; - - const searchParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - searchParams.append(key, String(value)); - } - }); - - const paramString = searchParams.toString(); - return paramString ? `${fullURL}?${paramString}` : fullURL; - } - - private async makeRequest( - url: string, - config: RequestConfig = {} - ): Promise> { - const { - method = 'GET', - headers = {}, - params, - data, - timeout = this.timeout, - baseURL = this.baseURL - } = config; - - const fullURL = this.buildURL(url, params); - const mergedHeaders = { ...this.defaultHeaders, ...headers }; - - /* global RequestInit */ - const fetchOptions: RequestInit = { - method, - headers: mergedHeaders + constructor( + private config: { baseURL?: string; headers?: Record; timeout?: number } = {} + ) {} + + private async request(url: string, config: RequestConfig): Promise> { + const isFormData = config.data instanceof FormData; + const headers: Record = { + // FormData sets its own Content-Type so it carries the multipart boundary. + ...(isFormData ? {} : { 'Content-Type': 'application/json' }), + ...this.config.headers, + ...config.headers }; + let body: BodyInit | undefined; - if (data && method !== 'GET') { - if (data instanceof FormData) { - fetchOptions.body = data; - // Remove Content-Type to let browser set it with boundary - delete mergedHeaders['Content-Type']; - } else if (typeof data === 'object') { - fetchOptions.body = JSON.stringify(data); - } else { - fetchOptions.body = data; - } + if (config.data !== undefined && config.method !== 'GET') { + body = + isFormData || typeof config.data !== 'object' ? config.data : JSON.stringify(config.data); } - // Timeout handling - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - fetchOptions.signal = controller.signal; - + let response: globalThis.Response; try { - const response = await fetch(fullURL, fetchOptions); - - let responseData: T; - const contentType = response.headers.get('content-type'); - - if (config.responseType === 'blob') { - responseData = (await response.blob()) as T; - } else if (contentType?.includes('application/json')) { - responseData = await response.json(); - } else { - responseData = (await response.text()) as T; - } - - clearTimeout(timeoutId); - - const result: Response = { - data: responseData, - status: response.status, - statusText: response.statusText, - headers: response.headers, - config - }; - - if (!response.ok) { - throw new HttpError(`Request failed with status ${response.status}`, config, result); - } - - return result; + response = await fetch(url.startsWith('http') ? url : `${this.config.baseURL ?? ''}${url}`, { + method: config.method, + headers, + body, + signal: AbortSignal.timeout(config.timeout ?? this.config.timeout ?? 10000) + }); } catch (error: any) { - clearTimeout(timeoutId); - - if (error instanceof HttpError) { - throw error; - } - - if (error.name === 'AbortError') { - throw new HttpError('Request timeout', config); - } + throw new HttpError(error?.name === 'TimeoutError' ? 'Request timeout' : 'Network error'); + } - throw new HttpError(error.message || 'Network error', config); + const contentType = response.headers.get('content-type'); + const data = ( + config.responseType === 'blob' + ? await response.blob() + : contentType?.includes('application/json') + ? await response.json() + : await response.text() + ) as T; + + const result = { data, status: response.status }; + if (!response.ok) { + throw new HttpError(`Request failed with status ${response.status}`, response.status, result); } + return result; } - // Main HTTP methods - async get( - url: string, - config?: Omit - ): Promise> { - return this.makeRequest(url, { ...config, method: 'GET' }); + get(url: string, config: RequestConfig = {}) { + return this.request(url, { ...config, method: 'GET' }); } - async post( - url: string, - data?: any, - config?: Omit - ): Promise> { - return this.makeRequest(url, { ...config, method: 'POST', data }); + post(url: string, data?: any, config: RequestConfig = {}) { + return this.request(url, { ...config, method: 'POST', data }); } - async put( - url: string, - data?: any, - config?: Omit - ): Promise> { - return this.makeRequest(url, { ...config, method: 'PUT', data }); + put(url: string, data?: any, config: RequestConfig = {}) { + return this.request(url, { ...config, method: 'PUT', data }); } - async patch( - url: string, - data?: any, - config?: Omit - ): Promise> { - return this.makeRequest(url, { ...config, method: 'PATCH', data }); + patch(url: string, data?: any, config: RequestConfig = {}) { + return this.request(url, { ...config, method: 'PATCH', data }); } - async delete( - url: string, - config?: Omit - ): Promise> { - return this.makeRequest(url, { ...config, method: 'DELETE' }); + delete(url: string, config: RequestConfig = {}) { + return this.request(url, { ...config, method: 'DELETE' }); } } diff --git a/src/lib/helper/label.helper.ts b/src/lib/helper/label.helper.ts deleted file mode 100644 index 875a3a89..00000000 --- a/src/lib/helper/label.helper.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function makeLabelHelper( - map: Record string>) => string>, - defaultKey: T -) { - return (value: string, m: Record string>): string => { - return map[value as T]?.(m) ?? map[defaultKey](m); - }; -} diff --git a/src/lib/helper/recurrence.helper.ts b/src/lib/helper/recurrence.helper.ts index 1357a07b..e2bf4135 100644 --- a/src/lib/helper/recurrence.helper.ts +++ b/src/lib/helper/recurrence.helper.ts @@ -9,7 +9,7 @@ * @param interval - The interval (e.g., every 1 year, every 2 months) * @returns The next occurrence date */ -export function calculateNextOccurrence( +function calculateNextOccurrence( currentDate: Date, recurrenceType: string, interval: number = 1 @@ -42,7 +42,7 @@ export function calculateNextOccurrence( * @param recurrenceType - Type of recurrence * @returns true if the date should recur */ -export function shouldRecur(recurrenceType: string): boolean { +function shouldRecur(recurrenceType: string): boolean { return ['yearly', 'monthly', 'weekly', 'daily'].includes(recurrenceType); } diff --git a/src/lib/helper/settings-form.helper.ts b/src/lib/helper/settings-form.helper.ts index af613dd8..857919c0 100644 --- a/src/lib/helper/settings-form.helper.ts +++ b/src/lib/helper/settings-form.helper.ts @@ -4,7 +4,7 @@ import { data as currencies } from 'currency-codes'; import { getCurrencySymbol } from '$lib/helper/format.helper'; import { z } from 'zod/v4'; -export const settingsConfigSchema = z.object({ +const settingsConfigSchema = z.object({ dateFormat: z.string(), locale: z.string().min(2), timezone: z.string().min(3), diff --git a/src/lib/helper/timezone.helper.ts b/src/lib/helper/timezone.helper.ts index b91bf82a..3eba8d16 100644 --- a/src/lib/helper/timezone.helper.ts +++ b/src/lib/helper/timezone.helper.ts @@ -1,5 +1,7 @@ import { formatInTimeZone } from 'date-fns-tz'; -import { CANONICAL_TIMEZONES } from '$lib/constants/timezones'; + +// Intl omits UTC itself from the zone list, but it's the app's default timezone. +const TIMEZONES = ['UTC', ...Intl.supportedValuesOf('timeZone')]; export const getTimezoneOptions = (): { value: string; @@ -8,24 +10,14 @@ export const getTimezoneOptions = (): { }[] => { const referenceDate = new Date('2024-01-01T12:00:00Z'); - return CANONICAL_TIMEZONES.map((zone) => { - try { - const offset = formatInTimeZone(referenceDate, zone, 'xxx'); - return { - value: zone, - label: `[${offset}] ${zone}`, - offset: Number(offset.replace(':', '')) - }; - } catch (e) { - return { - value: zone, - label: zone, - offset: 0 - }; - } + return TIMEZONES.map((zone) => { + const offset = formatInTimeZone(referenceDate, zone, 'xxx'); + return { + value: zone, + label: `[${offset}] ${zone}`, + offset: Number(offset.replace(':', '')) + }; }).sort((a, b) => a.offset - b.offset); }; -export const isValidTimezone = (tz: string) => { - return CANONICAL_TIMEZONES.includes(tz as any); -}; +export const isValidTimezone = (tz: string) => TIMEZONES.includes(tz); diff --git a/src/lib/helper/unit.helper.ts b/src/lib/helper/unit.helper.ts index 0d7ba8b7..23c606c8 100644 --- a/src/lib/helper/unit.helper.ts +++ b/src/lib/helper/unit.helper.ts @@ -59,7 +59,7 @@ export const formatCurrency = (amount: number, config: FormatConfig): string => }).format(amount); }; -export const getFuelVolumeUnit = (fuelType: string, config: FormatConfig): string => { +const getFuelVolumeUnit = (fuelType: string, config: FormatConfig): string => { switch (fuelType) { case 'lpg': return config.unitOfLpg || config.unitOfVolume; diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 723d001f..00000000 --- a/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type ApiResponse } from './response'; diff --git a/src/lib/scope/vehicle-scope.svelte.ts b/src/lib/scope/vehicle-scope.svelte.ts index 365119fc..c1220d8d 100644 --- a/src/lib/scope/vehicle-scope.svelte.ts +++ b/src/lib/scope/vehicle-scope.svelte.ts @@ -1,9 +1,9 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; -import type { Vehicle } from '$lib/domain'; +import type { Vehicle } from '$lib/domain/vehicle'; /** Query param used to express the current vehicle scope in the URL. */ -export const SCOPE_PARAM = 'vehicle'; +const SCOPE_PARAM = 'vehicle'; export interface VehicleScope { vehicleId?: string; diff --git a/src/lib/services/config.service.ts b/src/lib/services/config.service.ts index 358a6fe9..c4afbe32 100644 --- a/src/lib/services/config.service.ts +++ b/src/lib/services/config.service.ts @@ -1,4 +1,5 @@ -import type { Config, Response } from '$lib/domain'; +import type { Config } from '$lib/domain/config'; +import type { Response } from '$lib/domain/shared'; import { apiClient } from '$lib/helper/api.helper'; export const saveConfig = async (configs: Config[]): Promise> => { diff --git a/src/lib/services/entity-service.ts b/src/lib/services/entity-service.ts index ff35721f..3ef0e875 100644 --- a/src/lib/services/entity-service.ts +++ b/src/lib/services/entity-service.ts @@ -1,8 +1,8 @@ -import type { Response } from '$lib/domain'; +import type { Response } from '$lib/domain/shared'; import { apiClient } from '$lib/helper/api.helper'; import { uploadFile } from './file.service'; -export function extractApiError(e: unknown, fallback: string): string { +function extractApiError(e: unknown, fallback: string): string { const err = e as { response?: { data?: { message?: string } } }; return err.response?.data?.message || fallback; } diff --git a/src/lib/services/file.service.ts b/src/lib/services/file.service.ts index e609c8c0..27100380 100644 --- a/src/lib/services/file.service.ts +++ b/src/lib/services/file.service.ts @@ -1,5 +1,5 @@ import { HttpClient } from '$lib/helper/http.helper'; -import type { Response } from '$lib/domain'; +import type { Response } from '$lib/domain/shared'; import type { ApiResponse } from '$lib/response'; import { withBase } from '$lib/utils'; diff --git a/src/lib/services/fuel.service.ts b/src/lib/services/fuel.service.ts index 02028c62..95659596 100644 --- a/src/lib/services/fuel.service.ts +++ b/src/lib/services/fuel.service.ts @@ -1,4 +1,4 @@ -import type { FuelLog } from '$lib/domain'; +import type { FuelLog } from '$lib/domain/fuel'; import { createEntityService } from './entity-service'; const { diff --git a/src/lib/services/insurance.service.ts b/src/lib/services/insurance.service.ts index 695e24ce..0f4833c5 100644 --- a/src/lib/services/insurance.service.ts +++ b/src/lib/services/insurance.service.ts @@ -1,14 +1,9 @@ -import type { Insurance } from '$lib/domain'; +import type { Insurance } from '$lib/domain/insurance'; import { createEntityService } from './entity-service'; -const { - save, - saveWithAttachment, - delete: remove -} = createEntityService({ +const { saveWithAttachment, delete: remove } = createEntityService({ basePath: 'insurance' }); -export const saveInsurance = save; export const saveInsuranceWithAttachment = saveWithAttachment; export const deleteInsurance = remove; diff --git a/src/lib/services/maintenance.service.ts b/src/lib/services/maintenance.service.ts index de7c5573..657898fe 100644 --- a/src/lib/services/maintenance.service.ts +++ b/src/lib/services/maintenance.service.ts @@ -1,16 +1,11 @@ -import type { MaintenanceLog } from '$lib/domain'; +import type { MaintenanceLog } from '$lib/domain/maintenance'; import { createEntityService } from './entity-service'; import { apiClient } from '$lib/helper/api.helper'; -const { - save, - saveWithAttachment, - delete: remove -} = createEntityService({ +const { saveWithAttachment, delete: remove } = createEntityService({ basePath: 'maintenance-logs' }); -export const saveMaintenanceLog = save; export const saveMaintenanceLogWithAttachment = saveWithAttachment; export const deleteMaintenanceLog = remove; diff --git a/src/lib/services/notification-provider.service.ts b/src/lib/services/notification-provider.service.ts index 945f02fa..81a17a25 100644 --- a/src/lib/services/notification-provider.service.ts +++ b/src/lib/services/notification-provider.service.ts @@ -17,16 +17,6 @@ export const getProviders = async (): Promise => { - try { - const response = await apiClient.get(`/notification-providers/${id}`); - return response.data.data; - } catch (error) { - console.error('Failed to fetch notification provider:', error); - throw error; - } -}; - export const createProvider = async ( provider: CreateNotificationProvider ): Promise => { diff --git a/src/lib/services/notification.service.ts b/src/lib/services/notification.service.ts index b3adaf30..448bc12c 100644 --- a/src/lib/services/notification.service.ts +++ b/src/lib/services/notification.service.ts @@ -1,4 +1,5 @@ -import type { Notification, Response } from '$lib/domain'; +import type { Notification } from '$lib/domain/notification'; +import type { Response } from '$lib/domain/shared'; import { apiClient } from '$lib/helper/api.helper'; export const getNotifications = async (vehicleId: string): Promise> => { diff --git a/src/lib/services/pucc.service.ts b/src/lib/services/pucc.service.ts index 2e679cd1..eaefae34 100644 --- a/src/lib/services/pucc.service.ts +++ b/src/lib/services/pucc.service.ts @@ -1,18 +1,9 @@ -import type { PollutionCertificate } from '$lib/domain'; +import type { PollutionCertificate } from '$lib/domain/pucc'; import { createEntityService } from './entity-service'; -const { - save, - saveWithAttachment, - delete: remove -} = createEntityService({ +const { saveWithAttachment, delete: remove } = createEntityService({ basePath: 'pucc' }); -export const savePucc = save; -export const savePuccWithAttachment = saveWithAttachment; -export const deletePucc = remove; - -export const savePollutionCertificate = save; export const savePollutionCertificateWithAttachment = saveWithAttachment; export const deletePollutionCertificate = remove; diff --git a/src/lib/services/reminder.service.ts b/src/lib/services/reminder.service.ts index cafc7766..610d88dc 100644 --- a/src/lib/services/reminder.service.ts +++ b/src/lib/services/reminder.service.ts @@ -1,4 +1,4 @@ -import type { Reminder } from '$lib/domain'; +import type { Reminder } from '$lib/domain/reminder'; import { createEntityService } from './entity-service'; const { save, delete: remove } = createEntityService({ diff --git a/src/lib/services/vehicle.service.ts b/src/lib/services/vehicle.service.ts index 7ac29c2b..2884501c 100644 --- a/src/lib/services/vehicle.service.ts +++ b/src/lib/services/vehicle.service.ts @@ -1,4 +1,5 @@ -import type { Response, Vehicle } from '$lib/domain'; +import type { Response } from '$lib/domain/shared'; +import type { Vehicle } from '$lib/domain/vehicle'; import { apiClient } from '$lib/helper/api.helper'; import { uploadFile } from './file.service'; diff --git a/src/lib/stores/auth.svelte.ts b/src/lib/stores/auth.svelte.ts index c0d6b0a4..11d59bb5 100644 --- a/src/lib/stores/auth.svelte.ts +++ b/src/lib/stores/auth.svelte.ts @@ -28,9 +28,7 @@ class AuthStore { } try { - const { data: res } = await apiClient.get('/auth', { - skipInterceptors: true - }); + const { data: res } = await apiClient.get('/auth'); this.isAuthDisabled = !!res.data?.isAuthDisabled; this.hasUsers = res.data?.hasUsers ?? false; @@ -57,11 +55,7 @@ class AuthStore { login = async (username: string, password: string) => { try { - const { data: res } = await apiClient.post( - '/auth', - { username, password }, - { skipInterceptors: true } - ); + const { data: res } = await apiClient.post('/auth', { username, password }); if (res.success && res.data) { this.user = res.data.user; @@ -81,11 +75,10 @@ class AuthStore { register = async (username: string, password: string) => { try { - const { data: res } = await apiClient.post( - '/auth/register', - { username, password }, - { skipInterceptors: true } - ); + const { data: res } = await apiClient.post('/auth/register', { + username, + password + }); if (res.success) { toast.success('User created successfully. Please login.'); diff --git a/src/lib/stores/chart.svelte.ts b/src/lib/stores/chart.svelte.ts index 74f383d7..82401d02 100644 --- a/src/lib/stores/chart.svelte.ts +++ b/src/lib/stores/chart.svelte.ts @@ -1,4 +1,5 @@ -import type { DataPoint, FuelLog } from '$lib/domain'; +import type { FuelLog } from '$lib/domain/fuel'; +import type { DataPoint } from '$lib/domain/shared'; import { fuelLogStore } from './fuel-log.svelte'; const calculateCostData = (logs: FuelLog[]) => { diff --git a/src/lib/stores/fuel-log.svelte.ts b/src/lib/stores/fuel-log.svelte.ts index deab26c9..580c1538 100644 --- a/src/lib/stores/fuel-log.svelte.ts +++ b/src/lib/stores/fuel-log.svelte.ts @@ -1,4 +1,4 @@ -import type { FuelLog } from '$lib/domain'; +import type { FuelLog } from '$lib/domain/fuel'; import { compareDesc } from 'date-fns'; import { createEntityStore } from './entity-store.svelte'; diff --git a/src/lib/stores/insurance.svelte.ts b/src/lib/stores/insurance.svelte.ts index 4c5be95f..5241e330 100644 --- a/src/lib/stores/insurance.svelte.ts +++ b/src/lib/stores/insurance.svelte.ts @@ -1,4 +1,4 @@ -import type { Insurance } from '$lib/domain'; +import type { Insurance } from '$lib/domain/insurance'; import { createEntityStore } from './entity-store.svelte'; const entityStore = createEntityStore({ diff --git a/src/lib/stores/maintenance.svelte.ts b/src/lib/stores/maintenance.svelte.ts index 846b791e..45e73653 100644 --- a/src/lib/stores/maintenance.svelte.ts +++ b/src/lib/stores/maintenance.svelte.ts @@ -1,4 +1,4 @@ -import type { MaintenanceLog } from '$lib/domain'; +import type { MaintenanceLog } from '$lib/domain/maintenance'; import { compareDesc } from 'date-fns'; import { createEntityStore } from './entity-store.svelte'; diff --git a/src/lib/stores/notification.svelte.ts b/src/lib/stores/notification.svelte.ts index 10de83d9..c2c8f66b 100644 --- a/src/lib/stores/notification.svelte.ts +++ b/src/lib/stores/notification.svelte.ts @@ -11,7 +11,7 @@ import * as m from '$lib/paraglide/messages/_index.js'; type NotificationType = Notification['type']; -export const NAVIGATION_MAP: Record = { +const NAVIGATION_MAP: Record = { information: '/dashboard', insurance: '/insurance', pollution: '/pollution', diff --git a/src/lib/stores/pucc.svelte.ts b/src/lib/stores/pucc.svelte.ts index a8728253..060e460a 100644 --- a/src/lib/stores/pucc.svelte.ts +++ b/src/lib/stores/pucc.svelte.ts @@ -1,4 +1,4 @@ -import type { PollutionCertificate } from '$lib/domain'; +import type { PollutionCertificate } from '$lib/domain/pucc'; import { createEntityStore } from './entity-store.svelte'; const entityStore = createEntityStore({ diff --git a/src/lib/stores/reminder.svelte.ts b/src/lib/stores/reminder.svelte.ts index 61d9376a..4ad00bd9 100644 --- a/src/lib/stores/reminder.svelte.ts +++ b/src/lib/stores/reminder.svelte.ts @@ -1,4 +1,4 @@ -import type { Reminder } from '$lib/domain'; +import type { Reminder } from '$lib/domain/reminder'; import { createEntityStore } from './entity-store.svelte'; function parseReminder(raw: unknown): Reminder { diff --git a/src/lib/stores/vehicle.svelte.ts b/src/lib/stores/vehicle.svelte.ts index bf156bc1..3000ff3f 100644 --- a/src/lib/stores/vehicle.svelte.ts +++ b/src/lib/stores/vehicle.svelte.ts @@ -1,4 +1,4 @@ -import type { Vehicle } from '$lib/domain'; +import type { Vehicle } from '$lib/domain/vehicle'; import { apiClient } from '$lib/helper/api.helper'; import type { ApiResponse } from '$lib/response'; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d89395e4..9a02d407 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -36,7 +36,7 @@ const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur', 'yi']; * @param locale - Language/locale code (e.g., 'ar', 'en') * @returns true if the language is RTL */ -export function isRtlLanguage(locale: string): boolean { +function isRtlLanguage(locale: string): boolean { return RTL_LANGUAGES.includes(locale.toLowerCase().split('-')[0]); } diff --git a/src/lib/utils/theme.ts b/src/lib/utils/theme.ts index 43d49ed6..b2d60ab8 100644 --- a/src/lib/utils/theme.ts +++ b/src/lib/utils/theme.ts @@ -22,7 +22,7 @@ export function saveTheme(theme: ThemeName): void { /** * Check if dark mode is currently active */ -export function isDarkMode(): boolean { +function isDarkMode(): boolean { if (typeof document === 'undefined') return false; return document.documentElement.classList.contains('dark'); } @@ -58,19 +58,6 @@ export function applyThemeColors( }); } -/** - * Reset theme to default (remove all custom theme variables) - */ -export function resetThemeColors(): void { - if (typeof document === 'undefined') return; - const root = document.documentElement; - const customProps = ['primary', 'primary-foreground', 'ring']; - - customProps.forEach((prop) => { - root.style.removeProperty(`--${prop}`); - }); -} - /** * Add or remove theme class from HTML element */ @@ -79,11 +66,3 @@ export function setThemeClass(theme: ThemeName): void { const root = document.documentElement; root.setAttribute('data-theme', theme); } - -/** - * Get the current system theme preference - */ -export function getSystemTheme(): 'light' | 'dark' { - if (typeof window === 'undefined') return 'light'; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; -} diff --git a/src/routes/(app)/reminders/+page.svelte b/src/routes/(app)/reminders/+page.svelte index be2a201f..7af16d0c 100644 --- a/src/routes/(app)/reminders/+page.svelte +++ b/src/routes/(app)/reminders/+page.svelte @@ -8,7 +8,7 @@ import ReminderList from '$feature/reminder/ReminderList.svelte'; import LabelWithIcon from '$appui/LabelWithIcon.svelte'; import Button from '$ui/button/button.svelte'; - import type { Reminder } from '$lib/domain'; + import type { Reminder } from '$lib/domain/reminder'; import * as m from '$lib/paraglide/messages'; import { feature_reminders_disabled_title, diff --git a/src/routes/api/vehicles/[id]/fuel-logs/+server.ts b/src/routes/api/vehicles/[id]/fuel-logs/+server.ts index 1b11f4e3..d228f97b 100644 --- a/src/routes/api/vehicles/[id]/fuel-logs/+server.ts +++ b/src/routes/api/vehicles/[id]/fuel-logs/+server.ts @@ -3,14 +3,6 @@ import * as fuelLogService from '$server/services/fuelLogService'; import { fuelSchema } from '$lib/domain/fuel'; import { jsonResponse, parseBody, withRouteErrorHandling } from '$server/utils/route-handler'; -export const GET: RequestHandler = async (event) => { - return withRouteErrorHandling('Fuel logs GET error:', async () => { - const { id } = event.params; - const result = await fuelLogService.getFuelLogs(id); - return jsonResponse(result); - }); -}; - export const POST: RequestHandler = async (event) => { return withRouteErrorHandling('Fuel logs POST error:', async () => { const { id } = event.params; diff --git a/src/routes/api/vehicles/[id]/fuel-logs/[logId]/+server.ts b/src/routes/api/vehicles/[id]/fuel-logs/[logId]/+server.ts index 2450ed77..6cd73492 100644 --- a/src/routes/api/vehicles/[id]/fuel-logs/[logId]/+server.ts +++ b/src/routes/api/vehicles/[id]/fuel-logs/[logId]/+server.ts @@ -23,8 +23,8 @@ export const PUT: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => { return withRouteErrorHandling('Fuel log DELETE error:', async () => { - const { logId } = event.params; - const result = await fuelLogService.deleteFuelLog(logId); + const { id, logId } = event.params; + const result = await fuelLogService.deleteFuelLog(id, logId); return jsonResponse(result); }); }; diff --git a/src/routes/api/vehicles/[id]/insurance/+server.ts b/src/routes/api/vehicles/[id]/insurance/+server.ts index 6c55f495..7304c717 100644 --- a/src/routes/api/vehicles/[id]/insurance/+server.ts +++ b/src/routes/api/vehicles/[id]/insurance/+server.ts @@ -3,14 +3,6 @@ import * as insuranceService from '$server/services/insuranceService'; import { insuranceSchema } from '$lib/domain/insurance'; import { jsonResponse, parseBody, withRouteErrorHandling } from '$server/utils/route-handler'; -export const GET: RequestHandler = async (event) => { - return withRouteErrorHandling('Insurance GET error:', async () => { - const { id } = event.params; - const result = await insuranceService.getInsurances(id); - return jsonResponse(result); - }); -}; - export const POST: RequestHandler = async (event) => { return withRouteErrorHandling('Insurance POST error:', async () => { const { id } = event.params; diff --git a/src/routes/api/vehicles/[id]/insurance/[insuranceId]/+server.ts b/src/routes/api/vehicles/[id]/insurance/[insuranceId]/+server.ts index 76ce3eab..7519ccab 100644 --- a/src/routes/api/vehicles/[id]/insurance/[insuranceId]/+server.ts +++ b/src/routes/api/vehicles/[id]/insurance/[insuranceId]/+server.ts @@ -28,8 +28,8 @@ export const PUT: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => { return withRouteErrorHandling('Insurance DELETE error:', async () => { - const { insuranceId } = event.params; - const result = await insuranceService.deleteInsurance(insuranceId); + const { id, insuranceId } = event.params; + const result = await insuranceService.deleteInsurance(id, insuranceId); return jsonResponse(result); }); }; diff --git a/src/routes/api/vehicles/[id]/maintenance-logs/+server.ts b/src/routes/api/vehicles/[id]/maintenance-logs/+server.ts index bae36a41..02037f4b 100644 --- a/src/routes/api/vehicles/[id]/maintenance-logs/+server.ts +++ b/src/routes/api/vehicles/[id]/maintenance-logs/+server.ts @@ -3,14 +3,6 @@ import * as maintenanceLogService from '$server/services/maintenanceLogService'; import { maintenanceSchema } from '$lib/domain/maintenance'; import { jsonResponse, parseBody, withRouteErrorHandling } from '$server/utils/route-handler'; -export const GET: RequestHandler = async (event) => { - return withRouteErrorHandling('Maintenance logs GET error:', async () => { - const { id } = event.params; - const result = await maintenanceLogService.getMaintenanceLogs(id); - return jsonResponse(result); - }); -}; - export const POST: RequestHandler = async (event) => { return withRouteErrorHandling('Maintenance logs POST error:', async () => { const { id } = event.params; diff --git a/src/routes/api/vehicles/[id]/pucc/+server.ts b/src/routes/api/vehicles/[id]/pucc/+server.ts index 34555fea..e77315c3 100644 --- a/src/routes/api/vehicles/[id]/pucc/+server.ts +++ b/src/routes/api/vehicles/[id]/pucc/+server.ts @@ -3,14 +3,6 @@ import * as pollutionCertificateService from '$server/services/pollutionCertific import { pollutionCertificateSchema } from '$lib/domain/pucc'; import { jsonResponse, parseBody, withRouteErrorHandling } from '$server/utils/route-handler'; -export const GET: RequestHandler = async (event) => { - return withRouteErrorHandling('PUCC GET error:', async () => { - const { id } = event.params; - const result = await pollutionCertificateService.getPollutionCertificates(id); - return jsonResponse(result); - }); -}; - export const POST: RequestHandler = async (event) => { return withRouteErrorHandling('PUCC POST error:', async () => { const { id } = event.params; diff --git a/src/routes/api/vehicles/[id]/pucc/[puccId]/+server.ts b/src/routes/api/vehicles/[id]/pucc/[puccId]/+server.ts index 48a3b60e..eeac4893 100644 --- a/src/routes/api/vehicles/[id]/pucc/[puccId]/+server.ts +++ b/src/routes/api/vehicles/[id]/pucc/[puccId]/+server.ts @@ -32,8 +32,8 @@ export const PUT: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => { return withRouteErrorHandling('PUCC DELETE error:', async () => { - const { puccId } = event.params; - const result = await pollutionCertificateService.deletePollutionCertificate(puccId); + const { id, puccId } = event.params; + const result = await pollutionCertificateService.deletePollutionCertificate(id, puccId); return jsonResponse(result); }); }; diff --git a/src/routes/api/vehicles/[id]/reminders/+server.ts b/src/routes/api/vehicles/[id]/reminders/+server.ts index b679a224..27bccb15 100644 --- a/src/routes/api/vehicles/[id]/reminders/+server.ts +++ b/src/routes/api/vehicles/[id]/reminders/+server.ts @@ -3,14 +3,6 @@ import * as reminderService from '$server/services/reminderService'; import { reminderSchema } from '$lib/domain/reminder'; import { jsonResponse, parseBody, withRouteErrorHandling } from '$server/utils/route-handler'; -export const GET: RequestHandler = async (event) => { - return withRouteErrorHandling('Reminders GET error:', async () => { - const { id } = event.params; - const result = await reminderService.getReminders(id); - return jsonResponse(result); - }); -}; - export const POST: RequestHandler = async (event) => { return withRouteErrorHandling('Reminders POST error:', async () => { const { id } = event.params; diff --git a/src/server/config/appVersion.ts b/src/server/config/appVersion.ts index 837f8869..3ddde427 100644 --- a/src/server/config/appVersion.ts +++ b/src/server/config/appVersion.ts @@ -1,25 +1,6 @@ -import { execFileSync } from 'node:child_process'; import packageInfo from '../../../package.json'; import { env } from '$lib/config/env.server'; export const packageVersion = packageInfo.version; -function getBranchName() { - try { - const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'] - }).trim(); - - if (!branch || branch === 'HEAD') { - return 'unknown'; - } - - return branch; - } catch { - return 'unknown'; - } -} - -export const appVersion = - env.APP_VERSION || (env.NODE_ENV === 'production' ? packageVersion : getBranchName()); +export const appVersion = env.APP_VERSION || packageVersion; diff --git a/src/server/config/logger.ts b/src/server/config/logger.ts index ea0f9205..d685e270 100644 --- a/src/server/config/logger.ts +++ b/src/server/config/logger.ts @@ -1,49 +1,41 @@ +import { appendFile } from 'fs/promises'; +import path from 'path'; import { env } from '$lib/config/env.server'; -import winston from 'winston'; - -const logFormatter = winston.format.combine( - winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - winston.format.errors({ stack: true }), - winston.format.printf(({ timestamp, level, message, stack, ...meta }) => { - let logMessage = `${timestamp} [${level}]: ${message}`; - - // Add stack trace on new lines if present - if (stack) { - logMessage += '\n' + stack; - } - - // Add any remaining metadata - const metaKeys = Object.keys(meta); - if (metaKeys.length > 0) { - logMessage += '\n' + JSON.stringify(meta, null, 2); - } - - return logMessage; - }) -); - -const transports: winston.transport[] = [ - new winston.transports.File({ - dirname: env.LOG_DIR || './logs', - filename: `tracktor.log` - }) -]; - -if (env.NODE_ENV !== 'test') { - transports.push( - new winston.transports.Console({ - format: winston.format.combine(winston.format.colorize(), logFormatter) - }) - ); + +const LEVELS = ['error', 'warn', 'info', 'debug'] as const; +type Level = (typeof LEVELS)[number]; + +const threshold = LEVELS.indexOf((env.LOG_LEVEL as Level) || 'info'); +const logFile = path.join(env.LOG_DIR || './logs', 'tracktor.log'); + +function format(level: Level, message: unknown, meta: unknown[]): string { + const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); + const details = meta + .map((item) => + item instanceof Error ? (item.stack ?? item.message) : JSON.stringify(item, null, 2) + ) + .filter(Boolean); + + return [`${timestamp} [${level}]: ${message}`, ...details].join('\n'); } -const logger = winston.createLogger({ - level: env.LOG_LEVEL || 'info', - exitOnError: false, - format: logFormatter, - transports -}); +function log(level: Level, message: unknown, ...meta: unknown[]): void { + if (LEVELS.indexOf(level) > threshold) return; + + const line = format(level, message, meta); + + if (env.NODE_ENV !== 'test') { + console[level === 'debug' ? 'log' : level](line); + } + // Fire and forget: a failed log write must never take a request down with it. + void appendFile(logFile, `${line}\n`).catch(() => {}); +} -// logger.info(`Winston logger configured with ${transports.length} transports`); +const logger = { + error: (message: unknown, ...meta: unknown[]) => log('error', message, ...meta), + warn: (message: unknown, ...meta: unknown[]) => log('warn', message, ...meta), + info: (message: unknown, ...meta: unknown[]) => log('info', message, ...meta), + debug: (message: unknown, ...meta: unknown[]) => log('debug', message, ...meta) +}; export default logger; diff --git a/src/server/db/init.ts b/src/server/db/init.ts index e77431c7..e3ebea54 100644 --- a/src/server/db/init.ts +++ b/src/server/db/init.ts @@ -3,7 +3,6 @@ import { db } from './index'; import { seedData } from './seeders'; import { logger } from '$server/config'; import { resolve } from 'path'; -import { applyPatches } from './patch'; import { env } from '$lib/config/env.server'; /** @@ -28,11 +27,6 @@ export async function initializeDatabase(): Promise { logger.info('Running data seeding if applicable...'); await seedData(); logger.info('Data seeding completed successfully'); - - // Apply Patches if needed. - logger.info('Running database patches if applicable..'); - await applyPatches(); - logger.info('Database patches applied successfully'); logger.info('Database initialization completed successfully'); } catch (error) { logger.error('Failed to initialize database:', error); diff --git a/src/server/db/patch/index.ts b/src/server/db/patch/index.ts deleted file mode 100644 index 987e6368..00000000 --- a/src/server/db/patch/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { logger } from '$server/config/index'; -import { db } from '$server/db/index'; -import { sql } from 'drizzle-orm'; - -/** - * Database patch to add missing columns to fuel_logs table - * Adds 'filled' and 'missed_last' columns if they don't exist - */ -export async function applyPatches(): Promise { - try { - await addFuelLogColumns(); - } catch (error) { - logger.error('Error while applying database patches : ', error); - throw error; - } -} - -/** - * Add filled and missed_last columns to fuel_logs table if they don't exist - */ -async function addFuelLogColumns(): Promise { - // First check if the fuel_logs table exists - const tableExists = await db.all( - sql`SELECT name FROM sqlite_master WHERE type='table' AND name='fuel_logs'` - ); - - if (tableExists.length === 0) { - return; - } - - // Check if columns exist by querying table info - const tableInfo = await db.all(sql`PRAGMA table_info(fuel_logs)`); - - const existingColumns = tableInfo.map((col: any) => col.name); - const hasFilledColumn = existingColumns.includes('filled'); - const hasMissedLastColumn = existingColumns.includes('missed_last'); - - if (!hasFilledColumn) { - logger.info("Adding 'filled' column to fuel_logs table..."); - await db.run(sql`ALTER TABLE fuel_logs ADD COLUMN filled INTEGER DEFAULT 1 NOT NULL`); - logger.info("Successfully added 'filled' column"); - } - - if (!hasMissedLastColumn) { - logger.info("Adding 'missed_last' column to fuel_logs table..."); - await db.run(sql`ALTER TABLE fuel_logs ADD COLUMN missed_last INTEGER DEFAULT 0 NOT NULL`); - logger.info("Successfully added 'missed_last' column"); - } -} diff --git a/src/server/db/seeders/index.ts b/src/server/db/seeders/index.ts index ac722e28..8de54136 100644 --- a/src/server/db/seeders/index.ts +++ b/src/server/db/seeders/index.ts @@ -86,7 +86,7 @@ function buildMileageSeries(startOdometer: number, count: number): number[] { }); } -export const clearDb = async () => { +const clearDb = async () => { await db.delete(notificationTable); await db.delete(pollutionCertificateTable); await db.delete(maintenanceLogTable); diff --git a/src/server/middlewares/auth.ts b/src/server/middlewares/auth.ts index e0dd103f..932d4b61 100644 --- a/src/server/middlewares/auth.ts +++ b/src/server/middlewares/auth.ts @@ -1,93 +1,66 @@ import { env } from '$lib/config/env.server'; -import { BaseMiddleware, type MiddlewareResult } from './base'; -import type { RequestEvent } from '@sveltejs/kit'; -import { CorsMiddleware } from './cors'; +import type { Handle } from '@sveltejs/kit'; +import { errorResponse } from './cors'; import { AppError, Status } from '$server/exceptions/AppError'; import { validateSession, getUsersCount } from '$server/services/authService'; const BYPASS_PATHS = ['/api/auth', '/api/health', '/api/config/branding']; -export class AuthMiddleware extends BaseMiddleware { - protected async process(event: RequestEvent): Promise { - if (!this.requiresAuth(event.url.pathname)) { - return { continue: true }; - } - return await this.handleAuthentication(event); +function requiresAuth(pathname: string): boolean { + if (env.DISABLE_AUTH || !pathname.startsWith('/api')) { + return false; } - private async handleAuthentication(event: RequestEvent): Promise { - const usersStatus = await getUsersCount(); - if (!usersStatus.hasUsers) { - return { - response: CorsMiddleware.createErrorResponse( - 'No users found. Please create a user account first.', - Status.BAD_REQUEST, - event.request - ), - continue: false - }; - } - - const authHeader = event.request.headers.get('Authorization'); - const sessionToken = authHeader?.replace('Bearer ', '') || event.cookies.get('session'); - - if (!sessionToken) { - return { - response: CorsMiddleware.createErrorResponse( - 'Session token is required. Please login first.', - Status.UNAUTHORIZED, - event.request - ), - continue: false - }; - } - - try { - const { user } = await validateSession(sessionToken); + return !BYPASS_PATHS.some((path) => pathname.startsWith(path)); +} - if (!user) { - return { - response: CorsMiddleware.createErrorResponse( - 'Invalid or expired session. Please login again.', - Status.UNAUTHORIZED, - event.request - ), - continue: false - }; - } +export const handleAuth: Handle = async ({ event, resolve }) => { + if (!requiresAuth(event.url.pathname)) { + return resolve(event); + } - event.locals.user = user; - return { continue: true }; - } catch (error) { - let statusCode = Status.INTERNAL_SERVER_ERROR; - let message = 'Authentication failed'; + const usersStatus = await getUsersCount(); + if (!usersStatus.hasUsers) { + return errorResponse( + 'No users found. Please create a user account first.', + Status.BAD_REQUEST, + event.request + ); + } - if (error instanceof AppError) { - statusCode = error.status; - message = error.message; - } + const authHeader = event.request.headers.get('Authorization'); + const sessionToken = authHeader?.replace('Bearer ', '') || event.cookies.get('session'); - return { - response: CorsMiddleware.createErrorResponse( - message, - statusCode, - event.request, - error as Error - ), - continue: false - }; - } + if (!sessionToken) { + return errorResponse( + 'Session token is required. Please login first.', + Status.UNAUTHORIZED, + event.request + ); } - private requiresAuth(pathname: string): boolean { - if (env.DISABLE_AUTH) { - return false; - } + try { + const { user } = await validateSession(sessionToken); - if (!pathname.startsWith('/api')) { - return false; + if (!user) { + return errorResponse( + 'Invalid or expired session. Please login again.', + Status.UNAUTHORIZED, + event.request + ); } - return !BYPASS_PATHS.some((path) => pathname.startsWith(path)); + event.locals.user = user; + } catch (error) { + const isAppError = error instanceof AppError; + + return errorResponse( + isAppError ? error.message : 'Authentication failed', + isAppError ? error.status : Status.INTERNAL_SERVER_ERROR, + event.request, + error as Error + ); } -} + + return resolve(event); +}; diff --git a/src/server/middlewares/base.ts b/src/server/middlewares/base.ts deleted file mode 100644 index 1e054a48..00000000 --- a/src/server/middlewares/base.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { RequestEvent } from '@sveltejs/kit'; - -export interface MiddlewareResult { - response?: Response; - continue: boolean; -} - -export abstract class BaseMiddleware { - protected next?: BaseMiddleware; - - setNext(middleware: BaseMiddleware): BaseMiddleware { - this.next = middleware; - return middleware; - } - - async handle(event: RequestEvent): Promise { - const result = await this.process(event); - - if (!result.continue || result.response) { - return result; - } - - if (this.next) { - return await this.next.handle(event); - } - - return { continue: true }; - } - - protected abstract process(event: RequestEvent): Promise; -} - -export class MiddlewareChain { - private firstMiddleware?: BaseMiddleware; - - public init(middlewares: BaseMiddleware[]) { - if (middlewares.length === 0) return; - - this.firstMiddleware = middlewares[0]; - let current = this.firstMiddleware; - - for (let i = 1; i < middlewares.length; i++) { - current = current.setNext(middlewares[i]); - } - } - - async handle(event: RequestEvent): Promise { - if (!this.firstMiddleware) { - return { response: undefined, continue: false }; - } - return await this.firstMiddleware!.handle(event); - } -} diff --git a/src/server/middlewares/cors.ts b/src/server/middlewares/cors.ts index b56ecbeb..0deaf5c0 100644 --- a/src/server/middlewares/cors.ts +++ b/src/server/middlewares/cors.ts @@ -1,76 +1,45 @@ import { serverEnv } from '$lib/config/env.server'; -import type { RequestEvent } from '@sveltejs/kit'; -import { BaseMiddleware, type MiddlewareResult } from './base'; -import type { ApiResponse } from '$lib'; +import type { Handle } from '@sveltejs/kit'; +import type { ApiResponse } from '$lib/response'; -function corsHeaders(request: Request): Record { - return { - 'Access-Control-Allow-Origin': CorsMiddleware.getCorsOrigin(request), - 'Access-Control-Allow-Credentials': 'true' - }; -} - -export class CorsMiddleware extends BaseMiddleware { - protected async process(event: RequestEvent): Promise { - if (event.request.method === 'OPTIONS') { - return { - response: this.handleCorsOptions(event.request), - continue: false - }; - } +export function getCorsOrigin(request: Request): string { + const requestOrigin = request.headers.get('origin'); - return { continue: true }; + if (!requestOrigin || serverEnv?.CORS_ORIGINS.includes('*')) { + return '*'; } - public static getCorsOrigin(request: Request): string { - const requestOrigin = request.headers.get('origin'); - - if (!requestOrigin) { - return '*'; - } - - if (serverEnv?.CORS_ORIGINS.includes('*')) { - return '*'; - } - - if (serverEnv?.CORS_ORIGINS.includes(requestOrigin)) { - return requestOrigin; - } - - return serverEnv?.CORS_ORIGINS[0] || '*'; + if (serverEnv?.CORS_ORIGINS.includes(requestOrigin)) { + return requestOrigin; } - public static createErrorResponse( - message: string, - status: number, - request: Request, - error?: Error - ): Response { - const errorResponse: ApiResponse = { - success: false, - message, - errors: error ? [error] : [] - }; - - return new Response(JSON.stringify(errorResponse), { - status, - headers: { - 'Content-Type': 'application/json', - ...corsHeaders(request) - } - }); - } + return serverEnv?.CORS_ORIGINS[0] || '*'; +} - public static addCorsHeaders(response: Response, request: Request): void { - response.headers.set('Access-Control-Allow-Origin', CorsMiddleware.getCorsOrigin(request)); - response.headers.set('Access-Control-Allow-Credentials', 'true'); - } +export function errorResponse( + message: string, + status: number, + request: Request, + error?: Error +): Response { + const body: ApiResponse = { success: false, message, errors: error ? [error] : [] }; + + return new Response(JSON.stringify(body), { + status, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': getCorsOrigin(request), + 'Access-Control-Allow-Credentials': 'true' + } + }); +} - private handleCorsOptions(request: Request): Response { +export const handleCors: Handle = async ({ event, resolve }) => { + if (event.request.method === 'OPTIONS') { return new Response(null, { status: 200, headers: { - 'Access-Control-Allow-Origin': CorsMiddleware.getCorsOrigin(request), + 'Access-Control-Allow-Origin': getCorsOrigin(event.request), 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-User-PIN', 'Access-Control-Allow-Credentials': 'true', @@ -78,4 +47,9 @@ export class CorsMiddleware extends BaseMiddleware { } }); } -} + + const response = await resolve(event); + response.headers.set('Access-Control-Allow-Origin', getCorsOrigin(event.request)); + response.headers.set('Access-Control-Allow-Credentials', 'true'); + return response; +}; diff --git a/src/server/middlewares/index.ts b/src/server/middlewares/index.ts deleted file mode 100644 index c744e034..00000000 --- a/src/server/middlewares/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { CorsMiddleware } from './cors'; -export { RateLimitMiddleware } from './rateLimit'; -export { LoggingMiddleware } from './logging'; -export { AuthMiddleware } from './auth'; diff --git a/src/server/middlewares/logging.ts b/src/server/middlewares/logging.ts index afce469a..a418d549 100644 --- a/src/server/middlewares/logging.ts +++ b/src/server/middlewares/logging.ts @@ -1,30 +1,18 @@ import { logger } from '$server/config'; -import type { RequestEvent } from '@sveltejs/kit'; -import { BaseMiddleware, type MiddlewareResult } from './base'; +import type { Handle, RequestEvent } from '@sveltejs/kit'; import { env } from '$lib/config/env.server'; -export class LoggingMiddleware extends BaseMiddleware { - protected async process(event: RequestEvent): Promise { - if (env.LOG_REQUESTS) { - let message = `${this.getNormalizedIPv4Address(this.getClientAddress(event))} - ${event.request.method} - ${event.url.pathname}`; - logger.info(message); - } +function getClientAddress(event: RequestEvent): string { + const forwardedFor = event.request.headers.get('x-forwarded-for'); + const ip = forwardedFor ? forwardedFor.split(',')[0].trim() : event.getClientAddress(); - return { continue: true }; - } + return ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip; +} - private getClientAddress(event: RequestEvent): string { - const forwardedFor = event.request.headers.get('x-forwarded-for'); - if (forwardedFor) { - return forwardedFor.split(',')[0].trim(); - } - return event.getClientAddress(); +export const handleLogging: Handle = ({ event, resolve }) => { + if (env.LOG_REQUESTS) { + logger.info(`${getClientAddress(event)} - ${event.request.method} - ${event.url.pathname}`); } - private getNormalizedIPv4Address(ip: string): string { - if (ip.startsWith('::ffff:')) { - return ip.replace('::ffff:', ''); - } - return ip; - } -} + return resolve(event); +}; diff --git a/src/server/middlewares/rateLimit.ts b/src/server/middlewares/rateLimit.ts deleted file mode 100644 index 1475de9c..00000000 --- a/src/server/middlewares/rateLimit.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { RequestEvent } from '@sveltejs/kit'; -import { BaseMiddleware, type MiddlewareResult } from './base'; -import { CorsMiddleware } from './cors'; - -const RATE_LIMIT_WINDOW = 60 * 1000; -const RATE_LIMIT_MAX = 1000; - -export class RateLimitMiddleware extends BaseMiddleware { - private rateLimitStore = new Map(); - - protected async process(event: RequestEvent): Promise { - let clientAddress: string; - - try { - clientAddress = event.getClientAddress(); - } catch { - clientAddress = 'unknown-client'; - } - - const isAllowed = this.checkRateLimit(clientAddress); - - if (!isAllowed) { - return { - response: CorsMiddleware.createErrorResponse( - 'Rate Limit Exceeded. Please try again later.', - 429, - event.request - ), - continue: false - }; - } - - return { continue: true }; - } - - private checkRateLimit(ip: string): boolean { - const now = Date.now(); - const key = ip; - - let entry = this.rateLimitStore.get(key); - if (entry && now > entry.resetTime) { - entry = undefined; - this.rateLimitStore.delete(key); - } - if (!entry) { - entry = { - count: 1, - resetTime: now + RATE_LIMIT_WINDOW - }; - this.rateLimitStore.set(key, entry); - return true; - } - entry.count++; - - if (entry.count > RATE_LIMIT_MAX) { - return false; - } - return true; - } -} diff --git a/src/server/services/emailTemplateService.ts b/src/server/services/emailTemplateService.ts index b895b68e..20b45d8b 100644 --- a/src/server/services/emailTemplateService.ts +++ b/src/server/services/emailTemplateService.ts @@ -2,7 +2,7 @@ import type { Notification } from '$lib/domain/notification'; import { env } from '$lib/config/env.server'; import { formatDate, getDaysUntil, NOTIFICATION_TYPE_META } from './notification-service.helper'; -export interface NotificationGroup { +interface NotificationGroup { type: string; notifications: Notification[]; label: string; diff --git a/src/server/services/fuelLogService.ts b/src/server/services/fuelLogService.ts index 1299538b..b4cabce7 100644 --- a/src/server/services/fuelLogService.ts +++ b/src/server/services/fuelLogService.ts @@ -4,7 +4,6 @@ import { z } from 'zod'; import { eq, getTableColumns } from 'drizzle-orm'; import { fuelSchema } from '$lib/domain/fuel'; import { computeMileagePerWindow, type FuelLogInput } from '$lib/domain/fuel/mileage'; -import { validateVehicleExistsByLicensePlate } from '../utils/serviceUtils'; import { getConfigsByKeys } from './configService'; import { createOwnedEntityService } from '../utils/entity-service-factory'; @@ -115,22 +114,3 @@ export const getFuelLogs = async (vehicleId?: string) => { withMileageMetrics(group, mileageFormat, configMap) ); }; - -export const addFuelLogByLicensePlate = async ( - licensePlate: string, - fuelLogData: FuelLogPayload -) => { - await validateVehicleExistsByLicensePlate(licensePlate); - const vehicle = await db.query.vehicleTable.findFirst({ - where: (vehicle, { eq }) => eq(vehicle.licensePlate, licensePlate) - }); - return await addFuelLog(vehicle!.id, fuelLogData); -}; - -export const getFuelLogsByLicensePlate = async (licensePlate: string) => { - await validateVehicleExistsByLicensePlate(licensePlate); - const vehicle = await db.query.vehicleTable.findFirst({ - where: (vehicle, { eq }) => eq(vehicle.licensePlate, licensePlate) - }); - return await getFuelLogs(vehicle!.id); -}; diff --git a/src/server/services/maintenanceLogService.ts b/src/server/services/maintenanceLogService.ts index 65549bf2..23689892 100644 --- a/src/server/services/maintenanceLogService.ts +++ b/src/server/services/maintenanceLogService.ts @@ -8,7 +8,7 @@ import { maintenanceSchema } from '$lib/domain/maintenance'; type MaintenanceLogPayload = Omit, 'id' | 'vehicleId'>; type MaintenanceLogUpdatePayload = Partial; -const { add, getById, update, removeScoped } = createOwnedEntityService< +const { add, getById, update, remove } = createOwnedEntityService< MaintenanceLogPayload, MaintenanceLogUpdatePayload >({ @@ -19,7 +19,7 @@ const { add, getById, update, removeScoped } = createOwnedEntityService< export const addMaintenanceLog = add; export const getMaintenanceLogById = getById; export const updateMaintenanceLog = update; -export const deleteMaintenanceLog = removeScoped; +export const deleteMaintenanceLog = remove; export const getMaintenanceLogs = async (vehicleId?: string) => { if (vehicleId) { diff --git a/src/server/services/notification-provider-http.helper.ts b/src/server/services/notification-provider-http.helper.ts index 2dbc08da..a1a29b61 100644 --- a/src/server/services/notification-provider-http.helper.ts +++ b/src/server/services/notification-provider-http.helper.ts @@ -1,4 +1,11 @@ -import type { WebhookProviderConfig } from '$lib/domain/notification-provider'; +import type { + GotifyProviderConfig, + WebhookProviderConfig +} from '$lib/domain/notification-provider'; + +/* global RequestInit */ + +export type ProviderSendResult = { success: boolean; error?: string }; export function buildWebhookHeaders(config: WebhookProviderConfig): Record { const headers: Record = { @@ -19,3 +26,50 @@ export function buildWebhookHeaders(config: WebhookProviderConfig): Record { + try { + const response = await fetch(url, init); + + return response.ok + ? { success: true } + : { success: false, error: `HTTP ${response.status}: ${response.statusText}` }; + } catch (error) { + return { success: false, error: (error as Error).message || fallbackError }; + } +} + +export function sendWebhook( + config: WebhookProviderConfig, + payload: Record +): Promise { + return post( + config.url, + { + method: config.method, + headers: buildWebhookHeaders(config), + body: JSON.stringify({ ...payload, timestamp: new Date().toISOString() }) + }, + 'Failed to send webhook' + ); +} + +export function sendGotify( + config: GotifyProviderConfig, + title: string, + message: string +): Promise { + return post( + `${config.serverUrl}/message?token=${config.appToken}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, message, priority: config.priority }) + }, + 'Failed to send Gotify notification' + ); +} diff --git a/src/server/services/notification-provider-test.service.ts b/src/server/services/notification-provider-test.service.ts index e090df08..fa0a92a3 100644 --- a/src/server/services/notification-provider-test.service.ts +++ b/src/server/services/notification-provider-test.service.ts @@ -6,103 +6,41 @@ import type { } from '$lib/domain/notification-provider'; import { AppError, Status } from '$server/exceptions/AppError'; -import { buildWebhookHeaders } from './notification-provider-http.helper'; +import { + sendGotify, + sendWebhook, + type ProviderSendResult +} from './notification-provider-http.helper'; import { testEmailProvider } from './emailNotificationService'; -export type NotificationProviderTestResult = { - success: boolean; - error?: string; -}; - -type NotificationProviderTestOptions = { +export type NotificationProviderTestOptions = { testEmail?: string; testMessage?: string; }; const DEFAULT_TEST_MESSAGE = 'This is a test notification from Tracktor'; -async function testWebhookProvider( - config: WebhookProviderConfig, - testMessage: string -): Promise { - try { - const response = await fetch(config.url, { - method: config.method, - headers: buildWebhookHeaders(config), - body: JSON.stringify({ - title: 'Tracktor test notification', - message: testMessage, - timestamp: new Date().toISOString(), - test: true - }) - }); - - if (!response.ok) { - return { - success: false, - error: `HTTP ${response.status}: ${response.statusText}` - }; - } - - return { success: true }; - } catch (error) { - const err = error as Error; - - return { - success: false, - error: err.message || 'Failed to send webhook' - }; - } -} - -async function testGotifyProvider( - config: GotifyProviderConfig, - testMessage: string -): Promise { - try { - const response = await fetch(`${config.serverUrl}/message?token=${config.appToken}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - title: 'Tracktor test notification', - message: testMessage, - priority: config.priority - }) - }); - - if (!response.ok) { - return { - success: false, - error: `HTTP ${response.status}: ${response.statusText}` - }; - } - - return { success: true }; - } catch (error) { - const err = error as Error; - - return { - success: false, - error: err.message || 'Failed to send Gotify notification' - }; - } -} - export async function testNotificationProvider( provider: NotificationProviderWithParsedConfig, options: NotificationProviderTestOptions = {} -): Promise { +): Promise { const testMessage = options.testMessage || DEFAULT_TEST_MESSAGE; switch (provider.config.type) { case 'email': return testEmailProvider(provider.config as EmailProviderConfig, options.testEmail); case 'webhook': - return testWebhookProvider(provider.config as WebhookProviderConfig, testMessage); + return sendWebhook(provider.config as WebhookProviderConfig, { + title: 'Tracktor test notification', + message: testMessage, + test: true + }); case 'gotify': - return testGotifyProvider(provider.config as GotifyProviderConfig, testMessage); + return sendGotify( + provider.config as GotifyProviderConfig, + 'Tracktor test notification', + testMessage + ); default: throw new AppError('Provider type is not supported for testing', Status.BAD_REQUEST); } diff --git a/src/server/services/notification-service.helper.ts b/src/server/services/notification-service.helper.ts index 3c7b1de9..fe7ab964 100644 --- a/src/server/services/notification-service.helper.ts +++ b/src/server/services/notification-service.helper.ts @@ -31,11 +31,11 @@ export const NOTIFICATION_TYPE_META: Record< registration: { channel: 'alert', label: 'Registration', color: '#ea580c' } }; -export function toDateOnly(value: Date): Date { +function toDateOnly(value: Date): Date { return new Date(value.getFullYear(), value.getMonth(), value.getDate()); } -export function calculateReminderNotificationDate(dueDate: Date, remindSchedule: string): Date { +function calculateReminderNotificationDate(dueDate: Date, remindSchedule: string): Date { const notificationDate = new Date(dueDate); switch (remindSchedule) { diff --git a/src/server/services/notificationDispatchService.ts b/src/server/services/notificationDispatchService.ts index 807a4d33..3c4ad507 100644 --- a/src/server/services/notificationDispatchService.ts +++ b/src/server/services/notificationDispatchService.ts @@ -13,13 +13,9 @@ import { groupNotifications } from './emailTemplateService'; import { sendEmail } from './emailNotificationService'; -import { buildWebhookHeaders } from './notification-provider-http.helper'; +import { sendGotify, sendWebhook } from './notification-provider-http.helper'; import { getEnabledProvidersForChannels } from './notificationProviderService'; -import { - getActiveNotificationsForChannels, - getPendingNotificationsForChannels, - syncAllNotifications -} from './notificationService'; +import { getPendingNotificationsForChannels, syncAllNotifications } from './notificationService'; type DispatchResult = { providerId: string; @@ -30,104 +26,36 @@ type DispatchResult = { notificationCount: number; }; -async function sendWebhookNotification( +async function send( provider: NotificationProviderWithParsedConfig, notifications: Notification[] -): Promise { - const config = provider.config as WebhookProviderConfig; +): Promise<{ success: boolean; error?: string }> { + const groups = groupNotifications(notifications); + const digest = generatePlainTextDigest(groups, notifications.length); - try { - const response = await fetch(config.url, { - method: config.method, - headers: buildWebhookHeaders(config), - body: JSON.stringify({ + switch (provider.type) { + case 'email': + return sendEmail({ + providerId: provider.id, + subject: `Tracktor: ${notifications.length} pending notification${notifications.length === 1 ? '' : 's'}`, + text: digest, + html: generateHtmlDigest(groups, notifications.length) + }); + case 'webhook': + return sendWebhook(provider.config as WebhookProviderConfig, { title: 'Tracktor notifications', notificationCount: notifications.length, channels: provider.channels, - notifications, - timestamp: new Date().toISOString() - }) - }); - - if (!response.ok) { - return { - providerId: provider.id, - providerName: provider.name, - providerType: provider.type, - success: false, - error: `HTTP ${response.status}: ${response.statusText}`, - notificationCount: notifications.length - }; - } - - return { - providerId: provider.id, - providerName: provider.name, - providerType: provider.type, - success: true, - notificationCount: notifications.length - }; - } catch (error) { - const err = error as Error; - return { - providerId: provider.id, - providerName: provider.name, - providerType: provider.type, - success: false, - error: err.message, - notificationCount: notifications.length - }; - } -} - -async function sendGotifyNotification( - provider: NotificationProviderWithParsedConfig, - notifications: Notification[] -): Promise { - const config = provider.config as GotifyProviderConfig; - const groups = groupNotifications(notifications); - - try { - const response = await fetch(`${config.serverUrl}/message?token=${config.appToken}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - title: `Tracktor Notification Summary`, - message: generatePlainTextDigest(groups, notifications.length), - priority: config.priority - }) - }); - - if (!response.ok) { - return { - providerId: provider.id, - providerName: provider.name, - providerType: provider.type, - success: false, - error: `HTTP ${response.status}: ${response.statusText}`, - notificationCount: notifications.length - }; - } - - return { - providerId: provider.id, - providerName: provider.name, - providerType: provider.type, - success: true, - notificationCount: notifications.length - }; - } catch (error) { - const err = error as Error; - return { - providerId: provider.id, - providerName: provider.name, - providerType: provider.type, - success: false, - error: err.message, - notificationCount: notifications.length - }; + notifications + }); + case 'gotify': + return sendGotify( + provider.config as GotifyProviderConfig, + 'Tracktor Notification Summary', + digest + ); + default: + return { success: false, error: `Unsupported provider type: ${provider.type}` }; } } @@ -135,59 +63,26 @@ async function sendNotificationsToProvider( provider: NotificationProviderWithParsedConfig, notifications: Notification[] ): Promise { - if (notifications.length === 0) { - return { - providerId: provider.id, - providerName: provider.name, - providerType: provider.type, - success: true, - notificationCount: 0 - }; - } - - if (provider.type === 'email') { - const groups = groupNotifications(notifications); - const result = await sendEmail({ - providerId: provider.id, - subject: `Tracktor: ${notifications.length} pending notification${notifications.length === 1 ? '' : 's'}`, - text: generatePlainTextDigest(groups, notifications.length), - html: generateHtmlDigest(groups, notifications.length) - }); - - return { - providerId: provider.id, - providerName: provider.name, - providerType: provider.type, - success: result.success, - error: result.error, - notificationCount: notifications.length - }; - } - - if (provider.type === 'webhook') { - return sendWebhookNotification(provider, notifications); - } - - if (provider.type === 'gotify') { - return sendGotifyNotification(provider, notifications); - } + const result = + notifications.length === 0 ? { success: true } : await send(provider, notifications); return { providerId: provider.id, providerName: provider.name, providerType: provider.type, - success: false, - error: `Unsupported provider type: ${provider.type}`, - notificationCount: notifications.length + notificationCount: notifications.length, + ...result }; } -async function dispatchNotifications(useAllNotifications: boolean): Promise<{ +export async function dispatchScheduledNotifications(): Promise<{ success: boolean; notificationCount: number; providerCount: number; results: DispatchResult[]; }> { + await syncAllNotifications(); + const providers = await getEnabledProvidersForChannels([ 'reminder', 'alert', @@ -204,10 +99,7 @@ async function dispatchNotifications(useAllNotifications: boolean): Promise<{ } const channels = Array.from(new Set(providers.flatMap((provider) => provider.channels))); - const notificationResult = useAllNotifications - ? await getActiveNotificationsForChannels(channels) - : await getPendingNotificationsForChannels(channels); - const allNotifications = notificationResult as Notification[]; + const allNotifications = (await getPendingNotificationsForChannels(channels)) as Notification[]; const results = await Promise.all( providers.map((provider) => { @@ -234,22 +126,3 @@ async function dispatchNotifications(useAllNotifications: boolean): Promise<{ results }; } - -export async function dispatchScheduledNotifications(): Promise<{ - success: boolean; - notificationCount: number; - providerCount: number; - results: DispatchResult[]; -}> { - await syncAllNotifications(); - return dispatchNotifications(false); -} - -export async function dispatchAllNotificationsToEnabledProviders(): Promise<{ - success: boolean; - notificationCount: number; - providerCount: number; - results: DispatchResult[]; -}> { - return dispatchNotifications(true); -} diff --git a/src/server/services/notificationProviderService.ts b/src/server/services/notificationProviderService.ts index 8a70f00a..cc1dba95 100644 --- a/src/server/services/notificationProviderService.ts +++ b/src/server/services/notificationProviderService.ts @@ -159,16 +159,3 @@ export const getEnabledProvidersByType = async (type: string) => { return providers.map(parseProvider); }; - -export const getEnabledProvidersByIds = async (providerIds: string[]) => { - if (providerIds.length === 0) { - return []; - } - - const providers = await db.query.notificationProviderTable.findMany({ - where: (provider, { and, eq, inArray }) => - and(eq(provider.isEnabled, true), inArray(provider.id, providerIds)) - }); - - return providers.map(parseProvider); -}; diff --git a/src/server/services/notificationSchedulerService.ts b/src/server/services/notificationSchedulerService.ts index 44ea52ad..ee663767 100644 --- a/src/server/services/notificationSchedulerService.ts +++ b/src/server/services/notificationSchedulerService.ts @@ -33,7 +33,7 @@ async function isNotificationProcessingEnabled(): Promise { return true; } -export async function processScheduledNotifications(): Promise { +async function processScheduledNotifications(): Promise { const result = await dispatchScheduledNotifications(); if (result.success) { @@ -82,13 +82,3 @@ export async function initializeNotificationScheduler(): Promise { export async function reloadNotificationScheduler(): Promise { await initializeNotificationScheduler(); } - -export function stopNotificationScheduler(): void { - if (!scheduledTask) { - return; - } - - scheduledTask.stop(); - scheduledTask = null; - logger.info('Notification scheduler stopped'); -} diff --git a/src/server/services/vehicleService.ts b/src/server/services/vehicleService.ts index 1ad02473..5ec379f3 100644 --- a/src/server/services/vehicleService.ts +++ b/src/server/services/vehicleService.ts @@ -211,19 +211,6 @@ export const deleteVehicle = async (id: string) => { }; // Get vehicles with minimal data for dropdown/selection purposes -export const getVehiclesMinimal = async () => { - const vehicles = await db.query.vehicleTable.findMany({ - columns: { - id: true, - make: true, - model: true, - year: true, - licensePlate: true - } - }); - return vehicles; -}; - export const getVehicleSummary = async (id: string) => { const [vehicle, fuelLogsCount, maintenanceLogsCount] = await Promise.all([ getVehicleById(id), diff --git a/src/server/utils/entity-service-factory.ts b/src/server/utils/entity-service-factory.ts index 41d57d03..96cab771 100644 --- a/src/server/utils/entity-service-factory.ts +++ b/src/server/utils/entity-service-factory.ts @@ -53,18 +53,13 @@ export function createOwnedEntityService>( return updated[0]; }, - remove: async (id: string) => { - return performDelete(table, id, entityName); - }, - - removeScoped: async (vehicleId: string, id: string) => { + remove: async (vehicleId: string, id: string) => { const rows = await db .select() .from(table) .where(and(eq(table.vehicleId, vehicleId), eq(table.id, id))) .limit(1); - const existing = rows[0]; - if (!existing) { + if (!rows[0]) { throw new AppError(`No ${entityName} found for id: ${id}`, Status.NOT_FOUND); } return performDelete(table, id, entityName); diff --git a/src/server/utils/errorHandler.ts b/src/server/utils/errorHandler.ts deleted file mode 100644 index 9ae94dfa..00000000 --- a/src/server/utils/errorHandler.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { logger } from '../config/index'; -import { AppError } from '../exceptions/AppError'; - -function serializeError(error: unknown): { message: string; code?: number } { - if (error instanceof AppError) { - return { message: error.message, code: error.status }; - } - if (error instanceof Error) { - return { message: error.message }; - } - return { message: String(error) }; -} - -export function createErrorResponseBody(error: unknown): { - success: false; - error: { message: string; code?: number }; -} { - return { - success: false, - error: serializeError(error) - }; -} - -export function logError(error: unknown, event: { request: Request; url: URL }): void { - logger.error(`Error in ${event.request.method} - ${event.url.pathname}`, error); -} diff --git a/src/server/utils/fs.ts b/src/server/utils/fs.ts index 63670a17..06d2a953 100644 --- a/src/server/utils/fs.ts +++ b/src/server/utils/fs.ts @@ -4,17 +4,12 @@ import path from 'path'; import { logger } from '$server/config'; import { serverEnv } from '$lib/config/env.server'; -export type EnsureAppDirectoriesOptions = { - uploadsDir?: string; - dataDir?: string; -}; - /** * Ensures a directory exists and is actually a directory. * If it does not exist, it is created recursively. * Throws if the path exists but is not a directory, or if creation fails. */ -export async function ensureDirectoryExists(dirPath: string, label?: string): Promise { +async function ensureDirectoryExists(dirPath: string, label?: string): Promise { const resolvedPath = path.resolve(dirPath); const name = label || resolvedPath; @@ -44,12 +39,10 @@ export async function ensureDirectoryExists(dirPath: string, label?: string): Pr * Logs creation events and throws if any directory cannot be created, * allowing the caller to stop the application. */ -export async function ensureAppDirectories( - options: EnsureAppDirectoriesOptions = {} -): Promise { - const uploadsDir = options.uploadsDir ?? serverEnv.UPLOADS_DIR ?? './uploads'; +export async function ensureAppDirectories(): Promise { + const uploadsDir = serverEnv.UPLOADS_DIR ?? './uploads'; const logDir = serverEnv.LOG_DIR ?? './logs'; - const dataDir = options.dataDir ?? path.dirname(serverEnv.DB_PATH ?? './tracktor.db'); + const dataDir = path.dirname(serverEnv.DB_PATH ?? './tracktor.db'); try { await Promise.all([ diff --git a/src/server/utils/route-handler.ts b/src/server/utils/route-handler.ts index 8980270a..f8f09f6a 100644 --- a/src/server/utils/route-handler.ts +++ b/src/server/utils/route-handler.ts @@ -1,4 +1,4 @@ -import { error, json, type RequestEvent } from '@sveltejs/kit'; +import { error, isHttpError, isRedirect, json, type RequestEvent } from '@sveltejs/kit'; import type { ApiResponse } from '$lib/response'; import { z, ZodError } from 'zod'; import { AppError } from '$server/exceptions/AppError'; @@ -14,7 +14,7 @@ export function jsonResponse( return json(response, init); } -export function rethrowRouteError(err: unknown, fallbackMessage = 'Internal server error'): never { +function rethrowRouteError(err: unknown, fallbackMessage = 'Internal server error'): never { if (err instanceof ZodError) { throw error(400, `Validation error: ${err.issues.map((issue) => issue.message).join(', ')}`); } @@ -23,7 +23,8 @@ export function rethrowRouteError(err: unknown, fallbackMessage = 'Internal serv throw error(err.status, err.message); } - if (err instanceof Error && 'status' in err) { + // `error()` and `redirect()` throw plain objects, not Errors — instanceof never matches them. + if (isHttpError(err) || isRedirect(err)) { throw err; } diff --git a/src/server/utils/serviceUtils.ts b/src/server/utils/serviceUtils.ts index 963fd76d..cd3bad3e 100644 --- a/src/server/utils/serviceUtils.ts +++ b/src/server/utils/serviceUtils.ts @@ -17,21 +17,6 @@ export const validateVehicleExists = async (vehicleId: string): Promise => } }; -/** - * Validates that a vehicle exists by license plate - * @param licensePlate - The license plate to validate - * @throws AppError if vehicle is not found - */ -export const validateVehicleExistsByLicensePlate = async (licensePlate: string): Promise => { - const vehicle = await db.query.vehicleTable.findFirst({ - where: (vehicles, { eq }) => eq(vehicles.licensePlate, licensePlate) - }); - - if (!vehicle) { - throw new AppError(`No vehicle found for license plate : ${licensePlate}`, Status.NOT_FOUND); - } -}; - /** * Generic delete operation with standard error handling * @param table - The database table to delete from diff --git a/src/server/utils/session.ts b/src/server/utils/session.ts index 390a13fc..40b44d6a 100644 --- a/src/server/utils/session.ts +++ b/src/server/utils/session.ts @@ -109,7 +109,7 @@ export interface User { username: string; } -export interface SessionValidationResult { +interface SessionValidationResult { session: Session | null; user: User | null; } From 9115d81e27c359d60008a8e56735d02d82081d18 Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Sat, 1 Aug 2026 10:08:29 +0530 Subject: [PATCH 12/23] Enhancements - [x] App name in sidebar is clickable and take to "/" The tracktor icon must follow theme colour - [x] Theme changer settings and profile and bottom aligned. - [x] Better segregation of menus in sidebar - [x] Theme colour is better followed or removed if that does't align correctly and give options for dark mode like OLED/a few lighter options - [x] Demo notice looks weird find a better position for that - [x] Add calendar widget to show all upcoming or previous actions on date --- i18n/messages/en.json | 6 + .../components/dashboard/widget-registry.ts | 9 + .../dashboard/widgets/CalendarWidget.svelte | 175 ++++++++++++++++++ .../SettingsPersonalizationTab.svelte | 14 ++ src/lib/components/layout/AppSidebar.svelte | 64 ++++--- src/lib/domain/dashboard.ts | 7 +- src/lib/helper/config.helper.ts | 4 +- src/lib/helper/settings-form.helper.ts | 6 + src/lib/stores/config.svelte.ts | 3 +- src/lib/stores/theme.svelte.ts | 45 ++++- src/lib/types/theme.ts | 3 + src/lib/utils/theme.ts | 27 ++- src/routes/(app)/+layout.svelte | 33 ++-- src/routes/(app)/settings/+page.svelte | 6 + src/styles/app.css | 30 +++ 15 files changed, 373 insertions(+), 59 deletions(-) create mode 100644 src/lib/components/dashboard/widgets/CalendarWidget.svelte diff --git a/i18n/messages/en.json b/i18n/messages/en.json index 3cb19559..775d2763 100644 --- a/i18n/messages/en.json +++ b/i18n/messages/en.json @@ -35,10 +35,12 @@ "settings_label_unit_distance": "Unit of Distance", "settings_label_unit_volume": "Unit of Fuel", "settings_label_theme": "Theme", + "settings_label_dark_mode": "Dark Mode Style", "settings_label_custom_css": "Custom CSS", "settings_update_button": "Update Settings", "settings_select_unit_system": "Select unit system", "settings_select_theme": "Select theme", + "settings_select_dark_mode": "Select dark mode style", "settings_desc_date_format": "Choose your preferred date format", "settings_desc_locale": "Choose the language for the interface", "settings_desc_timezone": "Choose your timezone for date display", @@ -51,6 +53,7 @@ "settings_mileage_format_fuel_per_distance": "Fuel per Distance (e.g., L/100km)", "settings_mileage_format_uk_mpg": "UK MPG (miles per imperial gallon)", "settings_desc_theme": "Choose your preferred theme", + "settings_desc_dark_mode": "Choose the contrast style used when dark mode is active", "settings_desc_custom_css": "CSS Styles for customizing the interface", "settings_select_language": "Select language", "settings_updated_success": "Configuration updated successfully!", @@ -556,6 +559,9 @@ "theme_teal": "Teal", "theme_indigo": "Indigo", "theme_pink": "Pink", + "dark_variant_default": "Default", + "dark_variant_dim": "Dim", + "dark_variant_oled": "OLED (True Black)", "settings_tab_notifications": "Notifications", "settings_personalization_desc": "Customize your experience with themes, languages, and formats.", "settings_section_general": "General", diff --git a/src/lib/components/dashboard/widget-registry.ts b/src/lib/components/dashboard/widget-registry.ts index f3eef40c..a880b217 100644 --- a/src/lib/components/dashboard/widget-registry.ts +++ b/src/lib/components/dashboard/widget-registry.ts @@ -10,6 +10,7 @@ import VehicleHealthWidget from './widgets/VehicleHealthWidget.svelte'; import UpcomingRemindersWidget from './widgets/UpcomingRemindersWidget.svelte'; import VehicleQuickListWidget from './widgets/VehicleQuickListWidget.svelte'; import RecentActivityWidget from './widgets/RecentActivityWidget.svelte'; +import CalendarWidget from './widgets/CalendarWidget.svelte'; import Car from '@lucide/svelte/icons/car'; import Route from '@lucide/svelte/icons/route'; import Fuel from '@lucide/svelte/icons/fuel'; @@ -176,5 +177,13 @@ export const WIDGET_REGISTRY: Record = { component: RecentActivityWidget, defaultColSpan: 6, defaultRowSpan: 8 + }, + 'activity-calendar': { + type: 'activity-calendar', + title: 'Activity Calendar', + description: 'Upcoming reminders and past fuel/maintenance activity by date', + component: CalendarWidget, + defaultColSpan: 4, + defaultRowSpan: 10 } }; diff --git a/src/lib/components/dashboard/widgets/CalendarWidget.svelte b/src/lib/components/dashboard/widgets/CalendarWidget.svelte new file mode 100644 index 00000000..5c47833e --- /dev/null +++ b/src/lib/components/dashboard/widgets/CalendarWidget.svelte @@ -0,0 +1,175 @@ + + +{#if loading && !summary} +
    + Loading calendar... +
    +{:else} +
    + + + {#snippet children({ months, weekdays })} + + + + + + {#each months as month, monthIndex (month)} + + + + + + + + {#each weekdays as weekday (weekday)} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + {@const events = eventsByDate.get(date.toString()) ?? []} + + + {#if events.length} +
    + {#each events.slice(0, 3) as event (event.id)} + + {/each} +
    + {/if} +
    + {/each} +
    + {/each} +
    +
    +
    + {/each} +
    + {/snippet} +
    + +
    +

    + {formatDateForCalendar(selected)} +

    + {#if selectedEvents.length === 0} +

    No activity or reminders on this date.

    + {:else} +
      + {#each selectedEvents as event (event.id)} + {@const Icon = KIND_ICONS[event.kind]} +
    • + + + + {event.label} +
    • + {/each} +
    + {/if} +
    +
    +{/if} diff --git a/src/lib/components/feature/settings/SettingsPersonalizationTab.svelte b/src/lib/components/feature/settings/SettingsPersonalizationTab.svelte index e130be06..f47e6ec3 100644 --- a/src/lib/components/feature/settings/SettingsPersonalizationTab.svelte +++ b/src/lib/components/feature/settings/SettingsPersonalizationTab.svelte @@ -10,6 +10,7 @@ import Earth from '@lucide/svelte/icons/earth'; import Languages from '@lucide/svelte/icons/languages'; import Palette from '@lucide/svelte/icons/palette'; + import MoonStar from '@lucide/svelte/icons/moon-star'; import SettingsSelectField from './SettingsSelectField.svelte'; interface Props { @@ -17,6 +18,7 @@ formData: any; processing: boolean; themeOptions: Array; + darkVariantOptions: Array; localeOptions: Array; currencyOptions: Array; getTimezoneOptions: () => Array; @@ -29,6 +31,7 @@ formData, processing, themeOptions, + darkVariantOptions, localeOptions, currencyOptions, getTimezoneOptions, @@ -49,6 +52,17 @@ bind:value={$formData.theme} disabled={processing} /> + -
    {m.app_name()} -
    +
    - {#each navGroups as group (group.label)} + {#each navGroups as group, i (group.label)} + {#if i > 0} + + {/if} {group.label} @@ -168,28 +175,37 @@ {/each} - - - - - - - - handleNavClick('/settings')} - tooltipContent={m.settings_title()} - > - - {m.settings_title()} - - - - - + + + handleNavClick('/settings')} + tooltipContent={m.settings_title()} + > + + {m.settings_title()} + + + + + + + {m.theme_toggle_label()} + + + + {#if authStore.isLoggedIn} diff --git a/src/lib/domain/dashboard.ts b/src/lib/domain/dashboard.ts index 0b6bf472..34777934 100644 --- a/src/lib/domain/dashboard.ts +++ b/src/lib/domain/dashboard.ts @@ -98,7 +98,8 @@ export const WIDGET_TYPES = { 'vehicle-health-distribution': 'vehicle-health-distribution', 'upcoming-reminders-list': 'upcoming-reminders-list', 'vehicle-quick-list': 'vehicle-quick-list', - 'recent-activity-feed': 'recent-activity-feed' + 'recent-activity-feed': 'recent-activity-feed', + 'activity-calendar': 'activity-calendar' } as const; export type WidgetType = keyof typeof WIDGET_TYPES; @@ -165,7 +166,9 @@ const WIDGET_MIN_SIZES: Partial> = { 'efficiency-leaderboard': { minColSpan: 3, minRowSpan: 4 }, 'upcoming-reminders-list': { minColSpan: 3, minRowSpan: 4 }, 'vehicle-quick-list': { minColSpan: 3, minRowSpan: 4 }, - 'recent-activity-feed': { minColSpan: 3, minRowSpan: 4 } + 'recent-activity-feed': { minColSpan: 3, minRowSpan: 4 }, + // Month grid (7 cols) plus a per-date event list below it. + 'activity-calendar': { minColSpan: 4, minRowSpan: 8 } }; export function widgetMinSize(type: WidgetType): WidgetMinSize { diff --git a/src/lib/helper/config.helper.ts b/src/lib/helper/config.helper.ts index ca1b802d..d3880831 100644 --- a/src/lib/helper/config.helper.ts +++ b/src/lib/helper/config.helper.ts @@ -31,7 +31,7 @@ export function rawConfigToFormData( /** * Convert form data back into a Config[] array suitable for the save API. * Merges with existing rawConfig to preserve descriptions and any extra keys. - * The `theme` key is excluded since it is stored client-side only. + * The `theme` and `darkVariant` keys are excluded since they are stored client-side only. */ export function formDataToConfigs( formData: Record, @@ -42,7 +42,7 @@ export function formDataToConfigs( configMap.set(item.key, item); } for (const [key, value] of Object.entries(formData)) { - if (key === 'theme') continue; + if (key === 'theme' || key === 'darkVariant') continue; const stringValue = typeof value === 'boolean' ? String(value) : ((value || '') as string); const existing = configMap.get(key); if (existing) { diff --git a/src/lib/helper/settings-form.helper.ts b/src/lib/helper/settings-form.helper.ts index 857919c0..a4f9bbef 100644 --- a/src/lib/helper/settings-form.helper.ts +++ b/src/lib/helper/settings-form.helper.ts @@ -17,6 +17,7 @@ const settingsConfigSchema = z.object({ .enum(['distance-per-fuel', 'fuel-per-distance', 'uk-mpg']) .default('distance-per-fuel'), theme: z.string().default('light'), + darkVariant: z.string().default('default'), customCss: z.string().optional(), featureFuelLog: z.boolean().default(true), featureMaintenance: z.boolean().default(true), @@ -86,6 +87,11 @@ export function createSettingsOptions( label: theme.label, colorPreview: theme.colors?.primary || '#000' })), + darkVariantOptions: [ + { value: 'default', label: m.dark_variant_default() }, + { value: 'dim', label: m.dark_variant_dim() }, + { value: 'oled', label: m.dark_variant_oled() } + ], currencyOptions: currencies.map((currency) => ({ value: currency.code, label: `${getCurrencySymbol(currency.code)} - ${currency.currency} ` diff --git a/src/lib/stores/config.svelte.ts b/src/lib/stores/config.svelte.ts index 937e7b5c..5c54ab38 100644 --- a/src/lib/stores/config.svelte.ts +++ b/src/lib/stores/config.svelte.ts @@ -23,7 +23,8 @@ const DEFAULT_CONFIGS: Configs = { featureOverview: true, notificationProcessingEnabled: true, notificationProcessingSchedule: '0 9 * * *', - theme: 'light' + theme: 'light', + darkVariant: 'default' }; /** diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts index 9e97f4ff..d7b92f6b 100644 --- a/src/lib/stores/theme.svelte.ts +++ b/src/lib/stores/theme.svelte.ts @@ -1,10 +1,22 @@ -import { type ThemeName, type ThemeConfig } from '$lib/types/theme'; +import { type ThemeName, type ThemeConfig, type DarkVariant } from '$lib/types/theme'; import { themes } from '$lib/config/themes'; -import { getStoredTheme, saveTheme, applyThemeColors, setThemeClass } from '$lib/utils/theme'; +import { + getStoredTheme, + saveTheme, + applyThemeColors, + setThemeClass, + getStoredDarkVariant, + saveDarkVariant, + setDarkVariantAttr +} from '$lib/utils/theme'; + +const DARK_VARIANTS: DarkVariant[] = ['default', 'dim', 'oled']; interface ThemeStore { theme: ThemeName; + darkVariant: DarkVariant; setTheme: (name: ThemeName) => void; + setDarkVariant: (variant: DarkVariant) => void; getThemes: () => ThemeConfig[]; getActiveTheme: () => ThemeConfig | undefined; initializeTheme: () => void; @@ -12,6 +24,7 @@ interface ThemeStore { function createThemeStore(): ThemeStore { let theme = $state('slate'); + let darkVariant = $state('default'); let initialized = false; function applyTheme(name: ThemeName) { @@ -26,13 +39,14 @@ function createThemeStore(): ThemeStore { if (initialized || typeof window === 'undefined') return; const storedTheme = getStoredTheme(); - if (storedTheme && storedTheme in themes) { - theme = storedTheme; - } else { - theme = 'slate'; - } - + theme = storedTheme && storedTheme in themes ? storedTheme : 'slate'; applyTheme(theme); + + const storedVariant = getStoredDarkVariant(); + darkVariant = + storedVariant && DARK_VARIANTS.includes(storedVariant) ? storedVariant : 'default'; + setDarkVariantAttr(darkVariant); + initialized = true; } @@ -44,6 +58,14 @@ function createThemeStore(): ThemeStore { } } + function setDarkVariant(variant: DarkVariant) { + if (DARK_VARIANTS.includes(variant)) { + darkVariant = variant; + setDarkVariantAttr(variant); + saveDarkVariant(variant); + } + } + function getThemes(): ThemeConfig[] { return Object.values(themes); } @@ -59,7 +81,14 @@ function createThemeStore(): ThemeStore { set theme(value: ThemeName) { setTheme(value); }, + get darkVariant() { + return darkVariant; + }, + set darkVariant(value: DarkVariant) { + setDarkVariant(value); + }, setTheme, + setDarkVariant, getThemes, getActiveTheme, initializeTheme diff --git a/src/lib/types/theme.ts b/src/lib/types/theme.ts index dd789581..461bcd57 100644 --- a/src/lib/types/theme.ts +++ b/src/lib/types/theme.ts @@ -12,6 +12,9 @@ export type ThemeName = | 'indigo' | 'pink'; +/** Dark-mode contrast style, independent of the accent color chosen via ThemeName. */ +export type DarkVariant = 'default' | 'dim' | 'oled'; + export interface ThemeColors { background?: string; foreground?: string; diff --git a/src/lib/utils/theme.ts b/src/lib/utils/theme.ts index b2d60ab8..366145e1 100644 --- a/src/lib/utils/theme.ts +++ b/src/lib/utils/theme.ts @@ -1,6 +1,7 @@ -import type { ThemeName, ThemeConfig } from '$lib/types/theme'; +import type { ThemeName, ThemeConfig, DarkVariant } from '$lib/types/theme'; const THEME_STORAGE_KEY = 'tracktor-theme'; +const DARK_VARIANT_STORAGE_KEY = 'tracktor-dark-variant'; /** * Get the theme from localStorage @@ -66,3 +67,27 @@ export function setThemeClass(theme: ThemeName): void { const root = document.documentElement; root.setAttribute('data-theme', theme); } + +/** + * Get the dark mode variant from localStorage + */ +export function getStoredDarkVariant(): DarkVariant | null { + if (typeof window === 'undefined') return null; + return (localStorage.getItem(DARK_VARIANT_STORAGE_KEY) as DarkVariant) || null; +} + +/** + * Save the dark mode variant to localStorage + */ +export function saveDarkVariant(variant: DarkVariant): void { + if (typeof window === 'undefined') return; + localStorage.setItem(DARK_VARIANT_STORAGE_KEY, variant); +} + +/** + * Apply the dark mode variant attribute, read by the `.dark[data-dark-variant=...]` CSS overrides + */ +export function setDarkVariantAttr(variant: DarkVariant): void { + if (typeof document === 'undefined') return; + document.documentElement.setAttribute('data-dark-variant', variant); +} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 2ce25077..e2a09d4b 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -3,14 +3,12 @@ import * as Sidebar from '$ui/sidebar/index.js'; import AppSidebar from '$layout/AppSidebar.svelte'; import AppSheet from '$layout/AppSheet.svelte'; - import ThemeToggle from '$appui/ThemeToggle.svelte'; import Notifications from '$layout/Notifications.svelte'; import { configStore } from '$lib/stores/config.svelte'; import { vehicleStore } from '$stores/vehicle.svelte'; import { authStore } from '$stores/auth.svelte'; import { env } from '$lib/config/env'; import TriangleAlert from '@lucide/svelte/icons/triangle-alert'; - import LabelWithIcon from '$appui/LabelWithIcon.svelte'; import { demo_banner, default_login } from '$lib/paraglide/messages/_index.js'; let { data, children }: LayoutProps = $props(); @@ -36,31 +34,24 @@ -
    +
    -
    - - -
    -
    - {#if demoMode} -
    - - {demo_banner()} - + + {demo_banner()} {#if !env.DISABLE_AUTH} {default_login()} {/if} - +
    + {/if} +
    +
    - {/if} +
    {@render children()}
    diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 55c961cb..57176f32 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -45,6 +45,9 @@ if (f.data.theme) { themeStore.setTheme(f.data.theme as any); } + if (f.data.darkVariant) { + themeStore.setDarkVariant(f.data.darkVariant as any); + } const updatedConfig = formDataToConfigs(f.data as SettingsConfig, configStore.rawConfig); @@ -101,6 +104,7 @@ const { themeOptions, + darkVariantOptions, currencyOptions, uodOptions, uovOptions, @@ -146,6 +150,7 @@ ) as SettingsConfig; // Add current theme to form data (theme is client-side only) configData.theme = themeStore.theme; + configData.darkVariant = themeStore.darkVariant; notificationProcessingEnabled = configData.notificationProcessingEnabled !== false; formData.set(configData); } @@ -223,6 +228,7 @@ {formData} {processing} {themeOptions} + {darkVariantOptions} {localeOptions} {currencyOptions} {getTimezoneOptions} diff --git a/src/styles/app.css b/src/styles/app.css index d17053b9..251f77c2 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -96,6 +96,36 @@ html[dir='ltr'] { --cp-button-hover-color: var(--color-ring); } +/* Softer, lower-contrast dark surfaces for evening/low-light reading. */ +.dark[data-dark-variant='dim'] { + --background: oklch(0.225 0 0); + --card: oklch(0.265 0 0); + --popover: oklch(0.265 0 0); + --secondary: oklch(0.32 0 0); + --secondary-foreground: oklch(0.94 0 0); + --muted: oklch(0.32 0 0); + --muted-foreground: oklch(0.72 0 0); + --accent: oklch(0.34 0 0); + --border: oklch(1 0 0 / 12%); + --input: oklch(1 0 0 / 16%); + --sidebar: oklch(0.245 0 0); + --sidebar-accent: oklch(0.32 0 0); +} + +/* True-black surfaces for OLED screens. */ +.dark[data-dark-variant='oled'] { + --background: oklch(0 0 0); + --card: oklch(0.09 0 0); + --popover: oklch(0.09 0 0); + --secondary: oklch(0.13 0 0); + --muted: oklch(0.13 0 0); + --accent: oklch(0.15 0 0); + --border: oklch(1 0 0 / 8%); + --input: oklch(1 0 0 / 10%); + --sidebar: oklch(0 0 0); + --sidebar-accent: oklch(0.12 0 0); +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); From 1cc3ed234f2dfa0ca0533a333392a06791a552e4 Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Tue, 4 Aug 2026 09:07:01 +0530 Subject: [PATCH 13/23] Garage Page Enhancements --- .env.example | 1 - CLAUDE.md | 2 +- docs/openwiki/architecture/routing-and-api.md | 50 +- docs/openwiki/domain/data-models.md | 2 +- i18n/messages/en.json | 33 +- src/__tests__/file-upload-response.test.ts | 19 + .../widgets/VehicleQuickListWidget.svelte | 2 +- .../feature/vehicle/VehicleCard.svelte | 22 +- .../feature/vehicle/VehicleCardHeader.svelte | 81 ++- .../vehicle/VehicleDetailsModal.svelte | 167 ----- .../feature/vehicle/VehicleForm.svelte | 68 +- .../feature/vehicle/VehicleHubActivity.svelte | 115 ++++ .../feature/vehicle/VehicleHubDetails.svelte | 129 ++++ .../vehicle/VehicleHubFeatureLinks.svelte | 73 ++- .../feature/vehicle/VehicleHubHero.svelte | 193 +++--- .../feature/vehicle/VehicleHubStats.svelte | 48 -- .../feature/vehicle/VehicleImage.svelte | 18 + .../feature/vehicle/VehicleListItem.svelte | 11 +- src/lib/components/layout/AppSidebar.svelte | 8 +- .../components/ui/sheet/sheet-content.svelte | 2 +- .../components/ui/sheet/sheet-overlay.svelte | 5 +- .../ui/sidebar/sidebar-group-label.svelte | 2 +- src/lib/components/ui/sidebar/sidebar.svelte | 4 +- src/lib/domain/vehicle.ts | 124 +++- src/lib/services/entity-service.ts | 2 +- src/lib/services/maintenance.service.ts | 2 +- src/lib/services/notification.service.ts | 8 +- src/lib/services/vehicle.service.ts | 4 +- .../(app)/{vehicles => garage}/+page.svelte | 17 +- .../{vehicles => garage}/[id]/+page.server.ts | 0 src/routes/(app)/garage/[id]/+page.svelte | 82 +++ src/routes/(app)/vehicles/[id]/+page.svelte | 31 - src/routes/api/files/+server.ts | 13 +- ...801044257_add_vehicle_type_to_vehicles.sql | 2 + .../meta/20260801044257_snapshot.json | 617 ++++++++++++++++++ src/server/db/migrations/meta/_journal.json | 7 + src/server/db/schema/vehicle.ts | 17 + src/server/services/vehicleService.ts | 95 ++- static/default-dark.png | Bin 0 -> 1377371 bytes static/default-light.png | Bin 0 -> 1141267 bytes static/default-vehicle.png | Bin 141827 -> 0 bytes 41 files changed, 1562 insertions(+), 514 deletions(-) create mode 100644 src/__tests__/file-upload-response.test.ts delete mode 100644 src/lib/components/feature/vehicle/VehicleDetailsModal.svelte create mode 100644 src/lib/components/feature/vehicle/VehicleHubActivity.svelte create mode 100644 src/lib/components/feature/vehicle/VehicleHubDetails.svelte delete mode 100644 src/lib/components/feature/vehicle/VehicleHubStats.svelte create mode 100644 src/lib/components/feature/vehicle/VehicleImage.svelte rename src/routes/(app)/{vehicles => garage}/+page.svelte (79%) rename src/routes/(app)/{vehicles => garage}/[id]/+page.server.ts (100%) create mode 100644 src/routes/(app)/garage/[id]/+page.svelte delete mode 100644 src/routes/(app)/vehicles/[id]/+page.svelte create mode 100644 src/server/db/migrations/20260801044257_add_vehicle_type_to_vehicles.sql create mode 100644 src/server/db/migrations/meta/20260801044257_snapshot.json create mode 100644 static/default-dark.png create mode 100644 static/default-light.png delete mode 100644 static/default-vehicle.png diff --git a/.env.example b/.env.example index 6fa6bccc..1dd115dc 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,6 @@ DB_PATH="./tracktor.db" UPLOADS_DIR="./uploads" # Application Features -BASE_URL=http://localhost:3000 TRACKTOR_DEMO_MODE=false FORCE_DATA_SEED=false TRACKTOR_DISABLE_AUTH=false diff --git a/CLAUDE.md b/CLAUDE.md index 2eaa7586..e14abb75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ Note: test coverage is currently minimal (essentially a placeholder in `src/__te - `src/routes/(app)/*` — authenticated app pages (dashboard, fuel, maintenance, insurance, pollution, reminders, vehicles, expenses, reports, settings), sharing the app shell (`AppSidebar`, `+layout.svelte`). - `src/routes/(auth)/*` — login/register, outside the app shell. -- `src/routes/api/*` — REST endpoints as `+server.ts` files; vehicle-scoped resources nest under `api/vehicles/[id]/...`. +- `src/routes/api/*` — REST endpoints as `+server.ts` files; vehicle-scoped resources nest under `api/garage/[id]/...`. The app recently moved from a `/dashboard/*` nested-route structure to top-level feature routes (`/fuel`, `/insurance`, `/maintenance`, `/pollution`, `/reminders`) with a single sidebar app shell — the old `dashboard/(feature)` routes are being removed in favor of this flatter structure with fleet-wide/vehicle-selector support baked into each page. diff --git a/docs/openwiki/architecture/routing-and-api.md b/docs/openwiki/architecture/routing-and-api.md index 56547a71..975bb111 100644 --- a/docs/openwiki/architecture/routing-and-api.md +++ b/docs/openwiki/architecture/routing-and-api.md @@ -42,49 +42,49 @@ All API routes return the shared `ApiResponse` shape (`success`, `data`, `messag - `GET /api/vehicles` — list all vehicles. - `POST /api/vehicles` — create vehicle. - `PUT /api/vehicles` — update vehicle. -- `GET /api/vehicles/[id]` — get one vehicle. -- `DELETE /api/vehicles/[id]` — delete vehicle. +- `GET /api/garage/[id]` — get one vehicle. +- `DELETE /api/garage/[id]` — delete vehicle. ### Fuel logs -- `GET /api/vehicles/[id]/fuel-logs` — list fuel logs for a vehicle with computed `distanceDriven` and mileage. -- `POST /api/vehicles/[id]/fuel-logs` — create fuel log. -- `PUT /api/vehicles/[id]/fuel-logs/[logId]` — update fuel log. -- `DELETE /api/vehicles/[id]/fuel-logs/[logId]` — delete fuel log. +- `GET /api/garage/[id]/fuel-logs` — list fuel logs for a vehicle with computed `distanceDriven` and mileage. +- `POST /api/garage/[id]/fuel-logs` — create fuel log. +- `PUT /api/garage/[id]/fuel-logs/[logId]` — update fuel log. +- `DELETE /api/garage/[id]/fuel-logs/[logId]` — delete fuel log. ### Maintenance logs -- `GET /api/vehicles/[id]/maintenance-logs` — list maintenance logs. -- `POST /api/vehicles/[id]/maintenance-logs` — create maintenance log. -- `PUT /api/vehicles/[id]/maintenance-logs/[logId]` — update maintenance log. -- `DELETE /api/vehicles/[id]/maintenance-logs/[logId]` — delete maintenance log. -- `GET /api/vehicles/[id]/maintenance-logs/export-pdf` — PDF export (added in `d34021f`). +- `GET /api/garage/[id]/maintenance-logs` — list maintenance logs. +- `POST /api/garage/[id]/maintenance-logs` — create maintenance log. +- `PUT /api/garage/[id]/maintenance-logs/[logId]` — update maintenance log. +- `DELETE /api/garage/[id]/maintenance-logs/[logId]` — delete maintenance log. +- `GET /api/garage/[id]/maintenance-logs/export-pdf` — PDF export (added in `d34021f`). ### Insurance -- `GET /api/vehicles/[id]/insurance` -- `POST /api/vehicles/[id]/insurance` -- `PUT /api/vehicles/[id]/insurance/[insuranceId]` -- `DELETE /api/vehicles/[id]/insurance/[insuranceId]` +- `GET /api/garage/[id]/insurance` +- `POST /api/garage/[id]/insurance` +- `PUT /api/garage/[id]/insurance/[insuranceId]` +- `DELETE /api/garage/[id]/insurance/[insuranceId]` ### PUCC (pollution certificates) -- `GET /api/vehicles/[id]/pucc` -- `POST /api/vehicles/[id]/pucc` -- `PUT /api/vehicles/[id]/pucc/[puccId]` -- `DELETE /api/vehicles/[id]/pucc/[puccId]` +- `GET /api/garage/[id]/pucc` +- `POST /api/garage/[id]/pucc` +- `PUT /api/garage/[id]/pucc/[puccId]` +- `DELETE /api/garage/[id]/pucc/[puccId]` ### Reminders -- `GET /api/vehicles/[id]/reminders` -- `POST /api/vehicles/[id]/reminders` -- `PUT /api/vehicles/[id]/reminders/[reminderId]` -- `DELETE /api/vehicles/[id]/reminders/[reminderId]` +- `GET /api/garage/[id]/reminders` +- `POST /api/garage/[id]/reminders` +- `PUT /api/garage/[id]/reminders/[reminderId]` +- `DELETE /api/garage/[id]/reminders/[reminderId]` ### Notifications -- `GET /api/vehicles/[id]/notifications` -- `PUT /api/vehicles/[id]/notifications/[notificationId]` +- `GET /api/garage/[id]/notifications` +- `PUT /api/garage/[id]/notifications/[notificationId]` - `GET /api/notifications/test-enabled-providers` — trigger a test dispatch. - `POST /api/test-email-digest` — send a test email digest. diff --git a/docs/openwiki/domain/data-models.md b/docs/openwiki/domain/data-models.md index 52fb3cdf..bdc22a15 100644 --- a/docs/openwiki/domain/data-models.md +++ b/docs/openwiki/domain/data-models.md @@ -53,7 +53,7 @@ Recent additions: - Schema: `src/server/db/schema/maintenance-logs.ts` - Server service: `src/server/services/maintenanceLogService.ts` - Client service: `src/lib/services/maintenance.service.ts` -- PDF export: `src/server/services/maintenanceLogPdfService.ts` + `src/routes/api/vehicles/[id]/maintenance-logs/export-pdf/+server.ts` +- PDF export: `src/server/services/maintenanceLogPdfService.ts` + `src/routes/api/garage/[id]/maintenance-logs/export-pdf/+server.ts` Fields: `id`, `vehicleId`, `date`, `odometer`, `serviceCenter`, `cost`, `notes`, `attachment`. diff --git a/i18n/messages/en.json b/i18n/messages/en.json index 775d2763..7dd3c25c 100644 --- a/i18n/messages/en.json +++ b/i18n/messages/en.json @@ -64,6 +64,9 @@ "common_litre": "Litre", "common_gallon": "Gallon", "common_submit": "Submit", + "common_view": "View", + "common_view_all": "View All", + "common_add": "Add", "common_yes": "Yes", "common_no": "No", "common_cancel": "Cancel", @@ -101,6 +104,9 @@ "vehicle_form_fuel_type_label": "Fuel Type", "vehicle_form_fuel_type_desc": "Type of fuel used by the vehicle", "vehicle_form_fuel_type_placeholder": "Select fuel type", + "vehicle_form_vehicle_type_label": "Vehicle Type", + "vehicle_form_vehicle_type_desc": "Category of the vehicle", + "vehicle_form_vehicle_type_placeholder": "Select vehicle type", "vehicle_form_odometer_label": "Odometer", "vehicle_form_odometer_desc": "Current vehicle odometer reading", "vehicle_form_license_label": "License Plate", @@ -422,6 +428,7 @@ "custom_fields_empty_message": "No custom fields added. Click \"Add Field\" to get started.", "vehicle_details_vin": "VIN", "vehicle_details_not_specified": "Not specified", + "vehicle_details_not_available": "Not available", "vehicle_details_section_title": "Details", "vehicle_details_license_plate": "License Plate", "vehicle_details_fuel_type": "Fuel Type", @@ -429,6 +436,16 @@ "vehicle_details_not_recorded": "Not recorded", "vehicle_details_color": "Color", "vehicle_details_year": "Year", + "vehicle_type_car": "Car", + "vehicle_type_motorcycle": "Motorcycle", + "vehicle_type_scooter": "Scooter", + "vehicle_type_truck": "Truck", + "vehicle_type_van": "Van", + "vehicle_type_bus": "Bus", + "vehicle_type_farm_vehicle": "Farm Vehicle", + "vehicle_type_yacht": "Yacht", + "vehicle_type_rv": "RV / Caravan", + "vehicle_type_other": "Other", "fuel_type_diesel": "Diesel", "fuel_type_petrol": "Petrol", "fuel_type_electric": "Electric", @@ -710,7 +727,21 @@ "notif_cron_hourly_at_minute": "Every hour at minute {n}", "notif_cron_daily_at": "Daily at {time}", "vehicle_hub_back_to_vehicles": "Back to Vehicles", - "vehicle_hub_manage_title": "Manage this vehicle", + "vehicle_hub_plate_copied": "License plate copied", + "vehicle_hub_insurance_valid_till": "Insurance Valid Till", + "vehicle_hub_pollution_valid_till": "Pollution Valid Till", + "vehicle_hub_vehicle_type": "Vehicle Type", + "vehicle_hub_activity_title": "Recent Activity", + "vehicle_hub_activity_empty": "No recent activity", + "vehicle_hub_activity_fuel_added": "Fuel Added", + "vehicle_hub_activity_maintenance": "Maintenance", + "vehicle_hub_activity_insurance_renewed": "Insurance Renewed", + "vehicle_hub_activity_policy_prefix": "Policy #", + "vehicle_hub_records_count": "{count} records", + "vehicle_hub_valid_till": "Valid till {date}", + "vehicle_hub_upcoming_count": "{count} upcoming", + "vehicle_hub_view_details": "View Details", + "vehicle_hub_manage_title": "Manage Vehicle", "vehicle_hub_stat_odometer": "Odometer", "vehicle_hub_stat_mileage": "Overall Mileage", "vehicle_hub_stat_fuel_logs": "Fuel Logs", diff --git a/src/__tests__/file-upload-response.test.ts b/src/__tests__/file-upload-response.test.ts new file mode 100644 index 00000000..32cdd7de --- /dev/null +++ b/src/__tests__/file-upload-response.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('fs/promises', () => ({ writeFile: vi.fn().mockResolvedValue(undefined) })); + +import { POST } from '../routes/api/files/+server'; + +describe('POST /api/files', () => { + it('returns the filename where uploadFile() reads it (res.data.filename)', async () => { + const body = new FormData(); + body.append('file', new File(['x'], 'car.png', { type: 'image/png' })); + + const response = await POST({ + request: new Request('http://localhost/api/files', { method: 'POST', body }) + } as Parameters[0]); + + const payload = await response.json(); + expect(payload.data.filename).toMatch(/car\.png$/); + }); +}); diff --git a/src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte b/src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte index 0768a457..ed851aff 100644 --- a/src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte +++ b/src/lib/components/dashboard/widgets/VehicleQuickListWidget.svelte @@ -10,7 +10,7 @@ { - if (vehicle.id) goto(`/vehicles/${vehicle.id}`); + if (vehicle.id) goto(`/garage/${vehicle.id}`); }} actions={false} /> diff --git a/src/lib/components/feature/vehicle/VehicleCard.svelte b/src/lib/components/feature/vehicle/VehicleCard.svelte index 7930b2b3..2cf5833a 100644 --- a/src/lib/components/feature/vehicle/VehicleCard.svelte +++ b/src/lib/components/feature/vehicle/VehicleCard.svelte @@ -1,5 +1,4 @@ -
    - {#if imageUrl} - car - {/if} -
    -
    -
    -
    -
    - {vehicle.make} {vehicle.model} + +
    + + +
    +
    +
    +
    +
    -

    - {#if vehicle.color} - - {/if} -

    -
    -
    - - + + {vehicle.make} + {vehicle.model} +
    +

    + {#if vehicle.color} + + {/if} +

    -
    +
    + + +
    diff --git a/src/lib/components/feature/vehicle/VehicleDetailsModal.svelte b/src/lib/components/feature/vehicle/VehicleDetailsModal.svelte deleted file mode 100644 index 54f8a4c5..00000000 --- a/src/lib/components/feature/vehicle/VehicleDetailsModal.svelte +++ /dev/null @@ -1,167 +0,0 @@ - - - - - -
    -
    - {#if vehicle.color} -
    - {/if} - - - -
    -

    - {vehicle.make} - {vehicle.model} -

    - -
    -
    - - -
    - {#if vehicle.licensePlate} - - {vehicle.licensePlate} - - {/if} - - {vehicle.year} - - - {fuelLabel} - - {#if odometerText} - - {odometerText} - - {/if} -
    - - -
    - -
    -
    -

    - - {m.vehicle_details_section_title()} -

    - -
    -
    -
    -

    - {m.vehicle_details_license_plate()} -

    -

    - {vehicle.licensePlate || m.vehicle_details_not_specified()} -

    -
    -
    -

    - {m.vehicle_details_vin()} -

    - {#if vehicle.vin} -

    {vehicle.vin}

    - {:else} -

    - {m.vehicle_details_not_specified()} -

    - {/if} -
    -
    -

    - {m.vehicle_details_fuel_type()} -

    -

    {fuelLabel}

    -
    -
    -

    - {m.vehicle_details_odometer()} -

    -

    - {odometerText || m.vehicle_details_not_recorded()} -

    -
    -
    -

    - {m.vehicle_details_color()} -

    -

    - {vehicle.color || m.vehicle_details_not_specified()} -

    -
    -
    -

    - {m.vehicle_details_year()} -

    -

    {vehicle.year}

    -
    -
    -
    - - - {#if vehicle.customFields && Object.keys(vehicle.customFields).length > 0} -
    -
    -

    - - {m.custom_fields_label()} -

    - -
    -
    - {#each Object.entries(vehicle.customFields) as [key, value]} -
    -

    {key}

    -

    {value}

    -
    - {/each} -
    -
    - {/if} -
    -
    -
    diff --git a/src/lib/components/feature/vehicle/VehicleForm.svelte b/src/lib/components/feature/vehicle/VehicleForm.svelte index e33ed22c..959b86f7 100644 --- a/src/lib/components/feature/vehicle/VehicleForm.svelte +++ b/src/lib/components/feature/vehicle/VehicleForm.svelte @@ -11,7 +11,14 @@ import Input from '$appui/input.svelte'; import { saveVehicleWithImage } from '$lib/services/vehicle.service'; import { vehicleStore } from '$stores/vehicle.svelte'; - import { vehicleSchema, FUEL_TYPES, getFuelTypeLabel } from '$lib/domain/vehicle'; + import { + vehicleSchema, + FUEL_TYPES, + VEHICLE_TYPES, + getFuelTypeLabel, + getVehicleTypeLabel, + getVehicleTypeIcon + } from '$lib/domain/vehicle'; import Building2 from '@lucide/svelte/icons/building-2'; import Calendar from '@lucide/svelte/icons/calendar'; import CarFront from '@lucide/svelte/icons/car-front'; @@ -183,6 +190,38 @@
    + + + {#snippet children({ props })} + {@const VehicleTypeIcon = getVehicleTypeIcon($formData.vehicleType)} + + {m.vehicle_form_vehicle_type_label()} + + + +
    + + + {$formData.vehicleType + ? getVehicleTypeLabel($formData.vehicleType, m) + : m.vehicle_form_vehicle_type_placeholder()} + +
    +
    + + {#each Object.keys(VEHICLE_TYPES) as value} + {@const ItemIcon = getVehicleTypeIcon(value)} + + + {getVehicleTypeLabel(value, m)} + + {/each} + +
    + {/snippet} +
    + +
    {#snippet children({ props })} @@ -210,6 +249,8 @@ +
    +
    {#snippet children({ props })} @@ -222,20 +263,19 @@ + + + {#snippet children({ props })} + + {m.vehicle_form_license_label()} + + + {/snippet} + + + +
    - - - - {#snippet children({ props })} - - {m.vehicle_form_license_label()} - - - {/snippet} - - - - {#snippet children({ props })} diff --git a/src/lib/components/feature/vehicle/VehicleHubActivity.svelte b/src/lib/components/feature/vehicle/VehicleHubActivity.svelte new file mode 100644 index 00000000..b6117b92 --- /dev/null +++ b/src/lib/components/feature/vehicle/VehicleHubActivity.svelte @@ -0,0 +1,115 @@ + + +
    +
    +
    +

    {m.vehicle_hub_activity_title()}

    +
    +
    + +
    + + {#if entries.length === 0} +

    {m.vehicle_hub_activity_empty()}

    + {:else} +
    + {#each entries as entry (entry.id)} + {@const style = KIND_STYLES[entry.kind]} + {@const sub = subtitle(entry)} + + {/each} +
    + {/if} +
    diff --git a/src/lib/components/feature/vehicle/VehicleHubDetails.svelte b/src/lib/components/feature/vehicle/VehicleHubDetails.svelte new file mode 100644 index 00000000..ab0115d9 --- /dev/null +++ b/src/lib/components/feature/vehicle/VehicleHubDetails.svelte @@ -0,0 +1,129 @@ + + +{#snippet row(item: InfoRow)} +
    + + + +
    +

    {item.label}

    +
    + {#if item.colorDot} + + {/if} +

    {item.value}

    + {#if item.badge} + + {/if} +
    +
    +
    +{/snippet} + +
    +
    +

    {m.vehicle_details_section_title()}

    +
    +
    +
    + {#each leftRows as item (item.label)} + {@render row(item)} + {/each} + {#each rightRows as item (item.label)} + {@render row(item)} + {/each} +
    + + {#if customFieldEntries.length > 0} +
    +

    {m.custom_fields_label()}

    +
    +
    +
    + {#each customFieldEntries as [key, value] (key)} +
    +

    {key}

    +

    {value}

    +
    + {/each} +
    + {/if} +
    diff --git a/src/lib/components/feature/vehicle/VehicleHubFeatureLinks.svelte b/src/lib/components/feature/vehicle/VehicleHubFeatureLinks.svelte index a0281f97..982ae7e3 100644 --- a/src/lib/components/feature/vehicle/VehicleHubFeatureLinks.svelte +++ b/src/lib/components/feature/vehicle/VehicleHubFeatureLinks.svelte @@ -8,21 +8,21 @@ import CirclePlus from '@lucide/svelte/icons/circle-plus'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; import FeatureGate from '$feature/FeatureGate.svelte'; - import IconButton from '$appui/IconButton.svelte'; + import Button from '$ui/button/button.svelte'; import { sheetStore } from '$stores/sheet.svelte'; import { Features } from '$lib/helper/feature.helper'; + import { formatDate } from '$lib/helper/format.helper'; import FuelLogForm from '../fuel/FuelLogForm.svelte'; import MaintenanceForm from '../maintenance/MaintenanceForm.svelte'; - import InsuranceForm from '../insurance/InsuranceForm.svelte'; - import PollutionCertificateForm from '../pollution/PollutionCertificateForm.svelte'; - import ReminderForm from '../reminder/ReminderForm.svelte'; + import type { VehicleHubSummary } from '$lib/domain/vehicle'; import * as m from '$lib/paraglide/messages'; interface Props { - vehicleId: string; + vehicle: VehicleHubSummary; } - let { vehicleId }: Props = $props(); + let { vehicle }: Props = $props(); + const vehicleId = $derived(vehicle.id ?? ''); const links = $derived([ { @@ -30,6 +30,7 @@ feature: Features.FUEL_LOG, icon: Fuel, label: m.nav_fuel_logs(), + subtitle: m.vehicle_hub_records_count({ count: vehicle.totalFuelLogs ?? 0 }), href: `/fuel?vehicle=${vehicleId}`, dotClass: 'bg-green-500/10 text-green-600 dark:text-green-400', addAriaLabel: m.vehicle_action_add_fuel_log(), @@ -41,6 +42,7 @@ feature: Features.MAINTENANCE, icon: Wrench, label: m.nav_maintenance(), + subtitle: m.vehicle_hub_records_count({ count: vehicle.totalMaintenanceLogs ?? 0 }), href: `/maintenance?vehicle=${vehicleId}`, dotClass: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', addAriaLabel: m.vehicle_action_add_maintenance_log(), @@ -54,43 +56,44 @@ feature: Features.INSURANCE, icon: Shield, label: m.nav_insurance(), + subtitle: vehicle.insuranceValidTill + ? m.vehicle_hub_valid_till({ date: formatDate(vehicle.insuranceValidTill) }) + : m.vehicle_details_not_available(), href: `/insurance?vehicle=${vehicleId}`, - dotClass: 'bg-sky-500/10 text-sky-600 dark:text-sky-400', - addAriaLabel: m.vehicle_action_add_insurance(), - onAdd: () => - sheetStore.openSheet(InsuranceForm, m.vehicle_action_add_insurance(), '', { vehicleId }) + dotClass: 'bg-sky-500/10 text-sky-600 dark:text-sky-400' }, { id: 'pollution', feature: Features.PUCC, icon: BadgeCheck, label: m.nav_pollution(), + subtitle: vehicle.puccValidTill + ? m.vehicle_hub_valid_till({ date: formatDate(vehicle.puccValidTill) }) + : m.vehicle_details_not_available(), href: `/pollution?vehicle=${vehicleId}`, - dotClass: 'bg-fuchsia-500/10 text-fuchsia-600 dark:text-fuchsia-400', - addAriaLabel: m.vehicle_action_add_pollution(), - onAdd: () => - sheetStore.openSheet(PollutionCertificateForm, m.vehicle_action_add_pollution(), '', { - vehicleId - }) + dotClass: 'bg-fuchsia-500/10 text-fuchsia-600 dark:text-fuchsia-400' }, { id: 'reminders', feature: Features.REMINDERS, icon: BellRing, label: m.nav_reminders(), + subtitle: m.vehicle_hub_upcoming_count({ count: vehicle.upcomingRemindersCount ?? 0 }), href: `/reminders?vehicle=${vehicleId}`, - dotClass: 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400', - addAriaLabel: m.vehicle_action_add_reminder(), - onAdd: () => - sheetStore.openSheet(ReminderForm, m.vehicle_action_add_reminder(), '', { vehicleId }) + dotClass: 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400' } ]); - {:else}
    {#if Icon}
    @@ -71,9 +71,14 @@
    {/if} -
    - {label} - {value} +
    + {label} + {value} {@render trendBadge()}
    diff --git a/src/lib/components/dashboard/StatusPill.svelte b/src/lib/components/dashboard/StatusPill.svelte index 9db7371b..7a47afc3 100644 --- a/src/lib/components/dashboard/StatusPill.svelte +++ b/src/lib/components/dashboard/StatusPill.svelte @@ -10,7 +10,8 @@ | 'needs_action' | 'attention' | 'good' - | 'not_available'; + | 'not_available' + | 'upcoming'; interface Props { status: Status; @@ -27,7 +28,8 @@ needs_action: 'Needs Action', attention: 'Attention', good: 'Good', - not_available: 'N/A' + not_available: 'N/A', + upcoming: 'Upcoming' }; const displayLabel = $derived(label ?? defaultLabels[status]); @@ -35,7 +37,7 @@ const variant = $derived( status === 'expired' || status === 'needs_action' ? 'destructive' - : status === 'expiring_soon' || status === 'attention' + : status === 'expiring_soon' || status === 'attention' || status === 'upcoming' ? 'secondary' : status === 'not_available' ? 'outline' @@ -49,7 +51,9 @@ ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300 border-amber-200 dark:border-amber-800' : status === 'not_available' ? 'bg-gray-100 text-gray-800 dark:bg-gray-900/50 dark:text-gray-300 border-gray-200 dark:border-gray-800' - : 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300 border-red-200 dark:border-red-800' + : status === 'upcoming' + ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300 border-blue-200 dark:border-blue-800' + : 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300 border-red-200 dark:border-red-800' ); diff --git a/src/lib/components/dashboard/widget-registry.ts b/src/lib/components/dashboard/widget-registry.ts index a880b217..dbe86978 100644 --- a/src/lib/components/dashboard/widget-registry.ts +++ b/src/lib/components/dashboard/widget-registry.ts @@ -5,6 +5,8 @@ import ExpenseBreakdownWidget from './widgets/ExpenseBreakdownWidget.svelte'; import MonthlyExpenseTrendWidget from './widgets/MonthlyExpenseTrendWidget.svelte'; import VehicleLeaderboardWidget from './widgets/VehicleLeaderboardWidget.svelte'; import FleetFuelTrendWidget from './widgets/FleetFuelTrendWidget.svelte'; +import FuelConsumptionTrendWidget from './widgets/FuelConsumptionTrendWidget.svelte'; +import MileageOverviewWidget from './widgets/MileageOverviewWidget.svelte'; import StatusDonutWidget from './widgets/StatusDonutWidget.svelte'; import VehicleHealthWidget from './widgets/VehicleHealthWidget.svelte'; import UpcomingRemindersWidget from './widgets/UpcomingRemindersWidget.svelte'; @@ -119,6 +121,22 @@ export const WIDGET_REGISTRY: Record = { defaultColSpan: 9, defaultRowSpan: 8 }, + 'fuel-consumption-trend': { + type: 'fuel-consumption-trend', + title: 'Fuel Consumption Trend', + description: 'Fuel usage over time, one line per vehicle', + component: FuelConsumptionTrendWidget, + defaultColSpan: 9, + defaultRowSpan: 8 + }, + 'mileage-overview-trend': { + type: 'mileage-overview-trend', + title: 'Mileage Overview', + description: 'Mileage over time, one line per vehicle', + component: MileageOverviewWidget, + defaultColSpan: 9, + defaultRowSpan: 8 + }, 'efficiency-leaderboard': { type: 'efficiency-leaderboard', title: 'Efficiency Leaderboard', diff --git a/src/lib/components/dashboard/widgets/FuelConsumptionTrendWidget.svelte b/src/lib/components/dashboard/widgets/FuelConsumptionTrendWidget.svelte new file mode 100644 index 00000000..a95288d5 --- /dev/null +++ b/src/lib/components/dashboard/widgets/FuelConsumptionTrendWidget.svelte @@ -0,0 +1,22 @@ + + + `${value.toFixed(1)} L`} + xFormatter={(v: Date) => + v.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })} +/> diff --git a/src/lib/components/dashboard/widgets/MileageOverviewWidget.svelte b/src/lib/components/dashboard/widgets/MileageOverviewWidget.svelte new file mode 100644 index 00000000..2b456443 --- /dev/null +++ b/src/lib/components/dashboard/widgets/MileageOverviewWidget.svelte @@ -0,0 +1,23 @@ + + + formatMileage(value, series.fuelType)} + xFormatter={(v: Date) => + v.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })} +/> diff --git a/src/lib/components/feature/fuel/FuelLogList.svelte b/src/lib/components/feature/fuel/FuelLogList.svelte index 43176c47..1e152da7 100644 --- a/src/lib/components/feature/fuel/FuelLogList.svelte +++ b/src/lib/components/feature/fuel/FuelLogList.svelte @@ -2,11 +2,15 @@ import { type FuelLog } from '$lib/domain/fuel'; import Badge from '$ui/badge/badge.svelte'; + import Button from '$ui/button/button.svelte'; + import Input from '$appui/input.svelte'; + import * as DropdownMenu from '$ui/dropdown-menu'; import { formatTableBoolean, formatTableFuelAmount, formatTableMileage } from '$helper/table-cell.helper'; + import { getColumnDisplayName } from '$helper/table.helper'; import FuelLogContextMenu from './FuelLogContextMenu.svelte'; import Banknote from '@lucide/svelte/icons/banknote'; import Rabbit from '@lucide/svelte/icons/rabbit'; @@ -17,7 +21,12 @@ import PaintBucket from '@lucide/svelte/icons/paint-bucket'; import SquircleDashed from '@lucide/svelte/icons/squircle-dashed'; import Paperclip from '@lucide/svelte/icons/paperclip'; - import type { ColumnDef } from '@tanstack/table-core'; + import CirclePlus from '@lucide/svelte/icons/circle-plus'; + import ImportIcon from '@lucide/svelte/icons/import'; + import Columns3 from '@lucide/svelte/icons/columns-3'; + import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; + import SearchIcon from '@lucide/svelte/icons/search'; + import type { ColumnDef, Table } from '@tanstack/table-core'; import { renderComponent, renderSnippet } from '$ui/data-table'; import LabelWithIcon from '$appui/LabelWithIcon.svelte'; import StoreResourceState from '$appui/StoreResourceState.svelte'; @@ -35,6 +44,7 @@ import { vehicleStore } from '$stores/vehicle.svelte'; import { page } from '$app/state'; import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; + import * as m from '$lib/paraglide/messages'; import { col_date, col_odometer, @@ -52,6 +62,13 @@ common_no } from '$lib/paraglide/messages/_index.js'; + interface Props { + addAction?: (() => void) | null; + importAction?: (() => void) | null; + } + + let { addAction = null, importAction = null }: Props = $props(); + const scope = $derived(readVehicleScope(page.url, vehicleStore.vehicles)); const selectedVehicle = $derived(scope.vehicle); const volumeLabel = $derived( @@ -222,17 +239,87 @@ }); - - {#snippet skeleton()} - - {/snippet} - - +
    + + {#snippet skeleton()} + + {/snippet} + + {#snippet toolbar(table: Table)} +
    + table.getColumn('notes')?.setFilterValue(e.currentTarget.value)} + onchange={(e) => { + table.getColumn('notes')?.setFilterValue(e.currentTarget.value); + }} + icon={SearchIcon} + class="bg-background/60 h-full max-w-sm" + /> +
    + + + {#snippet child({ props })} + + {/snippet} + + + {#each table + .getAllColumns() + .filter((col: any) => typeof col.accessorFn !== 'undefined' && col.getCanHide()) as column (column.id)} + column.toggleVisibility(!!value)} + > + {getColumnDisplayName(column)} + + {/each} + + + {@render actionButtons()} +
    +
    + {/snippet} +
    +
    +
    + +{#snippet actionButtons()} + {#if importAction} + + {/if} + {#if addAction} + + {/if} +{/snippet} {#snippet badge({ value }: { value: boolean })}
    diff --git a/src/lib/components/feature/fuel/FuelLogTab.svelte b/src/lib/components/feature/fuel/FuelLogTab.svelte index ad9c46bb..26e33019 100644 --- a/src/lib/components/feature/fuel/FuelLogTab.svelte +++ b/src/lib/components/feature/fuel/FuelLogTab.svelte @@ -1,20 +1,12 @@ - sheetStore.openSheet(FuelLogForm, fuel_add_title(), '')} + importAction={() => sheetStore.openSheet(FuelLogImportForm, fuel_import_title(), '')} /> diff --git a/src/lib/components/feature/maintenance/MaintenanceLogList.svelte b/src/lib/components/feature/maintenance/MaintenanceLogList.svelte index 3eb71a56..bfa80175 100644 --- a/src/lib/components/feature/maintenance/MaintenanceLogList.svelte +++ b/src/lib/components/feature/maintenance/MaintenanceLogList.svelte @@ -1,14 +1,23 @@ - - {#snippet skeleton()} - - {/snippet} -
    - -
    -
    +
    + + {#snippet skeleton()} + + {/snippet} + + {#snippet toolbar(table: Table)} +
    + table.getColumn('serviceCenter')?.setFilterValue(e.currentTarget.value)} + onchange={(e) => { + table.getColumn('serviceCenter')?.setFilterValue(e.currentTarget.value); + }} + icon={SearchIcon} + class="bg-background/60 h-full max-w-sm" + /> +
    + + + {#snippet child({ props })} + + {/snippet} + + + {#each table + .getAllColumns() + .filter((col: any) => typeof col.accessorFn !== 'undefined' && col.getCanHide()) as column (column.id)} + column.toggleVisibility(!!value)} + > + {getColumnDisplayName(column)} + + {/each} + + + {@render actionButtons()} +
    +
    + {/snippet} +
    +
    +
    + +{#snippet actionButtons()} + {#if exportAction} + + {/if} + {#if addAction} + + {/if} +{/snippet} {#snippet serviceCenterCell({ value }: { value: string | null })}
    {formatTableText(value)}
    diff --git a/src/lib/components/feature/maintenance/MaintenanceTimeline.svelte b/src/lib/components/feature/maintenance/MaintenanceTimeline.svelte index 0ade24b8..99e4969f 100644 --- a/src/lib/components/feature/maintenance/MaintenanceTimeline.svelte +++ b/src/lib/components/feature/maintenance/MaintenanceTimeline.svelte @@ -1,39 +1,159 @@ -{#if logs.length === 0} -
    - No service history yet -
    -{:else} -
      - {#each logs as log (log.id)} -
    1. -
      - - -
      -
      -
      -

      {log.serviceCenter}

      - - Completed - -
      -

      - {formatDate(log.date)} · at {formatDistance(log.odometer)} +

      +
      +
      + + + +

      {m.maintenance_stat_next_service()}

      +
      + + {#if nextReminder} + {@const days = daysUntil(nextReminder.dueDate)} + +

      {formatDate(nextReminder.dueDate)}

      + {#if isFleet} +

      + {`${nextReminder.vehicleMake ?? ''} ${nextReminder.vehicleModel ?? ''}`.trim()} +

      + {/if} +

      + {nextReminder.note || m.maintenance_next_service_fallback()} +

      + + {#if upcoming.length > 1} +
      +

      + {m.maintenance_also_upcoming()}

      - {#if log.cost} -

      {formatCurrency(log.cost)}

      - {/if} + {#each upcoming.slice(1, 4) as reminder (reminderKey(reminder))} +
      + {reminderTitle(reminder)} + {formatDate(reminder.dueDate)} +
      + {/each}
      -
    2. - {/each} -
    -{/if} + {/if} + {:else} +
    + {m.maintenance_upcoming_empty()} +
    + {/if} +
    + +
    + {#if timelineEntries.length === 0} +
    + {m.maintenance_history_empty()} +
    + {:else} +
      + {#each timelineEntries as entry, i (entry.key)} +
    1. +
      + + {#if i < timelineEntries.length - 1} + + {/if} +
      +
      +
      +

      {formatDate(entry.date)}

      + {#if entry.kind === 'upcoming'} + {@const days = daysUntil(entry.reminder.dueDate)} + + {:else} + + {m.reminder_status_completed()} + + {/if} +
      + {#if entry.kind === 'upcoming'} +

      + {reminderTitle(entry.reminder)} +

      + {:else} +

      + {#if isFleet} + {`${entry.log.vehicleMake ?? ''} ${entry.log.vehicleModel ?? ''}`.trim()} · {entry + .log.serviceCenter} + {:else} + {entry.log.serviceCenter} + {/if} +

      +

      at {formatDistance(entry.log.odometer)}

      + {#if entry.log.cost} +

      {formatCurrency(entry.log.cost)}

      + {/if} + {/if} +
      +
    2. + {/each} +
    + {/if} +
    +
    diff --git a/src/lib/components/feature/maintenance/MaintenenceLogTab.svelte b/src/lib/components/feature/maintenance/MaintenenceLogTab.svelte index ff29d1f2..cccd8ddc 100644 --- a/src/lib/components/feature/maintenance/MaintenenceLogTab.svelte +++ b/src/lib/components/feature/maintenance/MaintenenceLogTab.svelte @@ -1,7 +1,7 @@ - sheetStore.openSheet(MaintenanceForm, m.maintenance_add_action(), '')} + exportAction={scope.vehicleId ? handleExportPdf : null} /> diff --git a/src/lib/components/feature/overview/AreaChart.svelte b/src/lib/components/feature/overview/AreaChart.svelte index 43c5936f..6c2c21b5 100644 --- a/src/lib/components/feature/overview/AreaChart.svelte +++ b/src/lib/components/feature/overview/AreaChart.svelte @@ -4,6 +4,7 @@ import { curveCatmullRom } from 'd3-shape'; import { Area, AreaChart, LinearGradient, type AreaChartProps } from 'layerchart'; import type { DataPoint } from '$lib/domain/shared'; + import ChartPoints from '$appui/ChartPoints.svelte'; import LabelWithIcon from '$appui/LabelWithIcon.svelte'; import CircleSlash2 from '@lucide/svelte/icons/circle-slash-2'; import Skeleton from '$lib/components/ui/skeleton/skeleton.svelte'; @@ -40,11 +41,10 @@ motion: 'tween' }, xAxis: { - format: (v: Date) => v.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' }), - tickLabelProps: { - rotate: 325, - textAnchor: 'end' - } + format: (v: Date) => v.toLocaleDateString('en-IN', { month: 'short' }) + }, + yAxis: { + format: (v: number) => valueFormatter?.(v) ?? v.toString() }, grid: { style: 'stroke-dasharray: 2', @@ -84,7 +84,7 @@ id="chart-area-{title}" class={bare ? 'area-chart relative flex h-full flex-col' - : 'area-chart lg:bg-background/50 bg-secondary relative rounded-lg px-4 pt-2 pb-6 lg:p-6'} + : 'area-chart lg:bg-background/50 bg-secondary relative rounded-xl border px-4 pt-2 pb-6 lg:p-6'} >
    {#if !bare} @@ -111,14 +111,15 @@
    {:else if chartData.length != 0} - + {#snippet tooltip()} @@ -165,6 +166,7 @@ /> {/snippet} + {:else}
    {#if title}
    diff --git a/src/lib/components/feature/overview/FuelAmountChart.svelte b/src/lib/components/feature/overview/FuelAmountChart.svelte deleted file mode 100644 index 3c54bb57..00000000 --- a/src/lib/components/feature/overview/FuelAmountChart.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - `${value.toFixed(1)} L`} - xFormatter={(v: Date) => - v.toLocaleDateString('en-IN', { - day: '2-digit', - month: 'short', - year: 'numeric' - })} -/> diff --git a/src/lib/components/feature/overview/MileageChart.svelte b/src/lib/components/feature/overview/MileageChart.svelte deleted file mode 100644 index fa6d3db3..00000000 --- a/src/lib/components/feature/overview/MileageChart.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - - formatMileage(value, selectedVehicle?.fuelType ?? 'petrol')} - xFormatter={(v: Date) => - v.toLocaleDateString('en-IN', { - day: '2-digit', - month: 'short', - year: 'numeric' - })} -/> diff --git a/src/lib/components/feature/overview/VehicleTrendChart.svelte b/src/lib/components/feature/overview/VehicleTrendChart.svelte new file mode 100644 index 00000000..8b419484 --- /dev/null +++ b/src/lib/components/feature/overview/VehicleTrendChart.svelte @@ -0,0 +1,190 @@ + + +
    +
    + {#if !bare} + {title} + {/if} + {#if coloredSeries.length > 1} +
    + {#each coloredSeries as s (s.vehicleId)} + + + {s.label} + {#if s.formattedAverage !== undefined} + · Avg {s.formattedAverage} + {/if} + + {/each} +
    + {:else if coloredSeries[0]?.formattedAverage !== undefined} + + + Avg: {coloredSeries[0].formattedAverage} + + {/if} +
    + {#if loading} +
    +
    + {#each [40, 65, 45, 80, 55, 70, 50, 85, 60, 75] as height, i (i)} + + {/each} +
    + +
    + {:else if hasData} + + ({ + key: s.vehicleId, + label: s.label, + color: s.color, + data: s.data + }))} + axis + props={chartProps} + > + {#snippet tooltip()} + + {#snippet formatter({ value, name })} + {@const matched = coloredSeries.find((s) => s.label === name)} + {@const formattedValue = + typeof value === 'number' + ? ((matched ? valueFormatter?.(value, matched) : undefined) ?? value.toFixed(2)) + : value} + {name} + {formattedValue} + {/snippet} + + {/snippet} + {#snippet marks()} + {#each coloredSeries as s (s.vehicleId)} + + + {/each} + {/snippet} + + + {:else} +
    + +
    + {/if} +
    diff --git a/src/lib/components/feature/reminder/ReminderCalendar.svelte b/src/lib/components/feature/reminder/ReminderCalendar.svelte new file mode 100644 index 00000000..352d9199 --- /dev/null +++ b/src/lib/components/feature/reminder/ReminderCalendar.svelte @@ -0,0 +1,138 @@ + + + + {#snippet children({ months, weekdays })} + + + + + + {#each months as month, monthIndex (month)} + + + + + + + + {#each weekdays as weekday (weekday)} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + {@const events = eventsByDate.get(date.toString()) ?? []} + + + {#if events.length} +
    + {#each events.slice(0, 3) as event (reminderKey(event))} + + {/each} +
    + {/if} +
    + {/each} +
    + {/each} +
    +
    +
    + {/each} +
    + {/snippet} +
    + +
    + + + {m.reminder_section_upcoming()} + + + + {m.reminder_status_overdue()} + + + + {m.reminder_status_completed()} + +
    diff --git a/src/lib/components/layout/AppTable.svelte b/src/lib/components/layout/AppTable.svelte index 7d318d06..57e16429 100644 --- a/src/lib/components/layout/AppTable.svelte +++ b/src/lib/components/layout/AppTable.svelte @@ -9,8 +9,10 @@ type PaginationState, type RowSelectionState, type SortingState, + type Table as TanstackTable, type VisibilityState } from '@tanstack/table-core'; + import type { Snippet } from 'svelte'; import { applyUpdater, getColumnDisplayName, getVisiblePageItems } from '$helper/table.helper'; import { createSvelteTable, FlexRender } from '$ui/data-table/index.js'; import * as Table from '$ui/table/index.js'; @@ -33,9 +35,11 @@ type DataTableProps = { columns: ColumnDef[]; data: TData[]; + /** Replaces the default search+columns row with a caller-supplied toolbar. */ + toolbar?: Snippet<[TanstackTable]>; }; let pageSize = $state('5'); - let { columns, data }: DataTableProps = $props(); + let { columns, data, toolbar }: DataTableProps = $props(); let pagination = $derived({ pageIndex: 0, pageSize: Number(pageSize) @@ -90,42 +94,46 @@
    {:else} -
    - table.getColumn('notes')?.setFilterValue(e.currentTarget.value)} - onchange={(e) => { - table.getColumn('notes')?.setFilterValue(e.currentTarget.value); - }} - icon={Search} - class="bg-background/60 h-full max-w-sm" - /> - - - {#snippet child({ props })} - - {/snippet} - - - {#each table - .getAllColumns() - .filter((col: any) => typeof col.accessorFn !== 'undefined' && col.getCanHide()) as column (column.id)} - column.toggleVisibility(!!value)} - > - {getColumnDisplayName(column)} - - {/each} - - -
    + {#if toolbar} + {@render toolbar(table)} + {:else} +
    + table.getColumn('notes')?.setFilterValue(e.currentTarget.value)} + onchange={(e) => { + table.getColumn('notes')?.setFilterValue(e.currentTarget.value); + }} + icon={Search} + class="bg-background/60 h-full max-w-sm" + /> + + + {#snippet child({ props })} + + {/snippet} + + + {#each table + .getAllColumns() + .filter((col: any) => typeof col.accessorFn !== 'undefined' && col.getCanHide()) as column (column.id)} + column.toggleVisibility(!!value)} + > + {getColumnDisplayName(column)} + + {/each} + + +
    + {/if}
    diff --git a/src/lib/domain/dashboard.ts b/src/lib/domain/dashboard.ts index 34777934..d716b947 100644 --- a/src/lib/domain/dashboard.ts +++ b/src/lib/domain/dashboard.ts @@ -92,6 +92,8 @@ export const WIDGET_TYPES = { 'monthly-expense-trend': 'monthly-expense-trend', 'cost-by-vehicle-leaderboard': 'cost-by-vehicle-leaderboard', 'fleet-fuel-trend': 'fleet-fuel-trend', + 'fuel-consumption-trend': 'fuel-consumption-trend', + 'mileage-overview-trend': 'mileage-overview-trend', 'efficiency-leaderboard': 'efficiency-leaderboard', 'pucc-status-donut': 'pucc-status-donut', 'insurance-status-donut': 'insurance-status-donut', @@ -161,6 +163,8 @@ const WIDGET_MIN_SIZES: Partial> = { // Plot area plus a rotated x-axis. 'monthly-expense-trend': { minColSpan: 4, minRowSpan: 6 }, 'fleet-fuel-trend': { minColSpan: 4, minRowSpan: 6 }, + 'fuel-consumption-trend': { minColSpan: 4, minRowSpan: 6 }, + 'mileage-overview-trend': { minColSpan: 4, minRowSpan: 6 }, // Scrolling lists stay usable at a couple of visible rows. 'cost-by-vehicle-leaderboard': { minColSpan: 3, minRowSpan: 4 }, 'efficiency-leaderboard': { minColSpan: 3, minRowSpan: 4 }, diff --git a/src/lib/services/entity-service.ts b/src/lib/services/entity-service.ts index c12c8a60..3ef0e875 100644 --- a/src/lib/services/entity-service.ts +++ b/src/lib/services/entity-service.ts @@ -20,7 +20,7 @@ export function createEntityService => { - const response = await apiClient.get(`/garage/${vehicleId}/maintenance-logs/export-pdf`, { + const response = await apiClient.get(`/vehicles/${vehicleId}/maintenance-logs/export-pdf`, { responseType: 'blob' }); diff --git a/src/lib/services/notification.service.ts b/src/lib/services/notification.service.ts index 092b0a0f..9ebe95fb 100644 --- a/src/lib/services/notification.service.ts +++ b/src/lib/services/notification.service.ts @@ -5,7 +5,7 @@ import { apiClient } from '$lib/helper/api.helper'; export const getNotifications = async (vehicleId: string): Promise> => { const res: Response = { status: 'OK' }; try { - const response = await apiClient.get(`/garage/${vehicleId}/notifications`); + const response = await apiClient.get(`/vehicles/${vehicleId}/notifications`); // API returns { data: notifications[], success: true } res.data = response.data.data || response.data; } catch (e: any) { @@ -49,7 +49,7 @@ export const markAllNotificationsAsRead = async ( ): Promise> => { const res: Response = { status: 'OK' }; try { - const response = await apiClient.put(`/garage/${vehicleId}/notifications`); + const response = await apiClient.put(`/vehicles/${vehicleId}/notifications`); res.data = response.data.data || response.data; } catch (e: any) { res.status = 'ERROR'; @@ -65,7 +65,7 @@ export const clearNotification = async ( const res: Response = { status: 'OK' }; try { const response = await apiClient.delete( - `/garage/${vehicleId}/notifications/${notificationId}` + `/vehicles/${vehicleId}/notifications/${notificationId}` ); res.data = response.data.data || response.data; } catch (e: any) { diff --git a/src/lib/services/vehicle.service.ts b/src/lib/services/vehicle.service.ts index 2c4be029..2884501c 100644 --- a/src/lib/services/vehicle.service.ts +++ b/src/lib/services/vehicle.service.ts @@ -39,7 +39,7 @@ const saveVehicle = async ( ): Promise> => { const res: Response = { status: 'OK' }; try { - const response = await apiClient[method.toLowerCase() as 'put' | 'post']('/garage/', vehicle); + const response = await apiClient[method.toLowerCase() as 'put' | 'post']('/vehicles/', vehicle); res.data = response.data; } catch (e: any) { res.status = 'ERROR'; @@ -51,7 +51,7 @@ const saveVehicle = async ( export const deleteVehicle = async (vehicleId: string): Promise> => { const res: Response = { status: 'OK' }; try { - await apiClient.delete(`/garage/${vehicleId}`); + await apiClient.delete(`/vehicles/${vehicleId}`); res.data = vehicleId; } catch (e: any) { res.status = 'ERROR'; diff --git a/src/lib/stores/chart.svelte.ts b/src/lib/stores/chart.svelte.ts index 82401d02..a43a5063 100644 --- a/src/lib/stores/chart.svelte.ts +++ b/src/lib/stores/chart.svelte.ts @@ -1,6 +1,8 @@ import type { FuelLog } from '$lib/domain/fuel'; import type { DataPoint } from '$lib/domain/shared'; +import type { Vehicle } from '$lib/domain/vehicle'; import { fuelLogStore } from './fuel-log.svelte'; +import { vehicleStore } from './vehicle.svelte'; const calculateCostData = (logs: FuelLog[]) => { return logs @@ -38,10 +40,56 @@ const calculateFuelAmountData = (logs: FuelLog[]) => { .sort((a, b) => a.x.getTime() - b.x.getTime()); }; +export interface VehicleTrendSeries { + vehicleId: string; + label: string; + fuelType: Vehicle['fuelType']; + color: string | null; + data: DataPoint[]; +} + +const groupByVehicle = ( + logs: FuelLog[], + calculate: (logs: FuelLog[]) => DataPoint[] +): VehicleTrendSeries[] => { + const byVehicle = new Map(); + for (const log of logs) { + const group = byVehicle.get(log.vehicleId); + if (group) { + group.push(log); + } else { + byVehicle.set(log.vehicleId, [log]); + } + } + + return Array.from(byVehicle.entries()).map(([vehicleId, vehicleLogs]) => { + const vehicle = vehicleStore.vehicles?.find((v) => v.id === vehicleId); + const label = vehicle + ? `${vehicle.make} ${vehicle.model}` + : `${vehicleLogs[0].vehicleMake ?? ''} ${vehicleLogs[0].vehicleModel ?? ''}`.trim() || + vehicleLogs[0].vehiclePlate || + vehicleId; + return { + vehicleId, + label, + fuelType: vehicle?.fuelType ?? 'petrol', + color: vehicle?.color ?? null, + data: calculate(vehicleLogs) + }; + }); +}; + class ChartStore { mileageData? = $derived(calculateMileageData(fuelLogStore.fuelLogs || [])); costData? = $derived(calculateCostData(fuelLogStore.fuelLogs || [])); fuelAmountData? = $derived(calculateFuelAmountData(fuelLogStore.fuelLogs || [])); + + mileageByVehicle = $derived( + groupByVehicle(fuelLogStore.fuelLogs || [], calculateMileageData) + ); + fuelAmountByVehicle = $derived( + groupByVehicle(fuelLogStore.fuelLogs || [], calculateFuelAmountData) + ); } export const chartStore = new ChartStore(); diff --git a/src/routes/(app)/fuel/+page.svelte b/src/routes/(app)/fuel/+page.svelte index a4024193..05d8f958 100644 --- a/src/routes/(app)/fuel/+page.svelte +++ b/src/routes/(app)/fuel/+page.svelte @@ -5,9 +5,9 @@ import Route from '@lucide/svelte/icons/route'; import Gauge from '@lucide/svelte/icons/gauge'; import { fuelLogStore } from '$stores/fuel-log.svelte'; - import MileageChart from '$feature/overview/MileageChart.svelte'; - import FuelAmountChart from '$feature/overview/FuelAmountChart.svelte'; - import FleetChartPlaceholder from '$feature/overview/FleetChartPlaceholder.svelte'; + import { chartStore } from '$stores/chart.svelte'; + import { formatMileage } from '$lib/helper/format.helper'; + import VehicleTrendChart from '$feature/overview/VehicleTrendChart.svelte'; import FuelLogTab from '$feature/fuel/FuelLogTab.svelte'; import { Features } from '$lib/helper/feature.helper'; import FeaturePageShell from '$feature/shared/FeaturePageShell.svelte'; @@ -40,7 +40,7 @@ disabledTitle={feature_fuel_disabled_title()} disabledHint={feature_fuel_disabled_hint()} > - {#snippet children(scope)} + {#snippet children(_scope)}
    - {#if scope.isFleet} - - - {:else} - - - {/if} + `${value.toFixed(1)} L`} + xFormatter={(v: Date) => + v.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })} + /> + formatMileage(value, series.fuelType)} + xFormatter={(v: Date) => + v.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })} + />
    diff --git a/src/routes/(app)/maintenance/+page.svelte b/src/routes/(app)/maintenance/+page.svelte index f8cf38bb..a65d5d1e 100644 --- a/src/routes/(app)/maintenance/+page.svelte +++ b/src/routes/(app)/maintenance/+page.svelte @@ -3,16 +3,20 @@ import CalendarDays from '@lucide/svelte/icons/calendar-days'; import Gauge from '@lucide/svelte/icons/gauge'; import HeartPulse from '@lucide/svelte/icons/heart-pulse'; + import Wrench from '@lucide/svelte/icons/wrench'; + import DollarSign from '@lucide/svelte/icons/dollar-sign'; + import AlertTriangle from '@lucide/svelte/icons/alert-triangle'; import { vehicleStore } from '$stores/vehicle.svelte'; import { page } from '$app/state'; import { readVehicleScope } from '$lib/scope/vehicle-scope.svelte'; import { maintenanceStore } from '$stores/maintenance.svelte'; + import { reminderStore } from '$stores/reminder.svelte'; import MaintenenceLogTab from '$feature/maintenance/MaintenenceLogTab.svelte'; import MaintenanceTimeline from '$feature/maintenance/MaintenanceTimeline.svelte'; import { Features } from '$lib/helper/feature.helper'; import FeaturePageShell from '$feature/shared/FeaturePageShell.svelte'; import * as Tabs from '$ui/tabs'; - import { formatDate } from '$lib/helper/format.helper'; + import { formatCurrency, formatDate } from '$lib/helper/format.helper'; import { feature_maintenance_disabled_title, feature_maintenance_disabled_hint, @@ -21,9 +25,11 @@ maintenance_stat_last_service, maintenance_stat_next_service, maintenance_stat_odometer, + maintenance_stat_total_services, + maintenance_stat_total_spent, + maintenance_stat_due_soon, maintenance_tab_overview, - maintenance_tab_history, - maintenance_timeline_title + maintenance_tab_history } from '$lib/paraglide/messages/_index.js'; let lastScopeKey: string | undefined; @@ -36,16 +42,36 @@ if (scopeKey !== lastScopeKey) { lastScopeKey = scopeKey; maintenanceStore.refreshMaintenanceLogs(vehicleId); + reminderStore.refreshReminders(vehicleId); } }); const logs = $derived(maintenanceStore.maintenanceLogs ?? []); - const latestOdometer = $derived.by(() => { - if (logs.length === 0) return 0; - return Math.max(...logs.map((l) => l.odometer || 0)); - }); - const nextServiceOdometer = $derived(latestOdometer > 0 ? latestOdometer + 5000 : 0); + const maintenanceReminders = $derived( + (reminderStore.reminders ?? []).filter((r) => r.type === 'maintenance') + ); + const upcomingReminders = $derived( + maintenanceReminders + .filter((r) => !r.isCompleted) + .slice() + .sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime()) + ); + + // Single-vehicle stats const lastServiceDate = $derived(logs.length > 0 ? logs[0].date : null); + const nextServiceDate = $derived(upcomingReminders[0]?.dueDate ?? null); + const currentOdometer = $derived(scope.vehicle?.odometer ?? logs[0]?.odometer ?? 0); + + // Fleet-wide stats + const totalServices = $derived(logs.length); + const totalSpent = $derived(logs.reduce((sum, l) => sum + (l.cost || 0), 0)); + const vehiclesDueSoon = $derived.by(() => { + const dueSoonMs = Date.now() + 30 * 24 * 60 * 60 * 1000; + const vehicleIds = new Set( + upcomingReminders.filter((r) => r.dueDate.getTime() <= dueSoonMs).map((r) => r.vehicleId) + ); + return vehicleIds.size; + }); -
    - - 0 ? `${nextServiceOdometer.toLocaleString()} km` : '--'} - color="bg-gradient-to-br from-amber-400 to-amber-600 shadow-amber-500/30" - /> - 0 ? `${latestOdometer.toLocaleString()} km` : '--'} - color="bg-gradient-to-br from-blue-400 to-blue-600 shadow-blue-500/30" - /> -
    + {#if scope.isFleet} +
    + 0 ? totalServices : '--'} + color="bg-gradient-to-br from-emerald-400 to-emerald-600 shadow-emerald-500/30" + /> + 0 ? formatCurrency(totalSpent) : '--'} + color="bg-gradient-to-br from-amber-400 to-amber-600 shadow-amber-500/30" + /> + +
    + {:else} +
    + + + 0 ? `${currentOdometer.toLocaleString()} km` : '--'} + color="bg-gradient-to-br from-blue-400 to-blue-600 shadow-blue-500/30" + /> +
    + {/if} @@ -84,8 +133,11 @@
    -

    {maintenance_timeline_title()}

    - +
    diff --git a/src/routes/(app)/reminders/+page.svelte b/src/routes/(app)/reminders/+page.svelte index 7af16d0c..a8c30fda 100644 --- a/src/routes/(app)/reminders/+page.svelte +++ b/src/routes/(app)/reminders/+page.svelte @@ -1,14 +1,41 @@ {/snippet} - -
    -

    {m.reminder_section_upcoming()}

    - -
    + {#if isEmpty} +
    + + + +
    +

    {m.reminder_empty_title()}

    +

    {m.reminder_list_empty()}

    +
    + +
    + {:else} +
    + + + + +
    + +
    +
    +
    +

    + {selectedDateLabel + ? m.reminder_list_title_for_date({ date: selectedDateLabel }) + : m.reminder_list_title()} +

    + {#if selectedDateKey} + + {/if} +
    + + {#if reminderStore.processing && displayedReminders.length === 0} +

    {m.common_loading()}

    + {:else if displayedReminders.length === 0} +

    + {selectedDateKey ? m.reminder_calendar_empty_day() : m.reminder_list_empty()} +

    + {:else} +
      + {#each displayedReminders as reminder (reminderKey(reminder))} + {@const typeStyle = TYPE_STYLES[reminder.type]} + {@const info = statusFor(reminder)} +
    • +
      + + + +
      +
      +

      + {getReminderTypeLabel(reminder.type, m)} +

      +
      + +
      +
      -
      -

      {m.reminder_section_completed()}

      - -
      + {#if subtitleFor(reminder)} +

      {subtitleFor(reminder)}

      + {/if} +
      +
      +

      + {formatDate(reminder.dueDate)} + {#if info.sub} + ({info.sub}) + {/if} +

      + +
      + + +
      +
      +
      +
    • + {/each} +
    + {/if} +
    + +
    +
    +

    {m.reminder_calendar_title()}

    + +
    + +
    +

    {m.reminder_quick_actions_title()}

    +
    + + + + +
    +
    +
    +
    + +
    + +
    + + {#if showFullList} + + +
    +

    {m.reminder_section_upcoming()}

    + +
    + +
    +

    {m.reminder_section_completed()}

    + +
    + {/if} + {/if}
    + + diff --git a/src/server/db/migrations/20260804073000_fix_notification_key_unique_index.sql b/src/server/db/migrations/20260804073000_fix_notification_key_unique_index.sql new file mode 100644 index 00000000..d00c64b9 --- /dev/null +++ b/src/server/db/migrations/20260804073000_fix_notification_key_unique_index.sql @@ -0,0 +1,5 @@ +-- Migration 20260322000000_add_indexes silently failed to create this unique index on some +-- databases (likely due to duplicate notification_key rows present at the time), while still being +-- recorded as applied — leaving `syncVehicleNotifications`'s ON CONFLICT (notification_key) upsert +-- with no matching unique constraint to target. Re-create it defensively. +CREATE UNIQUE INDEX IF NOT EXISTS idx_notifications_notification_key ON notifications (notification_key); diff --git a/src/server/db/migrations/meta/_journal.json b/src/server/db/migrations/meta/_journal.json index f51f5c8d..ad74f5d2 100644 --- a/src/server/db/migrations/meta/_journal.json +++ b/src/server/db/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1785559377786, "tag": "20260801044257_add_vehicle_type_to_vehicles", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1785825446204, + "tag": "20260804073000_fix_notification_key_unique_index", + "breakpoints": true } ] } From 4491cf2b3f4a9bdb3f69ce90e2823be84f270a3c Mon Sep 17 00:00:00 2001 From: Javed Hussain Date: Tue, 4 Aug 2026 19:06:53 +0530 Subject: [PATCH 15/23] Merge pucc/insurance entries to compliance --- i18n/messages/en.json | 208 ++++++++---------- .../dashboard/StackedAreaChart.svelte | 2 +- .../components/dashboard/widget-registry.ts | 8 +- .../widgets/ExpenseBreakdownWidget.svelte | 4 +- .../widgets/StatusDonutWidget.svelte | 4 +- .../compliance/ComplianceContextMenu.svelte | 36 +++ .../ComplianceForm.svelte} | 136 ++++++++---- .../feature/compliance/ComplianceList.svelte | 154 +++++++++++++ .../insurance/InsuranceContextMenu.svelte | 36 --- .../feature/insurance/InsuranceList.svelte | 141 ------------ .../feature/insurance/InsuranceTab.svelte | 13 -- .../pollution/PollutionCertificateForm.svelte | 196 ----------------- .../pollution/PollutionCertificateList.svelte | 152 ------------- .../feature/pollution/PuccContextMenu.svelte | 37 ---- .../settings/SettingsFeaturesTab.svelte | 16 +- .../feature/vehicle/VehicleCard.svelte | 23 +- .../feature/vehicle/VehicleHubActivity.svelte | 14 +- .../feature/vehicle/VehicleHubDetails.svelte | 10 +- .../vehicle/VehicleHubFeatureLinks.svelte | 20 +- .../feature/vehicle/VehicleHubHero.svelte | 10 +- src/lib/components/layout/AppSidebar.svelte | 14 +- .../components/layout/Notifications.svelte | 8 +- src/lib/domain/compliance.ts | 204 +++++++++++++++++ src/lib/domain/config.ts | 3 +- src/lib/domain/dashboard.ts | 10 +- src/lib/domain/insurance.ts | 87 -------- src/lib/domain/notification.ts | 3 +- src/lib/domain/pucc.ts | 107 --------- src/lib/domain/vehicle.ts | 10 +- src/lib/helper/feature.helper.ts | 3 +- src/lib/helper/settings-form.helper.ts | 3 +- src/lib/services/autocomplete.service.ts | 14 +- src/lib/services/compliance.service.ts | 9 + src/lib/services/insurance.service.ts | 9 - src/lib/services/notification.service.ts | 2 +- src/lib/services/pucc.service.ts | 9 - src/lib/stores/compliance.svelte.ts | 21 ++ src/lib/stores/config.svelte.ts | 3 +- src/lib/stores/insurance.svelte.ts | 21 -- src/lib/stores/notification.svelte.ts | 5 +- src/lib/stores/pucc.svelte.ts | 21 -- src/routes/(app)/compliance/+page.svelte | 176 +++++++++++++++ src/routes/(app)/insurance/+page.svelte | 21 -- src/routes/(app)/pollution/+page.svelte | 106 --------- src/routes/(app)/reminders/+page.svelte | 73 +----- src/routes/(app)/reports/+page.svelte | 14 +- src/routes/api/autocomplete/+server.ts | 10 +- .../api/{insurance => compliance}/+server.ts | 7 +- src/routes/api/pucc/+server.ts | 11 - .../api/vehicles/[id]/compliance/+server.ts | 14 ++ .../[id]/compliance/[complianceId]/+server.ts | 35 +++ .../api/vehicles/[id]/insurance/+server.ts | 14 -- .../[id]/insurance/[insuranceId]/+server.ts | 35 --- src/routes/api/vehicles/[id]/pucc/+server.ts | 14 -- .../vehicles/[id]/pucc/[puccId]/+server.ts | 39 ---- ...0260804090000_add_compliance_documents.sql | 22 ++ ...20260804090100_migrate_compliance_data.sql | 22 ++ ...60804090200_drop_insurance_pucc_tables.sql | 3 + src/server/db/migrations/meta/_journal.json | 21 ++ .../db/schema/{insurance.ts => compliance.ts} | 15 +- src/server/db/schema/index.ts | 6 +- src/server/db/schema/pucc.ts | 26 --- src/server/db/seeders/index.ts | 38 ++-- src/server/services/autocompleteService.ts | 36 +-- src/server/services/complianceService.ts | 52 +++++ src/server/services/dashboardService.ts | 166 +++++++------- src/server/services/data-transfer.service.ts | 17 +- src/server/services/insuranceService.ts | 45 ---- .../services/notification-service.helper.ts | 3 +- src/server/services/notificationService.ts | 64 ++---- .../services/pollutionCertificateService.ts | 53 ----- src/server/services/vehicleService.ts | 184 +++++++--------- src/types/sveltekit.d.ts | 3 +- 73 files changed, 1261 insertions(+), 1870 deletions(-) create mode 100644 src/lib/components/feature/compliance/ComplianceContextMenu.svelte rename src/lib/components/feature/{insurance/InsuranceForm.svelte => compliance/ComplianceForm.svelte} (53%) create mode 100644 src/lib/components/feature/compliance/ComplianceList.svelte delete mode 100644 src/lib/components/feature/insurance/InsuranceContextMenu.svelte delete mode 100644 src/lib/components/feature/insurance/InsuranceList.svelte delete mode 100644 src/lib/components/feature/insurance/InsuranceTab.svelte delete mode 100644 src/lib/components/feature/pollution/PollutionCertificateForm.svelte delete mode 100644 src/lib/components/feature/pollution/PollutionCertificateList.svelte delete mode 100644 src/lib/components/feature/pollution/PuccContextMenu.svelte create mode 100644 src/lib/domain/compliance.ts delete mode 100644 src/lib/domain/insurance.ts delete mode 100644 src/lib/domain/pucc.ts create mode 100644 src/lib/services/compliance.service.ts delete mode 100644 src/lib/services/insurance.service.ts delete mode 100644 src/lib/services/pucc.service.ts create mode 100644 src/lib/stores/compliance.svelte.ts delete mode 100644 src/lib/stores/insurance.svelte.ts delete mode 100644 src/lib/stores/pucc.svelte.ts create mode 100644 src/routes/(app)/compliance/+page.svelte delete mode 100644 src/routes/(app)/insurance/+page.svelte delete mode 100644 src/routes/(app)/pollution/+page.svelte rename src/routes/api/{insurance => compliance}/+server.ts (51%) delete mode 100644 src/routes/api/pucc/+server.ts create mode 100644 src/routes/api/vehicles/[id]/compliance/+server.ts create mode 100644 src/routes/api/vehicles/[id]/compliance/[complianceId]/+server.ts delete mode 100644 src/routes/api/vehicles/[id]/insurance/+server.ts delete mode 100644 src/routes/api/vehicles/[id]/insurance/[insuranceId]/+server.ts delete mode 100644 src/routes/api/vehicles/[id]/pucc/+server.ts delete mode 100644 src/routes/api/vehicles/[id]/pucc/[puccId]/+server.ts create mode 100644 src/server/db/migrations/20260804090000_add_compliance_documents.sql create mode 100644 src/server/db/migrations/20260804090100_migrate_compliance_data.sql create mode 100644 src/server/db/migrations/20260804090200_drop_insurance_pucc_tables.sql rename src/server/db/schema/{insurance.ts => compliance.ts} (58%) delete mode 100644 src/server/db/schema/pucc.ts create mode 100644 src/server/services/complianceService.ts delete mode 100644 src/server/services/insuranceService.ts delete mode 100644 src/server/services/pollutionCertificateService.ts diff --git a/i18n/messages/en.json b/i18n/messages/en.json index d0428a8c..2a2c4b94 100644 --- a/i18n/messages/en.json +++ b/i18n/messages/en.json @@ -90,8 +90,6 @@ "nav_overview": "Overview", "nav_fuel_logs": "Fuel Logs", "nav_maintenance": "Maintenance", - "nav_insurance": "Insurance", - "nav_pollution": "Pollution Certificate", "nav_reminders": "Reminders", "tools_export_data": "Export Data", "tools_import_data": "Import Data", @@ -123,8 +121,6 @@ "vehicle_delete_error": "Some error occurred while deleting vehicle.", "vehicle_action_add_fuel_log": "Add Fuel Log", "vehicle_action_add_maintenance_log": "Add Maintenance Log", - "vehicle_action_add_insurance": "Add Insurance", - "vehicle_action_add_pollution": "Add Pollution Certificate", "vehicle_action_add_reminder": "Add Reminder", "vehicle_action_more_info": "More info", "vehicle_action_update_vehicle": "Update Vehicle", @@ -164,12 +160,8 @@ "feature_fuel_disabled_hint": "Enable this feature in Settings to track fuel consumption", "feature_maintenance_disabled_title": "Maintenance Feature Disabled", "feature_maintenance_disabled_hint": "Enable this feature in Settings to manage maintenance records", - "feature_pucc_disabled_title": "PUCC Feature Disabled", - "feature_pucc_disabled_hint": "Enable this feature in Settings to manage pollution certificates", "feature_reminders_disabled_title": "Reminders Feature Disabled", "feature_reminders_disabled_hint": "Enable this feature in Settings to manage vehicle reminders", - "feature_insurance_disabled_title": "Insurance Feature Disabled", - "feature_insurance_disabled_hint": "Enable this feature in Settings to manage insurance details", "overview_chart_no_data": "No data available", "overview_chart_cost_label": "Cost", "overview_chart_cost_title": "Cost over Time in ({currency})", @@ -244,12 +236,8 @@ "feature_desc_fuel": "Track and manage fuel consumption and refueling history", "feature_label_maintenance": "Maintenance", "feature_desc_maintenance": "Record and schedule vehicle maintenance activities", - "feature_label_pucc": "Pollution", - "feature_desc_pucc": "Manage Pollution Under Control Certificate records", "feature_label_reminders": "Reminders", "feature_desc_reminders": "Set and receive reminders for important vehicle events", - "feature_label_insurance": "Insurance", - "feature_desc_insurance": "Manage vehicle insurance details and renewals", "feature_label_overview": "Overview", "feature_desc_overview": "Display overview dashboard with key vehicle metrics", "settings_error_date_format_invalid": "Format not valid", @@ -283,83 +271,6 @@ "maintenance_export_pdf": "Export PDF", "maintenance_delete_success": "Deleted maintenance log.", "maintenance_delete_error": "Some error occurred while deleting maintenance log.", - "insurance_form_provider_label": "Insurance Provider", - "insurance_form_provider_desc": "Name of the insurance company", - "insurance_form_policy_number_label": "Insurance Policy Number", - "insurance_form_policy_number_desc": "Policy number from your insurance document", - "insurance_form_start_date_label": "Insurance Start Date", - "insurance_form_start_date_desc": "Date when coverage begins", - "insurance_form_recurrence_type_label": "How should this insurance renew?", - "insurance_form_recurrence_type_desc": "Renewal type for this insurance", - "insurance_form_recurrence_interval_label": "Renewal Frequency", - "insurance_form_recurrence_interval_desc": "How often the insurance renews", - "insurance_form_end_date_label": "Insurance End Date", - "insurance_form_end_date_desc": "Date when coverage expires", - "insurance_form_cost_label": "Insurance Cost", - "insurance_form_cost_desc": "Annual or policy period cost", - "insurance_form_notes_label": "Additional Notes", - "insurance_form_notes_desc": "Any extra information about the policy", - "insurance_form_notes_placeholder": "Add more details about the insurance...", - "insurance_form_attachment_label": "Policy Document", - "insurance_form_attachment_desc": "Upload policy document", - "insurance_form_error_fix": "Please fix the errors in the form before submitting.", - "insurance_toast_saved": "Insurance saved successfully", - "insurance_toast_updated": "Insurance updated successfully", - "insurance_toast_error_prefix": "Error while saving: ", - "insurance_list_empty": "No Insurance found for this vehicle.", - "insurance_col_policy_number": "Policy Number", - "insurance_col_cost": "Cost", - "insurance_col_start_date": "Start Date", - "insurance_col_end_date": "End Date", - "insurance_col_next_due": "Next Due", - "insurance_col_recurrence": "Recurrence", - "insurance_col_notes": "Notes", - "insurance_menu_open": "Open menu", - "insurance_menu_edit": "Edit", - "insurance_menu_delete": "Delete", - "insurance_menu_sheet_title": "Update Insurance", - "insurance_tab_title": "Insurance Details", - "insurance_add_action": "Add Insurance", - "insurance_delete_success": "Deleted Insurance.", - "insurance_delete_error": "Some error occurred while deleting insurance.", - "insurance_col_view_document": "View Document", - "pollution_form_certificate_number_label": "Certificate Number", - "pollution_form_certificate_number_desc": "Pollution certificate number", - "pollution_form_issue_date_label": "Issue Date", - "pollution_form_issue_date_desc": "Certificate issue date", - "pollution_form_recurrence_type_label": "How should this certificate renew?", - "pollution_form_recurrence_type_desc": "Renewal type for this certificate", - "pollution_form_recurrence_interval_label": "Renewal Frequency", - "pollution_form_recurrence_interval_desc": "How often the certificate renews", - "pollution_form_expiry_date_label": "Expiry Date", - "pollution_form_expiry_date_desc": "PUCC expiry date", - "pollution_form_testing_center_label": "Testing Center", - "pollution_form_testing_center_desc": "Testing center name", - "pollution_form_notes_label": "Additional Notes", - "pollution_form_notes_desc": "Any extra information", - "pollution_form_notes_placeholder": "Add additional notes...", - "pollution_form_attachment_label": "Certificate Document", - "pollution_form_attachment_desc": "Upload certificate document", - "pollution_form_error_fix": "Please fix the errors in the form before submitting.", - "pollution_toast_saved": "Pollution Certificate saved successfully", - "pollution_toast_updated": "Pollution Certificate updated successfully", - "pollution_toast_error_prefix": "Error while saving: ", - "pollution_list_empty": "No Pollution Certificates for this vehicle.", - "pollution_col_certificate_number": "Certificate Number", - "pollution_col_issue_date": "Issue Date", - "pollution_col_expiry_date": "Expiry Date", - "pollution_col_next_due": "Next Due", - "pollution_col_testing_center": "Testing Center", - "pollution_col_notes": "Notes", - "pollution_col_view_certificate": "View Certificate", - "pollution_col_recurrence": "Recurrence", - "pollution_menu_open": "Open menu", - "pollution_menu_edit": "Edit", - "pollution_menu_delete": "Delete", - "pollution_menu_sheet_title": "Update Pollution Certificate", - "pollution_add_action": "Add Pollution Certificate", - "pollution_delete_success": "Deleted PUCC.", - "pollution_delete_error": "Some error occurred while deleting PUCC.", "reminder_form_due_date_label": "Due Date", "reminder_form_due_date_desc": "When should this reminder trigger?", "reminder_form_type_label": "Type", @@ -402,8 +313,6 @@ "reminder_status_error": "Unable to update reminder status.", "reminder_toast_error_fallback": "Failed to save reminder.", "settings_sheet_title": "Settings", - "feature_label_pollution": "Pollution", - "feature_desc_pollution": "Manage Pollution Under Control Certificate records", "profile_menu_item": "Profile", "profile_sheet_title": "Profile", "profile_sheet_desc": "Update your username and password", @@ -471,14 +380,6 @@ "recurrence_interval_months": "months", "recurrence_interval_years": "years", "recurrence_until": "Until", - "insurance_recurrence_type_fixed": "Fixed end date", - "insurance_recurrence_type_yearly": "Renews yearly", - "insurance_recurrence_type_monthly": "Renews monthly", - "insurance_recurrence_type_no_end": "No end date", - "pollution_recurrence_type_fixed": "Fixed end date", - "pollution_recurrence_type_yearly": "Renews yearly", - "pollution_recurrence_type_monthly": "Renews monthly", - "pollution_recurrence_type_no_end": "No end date", "file_drop_existing_note": "Existing attachment (Click to view)", "fuel_import_step_1_title": "Step 1 : Upload CSV file", "fuel_import_step_1_desc": "Select a delimited text file containing your fuel log data to begin the import process.", @@ -552,13 +453,9 @@ "reminder_type_registration": "Registration / Tax", "reminder_type_inspection": "Inspection", "reminder_type_custom": "Custom", - "alert_type_insurance": "Insurance", - "alert_type_pucc": "Pollution Certificate", "alert_status_expired_ago": "{label} expired {days} days ago", "alert_status_expires_in": "{label} expires in {days} days", "alert_status_valid_for": "{label} valid for {days} days", - "alert_insurance_active_no_end": "Insurance is active with no end date", - "alert_pucc_active_no_end": "PUCC is active with no end date", "alert_record_not_found": "{label} record not found. Add details to stay compliant.", "settings_error_format_not_valid": "Format not valid", "common_kilogram_unit": "Kilogram (kg)", @@ -731,14 +628,11 @@ "vehicle_hub_back_to_vehicles": "Back to Vehicles", "vehicle_hub_plate_copied": "License plate copied", "vehicle_hub_insurance_valid_till": "Insurance Valid Till", - "vehicle_hub_pollution_valid_till": "Pollution Valid Till", "vehicle_hub_vehicle_type": "Vehicle Type", "vehicle_hub_activity_title": "Recent Activity", "vehicle_hub_activity_empty": "No recent activity", "vehicle_hub_activity_fuel_added": "Fuel Added", "vehicle_hub_activity_maintenance": "Maintenance", - "vehicle_hub_activity_insurance_renewed": "Insurance Renewed", - "vehicle_hub_activity_policy_prefix": "Policy #", "vehicle_hub_records_count": "{count} records", "vehicle_hub_valid_till": "Valid till {date}", "vehicle_hub_upcoming_count": "{count} upcoming", @@ -771,20 +665,6 @@ "maintenance_history_empty": "No service history yet", "maintenance_next_service_fallback": "General maintenance service", "maintenance_also_upcoming": "Also coming up", - "insurance_page_title": "Insurance", - "insurance_page_description": "Manage insurance records", - "pollution_page_title": "Pollution (PUC)", - "pollution_page_description": "Track pollution certificate compliance", - "pollution_filter_all": "All", - "pollution_filter_valid": "Valid", - "pollution_filter_expiring_soon": "Expiring Soon", - "pollution_filter_expired": "Expired", - "pollution_stat_total": "Total", - "pollution_stat_valid": "Valid", - "pollution_stat_expiring_soon": "Expiring Soon", - "pollution_stat_expired": "Expired", - "pollution_cta_heading": "Keep your vehicles compliant", - "pollution_cta_description": "Keep your PUC certificates updated to avoid penalties and ensure a cleaner environment.", "reminder_page_title": "Reminders", "reminder_page_description": "Stay on top of upcoming service, insurance and PUC dates", "reminder_filter_all": "All", @@ -812,7 +692,6 @@ "reports_section_exports": "Exports", "reports_stat_fuel_costs": "Fuel Costs", "reports_stat_maintenance_costs": "Maintenance Costs", - "reports_stat_insurance_costs": "Insurance Costs", "reports_chart_breakdown_title": "Expense Breakdown", "reports_chart_trend_title": "Monthly Expense Trend", "reports_chart_trend_unavailable": "Monthly trend is only available for all vehicles combined", @@ -821,5 +700,90 @@ "reports_export_maintenance_hint": "Select a vehicle to export its maintenance history", "reports_export_data_title": "Full Data Export", "reports_export_data_description": "Export all fleet data as JSON", - "reports_export_action": "Export" + "reports_export_action": "Export", + "nav_compliance": "Compliance", + "vehicle_action_add_compliance": "Add Compliance Document", + "feature_compliance_disabled_title": "Compliance Feature Disabled", + "feature_compliance_disabled_hint": "Enable this feature in Settings to manage insurance, emissions, roadworthiness and registration records", + "feature_label_compliance": "Compliance", + "feature_desc_compliance": "Manage insurance, emissions, roadworthiness and registration records", + "compliance_type_insurance": "Insurance", + "compliance_type_emissions": "Emissions / Pollution", + "compliance_type_roadworthiness": "Roadworthiness / Safety Inspection", + "compliance_type_registration": "Registration / Road Tax", + "compliance_type_other": "Other", + "compliance_field_policy_number": "Policy Number", + "compliance_field_certificate_number": "Certificate Number", + "compliance_field_registration_number": "Registration Number", + "compliance_field_document_number": "Document Number", + "compliance_field_provider": "Insurance Provider", + "compliance_field_testing_center": "Testing Center", + "compliance_field_inspection_center": "Inspection Center", + "compliance_field_issuing_authority": "Issuing Authority", + "compliance_recurrence_type_fixed": "Fixed end date", + "compliance_recurrence_type_yearly": "Renews yearly", + "compliance_recurrence_type_monthly": "Renews monthly", + "compliance_recurrence_type_no_end": "No end date", + "compliance_form_type_label": "Compliance Type", + "compliance_form_type_desc": "What kind of compliance document is this", + "compliance_form_other_label_label": "Type Name", + "compliance_form_other_label_desc": "Name this compliance type, e.g. \"WOF (New Zealand)\" or \"TÜV (Germany)\"", + "compliance_form_attachment_label": "Document", + "compliance_form_attachment_desc": "Upload the document", + "compliance_form_issuer_desc": "The provider, testing center, or authority that issued this document", + "compliance_form_document_number_desc": "The number printed on the document", + "compliance_form_start_date_label": "Start Date", + "compliance_form_start_date_desc": "Date when this document takes effect", + "compliance_form_recurrence_type_label": "How should this renew?", + "compliance_form_recurrence_type_desc": "Renewal type for this document", + "compliance_form_recurrence_interval_desc": "How often the document renews", + "compliance_form_end_date_label": "End Date", + "compliance_form_end_date_desc": "Date when this document expires", + "compliance_form_cost_label": "Cost", + "compliance_form_cost_desc": "Cost for this document, if any", + "compliance_form_notes_label": "Additional Notes", + "compliance_form_notes_desc": "Any extra information", + "compliance_form_notes_placeholder": "Add more details...", + "compliance_form_error_fix": "Please fix the errors in the form before submitting.", + "compliance_toast_saved": "Compliance document saved successfully", + "compliance_toast_updated": "Compliance document updated successfully", + "compliance_toast_error_prefix": "Error while saving: ", + "compliance_list_empty": "No compliance documents found for this vehicle.", + "compliance_col_cost": "Cost", + "compliance_col_start_date": "Start Date", + "compliance_col_end_date": "End Date", + "compliance_col_next_due": "Next Due", + "compliance_col_recurrence": "Recurrence", + "compliance_col_notes": "Notes", + "compliance_col_view_document": "View Document", + "compliance_menu_open": "Open menu", + "compliance_menu_edit": "Edit", + "compliance_menu_delete": "Delete", + "compliance_menu_sheet_title": "Update Compliance Document", + "compliance_delete_success": "Deleted compliance document.", + "compliance_delete_error": "Some error occurred while deleting the compliance document.", + "compliance_page_title": "Compliance", + "compliance_page_description": "Track insurance, emissions, roadworthiness and registration compliance", + "compliance_add_action": "Add Compliance Document", + "compliance_filter_all_types": "All Types", + "compliance_filter_all": "All", + "compliance_filter_valid": "Valid", + "compliance_filter_expiring_soon": "Expiring Soon", + "compliance_filter_expired": "Expired", + "compliance_stat_total": "Total", + "compliance_stat_valid": "Valid", + "compliance_stat_expiring_soon": "Expiring Soon", + "compliance_stat_expired": "Expired", + "compliance_cta_heading": "Keep your vehicles compliant", + "compliance_cta_description": "Keep your compliance documents updated to avoid penalties and stay road-legal.", + "vehicle_hub_other_compliance_valid_till": "Other Compliance Valid Till", + "vehicle_hub_activity_compliance_updated": "Compliance Document Updated", + "vehicle_hub_activity_document_prefix": "Doc #", + "reports_stat_compliance_costs": "Compliance Costs", + "compliance_col_document": "Compliance", + "compliance_col_status": "Status", + "compliance_col_days_left": "Days Left", + "compliance_col_action": "Action", + "compliance_action_view": "View", + "compliance_action_renew": "Renew Now" } diff --git a/src/lib/components/dashboard/StackedAreaChart.svelte b/src/lib/components/dashboard/StackedAreaChart.svelte index 063e99f5..34d1d6dc 100644 --- a/src/lib/components/dashboard/StackedAreaChart.svelte +++ b/src/lib/components/dashboard/StackedAreaChart.svelte @@ -25,7 +25,7 @@ const SERIES = [ { key: 'fuel', label: 'Fuel', color: 'var(--chart-1)' }, { key: 'maintenance', label: 'Maintenance', color: 'var(--chart-2)' }, - { key: 'insurance', label: 'Insurance', color: 'var(--chart-3)' } + { key: 'compliance', label: 'Compliance', color: 'var(--chart-3)' } ] as const; const chartData = $derived(data.map((point) => ({ ...point, x: new Date(`${point.month}-01`) }))); diff --git a/src/lib/components/dashboard/widget-registry.ts b/src/lib/components/dashboard/widget-registry.ts index dbe86978..43b056b2 100644 --- a/src/lib/components/dashboard/widget-registry.ts +++ b/src/lib/components/dashboard/widget-registry.ts @@ -91,7 +91,7 @@ export const WIDGET_REGISTRY: Record = { 'expense-breakdown-donut': { type: 'expense-breakdown-donut', title: 'Expenses by Category', - description: 'Fuel vs. maintenance vs. insurance spend', + description: 'Fuel vs. maintenance vs. compliance spend', component: ExpenseBreakdownWidget, defaultColSpan: 6, defaultRowSpan: 8 @@ -148,10 +148,10 @@ export const WIDGET_REGISTRY: Record = { }, 'pucc-status-donut': { type: 'pucc-status-donut', - title: 'PUC Status', - description: 'Pollution certificate status across the fleet', + title: 'Other Compliance Status', + description: 'Emissions, roadworthiness & registration status across the fleet', component: StatusDonutWidget, - extraProps: { metric: 'pucc' }, + extraProps: { metric: 'other' }, defaultColSpan: 6, defaultRowSpan: 6 }, diff --git a/src/lib/components/dashboard/widgets/ExpenseBreakdownWidget.svelte b/src/lib/components/dashboard/widgets/ExpenseBreakdownWidget.svelte index 04bcdf58..af518ddd 100644 --- a/src/lib/components/dashboard/widgets/ExpenseBreakdownWidget.svelte +++ b/src/lib/components/dashboard/widgets/ExpenseBreakdownWidget.svelte @@ -15,8 +15,8 @@ color: 'var(--chart-2)' }, { - name: 'Insurance', - value: summary.expenses.breakdown.insurance, + name: 'Compliance', + value: summary.expenses.breakdown.compliance, color: 'var(--chart-3)' } ].filter((d) => d.value > 0) diff --git a/src/lib/components/dashboard/widgets/StatusDonutWidget.svelte b/src/lib/components/dashboard/widgets/StatusDonutWidget.svelte index 501c197b..1e63d46d 100644 --- a/src/lib/components/dashboard/widgets/StatusDonutWidget.svelte +++ b/src/lib/components/dashboard/widgets/StatusDonutWidget.svelte @@ -1,5 +1,5 @@ + + sheetStore.openSheet(ComplianceForm, m.compliance_menu_sheet_title(), '', document)} + onDelete={deleteDoc} +/> diff --git a/src/lib/components/feature/insurance/InsuranceForm.svelte b/src/lib/components/feature/compliance/ComplianceForm.svelte similarity index 53% rename from src/lib/components/feature/insurance/InsuranceForm.svelte rename to src/lib/components/feature/compliance/ComplianceForm.svelte index 22ec14e0..593f5762 100644 --- a/src/lib/components/feature/insurance/InsuranceForm.svelte +++ b/src/lib/components/feature/compliance/ComplianceForm.svelte @@ -1,21 +1,27 @@ - e.preventDefault()}> + e.preventDefault()}>
    {#if !suppliedVehicleId} {/if} + + + {#snippet children({ props })} + {@const TypeIcon = getComplianceTypeIcon($formData.type)} + {m.compliance_form_type_label()} + + +
    + + {getComplianceTypeLabel($formData.type, m)} +
    +
    + + {#each Object.keys(COMPLIANCE_TYPES) as value} + {@const ItemIcon = getComplianceTypeIcon(value)} + + + {getComplianceTypeLabel(value, m)} + + {/each} + +
    + {/snippet} +
    + +
    + + {#if $formData.type === 'other'} + + + {#snippet children({ props })} + {m.compliance_form_other_label_label()} + + {/snippet} + + + + {/if} + - {m.insurance_form_attachment_label()}{m.compliance_form_attachment_label()} - + + {#snippet children({ props })} - {m.insurance_form_provider_label()}{getComplianceIssuerLabel($formData.type, m)} {/snippet} @@ -137,13 +189,13 @@ - + {#snippet children({ props })} - {m.insurance_form_policy_number_label()}{getComplianceDocumentNumberLabel($formData.type, m)} - + {/snippet} @@ -152,8 +204,8 @@ {#snippet children({ props })} - {m.insurance_form_start_date_label()}{m.compliance_form_start_date_label()} {/snippet} @@ -164,25 +216,25 @@ {#snippet children({ props })} - {m.insurance_form_cost_label()}{m.compliance_form_cost_label()} {/snippet} @@ -193,12 +245,12 @@ {#snippet children({ props })} - {m.insurance_form_notes_label()}{m.compliance_form_notes_label()}