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.

Sisyphus app icon

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.

expo-router index (task list) · settings (export)
React Native components TasksHeader · TaskRow · NewTaskForm · EditTaskForm
TaskService insert · update · complete · delete · export
expo-sqlite sisyphus.db · tasks table

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.

models/Task.ts
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.

app/_layout.tsx · initTaskDb
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.

services/TaskService.ts · setTaskComplete
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.

app/index.tsx · handleCompleteTask
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.

components/TasksHeader.tsx · position math
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.

components/TasksHeader.tsx · animation effect
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.

components/TaskRow.tsx · button colors
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:

components/NewTaskForm.tsx · handleSubmit
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.

services/TaskService.ts · exportTasksAsJson
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:

The name is the design. Tasks that recur don't complete — they reset. The boulder always comes back down. The app makes that explicit rather than hiding it.

Try It — Android APK

If you're on Android and comfortable enabling Install unknown apps in developer settings, you can sideload the app directly.

  1. On your device, go to Settings → Apps → Special app access → Install unknown apps and allow your browser.
  2. Download the APK below.
  3. 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.apk

What This Stack Demonstrates