diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 40ea27b..0edcbc9 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -7,6 +7,7 @@ import type { SessionType } from '@/lib/types'; import { formatDate, formatDateTime } from '@/lib/date'; import { RegisterForLocalNotificationsAsync } from '@/lib/notifications'; import { CheckAssignmentCompletion } from '@/lib/progress'; +import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults'; import { supabase } from "@/lib/supabase"; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { Session } from '@supabase/supabase-js'; @@ -73,7 +74,7 @@ const FLOW_STEPS = [ { label: '4', 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; @@ -545,6 +546,71 @@ export default function HomeScreen() { setCompletingTaskId(null); }, [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 ( - How the app is structured + How work is organized Study flow @@ -617,7 +683,7 @@ export default function HomeScreen() { - 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. {'Subject -> Assignment -> Task -> Sprint'} + + The dashboard then helps you resume an active session, start the next sprint, or review recent study progress. + - Start with Subjects + Open Subjects @@ -723,7 +792,9 @@ export default function HomeScreen() { }) } > - Open Session + + {activeSprint.sessionType === 'focus' ? 'Resume Sprint' : 'Resume Break'} + ) : ( @@ -800,6 +871,18 @@ export default function HomeScreen() { {task.subjectTitle} • {task.assignmentTitle} • {formatDate(task.deadline)} + { + event.stopPropagation(); + void handleStartSprint(task); + }} + > + + Start Sprint + + + - How the app is structured + How work is organized Study flow @@ -139,7 +139,7 @@ export default function Subjects() { - 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. {'Subject -> Assignment -> Task -> Sprint'} + + Subjects hold the study structure. The dashboard is where you resume active sessions, start the next sprint, and check recent progress. + { - setIsFlowInfoVisible(false); - router.push('/subjects'); - }} + onPress={() => setIsFlowInfoVisible(false)} > - Start with Subjects + Close Guide diff --git a/app/task/timer.tsx b/app/task/timer.tsx index 26d8e39..8fa4f50 100644 --- a/app/task/timer.tsx +++ b/app/task/timer.tsx @@ -3,6 +3,10 @@ import { RemoveActiveSession, SaveActiveSession, } from '@/lib/asyncStorage'; +import { + DEFAULT_FOCUS_DURATION_MINUTES, + DEFAULT_SHORT_BREAK_DURATION_MINUTES, +} from '@/lib/sessionDefaults'; import { supabase } from '@/lib/supabase'; import type { SessionType, Task } from '@/lib/types'; 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 */ - 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; @@ -48,8 +51,6 @@ 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 SHORT_BREAK_DURATION_MINUTES = 5; - type PostSessionPrompt = { completedSessionType: SessionType; returnTaskId: string | null; @@ -94,15 +95,17 @@ type StartSessionInput = { export default function TimerScreen() { 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 [timerOverlayVisible, setTimerOverlayVisible] = React.useState(false); const [timeRemaining, setTimeRemaining] = React.useState(0); const [task, setTask] = React.useState(null); const [currentSessionType, setCurrentSessionType] = React.useState('focus'); const [postSessionPrompt, setPostSessionPrompt] = React.useState(null); + const [pickerDuration, setPickerDuration] = React.useState(DEFAULT_FOCUS_DURATION_MINUTES); const scrollX = React.useRef(new Animated.Value(0)).current; + const pickerListRef = React.useRef | null>(null); 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; @@ -125,18 +128,21 @@ export default function TimerScreen() { const cancelHoldIdRef = 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; sessionType?: SessionType; durationMinutes?: string; durationSeconds?: string; returnTaskId?: string; + chooseDuration?: string; }>(); const timerOverlayHeight = Math.max(containerHeight, 1); const timerOverlayOffscreenY = timerOverlayHeight + 1000; const selectedSessionType: SessionType = sessionTypeParam ?? 'focus'; const showDurationPicker = - selectedSessionType === 'focus' && durationMinutes == null && durationSeconds == null; + selectedSessionType === 'focus' && + chooseDuration === 'true' && + durationSeconds == null; const selectedDurationMinutes = React.useMemo(() => { if (!durationMinutes) { return null; @@ -163,6 +169,45 @@ export default function TimerScreen() { return parsedDuration; }, [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(() => { if (containerHeight > 0 && !timerIsRunning) { @@ -234,11 +279,6 @@ export default function TimerScreen() { outputRange: [0, 200], }); - const pickerOpacity = buttonAnimation.interpolate({ - inputRange: [0, 1], - outputRange: [1, 0], - }); - const taskDetailsOpacity = taskDetailsAnimation.interpolate({ inputRange: [0, 1], outputRange: [0, 1], @@ -615,7 +655,6 @@ export default function TimerScreen() { cancelOverlayAnimation, containerHeight, countdownAnimation, - duration, runStartSequence, startCountdown, taskDetailsAnimation, @@ -628,14 +667,24 @@ export default function TimerScreen() { } const totalSeconds = - selectedDurationSeconds ?? (selectedDurationMinutes ?? duration) * TIMER_UNIT_IN_SECONDS; + selectedDurationSeconds ?? + (showDurationPicker ? pickerDuration : (displayDurationMinutes ?? duration)) * TIMER_UNIT_IN_SECONDS; await startSession({ sessionType: selectedSessionType, taskId: selectedSessionType === 'focus' ? (tId ?? null) : null, durationSeconds: totalSeconds, }); - }, [duration, selectedDurationMinutes, selectedDurationSeconds, selectedSessionType, startSession, tId]); + }, [ + displayDurationMinutes, + duration, + pickerDuration, + selectedDurationSeconds, + selectedSessionType, + showDurationPicker, + startSession, + tId, + ]); const handleStartShortBreak = React.useCallback(() => { setPostSessionPrompt(null); @@ -643,7 +692,7 @@ export default function TimerScreen() { pathname: '/task/timer', params: { sessionType: 'short_break', - durationMinutes: String(SHORT_BREAK_DURATION_MINUTES), + durationMinutes: String(DEFAULT_SHORT_BREAK_DURATION_MINUTES), returnTaskId: tId ?? undefined, }, }); @@ -658,7 +707,10 @@ export default function TimerScreen() { setPostSessionPrompt(null); router.replace({ pathname: '/task/timer', - params: { tId: postSessionPrompt.returnTaskId }, + params: { + tId: postSessionPrompt.returnTaskId, + durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES), + }, }); }, [postSessionPrompt]); @@ -667,6 +719,22 @@ export default function TimerScreen() { 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(() => { if (!timerIsRunning) { return; @@ -736,6 +804,58 @@ export default function TimerScreen() { 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 ( + + + {item} + + + ); + }, + [scrollX] + ); + const handleCancelHoldStart = React.useCallback(() => { animateButtonPress(true); cancelHoldIdRef.current += 1; @@ -815,57 +935,6 @@ export default function TimerScreen() { }); }, [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 ( - - - {item} - - - ); - }, - [pickerOpacity, scrollX] - ); - return ( item.toString()} horizontal bounces={false} + keyExtractor={(item) => item.toString()} onScroll={Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], { useNativeDriver: true, })} - showsHorizontalScrollIndicator={false} onMomentumScrollEnd={handleTimerPickerMomentumEnd} + showsHorizontalScrollIndicator={false} snapToInterval={ITEM_SIZE} decelerationRate="fast" style={styles.timerPickerList} contentContainerStyle={styles.timerPickerContent} renderItem={renderTimerItem} + getItemLayout={(_, index) => ({ + length: ITEM_SIZE, + offset: ITEM_SIZE * index, + index, + })} + initialNumToRender={TIMER_OPTIONS.length} /> ) : !timerIsRunning ? ( @@ -1002,11 +1077,18 @@ export default function TimerScreen() { {selectedDurationSeconds != null ? `${selectedDurationSeconds} sec` - : `${selectedDurationMinutes ?? SHORT_BREAK_DURATION_MINUTES} min`} + : `${displayDurationMinutes ?? duration} min`} - 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.'} + {selectedSessionType === 'focus' ? ( + + Choose a different duration + + ) : null} ) : null} @@ -1041,7 +1123,7 @@ export default function TimerScreen() { {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.'} @@ -1050,8 +1132,11 @@ export default function TimerScreen() { Start short break - - Skip break + + Continue same task + + + Back to dashboard ) : ( @@ -1102,6 +1187,7 @@ const styles = StyleSheet.create({ left: 0, right: 0, flex: 1, + alignItems: 'center', }, timerPickerList: { flexGrow: 0, @@ -1127,6 +1213,23 @@ const styles = StyleSheet.create({ marginTop: 16, 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: { paddingHorizontal: ITEM_SPACING, }, @@ -1135,7 +1238,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, - text: { + timerOptionText: { fontSize: ITEM_SIZE * 0.8, fontFamily: 'Menlo', color: colors.text, @@ -1258,4 +1361,15 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '700', }, + postSessionTertiaryButton: { + minHeight: 48, + alignItems: 'center', + justifyContent: 'center', + marginTop: 10, + }, + postSessionTertiaryButtonText: { + color: '#52606D', + fontSize: 15, + fontWeight: '700', + }, }); diff --git a/app/task/viewDetailsTask.tsx b/app/task/viewDetailsTask.tsx index 6930a72..81885bf 100644 --- a/app/task/viewDetailsTask.tsx +++ b/app/task/viewDetailsTask.tsx @@ -1,6 +1,7 @@ import { GetActiveSession, RemoveActiveSession } from '@/lib/asyncStorage'; import { formatDateTime } from '@/lib/date'; import { CheckAssignmentCompletion } from '@/lib/progress'; +import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults'; import { getSubjectColorSet, type SubjectColor } from '@/lib/subjectColors'; import { supabase } from '@/lib/supabase'; import type { Task } from '@/lib/types'; @@ -137,7 +138,10 @@ const handleSprintStart = async () => { if (!activeSession) { router.push({ pathname: '/task/timer', - params: { tId: task?.tId}, + params: { + tId: task?.tId, + durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES), + }, }); return; } @@ -150,7 +154,10 @@ const handleSprintStart = async () => { await RemoveActiveSession(); router.push({ pathname: '/task/timer', - params: { tId: task?.tId} + params: { + tId: task?.tId, + durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES), + } }); return; } @@ -159,13 +166,16 @@ const handleSprintStart = async () => { if (activeSession.taskId === task?.tId) { router.push({ pathname: '/task/timer', - params: { tId: activeSession.taskId ?? undefined }}); + params: { + tId: activeSession.taskId ?? undefined, + durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES), + }}); return; } Alert.alert( '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', }, { @@ -175,7 +185,10 @@ const handleSprintStart = async () => { await RemoveActiveSession(); router.push({ pathname: '/task/timer', - params: { tId: task?.tId }, + params: { + tId: task?.tId, + durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES), + }, }); }, }, @@ -395,7 +408,21 @@ return ( {isOwner && ( - + + handleSprintStart()} + > + + Start Sprint + + + + + Starts a {DEFAULT_FOCUS_DURATION_MINUTES} minute focus sprint for this task. + + + @@ -409,15 +436,6 @@ return ( Edit - - handleSprintStart() - }> - - Start Sprint - - DeleteTask(task.tId)} @@ -426,6 +444,7 @@ return ( Delete + )} diff --git a/deploy/signup-confirmation/docker-compose.yml b/deploy/signup-confirmation/docker-compose.yml index 3f2fe29..581b647 100644 --- a/deploy/signup-confirmation/docker-compose.yml +++ b/deploy/signup-confirmation/docker-compose.yml @@ -1,9 +1,14 @@ +networks: + caddy_shared: + external: true services: signup-confirmation: image: nginx:alpine container_name: study-sprint-signup-confirmation - restart: unless-stopped - ports: - - "8080:80" + restart: always + expose: + - "80" + networks: + - caddy_shared volumes: - ./site:/usr/share/nginx/html:ro diff --git a/lib/sessionDefaults.ts b/lib/sessionDefaults.ts new file mode 100644 index 0000000..0ef6149 --- /dev/null +++ b/lib/sessionDefaults.ts @@ -0,0 +1,2 @@ +export const DEFAULT_FOCUS_DURATION_MINUTES = 25; +export const DEFAULT_SHORT_BREAK_DURATION_MINUTES = 5; diff --git a/notes/vision-gap-closure-plan-2026-05-03.md b/notes/vision-gap-closure-plan-2026-05-03.md index 64c955f..7504c3e 100644 --- a/notes/vision-gap-closure-plan-2026-05-03.md +++ b/notes/vision-gap-closure-plan-2026-05-03.md @@ -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 ### #VisionGap diff --git a/notes/work-report-timer-2026-05-04.md b/notes/work-report-timer-2026-05-04.md new file mode 100644 index 0000000..1995698 --- /dev/null +++ b/notes/work-report-timer-2026-05-04.md @@ -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 +```