Merge pull request #8 from Fhj0607/timerTask - ohhhhhh mama this is a big one
Timer task merge with hopes and prayers
@@ -1,4 +1,6 @@
|
||||
import { getSetupStatus } from "@/lib/setupStatus";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||
import { Session } from "@supabase/supabase-js";
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Redirect, router, Tabs } from "expo-router";
|
||||
@@ -32,6 +34,8 @@ function UseNotificationObserver() {
|
||||
export default function TabLayout() {
|
||||
const [session, SetSession] = useState<Session | null>(null)
|
||||
const [loading, SetLoading] = useState(true);
|
||||
const [setupChecked, setSetupChecked] = useState(false);
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
|
||||
UseNotificationObserver();
|
||||
|
||||
@@ -51,7 +55,29 @@ export default function TabLayout() {
|
||||
return () => sub.subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
useEffect(() => {
|
||||
const checkSetupStatus = async () => {
|
||||
if (!session?.user.id) {
|
||||
setNeedsSetup(false);
|
||||
setSetupChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const setupStatus = await getSetupStatus(session.user.id);
|
||||
setNeedsSetup(!setupStatus.isSetupComplete);
|
||||
} catch {
|
||||
setNeedsSetup(true);
|
||||
} finally {
|
||||
setSetupChecked(true);
|
||||
}
|
||||
};
|
||||
|
||||
setSetupChecked(false);
|
||||
void checkSetupStatus();
|
||||
}, [session?.user.id]);
|
||||
|
||||
if (loading || !setupChecked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -59,14 +85,34 @@ export default function TabLayout() {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
return <Redirect href="/setup" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: true,
|
||||
}}>
|
||||
<Tabs.Screen name="index" options={{title: 'Dashboard', tabBarLabel: 'Dashboard', }} />
|
||||
<Tabs.Screen name="subjects" options={{title: "Subjects"}} />
|
||||
<Tabs.Screen name="timer" options={{title: "Timer"}} />
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Dashboard',
|
||||
tabBarLabel: 'Dashboard',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<MaterialIcons name="dashboard" color={color} size={size} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="subjects"
|
||||
options={{
|
||||
title: "Subjects",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<MaterialIcons name="menu-book" color={color} size={size} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
1003
app/(tabs)/index.tsx
@@ -1,16 +1,43 @@
|
||||
import { getSetupStatus } from '@/lib/setupStatus';
|
||||
import { SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Subject } from '@/lib/types';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { router, Stack, useFocusEffect } from 'expo-router';
|
||||
import { Redirect, router, Stack, useFocusEffect } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Alert, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { Alert, Modal, Pressable, ScrollView, Text, View, ActivityIndicator } from 'react-native';
|
||||
|
||||
import type { SubjectColor } from '@/lib/subjectColors';
|
||||
|
||||
const FLOW_STEPS = [
|
||||
{
|
||||
label: '1',
|
||||
title: 'Subject',
|
||||
description: 'Start with the broad area you are studying, like one course or one exam you are preparing for.',
|
||||
},
|
||||
{
|
||||
label: '2',
|
||||
title: 'Assignment',
|
||||
description: 'Inside that, add the bigger piece of work you want to move forward, like a project, a problem set, or revision block.',
|
||||
},
|
||||
{
|
||||
label: '3',
|
||||
title: 'Task',
|
||||
description: 'Then break it down into one task that feels concrete enough to begin without overthinking it.',
|
||||
},
|
||||
{
|
||||
label: '4',
|
||||
title: 'Sprint',
|
||||
description: 'That task is what you bring into a focus session. After a sprint, you take a short pause, then come back to the same kind of focused work. After a few rounds, the app gives you a longer pause so the rhythm still feels sustainable.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function Subjects() {
|
||||
const [subjects, SetSubjects] = useState<Subject[]>([]);
|
||||
const [session, SetSession] = useState<Session | null>(null);
|
||||
const [isFlowInfoVisible, setIsFlowInfoVisible] = useState(false);
|
||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||
const [isLoading, SetIsLoading] = useState(true);
|
||||
|
||||
const activeSubjects = subjects.filter((subject) => subject.isActive);
|
||||
@@ -30,6 +57,27 @@ export default function Subjects() {
|
||||
return () => sub.subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSetupGate = async () => {
|
||||
if (!session?.user.id) {
|
||||
setNeedsSetup(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const setupStatus = await getSetupStatus(session.user.id);
|
||||
setNeedsSetup(!setupStatus.isSetupComplete);
|
||||
} catch {
|
||||
setNeedsSetup(true);
|
||||
}
|
||||
};
|
||||
|
||||
setNeedsSetup(null);
|
||||
void loadSetupGate();
|
||||
}, [session?.user.id]);
|
||||
|
||||
const GetSubjects = useCallback(async () => {
|
||||
if (!session?.user.id) return;
|
||||
const GetSubjects = async () => {
|
||||
if (!session?.user.id) {
|
||||
SetIsLoading(false);
|
||||
@@ -55,17 +103,24 @@ export default function Subjects() {
|
||||
}
|
||||
|
||||
SetSubjects((data as Subject[]) ?? []);
|
||||
}, [session?.user.id]);
|
||||
SetIsLoading(false);
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (session) {
|
||||
GetSubjects();
|
||||
void GetSubjects();
|
||||
}
|
||||
}, [session])
|
||||
}, [GetSubjects, session])
|
||||
);
|
||||
|
||||
if (session && needsSetup === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
return <Redirect href="/setup" />;
|
||||
const RenderSubjectCard = (subject: Subject) => {
|
||||
const colorKey: SubjectColor = subject.color ?? 'slate';
|
||||
const colorSet = SUBJECT_COLORS[colorKey];
|
||||
@@ -145,19 +200,119 @@ export default function Subjects() {
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Subjects',
|
||||
headerTitleAlign: 'center',
|
||||
headerLeft: () => (
|
||||
<View className="ml-3">
|
||||
<Pressable
|
||||
className="h-10.5 w-11 items-center justify-center rounded-full"
|
||||
onPress={() => setIsFlowInfoVisible(true)}
|
||||
>
|
||||
<MaterialIcons name="help" size={36} color="#52606D" />
|
||||
</Pressable>
|
||||
</View>
|
||||
),
|
||||
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="mr-3">
|
||||
<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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent
|
||||
visible={isFlowInfoVisible}
|
||||
onRequestClose={() => setIsFlowInfoVisible(false)}
|
||||
>
|
||||
<View className="flex-1 justify-center bg-[rgba(15,23,42,0.42)] px-5">
|
||||
<Pressable
|
||||
className="absolute inset-0"
|
||||
onPress={() => setIsFlowInfoVisible(false)}
|
||||
/>
|
||||
|
||||
<View className="max-h-[80%] gap-4 rounded-[28px] bg-[#FCFDFE] p-5 shadow-lg">
|
||||
<View className="flex-row items-start justify-between gap-3">
|
||||
<View>
|
||||
<Text className="text-xs font-bold uppercase tracking-[0.8px] text-[#7B8794]">
|
||||
How work is organized
|
||||
</Text>
|
||||
<Text className="mt-1 text-[28px] font-extrabold text-[#1F2933]">
|
||||
Study flow
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
className="h-9 w-9 items-center justify-center rounded-full bg-[#EFF3F8]"
|
||||
onPress={() => setIsFlowInfoVisible(false)}
|
||||
>
|
||||
<MaterialIcons name="close" size={18} color="#52606D" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text className="text-[15px] leading-[22px] text-[#52606D]">
|
||||
The idea is to make getting started feel lighter. You decide what you are studying, narrow it down to one clear task, and then let the app carry you through a simple rhythm of focus and recovery.
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
className="max-h-80"
|
||||
contentContainerStyle={{ gap: 4 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{FLOW_STEPS.map((step, index) => (
|
||||
<View key={step.title} className="flex-row gap-[14px]">
|
||||
<View className="items-center">
|
||||
<View className="h-8 w-8 items-center justify-center rounded-full bg-[#323F4E]">
|
||||
<Text className="text-[13px] font-extrabold text-white">
|
||||
{step.label}
|
||||
</Text>
|
||||
</View>
|
||||
{index < FLOW_STEPS.length - 1 ? (
|
||||
<View className="my-[6px] min-h-7 w-[2px] flex-1 bg-[#D5D9DF]" />
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="flex-1 pb-[18px]">
|
||||
<Text className="text-lg font-bold text-[#1F2933]">
|
||||
{step.title}
|
||||
</Text>
|
||||
<Text className="mt-1 text-sm leading-[21px] text-[#52606D]">
|
||||
{step.description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<View className="rounded-[18px] bg-[#F1F5F9] px-4 py-[14px]">
|
||||
<Text className="text-xs font-bold uppercase tracking-[0.8px] text-[#7B8794]">
|
||||
Quick map
|
||||
</Text>
|
||||
<Text className="mt-[6px] text-base font-bold text-[#1F2933]">
|
||||
{'Subject -> Assignment -> Task -> Sprint'}
|
||||
</Text>
|
||||
<Text className="mt-2 text-sm leading-[20px] text-[#52606D]">
|
||||
In practice, that usually becomes focus session, short pause, focus session again, and eventually a longer pause when you have done a few solid rounds.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
className="min-h-12 items-center justify-center rounded-2xl bg-[#323F4E] px-4"
|
||||
onPress={() => setIsFlowInfoVisible(false)}
|
||||
>
|
||||
<Text className="text-[15px] font-bold text-white">Close Guide</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
@@ -176,12 +331,22 @@ export default function Subjects() {
|
||||
</View>
|
||||
) : subjects.length === 0 ? (
|
||||
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||
<Text className="text-center text-xl font-bold text-text-main">
|
||||
No subjects yet
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||
Create your first subject to get started.
|
||||
<Text className="mt-2 text-center text-sm leading-5 text-text-secondary">
|
||||
Start with one subject so the rest of your study path has a clear
|
||||
place to live.
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
className="mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={() => router.push('/setup')}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
Start Guided Setup
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
@@ -237,14 +402,16 @@ export default function Subjects() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
className="mt-2 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={() => router.push('/subject/upsertSubject')}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
Create Subject
|
||||
</Text>
|
||||
</Pressable>
|
||||
{subjects.length > 0 ? (
|
||||
<Pressable
|
||||
className="mt-2 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={() => router.push('/subject/upsertSubject')}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
Create Subject
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,820 +0,0 @@
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
const colors = {
|
||||
black: '#323F4E',
|
||||
red: '#F76A6A',
|
||||
text: '#ffffff',
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
TODO
|
||||
Make timer count down even when app is un-focused or closed.
|
||||
Set const endTime = Date.now() + duration and save that to the task, maybe?
|
||||
Then trigger notif when endTime == Date.now()?
|
||||
Then fetch endTime from DB -> if null then timer is inactive
|
||||
if !null then set timer to endTime - Date.now() and start
|
||||
Might have to save duration as well in DB to preserve timer animation persistance
|
||||
*/
|
||||
|
||||
|
||||
const TIMER_OPTIONS = [...Array(13).keys()].map((index) => (index === 0 ? 1 : index * 5));
|
||||
const ITEM_SIZE = width * 0.38;
|
||||
const ITEM_SPACING = (width - ITEM_SIZE) / 2;
|
||||
const TIMER_UNIT_IN_SECONDS = 60;
|
||||
const HOLD_TO_CANCEL_MS = 2000;
|
||||
const CANCEL_ANIMATION_DELAY_MS = 250;
|
||||
const BUTTON_PRESS_IN_MS = 80;
|
||||
const BUTTON_PRESS_OUT_MS = 140;
|
||||
|
||||
const placeholderTask = {
|
||||
name: 'Read chapter 4',
|
||||
description: 'Focus on the summary questions and write down anything unclear.',
|
||||
};
|
||||
|
||||
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 TimerScreen() {
|
||||
const [containerHeight, setContainerHeight] = React.useState(0);
|
||||
const [duration, setDuration] = React.useState(TIMER_OPTIONS[0]);
|
||||
const [timerIsRunning, setIsRunning] = React.useState(false);
|
||||
const [timeRemaining, setTimeRemaining] = React.useState(0);
|
||||
|
||||
const scrollX = React.useRef(new Animated.Value(0)).current;
|
||||
const timerAnimation = React.useRef(new Animated.Value(0)).current;
|
||||
const buttonAnimation = React.useRef(new Animated.Value(0)).current;
|
||||
const taskDetailsAnimation = React.useRef(new Animated.Value(0)).current;
|
||||
const countdownAnimation = React.useRef(new Animated.Value(0)).current;
|
||||
const cancelButtonAnimation = React.useRef(new Animated.Value(0)).current;
|
||||
const pressedButtonAnimation = React.useRef(new Animated.Value(0)).current;
|
||||
const focusModeAnimation = React.useRef(new Animated.Value(0)).current;
|
||||
const cancelOverlayAnimation = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
const countdownRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const cancelHoldTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const cancelHoldAnimationDelayRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const runningAnimationRef = React.useRef<Animated.CompositeAnimation | null>(null);
|
||||
const progressAnimationRef = React.useRef<Animated.CompositeAnimation | null>(null);
|
||||
const sessionStartedAtRef = React.useRef<number | null>(null);
|
||||
const sessionDurationMsRef = React.useRef(0);
|
||||
const cancelAccelStartedRef = React.useRef(false);
|
||||
const cancelHoldActiveRef = React.useRef(false);
|
||||
const cancelHoldIdRef = React.useRef(0);
|
||||
const cancelHoldStartedAtRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (containerHeight > 0 && !timerIsRunning) {
|
||||
timerAnimation.setValue(containerHeight);
|
||||
}
|
||||
}, [containerHeight, timerIsRunning, timerAnimation]);
|
||||
|
||||
const pressedButtonScale = pressedButtonAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 0.9],
|
||||
});
|
||||
|
||||
const cancelButtonTranslateY = cancelButtonAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [16, 0],
|
||||
});
|
||||
|
||||
// Real timer progress comes from timerAnimation. The cancel hold adds a
|
||||
// temporary visual offset on top so release/cancel logic does not fight the
|
||||
// underlying progress animation.
|
||||
const timerOverlayTranslateY = Animated.add(
|
||||
timerAnimation,
|
||||
cancelOverlayAnimation
|
||||
).interpolate({
|
||||
inputRange: [0, Math.max(containerHeight, 1)],
|
||||
outputRange: [0, Math.max(containerHeight, 1)],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
const countdownTranslateX = focusModeAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, -width * 0.3],
|
||||
});
|
||||
|
||||
const countdownTranslateY = focusModeAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, -containerHeight * 0.35],
|
||||
});
|
||||
|
||||
const countdownScale = focusModeAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 0.55],
|
||||
});
|
||||
|
||||
const startButtonOpacity = buttonAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 0],
|
||||
});
|
||||
|
||||
const startButtonTranslateY = buttonAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 200],
|
||||
});
|
||||
|
||||
const pickerOpacity = buttonAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 0],
|
||||
});
|
||||
|
||||
const taskDetailsOpacity = taskDetailsAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 1],
|
||||
});
|
||||
|
||||
const taskDetailsTranslateY = taskDetailsAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [20, 0],
|
||||
});
|
||||
|
||||
const clearCountdownInterval = React.useCallback(() => {
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearCancelHoldTimeouts = React.useCallback(() => {
|
||||
if (cancelHoldTimeoutRef.current) {
|
||||
clearTimeout(cancelHoldTimeoutRef.current);
|
||||
cancelHoldTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (cancelHoldAnimationDelayRef.current) {
|
||||
clearTimeout(cancelHoldAnimationDelayRef.current);
|
||||
cancelHoldAnimationDelayRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopRunningAnimations = React.useCallback(() => {
|
||||
runningAnimationRef.current?.stop();
|
||||
runningAnimationRef.current = null;
|
||||
|
||||
progressAnimationRef.current?.stop();
|
||||
progressAnimationRef.current = null;
|
||||
|
||||
cancelOverlayAnimation.stopAnimation();
|
||||
}, [cancelOverlayAnimation]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
clearCountdownInterval();
|
||||
clearCancelHoldTimeouts();
|
||||
stopRunningAnimations();
|
||||
};
|
||||
}, [clearCancelHoldTimeouts, clearCountdownInterval, stopRunningAnimations]);
|
||||
|
||||
const animateButtonPress = React.useCallback(
|
||||
(pressed: boolean) => {
|
||||
Animated.timing(pressedButtonAnimation, {
|
||||
toValue: pressed ? 1 : 0,
|
||||
duration: pressed ? BUTTON_PRESS_IN_MS : BUTTON_PRESS_OUT_MS,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
},
|
||||
[pressedButtonAnimation]
|
||||
);
|
||||
|
||||
const resetSessionValues = React.useCallback(() => {
|
||||
sessionStartedAtRef.current = null;
|
||||
sessionDurationMsRef.current = 0;
|
||||
cancelHoldActiveRef.current = false;
|
||||
cancelAccelStartedRef.current = false;
|
||||
|
||||
timerAnimation.setValue(containerHeight);
|
||||
cancelOverlayAnimation.setValue(0);
|
||||
setTimeRemaining(0);
|
||||
setIsRunning(false);
|
||||
}, [cancelOverlayAnimation, containerHeight, timerAnimation]);
|
||||
|
||||
const finishTimer = React.useCallback(() => {
|
||||
clearCountdownInterval();
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(countdownAnimation, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(focusModeAnimation, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(taskDetailsAnimation, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(buttonAnimation, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(cancelButtonAnimation, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setIsRunning(false);
|
||||
/* TODO
|
||||
Implement store and send of ellapsed time value in seconds to DB
|
||||
for total time spent statistic
|
||||
*/
|
||||
|
||||
resetSessionValues();
|
||||
});
|
||||
});
|
||||
}, [
|
||||
buttonAnimation,
|
||||
cancelButtonAnimation,
|
||||
clearCountdownInterval,
|
||||
countdownAnimation,
|
||||
focusModeAnimation,
|
||||
resetSessionValues,
|
||||
taskDetailsAnimation,
|
||||
]);
|
||||
|
||||
// This picks up the timer overlay animation from the current Y position and
|
||||
// runs it to the bottom over the remaining session time.
|
||||
const startProgressAnimation = React.useCallback(
|
||||
(fromY: number) => {
|
||||
const elapsedRatio = fromY / containerHeight;
|
||||
const remainingMs = sessionDurationMsRef.current * (1 - elapsedRatio);
|
||||
|
||||
sessionStartedAtRef.current = Date.now() - sessionDurationMsRef.current * elapsedRatio;
|
||||
timerAnimation.setValue(fromY);
|
||||
|
||||
const progressAnimation = Animated.timing(timerAnimation, {
|
||||
toValue: containerHeight,
|
||||
duration: remainingMs,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
|
||||
progressAnimationRef.current = progressAnimation;
|
||||
progressAnimation.start(({ finished }) => {
|
||||
progressAnimationRef.current = null;
|
||||
|
||||
if (!finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finishTimer();
|
||||
});
|
||||
},
|
||||
[containerHeight, finishTimer, timerAnimation]
|
||||
);
|
||||
|
||||
const runStartSequence = React.useCallback(() => {
|
||||
const runningAnimation = Animated.sequence([
|
||||
Animated.parallel([
|
||||
Animated.timing(buttonAnimation, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(cancelButtonAnimation, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(countdownAnimation, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(timerAnimation, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
Animated.timing(focusModeAnimation, {
|
||||
toValue: 1,
|
||||
duration: 450,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(taskDetailsAnimation, {
|
||||
toValue: 1,
|
||||
duration: 500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
runningAnimationRef.current = runningAnimation;
|
||||
runningAnimation.start(({ finished }) => {
|
||||
runningAnimationRef.current = null;
|
||||
|
||||
if (!finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
startProgressAnimation(0);
|
||||
});
|
||||
}, [
|
||||
buttonAnimation,
|
||||
cancelButtonAnimation,
|
||||
countdownAnimation,
|
||||
focusModeAnimation,
|
||||
startProgressAnimation,
|
||||
taskDetailsAnimation,
|
||||
timerAnimation,
|
||||
]);
|
||||
|
||||
const startCountdown = React.useCallback(
|
||||
(totalSeconds: number) => {
|
||||
setTimeRemaining(totalSeconds);
|
||||
clearCountdownInterval();
|
||||
|
||||
countdownRef.current = setInterval(() => {
|
||||
setTimeRemaining((currentTime) => {
|
||||
if (currentTime <= 1) {
|
||||
clearCountdownInterval();
|
||||
return 0;
|
||||
}
|
||||
|
||||
return currentTime - 1;
|
||||
});
|
||||
}, 1000);
|
||||
},
|
||||
[clearCountdownInterval]
|
||||
);
|
||||
|
||||
const startTimerSession = React.useCallback(() => {
|
||||
if (timerIsRunning || containerHeight === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setIsRunning(true);
|
||||
|
||||
taskDetailsAnimation.setValue(0);
|
||||
countdownAnimation.setValue(0);
|
||||
cancelOverlayAnimation.setValue(0);
|
||||
|
||||
const totalSeconds = duration * TIMER_UNIT_IN_SECONDS;
|
||||
sessionStartedAtRef.current = Date.now();
|
||||
sessionDurationMsRef.current = totalSeconds * 1000;
|
||||
|
||||
startCountdown(totalSeconds);
|
||||
runStartSequence();
|
||||
}, [
|
||||
cancelOverlayAnimation,
|
||||
containerHeight,
|
||||
countdownAnimation,
|
||||
duration,
|
||||
runStartSequence,
|
||||
startCountdown,
|
||||
taskDetailsAnimation,
|
||||
timerIsRunning,
|
||||
]);
|
||||
|
||||
const cancelTimer = React.useCallback(() => {
|
||||
if (!timerIsRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearCountdownInterval();
|
||||
clearCancelHoldTimeouts();
|
||||
stopRunningAnimations();
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(cancelButtonAnimation, {
|
||||
toValue: 0,
|
||||
duration: 180,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(taskDetailsAnimation, {
|
||||
toValue: 0,
|
||||
duration: 220,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(focusModeAnimation, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(countdownAnimation, {
|
||||
toValue: 0,
|
||||
duration: 180,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(timerAnimation, {
|
||||
toValue: containerHeight,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(cancelOverlayAnimation, {
|
||||
toValue: 0,
|
||||
duration: 120,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
Animated.timing(buttonAnimation, {
|
||||
toValue: 0,
|
||||
duration: 220,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
resetSessionValues();
|
||||
});
|
||||
});
|
||||
}, [
|
||||
buttonAnimation,
|
||||
cancelButtonAnimation,
|
||||
cancelOverlayAnimation,
|
||||
clearCancelHoldTimeouts,
|
||||
clearCountdownInterval,
|
||||
containerHeight,
|
||||
countdownAnimation,
|
||||
focusModeAnimation,
|
||||
resetSessionValues,
|
||||
stopRunningAnimations,
|
||||
taskDetailsAnimation,
|
||||
timerAnimation,
|
||||
timerIsRunning,
|
||||
]);
|
||||
|
||||
const handleCancelHoldStart = React.useCallback(() => {
|
||||
animateButtonPress(true);
|
||||
cancelHoldIdRef.current += 1;
|
||||
|
||||
const cancelHoldId = cancelHoldIdRef.current;
|
||||
cancelHoldActiveRef.current = true;
|
||||
cancelHoldStartedAtRef.current = Date.now();
|
||||
cancelAccelStartedRef.current = false;
|
||||
|
||||
cancelHoldAnimationDelayRef.current = setTimeout(() => {
|
||||
cancelHoldAnimationDelayRef.current = null;
|
||||
|
||||
if (!cancelHoldActiveRef.current || cancelHoldIdRef.current !== cancelHoldId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The hold starts with normal button feedback. After a short delay, we
|
||||
// begin the accelerated red overlay preview so quick taps do not cause a
|
||||
// jolt, while long holds still clearly show that cancel is about to fire.
|
||||
cancelAccelStartedRef.current = true;
|
||||
cancelOverlayAnimation.setValue(0);
|
||||
|
||||
const elapsedHoldMs = Date.now() - cancelHoldStartedAtRef.current;
|
||||
const remainingHoldMs = Math.max(1, HOLD_TO_CANCEL_MS - elapsedHoldMs);
|
||||
const sessionStartedAt = sessionStartedAtRef.current ?? Date.now();
|
||||
const elapsedAtCancelMs = Date.now() + remainingHoldMs - sessionStartedAt;
|
||||
const expectedProgress = elapsedAtCancelMs / sessionDurationMsRef.current;
|
||||
const clampedProgress = Math.max(0, Math.min(expectedProgress, 1));
|
||||
const expectedYAtCancel = containerHeight * clampedProgress;
|
||||
const cancelOffset = Math.max(0, containerHeight - expectedYAtCancel);
|
||||
|
||||
Animated.timing(cancelOverlayAnimation, {
|
||||
toValue: cancelOffset,
|
||||
duration: remainingHoldMs,
|
||||
easing: Easing.in(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, CANCEL_ANIMATION_DELAY_MS);
|
||||
|
||||
cancelHoldTimeoutRef.current = setTimeout(() => {
|
||||
cancelHoldActiveRef.current = false;
|
||||
cancelHoldIdRef.current += 1;
|
||||
cancelAccelStartedRef.current = false;
|
||||
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
cancelTimer();
|
||||
cancelHoldTimeoutRef.current = null;
|
||||
}, HOLD_TO_CANCEL_MS);
|
||||
}, [animateButtonPress, cancelOverlayAnimation, cancelTimer, containerHeight]);
|
||||
|
||||
const handleCancelHoldEnd = React.useCallback(() => {
|
||||
animateButtonPress(false);
|
||||
cancelHoldActiveRef.current = false;
|
||||
cancelHoldIdRef.current += 1;
|
||||
|
||||
clearCancelHoldTimeouts();
|
||||
|
||||
if (!cancelAccelStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAccelStartedRef.current = false;
|
||||
cancelOverlayAnimation.stopAnimation((currentOffset) => {
|
||||
cancelOverlayAnimation.setValue(currentOffset);
|
||||
Animated.timing(cancelOverlayAnimation, {
|
||||
toValue: 0,
|
||||
duration: 750,
|
||||
easing: Easing.in(Easing.bounce),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
});
|
||||
}, [animateButtonPress, cancelOverlayAnimation, clearCancelHoldTimeouts]);
|
||||
|
||||
const handleTimerPickerMomentumEnd = React.useCallback(
|
||||
(event: { nativeEvent: { contentOffset: { x: number } } }) => {
|
||||
if (timerIsRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = Math.round(event.nativeEvent.contentOffset.x / ITEM_SIZE);
|
||||
const clampedIndex = Math.max(0, Math.min(index, TIMER_OPTIONS.length - 1));
|
||||
setDuration(TIMER_OPTIONS[clampedIndex]);
|
||||
},
|
||||
[timerIsRunning]
|
||||
);
|
||||
|
||||
const renderTimerItem = React.useCallback(
|
||||
({ item, index }: { item: number; index: number }) => {
|
||||
const inputRange = [
|
||||
(index - 1) * ITEM_SIZE,
|
||||
index * ITEM_SIZE,
|
||||
(index + 1) * ITEM_SIZE,
|
||||
];
|
||||
|
||||
const baseOpacity = scrollX.interpolate({
|
||||
inputRange,
|
||||
outputRange: [0.4, 1, 0.4],
|
||||
});
|
||||
|
||||
const opacity = Animated.multiply(baseOpacity, pickerOpacity);
|
||||
const scale = scrollX.interpolate({
|
||||
inputRange,
|
||||
outputRange: [0.7, 1, 0.7],
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.timerOptionItem}>
|
||||
<Animated.Text
|
||||
style={[
|
||||
styles.text,
|
||||
{
|
||||
opacity,
|
||||
transform: [{ scale }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{item}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[pickerOpacity, scrollX]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
onLayout={(event) => {
|
||||
setContainerHeight(event.nativeEvent.layout.height);
|
||||
}}
|
||||
>
|
||||
<StatusBar hidden />
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
styles.timerOverlay,
|
||||
{
|
||||
height: containerHeight,
|
||||
width,
|
||||
transform: [{ translateY: timerOverlayTranslateY }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
styles.startButtonContainer,
|
||||
{
|
||||
opacity: startButtonOpacity,
|
||||
transform: [{ translateY: startButtonTranslateY }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
disabled={timerIsRunning}
|
||||
onPress={startTimerSession}
|
||||
onPressIn={() => animateButtonPress(true)}
|
||||
onPressOut={() => animateButtonPress(false)}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.roundButton,
|
||||
{
|
||||
transform: [{ scale: pressedButtonScale }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text className="text-text-main text-xl">Start</Text>
|
||||
<Text className="text-text-main text-xl">Sprint</Text>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
pointerEvents={timerIsRunning ? 'auto' : 'none'}
|
||||
style={[
|
||||
styles.cancelButtonContainer,
|
||||
{
|
||||
opacity: cancelButtonAnimation,
|
||||
transform: [{ translateY: cancelButtonTranslateY }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity onPressIn={handleCancelHoldStart} onPressOut={handleCancelHoldEnd}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.cancelButton,
|
||||
{
|
||||
transform: [{ scale: pressedButtonScale }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text className="text-text-main text-xl">Hold to end sprint</Text>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
styles.countdownOverlay,
|
||||
{
|
||||
opacity: countdownAnimation,
|
||||
transform: [
|
||||
{ translateX: countdownTranslateX },
|
||||
{ translateY: countdownTranslateY },
|
||||
{ scale: countdownScale },
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.countdownText}>{formatTime(timeRemaining)}</Text>
|
||||
</Animated.View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.timerPickerWrapper,
|
||||
{
|
||||
top: containerHeight / 3,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Animated.FlatList
|
||||
data={TIMER_OPTIONS}
|
||||
scrollEnabled={!timerIsRunning}
|
||||
keyExtractor={(item) => item.toString()}
|
||||
horizontal
|
||||
bounces={false}
|
||||
onScroll={Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], {
|
||||
useNativeDriver: true,
|
||||
})}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={handleTimerPickerMomentumEnd}
|
||||
snapToInterval={ITEM_SIZE}
|
||||
decelerationRate="fast"
|
||||
style={styles.timerPickerList}
|
||||
contentContainerStyle={styles.timerPickerContent}
|
||||
renderItem={renderTimerItem}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
styles.taskDetails,
|
||||
{
|
||||
opacity: taskDetailsOpacity,
|
||||
transform: [{ translateY: taskDetailsTranslateY }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.taskName}>{placeholderTask.name}</Text>
|
||||
<Text style={styles.taskDescription}>{placeholderTask.description}</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.black,
|
||||
},
|
||||
timerOverlay: {
|
||||
backgroundColor: colors.red,
|
||||
},
|
||||
startButtonContainer: {
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
paddingBottom: 100,
|
||||
},
|
||||
roundButton: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 80,
|
||||
backgroundColor: '#beb9a7',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
timerPickerWrapper: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
flex: 1,
|
||||
},
|
||||
timerPickerList: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
timerPickerContent: {
|
||||
paddingHorizontal: ITEM_SPACING,
|
||||
},
|
||||
timerOptionItem: {
|
||||
width: ITEM_SIZE,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
fontSize: ITEM_SIZE * 0.8,
|
||||
fontFamily: 'Menlo',
|
||||
color: colors.text,
|
||||
fontWeight: '900',
|
||||
},
|
||||
taskDetails: {
|
||||
position: 'absolute',
|
||||
top: height * 0.34,
|
||||
left: 32,
|
||||
right: 32,
|
||||
alignItems: 'center',
|
||||
},
|
||||
taskName: {
|
||||
color: colors.text,
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
textAlign: 'center',
|
||||
},
|
||||
taskDescription: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
lineHeight: 32,
|
||||
marginTop: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
countdownText: {
|
||||
fontSize: ITEM_SIZE * 0.32,
|
||||
fontFamily: 'Menlo',
|
||||
color: colors.text,
|
||||
fontWeight: '900',
|
||||
textAlign: 'center',
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 44,
|
||||
alignItems: 'center',
|
||||
zIndex: 2,
|
||||
},
|
||||
cancelButton: {
|
||||
minWidth: 112,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(155, 155, 155, 0.35)',
|
||||
backgroundColor: '#beb9a7',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 22,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
countdownOverlay: {
|
||||
position: 'absolute',
|
||||
top: height / 3,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
@@ -5,11 +5,12 @@ export default function RootLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="setup" options={{ headerShown: true }} />
|
||||
<Stack.Screen name="subject" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="assignment" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="task" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="createUser" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="createUser" options={{ headerShown: true, title: "" }} />
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
export default function UpsertAssignment() {
|
||||
const { aId, sId: routeSId } = useLocalSearchParams<{
|
||||
const { aId, sId: routeSId, flow } = useLocalSearchParams<{
|
||||
aId?: string;
|
||||
sId?: string;
|
||||
flow?: string;
|
||||
}>();
|
||||
|
||||
const isEditMode = Boolean(aId);
|
||||
const isSetupFlow = flow === 'setup';
|
||||
|
||||
const [title, SetTitle] = useState('');
|
||||
const [description, SetDescription] = useState('');
|
||||
@@ -195,6 +197,17 @@ export default function UpsertAssignment() {
|
||||
|
||||
SetIsSaving(false);
|
||||
|
||||
if (!isEditMode && isSetupFlow) {
|
||||
router.replace({
|
||||
pathname: '/task/upsertTask',
|
||||
params: {
|
||||
aId: savedAssignment.aId,
|
||||
flow: 'setup',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
isEditMode
|
||||
? 'Assignment successfully updated!'
|
||||
@@ -223,6 +236,7 @@ export default function UpsertAssignment() {
|
||||
options={{
|
||||
title: isEditMode ? 'Edit Assignment' : 'Create Assignment',
|
||||
headerTitleStyle: defaultStyles.title,
|
||||
headerTitleAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -258,7 +272,9 @@ export default function UpsertAssignment() {
|
||||
<TextInput
|
||||
testID = "assignment-title-input"
|
||||
className={inputClassName}
|
||||
placeholder="Enter assignment title"
|
||||
placeholder={
|
||||
isSetupFlow ? 'e.g. Weekly problem set 3' : 'Enter assignment title'
|
||||
}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={title}
|
||||
onChangeText={SetTitle}
|
||||
@@ -270,7 +286,11 @@ export default function UpsertAssignment() {
|
||||
<Text className={labelClassName}>Description</Text>
|
||||
<TextInput
|
||||
className={`${inputClassName} min-h-28`}
|
||||
placeholder="Add a short description"
|
||||
placeholder={
|
||||
isSetupFlow
|
||||
? 'e.g. Finish the next exercise set before Friday'
|
||||
: 'Add a short description'
|
||||
}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={description}
|
||||
onChangeText={SetDescription}
|
||||
@@ -283,7 +303,7 @@ export default function UpsertAssignment() {
|
||||
<Text className={labelClassName}>Deadline</Text>
|
||||
<TextInput
|
||||
className={inputClassName}
|
||||
placeholder="YYYY-MM-DD"
|
||||
placeholder={isSetupFlow ? 'e.g. 2026-05-14' : 'YYYY-MM-DD'}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={deadline}
|
||||
onChangeText={SetDeadline}
|
||||
|
||||
@@ -229,6 +229,7 @@ export default function ViewDetailsAssignment() {
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Details',
|
||||
headerTitleAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -326,6 +327,37 @@ export default function ViewDetailsAssignment() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-5">
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Tasks completed
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm font-bold text-text-main">
|
||||
{completedTasks}/{totalTasks}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="h-3 overflow-hidden rounded-full bg-app-subtle">
|
||||
<View
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: colorSet.strong,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="mt-2 text-xs font-medium text-text-secondary">
|
||||
{remainingTasks === 0
|
||||
? 'All tasks complete'
|
||||
: `${remainingTasks} task${remainingTasks === 1 ? '' : 's'} remaining`}
|
||||
</Text>
|
||||
|
||||
<Text className="mt-1 text-xs text-text-muted">
|
||||
Based only on completed tasks in this assignment.
|
||||
</Text>
|
||||
</View>
|
||||
{totalTasks > 0 ? (
|
||||
<View className="mt-5">
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
@@ -514,7 +546,9 @@ export default function ViewDetailsAssignment() {
|
||||
{section.emptyMessage}
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||
Tasks for this assignment will show up here.
|
||||
{tasks.length === 0
|
||||
? 'Create the first task so this assignment turns into one clear next action.'
|
||||
: 'Tasks for this assignment will show up here.'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
KeyboardEvent,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -18,6 +20,48 @@ export default function CreateUser() {
|
||||
const [email, SetEmail] = useState('');
|
||||
const [password, SetPassword] = useState('');
|
||||
const [isLoading, SetIsLoading] = useState(false);
|
||||
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const cardLift = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
|
||||
const handleKeyboardShow = (event: KeyboardEvent) => {
|
||||
setIsKeyboardVisible(true);
|
||||
|
||||
const keyboardHeight = event.endCoordinates.height;
|
||||
const liftAmount = Math.min(
|
||||
Platform.OS === 'ios' ? keyboardHeight * 0.5 : keyboardHeight * 0.6,
|
||||
260
|
||||
);
|
||||
|
||||
Animated.timing(cardLift, {
|
||||
toValue: -liftAmount,
|
||||
duration: event.duration ?? 220,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const handleKeyboardHide = () => {
|
||||
setIsKeyboardVisible(false);
|
||||
|
||||
Animated.timing(cardLift, {
|
||||
toValue: 0,
|
||||
duration: 220,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const showSubscription = Keyboard.addListener(showEvent, handleKeyboardShow);
|
||||
const hideSubscription = Keyboard.addListener(hideEvent, handleKeyboardHide);
|
||||
|
||||
return () => {
|
||||
showSubscription.remove();
|
||||
hideSubscription.remove();
|
||||
};
|
||||
}, [cardLift]);
|
||||
|
||||
const SignUp = async () => {
|
||||
if (email.trim() === '' || password.trim() === '') {
|
||||
@@ -47,7 +91,7 @@ export default function CreateUser() {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
router.replace('/');
|
||||
router.replace('/setup');
|
||||
};
|
||||
|
||||
const inputClassName =
|
||||
@@ -57,90 +101,113 @@ export default function CreateUser() {
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1 bg-app-bg"
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 24}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
className="flex-1"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
justifyContent: isKeyboardVisible ? 'flex-start' : 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 64,
|
||||
paddingBottom: 32,
|
||||
paddingTop: isKeyboardVisible ? 24 : 64,
|
||||
paddingBottom: isKeyboardVisible ? 96 : 32,
|
||||
}}
|
||||
>
|
||||
<View className="mb-10">
|
||||
<Text className="mt-5 text-4xl font-bold text-text-main">
|
||||
Study Sprint
|
||||
</Text>
|
||||
|
||||
<Text className="mt-3 text-base leading-6 text-text-secondary">
|
||||
Organize subjects, assignments, and tasks in one calm workflow.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-2xl font-bold text-text-main">
|
||||
Create account
|
||||
</Text>
|
||||
<Text className="mt-2 text-sm leading-5 text-text-secondary">
|
||||
Start your next study sprint.
|
||||
</Text>
|
||||
|
||||
<View className="mt-6 mb-5">
|
||||
<Text className="mb-2 text-sm font-semibold text-text-secondary">
|
||||
Email
|
||||
<Animated.View style={{ transform: [{ translateY: cardLift }] }}>
|
||||
<View className="mb-10">
|
||||
<Text className="mt-5 text-4xl font-bold text-text-main">
|
||||
Study Sprint
|
||||
</Text>
|
||||
|
||||
<Text className="mt-3 text-base leading-6 text-text-secondary">
|
||||
Organize subjects, assignments, and tasks in one calm workflow.
|
||||
</Text>
|
||||
<TextInput
|
||||
className={inputClassName}
|
||||
placeholder="you@example.com"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
value={email}
|
||||
onChangeText={SetEmail}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-sm font-semibold text-text-secondary">
|
||||
Password
|
||||
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-2xl font-bold text-text-main">
|
||||
Create account
|
||||
</Text>
|
||||
<TextInput
|
||||
className={inputClassName}
|
||||
placeholder="Create a password"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={SetPassword}
|
||||
/>
|
||||
<Text className="mt-2 text-sm leading-5 text-text-secondary">
|
||||
Start your next study sprint.
|
||||
</Text>
|
||||
|
||||
<View className="mt-5 rounded-2xl border border-app-border bg-app-subtle p-4">
|
||||
<Text className="text-sm font-bold text-text-main">
|
||||
What this app does
|
||||
</Text>
|
||||
<Text className="mt-1 text-sm leading-5 text-text-secondary">
|
||||
Study Sprint helps you move from subject to assignment to task,
|
||||
then into a focused sprint.
|
||||
</Text>
|
||||
<Text className="mt-3 text-sm font-bold text-text-main">
|
||||
Why an account exists
|
||||
</Text>
|
||||
<Text className="mt-1 text-sm leading-5 text-text-secondary">
|
||||
Your account keeps that structure and your tracked study
|
||||
progress attached to you.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="mt-6 mb-5">
|
||||
<Text className="mb-2 text-sm font-semibold text-text-secondary">
|
||||
Email
|
||||
</Text>
|
||||
<TextInput
|
||||
className={inputClassName}
|
||||
placeholder="you@example.com"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
value={email}
|
||||
onChangeText={SetEmail}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-sm font-semibold text-text-secondary">
|
||||
Password
|
||||
</Text>
|
||||
<TextInput
|
||||
className={inputClassName}
|
||||
placeholder="Create a password so your progress follows you"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={SetPassword}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
className={`h-14 items-center justify-center rounded-2xl ${
|
||||
isLoading ? 'bg-accent-disabled' : 'bg-accent'
|
||||
}`}
|
||||
onPress={SignUp}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
className="mt-4 h-12 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
|
||||
onPress={() => router.push('/login')}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Already have an account? Log in
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
className={`h-14 items-center justify-center rounded-2xl ${
|
||||
isLoading ? 'bg-accent-disabled' : 'bg-accent'
|
||||
}`}
|
||||
onPress={SignUp}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
className="mt-4 h-12 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
|
||||
onPress={() => router.push('/login')}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Already have an account? Log in
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,46 @@
|
||||
import { getSetupStatus } from "@/lib/setupStatus";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Alert, Keyboard, KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, TextInput, TouchableWithoutFeedback, View } from "react-native";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Keyboard, KeyboardAvoidingView, KeyboardEvent, Platform, Pressable, ScrollView, Text, TextInput, TouchableWithoutFeedback, View } from "react-native";
|
||||
|
||||
export default function Login() {
|
||||
const [email, SetEmail] = useState('');
|
||||
const [password, SetPassword] = useState('');
|
||||
const [isLoading, SetIsLoading] = useState(false);
|
||||
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
|
||||
const handleKeyboardShow = (event: KeyboardEvent) => {
|
||||
setIsKeyboardVisible(true);
|
||||
|
||||
const keyboardHeight = event.endCoordinates.height;
|
||||
const offsetBaseline = Platform.OS === 'ios' ? 180 : 140;
|
||||
const nextScrollOffset = Math.max(0, keyboardHeight - offsetBaseline);
|
||||
|
||||
scrollViewRef.current?.scrollTo({
|
||||
y: nextScrollOffset,
|
||||
animated: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyboardHide = () => {
|
||||
setIsKeyboardVisible(false);
|
||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
};
|
||||
|
||||
const showSubscription = Keyboard.addListener(showEvent, handleKeyboardShow);
|
||||
const hideSubscription = Keyboard.addListener(hideEvent, handleKeyboardHide);
|
||||
|
||||
return () => {
|
||||
showSubscription.remove();
|
||||
hideSubscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = async () => {
|
||||
if(email.trim() === '' || password.trim() === '') {
|
||||
@@ -16,7 +50,7 @@ export default function Login() {
|
||||
|
||||
SetIsLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
@@ -28,7 +62,17 @@ export default function Login() {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace("/");
|
||||
if (!data.user?.id) {
|
||||
Alert.alert("Login failed, missing user session after sign-in");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const setupStatus = await getSetupStatus(data.user.id);
|
||||
router.replace(setupStatus.isSetupComplete ? "/" : "/setup");
|
||||
} catch {
|
||||
router.replace("/setup");
|
||||
}
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
@@ -38,17 +82,21 @@ export default function Login() {
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1 bg-app-bg"
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 24}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
className="flex-1"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
justifyContent: isKeyboardVisible ? 'flex-start' : 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 64,
|
||||
paddingBottom: 32,
|
||||
paddingTop: isKeyboardVisible ? 24 : 64,
|
||||
paddingBottom: isKeyboardVisible ? 96 : 32,
|
||||
}}
|
||||
>
|
||||
<View className="mb-10">
|
||||
@@ -70,6 +118,16 @@ export default function Login() {
|
||||
Continue your study workflow.
|
||||
</Text>
|
||||
|
||||
<View className="mt-5 rounded-2xl border border-app-border bg-app-subtle p-4">
|
||||
<Text className="text-sm font-bold text-text-main">
|
||||
Your study path stays with your account
|
||||
</Text>
|
||||
<Text className="mt-1 text-sm leading-5 text-text-secondary">
|
||||
Subjects, assignments, tasks, and tracked sprint progress follow
|
||||
you after you sign in.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="mb-5 mt-6">
|
||||
<Text className="mb-2 text-sm font-semibold text-text-secondary">
|
||||
Email
|
||||
@@ -97,6 +155,9 @@ export default function Login() {
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={SetPassword}
|
||||
onFocus={() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -125,4 +186,4 @@ export default function Login() {
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
349
app/setup.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
GetActiveSession,
|
||||
GetSetupSprintDemoUsed,
|
||||
SaveSetupSprintDemoUsed,
|
||||
type ActiveSession,
|
||||
} from '@/lib/asyncStorage';
|
||||
import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults';
|
||||
import { getSetupStatus, type SetupStepKey } from '@/lib/setupStatus';
|
||||
import { finalizeStoredSession } from '@/lib/sessionLifecycle';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { Redirect, Stack, router, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
type SetupState = {
|
||||
subjectId: string | null;
|
||||
assignmentId: string | null;
|
||||
taskId: string | null;
|
||||
completedFocusSessions: number;
|
||||
};
|
||||
|
||||
const SETUP_STEPS = [
|
||||
{
|
||||
key: 'subject',
|
||||
title: 'Create your first subject',
|
||||
description:
|
||||
'Start with one course or study area so the rest of the structure has a clear home.',
|
||||
},
|
||||
{
|
||||
key: 'assignment',
|
||||
title: 'Create your first assignment',
|
||||
description:
|
||||
'Add one project, exercise set, or exam-prep block inside that subject.',
|
||||
},
|
||||
{
|
||||
key: 'task',
|
||||
title: 'Create your first task',
|
||||
description:
|
||||
'Break the assignment into one concrete thing you can actually sit down and do.',
|
||||
},
|
||||
{
|
||||
key: 'sprint',
|
||||
title: 'Start your first sprint',
|
||||
description:
|
||||
'Begin one focused study session so the app immediately turns into action instead of setup.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function SetupScreen() {
|
||||
const {
|
||||
subjectId: subjectIdParam,
|
||||
assignmentId: assignmentIdParam,
|
||||
taskId: taskIdParam,
|
||||
} = useLocalSearchParams<{
|
||||
subjectId?: string;
|
||||
assignmentId?: string;
|
||||
taskId?: string;
|
||||
}>();
|
||||
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [isAuthLoading, setIsAuthLoading] = useState(true);
|
||||
const [setupState, setSetupState] = useState<SetupState>({
|
||||
subjectId: subjectIdParam ?? null,
|
||||
assignmentId: assignmentIdParam ?? null,
|
||||
taskId: taskIdParam ?? null,
|
||||
completedFocusSessions: 0,
|
||||
});
|
||||
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
setSession(data.session ?? null);
|
||||
setIsAuthLoading(false);
|
||||
});
|
||||
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||
setSession(newSession);
|
||||
setIsAuthLoading(false);
|
||||
});
|
||||
|
||||
return () => sub.subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const loadSetupState = useCallback(async () => {
|
||||
if (!session?.user.id) {
|
||||
setSetupState({
|
||||
subjectId: null,
|
||||
assignmentId: null,
|
||||
taskId: null,
|
||||
completedFocusSessions: 0,
|
||||
});
|
||||
setActiveSession(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const [storedActiveSession, status] = await Promise.all([
|
||||
GetActiveSession(),
|
||||
getSetupStatus(session.user.id),
|
||||
]);
|
||||
|
||||
if (storedActiveSession && storedActiveSession.endTime <= Date.now()) {
|
||||
await finalizeStoredSession('expired', storedActiveSession);
|
||||
setActiveSession(null);
|
||||
} else {
|
||||
setActiveSession(storedActiveSession);
|
||||
}
|
||||
|
||||
setSetupState({
|
||||
subjectId: subjectIdParam ?? status.subjectId,
|
||||
assignmentId: assignmentIdParam ?? status.assignmentId,
|
||||
taskId: taskIdParam ?? status.taskId,
|
||||
completedFocusSessions: status.completedFocusSessions,
|
||||
});
|
||||
}, [assignmentIdParam, session?.user.id, subjectIdParam, taskIdParam]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void loadSetupState();
|
||||
}, [loadSetupState])
|
||||
);
|
||||
|
||||
const currentStep: SetupStepKey = (() => {
|
||||
if (!setupState.subjectId) {
|
||||
return 'subject';
|
||||
}
|
||||
|
||||
if (!setupState.assignmentId) {
|
||||
return 'assignment';
|
||||
}
|
||||
|
||||
if (!setupState.taskId) {
|
||||
return 'task';
|
||||
}
|
||||
|
||||
return 'sprint';
|
||||
})();
|
||||
|
||||
const isSetupComplete =
|
||||
setupState.taskId !== null && setupState.completedFocusSessions > 0;
|
||||
|
||||
const handlePrimaryAction = useCallback(async () => {
|
||||
if (isSetupComplete) {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep === 'subject') {
|
||||
router.push({
|
||||
pathname: '/subject/upsertSubject',
|
||||
params: { flow: 'setup' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep === 'assignment' && setupState.subjectId) {
|
||||
router.push({
|
||||
pathname: '/assignment/upsertAssignment',
|
||||
params: {
|
||||
sId: setupState.subjectId,
|
||||
flow: 'setup',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep === 'task' && setupState.assignmentId) {
|
||||
router.push({
|
||||
pathname: '/task/upsertTask',
|
||||
params: {
|
||||
aId: setupState.assignmentId,
|
||||
flow: 'setup',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!setupState.taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshActiveSession = await GetActiveSession();
|
||||
|
||||
if (freshActiveSession && freshActiveSession.endTime > Date.now()) {
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: freshActiveSession.taskId
|
||||
? { tId: freshActiveSession.taskId }
|
||||
: {
|
||||
sessionType: freshActiveSession.sessionType,
|
||||
durationMinutes: String(
|
||||
Math.max(1, Math.round(freshActiveSession.durationSeconds / 60))
|
||||
),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (freshActiveSession) {
|
||||
await finalizeStoredSession('expired', freshActiveSession);
|
||||
setActiveSession(null);
|
||||
}
|
||||
|
||||
const shouldUseDemoSprint = session?.user.id
|
||||
? !(await GetSetupSprintDemoUsed(session.user.id))
|
||||
: false;
|
||||
|
||||
if (shouldUseDemoSprint && session?.user.id) {
|
||||
await SaveSetupSprintDemoUsed(session.user.id);
|
||||
}
|
||||
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: shouldUseDemoSprint
|
||||
? {
|
||||
tId: setupState.taskId,
|
||||
durationSeconds: '5',
|
||||
onboardingDemo: 'true',
|
||||
}
|
||||
: {
|
||||
tId: setupState.taskId,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
},
|
||||
});
|
||||
}, [currentStep, isSetupComplete, session?.user.id, setupState]);
|
||||
|
||||
const primaryLabel = isSetupComplete
|
||||
? 'Go to dashboard'
|
||||
: currentStep === 'subject'
|
||||
? 'Create first subject'
|
||||
: currentStep === 'assignment'
|
||||
? 'Create first assignment'
|
||||
: currentStep === 'task'
|
||||
? 'Create first task'
|
||||
: activeSession
|
||||
? 'Open active sprint'
|
||||
: 'Start first sprint';
|
||||
|
||||
if (isAuthLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Guided Setup',
|
||||
headerTitleAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 32,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-xs font-bold uppercase tracking-[0.8px] text-text-muted">
|
||||
First-time setup
|
||||
</Text>
|
||||
<Text className="mt-2 text-3xl font-bold text-text-main">
|
||||
Build one simple study path
|
||||
</Text>
|
||||
<Text className="mt-3 text-base leading-6 text-text-secondary">
|
||||
You only need one subject, one assignment, one task, and one sprint to
|
||||
make the app useful.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="mt-6 gap-3">
|
||||
{SETUP_STEPS.map((step, index) => {
|
||||
const isDone =
|
||||
step.key === 'subject'
|
||||
? Boolean(setupState.subjectId)
|
||||
: step.key === 'assignment'
|
||||
? Boolean(setupState.assignmentId)
|
||||
: step.key === 'task'
|
||||
? Boolean(setupState.taskId)
|
||||
: isSetupComplete;
|
||||
const isCurrent = !isDone && currentStep === step.key;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={step.key}
|
||||
className={`rounded-3xl border p-4 ${
|
||||
isCurrent
|
||||
? 'border-accent bg-accent-soft'
|
||||
: 'border-app-border bg-app-surface'
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-start">
|
||||
<View
|
||||
className={`mr-3 h-8 w-8 items-center justify-center rounded-full ${
|
||||
isDone ? 'bg-accent' : isCurrent ? 'bg-text-main' : 'bg-app-subtle'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-sm font-bold ${
|
||||
isDone || isCurrent ? 'text-text-inverse' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{isDone ? '✓' : index + 1}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className="text-lg font-bold text-text-main">
|
||||
{step.title}
|
||||
</Text>
|
||||
<Text className="mt-1 text-sm leading-5 text-text-secondary">
|
||||
{step.description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<View className="mt-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
{isSetupComplete
|
||||
? 'You have already completed at least one focus sprint.'
|
||||
: currentStep === 'sprint'
|
||||
? 'The structure is ready. The next step is to actually begin a sprint.'
|
||||
: 'Follow the next step below. The rest of the app will make more sense once that path exists.'}
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
className="mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={handlePrimaryAction}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
{primaryLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -20,8 +20,9 @@ import {
|
||||
|
||||
|
||||
export default function UpsertSubject() {
|
||||
const { sId } = useLocalSearchParams<{ sId?: string }>();
|
||||
const { sId, flow } = useLocalSearchParams<{ sId?: string; flow?: string }>();
|
||||
const isEditMode = Boolean(sId);
|
||||
const isSetupFlow = flow === 'setup';
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -88,19 +89,30 @@ export default function UpsertSubject() {
|
||||
|
||||
const result = isEditMode && sId
|
||||
? await supabase.from('subjects').update(payload).eq('sId', sId)
|
||||
: await supabase.from('subjects').insert(payload);
|
||||
: await supabase.from('subjects').insert(payload).select().single();
|
||||
|
||||
setIsSaving(false);
|
||||
|
||||
if(result.error) {
|
||||
Alert.alert(
|
||||
isEditMode
|
||||
isEditMode
|
||||
? 'Subject could not be updated, please try again'
|
||||
: 'Subject could not be created, please try again'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditMode && isSetupFlow && result.data?.sId) {
|
||||
router.replace({
|
||||
pathname: '/assignment/upsertAssignment',
|
||||
params: {
|
||||
sId: result.data.sId,
|
||||
flow: 'setup',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
isEditMode ? 'Subject updated successfully!' : 'Subject created successfully!'
|
||||
);
|
||||
@@ -129,6 +141,7 @@ export default function UpsertSubject() {
|
||||
options= {{
|
||||
title: isEditMode ? 'Edit Subject' : 'Create Subject',
|
||||
headerTitleStyle: defaultStyles.title,
|
||||
headerTitleAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -153,7 +166,7 @@ export default function UpsertSubject() {
|
||||
</Text>
|
||||
<Text className="mt-2 text-base leading-6 text-text-secondary">
|
||||
{isEditMode? ' Update this subject and keep your study structure organized.'
|
||||
: 'Add a subject to organize your assignments and studyt tasks.'}
|
||||
: 'Add a subject to organize your assignments and study tasks.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -161,8 +174,8 @@ export default function UpsertSubject() {
|
||||
<View className="mb-5">
|
||||
<Text className={labelClassName}>Title</Text>
|
||||
<TextInput className={inputClassName}
|
||||
placeholder={isSetupFlow ? 'e.g. Algorithms' : 'Enter subject title'}
|
||||
testID = "subject-title-input"
|
||||
placeholder="Enter subject title"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
@@ -174,7 +187,7 @@ export default function UpsertSubject() {
|
||||
<Text className={labelClassName}>Description</Text>
|
||||
<TextInput
|
||||
className={`${inputClassName} min-h-28`}
|
||||
placeholder="Add a short description"
|
||||
placeholder={isSetupFlow ? 'e.g. Lectures, problem sets, and exam prep' : 'Add a short description'}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
@@ -350,4 +363,4 @@ export default function UpsertSubject() {
|
||||
</KeyboardAvoidingView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@ export default function ViewDetailsSubject() {
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Subject Details',
|
||||
headerTitleAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -344,6 +345,11 @@ export default function ViewDetailsSubject() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-5">
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Assignments completed
|
||||
</Text>
|
||||
{totalAssignments > 0 ? (
|
||||
<View className="mt-5">
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
@@ -537,7 +543,9 @@ export default function ViewDetailsSubject() {
|
||||
{section.emptyMessage}
|
||||
</Text>
|
||||
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||
Assignments for this subject will show up here.
|
||||
{assignments.length === 0
|
||||
? 'Create the first assignment to give this subject a real study path.'
|
||||
: 'Assignments for this subject will show up here.'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,7 @@ export default function TaskLayout() {
|
||||
<Stack>
|
||||
<Stack.Screen name="upsertTask" options={{ title: "Create Task" }} />
|
||||
<Stack.Screen name="viewDetailsTask" options={{ title: "Task Details" }} />
|
||||
<Stack.Screen name='timer' options={{title: 'Sprint'}} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
1481
app/task/timer.tsx
Normal file
@@ -1,4 +1,5 @@
|
||||
import { defaultStyles } from '@/constants/defaultStyles';
|
||||
import { SaveSetupSprintDemoUsed } from '@/lib/asyncStorage';
|
||||
import { CheckAssignmentCompletion } from '@/lib/progress';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { Task } from '@/lib/types';
|
||||
@@ -19,12 +20,14 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
export default function UpsertTask() {
|
||||
const { tId, aId: routeAId } = useLocalSearchParams<{
|
||||
const { tId, aId: routeAId, flow } = useLocalSearchParams<{
|
||||
tId?: string;
|
||||
aId?: string;
|
||||
flow?: string;
|
||||
}>();
|
||||
|
||||
const isEditMode = Boolean(tId);
|
||||
const isSetupFlow = flow === 'setup';
|
||||
|
||||
const [title, SetTitle] = useState('');
|
||||
const [description, SetDescription] = useState('');
|
||||
@@ -100,7 +103,7 @@ export default function UpsertTask() {
|
||||
const result =
|
||||
isEditMode && tId
|
||||
? await supabase.from('tasks').update(payload).eq('tId', tId)
|
||||
: await supabase.from('tasks').insert(payload);
|
||||
: await supabase.from('tasks').insert(payload).select().single();
|
||||
|
||||
if (result.error) {
|
||||
SetIsSaving(false);
|
||||
@@ -122,6 +125,19 @@ export default function UpsertTask() {
|
||||
|
||||
SetIsSaving(false);
|
||||
|
||||
if (!isEditMode && isSetupFlow && result.data?.tId) {
|
||||
await SaveSetupSprintDemoUsed(data.user.id);
|
||||
router.replace({
|
||||
pathname: '/task/timer',
|
||||
params: {
|
||||
tId: result.data.tId,
|
||||
durationSeconds: '5',
|
||||
onboardingDemo: 'true',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
isEditMode ? 'Task successfully updated!' : 'Task successfully created!'
|
||||
);
|
||||
@@ -148,6 +164,7 @@ export default function UpsertTask() {
|
||||
options={{
|
||||
title: isEditMode ? 'Edit Task' : 'Create Task',
|
||||
headerTitleStyle: defaultStyles.title,
|
||||
headerTitleAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -183,7 +200,7 @@ export default function UpsertTask() {
|
||||
<TextInput
|
||||
testID="task-title-input"
|
||||
className={inputClassName}
|
||||
placeholder="Enter task title"
|
||||
placeholder={isSetupFlow ? 'e.g. Solve questions 1-3' : 'Enter task title'}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={title}
|
||||
onChangeText={SetTitle}
|
||||
@@ -195,7 +212,11 @@ export default function UpsertTask() {
|
||||
<Text className={labelClassName}>Description</Text>
|
||||
<TextInput
|
||||
className={`${inputClassName} min-h-28`}
|
||||
placeholder="Add a short description"
|
||||
placeholder={
|
||||
isSetupFlow
|
||||
? 'e.g. Work through the first three tasks without notes'
|
||||
: 'Add a short description'
|
||||
}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={description}
|
||||
onChangeText={SetDescription}
|
||||
@@ -274,4 +295,4 @@ export default function UpsertTask() {
|
||||
</KeyboardAvoidingView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { GetActiveSession } from '@/lib/asyncStorage';
|
||||
import { formatDateTime } from '@/lib/date';
|
||||
import { CheckAssignmentCompletion } from '@/lib/progress';
|
||||
import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults';
|
||||
import { finalizeStoredSession } from '@/lib/sessionLifecycle';
|
||||
import { getSubjectColorSet, type SubjectColor } from '@/lib/subjectColors';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { Task } from '@/lib/types';
|
||||
@@ -8,28 +11,84 @@ import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, 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 [isLoading, SetIsLoading] = useState(false);
|
||||
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 [completedFocusSessions, setCompletedFocusSessions] = useState(0);
|
||||
const [contextMeta, setContextMeta] = useState({
|
||||
subjectTitle: 'No Subject',
|
||||
assignmentTitle: 'No Assignment',
|
||||
subjectColor: 'slate' as SubjectColor,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => SetSession(data.session ?? null));
|
||||
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||
SetSession(newSession);
|
||||
});
|
||||
|
||||
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 loadTaskStudyActivity = useCallback(async (taskId: string, userId: string) => {
|
||||
const { count, error } = await supabase
|
||||
.from('sprint_sessions')
|
||||
.select('sessionId', { count: 'exact', head: true })
|
||||
.eq('taskId', taskId)
|
||||
.eq('userId', userId)
|
||||
.eq('sessionType', 'focus')
|
||||
.eq('status', 'completed');
|
||||
|
||||
return () => sub.subscription.unsubscribe();
|
||||
}, []);
|
||||
if (error) {
|
||||
setCompletedFocusSessions(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setCompletedFocusSessions(count ?? 0);
|
||||
}, []);
|
||||
|
||||
const GetTask = useCallback(async (taskId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.eq('tId', taskId)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
Alert.alert('Task could not be fetched, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
SetTask(data);
|
||||
await loadTaskStudyActivity(taskId, data.uId);
|
||||
|
||||
if (data.aId) {
|
||||
const { data: assignmentData, error: assignmentError } = await supabase
|
||||
.from('assignments')
|
||||
.select('title, sId')
|
||||
.eq('aId', data.aId)
|
||||
const GetTask = async (taskId: string) => {
|
||||
SetIsLoading(true);
|
||||
|
||||
@@ -41,11 +100,23 @@ export default function ViewDetailsTask() {
|
||||
|
||||
SetIsLoading(false);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (assignmentData.sId) {
|
||||
const { data: subjectData, error: subjectError } = await supabase
|
||||
.from('subjects')
|
||||
.select('title, color')
|
||||
.eq('sId', assignmentData.sId)
|
||||
.single();
|
||||
|
||||
if (subjectError || !subjectData) {
|
||||
SetTask(data);
|
||||
|
||||
if (data.aId) {
|
||||
@@ -62,12 +133,20 @@ export default function ViewDetailsTask() {
|
||||
if (assignmentError || !assignmentData) {
|
||||
setContextMeta({
|
||||
subjectTitle: 'Unknown Subject',
|
||||
assignmentTitle: 'Unknown Assignment',
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [loadTaskStudyActivity]);
|
||||
if (assignmentData.sId) {
|
||||
SetIsLoading(true);
|
||||
|
||||
@@ -88,58 +167,72 @@ export default function ViewDetailsTask() {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, [GetTask, session, tId])
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (session && tId) {
|
||||
GetTask(tId);
|
||||
const handleSprintStart = async () => {
|
||||
const activeSession = await GetActiveSession();
|
||||
|
||||
if (!activeSession) {
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: {
|
||||
tId: task?.tId,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const secondsLeft = Math.ceil((activeSession.endTime - Date.now()) / 1000)
|
||||
|
||||
if (secondsLeft <= 0) {
|
||||
await finalizeStoredSession('expired', activeSession);
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: {
|
||||
tId: task?.tId,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
}
|
||||
}, [session, tId])
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (activeSession.taskId === task?.tId) {
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: {
|
||||
tId: activeSession.taskId ?? undefined,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
}});
|
||||
return;
|
||||
}
|
||||
|
||||
const DeleteTask = async (taskId: string) => {
|
||||
Alert.alert(
|
||||
'Delete Task',
|
||||
'Are you sure you want to delete this task?',
|
||||
'Active session in progress',
|
||||
`End the current session and start a new ${DEFAULT_FOCUS_DURATION_MINUTES} minute sprint on this task?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel', },
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '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 finalizeStoredSession('cancelled', activeSession);
|
||||
router.push({
|
||||
pathname: '/task/timer',
|
||||
params: {
|
||||
tId: task?.tId,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -175,35 +268,51 @@ export default function ViewDetailsTask() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<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 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);
|
||||
|
||||
const isOwner = session?.user.id === task.uId;
|
||||
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
|
||||
@@ -229,80 +338,188 @@ export default function ViewDetailsTask() {
|
||||
borderColor: colorSet.strong,
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-start">
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-2xl font-bold ${
|
||||
task.isCompleted ? 'text-text-secondary' : 'text-text-main'
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</Text>
|
||||
<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>
|
||||
|
||||
{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
|
||||
testID="delete-task-button"
|
||||
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 className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
|
||||
<Text className="text-xs font-semibold text-text-secondary">
|
||||
Status: {task.isCompleted ? 'Completed' : 'Not completed'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-5 rounded-2xl bg-app-subtle p-4">
|
||||
<Text className="text-sm font-semibold text-text-secondary">
|
||||
Study activity
|
||||
</Text>
|
||||
<Text className="mt-1 text-xs leading-5 text-text-muted">
|
||||
This tracks focused work on the task separately from whether the task is marked completed.
|
||||
</Text>
|
||||
|
||||
<View className="mt-4 flex-row gap-3">
|
||||
<View className="flex-1 rounded-2xl bg-app-surface px-4 py-3">
|
||||
<Text className="text-xs font-semibold uppercase tracking-[0.6px] text-text-muted">
|
||||
Focus time
|
||||
</Text>
|
||||
<Text className="mt-1 text-lg font-bold text-text-main">
|
||||
{formatTrackedTime(task.totalTimeInSeconds ?? 0)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-1 rounded-2xl bg-app-surface px-4 py-3">
|
||||
<Text className="text-xs font-semibold uppercase tracking-[0.6px] text-text-muted">
|
||||
Completed sessions
|
||||
</Text>
|
||||
<Text className="mt-1 text-lg font-bold text-text-main">
|
||||
{completedFocusSessions}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="mt-2 text-sm text-text-muted">
|
||||
Last changed: {formatDateTime(task.lastChanged)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isOwner && (
|
||||
<View className="mt-5 border-t border-app-border pt-5">
|
||||
<Pressable
|
||||
className="h-14 items-center justify-center rounded-2xl bg-accent"
|
||||
onPress={() => handleSprintStart()}
|
||||
>
|
||||
<Text className="text-base font-bold text-text-inverse">
|
||||
Start Sprint
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text className="mt-3 text-sm text-text-muted">
|
||||
Starts a {DEFAULT_FOCUS_DURATION_MINUTES} minute focus sprint for this task.
|
||||
</Text>
|
||||
|
||||
<View className="mt-4 flex-row">
|
||||
<Pressable
|
||||
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/task/upsertTask',
|
||||
params: { tId: task.tId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text className="text-sm font-bold text-text-secondary">
|
||||
Edit
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||
onPress={() => DeleteTask(task.tId)}
|
||||
>
|
||||
<Text className="text-sm font-bold text-status-danger">
|
||||
Delete
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 551 KiB |
|
Before Width: | Height: | Size: 551 KiB After Width: | Height: | Size: 551 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 551 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 551 KiB |
|
Before Width: | Height: | Size: 551 KiB |
21
deploy/signup-confirmation/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Signup Confirmation Page
|
||||
|
||||
This serves a very small static confirmation page with `nginx`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
It will be available on port `8080` on the VPS.
|
||||
|
||||
## Files
|
||||
|
||||
- `docker-compose.yml`: starts `nginx:alpine`
|
||||
- `site/index.html`: the page shown after email confirmation
|
||||
|
||||
## Notes
|
||||
|
||||
- If you already have a reverse proxy on the VPS, point your domain or subdomain to `http://localhost:8080`.
|
||||
- If you want this container to bind directly to port `80`, change `8080:80` to `80:80` in `docker-compose.yml`.
|
||||
14
deploy/signup-confirmation/docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
networks:
|
||||
caddy_shared:
|
||||
external: true
|
||||
services:
|
||||
signup-confirmation:
|
||||
image: nginx:alpine
|
||||
container_name: study-sprint-signup-confirmation
|
||||
restart: always
|
||||
expose:
|
||||
- "80"
|
||||
networks:
|
||||
- caddy_shared
|
||||
volumes:
|
||||
- ./site:/usr/share/nginx/html:ro
|
||||
75
deploy/signup-confirmation/site/index.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Study Sprint</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f7fb;
|
||||
--card: #ffffff;
|
||||
--text: #1f2937;
|
||||
--muted: #5b6472;
|
||||
--accent: #3b82f6;
|
||||
--border: #d9e2ec;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top, #e6f1ff 0%, transparent 45%),
|
||||
linear-gradient(180deg, var(--bg) 0%, #eef3f8 100%);
|
||||
font-family: Arial, sans-serif;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(100%, 560px);
|
||||
padding: 40px 28px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
background: var(--card);
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 5vw, 2.7rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 16px 0 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<p class="eyebrow">Study Sprint</p>
|
||||
<h1>Thank you for signing up.</h1>
|
||||
<p>Your email has been confirmed. You can now sign in to your account in the Study Sprint app.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,26 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { SessionType } from '@/lib/types';
|
||||
|
||||
const notificationKey = (aId: string) => `assignment_notification_${aId}`;
|
||||
const setupSprintDemoKey = (userId: string) => `setup_sprint_demo_${userId}`;
|
||||
const activeSprintKey = 'active_sprint';
|
||||
const studyCycleKey = 'study_cycle';
|
||||
|
||||
export type ActiveSession = {
|
||||
sessionId: string;
|
||||
sessionType: SessionType;
|
||||
taskId: string | null;
|
||||
returnTaskId?: string | null;
|
||||
durationSeconds: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
export type StudyCycle = {
|
||||
taskId: string;
|
||||
completedFocusSessions: number;
|
||||
lastCompletedSessionType: SessionType;
|
||||
lastCompletedAt: number;
|
||||
};
|
||||
|
||||
export async function SaveAssignmentNotificationId(aId: string, notificationId: string) {
|
||||
await AsyncStorage.setItem(notificationKey(aId), notificationId);
|
||||
@@ -12,4 +32,49 @@ export async function GetAssignmentNotificationId(aId: string) {
|
||||
|
||||
export async function RemoveAssignmentNotificationId(aId: string) {
|
||||
await AsyncStorage.removeItem(notificationKey(aId));
|
||||
}
|
||||
}
|
||||
|
||||
export async function SaveActiveSession(activeSession: ActiveSession) {
|
||||
await AsyncStorage.setItem(activeSprintKey, JSON.stringify(activeSession));
|
||||
}
|
||||
|
||||
export async function GetActiveSession() {
|
||||
const activeSession = await AsyncStorage.getItem(activeSprintKey);
|
||||
|
||||
if (!activeSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(activeSession) as ActiveSession;
|
||||
}
|
||||
|
||||
export async function RemoveActiveSession() {
|
||||
await AsyncStorage.removeItem(activeSprintKey);
|
||||
}
|
||||
|
||||
export async function SaveStudyCycle(studyCycle: StudyCycle) {
|
||||
await AsyncStorage.setItem(studyCycleKey, JSON.stringify(studyCycle));
|
||||
}
|
||||
|
||||
export async function GetStudyCycle() {
|
||||
const studyCycle = await AsyncStorage.getItem(studyCycleKey);
|
||||
|
||||
if (!studyCycle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(studyCycle) as StudyCycle;
|
||||
}
|
||||
|
||||
export async function RemoveStudyCycle() {
|
||||
await AsyncStorage.removeItem(studyCycleKey);
|
||||
}
|
||||
|
||||
export async function GetSetupSprintDemoUsed(userId: string) {
|
||||
const value = await AsyncStorage.getItem(setupSprintDemoKey(userId));
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
export async function SaveSetupSprintDemoUsed(userId: string) {
|
||||
await AsyncStorage.setItem(setupSprintDemoKey(userId), 'true');
|
||||
}
|
||||
|
||||
5
lib/sessionDefaults.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const DEFAULT_FOCUS_DURATION_MINUTES = 25;
|
||||
export const DEFAULT_SHORT_BREAK_DURATION_MINUTES = 5;
|
||||
export const DEFAULT_LONG_BREAK_DURATION_MINUTES = 15;
|
||||
export const FOCUS_SESSIONS_PER_LONG_BREAK = 4;
|
||||
export const STUDY_CYCLE_IDLE_RESET_MINUTES = 120;
|
||||
37
lib/sessionLifecycle.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
GetActiveSession,
|
||||
RemoveActiveSession,
|
||||
RemoveStudyCycle,
|
||||
type ActiveSession,
|
||||
} from '@/lib/asyncStorage';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
export type FinalSessionStatus = 'completed' | 'cancelled' | 'expired';
|
||||
|
||||
export async function finalizeStoredSession(
|
||||
finalStatus: FinalSessionStatus,
|
||||
activeSessionOverride?: ActiveSession | null
|
||||
) {
|
||||
const activeSession = activeSessionOverride ?? await GetActiveSession();
|
||||
|
||||
if (!activeSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await RemoveActiveSession();
|
||||
|
||||
if (finalStatus !== 'completed') {
|
||||
await RemoveStudyCycle();
|
||||
}
|
||||
|
||||
const { error } = await supabase.rpc('finalize_sprint_session', {
|
||||
p_session_id: activeSession.sessionId,
|
||||
p_final_status: finalStatus,
|
||||
p_ended_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
activeSession,
|
||||
error,
|
||||
};
|
||||
}
|
||||
84
lib/setupStatus.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
export type SetupStepKey = 'subject' | 'assignment' | 'task' | 'sprint';
|
||||
|
||||
export type SetupStatus = {
|
||||
subjectId: string | null;
|
||||
assignmentId: string | null;
|
||||
taskId: string | null;
|
||||
completedFocusSessions: number;
|
||||
currentStep: SetupStepKey;
|
||||
isSetupComplete: boolean;
|
||||
};
|
||||
|
||||
export async function getSetupStatus(userId: string): Promise<SetupStatus> {
|
||||
const [subjectResult, assignmentResult, taskResult, focusSessionResult] = await Promise.all([
|
||||
supabase
|
||||
.from('subjects')
|
||||
.select('sId')
|
||||
.eq('uId', userId)
|
||||
.order('lastChanged', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('assignments')
|
||||
.select('aId')
|
||||
.eq('uId', userId)
|
||||
.order('lastChanged', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('tId')
|
||||
.eq('uId', userId)
|
||||
.order('lastChanged', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('sprint_sessions')
|
||||
.select('sessionId', { count: 'exact', head: true })
|
||||
.eq('userId', userId)
|
||||
.eq('sessionType', 'focus')
|
||||
.eq('status', 'completed'),
|
||||
]);
|
||||
|
||||
if (subjectResult.error) {
|
||||
throw subjectResult.error;
|
||||
}
|
||||
|
||||
if (assignmentResult.error) {
|
||||
throw assignmentResult.error;
|
||||
}
|
||||
|
||||
if (taskResult.error) {
|
||||
throw taskResult.error;
|
||||
}
|
||||
|
||||
if (focusSessionResult.error) {
|
||||
throw focusSessionResult.error;
|
||||
}
|
||||
|
||||
const subjectId = subjectResult.data?.sId ?? null;
|
||||
const assignmentId = assignmentResult.data?.aId ?? null;
|
||||
const taskId = taskResult.data?.tId ?? null;
|
||||
const completedFocusSessions = focusSessionResult.count ?? 0;
|
||||
|
||||
let currentStep: SetupStepKey = 'sprint';
|
||||
|
||||
if (!subjectId) {
|
||||
currentStep = 'subject';
|
||||
} else if (!assignmentId) {
|
||||
currentStep = 'assignment';
|
||||
} else if (!taskId) {
|
||||
currentStep = 'task';
|
||||
}
|
||||
|
||||
return {
|
||||
subjectId,
|
||||
assignmentId,
|
||||
taskId,
|
||||
completedFocusSessions,
|
||||
currentStep,
|
||||
isSetupComplete: taskId !== null && completedFocusSessions > 0,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { SubjectColor } from '@/lib/subjectColors';
|
||||
|
||||
export type SessionType = 'focus' | 'short_break' | 'long_break';
|
||||
|
||||
export type Task = {
|
||||
tId: string;
|
||||
title: string;
|
||||
@@ -8,6 +10,7 @@ export type Task = {
|
||||
lastChanged: string;
|
||||
uId: string;
|
||||
aId: string;
|
||||
totalTimeInSeconds: number;
|
||||
};
|
||||
|
||||
export type Assignment = {
|
||||
|
||||
BIN
notes/projectVision/AppDev_Project_Vision.pdf
Normal file
230
notes/work-report-timer-2026-05-01.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Task Timer Integration and App Polish Work Report
|
||||
|
||||
## #Overview
|
||||
Today the timer work moved from being a standalone tab experiment into the actual task workflow.
|
||||
|
||||
The main commit used for this summary is:
|
||||
|
||||
```text
|
||||
c74062c Implemented timer into task details, uploaded example images for app and centred headers on all screens
|
||||
```
|
||||
|
||||
The work focused on connecting the sprint timer to individual tasks, preserving an active sprint locally, cleaning up routing, and polishing the surrounding app presentation. The timer is no longer exposed as a top-level tab. It now belongs to the task details flow where a sprint naturally starts from a selected task.
|
||||
|
||||
---
|
||||
|
||||
## #ImplementedFeatures
|
||||
|
||||
### #TimerRouteIntegration
|
||||
Moved the timer route from the tab navigator into the task stack:
|
||||
- removed the old timer tab from `app/(tabs)/_layout.tsx`
|
||||
- added `timer` as a screen in `app/task/_layout.tsx`
|
||||
- moved the timer implementation from `app/(tabs)/timer.tsx` to `app/task/timer.tsx`
|
||||
- added a `Start Sprint` action from the task details screen
|
||||
- passed the selected task id into the timer route using `tId`
|
||||
|
||||
This makes the timer part of the task workflow instead of a separate global screen.
|
||||
|
||||
---
|
||||
|
||||
### #TaskAwareTimer
|
||||
Updated the timer screen so it can load and display the selected task:
|
||||
- reads `tId` from route params
|
||||
- fetches the matching task from Supabase
|
||||
- shows the task title and description during the sprint
|
||||
- falls back to generic sprint text if task data is missing
|
||||
|
||||
This replaces the earlier placeholder-task model and makes the sprint screen reflect the actual task being worked on.
|
||||
|
||||
---
|
||||
|
||||
### #ActiveSprintPersistence
|
||||
Added local persistence for the current sprint in `lib/asyncStorage.ts`:
|
||||
- added an `ActiveSprint` type
|
||||
- added `SaveActiveSprint(...)`
|
||||
- added `GetActiveSprint()`
|
||||
- added `RemoveActiveSprint()`
|
||||
- stores the active task id, sprint duration, and calculated end time
|
||||
|
||||
The timer now saves the sprint end time when a session starts. When the timer screen is reopened for the same task, it can restore the remaining sprint time instead of treating the session as gone.
|
||||
|
||||
---
|
||||
|
||||
### #TimeBasedCountdown
|
||||
Changed the countdown ownership toward wall-clock time:
|
||||
- calculates `endTime` when the sprint starts
|
||||
- updates remaining time from `Date.now()`
|
||||
- restores progress from elapsed time when an active sprint is found
|
||||
- removes the active sprint when it expires or is cancelled
|
||||
|
||||
This is a step toward making the timer more robust when the app is backgrounded or the timer screen is reopened.
|
||||
|
||||
---
|
||||
|
||||
### #HoldCancelOverlayWork
|
||||
Continued work on the red hold-to-cancel timer overlay:
|
||||
- kept `timerAnimation` as the main timer-progress value
|
||||
- kept `cancelOverlayAnimation` as the temporary hold-preview offset
|
||||
- added measured overlay height handling through `containerHeight`
|
||||
- added an offscreen reset position for the red overlay
|
||||
- added `timerOverlayVisible` so the red overlay can be hidden immediately after manual cancel fires
|
||||
|
||||
The final direction was to stop relying only on moving the red overlay offscreen. The cancel path now also hides the overlay by opacity before the rest of the return animations run.
|
||||
|
||||
---
|
||||
|
||||
### #HeaderAlignmentPolish
|
||||
Centered navigation titles across the main app screens:
|
||||
- dashboard
|
||||
- subjects
|
||||
- subject create/edit
|
||||
- subject details
|
||||
- assignment create/edit
|
||||
- assignment details
|
||||
- task create/edit
|
||||
- task details
|
||||
- sprint timer
|
||||
|
||||
This was a small visual consistency pass, but it makes the app feel less uneven between screens.
|
||||
|
||||
---
|
||||
|
||||
### #ImageAssetUpdate
|
||||
Updated the app image assets:
|
||||
- replaced the main icon and splash image files under `assets/images/`
|
||||
- moved `master.png` into `assets/images/`
|
||||
- removed the older `assets/study-sprint-image-pack/` copies
|
||||
|
||||
This keeps the active image assets in the folder Expo expects instead of keeping a separate image-pack folder around.
|
||||
|
||||
---
|
||||
|
||||
## #ProblemsAndSetbacks
|
||||
|
||||
### #CancelOverlayBug
|
||||
The main setback today was the red hold-to-cancel overlay still being visible after manual cancel.
|
||||
|
||||
Several possible causes were investigated:
|
||||
- the overlay using `Dimensions.get('window')` instead of the measured container height
|
||||
- the overlay being clamped to the screen height
|
||||
- the overlay view being stretched by `StyleSheet.absoluteFillObject`
|
||||
- the overlay not being moved far enough below the screen
|
||||
|
||||
The first fixes improved the logic but did not remove the visible red bar in runtime feedback. The latest approach adds explicit overlay visibility state so the red timer layer can be hidden directly when cancel completes.
|
||||
|
||||
---
|
||||
|
||||
### #ManualCancelVsNaturalFinish
|
||||
Another important clarification was that the problematic path was manual cancel, not the natural "timer reached zero" flow.
|
||||
|
||||
The cancel path has extra moving parts:
|
||||
- hold delay
|
||||
- hold completion timeout
|
||||
- `cancelOverlayAnimation`
|
||||
- haptic warning
|
||||
- return animations
|
||||
|
||||
That made the bug harder to reason about than the normal finish path.
|
||||
|
||||
---
|
||||
|
||||
### #RuntimeConfidence
|
||||
Static checks passed, but the final visual fix still needs manual runtime confirmation on the device/emulator.
|
||||
|
||||
The timer animation issue is visual and interaction-dependent, so TypeScript and lint can confirm that the code is valid, but they cannot prove that the red overlay is gone in the actual UI.
|
||||
|
||||
---
|
||||
|
||||
## #CurrentState
|
||||
|
||||
The current unpushed commit has the timer integrated into the task flow.
|
||||
|
||||
The app now supports:
|
||||
- starting a sprint from task details
|
||||
- opening the timer as `/task/timer`
|
||||
- loading the selected task into the sprint screen
|
||||
- saving active sprint state locally
|
||||
- restoring active sprint progress from stored end time
|
||||
- cancelling and clearing active sprint state
|
||||
- centered headers across the main screens
|
||||
- updated Expo image assets
|
||||
|
||||
The remaining runtime risk is the red hold-to-cancel overlay. The newest implementation hides the overlay with explicit `timerOverlayVisible` state after manual cancel, but this still needs to be verified by pressing through the cancel flow in the app.
|
||||
|
||||
---
|
||||
|
||||
## #Verification
|
||||
|
||||
Static checks were run after the timer changes:
|
||||
|
||||
```text
|
||||
npx tsc --noEmit
|
||||
exited successfully
|
||||
```
|
||||
|
||||
```text
|
||||
npm run lint
|
||||
exited successfully with one existing warning
|
||||
```
|
||||
|
||||
The lint warning is unrelated to today's timer work:
|
||||
|
||||
```text
|
||||
app/(tabs)/subjects.tsx
|
||||
React Hook useCallback has a missing dependency: 'GetSubjects'
|
||||
```
|
||||
|
||||
The summary above is based on the unpushed commit diff from:
|
||||
|
||||
```text
|
||||
origin/timerTask..HEAD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## #FilesChanged
|
||||
|
||||
Main timer and task-flow files:
|
||||
|
||||
```text
|
||||
app/task/timer.tsx
|
||||
app/task/_layout.tsx
|
||||
app/task/viewDetailsTask.tsx
|
||||
lib/asyncStorage.ts
|
||||
```
|
||||
|
||||
Navigation polish files:
|
||||
|
||||
```text
|
||||
app/(tabs)/_layout.tsx
|
||||
app/(tabs)/index.tsx
|
||||
app/(tabs)/subjects.tsx
|
||||
app/assignment/upsertAssignment.tsx
|
||||
app/assignment/viewDetailsAssignment.tsx
|
||||
app/subject/upsertSubject.tsx
|
||||
app/subject/viewDetailsSubject.tsx
|
||||
app/task/upsertTask.tsx
|
||||
```
|
||||
|
||||
Image asset files:
|
||||
|
||||
```text
|
||||
assets/images/
|
||||
assets/study-sprint-image-pack/
|
||||
```
|
||||
|
||||
New note added:
|
||||
|
||||
```text
|
||||
notes/work-report-timer-2026-05-01.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## #Conclusion
|
||||
|
||||
Today's work moved the timer from an isolated feature into the real task workflow.
|
||||
|
||||
The biggest progress was routing and ownership: a sprint now starts from a task, carries that task id into the timer, displays task details during the sprint, and stores active sprint state locally. The surrounding app also received a small consistency pass through centered headers and updated image assets.
|
||||
|
||||
The main setback was the manual hold-to-cancel red overlay bug. The implementation has gone through several attempts, and the current version now hides the overlay directly after cancel instead of relying only on moving it out of view. The next step is to verify that final visual behavior live in the app.
|
||||
282
notes/work-report-timer-2026-05-02.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# 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:
|
||||
- added a `Tasks with upcoming deadlines` section to the dashboard 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.
|
||||
|
||||
---
|
||||
|
||||
### #DashboardTaskCompletion
|
||||
Extended the dashboard task cards so they can directly affect task progress:
|
||||
- added a `Mark as completed` action to each upcoming task card
|
||||
- updated the action to write `isCompleted = true` back to the matching task row
|
||||
- reused `CheckAssignmentCompletion(...)` so assignment completion status stays in sync with task completion
|
||||
- removed the completed task from the dashboard list immediately after a successful update
|
||||
- added a confirmation alert before the completion update runs
|
||||
|
||||
This gives the dashboard a lightweight task-management action instead of making it read-only.
|
||||
|
||||
---
|
||||
|
||||
### #DashboardAndSubjectsHelpFlow
|
||||
Added a small help/info entry point to explain the app structure more clearly:
|
||||
- added a help button in the dashboard header
|
||||
- added the same help/info pattern to the subjects screen
|
||||
- opened a compact modal that explains the app flow as `Subject -> Assignment -> Task -> Sprint`
|
||||
- added a clear summary block and a closing action inside the modal
|
||||
|
||||
This gives new users a direct explanation of how the app model fits together without leaving the current screen.
|
||||
|
||||
---
|
||||
|
||||
### #HeaderAndStylingPolish
|
||||
Continued local UI cleanup around the dashboard and subjects screens:
|
||||
- aligned the header actions so help and logout use the same general layout pattern on both screens
|
||||
- converted the dashboard screen from local `StyleSheet` usage to NativeWind/Tailwind class-based styling
|
||||
- converted the subjects help/modal block away from `styles.*` references and into NativeWind classes
|
||||
- kept the visual structure local to the affected screens rather than introducing shared abstractions
|
||||
|
||||
This keeps the dashboard and subjects screens stylistically closer to each other while staying within the current app structure.
|
||||
|
||||
---
|
||||
|
||||
### #ActiveSprintDashboardFix
|
||||
Fixed a rendering bug in the dashboard state logic:
|
||||
- the `Tasks with upcoming deadlines` section had been placed inside the `no active sprint` branch
|
||||
- when a sprint was active, the upcoming task section disappeared entirely
|
||||
- moved the upcoming task section out of that conditional so both the active sprint card and upcoming tasks render together
|
||||
|
||||
This keeps the dashboard useful while a sprint is already running instead of hiding the rest of the user's near-term work.
|
||||
|
||||
---
|
||||
|
||||
## #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.
|
||||
|
||||
---
|
||||
|
||||
### #UpcomingTasksVisibilityBug
|
||||
Another dashboard bug appeared after the active sprint card had been added.
|
||||
|
||||
The issue was not in the Supabase query itself. The problem was that the upcoming-deadlines section was rendered only in the `no active sprint` branch of the dashboard conditional.
|
||||
|
||||
This meant:
|
||||
- the active sprint card appeared correctly
|
||||
- the upcoming task data was still loaded
|
||||
- but the list was hidden whenever a sprint existed
|
||||
|
||||
The fix was to move the upcoming task section outside that conditional so the dashboard can show both at once.
|
||||
|
||||
---
|
||||
|
||||
## #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 even while a sprint is active
|
||||
- marking upcoming dashboard tasks as completed with a confirmation step
|
||||
- opening a help/info modal from both the dashboard and subjects headers
|
||||
- using NativeWind/Tailwind styling for the dashboard screen and the subjects help modal block
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
```text
|
||||
npx eslint app/(tabs)/index.tsx
|
||||
exited successfully
|
||||
```
|
||||
|
||||
```text
|
||||
npx eslint app/(tabs)/subjects.tsx
|
||||
completed with one existing warning
|
||||
```
|
||||
|
||||
The later dashboard/subjects polish work was verified with local static checks and code inspection. 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
|
||||
app/(tabs)/subjects.tsx
|
||||
lib/progress.ts
|
||||
```
|
||||
|
||||
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 later dashboard follow-up made that surrounding UI more useful by keeping upcoming tasks visible during active sprints, allowing quick task completion from the dashboard itself, and adding a lightweight in-app explanation of the subject/assignment/task/sprint model.
|
||||
392
notes/work-report-timer-2026-05-03.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Focus, Dashboard, And Progress Model Work Report
|
||||
|
||||
## #Overview
|
||||
Today the timer work moved from a sprint-only model toward a more general session flow that can support both focused work and breaks.
|
||||
|
||||
The main goal was to start closing the vision gap around `focus -> break -> continue`, while keeping the implementation local to the existing timer route instead of introducing a larger navigation or state-management rewrite.
|
||||
|
||||
The work therefore covered both app-side session-model changes and the Supabase function updates needed to make the new flow actually start and finalize sessions correctly.
|
||||
|
||||
Later in the same work session, the scope also expanded into the dashboard and the progress presentation on the detail screens so the app better matches the remaining vision-gap plan.
|
||||
|
||||
The scope then expanded one step further into first-time-user friction, so the work also covered a guided onboarding path and clearer empty states for new accounts.
|
||||
|
||||
Later still, the work expanded beyond the app itself into the signup-confirmation path around account creation. That included auth-screen behavior fixes, a shorter guided-setup timer for quick verification, a minimal confirmation landing page for VPS deployment, Caddy routing, and a less boilerplate-looking confirmation email template.
|
||||
|
||||
---
|
||||
|
||||
## #ImplementedFeatures
|
||||
|
||||
### #GeneralSessionModel
|
||||
Changed the local timer model from a sprint-specific structure into a more general session structure:
|
||||
- added `SessionType` in `lib/types.ts`
|
||||
- introduced the session types:
|
||||
- `focus`
|
||||
- `short_break`
|
||||
- `long_break`
|
||||
- replaced the old `ActiveSprint` shape with `ActiveSession` in `lib/asyncStorage.ts`
|
||||
- stored `sessionType` together with `sessionId`, `taskId`, `durationSeconds`, and `endTime`
|
||||
|
||||
This means the active timer is no longer assumed to always be a task-linked focus sprint.
|
||||
|
||||
---
|
||||
|
||||
### #TimerSessionStartAndRestore
|
||||
Updated the timer screen so it can start and restore different session types:
|
||||
- replaced sprint-specific storage calls with `GetActiveSession(...)`, `SaveActiveSession(...)`, and `RemoveActiveSession(...)`
|
||||
- generalized the timer start path into `startSession(...)`
|
||||
- passed `p_session_type` into the Supabase `start_sprint_session(...)` RPC
|
||||
- kept task linkage only for `focus` sessions
|
||||
- updated the restore logic so a focus session restores by `tId`, while break sessions restore by `sessionType`
|
||||
|
||||
This gives the existing timer screen enough information to behave differently for focus sessions and break sessions without creating a second timer screen.
|
||||
|
||||
---
|
||||
|
||||
### #DashboardAndTaskIntegration
|
||||
Updated the surrounding screens so they understand the new active-session shape:
|
||||
- updated `app/task/viewDetailsTask.tsx` to read the new active session model
|
||||
- updated `app/(tabs)/index.tsx` so the dashboard card can describe either a focus session or a break session
|
||||
- made the dashboard open the timer with either a task id or a break-session configuration, depending on what is active
|
||||
|
||||
This keeps the rest of the app aligned with the timer change, instead of leaving the new session model isolated to one file.
|
||||
|
||||
---
|
||||
|
||||
### #DashboardProgressAndHistory
|
||||
Extended the dashboard so it works more clearly as a study-activity overview:
|
||||
- added a compact `Study progress` summary near the top of the dashboard
|
||||
- showed:
|
||||
- `Focus sessions today`
|
||||
- `Minutes today`
|
||||
- `Minutes this week`
|
||||
- loaded the summary from `sprint_sessions` instead of from planning data
|
||||
- added a `Recent sessions` section showing:
|
||||
- task title when available
|
||||
- session type
|
||||
- duration
|
||||
- final status
|
||||
- date and time
|
||||
- added a small `Recently completed tasks` section based on recent task completion updates
|
||||
|
||||
This moved the dashboard closer to the vision requirement that progress should reflect actual study behavior rather than only task structure.
|
||||
|
||||
---
|
||||
|
||||
### #DashboardLayoutRestructure
|
||||
Reworked the order of the dashboard sections so the screen reads more clearly as a home surface:
|
||||
- kept the active-session card at the top when relevant
|
||||
- placed `Study progress` before the task lists
|
||||
- moved `Tasks with upcoming deadlines` directly under the progress summary
|
||||
- pushed `Recent sessions` and `Recently completed tasks` lower as secondary context
|
||||
- made the lower history area work as a side-by-side layout when screen width allows it
|
||||
- changed the dashboard body to a scrollable layout so the extra sections still fit without clipping
|
||||
|
||||
The result is a dashboard that moves from orientation, to next action, to history instead of feeling like a stacked report page.
|
||||
|
||||
---
|
||||
|
||||
### #ConsistentProgressModel
|
||||
Aligned the progress language across the detail screens so each layer measures one clear thing:
|
||||
- on the subject details screen, changed the progress label from `Assignment Progress` to `Assignments completed`
|
||||
- added helper text clarifying that subject progress is based only on completed assignments
|
||||
- on the assignment details screen, changed the progress label from `Task Progress` to `Tasks completed`
|
||||
- added helper text clarifying that assignment progress is based only on completed tasks
|
||||
- on the task details screen, separated completion state from study activity
|
||||
- added a dedicated `Study activity` block showing:
|
||||
- tracked focus time from `tasks.totalTimeInSeconds`
|
||||
- completed focus-session count from `sprint_sessions`
|
||||
- added an explicit task status label so completion state is not confused with study effort
|
||||
|
||||
This made the meaning of progress more consistent:
|
||||
- `Subject` now reads as assignment completion
|
||||
- `Assignment` now reads as task completion
|
||||
- `Task` now reads as study effort plus completion state
|
||||
- `Dashboard` now reads as recent study activity
|
||||
|
||||
---
|
||||
|
||||
### #FirstTimeSetupAndEmptyStates
|
||||
Added the first guided setup flow so new users are pushed into one clear study path instead of landing in an empty app:
|
||||
- added a dedicated `app/setup.tsx` route for first-time setup
|
||||
- changed signup so a newly authenticated user is routed to setup instead of directly to the dashboard
|
||||
- built the setup flow as a strict sequence:
|
||||
- create first subject
|
||||
- create first assignment
|
||||
- create first task
|
||||
- start first sprint
|
||||
- updated the subject, assignment, and task creation screens so they can advance automatically to the next setup step
|
||||
- removed the setup-breaking success popups between those guided creation steps
|
||||
- added short auth-screen explanations describing:
|
||||
- what the app does
|
||||
- why an account exists
|
||||
- that study structure and progress follow the user
|
||||
- added clearer empty states on the dashboard and subjects screen that point the user into guided setup
|
||||
- tightened the empty-state copy on subject and assignment details so each one points toward the next required object in the hierarchy
|
||||
|
||||
This closes a large part of the first-run friction gap without introducing a separate onboarding system or broader navigation rewrite.
|
||||
|
||||
---
|
||||
|
||||
### #AuthScreenKeyboardHandling
|
||||
Adjusted the auth screens so text inputs do not stay buried behind the on-screen keyboard:
|
||||
- updated `app/login.tsx` so the login content scrolls and shifts upward when the keyboard opens
|
||||
- updated `app/createUser.tsx` so the entire create-account content block lifts upward with the keyboard instead of only trying to scroll one input into view
|
||||
- kept the changes local to the auth screens instead of introducing a broader shared keyboard abstraction
|
||||
|
||||
This was aimed specifically at the real usability problem where the password field could end up hidden during login or signup.
|
||||
|
||||
---
|
||||
|
||||
### #SignupNavigationAndHeaderAlignment
|
||||
Adjusted the signup screen navigation so it matches the rest of the app more closely:
|
||||
- removed the temporary in-screen back button experiment from the signup page
|
||||
- re-enabled the normal stack header for `createUser` in `app/_layout.tsx`
|
||||
- kept signup navigation on the default app-style back arrow instead of a one-off local control
|
||||
|
||||
This kept the auth flow visually more consistent with the rest of the route stack.
|
||||
|
||||
---
|
||||
|
||||
### #GuidedSetupFiveSecondSprint
|
||||
Changed guided setup so the first sprint can be tested almost immediately:
|
||||
- updated `app/setup.tsx` so the setup flow opens the timer with a fixed `5` second duration
|
||||
- extended `app/task/timer.tsx` so it can also accept an explicit `durationSeconds` route param
|
||||
- kept the rest of the timer behavior unchanged, so the setup-specific shortcut still runs through the same session start, storage, and completion flow as normal timers
|
||||
|
||||
This made the first-run path quicker to test without changing the broader timer model back to a special-case setup implementation.
|
||||
|
||||
---
|
||||
|
||||
### #SignupConfirmationDeployment
|
||||
Built the first deployable confirmation landing page outside the Expo app:
|
||||
- added `deploy/signup-confirmation/site/index.html` as a minimal static confirmation page
|
||||
- added `deploy/signup-confirmation/docker-compose.yml` so the page can be served with `nginx:alpine`
|
||||
- added a small README for VPS deployment notes and port mapping
|
||||
- verified the page deployment path together with the external VPS/domain setup already in use
|
||||
|
||||
This created a concrete destination URL for signup confirmation emails instead of leaving the email to resolve into a blank or undefined endpoint.
|
||||
|
||||
---
|
||||
|
||||
### #CaddyAndEmailConfirmationPolish
|
||||
Finished the external confirmation flow around signup:
|
||||
- corrected the Caddy reverse-proxy target from container port `8080` to `80` for the `nginx` confirmation container
|
||||
- confirmed that the confirmation page then resolved correctly behind the existing Caddy-plus-Docker setup
|
||||
- replaced the original bare confirmation email body with a cleaner branded HTML email using the existing `{{ .ConfirmationURL }}` placeholder
|
||||
|
||||
This moved the signup confirmation flow from a functional but rough setup into something that is both deployable and presentable.
|
||||
|
||||
---
|
||||
|
||||
### #PostSessionBreakFlow
|
||||
Added the first real post-session flow in the timer UI:
|
||||
- after a completed focus session, the timer now shows:
|
||||
- `Start short break`
|
||||
- `Skip break`
|
||||
- starting the break reopens the same timer route in `short_break` mode
|
||||
- after a completed short break, the timer now shows:
|
||||
- `Continue with same task`
|
||||
- `Back to dashboard`
|
||||
- passed `returnTaskId` through the route so the timer can return the user to the original task after the break
|
||||
|
||||
This is the first implementation of an actual study loop rather than a timer that simply ends and disappears.
|
||||
|
||||
---
|
||||
|
||||
### #BreakTimerPresentation
|
||||
Adjusted the timer UI so break sessions read more clearly:
|
||||
- added a fixed-duration block for break sessions instead of showing the normal duration picker
|
||||
- used a fixed 5-minute short-break duration for the first implementation
|
||||
- kept the focus-session picker unchanged
|
||||
- made the break start button match the existing `Start Sprint` button styling, but show only `Start`
|
||||
- removed the bug where picker or pre-start break elements remained visible on top of the running break session
|
||||
|
||||
This keeps the first break flow minimal and visually consistent with the existing timer screen.
|
||||
|
||||
---
|
||||
|
||||
### #SupabaseFunctionAlignment
|
||||
Adjusted the Supabase side so the new app flow could actually run:
|
||||
- updated `start_sprint_session(...)` to accept `p_session_type`
|
||||
- allowed break sessions to start with `taskId = null`
|
||||
- aligned the SQL with the real table schema using:
|
||||
- `sessionId`
|
||||
- `taskId`
|
||||
- `userId`
|
||||
- `sessionType`
|
||||
- `countedIntoTaskTotal`
|
||||
- corrected function-return behavior so the app receives the created session id in the shape it expects
|
||||
- kept finalize logic so only `focus` sessions contribute to `tasks.totalTimeInSeconds`
|
||||
|
||||
Without this database alignment, the app-side session model would compile but still fail when starting real sessions.
|
||||
|
||||
---
|
||||
|
||||
## #ProblemsAndSetbacks
|
||||
|
||||
### #SchemaMismatch
|
||||
The main blocker today was that the first SQL version assumed table columns that did not exist in the real Supabase schema.
|
||||
|
||||
The actual `sprint_sessions` table already contained:
|
||||
- `sessionId`
|
||||
- `taskId`
|
||||
- `userId`
|
||||
- `plannedDuration`
|
||||
- `startedAt`
|
||||
- `endedAt`
|
||||
- `elapsedSeconds`
|
||||
- `status`
|
||||
- `countedIntoTaskTotal`
|
||||
- `sessionType`
|
||||
|
||||
But it did not contain `createdAt` or `updatedAt`, so the first function version failed at runtime.
|
||||
|
||||
---
|
||||
|
||||
### #FunctionReturnShape
|
||||
Another blocker was the shape of the return value from `start_sprint_session(...)`.
|
||||
|
||||
Even after the insert worked, the app still showed:
|
||||
- `Session could not be created.`
|
||||
|
||||
The issue was not the insert itself, but that the returned value shape did not match what `getSessionId(...)` was looking for on the app side.
|
||||
|
||||
This had to be corrected so the RPC returned the created session id in a directly readable object shape.
|
||||
|
||||
---
|
||||
|
||||
### #PauseUIScreenOverlap
|
||||
The first version of the break UI had presentation bugs:
|
||||
- the pause start button text looked cramped and awkward
|
||||
- pre-start pause UI stayed visible after the break actually started
|
||||
- picker or fixed-duration elements overlapped the running break session
|
||||
|
||||
This was corrected by hiding pre-start break UI while the timer is running and by reverting the pause start button back to the same visual model as the existing sprint start button.
|
||||
|
||||
---
|
||||
|
||||
### #ConfirmationRoutePortMismatch
|
||||
The external signup-confirmation deployment initially failed behind Caddy with `HTTP ERROR 502`.
|
||||
|
||||
The actual issue was not the Docker network arrangement itself, but that the reverse proxy was targeting `signup-confirmation:8080` even though the `nginx` container listens internally on port `80`.
|
||||
|
||||
Changing the upstream target to the real container port fixed the route.
|
||||
|
||||
---
|
||||
|
||||
## #CurrentState
|
||||
|
||||
The timer flow now goes further than the previous sprint-only model.
|
||||
|
||||
The app now supports:
|
||||
- starting a `focus` session tied to a task
|
||||
- starting a `short_break` session with no task linkage
|
||||
- storing and restoring the active session with its type
|
||||
- showing a post-focus decision between taking a break or skipping it
|
||||
- returning from a completed short break into the same task flow or back to the dashboard
|
||||
- keeping break sessions out of task time totals
|
||||
|
||||
At this point, the app has the first working version of the focus-and-break loop described in the vision plan, even though the cycle logic and long-break offer are not implemented yet.
|
||||
|
||||
The dashboard also now gives a clearer answer to:
|
||||
- `What have I done today?`
|
||||
- `What should I work on next?`
|
||||
|
||||
And the detail screens now separate planning completion from study activity more explicitly, which makes the app easier to read without having to infer what each progress bar means.
|
||||
|
||||
For a brand-new user, the app also no longer drops straight into a generic empty state after account creation. There is now a clearer route from signup to:
|
||||
- first subject
|
||||
- first assignment
|
||||
- first task
|
||||
- first sprint
|
||||
|
||||
That makes the hierarchy feel more guided and less like a blank structure the user has to interpret alone.
|
||||
|
||||
The signup path also now has a more complete confirmation loop around it:
|
||||
- the auth screens behave more safely when the mobile keyboard opens
|
||||
- guided setup can launch a very short first sprint for fast verification
|
||||
- the confirmation email can point to a real public landing page
|
||||
- that landing page has a working Docker/Caddy deployment path on the VPS
|
||||
- the email itself no longer looks like a raw boilerplate template
|
||||
|
||||
---
|
||||
|
||||
## #Verification
|
||||
|
||||
During today's work, the following behaviors were verified through implementation checks and runtime iteration:
|
||||
- the new session model compiles across timer, dashboard, task details, and local storage
|
||||
- `start_sprint_session(...)` now succeeds after the Supabase function updates
|
||||
- the timer can start using the new session-based flow
|
||||
- break sessions no longer leave the picker or fixed-duration setup visible on top of the running timer
|
||||
- the dashboard compiles with the new progress-summary, recent-session, and recent-completion sections
|
||||
- the task details screen compiles with a new `sprint_sessions`-based completed-session count
|
||||
- the subject and assignment detail screens now label completion metrics more explicitly
|
||||
- the new guided setup route compiles and links correctly with the subject, assignment, and task creation flow
|
||||
- the login and signup screens compile after the keyboard-handling adjustments
|
||||
- the guided setup route now opens the timer with an explicit 5-second fixed duration
|
||||
- the deployable signup-confirmation page was brought up behind the VPS Caddy setup after correcting the upstream container port from `8080` to `80`
|
||||
- the confirmation email template was updated to a cleaner HTML version while keeping `{{ .ConfirmationURL }}` as the actual confirmation link placeholder
|
||||
|
||||
Static verification also passed:
|
||||
|
||||
```text
|
||||
npx tsc --noEmit
|
||||
exited successfully
|
||||
|
||||
npm run lint
|
||||
exited with existing warning only in:
|
||||
- app/task/timer.tsx
|
||||
```
|
||||
|
||||
I did not run a live interactive app test for the later dashboard and progress-model changes. That part of the verification is static rather than runtime-confirmed.
|
||||
|
||||
---
|
||||
|
||||
## #FilesChanged
|
||||
|
||||
Main app files worked on:
|
||||
|
||||
```text
|
||||
app/task/timer.tsx
|
||||
app/task/viewDetailsTask.tsx
|
||||
app/(tabs)/index.tsx
|
||||
app/(tabs)/subjects.tsx
|
||||
app/setup.tsx
|
||||
app/subject/viewDetailsSubject.tsx
|
||||
app/subject/upsertSubject.tsx
|
||||
app/assignment/viewDetailsAssignment.tsx
|
||||
app/assignment/upsertAssignment.tsx
|
||||
app/task/upsertTask.tsx
|
||||
app/createUser.tsx
|
||||
app/login.tsx
|
||||
app/_layout.tsx
|
||||
lib/asyncStorage.ts
|
||||
lib/types.ts
|
||||
deploy/signup-confirmation/docker-compose.yml
|
||||
deploy/signup-confirmation/site/index.html
|
||||
deploy/signup-confirmation/README.md
|
||||
```
|
||||
|
||||
New note added:
|
||||
|
||||
```text
|
||||
notes/work-report-timer-2026-05-03.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## #Conclusion
|
||||
|
||||
The main result today was not just a timer change, but a broader step toward closing the remaining vision gaps around study flow and progress clarity.
|
||||
|
||||
The app now has:
|
||||
- a session model that can represent both focused work and breaks
|
||||
- the first concrete `focus -> break -> continue` path from the vision plan
|
||||
- a dashboard that reflects recent study effort more directly
|
||||
- detail screens that use more explicit and consistent progress meanings
|
||||
- a first guided onboarding path that leads a new user from signup to their first workable sprint path
|
||||
- more usable auth screens when entering credentials on mobile
|
||||
- a complete basic signup-confirmation flow that now reaches a real deployed landing page and a cleaner confirmation email
|
||||
|
||||
The remaining work in this area is now less about inventing the model from scratch and more about extending, polishing, and live-validating the pieces that are already in place.
|
||||
196
notes/work-report-timer-2026-05-04.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Main Flow Tightening and Timer Duration Picker Work Report
|
||||
|
||||
## #Overview
|
||||
Today's work focused on the next concrete step in the vision-gap plan after the already completed sections.
|
||||
|
||||
The main goal was to reduce friction in the path from choosing work to actually starting a focus session. That meant tightening the task-level and dashboard-level sprint actions, introducing a consistent default focus duration, and making the timer screen feel faster to enter without removing the older duration-picker path entirely.
|
||||
|
||||
Later in the same work session, the scope narrowed further into the timer screen itself because the reintroduced picker flow behaved incorrectly. That led to a smaller follow-up fix focused specifically on stabilizing the picker state and preventing the screen from resetting while the user scrolls.
|
||||
|
||||
The scope also expanded into the help-flow modal on the dashboard and subjects screens so its explanation of the app structure matches the way the app now actually works.
|
||||
|
||||
After that, one more timer-flow bug surfaced in the post-session overlay itself. A completed break could still reuse the focus-session action menu, which incorrectly offered the user another break instead of only the actions that make sense after a break has ended.
|
||||
|
||||
---
|
||||
|
||||
## #ImplementedFeatures
|
||||
|
||||
### #DefaultFocusDuration
|
||||
Introduced a shared default session-duration source for the low-friction focus flow:
|
||||
- added `lib/sessionDefaults.ts`
|
||||
- defined:
|
||||
- `DEFAULT_FOCUS_DURATION_MINUTES`
|
||||
- `DEFAULT_SHORT_BREAK_DURATION_MINUTES`
|
||||
- reused those constants across the timer, task details, and dashboard paths
|
||||
|
||||
This removed the need to hardcode the same default duration in multiple places and made the main sprint path more consistent.
|
||||
|
||||
---
|
||||
|
||||
### #TaskDetailsPrimarySprintAction
|
||||
Updated the task details screen so `Start Sprint` is the strongest action on the page:
|
||||
- moved `Start Sprint` out of the lower row of equal-weight controls
|
||||
- made it the primary full-width action above `Edit` and `Delete`
|
||||
- added small helper text clarifying that the action starts a `25` minute focus sprint
|
||||
- updated the task-details start flow so it passes the default focus duration into the timer route
|
||||
- tightened the active-session replacement alert text so it clearly states what will happen before the current session is replaced
|
||||
|
||||
This makes the task screen push the user more directly toward real study work instead of presenting sprint start as only one option among several management actions.
|
||||
|
||||
---
|
||||
|
||||
### #DashboardDirectSprintStart
|
||||
Reduced dashboard-to-timer friction for upcoming tasks:
|
||||
- added a `Start Sprint` action directly on the `Tasks with upcoming deadlines` cards
|
||||
- made that action open the timer immediately with the shared default focus duration
|
||||
- handled the three relevant states:
|
||||
- no active session
|
||||
- an expired stored session
|
||||
- an already running different session that must be explicitly replaced
|
||||
- renamed the active-session dashboard button from `Open Session` to:
|
||||
- `Resume Sprint`
|
||||
- `Resume Break`
|
||||
|
||||
This removed one unnecessary detour where the user had to open task details first before reaching the timer.
|
||||
|
||||
---
|
||||
|
||||
### #TimerDefaultDurationFlow
|
||||
Changed the timer entry flow so focus sessions no longer force the user through duration selection before they can begin:
|
||||
- changed the default focus-session setup to show a fixed default duration first
|
||||
- kept break sessions on a fixed-duration path as before
|
||||
- made the start action use the default focus duration immediately unless the user actively chooses a custom one
|
||||
|
||||
This better matches the low-friction part of the vision plan, where starting work should feel immediate rather than configuration-heavy.
|
||||
|
||||
---
|
||||
|
||||
### #CustomDurationReturnPath
|
||||
Reintroduced the old duration-picker flow as an explicit optional side path instead of the default:
|
||||
- added a `Choose a different duration` button on the pre-start focus timer screen
|
||||
- reopened the old picker presentation only when the route enters an explicit picker mode
|
||||
|
||||
This keeps the faster default path while still preserving the older manual-duration interaction for users who want it, without adding a second reversal action inside the picker itself.
|
||||
|
||||
---
|
||||
|
||||
### #PostSessionActionClarity
|
||||
Adjusted the timer completion overlay so the focus-session exit path is more explicit:
|
||||
- after a completed focus session, the overlay now offers:
|
||||
- `Start short break`
|
||||
- `Continue same task`
|
||||
- `Back to dashboard`
|
||||
- updated the explanation text so the available next actions are described directly in the overlay copy
|
||||
|
||||
This makes the post-session decision path closer to the plan's requirement that break, continue, and dashboard-return actions should be simple and explicit.
|
||||
|
||||
---
|
||||
|
||||
### #TimerPickerGlitchFix
|
||||
Fixed the first version of the restored duration picker after it showed unstable behavior:
|
||||
- the picker numbers could initially appear blank until the list was scrolled
|
||||
- the selected duration could snap back incorrectly when scrolling ended
|
||||
- the cause was that the picker route was being rewritten while the user interacted with the list
|
||||
- changed picker selection to use local component state instead of route replacement on every scroll stop
|
||||
- added explicit initial offset restoration on picker open so the visible selection matches the current duration immediately
|
||||
- kept the route change only for entering or leaving picker mode, not for every intermediate selection
|
||||
|
||||
This made the picker usable again without undoing the lower-friction default entry flow.
|
||||
|
||||
---
|
||||
|
||||
### #HelpFlowAlignment
|
||||
Updated the help modal so it matches the current app structure more closely:
|
||||
- kept the main hierarchy as:
|
||||
- `Subject`
|
||||
- `Assignment`
|
||||
- `Task`
|
||||
- `Sprint`
|
||||
- updated the `Sprint` explanation so it now reflects the real post-session flow:
|
||||
- take a break
|
||||
- continue the same task
|
||||
- return to the dashboard
|
||||
- changed the supporting copy so it explains that the work path now leads into both sprints and breaks instead of only into one focused work session
|
||||
- added quick-map text clarifying the dashboard's current role:
|
||||
- resume active session
|
||||
- start next sprint
|
||||
- review recent progress
|
||||
- changed the help CTA on the dashboard from `Start with Subjects` to `Open Subjects`
|
||||
- changed the help CTA on the subjects screen from `Start with Subjects` to `Close Guide`
|
||||
|
||||
This keeps the help flow aligned with the app's actual current behavior instead of leaving it stuck on an older sprint-only interpretation.
|
||||
|
||||
---
|
||||
|
||||
### #PostBreakMenuFix
|
||||
Fixed a timer completion bug where a finished break could still produce the same action menu as a finished focus session:
|
||||
- the post-session overlay had to know which session type actually just ended
|
||||
- the previous flow could fall back to local screen state instead of the persisted active session
|
||||
- this caused a break completion to sometimes be treated like a focus completion
|
||||
- changed the completion flow so it reads the stored active session before building the post-session prompt
|
||||
- reused that same session snapshot when finalizing the session in Supabase
|
||||
|
||||
This means the overlay now behaves correctly after a break finishes:
|
||||
- it does not offer `Start short break` again
|
||||
- it instead keeps the narrower break-finished path:
|
||||
- continue with the same task
|
||||
- go back to the dashboard
|
||||
|
||||
---
|
||||
|
||||
## #ProblemsAndSetbacks
|
||||
|
||||
### #PickerStateReset
|
||||
The main issue during this work happened after the older picker screen was reintroduced as an optional path.
|
||||
|
||||
The first implementation reopened the picker route correctly, but it also updated the route params again when scrolling stopped. In practice this caused two visible problems:
|
||||
- the initial number presentation was unstable
|
||||
- the selected value could reset unexpectedly after momentum ended
|
||||
|
||||
The fix was to keep picker selection local to `app/task/timer.tsx` while the picker is open, and only use route params to decide whether the picker mode should be shown in the first place.
|
||||
|
||||
### #PostSessionTypeMismatch
|
||||
Another issue appeared after the post-session focus actions were introduced.
|
||||
|
||||
The completion overlay already had separate UI for `focus` and `break` sessions, but the value used to choose between them was not robust enough. In practice, that made it possible for a finished break to reopen the focus-style menu and incorrectly offer another break.
|
||||
|
||||
The fix was to derive the completed session type from the persisted active session that had actually been running, rather than relying only on the screen's local state at the moment the animation finished.
|
||||
|
||||
---
|
||||
|
||||
## #CurrentState
|
||||
|
||||
The timer/task/dashboard flow now does more to push the user into focused work with fewer unnecessary steps.
|
||||
|
||||
The app now supports:
|
||||
- a shared default focus duration for the main sprint path
|
||||
- a stronger `Start Sprint` action on the task details screen
|
||||
- direct sprint start from dashboard upcoming-task cards
|
||||
- clearer `Resume Sprint` and `Resume Break` wording on the dashboard
|
||||
- a fixed default-duration entry state on the timer screen
|
||||
- an optional custom-duration picker path instead of a forced picker
|
||||
- explicit post-focus next actions for break, continue, or dashboard return
|
||||
- a stable picker implementation that keeps its selected value while the user scrolls
|
||||
- a corrected break-finished overlay that no longer offers another pause when the completed session was already a break
|
||||
- a help-flow explanation that now matches the real sprint, break, dashboard, and subjects flow more closely
|
||||
|
||||
At this point, the timer flow is more aligned with the vision requirement that starting work should feel fast, focused, and low-friction rather than like a chain of setup steps.
|
||||
|
||||
---
|
||||
|
||||
## #Verification
|
||||
|
||||
Static checks were run after the implementation work and after the picker bug fix:
|
||||
|
||||
An additional static check was also run after the post-break menu fix:
|
||||
|
||||
```text
|
||||
npx tsc --noEmit
|
||||
exited successfully
|
||||
|
||||
npm run lint
|
||||
exited successfully
|
||||
|
||||
npx tsc --noEmit
|
||||
exited successfully
|
||||
```
|
||||
270
notes/work-report-timer-2026-05-05.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Session Reliability and Break-Cycle Work Report
|
||||
|
||||
## #Overview
|
||||
Today's work continued from the updated vision-gap plan, with the focus narrowed to the remaining timer and session-state gaps rather than broader feature expansion.
|
||||
|
||||
The first goal was to finish the last missing part of the focus-and-break loop by making the app distinguish between a short break and a long break in a way that matches an actual study cycle instead of using total historical session count.
|
||||
|
||||
After that, the scope shifted into reliability work because the remaining highest-risk issue was not missing UI, but the possibility that local active-session state and recorded session history could drift apart. That led to a review of how the dashboard, setup flow, task details screen, and timer screen each handled expired, cancelled, or replaced sessions.
|
||||
|
||||
Later in the same work session, the focus narrowed again into wording and flow polish on the timer screen. The break and sprint descriptions were rewritten so they better reflect the app's goal of supporting structured study behavior, and two runtime regressions reported after testing were fixed in the timer flow itself.
|
||||
|
||||
After that, the work shifted into the remaining first-time-user gap from the vision plan. The login and tab flows were tightened so incomplete users are routed into guided setup automatically, and the first guided sprint was changed into a short onboarding demo instead of dropping a new user straight into a normal 25-minute timer.
|
||||
|
||||
The final pass of the day was smaller, but still tied to the same product goal. The help modals on the dashboard and subjects screens were rewritten so they explain the focus-session and break rhythm in a more human way instead of sounding like a rigid step list.
|
||||
|
||||
After that, one final navigation-polish pass was added on the tabs layout itself. The bottom tabs were given explicit icons so the app's primary navigation reads faster at a glance and feels less unfinished.
|
||||
|
||||
---
|
||||
|
||||
## #ImplementedFeatures
|
||||
|
||||
### #LongBreakCycleCompletion
|
||||
Finished the missing long-break part of the focus-and-break loop:
|
||||
- extended the shared session defaults in `lib/sessionDefaults.ts`
|
||||
- added:
|
||||
- `DEFAULT_LONG_BREAK_DURATION_MINUTES`
|
||||
- `FOCUS_SESSIONS_PER_LONG_BREAK`
|
||||
- `STUDY_CYCLE_IDLE_RESET_MINUTES`
|
||||
- updated the timer flow so the next break is chosen from a local study-cycle model instead of total historical session count
|
||||
|
||||
The long-break rule now follows a small continuous-cycle interpretation of study flow rather than incorrectly counting unrelated sessions from earlier in the day or from previous days.
|
||||
|
||||
---
|
||||
|
||||
### #StudyCycleState
|
||||
Added a small local study-cycle state to support the new break behavior:
|
||||
- extended `lib/asyncStorage.ts`
|
||||
- added `StudyCycle`
|
||||
- added:
|
||||
- `SaveStudyCycle`
|
||||
- `GetStudyCycle`
|
||||
- `RemoveStudyCycle`
|
||||
- tracked:
|
||||
- the task tied to the current cycle
|
||||
- how many focus sessions have been completed in that cycle
|
||||
- the last completed session type
|
||||
- the last completion timestamp
|
||||
|
||||
This keeps the long-break offer tied to the current study run instead of to the user's entire database history.
|
||||
|
||||
---
|
||||
|
||||
### #DynamicBreakPrompt
|
||||
Updated the timer completion overlay so post-focus actions are based on the actual cycle state:
|
||||
- expanded the post-session prompt model to include the next break type
|
||||
- replaced the hardcoded short-break action with a dynamic break action
|
||||
- updated the overlay text and button label so the user now sees:
|
||||
- `Start short break`
|
||||
- or `Start long break`
|
||||
depending on the current cycle
|
||||
|
||||
This completes the missing loop from the plan where break behavior should feel intentional rather than unfinished.
|
||||
|
||||
---
|
||||
|
||||
### #SessionLifecycleConsistency
|
||||
Introduced a shared finalization path for active sessions:
|
||||
- added `lib/sessionLifecycle.ts`
|
||||
- introduced `finalizeStoredSession(...)`
|
||||
- moved repeated session-finalization behavior into one place:
|
||||
- remove local active-session state
|
||||
- clear study-cycle state when the final status is not `completed`
|
||||
- finalize the same `sessionId` in Supabase with:
|
||||
- `completed`
|
||||
- `cancelled`
|
||||
- `expired`
|
||||
|
||||
This reduced the risk that one screen would only clear local storage while another screen properly finalized the database record.
|
||||
|
||||
---
|
||||
|
||||
### #CrossScreenReliabilityFixes
|
||||
Applied the shared finalization path across the screens that handle active-session recovery or replacement:
|
||||
- `app/task/timer.tsx`
|
||||
- `app/(tabs)/index.tsx`
|
||||
- `app/task/viewDetailsTask.tsx`
|
||||
- `app/setup.tsx`
|
||||
|
||||
The updated paths now explicitly finalize sessions when they are:
|
||||
- expired on reopen
|
||||
- expired while being observed from the dashboard
|
||||
- cancelled because the user replaces one active sprint with another
|
||||
|
||||
This closes a real reliability gap where the app could previously lose the active local session while leaving the recorded session in the database unfinalized.
|
||||
|
||||
---
|
||||
|
||||
### #TimerAndBreakCopyPolish
|
||||
Rewrote the timer and break descriptions so they better match the product's intended tone:
|
||||
- updated the pre-start sprint description
|
||||
- updated the pre-start break description
|
||||
- updated the focus fallback description on the running timer
|
||||
- updated the post-focus and post-break explanation copy
|
||||
|
||||
The new wording emphasizes that structured focus and intentional breaks matter for studying, instead of sounding like placeholder or utility-only text.
|
||||
|
||||
---
|
||||
|
||||
### #ReportedRegressionFixes
|
||||
Fixed two runtime issues discovered during manual testing after the earlier session-cycle changes:
|
||||
- after cancelling a focus session, the sprint-duration view could appear visually blank until the user manually dragged the picker
|
||||
- after completing a long break, `Continue with same task` could route the user back to the dashboard instead of returning to the correct task flow
|
||||
|
||||
The fixes were:
|
||||
- reinitializing the picker-offset path when the timer returns to a non-running state
|
||||
- preserving `returnTaskId` inside the stored active-session shape so break sessions keep the correct task context all the way through completion
|
||||
|
||||
---
|
||||
|
||||
### #OnboardingRoutingGuard
|
||||
Closed the remaining onboarding-routing gap so incomplete users are pushed into guided setup instead of being left in the dashboard tabs:
|
||||
- added `lib/setupStatus.ts`
|
||||
- moved the shared setup-completion rule into one place
|
||||
- updated:
|
||||
- `app/login.tsx`
|
||||
- `app/(tabs)/_layout.tsx`
|
||||
- `app/(tabs)/index.tsx`
|
||||
- `app/(tabs)/subjects.tsx`
|
||||
- setup completion is now checked from the same source in login, tab entry, dashboard, and subjects
|
||||
|
||||
This made the setup flow enforceable instead of depending on the user noticing the guided-setup card in the dashboard.
|
||||
|
||||
---
|
||||
|
||||
### #FirstSprintDemoFlow
|
||||
Adjusted the first guided sprint so the first-time experience better matches the low-friction vision goal:
|
||||
- extended `lib/asyncStorage.ts`
|
||||
- added:
|
||||
- `GetSetupSprintDemoUsed`
|
||||
- `SaveSetupSprintDemoUsed`
|
||||
- updated `app/task/upsertTask.tsx` and `app/setup.tsx` so the first setup sprint uses:
|
||||
- `durationSeconds: '5'`
|
||||
- `onboardingDemo: 'true'`
|
||||
- updated `app/task/timer.tsx` so that onboarding-demo sprint completion:
|
||||
- skips the normal session-complete modal
|
||||
- routes directly to the dashboard
|
||||
|
||||
This keeps the first sprint short enough to demonstrate the flow without locking a new user into a full focus block, while still falling back to the normal focus-session duration after the demo has been used once.
|
||||
|
||||
---
|
||||
|
||||
### #HelpModalFlowCopy
|
||||
Updated the help modals on the dashboard and subjects screens so they explain the intended study rhythm more naturally:
|
||||
- updated:
|
||||
- `app/(tabs)/index.tsx`
|
||||
- `app/(tabs)/subjects.tsx`
|
||||
- rewrote the flow-step descriptions so they feel less mechanical
|
||||
- added clearer wording about the actual intended loop:
|
||||
- focus session
|
||||
- short pause
|
||||
- focus session again
|
||||
- longer pause after a few rounds
|
||||
|
||||
This better matches the app's tone and makes the focus/break cycle easier to understand from inside the product itself.
|
||||
|
||||
---
|
||||
|
||||
### #TabBarIconPolish
|
||||
Added explicit icons to the bottom-tab navigation so the two primary surfaces are easier to scan:
|
||||
- updated `app/(tabs)/_layout.tsx`
|
||||
- reused the existing `MaterialIcons` set already used elsewhere in the app
|
||||
- assigned:
|
||||
- `dashboard` to the dashboard tab
|
||||
- `menu-book` to the subjects tab
|
||||
|
||||
This was a small UI polish pass, but it improves immediate navigation clarity and makes the tab bar feel more intentional instead of placeholder-like.
|
||||
|
||||
---
|
||||
|
||||
## #ProblemsAndSetbacks
|
||||
|
||||
### #SessionTruthDivergence
|
||||
The main reliability issue uncovered today was not in the timer animation itself, but in how different screens treated expired or replaced sessions.
|
||||
|
||||
Several screens could detect that a stored session was no longer valid, but some of them only removed the local active-session entry instead of also finalizing the matching `sprint_sessions` row in Supabase. That created a risk where the UI and the database could tell different stories about the same session.
|
||||
|
||||
The fix was to stop duplicating that logic screen by screen and route those paths through a shared finalization helper instead.
|
||||
|
||||
### #PostChangeRuntimeRegressions
|
||||
After the cycle and reliability changes landed, manual testing surfaced two smaller regressions in the timer screen:
|
||||
- the duration screen could look empty after cancelling a focus session
|
||||
- the break-return flow lost its task target after a long break
|
||||
|
||||
These were not architectural problems, but they were both important because they affected the user's immediate understanding of the timer flow after interacting with it.
|
||||
|
||||
### #OnboardingFlowMismatch
|
||||
Manual testing later uncovered a smaller flow mismatch inside guided setup:
|
||||
- the first task created in setup could still open the timer with the normal 25-minute focus default
|
||||
- returning to the guided-setup screen afterwards could then launch a different 5-second demo path
|
||||
|
||||
The problem was that task creation in setup and the setup screen itself were using two different timer-entry paths. The fix was to make those paths share the same one-time onboarding-demo rule.
|
||||
|
||||
---
|
||||
|
||||
## #CurrentState
|
||||
|
||||
The timer and session model are now closer to the final intended behavior in the vision-gap plan.
|
||||
|
||||
The app now supports:
|
||||
- a simple long-break rule tied to the current study cycle
|
||||
- a local cycle model that avoids counting unrelated older sessions
|
||||
- a post-focus overlay that correctly offers either a short break or a long break
|
||||
- a shared session-finalization path used across timer, dashboard, setup, and task-details flows
|
||||
- better consistency between active local session state and recorded session history
|
||||
- more intentional sprint and break wording on the timer screen
|
||||
- preserved task-return context across long-break completion
|
||||
- corrected timer-screen recovery after cancelling a focus session
|
||||
- automatic routing into guided setup for incomplete users after login and tab entry
|
||||
- a one-time onboarding sprint demo that uses a 5-second timer
|
||||
- direct dashboard routing after the onboarding demo completes, without the normal completion modal
|
||||
- help modals that explain the study loop in a more natural way
|
||||
- explicit tab icons that make dashboard and subjects easier to distinguish at a glance
|
||||
|
||||
At this point, the timer/session work is closer to a finished loop, and the first-time-user path is more in line with the intended product vision. The biggest remaining work is now less about feature gaps and more about making sure the final report and final app behavior stay aligned.
|
||||
|
||||
---
|
||||
|
||||
## #Verification
|
||||
|
||||
Static checks were run after the main implementation work and again after the regression fixes:
|
||||
|
||||
```text
|
||||
npx tsc --noEmit
|
||||
exited successfully
|
||||
|
||||
npm run lint
|
||||
exited successfully
|
||||
|
||||
npx tsc --noEmit
|
||||
exited successfully
|
||||
|
||||
npm run lint
|
||||
exited successfully
|
||||
|
||||
npx tsc --noEmit
|
||||
exited successfully
|
||||
|
||||
npm run lint
|
||||
exited successfully
|
||||
|
||||
npx tsc --noEmit
|
||||
exited successfully
|
||||
|
||||
npm run lint
|
||||
exited successfully
|
||||
```
|
||||
|
||||
Manual testing also confirmed most of the intended behavior from today's scope. Two regressions were found during that testing, both inside the timer flow, and both were fixed in the same work session:
|
||||
- blank sprint-duration state after cancelling a focus session
|
||||
- incorrect dashboard return after pressing `Continue with same task` following a long break
|
||||
|
||||
Later manual testing also validated the guided-setup flow after the onboarding fixes:
|
||||
- incomplete users were routed into guided setup instead of landing in dashboard tabs
|
||||
- the first setup sprint used the intended 5-second demo timer
|
||||
- after the demo finished, the user was sent directly to the dashboard without seeing the normal session-complete modal
|
||||
|
||||
The final UI pass for the day was lighter and did not change behavior, but the resulting tabs-layout diff was reviewed directly and confirmed to be limited to navigation presentation:
|
||||
- explicit `MaterialIcons` import in the tabs layout
|
||||
- `dashboard` icon for the dashboard tab
|
||||
- `menu-book` icon for the subjects tab
|
||||