reworked the timer flow, set a default timer duration, updated help button modal and more

This commit is contained in:
Chris Sanden
2026-05-04 17:19:59 +02:00
parent 907fa18841
commit 245b6db3fd
8 changed files with 503 additions and 107 deletions

View File

@@ -7,6 +7,7 @@ import type { SessionType } from '@/lib/types';
import { formatDate, formatDateTime } from '@/lib/date'; import { formatDate, formatDateTime } from '@/lib/date';
import { RegisterForLocalNotificationsAsync } from '@/lib/notifications'; import { RegisterForLocalNotificationsAsync } from '@/lib/notifications';
import { CheckAssignmentCompletion } from '@/lib/progress'; import { CheckAssignmentCompletion } from '@/lib/progress';
import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults';
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Session } from '@supabase/supabase-js'; import { Session } from '@supabase/supabase-js';
@@ -73,7 +74,7 @@ const FLOW_STEPS = [
{ {
label: '4', label: '4',
title: 'Sprint', title: 'Sprint',
description: 'A sprint is one focused work session tied to a single task and tracked by the timer.', description: 'A sprint is one focused work session tied to a single task. After it ends, you can take a break, continue the same task, or return to the dashboard.',
}, },
] as const; ] as const;
@@ -545,6 +546,71 @@ export default function HomeScreen() {
setCompletingTaskId(null); setCompletingTaskId(null);
}, [completingTaskId]); }, [completingTaskId]);
const handleStartSprint = useCallback(async (task: UpcomingDeadlineTask) => {
const storedSession = await GetActiveSession();
if (!storedSession) {
router.push({
pathname: '/task/timer',
params: {
tId: task.tId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
});
return;
}
const secondsLeft = Math.ceil((storedSession.endTime - Date.now()) / 1000);
if (secondsLeft <= 0) {
await RemoveActiveSession();
router.push({
pathname: '/task/timer',
params: {
tId: task.tId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
});
return;
}
if (storedSession.taskId === task.tId) {
router.push({
pathname: '/task/timer',
params: {
tId: task.tId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
});
return;
}
Alert.alert(
'Active session in progress',
`End the current session and start a new ${DEFAULT_FOCUS_DURATION_MINUTES} minute sprint on "${task.title}"?`,
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Start sprint',
style: 'destructive',
onPress: async () => {
await RemoveActiveSession();
router.push({
pathname: '/task/timer',
params: {
tId: task.tId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
});
},
},
]
);
}, []);
return ( return (
<View className="flex-1 bg-app-bg"> <View className="flex-1 bg-app-bg">
<Stack.Screen <Stack.Screen
@@ -601,7 +667,7 @@ export default function HomeScreen() {
<View className="flex-row items-start justify-between gap-3"> <View className="flex-row items-start justify-between gap-3">
<View> <View>
<Text className="text-xs font-bold uppercase tracking-[0.8px] text-[#7B8794]"> <Text className="text-xs font-bold uppercase tracking-[0.8px] text-[#7B8794]">
How the app is structured How work is organized
</Text> </Text>
<Text className="mt-1 text-[28px] font-extrabold text-[#1F2933]"> <Text className="mt-1 text-[28px] font-extrabold text-[#1F2933]">
Study flow Study flow
@@ -617,7 +683,7 @@ export default function HomeScreen() {
</View> </View>
<Text className="text-[15px] leading-[22px] text-[#52606D]"> <Text className="text-[15px] leading-[22px] text-[#52606D]">
Build your work from the big container down to the focused work session. Build your work from the big container down to one concrete task, then use sprints and breaks to move that work forward.
</Text> </Text>
<ScrollView <ScrollView
@@ -653,6 +719,9 @@ export default function HomeScreen() {
<Text className="mt-[6px] text-base font-bold text-[#1F2933]"> <Text className="mt-[6px] text-base font-bold text-[#1F2933]">
{'Subject -> Assignment -> Task -> Sprint'} {'Subject -> Assignment -> Task -> Sprint'}
</Text> </Text>
<Text className="mt-2 text-sm leading-[20px] text-[#52606D]">
The dashboard then helps you resume an active session, start the next sprint, or review recent study progress.
</Text>
</View> </View>
<Pressable <Pressable
@@ -662,7 +731,7 @@ export default function HomeScreen() {
router.push('/subjects'); router.push('/subjects');
}} }}
> >
<Text className="text-[15px] font-bold text-white">Start with Subjects</Text> <Text className="text-[15px] font-bold text-white">Open Subjects</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -723,7 +792,9 @@ export default function HomeScreen() {
}) })
} }
> >
<Text className="text-[15px] font-bold text-white">Open Session</Text> <Text className="text-[15px] font-bold text-white">
{activeSprint.sessionType === 'focus' ? 'Resume Sprint' : 'Resume Break'}
</Text>
</Pressable> </Pressable>
</View> </View>
) : ( ) : (
@@ -800,6 +871,18 @@ export default function HomeScreen() {
{task.subjectTitle} {task.assignmentTitle} {formatDate(task.deadline)} {task.subjectTitle} {task.assignmentTitle} {formatDate(task.deadline)}
</Text> </Text>
<Pressable
className="mt-2 min-h-10 items-center justify-center rounded-xl bg-[#DCE8F7] px-4"
onPress={(event) => {
event.stopPropagation();
void handleStartSprint(task);
}}
>
<Text className="text-sm font-bold text-[#1F2933]">
Start Sprint
</Text>
</Pressable>
<Pressable <Pressable
className={`mt-2 min-h-10 items-center justify-center rounded-xl px-4 ${ className={`mt-2 min-h-10 items-center justify-center rounded-xl px-4 ${
completingTaskId === task.tId ? 'bg-[#9AA5B1]' : 'bg-[#323F4E]' completingTaskId === task.tId ? 'bg-[#9AA5B1]' : 'bg-[#323F4E]'

View File

@@ -28,7 +28,7 @@ const FLOW_STEPS = [
{ {
label: '4', label: '4',
title: 'Sprint', title: 'Sprint',
description: 'A sprint is one focused work session tied to a single task and tracked by the timer.', description: 'A sprint is one focused work session tied to a single task. After it ends, you can take a break, continue the same task, or return to the dashboard.',
}, },
] as const; ] as const;
@@ -123,7 +123,7 @@ export default function Subjects() {
<View className="flex-row items-start justify-between gap-3"> <View className="flex-row items-start justify-between gap-3">
<View> <View>
<Text className="text-xs font-bold uppercase tracking-[0.8px] text-[#7B8794]"> <Text className="text-xs font-bold uppercase tracking-[0.8px] text-[#7B8794]">
How the app is structured How work is organized
</Text> </Text>
<Text className="mt-1 text-[28px] font-extrabold text-[#1F2933]"> <Text className="mt-1 text-[28px] font-extrabold text-[#1F2933]">
Study flow Study flow
@@ -139,7 +139,7 @@ export default function Subjects() {
</View> </View>
<Text className="text-[15px] leading-[22px] text-[#52606D]"> <Text className="text-[15px] leading-[22px] text-[#52606D]">
Build your work from the big container down to the focused work session. Build your work from the big container down to one concrete task, then use sprints and breaks to move that work forward.
</Text> </Text>
<ScrollView <ScrollView
@@ -179,16 +179,16 @@ export default function Subjects() {
<Text className="mt-[6px] text-base font-bold text-[#1F2933]"> <Text className="mt-[6px] text-base font-bold text-[#1F2933]">
{'Subject -> Assignment -> Task -> Sprint'} {'Subject -> Assignment -> Task -> Sprint'}
</Text> </Text>
<Text className="mt-2 text-sm leading-[20px] text-[#52606D]">
Subjects hold the study structure. The dashboard is where you resume active sessions, start the next sprint, and check recent progress.
</Text>
</View> </View>
<Pressable <Pressable
className="min-h-12 items-center justify-center rounded-2xl bg-[#323F4E] px-4" className="min-h-12 items-center justify-center rounded-2xl bg-[#323F4E] px-4"
onPress={() => { onPress={() => setIsFlowInfoVisible(false)}
setIsFlowInfoVisible(false);
router.push('/subjects');
}}
> >
<Text className="text-[15px] font-bold text-white">Start with Subjects</Text> <Text className="text-[15px] font-bold text-white">Close Guide</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>

View File

@@ -3,6 +3,10 @@ import {
RemoveActiveSession, RemoveActiveSession,
SaveActiveSession, SaveActiveSession,
} from '@/lib/asyncStorage'; } from '@/lib/asyncStorage';
import {
DEFAULT_FOCUS_DURATION_MINUTES,
DEFAULT_SHORT_BREAK_DURATION_MINUTES,
} from '@/lib/sessionDefaults';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import type { SessionType, Task } from '@/lib/types'; import type { SessionType, Task } from '@/lib/types';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
@@ -39,7 +43,6 @@ const colors = {
Might have to save duration as well in DB to preserve timer animation persistance 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 TIMER_OPTIONS = [...Array(13).keys()].map((index) => (index === 0 ? 1 : index * 5));
const ITEM_SIZE = width * 0.38; const ITEM_SIZE = width * 0.38;
const ITEM_SPACING = (width - ITEM_SIZE) / 2; const ITEM_SPACING = (width - ITEM_SIZE) / 2;
@@ -48,8 +51,6 @@ const HOLD_TO_CANCEL_MS = 2000;
const CANCEL_ANIMATION_DELAY_MS = 250; const CANCEL_ANIMATION_DELAY_MS = 250;
const BUTTON_PRESS_IN_MS = 80; const BUTTON_PRESS_IN_MS = 80;
const BUTTON_PRESS_OUT_MS = 140; const BUTTON_PRESS_OUT_MS = 140;
const SHORT_BREAK_DURATION_MINUTES = 5;
type PostSessionPrompt = { type PostSessionPrompt = {
completedSessionType: SessionType; completedSessionType: SessionType;
returnTaskId: string | null; returnTaskId: string | null;
@@ -94,15 +95,17 @@ type StartSessionInput = {
export default function TimerScreen() { export default function TimerScreen() {
const [containerHeight, setContainerHeight] = React.useState(0); const [containerHeight, setContainerHeight] = React.useState(0);
const [duration, setDuration] = React.useState(TIMER_OPTIONS[0]); const duration = DEFAULT_FOCUS_DURATION_MINUTES;
const [timerIsRunning, setIsRunning] = React.useState(false); const [timerIsRunning, setIsRunning] = React.useState(false);
const [timerOverlayVisible, setTimerOverlayVisible] = React.useState(false); const [timerOverlayVisible, setTimerOverlayVisible] = React.useState(false);
const [timeRemaining, setTimeRemaining] = React.useState(0); const [timeRemaining, setTimeRemaining] = React.useState(0);
const [task, setTask] = React.useState<Task | null>(null); const [task, setTask] = React.useState<Task | null>(null);
const [currentSessionType, setCurrentSessionType] = React.useState<SessionType>('focus'); const [currentSessionType, setCurrentSessionType] = React.useState<SessionType>('focus');
const [postSessionPrompt, setPostSessionPrompt] = React.useState<PostSessionPrompt | null>(null); const [postSessionPrompt, setPostSessionPrompt] = React.useState<PostSessionPrompt | null>(null);
const [pickerDuration, setPickerDuration] = React.useState(DEFAULT_FOCUS_DURATION_MINUTES);
const scrollX = React.useRef(new Animated.Value(0)).current; const scrollX = React.useRef(new Animated.Value(0)).current;
const pickerListRef = React.useRef<Animated.FlatList<number> | null>(null);
const timerAnimation = 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 buttonAnimation = React.useRef(new Animated.Value(0)).current;
const taskDetailsAnimation = React.useRef(new Animated.Value(0)).current; const taskDetailsAnimation = React.useRef(new Animated.Value(0)).current;
@@ -125,18 +128,21 @@ export default function TimerScreen() {
const cancelHoldIdRef = React.useRef(0); const cancelHoldIdRef = React.useRef(0);
const cancelHoldStartedAtRef = React.useRef(0); const cancelHoldStartedAtRef = React.useRef(0);
const { tId, sessionType: sessionTypeParam, durationMinutes, durationSeconds, returnTaskId } = useLocalSearchParams<{ const { tId, sessionType: sessionTypeParam, durationMinutes, durationSeconds, returnTaskId, chooseDuration } = useLocalSearchParams<{
tId?: string; tId?: string;
sessionType?: SessionType; sessionType?: SessionType;
durationMinutes?: string; durationMinutes?: string;
durationSeconds?: string; durationSeconds?: string;
returnTaskId?: string; returnTaskId?: string;
chooseDuration?: string;
}>(); }>();
const timerOverlayHeight = Math.max(containerHeight, 1); const timerOverlayHeight = Math.max(containerHeight, 1);
const timerOverlayOffscreenY = timerOverlayHeight + 1000; const timerOverlayOffscreenY = timerOverlayHeight + 1000;
const selectedSessionType: SessionType = sessionTypeParam ?? 'focus'; const selectedSessionType: SessionType = sessionTypeParam ?? 'focus';
const showDurationPicker = const showDurationPicker =
selectedSessionType === 'focus' && durationMinutes == null && durationSeconds == null; selectedSessionType === 'focus' &&
chooseDuration === 'true' &&
durationSeconds == null;
const selectedDurationMinutes = React.useMemo(() => { const selectedDurationMinutes = React.useMemo(() => {
if (!durationMinutes) { if (!durationMinutes) {
return null; return null;
@@ -163,6 +169,45 @@ export default function TimerScreen() {
return parsedDuration; return parsedDuration;
}, [durationSeconds]); }, [durationSeconds]);
const displayDurationMinutes = React.useMemo(() => {
if (selectedDurationSeconds != null) {
return null;
}
if (selectedDurationMinutes != null) {
return selectedDurationMinutes;
}
if (selectedSessionType === 'focus') {
return DEFAULT_FOCUS_DURATION_MINUTES;
}
return DEFAULT_SHORT_BREAK_DURATION_MINUTES;
}, [selectedDurationMinutes, selectedDurationSeconds, selectedSessionType]);
React.useEffect(() => {
if (showDurationPicker) {
setPickerDuration(displayDurationMinutes ?? duration);
}
}, [displayDurationMinutes, duration, showDurationPicker]);
React.useEffect(() => {
if (!showDurationPicker) {
return;
}
const selectedIndex = Math.max(0, TIMER_OPTIONS.indexOf(pickerDuration));
const nextOffset = selectedIndex * ITEM_SIZE;
scrollX.setValue(nextOffset);
requestAnimationFrame(() => {
pickerListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
});
}, [pickerDuration, scrollX, showDurationPicker]);
React.useEffect(() => { React.useEffect(() => {
if (containerHeight > 0 && !timerIsRunning) { if (containerHeight > 0 && !timerIsRunning) {
@@ -234,11 +279,6 @@ export default function TimerScreen() {
outputRange: [0, 200], outputRange: [0, 200],
}); });
const pickerOpacity = buttonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0],
});
const taskDetailsOpacity = taskDetailsAnimation.interpolate({ const taskDetailsOpacity = taskDetailsAnimation.interpolate({
inputRange: [0, 1], inputRange: [0, 1],
outputRange: [0, 1], outputRange: [0, 1],
@@ -615,7 +655,6 @@ export default function TimerScreen() {
cancelOverlayAnimation, cancelOverlayAnimation,
containerHeight, containerHeight,
countdownAnimation, countdownAnimation,
duration,
runStartSequence, runStartSequence,
startCountdown, startCountdown,
taskDetailsAnimation, taskDetailsAnimation,
@@ -628,14 +667,24 @@ export default function TimerScreen() {
} }
const totalSeconds = const totalSeconds =
selectedDurationSeconds ?? (selectedDurationMinutes ?? duration) * TIMER_UNIT_IN_SECONDS; selectedDurationSeconds ??
(showDurationPicker ? pickerDuration : (displayDurationMinutes ?? duration)) * TIMER_UNIT_IN_SECONDS;
await startSession({ await startSession({
sessionType: selectedSessionType, sessionType: selectedSessionType,
taskId: selectedSessionType === 'focus' ? (tId ?? null) : null, taskId: selectedSessionType === 'focus' ? (tId ?? null) : null,
durationSeconds: totalSeconds, durationSeconds: totalSeconds,
}); });
}, [duration, selectedDurationMinutes, selectedDurationSeconds, selectedSessionType, startSession, tId]); }, [
displayDurationMinutes,
duration,
pickerDuration,
selectedDurationSeconds,
selectedSessionType,
showDurationPicker,
startSession,
tId,
]);
const handleStartShortBreak = React.useCallback(() => { const handleStartShortBreak = React.useCallback(() => {
setPostSessionPrompt(null); setPostSessionPrompt(null);
@@ -643,7 +692,7 @@ export default function TimerScreen() {
pathname: '/task/timer', pathname: '/task/timer',
params: { params: {
sessionType: 'short_break', sessionType: 'short_break',
durationMinutes: String(SHORT_BREAK_DURATION_MINUTES), durationMinutes: String(DEFAULT_SHORT_BREAK_DURATION_MINUTES),
returnTaskId: tId ?? undefined, returnTaskId: tId ?? undefined,
}, },
}); });
@@ -658,7 +707,10 @@ export default function TimerScreen() {
setPostSessionPrompt(null); setPostSessionPrompt(null);
router.replace({ router.replace({
pathname: '/task/timer', pathname: '/task/timer',
params: { tId: postSessionPrompt.returnTaskId }, params: {
tId: postSessionPrompt.returnTaskId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
}); });
}, [postSessionPrompt]); }, [postSessionPrompt]);
@@ -667,6 +719,22 @@ export default function TimerScreen() {
router.replace('/'); router.replace('/');
}, []); }, []);
const handleChooseCustomDuration = React.useCallback(() => {
if (timerIsRunning || selectedSessionType !== 'focus') {
return;
}
router.replace({
pathname: '/task/timer',
params: {
tId: tId ?? undefined,
sessionType: 'focus',
chooseDuration: 'true',
durationMinutes: String(displayDurationMinutes ?? duration),
},
});
}, [displayDurationMinutes, duration, selectedSessionType, tId, timerIsRunning]);
const cancelTimer = React.useCallback(() => { const cancelTimer = React.useCallback(() => {
if (!timerIsRunning) { if (!timerIsRunning) {
return; return;
@@ -736,6 +804,58 @@ export default function TimerScreen() {
timerIsRunning, timerIsRunning,
]); ]);
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));
const nextDuration = TIMER_OPTIONS[clampedIndex];
setPickerDuration(nextDuration);
},
[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 opacity = scrollX.interpolate({
inputRange,
outputRange: [0.38, 1, 0.38],
});
const scale = scrollX.interpolate({
inputRange,
outputRange: [0.72, 1, 0.72],
});
return (
<View style={styles.timerOptionItem}>
<Animated.Text
style={[
styles.timerOptionText,
{
opacity,
transform: [{ scale }],
},
]}
>
{item}
</Animated.Text>
</View>
);
},
[scrollX]
);
const handleCancelHoldStart = React.useCallback(() => { const handleCancelHoldStart = React.useCallback(() => {
animateButtonPress(true); animateButtonPress(true);
cancelHoldIdRef.current += 1; cancelHoldIdRef.current += 1;
@@ -815,57 +935,6 @@ export default function TimerScreen() {
}); });
}, [animateButtonPress, cancelOverlayAnimation, clearCancelHoldTimeouts]); }, [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 ( return (
<View <View
style={styles.container} style={styles.container}
@@ -980,21 +1049,27 @@ export default function TimerScreen() {
]} ]}
> >
<Animated.FlatList <Animated.FlatList
ref={pickerListRef}
data={TIMER_OPTIONS} data={TIMER_OPTIONS}
scrollEnabled={!timerIsRunning}
keyExtractor={(item) => item.toString()}
horizontal horizontal
bounces={false} bounces={false}
keyExtractor={(item) => item.toString()}
onScroll={Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], { onScroll={Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], {
useNativeDriver: true, useNativeDriver: true,
})} })}
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={handleTimerPickerMomentumEnd} onMomentumScrollEnd={handleTimerPickerMomentumEnd}
showsHorizontalScrollIndicator={false}
snapToInterval={ITEM_SIZE} snapToInterval={ITEM_SIZE}
decelerationRate="fast" decelerationRate="fast"
style={styles.timerPickerList} style={styles.timerPickerList}
contentContainerStyle={styles.timerPickerContent} contentContainerStyle={styles.timerPickerContent}
renderItem={renderTimerItem} renderItem={renderTimerItem}
getItemLayout={(_, index) => ({
length: ITEM_SIZE,
offset: ITEM_SIZE * index,
index,
})}
initialNumToRender={TIMER_OPTIONS.length}
/> />
</View> </View>
) : !timerIsRunning ? ( ) : !timerIsRunning ? (
@@ -1002,11 +1077,18 @@ export default function TimerScreen() {
<Text style={styles.fixedDurationLabel}> <Text style={styles.fixedDurationLabel}>
{selectedDurationSeconds != null {selectedDurationSeconds != null
? `${selectedDurationSeconds} sec` ? `${selectedDurationSeconds} sec`
: `${selectedDurationMinutes ?? SHORT_BREAK_DURATION_MINUTES} min`} : `${displayDurationMinutes ?? duration} min`}
</Text> </Text>
<Text style={styles.fixedDurationDescription}> <Text style={styles.fixedDurationDescription}>
This session uses a fixed duration so you can move straight into the next step. {selectedSessionType === 'focus'
? 'This sprint uses the default focus duration so you can begin immediately.'
: 'This session uses a fixed duration so you can move straight into the next step.'}
</Text> </Text>
{selectedSessionType === 'focus' ? (
<TouchableOpacity onPress={handleChooseCustomDuration} style={styles.durationPickerLink}>
<Text style={styles.durationPickerLinkText}>Choose a different duration</Text>
</TouchableOpacity>
) : null}
</View> </View>
) : null} ) : null}
@@ -1041,7 +1123,7 @@ export default function TimerScreen() {
</Text> </Text>
<Text style={styles.postSessionBody}> <Text style={styles.postSessionBody}>
{postSessionPrompt.completedSessionType === 'focus' {postSessionPrompt.completedSessionType === 'focus'
? 'Start a short break now or skip it and return to your dashboard.' ? 'Take a short break, jump straight into another sprint on the same task, or head back to the dashboard.'
: 'Jump back into the same task or head back to the dashboard.'} : 'Jump back into the same task or head back to the dashboard.'}
</Text> </Text>
@@ -1050,8 +1132,11 @@ export default function TimerScreen() {
<TouchableOpacity onPress={handleStartShortBreak} style={styles.postSessionPrimaryButton}> <TouchableOpacity onPress={handleStartShortBreak} style={styles.postSessionPrimaryButton}>
<Text style={styles.postSessionPrimaryButtonText}>Start short break</Text> <Text style={styles.postSessionPrimaryButtonText}>Start short break</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={handleBackToDashboard} style={styles.postSessionSecondaryButton}> <TouchableOpacity onPress={handleContinueSameTask} style={styles.postSessionSecondaryButton}>
<Text style={styles.postSessionSecondaryButtonText}>Skip break</Text> <Text style={styles.postSessionSecondaryButtonText}>Continue same task</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleBackToDashboard} style={styles.postSessionTertiaryButton}>
<Text style={styles.postSessionTertiaryButtonText}>Back to dashboard</Text>
</TouchableOpacity> </TouchableOpacity>
</> </>
) : ( ) : (
@@ -1102,6 +1187,7 @@ const styles = StyleSheet.create({
left: 0, left: 0,
right: 0, right: 0,
flex: 1, flex: 1,
alignItems: 'center',
}, },
timerPickerList: { timerPickerList: {
flexGrow: 0, flexGrow: 0,
@@ -1127,6 +1213,23 @@ const styles = StyleSheet.create({
marginTop: 16, marginTop: 16,
textAlign: 'center', textAlign: 'center',
}, },
durationPickerLink: {
marginTop: 18,
minHeight: 42,
paddingHorizontal: 18,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 999,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.24)',
backgroundColor: 'rgba(255,255,255,0.08)',
},
durationPickerLinkText: {
color: '#F3EBDD',
fontSize: 15,
fontWeight: '700',
textAlign: 'center',
},
timerPickerContent: { timerPickerContent: {
paddingHorizontal: ITEM_SPACING, paddingHorizontal: ITEM_SPACING,
}, },
@@ -1135,7 +1238,7 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
text: { timerOptionText: {
fontSize: ITEM_SIZE * 0.8, fontSize: ITEM_SIZE * 0.8,
fontFamily: 'Menlo', fontFamily: 'Menlo',
color: colors.text, color: colors.text,
@@ -1258,4 +1361,15 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: '700',
}, },
postSessionTertiaryButton: {
minHeight: 48,
alignItems: 'center',
justifyContent: 'center',
marginTop: 10,
},
postSessionTertiaryButtonText: {
color: '#52606D',
fontSize: 15,
fontWeight: '700',
},
}); });

