From b437643475d9e78676df5cd71066f43589e0dc13 Mon Sep 17 00:00:00 2001 From: Chris Sanden Date: Sat, 2 May 2026 17:44:11 +0200 Subject: [PATCH] added total time spent functionality for tasks and updated dashboard to display upcoming, uncompleted tasks sorted by date ascending --- app/(tabs)/index.tsx | 341 +++++++++++++++- app/task/timer.tsx | 80 +++- app/task/viewDetailsTask.tsx | 543 +++++++++++++++----------- lib/asyncStorage.ts | 1 + lib/types.ts | 1 + notes/work-report-timer-2026-05-02.md | 209 ++++++++++ 6 files changed, 926 insertions(+), 249 deletions(-) create mode 100644 notes/work-report-timer-2026-05-02.md diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 29084d6..aa6a730 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,13 +1,175 @@ import { defaultStyles } from "@/constants/defaultStyles"; +import { + GetActiveSprint, + RemoveActiveSprint, + type ActiveSprint, +} from '@/lib/asyncStorage'; +import { formatDate } from '@/lib/date'; import { RegisterForLocalNotificationsAsync } from '@/lib/notifications'; import { supabase } from "@/lib/supabase"; import { Session } from '@supabase/supabase-js'; -import { Stack } from "expo-router"; -import { useEffect, useState } from 'react'; -import { Button, Text, View } from "react-native"; +import { router, Stack, useFocusEffect } from "expo-router"; +import { useCallback, useEffect, useState } from 'react'; +import { Button, Pressable, StyleSheet, Text, View } from "react-native"; + +type UpcomingDeadlineTask = { + tId: string; + title: string; + description: string; + aId: string; + subjectTitle: string; + assignmentTitle: string; + deadline: string; +}; + +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')}`; +} 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 [upcomingDeadlineTasks, setUpcomingDeadlineTasks] = useState([]); + + const loadActiveSprint = useCallback(async () => { + const storedSprint = await GetActiveSprint(); + + 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 RemoveActiveSprint(); + setActiveSprint(null); + setActiveSprintTaskTitle(null); + setActiveSprintTaskDesc(null); + setRemainingSeconds(0); + return; + } + + setActiveSprint(storedSprint); + setRemainingSeconds(secondsLeft); + + 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]); useEffect(() => { supabase.auth @@ -27,7 +189,37 @@ export default function HomeScreen() { if (session) { RegisterForLocalNotificationsAsync(); } - }, [session]) + }, [session]); + + useFocusEffect( + useCallback(() => { + void loadActiveSprint(); + void loadUpcomingDeadlineTasks(); + }, [loadActiveSprint, 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 RemoveActiveSprint(); + setActiveSprint(null); + setActiveSprintTaskTitle(null); + } + }, 1000); + + return () => clearInterval(intervalId); + }, [activeSprint]); return ( @@ -47,8 +239,145 @@ export default function HomeScreen() { /> - Hello, World! + {activeSprint ? ( + + Active Sprint + + {activeSprintTaskTitle ?? 'Selected task'} + + {activeSprintTaskDesc ?? null} + + {formatTime(remainingSeconds)} remaining + + + + router.push({ + pathname: '/task/timer', + params: { tId: activeSprint.taskId }, + }) + } + > + Open Sprint + + + ) : ( + <> + No active sprint right now. + + + Tasks with upcoming deadlines + + {upcomingDeadlineTasks.length > 0 ? ( + upcomingDeadlineTasks.map((task) => ( + + router.push({ + pathname: '/task/viewDetailsTask', + params: { tId: task.tId }, + }) + } + > + {task.title} + {task.description ? ( + + {task.description} + + ) : null} + + {task.subjectTitle} • {task.assignmentTitle} • {formatDate(task.deadline)} + + + )) + ) : ( + No upcoming task deadlines. + )} + + + )} - ) + ); } + +const styles = StyleSheet.create({ + activeSprintCard: { + borderWidth: 1, + borderColor: '#D5D9DF', + borderRadius: 16, + padding: 16, + backgroundColor: '#F7F9FC', + gap: 8, + }, + cardEyebrow: { + fontSize: 13, + fontWeight: '600', + color: '#5D6B7A', + }, + cardTitle: { + fontSize: 20, + fontWeight: '700', + color: '#1F2933', + }, + cardDesc: { + fontSize: 14, + fontWeight: '500', + color: '#38414b', + }, + cardMeta: { + fontSize: 15, + color: '#52606D', + }, + resumeButton: { + marginTop: 8, + minHeight: 44, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#323F4E', + paddingHorizontal: 16, + }, + resumeButtonText: { + fontSize: 15, + fontWeight: '700', + color: '#FFFFFF', + }, + deadlineSection: { + marginTop: 24, + gap: 12, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + color: '#1F2933', + }, + deadlineTaskCard: { + borderWidth: 1, + borderColor: '#D5D9DF', + borderRadius: 16, + padding: 16, + backgroundColor: '#FFFFFF', + gap: 6, + }, + deadlineTaskTitle: { + fontSize: 16, + fontWeight: '700', + color: '#1F2933', + }, + deadlineTaskDescription: { + fontSize: 14, + color: '#52606D', + }, + deadlineTaskMeta: { + fontSize: 13, + fontWeight: '600', + color: '#7B8794', + }, + emptyDeadlineText: { + fontSize: 14, + color: '#7B8794', + }, +}); diff --git a/app/task/timer.tsx b/app/task/timer.tsx index 933f431..7e1a038 100644 --- a/app/task/timer.tsx +++ b/app/task/timer.tsx @@ -9,6 +9,7 @@ import * as Haptics from 'expo-haptics'; import { Stack, useLocalSearchParams } from 'expo-router'; import * as React from 'react'; import { + Alert, Animated, Dimensions, Easing, @@ -55,6 +56,19 @@ function formatTime(totalSeconds: number) { return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } +function getSessionId(sessionData: unknown) { + if (!sessionData || typeof sessionData !== 'object') { + return null; + } + + const maybeSession = sessionData as { + sessionId?: string; + sessionid?: string; + }; + + return maybeSession.sessionId ?? maybeSession.sessionid ?? null; +} + export default function TimerScreen() { const [containerHeight, setContainerHeight] = React.useState(0); const [duration, setDuration] = React.useState(TIMER_OPTIONS[0]); @@ -175,6 +189,29 @@ export default function TimerScreen() { outputRange: [20, 0], }); + const finalizeSprintSession = React.useCallback(async (finalStatus: 'completed' | 'cancelled' | 'expired') => { + const activeSprint = await GetActiveSprint(); + + if (!activeSprint) { + return; + } + + await RemoveActiveSprint(); + + const { error } = await supabase.rpc('finalize_sprint_session', { + p_session_id: activeSprint.sessionId, + p_final_status: finalStatus, + p_ended_at: new Date().toISOString(), + }); + + if (error) { + Alert.alert( + 'Could not finalize sprint session', + error.message + ); + } + }, []); + const clearCountdownInterval = React.useCallback(() => { if (countdownRef.current) { clearInterval(countdownRef.current); @@ -239,7 +276,7 @@ export default function TimerScreen() { const finishTimer = React.useCallback(() => { clearCountdownInterval(); - void RemoveActiveSprint(); + void finalizeSprintSession('completed'); Animated.parallel([ Animated.timing(countdownAnimation, { @@ -284,6 +321,7 @@ export default function TimerScreen() { cancelButtonAnimation, clearCountdownInterval, countdownAnimation, + finalizeSprintSession, focusModeAnimation, resetSessionValues, taskDetailsAnimation, @@ -392,7 +430,7 @@ export default function TimerScreen() { const remainingMs = activeSprint.endTime - Date.now(); if (remainingMs <= 0) { - await RemoveActiveSprint(); + await finalizeSprintSession('expired'); return; } @@ -424,6 +462,7 @@ export default function TimerScreen() { cancelOverlayAnimation, containerHeight, countdownAnimation, + finalizeSprintSession, focusModeAnimation, startCountdown, startProgressAnimation, @@ -433,11 +472,37 @@ export default function TimerScreen() { timerIsRunning, ]); - const startTimerSession = React.useCallback(() => { + const startTimerSession = React.useCallback(async () => { if (!tId || timerIsRunning || containerHeight === 0) { return; } + const totalSeconds = duration * TIMER_UNIT_IN_SECONDS; + const endTime = Date.now() + totalSeconds * 1000; + const { data: userData, error: userError } = await supabase.auth.getUser(); + + if (userError || !userData.user?.id) { + Alert.alert('Could not start sprint', 'Missing signed-in user for sprint session.'); + return; + } + + const { data: sessionData, error: sessionError } = await supabase.rpc('start_sprint_session', { + p_task_id: tId, + p_user_id: userData.user.id, + p_planned_duration: totalSeconds, + p_started_at: new Date().toISOString(), + }); + + const sessionId = getSessionId(sessionData); + + if (sessionError || !sessionId) { + Alert.alert( + 'Could not start sprint', + sessionError?.message ?? 'Sprint session could not be created.' + ); + return; + } + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); setIsRunning(true); setTimerOverlayVisible(true); @@ -446,12 +511,11 @@ export default function TimerScreen() { countdownAnimation.setValue(0); cancelOverlayAnimation.setValue(0); - const totalSeconds = duration * TIMER_UNIT_IN_SECONDS; - const endTime = Date.now() + totalSeconds * 1000; sessionStartedAtRef.current = Date.now(); sessionDurationMsRef.current = totalSeconds * 1000; void SaveActiveSprint({ + sessionId, taskId: tId, durationSeconds: totalSeconds, endTime, @@ -478,7 +542,7 @@ export default function TimerScreen() { clearCountdownInterval(); clearCancelHoldTimeouts(); - void RemoveActiveSprint(); + void finalizeSprintSession('cancelled'); runningAnimationRef.current?.stop(); runningAnimationRef.current = null; @@ -531,6 +595,7 @@ export default function TimerScreen() { clearCancelHoldTimeouts, clearCountdownInterval, countdownAnimation, + finalizeSprintSession, focusModeAnimation, resetSessionValues, taskDetailsAnimation, @@ -680,8 +745,7 @@ export default function TimerScreen() { (); +function formatTrackedTime(totalSeconds: number) { + if (totalSeconds <= 0) { + return '0m'; + } - const [task, SetTask] = useState(null); - const [session, SetSession] = useState(null); - const [contextMeta, setContextMeta] = useState({ - subjectTitle: 'No Subject', - assignmentTitle: 'No Assignment', - subjectColor: 'slate' as SubjectColor, + 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`; +} + +export default function ViewDetailsTask() { +const { tId } = useLocalSearchParams<{ tId: string }>(); + +const [task, SetTask] = useState(null); +const [session, SetSession] = useState(null); +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); }); - useEffect(() => { - supabase.auth.getSession().then(({ data }) => SetSession(data.session ?? null)); + return () => sub.subscription.unsubscribe(); +}, []); - const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => { - SetSession(newSession); - }); +const GetTask = async (taskId: string) => { + const { data, error } = await supabase + .from('tasks') + .select('*') + .eq('tId', taskId) + .single(); - return () => sub.subscription.unsubscribe(); - }, []); + if (error || !data) { + Alert.alert('Task could not be fetched, please try again'); + return; + } - const GetTask = async (taskId: string) => { - const { data, error } = await supabase - .from('tasks') - .select('*') - .eq('tId', taskId) + SetTask(data); + + if (data.aId) { + const { data: assignmentData, error: assignmentError } = await supabase + .from('assignments') + .select('title, sId') + .eq('aId', data.aId) .single(); - if (error || !data) { - Alert.alert('Task could not be fetched, please try again'); + if (assignmentError || !assignmentData) { + setContextMeta({ + subjectTitle: 'Unknown Subject', + assignmentTitle: 'Unknown Assignment', + subjectColor: 'slate', + }); return; } - SetTask(data); - - if (data.aId) { - const { data: assignmentData, error: assignmentError } = await supabase - .from('assignments') - .select('title, sId') - .eq('aId', data.aId) + if (assignmentData.sId) { + const { data: subjectData, error: subjectError } = await supabase + .from('subjects') + .select('title, color') + .eq('sId', assignmentData.sId) .single(); - if (assignmentError || !assignmentData) { + if (subjectError || !subjectData) { setContextMeta({ subjectTitle: 'Unknown Subject', - assignmentTitle: 'Unknown Assignment', + assignmentTitle: assignmentData.title ?? 'Unknown Assignment', subjectColor: 'slate', }); return; } - if (assignmentData.sId) { - const { data: subjectData, error: subjectError } = await supabase - .from('subjects') - .select('title, color') - .eq('sId', assignmentData.sId) - .single(); - - if (subjectError || !subjectData) { - setContextMeta({ - subjectTitle: 'Unknown Subject', - assignmentTitle: assignmentData.title ?? 'Unknown Assignment', - subjectColor: 'slate', - }); - return; - } - - setContextMeta({ - subjectTitle: subjectData.title ?? 'Unknown Subject', - assignmentTitle: assignmentData.title ?? 'Unknown Assignment', - subjectColor: (subjectData.color as SubjectColor | undefined) ?? 'slate', - }); - } + setContextMeta({ + subjectTitle: subjectData.title ?? 'Unknown Subject', + assignmentTitle: assignmentData.title ?? 'Unknown Assignment', + subjectColor: (subjectData.color as SubjectColor | undefined) ?? 'slate', + }); } - }; + } +}; - useFocusEffect( - useCallback(() => { - if (session && tId) { - GetTask(tId); - } - }, [session, tId]) - ); +useFocusEffect( + useCallback(() => { + if (session && tId) { + GetTask(tId); + } + }, [session, tId]) +); + +const handleSprintStart = async () => { + const activeSprint = await GetActiveSprint(); + + if (!activeSprint) { + router.push({ + pathname: '/task/timer', + params: { tId: task?.tId}, + }); + return; + } + + + + const secondsLeft = Math.ceil((activeSprint.endTime - Date.now()) / 1000) + + if (secondsLeft <= 0) { + await RemoveActiveSprint(); + router.push({ + pathname: '/task/timer', + params: { tId: task?.tId} + }); + return; + } + + + if (activeSprint!.taskId === task?.tId) { + router.push({ + pathname: '/task/timer', + params: { tId: activeSprint!.taskId}}); + return; + } - const DeleteTask = async (taskId: string) => { Alert.alert( - 'Delete Task', - 'Are you sure you want to delete this task?', + 'Active sprint in progress', + 'Starting a new sprint will end the current active sprint', [ + { text: 'Cancel', style: 'cancel', }, { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'Delete', + text: 'Start new sprint', 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(); + await RemoveActiveSprint(); + router.push({ + pathname: '/task/timer', + params: { tId: task?.tId }, + }); }, }, ] ); - }; - - const colorSet = getSubjectColorSet(contextMeta.subjectColor); - - if (!task) { - return ( - - ( - await supabase.auth.signOut()} - > - - Logout - - - ), - }} - /> - - - - Task not found - - - The task could not be loaded. - - - router.back()} - > - - Go back - - - - - ); } - const isOwner = session?.user.id === task.uId; +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 (!task) { return ( ( - - - {task.isCompleted && ( - - )} - + + Task not found + + + The task could not be loaded. + - - - {task.title} - - - {task.description ? ( - - {task.description} - - ) : ( - - No description added. - - )} - - - - - {contextMeta.subjectTitle} - - - - - - {contextMeta.assignmentTitle} - - - - - - Last changed: {formatDateTime(task.lastChanged)} - - - - - {isOwner && ( - - - router.push({ - pathname: '/task/upsertTask', - params: { tId: task.tId }, - }) - } - > - - Edit - - - - router.push({ - pathname: '/task/timer', - params: { tId: task.tId} - }) - }> - - Start Sprint - - - DeleteTask(task.tId)} - > - - Delete - - - - )} + router.back()} + > + + Go back + + ); } + +const isOwner = session?.user.id === task.uId; + +return ( + + ( + await supabase.auth.signOut()} + > + + Logout + + + ), + }} + /> + + + + + {task.isCompleted && ( + + )} + + + + + {task.title} + + + {task.description ? ( + + {task.description} + + ) : ( + + No description added. + + )} + + + + + {contextMeta.subjectTitle} + + + + + + {contextMeta.assignmentTitle} + + + + + + Last changed: {formatDateTime(task.lastChanged)} + + + Time spent: {formatTrackedTime(task.totalTimeInSeconds ?? 0)} + + + + + {isOwner && ( + + + router.push({ + pathname: '/task/upsertTask', + params: { tId: task.tId }, + }) + } + > + + Edit + + + + handleSprintStart() + }> + + Start Sprint + + + DeleteTask(task.tId)} + > + + Delete + + + + )} + + + ); +} diff --git a/lib/asyncStorage.ts b/lib/asyncStorage.ts index cf5d285..dcd72e2 100644 --- a/lib/asyncStorage.ts +++ b/lib/asyncStorage.ts @@ -4,6 +4,7 @@ const notificationKey = (aId: string) => `assignment_notification_${aId}`; const activeSprintKey = 'active_sprint'; export type ActiveSprint = { + sessionId: string, taskId: string; durationSeconds: number; endTime: number; diff --git a/lib/types.ts b/lib/types.ts index e1bafb6..65e93a2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -8,6 +8,7 @@ export type Task = { lastChanged: string; uId: string; aId: string; + totalTimeInSeconds: number; }; export type Assignment = { diff --git a/notes/work-report-timer-2026-05-02.md b/notes/work-report-timer-2026-05-02.md new file mode 100644 index 0000000..3d35610 --- /dev/null +++ b/notes/work-report-timer-2026-05-02.md @@ -0,0 +1,209 @@ +# Timer Session Tracking and Dashboard Integration Work Report + +## #Overview +Today the timer work moved beyond local in-memory behavior and into a more durable sprint-session model. + +The main direction was to make sprint time count toward task progress in a safer way, while also surfacing that progress in the app UI. This meant extending the timer flow with database-backed sprint sessions, making task time visible on the task details screen, and continuing the dashboard integration so active or upcoming work is easier to reach. + +The work stayed focused on the timer/task/dashboard path rather than broad app refactoring. + +--- + +## #ImplementedFeatures + +### #SprintSessionPersistence +Moved the timer session model toward a more robust database-backed structure: +- created a `sprint_sessions` table in Supabase +- added a `sessionId` field to the local `ActiveSprint` type in `lib/asyncStorage.ts` +- updated the timer start flow to create a sprint session in the database before entering the running timer state +- kept local `active_sprint` storage as the resume handle, but now tied it to a real database session instead of only a task id and end time + +This changes the active sprint from being only a local timer state into a recordable session that can later be finalized safely. + +--- + +### #TaskTimeTracking +Added task-level study time tracking: +- added `totalTimeInSeconds` to the task model in `lib/types.ts` +- verified that cancelling a sprint updates both `sprint_sessions` and the task total in the database +- verified that expired sessions also finalize correctly and contribute time as expected + +This gives each task a running total of time spent, rather than leaving the timer as a standalone UI action with no durable result on the task itself. + +--- + +### #FinalizeFlowRepair +Adjusted the timer finalize flow so session teardown and restore logic stop fighting each other: +- added a `finalizeSprintSession(...)` path in `app/task/timer.tsx` +- updated natural finish, cancel, and expired restore paths to use the database finalize flow +- removed the local active sprint before the finalize RPC completes so the restore effect does not immediately re-open a just-cancelled timer +- added alerts for sprint-session creation/finalization failures instead of silently leaving the screen in a half-running state + +This fixed the case where cancelling the timer appeared to work visually, but then the sprint popped back open because restore logic still saw a locally active session. + +--- + +### #TimerStartGuarding +Tightened the sprint-start path in the timer screen: +- delayed `setIsRunning(true)` until after the `start_sprint_session` RPC succeeds +- added handling for the returned session id before local sprint state is saved +- added fallback handling for session id shape differences in the RPC response + +Before this, the timer UI could enter a partial running state if the database session failed to start, which made the header change without actually starting the timer animation flow. + +--- + +### #TaskDetailsTimeDisplay +Made the recorded task time visible in the task details screen: +- added a local formatter for tracked time in `app/task/viewDetailsTask.tsx` +- displayed `Time spent: ...` under the existing metadata block on the task details screen + +This is the first direct UI confirmation that the timer is affecting persistent task data rather than only changing temporary timer state. + +--- + +### #DashboardSprintVisibility +Extended the dashboard so it reflects timer/task state more clearly: +- added dashboard support for reading and displaying the current active sprint from local storage +- showed the active sprint task title, description, and remaining time +- added an `Open Sprint` action that links directly back into the running timer + +This gives the user a global way to get back to an already running sprint after navigating away from the timer screen. + +--- + +### #UpcomingDeadlineCards +Added a deadline-based task section to the dashboard when no sprint is active: +- added a `Tasks with upcoming deadlines` section under the `No active sprint right now.` state +- fetched active tasks together with their assignment and subject context +- sorted the tasks by assignment deadline in ascending order +- rendered clickable cards that open the relevant task details screen +- updated the metadata line at the bottom of each card to show subject, assignment, and deadline + +This makes the dashboard more useful as a next-action screen instead of only a placeholder when no sprint is running. + +--- + +## #ProblemsAndSetbacks + +### #QuotedColumnNames +The first major issue today came from the new sprint-session SQL functions using unquoted camelCase column names. + +The database columns used names such as: +- `sessionId` +- `taskId` +- `startedAt` +- `elapsedSeconds` + +Without quotes, Postgres treated these as lowercase names like `sessionid` and `taskid`, which caused RPC failures when starting or finalizing sprint sessions. + +This had to be corrected in the SQL functions before the app-side timer integration could work. + +--- + +### #RowLevelSecurity +The next blocker was row-level security on `sprint_sessions`. + +Even after the SQL functions matched the correct columns, session creation still failed until the insert/select/update permissions allowed authenticated users to work with their own sprint-session rows. + +This was a necessary database-layer fix before the new robust timer flow could be tested end to end. + +--- + +### #CancelRestoreRace +Another significant bug showed up after the new finalize flow was wired in: +- the cancel animation ran +- the timer visually closed +- then the sprint reopened immediately + +The cause was that the restore effect still found `active_sprint` in local storage while the cancel/finalize path was still finishing. Removing the local active sprint earlier in the finalize path fixed that race. + +--- + +### #DashboardListInterpretation +There was also a dashboard-listing issue where the upcoming-deadlines section could appear to only show tasks from one subject. + +The actual cause was not the subject join itself, but the fact that the list had been truncated after sorting. That made the section biased toward whichever subject owned the earliest deadlines in the current data. + +--- + +## #CurrentState + +The timer/task flow now goes further than yesterday's integration work. + +The app now supports: +- creating a real sprint session in the database when a timer starts +- finalizing sprint sessions on cancel and expiry +- adding tracked session time into `tasks.totalTimeInSeconds` +- showing tracked task time on the task details screen +- reopening the active sprint from the dashboard +- showing upcoming deadline task cards on the dashboard when no sprint is active + +At this point, the timer is no longer only integrated into the task route. It is now also contributing durable progress data back into the task model and exposing more of that state in surrounding screens. + +--- + +## #Verification + +During today's work, the following behaviors were verified manually through the app plus database inspection: +- sprint creation now succeeds after fixing quoted column names and RLS +- cancelling a sprint updates both `sprint_sessions` and `tasks.totalTimeInSeconds` +- expired sprint finalization also updates the database as expected +- the cancel flow no longer reopens the timer immediately after the close animation + +Static checks were also run during the implementation work: + +```text +npx tsc --noEmit +exited successfully +``` + +```text +npm run lint -- app/task/timer.tsx +exited successfully +``` + +```text +npm run lint -- app/task/viewDetailsTask.tsx +exited successfully +``` + +```text +npm run lint -- app/(tabs)/index.tsx +exited successfully +``` + +The summary above is based on today's working-tree changes plus the live runtime/database checks done while fixing the timer session flow. + +--- + +## #FilesChanged + +Main timer/session files: + +```text +app/task/timer.tsx +lib/asyncStorage.ts +lib/types.ts +``` + +Task details and dashboard files: + +```text +app/task/viewDetailsTask.tsx +app/(tabs)/index.tsx +``` + +New note added: + +```text +notes/work-report-timer-2026-05-02.md +``` + +--- + +## #Conclusion + +Today's work turned the timer into something closer to a real task-tracking feature instead of only a screen-local countdown. + +The biggest progress was introducing sprint sessions as database-backed records, finalizing them into tracked task time, and then surfacing that state back into the app through task details and the dashboard. The timer is now contributing to persistent task progress, and the surrounding screens are beginning to reflect that progress in a useful way.