Merge branch 'main' into timerTask
This commit is contained in:
82
__tests__/assignment/createAssignment.test.tsx
Normal file
82
__tests__/assignment/createAssignment.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import UpsertAssignment from "@/app/assignment/upsertAssignment";
|
||||
import { CheckSubjectCompletion } from "@/lib/progress";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||
import { router } from "expo-router";
|
||||
|
||||
const mockSingle = jest.fn();
|
||||
const mockSelect = jest.fn(() => ({ single: mockSingle, }));
|
||||
const mockInsert = jest.fn(() => ({ select: mockSelect, }));
|
||||
|
||||
jest.mock("expo-router", () => ({
|
||||
router: {
|
||||
back: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
},
|
||||
Stack: {
|
||||
Screen: () => null,
|
||||
},
|
||||
useLocalSearchParams: () => ({
|
||||
sId: "subject-123",
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/progress", () => ({
|
||||
CheckSubjectCompletion: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/asyncStorage", () => ({
|
||||
GetAssignmentNotificationId: jest.fn(() => Promise.resolve()),
|
||||
SaveAssignmentNotificationId: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("expo-notifications", () => ({
|
||||
scheduleNotificationAsync: jest.fn(() => Promise.resolve("notification-123")),
|
||||
SchedulableTriggerInputTypes: {
|
||||
DATE: "date",
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: { user: { id: "user-123" } },
|
||||
error: null,
|
||||
})
|
||||
),
|
||||
},
|
||||
from: jest.fn(() => ({
|
||||
insert: mockInsert,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
test("creates an assignment and navigates back", async () => {
|
||||
mockSingle.mockResolvedValue({
|
||||
data: {
|
||||
aId: "assignment-123",
|
||||
title: "create a simple test",
|
||||
deadline: "",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
const screen = render(<UpsertAssignment />);
|
||||
fireEvent.changeText(screen.getByTestId("assignment-title-input"), "create a simple test");
|
||||
fireEvent.press(screen.getByTestId("upsert-assignment-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(supabase.from).toHaveBeenCalledWith("assignments");
|
||||
expect(mockInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "create a simple test",
|
||||
uId: "user-123",
|
||||
sId: "subject-123",
|
||||
})
|
||||
);
|
||||
expect(CheckSubjectCompletion).toHaveBeenCalledWith("subject-123");
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
141
__tests__/assignment/deleteAssignment.test.tsx
Normal file
141
__tests__/assignment/deleteAssignment.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import ViewDetailsAssignment from "@/app/assignment/viewDetailsAssignment";
|
||||
import { CheckSubjectCompletion } from "@/lib/progress";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||
import { router } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
const mockAssignmentSingle = jest.fn();
|
||||
const mockAssignmentSelectEq = jest.fn(() => ({ single: mockAssignmentSingle, }));
|
||||
const mockAssignmentSelect = jest.fn(() => ({ eq: mockAssignmentSelectEq, }));
|
||||
const mockAssignmentDeleteEq = jest.fn();
|
||||
const mockAssignmentDelete = jest.fn(() => ({ eq: mockAssignmentDeleteEq, }));
|
||||
|
||||
const mockTasksSelectEq = jest.fn();
|
||||
const mockTasksSelect = jest.fn(() => ({ eq: mockTasksSelectEq }));
|
||||
|
||||
const mockSubjectSingle = jest.fn();
|
||||
const mockSubjectSelectEq = jest.fn(() => ({ single: mockSubjectSingle }));
|
||||
const mockSubjectSelect = jest.fn(() => ({ eq: mockSubjectSelectEq }));
|
||||
|
||||
jest.mock("expo-router", () => ({
|
||||
router: {
|
||||
back: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
},
|
||||
Stack: {
|
||||
Screen: () => null,
|
||||
},
|
||||
useLocalSearchParams: () => ({
|
||||
aId: "assignment-123",
|
||||
}),
|
||||
useFocusEffect: (callback: () => void) => {
|
||||
const React = require("react");
|
||||
React.useEffect(callback, [callback]);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/progress", () => ({
|
||||
CheckSubjectCompletion: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: { user: { id: "user-123" } },
|
||||
error: null,
|
||||
})
|
||||
),
|
||||
getSession: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
session: {
|
||||
user: { id: "user-123" },
|
||||
},
|
||||
},
|
||||
})
|
||||
),
|
||||
onAuthStateChange: jest.fn(() => ({
|
||||
data: {
|
||||
subscription: {
|
||||
unsubscribe: jest.fn(),
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
from: jest.fn((table: string) => {
|
||||
if (table === "assignments") {
|
||||
return {
|
||||
select: mockAssignmentSelect,
|
||||
delete: mockAssignmentDelete,
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "tasks") {
|
||||
return {
|
||||
select: mockTasksSelect,
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "subjects") {
|
||||
return {
|
||||
select: mockSubjectSelect,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const alertSpy = jest.spyOn(Alert, "alert");
|
||||
|
||||
test("deletes a task and navigates back", async () => {
|
||||
mockAssignmentSingle.mockResolvedValue({
|
||||
data: {
|
||||
aId: "assignment-123",
|
||||
title: "create a simple test",
|
||||
uId: "user-123",
|
||||
sId: "subject-123"
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
mockTasksSelectEq.mockResolvedValue({ data: [], error: null, })
|
||||
mockSubjectSingle.mockResolvedValue({
|
||||
data: {
|
||||
sId: "subject-123",
|
||||
title: "ikt205g26v",
|
||||
color: "blue",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
mockAssignmentDeleteEq.mockResolvedValue({ error: null, });
|
||||
|
||||
const screen = render(<ViewDetailsAssignment />);
|
||||
|
||||
await screen.findByText("create a simple test");
|
||||
await screen.findByText("ikt205g26v");
|
||||
|
||||
fireEvent.press(await screen.findByTestId("delete-assignment-button"));
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith(
|
||||
"Delete Assignment",
|
||||
"Are you sure you want to delete this assignment?",
|
||||
expect.any(Array),
|
||||
);
|
||||
|
||||
const alertButtons = alertSpy.mock.calls[0][2];
|
||||
const confirmDeleteButton = alertButtons[1];
|
||||
|
||||
await confirmDeleteButton.onPress();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(supabase.from).toHaveBeenCalledWith("assignments");
|
||||
expect(mockAssignmentDelete).toHaveBeenCalled();
|
||||
expect(mockAssignmentDeleteEq).toHaveBeenCalledWith("aId", "assignment-123");
|
||||
expect(CheckSubjectCompletion).toHaveBeenCalledWith("subject-123");
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
100
__tests__/assignment/editAssignment.test.tsx
Normal file
100
__tests__/assignment/editAssignment.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import UpsertAssignment from "@/app/assignment/upsertAssignment";
|
||||
import { CheckSubjectCompletion } from "@/lib/progress";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||
import { router } from "expo-router";
|
||||
|
||||
const mockUpdateSingle = jest.fn();
|
||||
const mockUpdateSelect = jest.fn(() => ({ single: mockUpdateSingle, }));
|
||||
const mockUpdateEq = jest.fn(() => ({ select: mockUpdateSelect, }));
|
||||
const mockUpdate = jest.fn(() => ({ eq: mockUpdateEq, }));
|
||||
const mockSingle = jest.fn();
|
||||
const mockSelectEq = jest.fn(() => ({ single: mockSingle, }));
|
||||
const mockSelect = jest.fn(() => ({ eq: mockSelectEq, }));
|
||||
|
||||
jest.mock("expo-router", () => ({
|
||||
router: {
|
||||
back: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
},
|
||||
Stack: {
|
||||
Screen: () => null,
|
||||
},
|
||||
useLocalSearchParams: () => ({
|
||||
aId: "assignment-123",
|
||||
}),
|
||||
useFocusEffect: (callback: () => void) => callback(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/progress", () => ({
|
||||
CheckSubjectCompletion: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/asyncStorage", () => ({
|
||||
GetAssignmentNotificationId: jest.fn(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
jest.mock("expo-notifications", () => ({
|
||||
scheduleNotificationAsync: jest.fn(() => Promise.resolve("notification-123")),
|
||||
SchedulableTriggerInputTypes: {
|
||||
DATE: "date",
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: { user: { id: "user-123" } },
|
||||
error: null,
|
||||
})
|
||||
),
|
||||
},
|
||||
from: jest.fn(() => ({
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
test("updates an assignment and navigates back", async () => {
|
||||
mockSingle.mockResolvedValue({
|
||||
data: {
|
||||
aId: "assignment-123",
|
||||
title: "create a simple test",
|
||||
deadline: "2026-04-25",
|
||||
uId: "user-123",
|
||||
sId: "subject-123",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
mockUpdateSingle.mockResolvedValue({
|
||||
data: {
|
||||
aId: "assignment-123",
|
||||
title: "create a harder test",
|
||||
deadline: "2026-04-25",
|
||||
uId: "user-123",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
const screen = render(<UpsertAssignment />);
|
||||
fireEvent.changeText(await screen.findByTestId("assignment-title-input"), "create a harder test");
|
||||
fireEvent.press(screen.getByTestId("upsert-assignment-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(supabase.from).toHaveBeenCalledWith("assignments");
|
||||
expect(mockSelect).toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "create a harder test",
|
||||
uId: "user-123",
|
||||
deadline: "2026-04-25",
|
||||
})
|
||||
);
|
||||
expect(mockUpdateSingle).toHaveBeenCalled();
|
||||
expect(CheckSubjectCompletion).toHaveBeenCalledWith("subject-123");
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
79
__tests__/authGuard.test.tsx
Normal file
79
__tests__/authGuard.test.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import TabLayout from "@/app/(tabs)/_layout";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { render, waitFor } from "@testing-library/react-native";
|
||||
|
||||
jest.mock("expo-router", () => {
|
||||
const React = require("react");
|
||||
const { Text, View } = require("react-native");
|
||||
|
||||
const MockTabs = ({ children }: { children?: React.ReactNode }) => (
|
||||
<View>
|
||||
<Text>tabs</Text>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
MockTabs.Screen = () => null;
|
||||
|
||||
return {
|
||||
Redirect: ({ href }: { href: string }) => <Text>redirect:{href}</Text>,
|
||||
Tabs: MockTabs,
|
||||
router: {
|
||||
push: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("expo-notifications", () => ({
|
||||
getLastNotificationResponse: jest.fn(() => null),
|
||||
addNotificationResponseReceivedListener: jest.fn(() => ({
|
||||
remove: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: jest.fn(),
|
||||
onAuthStateChange: jest.fn(() => ({
|
||||
data: {
|
||||
subscription: {
|
||||
unsubscribe: jest.fn(),
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if there is no session", async () => {
|
||||
(supabase.auth.getSession as jest.Mock).mockResolvedValue({
|
||||
data: { session: null },
|
||||
});
|
||||
|
||||
const screen = render(<TabLayout />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("redirect:/login")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test("renders tabs when session exists", async () => {
|
||||
(supabase.auth.getSession as jest.Mock).mockResolvedValue({
|
||||
data: {
|
||||
session: {
|
||||
user: { id: "user-123" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const screen = render(<TabLayout />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("tabs")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
52
__tests__/subject/createSubject.test.tsx
Normal file
52
__tests__/subject/createSubject.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import UpsertSubject from "@/app/subject/upsertSubject";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||
import { router } from "expo-router";
|
||||
|
||||
const mockInsert = jest.fn();
|
||||
|
||||
jest.mock("expo-router", () => ({
|
||||
router: {
|
||||
back: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
},
|
||||
Stack: {
|
||||
Screen: () => null,
|
||||
},
|
||||
useLocalSearchParams: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: { user: { id: "user-123" } },
|
||||
error: null,
|
||||
})
|
||||
),
|
||||
},
|
||||
from: jest.fn(() => ({
|
||||
insert: mockInsert,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
test("creates a subject and navigates back", async () => {
|
||||
mockInsert.mockResolvedValue({ error: null });
|
||||
|
||||
const screen = render(<UpsertSubject />);
|
||||
fireEvent.changeText(screen.getByTestId("subject-title-input"), "ikt205g26v");
|
||||
fireEvent.press(screen.getByTestId("upsert-subject-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(supabase.from).toHaveBeenCalledWith("subjects");
|
||||
expect(mockInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "ikt205g26v",
|
||||
uId: "user-123",
|
||||
})
|
||||
);
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
116
__tests__/subject/deleteSubject.test.tsx
Normal file
116
__tests__/subject/deleteSubject.test.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import ViewDetailsSubject from "@/app/subject/viewDetailsSubject";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||
import { router } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
const mockSubjectSingle = jest.fn();
|
||||
const mockSubjectSelectEq = jest.fn(() => ({ single: mockSubjectSingle }));
|
||||
const mockSubjectSelect = jest.fn(() => ({ eq: mockSubjectSelectEq }));
|
||||
const mockSubjectDeleteEq = jest.fn();
|
||||
const mockSubjectDelete = jest.fn(() => ({ eq: mockSubjectDeleteEq }));
|
||||
|
||||
const mockAssignmentsOrder = jest.fn();
|
||||
const mockAssignmentsEq = jest.fn(() => ({ order: mockAssignmentsOrder }));
|
||||
const mockAssignmentsSelect = jest.fn(() => ({ eq: mockAssignmentsEq }));
|
||||
|
||||
jest.mock("expo-router", () => ({
|
||||
router: {
|
||||
back: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
},
|
||||
Stack: {
|
||||
Screen: () => null,
|
||||
},
|
||||
useLocalSearchParams: () => ({
|
||||
sId: "subject-123",
|
||||
}),
|
||||
useFocusEffect: (callback: () => void) => {
|
||||
const React = require("react");
|
||||
React.useEffect(callback, [callback]);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: { user: { id: "user-123" } },
|
||||
error: null,
|
||||
})
|
||||
),
|
||||
getSession: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
session: {
|
||||
user: { id: "user-123" },
|
||||
},
|
||||
},
|
||||
})
|
||||
),
|
||||
onAuthStateChange: jest.fn(() => ({
|
||||
data: {
|
||||
subscription: {
|
||||
unsubscribe: jest.fn(),
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
from: jest.fn((table) => {
|
||||
if (table === "subjects") {
|
||||
return {
|
||||
select: mockSubjectSelect,
|
||||
delete: mockSubjectDelete,
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "assignments") {
|
||||
return {
|
||||
select: mockAssignmentsSelect,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const alertSpy = jest.spyOn(Alert, "alert");
|
||||
|
||||
test("deletes a subject and navigates back", async () => {
|
||||
mockSubjectSingle.mockResolvedValue({
|
||||
data: {
|
||||
sId: "subject-123",
|
||||
title: "ikt205g26v",
|
||||
uId: "user-123",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
mockAssignmentsOrder.mockResolvedValue({ data: [], error: null, })
|
||||
mockSubjectDeleteEq.mockResolvedValue({ error: null, });
|
||||
|
||||
const screen = render(<ViewDetailsSubject />);
|
||||
|
||||
await screen.findByText("ikt205g26v");
|
||||
|
||||
fireEvent.press(await screen.findByTestId("delete-subject-button"));
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith(
|
||||
"Delete Subject",
|
||||
"Are you sure you want to delete this subject?",
|
||||
expect.any(Array),
|
||||
);
|
||||
|
||||
const alertButtons = alertSpy.mock.calls[0][2];
|
||||
const confirmDeleteButton = alertButtons[1];
|
||||
|
||||
await confirmDeleteButton.onPress();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(supabase.from).toHaveBeenCalledWith("subjects");
|
||||
expect(mockSubjectDelete).toHaveBeenCalled();
|
||||
expect(mockSubjectDeleteEq).toHaveBeenCalledWith("sId", "subject-123");
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
70
__tests__/subject/editSubject.test.tsx
Normal file
70
__tests__/subject/editSubject.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import UpsertSubject from "@/app/subject/upsertSubject";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||
import { router } from "expo-router";
|
||||
|
||||
const mockUpdateEq = jest.fn();
|
||||
const mockUpdate = jest.fn(() => ({ eq: mockUpdateEq, }));
|
||||
const mockSingle = jest.fn();
|
||||
const mockSelectEq = jest.fn(() => ({ single: mockSingle, }));
|
||||
const mockSelect = jest.fn(() => ({ eq: mockSelectEq, }));
|
||||
|
||||
jest.mock("expo-router", () => ({
|
||||
router: {
|
||||
back: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
},
|
||||
Stack: {
|
||||
Screen: () => null,
|
||||
},
|
||||
useLocalSearchParams: () => ({
|
||||
sId: "subject-123",
|
||||
}),
|
||||
useFocusEffect: (callback: () => void) => callback(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: { user: { id: "user-123" } },
|
||||
error: null,
|
||||
})
|
||||
),
|
||||
},
|
||||
from: jest.fn(() => ({
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
test("updates a subject and navigates back", async () => {
|
||||
mockSingle.mockResolvedValue({
|
||||
data: {
|
||||
sId: "subject-123",
|
||||
title: "ikt205g26v",
|
||||
uId: "user-123",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
mockUpdateEq.mockResolvedValue({ error: null, });
|
||||
|
||||
const screen = render(<UpsertSubject />);
|
||||
fireEvent.changeText(await screen.findByTestId("subject-title-input"), "ikt206g26v");
|
||||
fireEvent.press(screen.getByTestId("upsert-subject-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(supabase.from).toHaveBeenCalledWith("subjects");
|
||||
expect(mockSelect).toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "ikt206g26v",
|
||||
uId: "user-123",
|
||||
})
|
||||
);
|
||||
expect(mockUpdateEq).toHaveBeenCalledWith("sId", "subject-123");
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
61
__tests__/task/createTask.test.tsx
Normal file
61
__tests__/task/createTask.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import UpsertTask from "@/app/task/upsertTask";
|
||||
import { CheckAssignmentCompletion } from "@/lib/progress";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||
import { router } from "expo-router";
|
||||
|
||||
const mockInsert = jest.fn();
|
||||
|
||||
jest.mock("expo-router", () => ({
|
||||
router: {
|
||||
back: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
},
|
||||
Stack: {
|
||||
Screen: () => null,
|
||||
},
|
||||
useLocalSearchParams: () => ({
|
||||
aId: "assignment-123",
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/progress", () => ({
|
||||
CheckAssignmentCompletion: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: { user: { id: "user-123" } },
|
||||
error: null,
|
||||
})
|
||||
),
|
||||
},
|
||||
from: jest.fn(() => ({
|
||||
insert: mockInsert,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
test("creates a task and navigates back", async () => {
|
||||
mockInsert.mockResolvedValue({ error: null });
|
||||
|
||||
const screen = render(<UpsertTask />);
|
||||
fireEvent.changeText(screen.getByTestId("task-title-input"), "Read chapter 4");
|
||||
fireEvent.press(screen.getByTestId("upsert-task-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(supabase.from).toHaveBeenCalledWith("tasks");
|
||||
expect(mockInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Read chapter 4",
|
||||
uId: "user-123",
|
||||
aId: "assignment-123",
|
||||
})
|
||||
);
|
||||
expect(CheckAssignmentCompletion).toHaveBeenCalledWith("assignment-123");
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
150
__tests__/task/deleteTask.test.tsx
Normal file
150
__tests__/task/deleteTask.test.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import ViewDetailsTask from "@/app/task/viewDetailsTask";
|
||||
import { CheckAssignmentCompletion } from "@/lib/progress";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||
import { router } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
const mockTaskSingle = jest.fn();
|
||||
const mockTaskSelectEq = jest.fn(() => ({ single: mockTaskSingle }));
|
||||
const mockTaskSelect = jest.fn(() => ({ eq: mockTaskSelectEq }));
|
||||
const mockTaskDeleteEq = jest.fn();
|
||||
const mockTaskDelete = jest.fn(() => ({ eq: mockTaskDeleteEq }));
|
||||
|
||||
const mockAssignmentSingle = jest.fn();
|
||||
const mockAssignmentSelectEq = jest.fn(() => ({ single: mockAssignmentSingle }));
|
||||
const mockAssignmentSelect = jest.fn(() => ({ eq: mockAssignmentSelectEq }));
|
||||
|
||||
const mockSubjectSingle = jest.fn();
|
||||
const mockSubjectSelectEq = jest.fn(() => ({ single: mockSubjectSingle }));
|
||||
const mockSubjectSelect = jest.fn(() => ({ eq: mockSubjectSelectEq }));
|
||||
|
||||
jest.mock("expo-router", () => ({
|
||||
router: {
|
||||
back: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
},
|
||||
Stack: {
|
||||
Screen: () => null,
|
||||
},
|
||||
useLocalSearchParams: () => ({
|
||||
tId: "task-123",
|
||||
}),
|
||||
useFocusEffect: (callback: () => void) => {
|
||||
const React = require("react");
|
||||
React.useEffect(callback, [callback]);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/progress", () => ({
|
||||
CheckAssignmentCompletion: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: { user: { id: "user-123" } },
|
||||
error: null,
|
||||
})
|
||||
),
|
||||
getSession: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
session: {
|
||||
user: { id: "user-123" },
|
||||
},
|
||||
},
|
||||
})
|
||||
),
|
||||
onAuthStateChange: jest.fn(() => ({
|
||||
data: {
|
||||
subscription: {
|
||||
unsubscribe: jest.fn(),
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
from: jest.fn((table: string) => {
|
||||
if (table === "tasks") {
|
||||
return {
|
||||
select: mockTaskSelect,
|
||||
delete: mockTaskDelete,
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "assignments") {
|
||||
return {
|
||||
select: mockAssignmentSelect,
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "subjects") {
|
||||
return {
|
||||
select: mockSubjectSelect,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const alertSpy = jest.spyOn(Alert, "alert");
|
||||
|
||||
test("deletes a task and navigates back", async () => {
|
||||
mockTaskSingle.mockResolvedValue({
|
||||
data: {
|
||||
tId: "task-123",
|
||||
title: "Read chapter 4",
|
||||
uId: "user-123",
|
||||
aId: "assignment-123",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
mockAssignmentSingle.mockResolvedValue({
|
||||
data: {
|
||||
aId: "assignment-123",
|
||||
title: "create a simple test",
|
||||
uId: "user-123",
|
||||
sId: "subject-123",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
mockSubjectSingle.mockResolvedValue({
|
||||
data: {
|
||||
sId: "subject-123",
|
||||
title: "ikt205g26v",
|
||||
color: "blue",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
mockTaskDeleteEq.mockResolvedValue({ error: null, });
|
||||
|
||||
const screen = render(<ViewDetailsTask />);
|
||||
|
||||
await screen.findByText("Read chapter 4");
|
||||
await screen.findByText("ikt205g26v");
|
||||
|
||||
fireEvent.press(await screen.findByTestId("delete-task-button"));
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith(
|
||||
"Delete Task",
|
||||
"Are you sure you want to delete this task?",
|
||||
expect.any(Array),
|
||||
);
|
||||
|
||||
const alertButtons = alertSpy.mock.calls[0][2];
|
||||
const confirmDeleteButton = alertButtons[1];
|
||||
|
||||
await confirmDeleteButton.onPress();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(supabase.from).toHaveBeenCalledWith("tasks");
|
||||
expect(mockTaskDelete).toHaveBeenCalled();
|
||||
expect(mockTaskDeleteEq).toHaveBeenCalledWith("tId", "task-123");
|
||||
expect(CheckAssignmentCompletion).toHaveBeenCalledWith("assignment-123");
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
78
__tests__/task/editTask.test.tsx
Normal file
78
__tests__/task/editTask.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import UpsertTask from "@/app/task/upsertTask";
|
||||
import { CheckAssignmentCompletion } from "@/lib/progress";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||
import { router } from "expo-router";
|
||||
|
||||
const mockUpdateEq = jest.fn();
|
||||
const mockUpdate = jest.fn(() => ({ eq: mockUpdateEq, }));
|
||||
const mockSingle = jest.fn();
|
||||
const mockSelectEq = jest.fn(() => ({ single: mockSingle, }));
|
||||
const mockSelect = jest.fn(() => ({ eq: mockSelectEq, }));
|
||||
|
||||
jest.mock("expo-router", () => ({
|
||||
router: {
|
||||
back: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
},
|
||||
Stack: {
|
||||
Screen: () => null,
|
||||
},
|
||||
useLocalSearchParams: () => ({
|
||||
tId: "task-123",
|
||||
}),
|
||||
useFocusEffect: (callback: () => void) => callback(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/progress", () => ({
|
||||
CheckAssignmentCompletion: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: { user: { id: "user-123" } },
|
||||
error: null,
|
||||
})
|
||||
),
|
||||
},
|
||||
from: jest.fn(() => ({
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
test("updates a task and navigates back", async () => {
|
||||
mockSingle.mockResolvedValue({
|
||||
data: {
|
||||
tId: "task-123",
|
||||
title: "Read chapter 4",
|
||||
uId: "user-123",
|
||||
aId: "assignment-123",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
mockUpdateEq.mockResolvedValue({ error: null, });
|
||||
|
||||
const screen = render(<UpsertTask />);
|
||||
fireEvent.changeText(await screen.findByTestId("task-title-input"), "Read chapter 5");
|
||||
fireEvent.press(screen.getByTestId("upsert-task-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(supabase.from).toHaveBeenCalledWith("tasks");
|
||||
expect(mockSelect).toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Read chapter 5",
|
||||
uId: "user-123",
|
||||
aId: "assignment-123",
|
||||
})
|
||||
);
|
||||
expect(mockUpdateEq).toHaveBeenCalledWith("tId", "task-123");
|
||||
expect(CheckAssignmentCompletion).toHaveBeenCalledWith("assignment-123");
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { SUBJECT_COLORS } from '@/lib/subjectColors';
|
||||
import { getSetupStatus } from '@/lib/setupStatus';
|
||||
import { SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Subject } from '@/lib/types';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { Redirect, router, Stack, useFocusEffect } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { Alert, Modal, Pressable, ScrollView, Text, View, ActivityIndicator } from 'react-native';
|
||||
|
||||
import type { SubjectColor } from '@/lib/subjectColors';
|
||||
|
||||
@@ -38,6 +38,10 @@ export default function Subjects() {
|
||||
const [session, SetSession] = useState<Session | null>(null);
|
||||
const [isFlowInfoVisible, setIsFlowInfoVisible] = useState(false);
|
||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||
const [isLoading, SetIsLoading] = useState(true);
|
||||
|
||||
const activeSubjects = subjects.filter((subject) => subject.isActive);
|
||||
const inactiveSubjects = subjects.filter((subject) => !subject.isActive);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
@@ -74,6 +78,15 @@ export default function Subjects() {
|
||||
|
||||
const GetSubjects = useCallback(async () => {
|
||||
if (!session?.user.id) return;
|
||||
const GetSubjects = async () => {
|
||||
if (!session?.user.id) {
|
||||
SetIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
SetIsLoading(true);
|
||||
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subjects')
|
||||
@@ -81,13 +94,18 @@ export default function Subjects() {
|
||||
.eq('uId', session.user.id)
|
||||
.order('lastChanged', { ascending: false });
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Subjects could not be fetched, please try again');
|
||||
SetIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
SetSubjects((data as Subject[]) ?? []);
|
||||
}, [session?.user.id]);
|
||||
SetIsLoading(false);
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -103,6 +121,78 @@ export default function Subjects() {
|
||||
|
||||
if (needsSetup) {
|
||||
return <Redirect href="/setup" />;
|
||||
const RenderSubjectCard = (subject: Subject) => {
|
||||
const colorKey: SubjectColor = subject.color ?? 'slate';
|
||||
const colorSet = SUBJECT_COLORS[colorKey];
|
||||
const firstLetter = subject.title?.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={subject.sId}
|
||||
className="mb-4 rounded-3xl bg-app-surface p-4"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorSet.strong,
|
||||
}}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/subject/viewDetailsSubject',
|
||||
params: { sId: subject.sId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<View
|
||||
className="mr-3 h-12 w-12 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: colorSet.soft }}
|
||||
>
|
||||
<Text
|
||||
className="text-base font-bold"
|
||||
style={{ color: colorSet.strong }}
|
||||
>
|
||||
{firstLetter}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-base font-bold text-text-main"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{subject.title}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
className="mt-1 text-sm leading-5 text-text-secondary"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{subject.description || 'No description added.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="ml-3">
|
||||
<View
|
||||
className="rounded-full px-3 py-1"
|
||||
style={{ backgroundColor: colorSet.soft }}
|
||||
>
|
||||
<Text
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: colorSet.strong }}
|
||||
>
|
||||
{subject.isActive ? 'Active' : 'Inactive'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-app-bg">
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -232,14 +322,14 @@ export default function Subjects() {
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View className="mb-6">
|
||||
<Text className="text-3xl font-bold text-text-main">Subjects</Text>
|
||||
<Text className="mt-2 text-base leading-6 text-text-secondary">
|
||||
Pick a subject to manage assignments and tasks.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{subjects.length === 0 ? (
|
||||
{isLoading ? (
|
||||
<View className="items-center justify-center rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text className="mt-4 text-center text-base font-semibold text-text-secondary">
|
||||
Loading subjects...
|
||||
</Text>
|
||||
</View>
|
||||
) : subjects.length === 0 ? (
|
||||
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-center text-xl font-bold text-text-main">
|
||||
No subjects yet
|
||||
@@ -260,74 +350,55 @@ export default function Subjects() {
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{subjects.map((subject) => {
|
||||
const colorKey: SubjectColor = subject.color ?? 'slate';
|
||||
const colorSet = SUBJECT_COLORS[colorKey];
|
||||
<View className="mb-3 mt-2 flex-row items-center justify-between">
|
||||
<Text className="text-lg font-bold text-text-main">
|
||||
Active Subjects
|
||||
</Text>
|
||||
|
||||
const firstLetter =
|
||||
subject.title?.trim().charAt(0).toUpperCase() || '?';
|
||||
<View className="rounded-full bg-app-subtle px-3 py-1">
|
||||
<Text className="text-xs font-semibold text-text-muted">
|
||||
{activeSubjects.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={subject.sId}
|
||||
className="mb-4 rounded-3xl bg-app-surface p-4"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorSet.strong,
|
||||
}}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/subject/viewDetailsSubject',
|
||||
params: { sId: subject.sId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<View
|
||||
className="mr-3 h-12 w-12 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: colorSet.soft }}
|
||||
>
|
||||
<Text
|
||||
className="text-base font-bold"
|
||||
style={{ color: colorSet.strong }}
|
||||
>
|
||||
{firstLetter}
|
||||
</Text>
|
||||
</View>
|
||||
{activeSubjects.length === 0 ? (
|
||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||
No active subjects
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||
Subjects with ongoing work will show up here.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
activeSubjects.map(RenderSubjectCard)
|
||||
)}
|
||||
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-base font-bold text-text-main"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{subject.title}
|
||||
</Text>
|
||||
<View className="mb-3 mt-2 flex-row items-center justify-between">
|
||||
<Text className="text-lg font-bold text-text-main">
|
||||
Inactive Subjects
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
className="mt-1 text-sm leading-5 text-text-secondary"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{subject.description || 'No description added.'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="rounded-full bg-app-subtle px-3 py-1">
|
||||
<Text className="text-xs font-semibold text-text-muted">
|
||||
{inactiveSubjects.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="ml-3">
|
||||
<View
|
||||
className="rounded-full px-3 py-1"
|
||||
style={{ backgroundColor: colorSet.soft }}
|
||||
>
|
||||
<Text
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: colorSet.strong }}
|
||||
>
|
||||
{subject.isActive ? 'Active' : 'Inactive'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{inactiveSubjects.length === 0 ? (
|
||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||
No inactive subjects
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||
Completed or paused subjects will show up here.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
inactiveSubjects.map(RenderSubjectCard)
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -270,6 +270,7 @@ export default function UpsertAssignment() {
|
||||
<View className="mb-5">
|
||||
<Text className={labelClassName}>Title</Text>
|
||||
<TextInput
|
||||
testID = "assignment-title-input"
|
||||
className={inputClassName}
|
||||
placeholder={
|
||||
isSetupFlow ? 'e.g. Weekly problem set 3' : 'Enter assignment title'
|
||||
@@ -345,6 +346,7 @@ export default function UpsertAssignment() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID = "upsert-assignment-button"
|
||||
className={`h-14 items-center justify-center rounded-2xl ${
|
||||
isSaving ? 'bg-accent-disabled' : 'bg-accent'
|
||||
}`}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Assignment, Task } from '@/lib/types';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Pressable, SectionList, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Alert, Pressable, SectionList, Text, View } from "react-native";
|
||||
|
||||
|
||||
export default function ViewDetailsAssignment() {
|
||||
@@ -14,6 +14,7 @@ export default function ViewDetailsAssignment() {
|
||||
const [assignment, SetAssignment] = useState<Assignment | null>(null);
|
||||
const [tasks, SetTasks] = useState<Task[]>([]);
|
||||
const [session, SetSession] = useState<Session | null>(null);
|
||||
const [isLoading, SetIsLoading] = useState(false);
|
||||
const [subjectMeta, setSubjectMeta] = useState({
|
||||
title: 'No Subject',
|
||||
color: 'slate' as SubjectColor,
|
||||
@@ -34,11 +35,15 @@ export default function ViewDetailsAssignment() {
|
||||
[])
|
||||
|
||||
const GetAssignment = async (assignmentId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('assignments')
|
||||
.select('*')
|
||||
.eq('aId', assignmentId)
|
||||
.single();
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('assignments')
|
||||
.select('*')
|
||||
.eq('aId', assignmentId)
|
||||
.single();
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (error || !data) {
|
||||
Alert.alert('Assignment could not be fetched, please try again');
|
||||
@@ -48,12 +53,16 @@ export default function ViewDetailsAssignment() {
|
||||
SetAssignment(data);
|
||||
|
||||
if (data.sId) {
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data: subjectData, error: subjectError } = await supabase
|
||||
.from('subjects')
|
||||
.select('title, color')
|
||||
.eq('sId', data.sId)
|
||||
.single();
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (subjectError || !subjectData) {
|
||||
setSubjectMeta({
|
||||
title: 'Unknown Subject',
|
||||
@@ -70,8 +79,12 @@ export default function ViewDetailsAssignment() {
|
||||
};
|
||||
|
||||
const GetTasks = async (aId: string) => {
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data, error } = await supabase.from("tasks").select("*").eq("aId", aId);
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (error) {
|
||||
Alert.alert("Tasks could not be fetched, please try again");
|
||||
return;
|
||||
@@ -165,6 +178,32 @@ export default function ViewDetailsAssignment() {
|
||||
)
|
||||
}
|
||||
|
||||
const ToggleTaskCompletion = async (task: Task) => {
|
||||
const nextIsCompleted = !task.isCompleted;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("tasks")
|
||||
.update({
|
||||
isCompleted: nextIsCompleted,
|
||||
lastChanged: new Date().toISOString(),
|
||||
})
|
||||
.eq("tId", task.tId);
|
||||
|
||||
if (error) {
|
||||
Alert.alert("Task could not be updated, please try again");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await CheckAssignmentCompletion(task.aId);
|
||||
} catch {
|
||||
Alert.alert("Failed to update assignment completion state");
|
||||
}
|
||||
|
||||
await GetTasks(task.aId);
|
||||
await GetAssignment(task.aId);
|
||||
}
|
||||
|
||||
const colorSet = getSubjectColorSet(subjectMeta.color);
|
||||
|
||||
const completedTasks = tasks.filter((task) => task.isCompleted).length;
|
||||
@@ -176,6 +215,14 @@ export default function ViewDetailsAssignment() {
|
||||
? 0
|
||||
: Math.round((completedTasks / totalTasks) * 100);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-app-bg">
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
@@ -233,7 +280,7 @@ export default function ViewDetailsAssignment() {
|
||||
<SectionList
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 20, paddingBottom: 32 }}
|
||||
sections={taskSections}
|
||||
sections={totalTasks === 0 ? [] : taskSections}
|
||||
keyExtractor={(item) => item.tId}
|
||||
showsVerticalScrollIndicator={false}
|
||||
stickySectionHeadersEnabled={false}
|
||||
@@ -247,18 +294,6 @@ export default function ViewDetailsAssignment() {
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-start">
|
||||
<View
|
||||
className="mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2"
|
||||
style={{
|
||||
borderColor: assignment.isCompleted ? colorSet.strong : '#DDD6C8',
|
||||
backgroundColor: assignment.isCompleted ? colorSet.strong : '#EFEBE3',
|
||||
}}
|
||||
>
|
||||
{assignment.isCompleted && (
|
||||
<Text className="text-sm font-bold text-text-inverse">✓</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className="text-2xl font-bold text-text-main">
|
||||
{assignment.title}
|
||||
@@ -323,6 +358,35 @@ export default function ViewDetailsAssignment() {
|
||||
Based only on completed tasks in this assignment.
|
||||
</Text>
|
||||
</View>
|
||||
{totalTasks > 0 ? (
|
||||
<View className="mt-5">
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Task Progress
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm font-bold text-text-main">
|
||||
{completedTasks}/{totalTasks}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="h-3 overflow-hidden rounded-full bg-app-subtle">
|
||||
<View
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: colorSet.strong,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="mt-2 text-xs font-medium text-text-secondary">
|
||||
{remainingTasks === 0
|
||||
? 'All tasks complete'
|
||||
: `${remainingTasks} task${remainingTasks === 1 ? '' : 's'} remaining`}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text className="mt-4 text-sm text-text-muted">
|
||||
Last changed: {formatDateTime(assignment.lastChanged)}
|
||||
@@ -335,7 +399,7 @@ export default function ViewDetailsAssignment() {
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/assignment/upsertAssignment',
|
||||
pathname: '../assignment/upsertAssignment',
|
||||
params: { aId: assignment.aId },
|
||||
})
|
||||
}
|
||||
@@ -344,6 +408,7 @@ export default function ViewDetailsAssignment() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID="delete-assignment-button"
|
||||
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||
onPress={() => DeleteAssignment(assignment.aId)}
|
||||
>
|
||||
@@ -358,7 +423,7 @@ export default function ViewDetailsAssignment() {
|
||||
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/task/upsertTask',
|
||||
pathname: '../task/upsertTask',
|
||||
params: { aId: assignment.aId },
|
||||
})
|
||||
}
|
||||
@@ -400,18 +465,6 @@ export default function ViewDetailsAssignment() {
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-start">
|
||||
<View
|
||||
className="mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2"
|
||||
style={{
|
||||
borderColor: item.isCompleted ? colorSet.strong : '#DDD6C8',
|
||||
backgroundColor: item.isCompleted ? colorSet.strong : '#EFEBE3',
|
||||
}}
|
||||
>
|
||||
{item.isCompleted && (
|
||||
<Text className="text-sm font-bold text-text-inverse">✓</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-base font-bold ${
|
||||
@@ -435,11 +488,24 @@ export default function ViewDetailsAssignment() {
|
||||
|
||||
{isOwner && (
|
||||
<View className="mt-4 flex-row border-t border-app-border pt-4">
|
||||
<Pressable
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl py-3"
|
||||
style={{ backgroundColor: item.isCompleted ? '#EFEBE3' : colorSet.soft }}
|
||||
onPress={() => ToggleTaskCompletion(item)}
|
||||
>
|
||||
<Text
|
||||
className="text-sm font-bold"
|
||||
style={{ color: colorSet.strong }}
|
||||
>
|
||||
{item.isCompleted ? 'Reopen' : 'Complete'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/task/upsertTask',
|
||||
pathname: '../task/upsertTask',
|
||||
params: { tId: item.tId },
|
||||
})
|
||||
}
|
||||
@@ -460,6 +526,19 @@ export default function ViewDetailsAssignment() {
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<View
|
||||
className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5"
|
||||
style={{ borderColor: colorSet.strong }}
|
||||
>
|
||||
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||
No tasks needed yet
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||
Add tasks if this assignment needs smaller steps.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
renderSectionFooter={({ section }) =>
|
||||
section.data.length === 0 ? (
|
||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5" style={{ borderColor: colorSet.strong }}>
|
||||
|
||||
@@ -175,6 +175,7 @@ export default function UpsertSubject() {
|
||||
<Text className={labelClassName}>Title</Text>
|
||||
<TextInput className={inputClassName}
|
||||
placeholder={isSetupFlow ? 'e.g. Algorithms' : 'Enter subject title'}
|
||||
testID = "subject-title-input"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
@@ -324,6 +325,7 @@ export default function UpsertSubject() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID = "upsert-subject-button"
|
||||
className={`h-14 items-center justify-center rounded-2xl ${
|
||||
isSaving
|
||||
? 'bg-accent-disabled'
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Assignment } from '@/lib/types';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Pressable, SectionList, Text, View } from 'react-native';
|
||||
import { ActivityIndicator, Alert, Pressable, SectionList, Text, View } from 'react-native';
|
||||
|
||||
export type Subject = {
|
||||
sId: string;
|
||||
@@ -23,6 +23,7 @@ export default function ViewDetailsSubject() {
|
||||
const [subject, SetSubject] = useState<Subject | null>(null);
|
||||
const [assignments, SetAssignments] = useState<Assignment[]>([]);
|
||||
const [session, SetSession] = useState<Session | null>(null);
|
||||
const [isLoading, SetIsLoading] = useState(false);
|
||||
|
||||
const assignmentSections = [
|
||||
{
|
||||
@@ -48,12 +49,16 @@ export default function ViewDetailsSubject() {
|
||||
}, []);
|
||||
|
||||
const GetSubject = async (subjectId: string) => {
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subjects')
|
||||
.select('*')
|
||||
.eq('sId', subjectId)
|
||||
.single();
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Subject could not be fetched, please try again');
|
||||
return;
|
||||
@@ -63,12 +68,16 @@ export default function ViewDetailsSubject() {
|
||||
};
|
||||
|
||||
const GetAssignments = async (subjectId: string) => {
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('assignments')
|
||||
.select('*')
|
||||
.eq('sId', subjectId)
|
||||
.order('deadline', { ascending: true });
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Assignments could not be fetched, please try again');
|
||||
return;
|
||||
@@ -77,12 +86,44 @@ export default function ViewDetailsSubject() {
|
||||
SetAssignments(data ?? []);
|
||||
};
|
||||
|
||||
const ToggleAssignmentCompletion = async (assignment: Assignment) => {
|
||||
const nextIsCompleted = !assignment.isCompleted;
|
||||
|
||||
const { error } = await supabase
|
||||
.from('assignments')
|
||||
.update({
|
||||
isCompleted: nextIsCompleted,
|
||||
lastChanged: new Date().toISOString(),
|
||||
})
|
||||
.eq('aId', assignment.aId);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Assignment could not be updated, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await CheckSubjectCompletion(assignment.sId);
|
||||
} catch {
|
||||
Alert.alert('Failed to update subject status');
|
||||
}
|
||||
|
||||
await GetAssignments(assignment.sId);
|
||||
await GetSubject(assignment.sId);
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (session && sId) {
|
||||
GetSubject(sId);
|
||||
GetAssignments(sId);
|
||||
if (!session || !sId) {
|
||||
return;
|
||||
}
|
||||
|
||||
SetIsLoading(true);
|
||||
SetSubject(null);
|
||||
|
||||
Promise.all([GetSubject(sId), GetAssignments(sId)]).finally(() => {
|
||||
SetIsLoading(false);
|
||||
});
|
||||
}, [session, sId])
|
||||
);
|
||||
|
||||
@@ -167,6 +208,25 @@ export default function ViewDetailsSubject() {
|
||||
? 0
|
||||
: Math.round((completedAssignments / totalAssignments) * 100);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Subject Details',
|
||||
}}
|
||||
/>
|
||||
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text className="mt-4 text-base font-semibold text-text-secondary">
|
||||
Loading subject...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
@@ -228,7 +288,7 @@ export default function ViewDetailsSubject() {
|
||||
paddingTop: 20,
|
||||
paddingBottom: 32,
|
||||
}}
|
||||
sections={assignmentSections}
|
||||
sections={totalAssignments === 0 ? [] : assignmentSections}
|
||||
keyExtractor={(item) => item.aId}
|
||||
showsVerticalScrollIndicator={false}
|
||||
stickySectionHeadersEnabled={false}
|
||||
@@ -290,34 +350,37 @@ export default function ViewDetailsSubject() {
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Assignments completed
|
||||
</Text>
|
||||
{totalAssignments > 0 ? (
|
||||
<View className="mt-5">
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Assignment Progress
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm font-bold text-text-main">
|
||||
{completedAssignments}/{totalAssignments}
|
||||
<Text className="text-sm font-bold text-text-main">
|
||||
{completedAssignments}/{totalAssignments}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="h-3 overflow-hidden rounded-full bg-app-subtle">
|
||||
<View
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: colorSet.strong,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="mt-2 text-xs font-medium text-text-secondary">
|
||||
{remainingAssignments === 0
|
||||
? 'All assignments complete'
|
||||
: `${remainingAssignments} assignment${
|
||||
remainingAssignments === 1 ? '' : 's'
|
||||
} remaining`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="h-3 overflow-hidden rounded-full bg-app-subtle">
|
||||
<View
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: colorSet.strong,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="mt-2 text-xs font-medium text-text-secondary">
|
||||
{remainingAssignments === 0
|
||||
? 'All assignments complete'
|
||||
: `${remainingAssignments} assignment${
|
||||
remainingAssignments === 1 ? '' : 's'
|
||||
} remaining`}
|
||||
</Text>
|
||||
|
||||
<Text className="mt-1 text-xs text-text-muted">
|
||||
Based only on completed assignments in this subject.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text className="mt-4 text-sm text-text-muted">
|
||||
Last changed: {formatDateTime(subject.lastChanged)}
|
||||
@@ -328,7 +391,7 @@ export default function ViewDetailsSubject() {
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/subject/upsertSubject',
|
||||
pathname: '../subject/upsertSubject',
|
||||
params: { sId: subject.sId },
|
||||
})
|
||||
}
|
||||
@@ -339,6 +402,7 @@ export default function ViewDetailsSubject() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID="delete-subject-button"
|
||||
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||
onPress={() => DeleteSubject(subject.sId)}
|
||||
>
|
||||
@@ -353,13 +417,13 @@ export default function ViewDetailsSubject() {
|
||||
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/assignment/upsertAssignment',
|
||||
pathname: '../assignment/upsertAssignment',
|
||||
params: { sId: subject.sId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
Create Assignment
|
||||
Add Assignment
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -385,15 +449,16 @@ export default function ViewDetailsSubject() {
|
||||
borderColor: colorSet.strong,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/assignment/viewDetailsAssignment',
|
||||
params: { aId: item.aId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<View className="flex-row items-center">
|
||||
<Pressable
|
||||
className="flex-1"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/assignment/viewDetailsAssignment',
|
||||
params: { aId: item.aId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-base font-bold ${
|
||||
@@ -416,16 +481,29 @@ export default function ViewDetailsSubject() {
|
||||
Deadline: {formatDate(item.deadline)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{isOwner && (
|
||||
<View className="mt-4 flex-row border-t border-app-border pt-4">
|
||||
<Pressable
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl py-3"
|
||||
style={{ backgroundColor: item.isCompleted ? '#EFEBE3' : colorSet.soft }}
|
||||
onPress={() => ToggleAssignmentCompletion(item)}
|
||||
>
|
||||
<Text
|
||||
className="text-sm font-bold"
|
||||
style={{ color: colorSet.strong }}
|
||||
>
|
||||
{item.isCompleted ? 'Reopen' : 'Complete'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/assignment/upsertAssignment',
|
||||
pathname: '../assignment/upsertAssignment',
|
||||
params: { aId: item.aId },
|
||||
})
|
||||
}
|
||||
@@ -448,6 +526,16 @@ export default function ViewDetailsSubject() {
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||
No assignments yet
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||
Add one when this subject has work to track.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
renderSectionFooter={({ section }) =>
|
||||
section.data.length === 0 ? (
|
||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
|
||||
@@ -198,6 +198,7 @@ export default function UpsertTask() {
|
||||
<View className="mb-5">
|
||||
<Text className={labelClassName}>Title</Text>
|
||||
<TextInput
|
||||
testID="task-title-input"
|
||||
className={inputClassName}
|
||||
placeholder={isSetupFlow ? 'e.g. Solve questions 1-3' : 'Enter task title'}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
@@ -258,6 +259,7 @@ export default function UpsertTask() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID="upsert-task-button"
|
||||
className={`h-14 items-center justify-center rounded-2xl ${
|
||||
isSaving ? 'bg-accent-disabled' : 'bg-accent'
|
||||
}`}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { Task } from '@/lib/types';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Pressable, Text, View } from 'react-native';
|
||||
import { ActivityIndicator, Alert, Pressable, Text, View } from 'react-native';
|
||||
|
||||
function formatTrackedTime(totalSeconds: number) {
|
||||
if (totalSeconds <= 0) {
|
||||
@@ -89,7 +89,16 @@ const GetTask = useCallback(async (taskId: string) => {
|
||||
.from('assignments')
|
||||
.select('title, sId')
|
||||
.eq('aId', data.aId)
|
||||
const GetTask = async (taskId: string) => {
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.eq('tId', taskId)
|
||||
.single();
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (assignmentError || !assignmentData) {
|
||||
setContextMeta({
|
||||
@@ -108,6 +117,20 @@ const GetTask = useCallback(async (taskId: string) => {
|
||||
.single();
|
||||
|
||||
if (subjectError || !subjectData) {
|
||||
SetTask(data);
|
||||
|
||||
if (data.aId) {
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data: assignmentData, error: assignmentError } = await supabase
|
||||
.from('assignments')
|
||||
.select('title, sId')
|
||||
.eq('aId', data.aId)
|
||||
.single();
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (assignmentError || !assignmentData) {
|
||||
setContextMeta({
|
||||
subjectTitle: 'Unknown Subject',
|
||||
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
|
||||
@@ -124,6 +147,25 @@ const GetTask = useCallback(async (taskId: string) => {
|
||||
}
|
||||
}
|
||||
}, [loadTaskStudyActivity]);
|
||||
if (assignmentData.sId) {
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data: subjectData, error: subjectError } = await supabase
|
||||
.from('subjects')
|
||||
.select('title, color')
|
||||
.eq('sId', assignmentData.sId)
|
||||
.single();
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (subjectError || !subjectData) {
|
||||
setContextMeta({
|
||||
subjectTitle: 'Unknown Subject',
|
||||
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
|
||||
subjectColor: 'slate',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -195,8 +237,37 @@ const handleSprintStart = async () => {
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const colorSet = getSubjectColorSet(contextMeta.subjectColor);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-app-bg">
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Task Details',
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
className="rounded-full bg-app-subtle px-4 py-2"
|
||||
onPress={async () => await supabase.auth.signOut()}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Logout
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
const DeleteTask = async (taskId: string) => {
|
||||
Alert.alert(
|
||||
|
||||
@@ -36,4 +36,4 @@ export async function RegisterForLocalNotificationsAsync() {
|
||||
HandleRegistrationError('Permission not granted for local notifications');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
564
notes/architecture-note.md
Normal file
564
notes/architecture-note.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# Study Sprint - Architecture Redesign Notes
|
||||
|
||||
## Purpose of this note
|
||||
This note documents the architectural and UI redesign work completed on the app documenting
|
||||
- what changed
|
||||
- why it changed
|
||||
- how the strucutre improved
|
||||
- which design decisions were intentional
|
||||
|
||||
---
|
||||
|
||||
## Initial app state
|
||||
The app originally had a flatter and more fragmented structure than the actual data model supported.
|
||||
|
||||
The conceptual data hierarchy of the app is:
|
||||
|
||||
**Subject -> Assignment -> Task**
|
||||
|
||||
However, the earlier navigation and screen structure did not consistently reflect that hierarchy. In practice, this caused duplicated views, weak context, and screens that felt disconnected from their parent entities.
|
||||
|
||||
Main problems in the earlier version:
|
||||
- top-level tabs included separate screens for **Subjects**, **Assignments**, and **Tasks**
|
||||
- tasks existed as their own top-level area even though they are children of assignments
|
||||
- assignments also felt partially detached from subjects
|
||||
- repeated CRUD patterns created too many screens and too much UI duplication
|
||||
- many screens relied on older `defaultStyles` patterns rather than a clearer component/card-based structure
|
||||
- raw timestamps and brrittle date inputs created poor usability
|
||||
- create/edit flows were separated even when both used the same form structure
|
||||
|
||||
---
|
||||
|
||||
## Core redesign goal
|
||||
The redesign aimed to make the app follow its real conceptual model more closely
|
||||
|
||||
**Dashboard -> overview**
|
||||
**Subjects -> actual content hierarchy entry point**
|
||||
**Timer -> separate utility tool**
|
||||
|
||||
And inside the content structure:
|
||||
|
||||
**Subject -> Assignment -> Task**
|
||||
|
||||
The main design philosophy throughout the redesign was:
|
||||
|
||||
- calm
|
||||
- intuitive
|
||||
- minimal
|
||||
- low visual noise
|
||||
- predictable interaction patterns
|
||||
- stronger hierarchy
|
||||
- less redundancy
|
||||
|
||||
---
|
||||
|
||||
# 1. Navigation architecture redesign
|
||||
|
||||
## Previous navigation problem
|
||||
The earlier tab setup exposed too many top-level destinations:
|
||||
- Dashboard/Home
|
||||
- Subjects
|
||||
- Assignments
|
||||
- Tasks
|
||||
- Timer
|
||||
|
||||
THis was a problem because Tasks and Assignments were not truly top-level concepts in the product model. They belong to parent entities.
|
||||
|
||||
This caused:
|
||||
- duplicated list views
|
||||
- extra screens with weaker contextual meaning
|
||||
- cognitive overload
|
||||
- a flatter app structure than intended
|
||||
|
||||
## Navigation redesign decision
|
||||
The top-level tabs were simplified to:
|
||||
|
||||
- **Dashboard**
|
||||
- **Subjects**
|
||||
- **Timer**
|
||||
|
||||
## Why this is better
|
||||
This better matches the app structure:
|
||||
- **Dashboard** = overview and corss-cutting information
|
||||
- **Subjects** = main entry point into academic content
|
||||
- **Timer** = standalone utility
|
||||
|
||||
Assignments and tasks were removed from the top-level tab bar and are now only accessed through the hierarchy:
|
||||
- subject details contains assignments
|
||||
- assignment details contain tasks
|
||||
|
||||
### Effect of this change
|
||||
This reduced redundancy and made the app feel more coherent. It also aligned the user flow with the actual data relationships rather than exposing every model as its own first-class navigation area.
|
||||
|
||||
---
|
||||
|
||||
# 2. Content hierarchy redesign
|
||||
|
||||
## Original issue
|
||||
Subjects, assignments, and tasks were partially treated like parallel entities rather than nested entities.
|
||||
|
||||
This weakened context:
|
||||
- a task could appear without clearly indicating its assignment or subject
|
||||
- an assignment could feel detached from its subject
|
||||
- the hierarchy existed in the database but was not always communicated in the UI
|
||||
|
||||
## Redesign decision
|
||||
The hierarchy was made explicit in the UI
|
||||
|
||||
- subjects page shows only subject cards
|
||||
- subject detail page becomes the main hub fro the selected subject
|
||||
- assignment detail page becomes the main hub for the selected assignment
|
||||
- task detail page becomes the main hub for the selected task
|
||||
|
||||
### Result
|
||||
The app now better communicates:
|
||||
- where the user is
|
||||
- what the current item belongs to
|
||||
- how to move deeper into the structure
|
||||
|
||||
---
|
||||
|
||||
# 3. Subject list redesign
|
||||
|
||||
## Original issue
|
||||
The subject list screen contained too much management UI directly in the list:
|
||||
- edit buttons
|
||||
- delete buttons
|
||||
- progress bar
|
||||
- cluttered card actions
|
||||
|
||||
This made the subject list feel like a control panel rather than a clean browsing screen.
|
||||
|
||||
## Redesign decision
|
||||
The subject list was simplified into clean, tappable subject cards.
|
||||
|
||||
Each subject card now focuses on:
|
||||
- subject title
|
||||
- optional description
|
||||
- active/inactive pill
|
||||
- subject-specific color identity
|
||||
- full-card tap to open subject details
|
||||
|
||||
### Removed from list cards
|
||||
- inline edit button
|
||||
- inline delete button
|
||||
- progress bar
|
||||
- management-heavy layout
|
||||
|
||||
### Why
|
||||
The list screen should be for browsing and selecting. Management actions belong inside the detail screen for that entitiy
|
||||
|
||||
### Result
|
||||
The subject list become calmer, easier to scan, and more aligned with the principle that cards should act as entry points rather than mini dashboards.
|
||||
|
||||
---
|
||||
|
||||
# 4. Subject detail screen redesign
|
||||
|
||||
## Original issue
|
||||
The subject detail screenw as still using older styling patterns and did not fully behave as the subject "hub".
|
||||
|
||||
## Redesign decision
|
||||
The subject detail screen was redesigned as the main management hub for the specific subject.
|
||||
|
||||
It now includes:
|
||||
- a subject summary card
|
||||
- subject status and metadata
|
||||
- subject-specific color styling
|
||||
- subject actions (edit/delete)
|
||||
- create assignment action
|
||||
- assignment sections below the subject summary
|
||||
|
||||
### Why this matters
|
||||
This screen now clearly answers:
|
||||
- what this subject is
|
||||
- how active/complete it is
|
||||
- what assignments belong to it
|
||||
- what actions can be taken at the subject level
|
||||
|
||||
This better reflects the product structure.
|
||||
|
||||
---
|
||||
|
||||
# 5. Assignment detail screen redesign
|
||||
|
||||
### Original issue
|
||||
Assignments were previously styled more generically and did not always preserve clear subject context.
|
||||
|
||||
## Redesign decision
|
||||
The assignment detail screen was redesigned to:
|
||||
- function as the hub for one assignment
|
||||
- show clear metadata
|
||||
- show progress through child tasks
|
||||
- expose only assignment-relevant actions
|
||||
- preserve visual inheritance from the parent subject
|
||||
|
||||
### New structure
|
||||
The assignment detail screen now includes:
|
||||
- assignment summary card
|
||||
- subject context pill
|
||||
- deadline metadata
|
||||
- progress section showing task completion
|
||||
- task list organized by completion state
|
||||
- create task action
|
||||
|
||||
### Why
|
||||
Assignments are not independent from subjects. The redesign makes that relationship visible without making the screen visually noisy.
|
||||
|
||||
---
|
||||
|
||||
# 6. Task detail screen redesign
|
||||
|
||||
## Original issue
|
||||
Tasks were at risk of losing hierarchical context because they are the deepest level in the model.
|
||||
|
||||
## Redesign decision
|
||||
The task detail screen was redesigned to preserve parent context explicitly.
|
||||
|
||||
It now includes:
|
||||
- task summary card
|
||||
- subject context pill
|
||||
- assignment context pill
|
||||
- task state and metadata
|
||||
- parent-aware styling
|
||||
|
||||
### Why
|
||||
Tasks are the easiest place for context loss. By surfacing both subject and assignment, the user always knows where the task belongs.
|
||||
|
||||
---
|
||||
|
||||
# 7. Upsert-based form architecture
|
||||
|
||||
## Original issue
|
||||
The app originally used separate create and edit screens for the same entities, even when both screens used almost identical form fields and validation.
|
||||
|
||||
This created:
|
||||
- duplicated UI code
|
||||
- repeated logic
|
||||
- more route clutter
|
||||
- higher maintenance cost
|
||||
|
||||
## Redesign decision
|
||||
Create/edit flows were consolidated into upsert-style screens where appropriate.
|
||||
|
||||
### Implemented
|
||||
- `upsertSubject`
|
||||
- `upsertAssignment`
|
||||
- `upsertTask`
|
||||
|
||||
### Pattern used
|
||||
The form checks whether an ID exists:
|
||||
- if no ID exists -> create mode
|
||||
- if an ID exists -> edit mode
|
||||
|
||||
The same screen handles:
|
||||
- initial default values
|
||||
- loading existing data when editing
|
||||
- submit behavior for insert vs update
|
||||
|
||||
### Why this is better
|
||||
This reduces duplication while keeping the form styling and validation consistent.
|
||||
|
||||
### Tradeoff
|
||||
This introduces a little more branching inside a single screen, but the tradeoff is worth it because create and edit flows are structurally very similar for these entities.
|
||||
|
||||
---
|
||||
|
||||
# 8. Shared utility extraction
|
||||
|
||||
## Original issue
|
||||
Small but important logic was duplicated across screens.
|
||||
|
||||
## Redesign decision
|
||||
Shared helpers were moved into reusable modules.
|
||||
|
||||
### Added shared utilities
|
||||
- `lib/date.ts`
|
||||
- `lib/subjectColors.ts`
|
||||
|
||||
## Date formatting utility
|
||||
A shared date formatting module was introduced to standardize:
|
||||
- date-only display
|
||||
- date-time display
|
||||
|
||||
This replaced raw timestamp rendering such as ISO strings.
|
||||
|
||||
### Why
|
||||
Raw database timestamps were ugly and difficult to read. Formatting them centrally improves both UI quality and consistency.
|
||||
|
||||
## Subject color utility
|
||||
A shared subject color configuration was introduced to centralize:
|
||||
- available subject colors
|
||||
- subject color type
|
||||
- mapping from logical color key to visual values
|
||||
- helper function for retrieving the correct color set
|
||||
|
||||
### Why
|
||||
This prevents duplicated color logic and ensures consistent us of subject-specific accent colors across screens.
|
||||
|
||||
---
|
||||
|
||||
# 9. Subject color system and inheritance
|
||||
|
||||
## Original issue
|
||||
The app initially relied mainly on global app accent colors, which made it harder to preserve identity across nested subject content.
|
||||
|
||||
## Redesign decision
|
||||
Subjects were given their own user-selectable accent color.
|
||||
|
||||
### Color choice
|
||||
The user chooses from a controlled palette instead of arbitrary colors.
|
||||
|
||||
### Why a controlled palette
|
||||
This preserves:
|
||||
- aesthetic consistency
|
||||
- readability
|
||||
- predictable contrast
|
||||
- low visual noise
|
||||
|
||||
## How color is used
|
||||
The subject color is used as a contextual accent, not a replacement for the whole theme.
|
||||
|
||||
Used on:
|
||||
- subject card border
|
||||
- subject preview card
|
||||
- subject pills
|
||||
- inherited borders and indicators in assignment/task screens
|
||||
- progress bars and completion indicators where appropriate
|
||||
|
||||
## Important design rule
|
||||
The subject color was not used for everything.
|
||||
|
||||
Primary action buttons such as:
|
||||
- create
|
||||
- save
|
||||
- login
|
||||
remain on the **global app accent**
|
||||
|
||||
### Why
|
||||
This preserves a consistent interaction language:
|
||||
- app accent = primary action
|
||||
- subject color = content identity / context
|
||||
|
||||
This separation was an intentional design decision.
|
||||
|
||||
---
|
||||
|
||||
# 10. Card-based UI redesign
|
||||
|
||||
## Original issue
|
||||
Several screens still relied on older layout/styling conventions that felt less coherent and more cluttered.
|
||||
|
||||
## Redesign decision
|
||||
The redesign shifted toward a more consistent card-based interface using:
|
||||
- bordered surface cards
|
||||
- semantic Tailwind theme classes
|
||||
- more restrained spacing
|
||||
- contextual pills
|
||||
- reduced action clutter
|
||||
|
||||
### Card design goals
|
||||
- easier scanning
|
||||
- stronger visual hierarchy
|
||||
- fewer floating controls
|
||||
- more predictable composition
|
||||
|
||||
### Result
|
||||
The app feels more structured and less noisy.
|
||||
|
||||
---
|
||||
|
||||
# 11. Metadata and pill system
|
||||
|
||||
## Original issue
|
||||
Status and metadata were previously shown in less consistent ways, including redundant indicators.
|
||||
|
||||
## Redesign decision
|
||||
Metadata display was standardized using pill elements for small contextual information.
|
||||
|
||||
Examples include:
|
||||
- subject name
|
||||
- assignment parent
|
||||
- deadline
|
||||
- active/inactive state
|
||||
|
||||
## Important cleanup decision
|
||||
Some pills were removed when they became redundant.
|
||||
|
||||
Example:
|
||||
- completed/in-progress pill was removed in places where the same information was already communicated by a checkbox or progress structure
|
||||
|
||||
### Why
|
||||
This reduced duplication and visual clutter.
|
||||
|
||||
---
|
||||
|
||||
# 12. Progress display redesign
|
||||
|
||||
## Original issue
|
||||
Progress indicators were previously placed too aggressively in list views where they created clutter.
|
||||
|
||||
## Redesign decision
|
||||
Progress bars were kept only where they make structural sense:
|
||||
- subject detail
|
||||
- assignment detail
|
||||
|
||||
### Why
|
||||
These are hub screens where progress is meaningful.
|
||||
|
||||
Progress bars were intentionally removed from places like subject list cards where they overloaded a browsing view.
|
||||
|
||||
### Additional improvement
|
||||
Progress is now shown with both:
|
||||
- a percentage bar
|
||||
- an `x / y` completed count
|
||||
- remaining item count text
|
||||
|
||||
This makes progress more understandable than a bar alone.
|
||||
|
||||
---
|
||||
|
||||
# 13. Authentication-related debugging insight
|
||||
During development, a major debugging issue turned out not to be screen architecture at all, but session/auth failure.
|
||||
|
||||
This surfaced as:
|
||||
- fetch failures
|
||||
- apparent data loading errors
|
||||
- misleading “network request failed” behavior
|
||||
|
||||
### Takeaway
|
||||
Auth state and session expiry can easily masquerade as architecture or fetch bugs.
|
||||
|
||||
This reinforced the importance of:
|
||||
- clearer auth handling
|
||||
- not assuming every fetch failure is a UI/data issue
|
||||
- checking session state early when debugging
|
||||
|
||||
---
|
||||
|
||||
# 14. Time handling and dual-boot issue insight
|
||||
A separate development issue was discovered related to system time mismatch in a Windows/Linux dual-boot environment.
|
||||
|
||||
Although not an app architecture feature directly, it affected development by causing:
|
||||
- failed requests
|
||||
- misleading network/auth behavior
|
||||
|
||||
### Development takeaway
|
||||
System time correctness matters for:
|
||||
- authentication
|
||||
- HTTPS
|
||||
- tokens
|
||||
- scheduled features
|
||||
|
||||
This was important context during debugging and implementation.
|
||||
|
||||
---
|
||||
|
||||
# 15. Notifications and reminders
|
||||
Assignment creation/updating included work around deadline reminders.
|
||||
|
||||
### Behavior
|
||||
When an assignment has a valid future deadline:
|
||||
- a reminder can be scheduled
|
||||
- previous reminders are updated/cancelled when necessary
|
||||
- notification IDs are stored through async storage helpers
|
||||
|
||||
### Why this matters architecturally
|
||||
This means assignment upsert behavior is not only CRUD. It also coordinates:
|
||||
- persistence
|
||||
- reminder scheduling
|
||||
- reminder cleanup
|
||||
|
||||
This is relevant for the final report because it shows that form flows have side effects beyond database writes.
|
||||
|
||||
---
|
||||
|
||||
# 16. General design principles used across the redesign
|
||||
|
||||
## Principle 1: reflect the true data hierarchy
|
||||
The UI should match the conceptual model:
|
||||
- subjects contain assignments
|
||||
- assignments contain tasks
|
||||
|
||||
## Principle 2: remove redundant top-level structure
|
||||
Not every data model deserves a top-level tab.
|
||||
|
||||
## Principle 3: keep list screens for browsing
|
||||
Heavy management actions should live in detail screens.
|
||||
|
||||
## Principle 4: preserve context
|
||||
The deeper the user goes, the more important parent context becomes.
|
||||
|
||||
## Principle 5: use color as identity, not decoration
|
||||
Subject colors provide contextual identity without overwhelming the UI.
|
||||
|
||||
## Principle 6: keep primary actions globally consistent
|
||||
App accent remains the primary action language.
|
||||
|
||||
## Principle 7: reduce duplication
|
||||
Reusable upsert screens and shared utilities reduce maintenance cost.
|
||||
|
||||
## Principle 8: avoid visual noise
|
||||
Redundant pills, repeated indicators, and crowded cards were intentionally reduced.
|
||||
|
||||
---
|
||||
|
||||
# 17. Current architecture summary
|
||||
|
||||
## Tabs
|
||||
- Dashboard
|
||||
- Subjects
|
||||
- Timer
|
||||
|
||||
## Hierarchy
|
||||
- Subject
|
||||
- Assignment
|
||||
- Task
|
||||
|
||||
## Reusable utilities
|
||||
- date formatting
|
||||
- subject color mapping
|
||||
- progress checks
|
||||
- async storage notification helpers
|
||||
|
||||
## Form strategy
|
||||
- upsert-style forms for core entities
|
||||
|
||||
## Design system
|
||||
- NativeWind
|
||||
- semantic Tailwind tokens
|
||||
- shared card/pill patterns
|
||||
- subject color inheritance
|
||||
|
||||
---
|
||||
|
||||
# 18. Final outcome
|
||||
The redesign changed the app from a flatter CRUD-style structure into a more coherent hierarchical study workflow.
|
||||
|
||||
The main improvements were:
|
||||
- better navigation structure
|
||||
- reduced redundancy
|
||||
- clearer subject/assignment/task relationships
|
||||
- stronger contextual design
|
||||
- less cluttered list screens
|
||||
- reusable form patterns
|
||||
- centralized shared helpers
|
||||
- more polished and consistent UI
|
||||
|
||||
Overall, the app now better reflects its intended purpose as a study productivity tool organized around the academic structure of:
|
||||
**Subject → Assignment → Task**
|
||||
|
||||
---
|
||||
|
||||
# 19. Possible items to mention later in the final report
|
||||
These are likely worth discussing explicitly:
|
||||
|
||||
- why Tasks and Assignments were removed as top-level tabs
|
||||
- why subject detail and assignment detail were turned into hubs
|
||||
- why create/edit were merged into upsert patterns
|
||||
- why a controlled color palette was used instead of arbitrary colors
|
||||
- why subject color was used for context, not for primary actions
|
||||
- why duplicated metadata indicators were removed
|
||||
- why shared date formatting and subject color utilities were extracted
|
||||
- how preserving hierarchy improved usability
|
||||
- how debugging/auth issues affected development decisions
|
||||
71
notes/work-report-2026-04-24-to-25.md
Normal file
71
notes/work-report-2026-04-24-to-25.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# CRUD Testing Summary (React Native + Jest + Supabase)
|
||||
|
||||
## What these tests are about
|
||||
Tests verify **app behavior**, not Supabase itself.
|
||||
|
||||
They check:
|
||||
- User interaction works
|
||||
- Correct database functions are called
|
||||
- Navigation happens after actions
|
||||
|
||||
---
|
||||
|
||||
## CRUD Breakdown
|
||||
|
||||
### CREATE
|
||||
- User inputs data
|
||||
- `insert()` is called
|
||||
- App navigates back
|
||||
|
||||
Flow:
|
||||
User → type → press create → insert() → router.back()
|
||||
|
||||
---
|
||||
|
||||
### READ
|
||||
- Data is fetched (`select().eq().single()`)
|
||||
- State updates
|
||||
- UI renders correct content
|
||||
|
||||
---
|
||||
|
||||
### UPDATE
|
||||
- Existing data is loaded
|
||||
- User edits input
|
||||
- `update().eq()` is called with correct values
|
||||
- Navigation happens
|
||||
|
||||
---
|
||||
|
||||
### DELETE
|
||||
- User presses delete
|
||||
- `Alert.alert()` is triggered
|
||||
- Confirm button (`onPress`) is manually called in test
|
||||
- `delete().eq()` runs
|
||||
- Navigation happens
|
||||
|
||||
---
|
||||
|
||||
## Why mocking is used
|
||||
- No real database calls
|
||||
- Faster tests
|
||||
- Full control over success/error cases
|
||||
- No side effects (no real data created/deleted)
|
||||
|
||||
---
|
||||
|
||||
## Mock rule
|
||||
The mock must match the real call chain:
|
||||
|
||||
Real:
|
||||
from → update → eq → select → single
|
||||
|
||||
Mock:
|
||||
from() → update() → eq() → select() → single()
|
||||
|
||||
If not → errors like:
|
||||
".select is not a function"
|
||||
|
||||
---
|
||||
|
||||
https://oss.callstack.com/react-native-testing-library/
|
||||
2910
package-lock.json
generated
2910
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -9,7 +9,8 @@
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
"lint": "expo lint",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
@@ -48,10 +49,20 @@
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "^55.0.16",
|
||||
"patch-package": "^8.0.1",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
"private": true,
|
||||
"jest": {
|
||||
"preset": "jest-expo"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user