diff --git a/src/__tests__/date-validation.test.ts b/src/__tests__/date-validation.test.ts
new file mode 100644
index 00000000..a247b028
--- /dev/null
+++ b/src/__tests__/date-validation.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/components/dashboard/StackedAreaChart.svelte b/src/lib/components/dashboard/StackedAreaChart.svelte
index 3ba00cee..e68e65fc 100644
--- a/src/lib/components/dashboard/StackedAreaChart.svelte
+++ b/src/lib/components/dashboard/StackedAreaChart.svelte
@@ -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,
@@ -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',
@@ -104,7 +105,7 @@
{#snippet formatter({ value, name })}
{name}
- {typeof value === 'number' ? `$${value.toFixed(2)}` : value}
+ {typeof value === 'number' ? formatCurrency(value) : value}
{/snippet}
diff --git a/src/lib/components/feature/compliance/ComplianceForm.svelte b/src/lib/components/feature/compliance/ComplianceForm.svelte
index 593f5762..e6196e56 100644
--- a/src/lib/components/feature/compliance/ComplianceForm.svelte
+++ b/src/lib/components/feature/compliance/ComplianceForm.svelte
@@ -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,
@@ -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';
@@ -45,7 +46,7 @@
);
const sf = createSheetForm({
- schema: complianceSchema,
+ schema: complianceFormSchema(configs.dateFormat),
onUpdated: async ({ form: f }) => {
if (f.valid) {
sf.processing = true;
diff --git a/src/lib/components/feature/fuel/FuelLogForm.svelte b/src/lib/components/feature/fuel/FuelLogForm.svelte
index ff4fe7e7..c513b824 100644
--- a/src/lib/components/feature/fuel/FuelLogForm.svelte
+++ b/src/lib/components/feature/fuel/FuelLogForm.svelte
@@ -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';
@@ -66,7 +67,7 @@
);
const sf = createSheetForm({
- schema: fuelSchema,
+ schema: fuelFormSchema(configs.dateFormat),
validationMethod: 'onsubmit',
onUpdated: async ({ form: f }) => {
if (f.valid) {
diff --git a/src/lib/components/feature/maintenance/MaintenanceForm.svelte b/src/lib/components/feature/maintenance/MaintenanceForm.svelte
index ca9820e1..7a8726d1 100644
--- a/src/lib/components/feature/maintenance/MaintenanceForm.svelte
+++ b/src/lib/components/feature/maintenance/MaintenanceForm.svelte
@@ -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';
@@ -34,7 +35,7 @@
);
const sf = createSheetForm({
- schema: maintenanceSchema,
+ schema: maintenanceFormSchema(configs.dateFormat),
onUpdated: async ({ form: f }) => {
if (f.valid) {
sf.processing = true;
diff --git a/src/lib/components/feature/reminder/ReminderForm.svelte b/src/lib/components/feature/reminder/ReminderForm.svelte
index c17f82a2..b72bff52 100644
--- a/src/lib/components/feature/reminder/ReminderForm.svelte
+++ b/src/lib/components/feature/reminder/ReminderForm.svelte
@@ -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';
@@ -35,7 +36,7 @@
let { data }: { data?: Partial } = $props();
const sf = createSheetForm({
- schema: reminderSchema,
+ schema: reminderFormSchema(configs.dateFormat),
onUpdated: async ({ form: f }) => {
if (f.valid) {
sf.processing = true;
diff --git a/src/lib/domain/compliance.ts b/src/lib/domain/compliance.ts
index 8492746b..b7186f25 100644
--- a/src/lib/domain/compliance.ts
+++ b/src/lib/domain/compliance.ts
@@ -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';
/**
@@ -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' }
+ );
diff --git a/src/lib/domain/fuel.ts b/src/lib/domain/fuel.ts
index 0e923b96..89ca4323 100644
--- a/src/lib/domain/fuel.ts
+++ b/src/lib/domain/fuel.ts
@@ -1,5 +1,5 @@
import { z } from 'zod';
-import { apiDateString } from './shared';
+import { apiDateString, dateStringForFormat } from './shared';
export interface FuelLog {
id: string | null;
@@ -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;
diff --git a/src/lib/domain/maintenance.ts b/src/lib/domain/maintenance.ts
index f1383893..3a157d93 100644
--- a/src/lib/domain/maintenance.ts
+++ b/src/lib/domain/maintenance.ts
@@ -1,5 +1,5 @@
import { z } from 'zod';
-import { apiDateString } from './shared';
+import { apiDateString, dateStringForFormat } from './shared';
export interface MaintenanceLog {
id: string | null;
@@ -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;
diff --git a/src/lib/domain/reminder.ts b/src/lib/domain/reminder.ts
index 65deaccd..bd5e6a02 100644
--- a/src/lib/domain/reminder.ts
+++ b/src/lib/domain/reminder.ts
@@ -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',
@@ -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;
diff --git a/src/lib/domain/shared.ts b/src/lib/domain/shared.ts
index 502cfd0f..c147f128 100644
--- a/src/lib/domain/shared.ts
+++ b/src/lib/domain/shared.ts
@@ -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;
diff --git a/src/routes/(app)/fuel/+page.svelte b/src/routes/(app)/fuel/+page.svelte
index a4365f31..dbc4e0c9 100644
--- a/src/routes/(app)/fuel/+page.svelte
+++ b/src/routes/(app)/fuel/+page.svelte
@@ -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';
@@ -52,7 +52,7 @@
0 ? `$${totalCost.toFixed(2)}` : '--'}
+ value={totalCost > 0 ? formatCurrency(totalCost) : '--'}
color={ACCENT.ochre.gradient}
/>