diff --git a/App.js b/App.js index 6a22277..6282885 100644 --- a/App.js +++ b/App.js @@ -1,8 +1,8 @@ import React, {useEffect, useMemo, useState} from "react"; import {SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TextInput, TouchableOpacity, View} from "react-native"; import {summarize, recommendDuration} from "./src/analytics.js"; -import {createSession, remainingSeconds, SessionStatus, transition} from "./src/focusEngine.js"; -import {loadSessions, saveSessions} from "./src/store.js"; +import {createSession, recoverSession, remainingSeconds, SessionStatus, transition} from "./src/focusEngine.js"; +import {loadActiveSession, loadSessions, saveActiveSession, saveSessions} from "./src/store.js"; const palette = {bg:"#0c0c0b", panel:"#171714", line:"#2b2b25", text:"#f2f0e8", muted:"#9b9a8e", lime:"#d7ff64", red:"#ff806f"}; @@ -13,8 +13,16 @@ export default function App() { const [duration, setDuration] = useState(25); const [tab, setTab] = useState("focus"); const [now, setNow] = useState(Date.now()); + const [hydrated, setHydrated] = useState(false); - useEffect(() => { loadSessions().then(setSessions); }, []); + useEffect(() => { + Promise.all([loadSessions(), loadActiveSession()]).then(([stored, checkpoint]) => { + setSessions(stored); + setActive(recoverSession(checkpoint)); + setHydrated(true); + }); + }, []); + useEffect(() => { if (hydrated) saveActiveSession(active); }, [active, hydrated]); useEffect(() => { if (active?.status !== SessionStatus.RUNNING) return undefined; const timer = setInterval(() => { @@ -27,7 +35,7 @@ export default function App() { useEffect(() => { if (active && [SessionStatus.COMPLETED, SessionStatus.ABANDONED].includes(active.status)) { const next = [...sessions, active]; - setSessions(next); saveSessions(next); setActive(null); + setSessions(next); saveSessions(next); saveActiveSession(null); setActive(null); } }, [active?.status]); @@ -90,4 +98,3 @@ function formatTime(seconds){return `${String(Math.floor(seconds/60)).padStart(2 const styles = StyleSheet.create({ safe:{flex:1,backgroundColor:palette.bg},header:{paddingHorizontal:22,paddingTop:14,paddingBottom:12,flexDirection:"row",justifyContent:"space-between",alignItems:"center"},brand:{color:palette.text,fontSize:22,fontWeight:"900",letterSpacing:-1},streak:{color:palette.muted,fontSize:12},tabs:{flexDirection:"row",marginHorizontal:22,borderBottomWidth:1,borderBottomColor:palette.line},tab:{paddingVertical:12,marginRight:24},tabActive:{borderBottomWidth:2,borderBottomColor:palette.lime},tabText:{color:palette.muted,fontWeight:"700"},tabTextActive:{color:palette.text},content:{padding:22,paddingBottom:60},kicker:{color:palette.lime,fontSize:11,fontWeight:"900",letterSpacing:1.5,marginTop:20,marginBottom:12},hero:{color:palette.text,fontSize:44,lineHeight:46,fontWeight:"900",letterSpacing:-2.2,maxWidth:330},input:{backgroundColor:palette.panel,borderWidth:1,borderColor:palette.line,borderRadius:16,padding:18,color:palette.text,fontSize:17,marginTop:28},label:{color:palette.muted,fontSize:11,fontWeight:"800",letterSpacing:1.3,marginTop:26,marginBottom:10},durations:{flexDirection:"row",gap:8},duration:{flex:1,backgroundColor:palette.panel,borderWidth:1,borderColor:palette.line,borderRadius:14,paddingVertical:14,alignItems:"center"},durationActive:{backgroundColor:palette.lime,borderColor:palette.lime},durationText:{color:palette.text,fontSize:21,fontWeight:"900"},durationTextActive:{color:palette.bg},minute:{color:palette.muted,fontSize:10},primary:{backgroundColor:palette.lime,borderRadius:15,padding:18,alignItems:"center",marginTop:22},primaryText:{color:palette.bg,fontWeight:"900",fontSize:16},disabled:{opacity:.35},recommendation:{flexDirection:"row",gap:16,backgroundColor:palette.panel,borderRadius:16,padding:18,marginTop:24,borderWidth:1,borderColor:palette.line},recNumber:{color:palette.lime,fontWeight:"900",fontSize:20},recTitle:{color:palette.text,fontWeight:"800",marginBottom:5},recBody:{color:palette.muted,lineHeight:19,fontSize:13},sessionTitle:{color:palette.text,fontWeight:"900",fontSize:38,lineHeight:41,letterSpacing:-1.6},timerWrap:{marginVertical:60},timer:{color:palette.text,fontSize:78,fontWeight:"200",textAlign:"center",fontVariant:["tabular-nums"]},track:{height:5,backgroundColor:palette.line,borderRadius:4,overflow:"hidden",marginTop:24},fill:{height:"100%",backgroundColor:palette.lime},row:{flexDirection:"row",gap:10},secondary:{flex:1,borderRadius:14,borderWidth:1,borderColor:palette.line,padding:16,alignItems:"center"},secondaryText:{color:palette.text,fontWeight:"800"},end:{padding:16,alignItems:"center",marginTop:10},endText:{color:palette.red,fontWeight:"700"},interruptions:{color:palette.muted,textAlign:"center",marginTop:20},statGrid:{flexDirection:"row",gap:8,marginTop:28},stat:{flex:1,backgroundColor:palette.panel,borderRadius:14,padding:14,borderWidth:1,borderColor:palette.line},statValue:{color:palette.text,fontSize:24,fontWeight:"900"},statLabel:{color:palette.muted,fontSize:11,marginTop:3},chart:{height:170,flexDirection:"row",alignItems:"flex-end",justifyContent:"space-between",marginTop:34,borderBottomWidth:1,borderBottomColor:palette.line,paddingHorizontal:4},barColumn:{alignItems:"center",width:38},bar:{width:22,backgroundColor:palette.lime,borderTopLeftRadius:5,borderTopRightRadius:5},barValue:{color:palette.muted,fontSize:9,marginBottom:5},day:{color:palette.muted,fontSize:9,marginTop:7,marginBottom:-20},insightCard:{backgroundColor:palette.panel,borderRadius:16,padding:20,marginTop:50,borderWidth:1,borderColor:palette.line},insightLarge:{color:palette.lime,fontSize:26,fontWeight:"900",marginVertical:10} }); - diff --git a/README.md b/README.md index 2edf500..c7e7563 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,10 @@ Unplugged is a mobile focus companion built around intentional sessions instead of punitive app blocking. It runs a resilient timer, records interruptions, suggests smaller sessions when completion drops, and turns session history into useful weekly patterns. -Current state: the Expo app, local persistence, focus-state machine, interruption tracking, adaptive recommendations, weekly analytics, and unit tests are working. Notification reminders and cross-device sync are still being tested. +Current state: the Expo app, resilient active-session checkpoints, background timer recovery, interruption tracking, adaptive recommendations, weekly analytics, and unit tests are working. Notification reminders and cross-device sync are still being tested. ```bash npm install npm test npm start ``` - diff --git a/src/analytics.js b/src/analytics.js index b237b5b..1f3838b 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -27,11 +27,17 @@ export function recommendDuration(sessions, fallback = 25) { const finished = sessions.filter(session => [SessionStatus.COMPLETED, SessionStatus.ABANDONED].includes(session.status)).slice(-12); if (finished.length < 3) return {minutes: fallback, reason: "Complete a few sessions to personalize this."}; const completionRate = finished.filter(session => session.status === SessionStatus.COMPLETED).length / finished.length; + const interruptionRate = finished.reduce((total, session) => total + session.interruptions.length, 0) / finished.length; const completedDurations = finished .filter(session => session.status === SessionStatus.COMPLETED) .map(session => Math.round(session.targetSeconds / 60)); const median = completedDurations.length ? medianOf(completedDurations) : fallback; - if (completionRate < 0.5) return {minutes: Math.max(10, median - 5), reason: "Recent sessions often ended early, so the next block is smaller."}; + if (completionRate < 0.5 || interruptionRate >= 2) return { + minutes: Math.max(10, median - 5), + reason: completionRate < 0.5 + ? "Recent sessions often ended early, so the next block is smaller." + : "Recent sessions had frequent interruptions, so the next block is easier to protect." + }; if (completionRate > 0.8) return {minutes: Math.min(60, median + 5), reason: "You have been finishing consistently; try a slightly longer block."}; return {minutes: median, reason: "This length matches your recent completed sessions."}; } @@ -58,4 +64,3 @@ function medianOf(values) { const middle = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2); } - diff --git a/src/focusEngine.js b/src/focusEngine.js index 9ea0533..f262039 100644 --- a/src/focusEngine.js +++ b/src/focusEngine.js @@ -63,7 +63,12 @@ export function transition(session, event, now = Date.now()) { } } +export function recoverSession(session, now = Date.now()) { + if (!session || !Object.values(SessionStatus).includes(session.status)) return null; + if (session.status !== SessionStatus.RUNNING) return session; + return transition(session, {type: "tick"}, now); +} + function assertStatus(session, expected) { if (session.status !== expected) throw new Error(`expected ${expected}, got ${session.status}`); } - diff --git a/src/store.js b/src/store.js index 2e00367..67ffb46 100644 --- a/src/store.js +++ b/src/store.js @@ -1,6 +1,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; const KEY = "unplugged:sessions:v1"; +const ACTIVE_KEY = "unplugged:active:v1"; export async function loadSessions() { const raw = await AsyncStorage.getItem(KEY); @@ -19,3 +20,13 @@ export async function saveSessions(sessions) { return trimmed; } +export async function loadActiveSession() { + const raw = await AsyncStorage.getItem(ACTIVE_KEY); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } +} + +export async function saveActiveSession(session) { + if (session) await AsyncStorage.setItem(ACTIVE_KEY, JSON.stringify(session)); + else await AsyncStorage.removeItem(ACTIVE_KEY); +} diff --git a/test/analytics.test.js b/test/analytics.test.js index afd7c39..3be4c73 100644 --- a/test/analytics.test.js +++ b/test/analytics.test.js @@ -25,3 +25,10 @@ test("recommendation contracts after abandoned sessions", () => { assert.equal(recommendDuration(sessions).minutes,20); }); +test("recommendation contracts when interruptions are frequent", () => { + const sessions = [session(SessionStatus.COMPLETED,25,"phone"),session(SessionStatus.COMPLETED,25,"phone"),session(SessionStatus.COMPLETED,25,"phone")]; + sessions.forEach(value => value.interruptions.push({reason:"noise",at:now})); + const result = recommendDuration(sessions); + assert.equal(result.minutes,20); + assert.match(result.reason,/interruptions/); +}); diff --git a/test/focusEngine.test.js b/test/focusEngine.test.js index 6014c5a..38d96e4 100644 --- a/test/focusEngine.test.js +++ b/test/focusEngine.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import {createSession, elapsedSeconds, remainingSeconds, SessionStatus, transition} from "../src/focusEngine.js"; +import {createSession, elapsedSeconds, recoverSession, remainingSeconds, SessionStatus, transition} from "../src/focusEngine.js"; test("timer survives pauses without counting paused time", () => { const session = createSession({id:"1", intention:"Study", durationMinutes:5, startedAt:1_000}); @@ -23,3 +23,9 @@ test("interruptions preserve reason and timestamp", () => { assert.deepEqual(changed.interruptions[0], {at:12_000, reason:"notification"}); }); +test("a running checkpoint completes while the app is suspended", () => { + const session = createSession({id:"1", intention:"Study", durationMinutes:5, startedAt:0}); + const recovered = recoverSession(session, 360_000); + assert.equal(recovered.status, SessionStatus.COMPLETED); + assert.equal(recovered.elapsedBeforeRun, 300); +});