added total time spent functionality for tasks and updated dashboard to display upcoming, uncompleted tasks sorted by date ascending
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,18 +9,37 @@ 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({
|
||||
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(() => {
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => SetSession(data.session ?? null));
|
||||
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||
@@ -27,9 +47,9 @@ export default function ViewDetailsTask() {
|
||||
});
|
||||
|
||||
return () => sub.subscription.unsubscribe();
|
||||
}, []);
|
||||
}, []);
|
||||
|
||||
const GetTask = async (taskId: string) => {
|
||||
const GetTask = async (taskId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
@@ -82,17 +102,70 @@ export default function ViewDetailsTask() {
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (session && tId) {
|
||||
GetTask(tId);
|
||||
}
|
||||
}, [session, tId])
|
||||
);
|
||||
);
|
||||
|
||||
const DeleteTask = async (taskId: string) => {
|
||||
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;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Active sprint in progress',
|
||||
'Starting a new sprint will end the current active sprint',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel', },
|
||||
{
|
||||
text: 'Start new sprint',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await RemoveActiveSprint();
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: { tId: task?.tId },
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const DeleteTask = async (taskId: string) => {
|
||||
Alert.alert(
|
||||
'Delete Task',
|
||||
'Are you sure you want to delete this task?',
|
||||
@@ -131,11 +204,11 @@ export default function ViewDetailsTask() {
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const colorSet = getSubjectColorSet(contextMeta.subjectColor);
|
||||
const colorSet = getSubjectColorSet(contextMeta.subjectColor);
|
||||
|
||||
if (!task) {
|
||||
if (!task) {
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
<Stack.Screen
|
||||
@@ -179,11 +252,11 @@ export default function ViewDetailsTask() {
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isOwner = session?.user.id === task.uId;
|
||||
const isOwner = session?.user.id === task.uId;
|
||||
|
||||
return (
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
@@ -264,6 +337,9 @@ export default function ViewDetailsTask() {
|
||||
<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>
|
||||
|
||||
@@ -285,10 +361,7 @@ export default function ViewDetailsTask() {
|
||||
<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}
|
||||
})
|
||||
handleSprintStart()
|
||||
}>
|
||||
<Text className='text.sm font-bold text-text-secondary'>
|
||||
Start Sprint
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Task = {
|
||||
lastChanged: string;
|
||||
uId: string;
|
||||
aId: string;
|
||||
totalTimeInSeconds: number;
|
||||
};
|
||||
|
||||
export type Assignment = {
|
||||
|
||||
209
notes/work-report-timer-2026-05-02.md
Normal file
209
notes/work-report-timer-2026-05-02.md
Normal 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.
|
||||
Reference in New Issue
Block a user