Mobile / React Native
Building Sisyphus — A Recurring Task Tracker with Expo and SQLite
A local-first React Native app for tracking periodic recurring tasks — things like watering plants, cleaning blinds, dusting shelves — where completing a task simply schedules the next one.
The whole thing runs on-device with a SQLite database, no backend, no account required. The header is an animated boulder rolling up a hill to only roll back down as tasks cycle.
The Idea
A lot of recurring maintenance tasks don't fit neatly into a calendar. They're not "every Monday" — they're "every 11 days," "every 3 days," or "every time I remember to do it." Standard to-do apps treat them as one-off items. Completing the task means re-adding it. That friction is enough to stop using the app entirely.
Sisyphus takes a different approach: completing a task just advances its due date forward by its frequency. The task never disappears — it comes back around. The list stays ordered by what's due soonest, so the most urgent item is always at the top.
Architecture Overview
The stack is minimal — one screen, one database, no network calls.
Navigation is handled by Expo Router's Stack. The database is initialized
once in the root layout via SQLiteProvider and accessed anywhere in the tree
with useSQLiteContext(). Components talk to the database through a thin
service layer — no state management library, no context beyond what SQLite provides.
The Data Model
Each task has five fields. The scheduling logic lives entirely in
dueDate and frequencyDays.
export interface Task {
id: number;
taskName: string;
frequencyDays: number;
lastComplete: number; // epoch ms
dueDate: number; // epoch ms
}
The database table is created on first launch via the onInit callback on
SQLiteProvider. Using CREATE TABLE IF NOT EXISTS makes the
initialization idempotent — safe to call on every app start.
await db.execAsync(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
taskName TEXT NOT NULL,
frequencyDays INTEGER NOT NULL,
dueDate INTEGER NOT NULL,
lastComplete INTEGER);
`);
Dates are stored as epoch milliseconds rather than formatted strings.
This keeps sorting trivial (ORDER BY dueDate just works),
avoids locale parsing issues, and makes the "advance by N days" arithmetic
straightforward integer math.
The Task Service
All database operations live in services/TaskService.ts. Each function
takes the database handle as its first argument, keeping them pure and easy to follow.
The most important function is setTaskComplete. It doesn't delete the row —
it advances dueDate by the task's frequency and records the completion timestamp.
export async function setTaskComplete(db: SQLiteDatabase, task: Task) {
return await db.runAsync(
`UPDATE tasks SET dueDate = ?, lastComplete = ? WHERE id = ?;`,
Date.now() + task.frequencyDays * 24 * 60 * 60 * 1000,
Date.now(),
task.id);
}
Every mutation follows the same pattern in the calling component: run the service function, then re-fetch the full ordered task list and replace state. No optimistic updates, no partial syncs — the list always reflects what's in the database.
const handleCompleteTask = async (task: Task) => {
await TaskServices.setTaskComplete(db, task);
const result = await TaskServices.getDueDateOrderedTasks(db);
setTasks(result);
setCompletionCount(prev => prev + 1);
};
The completionCount increment is the only piece of state that isn't
derived from the database — it feeds the header animation independently of the task list.
The Animated Header
The TasksHeader component is where the app earns its name. A white circle
— the boulder — rolls along a diagonal line across the header. Every task completion
advances it one step. After every four completions it snaps back to the start.
The line is computed dynamically from the screen width and status bar height so it scales correctly across devices. The circle's positions are distributed along the line, offset perpendicular to it so the boulder sits above the track.
const lineStart = { x: PADDING, y: statusBarHeight + 125 };
const lineEnd = { x: width - PADDING, y: statusBarHeight + 18 };
const dx = lineEnd.x - lineStart.x;
const dy = lineEnd.y - lineStart.y;
const lineLength = Math.sqrt(dx * dx + dy * dy);
// unit normal pointing above the line
const nx = dy / lineLength;
const ny = -dx / lineLength;
const positions = Array.from({ length: STEPS - 1 }, (_, i) => ({
x: lineStart.x + (lineEnd.x - lineStart.x) * (i / (STEPS - 1)),
y: lineStart.y + (lineEnd.y - lineStart.y) * (i / (STEPS - 1))
+ CIRCLE_ABOVE_OFFSET * ny - CIRCLE_R,
}));
The animation uses React Native's Animated.Value with
useNativeDriver: true, so the translation runs on the UI thread
and doesn't block the JS thread during list re-renders.
useEffect(() => {
const step = completionCount % (STEPS - 1);
const isReset = completionCount > 0 && step === 0;
Animated.parallel([
Animated.timing(animX, {
toValue: positions[isReset ? 0 : step].x,
duration: 500,
useNativeDriver: true,
}),
Animated.timing(animY, {
toValue: positions[isReset ? 0 : step].y,
duration: 500,
useNativeDriver: true,
}),
]).start();
}, [completionCount]);
The myth works as a UI metaphor because it's accurate: the progress resets, and that's the point. The tasks come back. The boulder comes back. You do it again.
Task Rows and the FAB
The main screen is a FlatList of TaskRow components ordered
by dueDate. Each row shows the task name, its due date, and two action
buttons — a green checkmark to complete and a blue pencil to edit.
taskRowCompleted: {
backgroundColor: '#9bcb65', // green
},
taskRowEdit: {
backgroundColor: '#4A90E2', // blue — matches header and FAB
},
New tasks are created via a floating action button in the bottom-right corner. Tapping
it slides up a modal form. The same blue #4A90E2 is used for the FAB,
the header background, and every primary action button — a deliberately small palette.
Forms and Validation
Both NewTaskForm and EditTaskForm are modal sheets.
The new task form validates its two inputs before calling back to the parent:
- Task name — must be a non-empty string after trimming.
- Frequency — must parse as a positive integer. Floats and negatives are rejected at the validation step, not the database layer.
const parsedFrequency = parseInt(taskFrequency.trim(), 10);
const frequencyValid =
taskFrequency.trim().length > 0 &&
Number.isInteger(parsedFrequency) &&
parsedFrequency > 0;
if (nameValid && frequencyValid) {
onAddTask(taskName.trim(), parsedFrequency);
setTaskName('');
toggleFormVisibility();
}
The edit form adds a guarded delete: tapping "Delete Task" first flips a
confirmingDelete boolean, swapping the button for a two-step
Confirm / Cancel row. This prevents an accidental tap from removing a task with no undo.
Settings and Export
The settings screen is a single option: export all tasks as JSON. It uses
expo-file-system to write the file to the app's document directory, then
expo-sharing to hand it to the native share sheet. From there it can go
to Files, AirDrop, email — whatever the OS offers.
export async function exportTasksAsJson(db: SQLiteDatabase) {
const tasks = await getDueDateOrderedTasks(db);
const json = JSON.stringify(tasks, null, 2);
const path = FileSystem.documentDirectory + 'sisyphus-tasks.json';
await FileSystem.writeAsStringAsync(path, json, {
encoding: FileSystem.EncodingType.UTF8
});
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(path, {
mimeType: 'application/json',
dialogTitle: 'Export Tasks'
});
}
}
The export is a plain JSON array of task objects — no proprietary format, no encoding. Readable in any text editor, importable by any script.
Design Decisions Worth Noting
A few choices that shaped the final structure:
- No cloud sync. Everything is local. SQLite on the device is the only store. This keeps the app fast, private, and functional without a network connection. The JSON export exists as a manual escape hatch.
-
Epoch timestamps over date strings. Sorting, arithmetic, and
comparisons are all integer operations. Formatting happens once at render time
via
EpochToDDMMYYYY(). - State is always derived from the database. After every mutation the full task list is re-fetched. This trades a small amount of read overhead for correctness — there's no risk of the UI drifting from what's on disk.
Try It — Android APK
If you're on Android and comfortable enabling Install unknown apps in developer settings, you can sideload the app directly.
- On your device, go to Settings → Apps → Special app access → Install unknown apps and allow your browser.
- Download the APK below.
- Open the downloaded file and tap Install.
This is an unsigned release build distributed outside the Play Store. You are trusting me. Only do this if that's a trade-off you're comfortable with.
Download sisyphus.apkWhat This Stack Demonstrates
- Local-first mobile app design with on-device SQLite via expo-sqlite
- Epoch-based scheduling — dueDate advances by frequencyDays * 24h on each completion
- React Native Animated API with native driver for smooth, JS-thread-independent animation
- Expo Router's Stack navigator with SQLiteProvider wrapping the entire tree
- Modal forms with inline validation before any database call is made
- Two-step delete confirmation as a lightweight guard against accidental data loss
- expo-file-system + expo-sharing for native JSON export without a backend