added total time spent functionality for tasks and updated dashboard to display upcoming, uncompleted tasks sorted by date ascending

This commit is contained in:
Chris Sanden
2026-05-02 17:44:11 +02:00
parent 387ab2833d
commit b437643475
6 changed files with 926 additions and 249 deletions

View File

@@ -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<Session | null>(null);
const [activeSprint, setActiveSprint] = useState<ActiveSprint | null>(null);
const [activeSprintTaskTitle, setActiveSprintTaskTitle] = useState<string | null>(null);
const [activeSprintTaskDesc, setActiveSprintTaskDesc] = useState<string | null>(null);
const [remainingSeconds, setRemainingSeconds] = useState(0);
const [upcomingDeadlineTasks, setUpcomingDeadlineTasks] = useState<UpcomingDeadlineTask[]>([]);
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 (
<View style={defaultStyles.container}>
@@ -47,8 +239,145 @@ export default function HomeScreen() {
/>
<View style={defaultStyles.container}>
<Text style={defaultStyles.body}>Hello, World!</Text>
{activeSprint ? (
<View style={styles.activeSprintCard}>
<Text style={styles.cardEyebrow}>Active Sprint</Text>
<Text style={styles.cardTitle}>
{activeSprintTaskTitle ?? 'Selected task'}
</Text>
<Text style={styles.cardDesc}> {activeSprintTaskDesc ?? null} </Text>
<Text style={styles.cardMeta}>
{formatTime(remainingSeconds)} remaining
</Text>
<Pressable
style={styles.resumeButton}
onPress={() =>
router.push({
pathname: '/task/timer',
params: { tId: activeSprint.taskId },
})
}
>
<Text style={styles.resumeButtonText}>Open Sprint</Text>
</Pressable>
</View>
) : (
<>
<Text style={defaultStyles.body}>No active sprint right now.</Text>
<View style={styles.deadlineSection}>
<Text style={styles.sectionTitle}>Tasks with upcoming deadlines</Text>
{upcomingDeadlineTasks.length > 0 ? (
upcomingDeadlineTasks.map((task) => (
<Pressable
key={task.tId}
style={styles.deadlineTaskCard}
onPress={() =>
router.push({
pathname: '/task/viewDetailsTask',
params: { tId: task.tId },
})
}
>
<Text style={styles.deadlineTaskTitle}>{task.title}</Text>
{task.description ? (
<Text style={styles.deadlineTaskDescription} numberOfLines={2}>
{task.description}
</Text>
) : null}
<Text style={styles.deadlineTaskMeta}>
{task.subjectTitle} {task.assignmentTitle} {formatDate(task.deadline)}
</Text>
</Pressable>
))
) : (
<Text style={styles.emptyDeadlineText}>No upcoming task deadlines.</Text>
)}
</View>
</>
)}
</View>
</View>
)
);
}
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',
},
});

View File

@@ -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() {
<Stack.Screen
options={{
title: timerIsRunning ? '' : 'Sprint',
headerBackVisible: !timerIsRunning,
title: timerIsRunning ? '' : 'Sprint duration',
headerTransparent: true,
headerTintColor: colors.text,
headerTitleAlign: 'center',

View File

@@ -1,3 +1,4 @@
import { GetActiveSprint, RemoveActiveSprint } from '@/lib/asyncStorage';
import { formatDateTime } from '@/lib/date';
import { CheckAssignmentCompletion } from '@/lib/progress';
import { getSubjectColorSet, type SubjectColor } from '@/lib/subjectColors';
@@ -8,187 +9,211 @@ import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router
import { useCallback, useEffect, useState } from 'react';
import { Alert, Pressable, Text, View } from 'react-native';
export default function ViewDetailsTask() {
const { tId } = useLocalSearchParams<{ tId: string }>();
function formatTrackedTime(totalSeconds: number) {
if (totalSeconds <= 0) {
return '0m';
}
const [task, SetTask] = useState<Task | null>(null);
const [session, SetSession] = useState<Session | null>(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<Task | null>(null);
const [session, SetSession] = useState<Session | null>(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 (
<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>
),
}}
/>
<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>
<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;
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 (
<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"
@@ -209,102 +234,150 @@ export default function ViewDetailsTask() {
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>
<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>
<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 }}
>
<Text
className="text-xs font-semibold"
style={{ color: colorSet.strong }}
>
{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>
<Text className="mt-2 text-sm text-text-muted">
Last changed: {formatDateTime(task.lastChanged)}
</Text>
</View>
</View>
{isOwner && (
<View className="mt-5 flex-row border-t border-app-border pt-5">
<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='mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3'
onPress={() =>
router.push({
pathname: '/task/timer',
params: { tId: task.tId}
})
}>
<Text className='text.sm font-bold text-text-secondary'>
Start Sprint
</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>
)}
<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()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
),
}}
/>
<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 }}
>
<Text
className="text-xs font-semibold"
style={{ color: colorSet.strong }}
>
{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>
<Text className="mt-2 text-sm text-text-muted">
Last changed: {formatDateTime(task.lastChanged)}
</Text>
<Text className="mt-1 text-sm text-text-muted">
Time spent: {formatTrackedTime(task.totalTimeInSeconds ?? 0)}
</Text>
</View>
</View>
{isOwner && (
<View className="mt-5 flex-row border-t border-app-border pt-5">
<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='mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3'
onPress={() =>
handleSprintStart()
}>
<Text className='text.sm font-bold text-text-secondary'>
Start Sprint
</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 File

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

View File

@@ -8,6 +8,7 @@ export type Task = {
lastChanged: string;
uId: string;
aId: string;
totalTimeInSeconds: number;
};
export type Assignment = {

View File

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