Skip to content

Commit db86476

Browse files
authored
v3.0.5 (#586)
* fix(settings): harden disabled-course persistence and cross-device refresh - always refetch user settings on mount/focus/reconnect with 60s polling - await disabled course enable/disable persistence before showing success toast - make settings update helpers async via mutateAsync - update disabled-course and provider tests for async flows and refetch policy * fix(user-settings): update mutation calls to use mutate instead of mutateAsync * fix(course-card): prevent default event behavior on button clicks and update error handling * fix(useDisabledCourses): add async/await to example for disabling and enabling courses
1 parent 24ffaad commit db86476

11 files changed

Lines changed: 93 additions & 50 deletions

File tree

.example.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ NEXT_PUBLIC_APP_NAME=GhostClass
4343
# (calculate-version job). A GitHub Secret here would always be stale after
4444
# an auto-version bump. Keep in sync with package.json for local dev only.
4545
# 🔨 Build-time (auto-derived from git tag by pipeline — not a GitHub Secret)
46-
NEXT_PUBLIC_APP_VERSION=3.0.4
46+
NEXT_PUBLIC_APP_VERSION=3.0.5
4747

4848
# ⚠️ Your production domain WITHOUT https://
4949
# All URL-based variables are derived from this.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ghostclass",
3-
"version": "3.0.4",
3+
"version": "3.0.5",
44
"private": true,
55
"engines": {
66
"node": ">=22.12.0",

public/openapi/openapi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ openapi: 3.1.0
66

77
info:
88
title: GhostClass API
9-
version: 3.0.4
9+
version: 3.0.5
1010
description: |
1111
**GhostClass API** provides endpoints for managing attendance synchronization with EzyGo.
1212

src/app/(protected)/tracking/TrackingClient.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ export default function TrackingClient() {
412412
)}
413413

414414
{showPinnedCourse && activeCourseMeta && (
415-
<div className="fixed top-[88px] left-1/2 z-30 flex w-[min(44rem,calc(100%-2rem))] -translate-x-1/2 items-center gap-2 rounded-md border border-border/70 bg-background/96 px-3 py-2 shadow-md backdrop-blur-sm">
415+
<div className="fixed top-22 left-1/2 z-30 flex w-[min(44rem,calc(100%-2rem))] -translate-x-1/2 items-center gap-2 rounded-md border border-border/70 bg-background/96 px-3 py-2 shadow-md backdrop-blur-sm">
416416
<div className="rounded-md bg-primary/10 p-1.5 text-primary"><BookOpen size={16} aria-hidden="true" /></div>
417417
<h3 className="text-left text-sm font-semibold text-foreground/90 capitalize">{activeCourseMeta.displayCourseName.toLowerCase()}</h3>
418418
{activeCourseMeta.isDisabled && (

src/components/attendance/__tests__/course-card.test.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import { render, screen, within, fireEvent } from '@testing-library/react';
2+
import { render, screen, within, fireEvent, waitFor } from '@testing-library/react';
33
import { CourseCard, ExtendedCourse } from '../course-card';
44
import { useCourseDetails } from '@/hooks/courses/attendance';
55
import { toast } from 'sonner';
@@ -294,8 +294,10 @@ describe('CourseCard', () => {
294294
fireEvent.click(toggle);
295295
const disableConfirmBtn = await screen.findByRole('button', { name: /^disable$/i });
296296
fireEvent.click(disableConfirmBtn);
297-
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('CS101 disabled', {
298-
description: 'Challenge passed',
297+
await waitFor(() => {
298+
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('CS101 disabled', {
299+
description: 'Challenge passed',
300+
});
299301
});
300302
});
301303

@@ -314,7 +316,9 @@ describe('CourseCard', () => {
314316
fireEvent.click(toggle);
315317
const enableConfirmBtn = await screen.findByRole('button', { name: /^enable$/i });
316318
fireEvent.click(enableConfirmBtn);
317-
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('CS101 enabled');
319+
await waitFor(() => {
320+
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('CS101 enabled');
321+
});
318322
});
319323
});
320324

src/components/attendance/course-card.tsx

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -652,14 +652,19 @@ export function CourseCard({ course }: CourseCardProps) {
652652
<AlertDialogAction
653653
className="custom-button bg-red-600! hover:bg-red-700! border-none!"
654654
disabled={isOtherReason && !customReason.trim()}
655-
onClick={() => {
655+
onClick={async (event) => {
656+
event.preventDefault();
656657
if (!courseCode) return;
657658
const reason = isOtherReason ? customReason.trim() : disableReason;
658-
disableCourse(courseCode, reason);
659-
setShowDisableDialog(false);
660-
toast.success(`${courseCode} disabled`, {
661-
description: reason,
662-
});
659+
try {
660+
await disableCourse(courseCode, reason);
661+
setShowDisableDialog(false);
662+
toast.success(`${courseCode} disabled`, {
663+
description: reason,
664+
});
665+
} catch {
666+
// Provider-level mutation handler already displays an error toast.
667+
}
663668
}}
664669
>
665670
Disable
@@ -682,11 +687,16 @@ export function CourseCard({ course }: CourseCardProps) {
682687
<AlertDialogCancel className="custom-button">Cancel</AlertDialogCancel>
683688
<AlertDialogAction
684689
className="custom-button bg-green-600! hover:bg-green-700! border-none!"
685-
onClick={() => {
690+
onClick={async (event) => {
691+
event.preventDefault();
686692
if (!courseCode) return;
687-
enableCourse(courseCode);
688-
setShowEnableDialog(false);
689-
toast.success(`${courseCode} enabled`);
693+
try {
694+
await enableCourse(courseCode);
695+
setShowEnableDialog(false);
696+
toast.success(`${courseCode} enabled`);
697+
} catch {
698+
// Provider-level mutation handler already displays an error toast.
699+
}
690700
}}
691701
>
692702
Enable

src/hooks/courses/__tests__/useDisabledCourses.test.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,13 @@ describe("useDisabledCourses", () => {
8282
expect(result.current.disabledCodes.size).toBe(0);
8383
});
8484

85-
it("disableCourse calls updateDisabledCourses with correct map", () => {
85+
it("disableCourse calls updateDisabledCourses with correct map", async () => {
8686
const { result } = renderHook(() =>
8787
useDisabledCourses({ academicYear: "2025-2026", semester: "even" })
8888
);
8989

90-
act(() => {
91-
result.current.disableCourse("CS202", "Other reason");
90+
await act(async () => {
91+
await result.current.disableCourse("CS202", "Other reason");
9292
});
9393

9494
expect(mockUpdateDisabledCourses).toHaveBeenCalledWith({
@@ -99,38 +99,38 @@ describe("useDisabledCourses", () => {
9999
});
100100
});
101101

102-
it("enableCourse removes course from map", () => {
102+
it("enableCourse removes course from map", async () => {
103103
const { result } = renderHook(() =>
104104
useDisabledCourses({ academicYear: "2025-2026", semester: "even" })
105105
);
106106

107-
act(() => {
108-
result.current.enableCourse("CS101");
107+
await act(async () => {
108+
await result.current.enableCourse("CS101");
109109
});
110110

111111
// Entire semester bucket should be removed since it becomes empty
112112
expect(mockUpdateDisabledCourses).toHaveBeenCalledWith({});
113113
});
114114

115-
it("enableCourse is a no-op when semester key is null", () => {
115+
it("enableCourse is a no-op when semester key is null", async () => {
116116
const { result } = renderHook(() =>
117117
useDisabledCourses({ academicYear: null, semester: null })
118118
);
119119

120-
act(() => {
121-
result.current.enableCourse("CS101");
120+
await act(async () => {
121+
await result.current.enableCourse("CS101");
122122
});
123123

124124
expect(mockUpdateDisabledCourses).not.toHaveBeenCalled();
125125
});
126126

127-
it("disableCourse is a no-op when semester key is null", () => {
127+
it("disableCourse is a no-op when semester key is null", async () => {
128128
const { result } = renderHook(() =>
129129
useDisabledCourses({ academicYear: null, semester: null })
130130
);
131131

132-
act(() => {
133-
result.current.disableCourse("CS101", "reason");
132+
await act(async () => {
133+
await result.current.disableCourse("CS101", "reason");
134134
});
135135

136136
expect(mockUpdateDisabledCourses).not.toHaveBeenCalled();

src/hooks/courses/useDisabledCourses.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ export interface UseDisabledCoursesReturn {
3333
/** Get the disable reason for a course code in the current semester (or null) */
3434
getDisableReason: (code: string) => string | null;
3535
/** Disable a course code with a reason in the current semester */
36-
disableCourse: (code: string, reason: string) => void;
36+
disableCourse: (code: string, reason: string) => Promise<void>;
3737
/** Enable a previously-disabled course code in the current semester */
38-
enableCourse: (code: string) => void;
38+
enableCourse: (code: string) => Promise<void>;
3939
/** Whether the settings are still loading */
4040
isLoading: boolean;
4141
}
@@ -48,14 +48,16 @@ export interface UseDisabledCoursesReturn {
4848
*
4949
* @example
5050
* ```tsx
51+
* async function handleCourseToggle() {
5152
* const { isDisabled, disableCourse, enableCourse } = useDisabledCourses({
5253
* academicYear: "2025-2026",
5354
* semester: "even",
5455
* });
5556
*
5657
* if (isDisabled("GXEST204")) { … }
57-
* disableCourse("GXEST204", "Challenge passed");
58-
* enableCourse("GXEST204");
58+
* await disableCourse("GXEST204", "Challenge passed");
59+
* await enableCourse("GXEST204");
60+
* }
5961
* ```
6062
*/
6163
export function useDisabledCourses({
@@ -103,18 +105,18 @@ export function useDisabledCourses({
103105
);
104106

105107
const disableCourse = useCallback(
106-
(code: string, reason: string) => {
108+
async (code: string, reason: string) => {
107109
if (!semKey) return;
108110
const newMap: DisabledCoursesMap = structuredClone(disabledCoursesMap);
109111
if (!newMap[semKey]) newMap[semKey] = {};
110112
newMap[semKey][code.toUpperCase()] = reason;
111-
updateDisabledCourses(newMap);
113+
await updateDisabledCourses(newMap);
112114
},
113115
[semKey, disabledCoursesMap, updateDisabledCourses]
114116
);
115117

116118
const enableCourse = useCallback(
117-
(code: string) => {
119+
async (code: string) => {
118120
if (!semKey) return;
119121
const newMap: DisabledCoursesMap = structuredClone(disabledCoursesMap);
120122
if (!newMap[semKey]) return;
@@ -130,7 +132,7 @@ export function useDisabledCourses({
130132
if (Object.keys(newMap[semKey]).length === 0) {
131133
delete newMap[semKey];
132134
}
133-
updateDisabledCourses(newMap);
135+
await updateDisabledCourses(newMap);
134136
},
135137
[semKey, disabledCoursesMap, updateDisabledCourses]
136138
);

src/providers/__tests__/user-settings.test.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,14 @@ describe('UserSettingsProvider', () => {
159159
expect(screen.getByTestId('loading').textContent).toBe('true');
160160
});
161161

162-
it('provides isLoading=true when query is fetching', () => {
162+
it('keeps isLoading=false during background fetching', () => {
163163
vi.mocked(useQuery).mockReturnValue({
164164
data: undefined,
165165
isLoading: false,
166166
isFetching: true,
167167
} as any);
168168
render(<WrappedConsumer />);
169-
expect(screen.getByTestId('loading').textContent).toBe('true');
169+
expect(screen.getByTestId('loading').textContent).toBe('false');
170170
});
171171

172172
it('provides settings when query returns data', () => {
@@ -189,6 +189,21 @@ describe('UserSettingsProvider', () => {
189189
expect(screen.getByTestId('settings').textContent).toBe('no-settings');
190190
});
191191

192+
it('configures refetch policy for cross-device settings sync', () => {
193+
render(<WrappedConsumer />);
194+
195+
const firstCallArgs = vi.mocked(useQuery).mock.calls[0]?.[0] as unknown as
196+
| Record<string, unknown>
197+
| undefined;
198+
199+
expect(firstCallArgs).toBeDefined();
200+
expect(firstCallArgs?.refetchOnMount).toBe('always');
201+
expect(firstCallArgs?.refetchOnWindowFocus).toBe('always');
202+
expect(firstCallArgs?.refetchOnReconnect).toBe('always');
203+
expect(firstCallArgs?.refetchInterval).toBe(60 * 1000);
204+
expect(firstCallArgs?.refetchIntervalInBackground).toBe(false);
205+
});
206+
192207
describe('useUserSettings guard', () => {
193208
it('throws when used outside provider', () => {
194209
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -242,7 +257,7 @@ describe('UserSettingsProvider', () => {
242257
</UserSettingsProvider>
243258
);
244259
screen.getByRole('button').click();
245-
expect(mockMutate).toHaveBeenCalledWith({ disabled_courses: map });
260+
expect(mockMutateAsync).toHaveBeenCalledWith({ disabled_courses: map });
246261
});
247262
});
248263

0 commit comments

Comments
 (0)