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} />