From e3b13473564007536da957ab88d75dea841e0651 Mon Sep 17 00:00:00 2001 From: Michael Varrieur Date: Wed, 22 Apr 2026 17:41:10 -0400 Subject: [PATCH 1/2] First pass PR 4 --- motomate/src/lib/db/repositories/workflow.ts | 11 +- motomate/src/lib/i18n/locales/de.json | 9 +- motomate/src/lib/i18n/locales/en.json | 9 +- motomate/src/lib/i18n/locales/es.json | 9 +- motomate/src/lib/i18n/locales/fr.json | 9 +- motomate/src/lib/i18n/locales/it.json | 9 +- motomate/src/lib/i18n/locales/nl.json | 9 +- motomate/src/lib/i18n/locales/pt.json | 9 +- motomate/src/lib/workflow/engine.ts | 109 ++--- motomate/src/lib/workflow/rules.ts | 4 +- motomate/src/lib/workflow/triggers.ts | 410 ++++++++++++++++++ .../(app)/settings/workflows/+page.server.ts | 93 ++-- .../(app)/settings/workflows/+page.svelte | 6 +- 13 files changed, 542 insertions(+), 154 deletions(-) create mode 100644 motomate/src/lib/workflow/triggers.ts diff --git a/motomate/src/lib/db/repositories/workflow.ts b/motomate/src/lib/db/repositories/workflow.ts index 4d3896d..6dc7d2b 100644 --- a/motomate/src/lib/db/repositories/workflow.ts +++ b/motomate/src/lib/db/repositories/workflow.ts @@ -5,9 +5,11 @@ import { CreateWorkflowRuleSchema, UpdateWorkflowTriggerSchema } from '../../val import type { InsertWorkflowRule, WorkflowRule, RuleTrigger } from '../schema.js'; import { generateId } from '../../utils/id.js'; import { PRESET_RULES } from '../../workflow/rules.js'; +import { normalizeWorkflowTrigger } from '../../workflow/triggers.js'; export async function createWorkflowRule(userId: string, input: unknown): Promise { const parsed = CreateWorkflowRuleSchema.parse(input); + normalizeWorkflowTrigger(parsed.trigger); const id = generateId(); const row: InsertWorkflowRule = { ...parsed, id, user_id: userId }; await db.insert(workflow_rules).values(row); @@ -46,6 +48,7 @@ export async function updateWorkflowRuleTrigger( trigger: RuleTrigger ): Promise { const { trigger: validatedTrigger } = UpdateWorkflowTriggerSchema.parse({ id, trigger }); + normalizeWorkflowTrigger(validatedTrigger); await db .update(workflow_rules) .set({ trigger: validatedTrigger, updated_at: new Date().toISOString() }) @@ -58,10 +61,14 @@ export async function seedPresetRulesForUser(userId: string): Promise { for (const preset of PRESET_RULES) { const existingRule = existingByName.get(preset.name); if (existingRule) { - // Refresh action template so existing users get updated notification bodies + // Refresh action template and description so existing users get updated workflow copy. await db .update(workflow_rules) - .set({ actions: preset.actions, updated_at: new Date().toISOString() }) + .set({ + description: preset.description, + actions: preset.actions, + updated_at: new Date().toISOString() + }) .where(eq(workflow_rules.id, existingRule.id)); } else { await createWorkflowRule(userId, { diff --git a/motomate/src/lib/i18n/locales/de.json b/motomate/src/lib/i18n/locales/de.json index 7743783..849c36b 100644 --- a/motomate/src/lib/i18n/locales/de.json +++ b/motomate/src/lib/i18n/locales/de.json @@ -771,8 +771,8 @@ "loadPresets": "Voreinstellungen wiederherstellen", "empty": "Noch keine Regeln. Stellen Sie die Voreinstellungen wieder her oder erstellen Sie eigene Regeln.", "presets": { - "maintenanceDueSoonKm": "Wartung bald fällig (km)", - "maintenanceOverdueKm": "Wartung überfällig (km)", + "maintenanceDueSoonKm": "Wartung bald fällig", + "maintenanceOverdueKm": "Wartung überfällig", "maintenanceDueSoonDate": "Wartung bald fällig (Datum)", "maintenanceOverdueDate": "Wartung überfällig (Datum)", "odometerNudge": "Kilometerstand-Erinnerung" @@ -873,6 +873,7 @@ "cooldown": "Wartet bis {date}", "waiting": "Wartet auf nächsten Service-Eintrag", "inKm": "In {km} km", + "inMeasurement": "In {value} {unit}", "onDate": "Ab {date}", "trackerLabel": "({tracker})", "noData": "Keine Daten" @@ -941,11 +942,11 @@ "notifications": { "maintenanceDueSoonKm": { "title": "Wartung bald fällig", - "body": "'{{tracker_name}}' bei {{vehicle_name}} ist in {{km_remaining}} km fällig." + "body": "'{{tracker_name}}' bei {{vehicle_name}} ist in {{measurement_value}} {{measurement_unit}} fällig." }, "maintenanceOverdueKm": { "title": "Wartung überfällig", - "body": "'{{tracker_name}}' bei {{vehicle_name}} ist {{km_over}} km überfällig." + "body": "'{{tracker_name}}' bei {{vehicle_name}} ist {{measurement_value}} {{measurement_unit}} überfällig." }, "maintenanceDueSoonDate": { "title": "Wartung bald fällig", diff --git a/motomate/src/lib/i18n/locales/en.json b/motomate/src/lib/i18n/locales/en.json index 9b709c3..40386e0 100644 --- a/motomate/src/lib/i18n/locales/en.json +++ b/motomate/src/lib/i18n/locales/en.json @@ -781,8 +781,8 @@ "loadPresets": "Restore preset rules", "empty": "No rules yet. Restore the preset rules to get started, or create your own.", "presets": { - "maintenanceDueSoonKm": "Maintenance due soon (km)", - "maintenanceOverdueKm": "Maintenance overdue (km)", + "maintenanceDueSoonKm": "Maintenance due soon", + "maintenanceOverdueKm": "Maintenance overdue", "maintenanceDueSoonDate": "Maintenance due soon (date)", "maintenanceOverdueDate": "Maintenance overdue (date)", "odometerNudge": "Odometer nudge" @@ -883,6 +883,7 @@ "cooldown": "Cooling down until {date}", "waiting": "Waiting for next service log", "inKm": "In {km} km", + "inMeasurement": "In {value} {unit}", "onDate": "From {date}", "trackerLabel": "({tracker})", "noData": "No data" @@ -1005,11 +1006,11 @@ "notifications": { "maintenanceDueSoonKm": { "title": "Maintenance due soon", - "body": "'{{tracker_name}}' on {{vehicle_name}} is due in {{km_remaining}} km." + "body": "'{{tracker_name}}' on {{vehicle_name}} is due in {{measurement_value}} {{measurement_unit}}." }, "maintenanceOverdueKm": { "title": "Maintenance overdue", - "body": "'{{tracker_name}}' on {{vehicle_name}} is {{km_over}} km overdue." + "body": "'{{tracker_name}}' on {{vehicle_name}} is {{measurement_value}} {{measurement_unit}} overdue." }, "maintenanceDueSoonDate": { "title": "Maintenance due soon", diff --git a/motomate/src/lib/i18n/locales/es.json b/motomate/src/lib/i18n/locales/es.json index 3f78dc4..65f7515 100644 --- a/motomate/src/lib/i18n/locales/es.json +++ b/motomate/src/lib/i18n/locales/es.json @@ -770,8 +770,8 @@ "loadPresets": "Restaurar reglas predeterminadas", "empty": "Sin reglas todavía. Restaura las reglas predeterminadas para empezar, o crea las tuyas.", "presets": { - "maintenanceDueSoonKm": "Mantenimiento próximo (km)", - "maintenanceOverdueKm": "Mantenimiento vencido (km)", + "maintenanceDueSoonKm": "Mantenimiento próximo", + "maintenanceOverdueKm": "Mantenimiento vencido", "maintenanceDueSoonDate": "Mantenimiento próximo (fecha)", "maintenanceOverdueDate": "Mantenimiento vencido (fecha)", "odometerNudge": "Recordatorio de odómetro" @@ -872,6 +872,7 @@ "cooldown": "En espera hasta {date}", "waiting": "Esperando el próximo registro de servicio", "inKm": "En {km} km", + "inMeasurement": "En {value} {unit}", "onDate": "A partir del {date}", "trackerLabel": "({tracker})", "noData": "Sin datos" @@ -940,11 +941,11 @@ "notifications": { "maintenanceDueSoonKm": { "title": "Mantenimiento próximo", - "body": "'{{tracker_name}}' en {{vehicle_name}} vence en {{km_remaining}} km." + "body": "'{{tracker_name}}' en {{vehicle_name}} vence en {{measurement_value}} {{measurement_unit}}." }, "maintenanceOverdueKm": { "title": "Mantenimiento vencido", - "body": "'{{tracker_name}}' en {{vehicle_name}} está {{km_over}} km vencido." + "body": "'{{tracker_name}}' en {{vehicle_name}} está {{measurement_value}} {{measurement_unit}} vencido." }, "maintenanceDueSoonDate": { "title": "Mantenimiento próximo", diff --git a/motomate/src/lib/i18n/locales/fr.json b/motomate/src/lib/i18n/locales/fr.json index 0ae1662..4b79783 100644 --- a/motomate/src/lib/i18n/locales/fr.json +++ b/motomate/src/lib/i18n/locales/fr.json @@ -771,8 +771,8 @@ "loadPresets": "Restaurer les règles préréglées", "empty": "Aucune règle pour l'instant. Restaurez les règles préréglées ou créez les vôtres.", "presets": { - "maintenanceDueSoonKm": "Entretien bientôt dû (km)", - "maintenanceOverdueKm": "Entretien en retard (km)", + "maintenanceDueSoonKm": "Entretien bientôt dû", + "maintenanceOverdueKm": "Entretien en retard", "maintenanceDueSoonDate": "Entretien bientôt dû (date)", "maintenanceOverdueDate": "Entretien en retard (date)", "odometerNudge": "Rappel kilométrage" @@ -873,6 +873,7 @@ "cooldown": "En attente jusqu'au {date}", "waiting": "En attente du prochain journal de service", "inKm": "Dans {km} km", + "inMeasurement": "Dans {value} {unit}", "onDate": "À partir du {date}", "trackerLabel": "({tracker})", "noData": "Aucune donnée" @@ -941,11 +942,11 @@ "notifications": { "maintenanceDueSoonKm": { "title": "Entretien bientôt dû", - "body": "'{{tracker_name}}' sur {{vehicle_name}} arrive à échéance dans {{km_remaining}} km." + "body": "'{{tracker_name}}' sur {{vehicle_name}} arrive à échéance dans {{measurement_value}} {{measurement_unit}}." }, "maintenanceOverdueKm": { "title": "Entretien en retard", - "body": "'{{tracker_name}}' sur {{vehicle_name}} est en retard de {{km_over}} km." + "body": "'{{tracker_name}}' sur {{vehicle_name}} est en retard de {{measurement_value}} {{measurement_unit}}." }, "maintenanceDueSoonDate": { "title": "Entretien bientôt dû", diff --git a/motomate/src/lib/i18n/locales/it.json b/motomate/src/lib/i18n/locales/it.json index 2893867..4aa233d 100644 --- a/motomate/src/lib/i18n/locales/it.json +++ b/motomate/src/lib/i18n/locales/it.json @@ -770,8 +770,8 @@ "loadPresets": "Ripristina regole predefinite", "empty": "Nessuna regola ancora. Ripristina le regole predefinite per iniziare, o creane di tue.", "presets": { - "maintenanceDueSoonKm": "Manutenzione imminente (km)", - "maintenanceOverdueKm": "Manutenzione scaduta (km)", + "maintenanceDueSoonKm": "Manutenzione imminente", + "maintenanceOverdueKm": "Manutenzione scaduta", "maintenanceDueSoonDate": "Manutenzione imminente (data)", "maintenanceOverdueDate": "Manutenzione scaduta (data)", "odometerNudge": "Promemoria chilometri" @@ -872,6 +872,7 @@ "cooldown": "In attesa fino al {date}", "waiting": "In attesa del prossimo registro di servizio", "inKm": "Tra {km} km", + "inMeasurement": "Tra {value} {unit}", "onDate": "Dal {date}", "trackerLabel": "({tracker})", "noData": "Nessun dato" @@ -940,11 +941,11 @@ "notifications": { "maintenanceDueSoonKm": { "title": "Manutenzione in scadenza", - "body": "'{{tracker_name}}' su {{vehicle_name}} è in scadenza tra {{km_remaining}} km." + "body": "'{{tracker_name}}' su {{vehicle_name}} è in scadenza tra {{measurement_value}} {{measurement_unit}}." }, "maintenanceOverdueKm": { "title": "Manutenzione scaduta", - "body": "'{{tracker_name}}' su {{vehicle_name}} è scaduta di {{km_over}} km." + "body": "'{{tracker_name}}' su {{vehicle_name}} è scaduta di {{measurement_value}} {{measurement_unit}}." }, "maintenanceDueSoonDate": { "title": "Manutenzione in scadenza", diff --git a/motomate/src/lib/i18n/locales/nl.json b/motomate/src/lib/i18n/locales/nl.json index cf576d8..c3da30d 100644 --- a/motomate/src/lib/i18n/locales/nl.json +++ b/motomate/src/lib/i18n/locales/nl.json @@ -771,8 +771,8 @@ "loadPresets": "Standaardregels herstellen", "empty": "Nog geen regels. Herstel de standaardregels om te beginnen, of maak je eigen.", "presets": { - "maintenanceDueSoonKm": "Onderhoud binnenkort (km)", - "maintenanceOverdueKm": "Onderhoud achterstallig (km)", + "maintenanceDueSoonKm": "Onderhoud binnenkort", + "maintenanceOverdueKm": "Onderhoud achterstallig", "maintenanceDueSoonDate": "Onderhoud binnenkort (datum)", "maintenanceOverdueDate": "Onderhoud achterstallig (datum)", "odometerNudge": "Kilometerstand herinnering" @@ -873,6 +873,7 @@ "cooldown": "Wacht tot {date}", "waiting": "Wacht op volgend service-log", "inKm": "Over {km} km", + "inMeasurement": "Over {value} {unit}", "onDate": "Vanaf {date}", "trackerLabel": "({tracker})", "noData": "Geen gegevens" @@ -941,11 +942,11 @@ "notifications": { "maintenanceDueSoonKm": { "title": "Onderhoud binnenkort vereist", - "body": "'{{tracker_name}}' op {{vehicle_name}} is over {{km_remaining}} km vereist." + "body": "'{{tracker_name}}' op {{vehicle_name}} is over {{measurement_value}} {{measurement_unit}} vereist." }, "maintenanceOverdueKm": { "title": "Onderhoud achterstallig", - "body": "'{{tracker_name}}' op {{vehicle_name}} is {{km_over}} km achterstallig." + "body": "'{{tracker_name}}' op {{vehicle_name}} is {{measurement_value}} {{measurement_unit}} achterstallig." }, "maintenanceDueSoonDate": { "title": "Onderhoud binnenkort vereist", diff --git a/motomate/src/lib/i18n/locales/pt.json b/motomate/src/lib/i18n/locales/pt.json index e0da14e..d836eb7 100644 --- a/motomate/src/lib/i18n/locales/pt.json +++ b/motomate/src/lib/i18n/locales/pt.json @@ -770,8 +770,8 @@ "loadPresets": "Restaurar regras predefinidas", "empty": "Ainda sem regras. Restaure as regras predefinidas para começar, ou crie as suas próprias.", "presets": { - "maintenanceDueSoonKm": "Manutenção em breve (km)", - "maintenanceOverdueKm": "Manutenção em atraso (km)", + "maintenanceDueSoonKm": "Manutenção em breve", + "maintenanceOverdueKm": "Manutenção em atraso", "maintenanceDueSoonDate": "Manutenção em breve (data)", "maintenanceOverdueDate": "Manutenção em atraso (data)", "odometerNudge": "Lembrete do odómetro" @@ -872,6 +872,7 @@ "cooldown": "A aguardar até {date}", "waiting": "A aguardar o próximo registo de serviço", "inKm": "Em {km} km", + "inMeasurement": "Em {value} {unit}", "onDate": "A partir de {date}", "trackerLabel": "({tracker})", "noData": "Sem dados" @@ -940,11 +941,11 @@ "notifications": { "maintenanceDueSoonKm": { "title": "Manutenção em breve", - "body": "'{{tracker_name}}' em {{vehicle_name}} vence em {{km_remaining}} km." + "body": "'{{tracker_name}}' em {{vehicle_name}} vence em {{measurement_value}} {{measurement_unit}}." }, "maintenanceOverdueKm": { "title": "Manutenção em atraso", - "body": "'{{tracker_name}}' em {{vehicle_name}} está {{km_over}} km em atraso." + "body": "'{{tracker_name}}' em {{vehicle_name}} está {{measurement_value}} {{measurement_unit}} em atraso." }, "maintenanceDueSoonDate": { "title": "Manutenção em breve", diff --git a/motomate/src/lib/workflow/engine.ts b/motomate/src/lib/workflow/engine.ts index f772249..43e52d5 100644 --- a/motomate/src/lib/workflow/engine.ts +++ b/motomate/src/lib/workflow/engine.ts @@ -5,6 +5,12 @@ import { renderTemplate } from './rules.js'; import { serverT } from '$lib/i18n/server.js'; import { recomputeTrackerStatuses } from '$lib/db/repositories/maintenance.js'; import type { RuleTrigger, RuleNotification, Vehicle } from '$lib/db/schema.js'; +import { + buildMaintenanceNotificationVars, + evaluateDateTrigger, + evaluateMaintenanceTrigger, + normalizeWorkflowTrigger +} from './triggers.js'; // Each fired result carries the template vars and, for tracker-based triggers, // the tracker id + its current state (so the main loop can do per-tracker cooldown @@ -109,17 +115,20 @@ async function evalTrigger( _userId: string ): Promise { const today = new Date(); + const normalizedTrigger = normalizeWorkflowTrigger(trigger); - switch (trigger.type) { + switch (normalizedTrigger.kind) { case 'no_odometer_update': { - const cutoff = new Date(today.getTime() - trigger.days * 86400000).toISOString(); + const cutoff = new Date(today.getTime() - normalizedTrigger.days * 86400000).toISOString(); const stale = (vehicle.updated_at ?? vehicle.created_at) < cutoff; if (!stale) return []; - return [{ vars: { vehicle_name: vehicle.name, days: trigger.days } }]; + return [{ vars: { vehicle_name: vehicle.name, days: normalizedTrigger.days } }]; } case 'calendar_date': { - const fired = today.getMonth() + 1 === trigger.month && today.getDate() === trigger.day; + const fired = + today.getMonth() + 1 === normalizedTrigger.month && + today.getDate() === normalizedTrigger.day; if (!fired) return []; return [{ vars: { vehicle_name: vehicle.name } }]; } @@ -134,7 +143,7 @@ async function evalTrigger( const daysUntil = Math.ceil( (new Date(doc.expires_at).getTime() - today.getTime()) / 86400000 ); - if (daysUntil >= 0 && daysUntil <= trigger.days_before) { + if (daysUntil >= 0 && daysUntil <= normalizedTrigger.daysBefore) { results.push({ vars: { vehicle_name: vehicle.name, @@ -148,10 +157,8 @@ async function evalTrigger( return results; } - case 'odometer_upcoming': - case 'odometer_overdue': - case 'date_upcoming': - case 'date_overdue': { + case 'maintenance': + case 'date': { const trackers = await db.query.active_trackers.findMany({ where: eq(active_trackers.vehicle_id, vehicle.id), with: { template: true } @@ -167,72 +174,36 @@ async function evalTrigger( trackerStatus: tracker.status }; - // Use tracker.status as the single source of truth, same field the UI uses. This prevents boundary collisions and keeps notification logic - // consistent with what the user sees on the maintenance page. - - if ( - trigger.type === 'odometer_upcoming' && - tracker.status === 'due' && - tracker.next_due_odometer !== null - ) { - const km_remaining = Math.max(0, tracker.next_due_odometer - vehicle.current_odometer); - results.push({ - ...base, - vars: { vehicle_name: vehicle.name, km_remaining, tracker_id: tracker.id, tracker_name } - }); - } - - if ( - trigger.type === 'odometer_overdue' && - tracker.status === 'overdue' && - tracker.next_due_odometer !== null - ) { - const km_over = Math.max(0, vehicle.current_odometer - tracker.next_due_odometer); - results.push({ - ...base, - vars: { vehicle_name: vehicle.name, km_over, tracker_id: tracker.id, tracker_name } - }); - } - - if ( - trigger.type === 'date_upcoming' && - tracker.status === 'due' && - tracker.next_due_at !== null - ) { - const daysLeft = Math.max( - 0, - Math.ceil((new Date(tracker.next_due_at).getTime() - today.getTime()) / 86400000) + if (normalizedTrigger.kind === 'maintenance') { + const evaluation = evaluateMaintenanceTrigger( + normalizedTrigger, + vehicle, + tracker, + tracker.template ); + if (!evaluation?.matches) continue; results.push({ ...base, - vars: { - vehicle_name: vehicle.name, - days_remaining: daysLeft, - due_date: tracker.next_due_at, - tracker_name - } + vars: buildMaintenanceNotificationVars(vehicle.name, evaluation, tracker.id) }); + continue; } - if ( - trigger.type === 'date_overdue' && - tracker.status === 'overdue' && - tracker.next_due_at !== null - ) { - const days_over = Math.max( - 0, - Math.ceil((today.getTime() - new Date(tracker.next_due_at).getTime()) / 86400000) - ); - results.push({ - ...base, - vars: { - vehicle_name: vehicle.name, - days_over, - due_date: tracker.next_due_at, - tracker_name - } - }); - } + if (tracker.next_due_at === null) continue; + const evaluation = evaluateDateTrigger(normalizedTrigger, tracker.next_due_at, today); + if (!evaluation.matches) continue; + results.push({ + ...base, + vars: { + vehicle_name: vehicle.name, + tracker_name, + due_date: tracker.next_due_at, + measurement_basis: 'date', + measurement_relation: evaluation.relation, + days_remaining: evaluation.daysUntilDue, + days_over: evaluation.daysOverdue + } + }); } return results; diff --git a/motomate/src/lib/workflow/rules.ts b/motomate/src/lib/workflow/rules.ts index ce5bcdf..6eedfe0 100644 --- a/motomate/src/lib/workflow/rules.ts +++ b/motomate/src/lib/workflow/rules.ts @@ -10,7 +10,7 @@ export interface PresetRule { export const PRESET_RULES: PresetRule[] = [ { name: 'settings.workflows.presets.maintenanceDueSoonKm', - description: 'Notify 500 km before any tracker reaches its odometer threshold', + description: 'Notify 500 distance units before any tracker reaches its maintenance threshold', trigger: { type: 'odometer_upcoming', km_before: 500 }, actions: { title: 'notifications.maintenanceDueSoonKm.title', @@ -19,7 +19,7 @@ export const PRESET_RULES: PresetRule[] = [ }, { name: 'settings.workflows.presets.maintenanceOverdueKm', - description: 'Notify when any tracker is past its odometer threshold', + description: 'Notify when any tracker is past its maintenance threshold', trigger: { type: 'odometer_overdue', km_past: 0 }, actions: { title: 'notifications.maintenanceOverdueKm.title', diff --git a/motomate/src/lib/workflow/triggers.ts b/motomate/src/lib/workflow/triggers.ts new file mode 100644 index 0000000..4ad757a --- /dev/null +++ b/motomate/src/lib/workflow/triggers.ts @@ -0,0 +1,410 @@ +import type { ActiveTracker, RuleTrigger, TaskTemplate, Vehicle } from '$lib/db/schema.js'; +import { + DEFAULT_ODOMETER_UNIT, + areMeasurementsComparable, + compareMeasurements, + getMeasurementBasis, + isDistanceUnit, + resolveMeasurementValue, + type DistanceUnit, + type MeasurementBasis, + type MeasurementUnit, + type MeasurementValue +} from '$lib/utils/measurement.js'; + +export type WorkflowMaintenanceTrigger = { + kind: 'maintenance'; + phase: 'upcoming' | 'overdue'; + basis: 'distance'; + threshold: number; + legacyType: 'odometer_upcoming' | 'odometer_overdue'; +}; + +export type NormalizedWorkflowTrigger = + | WorkflowMaintenanceTrigger + | { + kind: 'date'; + phase: 'upcoming' | 'overdue'; + threshold: number; + legacyType: 'date_upcoming' | 'date_overdue'; + } + | { + kind: 'calendar_date'; + month: number; + day: number; + legacyType: 'calendar_date'; + } + | { + kind: 'no_odometer_update'; + days: number; + legacyType: 'no_odometer_update'; + } + | { + kind: 'document_expiring'; + daysBefore: number; + legacyType: 'document_expiring'; + }; + +type VehicleMeasurementSource = Pick< + Vehicle, + 'current_measurement' | 'current_measurement_unit' | 'current_odometer' | 'odometer_unit' +>; + +type TrackerMeasurementSource = Pick< + ActiveTracker, + 'next_due_measurement' | 'next_due_odometer' | 'measurement_unit' +>; + +type TrackerTemplateSource = Pick; + +export type ComparableTrackerMeasurement = { + basis: MeasurementBasis; + unit: MeasurementUnit; + currentMeasurement: MeasurementValue; + dueMeasurement: MeasurementValue; + delta: number; + absoluteDelta: number; + usedLegacyFallback: boolean; +}; + +export type MaintenanceTriggerEvaluation = { + trigger: WorkflowMaintenanceTrigger; + measurement: ComparableTrackerMeasurement; + trackerName: string; + matches: boolean; + readyAtCurrentMeasurement: boolean; + measurementUntilFire: number; + measurementValue: number; + legacyDistanceValue: number; + legacyAliasKey: 'km_remaining' | 'km_over'; + legacyAliasValue: number; + measurementRelation: 'remaining' | 'overdue'; +}; + +export type DateTrigger = Extract; + +export type DateTriggerEvaluation = { + trigger: DateTrigger; + matches: boolean; + fireAt: string; + daysValue: number; + daysUntilFire: number; + daysUntilDue: number; + daysOverdue: number; + relation: 'remaining' | 'overdue'; +}; + +type MeasurementPairCandidate = { + measurement: MeasurementValue; + usedLegacyFallback: boolean; +}; + +function resolveLegacyDistanceMeasurement( + value: number | null | undefined, + unit: MeasurementUnit | null | undefined +): MeasurementValue | null { + if (value == null) return null; + const distanceUnit: DistanceUnit = isDistanceUnit(unit) ? unit : DEFAULT_ODOMETER_UNIT; + return resolveMeasurementValue(value, distanceUnit); +} + +function getVehicleMeasurementCandidates( + vehicle: VehicleMeasurementSource +): MeasurementPairCandidate[] { + const candidates: MeasurementPairCandidate[] = []; + const canonical = resolveMeasurementValue( + vehicle.current_measurement, + vehicle.current_measurement_unit + ); + if (canonical) { + candidates.push({ measurement: canonical, usedLegacyFallback: false }); + } + + const legacy = resolveLegacyDistanceMeasurement(vehicle.current_odometer, vehicle.odometer_unit); + if ( + legacy && + !candidates.some(({ measurement }) => compareMeasurements(measurement, legacy) === 0) + ) { + candidates.push({ measurement: legacy, usedLegacyFallback: true }); + } + + return candidates; +} + +function getTrackerMeasurementCandidates( + tracker: TrackerMeasurementSource +): MeasurementPairCandidate[] { + const candidates: MeasurementPairCandidate[] = []; + const canonical = resolveMeasurementValue(tracker.next_due_measurement, tracker.measurement_unit); + if (canonical) { + candidates.push({ measurement: canonical, usedLegacyFallback: false }); + } + + const legacy = resolveLegacyDistanceMeasurement( + tracker.next_due_odometer, + tracker.measurement_unit + ); + if ( + legacy && + !candidates.some(({ measurement }) => compareMeasurements(measurement, legacy) === 0) + ) { + candidates.push({ measurement: legacy, usedLegacyFallback: true }); + } + + return candidates; +} + +export function normalizeWorkflowTrigger(trigger: RuleTrigger): NormalizedWorkflowTrigger { + switch (trigger.type) { + case 'odometer_upcoming': + return { + kind: 'maintenance', + phase: 'upcoming', + basis: 'distance', + threshold: trigger.km_before, + legacyType: trigger.type + }; + case 'odometer_overdue': + return { + kind: 'maintenance', + phase: 'overdue', + basis: 'distance', + threshold: trigger.km_past, + legacyType: trigger.type + }; + case 'date_upcoming': + return { + kind: 'date', + phase: 'upcoming', + threshold: trigger.days_before, + legacyType: trigger.type + }; + case 'date_overdue': + return { + kind: 'date', + phase: 'overdue', + threshold: trigger.days_past, + legacyType: trigger.type + }; + case 'calendar_date': + return { + kind: 'calendar_date', + month: trigger.month, + day: trigger.day, + legacyType: trigger.type + }; + case 'no_odometer_update': + return { + kind: 'no_odometer_update', + days: trigger.days, + legacyType: trigger.type + }; + case 'document_expiring': + return { + kind: 'document_expiring', + daysBefore: trigger.days_before, + legacyType: trigger.type + }; + } +} + +export function getComparableTrackerMeasurement( + vehicle: VehicleMeasurementSource, + tracker: TrackerMeasurementSource, + requiredBasis?: MeasurementBasis +): ComparableTrackerMeasurement | null { + const vehicleCandidates = getVehicleMeasurementCandidates(vehicle); + const trackerCandidates = getTrackerMeasurementCandidates(tracker); + + for (const vehicleCandidate of vehicleCandidates) { + for (const trackerCandidate of trackerCandidates) { + if (!areMeasurementsComparable(vehicleCandidate.measurement, trackerCandidate.measurement)) { + continue; + } + + if (requiredBasis && vehicleCandidate.measurement.basis !== requiredBasis) { + continue; + } + + const delta = + compareMeasurements(vehicleCandidate.measurement, trackerCandidate.measurement) ?? 0; + + return { + basis: vehicleCandidate.measurement.basis, + unit: vehicleCandidate.measurement.unit, + currentMeasurement: vehicleCandidate.measurement, + dueMeasurement: trackerCandidate.measurement, + delta, + absoluteDelta: Math.abs(delta), + usedLegacyFallback: + vehicleCandidate.usedLegacyFallback || trackerCandidate.usedLegacyFallback + }; + } + } + + return null; +} + +export function getTrackerMeasurementUnitLabel(unit: MeasurementUnit): string { + return unit; +} + +export function getLegacyDistanceAliasValue( + measurement: ComparableTrackerMeasurement +): number | null { + return measurement.basis === 'distance' ? measurement.absoluteDelta : null; +} + +export function getDistanceTriggerPreviewWindowKm( + trigger: WorkflowMaintenanceTrigger, + trackerTemplate?: TrackerTemplateSource | null +): number { + if (trigger.phase === 'upcoming') { + return trigger.threshold; + } + + return trigger.threshold === 0 + ? 0 + : Math.max(trigger.threshold, trackerTemplate?.interval_km ?? trigger.threshold); +} + +function startOfUtcDay(input: Date | string): Date { + const date = typeof input === 'string' ? new Date(`${input}T00:00:00Z`) : input; + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function addDays(date: Date, days: number): Date { + return new Date(date.getTime() + days * 86400000); +} + +export function evaluateDateTrigger( + normalizedTrigger: DateTrigger, + dueAt: string, + now: Date = new Date() +): DateTriggerEvaluation { + const today = startOfUtcDay(now); + const dueDate = startOfUtcDay(dueAt); + const daysUntilDue = Math.ceil((dueDate.getTime() - today.getTime()) / 86400000); + const daysOverdue = Math.max(0, Math.floor((today.getTime() - dueDate.getTime()) / 86400000)); + + if (normalizedTrigger.phase === 'upcoming') { + const fireAt = addDays(dueDate, -normalizedTrigger.threshold).toISOString(); + const matches = today.getTime() >= startOfUtcDay(fireAt).getTime() && daysUntilDue >= 0; + return { + trigger: normalizedTrigger, + matches, + fireAt, + daysValue: Math.max(0, daysUntilDue), + daysUntilFire: Math.max( + 0, + Math.ceil((startOfUtcDay(fireAt).getTime() - today.getTime()) / 86400000) + ), + daysUntilDue: Math.max(0, daysUntilDue), + daysOverdue, + relation: 'remaining' + }; + } + + const overdueThreshold = normalizedTrigger.threshold === 0 ? 1 : normalizedTrigger.threshold; + const fireAt = addDays(dueDate, overdueThreshold).toISOString(); + const matches = today.getTime() >= startOfUtcDay(fireAt).getTime(); + return { + trigger: normalizedTrigger, + matches, + fireAt, + daysValue: daysOverdue, + daysUntilFire: Math.max( + 0, + Math.ceil((startOfUtcDay(fireAt).getTime() - today.getTime()) / 86400000) + ), + daysUntilDue: Math.max(0, daysUntilDue), + daysOverdue, + relation: 'overdue' + }; +} + +export function evaluateMaintenanceTrigger( + normalizedTrigger: WorkflowMaintenanceTrigger, + vehicle: VehicleMeasurementSource, + tracker: TrackerMeasurementSource, + trackerTemplate?: TrackerTemplateSource | null +): MaintenanceTriggerEvaluation | null { + const measurement = getComparableTrackerMeasurement(vehicle, tracker, normalizedTrigger.basis); + if (!measurement) { + return null; + } + + const trackerName = trackerTemplate?.name ?? ''; + const threshold = normalizedTrigger.threshold; + const legacyDistanceValue = getLegacyDistanceAliasValue(measurement) ?? 0; + + if (normalizedTrigger.phase === 'upcoming') { + const matches = measurement.delta >= -threshold && measurement.delta <= 0; + const measurementUntilFire = Math.max(0, -threshold - measurement.delta); + const measurementValue = Math.max(0, -measurement.delta); + return { + trigger: normalizedTrigger, + measurement, + trackerName, + matches, + readyAtCurrentMeasurement: matches, + measurementUntilFire, + measurementValue, + legacyDistanceValue, + legacyAliasKey: 'km_remaining', + legacyAliasValue: legacyDistanceValue, + measurementRelation: 'remaining' + }; + } + + const overdueThreshold = threshold === 0 ? 1 : threshold; + const matches = measurement.delta >= overdueThreshold; + const measurementUntilFire = Math.max(0, overdueThreshold - measurement.delta); + const measurementValue = Math.max(0, measurement.delta); + + return { + trigger: normalizedTrigger, + measurement, + trackerName, + matches, + readyAtCurrentMeasurement: matches, + measurementUntilFire, + measurementValue, + legacyDistanceValue, + legacyAliasKey: 'km_over', + legacyAliasValue: legacyDistanceValue, + measurementRelation: 'overdue' + }; +} + +export function buildMaintenanceNotificationVars( + vehicleName: string, + evaluation: MaintenanceTriggerEvaluation, + trackerId?: string +): Record { + const vars: Record = { + vehicle_name: vehicleName, + tracker_name: evaluation.trackerName, + measurement_value: evaluation.measurementValue, + measurement_unit: getTrackerMeasurementUnitLabel(evaluation.measurement.unit), + measurement_basis: evaluation.measurement.basis, + measurement_delta: evaluation.measurement.absoluteDelta, + measurement_relation: evaluation.measurementRelation, + current_measurement: evaluation.measurement.currentMeasurement.value, + due_measurement: evaluation.measurement.dueMeasurement.value + }; + + if (trackerId) { + vars.tracker_id = trackerId; + } + + if (evaluation.measurement.basis === 'distance') { + vars[evaluation.legacyAliasKey] = evaluation.legacyAliasValue; + } + + return vars; +} + +export function getMeasurementBasisFromUnit(unit: MeasurementUnit): MeasurementBasis { + return getMeasurementBasis(unit); +} diff --git a/motomate/src/routes/(app)/settings/workflows/+page.server.ts b/motomate/src/routes/(app)/settings/workflows/+page.server.ts index cd46874..03b0299 100644 --- a/motomate/src/routes/(app)/settings/workflows/+page.server.ts +++ b/motomate/src/routes/(app)/settings/workflows/+page.server.ts @@ -14,25 +14,20 @@ import { RuleTriggerSchema } from '$lib/validators/schemas.js'; import type { RuleTrigger } from '$lib/db/schema.js'; import { db } from '$lib/db/index.js'; import { documents } from '$lib/db/schema.js'; +import { + evaluateDateTrigger, + evaluateMaintenanceTrigger, + normalizeWorkflowTrigger +} from '$lib/workflow/triggers.js'; export type NextFireInfo = | { kind: 'ready' } | { kind: 'cooldown'; until: string } | { kind: 'waiting' } - | { kind: 'km'; kmRemaining: number; trackerName: string } + | { kind: 'measurement'; remaining: number; unit: string; trackerName: string } | { kind: 'date'; fireAt: string; trackerName?: string } | { kind: 'none' }; -const DUE_SOON_FACTOR = 0.2; - -function kmWindow(intervalKm: number): number { - return Math.min(500, Math.max(50, Math.round(intervalKm * DUE_SOON_FACTOR))); -} - -function dayWindow(intervalMonths: number): number { - return Math.min(14, Math.max(3, Math.round(intervalMonths * 30 * DUE_SOON_FACTOR))); -} - function toUtcDate(sqliteStr: string): Date { const iso = sqliteStr.includes('T') ? sqliteStr : sqliteStr.replace(' ', 'T'); return new Date(iso.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso + 'Z'); @@ -63,7 +58,7 @@ async function computeNextFireInfo( vehicles: Awaited>, trackersByVehicle: Map>> ): Promise { - const trigger = rule.trigger; + const normalizedTrigger = normalizeWorkflowTrigger(rule.trigger); const now = Date.now(); const scopedVehicles = rule.vehicle_id @@ -72,13 +67,12 @@ async function computeNextFireInfo( if (scopedVehicles.length === 0) return { kind: 'none' }; - switch (trigger.type) { - case 'odometer_upcoming': - case 'odometer_overdue': - case 'date_upcoming': - case 'date_overdue': { - let bestKmRemaining = Infinity; - let bestKmTrackerName = ''; + switch (normalizedTrigger.kind) { + case 'maintenance': + case 'date': { + let bestMeasurementRemaining = Infinity; + let bestMeasurementUnit = 'km'; + let bestMeasurementTrackerName = ''; let bestDateFireAt = ''; let bestDateTrackerName = ''; let hasReady = false; @@ -99,37 +93,31 @@ async function computeNextFireInfo( const trackerName = tracker.template?.name ?? ''; - if (trigger.type === 'odometer_upcoming' || trigger.type === 'odometer_overdue') { - if (tracker.next_due_odometer === null) continue; - const threshold = - trigger.type === 'odometer_upcoming' - ? tracker.next_due_odometer - kmWindow(tracker.template?.interval_km ?? 500) - : tracker.next_due_odometer; + if (normalizedTrigger.kind === 'maintenance') { + const evaluation = evaluateMaintenanceTrigger( + normalizedTrigger, + vehicle, + tracker, + tracker.template + ); + if (!evaluation) continue; - if (vehicle.current_odometer >= threshold) { + if (evaluation.readyAtCurrentMeasurement) { hasReady = true; - } else { - const remaining = threshold - vehicle.current_odometer; - if (remaining < bestKmRemaining) { - bestKmRemaining = remaining; - bestKmTrackerName = trackerName; - } + } else if (evaluation.measurementUntilFire < bestMeasurementRemaining) { + bestMeasurementRemaining = evaluation.measurementUntilFire; + bestMeasurementUnit = evaluation.measurement.unit; + bestMeasurementTrackerName = trackerName; } } else { if (tracker.next_due_at === null) continue; - const dueDate = new Date(tracker.next_due_at); - const fireDate = - trigger.type === 'date_upcoming' - ? new Date( - dueDate.getTime() - dayWindow(tracker.template?.interval_months ?? 1) * 86400000 - ) - : dueDate; + const evaluation = evaluateDateTrigger(normalizedTrigger, tracker.next_due_at); - if (now >= fireDate.getTime()) { + if (evaluation.matches) { hasReady = true; } else { - if (!bestDateFireAt || fireDate.toISOString() < bestDateFireAt) { - bestDateFireAt = fireDate.toISOString(); + if (!bestDateFireAt || evaluation.fireAt < bestDateFireAt) { + bestDateFireAt = evaluation.fireAt; bestDateTrackerName = trackerName; } } @@ -141,9 +129,14 @@ async function computeNextFireInfo( if (hasReady) return { kind: 'ready' }; if (allWaiting) return { kind: 'waiting' }; - if (trigger.type === 'odometer_upcoming' || trigger.type === 'odometer_overdue') { - if (bestKmRemaining < Infinity) - return { kind: 'km', kmRemaining: bestKmRemaining, trackerName: bestKmTrackerName }; + if (normalizedTrigger.kind === 'maintenance') { + if (bestMeasurementRemaining < Infinity) + return { + kind: 'measurement', + remaining: bestMeasurementRemaining, + unit: bestMeasurementUnit, + trackerName: bestMeasurementTrackerName + }; return { kind: 'waiting' }; } else { if (bestDateFireAt) @@ -156,7 +149,7 @@ async function computeNextFireInfo( let soonestFire: Date | null = null; for (const vehicle of scopedVehicles) { const lastUpdated = toUtcDate(vehicle.updated_at ?? vehicle.created_at); - const fireTime = new Date(lastUpdated.getTime() + trigger.days * 86400000); + const fireTime = new Date(lastUpdated.getTime() + normalizedTrigger.days * 86400000); if (!soonestFire || fireTime < soonestFire) soonestFire = fireTime; } if (!soonestFire) return { kind: 'none' }; @@ -171,11 +164,11 @@ async function computeNextFireInfo( } case 'calendar_date': { - const next = nextCalendarOccurrence(trigger.month, trigger.day); + const next = nextCalendarOccurrence(normalizedTrigger.month, normalizedTrigger.day); const today = new Date(); const isToday = - next.getMonth() + 1 === trigger.month && - next.getDate() === trigger.day && + next.getMonth() + 1 === normalizedTrigger.month && + next.getDate() === normalizedTrigger.day && next.getFullYear() === today.getFullYear(); if (isToday) { @@ -197,7 +190,7 @@ async function computeNextFireInfo( for (const doc of docs) { if (!doc.expires_at) continue; const fireTime = new Date( - new Date(doc.expires_at).getTime() - trigger.days_before * 86400000 + new Date(doc.expires_at).getTime() - normalizedTrigger.daysBefore * 86400000 ); if (!soonestFire || fireTime < soonestFire) soonestFire = fireTime; } diff --git a/motomate/src/routes/(app)/settings/workflows/+page.svelte b/motomate/src/routes/(app)/settings/workflows/+page.svelte index ffda2a7..03864c7 100644 --- a/motomate/src/routes/(app)/settings/workflows/+page.svelte +++ b/motomate/src/routes/(app)/settings/workflows/+page.svelte @@ -84,9 +84,9 @@ }); case 'waiting': return $_('settings.notifications.scheduledRules.waiting'); - case 'km': { - const base = $_('settings.notifications.scheduledRules.inKm', { - values: { km: info.kmRemaining } + case 'measurement': { + const base = $_('settings.notifications.scheduledRules.inMeasurement', { + values: { value: info.remaining, unit: info.unit } }); return info.trackerName ? `${base} ${$_('settings.notifications.scheduledRules.trackerLabel', { values: { tracker: info.trackerName } })}` From f281a91d459f2e7473c6a7c30d0d113c233e24b4 Mon Sep 17 00:00:00 2001 From: Michael Varrieur Date: Mon, 27 Apr 2026 08:48:39 -0400 Subject: [PATCH 2/2] Next pass --- motomate/src/lib/workflow/preview-core.ts | 16 ++ motomate/src/lib/workflow/preview.ts | 184 ++++++++++++++++ .../(app)/settings/workflows/+page.server.ts | 206 +----------------- .../(app)/settings/workflows/+page.svelte | 2 +- .../tests/workflow-settings-preview.test.ts | 28 +++ motomate/src/tests/workflow-triggers.test.ts | 124 +++++++++++ motomate/vitest.config.ts | 6 + 7 files changed, 362 insertions(+), 204 deletions(-) create mode 100644 motomate/src/lib/workflow/preview-core.ts create mode 100644 motomate/src/lib/workflow/preview.ts create mode 100644 motomate/src/tests/workflow-settings-preview.test.ts create mode 100644 motomate/src/tests/workflow-triggers.test.ts diff --git a/motomate/src/lib/workflow/preview-core.ts b/motomate/src/lib/workflow/preview-core.ts new file mode 100644 index 0000000..f36de85 --- /dev/null +++ b/motomate/src/lib/workflow/preview-core.ts @@ -0,0 +1,16 @@ +export type NextFireInfo = + | { kind: 'ready' } + | { kind: 'cooldown'; until: string } + | { kind: 'waiting' } + | { kind: 'measurement'; remaining: number; unit: string; trackerName: string } + | { kind: 'date'; fireAt: string; trackerName?: string } + | { kind: 'none' }; + +export function nextCalendarOccurrence(month: number, day: number): Date { + const now = new Date(); + const year = now.getFullYear(); + const candidate = new Date(year, month - 1, day); + const startOfToday = new Date(year, now.getMonth(), now.getDate()); + if (candidate < startOfToday) candidate.setFullYear(year + 1); + return candidate; +} diff --git a/motomate/src/lib/workflow/preview.ts b/motomate/src/lib/workflow/preview.ts new file mode 100644 index 0000000..b4af011 --- /dev/null +++ b/motomate/src/lib/workflow/preview.ts @@ -0,0 +1,184 @@ +import { and, eq } from 'drizzle-orm'; +import type { RuleTrigger } from '$lib/db/schema.js'; +import { db } from '$lib/db/index.js'; +import { documents } from '$lib/db/schema.js'; +import { getVehiclesByUser } from '$lib/db/repositories/vehicles.js'; +import { recomputeTrackerStatuses } from '$lib/db/repositories/maintenance.js'; +import { type NextFireInfo, nextCalendarOccurrence } from '$lib/workflow/preview-core.js'; +import { + evaluateDateTrigger, + evaluateMaintenanceTrigger, + normalizeWorkflowTrigger +} from '$lib/workflow/triggers.js'; + +function toUtcDate(sqliteStr: string): Date { + const iso = sqliteStr.includes('T') ? sqliteStr : sqliteStr.replace(' ', 'T'); + return new Date(iso.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso + 'Z'); +} + +function cooldownInfo(lastTriggeredAt: string, now: number): NextFireInfo | null { + const fired = toUtcDate(lastTriggeredAt).getTime(); + const cooldownEnd = fired + 23 * 3600000; + if (now < cooldownEnd) return { kind: 'cooldown', until: new Date(cooldownEnd).toISOString() }; + return null; +} + +export async function computeNextFireInfo( + rule: { + id: string; + vehicle_id: string | null; + trigger: RuleTrigger; + last_triggered_at: string | null; + }, + vehicles: Awaited>, + trackersByVehicle: Map>> +): Promise { + const normalizedTrigger = normalizeWorkflowTrigger(rule.trigger); + const now = Date.now(); + + const scopedVehicles = rule.vehicle_id + ? vehicles.filter((v) => v.id === rule.vehicle_id) + : vehicles; + + if (scopedVehicles.length === 0) return { kind: 'none' }; + + switch (normalizedTrigger.kind) { + case 'maintenance': + case 'date': { + let bestMeasurementRemaining = Infinity; + let bestMeasurementUnit = 'km'; + let bestMeasurementTrackerName = ''; + let bestDateFireAt = ''; + let bestDateTrackerName = ''; + let hasReady = false; + let allWaiting = true; + let hasAnyTracker = false; + + for (const vehicle of scopedVehicles) { + const trackers = trackersByVehicle.get(vehicle.id) ?? []; + + for (const tracker of trackers) { + hasAnyTracker = true; + const notifiedBy = ((tracker.state as Record)?.notified_by ?? + {}) as Record; + const alreadyFired = Object.keys(notifiedBy).length > 0; + + if (!alreadyFired) allWaiting = false; + if (alreadyFired) continue; + + const trackerName = tracker.template?.name ?? ''; + + if (normalizedTrigger.kind === 'maintenance') { + const evaluation = evaluateMaintenanceTrigger( + normalizedTrigger, + vehicle, + tracker, + tracker.template + ); + if (!evaluation) continue; + + if (evaluation.readyAtCurrentMeasurement) { + hasReady = true; + } else if (evaluation.measurementUntilFire < bestMeasurementRemaining) { + bestMeasurementRemaining = evaluation.measurementUntilFire; + bestMeasurementUnit = evaluation.measurement.unit; + bestMeasurementTrackerName = trackerName; + } + } else { + if (tracker.next_due_at === null) continue; + const evaluation = evaluateDateTrigger(normalizedTrigger, tracker.next_due_at); + + if (evaluation.matches) { + hasReady = true; + } else if (!bestDateFireAt || evaluation.fireAt < bestDateFireAt) { + bestDateFireAt = evaluation.fireAt; + bestDateTrackerName = trackerName; + } + } + } + } + + if (!hasAnyTracker) return { kind: 'none' }; + if (hasReady) return { kind: 'ready' }; + if (allWaiting) return { kind: 'waiting' }; + + if (normalizedTrigger.kind === 'maintenance') { + if (bestMeasurementRemaining < Infinity) { + return { + kind: 'measurement', + remaining: bestMeasurementRemaining, + unit: bestMeasurementUnit, + trackerName: bestMeasurementTrackerName + }; + } + return { kind: 'waiting' }; + } + + if (bestDateFireAt) { + return { kind: 'date', fireAt: bestDateFireAt, trackerName: bestDateTrackerName }; + } + return { kind: 'waiting' }; + } + + case 'no_odometer_update': { + let soonestFire: Date | null = null; + for (const vehicle of scopedVehicles) { + const lastUpdated = toUtcDate(vehicle.updated_at ?? vehicle.created_at); + const fireTime = new Date(lastUpdated.getTime() + normalizedTrigger.days * 86400000); + if (!soonestFire || fireTime < soonestFire) soonestFire = fireTime; + } + if (!soonestFire) return { kind: 'none' }; + if (soonestFire.getTime() <= now) { + if (rule.last_triggered_at) { + const cd = cooldownInfo(rule.last_triggered_at, now); + if (cd) return cd; + } + return { kind: 'ready' }; + } + return { kind: 'date', fireAt: soonestFire.toISOString() }; + } + + case 'calendar_date': { + const next = nextCalendarOccurrence(normalizedTrigger.month, normalizedTrigger.day); + const today = new Date(); + const isToday = + next.getMonth() + 1 === normalizedTrigger.month && + next.getDate() === normalizedTrigger.day && + next.getFullYear() === today.getFullYear(); + + if (isToday) { + if (rule.last_triggered_at) { + const cd = cooldownInfo(rule.last_triggered_at, now); + if (cd) return cd; + } + return { kind: 'ready' }; + } + return { kind: 'date', fireAt: next.toISOString() }; + } + + case 'document_expiring': { + let soonestFire: Date | null = null; + for (const vehicle of scopedVehicles) { + const docs = await db.query.documents.findMany({ + where: and(eq(documents.vehicle_id, vehicle.id)) + }); + for (const doc of docs) { + if (!doc.expires_at) continue; + const fireTime = new Date( + new Date(doc.expires_at).getTime() - normalizedTrigger.daysBefore * 86400000 + ); + if (!soonestFire || fireTime < soonestFire) soonestFire = fireTime; + } + } + if (!soonestFire) return { kind: 'none' }; + if (soonestFire.getTime() <= now) { + if (rule.last_triggered_at) { + const cd = cooldownInfo(rule.last_triggered_at, now); + if (cd) return cd; + } + return { kind: 'ready' }; + } + return { kind: 'date', fireAt: soonestFire.toISOString() }; + } + } +} diff --git a/motomate/src/routes/(app)/settings/workflows/+page.server.ts b/motomate/src/routes/(app)/settings/workflows/+page.server.ts index 03b0299..60b4176 100644 --- a/motomate/src/routes/(app)/settings/workflows/+page.server.ts +++ b/motomate/src/routes/(app)/settings/workflows/+page.server.ts @@ -1,215 +1,16 @@ import { fail } from '@sveltejs/kit'; -import { eq, and } from 'drizzle-orm'; import type { Actions, PageServerLoad } from './$types'; import { - getWorkflowRulesByUser, - toggleWorkflowRule, deleteWorkflowRule, + getWorkflowRulesByUser, seedPresetRulesForUser, + toggleWorkflowRule, updateWorkflowRuleTrigger } from '$lib/db/repositories/workflow.js'; import { getVehiclesByUser } from '$lib/db/repositories/vehicles.js'; import { recomputeTrackerStatuses } from '$lib/db/repositories/maintenance.js'; import { RuleTriggerSchema } from '$lib/validators/schemas.js'; -import type { RuleTrigger } from '$lib/db/schema.js'; -import { db } from '$lib/db/index.js'; -import { documents } from '$lib/db/schema.js'; -import { - evaluateDateTrigger, - evaluateMaintenanceTrigger, - normalizeWorkflowTrigger -} from '$lib/workflow/triggers.js'; - -export type NextFireInfo = - | { kind: 'ready' } - | { kind: 'cooldown'; until: string } - | { kind: 'waiting' } - | { kind: 'measurement'; remaining: number; unit: string; trackerName: string } - | { kind: 'date'; fireAt: string; trackerName?: string } - | { kind: 'none' }; - -function toUtcDate(sqliteStr: string): Date { - const iso = sqliteStr.includes('T') ? sqliteStr : sqliteStr.replace(' ', 'T'); - return new Date(iso.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso + 'Z'); -} - -function cooldownInfo(lastTriggeredAt: string, now: number): NextFireInfo | null { - const fired = toUtcDate(lastTriggeredAt).getTime(); - const cooldownEnd = fired + 23 * 3600000; - if (now < cooldownEnd) return { kind: 'cooldown', until: new Date(cooldownEnd).toISOString() }; - return null; -} - -function nextCalendarOccurrence(month: number, day: number): Date { - const now = new Date(); - const year = now.getFullYear(); - const candidate = new Date(year, month - 1, day); - if (candidate <= now) candidate.setFullYear(year + 1); - return candidate; -} - -async function computeNextFireInfo( - rule: { - id: string; - vehicle_id: string | null; - trigger: RuleTrigger; - last_triggered_at: string | null; - }, - vehicles: Awaited>, - trackersByVehicle: Map>> -): Promise { - const normalizedTrigger = normalizeWorkflowTrigger(rule.trigger); - const now = Date.now(); - - const scopedVehicles = rule.vehicle_id - ? vehicles.filter((v) => v.id === rule.vehicle_id) - : vehicles; - - if (scopedVehicles.length === 0) return { kind: 'none' }; - - switch (normalizedTrigger.kind) { - case 'maintenance': - case 'date': { - let bestMeasurementRemaining = Infinity; - let bestMeasurementUnit = 'km'; - let bestMeasurementTrackerName = ''; - let bestDateFireAt = ''; - let bestDateTrackerName = ''; - let hasReady = false; - let allWaiting = true; - let hasAnyTracker = false; - - for (const vehicle of scopedVehicles) { - const trackers = trackersByVehicle.get(vehicle.id) ?? []; - - for (const tracker of trackers) { - hasAnyTracker = true; - const notifiedBy = ((tracker.state as Record)?.notified_by ?? - {}) as Record; - const alreadyFired = Object.keys(notifiedBy).length > 0; - - if (!alreadyFired) allWaiting = false; - if (alreadyFired) continue; - - const trackerName = tracker.template?.name ?? ''; - - if (normalizedTrigger.kind === 'maintenance') { - const evaluation = evaluateMaintenanceTrigger( - normalizedTrigger, - vehicle, - tracker, - tracker.template - ); - if (!evaluation) continue; - - if (evaluation.readyAtCurrentMeasurement) { - hasReady = true; - } else if (evaluation.measurementUntilFire < bestMeasurementRemaining) { - bestMeasurementRemaining = evaluation.measurementUntilFire; - bestMeasurementUnit = evaluation.measurement.unit; - bestMeasurementTrackerName = trackerName; - } - } else { - if (tracker.next_due_at === null) continue; - const evaluation = evaluateDateTrigger(normalizedTrigger, tracker.next_due_at); - - if (evaluation.matches) { - hasReady = true; - } else { - if (!bestDateFireAt || evaluation.fireAt < bestDateFireAt) { - bestDateFireAt = evaluation.fireAt; - bestDateTrackerName = trackerName; - } - } - } - } - } - - if (!hasAnyTracker) return { kind: 'none' }; - if (hasReady) return { kind: 'ready' }; - if (allWaiting) return { kind: 'waiting' }; - - if (normalizedTrigger.kind === 'maintenance') { - if (bestMeasurementRemaining < Infinity) - return { - kind: 'measurement', - remaining: bestMeasurementRemaining, - unit: bestMeasurementUnit, - trackerName: bestMeasurementTrackerName - }; - return { kind: 'waiting' }; - } else { - if (bestDateFireAt) - return { kind: 'date', fireAt: bestDateFireAt, trackerName: bestDateTrackerName }; - return { kind: 'waiting' }; - } - } - - case 'no_odometer_update': { - let soonestFire: Date | null = null; - for (const vehicle of scopedVehicles) { - const lastUpdated = toUtcDate(vehicle.updated_at ?? vehicle.created_at); - const fireTime = new Date(lastUpdated.getTime() + normalizedTrigger.days * 86400000); - if (!soonestFire || fireTime < soonestFire) soonestFire = fireTime; - } - if (!soonestFire) return { kind: 'none' }; - if (soonestFire.getTime() <= now) { - if (rule.last_triggered_at) { - const cd = cooldownInfo(rule.last_triggered_at, now); - if (cd) return cd; - } - return { kind: 'ready' }; - } - return { kind: 'date', fireAt: soonestFire.toISOString() }; - } - - case 'calendar_date': { - const next = nextCalendarOccurrence(normalizedTrigger.month, normalizedTrigger.day); - const today = new Date(); - const isToday = - next.getMonth() + 1 === normalizedTrigger.month && - next.getDate() === normalizedTrigger.day && - next.getFullYear() === today.getFullYear(); - - if (isToday) { - if (rule.last_triggered_at) { - const cd = cooldownInfo(rule.last_triggered_at, now); - if (cd) return cd; - } - return { kind: 'ready' }; - } - return { kind: 'date', fireAt: next.toISOString() }; - } - - case 'document_expiring': { - let soonestFire: Date | null = null; - for (const vehicle of scopedVehicles) { - const docs = await db.query.documents.findMany({ - where: and(eq(documents.vehicle_id, vehicle.id)) - }); - for (const doc of docs) { - if (!doc.expires_at) continue; - const fireTime = new Date( - new Date(doc.expires_at).getTime() - normalizedTrigger.daysBefore * 86400000 - ); - if (!soonestFire || fireTime < soonestFire) soonestFire = fireTime; - } - } - if (!soonestFire) return { kind: 'none' }; - if (soonestFire.getTime() <= now) { - if (rule.last_triggered_at) { - const cd = cooldownInfo(rule.last_triggered_at, now); - if (cd) return cd; - } - return { kind: 'ready' }; - } - return { kind: 'date', fireAt: soonestFire.toISOString() }; - } - - default: - return { kind: 'none' }; - } -} +import { computeNextFireInfo } from '$lib/workflow/preview.js'; export const load: PageServerLoad = async ({ locals }) => { const userId = locals.user!.id; @@ -219,7 +20,6 @@ export const load: PageServerLoad = async ({ locals }) => { getVehiclesByUser(userId) ]); - // Fresh tracker statuses for nextFire computation const trackersByVehicle = new Map>>(); for (const vehicle of userVehicles) { trackersByVehicle.set( diff --git a/motomate/src/routes/(app)/settings/workflows/+page.svelte b/motomate/src/routes/(app)/settings/workflows/+page.svelte index 03864c7..ac26b53 100644 --- a/motomate/src/routes/(app)/settings/workflows/+page.svelte +++ b/motomate/src/routes/(app)/settings/workflows/+page.svelte @@ -2,7 +2,7 @@ import { enhance } from '$app/forms'; import type { PageData } from './$types'; import type { RuleTrigger } from '$lib/db/schema.js'; - import type { NextFireInfo } from './+page.server.js'; + import type { NextFireInfo } from '$lib/workflow/preview-core.js'; import { _, waitLocale } from '$lib/i18n'; import { formatDateTime, formatDateLong } from '$lib/utils/format'; import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte'; diff --git a/motomate/src/tests/workflow-settings-preview.test.ts b/motomate/src/tests/workflow-settings-preview.test.ts new file mode 100644 index 0000000..ca39e69 --- /dev/null +++ b/motomate/src/tests/workflow-settings-preview.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { nextCalendarOccurrence } from '../lib/workflow/preview-core.js'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('workflow settings preview', () => { + it('keeps calendar occurrences on the current day instead of skipping to next year', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-21T12:00:00Z')); + + const result = nextCalendarOccurrence(4, 21); + expect(result.getFullYear()).toBe(2026); + expect(result.getMonth() + 1).toBe(4); + expect(result.getDate()).toBe(21); + }); + + it('advances calendar occurrences to next year only when the day is already past', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-22T12:00:00Z')); + + const result = nextCalendarOccurrence(4, 21); + expect(result.getFullYear()).toBe(2027); + expect(result.getMonth() + 1).toBe(4); + expect(result.getDate()).toBe(21); + }); +}); diff --git a/motomate/src/tests/workflow-triggers.test.ts b/motomate/src/tests/workflow-triggers.test.ts new file mode 100644 index 0000000..c8fc0a8 --- /dev/null +++ b/motomate/src/tests/workflow-triggers.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { + buildMaintenanceNotificationVars, + evaluateDateTrigger, + evaluateMaintenanceTrigger, + normalizeWorkflowTrigger +} from '../lib/workflow/triggers.js'; + +describe('workflow trigger normalization', () => { + it('normalizes legacy odometer triggers into maintenance triggers', () => { + const normalized = normalizeWorkflowTrigger({ type: 'odometer_upcoming', km_before: 500 }); + expect(normalized).toEqual({ + kind: 'maintenance', + phase: 'upcoming', + basis: 'distance', + threshold: 500, + legacyType: 'odometer_upcoming' + }); + }); + + it('normalizes document expiry triggers without changing persisted shape assumptions', () => { + const normalized = normalizeWorkflowTrigger({ type: 'document_expiring', days_before: 14 }); + expect(normalized).toEqual({ + kind: 'document_expiring', + daysBefore: 14, + legacyType: 'document_expiring' + }); + }); +}); + +describe('workflow trigger evaluation', () => { + it('evaluates maintenance upcoming triggers with canonical measurement fields', () => { + const trigger = normalizeWorkflowTrigger({ type: 'odometer_upcoming', km_before: 500 }); + if (trigger.kind !== 'maintenance') throw new Error('expected maintenance trigger'); + + const evaluation = evaluateMaintenanceTrigger( + trigger, + { + current_measurement: 1500, + current_measurement_unit: 'km', + current_odometer: 1500, + odometer_unit: 'km' + }, + { + next_due_measurement: 1800, + next_due_odometer: 1800, + measurement_unit: 'km' + }, + { name: 'Oil change', interval_km: 1000 } + ); + + expect(evaluation).not.toBeNull(); + expect(evaluation?.matches).toBe(true); + expect(evaluation?.measurementUntilFire).toBe(0); + expect(evaluation?.measurementValue).toBe(300); + expect(evaluation?.legacyAliasKey).toBe('km_remaining'); + expect(evaluation?.legacyAliasValue).toBe(300); + }); + + it('returns null for non-comparable maintenance measurements', () => { + 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: 'mi', + current_odometer: 20, + odometer_unit: 'mi' + }, + { + next_due_measurement: 100, + next_due_odometer: 100, + measurement_unit: 'km' + }, + { name: 'Chain service', interval_km: 100 } + ); + + expect(evaluation).toBeNull(); + }); + + 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'); + + const evaluation = evaluateDateTrigger(trigger, '2026-04-19', new Date('2026-04-21T12:00:00Z')); + expect(evaluation.relation).toBe('overdue'); + expect(evaluation.daysOverdue).toBe(2); + expect(typeof evaluation.matches).toBe('boolean'); + }); + + it('builds maintenance notification vars with neutral and legacy distance aliases', () => { + 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: 2000, + current_measurement_unit: 'km', + current_odometer: 2000, + odometer_unit: 'km' + }, + { + next_due_measurement: 1800, + next_due_odometer: 1800, + measurement_unit: 'km' + }, + { name: 'Brake check', interval_km: 1000 } + ); + + if (!evaluation) throw new Error('expected evaluation'); + + const vars = buildMaintenanceNotificationVars('Honda', evaluation, 'tracker-1'); + expect(vars.measurement_value).toBe(200); + expect(vars.measurement_unit).toBe('km'); + expect(vars.measurement_basis).toBe('distance'); + expect(vars.current_measurement).toBe(2000); + expect(vars.due_measurement).toBe(1800); + expect(vars.km_over).toBe(200); + expect(vars.tracker_id).toBe('tracker-1'); + }); +}); diff --git a/motomate/vitest.config.ts b/motomate/vitest.config.ts index 2c624dc..5c33831 100644 --- a/motomate/vitest.config.ts +++ b/motomate/vitest.config.ts @@ -1,6 +1,12 @@ +import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { + $lib: resolve(__dirname, 'src/lib') + } + }, test: { environment: 'node', include: ['src/tests/**/*.test.ts']