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 { getSetupStatus } from '@/lib/setupStatus';
|
||||||
|
import { SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import { Subject } from '@/lib/types';
|
import { Subject } from '@/lib/types';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { Session } from '@supabase/supabase-js';
|
import { Session } from '@supabase/supabase-js';
|
||||||
import { Redirect, router, Stack, useFocusEffect } from 'expo-router';
|
import { Redirect, router, Stack, useFocusEffect } from 'expo-router';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
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';
|
import type { SubjectColor } from '@/lib/subjectColors';
|
||||||
|
|
||||||
@@ -38,6 +38,10 @@ export default function Subjects() {
|
|||||||
const [session, SetSession] = useState<Session | null>(null);
|
const [session, SetSession] = useState<Session | null>(null);
|
||||||
const [isFlowInfoVisible, setIsFlowInfoVisible] = useState(false);
|
const [isFlowInfoVisible, setIsFlowInfoVisible] = useState(false);
|
||||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
supabase.auth.getSession().then(({ data }) => {
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
@@ -74,6 +78,15 @@ export default function Subjects() {
|
|||||||
|
|
||||||
const GetSubjects = useCallback(async () => {
|
const GetSubjects = useCallback(async () => {
|
||||||
if (!session?.user.id) return;
|
if (!session?.user.id) return;
|
||||||
|
const GetSubjects = async () => {
|
||||||
|
if (!session?.user.id) {
|
||||||
|
SetIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('subjects')
|
.from('subjects')
|
||||||
@@ -81,13 +94,18 @@ export default function Subjects() {
|
|||||||
.eq('uId', session.user.id)
|
.eq('uId', session.user.id)
|
||||||
.order('lastChanged', { ascending: false });
|
.order('lastChanged', { ascending: false });
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
Alert.alert('Subjects could not be fetched, please try again');
|
Alert.alert('Subjects could not be fetched, please try again');
|
||||||
|
SetIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SetSubjects((data as Subject[]) ?? []);
|
SetSubjects((data as Subject[]) ?? []);
|
||||||
}, [session?.user.id]);
|
}, [session?.user.id]);
|
||||||
|
SetIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -103,6 +121,78 @@ export default function Subjects() {
|
|||||||
|
|
||||||
if (needsSetup) {
|
if (needsSetup) {
|
||||||
return <Redirect href="/setup" />;
|
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 (
|
return (
|
||||||
@@ -232,14 +322,14 @@ export default function Subjects() {
|
|||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View className="mb-6">
|
{isLoading ? (
|
||||||
<Text className="text-3xl font-bold text-text-main">Subjects</Text>
|
<View className="items-center justify-center rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
<Text className="mt-2 text-base leading-6 text-text-secondary">
|
<ActivityIndicator size="large" color="#2563eb" />
|
||||||
Pick a subject to manage assignments and tasks.
|
<Text className="mt-4 text-center text-base font-semibold text-text-secondary">
|
||||||
</Text>
|
Loading subjects...
|
||||||
</View>
|
</Text>
|
||||||
|
</View>
|
||||||
{subjects.length === 0 ? (
|
) : subjects.length === 0 ? (
|
||||||
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
<Text className="text-center text-xl font-bold text-text-main">
|
<Text className="text-center text-xl font-bold text-text-main">
|
||||||
No subjects yet
|
No subjects yet
|
||||||
@@ -260,74 +350,55 @@ export default function Subjects() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View>
|
<View>
|
||||||
{subjects.map((subject) => {
|
<View className="mb-3 mt-2 flex-row items-center justify-between">
|
||||||
const colorKey: SubjectColor = subject.color ?? 'slate';
|
<Text className="text-lg font-bold text-text-main">
|
||||||
const colorSet = SUBJECT_COLORS[colorKey];
|
Active Subjects
|
||||||
|
</Text>
|
||||||
|
|
||||||
const firstLetter =
|
<View className="rounded-full bg-app-subtle px-3 py-1">
|
||||||
subject.title?.trim().charAt(0).toUpperCase() || '?';
|
<Text className="text-xs font-semibold text-text-muted">
|
||||||
|
{activeSubjects.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
return (
|
{activeSubjects.length === 0 ? (
|
||||||
<Pressable
|
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
key={subject.sId}
|
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||||
className="mb-4 rounded-3xl bg-app-surface p-4"
|
No active subjects
|
||||||
style={{
|
</Text>
|
||||||
borderWidth: 1,
|
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||||
borderColor: colorSet.strong,
|
Subjects with ongoing work will show up here.
|
||||||
}}
|
</Text>
|
||||||
onPress={() =>
|
</View>
|
||||||
router.push({
|
) : (
|
||||||
pathname: '/subject/viewDetailsSubject',
|
activeSubjects.map(RenderSubjectCard)
|
||||||
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">
|
<View className="mb-3 mt-2 flex-row items-center justify-between">
|
||||||
<Text
|
<Text className="text-lg font-bold text-text-main">
|
||||||
className="text-base font-bold text-text-main"
|
Inactive Subjects
|
||||||
numberOfLines={1}
|
</Text>
|
||||||
>
|
|
||||||
{subject.title}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text
|
<View className="rounded-full bg-app-subtle px-3 py-1">
|
||||||
className="mt-1 text-sm leading-5 text-text-secondary"
|
<Text className="text-xs font-semibold text-text-muted">
|
||||||
numberOfLines={2}
|
{inactiveSubjects.length}
|
||||||
>
|
</Text>
|
||||||
{subject.description || 'No description added.'}
|
</View>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="ml-3">
|
{inactiveSubjects.length === 0 ? (
|
||||||
<View
|
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
className="rounded-full px-3 py-1"
|
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||||
style={{ backgroundColor: colorSet.soft }}
|
No inactive subjects
|
||||||
>
|
</Text>
|
||||||
<Text
|
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||||
className="text-xs font-semibold"
|
Completed or paused subjects will show up here.
|
||||||
style={{ color: colorSet.strong }}
|
</Text>
|
||||||
>
|
</View>
|
||||||
{subject.isActive ? 'Active' : 'Inactive'}
|
) : (
|
||||||
</Text>
|
inactiveSubjects.map(RenderSubjectCard)
|
||||||
</View>
|
)}
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ export default function UpsertAssignment() {
|
|||||||
<View className="mb-5">
|
<View className="mb-5">
|
||||||
<Text className={labelClassName}>Title</Text>
|
<Text className={labelClassName}>Title</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
testID = "assignment-title-input"
|
||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
placeholder={
|
placeholder={
|
||||||
isSetupFlow ? 'e.g. Weekly problem set 3' : 'Enter assignment title'
|
isSetupFlow ? 'e.g. Weekly problem set 3' : 'Enter assignment title'
|
||||||
@@ -345,6 +346,7 @@ export default function UpsertAssignment() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
|
testID = "upsert-assignment-button"
|
||||||
className={`h-14 items-center justify-center rounded-2xl ${
|
className={`h-14 items-center justify-center rounded-2xl ${
|
||||||
isSaving ? 'bg-accent-disabled' : 'bg-accent'
|
isSaving ? 'bg-accent-disabled' : 'bg-accent'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Assignment, Task } from '@/lib/types';
|
|||||||
import { Session } from '@supabase/supabase-js';
|
import { Session } from '@supabase/supabase-js';
|
||||||
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
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() {
|
export default function ViewDetailsAssignment() {
|
||||||
@@ -14,6 +14,7 @@ export default function ViewDetailsAssignment() {
|
|||||||
const [assignment, SetAssignment] = useState<Assignment | null>(null);
|
const [assignment, SetAssignment] = useState<Assignment | null>(null);
|
||||||
const [tasks, SetTasks] = useState<Task[]>([]);
|
const [tasks, SetTasks] = useState<Task[]>([]);
|
||||||
const [session, SetSession] = useState<Session | null>(null);
|
const [session, SetSession] = useState<Session | null>(null);
|
||||||
|
const [isLoading, SetIsLoading] = useState(false);
|
||||||
const [subjectMeta, setSubjectMeta] = useState({
|
const [subjectMeta, setSubjectMeta] = useState({
|
||||||
title: 'No Subject',
|
title: 'No Subject',
|
||||||
color: 'slate' as SubjectColor,
|
color: 'slate' as SubjectColor,
|
||||||
@@ -34,11 +35,15 @@ export default function ViewDetailsAssignment() {
|
|||||||
[])
|
[])
|
||||||
|
|
||||||
const GetAssignment = async (assignmentId: string) => {
|
const GetAssignment = async (assignmentId: string) => {
|
||||||
const { data, error } = await supabase
|
SetIsLoading(true);
|
||||||
.from('assignments')
|
|
||||||
.select('*')
|
const { data, error } = await supabase
|
||||||
.eq('aId', assignmentId)
|
.from('assignments')
|
||||||
.single();
|
.select('*')
|
||||||
|
.eq('aId', assignmentId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
Alert.alert('Assignment could not be fetched, please try again');
|
Alert.alert('Assignment could not be fetched, please try again');
|
||||||
@@ -48,12 +53,16 @@ export default function ViewDetailsAssignment() {
|
|||||||
SetAssignment(data);
|
SetAssignment(data);
|
||||||
|
|
||||||
if (data.sId) {
|
if (data.sId) {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
const { data: subjectData, error: subjectError } = await supabase
|
const { data: subjectData, error: subjectError } = await supabase
|
||||||
.from('subjects')
|
.from('subjects')
|
||||||
.select('title, color')
|
.select('title, color')
|
||||||
.eq('sId', data.sId)
|
.eq('sId', data.sId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
if (subjectError || !subjectData) {
|
if (subjectError || !subjectData) {
|
||||||
setSubjectMeta({
|
setSubjectMeta({
|
||||||
title: 'Unknown Subject',
|
title: 'Unknown Subject',
|
||||||
@@ -70,8 +79,12 @@ export default function ViewDetailsAssignment() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GetTasks = async (aId: string) => {
|
const GetTasks = async (aId: string) => {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
const { data, error } = await supabase.from("tasks").select("*").eq("aId", aId);
|
const { data, error } = await supabase.from("tasks").select("*").eq("aId", aId);
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
Alert.alert("Tasks could not be fetched, please try again");
|
Alert.alert("Tasks could not be fetched, please try again");
|
||||||
return;
|
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 colorSet = getSubjectColorSet(subjectMeta.color);
|
||||||
|
|
||||||
const completedTasks = tasks.filter((task) => task.isCompleted).length;
|
const completedTasks = tasks.filter((task) => task.isCompleted).length;
|
||||||
@@ -176,6 +215,14 @@ export default function ViewDetailsAssignment() {
|
|||||||
? 0
|
? 0
|
||||||
: Math.round((completedTasks / totalTasks) * 100);
|
: 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) {
|
if (!assignment) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||||
@@ -233,7 +280,7 @@ export default function ViewDetailsAssignment() {
|
|||||||
<SectionList
|
<SectionList
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 20, paddingBottom: 32 }}
|
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 20, paddingBottom: 32 }}
|
||||||
sections={taskSections}
|
sections={totalTasks === 0 ? [] : taskSections}
|
||||||
keyExtractor={(item) => item.tId}
|
keyExtractor={(item) => item.tId}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
stickySectionHeadersEnabled={false}
|
stickySectionHeadersEnabled={false}
|
||||||
@@ -247,18 +294,6 @@ export default function ViewDetailsAssignment() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex-row items-start">
|
<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">
|
<View className="flex-1">
|
||||||
<Text className="text-2xl font-bold text-text-main">
|
<Text className="text-2xl font-bold text-text-main">
|
||||||
{assignment.title}
|
{assignment.title}
|
||||||
@@ -323,6 +358,35 @@ export default function ViewDetailsAssignment() {
|
|||||||
Based only on completed tasks in this assignment.
|
Based only on completed tasks in this assignment.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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">
|
<Text className="mt-4 text-sm text-text-muted">
|
||||||
Last changed: {formatDateTime(assignment.lastChanged)}
|
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"
|
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push({
|
router.push({
|
||||||
pathname: '/assignment/upsertAssignment',
|
pathname: '../assignment/upsertAssignment',
|
||||||
params: { aId: assignment.aId },
|
params: { aId: assignment.aId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -344,6 +408,7 @@ export default function ViewDetailsAssignment() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
|
testID="delete-assignment-button"
|
||||||
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||||
onPress={() => DeleteAssignment(assignment.aId)}
|
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"
|
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push({
|
router.push({
|
||||||
pathname: '/task/upsertTask',
|
pathname: '../task/upsertTask',
|
||||||
params: { aId: assignment.aId },
|
params: { aId: assignment.aId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -400,18 +465,6 @@ export default function ViewDetailsAssignment() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex-row items-start">
|
<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">
|
<View className="flex-1">
|
||||||
<Text
|
<Text
|
||||||
className={`text-base font-bold ${
|
className={`text-base font-bold ${
|
||||||
@@ -435,11 +488,24 @@ export default function ViewDetailsAssignment() {
|
|||||||
|
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<View className="mt-4 flex-row border-t border-app-border pt-4">
|
<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
|
<Pressable
|
||||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push({
|
router.push({
|
||||||
pathname: '/task/upsertTask',
|
pathname: '../task/upsertTask',
|
||||||
params: { tId: item.tId },
|
params: { tId: item.tId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -460,6 +526,19 @@ export default function ViewDetailsAssignment() {
|
|||||||
</View>
|
</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 }) =>
|
renderSectionFooter={({ section }) =>
|
||||||
section.data.length === 0 ? (
|
section.data.length === 0 ? (
|
||||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5" style={{ borderColor: colorSet.strong }}>
|
<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>
|
<Text className={labelClassName}>Title</Text>
|
||||||
<TextInput className={inputClassName}
|
<TextInput className={inputClassName}
|
||||||
placeholder={isSetupFlow ? 'e.g. Algorithms' : 'Enter subject title'}
|
placeholder={isSetupFlow ? 'e.g. Algorithms' : 'Enter subject title'}
|
||||||
|
testID = "subject-title-input"
|
||||||
placeholderTextColor="#9CA3AF"
|
placeholderTextColor="#9CA3AF"
|
||||||
value={title}
|
value={title}
|
||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
@@ -324,6 +325,7 @@ export default function UpsertSubject() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
|
testID = "upsert-subject-button"
|
||||||
className={`h-14 items-center justify-center rounded-2xl ${
|
className={`h-14 items-center justify-center rounded-2xl ${
|
||||||
isSaving
|
isSaving
|
||||||
? 'bg-accent-disabled'
|
? 'bg-accent-disabled'
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Assignment } from '@/lib/types';
|
|||||||
import { Session } from '@supabase/supabase-js';
|
import { Session } from '@supabase/supabase-js';
|
||||||
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
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 = {
|
export type Subject = {
|
||||||
sId: string;
|
sId: string;
|
||||||
@@ -23,6 +23,7 @@ export default function ViewDetailsSubject() {
|
|||||||
const [subject, SetSubject] = useState<Subject | null>(null);
|
const [subject, SetSubject] = useState<Subject | null>(null);
|
||||||
const [assignments, SetAssignments] = useState<Assignment[]>([]);
|
const [assignments, SetAssignments] = useState<Assignment[]>([]);
|
||||||
const [session, SetSession] = useState<Session | null>(null);
|
const [session, SetSession] = useState<Session | null>(null);
|
||||||
|
const [isLoading, SetIsLoading] = useState(false);
|
||||||
|
|
||||||
const assignmentSections = [
|
const assignmentSections = [
|
||||||
{
|
{
|
||||||
@@ -48,12 +49,16 @@ export default function ViewDetailsSubject() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const GetSubject = async (subjectId: string) => {
|
const GetSubject = async (subjectId: string) => {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('subjects')
|
.from('subjects')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('sId', subjectId)
|
.eq('sId', subjectId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
Alert.alert('Subject could not be fetched, please try again');
|
Alert.alert('Subject could not be fetched, please try again');
|
||||||
return;
|
return;
|
||||||
@@ -63,12 +68,16 @@ export default function ViewDetailsSubject() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GetAssignments = async (subjectId: string) => {
|
const GetAssignments = async (subjectId: string) => {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('assignments')
|
.from('assignments')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('sId', subjectId)
|
.eq('sId', subjectId)
|
||||||
.order('deadline', { ascending: true });
|
.order('deadline', { ascending: true });
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
Alert.alert('Assignments could not be fetched, please try again');
|
Alert.alert('Assignments could not be fetched, please try again');
|
||||||
return;
|
return;
|
||||||
@@ -77,12 +86,44 @@ export default function ViewDetailsSubject() {
|
|||||||
SetAssignments(data ?? []);
|
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(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
if (session && sId) {
|
if (!session || !sId) {
|
||||||
GetSubject(sId);
|
return;
|
||||||
GetAssignments(sId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetIsLoading(true);
|
||||||
|
SetSubject(null);
|
||||||
|
|
||||||
|
Promise.all([GetSubject(sId), GetAssignments(sId)]).finally(() => {
|
||||||
|
SetIsLoading(false);
|
||||||
|
});
|
||||||
}, [session, sId])
|
}, [session, sId])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,6 +208,25 @@ export default function ViewDetailsSubject() {
|
|||||||
? 0
|
? 0
|
||||||
: Math.round((completedAssignments / totalAssignments) * 100);
|
: 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) {
|
if (!subject) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||||
@@ -228,7 +288,7 @@ export default function ViewDetailsSubject() {
|
|||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
paddingBottom: 32,
|
paddingBottom: 32,
|
||||||
}}
|
}}
|
||||||
sections={assignmentSections}
|
sections={totalAssignments === 0 ? [] : assignmentSections}
|
||||||
keyExtractor={(item) => item.aId}
|
keyExtractor={(item) => item.aId}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
stickySectionHeadersEnabled={false}
|
stickySectionHeadersEnabled={false}
|
||||||
@@ -290,34 +350,37 @@ export default function ViewDetailsSubject() {
|
|||||||
<Text className="text-sm font-semibold text-text-secondary">
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
Assignments completed
|
Assignments completed
|
||||||
</Text>
|
</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">
|
<Text className="text-sm font-bold text-text-main">
|
||||||
{completedAssignments}/{totalAssignments}
|
{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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
) : null}
|
||||||
<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>
|
|
||||||
|
|
||||||
<Text className="mt-4 text-sm text-text-muted">
|
<Text className="mt-4 text-sm text-text-muted">
|
||||||
Last changed: {formatDateTime(subject.lastChanged)}
|
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"
|
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push({
|
router.push({
|
||||||
pathname: '/subject/upsertSubject',
|
pathname: '../subject/upsertSubject',
|
||||||
params: { sId: subject.sId },
|
params: { sId: subject.sId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -339,6 +402,7 @@ export default function ViewDetailsSubject() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
|
testID="delete-subject-button"
|
||||||
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||||
onPress={() => DeleteSubject(subject.sId)}
|
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"
|
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push({
|
router.push({
|
||||||
pathname: '/assignment/upsertAssignment',
|
pathname: '../assignment/upsertAssignment',
|
||||||
params: { sId: subject.sId },
|
params: { sId: subject.sId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text className="text-base font-bold text-text-inverse">
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
Create Assignment
|
Add Assignment
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -385,15 +449,16 @@ export default function ViewDetailsSubject() {
|
|||||||
borderColor: colorSet.strong,
|
borderColor: colorSet.strong,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable
|
<View className="flex-row items-center">
|
||||||
onPress={() =>
|
<Pressable
|
||||||
router.push({
|
className="flex-1"
|
||||||
pathname: '/assignment/viewDetailsAssignment',
|
onPress={() =>
|
||||||
params: { aId: item.aId },
|
router.push({
|
||||||
})
|
pathname: '/assignment/viewDetailsAssignment',
|
||||||
}
|
params: { aId: item.aId },
|
||||||
>
|
})
|
||||||
<View className="flex-row items-center">
|
}
|
||||||
|
>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text
|
<Text
|
||||||
className={`text-base font-bold ${
|
className={`text-base font-bold ${
|
||||||
@@ -416,16 +481,29 @@ export default function ViewDetailsSubject() {
|
|||||||
Deadline: {formatDate(item.deadline)}
|
Deadline: {formatDate(item.deadline)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Pressable>
|
||||||
</Pressable>
|
</View>
|
||||||
|
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<View className="mt-4 flex-row border-t border-app-border pt-4">
|
<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
|
<Pressable
|
||||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push({
|
router.push({
|
||||||
pathname: '/assignment/upsertAssignment',
|
pathname: '../assignment/upsertAssignment',
|
||||||
params: { aId: item.aId },
|
params: { aId: item.aId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -448,6 +526,16 @@ export default function ViewDetailsSubject() {
|
|||||||
</View>
|
</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 }) =>
|
renderSectionFooter={({ section }) =>
|
||||||
section.data.length === 0 ? (
|
section.data.length === 0 ? (
|
||||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
<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">
|
<View className="mb-5">
|
||||||
<Text className={labelClassName}>Title</Text>
|
<Text className={labelClassName}>Title</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
testID="task-title-input"
|
||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
placeholder={isSetupFlow ? 'e.g. Solve questions 1-3' : 'Enter task title'}
|
placeholder={isSetupFlow ? 'e.g. Solve questions 1-3' : 'Enter task title'}
|
||||||
placeholderTextColor="#9CA3AF"
|
placeholderTextColor="#9CA3AF"
|
||||||
@@ -258,6 +259,7 @@ export default function UpsertTask() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
|
testID="upsert-task-button"
|
||||||
className={`h-14 items-center justify-center rounded-2xl ${
|
className={`h-14 items-center justify-center rounded-2xl ${
|
||||||
isSaving ? 'bg-accent-disabled' : 'bg-accent'
|
isSaving ? 'bg-accent-disabled' : 'bg-accent'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { Task } from '@/lib/types';
|
|||||||
import { Session } from '@supabase/supabase-js';
|
import { Session } from '@supabase/supabase-js';
|
||||||
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
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) {
|
function formatTrackedTime(totalSeconds: number) {
|
||||||
if (totalSeconds <= 0) {
|
if (totalSeconds <= 0) {
|
||||||
@@ -89,7 +89,16 @@ const GetTask = useCallback(async (taskId: string) => {
|
|||||||
.from('assignments')
|
.from('assignments')
|
||||||
.select('title, sId')
|
.select('title, sId')
|
||||||
.eq('aId', data.aId)
|
.eq('aId', data.aId)
|
||||||
|
const GetTask = async (taskId: string) => {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('*')
|
||||||
|
.eq('tId', taskId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
if (assignmentError || !assignmentData) {
|
if (assignmentError || !assignmentData) {
|
||||||
setContextMeta({
|
setContextMeta({
|
||||||
@@ -108,6 +117,20 @@ const GetTask = useCallback(async (taskId: string) => {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (subjectError || !subjectData) {
|
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({
|
setContextMeta({
|
||||||
subjectTitle: 'Unknown Subject',
|
subjectTitle: 'Unknown Subject',
|
||||||
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
|
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
|
||||||
@@ -124,6 +147,25 @@ const GetTask = useCallback(async (taskId: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [loadTaskStudyActivity]);
|
}, [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(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
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) => {
|
const DeleteTask = async (taskId: string) => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ export async function RegisterForLocalNotificationsAsync() {
|
|||||||
HandleRegistrationError('Permission not granted for local notifications');
|
HandleRegistrationError('Permission not granted for local notifications');
|
||||||
return;
|
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",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
@@ -48,10 +49,20 @@
|
|||||||
"tailwindcss": "^3.4.19"
|
"tailwindcss": "^3.4.19"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react-native": "^13.3.3",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.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"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true,
|
||||||
}
|
"jest": {
|
||||||
|
"preset": "jest-expo"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user