Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Run it on a Raspberry Pi, a home server, or a $5 VPS. Your data stays yours.
- **Expenses & reports** — See what your vehicles actually cost you.
- **Dashboard** — A fleet-wide overview with widgets you can rearrange to your liking.
- **Auth & feature toggles** — Username/password login with sessions, and the ability to turn off features you don't need.
- **10 languages** — English, Hindi, Spanish, French, German, Italian, Arabic, Romanian, Hungarian, and Finnish.
- **11 languages** — English, Hindi, Spanish, French, German, Italian, Arabic, Romanian, Hungarian, Finnish, and Russian.

## Tech stack

Expand Down
6 changes: 3 additions & 3 deletions docs/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ Tracktor uses Paraglide (inlang) with the Svelte 5 addon for runtime-localized U

## Adding a new language

1. Add the language in your Paraglide source/messages `i18n/messages` and regenerate the compiled outputs under `src/lib/paraglide/messages/`.
2. Ensure the new language code is included in Paraglide's generated `locales` array (in `src/lib/paraglide/runtime.js`).
3. Optionally add a human-readable label in `src/lib/components/feature/settings/SettingsForm.svelte` in the `localeLabels` map.
1. Add a message file for the language in `i18n/messages/<locale>.json`, using `en.json` as the reference for keys.
2. Add the locale code to the `locales` array in `i18n/project.inlang/settings.json`. The Paraglide vite plugin regenerates `src/lib/paraglide/` (including the `locales` array) on the next `vite dev`/`vite build`.
3. Optionally add a human-readable label in the `localeLabels` map in `src/lib/helper/settings-form.helper.ts`.
4. Provide translations for your messages via inlang tooling (VS Code Sherlock, Fink, etc.).

## Notes
Expand Down
813 changes: 813 additions & 0 deletions i18n/messages/pt-PT.json

Large diffs are not rendered by default.

813 changes: 813 additions & 0 deletions i18n/messages/ru.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion i18n/project.inlang/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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", "ru", "pt-PT"]
}
88 changes: 88 additions & 0 deletions src/__tests__/date-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { apiDateString, dateStringForFormat } from '$lib/domain/shared';
import { maintenanceFormSchema } from '$lib/domain/maintenance';
import { complianceFormSchema } from '$lib/domain/compliance';

describe('localized date validation', () => {
it('accepts days greater than 12 in day-first formats', () => {
expect(dateStringForFormat('dd/MM/yyyy').safeParse('13/08/2026').success).toBe(true);
expect(dateStringForFormat('dd/MM/yyyy').safeParse('31/12/2026').success).toBe(true);
});

it('validates against the configured field order', () => {
expect(dateStringForFormat('MM/dd/yyyy').safeParse('08/13/2026').success).toBe(true);
expect(dateStringForFormat('MM/dd/yyyy').safeParse('13/08/2026').success).toBe(false);
});

it('rejects impossible and partially matching dates', () => {
const schema = dateStringForFormat('dd/MM/yyyy');
expect(schema.safeParse('31/02/2026').success).toBe(false);
expect(schema.safeParse('13/08/2026 trailing').success).toBe(false);
expect(schema.safeParse('').success).toBe(false);
});

it('rejects calendar dates that JavaScript would silently normalize', () => {
expect(apiDateString.safeParse('2026-02-31').success).toBe(false);
expect(apiDateString.safeParse('2026-08-13T12:00:00.000Z').success).toBe(true);
});

it('uses localized validation in the maintenance form schema', () => {
const result = maintenanceFormSchema('dd/MM/yyyy').safeParse({
id: null,
vehicleId: '123e4567-e89b-42d3-a456-426614174000',
date: '13/08/2026',
odometer: 1,
serviceCenter: 'Garage',
cost: 0,
notes: null,
attachment: null
});

expect(result.success).toBe(true);
});

it('compares localized compliance dates chronologically', () => {
const base = {
id: null,
vehicleId: '123e4567-e89b-42d3-a456-426614174000',
type: 'insurance' as const,
otherLabel: null,
documentNumber: 'ABC123',
issuer: 'Insurer',
startDate: '13/08/2026',
recurrenceType: 'none' as const,
recurrenceInterval: 1,
cost: null,
notes: null,
attachment: null
};

expect(
complianceFormSchema('dd/MM/yyyy').safeParse({ ...base, endDate: '14/08/2026' }).success
).toBe(true);
expect(
complianceFormSchema('dd/MM/yyyy').safeParse({ ...base, endDate: '12/08/2026' }).success
).toBe(false);
});

it('does not reverse valid ambiguous dates in day-first formats', () => {
const base = {
id: null,
vehicleId: '123e4567-e89b-42d3-a456-426614174000',
type: 'insurance' as const,
otherLabel: null,
documentNumber: 'ABC123',
issuer: 'Insurer',
startDate: '10/03/2026',
recurrenceType: 'none' as const,
recurrenceInterval: 1,
cost: null,
notes: null,
attachment: null
};

expect(
complianceFormSchema('dd/MM/yyyy').safeParse({ ...base, endDate: '03/10/2026' }).success
).toBe(true);
});
});
5 changes: 3 additions & 2 deletions src/lib/components/dashboard/StackedAreaChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import LegendInfoGroup from '$appui/LegendInfoGroup.svelte';
import CircleSlash2 from '@lucide/svelte/icons/circle-slash-2';
import Skeleton from '$lib/components/ui/skeleton/skeleton.svelte';
import { formatCurrency } from '$lib/helper/format.helper';

