Trip Planner State Machine Design¶
Purpose: Define explicit states, transitions, and invariants for trip lifecycle management.
Status: Design document (not yet implemented)
Inspiration: vault-platform trigger state machine patterns
Overview¶
Trips currently have a status column with CHECK constraint but no transition logic. Any status can change to any other status, which allows invalid states (e.g., completed → planning).
This document defines: 1. Valid states and their meaning 2. Allowed transitions between states 3. Invariants that must hold 4. Implementation approach
Current State¶
Schema (from 001_initial_schema.sql)¶
status TEXT DEFAULT 'planning' CHECK (status IN ('planning', 'booked', 'in_progress', 'completed', 'cancelled'))
Current Behavior¶
- Trips created with
status: 'planning' - Status can be updated to any valid value via
PATCH /api/trips/[id] - No transition validation
- No side effects on transition
State Definitions¶
┌─────────────────────────────────────────────────────────────────────┐
│ TRIP STATES │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ planning │────▶│ booked │────▶│in_progress────▶│completed │ │
│ │ │ │ │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ cancelled │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────┐ │
│ │ archived │ (Future: completed trips after 90 days) │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
State Descriptions¶
| State | Description | User Sees | Editable |
|---|---|---|---|
planning |
Initial state, trip is being designed | "Planning" | Full edit |
booked |
Reservations made, dates confirmed | "Booked" | Limited edit |
in_progress |
Trip is happening now | "In Progress" | Add notes only |
completed |
Trip finished | "Completed" | Read-only |
cancelled |
Trip abandoned | "Cancelled" | Read-only |
archived |
(Future) Old completed trips | Hidden by default | Read-only |
Transition Rules¶
Valid Transitions¶
| From | To | Trigger | Validation |
|---|---|---|---|
planning |
booked |
User action | Requires: title, start_date, end_date |
planning |
cancelled |
User action | None |
booked |
in_progress |
Auto or user | start_date <= today |
booked |
planning |
User action | None (downgrade allowed) |
booked |
cancelled |
User action | None |
in_progress |
completed |
Auto or user | end_date < today |
in_progress |
cancelled |
User action | None (emergency) |
completed |
archived |
Auto (90 days) | completed_at + 90 days < today |
Invalid Transitions (Blocked)¶
| From | To | Reason |
|---|---|---|
completed |
planning |
Trip already happened |
completed |
booked |
Trip already happened |
completed |
in_progress |
Trip already happened |
cancelled |
Any except planning |
Must reactivate first |
in_progress |
planning |
Trip already started |
in_progress |
booked |
Trip already started |
archived |
Any | Permanent state |
Transition Matrix¶
planning booked in_progress completed cancelled archived
planning - ✓ ✗ ✗ ✓ ✗
booked ✓ - ✓ ✗ ✓ ✗
in_progress ✗ ✗ - ✓ ✓ ✗
completed ✗ ✗ ✗ - ✗ ✓
cancelled ✓ ✗ ✗ ✗ - ✗
archived ✗ ✗ ✗ ✗ ✗ -
Invariants¶
State Invariants¶
| ID | Invariant | Verification |
|---|---|---|
| SM-01 | booked requires start_date and end_date |
Transition validation |
| SM-02 | in_progress requires start_date <= CURRENT_DATE |
Transition validation |
| SM-03 | completed requires end_date < CURRENT_DATE |
Transition validation |
| SM-04 | cancelled and completed trips are read-only |
API enforcement |
| SM-05 | Only forward transitions from in_progress |
Transition validation |
| SM-06 | completed_at is set when entering completed |
Trigger or app logic |
Data Invariants¶
| ID | Invariant | Verification |
|---|---|---|
| SM-07 | end_date >= start_date when both set |
CHECK constraint |
| SM-08 | Itineraries exist for trips in booked+ states |
Application warning |
| SM-09 | Locations reference valid itineraries | FK constraint |
Implementation Approach¶
Option A: Application-Level Validation (Recommended for MVP)¶
Add transition validation in the API route:
// lib/trip-state-machine.ts
export const VALID_TRANSITIONS: Record<string, string[]> = {
planning: ['booked', 'cancelled'],
booked: ['planning', 'in_progress', 'cancelled'],
in_progress: ['completed', 'cancelled'],
completed: ['archived'],
cancelled: ['planning'],
archived: [],
}
export function canTransition(from: string, to: string): boolean {
return VALID_TRANSITIONS[from]?.includes(to) ?? false
}
export function validateTransition(
trip: { status: string; start_date?: string; end_date?: string },
newStatus: string
): { valid: boolean; error?: string } {
// Check if transition is allowed
if (!canTransition(trip.status, newStatus)) {
return {
valid: false,
error: `Cannot transition from '${trip.status}' to '${newStatus}'`
}
}
// Validate preconditions
const today = new Date().toISOString().split('T')[0]
if (newStatus === 'booked') {
if (!trip.start_date || !trip.end_date) {
return { valid: false, error: 'Booked trips require start and end dates' }
}
}
if (newStatus === 'in_progress') {
if (!trip.start_date || trip.start_date > today) {
return { valid: false, error: 'Trip cannot start before start_date' }
}
}
if (newStatus === 'completed') {
if (!trip.end_date || trip.end_date >= today) {
return { valid: false, error: 'Trip cannot complete before end_date' }
}
}
return { valid: true }
}
Usage in API route:
// app/api/trips/[id]/route.ts
import { validateTransition } from '@/lib/trip-state-machine'
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const supabase = await createClient()
// ... auth check ...
const updates = await request.json()
// If status is being updated, validate transition
if (updates.status) {
// Fetch current trip
const { data: trip } = await supabase
.from('trips')
.select('status, start_date, end_date')
.eq('id', id)
.single()
if (!trip) {
return Response.json({ error: 'Trip not found' }, { status: 404 })
}
const validation = validateTransition(trip, updates.status)
if (!validation.valid) {
return Response.json({ error: validation.error }, { status: 400 })
}
// Add transition metadata
if (updates.status === 'completed') {
updates.completed_at = new Date().toISOString()
}
}
// ... continue with update ...
}
Option B: Database-Level Enforcement (Future)¶
Add a trigger function for stronger guarantees:
-- Migration: add_trip_status_transition_validation.sql
CREATE OR REPLACE FUNCTION trip_planner.validate_trip_status_transition()
RETURNS TRIGGER AS $$
DECLARE
valid_transitions text[][];
BEGIN
-- Define valid transitions
valid_transitions := ARRAY[
ARRAY['planning', 'booked'],
ARRAY['planning', 'cancelled'],
ARRAY['booked', 'planning'],
ARRAY['booked', 'in_progress'],
ARRAY['booked', 'cancelled'],
ARRAY['in_progress', 'completed'],
ARRAY['in_progress', 'cancelled'],
ARRAY['completed', 'archived'],
ARRAY['cancelled', 'planning']
];
-- Skip if status not changing
IF OLD.status = NEW.status THEN
RETURN NEW;
END IF;
-- Check if transition is valid
IF NOT ARRAY[ARRAY[OLD.status, NEW.status]] <@ valid_transitions THEN
RAISE EXCEPTION 'Invalid status transition from % to %', OLD.status, NEW.status;
END IF;
-- Validate preconditions
IF NEW.status = 'booked' AND (NEW.start_date IS NULL OR NEW.end_date IS NULL) THEN
RAISE EXCEPTION 'Booked trips require start and end dates';
END IF;
IF NEW.status = 'in_progress' AND NEW.start_date > CURRENT_DATE THEN
RAISE EXCEPTION 'Trip cannot start before start_date';
END IF;
IF NEW.status = 'completed' AND NEW.end_date >= CURRENT_DATE THEN
RAISE EXCEPTION 'Trip cannot complete before end_date';
END IF;
-- Set completed_at timestamp
IF NEW.status = 'completed' AND OLD.status != 'completed' THEN
NEW.completed_at := NOW();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trip_status_transition_check
BEFORE UPDATE ON trip_planner.trips
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION trip_planner.validate_trip_status_transition();
Option C: Hybrid (Recommended for Production)¶
- Application-level validation for fast feedback and good error messages
- Database trigger as safety net (catches direct SQL updates)
- Audit log for transition history
Automatic Transitions¶
Auto-Start Trip (booked → in_progress)¶
When start_date is reached and trip is booked:
Option 1: Cron job / scheduled function
// Run daily at midnight
async function autoStartTrips() {
const today = new Date().toISOString().split('T')[0]
await supabase
.from('trips')
.update({ status: 'in_progress' })
.eq('status', 'booked')
.lte('start_date', today)
}
Option 2: Check on page load
// In trip detail page
useEffect(() => {
if (trip.status === 'booked' && trip.start_date <= today) {
updateTripStatus(trip.id, 'in_progress')
}
}, [trip])
Auto-Complete Trip (in_progress → completed)¶
When end_date has passed and trip is in_progress:
Same options as auto-start.
Auto-Archive (completed → archived)¶
After 90 days in completed state:
// Run weekly
async function autoArchiveTrips() {
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - 90)
await supabase
.from('trips')
.update({ status: 'archived' })
.eq('status', 'completed')
.lt('completed_at', cutoff.toISOString())
}
UI Considerations¶
Status Badge Colors¶
| Status | Color | Icon |
|---|---|---|
planning |
Blue | Pencil |
booked |
Green | CalendarCheck |
in_progress |
Orange | Plane |
completed |
Gray | CheckCircle |
cancelled |
Red | XCircle |
archived |
Light Gray | Archive |
Status Actions¶
| Status | Available Actions |
|---|---|
planning |
Edit, Book, Cancel, Delete |
booked |
Edit (limited), Start, Unbook, Cancel |
in_progress |
Add Notes, Complete, Cancel |
completed |
View, Export, Archive |
cancelled |
Reactivate, Delete |
archived |
View only |
Edit Restrictions¶
| Status | What Can Be Edited |
|---|---|
planning |
Everything |
booked |
Budget, notes, itinerary details (not dates) |
in_progress |
Notes, actual costs, photos |
completed |
Nothing (read-only) |
cancelled |
Nothing (read-only) |
Migration Plan¶
Phase 1: Application Validation (Low Risk)¶
- Add
lib/trip-state-machine.ts - Update
app/api/trips/[id]/route.tsto validate transitions - Add UI feedback for invalid transitions
- Test all transition paths
Phase 2: Schema Enhancement (Medium Risk)¶
-
Add
completed_atcolumn: -
Add date validation constraint:
Phase 3: Database Trigger (Optional)¶
- Add transition validation trigger
- Test with existing data
- Add audit table for transition history (if needed)
Phase 4: Automatic Transitions (Optional)¶
- Implement auto-start logic
- Implement auto-complete logic
- Add user preference for auto-transitions
Testing Checklist¶
Unit Tests¶
- [ ]
canTransition()returns correct boolean for all state pairs - [ ]
validateTransition()enforces date preconditions - [ ] Invalid transitions are rejected
Integration Tests¶
- [ ] Create trip (starts in
planning) - [ ] Book trip (requires dates)
- [ ] Start trip (requires
start_date <= today) - [ ] Complete trip (requires
end_date < today) - [ ] Cancel from each state
- [ ] Reactivate cancelled trip
- [ ] Cannot go backwards from
completed
Edge Cases¶
- [ ] Trip with
start_date= today - [ ] Trip with
end_date= today - [ ] Timezone handling for date comparisons
- [ ] Concurrent updates to same trip
Related Documentation¶
- App Invariants - TP-02 status constraint
- Automated PR Review - State machine validation in reviews
- vault-platform/04-trigger-state-machine.md - Inspiration
This design document should be reviewed before implementation. The MVP approach (Option A) is recommended to validate the UX before adding database-level enforcement.