feat: Scheduled Maintenance feature complete

This commit is contained in:
Eric Gullickson
2025-12-22 14:12:33 -06:00
parent c017b8816f
commit 91b4534e76
44 changed files with 2740 additions and 117 deletions

View File

@@ -41,6 +41,11 @@ export class MaintenanceRepository {
nextDueMileage: row.next_due_mileage,
isActive: row.is_active,
emailNotifications: row.email_notifications,
scheduleType: row.schedule_type,
fixedDueDate: row.fixed_due_date,
reminderDays1: row.reminder_days_1,
reminderDays2: row.reminder_days_2,
reminderDays3: row.reminder_days_3,
createdAt: row.created_at,
updatedAt: row.updated_at
};
@@ -192,12 +197,18 @@ export class MaintenanceRepository {
nextDueMileage?: number | null;
isActive: boolean;
emailNotifications?: boolean;
scheduleType?: string;
fixedDueDate?: string | null;
reminderDays1?: number | null;
reminderDays2?: number | null;
reminderDays3?: number | null;
}): Promise<MaintenanceSchedule> {
const res = await this.db.query(
`INSERT INTO maintenance_schedules (
id, user_id, vehicle_id, category, subtypes, interval_months, interval_miles,
last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active, email_notifications
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13)
last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active, email_notifications,
schedule_type, fixed_due_date, reminder_days_1, reminder_days_2, reminder_days_3
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
RETURNING *`,
[
schedule.id,
@@ -213,6 +224,11 @@ export class MaintenanceRepository {
schedule.nextDueMileage ?? null,
schedule.isActive,
schedule.emailNotifications ?? false,
schedule.scheduleType ?? 'interval',
schedule.fixedDueDate ?? null,
schedule.reminderDays1 ?? null,
schedule.reminderDays2 ?? null,
schedule.reminderDays3 ?? null,
]
);
return this.mapMaintenanceSchedule(res.rows[0]);
@@ -245,7 +261,7 @@ export class MaintenanceRepository {
async updateSchedule(
id: string,
userId: string,
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage' | 'isActive' | 'emailNotifications'>>
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage' | 'isActive' | 'emailNotifications' | 'scheduleType' | 'fixedDueDate' | 'reminderDays1' | 'reminderDays2' | 'reminderDays3'>>
): Promise<MaintenanceSchedule | null> {
const fields: string[] = [];
const params: any[] = [];
@@ -291,6 +307,26 @@ export class MaintenanceRepository {
fields.push(`email_notifications = $${i++}`);
params.push(patch.emailNotifications);
}
if (patch.scheduleType !== undefined) {
fields.push(`schedule_type = $${i++}`);
params.push(patch.scheduleType);
}
if (patch.fixedDueDate !== undefined) {
fields.push(`fixed_due_date = $${i++}`);
params.push(patch.fixedDueDate);
}
if (patch.reminderDays1 !== undefined) {
fields.push(`reminder_days_1 = $${i++}`);
params.push(patch.reminderDays1);
}
if (patch.reminderDays2 !== undefined) {
fields.push(`reminder_days_2 = $${i++}`);
params.push(patch.reminderDays2);
}
if (patch.reminderDays3 !== undefined) {
fields.push(`reminder_days_3 = $${i++}`);
params.push(patch.reminderDays3);
}
if (!fields.length) return this.findScheduleById(id, userId);
@@ -306,4 +342,46 @@ export class MaintenanceRepository {
[id, userId]
);
}
async findMatchingSchedules(
userId: string,
vehicleId: string,
category: MaintenanceCategory,
subtypes: string[]
): Promise<MaintenanceSchedule[]> {
const result = await this.db.query(
`SELECT * FROM maintenance_schedules
WHERE user_id = $1
AND vehicle_id = $2
AND category = $3
AND is_active = true
AND schedule_type = 'time_since_last'
AND subtypes && $4::text[]
ORDER BY created_at DESC`,
[userId, vehicleId, category, subtypes]
);
return result.rows.map(row => this.mapMaintenanceSchedule(row));
}
async updateScheduleLastService(
id: string,
userId: string,
lastServiceDate: string,
lastServiceMileage?: number | null,
nextDueDate?: string | null,
nextDueMileage?: number | null
): Promise<MaintenanceSchedule | null> {
const result = await this.db.query(
`UPDATE maintenance_schedules SET
last_service_date = $1,
last_service_mileage = $2,
next_due_date = $3,
next_due_mileage = $4,
updated_at = CURRENT_TIMESTAMP
WHERE id = $5 AND user_id = $6
RETURNING *`,
[lastServiceDate, lastServiceMileage, nextDueDate, nextDueMileage, id, userId]
);
return result.rows[0] ? this.mapMaintenanceSchedule(result.rows[0]) : null;
}
}

View File

@@ -8,7 +8,8 @@ import type {
MaintenanceSchedule,
MaintenanceRecordResponse,
MaintenanceScheduleResponse,
MaintenanceCategory
MaintenanceCategory,
ScheduleType
} from './maintenance.types';
import { validateSubtypes } from './maintenance.types';
import { MaintenanceRepository } from '../data/maintenance.repository';
@@ -27,7 +28,7 @@ export class MaintenanceService {
}
const id = randomUUID();
return this.repo.insertRecord({
const record = await this.repo.insertRecord({
id,
userId,
vehicleId: body.vehicleId,
@@ -39,6 +40,11 @@ export class MaintenanceService {
shopName: body.shopName,
notes: body.notes,
});
// Auto-link: Find and update matching 'time_since_last' schedules
await this.autoLinkRecordToSchedules(userId, record);
return record;
}
async getRecord(userId: string, id: string): Promise<MaintenanceRecordResponse | null> {
@@ -191,12 +197,50 @@ export class MaintenanceService {
}
}
private async autoLinkRecordToSchedules(userId: string, record: MaintenanceRecord): Promise<void> {
// Find active 'time_since_last' schedules with matching category and overlapping subtypes
const matchingSchedules = await this.repo.findMatchingSchedules(
userId,
record.vehicleId,
record.category,
record.subtypes
);
for (const schedule of matchingSchedules) {
// Calculate new next due dates based on intervals
const nextDue = this.calculateNextDue({
lastServiceDate: record.date,
lastServiceMileage: record.odometerReading,
intervalMonths: schedule.intervalMonths,
intervalMiles: schedule.intervalMiles,
});
// Update the schedule with new last service info
await this.repo.updateScheduleLastService(
schedule.id,
userId,
record.date,
record.odometerReading,
nextDue.nextDueDate,
nextDue.nextDueMileage
);
}
}
private calculateNextDue(schedule: {
lastServiceDate?: string | null;
lastServiceMileage?: number | null;
intervalMonths?: number | null;
intervalMiles?: number | null;
scheduleType?: ScheduleType;
fixedDueDate?: string | null;
}): { nextDueDate: string | null; nextDueMileage: number | null } {
// Handle fixed_date type - just return the fixed date
if (schedule.scheduleType === 'fixed_date' && schedule.fixedDueDate) {
return { nextDueDate: schedule.fixedDueDate, nextDueMileage: null };
}
// For interval and time_since_last types, calculate based on last service
let nextDueDate: string | null = null;
let nextDueMileage: number | null = null;

View File

@@ -7,6 +7,7 @@ import { z } from 'zod';
// Category types
export type MaintenanceCategory = 'routine_maintenance' | 'repair' | 'performance_upgrade';
export type ScheduleType = 'interval' | 'fixed_date' | 'time_since_last';
// Subtype definitions (constants for validation)
export const ROUTINE_MAINTENANCE_SUBTYPES = [
@@ -85,12 +86,23 @@ export interface MaintenanceSchedule {
nextDueMileage?: number;
isActive: boolean;
emailNotifications: boolean;
scheduleType: ScheduleType;
fixedDueDate?: string | null;
reminderDays1?: number | null;
reminderDays2?: number | null;
reminderDays3?: number | null;
createdAt: string;
updatedAt: string;
}
// Zod schemas for validation (camelCase for API)
export const MaintenanceCategorySchema = z.enum(['routine_maintenance', 'repair', 'performance_upgrade']);
export const ScheduleTypeSchema = z.enum(['interval', 'fixed_date', 'time_since_last']);
const reminderDaysValidator = z.number().int().positive().refine(
(val) => [1, 7, 14, 30, 60].includes(val),
{ message: 'Reminder days must be one of: 1, 7, 14, 30, 60' }
);
export const CreateMaintenanceRecordSchema = z.object({
vehicleId: z.string().uuid(),
@@ -122,6 +134,11 @@ export const CreateScheduleSchema = z.object({
intervalMonths: z.number().int().positive().optional(),
intervalMiles: z.number().int().positive().optional(),
emailNotifications: z.boolean().optional(),
scheduleType: ScheduleTypeSchema.optional().default('interval'),
fixedDueDate: z.string().optional(),
reminderDays1: reminderDaysValidator.optional(),
reminderDays2: reminderDaysValidator.optional(),
reminderDays3: reminderDaysValidator.optional(),
});
export type CreateScheduleRequest = z.infer<typeof CreateScheduleSchema>;
@@ -132,6 +149,11 @@ export const UpdateScheduleSchema = z.object({
intervalMiles: z.number().int().positive().nullable().optional(),
isActive: z.boolean().optional(),
emailNotifications: z.boolean().optional(),
scheduleType: ScheduleTypeSchema.optional(),
fixedDueDate: z.string().nullable().optional(),
reminderDays1: reminderDaysValidator.nullable().optional(),
reminderDays2: reminderDaysValidator.nullable().optional(),
reminderDays3: reminderDaysValidator.nullable().optional(),
});
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;

View File

@@ -0,0 +1,24 @@
-- Add schedule_type to support different scheduling methods
ALTER TABLE maintenance_schedules
ADD COLUMN schedule_type VARCHAR(20) NOT NULL DEFAULT 'interval'
CHECK (schedule_type IN ('interval', 'fixed_date', 'time_since_last'));
-- Add fixed_due_date for fixed date schedules
ALTER TABLE maintenance_schedules
ADD COLUMN fixed_due_date DATE;
-- Add reminder columns for progressive notifications
ALTER TABLE maintenance_schedules
ADD COLUMN reminder_days_1 INTEGER
CHECK (reminder_days_1 IN (1, 7, 14, 30, 60));
ALTER TABLE maintenance_schedules
ADD COLUMN reminder_days_2 INTEGER
CHECK (reminder_days_2 IN (1, 7, 14, 30, 60));
ALTER TABLE maintenance_schedules
ADD COLUMN reminder_days_3 INTEGER
CHECK (reminder_days_3 IN (1, 7, 14, 30, 60));
-- Index for filtering by schedule type
CREATE INDEX idx_maintenance_schedules_schedule_type ON maintenance_schedules(schedule_type);