let {
data,
Expand Down Expand Up @@ -38,7 +39,7 @@
format: (v: Date) => v.toLocaleDateString('en-IN', { month: 'short', year: '2-digit' })
},
yAxis: {
format: (v: number) => `$${v.toFixed(0)}`
format: (v: number) => formatCurrency(v)
},
grid: {
style: 'stroke-dasharray: 2',
Expand Down Expand Up @@ -104,7 +105,7 @@
{#snippet formatter({ value, name })}
<span class="text-muted-foreground">{name}</span>
<span class="font-mono font-medium tabular-nums">
{typeof value === 'number' ? `$${value.toFixed(2)}` : value}
{typeof value === 'number' ? formatCurrency(value) : value}
</span>
{/snippet}
</Chart.Tooltip>
Expand Down
5 changes: 3 additions & 2 deletions src/lib/components/feature/compliance/ComplianceForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { createSheetForm } from '$lib/composables/sheet-form.svelte';
import * as m from '$lib/paraglide/messages';
import {
complianceSchema,
complianceFormSchema,
COMPLIANCE_TYPES,
COMPLIANCE_RECURRENCE_TYPES,
getComplianceRecurrenceTypeLabel,
Expand All @@ -20,6 +20,7 @@
getComplianceDocumentNumberLabel,
getComplianceIssuerLabel
} from '$lib/domain/compliance';
import configs from '$stores/config.svelte';
import { FileDropZone, AutocompleteInput } from '$lib/components/app';
import { getComplianceIssuerSuggestions } from '$lib/services/autocomplete.service';
import Banknote from '@lucide/svelte/icons/banknote';
Expand All @@ -45,7 +46,7 @@
);

const sf = createSheetForm({
schema: complianceSchema,
schema: complianceFormSchema(configs.dateFormat),
onUpdated: async ({ form: f }) => {
if (f.valid) {
sf.processing = true;
Expand Down
5 changes: 3 additions & 2 deletions src/lib/components/feature/fuel/FuelLogForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
import { fuelLogStore } from '$stores/fuel-log.svelte';
import { createSheetForm } from '$lib/composables/sheet-form.svelte';
import { FileDropZone } from '$lib/components/app';
import { fuelSchema } from '$lib/domain/fuel';
import { fuelFormSchema } from '$lib/domain/fuel';
import configs from '$stores/config.svelte';
import Banknote from '@lucide/svelte/icons/banknote';
import Calendar1 from '@lucide/svelte/icons/calendar-1';
import CircleGauge from '@lucide/svelte/icons/circle-gauge';
Expand Down Expand Up @@ -66,7 +67,7 @@
);

const sf = createSheetForm({
schema: fuelSchema,
schema: fuelFormSchema(configs.dateFormat),
validationMethod: 'onsubmit',
onUpdated: async ({ form: f }) => {
if (f.valid) {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/components/feature/maintenance/MaintenanceForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import Input from '$appui/input.svelte';
import { Textarea } from '$ui/textarea';
import { formatDate, parseDate } from '$lib/helper/format.helper';
import { maintenanceSchema } from '$lib/domain/maintenance';
import { maintenanceFormSchema } from '$lib/domain/maintenance';
import configs from '$stores/config.svelte';
import Banknote from '@lucide/svelte/icons/banknote';
import Hammer from '@lucide/svelte/icons/hammer';
import CircleGauge from '@lucide/svelte/icons/circle-gauge';
Expand Down Expand Up @@ -34,7 +35,7 @@
);

const sf = createSheetForm({
schema: maintenanceSchema,
schema: maintenanceFormSchema(configs.dateFormat),
onUpdated: async ({ form: f }) => {
if (f.valid) {
sf.processing = true;
Expand Down
5 changes: 3 additions & 2 deletions src/lib/components/feature/reminder/ReminderForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
REMINDER_TYPES,
REMINDER_SCHEDULES,
REMINDER_RECURRENCE_TYPES,
reminderSchema,
reminderFormSchema,
getReminderScheduleLabel,
getRecurrenceTypeLabel,
getReminderTypeLabel
} from '$lib/domain/reminder';
import configs from '$stores/config.svelte';
import { createSheetForm } from '$lib/composables/sheet-form.svelte';
import { sheetStore } from '$stores/sheet.svelte';
import SubmitButton from '$appui/SubmitButton.svelte';
Expand All @@ -35,7 +36,7 @@
let { data }: { data?: Partial<Reminder> } = $props();

const sf = createSheetForm({
schema: reminderSchema,
schema: reminderFormSchema(configs.dateFormat),
onUpdated: async ({ form: f }) => {
if (f.valid) {
sf.processing = true;
Expand Down
35 changes: 23 additions & 12 deletions src/lib/domain/compliance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import Leaf from '@lucide/svelte/icons/leaf';
import ClipboardCheck from '@lucide/svelte/icons/clipboard-check';
import FileText from '@lucide/svelte/icons/file-text';
import Shapes from '@lucide/svelte/icons/shapes';
import { apiDateString, optionalApiDateString } from './shared';
import {
apiDateString,
dateStringForFormat,
optionalApiDateString,
optionalDateStringForFormat
} from './shared';
import { parseWithFormat } from '$lib/helper/date.helper';
import { getNextDueDate } from '$lib/helper/recurrence.helper';

/**
Expand Down Expand Up @@ -188,17 +194,22 @@ export const complianceSchema = z
return !!data.otherLabel && data.otherLabel.trim().length > 0;
},
{ message: 'Please name the compliance type', path: ['otherLabel'] }
)
.refine(
(data) => {
if (data.recurrenceType === undefined) return true;
if (data.recurrenceType !== 'none') return true;
if (data.endDate === undefined) return true;
if (!data.endDate) return false;
if (!data.startDate) return true;
return new Date(data.endDate) > new Date(data.startDate);
},
{ message: 'End date must be after start date when recurrence is fixed' }
);

export type ComplianceSchema = typeof complianceSchema;

export const complianceFormSchema = (dateFormat: string) =>
complianceSchema
.safeExtend({
startDate: dateStringForFormat(dateFormat),
endDate: optionalDateStringForFormat(dateFormat)
})
.refine(
(data) => {
if (data.recurrenceType !== 'none' || !data.startDate || !data.endDate) return true;
const startDate = parseWithFormat(data.startDate, dateFormat);
const endDate = parseWithFormat(data.endDate, dateFormat);
return !!startDate && !!endDate && endDate > startDate;
},
{ message: 'End date must be after start date when recurrence is fixed' }
);
5 changes: 4 additions & 1 deletion src/lib/domain/fuel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { apiDateString } from './shared';
import { apiDateString, dateStringForFormat } from './shared';

export interface FuelLog {
id: string | null;
Expand Down Expand Up @@ -34,4 +34,7 @@ export const fuelSchema = z.object({
attachment: z.string().nullable()
});

export const fuelFormSchema = (dateFormat: string) =>
fuelSchema.extend({ date: dateStringForFormat(dateFormat) });

export type FuelSchema = typeof fuelSchema;
5 changes: 4 additions & 1 deletion src/lib/domain/maintenance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { apiDateString } from './shared';
import { apiDateString, dateStringForFormat } from './shared';

export interface MaintenanceLog {
id: string | null;
Expand Down Expand Up @@ -29,4 +29,7 @@ export const maintenanceSchema = z.object({
attachment: z.string().nullable()
});

export const maintenanceFormSchema = (dateFormat: string) =>
maintenanceSchema.extend({ date: dateStringForFormat(dateFormat) });

export type MaintenanceSchema = typeof maintenanceSchema;
13 changes: 12 additions & 1 deletion src/lib/domain/reminder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { z } from 'zod';
import { apiDateString, optionalApiDateString } from './shared';
import {
apiDateString,
dateStringForFormat,
optionalApiDateString,
optionalDateStringForFormat
} from './shared';

export const REMINDER_TYPES = {
maintenance: 'maintenance',
Expand Down Expand Up @@ -137,4 +142,10 @@ export const reminderSchema = z.object({
isCompleted: z.boolean().default(false)
});

export const reminderFormSchema = (dateFormat: string) =>
reminderSchema.extend({
dueDate: dateStringForFormat(dateFormat),
recurrenceEndDate: optionalDateStringForFormat(dateFormat)
});

export type ReminderSchema = typeof reminderSchema;
22 changes: 19 additions & 3 deletions src/lib/domain/shared.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
import { z } from 'zod';
import { parseWithFormat } from '$lib/helper/date.helper';
import { format, isValid, parseISO } from 'date-fns';

/**
* Validates date strings accepted by the API: anything the Date
* constructor can parse (ISO 8601 payloads from JSON-serialized Dates).
* Validates ISO 8601 date strings accepted by the API, including payloads
* produced by JSON-serialized Dates.
* Display-format validation for forms lives in format.helper (client-only).
*/
export const apiDateString = z
.string()
.refine((val) => !Number.isNaN(new Date(val).getTime()), 'Invalid date format');
.refine((value) => isValid(parseISO(value)), 'Invalid date format');

/** Nullable/optional variant for fields that can be empty or omitted. */
export const optionalApiDateString = apiDateString.nullable().optional();

/** Validates a date exactly as it is displayed in a localized form. */
export const dateStringForFormat = (dateFormat: string) =>
z.string().refine(
(value) => {
const parsed = parseWithFormat(value, dateFormat);
return parsed !== null && format(parsed, dateFormat) === value;
},
{ message: `Invalid date. Expected format: ${dateFormat}` }
);

/** Nullable/optional localized date used by form-only schemas. */
export const optionalDateStringForFormat = (dateFormat: string) =>
dateStringForFormat(dateFormat).nullable().optional();

export type DataPoint = {
x: Date | string;
y: number | null;
Expand Down
4 changes: 3 additions & 1 deletion src/lib/helper/settings-form.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export function createSettingsOptions(
it: 'Italiano',
hu: 'Magyar',
fi: 'Suomi',
ro: 'Română'
ro: 'Română',
ru: 'Русский',
'pt-PT': 'Português (Portugal)'
};

return {
Expand Down
4 changes: 2 additions & 2 deletions src/routes/(app)/fuel/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { fuelLogStore } from '$stores/fuel-log.svelte';
import { chartStore } from '$stores/chart.svelte';
import { ACCENT } from '$lib/helper/accent-color.helper';
import { formatMileage } from '$lib/helper/format.helper';
import { formatCurrency, 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';
Expand Down Expand Up @@ -52,7 +52,7 @@
<StatCard
icon={DollarSign}
label={fuel_stat_spent()}
value={totalCost > 0 ? `$${totalCost.toFixed(2)}` : '--'}
value={totalCost > 0 ? formatCurrency(totalCost) : '--'}
color={ACCENT.ochre.gradient}
/>
<StatCard
Expand Down
Loading