polished and finalised onboarding flow
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { getSetupStatus } from "@/lib/setupStatus";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { Session } from "@supabase/supabase-js";
|
||||
import * as Notifications from 'expo-notifications';
|
||||
@@ -32,6 +33,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 +54,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,6 +84,10 @@ export default function TabLayout() {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
return <Redirect href="/setup" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
@@ -68,4 +97,4 @@ export default function TabLayout() {
|
||||
<Tabs.Screen name="subjects" options={{title: "Subjects"}} />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ 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 { getSetupStatus } from '@/lib/setupStatus';
|
||||
import { finalizeStoredSession } from '@/lib/sessionLifecycle';
|
||||
import { supabase } from "@/lib/supabase";
|
||||
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 {
|
||||
Alert,
|
||||
@@ -164,6 +165,7 @@ export default function HomeScreen() {
|
||||
const [isFlowInfoVisible, setIsFlowInfoVisible] = useState(false);
|
||||
const [completingTaskId, setCompletingTaskId] = useState<string | null>(null);
|
||||
const [subjectCount, setSubjectCount] = useState(0);
|
||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||
|
||||
const loadActiveSprint = useCallback(async () => {
|
||||
const storedSprint = await GetActiveSession();
|
||||
@@ -479,6 +481,25 @@ export default function HomeScreen() {
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
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]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void loadActiveSprint();
|
||||
@@ -611,6 +632,14 @@ export default function HomeScreen() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (session && needsSetup === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
return <Redirect href="/setup" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg">
|
||||
<Stack.Screen
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { SUBJECT_COLORS } from '@/lib/subjectColors';
|
||||
import { getSetupStatus } from '@/lib/setupStatus';
|
||||
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 { Alert, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
@@ -36,6 +37,7 @@ 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);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
@@ -51,6 +53,25 @@ 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;
|
||||
|
||||
@@ -76,6 +97,14 @@ export default function Subjects() {
|
||||
}, [GetSubjects, session])
|
||||
);
|
||||
|
||||
if (session && needsSetup === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
return <Redirect href="/setup" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-app-bg">
|
||||
<Stack.Screen
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getSetupStatus } from "@/lib/setupStatus";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -49,7 +50,7 @@ export default function Login() {
|
||||
|
||||
SetIsLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
@@ -61,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 =
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { GetActiveSession, type ActiveSession } from '@/lib/asyncStorage';
|
||||
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, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
type SetupState = {
|
||||
@@ -40,8 +47,6 @@ const SETUP_STEPS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
type SetupStepKey = (typeof SETUP_STEPS)[number]['key'];
|
||||
|
||||
export default function SetupScreen() {
|
||||
const {
|
||||
subjectId: subjectIdParam,
|
||||
@@ -89,37 +94,10 @@ export default function SetupScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const [storedActiveSession, subjectResult, assignmentResult, taskResult, focusSessionResult] =
|
||||
await Promise.all([
|
||||
GetActiveSession(),
|
||||
supabase
|
||||
.from('subjects')
|
||||
.select('sId')
|
||||
.eq('uId', session.user.id)
|
||||
.order('lastChanged', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('assignments')
|
||||
.select('aId')
|
||||
.eq('uId', session.user.id)
|
||||
.order('lastChanged', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('tId')
|
||||
.eq('uId', session.user.id)
|
||||
.order('lastChanged', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('sprint_sessions')
|
||||
.select('sessionId', { count: 'exact', head: true })
|
||||
.eq('userId', session.user.id)
|
||||
.eq('sessionType', 'focus')
|
||||
.eq('status', 'completed'),
|
||||
]);
|
||||
const [storedActiveSession, status] = await Promise.all([
|
||||
GetActiveSession(),
|
||||
getSetupStatus(session.user.id),
|
||||
]);
|
||||
|
||||
if (storedActiveSession && storedActiveSession.endTime <= Date.now()) {
|
||||
await finalizeStoredSession('expired', storedActiveSession);
|
||||
@@ -129,10 +107,10 @@ export default function SetupScreen() {
|
||||
}
|
||||
|
||||
setSetupState({
|
||||
subjectId: subjectIdParam ?? subjectResult.data?.sId ?? null,
|
||||
assignmentId: assignmentIdParam ?? assignmentResult.data?.aId ?? null,
|
||||
taskId: taskIdParam ?? taskResult.data?.tId ?? null,
|
||||
completedFocusSessions: focusSessionResult.count ?? 0,
|
||||
subjectId: subjectIdParam ?? status.subjectId,
|
||||
assignmentId: assignmentIdParam ?? status.assignmentId,
|
||||
taskId: taskIdParam ?? status.taskId,
|
||||
completedFocusSessions: status.completedFocusSessions,
|
||||
});
|
||||
}, [assignmentIdParam, session?.user.id, subjectIdParam, taskIdParam]);
|
||||
|
||||
@@ -142,7 +120,7 @@ export default function SetupScreen() {
|
||||
}, [loadSetupState])
|
||||
);
|
||||
|
||||
const currentStep = useMemo<SetupStepKey>(() => {
|
||||
const currentStep: SetupStepKey = (() => {
|
||||
if (!setupState.subjectId) {
|
||||
return 'subject';
|
||||
}
|
||||
@@ -156,7 +134,7 @@ export default function SetupScreen() {
|
||||
}
|
||||
|
||||
return 'sprint';
|
||||
}, [setupState]);
|
||||
})();
|
||||
|
||||
const isSetupComplete =
|
||||
setupState.taskId !== null && setupState.completedFocusSessions > 0;
|
||||
@@ -223,14 +201,28 @@ export default function SetupScreen() {
|
||||
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: {
|
||||
tId: setupState.taskId,
|
||||
durationSeconds: '5',
|
||||
},
|
||||
params: shouldUseDemoSprint
|
||||
? {
|
||||
tId: setupState.taskId,
|
||||
durationSeconds: '5',
|
||||
onboardingDemo: 'true',
|
||||
}
|
||||
: {
|
||||
tId: setupState.taskId,
|
||||
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||
},
|
||||
});
|
||||
}, [currentStep, isSetupComplete, setupState]);
|
||||
}, [currentStep, isSetupComplete, session?.user.id, setupState]);
|
||||
|
||||
const primaryLabel = isSetupComplete
|
||||
? 'Go to dashboard'
|
||||
|
||||
@@ -139,14 +139,16 @@ export default function TimerScreen() {
|
||||
const cancelHoldIdRef = React.useRef(0);
|
||||
const cancelHoldStartedAtRef = React.useRef(0);
|
||||
|
||||
const { tId, sessionType: sessionTypeParam, durationMinutes, durationSeconds, returnTaskId, chooseDuration } = useLocalSearchParams<{
|
||||
const { tId, sessionType: sessionTypeParam, durationMinutes, durationSeconds, returnTaskId, chooseDuration, onboardingDemo } = useLocalSearchParams<{
|
||||
tId?: string;
|
||||
sessionType?: SessionType;
|
||||
durationMinutes?: string;
|
||||
durationSeconds?: string;
|
||||
returnTaskId?: string;
|
||||
chooseDuration?: string;
|
||||
onboardingDemo?: string;
|
||||
}>();
|
||||
const isOnboardingDemo = onboardingDemo === 'true';
|
||||
const timerOverlayHeight = Math.max(containerHeight, 1);
|
||||
const timerOverlayOffscreenY = timerOverlayHeight + 1000;
|
||||
const selectedSessionType: SessionType = sessionTypeParam ?? 'focus';
|
||||
@@ -484,13 +486,18 @@ export default function TimerScreen() {
|
||||
|
||||
setIsRunning(false);
|
||||
resetSessionValues();
|
||||
await finalizeSprintSession('completed', completedSession);
|
||||
|
||||
if (isOnboardingDemo && completedSessionType === 'focus') {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
setPostSessionPrompt({
|
||||
completedSessionType,
|
||||
returnTaskId: completedReturnTaskId,
|
||||
nextBreakType,
|
||||
});
|
||||
|
||||
await finalizeSprintSession('completed', completedSession);
|
||||
})();
|
||||
});
|
||||
});
|
||||
@@ -502,6 +509,7 @@ export default function TimerScreen() {
|
||||
currentSessionType,
|
||||
finalizeSprintSession,
|
||||
focusModeAnimation,
|
||||
isOnboardingDemo,
|
||||
resetSessionValues,
|
||||
syncStudyCycleAfterCompletion,
|
||||
taskDetailsAnimation,
|
||||
|
||||
@@ -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';
|
||||
@@ -125,9 +126,14 @@ 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 },
|
||||
params: {
|
||||
tId: result.data.tId,
|
||||
durationSeconds: '5',
|
||||
onboardingDemo: 'true',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
@@ -68,3 +69,12 @@ export async function GetStudyCycle() {
|
||||
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');
|
||||
}
|
||||
|
||||
84
lib/setupStatus.ts
Normal file
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,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,8 @@ After that, the scope shifted into reliability work because the remaining highes
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## #ImplementedFeatures
|
||||
@@ -111,6 +113,38 @@ The fixes were:
|
||||
|
||||
---
|
||||
|
||||
### #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.
|
||||
|
||||
---
|
||||
|
||||
## #ProblemsAndSetbacks
|
||||
|
||||
### #SessionTruthDivergence
|
||||
@@ -127,6 +161,13 @@ After the cycle and reliability changes landed, manual testing surfaced two smal
|
||||
|
||||
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
|
||||
@@ -142,8 +183,11 @@ The app now supports:
|
||||
- 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
|
||||
|
||||
At this point, the biggest remaining work in this area is no longer basic feature completion. It is verifying the edge cases that depend on real runtime behavior, such as backgrounding, reopen timing, and cross-screen recovery under actual app usage.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -161,6 +205,12 @@ exited successfully
|
||||
npx tsc --noEmit
|
||||
exited successfully
|
||||
|
||||
npm run lint
|
||||
exited successfully
|
||||
|
||||
npx tsc --noEmit
|
||||
exited successfully
|
||||
|
||||
npm run lint
|
||||
exited successfully
|
||||
```
|
||||
@@ -168,3 +218,8 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user