Compare commits

...

21 Commits

Author SHA1 Message Date
Chris Sanden
d25e241273 adding .apk 2026-05-07 00:57:16 +02:00
Chris Sanden
bc06704e0b changed app.json to be in line with what google console expects 2026-05-06 22:38:50 +02:00
Chris Sanden
e18fc525cf added logout button to guided setup screens 2026-05-06 20:32:19 +02:00
Chris Sanden
419463e5be updated tests to align with new logic 2026-05-06 17:15:41 +02:00
Chris Sanden
f2312bce38 removed automatic change for subject to inactive if all assignments are completed 2026-05-06 17:01:05 +02:00
Chris Sanden
66efbecf2f changed sequence of completion logic 2026-05-05 21:54:32 +02:00
Chris Sanden
ac6bfa1022 don't ask me how, but it works 2026-05-05 20:58:03 +02:00
Chris Sanden
a536be1047 Merge pull request #8 from Fhj0607/timerTask - ohhhhhh mama this is a big one
Timer task merge with hopes and prayers
2026-05-05 20:36:17 +02:00
Chris Sanden
27d04b2a3b Merge branch 'main' into timerTask 2026-05-05 20:34:58 +02:00
Chris Sanden
6127e6ec08 removed gap document 2026-05-05 20:16:39 +02:00
Teodor Salvesen
179fe3fd50 Merge pull request #7 from Fhj0607/tailwind
Tailwind
2026-05-05 14:31:28 +02:00
Teodor Salvesen
e5982c2eb3 Merge branch 'main' into tailwind 2026-05-05 14:31:02 +02:00
Teodor Salvesen
68f520b99e Merge pull request #6 from Fhj0607/tests
Tests
2026-05-05 14:25:27 +02:00
Fhj0607
d2e3c2e718 add architecture-note.md 2026-05-01 13:25:59 +02:00
Fhj0607
ae613f8707 redesigned completion and reopening subjects/assignments/tasks and how it is rendered 2026-05-01 12:36:58 +02:00
Fhj0607
e3c0b286b8 Assignment Progress now only renders when the subject has one or more assignments 2026-05-01 12:08:19 +02:00
Fhj0607
efb2e11893 Task Progress bar now only renders when totalTasks > 0 2026-05-01 12:06:15 +02:00
Fhj0607
7e33500fad subjects now loads instead of showing errors, before rendering db items 2026-05-01 12:04:44 +02:00
Teodor
3dcd392cd3 Adjusted crud tests + added auth tests 2026-04-30 22:21:56 +02:00
Teodor
545027a766 Merge branch 'main' of github.com:Fhj0607/Study-Sprint 2026-04-30 17:16:33 +02:00
Teodor
d7dc3cc72f crud tests for subjects, assignments and tasks 2026-04-25 23:32:11 +02:00
30 changed files with 5284 additions and 799 deletions

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View File

@@ -21,7 +21,7 @@
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "com.teodorsa.StudySprint"
"package": "com.softsand.studysprint"
},
"web": {
"output": "static",

View File

@@ -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>
)}

View File

@@ -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'
}`}

View File

@@ -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 }}>

View File

@@ -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>
),
}}
/>

View File

@@ -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'

View File

@@ -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">

View File

@@ -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);
})();
});
});

View File

@@ -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'
}`}

View File

@@ -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
View File

@@ -0,0 +1,4 @@
jest.mock(
"@react-native-async-storage/async-storage",
() => require("@react-native-async-storage/async-storage/jest/async-storage-mock")
);

View File

@@ -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
View 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

View File

@@ -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.

View 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/

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

Binary file not shown.