32 KiB
Maintenance Feature Implementation Plan
Project Context
MotoVaultPro is a hybrid platform using:
- Architecture: Modular monolith with feature capsules
- Backend: Fastify + PostgreSQL + Redis
- Frontend: React + TypeScript + Material-UI
- Development: Docker-first, production-only testing
- Requirements: Mobile + Desktop support for ALL features
Feature Overview
Implement comprehensive maintenance tracking with three main categories:
- Routine Maintenance - Regular service items (27 subtypes)
- Repair - Fix/repair work (5 subtypes)
- Performance Upgrade - Enhancements (5 subtypes)
Key Capabilities
- Track completed maintenance (records)
- Schedule recurring maintenance (schedules)
- Calculate next due dates (date-based and/or mileage-based)
- View upcoming/overdue maintenance
- Support multiple subtypes per record (checkboxes, not single select)
Display Format
- List View: "Category (count)" e.g., "Routine Maintenance (3)"
- Detail View: Show all selected subtypes with full details
Database Schema
Tables to Create
Drop existing maintenance tables (001_create_maintenance_tables.sql) and create new schema.
Migration: backend/src/features/maintenance/migrations/002_recreate_maintenance_tables.sql
-- Drop existing tables (clean slate)
DROP TABLE IF EXISTS maintenance_schedules CASCADE;
DROP TABLE IF EXISTS maintenance_logs CASCADE;
-- Create maintenance_records table
CREATE TABLE maintenance_records (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
vehicle_id UUID NOT NULL,
category VARCHAR(50) NOT NULL, -- 'routine_maintenance', 'repair', 'performance_upgrade'
subtypes TEXT[] NOT NULL, -- PostgreSQL array of selected subtypes
date DATE NOT NULL,
odometer_reading INTEGER,
cost DECIMAL(10, 2),
shop_name VARCHAR(200),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_maintenance_vehicle
FOREIGN KEY (vehicle_id)
REFERENCES vehicles(id)
ON DELETE CASCADE,
CONSTRAINT check_category
CHECK (category IN ('routine_maintenance', 'repair', 'performance_upgrade')),
CONSTRAINT check_subtypes_not_empty
CHECK (array_length(subtypes, 1) > 0)
);
-- Create maintenance_schedules table
CREATE TABLE maintenance_schedules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
vehicle_id UUID NOT NULL,
category VARCHAR(50) NOT NULL,
subtypes TEXT[] NOT NULL,
interval_months INTEGER,
interval_miles INTEGER,
last_service_date DATE,
last_service_mileage INTEGER,
next_due_date DATE,
next_due_mileage INTEGER,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_schedule_vehicle
FOREIGN KEY (vehicle_id)
REFERENCES vehicles(id)
ON DELETE CASCADE,
CONSTRAINT check_schedule_category
CHECK (category IN ('routine_maintenance', 'repair', 'performance_upgrade'))
);
-- Indexes for performance
CREATE INDEX idx_maintenance_records_user_id ON maintenance_records(user_id);
CREATE INDEX idx_maintenance_records_vehicle_id ON maintenance_records(vehicle_id);
CREATE INDEX idx_maintenance_records_date ON maintenance_records(date DESC);
CREATE INDEX idx_maintenance_records_category ON maintenance_records(category);
CREATE INDEX idx_maintenance_schedules_user_id ON maintenance_schedules(user_id);
CREATE INDEX idx_maintenance_schedules_vehicle_id ON maintenance_schedules(vehicle_id);
CREATE INDEX idx_maintenance_schedules_next_due_date ON maintenance_schedules(next_due_date);
CREATE INDEX idx_maintenance_schedules_active ON maintenance_schedules(is_active) WHERE is_active = true;
-- Triggers for updated_at
DROP TRIGGER IF EXISTS update_maintenance_records_updated_at ON maintenance_records;
CREATE TRIGGER update_maintenance_records_updated_at
BEFORE UPDATE ON maintenance_records
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_maintenance_schedules_updated_at ON maintenance_schedules;
CREATE TRIGGER update_maintenance_schedules_updated_at
BEFORE UPDATE ON maintenance_schedules
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Category and Subtype Definitions
Routine Maintenance (27 subtypes)
Accelerator Pedal
Air Filter Element
Brakes and Traction Control
Cabin Air Filter / Purifier
Coolant
Doors
Drive Belt
Engine Oil
Evaporative Emissions System
Exhaust System
Fluid - A/T
Fluid - Differential
Fluid - M/T
Fluid Filter - A/T
Fluids
Fuel Delivery and Air Induction
Hood Shock / Support
Neutral Safety Switch
Parking Brake System
Restraints and Safety Systems
Shift Interlock, A/T
Spark Plug
Steering and Suspension
Tires
Trunk / Liftgate Shock / Support
Washer Fluid
Wiper Blade
Repair (5 subtypes)
Engine
Transmission
Drivetrain
Exterior
Interior
Performance Upgrade (5 subtypes)
Engine
Drivetrain
Suspension
Wheels/Tires
Exterior
Backend Implementation
File Structure
backend/src/features/maintenance/
├── README.md # Feature documentation
├── index.ts # Public API exports
├── api/
│ ├── maintenance.routes.ts # Fastify routes
│ ├── maintenance.controller.ts # HTTP handlers
│ └── maintenance.validation.ts # Request validation
├── domain/
│ ├── maintenance.types.ts # TypeScript types + constants
│ └── maintenance.service.ts # Business logic
├── data/
│ └── maintenance.repository.ts # Database queries
├── migrations/
│ └── 002_recreate_maintenance_tables.sql
└── tests/
├── unit/
│ └── maintenance.service.test.ts
└── integration/
└── maintenance.integration.test.ts
Phase 1: Domain Layer
File: backend/src/features/maintenance/domain/maintenance.types.ts
/**
* @ai-summary Type definitions for maintenance feature
* @ai-context Supports three categories with specific subtypes, multiple selections allowed
*/
// Category types
export type MaintenanceCategory = 'routine_maintenance' | 'repair' | 'performance_upgrade';
// Subtype definitions (constants for validation)
export const ROUTINE_MAINTENANCE_SUBTYPES = [
'Accelerator Pedal',
'Air Filter Element',
'Brakes and Traction Control',
'Cabin Air Filter / Purifier',
'Coolant',
'Doors',
'Drive Belt',
'Engine Oil',
'Evaporative Emissions System',
'Exhaust System',
'Fluid - A/T',
'Fluid - Differential',
'Fluid - M/T',
'Fluid Filter - A/T',
'Fluids',
'Fuel Delivery and Air Induction',
'Hood Shock / Support',
'Neutral Safety Switch',
'Parking Brake System',
'Restraints and Safety Systems',
'Shift Interlock, A/T',
'Spark Plug',
'Steering and Suspension',
'Tires',
'Trunk / Liftgate Shock / Support',
'Washer Fluid',
'Wiper Blade'
] as const;
export const REPAIR_SUBTYPES = [
'Engine',
'Transmission',
'Drivetrain',
'Exterior',
'Interior'
] as const;
export const PERFORMANCE_UPGRADE_SUBTYPES = [
'Engine',
'Drivetrain',
'Suspension',
'Wheels/Tires',
'Exterior'
] as const;
// Database record types
export interface MaintenanceRecord {
id: string;
user_id: string;
vehicle_id: string;
category: MaintenanceCategory;
subtypes: string[];
date: string;
odometer_reading?: number;
cost?: number;
shop_name?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface MaintenanceSchedule {
id: string;
user_id: string;
vehicle_id: string;
category: MaintenanceCategory;
subtypes: string[];
interval_months?: number;
interval_miles?: number;
last_service_date?: string;
last_service_mileage?: number;
next_due_date?: string;
next_due_mileage?: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
// Request types
export interface CreateMaintenanceRecordRequest {
vehicle_id: string;
category: MaintenanceCategory;
subtypes: string[]; // Must have at least one
date: string;
odometer_reading?: number;
cost?: number;
shop_name?: string;
notes?: string;
}
export interface UpdateMaintenanceRecordRequest {
category?: MaintenanceCategory;
subtypes?: string[];
date?: string;
odometer_reading?: number;
cost?: number;
shop_name?: string;
notes?: string;
}
export interface CreateScheduleRequest {
vehicle_id: string;
category: MaintenanceCategory;
subtypes: string[];
interval_months?: number;
interval_miles?: number;
}
export interface UpdateScheduleRequest {
category?: MaintenanceCategory;
subtypes?: string[];
interval_months?: number;
interval_miles?: number;
is_active?: boolean;
}
// Response types
export interface MaintenanceRecordResponse extends MaintenanceRecord {
subtype_count: number; // For list display: "Routine Maintenance (3)"
}
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {
subtype_count: number;
is_due_soon?: boolean; // Within 30 days or 500 miles
is_overdue?: boolean;
}
// Validation helpers
export function getSubtypesForCategory(category: MaintenanceCategory): readonly string[] {
switch (category) {
case 'routine_maintenance': return ROUTINE_MAINTENANCE_SUBTYPES;
case 'repair': return REPAIR_SUBTYPES;
case 'performance_upgrade': return PERFORMANCE_UPGRADE_SUBTYPES;
}
}
export function validateSubtypes(category: MaintenanceCategory, subtypes: string[]): boolean {
if (!subtypes || subtypes.length === 0) return false;
const validSubtypes = getSubtypesForCategory(category);
return subtypes.every(st => validSubtypes.includes(st as any));
}
export function getCategoryDisplayName(category: MaintenanceCategory): string {
switch (category) {
case 'routine_maintenance': return 'Routine Maintenance';
case 'repair': return 'Repair';
case 'performance_upgrade': return 'Performance Upgrade';
}
}
File: backend/src/features/maintenance/domain/maintenance.service.ts
Key methods to implement:
-
createRecord(data, userId)- Validate vehicle ownership, validate subtypes match category, create record -
getRecords(userId, filters?)- Get user's records, apply filters (vehicleId, category, dateRange) -
getRecord(id, userId)- Get single record with ownership check -
updateRecord(id, data, userId)- Update with validation -
deleteRecord(id, userId)- Soft delete or hard delete -
getRecordsByVehicle(vehicleId, userId)- Vehicle-specific records -
createSchedule(data, userId)- Create recurring schedule, calculate initial next_due -
getSchedules(userId, filters?)- Get schedules with filters -
updateSchedule(id, data, userId)- Update schedule, recalculate next_due if intervals change -
deleteSchedule(id, userId)- Remove schedule -
getUpcomingMaintenance(vehicleId, userId)- Get schedules that are due soon or overdue -
calculateNextDue(schedule, currentDate, currentMileage)- Calculate next due date/mileage based on intervals
Cache Strategy:
- Records:
maintenance:records:user:{userId}- 5 min TTL - Vehicle records:
maintenance:records:vehicle:{vehicleId}- 5 min TTL - Schedules:
maintenance:schedules:vehicle:{vehicleId}- 5 min TTL - Upcoming:
maintenance:upcoming:{vehicleId}- 1 hour TTL
Phase 2: Data Layer
File: backend/src/features/maintenance/data/maintenance.repository.ts
Key methods (all use prepared statements, all filter by user_id):
-
insert(record)- INSERT with PostgreSQL array for subtypes -
findById(id, userId)- SELECT with user_id check -
findByUserId(userId)- SELECT user's records -
findByVehicleId(vehicleId, userId)- SELECT vehicle records with ownership check -
update(id, userId, data)- UPDATE with user_id check -
delete(id, userId)- DELETE with user_id check -
insertSchedule(schedule)- INSERT schedule -
findSchedulesByVehicle(vehicleId, userId)- SELECT vehicle schedules -
updateSchedule(id, userId, data)- UPDATE schedule -
deleteSchedule(id, userId)- DELETE schedule -
findDueSchedules(vehicleId, userId, currentDate, currentMileage)- Complex query for due/overdue
PostgreSQL Array Handling:
// Insert with array
await pool.query(
'INSERT INTO maintenance_records (subtypes, ...) VALUES ($1, ...)',
[[subtype1, subtype2, subtype3], ...]
);
// Query with array contains
await pool.query(
'SELECT * FROM maintenance_records WHERE $1 = ANY(subtypes)',
[searchSubtype]
);
Phase 3: API Layer
File: backend/src/features/maintenance/api/maintenance.routes.ts
import { FastifyInstance } from 'fastify';
import { MaintenanceController } from './maintenance.controller';
export async function maintenanceRoutes(app: FastifyInstance) {
const controller = new MaintenanceController();
// All routes require authentication
app.addHook('preHandler', app.authenticate);
// Maintenance Records
app.post('/maintenance/records', controller.createRecord.bind(controller));
app.get('/maintenance/records', controller.listRecords.bind(controller));
app.get('/maintenance/records/:id', controller.getRecord.bind(controller));
app.put('/maintenance/records/:id', controller.updateRecord.bind(controller));
app.delete('/maintenance/records/:id', controller.deleteRecord.bind(controller));
app.get('/maintenance/records/vehicle/:vehicleId', controller.getRecordsByVehicle.bind(controller));
// Maintenance Schedules
app.post('/maintenance/schedules', controller.createSchedule.bind(controller));
app.get('/maintenance/schedules', controller.listSchedules.bind(controller));
app.get('/maintenance/schedules/vehicle/:vehicleId', controller.getSchedulesByVehicle.bind(controller));
app.put('/maintenance/schedules/:id', controller.updateSchedule.bind(controller));
app.delete('/maintenance/schedules/:id', controller.deleteSchedule.bind(controller));
// Utility endpoints
app.get('/maintenance/upcoming/:vehicleId', controller.getUpcomingMaintenance.bind(controller));
app.get('/maintenance/subtypes/:category', controller.getSubtypes.bind(controller));
}
File: backend/src/features/maintenance/api/maintenance.controller.ts
Follow pattern from backend/src/features/documents/api/documents.controller.ts:
- Extract userId from
request.user.sub - Use structured logging with logger
- Return proper HTTP status codes (201 for create, 200 for success, 404 for not found, etc.)
- Handle errors gracefully
File: backend/src/features/maintenance/api/maintenance.validation.ts
Use validation schemas (Fastify schema or Zod):
- Validate category is valid enum
- Validate subtypes is non-empty array
- Validate subtypes match category (server-side validation)
- Validate dates, numeric values
- Validate UUIDs
File: backend/src/features/maintenance/index.ts
export { maintenanceRoutes } from './api/maintenance.routes';
export * from './domain/maintenance.types';
File: backend/src/app.ts
Update to register routes (remove lines 118-134 placeholder):
import { maintenanceRoutes } from './features/maintenance';
// ... in buildApp()
await app.register(maintenanceRoutes, { prefix: '/api' });
Phase 4: Testing
File: backend/src/features/maintenance/tests/unit/maintenance.service.test.ts
Test cases:
- Create record with valid data
- Reject invalid category
- Reject invalid subtypes for category
- Reject empty subtypes array
- Calculate next due date correctly
- Identify due soon vs overdue
- Handle edge cases (no previous service, etc.)
File: backend/src/features/maintenance/tests/integration/maintenance.integration.test.ts
Test full API workflow:
- Create, read, update, delete records
- Create and manage schedules
- Get upcoming maintenance
- Test authentication (reject without token)
- Test authorization (reject access to other user's data)
- Test PostgreSQL array operations
Frontend Implementation
File Structure
frontend/src/features/maintenance/
├── types/
│ └── maintenance.types.ts # Mirror backend types
├── api/
│ └── maintenance.api.ts # API client
├── hooks/
│ ├── useMaintenanceRecords.ts # Records query/mutation
│ ├── useMaintenanceSchedules.ts # Schedules query/mutation
│ └── useUpcomingMaintenance.ts # Upcoming items
├── components/ # Desktop components
│ ├── MaintenanceRecordForm.tsx
│ ├── MaintenanceRecordsList.tsx
│ ├── MaintenanceRecordDetail.tsx
│ ├── MaintenanceScheduleForm.tsx
│ ├── MaintenanceSchedulesList.tsx
│ ├── UpcomingMaintenanceCard.tsx
│ └── SubtypeCheckboxGroup.tsx # Reusable checkbox component
├── mobile/ # Mobile components
│ └── MaintenanceMobileScreen.tsx
└── pages/
└── MaintenancePage.tsx # Desktop page
Phase 5: Frontend Types and API
File: frontend/src/features/maintenance/types/maintenance.types.ts
Copy types from backend, export constants for dropdowns.
File: frontend/src/features/maintenance/api/maintenance.api.ts
import { api } from '../../../core/api/client';
export const maintenanceApi = {
// Records
createRecord: (data: CreateMaintenanceRecordRequest) =>
api.post('/api/maintenance/records', data),
getRecords: () =>
api.get<MaintenanceRecordResponse[]>('/api/maintenance/records'),
getRecord: (id: string) =>
api.get<MaintenanceRecordResponse>(`/api/maintenance/records/${id}`),
updateRecord: (id: string, data: UpdateMaintenanceRecordRequest) =>
api.put(`/api/maintenance/records/${id}`, data),
deleteRecord: (id: string) =>
api.delete(`/api/maintenance/records/${id}`),
getRecordsByVehicle: (vehicleId: string) =>
api.get<MaintenanceRecordResponse[]>(`/api/maintenance/records/vehicle/${vehicleId}`),
// Schedules
createSchedule: (data: CreateScheduleRequest) =>
api.post('/api/maintenance/schedules', data),
getSchedules: () =>
api.get<MaintenanceScheduleResponse[]>('/api/maintenance/schedules'),
getSchedulesByVehicle: (vehicleId: string) =>
api.get<MaintenanceScheduleResponse[]>(`/api/maintenance/schedules/vehicle/${vehicleId}`),
updateSchedule: (id: string, data: UpdateScheduleRequest) =>
api.put(`/api/maintenance/schedules/${id}`, data),
deleteSchedule: (id: string) =>
api.delete(`/api/maintenance/schedules/${id}`),
// Utility
getUpcoming: (vehicleId: string) =>
api.get<MaintenanceScheduleResponse[]>(`/api/maintenance/upcoming/${vehicleId}`),
getSubtypes: (category: MaintenanceCategory) =>
api.get<string[]>(`/api/maintenance/subtypes/${category}`)
};
Phase 6: React Hooks
File: frontend/src/features/maintenance/hooks/useMaintenanceRecords.ts
Use React Query pattern from fuel-logs:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { maintenanceApi } from '../api/maintenance.api';
export function useMaintenanceRecords(vehicleId?: string) {
const queryClient = useQueryClient();
const { data: records, isLoading, error } = useQuery({
queryKey: vehicleId ? ['maintenance-records', vehicleId] : ['maintenance-records'],
queryFn: () => vehicleId
? maintenanceApi.getRecordsByVehicle(vehicleId)
: maintenanceApi.getRecords()
});
const createMutation = useMutation({
mutationFn: maintenanceApi.createRecord,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenance-records'] });
}
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateMaintenanceRecordRequest }) =>
maintenanceApi.updateRecord(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenance-records'] });
}
});
const deleteMutation = useMutation({
mutationFn: maintenanceApi.deleteRecord,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenance-records'] });
}
});
return {
records,
isLoading,
error,
createRecord: createMutation.mutate,
updateRecord: updateMutation.mutate,
deleteRecord: deleteMutation.mutate
};
}
Similar hooks for schedules and upcoming maintenance.
Phase 7: Desktop Components
File: frontend/src/features/maintenance/components/SubtypeCheckboxGroup.tsx
Reusable component for subtype selection:
interface SubtypeCheckboxGroupProps {
category: MaintenanceCategory;
selectedSubtypes: string[];
onChange: (subtypes: string[]) => void;
}
export function SubtypeCheckboxGroup({ category, selectedSubtypes, onChange }: SubtypeCheckboxGroupProps) {
const subtypes = getSubtypesForCategory(category);
const handleToggle = (subtype: string) => {
if (selectedSubtypes.includes(subtype)) {
onChange(selectedSubtypes.filter(s => s !== subtype));
} else {
onChange([...selectedSubtypes, subtype]);
}
};
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px' }}>
{subtypes.map(subtype => (
<FormControlLabel
key={subtype}
control={
<Checkbox
checked={selectedSubtypes.includes(subtype)}
onChange={() => handleToggle(subtype)}
/>
}
label={subtype}
/>
))}
</div>
);
}
File: frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx
Form structure:
- Category dropdown (Routine Maintenance, Repair, Performance Upgrade)
- SubtypeCheckboxGroup (dynamically shows based on category)
- Date picker
- Odometer input (optional)
- Cost input (optional)
- Shop name input (optional)
- Notes textarea (optional)
- Submit and Cancel buttons
Validation:
- Category required
- At least one subtype required
- Selected subtypes must match category
- Date required
- Show error messages inline
File: frontend/src/features/maintenance/components/MaintenanceRecordsList.tsx
Table or card list showing:
- Date (sortable, default newest first)
- Category with count: "Routine Maintenance (3)"
- Odometer reading
- Cost
- Shop name
- Actions (view details, edit, delete)
Click row to navigate to detail view.
File: frontend/src/features/maintenance/components/MaintenanceRecordDetail.tsx
Full detail view:
- All fields displayed
- Subtypes shown as chips/badges
- Edit and Delete buttons
- Back button
File: frontend/src/features/maintenance/pages/MaintenancePage.tsx
Tabbed interface:
- Records Tab: List of completed maintenance
- Schedules Tab: Recurring maintenance schedules
- Upcoming Tab: Due soon and overdue items
Include:
- Vehicle selector (dropdown)
- Add new record/schedule buttons
- Filters (category, date range)
Phase 8: Mobile Components
File: frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx
Mobile-optimized design:
- GlassCard components (match existing pattern from documents/fuel-logs)
- Touch-friendly form inputs
- Large checkboxes (min 44x44px touch target)
- Single column layout
- Bottom sheet or full-screen modal for add/edit forms
- Swipe actions for delete
- Pull to refresh
Collapsible sections:
- Tap category to expand/collapse subtype checkboxes
- Accordion style to save vertical space
Mobile-specific considerations:
- Virtual scrolling for long lists
- Optimistic updates for instant feedback
- Loading skeletons
- Error boundaries
Phase 9: Route Integration
File: frontend/src/App.tsx
Desktop routes (update line 554):
import { lazy } from 'react';
const MaintenancePage = lazy(() => import('./features/maintenance/pages/MaintenancePage').then(m => ({ default: m.MaintenancePage })));
// In Routes:
<Route path="/maintenance" element={<MaintenancePage />} />
Mobile navigation:
- Maintenance already in Layout.tsx navigation (line 42)
- Consider if it should be in bottom nav or remain in hamburger menu
- If adding to bottom nav, update mobile nav items in App.tsx
Display Format Guidelines
List View (Records)
┌─────────────────────────────────────────┐
│ Jan 15, 2024 │
│ Routine Maintenance (3) │
│ 45,230 miles | $127.50 | Joe's Auto │
└─────────────────────────────────────────┘
Detail View (Full Record)
Date: January 15, 2024
Category: Routine Maintenance
Subtypes:
• Engine Oil
• Air Filter Element
• Cabin Air Filter / Purifier
Odometer: 45,230 miles
Cost: $127.50
Shop: Joe's Auto Service
Notes: Used synthetic 5W-30 oil. Recommended tire rotation at next visit.
Upcoming Maintenance (Color-Coded)
🟢 Good - Not due yet
🟡 Due Soon - Within 30 days or 500 miles
🔴 Overdue - Past due date or mileage
Business Rules
Validation Rules
- Category must be one of: routine_maintenance, repair, performance_upgrade
- Subtypes must be non-empty array
- All subtypes must be valid for the selected category
- Date required for records
- Vehicle must belong to user (ownership check)
- Interval (months OR miles OR both) required for schedules
Next Due Calculation (Schedules)
If interval_months AND interval_miles both set:
next_due_date = last_service_date + interval_months
next_due_mileage = last_service_mileage + interval_miles
Due when EITHER condition is met (whichever comes first)
If only interval_months:
next_due_date = last_service_date + interval_months
next_due_mileage = null
If only interval_miles:
next_due_date = null
next_due_mileage = last_service_mileage + interval_miles
Due Soon Logic
Due Soon (Yellow):
- next_due_date within 30 days of today
- OR next_due_mileage within 500 miles of current odometer
Overdue (Red):
- next_due_date in the past
- OR next_due_mileage < current odometer
Security Requirements
- All queries user-scoped: Every database query MUST filter by user_id
- Vehicle ownership: Validate user owns vehicle before any operation
- Prepared statements: NEVER concatenate SQL strings
- Authentication: All routes require valid JWT token
- Authorization: Users can only access their own data
Example repository pattern:
async findByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceRecord[]> {
// CORRECT - filters by both vehicle_id AND user_id
const result = await pool.query(
'SELECT * FROM maintenance_records WHERE vehicle_id = $1 AND user_id = $2',
[vehicleId, userId]
);
return result.rows;
}
Caching Strategy
Use Redis for caching:
// Records cache - 5 minutes
const cacheKey = `maintenance:records:user:${userId}`;
const ttl = 300; // 5 minutes
// Vehicle-specific cache - 5 minutes
const vehicleCacheKey = `maintenance:records:vehicle:${vehicleId}`;
// Upcoming maintenance - 1 hour (less frequently changing)
const upcomingCacheKey = `maintenance:upcoming:${vehicleId}`;
const upcomingTTL = 3600; // 1 hour
// Invalidate on create/update/delete
await cacheService.del(cacheKey);
await cacheService.del(vehicleCacheKey);
await cacheService.del(upcomingCacheKey);
Testing Strategy
Backend Tests
Unit Tests (backend/src/features/maintenance/tests/unit/)
- Test service methods with mocked repository
- Test validation logic (category, subtypes)
- Test next due calculation
- Test due soon/overdue logic
- Test edge cases (no previous service, missing data)
Integration Tests (backend/src/features/maintenance/tests/integration/)
- Test full API endpoints with test database
- Test authentication (401 without token)
- Test authorization (403 for other user's data)
- Test PostgreSQL array operations
- Test cascade deletes (vehicle deletion)
Run tests:
make shell-backend
npm test -- features/maintenance
Frontend Tests
Test components:
- Form validation (category, subtypes)
- Checkbox selection/deselection
- Mobile touch interactions
- Responsive layout
Manual Testing (Docker-Only)
- After each change:
make rebuild - Test mobile viewport: 375px width
- Test desktop viewport: 1920px width
- Test touch interactions on mobile
- Verify all linting hooks pass (zero tolerance)
Documentation Requirements
File: backend/src/features/maintenance/README.md
Follow pattern from fuel-logs README:
# Maintenance Feature Capsule
## Quick Summary (50 tokens)
Tracks vehicle maintenance including routine service, repairs, and performance upgrades. Supports multiple subtypes per record, recurring schedules, and upcoming/overdue calculations. User-scoped data with vehicle ownership enforcement.
## API Endpoints
[List all endpoints with descriptions]
## Structure
- **api/** - HTTP endpoints, routes, validators
- **domain/** - Business logic, types, rules
- **data/** - Repository, database queries
- **migrations/** - Feature-specific schema
- **tests/** - All feature tests
## Categories and Subtypes
[List all three categories and their subtypes]
## Dependencies
- Internal: core/auth, core/cache, core/config
- Database: maintenance_records, maintenance_schedules tables
- Feature: vehicles (vehicle_id FK)
## Business Rules
[Document validation, calculation logic, etc.]
## Testing
[Document test commands and examples]
Success Criteria Checklist
- Database migrations run cleanly (
make migrate) - Backend compiles without errors
- All backend unit tests pass
- All backend integration tests pass
- All TypeScript types are correct (no
anytypes) - All linting rules pass (ESLint, Prettier)
- Category dropdown works correctly
- Subtype checkboxes populate based on selected category
- Multiple subtypes can be selected
- Subtype validation prevents invalid selections
- Records display as "Category (count)" in list view
- Detail view shows all selected subtypes
- Schedule creation works
- Next due date calculation is correct
- Upcoming maintenance shows correct items
- Works on mobile (375px viewport)
- Touch targets are 44x44px minimum on mobile
- Works on desktop (1920px viewport)
- Responsive between mobile and desktop breakpoints
- No console errors
- No TODOs remaining in code
- README.md is complete and accurate
- Feature is registered in backend app.ts
- Feature is registered in frontend App.tsx routes
Common Pitfalls to Avoid
- PostgreSQL Arrays: Use proper array syntax
TEXT[]and array operations= ANY(subtypes) - User Scoping: NEVER forget
AND user_id = $Xin queries - Category Validation: Server-side validation is required (don't trust client)
- Empty Subtypes: Validate array is non-empty before saving
- Mobile Touch Targets: Checkboxes must be 44x44px minimum
- Cache Invalidation: Invalidate ALL relevant cache keys on update
- String Concatenation: NEVER concatenate SQL strings - use prepared statements
- Type Safety: Don't use
anytypes - define proper interfaces
Reference Files
For implementation patterns, refer to these existing features:
- Documents Feature:
backend/src/features/documents/(most recent, best example) - Fuel Logs Feature:
backend/src/features/fuel-logs/(similar complexity) - Documents Frontend:
frontend/src/features/documents/(mobile + desktop patterns)
Implementation Order
- Backend database migration
- Backend domain types
- Backend repository
- Backend service
- Backend API (routes, controller, validation)
- Backend tests
- Register routes in app.ts
- Frontend types
- Frontend API client
- Frontend hooks
- Desktop components (form, list, detail)
- Desktop page with tabs
- Mobile components
- Update routes in App.tsx
- Manual testing (docker rebuild)
- Documentation (README.md)
- Final validation (all criteria met)