Skip to content

Commit 63e6ba2

Browse files
committed
Implement recurring rides
1 parent b5a75a8 commit 63e6ba2

28 files changed

Lines changed: 9069 additions & 12464 deletions

File tree

frontend/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
"main": "index.js",
66
"dependencies": {
77
"@carriage-web/shared": "workspace:*",
8-
"react-scripts": "^5.0.1",
9-
"@tailwindcss/vite": "^4.2.2"
8+
"react-scripts": "^5.0.1"
109
},
1110
"scripts": {
1211
"dev": "vite",
@@ -36,6 +35,7 @@
3635
"@mui/utils": "^7.3.6",
3736
"@mui/x-date-pickers": "^7.23.1",
3837
"@react-aria/utils": "^3.25.2",
38+
"@tailwindcss/vite": "^4.2.2",
3939
"@types/crypto-js": "^4.2.2",
4040
"@types/google.maps": "^3.58.1",
4141
"@types/node": "^22.5.5",

frontend/src/components/RideDetails/RideActions.tsx

Lines changed: 68 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const RideActions: React.FC<RideActionsProps> = ({
6565
const [contactAdminOpen, setContactAdminOpen] = useState(false);
6666
const [updating, setUpdating] = useState(false);
6767
const [saving, setSaving] = useState(false);
68+
const [scopeDialogMode, setScopeDialogMode] = useState<'save' | 'cancel' | null>(null);
6869

6970
const ride = editedRide!; // We know this exists from the context
7071
const rideCompleted = ride.status === Status.COMPLETED;
@@ -91,32 +92,26 @@ const RideActions: React.FC<RideActionsProps> = ({
9192
};
9293

9394
const handleCancel = () => {
94-
setCancelConfirmOpen(true);
95-
};
96-
97-
const handleCancelConfirm = async () => {
98-
// Check for recurring rides (not supported yet)
99-
if (ride.isRecurring) {
100-
showToast('Recurring ride deletion not supported yet', ToastStatus.ERROR);
101-
return;
95+
// Recurring rides need scope selection before cancelling
96+
if (ride.recurrenceId) {
97+
setScopeDialogMode('cancel');
98+
} else {
99+
setCancelConfirmOpen(true);
102100
}
101+
};
103102

103+
const handleCancelConfirm = async (scope?: 'single' | 'future') => {
104104
try {
105-
// Call the DELETE endpoint like DeleteOrEditTypeModal does
106-
await axios.delete(`/api/rides/${ride.id}`);
105+
const url = scope
106+
? `/api/rides/${ride.id}?scope=${scope}`
107+
: `/api/rides/${ride.id}`;
108+
await axios.delete(url);
107109

108-
// Close the cancel confirmation modal
109110
setCancelConfirmOpen(false);
111+
setScopeDialogMode(null);
110112

111-
// Close the main ride details dialog since the ride no longer exists
112-
if (onClose) {
113-
onClose();
114-
}
115-
116-
// Refresh the rides data
113+
if (onClose) onClose();
117114
refreshRides();
118-
119-
// Show success message
120115
showToast('Ride Cancelled', ToastStatus.SUCCESS);
121116
} catch (error) {
122117
console.error('Failed to cancel ride:', error);
@@ -126,34 +121,35 @@ const RideActions: React.FC<RideActionsProps> = ({
126121

127122
const handleEdit = () => {
128123
if (isEditing) {
129-
// Save changes
130124
handleSave();
131125
} else {
132-
// Start editing
133126
startEditing();
134127
}
135128
};
136129

137-
const handleSave = async () => {
130+
const handleSave = async (scope?: 'single' | 'future') => {
131+
// If ride is recurring and no scope decided yet, show scope dialog
132+
if (ride.recurrenceId && scope === undefined) {
133+
setScopeDialogMode('save');
134+
return;
135+
}
136+
138137
setSaving(true);
139138
try {
140-
const success = await saveChanges();
139+
const success = await saveChanges(scope);
141140
if (success) {
142141
const message = isNewRide(ride)
143142
? 'Ride created successfully'
144143
: 'Ride saved successfully';
145144
showToast(message, ToastStatus.SUCCESS);
146145

147-
// Only refresh rides if the ride is on the current date being displayed in the context
148146
const rideDate = new Date(ride.startTime).toDateString();
149147
const contextDate = curDate.toDateString();
150148
if (rideDate === contextDate) {
151149
refreshRides();
152150
}
153151

154-
if (onClose) {
155-
onClose(); // Close modal after creating new ride
156-
}
152+
if (onClose) onClose();
157153
} else {
158154
const message = isNewRide(ride)
159155
? 'Failed to create ride'
@@ -168,6 +164,7 @@ const RideActions: React.FC<RideActionsProps> = ({
168164
showToast(message, ToastStatus.ERROR);
169165
} finally {
170166
setSaving(false);
167+
setScopeDialogMode(null);
171168
}
172169
};
173170

@@ -422,7 +419,7 @@ const RideActions: React.FC<RideActionsProps> = ({
422419
<DialogActions>
423420
<Button onClick={() => setCancelConfirmOpen(false)}>Keep Ride</Button>
424421
<Button
425-
onClick={handleCancelConfirm}
422+
onClick={() => handleCancelConfirm()}
426423
variant="contained"
427424
color="error"
428425
>
@@ -431,6 +428,49 @@ const RideActions: React.FC<RideActionsProps> = ({
431428
</DialogActions>
432429
</Dialog>
433430

431+
{/* Recurring Scope Dialog — shown when editing or cancelling a recurring ride */}
432+
<Dialog
433+
open={scopeDialogMode !== null}
434+
onClose={() => setScopeDialogMode(null)}
435+
maxWidth="xs"
436+
fullWidth
437+
>
438+
<DialogTitle>
439+
{scopeDialogMode === 'save' ? 'Edit Recurring Ride' : 'Cancel Recurring Ride'}
440+
</DialogTitle>
441+
<DialogContent>
442+
<Typography>
443+
{scopeDialogMode === 'save'
444+
? 'Do you want to edit just this ride, or this and all future rides in the series?'
445+
: 'Do you want to cancel just this ride, or this and all future rides in the series?'}
446+
</Typography>
447+
</DialogContent>
448+
<DialogActions>
449+
<Button onClick={() => setScopeDialogMode(null)}>Back</Button>
450+
<Button
451+
variant="outlined"
452+
onClick={() =>
453+
scopeDialogMode === 'save'
454+
? handleSave('single')
455+
: handleCancelConfirm('single')
456+
}
457+
>
458+
Just this ride
459+
</Button>
460+
<Button
461+
variant="contained"
462+
color={scopeDialogMode === 'cancel' ? 'error' : 'primary'}
463+
onClick={() =>
464+
scopeDialogMode === 'save'
465+
? handleSave('future')
466+
: handleCancelConfirm('future')
467+
}
468+
>
469+
This and all future rides
470+
</Button>
471+
</DialogActions>
472+
</Dialog>
473+
434474
{/* Contact Admin Modal */}
435475
<Dialog
436476
open={contactAdminOpen}

frontend/src/components/RideDetails/RideEditContext.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ interface RideEditContextType {
2525
startEditing: () => void;
2626
stopEditing: () => void;
2727
updateRideField: (field: keyof RideType, value: any) => void;
28-
saveChanges: () => Promise<boolean>;
28+
saveChanges: (scope?: 'single' | 'future') => Promise<boolean>;
2929
hasChanges: boolean;
3030
canEdit: boolean;
3131
userRole: UserRole;
@@ -143,7 +143,7 @@ export const RideEditProvider: React.FC<RideEditProviderProps> = ({
143143
return hasChangesResult;
144144
}, [editedRide, originalRide]);
145145

146-
const saveChanges = useCallback(async (): Promise<boolean> => {
146+
const saveChanges = useCallback(async (scope?: 'single' | 'future'): Promise<boolean> => {
147147
if (!editedRide) {
148148
return false;
149149
}
@@ -228,6 +228,9 @@ export const RideEditProvider: React.FC<RideEditProviderProps> = ({
228228
'type',
229229
'driver',
230230
'riders',
231+
'isRecurring',
232+
'recurrenceDays',
233+
'recurrenceEndDate',
231234
];
232235

233236
for (const field of fieldsToCheck) {
@@ -245,7 +248,7 @@ export const RideEditProvider: React.FC<RideEditProviderProps> = ({
245248
}
246249

247250
// Use optimistic update from RidesContext
248-
await updateRideInfo(ride.id, updatePayload);
251+
await updateRideInfo(ride.id, updatePayload, scope);
249252

250253
// Update the context with the optimistically updated ride
251254
if (onRideUpdated) {

frontend/src/components/RideDetails/RideOverview.tsx

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,6 @@ const getTemporalType = (ride: RideType): 'Past' | 'Active' | 'Upcoming' => {
5151
return 'Upcoming';
5252
};
5353

54-
// Helper function to format recurrence summary
55-
const getRecurrenceSummary = (ride: RideType): string => {
56-
if (!ride.isRecurring || !ride.rrule) return '';
57-
58-
// Basic RRULE parsing - in practice you might use a library like rrule
59-
if (ride.rrule.includes('FREQ=DAILY')) return 'Daily';
60-
if (ride.rrule.includes('FREQ=WEEKLY')) return 'Weekly';
61-
if (ride.rrule.includes('FREQ=MONTHLY')) return 'Monthly';
62-
return 'Custom recurrence';
63-
};
6454

6555
const getStatusColor = (
6656
status: Status
@@ -658,40 +648,51 @@ const RideOverview: React.FC<RideOverviewProps> = ({ userRole }) => {
658648
<FormControl size="small" sx={{ minWidth: 120 }}>
659649
<InputLabel>Repeat</InputLabel>
660650
<Select
661-
value={ride.isRecurring ? 'weekly' : 'none'}
662-
onChange={(e) =>
663-
updateRideField(
664-
'isRecurring',
665-
e.target.value !== 'none'
666-
)
667-
}
651+
value={(() => {
652+
if (!ride.isRecurring || !ride.recurrenceDays?.length) return 'none';
653+
const days = ride.recurrenceDays;
654+
if (days.length === 5 && [1,2,3,4,5].every(d => days.includes(d))) return 'daily';
655+
if (days.length === 1) return 'weekly';
656+
return 'custom';
657+
})()}
658+
onChange={(e) => {
659+
const val = e.target.value;
660+
if (val === 'none') {
661+
updateRideField('isRecurring', false);
662+
updateRideField('recurrenceDays', []);
663+
updateRideField('recurrenceEndDate', undefined);
664+
} else if (val === 'daily') {
665+
updateRideField('isRecurring', true);
666+
updateRideField('recurrenceDays', [1, 2, 3, 4, 5]);
667+
} else if (val === 'weekly') {
668+
updateRideField('isRecurring', true);
669+
updateRideField('recurrenceDays', [new Date(ride.startTime).getDay()]);
670+
} else if (val === 'custom') {
671+
updateRideField('isRecurring', true);
672+
}
673+
}}
668674
label="Repeat"
669675
>
670676
<MenuItem value="none">Does not repeat</MenuItem>
671-
<MenuItem value="daily">Daily</MenuItem>
677+
<MenuItem value="daily">Daily (Mon–Fri)</MenuItem>
672678
<MenuItem value="weekly">Weekly</MenuItem>
673-
<MenuItem value="monthly">Monthly</MenuItem>
674679
<MenuItem value="custom">Custom</MenuItem>
675680
</Select>
676681
</FormControl>
677682

678683
{ride.isRecurring && (
679-
<>
680-
<TextField
681-
label="End date (optional)"
682-
type="date"
683-
size="small"
684-
InputLabelProps={{ shrink: true }}
685-
placeholder="No end date"
686-
/>
687-
<Typography
688-
variant="caption"
689-
color="textSecondary"
690-
sx={{ fontStyle: 'italic' }}
691-
>
692-
Note: Full recurrence functionality coming soon
693-
</Typography>
694-
</>
684+
<TextField
685+
label="End date (optional)"
686+
type="date"
687+
size="small"
688+
InputLabelProps={{ shrink: true }}
689+
value={ride.recurrenceEndDate ? ride.recurrenceEndDate.split('T')[0] : ''}
690+
onChange={(e) => {
691+
const val = e.target.value;
692+
updateRideField('recurrenceEndDate', val ? new Date(val).toISOString() : undefined);
693+
}}
694+
placeholder="No end date"
695+
/>
695696
)}
696697
</div>
697698
) : (

frontend/src/context/RidesContext.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ type ridesState = {
2424
driverId?: string
2525
) => Promise<void>;
2626
assignDriver: (rideId: string, driverId: string) => Promise<void>;
27-
updateRideInfo: (rideId: string, updates: Partial<RideType>) => Promise<void>;
27+
updateRideInfo: (rideId: string, updates: Partial<RideType>, scope?: 'single' | 'future') => Promise<void>;
2828
createRide: (ride: Omit<RideType, 'id'>) => Promise<void>;
2929
cancelRide: (rideId: string) => Promise<void>;
3030
deleteRide: (rideId: string) => Promise<void>;
@@ -328,7 +328,7 @@ export const RidesProvider = ({ children }: RidesProviderProps) => {
328328
);
329329

330330
const updateRideInfo = useCallback(
331-
async (rideId: string, updates: Partial<RideType>) => {
331+
async (rideId: string, updates: Partial<RideType>, scope?: 'single' | 'future') => {
332332
let originalRide: RideType | undefined = getRideById(rideId);
333333

334334
// If ride not found in current context, fetch it from the server first
@@ -376,7 +376,7 @@ export const RidesProvider = ({ children }: RidesProviderProps) => {
376376
}
377377

378378
// Make API call
379-
const response = await axios.put(`/api/rides/${rideId}`, updates);
379+
const response = await axios.put(`/api/rides/${rideId}${scope ? `?scope=${scope}` : ''}`, updates);
380380
const serverRide = response.data.data;
381381
console.log(serverRide, 'serverride');
382382

0 commit comments

Comments
 (0)