TimeTrackly is a single-user, local-first time tracker. This document explains what we built and why.
- All data stays on your machine (privacy by design)
- No authentication, no syncing complexity (simplicity)
- Works completely offline (reliability)
- No enterprise complexity like distributed locks or horizontal scaling (appropriateness)
- Frontend (HTML/JavaScript) and backend (Node.js) both run on your machine
- Server is the source of truth for all data
- No cloud dependencies, no APIs
- Why: ~2K lines doesn't justify 40-70KB framework overhead + build step + learning curve.
- What: 8 ES6 modules (constants, utils, state, api, ui, reports, app, logger) with direct DOM manipulation.
- Tradeoff: Manual DOM re-rendering. Solution: discipline + always call
renderActiveTimers()after state changes.
- Why: Single-user, offline-first. Database adds setup complexity + dependencies for <100KB data. Human-readable files are easier to inspect and backup.
- What: Three files:
mtt-data.json(historical entries),mtt-active-state.json(active timers),mtt-suggestions.json(user suggestions). - Data Structures:
- Historical entry (
mtt-data.json):{ "project": "string", "task": "string", "totalDurationMs": number, "durationSeconds": number, "endTime": "ISO 8601", "createdAt": "ISO 8601", "notes": "string" } - Active timer (
mtt-active-state.json):{ "uuid-key": { "project": "string", "task": "string", "startTime": "ISO 8601 or null", "accumulatedMs": number, "isPaused": boolean, "notes": "string" } }
- Historical entry (
- Tradeoffs: No query language. Manual validation. Scales to ~10MB; path to SQLite exists if needed.
-
Why: ~2K lines doesn't need Redux (15KB + ceremony), MobX (magical), Zustand (extra dependency), or Context API (React-only).
-
What: Single
state.jsexports an object all modules import and mutate:export const state = { historicalEntries: [], activeTimers: {}, predefinedSuggestions: [], timerInterval: null, activeChartInstances: [], }; // Used everywhere import { state } from "./state.js"; state.activeTimers[id] = newTimer;
-
ES6 module caching: First import creates it once; all imports share the same reference.
-
Tradeoffs: No time-travel debugging or middleware. Can accidentally mutate without re-rendering (solution: discipline).
- Frontend loads
app.js - Fetch suggestions → historical entries → active timers from server
- Initialize UI lifecycle handlers
- User clicks button →
ui.jsevent handler - Mutate
state.activeTimers - POST
activeTimersto server (atomic write) - Call
renderActiveTimers()to update DOM - Show error toast on failure, rollback state
- Calculate final duration
- Move timer from
activeTimerstohistoricalEntries - POST both changes to server (atomic writes)
- Update DOM
- On failure: rollback both changes, show error
- Node.js Server: Built-ins only:
http,fs.promises,path - Core responsibilities:
- Serve frontend HTML/CSS/JS
- Validate incoming data (reject bad structure)
- Write files atomically (temp file → rename)
- Simple file locking (prevent concurrent writes)
- Structured JSON logging for debugging
- Health monitoring endpoint (
/api/health)
- Atomic writes: Prevent corruption if power fails mid-write; old file remains untouched.
- File locking: Prevents two writes at once; sufficient for single-user.
- Try-catch blocks with user notifications
- Global error and unhandledrejection handlers
- Rollback state on server save failure
- Graceful degradation if server unavailable
const previousState = { isPaused: timer.isPaused };
try {
timer.isPaused = !timer.isPaused;
await saveActiveStateToServer();
renderActiveTimers();
} catch (error) {
timer.isPaused = previousState.isPaused; // Undo
renderActiveTimers();
showError("Failed to toggle timer");
}- Pause timer updates when tab hidden (visibility API)
- Warn users before closing with active timers (beforeunload)
- Destroy Chart.js instances before recreating
- Clean up intervals and event listeners
- Sanitize dangerous chars (
<>"') - Max length limits (100 chars for project/task)
- Proper CSV escaping in exports
- Validate ISO 8601 dates with fallback
state.jshandles state, never touches DOMui.jsreads state and renders, never does API callsapi.jshandles network, never mutates state directlyutils.jspure functions (formatting, validation)app.jswires everything together
- Named constants replace magic numbers
- JSDoc type hints for IDE autocomplete
- Consistent error handling across modules
- Comprehensive test coverage (unit + E2E + API)
- Sections reduce visual clutter:
- Start New Timer: Collapsed by default (minimize distraction)
- Active Timers: Expanded by default (main feature)
- Export Data: Collapsed by default (infrequent use)
- All sections use smooth CSS transitions and icons for feedback.
- Suggestions come from two sources:
- User-editable
mtt-suggestions.jsonfile - Most recent unique
Project / Taskentries from history
- User-editable
- Users can customize suggestions by editing the JSON file and refreshing the browser.
- Separate tab with two charts (via Chart.js CDN):
- Project Distribution: Doughnut chart showing time per project
- Daily Time Logged: Bar chart for last 7 days
- Deterministic color generation ensures the same project always gets the same color across charts.
GET /api/health
Error Handling:
- Try-catch blocks with user notifications
- Global error and unhandledrejection handlers
- Rollback state on server save failure
- Graceful degradation if server unavailable
State Rollback Pattern:
const previousState = { isPaused: timer.isPaused };
try {
timer.isPaused = !timer.isPaused;
await saveActiveStateToServer();
renderActiveTimers();
} catch (error) {
timer.isPaused = previousState.isPaused; // Undo
renderActiveTimers();
showError("Failed to toggle timer");
}Memory Management:
- Pause timer updates when tab hidden (visibility API)
- Warn users before closing with active timers (beforeunload)
- Destroy Chart.js instances before recreating
- Clean up intervals and event listeners
Input Safety:
- Sanitize dangerous chars (
<>"') - Max length limits (100 chars for project/task)
- Proper CSV escaping in exports
- Validate ISO 8601 dates with fallback
Separation of Concerns:
state.jshandles state, never touches DOMui.jsreads state and renders, never does API callsapi.jshandles network, never mutates state directlyutils.jspure functions (formatting, validation)app.jswires everything together
Code Quality:
- Named constants replace magic numbers
- JSDoc type hints for IDE autocomplete
- Consistent error handling across modules
- Comprehensive test coverage (unit + E2E + API)
Sections reduce visual clutter:
- Start New Timer - Collapsed by default (minimize distraction)
- Active Timers - Expanded by default (main feature)
- Export Data - Collapsed by default (infrequent use)
All sections use smooth CSS transitions and icons for feedback.
Suggestions come from two sources:
- User-editable
mtt-suggestions.jsonfile - Most recent unique
Project / Taskentries from history
Users can customize suggestions by editing the JSON file and refreshing the browser.
Separate tab with two charts (via Chart.js CDN):
- Project Distribution - Doughnut chart showing time per project
- Daily Time Logged - Bar chart for last 7 days
Deterministic color generation ensures the same project always gets the same color across charts.
Endpoint: GET /api/health
{
"status": "ok",
"timestamp": "2025-11-07T12:00:00.000Z",
"uptime": 123.456,
"dataFiles": {
"data": true,
"activeState": true,
"suggestions": true
}
}- Check server health with
npm run healthor visithttp://localhost:13331/api/health
- Keep singleton approach (still works fine)
- Or split
state.jsinto multiple objects (per feature) - Or add tiny library like Zustand (~2KB, minimal refactor)
- Switch to real database (replace
api.js, keepstate.jsstructure) - Or add sync layer (Yjs/Automerge)
- Current code structure enables these paths because state, API, and UI are already separated.
- API Reference - Frontend module API reference
- Tests - Testing guide and patterns
- Development Workflow - Development workflow