Compare commits
21 Commits
2f8a770c2a
...
d25e241273
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d25e241273 | ||
|
|
bc06704e0b | ||
|
|
e18fc525cf | ||
|
|
419463e5be | ||
|
|
f2312bce38 | ||
|
|
66efbecf2f | ||
|
|
ac6bfa1022 | ||
|
|
a536be1047 | ||
|
|
27d04b2a3b | ||
|
|
6127e6ec08 | ||
|
|
179fe3fd50 | ||
|
|
e5982c2eb3 | ||
|
|
68f520b99e | ||
|
|
d2e3c2e718 | ||
|
|
ae613f8707 | ||
|
|
e3c0b286b8 | ||
|
|
efb2e11893 | ||
|
|
7e33500fad | ||
|
|
3dcd392cd3 | ||
|
|
545027a766 | ||
|
|
d7dc3cc72f |
77
__tests__/assignment/createAssignment.test.tsx
Normal file
77
__tests__/assignment/createAssignment.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import UpsertAssignment from "@/app/assignment/upsertAssignment";
|
||||
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/asyncStorage", () => ({
|
||||
GetAssignmentNotificationId: jest.fn(() => Promise.resolve()),
|
||||
SaveAssignmentNotificationId: jest.fn(() => Promise.resolve()),
|
||||
RemoveAssignmentNotificationId: 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(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 { 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/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 an assignment 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];
|
||||
expect(alertButtons).toBeDefined();
|
||||
const confirmDeleteButton = alertButtons?.[1];
|
||||
expect(confirmDeleteButton?.onPress).toBeDefined();
|
||||
|
||||
if (!confirmDeleteButton?.onPress) {
|
||||
throw new Error("Delete confirmation button missing");
|
||||
}
|
||||
|
||||
await confirmDeleteButton.onPress();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(supabase.from).toHaveBeenCalledWith("assignments");
|
||||
expect(mockAssignmentDelete).toHaveBeenCalled();
|
||||
expect(mockAssignmentDeleteEq).toHaveBeenCalledWith("aId", "assignment-123");
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
96
__tests__/assignment/editAssignment.test.tsx
Normal file
96
__tests__/assignment/editAssignment.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import UpsertAssignment from "@/app/assignment/upsertAssignment";
|
||||
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/asyncStorage", () => ({
|
||||
GetAssignmentNotificationId: jest.fn(() => Promise.resolve(null)),
|
||||
SaveAssignmentNotificationId: jest.fn(() => Promise.resolve()),
|
||||
RemoveAssignmentNotificationId: 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(() => ({
|
||||
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(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
92
__tests__/authGuard.test.tsx
Normal file
92
__tests__/authGuard.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import TabLayout from "@/app/(tabs)/_layout";
|
||||
import { getSetupStatus } from "@/lib/setupStatus";
|
||||
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/setupStatus", () => ({
|
||||
getSetupStatus: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: jest.fn(),
|
||||
onAuthStateChange: jest.fn(() => ({
|
||||
data: {
|
||||
subscription: {
|
||||
unsubscribe: jest.fn(),
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(getSetupStatus as jest.Mock).mockResolvedValue({
|
||||
subjectId: "subject-123",
|
||||
assignmentId: "assignment-123",
|
||||
taskId: "task-123",
|
||||
completedFocusSessions: 1,
|
||||
currentStep: "sprint",
|
||||
isSetupComplete: true,
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
63
__tests__/subject/createSubject.test.tsx
Normal file
63
__tests__/subject/createSubject.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
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 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: () => ({}),
|
||||
}));
|
||||
|
||||
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 () => {
|
||||
mockSingle.mockResolvedValue({
|
||||
data: {
|
||||
sId: "subject-123",
|
||||
title: "ikt205g26v",
|
||||
uId: "user-123",
|
||||
},
|
||||
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(mockSelect).toHaveBeenCalled();
|
||||
expect(mockSingle).toHaveBeenCalled();
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
122
__tests__/subject/deleteSubject.test.tsx
Normal file
122
__tests__/subject/deleteSubject.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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];
|
||||
expect(alertButtons).toBeDefined();
|
||||
const confirmDeleteButton = alertButtons?.[1];
|
||||
expect(confirmDeleteButton?.onPress).toBeDefined();
|
||||
|
||||
if (!confirmDeleteButton?.onPress) {
|
||||
throw new Error("Delete confirmation button missing");
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
73
__tests__/task/createTask.test.tsx
Normal file
73
__tests__/task/createTask.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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 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: () => ({
|
||||
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 () => {
|
||||
mockSingle.mockResolvedValue({
|
||||
data: {
|
||||
tId: "task-123",
|
||||
title: "Read chapter 4",
|
||||
uId: "user-123",
|
||||
aId: "assignment-123",
|
||||
},
|
||||
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(mockSelect).toHaveBeenCalled();
|
||||
expect(mockSingle).toHaveBeenCalled();
|
||||
expect(CheckAssignmentCompletion).toHaveBeenCalledWith("assignment-123");
|
||||
expect(router.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
169
__tests__/task/deleteTask.test.tsx
Normal file
169
__tests__/task/deleteTask.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
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 }));
|
||||
|
||||
const mockSprintSessionsEqCompleted = jest.fn();
|
||||
const mockSprintSessionsEqSessionType = jest.fn(() => ({ eq: mockSprintSessionsEqCompleted }));
|
||||
const mockSprintSessionsEqUser = jest.fn(() => ({ eq: mockSprintSessionsEqSessionType }));
|
||||
const mockSprintSessionsEqTask = jest.fn(() => ({ eq: mockSprintSessionsEqUser }));
|
||||
const mockSprintSessionsSelect = jest.fn(() => ({ eq: mockSprintSessionsEqTask }));
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "sprint_sessions") {
|
||||
return {
|
||||
select: mockSprintSessionsSelect,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
mockSprintSessionsEqCompleted.mockResolvedValue({ count: 0, error: null });
|
||||
mockTaskDeleteEq.mockResolvedValue({ error: null, });
|
||||
|
||||
const screen = render(<ViewDetailsTask />);
|
||||
|
||||
await screen.findByText("Read chapter 4");
|
||||
await screen.findByText("ikt205g26v");
|
||||
|
||||
fireEvent.press(await screen.findByText("Delete"));
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith(
|
||||
"Delete Task",
|
||||
"Are you sure you want to delete this task?",
|
||||
expect.any(Array),
|
||||
);
|
||||
|
||||
const alertButtons = alertSpy.mock.calls[0]?.[2];
|
||||
expect(alertButtons).toBeDefined();
|
||||
const confirmDeleteButton = alertButtons?.[1];
|
||||
expect(confirmDeleteButton?.onPress).toBeDefined();
|
||||
|
||||
if (!confirmDeleteButton?.onPress) {
|
||||
throw new Error("Delete confirmation button missing");
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
2
app.json
2
app.json
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.teodorsa.StudySprint"
|
||||
"package": "com.softsand.studysprint"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { SUBJECT_COLORS } from '@/lib/subjectColors';
|
||||
import { getSetupStatus } from '@/lib/setupStatus';
|
||||
import { SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Subject } from '@/lib/types';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { Redirect, router, Stack, useFocusEffect } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
import type { SubjectColor } from '@/lib/subjectColors';
|
||||
import { Alert, Modal, Pressable, ScrollView, Text, View, ActivityIndicator } from 'react-native';
|
||||
|
||||
const FLOW_STEPS = [
|
||||
{
|
||||
@@ -38,6 +36,10 @@ export default function Subjects() {
|
||||
const [session, SetSession] = useState<Session | null>(null);
|
||||
const [isFlowInfoVisible, setIsFlowInfoVisible] = useState(false);
|
||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||
const [isLoading, SetIsLoading] = useState(true);
|
||||
|
||||
const activeSubjects = subjects.filter((subject) => subject.isActive);
|
||||
const inactiveSubjects = subjects.filter((subject) => !subject.isActive);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
@@ -73,7 +75,12 @@ export default function Subjects() {
|
||||
}, [session?.user.id]);
|
||||
|
||||
const GetSubjects = useCallback(async () => {
|
||||
if (!session?.user.id) return;
|
||||
if (!session?.user.id) {
|
||||
SetIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subjects')
|
||||
@@ -81,6 +88,8 @@ export default function Subjects() {
|
||||
.eq('uId', session.user.id)
|
||||
.order('lastChanged', { ascending: false });
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Subjects could not be fetched, please try again');
|
||||
return;
|
||||
@@ -105,6 +114,80 @@ export default function Subjects() {
|
||||
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 (
|
||||
<View className="flex-1 bg-app-bg">
|
||||
<Stack.Screen
|
||||
@@ -232,14 +315,14 @@ export default function Subjects() {
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View className="mb-6">
|
||||
<Text className="text-3xl font-bold text-text-main">Subjects</Text>
|
||||
<Text className="mt-2 text-base leading-6 text-text-secondary">
|
||||
Pick a subject to manage assignments and tasks.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{subjects.length === 0 ? (
|
||||
{isLoading ? (
|
||||
<View className="items-center justify-center rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text className="mt-4 text-center text-base font-semibold text-text-secondary">
|
||||
Loading subjects...
|
||||
</Text>
|
||||
</View>
|
||||
) : subjects.length === 0 ? (
|
||||
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-center text-xl font-bold text-text-main">
|
||||
No subjects yet
|
||||
@@ -260,74 +343,55 @@ export default function Subjects() {
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{subjects.map((subject) => {
|
||||
const colorKey: SubjectColor = subject.color ?? 'slate';
|
||||
const colorSet = SUBJECT_COLORS[colorKey];
|
||||
<View className="mb-3 mt-2 flex-row items-center justify-between">
|
||||
<Text className="text-lg font-bold text-text-main">
|
||||
Active Subjects
|
||||
</Text>
|
||||
|
||||
const firstLetter =
|
||||
subject.title?.trim().charAt(0).toUpperCase() || '?';
|
||||
<View className="rounded-full bg-app-subtle px-3 py-1">
|
||||
<Text className="text-xs font-semibold text-text-muted">
|
||||
{activeSubjects.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={subject.sId}
|
||||
className="mb-4 rounded-3xl bg-app-surface p-4"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorSet.strong,
|
||||
}}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/subject/viewDetailsSubject',
|
||||
params: { sId: subject.sId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<View
|
||||
className="mr-3 h-12 w-12 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: colorSet.soft }}
|
||||
>
|
||||
<Text
|
||||
className="text-base font-bold"
|
||||
style={{ color: colorSet.strong }}
|
||||
>
|
||||
{firstLetter}
|
||||
</Text>
|
||||
</View>
|
||||
{activeSubjects.length === 0 ? (
|
||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||
No active subjects
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||
Subjects with ongoing work will show up here.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
activeSubjects.map(RenderSubjectCard)
|
||||
)}
|
||||
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-base font-bold text-text-main"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{subject.title}
|
||||
</Text>
|
||||
<View className="mb-3 mt-2 flex-row items-center justify-between">
|
||||
<Text className="text-lg font-bold text-text-main">
|
||||
Inactive Subjects
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
className="mt-1 text-sm leading-5 text-text-secondary"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{subject.description || 'No description added.'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="rounded-full bg-app-subtle px-3 py-1">
|
||||
<Text className="text-xs font-semibold text-text-muted">
|
||||
{inactiveSubjects.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="ml-3">
|
||||
<View
|
||||
className="rounded-full px-3 py-1"
|
||||
style={{ backgroundColor: colorSet.soft }}
|
||||
>
|
||||
<Text
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: colorSet.strong }}
|
||||
>
|
||||
{subject.isActive ? 'Active' : 'Inactive'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{inactiveSubjects.length === 0 ? (
|
||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||
No inactive subjects
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||
Completed or paused subjects will show up here.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
inactiveSubjects.map(RenderSubjectCard)
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defaultStyles } from '@/constants/defaultStyles';
|
||||
import * as AsyncStorage from '@/lib/asyncStorage';
|
||||
import { CheckSubjectCompletion } from '@/lib/progress';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { router, Stack, useLocalSearchParams } from 'expo-router';
|
||||
@@ -189,12 +188,6 @@ export default function UpsertAssignment() {
|
||||
savedAssignment.isCompleted
|
||||
);
|
||||
|
||||
try {
|
||||
await CheckSubjectCompletion(subjectId);
|
||||
} catch {
|
||||
Alert.alert('Failed to update subject status');
|
||||
}
|
||||
|
||||
SetIsSaving(false);
|
||||
|
||||
if (!isEditMode && isSetupFlow) {
|
||||
@@ -270,6 +263,7 @@ export default function UpsertAssignment() {
|
||||
<View className="mb-5">
|
||||
<Text className={labelClassName}>Title</Text>
|
||||
<TextInput
|
||||
testID = "assignment-title-input"
|
||||
className={inputClassName}
|
||||
placeholder={
|
||||
isSetupFlow ? 'e.g. Weekly problem set 3' : 'Enter assignment title'
|
||||
@@ -345,6 +339,7 @@ export default function UpsertAssignment() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID = "upsert-assignment-button"
|
||||
className={`h-14 items-center justify-center rounded-2xl ${
|
||||
isSaving ? 'bg-accent-disabled' : 'bg-accent'
|
||||
}`}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { formatDate, formatDateTime } from '@/lib/date';
|
||||
import { CheckAssignmentCompletion, CheckSubjectCompletion } from '@/lib/progress';
|
||||
import { CheckAssignmentCompletion } from '@/lib/progress';
|
||||
import { getSubjectColorSet, type SubjectColor } from '@/lib/subjectColors';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { Assignment, Task } from '@/lib/types';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Pressable, SectionList, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Alert, Pressable, SectionList, Text, View } from "react-native";
|
||||
|
||||
|
||||
export default function ViewDetailsAssignment() {
|
||||
@@ -14,6 +14,7 @@ export default function ViewDetailsAssignment() {
|
||||
const [assignment, SetAssignment] = useState<Assignment | null>(null);
|
||||
const [tasks, SetTasks] = useState<Task[]>([]);
|
||||
const [session, SetSession] = useState<Session | null>(null);
|
||||
const [isLoading, SetIsLoading] = useState(false);
|
||||
const [subjectMeta, setSubjectMeta] = useState({
|
||||
title: 'No Subject',
|
||||
color: 'slate' as SubjectColor,
|
||||
@@ -34,11 +35,15 @@ export default function ViewDetailsAssignment() {
|
||||
[])
|
||||
|
||||
const GetAssignment = async (assignmentId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('assignments')
|
||||
.select('*')
|
||||
.eq('aId', assignmentId)
|
||||
.single();
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('assignments')
|
||||
.select('*')
|
||||
.eq('aId', assignmentId)
|
||||
.single();
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (error || !data) {
|
||||
Alert.alert('Assignment could not be fetched, please try again');
|
||||
@@ -48,12 +53,16 @@ export default function ViewDetailsAssignment() {
|
||||
SetAssignment(data);
|
||||
|
||||
if (data.sId) {
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data: subjectData, error: subjectError } = await supabase
|
||||
.from('subjects')
|
||||
.select('title, color')
|
||||
.eq('sId', data.sId)
|
||||
.single();
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (subjectError || !subjectData) {
|
||||
setSubjectMeta({
|
||||
title: 'Unknown Subject',
|
||||
@@ -70,8 +79,12 @@ export default function ViewDetailsAssignment() {
|
||||
};
|
||||
|
||||
const GetTasks = async (aId: string) => {
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data, error } = await supabase.from("tasks").select("*").eq("aId", aId);
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (error) {
|
||||
Alert.alert("Tasks could not be fetched, please try again");
|
||||
return;
|
||||
@@ -111,16 +124,6 @@ export default function ViewDetailsAssignment() {
|
||||
|
||||
Alert.alert("Assignment deleted successfully!");
|
||||
|
||||
const sId = assignment?.sId;
|
||||
|
||||
if (sId) {
|
||||
try {
|
||||
await CheckSubjectCompletion(sId);
|
||||
} catch {
|
||||
Alert.alert("Failed to update subject status");
|
||||
}
|
||||
}
|
||||
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
@@ -165,6 +168,32 @@ export default function ViewDetailsAssignment() {
|
||||
)
|
||||
}
|
||||
|
||||
const ToggleTaskCompletion = async (task: Task) => {
|
||||
const nextIsCompleted = !task.isCompleted;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("tasks")
|
||||
.update({
|
||||
isCompleted: nextIsCompleted,
|
||||
lastChanged: new Date().toISOString(),
|
||||
})
|
||||
.eq("tId", task.tId);
|
||||
|
||||
if (error) {
|
||||
Alert.alert("Task could not be updated, please try again");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await CheckAssignmentCompletion(task.aId);
|
||||
} catch {
|
||||
Alert.alert("Failed to update assignment completion state");
|
||||
}
|
||||
|
||||
await GetTasks(task.aId);
|
||||
await GetAssignment(task.aId);
|
||||
}
|
||||
|
||||
const colorSet = getSubjectColorSet(subjectMeta.color);
|
||||
|
||||
const completedTasks = tasks.filter((task) => task.isCompleted).length;
|
||||
@@ -176,6 +205,14 @@ export default function ViewDetailsAssignment() {
|
||||
? 0
|
||||
: Math.round((completedTasks / totalTasks) * 100);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-app-bg">
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
@@ -233,7 +270,7 @@ export default function ViewDetailsAssignment() {
|
||||
<SectionList
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 20, paddingBottom: 32 }}
|
||||
sections={taskSections}
|
||||
sections={totalTasks === 0 ? [] : taskSections}
|
||||
keyExtractor={(item) => item.tId}
|
||||
showsVerticalScrollIndicator={false}
|
||||
stickySectionHeadersEnabled={false}
|
||||
@@ -247,18 +284,6 @@ export default function ViewDetailsAssignment() {
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-start">
|
||||
<View
|
||||
className="mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2"
|
||||
style={{
|
||||
borderColor: assignment.isCompleted ? colorSet.strong : '#DDD6C8',
|
||||
backgroundColor: assignment.isCompleted ? colorSet.strong : '#EFEBE3',
|
||||
}}
|
||||
>
|
||||
{assignment.isCompleted && (
|
||||
<Text className="text-sm font-bold text-text-inverse">✓</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className="text-2xl font-bold text-text-main">
|
||||
{assignment.title}
|
||||
@@ -323,7 +348,6 @@ export default function ViewDetailsAssignment() {
|
||||
Based only on completed tasks in this assignment.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="mt-4 text-sm text-text-muted">
|
||||
Last changed: {formatDateTime(assignment.lastChanged)}
|
||||
</Text>
|
||||
@@ -335,7 +359,7 @@ export default function ViewDetailsAssignment() {
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/assignment/upsertAssignment',
|
||||
pathname: '../assignment/upsertAssignment',
|
||||
params: { aId: assignment.aId },
|
||||
})
|
||||
}
|
||||
@@ -344,6 +368,7 @@ export default function ViewDetailsAssignment() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID="delete-assignment-button"
|
||||
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||
onPress={() => DeleteAssignment(assignment.aId)}
|
||||
>
|
||||
@@ -358,7 +383,7 @@ export default function ViewDetailsAssignment() {
|
||||
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/task/upsertTask',
|
||||
pathname: '../task/upsertTask',
|
||||
params: { aId: assignment.aId },
|
||||
})
|
||||
}
|
||||
@@ -400,18 +425,6 @@ export default function ViewDetailsAssignment() {
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-start">
|
||||
<View
|
||||
className="mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2"
|
||||
style={{
|
||||
borderColor: item.isCompleted ? colorSet.strong : '#DDD6C8',
|
||||
backgroundColor: item.isCompleted ? colorSet.strong : '#EFEBE3',
|
||||
}}
|
||||
>
|
||||
{item.isCompleted && (
|
||||
<Text className="text-sm font-bold text-text-inverse">✓</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-base font-bold ${
|
||||
@@ -435,11 +448,24 @@ export default function ViewDetailsAssignment() {
|
||||
|
||||
{isOwner && (
|
||||
<View className="mt-4 flex-row border-t border-app-border pt-4">
|
||||
<Pressable
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl py-3"
|
||||
style={{ backgroundColor: item.isCompleted ? '#EFEBE3' : colorSet.soft }}
|
||||
onPress={() => ToggleTaskCompletion(item)}
|
||||
>
|
||||
<Text
|
||||
className="text-sm font-bold"
|
||||
style={{ color: colorSet.strong }}
|
||||
>
|
||||
{item.isCompleted ? 'Reopen' : 'Complete'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/task/upsertTask',
|
||||
pathname: '../task/upsertTask',
|
||||
params: { tId: item.tId },
|
||||
})
|
||||
}
|
||||
@@ -460,6 +486,19 @@ export default function ViewDetailsAssignment() {
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<View
|
||||
className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5"
|
||||
style={{ borderColor: colorSet.strong }}
|
||||
>
|
||||
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||
No tasks needed yet
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||
Add tasks if this assignment needs smaller steps.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
renderSectionFooter={({ section }) =>
|
||||
section.data.length === 0 ? (
|
||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5" style={{ borderColor: colorSet.strong }}>
|
||||
|
||||
@@ -250,6 +250,16 @@ export default function SetupScreen() {
|
||||
options={{
|
||||
title: 'Guided Setup',
|
||||
headerTitleAlign: 'center',
|
||||
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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ export default function UpsertSubject() {
|
||||
<Text className={labelClassName}>Title</Text>
|
||||
<TextInput className={inputClassName}
|
||||
placeholder={isSetupFlow ? 'e.g. Algorithms' : 'Enter subject title'}
|
||||
testID = "subject-title-input"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
@@ -324,6 +325,7 @@ export default function UpsertSubject() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID = "upsert-subject-button"
|
||||
className={`h-14 items-center justify-center rounded-2xl ${
|
||||
isSaving
|
||||
? 'bg-accent-disabled'
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { formatDate, formatDateTime } from '@/lib/date';
|
||||
import { CheckSubjectCompletion } from '@/lib/progress';
|
||||
import { SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { Assignment } from '@/lib/types';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Pressable, SectionList, Text, View } from 'react-native';
|
||||
import { ActivityIndicator, Alert, Pressable, SectionList, Text, View } from 'react-native';
|
||||
|
||||
export type Subject = {
|
||||
sId: string;
|
||||
@@ -23,6 +22,7 @@ export default function ViewDetailsSubject() {
|
||||
const [subject, SetSubject] = useState<Subject | null>(null);
|
||||
const [assignments, SetAssignments] = useState<Assignment[]>([]);
|
||||
const [session, SetSession] = useState<Session | null>(null);
|
||||
const [isLoading, SetIsLoading] = useState(false);
|
||||
|
||||
const assignmentSections = [
|
||||
{
|
||||
@@ -48,12 +48,16 @@ export default function ViewDetailsSubject() {
|
||||
}, []);
|
||||
|
||||
const GetSubject = async (subjectId: string) => {
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subjects')
|
||||
.select('*')
|
||||
.eq('sId', subjectId)
|
||||
.single();
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Subject could not be fetched, please try again');
|
||||
return;
|
||||
@@ -63,12 +67,16 @@ export default function ViewDetailsSubject() {
|
||||
};
|
||||
|
||||
const GetAssignments = async (subjectId: string) => {
|
||||
SetIsLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('assignments')
|
||||
.select('*')
|
||||
.eq('sId', subjectId)
|
||||
.order('deadline', { ascending: true });
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Assignments could not be fetched, please try again');
|
||||
return;
|
||||
@@ -77,12 +85,38 @@ export default function ViewDetailsSubject() {
|
||||
SetAssignments(data ?? []);
|
||||
};
|
||||
|
||||
const ToggleAssignmentCompletion = async (assignment: Assignment) => {
|
||||
const nextIsCompleted = !assignment.isCompleted;
|
||||
|
||||
const { error } = await supabase
|
||||
.from('assignments')
|
||||
.update({
|
||||
isCompleted: nextIsCompleted,
|
||||
lastChanged: new Date().toISOString(),
|
||||
})
|
||||
.eq('aId', assignment.aId);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Assignment could not be updated, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
await GetAssignments(assignment.sId);
|
||||
await GetSubject(assignment.sId);
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (session && sId) {
|
||||
GetSubject(sId);
|
||||
GetAssignments(sId);
|
||||
if (!session || !sId) {
|
||||
return;
|
||||
}
|
||||
|
||||
SetIsLoading(true);
|
||||
SetSubject(null);
|
||||
|
||||
Promise.all([GetSubject(sId), GetAssignments(sId)]).finally(() => {
|
||||
SetIsLoading(false);
|
||||
});
|
||||
}, [session, sId])
|
||||
);
|
||||
|
||||
@@ -140,14 +174,6 @@ export default function ViewDetailsSubject() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subjectId) {
|
||||
try {
|
||||
await CheckSubjectCompletion(subjectId);
|
||||
} catch {
|
||||
Alert.alert('Failed to update subject status');
|
||||
}
|
||||
}
|
||||
|
||||
await GetAssignments(subjectId);
|
||||
await GetSubject(subjectId);
|
||||
|
||||
@@ -167,6 +193,25 @@ export default function ViewDetailsSubject() {
|
||||
? 0
|
||||
: Math.round((completedAssignments / totalAssignments) * 100);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Subject Details',
|
||||
}}
|
||||
/>
|
||||
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text className="mt-4 text-base font-semibold text-text-secondary">
|
||||
Loading subject...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
@@ -228,7 +273,7 @@ export default function ViewDetailsSubject() {
|
||||
paddingTop: 20,
|
||||
paddingBottom: 32,
|
||||
}}
|
||||
sections={assignmentSections}
|
||||
sections={totalAssignments === 0 ? [] : assignmentSections}
|
||||
keyExtractor={(item) => item.aId}
|
||||
showsVerticalScrollIndicator={false}
|
||||
stickySectionHeadersEnabled={false}
|
||||
@@ -288,7 +333,7 @@ export default function ViewDetailsSubject() {
|
||||
<View className="mt-5">
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Assignments completed
|
||||
Assignment Progress
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm font-bold text-text-main">
|
||||
@@ -328,7 +373,7 @@ export default function ViewDetailsSubject() {
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/subject/upsertSubject',
|
||||
pathname: '../subject/upsertSubject',
|
||||
params: { sId: subject.sId },
|
||||
})
|
||||
}
|
||||
@@ -339,6 +384,7 @@ export default function ViewDetailsSubject() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID="delete-subject-button"
|
||||
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||
onPress={() => DeleteSubject(subject.sId)}
|
||||
>
|
||||
@@ -353,13 +399,13 @@ export default function ViewDetailsSubject() {
|
||||
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/assignment/upsertAssignment',
|
||||
pathname: '../assignment/upsertAssignment',
|
||||
params: { sId: subject.sId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
Create Assignment
|
||||
Add Assignment
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -385,15 +431,16 @@ export default function ViewDetailsSubject() {
|
||||
borderColor: colorSet.strong,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/assignment/viewDetailsAssignment',
|
||||
params: { aId: item.aId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<View className="flex-row items-center">
|
||||
<Pressable
|
||||
className="flex-1"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/assignment/viewDetailsAssignment',
|
||||
params: { aId: item.aId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-base font-bold ${
|
||||
@@ -416,16 +463,29 @@ export default function ViewDetailsSubject() {
|
||||
Deadline: {formatDate(item.deadline)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{isOwner && (
|
||||
<View className="mt-4 flex-row border-t border-app-border pt-4">
|
||||
<Pressable
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl py-3"
|
||||
style={{ backgroundColor: item.isCompleted ? '#EFEBE3' : colorSet.soft }}
|
||||
onPress={() => ToggleAssignmentCompletion(item)}
|
||||
>
|
||||
<Text
|
||||
className="text-sm font-bold"
|
||||
style={{ color: colorSet.strong }}
|
||||
>
|
||||
{item.isCompleted ? 'Reopen' : 'Complete'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/assignment/upsertAssignment',
|
||||
pathname: '../assignment/upsertAssignment',
|
||||
params: { aId: item.aId },
|
||||
})
|
||||
}
|
||||
@@ -448,6 +508,16 @@ export default function ViewDetailsSubject() {
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||
No assignments yet
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||
Add one when this subject has work to track.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
renderSectionFooter={({ section }) =>
|
||||
section.data.length === 0 ? (
|
||||
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
|
||||
@@ -364,7 +364,7 @@ export default function TimerScreen() {
|
||||
[pressedButtonAnimation]
|
||||
);
|
||||
|
||||
const resetSessionValues = React.useCallback(() => {
|
||||
const resetSessionValues = React.useCallback((options?: { preservePostSessionPrompt?: boolean }) => {
|
||||
sessionStartedAtRef.current = null;
|
||||
sessionDurationMsRef.current = 0;
|
||||
cancelHoldActiveRef.current = false;
|
||||
@@ -376,7 +376,9 @@ export default function TimerScreen() {
|
||||
setTimerOverlayVisible(false);
|
||||
setTimeRemaining(0);
|
||||
setCurrentSessionType(selectedSessionType);
|
||||
setPostSessionPrompt(null);
|
||||
if (!options?.preservePostSessionPrompt) {
|
||||
setPostSessionPrompt(null);
|
||||
}
|
||||
setIsRunning(false);
|
||||
}, [cancelOverlayAnimation, selectedSessionType, timerAnimation, timerOverlayOffscreenY]);
|
||||
|
||||
@@ -484,20 +486,20 @@ export default function TimerScreen() {
|
||||
completedReturnTaskId
|
||||
);
|
||||
|
||||
setIsRunning(false);
|
||||
resetSessionValues();
|
||||
await finalizeSprintSession('completed', completedSession);
|
||||
|
||||
if (isOnboardingDemo && completedSessionType === 'focus') {
|
||||
resetSessionValues();
|
||||
void finalizeSprintSession('completed', completedSession);
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
resetSessionValues({ preservePostSessionPrompt: true });
|
||||
setPostSessionPrompt({
|
||||
completedSessionType,
|
||||
returnTaskId: completedReturnTaskId,
|
||||
nextBreakType,
|
||||
});
|
||||
void finalizeSprintSession('completed', completedSession);
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,6 +198,7 @@ export default function UpsertTask() {
|
||||
<View className="mb-5">
|
||||
<Text className={labelClassName}>Title</Text>
|
||||
<TextInput
|
||||
testID="task-title-input"
|
||||
className={inputClassName}
|
||||
placeholder={isSetupFlow ? 'e.g. Solve questions 1-3' : 'Enter task title'}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
@@ -258,6 +259,7 @@ export default function UpsertTask() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
testID="upsert-task-button"
|
||||
className={`h-14 items-center justify-center rounded-2xl ${
|
||||
isSaving ? 'bg-accent-disabled' : 'bg-accent'
|
||||
}`}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { Task } from '@/lib/types';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Pressable, Text, View } from 'react-native';
|
||||
import { ActivityIndicator, Alert, Pressable, Text, View } from 'react-native';
|
||||
|
||||
function formatTrackedTime(totalSeconds: number) {
|
||||
if (totalSeconds <= 0) {
|
||||
@@ -31,154 +31,160 @@ function formatTrackedTime(totalSeconds: number) {
|
||||
}
|
||||
|
||||
export default function ViewDetailsTask() {
|
||||
const { tId } = useLocalSearchParams<{ tId: string }>();
|
||||
const { tId } = useLocalSearchParams<{ tId: string }>();
|
||||
|
||||
const [task, SetTask] = useState<Task | null>(null);
|
||||
const [session, SetSession] = useState<Session | null>(null);
|
||||
const [completedFocusSessions, setCompletedFocusSessions] = useState(0);
|
||||
const [contextMeta, setContextMeta] = useState({
|
||||
subjectTitle: 'No Subject',
|
||||
assignmentTitle: 'No Assignment',
|
||||
subjectColor: 'slate' as SubjectColor,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => SetSession(data.session ?? null));
|
||||
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||
SetSession(newSession);
|
||||
const [task, SetTask] = useState<Task | null>(null);
|
||||
const [session, SetSession] = useState<Session | null>(null);
|
||||
const [isLoading, SetIsLoading] = useState(false);
|
||||
const [completedFocusSessions, setCompletedFocusSessions] = useState(0);
|
||||
const [contextMeta, setContextMeta] = useState({
|
||||
subjectTitle: 'No Subject',
|
||||
assignmentTitle: 'No Assignment',
|
||||
subjectColor: 'slate' as SubjectColor,
|
||||
});
|
||||
|
||||
return () => sub.subscription.unsubscribe();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => SetSession(data.session ?? null));
|
||||
|
||||
const loadTaskStudyActivity = useCallback(async (taskId: string, userId: string) => {
|
||||
const { count, error } = await supabase
|
||||
.from('sprint_sessions')
|
||||
.select('sessionId', { count: 'exact', head: true })
|
||||
.eq('taskId', taskId)
|
||||
.eq('userId', userId)
|
||||
.eq('sessionType', 'focus')
|
||||
.eq('status', 'completed');
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||
SetSession(newSession);
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setCompletedFocusSessions(0);
|
||||
return;
|
||||
}
|
||||
return () => sub.subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
setCompletedFocusSessions(count ?? 0);
|
||||
}, []);
|
||||
const loadTaskStudyActivity = useCallback(async (taskId: string, userId: string) => {
|
||||
const { count, error } = await supabase
|
||||
.from('sprint_sessions')
|
||||
.select('sessionId', { count: 'exact', head: true })
|
||||
.eq('taskId', taskId)
|
||||
.eq('userId', userId)
|
||||
.eq('sessionType', 'focus')
|
||||
.eq('status', 'completed');
|
||||
|
||||
const GetTask = useCallback(async (taskId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.eq('tId', taskId)
|
||||
.single();
|
||||
if (error) {
|
||||
setCompletedFocusSessions(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
Alert.alert('Task could not be fetched, please try again');
|
||||
return;
|
||||
}
|
||||
setCompletedFocusSessions(count ?? 0);
|
||||
}, []);
|
||||
|
||||
SetTask(data);
|
||||
await loadTaskStudyActivity(taskId, data.uId);
|
||||
const GetTask = useCallback(async (taskId: string) => {
|
||||
SetIsLoading(true);
|
||||
|
||||
if (data.aId) {
|
||||
const { data: assignmentData, error: assignmentError } = await supabase
|
||||
.from('assignments')
|
||||
.select('title, sId')
|
||||
.eq('aId', data.aId)
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.eq('tId', taskId)
|
||||
.single();
|
||||
|
||||
if (assignmentError || !assignmentData) {
|
||||
if (error || !data) {
|
||||
SetTask(null);
|
||||
setContextMeta({
|
||||
subjectTitle: 'Unknown Subject',
|
||||
assignmentTitle: 'Unknown Assignment',
|
||||
subjectColor: 'slate',
|
||||
});
|
||||
setCompletedFocusSessions(0);
|
||||
SetIsLoading(false);
|
||||
Alert.alert('Task could not be fetched, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
if (assignmentData.sId) {
|
||||
const { data: subjectData, error: subjectError } = await supabase
|
||||
.from('subjects')
|
||||
.select('title, color')
|
||||
.eq('sId', assignmentData.sId)
|
||||
SetTask(data);
|
||||
await loadTaskStudyActivity(taskId, data.uId);
|
||||
|
||||
let nextContextMeta = {
|
||||
subjectTitle: 'Unknown Subject',
|
||||
assignmentTitle: 'Unknown Assignment',
|
||||
subjectColor: 'slate' as SubjectColor,
|
||||
};
|
||||
|
||||
if (data.aId) {
|
||||
const { data: assignmentData, error: assignmentError } = await supabase
|
||||
.from('assignments')
|
||||
.select('title, sId')
|
||||
.eq('aId', data.aId)
|
||||
.single();
|
||||
|
||||
if (subjectError || !subjectData) {
|
||||
setContextMeta({
|
||||
subjectTitle: 'Unknown Subject',
|
||||
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
|
||||
subjectColor: 'slate',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!assignmentError && assignmentData) {
|
||||
nextContextMeta.assignmentTitle = assignmentData.title ?? 'Unknown Assignment';
|
||||
|
||||
setContextMeta({
|
||||
subjectTitle: subjectData.title ?? 'Unknown Subject',
|
||||
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
|
||||
subjectColor: (subjectData.color as SubjectColor | undefined) ?? 'slate',
|
||||
if (assignmentData.sId) {
|
||||
const { data: subjectData, error: subjectError } = await supabase
|
||||
.from('subjects')
|
||||
.select('title, color')
|
||||
.eq('sId', assignmentData.sId)
|
||||
.single();
|
||||
|
||||
if (!subjectError && subjectData) {
|
||||
nextContextMeta = {
|
||||
subjectTitle: subjectData.title ?? 'Unknown Subject',
|
||||
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
|
||||
subjectColor: (subjectData.color as SubjectColor | undefined) ?? 'slate',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContextMeta(nextContextMeta);
|
||||
SetIsLoading(false);
|
||||
}, [loadTaskStudyActivity]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (session && tId) {
|
||||
void GetTask(tId);
|
||||
}
|
||||
}, [GetTask, session, tId])
|
||||
);
|
||||
|
||||
const handleSprintStart = async () => {
|
||||
const activeSession = await GetActiveSession();
|
||||
|
||||
if (!activeSession) {
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: {
|
||||
tId: task?.tId,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [loadTaskStudyActivity]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (session && tId) {
|
||||
GetTask(tId);
|
||||
}
|
||||
}, [GetTask, session, tId])
|
||||
);
|
||||
|
||||
const handleSprintStart = async () => {
|
||||
const activeSession = await GetActiveSession();
|
||||
|
||||
if (!activeSession) {
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: {
|
||||
tId: task?.tId,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const secondsLeft = Math.ceil((activeSession.endTime - Date.now()) / 1000)
|
||||
|
||||
if (secondsLeft <= 0) {
|
||||
await finalizeStoredSession('expired', activeSession);
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: {
|
||||
tId: task?.tId,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
}
|
||||
});
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
const secondsLeft = Math.ceil((activeSession.endTime - Date.now()) / 1000);
|
||||
|
||||
if (activeSession.taskId === task?.tId) {
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: {
|
||||
tId: activeSession.taskId ?? undefined,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
}});
|
||||
return;
|
||||
if (secondsLeft <= 0) {
|
||||
await finalizeStoredSession('expired', activeSession);
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: {
|
||||
tId: task?.tId,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSession.taskId === task?.tId) {
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: {
|
||||
tId: activeSession.taskId ?? undefined,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Active session in progress',
|
||||
`End the current session and start a new ${DEFAULT_FOCUS_DURATION_MINUTES} minute sprint on this task?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel', },
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Start new sprint',
|
||||
style: 'destructive',
|
||||
@@ -195,58 +201,113 @@ const handleSprintStart = async () => {
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteTask = async (taskId: string) => {
|
||||
Alert.alert(
|
||||
'Delete Task',
|
||||
'Are you sure you want to delete this task?',
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const { error } = await supabase
|
||||
.from('tasks')
|
||||
.delete()
|
||||
.eq('tId', taskId);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Task could not be deleted, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
const aId = task?.aId;
|
||||
|
||||
if (aId) {
|
||||
try {
|
||||
await CheckAssignmentCompletion(aId);
|
||||
} catch {
|
||||
Alert.alert('Failed to update assignment completion state');
|
||||
}
|
||||
}
|
||||
|
||||
Alert.alert('Task deleted successfully!');
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const colorSet = getSubjectColorSet(contextMeta.subjectColor);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-app-bg">
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Task Details',
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
className="rounded-full bg-app-subtle px-4 py-2"
|
||||
onPress={async () => await supabase.auth.signOut()}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Logout
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
const DeleteTask = async (taskId: string) => {
|
||||
Alert.alert(
|
||||
'Delete Task',
|
||||
'Are you sure you want to delete this task?',
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const { error } = await supabase
|
||||
.from('tasks')
|
||||
.delete()
|
||||
.eq('tId', taskId);
|
||||
<View
|
||||
className="rounded-3xl bg-app-surface p-5"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorSet.strong,
|
||||
}}
|
||||
>
|
||||
<Text className="text-2xl font-bold text-text-main">
|
||||
Task not found
|
||||
</Text>
|
||||
<Text className="mt-2 text-base text-text-secondary">
|
||||
The task could not be loaded.
|
||||
</Text>
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Task could not be deleted, please try again');
|
||||
return;
|
||||
}
|
||||
<Pressable
|
||||
className="mt-5 h-12 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
Go back
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const aId = task?.aId;
|
||||
const isOwner = session?.user.id === task.uId;
|
||||
|
||||
if (aId) {
|
||||
try {
|
||||
await CheckAssignmentCompletion(aId);
|
||||
} catch {
|
||||
Alert.alert('Failed to update assignment completion state');
|
||||
}
|
||||
}
|
||||
|
||||
Alert.alert('Task deleted successfully!');
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const colorSet = getSubjectColorSet(contextMeta.subjectColor);
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Task Details',
|
||||
headerTitleAlign: 'center',
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
className="rounded-full bg-app-subtle px-4 py-2"
|
||||
@@ -267,188 +328,141 @@ if (!task) {
|
||||
borderColor: colorSet.strong,
|
||||
}}
|
||||
>
|
||||
<Text className="text-2xl font-bold text-text-main">
|
||||
Task not found
|
||||
</Text>
|
||||
<Text className="mt-2 text-base text-text-secondary">
|
||||
The task could not be loaded.
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
className="mt-5 h-12 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
Go back
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isOwner = session?.user.id === task.uId;
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Task Details',
|
||||
headerTitleAlign: 'center',
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
className="rounded-full bg-app-subtle px-4 py-2"
|
||||
onPress={async () => await supabase.auth.signOut()}
|
||||
<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: task.isCompleted ? colorSet.strong : '#DDD6C8',
|
||||
backgroundColor: task.isCompleted ? colorSet.strong : '#EFEBE3',
|
||||
}}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Logout
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{task.isCompleted ? (
|
||||
<Text className="text-sm font-bold text-text-inverse">✓</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View
|
||||
className="rounded-3xl bg-app-surface p-5"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorSet.strong,
|
||||
}}
|
||||
>
|
||||
<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: task.isCompleted ? colorSet.strong : '#DDD6C8',
|
||||
backgroundColor: task.isCompleted ? colorSet.strong : '#EFEBE3',
|
||||
}}
|
||||
>
|
||||
{task.isCompleted && (
|
||||
<Text className="text-sm font-bold text-text-inverse">✓</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-2xl font-bold ${
|
||||
task.isCompleted ? 'text-text-secondary' : 'text-text-main'
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</Text>
|
||||
|
||||
{task.description ? (
|
||||
<Text className="mt-3 text-base leading-6 text-text-secondary">
|
||||
{task.description}
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="mt-3 text-base text-text-muted">
|
||||
No description added.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View className="mt-4 flex-row flex-wrap">
|
||||
<View
|
||||
className="mr-2 mb-2 rounded-full px-3 py-1"
|
||||
style={{ backgroundColor: colorSet.soft }}
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-2xl font-bold ${
|
||||
task.isCompleted ? 'text-text-secondary' : 'text-text-main'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: colorSet.strong }}
|
||||
{task.title}
|
||||
</Text>
|
||||
|
||||
{task.description ? (
|
||||
<Text className="mt-3 text-base leading-6 text-text-secondary">
|
||||
{task.description}
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="mt-3 text-base text-text-muted">
|
||||
No description added.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View className="mt-4 flex-row flex-wrap">
|
||||
<View
|
||||
className="mr-2 mb-2 rounded-full px-3 py-1"
|
||||
style={{ backgroundColor: colorSet.soft }}
|
||||
>
|
||||
{contextMeta.subjectTitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
|
||||
<Text className="text-xs font-semibold text-text-secondary">
|
||||
{contextMeta.assignmentTitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
|
||||
<Text className="text-xs font-semibold text-text-secondary">
|
||||
Status: {task.isCompleted ? 'Completed' : 'Not completed'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-5 rounded-2xl bg-app-subtle p-4">
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Study activity
|
||||
</Text>
|
||||
<Text className="mt-1 text-xs leading-5 text-text-muted">
|
||||
This tracks focused work on the task separately from whether the task is marked completed.
|
||||
</Text>
|
||||
|
||||
<View className="mt-4 flex-row gap-3">
|
||||
<View className="flex-1 rounded-2xl bg-app-surface px-4 py-3">
|
||||
<Text className="text-xs font-semibold uppercase tracking-[0.6px] text-text-muted">
|
||||
Focus time
|
||||
</Text>
|
||||
<Text className="mt-1 text-lg font-bold text-text-main">
|
||||
{formatTrackedTime(task.totalTimeInSeconds ?? 0)}
|
||||
<Text
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: colorSet.strong }}
|
||||
>
|
||||
{contextMeta.subjectTitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-1 rounded-2xl bg-app-surface px-4 py-3">
|
||||
<Text className="text-xs font-semibold uppercase tracking-[0.6px] text-text-muted">
|
||||
Completed sessions
|
||||
<View className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
|
||||
<Text className="text-xs font-semibold text-text-secondary">
|
||||
{contextMeta.assignmentTitle}
|
||||
</Text>
|
||||
<Text className="mt-1 text-lg font-bold text-text-main">
|
||||
{completedFocusSessions}
|
||||
</View>
|
||||
|
||||
<View className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
|
||||
<Text className="text-xs font-semibold text-text-secondary">
|
||||
Status: {task.isCompleted ? 'Completed' : 'Not completed'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-5 rounded-2xl bg-app-subtle p-4">
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Study activity
|
||||
</Text>
|
||||
<Text className="mt-1 text-xs leading-5 text-text-muted">
|
||||
This tracks focused work on the task separately from whether the task is marked completed.
|
||||
</Text>
|
||||
|
||||
<View className="mt-4 flex-row gap-3">
|
||||
<View className="flex-1 rounded-2xl bg-app-surface px-4 py-3">
|
||||
<Text className="text-xs font-semibold uppercase tracking-[0.6px] text-text-muted">
|
||||
Focus time
|
||||
</Text>
|
||||
<Text className="mt-1 text-lg font-bold text-text-main">
|
||||
{formatTrackedTime(task.totalTimeInSeconds ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-1 rounded-2xl bg-app-surface px-4 py-3">
|
||||
<Text className="text-xs font-semibold uppercase tracking-[0.6px] text-text-muted">
|
||||
Completed sessions
|
||||
</Text>
|
||||
<Text className="mt-1 text-lg font-bold text-text-main">
|
||||
{completedFocusSessions}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="mt-2 text-sm text-text-muted">
|
||||
Last changed: {formatDateTime(task.lastChanged)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="mt-2 text-sm text-text-muted">
|
||||
Last changed: {formatDateTime(task.lastChanged)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isOwner ? (
|
||||
<View className="mt-5 border-t border-app-border pt-5">
|
||||
<Pressable
|
||||
className="h-14 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={handleSprintStart}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
Start Sprint
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text className="mt-3 text-sm text-text-muted">
|
||||
Starts a {DEFAULT_FOCUS_DURATION_MINUTES} minute focus sprint for this task.
|
||||
</Text>
|
||||
|
||||
<View className="mt-4 flex-row">
|
||||
<Pressable
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/task/upsertTask',
|
||||
params: { tId: task.tId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text className="text-sm font-bold text-text-secondary">
|
||||
Edit
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||
onPress={() => DeleteTask(task.tId)}
|
||||
>
|
||||
<Text className="text-sm font-bold text-status-danger">
|
||||
Delete
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{isOwner && (
|
||||
<View className="mt-5 border-t border-app-border pt-5">
|
||||
<Pressable
|
||||
className="h-14 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={() => handleSprintStart()}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
Start Sprint
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text className="mt-3 text-sm text-text-muted">
|
||||
Starts a {DEFAULT_FOCUS_DURATION_MINUTES} minute focus sprint for this task.
|
||||
</Text>
|
||||
|
||||
<View className="mt-4 flex-row">
|
||||
<Pressable
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/task/upsertTask',
|
||||
params: { tId: task.tId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text className="text-sm font-bold text-text-secondary">
|
||||
Edit
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||
onPress={() => DeleteTask(task.tId)}
|
||||
>
|
||||
<Text className="text-sm font-bold text-status-danger">
|
||||
Delete
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
4
jest.setup.js
Normal file
4
jest.setup.js
Normal file
@@ -0,0 +1,4 @@
|
||||
jest.mock(
|
||||
"@react-native-async-storage/async-storage",
|
||||
() => require("@react-native-async-storage/async-storage/jest/async-storage-mock")
|
||||
);
|
||||
@@ -13,17 +13,3 @@ export async function CheckAssignmentCompletion(aId: string) {
|
||||
|
||||
if (updateError) throw updateError;
|
||||
}
|
||||
|
||||
export async function CheckSubjectCompletion(sId: string) {
|
||||
const { data, error } = await supabase.from("assignments").select("aId, isCompleted").eq("sId", sId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const assignments = data ?? [];
|
||||
|
||||
const allCompleted = assignments.length > 0 && assignments.every((assignment) => assignment.isCompleted === true);
|
||||
|
||||
const { error: updateError } = await supabase.from("subjects").update({ isActive: allCompleted, lastChanged: new Date().toISOString()}).eq("sId", sId);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
}
|
||||
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
|
||||
@@ -1,288 +0,0 @@
|
||||
# Study Sprint Project Vision Gap Closure Plan
|
||||
|
||||
## #Overview
|
||||
This document turns the remaining gaps between the current app and the project vision into a concrete implementation plan.
|
||||
|
||||
Each section below covers one gap between the current version of Study Sprint and the product vision described in `notes/projectVision/AppDev_Project_Vision.pdf`.
|
||||
|
||||
The goal is not to expand the app into a large productivity platform. The goal is to close the remaining vision gaps while keeping the product small, student-focused, and realistic to complete.
|
||||
|
||||
The app direction assumes the current backend-based architecture remains in place. The report can explain that the project moved from a local-storage-first idea to a database-backed model because authentication and cross-device persistence would otherwise provide little practical value.
|
||||
|
||||
This note has been updated after the main May 3 and May 4 timer, dashboard, setup, and progress work. That means the earlier gaps around dashboard progress, recent session history, consistent progress labels, and most of the main sprint-start friction are no longer the main blockers.
|
||||
|
||||
What remains now is smaller, but more important:
|
||||
- finishing the last missing part of the focus-and-break loop
|
||||
- making session lifecycle behavior fully consistent
|
||||
- tightening first-time routing so incomplete users are guided automatically
|
||||
- making sure the final app behavior and final report tell the same story
|
||||
|
||||
---
|
||||
|
||||
## #Gap1FocusSessionsAndBreaks
|
||||
|
||||
### #VisionGap
|
||||
The vision describes a study app built around focus sessions and breaks.
|
||||
|
||||
The current app now supports task-linked focus sessions, short breaks, and post-session choices.
|
||||
|
||||
The main missing piece is that the break flow still stops short of a simple complete cycle rule.
|
||||
|
||||
### #WhyThisMatters
|
||||
The app is much closer to the promised study pattern than before, but it still does not fully behave like a complete focus-and-break system if every finished focus session only leads to the same short-break path.
|
||||
|
||||
### #Plan
|
||||
1. Keep the existing lightweight session model that distinguishes between `focus`, `short_break`, and `long_break`.
|
||||
2. Keep task linkage only on `focus` sessions so break sessions remain simple.
|
||||
3. Preserve the current post-focus action path:
|
||||
- `Start short break`
|
||||
- continue same task
|
||||
- back to dashboard
|
||||
4. Preserve the current post-break action path:
|
||||
- continue with same task
|
||||
- back to dashboard
|
||||
5. Add the missing simple cycle rule:
|
||||
- after a chosen number of completed focus sessions, offer `long_break` instead of `short_break`
|
||||
6. Keep the implementation intentionally small:
|
||||
- fixed default durations are enough
|
||||
- no need for advanced timer customization to satisfy the vision
|
||||
|
||||
### #DoneWhen
|
||||
- The app supports a full `focus -> short break -> continue` flow.
|
||||
- The app also supports a simple `long_break` cycle so break behavior feels intentional rather than incomplete.
|
||||
- Breaks are treated as real app states, not just something the user has to manage manually.
|
||||
- The timer flow fully matches the vision wording about focus sessions and breaks.
|
||||
|
||||
---
|
||||
|
||||
## #Gap2DashboardProgressAndHistory
|
||||
|
||||
### #VisionGap
|
||||
The vision says the app should make progress visible through completed sessions, study time, or simple statistics.
|
||||
|
||||
The current app already shows active sprint state, upcoming deadline tasks, and task time data, but the dashboard still needs to work more clearly as a progress overview.
|
||||
|
||||
### #WhyThisMatters
|
||||
The app should answer the question: `Am I actually making progress?`
|
||||
|
||||
If that answer is not obvious from the dashboard, the motivational part of the vision is only partially fulfilled.
|
||||
|
||||
### #Plan
|
||||
1. Add a compact dashboard progress summary near the top of the screen.
|
||||
2. Show at least these values:
|
||||
- `Focus sessions completed today`
|
||||
- `Minutes studied today`
|
||||
- `Minutes studied this week`
|
||||
3. Add a `Recent sessions` section below the summary.
|
||||
4. Show for each recent session:
|
||||
- task title if present
|
||||
- session type
|
||||
- duration
|
||||
- completed or cancelled state
|
||||
- time or date
|
||||
5. If there is room and it stays visually simple, add a small `Recently completed tasks` section after recent sessions.
|
||||
6. Keep the dashboard layout compact so it still feels like a low-friction home screen rather than a report page.
|
||||
|
||||
### #DoneWhen
|
||||
- The dashboard gives immediate visibility into recent study effort.
|
||||
- Session history is visible without needing a dedicated analytics screen.
|
||||
- Progress feels tied to real study behavior, not only to planning structure.
|
||||
|
||||
---
|
||||
|
||||
## #Gap3ConsistentProgressModel
|
||||
|
||||
### #VisionGap
|
||||
The vision expects progress to feel simple and understandable.
|
||||
|
||||
Right now, progress exists in multiple places, but it should be made more consistent so the user can understand what each screen is measuring.
|
||||
|
||||
### #WhyThisMatters
|
||||
If `progress` means one thing on one screen and something unrelated on another, the app feels less clear and less intentional.
|
||||
|
||||
### #Plan
|
||||
1. Define one clear meaning of progress per layer:
|
||||
- `Subject`: completed assignments out of total assignments
|
||||
- `Assignment`: completed tasks out of total tasks
|
||||
- `Task`: total study time plus completed focus sessions
|
||||
- `Dashboard`: today's and this week's study activity
|
||||
2. Review the labels on each screen so they match those meanings exactly.
|
||||
3. Make sure no screen mixes planning progress and session progress without clearly separating them.
|
||||
4. Re-check the database queries and UI labels so each metric comes from the right source of truth.
|
||||
5. If needed, add small helper text where a metric could otherwise be ambiguous.
|
||||
|
||||
### #DoneWhen
|
||||
- Each screen communicates one clear type of progress.
|
||||
- The app feels easier to understand from first use.
|
||||
- Progress presentation supports the vision goal of simplicity.
|
||||
|
||||
---
|
||||
|
||||
## #Gap4FirstTimeUserFriction
|
||||
|
||||
### #VisionGap
|
||||
The vision emphasizes low friction and ease of use from the first launch.
|
||||
|
||||
The current app already has onboarding copy, guided setup, and clearer empty states.
|
||||
|
||||
The remaining issue is that setup guidance is still too easy to bypass after login.
|
||||
|
||||
### #WhyThisMatters
|
||||
The app can still meet the low-friction goal even with auth, but only if incomplete users are routed toward the next meaningful action automatically instead of being left to discover setup on their own.
|
||||
|
||||
### #Plan
|
||||
1. Keep the current login and signup explanation copy about what the app does and why the account exists.
|
||||
2. Keep the guided setup flow as the main onboarding structure:
|
||||
- create first subject
|
||||
- create first assignment
|
||||
- create first task
|
||||
- start first sprint
|
||||
3. Add routing logic so users who still have no real study structure, or who have not completed the first sprint, are sent into setup automatically after login when appropriate.
|
||||
4. Keep the dashboard empty state, but do not rely on it as the only onboarding recovery path.
|
||||
5. Re-check that setup completion is based on a real first-use milestone:
|
||||
- not just account creation
|
||||
- but reaching the first meaningful study action
|
||||
|
||||
### #DoneWhen
|
||||
- A new user can reach their first sprint with minimal confusion.
|
||||
- An incomplete user is guided back into setup instead of being dropped into a less helpful dashboard state.
|
||||
- The structured hierarchy feels guided instead of heavy.
|
||||
|
||||
---
|
||||
|
||||
## #Gap5MainFlowFriction
|
||||
|
||||
### #VisionGap
|
||||
The vision promises a fast and focused experience that reduces procrastination rather than adding more friction.
|
||||
|
||||
The current app already has the right hierarchy, but the main flow should be tightened so starting real work feels faster.
|
||||
|
||||
### #WhyThisMatters
|
||||
Even a good feature set can feel wrong if the path to action is too slow or too fragmented.
|
||||
|
||||
### #Plan
|
||||
1. Make `Start Sprint` the strongest action on task-level screens.
|
||||
2. Use a sensible default sprint duration so the user can begin immediately without extra setup.
|
||||
3. Review the number of taps from dashboard to active study session and remove unnecessary detours.
|
||||
4. Ensure that post-sprint actions are explicit and simple:
|
||||
- start break
|
||||
- continue same task
|
||||
- return to dashboard
|
||||
5. Keep the dashboard focused on next actions rather than loading it with too many management controls.
|
||||
6. Re-check labels, button wording, and action order so the app always pushes the user toward concrete study work.
|
||||
|
||||
### #DoneWhen
|
||||
- Starting a study sprint feels fast.
|
||||
- The app consistently guides the user toward focused work.
|
||||
- The product behavior matches the vision goal of low-friction use.
|
||||
|
||||
---
|
||||
|
||||
|
||||
#################################################################
|
||||
#################################################################
|
||||
Most of the steps above have been completed#####################
|
||||
#################################################################
|
||||
#################################################################
|
||||
|
||||
|
||||
## #Gap6ReliabilityAndSessionState
|
||||
|
||||
### #VisionGap
|
||||
The vision identifies reliability as critical.
|
||||
|
||||
The app already has a stronger session model than before, but reliability work is now the most important remaining gap.
|
||||
|
||||
The biggest issue is not the presence of session data, but making sure every path updates both persisted active-session state and recorded session history consistently.
|
||||
|
||||
### #WhyThisMatters
|
||||
If the timer or sprint state feels inconsistent, the app loses trust very quickly, especially now that the app already exposes session history and progress metrics on the dashboard.
|
||||
|
||||
### #Plan
|
||||
1. Review the full session lifecycle and make sure every session ends in a valid final state:
|
||||
- `completed`
|
||||
- `cancelled`
|
||||
- `expired`
|
||||
- break sessions included
|
||||
2. Remove remaining places where the app only clears local active-session storage without also correctly finalizing the underlying recorded session.
|
||||
3. Make sure dashboard history, task totals, and active session state all reflect the same underlying session truth.
|
||||
4. Confirm that reopening the app after a session should have ended produces the correct finalization behavior.
|
||||
5. Confirm that replaced or interrupted sessions are explicitly treated as cancelled rather than silently disappearing.
|
||||
6. Confirm that cancelled sessions do not accidentally remain active in local resume storage.
|
||||
7. Test the edge cases around:
|
||||
- app backgrounding
|
||||
- app reopen
|
||||
- expired session reopen
|
||||
- replacing one active session with another
|
||||
- switching between timer and dashboard
|
||||
8. Document any remaining non-ideal behavior clearly if platform limitations prevent a perfect solution.
|
||||
|
||||
### #DoneWhen
|
||||
- Session state transitions are predictable.
|
||||
- History, task totals, and active session status stay in sync.
|
||||
- Replaced or interrupted sessions are not lost or misrepresented.
|
||||
- The timer feels dependable enough to support the vision's reliability requirement.
|
||||
|
||||
---
|
||||
|
||||
## #Gap7ScopeDisciplineAndVisionAlignment
|
||||
|
||||
### #VisionGap
|
||||
The vision values a realistic scope and a smaller polished product over a larger unfinished one.
|
||||
|
||||
To stay aligned with that, the remaining work needs to focus only on the features that directly close the vision gaps.
|
||||
|
||||
### #WhyThisMatters
|
||||
The fastest way to miss the vision now is to expand sideways into extra features instead of finishing the core loop properly and then leaving the report out of sync with the implemented product.
|
||||
|
||||
### #Plan
|
||||
1. Treat these as the remaining priority set:
|
||||
- final focus/break cycle completion
|
||||
- session lifecycle consistency
|
||||
- setup-routing polish for incomplete users
|
||||
- reliability and session-state cleanup
|
||||
- final vision/report alignment
|
||||
2. Explicitly avoid adding large optional features during this phase, such as:
|
||||
- social login
|
||||
- advanced analytics
|
||||
- calendar systems
|
||||
- collaboration tools
|
||||
- broad gamification
|
||||
3. Update the report wording so the architectural shift to DB/auth is explained as a pragmatic decision, not as a contradiction left unresolved.
|
||||
4. Re-check the final app against the vision using product outcomes rather than older implementation assumptions like strictly local persistence.
|
||||
5. Make sure the report does not describe older gaps as if they are still unresolved:
|
||||
- dashboard progress visibility
|
||||
- recent session history
|
||||
- progress meaning across screens
|
||||
- basic focus-to-break flow
|
||||
|
||||
### #DoneWhen
|
||||
- The team is working only on features that directly close vision gaps.
|
||||
- The report and the final app tell the same story.
|
||||
- The product remains small, focused, and polished enough for the project scope.
|
||||
|
||||
---
|
||||
|
||||
## #SuggestedImplementationOrder
|
||||
1. Finish the remaining focus/break cycle logic by adding the simple `long_break` offer.
|
||||
2. Fix the remaining session-lifecycle inconsistencies so replacing, expiring, cancelling, and resuming sessions all update the same underlying truth.
|
||||
3. Tighten onboarding routing so incomplete users are guided into setup automatically after login when needed.
|
||||
4. Run reliability testing across active, cancelled, expired, replaced, break, and restored session paths.
|
||||
5. Update the report so the DB/auth decision, the now-completed vision gaps, and the final remaining scope are described accurately.
|
||||
|
||||
---
|
||||
|
||||
## #FinalGoal
|
||||
When this plan is complete, Study Sprint should feel like a finished small study app rather than a partly connected prototype.
|
||||
|
||||
A user should be able to:
|
||||
- sign in and understand the app quickly
|
||||
- create a simple study structure without confusion
|
||||
- start a focus session tied to a task
|
||||
- continue naturally into a break
|
||||
- move through a simple and intentional break cycle
|
||||
- return and continue studying
|
||||
- see visible proof of progress on the dashboard
|
||||
- trust that session history and active-session state reflect the same reality
|
||||
|
||||
That is the point where the current app most clearly matches the project vision.
|
||||
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/
|
||||
@@ -15,6 +15,8 @@ The final pass of the day was smaller, but still tied to the same product goal.
|
||||
|
||||
After that, one final navigation-polish pass was added on the tabs layout itself. The bottom tabs were given explicit icons so the app's primary navigation reads faster at a glance and feels less unfinished.
|
||||
|
||||
After the branch work was merged back into `main`, one more cleanup pass was needed. That merge left multiple screen files in a syntactically broken state, so the final work shifted away from feature work and into restoring the current main branch to a usable state before report-focused delivery work.
|
||||
|
||||
---
|
||||
|
||||
## #ImplementedFeatures
|
||||
@@ -177,6 +179,23 @@ This was a small UI polish pass, but it improves immediate navigation clarity an
|
||||
|
||||
---
|
||||
|
||||
### #MainBranchMergeCleanup
|
||||
Repaired merge-related breakage after switching back to `main`:
|
||||
- fixed `app/(tabs)/subjects.tsx`
|
||||
- fixed `app/subject/viewDetailsSubject.tsx`
|
||||
- rebuilt `app/task/viewDetailsTask.tsx` into a consistent working version
|
||||
|
||||
The merge had left these files with duplicated blocks, broken hook structure, and invalid JSX. The cleanup work focused on restoring the intended screen behavior rather than changing product scope.
|
||||
|
||||
The result was:
|
||||
- subjects screen logic restored to a valid loading/setup/render flow
|
||||
- subject details screen header and progress area reconstructed
|
||||
- task details screen restored with working context, study activity, and sprint-start actions
|
||||
|
||||
This was not new feature work, but it was necessary delivery work because the main branch was no longer in a reliable edit/test state.
|
||||
|
||||
---
|
||||
|
||||
## #ProblemsAndSetbacks
|
||||
|
||||
### #SessionTruthDivergence
|
||||
@@ -200,6 +219,13 @@ Manual testing later uncovered a smaller flow mismatch inside guided setup:
|
||||
|
||||
The problem was that task creation in setup and the setup screen itself were using two different timer-entry paths. The fix was to make those paths share the same one-time onboarding-demo rule.
|
||||
|
||||
### #PostMergeCodeBreakage
|
||||
The final setback of the day came after the branch merge itself rather than from the timer/session work.
|
||||
|
||||
Several files on `main` were left in a partially merged state with duplicated code fragments and broken JSX structure. That meant the next pass could not start from feature verification alone, because basic app screens were no longer parseable.
|
||||
|
||||
The practical fix was to repair those files directly first, then re-run targeted verification on the restored app files before deciding whether any real feature regressions were still present.
|
||||
|
||||
---
|
||||
|
||||
## #CurrentState
|
||||
@@ -220,6 +246,7 @@ The app now supports:
|
||||
- direct dashboard routing after the onboarding demo completes, without the normal completion modal
|
||||
- help modals that explain the study loop in a more natural way
|
||||
- explicit tab icons that make dashboard and subjects easier to distinguish at a glance
|
||||
- repaired `main`-branch versions of the subjects, subject-details, and task-details screens after merge corruption
|
||||
|
||||
At this point, the timer/session work is closer to a finished loop, and the first-time-user path is more in line with the intended product vision. The biggest remaining work is now less about feature gaps and more about making sure the final report and final app behavior stay aligned.
|
||||
|
||||
@@ -268,3 +295,13 @@ The final UI pass for the day was lighter and did not change behavior, but the r
|
||||
- explicit `MaterialIcons` import in the tabs layout
|
||||
- `dashboard` icon for the dashboard tab
|
||||
- `menu-book` icon for the subjects tab
|
||||
|
||||
The final repair pass on `main` was verified separately:
|
||||
- `npx eslint app/(tabs)/subjects.tsx app/subject/viewDetailsSubject.tsx app/task/viewDetailsTask.tsx`
|
||||
- `git diff --check`
|
||||
- no remaining merge markers were found in `app/`, `lib/`, `components/`, or `notes/`
|
||||
|
||||
One broader static check still failed afterwards:
|
||||
- `npx tsc --noEmit`
|
||||
|
||||
That remaining failure was no longer caused by the repaired app screens. The reported errors were instead in the test setup under `__tests__/`, where Jest/testing-library types and modules were not currently configured in this branch.
|
||||
|
||||
2924
package-lock.json
generated
2924
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -9,7 +9,8 @@
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
"lint": "expo lint",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
@@ -48,10 +49,23 @@
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "^55.0.16",
|
||||
"patch-package": "^8.0.1",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
"private": true,
|
||||
"jest": {
|
||||
"preset": "jest-expo",
|
||||
"setupFiles": [
|
||||
"<rootDir>/jest.setup.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
BIN
studysprint.apk
Normal file
BIN
studysprint.apk
Normal file
Binary file not shown.
Reference in New Issue
Block a user