Skip to content

Commit b6c04f1

Browse files
committed
feat: implement real-time updates for user views
1 parent 4d53d23 commit b6c04f1

16 files changed

Lines changed: 315 additions & 64 deletions

File tree

‎docs/upsun.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ Upsun is the hosting provider for `gatech.edu` websites.
1010
## Caveats
1111

1212
- We only have access to a `mongodb:4.0` service, so we have to pin Mongoose to `8.15.2` to be compatible
13-
- It is a **fixed** project, not flex
13+
- It is a **fixed** project, not flex

‎src/app/admin/RidesTable.tsx‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"use client";
22

3-
import React, { useEffect, useState } from "react";
3+
import React, { useCallback, useEffect, useState } from "react";
44
import BogTable from "@/components/BogTable/BogTable";
55
import BogDropdown from "@/components/BogDropdown/BogDropdown";
66
import BogButton from "@/components/BogButton/BogButton";
77
import { RIDE_COLUMNS } from "./admin-table-data";
88
import type { TableRow } from "@/components/BogTable/BogTable";
99
import ConfirmActionModal from "./ConfirmActionModal";
10+
import { useRouteUpdates } from "@/components/NotificationProvider/useRouteUpdates";
1011

1112
type RouteEntry = {
1213
_id: string;
@@ -48,11 +49,14 @@ export default function RidesTable() {
4849
const [cancelErrors, setCancelErrors] = useState<Record<string, string>>({});
4950
const [pendingCancelId, setPendingCancelId] = useState<string | null>(null);
5051

51-
useEffect(() => {
52-
let cancelled = false;
53-
const load = async () => {
54-
setLoading(true);
55-
setError(null);
52+
// `silent` refetches (real-time updates) keep the table on screen instead
53+
// of swapping in the loading state.
54+
const loadRides = useCallback(
55+
async (options?: { silent?: boolean; isCancelled?: () => boolean }) => {
56+
if (!options?.silent) {
57+
setLoading(true);
58+
setError(null);
59+
}
5660
try {
5761
const [routesRes, locationsRes, driversRes, vehiclesRes] =
5862
await Promise.all([
@@ -87,8 +91,9 @@ export default function RidesTable() {
8791
locMap[String(loc._id)] = String(loc.name);
8892
}
8993
}
90-
if (cancelled) return;
94+
if (options?.isCancelled?.()) return;
9195
setRoutes(requested);
96+
setError(null);
9297
setLocationMap(locMap);
9398
setDrivers(Array.isArray(driversData) ? driversData : []);
9499
setVehicles(Array.isArray(vehiclesData) ? vehiclesData : []);
@@ -97,12 +102,21 @@ export default function RidesTable() {
97102
} finally {
98103
setLoading(false);
99104
}
100-
};
101-
load();
105+
},
106+
[],
107+
);
108+
109+
useEffect(() => {
110+
let cancelled = false;
111+
void loadRides({ isCancelled: () => cancelled });
102112
return () => {
103113
cancelled = true;
104114
};
105-
}, []);
115+
}, [loadRides]);
116+
117+
useRouteUpdates(() => {
118+
void loadRides({ silent: true });
119+
});
106120

107121
const handleAssign = async (routeId: string) => {
108122
const driverLabel = driverLabels[routeId];

‎src/app/api/shifts/[driverId]/rides/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import RouteModel, { RouteStatus } from "@/server/db/models/RouteModel";
66
import UserModel from "@/server/db/models/UserModel";
77
import VehicleModel from "@/server/db/models/VehicleModel";
88
import mongoose from "mongoose";
9+
import { emitRouteUpdate } from "@/server/notifications/emitRouteUpdate";
910

1011
export async function POST(
1112
req: NextRequest,
@@ -126,6 +127,7 @@ export async function POST(
126127
route.vehicle = vehicleEmbed;
127128
route.status = RouteStatus.Scheduled;
128129
await route.save();
130+
emitRouteUpdate(route);
129131
results.push({ rideId, success: true });
130132
}
131133

‎src/app/globals.css‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,9 @@ body {
2727
color: var(--foreground);
2828
font-family: var(--font-paragraph);
2929
}
30+
31+
/* Keep react-toast notifications below the sticky navbar (~89px tall). */
32+
.toastContainer.top-right {
33+
top: 10.4rem;
34+
z-index: 1100;
35+
}

‎src/app/profile/ProfileRidesTab.tsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"use client";
22

3-
import React, { useState, useEffect, useMemo } from "react";
3+
import React, { useState, useEffect, useMemo, useCallback } from "react";
44
import styles from "./profileRides.module.css";
55
import { estWeekRange, formatEstTime, formatEstDate } from "@/utils/dateEst";
6+
import { useRouteUpdates } from "@/components/NotificationProvider/useRouteUpdates";
67

78
type RouteUser = {
89
_id: string;
@@ -117,14 +118,14 @@ export function ProfileRidesTab({
117118
userType === "Vehicle" ? "this-week" : "all",
118119
);
119120

120-
useEffect(() => {
121+
const fetchRides = useCallback(() => {
121122
const param =
122123
userType === "Driver"
123124
? `driver=${userId}`
124125
: userType === "Vehicle"
125126
? `vehicle=${userId}`
126127
: `student=${userId}`;
127-
Promise.all([
128+
return Promise.all([
128129
fetch(`/api/routes?${param}`).then((r) => r.json()),
129130
fetch("/api/locations").then((r) => r.json()),
130131
])
@@ -135,11 +136,20 @@ export function ProfileRidesTab({
135136
for (const l of locData) map[l._id] = l.name;
136137
}
137138
setLocations(map);
139+
setError(null);
138140
})
139141
.catch(() => setError("Failed to load rides."))
140142
.finally(() => setLoading(false));
141143
}, [userId, userType]);
142144

145+
useEffect(() => {
146+
void fetchRides();
147+
}, [fetchRides]);
148+
149+
useRouteUpdates(() => {
150+
void fetchRides();
151+
});
152+
143153
const filtered = useMemo(() => {
144154
if (filter === "all") return routes;
145155
const offset = filter === "this-week" ? 0 : 1;

‎src/app/rides/DriverRidesView.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
formatEstDate,
1313
} from "@/utils/dateEst";
1414
import type { Shift } from "@/utils/types/user";
15+
import { useRouteUpdates } from "@/components/NotificationProvider/useRouteUpdates";
1516

1617
type Location = {
1718
_id: string;
@@ -157,6 +158,10 @@ export default function DriverRidesView({ userId }: { userId: string }) {
157158
void fetchRoutes();
158159
}, [mounted, fetchRoutes]);
159160

161+
useRouteUpdates(() => {
162+
if (mounted) void fetchRoutes();
163+
});
164+
160165
const locationIdToName = useMemo(
161166
() =>
162167
locations.reduce(

‎src/app/rides/[id]/page.tsx‎

Lines changed: 51 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import BogButton from "@/components/BogButton/BogButton";
1313
import { CancelRideModal } from "../CancelRideModal";
1414
import { getStudentStatusChipStyle } from "../RideCard";
1515
import styles from "./styles.module.css";
16+
import { useRouteUpdates } from "@/components/NotificationProvider/useRouteUpdates";
1617

1718
const CANCELLABLE_STATUSES = new Set(["Requested", "Scheduled"]);
1819

@@ -190,57 +191,67 @@ export default function RideDetailPage({
190191
}, [params]);
191192

192193
// Fetch route data
193-
useEffect(() => {
194+
const fetchRoute = useCallback(async () => {
194195
if (!routeId || sessionStatus !== "authenticated" || !session?.user?.userId)
195196
return;
196197

197-
const fetchRoute = async () => {
198-
try {
199-
const res = await fetch(`/api/routes/${routeId}`);
200-
201-
if (!res.ok) {
202-
if (res.status === 404) {
203-
throw new Error("Route not found");
204-
} else if (res.status === 403) {
205-
throw new Error("You do not have access to this route");
206-
}
207-
throw new Error("Failed to fetch route");
208-
}
198+
try {
199+
const res = await fetch(`/api/routes/${routeId}`);
209200

210-
const routeData: RouteData = await res.json();
211-
212-
if (session.user.type === "Student") {
213-
const studentId = getStudentId(routeData.student);
214-
if (studentId !== session.user.userId) {
215-
throw new Error("You are not the student for this route");
216-
}
217-
} else if (session.user.type === "Driver") {
218-
const driverId = getDriverId(routeData.driver);
219-
if (driverId !== session.user.userId) {
220-
router.push("/rides");
221-
return;
222-
}
223-
} else {
224-
throw new Error("Unauthorized");
201+
if (!res.ok) {
202+
if (res.status === 404) {
203+
throw new Error("Route not found");
204+
} else if (res.status === 403) {
205+
throw new Error("You do not have access to this route");
225206
}
207+
throw new Error("Failed to fetch route");
208+
}
226209

227-
setRoute(routeData);
228-
setError(null);
210+
const routeData: RouteData = await res.json();
229211

230-
// Determine if chat is eligible (ride is today)
231-
const chatEligible = isToday(routeData.scheduledPickupTime);
232-
setIsChatEligible(chatEligible);
233-
} catch (e) {
234-
setError(e instanceof Error ? e.message : "Failed to fetch route");
235-
setRoute(null);
236-
} finally {
237-
setLoading(false);
212+
if (session.user.type === "Student") {
213+
const studentId = getStudentId(routeData.student);
214+
if (studentId !== session.user.userId) {
215+
throw new Error("You are not the student for this route");
216+
}
217+
} else if (session.user.type === "Driver") {
218+
const driverId = getDriverId(routeData.driver);
219+
if (driverId !== session.user.userId) {
220+
router.push("/rides");
221+
return;
222+
}
223+
} else {
224+
throw new Error("Unauthorized");
238225
}
239-
};
240226

241-
fetchRoute();
227+
setRoute(routeData);
228+
setError(null);
229+
230+
// Determine if chat is eligible (ride is today)
231+
const chatEligible = isToday(routeData.scheduledPickupTime);
232+
setIsChatEligible(chatEligible);
233+
} catch (e) {
234+
setError(e instanceof Error ? e.message : "Failed to fetch route");
235+
setRoute(null);
236+
} finally {
237+
setLoading(false);
238+
}
242239
}, [routeId, session, sessionStatus, router]);
243240

241+
useEffect(() => {
242+
// This effect starts an asynchronous API request.
243+
// eslint-disable-next-line react-hooks/set-state-in-effect
244+
void fetchRoute();
245+
}, [fetchRoute]);
246+
247+
// Refetch when this route changes elsewhere (status, driver, cancellation).
248+
useRouteUpdates(
249+
() => {
250+
void fetchRoute();
251+
},
252+
{ routeId },
253+
);
254+
244255
// Fetch locations
245256
useEffect(() => {
246257
if (!route) return;

‎src/app/rides/page.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { RideCard } from "./RideCard";
1010
import DriverRidesView from "./DriverRidesView";
1111
import styles from "./styles.module.css";
1212
import { CancelRideModal } from "./CancelRideModal";
13+
import { useRouteUpdates } from "@/components/NotificationProvider/useRouteUpdates";
1314
import {
1415
estDateKey,
1516
estDayRange,
@@ -137,6 +138,11 @@ export default function RidesPage() {
137138
void fetchRides();
138139
}, [mounted, fetchRides]);
139140

141+
// Drivers get their own subscription inside DriverRidesView.
142+
useRouteUpdates(() => {
143+
if (mounted && session?.user?.type === "Student") void fetchRides();
144+
});
145+
140146
const handleCancel = useCallback(async (routeId: string) => {
141147
setCancellingId(routeId);
142148
try {
Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useRef } from "react";
3+
import { createContext, useContext, useEffect, useState } from "react";
44
import { useSession } from "next-auth/react";
55
import { io, type Socket } from "socket.io-client";
66
import { ToastContainer, toast } from "react-toast";
@@ -10,43 +10,55 @@ type ToastNotification = {
1010
message: string;
1111
};
1212

13+
// The per-user /notifications socket, shared so pages can listen for
14+
// real-time data events (see useRouteUpdates). Null until connected.
15+
const RealtimeSocketContext = createContext<Socket | null>(null);
16+
17+
export function useRealtimeSocket() {
18+
return useContext(RealtimeSocketContext);
19+
}
20+
1321
export default function NotificationProvider({
1422
children,
1523
}: {
1624
children: React.ReactNode;
1725
}) {
1826
const { data: session } = useSession();
19-
const socketRef = useRef<Socket | null>(null);
27+
const [socket, setSocket] = useState<Socket | null>(null);
2028

2129
useEffect(() => {
2230
const accessToken = session?.user?.accessToken;
2331
const wsUrl = process.env.NEXT_PUBLIC_WS_URL;
24-
if (!accessToken || !wsUrl || socketRef.current) return;
32+
if (!accessToken || !wsUrl) return;
2533

26-
const socket = io(`${wsUrl}/notifications`, {
34+
const newSocket = io(`${wsUrl}/notifications`, {
2735
auth: { token: accessToken },
2836
transports: ["websocket", "polling"],
2937
reconnection: true,
3038
reconnectionDelay: 1000,
3139
reconnectionDelayMax: 30000,
3240
reconnectionAttempts: 20,
3341
});
34-
socketRef.current = socket;
3542

36-
socket.on("notification", ({ type, message }: ToastNotification) => {
43+
newSocket.on("notification", ({ type, message }: ToastNotification) => {
3744
toast[type](message);
3845
});
3946

47+
// Publishing the socket from inside the effect is what lets consumers
48+
// subscribe; it is replaced whenever the access token changes.
49+
// eslint-disable-next-line react-hooks/set-state-in-effect
50+
setSocket(newSocket);
51+
4052
return () => {
41-
socket.disconnect();
42-
socketRef.current = null;
53+
newSocket.disconnect();
54+
setSocket(null);
4355
};
4456
}, [session?.user?.accessToken]);
4557

4658
return (
47-
<>
59+
<RealtimeSocketContext.Provider value={socket}>
4860
<ToastContainer position="top-right" />
4961
{children}
50-
</>
62+
</RealtimeSocketContext.Provider>
5163
);
5264
}

0 commit comments

Comments
 (0)