Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions App.js
Original file line number Diff line number Diff line change
@@ -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"};

Expand All @@ -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(() => {
Expand All @@ -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]);

Expand Down Expand Up @@ -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}
});

3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

9 changes: 7 additions & 2 deletions src/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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."};
}
Expand All @@ -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);
}

7 changes: 6 additions & 1 deletion src/focusEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

11 changes: 11 additions & 0 deletions src/store.js
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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);
}
7 changes: 7 additions & 0 deletions test/analytics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
8 changes: 7 additions & 1 deletion test/focusEngine.test.js
Original file line number Diff line number Diff line change
@@ -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});
Expand All @@ -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);
});