feat(frontend): add ESLint and Prettier with Svelte support

- Install eslint, prettier and related plugins
- Add lint and format npm scripts
- Configure eslint.config.js with Svelte + TypeScript rules
- Configure .prettierrc with Svelte plugin
- Fix code to comply with lint rules:
  - Use resolve() for navigation links
  - Use SvelteMap for reactive maps
  - Use writable  instead of  +
  - Remove unused imports and variables

Note: ignoreGoto is set to true due to eslint-plugin-svelte#1327
This commit is contained in:
Piotr Oleszczyk 2026-03-03 01:21:50 +01:00
parent 609995732b
commit 098b158b75
16 changed files with 2626 additions and 726 deletions

8
frontend/.prettierignore Normal file
View file

@ -0,0 +1,8 @@
node_modules
.svelte-kit
paraglide
build
dist
.env
.env.*
!.env.example

9
frontend/.prettierrc Normal file
View file

@ -0,0 +1,9 @@
{
"plugins": ["svelte"],
"overrides": [
{
"files": ["*.svelte"],
"parser": "svelte-eslint-parser"
}
]
}

47
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,47 @@
import js from "@eslint/js";
import svelte from "eslint-plugin-svelte";
import ts from "typescript-eslint";
import globals from "globals";
export default [
{
ignores: [
".svelte-kit",
"node_modules",
"build",
"dist",
"**/paraglide/**",
"**/lib/paraglide/**",
],
},
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs["flat/recommended"],
{
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
},
},
rules: {
"svelte/no-at-html-tags": "off",
"svelte/require-each-key": "off",
// TODO: Set ignoreGoto to false when https://github.com/sveltejs/eslint-plugin-svelte/issues/1327 is fixed
// The rule doesn't detect resolve() when used with string concatenation for query params
"svelte/no-navigation-without-resolve": [
"error",
{ ignoreLinks: true, ignoreGoto: true },
],
},
},
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: ts.parser,
},
},
},
];

View file

@ -1,37 +1,47 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
}, "lint": "eslint .",
"devDependencies": { "format": "prettier --write ."
"@internationalized/date": "^3.11.0", },
"@lucide/svelte": "^0.561.0", "devDependencies": {
"@sveltejs/adapter-node": "^5.0.0", "@eslint/js": "^10.0.1",
"@sveltejs/kit": "^2.50.2", "@internationalized/date": "^3.11.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@lucide/svelte": "^0.561.0",
"@tailwindcss/vite": "^4.2.1", "@sveltejs/adapter-node": "^5.0.0",
"svelte": "^5.51.0", "@sveltejs/kit": "^2.50.2",
"svelte-check": "^4.3.6", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"tailwind-variants": "^3.2.2", "@tailwindcss/vite": "^4.2.1",
"tailwindcss": "^4.2.1", "eslint": "^10.0.2",
"typescript": "^5.9.3", "eslint-plugin-svelte": "^3.15.0",
"vite": "^7.3.1" "globals": "^17.4.0",
}, "prettier": "^3.8.1",
"dependencies": { "prettier-plugin-svelte": "^3.5.0",
"@inlang/paraglide-js": "^2.13.0", "svelte": "^5.51.0",
"bits-ui": "^2.16.2", "svelte-check": "^4.3.6",
"clsx": "^2.1.1", "svelte-eslint-parser": "^1.5.1",
"lucide-svelte": "^0.575.0", "tailwind-variants": "^3.2.2",
"mode-watcher": "^1.1.0", "tailwindcss": "^4.2.1",
"svelte-dnd-action": "^0.9.69", "typescript": "^5.9.3",
"tailwind-merge": "^3.5.0" "typescript-eslint": "^8.56.1",
} "vite": "^7.3.1"
},
"dependencies": {
"@inlang/paraglide-js": "^2.13.0",
"bits-ui": "^2.16.2",
"clsx": "^2.1.1",
"lucide-svelte": "^0.575.0",
"mode-watcher": "^1.1.0",
"svelte-dnd-action": "^0.9.69",
"tailwind-merge": "^3.5.0"
}
} }

3141
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { page } from '$app/state'; import { page } from '$app/state';
import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
@ -9,13 +10,13 @@
let mobileMenuOpen = $state(false); let mobileMenuOpen = $state(false);
const navItems = $derived([ const navItems = $derived([
{ href: '/', label: m.nav_dashboard(), icon: '🏠' }, { href: resolve('/'), label: m.nav_dashboard(), icon: '🏠' },
{ href: '/products', label: m.nav_products(), icon: '🧴' }, { href: resolve('/products'), label: m.nav_products(), icon: '🧴' },
{ href: '/routines', label: m.nav_routines(), icon: '📋' }, { href: resolve('/routines'), label: m.nav_routines(), icon: '📋' },
{ href: '/routines/grooming-schedule', label: m.nav_grooming(), icon: '🪒' }, { href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: '🪒' },
{ href: '/health/medications', label: m.nav_medications(), icon: '💊' }, { href: resolve('/health/medications'), label: m.nav_medications(), icon: '💊' },
{ href: '/health/lab-results', label: m["nav_labResults"](), icon: '🔬' }, { href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: '🔬' },
{ href: '/skin', label: m.nav_skin(), icon: '✨' } { href: resolve('/skin'), label: m.nav_skin(), icon: '✨' }
]); ]);
function isActive(href: string) { function isActive(href: string) {

View file

@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
@ -16,7 +17,6 @@
TableHeader, TableHeader,
TableRow TableRow
} from '$lib/components/ui/table'; } from '$lib/components/ui/table';
import { goto } from '$app/navigation';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@ -33,6 +33,12 @@
let showForm = $state(false); let showForm = $state(false);
let selectedFlag = $state(''); let selectedFlag = $state('');
let filterFlag = $derived(data.flag ?? ''); let filterFlag = $derived(data.flag ?? '');
function onFlagChange(v: string) {
const base = resolve('/health/lab-results');
const url = v ? base + '?flag=' + v : base;
goto(url, { replaceState: true });
}
</script> </script>
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head> <svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
@ -61,9 +67,7 @@
<Select <Select
type="single" type="single"
value={filterFlag} value={filterFlag}
onValueChange={(v) => { onValueChange={onFlagChange}
goto(v ? `/health/lab-results?flag=${v}` : '/health/lab-results');
}}
> >
<SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger> <SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger>
<SelectContent> <SelectContent>

