diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 9f1e134..0662b8e 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,4 +1,6 @@ +import { getSetupStatus } from "@/lib/setupStatus"; import { supabase } from "@/lib/supabase"; +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import { Session } from "@supabase/supabase-js"; import * as Notifications from 'expo-notifications'; import { Redirect, router, Tabs } from "expo-router"; @@ -32,6 +34,8 @@ function UseNotificationObserver() { export default function TabLayout() { const [session, SetSession] = useState(null) const [loading, SetLoading] = useState(true); + const [setupChecked, setSetupChecked] = useState(false); + const [needsSetup, setNeedsSetup] = useState(false); UseNotificationObserver(); @@ -51,7 +55,29 @@ export default function TabLayout() { return () => sub.subscription.unsubscribe(); }, []); - if (loading) { + useEffect(() => { + const checkSetupStatus = async () => { + if (!session?.user.id) { + setNeedsSetup(false); + setSetupChecked(true); + return; + } + + try { + const setupStatus = await getSetupStatus(session.user.id); + setNeedsSetup(!setupStatus.isSetupComplete); + } catch { + setNeedsSetup(true); + } finally { + setSetupChecked(true); + } + }; + + setSetupChecked(false); + void checkSetupStatus(); + }, [session?.user.id]); + + if (loading || !setupChecked) { return null; } @@ -59,14 +85,34 @@ export default function TabLayout() { return ; } + if (needsSetup) { + return ; + } + return ( - - - + ( + + ), + }} + /> + ( + + ), + }} + /> ); } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 528f1e9..850b511 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,13 +1,465 @@ -import { defaultStyles } from "@/constants/defaultStyles"; +import { + GetActiveSession, + type ActiveSession, +} from '@/lib/asyncStorage'; +import type { SessionType } from '@/lib/types'; +import { formatDate, formatDateTime } from '@/lib/date'; import { RegisterForLocalNotificationsAsync } from '@/lib/notifications'; +import { CheckAssignmentCompletion } from '@/lib/progress'; +import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults'; +import { getSetupStatus } from '@/lib/setupStatus'; +import { finalizeStoredSession } from '@/lib/sessionLifecycle'; import { supabase } from "@/lib/supabase"; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { Session } from '@supabase/supabase-js'; -import { Stack } from "expo-router"; -import { useEffect, useState } from 'react'; -import { Button, Text, View } from "react-native"; +import { Redirect, router, Stack, useFocusEffect } from "expo-router"; +import { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Modal, + Pressable, + ScrollView, + Text, + View, +} from "react-native"; + +type UpcomingDeadlineTask = { + tId: string; + title: string; + description: string; + aId: string; + subjectTitle: string; + assignmentTitle: string; + deadline: string; +}; + +type DashboardProgressSummary = { + completedFocusSessionsToday: number; + minutesStudiedToday: number; + minutesStudiedThisWeek: number; +}; + +type RecentSession = { + sessionId: string; + taskTitle: string | null; + sessionType: SessionType; + elapsedSeconds: number; + status: string; + startedAt: string | null; + endedAt: string | null; +}; + +type RecentlyCompletedTask = { + tId: string; + title: string; + assignmentTitle: string; + lastChanged: string; +}; + +const FLOW_STEPS = [ + { + label: '1', + title: 'Subject', + description: 'Start with the broad area you are studying, like one course or one exam you are preparing for.', + }, + { + label: '2', + title: 'Assignment', + description: 'Inside that, add the bigger piece of work you want to move forward, like a project, a problem set, or revision block.', + }, + { + label: '3', + title: 'Task', + description: 'Then break it down into one task that feels concrete enough to begin without overthinking it.', + }, + { + label: '4', + title: 'Sprint', + description: 'That task is what you bring into a focus session. After a sprint, you take a short pause, then come back to the same kind of focused work. After a few rounds, the app gives you a longer pause so the rhythm still feels sustainable.', + }, +] as const; + +function formatTime(totalSeconds: number) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; +} + +function getSessionLabel(sessionType: SessionType) { + switch (sessionType) { + case 'short_break': + return 'Short Break'; + case 'long_break': + return 'Long Break'; + default: + return 'Active Sprint'; + } +} + +function formatTrackedMinutes(totalSeconds: number) { + return Math.floor(totalSeconds / 60); +} + +function formatTrackedDuration(totalSeconds: number) { + if (totalSeconds <= 0) { + return '0m'; + } + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + if (hours === 0) { + return `${minutes}m`; + } + + if (minutes === 0) { + return `${hours}h`; + } + + return `${hours}h ${minutes}m`; +} + +function getSessionStatusLabel(status: string) { + switch (status) { + case 'completed': + return 'Completed'; + case 'cancelled': + return 'Cancelled'; + case 'expired': + return 'Timed out'; + default: + return status; + } +} + +function getStartOfToday() { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); +} + +function getStartOfWeek() { + const today = getStartOfToday(); + const currentDay = today.getDay(); + const daysSinceMonday = (currentDay + 6) % 7; + + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - daysSinceMonday); + return startOfWeek; +} export default function HomeScreen() { const [session, SetSession] = useState(null); + const [activeSprint, setActiveSprint] = useState(null); + const [activeSprintTaskTitle, setActiveSprintTaskTitle] = useState(null); + const [activeSprintTaskDesc, setActiveSprintTaskDesc] = useState(null); + const [remainingSeconds, setRemainingSeconds] = useState(0); + const [dashboardSummary, setDashboardSummary] = useState({ + completedFocusSessionsToday: 0, + minutesStudiedToday: 0, + minutesStudiedThisWeek: 0, + }); + const [recentSessions, setRecentSessions] = useState([]); + const [recentlyCompletedTasks, setRecentlyCompletedTasks] = useState([]); + const [upcomingDeadlineTasks, setUpcomingDeadlineTasks] = useState([]); + const [isFlowInfoVisible, setIsFlowInfoVisible] = useState(false); + const [completingTaskId, setCompletingTaskId] = useState(null); + const [subjectCount, setSubjectCount] = useState(0); + const [needsSetup, setNeedsSetup] = useState(null); + + const loadActiveSprint = useCallback(async () => { + const storedSprint = await GetActiveSession(); + + if (!storedSprint) { + setActiveSprint(null); + setActiveSprintTaskTitle(null); + setActiveSprintTaskDesc(null); + setRemainingSeconds(0); + return; + } + + const secondsLeft = Math.max( + 0, + Math.ceil((storedSprint.endTime - Date.now()) / 1000) + ); + + if (secondsLeft <= 0) { + await finalizeStoredSession('expired', storedSprint); + setActiveSprint(null); + setActiveSprintTaskTitle(null); + setActiveSprintTaskDesc(null); + setRemainingSeconds(0); + return; + } + + setActiveSprint(storedSprint); + setRemainingSeconds(secondsLeft); + + if (!storedSprint.taskId) { + setActiveSprintTaskTitle(getSessionLabel(storedSprint.sessionType)); + setActiveSprintTaskDesc('Take the break before you jump into the next focus session.'); + return; + } + + const { data: dbTitle } = await supabase + .from('tasks') + .select('title') + .eq('tId', storedSprint.taskId) + .single(); + + const { data: dbDesc } = await supabase + .from('tasks') + .select('description') + .eq('tId', storedSprint.taskId) + .single(); + + setActiveSprintTaskTitle(dbTitle?.title ?? null); + setActiveSprintTaskDesc(dbDesc?.description); + }, []); + + const loadUpcomingDeadlineTasks = useCallback(async () => { + if (!session?.user.id) { + setUpcomingDeadlineTasks([]); + return; + } + + const { data: tasksData, error: tasksError } = await supabase + .from('tasks') + .select('tId, title, description, aId') + .eq('uId', session.user.id) + .eq('isCompleted', false); + + if (tasksError || !tasksData || tasksData.length === 0) { + setUpcomingDeadlineTasks([]); + return; + } + + const assignmentIds = [...new Set(tasksData.map((task) => task.aId).filter(Boolean))]; + + if (assignmentIds.length === 0) { + setUpcomingDeadlineTasks([]); + return; + } + + const { data: assignmentsData, error: assignmentsError } = await supabase + .from('assignments') + .select('aId, sId, title, deadline') + .in('aId', assignmentIds) + .eq('isCompleted', false); + + if (assignmentsError || !assignmentsData) { + setUpcomingDeadlineTasks([]); + return; + } + + const subjectIds = [...new Set(assignmentsData.map((assignment) => assignment.sId).filter(Boolean))]; + + const { data: subjectsData, error: subjectsError } = await supabase + .from('subjects') + .select('sId, title') + .in('sId', subjectIds); + + if (subjectsError || !subjectsData) { + setUpcomingDeadlineTasks([]); + return; + } + + const now = Date.now(); + const assignmentsById = new Map( + assignmentsData.map((assignment) => [assignment.aId, assignment]) + ); + const subjectsById = new Map( + subjectsData.map((subject) => [subject.sId, subject]) + ); + + const enrichedTasks = tasksData + .map((task) => { + const assignment = assignmentsById.get(task.aId); + + if (!assignment?.deadline) { + return null; + } + + const deadlineTime = new Date(assignment.deadline).getTime(); + + if (Number.isNaN(deadlineTime) || deadlineTime < now) { + return null; + } + + const subject = subjectsById.get(assignment.sId); + + return { + tId: task.tId, + title: task.title, + description: task.description, + aId: task.aId, + subjectTitle: subject?.title ?? 'Unknown Subject', + assignmentTitle: assignment.title, + deadline: assignment.deadline, + } satisfies UpcomingDeadlineTask; + }) + .filter((task): task is UpcomingDeadlineTask => task !== null) + .sort( + (left, right) => + new Date(left.deadline).getTime() - new Date(right.deadline).getTime() + ); + + setUpcomingDeadlineTasks(enrichedTasks); + }, [session?.user.id]); + + const loadDashboardProgress = useCallback(async () => { + if (!session?.user.id) { + setDashboardSummary({ + completedFocusSessionsToday: 0, + minutesStudiedToday: 0, + minutesStudiedThisWeek: 0, + }); + setRecentSessions([]); + setRecentlyCompletedTasks([]); + return; + } + + const startOfToday = getStartOfToday().toISOString(); + const startOfWeek = getStartOfWeek().toISOString(); + + const [ + { data: weeklySessions, error: weeklySessionsError }, + { data: rawRecentSessions, error: recentSessionsError }, + { data: completedTasks, error: completedTasksError }, + { count: fetchedSubjectCount, error: subjectCountError }, + ] = await Promise.all([ + supabase + .from('sprint_sessions') + .select('sessionId, sessionType, elapsedSeconds, status, startedAt, endedAt') + .eq('userId', session.user.id) + .eq('sessionType', 'focus') + .not('endedAt', 'is', null) + .gte('endedAt', startOfWeek), + supabase + .from('sprint_sessions') + .select('sessionId, taskId, sessionType, elapsedSeconds, status, startedAt, endedAt') + .eq('userId', session.user.id) + .not('endedAt', 'is', null) + .order('endedAt', { ascending: false }) + .limit(6), + supabase + .from('tasks') + .select('tId, title, aId, lastChanged') + .eq('uId', session.user.id) + .eq('isCompleted', true) + .order('lastChanged', { ascending: false }) + .limit(3), + supabase + .from('subjects') + .select('sId', { count: 'exact', head: true }) + .eq('uId', session.user.id), + ]); + + if (weeklySessionsError || recentSessionsError || completedTasksError || subjectCountError) { + setDashboardSummary({ + completedFocusSessionsToday: 0, + minutesStudiedToday: 0, + minutesStudiedThisWeek: 0, + }); + setRecentSessions([]); + setRecentlyCompletedTasks([]); + setSubjectCount(0); + return; + } + + setSubjectCount(fetchedSubjectCount ?? 0); + + const weeklySessionRows = weeklySessions ?? []; + const todaySummary = weeklySessionRows.reduce( + (summary, currentSession) => { + const endedAt = currentSession.endedAt ? new Date(currentSession.endedAt) : null; + + if (!endedAt || Number.isNaN(endedAt.getTime())) { + return summary; + } + + const elapsedSeconds = currentSession.elapsedSeconds ?? 0; + + summary.minutesStudiedThisWeek += formatTrackedMinutes(elapsedSeconds); + + if (endedAt >= new Date(startOfToday)) { + summary.minutesStudiedToday += formatTrackedMinutes(elapsedSeconds); + + if (currentSession.status === 'completed') { + summary.completedFocusSessionsToday += 1; + } + } + + return summary; + }, + { + completedFocusSessionsToday: 0, + minutesStudiedToday: 0, + minutesStudiedThisWeek: 0, + } satisfies DashboardProgressSummary + ); + + setDashboardSummary(todaySummary); + + const recentSessionRows = rawRecentSessions ?? []; + const recentTaskIds = [ + ...new Set( + recentSessionRows + .map((recentSession) => recentSession.taskId) + .filter((taskId): taskId is string => Boolean(taskId)) + ), + ]; + + const completedTaskRows = completedTasks ?? []; + const completedAssignmentIds = [ + ...new Set( + completedTaskRows + .map((task) => task.aId) + .filter((assignmentId): assignmentId is string => Boolean(assignmentId)) + ), + ]; + + const [{ data: recentTasks }, { data: completedAssignments }] = await Promise.all([ + recentTaskIds.length > 0 + ? supabase + .from('tasks') + .select('tId, title') + .in('tId', recentTaskIds) + : Promise.resolve({ data: [], error: null }), + completedAssignmentIds.length > 0 + ? supabase + .from('assignments') + .select('aId, title') + .in('aId', completedAssignmentIds) + : Promise.resolve({ data: [], error: null }), + ]); + + const tasksById = new Map((recentTasks ?? []).map((task) => [task.tId, task.title])); + const assignmentsById = new Map( + (completedAssignments ?? []).map((assignment) => [assignment.aId, assignment.title]) + ); + + setRecentSessions( + recentSessionRows.map((recentSession) => ({ + sessionId: recentSession.sessionId, + taskTitle: recentSession.taskId ? (tasksById.get(recentSession.taskId) ?? null) : null, + sessionType: recentSession.sessionType, + elapsedSeconds: recentSession.elapsedSeconds ?? 0, + status: recentSession.status, + startedAt: recentSession.startedAt, + endedAt: recentSession.endedAt, + })) + ); + + setRecentlyCompletedTasks( + completedTaskRows.map((task) => ({ + tId: task.tId, + title: task.title, + assignmentTitle: assignmentsById.get(task.aId) ?? 'Unknown Assignment', + lastChanged: task.lastChanged, + })) + ); + }, [session?.user.id]); useEffect(() => { supabase.auth @@ -27,27 +479,550 @@ export default function HomeScreen() { if (session) { RegisterForLocalNotificationsAsync(); } - }, [session]) + }, [session]); + + useEffect(() => { + const loadSetupGate = async () => { + if (!session?.user.id) { + setNeedsSetup(false); + return; + } + + try { + const setupStatus = await getSetupStatus(session.user.id); + setNeedsSetup(!setupStatus.isSetupComplete); + } catch { + setNeedsSetup(true); + } + }; + + setNeedsSetup(null); + void loadSetupGate(); + }, [session?.user.id]); + + useFocusEffect( + useCallback(() => { + void loadActiveSprint(); + void loadDashboardProgress(); + void loadUpcomingDeadlineTasks(); + }, [loadActiveSprint, loadDashboardProgress, loadUpcomingDeadlineTasks]) + ); + + useEffect(() => { + if (!activeSprint) { + return; + } + + const intervalId = setInterval(() => { + const secondsLeft = Math.max( + 0, + Math.ceil((activeSprint.endTime - Date.now()) / 1000) + ); + + setRemainingSeconds(secondsLeft); + + if (secondsLeft <= 0) { + void finalizeStoredSession('expired', activeSprint); + setActiveSprint(null); + setActiveSprintTaskTitle(null); + setActiveSprintTaskDesc(null); + } + }, 1000); + + return () => clearInterval(intervalId); + }, [activeSprint]); + + const handleTaskCompletion = useCallback(async (task: UpcomingDeadlineTask) => { + if (completingTaskId) { + return; + } + + setCompletingTaskId(task.tId); + + const { error } = await supabase + .from('tasks') + .update({ + isCompleted: true, + lastChanged: new Date().toISOString(), + }) + .eq('tId', task.tId); + + if (error) { + setCompletingTaskId(null); + Alert.alert('Task could not be completed, please try again'); + return; + } + + try { + await CheckAssignmentCompletion(task.aId); + } catch { + setCompletingTaskId(null); + Alert.alert('Task was updated, but assignment progress could not be refreshed'); + return; + } + + setUpcomingDeadlineTasks((currentTasks) => + currentTasks.filter((currentTask) => currentTask.tId !== task.tId) + ); + setCompletingTaskId(null); + }, [completingTaskId]); + + const handleStartSprint = useCallback(async (task: UpcomingDeadlineTask) => { + const storedSession = await GetActiveSession(); + + if (!storedSession) { + router.push({ + pathname: '/task/timer', + params: { + tId: task.tId, + durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES), + }, + }); + return; + } + + const secondsLeft = Math.ceil((storedSession.endTime - Date.now()) / 1000); + + if (secondsLeft <= 0) { + await finalizeStoredSession('expired', storedSession); + router.push({ + pathname: '/task/timer', + params: { + tId: task.tId, + durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES), + }, + }); + return; + } + + if (storedSession.taskId === task.tId) { + router.push({ + pathname: '/task/timer', + params: { + tId: task.tId, + 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 "${task.title}"?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Start sprint', + style: 'destructive', + onPress: async () => { + await finalizeStoredSession('cancelled', storedSession); + router.push({ + pathname: '/task/timer', + params: { + tId: task.tId, + durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES), + }, + }); + }, + }, + ] + ); + }, []); + + if (session && needsSetup === null) { + return null; + } + + if (needsSetup) { + return ; + } return ( - + { + return ( + + setIsFlowInfoVisible(true)} + > + + + + ) + }, headerRight: () => { return ( - -