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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions motomate/src/lib/auth/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
27 changes: 15 additions & 12 deletions motomate/src/lib/db/repositories/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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), {
Expand Down Expand Up @@ -933,15 +935,12 @@ export async function recomputeTrackerStatuses(
const needsInit = lastDoneMeasurement === null && t.last_done_at === null;
const fields: Partial<typeof active_trackers.$inferInsert> = {};

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), {
Expand Down Expand Up @@ -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) &&
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 1 addition & 5 deletions motomate/src/lib/db/repositories/service-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion motomate/src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
16 changes: 8 additions & 8 deletions motomate/src/lib/i18n/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@
"notes": "Notities",
"travels": "Routes",
"finance": "Financieel",
"usage": "Verbruik"
"usage": "Draaiuren"
}
},
"upcoming": "Aankomende",
Expand Down Expand Up @@ -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…",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -558,7 +558,7 @@
"date": "Datum",
"odometer": "Kilometerstand ({unit})",
"autoCompute": "— leeglaten voor automatische berekening",
"usage": "Verbruiksstand ({unit})"
"usage": "Draaiuren ({unit})"
},
"submit": "Wijzigingen opslaan"
},
Expand Down Expand Up @@ -636,7 +636,7 @@
"odometerOptional": "Optioneel",
"notes": "Notities",
"notesPlaceholder": "Extra details",
"usage": "Verbruiksstand ({unit})"
"usage": "Draaiuren ({unit})"
},
"deleteDialog": {
"title": "Deze transactie verwijderen?",
Expand Down Expand Up @@ -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"
}
},
Expand Down
124 changes: 80 additions & 44 deletions motomate/src/lib/pdf/maintenance-report.ts
Original file line number Diff line number Diff line change
@@ -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<typeof PDFDocumentClass>;

Expand Down Expand Up @@ -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<string, any> = {
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';
Expand All @@ -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<ReportTranslations>;
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<ReportTranslations>;
if (loaded) return { ...fallbacks, ...loaded };
return fallbacks;
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion motomate/src/lib/workflow/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 1 addition & 3 deletions motomate/src/lib/workflow/triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
export type WorkflowMaintenanceTrigger = {
kind: 'maintenance';
phase: 'upcoming' | 'overdue';
basis: 'distance';
basis?: MeasurementBasis;
threshold: number;
legacyType: 'odometer_upcoming' | 'odometer_overdue';
};
Expand Down Expand Up @@ -159,15 +159,13 @@ export function normalizeWorkflowTrigger(trigger: RuleTrigger): NormalizedWorkfl
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
};
Expand Down
Loading
Loading