View file

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { Product } from '$lib/types'; import type { Product } from '$lib/types';
import { resolve } from '$app/paths';
import { SvelteMap } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@ -37,7 +39,7 @@
return bc !== 0 ? bc : a.name.localeCompare(b.name); return bc !== 0 ? bc : a.name.localeCompare(b.name);
}); });
const map = new Map<string, Product[]>(); const map = new SvelteMap<string, Product[]>();
for (const p of items) { for (const p of items) {
if (!map.has(p.category)) map.set(p.category, []); if (!map.has(p.category)) map.set(p.category, []);
map.get(p.category)!.push(p); map.get(p.category)!.push(p);
@ -64,8 +66,8 @@
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p> <p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button href="/products/suggest" variant="outline">{m["products_suggest"]()}</Button> <Button href={resolve('/products/suggest')} variant="outline">{m["products_suggest"]()}</Button>
<Button href="/products/new">{m["products_addNew"]()}</Button> <Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
</div> </div>
</div> </div>

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
@ -21,7 +22,7 @@
<div class="max-w-2xl space-y-6"> <div class="max-w-2xl space-y-6">
<div> <div>
<a href="/products" class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a> <a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2> <h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2>
</div> </div>

View file

@ -184,9 +184,8 @@ export const actions: Actions = {
const contextRules = parseContextRules(form); const contextRules = parseContextRules(form);
if (contextRules) payload.context_rules = contextRules; if (contextRules) payload.context_rules = contextRules;
let product;
try { try {
product = await createProduct(payload); await createProduct(payload);
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData } from './$types'; import type { ActionData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@ -20,7 +21,7 @@
<div class="max-w-2xl space-y-6"> <div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="/products" class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a> <a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2> <h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
</div> </div>

View file

@ -1,4 +1,3 @@
import type { ActionData } from './$types';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions } from './$types'; import type { Actions } from './$types';

View file

@ -1,15 +1,12 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import type { ProductSuggestion } from '$lib/types'; import type { ProductSuggestion } from '$lib/types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let { data, form }: { data: PageData; form: ActionData } = $props();
let suggestions = $state<ProductSuggestion[] | null>(null); let suggestions = $state<ProductSuggestion[] | null>(null);
let reasoning = $state(''); let reasoning = $state('');
let loading = $state(false); let loading = $state(false);

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@ -28,8 +29,8 @@
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p> <p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button href="/routines/suggest" variant="outline">{m["routines_suggestAI"]()}</Button> <Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button>
<Button href="/routines/new">{m["routines_addNew"]()}</Button> <Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
</div> </div>
</div> </div>

View file

@ -19,15 +19,13 @@
SelectTrigger SelectTrigger
} from '$lib/components/ui/select'; } from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { SvelteMap } from 'svelte/reactivity';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
let { routine, products } = $derived(data); let { routine, products } = $derived(data);
// ── Steps local state (synced from server data) ─────────────── // ── Steps local state (synced from server data) ───────────────
let steps = $state<RoutineStep[]>([]); let steps = $derived([...(routine.steps ?? [])].sort((a, b) => a.order_index - b.order_index));
$effect(() => {
steps = [...(routine.steps ?? [])].sort((a, b) => a.order_index - b.order_index);
});
const nextOrderIndex = $derived( const nextOrderIndex = $derived(
steps.length ? Math.max(...steps.map((s) => s.order_index)) + 1 : 0 steps.length ? Math.max(...steps.map((s) => s.order_index)) + 1 : 0
@ -122,7 +120,7 @@
} }
const groupedProducts = $derived.by(() => { const groupedProducts = $derived.by(() => {
const groups = new Map<string, typeof products>(); const groups = new SvelteMap<string, typeof products>();
for (const p of products) { for (const p of products) {
const key = p.category ?? 'other'; const key = p.category ?? 'other';
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);

View file

@ -2,7 +2,7 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { ActionData, PageData } from './$types'; import type { PageData } from './$types';
import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types'; import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
@ -13,7 +13,7 @@
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data }: { data: PageData } = $props();
const productMap = $derived(Object.fromEntries(data.products.map((p) => [p.id, p]))); const productMap = $derived(Object.fromEntries(data.products.map((p) => [p.id, p])));