View File

@@ -1,6 +1,7 @@
import { GetActiveSession, RemoveActiveSession } from '@/lib/asyncStorage'; import { GetActiveSession, RemoveActiveSession } from '@/lib/asyncStorage';
import { formatDateTime } from '@/lib/date'; import { formatDateTime } from '@/lib/date';
import { CheckAssignmentCompletion } from '@/lib/progress'; import { CheckAssignmentCompletion } from '@/lib/progress';
import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults';
import { getSubjectColorSet, type SubjectColor } from '@/lib/subjectColors'; import { getSubjectColorSet, type SubjectColor } from '@/lib/subjectColors';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import type { Task } from '@/lib/types'; import type { Task } from '@/lib/types';
@@ -137,7 +138,10 @@ const handleSprintStart = async () => {
if (!activeSession) { if (!activeSession) {
router.push({ router.push({
pathname: '/task/timer', pathname: '/task/timer',
params: { tId: task?.tId}, params: {
tId: task?.tId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
}); });
return; return;
} }
@@ -150,7 +154,10 @@ const handleSprintStart = async () => {
await RemoveActiveSession(); await RemoveActiveSession();
router.push({ router.push({
pathname: '/task/timer', pathname: '/task/timer',
params: { tId: task?.tId} params: {
tId: task?.tId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
}
}); });
return; return;
} }
@@ -159,13 +166,16 @@ const handleSprintStart = async () => {
if (activeSession.taskId === task?.tId) { if (activeSession.taskId === task?.tId) {
router.push({ router.push({
pathname: '/task/timer', pathname: '/task/timer',
params: { tId: activeSession.taskId ?? undefined }}); params: {
tId: activeSession.taskId ?? undefined,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
}});
return; return;
} }
Alert.alert( Alert.alert(
'Active session in progress', 'Active session in progress',
'Starting a new sprint will end the current active session', `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', },
{ {
@@ -175,7 +185,10 @@ const handleSprintStart = async () => {
await RemoveActiveSession(); await RemoveActiveSession();
router.push({ router.push({
pathname: '/task/timer', pathname: '/task/timer',
params: { tId: task?.tId }, params: {
tId: task?.tId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
}); });
}, },
}, },
@@ -395,7 +408,21 @@ return (
</View> </View>
{isOwner && ( {isOwner && (
<View className="mt-5 flex-row border-t border-app-border pt-5"> <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 <Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3" className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() => onPress={() =>
@@ -409,15 +436,6 @@ return (
Edit Edit
</Text> </Text>
</Pressable> </Pressable>
<Pressable
className='mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3'
onPress={() =>
handleSprintStart()
}>
<Text className='text.sm font-bold text-text-secondary'>
Start Sprint
</Text>
</Pressable>
<Pressable <Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3" className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteTask(task.tId)} onPress={() => DeleteTask(task.tId)}
@@ -426,6 +444,7 @@ return (
Delete Delete
</Text> </Text>
</Pressable> </Pressable>
</View>
</View> </View>
)} )}
</View> </View>

View File

@@ -1,9 +1,14 @@
networks:
caddy_shared:
external: true
services: services:
signup-confirmation: signup-confirmation:
image: nginx:alpine image: nginx:alpine
container_name: study-sprint-signup-confirmation container_name: study-sprint-signup-confirmation
restart: unless-stopped restart: always
ports: expose:
- "8080:80" - "80"
networks:
- caddy_shared
volumes: volumes:
- ./site:/usr/share/nginx/html:ro - ./site:/usr/share/nginx/html:ro

2
lib/sessionDefaults.ts Normal file
View File

@@ -0,0 +1,2 @@
export const DEFAULT_FOCUS_DURATION_MINUTES = 25;
export const DEFAULT_SHORT_BREAK_DURATION_MINUTES = 5;

View File

@@ -166,6 +166,14 @@ Even a good feature set can feel wrong if the path to action is too slow or too
--- ---
#################################################################
#################################################################
The steps above have been completed##############################
#################################################################
#################################################################
## #Gap6ReliabilityAndSessionState ## #Gap6ReliabilityAndSessionState
### #VisionGap ### #VisionGap

View File

@@ -0,0 +1,165 @@
# 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.
---
## #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.
---
## #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.
---
## #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 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:
```text
npx tsc --noEmit
exited successfully
npm run lint
exited successfully
```