Minimal, offline-first task & reminder app
Web · Mobile · Desktop — one codebase to rule them all
- Project Overview
- Project Structure
- Quick Start (Web)
- Feature Guide
- Keyboard Shortcuts
- Data Model
- Platform Guides
- Cloud Sync (Supabase)
- Google Calendar Integration
- Push Notifications (Web)
- Deployment
- Customisation
- Roadmap
- Tech Stack Summary
Taskr is a distraction-free productivity tool built around three principles:
| Principle | Implementation |
|---|---|
| ⚡ Fast | Open → add task in < 2 sec via Quick Add bar |
| 📴 Offline | All data stored in localStorage — works with no internet |
| 🧩 Minimal | Only what matters: tasks, priorities, reminders, calendar |
- ✅ Full task CRUD (create / read / update / delete)
- ⏰ Due dates & times
- 🔁 Recurring tasks (daily / weekly / monthly)
- 📌 Pin important tasks to the top
- 🎯 Priority levels (High / Medium / Low) with colour coding
- 📅 Monthly calendar view with per-day task density
- 🔍 Full-text search across title and notes
- 📊 Today's progress bar
⚠️ Overdue task warning banner- 🌙 Dark / light mode (persisted)
- 📱 Responsive layout — sidebar on desktop, tab bar on mobile
- 💾 Offline-first via
localStorage
taskr/
├── index.html # App shell
├── vite.config.js # Vite bundler config
├── package.json
├── .gitignore
├── public/
│ └── manifest.json # PWA manifest
└── src/
├── main.jsx # React entry point
├── App.jsx # Root component, state, layout
├── hooks/
│ └── useLocalStorage.js # Persistent state hook
├── utils/
│ ├── helpers.js # uid(), todayISO(), relDate(), …
│ └── constants.js # Priority config, demo data, nav items
└── components/
├── TaskItem.jsx # Single task row + context menu
├── TaskModal.jsx # Add / edit modal
├── CalendarView.jsx # Monthly calendar + day detail
├── Sidebar.jsx # Desktop sidebar + mobile tab bar
├── QuickAdd.jsx # Inline quick-add strip
└── EmptyState.jsx # Empty-view illustration
| Tool | Minimum version | Install |
|---|---|---|
| Node.js | 18.x | https://nodejs.org or nvm install 18 |
| npm | 9.x | Bundled with Node |
# 1 — Clone / unzip the project
cd taskr
# 2 — Install dependencies (takes ~30 seconds)
npm install
# 3 — Start the dev server
npm run devThe app opens automatically at http://localhost:5173
npm run build # outputs to dist/
npm run preview # serve the production build locallyQuick Add (fastest path)
Type in the bar below the header → press Enter.
Creates a Medium-priority task due today.
Full modal
Click + New Task button (or press ⌘N / Ctrl+N).
Fill in title, notes, date, time, priority, recurrence, and pin toggle.
Press ⌘↩ to save or Esc to close.
| View | Shows |
|---|---|
| Today | Incomplete tasks due today |
| Upcoming | Incomplete tasks due after today |
| All Tasks | All incomplete tasks |
| Calendar | Monthly grid — click a day to inspect tasks |
| Completed | All completed tasks |
| Priority | Colour | Use for |
|---|---|---|
| High | Red | Blocking / urgent tasks |
| Medium | Orange | Normal work |
| Low | Green | Nice-to-have, housekeeping |
Set repeat to Daily / Weekly / Monthly in the modal.
The badge appears on the task row.
Note: auto-rescheduling on completion is on the roadmap (see §13).
Pin a task via the ⋯ context menu → Pin to top.
Pinned tasks always appear first, grouped separately.
| Shortcut | Action |
|---|---|
| ⌘N / Ctrl+N | Open new-task modal |
| ⌘↩ / Ctrl+↩ | Save modal form |
| Esc | Close modal |
| Enter | Submit Quick Add |
Tasks are stored as JSON in localStorage under the key taskr_v2_tasks.
interface Task {
id: string; // "t_<timestamp>_<random>"
title: string; // required
notes: string; // optional, free text
dueDate: string; // ISO date "YYYY-MM-DD"
dueTime: string; // "HH:MM" or ""
priority: "high" | "medium" | "low";
isCompleted: boolean;
isPinned: boolean;
recurring: "none" | "daily" | "weekly" | "monthly";
createdAt: string; // ISO datetime
}Additional persisted keys:
| Key | Value |
|---|---|
taskr_v2_filter |
active view filter |
taskr_v2_dark |
boolean (dark mode) |
Wrap the web build in Electron to get a native desktop window with system-tray support.
npm install --save-dev electron electron-builder concurrently wait-onconst { app, BrowserWindow, Tray, Menu, nativeImage } = require("electron");
const path = require("path");
let win, tray;
function createWindow() {
win = new BrowserWindow({
width: 1200,
height: 780,
minWidth: 420,
minHeight: 500,
titleBarStyle: "hiddenInset", // macOS native look
webPreferences: { nodeIntegration: false, contextIsolation: true },
icon: path.join(__dirname, "icon.png"),
});
// In dev, load Vite's dev server; in production, load built files
const isDev = process.env.NODE_ENV !== "production";
if (isDev) {
win.loadURL("http://localhost:5173");
win.webContents.openDevTools();
} else {
win.loadFile(path.join(__dirname, "../dist/index.html"));
}
}
function createTray() {
const icon = nativeImage.createFromPath(path.join(__dirname, "tray-icon.png"));
tray = new Tray(icon.resize({ width: 16 }));
const menu = Menu.buildFromTemplate([
{ label: "Open Taskr", click: () => win.show() },
{ label: "New Task", click: () => { win.show(); win.webContents.send("new-task"); } },
{ type: "separator" },
{ label: "Quit", click: () => app.quit() },
]);
tray.setContextMenu(menu);
tray.setToolTip("Taskr");
}
app.whenReady().then(() => {
createWindow();
createTray();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});"scripts": {
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron electron/main.js\"",
"electron:build": "npm run build && electron-builder"
},
"build": {
"appId": "com.yourname.taskr",
"productName": "Taskr",
"directories": { "output": "electron-dist" },
"files": ["dist/**/*", "electron/**/*"],
"mac": { "category": "public.app-category.productivity" },
"win": { "target": "nsis" },
"linux":{ "target": "AppImage" }
}npm run electron:dev # development
npm run electron:build # package for distributionThe logic (data model, hooks, utils) is identical. Only the UI components are rewritten using React Native primitives.
npx create-expo-app taskr-mobile --template blank
cd taskr-mobile
npx expo install @react-native-async-storage/async-storage
npx expo install expo-notifications
npx expo install expo-calendarCopy these files verbatim from the web project — they have zero browser dependencies:
src/utils/helpers.js → taskr-mobile/src/utils/helpers.js
src/utils/constants.js → taskr-mobile/src/utils/constants.js
// src/hooks/useAsyncStorage.js
import { useState, useEffect } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
export function useAsyncStorage(key, initial) {
const [value, setValue] = useState(initial);
useEffect(() => {
AsyncStorage.getItem(key)
.then((raw) => raw !== null && setValue(JSON.parse(raw)))
.catch(() => {});
}, [key]);
useEffect(() => {
AsyncStorage.setItem(key, JSON.stringify(value)).catch(() => {});
}, [key, value]);
return [value, setValue];
}| Web component | React Native equivalent |
|---|---|
<div> |
<View> |
<p>, <span> |
<Text> |
<button> |
<TouchableOpacity> or <Pressable> |
<input> |
<TextInput> |
<textarea> |
<TextInput multiline> |
<ul> / list |
<FlatList> (virtualised) |
import * as Notifications from "expo-notifications";
// Request permission on first launch
async function requestPermission() {
const { status } = await Notifications.requestPermissionsAsync();
return status === "granted";
}
// Schedule a reminder for a task
async function scheduleReminder(task) {
if (!task.dueDate || !task.dueTime) return;
const [h, m] = task.dueTime.split(":").map(Number);
const trigger = new Date(task.dueDate + "T" + task.dueTime);
trigger.setMinutes(trigger.getMinutes() - 15); // 15 min early
await Notifications.scheduleNotificationAsync({
content: {
title: "◈ Taskr Reminder",
body: task.title,
data: { taskId: task.id },
},
trigger,
});
}npx expo start # scan QR code with Expo Go
npx expo run:ios # requires macOS + Xcode
npx expo run:android # requires Android StudioSupabase gives you a Postgres database + real-time subscriptions + auth — all free up to 500MB.
- Create a project at https://supabase.com
- Run this SQL in the Supabase SQL editor:
create table tasks (
id text primary key,
user_id uuid references auth.users not null,
title text not null,
notes text default '',
due_date date,
due_time text default '',
priority text default 'medium',
is_completed boolean default false,
is_pinned boolean default false,
recurring text default 'none',
created_at timestamptz default now()
);
-- Row-level security: users see only their own tasks
alter table tasks enable row level security;
create policy "own tasks" on tasks
for all using (auth.uid() = user_id);- Install the client:
npm install @supabase/supabase-js- Create
src/lib/supabase.js:
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY
);- Create
.env.local(never commit this):
VITE_SUPABASE_URL=https://xxxx.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
Replace useLocalStorage writes with Supabase upserts:
// Save task
async function saveTaskRemote(task) {
const { data: { user } } = await supabase.auth.getUser();
await supabase.from("tasks").upsert({
...task,
user_id: user.id,
due_date: task.dueDate || null,
due_time: task.dueTime || "",
is_completed: task.isCompleted,
is_pinned: task.isPinned,
created_at: task.createdAt,
});
}
// Load tasks on startup
async function loadTasksRemote() {
const { data } = await supabase
.from("tasks")
.select("*")
.order("created_at", { ascending: false });
return data.map((row) => ({
...row,
dueDate: row.due_date || "",
dueTime: row.due_time || "",
isCompleted: row.is_completed,
isPinned: row.is_pinned,
createdAt: row.created_at,
}));
}Offline-first pattern: keep localStorage as the primary source; sync to Supabase in the background. On app load, check network — if online, merge remote → local.
Sync tasks to Google Calendar as events so they appear alongside your schedule.
- Go to https://console.cloud.google.com
- Create a project → Enable Google Calendar API
- Create OAuth 2.0 credentials (Web application)
- Add
http://localhost:5173to Authorised JavaScript origins
npm install @react-oauth/google// src/lib/googleAuth.js
const CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const SCOPE = "https://www.googleapis.com/auth/calendar.events";
export async function signInGoogle() {
return new Promise((resolve, reject) => {
const client = window.google.accounts.oauth2.initTokenClient({
client_id: CLIENT_ID,
scope: SCOPE,
callback: (resp) => resp.error ? reject(resp) : resolve(resp.access_token),
});
client.requestAccessToken();
});
}export async function pushTaskToCalendar(task, accessToken) {
if (!task.dueDate) return;
const start = task.dueTime
? { dateTime: `${task.dueDate}T${task.dueTime}:00`, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }
: { date: task.dueDate };
const end = task.dueTime
? { dateTime: `${task.dueDate}T${task.dueTime.replace(/(\d+)/, (h) => String(+h + 1).padStart(2, "0"))}:00` }
: { date: task.dueDate };
const body = {
summary: task.title,
description: task.notes || "",
start, end,
colorId: task.priority === "high" ? "11" : task.priority === "medium" ? "5" : "2",
};
const resp = await fetch("https://www.googleapis.com/calendar/v3/calendars/primary/events", {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return resp.json();
}Add VITE_GOOGLE_CLIENT_ID=your-client-id to .env.local.
Web Push lets reminders fire even when the browser tab is closed.
Create public/sw.js:
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title || "Taskr Reminder", {
body: data.body || "",
icon: "/icon-192.png",
badge: "/icon-192.png",
vibrate: [200, 100, 200],
})
);
});Register in your app:
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}Use the Notifications API for near-future reminders (same tab / PWA):
async function scheduleWebReminder(task) {
if (Notification.permission !== "granted") {
await Notification.requestPermission();
}
const [h, m] = (task.dueTime || "09:00").split(":").map(Number);
const fireAt = new Date(task.dueDate);
fireAt.setHours(h, m - 15, 0, 0); // 15 min before
const delay = fireAt.getTime() - Date.now();
if (delay < 0) return;
setTimeout(() => {
new Notification("◈ Taskr", { body: task.title, icon: "/icon-192.png" });
}, delay);
}npm install -g vercel
vercel --prodnpm run build
# Drag & drop the dist/ folder at netlify.com/drop
# — or —
npx netlify-cli deploy --prod --dir=distnpm install --save-dev gh-pagesAdd to package.json:
"homepage": "https://yourusername.github.io/taskr",
"scripts": {
"deploy": "npm run build && gh-pages -d dist"
}npm run deployFROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]docker build -t taskr .
docker run -p 8080:80 taskrEdit src/utils/constants.js → PRIORITY_CONFIG.
The main blue accent (#4f8ef7) appears inline in App.jsx and component files — do a project-wide find & replace.
Edit the @import URL in the GLOBAL_CSS constant inside App.jsx. Pair any Google Fonts combination you like.
- Add an entry to
NAV_ITEMSinconstants.js - Add a label to
FILTER_LABELSinconstants.js - Add a
casein theswitchblock insideApp.jsx'suseMemo
Add fields to the Task interface in the data model section above, extend the BLANK object in TaskModal.jsx, and add the corresponding <input> elements.
Planned features not yet implemented:
- Auto-reschedule recurring tasks on completion
- Drag-and-drop reordering
- Sub-tasks / checklist items
- Tags / labels
- Pomodoro timer integration
- Export to CSV / JSON
- End-to-end encryption for cloud sync
- Collaborative shared lists
- Siri / Google Assistant integration (mobile)
- Widget support (iOS / Android)
| Layer | Technology | Notes |
|---|---|---|
| UI framework | React 18 | Hooks-only, no class components |
| Bundler | Vite 5 | Sub-second HMR |
| Styling | Inline styles | Zero CSS-in-JS dependencies |
| Fonts | Bricolage Grotesque + JetBrains Mono | Via Google Fonts CDN |
| Storage | localStorage | Offline-first, zero setup |
| Desktop | Electron (optional) | See §7A |
| Mobile | React Native + Expo (optional) | See §7B |
| Cloud sync | Supabase (optional) | Postgres + realtime + auth |
| Calendar sync | Google Calendar API (optional) | See §9 |
| Notifications | Web Push + Service Worker | See §10 |
| Deployment | Vercel / Netlify / Docker | See §11 |
Made with ◈ — keep it minimal, keep it fast.