From d5a8e5faa83e956bb383443f65e039983b5c7ceb Mon Sep 17 00:00:00 2001 From: hawkinslabdev <59891413+hawkinslabdev@users.noreply.github.com> Date: Wed, 6 May 2026 18:42:08 +0200 Subject: [PATCH 1/2] fix: locale loading and PDF title rendering - use static JSON imports for locale files to ensure production availability - fallback to first notes line as title for orphaned service logs - reorder PDF entry structure: bold title first, then meta - add cost display to PDF entries with locale-aware formatting --- motomate/src/lib/pdf/maintenance-report.ts | 124 +++++++++++++-------- 1 file changed, 80 insertions(+), 44 deletions(-) diff --git a/motomate/src/lib/pdf/maintenance-report.ts b/motomate/src/lib/pdf/maintenance-report.ts index fd1c87d..7778c76 100644 --- a/motomate/src/lib/pdf/maintenance-report.ts +++ b/motomate/src/lib/pdf/maintenance-report.ts @@ -1,8 +1,13 @@ import PDFDocumentClass from 'pdfkit'; import { PDFDocument as LibPDFDocument, StandardFonts, rgb } from 'pdf-lib'; -import { readFileSync } from 'fs'; -import { resolve } from 'path'; import type { Vehicle, ServiceLog, Document as DocRecord } from '$lib/db/schema.js'; +import en_locale from '../i18n/locales/en.json'; +import nl_locale from '../i18n/locales/nl.json'; +import de_locale from '../i18n/locales/de.json'; +import es_locale from '../i18n/locales/es.json'; +import fr_locale from '../i18n/locales/fr.json'; +import it_locale from '../i18n/locales/it.json'; +import pt_locale from '../i18n/locales/pt.json'; type PDFDoc = InstanceType; @@ -50,6 +55,17 @@ const SUBTLE = '#9ca3af'; const ACCENT = '#2563eb'; const RULE = '#e5e7eb'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const LOCALE_DATA: Record = { + en: en_locale, + nl: nl_locale, + de: de_locale, + es: es_locale, + fr: fr_locale, + it: it_locale, + pt: pt_locale +}; + function loadTranslations(locale: string): ReportTranslations { const supported = ['en', 'nl', 'de', 'es', 'fr', 'it', 'pt']; const lang = supported.includes(locale) ? locale : 'en'; @@ -72,16 +88,9 @@ function loadTranslations(locale: string): ReportTranslations { seeFollowingPages: 'See following {count} pages', fileNotEmbeddable: 'File not embeddable in PDF' }; - try { - const raw = readFileSync(resolve('src/lib/i18n/locales', `${lang}.json`), 'utf-8'); - const json = JSON.parse(raw) as any; - const loaded = json?.vehicle?.edit?.settings?.report?.pdf as Partial; - if (loaded) { - return { ...fallbacks, ...loaded }; - } - } catch { - // fall through to fallbacks - } + const json = LOCALE_DATA[lang]; + const loaded = json?.vehicle?.edit?.settings?.report?.pdf as Partial; + if (loaded) return { ...fallbacks, ...loaded }; return fallbacks; } @@ -119,6 +128,14 @@ function fmtDateShort(iso: string, locale: string): string { } } +function fmtCurrency(cents: number, currency: string, locale: string): string { + try { + return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(cents / 100); + } catch { + return `${(cents / 100).toFixed(2)} ${currency}`; + } +} + function hRule(doc: PDFDoc, y: number, color = RULE, weight = 0.5): void { doc .save() @@ -326,63 +343,82 @@ function drawServiceLogs( const attachIds = (log.attachments as string[] | null) ?? []; const parts = (log.parts_used as Array<{ name: string; part_number?: string }> | null) ?? []; - const lines = 1 + (log.notes ? 1 : 0) + (log.remark ? 1 : 0) + (parts.length > 0 ? 1 : 0); - ensureSpace(doc, lines * 14 + 16); + + // Resolve tracker names (deleted trackers won't be in the map) + const names: string[] = []; + if (log.tracker_id) { + const n = trackerNames.get(log.tracker_id); + if (n) names.push(n); + } + for (const tid of (log.serviced_tracker_ids as string[] | null) ?? []) { + if (tid !== log.tracker_id) { + const n = trackerNames.get(tid); + if (n && !names.includes(n)) names.push(n); + } + } + + // Title: tracker names, or first line of notes as fallback + const trackerTitle = names.join(', '); + const notesFirstLine = log.notes?.split('\n')[0]?.trim() ?? ''; + const entryTitle = trackerTitle || notesFirstLine; + // Notes body: if first line was used as title, skip it + const notesBody = trackerTitle + ? (log.notes ?? '') + : log.notes?.includes('\n') + ? log.notes.split('\n').slice(1).join('\n').trim() + : ''; + + const lineCount = + (entryTitle ? 1 : 0) + + 1 + + (notesBody ? 1 : 0) + + (log.remark ? 1 : 0) + + (parts.length > 0 ? 1 : 0); + ensureSpace(doc, lineCount * 14 + 20); const entryTop = doc.y; + // Title row (bold, ink) + if (entryTitle) { + doc + .fontSize(10) + .font('Helvetica-Bold') + .fillColor(INK) + .text(entryTitle, ML, entryTop, { width: CW }); + } + + let cy = entryTitle ? doc.y + 2 : entryTop; + + // Meta row: date · odometer [· cost] + attachment refs right-aligned const dateStr = fmtDateShort(log.performed_at, locale); const odoStr = log.odometer_at_service != null ? `${log.odometer_at_service.toLocaleString(locale)} ${vehicle.odometer_unit ?? 'km'}` : ''; - const metaLine = odoStr ? `${dateStr} · ${odoStr}` : dateStr; - doc - .fontSize(9) - .font('Courier') - .fillColor(MUTED) - .text(metaLine, ML, entryTop, { lineBreak: false }); + const costStr = log.cost_cents != null ? fmtCurrency(log.cost_cents, log.currency, locale) : ''; + const metaLine = [dateStr, odoStr, costStr].filter(Boolean).join(' · '); + doc.fontSize(9).font('Courier').fillColor(MUTED).text(metaLine, ML, cy, { lineBreak: false }); const refs = attachIds .filter((id) => attachmentIndex.has(id)) .map((id) => `[A${attachmentIndex.get(id)}]`) .join(' '); if (refs) { - doc.fontSize(9).font('Courier').fillColor(ACCENT).text(refs, ML, entryTop, { + doc.fontSize(9).font('Courier').fillColor(ACCENT).text(refs, ML, cy, { width: CW, align: 'right', lineBreak: false }); } - let cy = entryTop + 14; - - const names: string[] = []; - if (log.tracker_id) { - const n = trackerNames.get(log.tracker_id); - if (n) names.push(n); - } - for (const tid of (log.serviced_tracker_ids as string[] | null) ?? []) { - if (tid !== log.tracker_id) { - const n = trackerNames.get(tid); - if (n && !names.includes(n)) names.push(n); - } - } - if (names.length > 0) { - doc - .fontSize(10) - .font('Helvetica') - .fillColor(INK) - .text(names.join(', '), ML, cy, { width: CW }); - cy = doc.y + 2; - } + cy = doc.y + 5; - if (log.notes) { + if (notesBody) { doc .fontSize(9) .font('Helvetica') .fillColor(MUTED) - .text(`${t.notes}: ${log.notes}`, ML, cy, { width: CW }); + .text(`${t.notes}: ${notesBody}`, ML, cy, { width: CW }); cy = doc.y + 2; } From ed41e5c17e8853a02c81d43b732228b2599680f9 Mon Sep 17 00:00:00 2001 From: hawkinslabdev <59891413+hawkinslabdev@users.noreply.github.com> Date: Wed, 6 May 2026 19:01:39 +0200 Subject: [PATCH 2/2] fix: hours-based tracker support and unit consistency - initialize `next_due_measurement` for hours trackers - remove distance-only guards in tracker status computation - remove hardcoded basis from odometer triggers - set unit-aware DUE_SOON window (1h for hours, 50km for distance) - use vehicle odometer unit in warning messages - patch existing trackers with null `next_due_measurement` --- motomate/src/lib/auth/rate-limit.ts | 4 +-- .../src/lib/db/repositories/maintenance.ts | 27 +++++++++------- .../src/lib/db/repositories/service-logs.ts | 6 +--- motomate/src/lib/db/schema.ts | 2 +- motomate/src/lib/i18n/locales/nl.json | 16 +++++----- motomate/src/lib/workflow/engine.ts | 2 +- motomate/src/lib/workflow/triggers.ts | 4 +-- .../routes/(app)/onboarding/+page.server.ts | 4 +-- .../(app)/vehicles/[id]/+page.server.ts | 7 +++-- .../vehicles/[id]/finance/+page.server.ts | 5 ++- .../vehicles/[id]/maintenance/+page.server.ts | 4 +-- .../src/routes/(auth)/login/+page.server.ts | 6 ++-- .../routes/(auth)/register/+page.server.ts | 1 - motomate/src/routes/api/export/+server.ts | 4 +-- motomate/src/routes/api/prefs/+server.ts | 1 - .../src/routes/api/push/subscribe/+server.ts | 2 +- motomate/src/service-worker.ts | 2 +- motomate/src/tests/workflow-triggers.test.ts | 31 +++++++++++++++++-- 18 files changed, 73 insertions(+), 55 deletions(-) diff --git a/motomate/src/lib/auth/rate-limit.ts b/motomate/src/lib/auth/rate-limit.ts index b130bca..8286209 100644 --- a/motomate/src/lib/auth/rate-limit.ts +++ b/motomate/src/lib/auth/rate-limit.ts @@ -1,6 +1,4 @@ -// In-memory rate limiter — suitable for a single-process self-hosted server. -// State resets on server restart, which is acceptable. - +// In-memory rate limiter (to be adjusted for distributed environments) interface Bucket { count: number; resetAt: number; diff --git a/motomate/src/lib/db/repositories/maintenance.ts b/motomate/src/lib/db/repositories/maintenance.ts index d1b0d09..410e02c 100644 --- a/motomate/src/lib/db/repositories/maintenance.ts +++ b/motomate/src/lib/db/repositories/maintenance.ts @@ -537,9 +537,11 @@ export async function createTracker( let next_due_odometer: number | null = null; let next_due_at: string | null = null; - if (interval?.basis === 'distance' && interval.interval_measurement != null) { + if (interval?.interval_measurement != null) { next_due_measurement = interval.interval_measurement; - next_due_odometer = interval.interval_measurement; + if (interval.basis === 'distance') { + next_due_odometer = interval.interval_measurement; + } } if (template?.interval_months) { next_due_at = formatISO(addMonths(parseISO(today), template.interval_months), { @@ -933,15 +935,12 @@ export async function recomputeTrackerStatuses( const needsInit = lastDoneMeasurement === null && t.last_done_at === null; const fields: Partial = {}; - if ( - needsInit && - nextDueMeasurement === null && - interval.basis === 'distance' && - interval.interval_measurement != null - ) { + if (needsInit && nextDueMeasurement === null && interval.interval_measurement != null) { nextDueMeasurement = interval.interval_measurement; fields.next_due_measurement = nextDueMeasurement; - fields.next_due_odometer = nextDueMeasurement; + if (interval.basis === 'distance') { + fields.next_due_odometer = nextDueMeasurement; + } } if (needsInit && nextDueAt === null && template.interval_months) { nextDueAt = formatISO(addMonths(parseISO(today), template.interval_months), { @@ -985,9 +984,13 @@ export async function recomputeTrackerStatuses( // TODO: expose DUE_SOON_FACTOR as a user setting in profile settings page const DUE_SOON_FACTOR = 0.2; const intervalMeasurement = interval.interval_measurement ?? template.interval_km; + // Min window is unit-aware: 50 for km/mi (meaningful look-ahead), 1 for hours + const minWindow = interval.basis === 'duration' ? 1 : 50; const kmWindow = intervalMeasurement - ? Math.min(500, Math.max(50, Math.round(intervalMeasurement * DUE_SOON_FACTOR))) - : 500; + ? Math.min(500, Math.max(minWindow, Math.round(intervalMeasurement * DUE_SOON_FACTOR))) + : interval.basis === 'duration' + ? 1 + : 500; const kmDueSoon = nextDueMeasurementValue != null && areMeasurementsComparable(vehicleMeasurement, nextDueMeasurementValue) && @@ -1015,7 +1018,7 @@ export async function recomputeTrackerStatuses( patches.push({ id: t.id, fields }); } - // Return tracker with locally computed values — no second DB read needed + // Return tracker with locally computed values results.push({ ...hydrateTracker(t), measurement_unit: diff --git a/motomate/src/lib/db/repositories/service-logs.ts b/motomate/src/lib/db/repositories/service-logs.ts index 92fb66c..54cd5f7 100644 --- a/motomate/src/lib/db/repositories/service-logs.ts +++ b/motomate/src/lib/db/repositories/service-logs.ts @@ -30,8 +30,6 @@ export async function createServiceLog(userId: string, input: unknown): Promise< measurement_at_service: parsed.odometer_at_service, measurement_unit: vehicle?.odometer_unit }; - - // Insert the log (sync — better-sqlite3) db.insert(service_logs).values(row).run(); // Reset primary tracker @@ -50,9 +48,7 @@ export async function createServiceLog(userId: string, input: unknown): Promise< } } - // Only advance the vehicle odometer/hours — never move it backwards. - // Logging historical entries (e.g. "oil change 400 km ago") must not - // overwrite a higher current reading. + // Only advance the vehicle odometer/hours; prevent to move it backwards. const serviceMeasurement = resolveMeasurementValue( row.measurement_at_service, row.measurement_unit ?? null diff --git a/motomate/src/lib/db/schema.ts b/motomate/src/lib/db/schema.ts index 8511c16..30479be 100644 --- a/motomate/src/lib/db/schema.ts +++ b/motomate/src/lib/db/schema.ts @@ -111,7 +111,7 @@ export const sessions = sqliteTable('sessions', { userId: text('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), - expiresAt: integer('expires_at').notNull() // unix timestamp — Lucia requires camelCase property names + expiresAt: integer('expires_at').notNull() // unix timestamp! }); export const magic_link_tokens = sqliteTable('magic_link_tokens', { diff --git a/motomate/src/lib/i18n/locales/nl.json b/motomate/src/lib/i18n/locales/nl.json index 7331287..18abce1 100644 --- a/motomate/src/lib/i18n/locales/nl.json +++ b/motomate/src/lib/i18n/locales/nl.json @@ -248,7 +248,7 @@ "notes": "Notities", "travels": "Routes", "finance": "Financieel", - "usage": "Verbruik" + "usage": "Draaiuren" } }, "upcoming": "Aankomende", @@ -292,7 +292,7 @@ "checkToReset": "(vink aan om te resetten)", "currentReading": "Huidige stand ({unit})", "attachments": "Bijlagen {optional}", - "usage": "Verbruiksstand ({unit})" + "usage": "Draaiuren ({unit})" }, "placeholders": { "partsUsed": "Gebruikte onderdelen, observaties…", @@ -321,7 +321,7 @@ "odoSame": "Je hebt al een stand van {num} {unit} opgeslagen in jouw administratie.", "odoLower": "Lager dan de hoogste opgenomen stand ({current} {unit}), wordt opgeslagen als historisch record." }, - "updateUsage": "Verbruik bijwerken" + "updateUsage": "Draaiuren bijwerken" }, "add": { "title": "Voertuig toevoegen", @@ -366,7 +366,7 @@ "title": "Kilometerstand bijwerken", "currentReading": "Huidige stand ({unit})", "update": "Bijwerken", - "usageTitle": "Verbruik bijwerken", + "usageTitle": "Draaiuren bijwerken", "currentUsage": "Huidige stand ({unit})" }, "vehicleDetails": "Voertuigdetails", @@ -464,7 +464,7 @@ "title": "Meeteenheid", "locked": "Eenheid kan je niet meer aanpassen na het aanmaken van je voertuig." }, - "usageUpdated": "Verbruik bijgewerkt" + "usageUpdated": "Draaiuren bijgewerkt" } }, "maintenance": { @@ -558,7 +558,7 @@ "date": "Datum", "odometer": "Kilometerstand ({unit})", "autoCompute": "— leeglaten voor automatische berekening", - "usage": "Verbruiksstand ({unit})" + "usage": "Draaiuren ({unit})" }, "submit": "Wijzigingen opslaan" }, @@ -636,7 +636,7 @@ "odometerOptional": "Optioneel", "notes": "Notities", "notesPlaceholder": "Extra details", - "usage": "Verbruiksstand ({unit})" + "usage": "Draaiuren ({unit})" }, "deleteDialog": { "title": "Deze transactie verwijderen?", @@ -964,7 +964,7 @@ "mileageDesc": "Nummer op je dashboard", "finance": "Financiën", "financeDesc": "Brandstof, onderdelen of andere kosten", - "usage": "Verbruik", + "usage": "Draaiuren", "usageDesc": "Uren of stand op je teller" } }, diff --git a/motomate/src/lib/workflow/engine.ts b/motomate/src/lib/workflow/engine.ts index 43e52d5..ce2f85b 100644 --- a/motomate/src/lib/workflow/engine.ts +++ b/motomate/src/lib/workflow/engine.ts @@ -107,7 +107,7 @@ async function dispatchNotification( await Promise.allSettled(tasks); } -// Returns an array — one entry per matching tracker (or document/date condition). +// Returns an array > one entry per matching tracker (or document/date condition). // Empty array means nothing fired. async function evalTrigger( trigger: RuleTrigger, diff --git a/motomate/src/lib/workflow/triggers.ts b/motomate/src/lib/workflow/triggers.ts index 2cb7b5b..8c09c06 100644 --- a/motomate/src/lib/workflow/triggers.ts +++ b/motomate/src/lib/workflow/triggers.ts @@ -14,7 +14,7 @@ import { export type WorkflowMaintenanceTrigger = { kind: 'maintenance'; phase: 'upcoming' | 'overdue'; - basis: 'distance'; + basis?: MeasurementBasis; threshold: number; legacyType: 'odometer_upcoming' | 'odometer_overdue'; }; @@ -159,7 +159,6 @@ export function normalizeWorkflowTrigger(trigger: RuleTrigger): NormalizedWorkfl return { kind: 'maintenance', phase: 'upcoming', - basis: 'distance', threshold: trigger.km_before, legacyType: trigger.type }; @@ -167,7 +166,6 @@ export function normalizeWorkflowTrigger(trigger: RuleTrigger): NormalizedWorkfl return { kind: 'maintenance', phase: 'overdue', - basis: 'distance', threshold: trigger.km_past, legacyType: trigger.type }; diff --git a/motomate/src/routes/(app)/onboarding/+page.server.ts b/motomate/src/routes/(app)/onboarding/+page.server.ts index e79746f..49d4c84 100644 --- a/motomate/src/routes/(app)/onboarding/+page.server.ts +++ b/motomate/src/routes/(app)/onboarding/+page.server.ts @@ -95,7 +95,7 @@ export const actions: Actions = { ); const lastServiceDate = String(data.last_service_date ?? '').trim(); - // Clamp to current odometer — a service cannot have happened beyond what the vehicle shows + // Clamp to current odometer; a service cannot have happened beyond what the vehicle shows const lastServiceOdo = Math.min( Number(data.last_service_odometer), vehicleInput.current_odometer @@ -121,7 +121,7 @@ export const actions: Actions = { await insertOdometerLog(vehicle.id, userId, vehicleInput.current_odometer); } } else if (vehicleInput.current_odometer > 0) { - // No service info entered — record the starting odometer as a baseline + // No service info entered; record the starting odometer as a baseline await insertOdometerLog(vehicle.id, userId, vehicleInput.current_odometer); } diff --git a/motomate/src/routes/(app)/vehicles/[id]/+page.server.ts b/motomate/src/routes/(app)/vehicles/[id]/+page.server.ts index cb6be46..3d4c30a 100644 --- a/motomate/src/routes/(app)/vehicles/[id]/+page.server.ts +++ b/motomate/src/routes/(app)/vehicles/[id]/+page.server.ts @@ -67,7 +67,7 @@ export const actions: Actions = { const raw = Object.fromEntries(formData); const currency = (locals.user as any)?.settings?.currency ?? 'EUR'; - // getAll() required — Object.fromEntries drops duplicate keys for multi-checkboxes + // getAll() required > Object.fromEntries drops duplicate keys for multi-checkboxes const resetTrackerIds = formData.getAll('reset_trackers').map(String).filter(Boolean); const trackerId = (raw.tracker_id as string) || resetTrackerIds[0] || undefined; @@ -199,12 +199,13 @@ export const actions: Actions = { const vehicle = await getVehicleById(params.id, locals.user!.id); const maxOdo = vehicle?.current_odometer ?? 0; + const unit = vehicle?.odometer_unit ?? 'km'; let warning: string | undefined; if (raw === maxOdo) { - warning = `You already have a reading of ${raw} km. Saving anyway for your records.`; + warning = `You already have a reading of ${raw} ${unit}. Saving anyway for your records.`; } else if (raw < maxOdo) { - warning = `Lower than the highest recorded reading (${maxOdo} km). Saved as a historical record.`; + warning = `Lower than the highest recorded reading (${maxOdo} ${unit}). Saved as a historical record.`; } else { await updateOdometer(params.id, locals.user!.id, raw, vehicle?.odometer_unit); } diff --git a/motomate/src/routes/(app)/vehicles/[id]/finance/+page.server.ts b/motomate/src/routes/(app)/vehicles/[id]/finance/+page.server.ts index 0bdbbeb..3acb4b7 100644 --- a/motomate/src/routes/(app)/vehicles/[id]/finance/+page.server.ts +++ b/motomate/src/routes/(app)/vehicles/[id]/finance/+page.server.ts @@ -85,12 +85,11 @@ export const load: PageServerLoad = async ({ parent, params, locals }) => { const year = new Date(tx.date).getFullYear(); byYear.set(year, (byYear.get(year) || 0) + tx.amountCents); - // Category breakdown — finance transactions use their category field; - // service logs (category: null) are grouped under 'service' + // Category breakdown. Note finance transactions use their category field; service logs (category: null) are grouped under 'service' const catKey = tx.category ?? (tx.type === 'service' ? 'service' : 'other'); byCategory.set(catKey, (byCategory.get(catKey) || 0) + tx.amountCents); - // Description breakdown — first line of notes, fallback to category label or type + // Description breakdown; first line of notes, fallback to category label or type const descKey = tx.notes?.split('\n')[0]?.trim() || (tx.category ? (categoryLabels[tx.category] ?? tx.category) : 'Service entry'); diff --git a/motomate/src/routes/(app)/vehicles/[id]/maintenance/+page.server.ts b/motomate/src/routes/(app)/vehicles/[id]/maintenance/+page.server.ts index 2a312d7..48314bf 100644 --- a/motomate/src/routes/(app)/vehicles/[id]/maintenance/+page.server.ts +++ b/motomate/src/routes/(app)/vehicles/[id]/maintenance/+page.server.ts @@ -147,7 +147,7 @@ export const actions: Actions = { const lastDoneOdo = String(raw.last_done_odometer || '').trim() !== '' ? Number(raw.last_done_odometer) : null; - // Explicit overrides — undefined means "auto-compute" + // Explicit overrides; undefined means "auto-compute" const nextDueOdoRaw = String(raw.next_due_odometer || '').trim(); const nextDueAtRaw = String(raw.next_due_at || '').trim(); const nextDueOdometer = nextDueOdoRaw !== '' ? Number(nextDueOdoRaw) : undefined; @@ -186,7 +186,7 @@ export const actions: Actions = { const raw = Object.fromEntries(formData); const id = String(raw.id); - // Must use getAll() — Object.fromEntries drops duplicate keys for multi-checkboxes + // Must use getAll(); Object.fromEntries drops duplicate keys for multi-checkboxes const resetTrackerIds = formData.getAll('reset_trackers').map(String); await updateServiceLog(id, params.id, locals.user!.id, { diff --git a/motomate/src/routes/(auth)/login/+page.server.ts b/motomate/src/routes/(auth)/login/+page.server.ts index 5de8531..7453218 100644 --- a/motomate/src/routes/(auth)/login/+page.server.ts +++ b/motomate/src/routes/(auth)/login/+page.server.ts @@ -68,8 +68,7 @@ export const actions: Actions = { const user = await getUserByEmail(parsed.data.email); - // Always run Argon2 verify — even when the user doesn't exist — so response - // time is constant and can't be used to enumerate valid email addresses. + // Always run Argon2 verify ; even when the user doesn't exist so response time is constant and can't be used to enumerate valid email addresses. const hashToCheck = user?.password_hash ?? (await getDummyHash()); const valid = await verify(hashToCheck, parsed.data.password, ARGON2_OPTS); @@ -77,8 +76,7 @@ export const actions: Actions = { return fail(400, { error: errors.invalidCredentials, email: parsed.data.email }); } - // Apply pre-login locale/theme to DB, but only when the DB still has - // the default value — never overwrite a setting the user already customized. + // Apply pre-login locale/theme to DB, but only when the DB still has the default value; never overwrite a setting the user already customized. const rawTheme = String(data.theme ?? ''); const rawLocale = String(data.locale ?? ''); const settingsPatch: Record = {}; diff --git a/motomate/src/routes/(auth)/register/+page.server.ts b/motomate/src/routes/(auth)/register/+page.server.ts index cc27cbb..c250424 100644 --- a/motomate/src/routes/(auth)/register/+page.server.ts +++ b/motomate/src/routes/(auth)/register/+page.server.ts @@ -51,7 +51,6 @@ export const actions: Actions = { const existing = await getUserByEmail(parsed.data.email); if (existing) { - // Neutral message — does not confirm whether the address is registered. return fail(400, { error: 'Check your details and try again, or log in if you already have an account.', email: parsed.data.email diff --git a/motomate/src/routes/api/export/+server.ts b/motomate/src/routes/api/export/+server.ts index af79994..d5a4c47 100644 --- a/motomate/src/routes/api/export/+server.ts +++ b/motomate/src/routes/api/export/+server.ts @@ -74,7 +74,7 @@ export const GET: RequestHandler = async ({ locals, url }) => { // export.json in root zipFiles['export.json'] = [strToU8(JSON.stringify(exportData, null, 2)), { mtime: now }]; - // Fetch document files — organised by vehicle / doc_type / filename + // Fetch document files and add to zip under documents/{vehicleId}/{docType}/{docId}-{safeName}.ext for (const { vehicle, documents } of vehicleData) { for (const doc of documents) { try { @@ -85,7 +85,7 @@ export const GET: RequestHandler = async ({ locals, url }) => { const mtime = new Date(doc.created_at); zipFiles[path] = [new Uint8Array(buf), { mtime }]; } catch { - // File missing from storage — skip gracefully + // File missing from storage } } } diff --git a/motomate/src/routes/api/prefs/+server.ts b/motomate/src/routes/api/prefs/+server.ts index 7d952ef..b7b51e0 100644 --- a/motomate/src/routes/api/prefs/+server.ts +++ b/motomate/src/routes/api/prefs/+server.ts @@ -12,7 +12,6 @@ export const PATCH: RequestHandler = async ({ locals, request }) => { const incoming: PagePrefs = body.page_prefs ?? {}; const existing: PagePrefs = (locals.user as any).settings?.page_prefs ?? {}; - // Deep merge one level — don't clobber sibling page prefs const merged: PagePrefs = { ...existing }; for (const key of Object.keys(incoming) as (keyof PagePrefs)[]) { (merged as any)[key] = { ...(existing[key] ?? {}), ...(incoming[key] ?? {}) }; diff --git a/motomate/src/routes/api/push/subscribe/+server.ts b/motomate/src/routes/api/push/subscribe/+server.ts index c49b9f3..7bb0b0f 100644 --- a/motomate/src/routes/api/push/subscribe/+server.ts +++ b/motomate/src/routes/api/push/subscribe/+server.ts @@ -10,7 +10,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { const sub = await request.json(); if (!sub.endpoint || !sub.keys) error(400, 'Invalid subscription'); - // Upsert — replace existing subscription for same endpoint + // Upsert await db.delete(push_subscriptions).where(eq(push_subscriptions.endpoint, sub.endpoint)); await db.insert(push_subscriptions).values({ id: generateId(), diff --git a/motomate/src/service-worker.ts b/motomate/src/service-worker.ts index 34c9418..ce703de 100644 --- a/motomate/src/service-worker.ts +++ b/motomate/src/service-worker.ts @@ -41,7 +41,7 @@ sw.addEventListener('fetch', (event) => { const url = new URL(event.request.url); const cache = await caches.open(CACHE); - // Precached assets (hashed) — always serve from cache + // Precached assets (hashed); we'll always serve from cache if (ASSETS.includes(url.pathname)) { const cached = await cache.match(url.pathname); if (cached) return cached; diff --git a/motomate/src/tests/workflow-triggers.test.ts b/motomate/src/tests/workflow-triggers.test.ts index c8fc0a8..f95c151 100644 --- a/motomate/src/tests/workflow-triggers.test.ts +++ b/motomate/src/tests/workflow-triggers.test.ts @@ -9,13 +9,14 @@ import { describe('workflow trigger normalization', () => { it('normalizes legacy odometer triggers into maintenance triggers', () => { const normalized = normalizeWorkflowTrigger({ type: 'odometer_upcoming', km_before: 500 }); - expect(normalized).toEqual({ + expect(normalized).toMatchObject({ kind: 'maintenance', phase: 'upcoming', - basis: 'distance', threshold: 500, legacyType: 'odometer_upcoming' }); + // basis is intentionally absent — resolved at evaluation time from vehicle/tracker + expect((normalized as { basis?: unknown }).basis).toBeUndefined(); }); it('normalizes document expiry triggers without changing persisted shape assumptions', () => { @@ -80,6 +81,32 @@ describe('workflow trigger evaluation', () => { expect(evaluation).toBeNull(); }); + it('evaluates maintenance triggers for hours-based vehicles', () => { + const trigger = normalizeWorkflowTrigger({ type: 'odometer_overdue', km_past: 0 }); + if (trigger.kind !== 'maintenance') throw new Error('expected maintenance trigger'); + + const evaluation = evaluateMaintenanceTrigger( + trigger, + { + current_measurement: 20, + current_measurement_unit: 'h', + current_odometer: 20, + odometer_unit: 'h' + }, + { + next_due_measurement: 15, + next_due_odometer: null, + measurement_unit: 'h' + }, + { name: 'Oil change', interval_km: null } + ); + + expect(evaluation).not.toBeNull(); + expect(evaluation?.matches).toBe(true); + expect(evaluation?.measurement.basis).toBe('duration'); + expect(evaluation?.measurement.unit).toBe('h'); + }); + it('evaluates date overdue triggers and preserves overdue relation metadata', () => { const trigger = normalizeWorkflowTrigger({ type: 'date_overdue', days_past: 0 }); if (trigger.kind !== 'date') throw new Error('expected date trigger');