focus-tracking

Implementation patterns for the pomodoro-dashboard timer engine, stats aggregation, and localStorage persistence layer. Use when building or modifying the core tracking logic.

7 stars

Best use case

focus-tracking is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

Implementation patterns for the pomodoro-dashboard timer engine, stats aggregation, and localStorage persistence layer. Use when building or modifying the core tracking logic.

Teams using focus-tracking should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/focus-tracking/SKILL.md --create-dirs "https://raw.githubusercontent.com/heldernoid/agentic-build-templates/main/projects/automation-productivity/pomodoro-dashboard/skills/focus-tracking/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/focus-tracking/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How focus-tracking Compares

Feature / Agentfocus-trackingStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

Implementation patterns for the pomodoro-dashboard timer engine, stats aggregation, and localStorage persistence layer. Use when building or modifying the core tracking logic.

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# focus-tracking Skill

## When to use

Use this skill when implementing or modifying:
- The `TimerEngine` class in `src/lib/timer.ts`
- The `storage.ts` localStorage wrappers
- Session recording and stats aggregation in `src/lib/stats.ts`
- The heatmap data builder
- The streak counter

## TimerEngine implementation

```typescript
// src/lib/timer.ts
export type Phase = 'pomodoro' | 'short_break' | 'long_break' | 'idle';

interface TimerEngineOptions {
  onTick: (remainingMs: number) => void;
  onComplete: () => void;
}

export class TimerEngine {
  private intervalId: ReturnType<typeof setInterval> | null = null;
  private endTime: number | null = null;
  private remainingMs: number;
  private paused = false;

  constructor(
    private durationMs: number,
    private options: TimerEngineOptions,
  ) {
    this.remainingMs = durationMs;
  }

  start(): void {
    if (this.intervalId !== null) return;
    this.endTime = Date.now() + this.remainingMs;
    this.paused = false;
    this.intervalId = setInterval(() => this.tick(), 250);
  }

  pause(): void {
    if (this.intervalId === null || this.endTime === null) return;
    this.remainingMs = Math.max(0, this.endTime - Date.now());
    clearInterval(this.intervalId);
    this.intervalId = null;
    this.paused = true;
  }

  resume(): void {
    this.start();
  }

  reset(): void {
    this.pause();
    this.remainingMs = this.durationMs;
    this.paused = false;
    this.endTime = null;
    this.options.onTick(this.remainingMs);
  }

  skip(): void {
    this.pause();
    this.options.onComplete();
  }

  setDuration(ms: number): void {
    this.durationMs = ms;
    this.reset();
  }

  getState(): { remainingMs: number; paused: boolean; endTime: number | null } {
    const remaining = this.endTime !== null && !this.paused
      ? Math.max(0, this.endTime - Date.now())
      : this.remainingMs;
    return { remainingMs: remaining, paused: this.paused, endTime: this.endTime };
  }

  private tick(): void {
    if (this.endTime === null) return;
    const remaining = this.endTime - Date.now();
    if (remaining <= 0) {
      clearInterval(this.intervalId!);
      this.intervalId = null;
      this.remainingMs = 0;
      this.options.onTick(0);
      this.options.onComplete();
    } else {
      this.options.onTick(remaining);
    }
  }
}
```

## Storage helpers

```typescript
// src/lib/storage.ts
import type { Settings, Task, Session, TimerState } from '../types/index.ts';

const PREFIX = 'pomo_';

function get<T>(key: string, fallback: T): T {
  try {
    const raw = localStorage.getItem(PREFIX + key);
    return raw !== null ? (JSON.parse(raw) as T) : fallback;
  } catch {
    return fallback;
  }
}

function set<T>(key: string, value: T): void {
  localStorage.setItem(PREFIX + key, JSON.stringify(value));
}

export const storage = {
  getSettings: (): Settings => get<Settings>('settings', defaultSettings()),
  setSettings: (s: Settings): void => set('settings', s),

  getTasks: (): Task[] => get<Task[]>('tasks', []),
  setTasks: (tasks: Task[]): void => set('tasks', tasks),

  getSessions: (): Session[] => get<Session[]>('sessions', []),
  addSession: (session: Session): void => {
    const sessions = storage.getSessions();
    sessions.push(session);
    set('sessions', sessions);
  },

  getTimerState: (): TimerState | null => get<TimerState | null>('state', null),
  setTimerState: (state: TimerState): void => set('state', state),
  clearTimerState: (): void => localStorage.removeItem(PREFIX + 'state'),
};

function defaultSettings(): Settings {
  return {
    pomodoroDuration: 25,
    shortBreakDuration: 5,
    longBreakDuration: 15,
    longBreakInterval: 4,
    autoStartBreaks: true,
    autoStartPomodoros: false,
    tickSound: false,
    alarmSound: true,
    alarmVolume: 0.7,
    desktopNotifications: true,
    theme: 'system',
  };
}
```

## Session recording

```typescript
// Called when a pomodoro completes normally
function recordSession(
  type: Session['type'],
  activeTaskId: string | null,
  durationMinutes: number,
  startedAt: Date,
  interrupted: boolean,
): void {
  const session: Session = {
    id: crypto.randomUUID(),
    type,
    taskId: activeTaskId,
    startedAt: startedAt.toISOString(),
    completedAt: new Date().toISOString(),
    durationMinutes,
    interrupted,
  };
  storage.addSession(session);

  // Increment task completedPomodoros if linked
  if (type === 'pomodoro' && activeTaskId && !interrupted) {
    const tasks = storage.getTasks();
    const idx = tasks.findIndex(t => t.id === activeTaskId);
    if (idx !== -1) {
      tasks[idx].completedPomodoros += 1;
      storage.setTasks(tasks);
    }
  }
}
```

## Stats aggregation

```typescript
// src/lib/stats.ts
import type { Session } from '../types/index.ts';

export interface StatsResult {
  totalPomodoros: number;
  totalFocusMinutes: number;
  streak: number;
  byDay: Record<string, number>; // ISO date -> count
}

function dateKey(iso: string): string {
  return iso.slice(0, 10); // YYYY-MM-DD
}

export function aggregateSessions(
  sessions: Session[],
  range: 'today' | 'week' | 'month' | 'all',
): StatsResult {
  const now = new Date();
  const todayKey = dateKey(now.toISOString());

  const filtered = sessions.filter(s => {
    if (s.type !== 'pomodoro' || s.interrupted) return false;
    const key = dateKey(s.completedAt);
    if (range === 'today') return key === todayKey;
    if (range === 'week') {
      const d = new Date(s.completedAt);
      const diffDays = (now.getTime() - d.getTime()) / 86400000;
      return diffDays < 7;
    }
    if (range === 'month') {
      return s.completedAt.slice(0, 7) === todayKey.slice(0, 7);
    }
    return true;
  });

  const byDay: Record<string, number> = {};
  let totalFocusMinutes = 0;
  for (const s of filtered) {
    const key = dateKey(s.completedAt);
    byDay[key] = (byDay[key] ?? 0) + 1;
    totalFocusMinutes += s.durationMinutes;
  }

  return {
    totalPomodoros: filtered.length,
    totalFocusMinutes,
    streak: currentStreak(sessions),
    byDay,
  };
}

export function currentStreak(sessions: Session[]): number {
  const completedDays = new Set(
    sessions
      .filter(s => s.type === 'pomodoro' && !s.interrupted)
      .map(s => dateKey(s.completedAt)),
  );

  let streak = 0;
  const today = new Date();
  for (let i = 0; i < 366; i++) {
    const d = new Date(today);
    d.setDate(today.getDate() - i);
    const key = d.toISOString().slice(0, 10);
    if (completedDays.has(key)) {
      streak++;
    } else if (i > 0) {
      // Allow missing today (session not yet completed)
      break;
    }
  }
  return streak;
}

export function buildHeatmap(sessions: Session[], weeks: number): number[][] {
  const byDay: Record<string, number> = {};
  for (const s of sessions) {
    if (s.type === 'pomodoro' && !s.interrupted) {
      const key = dateKey(s.completedAt);
      byDay[key] = (byDay[key] ?? 0) + 1;
    }
  }

  const today = new Date();
  // Align to the start of the week grid
  const grid: number[][] = [];
  for (let w = weeks - 1; w >= 0; w--) {
    const week: number[] = [];
    for (let d = 0; d < 7; d++) {
      const date = new Date(today);
      date.setDate(today.getDate() - (w * 7) - (6 - d));
      const key = date.toISOString().slice(0, 10);
      week.push(byDay[key] ?? 0);
    }
    grid.push(week);
  }
  return grid;
}
```

## Audio implementation

```typescript
// src/lib/audio.ts
let ctx: AudioContext | null = null;

function getCtx(): AudioContext {
  if (!ctx) ctx = new AudioContext();
  if (ctx.state === 'suspended') void ctx.resume();
  return ctx;
}

export function tick(volume: number): void {
  const c = getCtx();
  const osc = c.createOscillator();
  const gain = c.createGain();
  osc.connect(gain);
  gain.connect(c.destination);
  osc.frequency.value = 1000;
  gain.gain.setValueAtTime(volume * 0.1, c.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, c.currentTime + 0.08);
  osc.start(c.currentTime);
  osc.stop(c.currentTime + 0.08);
}

export function alarm(volume: number): void {
  const c = getCtx();
  const times = [0, 0.3, 0.6];
  for (const t of times) {
    const osc = c.createOscillator();
    const gain = c.createGain();
    osc.connect(gain);
    gain.connect(c.destination);
    osc.frequency.value = 880;
    osc.type = 'sine';
    gain.gain.setValueAtTime(volume, c.currentTime + t);
    gain.gain.exponentialRampToValueAtTime(0.001, c.currentTime + t + 0.25);
    osc.start(c.currentTime + t);
    osc.stop(c.currentTime + t + 0.25);
  }
}
```

## Phase transition logic

```typescript
function nextPhase(
  currentPhase: Phase,
  pomodoroCount: number,
  longBreakInterval: number,
): { phase: Phase; newCount: number } {
  if (currentPhase === 'pomodoro') {
    const newCount = pomodoroCount + 1;
    if (newCount % longBreakInterval === 0) {
      return { phase: 'long_break', newCount };
    }
    return { phase: 'short_break', newCount };
  }
  // break -> pomodoro
  return { phase: 'pomodoro', newCount: pomodoroCount };
}
```

## Persisting timer state on each tick

```typescript
// In the React timer component, on every tick:
useEffect(() => {
  if (!engine) return;
  const state: TimerState = {
    phase,
    endTime: engine.getState().endTime,
    remainingMs: engine.getState().remainingMs,
    paused: engine.getState().paused,
    pomodoroCount,
    activeTaskId,
  };
  storage.setTimerState(state);
}, [remainingMs]);
```

## Restoring timer state on mount

```typescript
useEffect(() => {
  const saved = storage.getTimerState();
  if (!saved) return;

  setPhase(saved.phase);
  setPomodoroCount(saved.pomodoroCount);
  setActiveTaskId(saved.activeTaskId);

  if (saved.paused || saved.endTime === null) {
    // Restore paused state
    setRemainingMs(saved.remainingMs);
  } else {
    // Timer was running - check if it expired while away
    const now = Date.now();
    if (saved.endTime <= now) {
      // Session would have completed; record as interrupted
      recordSession(saved.phase === 'pomodoro' ? 'pomodoro' : 'short_break',
        saved.activeTaskId, 0, new Date(saved.endTime - saved.remainingMs), true);
      // Advance to next phase
      const { phase: next } = nextPhase(saved.phase, saved.pomodoroCount, settings.longBreakInterval);
      setPhase(next);
    } else {
      // Resume with correct remaining time
      setRemainingMs(saved.endTime - now);
      engine?.resume();
    }
  }
}, []);
```

Related Skills

Skill: Uptime Monitoring

7
from heldernoid/agentic-build-templates

## Overview

Skill: Status Page

7
from heldernoid/agentic-build-templates

## Overview

Skill: unit-conversion

7
from heldernoid/agentic-build-templates

## Overview

Skill: recipe-scaler

7
from heldernoid/agentic-build-templates

## Overview

reading-list

7
from heldernoid/agentic-build-templates

Operate the reading-list API to save, manage, tag, search, and export articles.

email-digest

7
from heldernoid/agentic-build-templates

Configure, test, and troubleshoot the reading-list daily email digest delivered via nodemailer.

websocket-realtime

7
from heldernoid/agentic-build-templates

Use the WebSocket connection in poll-builder to receive live vote updates. Use when you need to stream real-time poll results, monitor a poll for new votes, or build a live dashboard. Triggers include "live results", "real-time updates", "stream votes", "watch poll", or "WebSocket".

poll-builder

7
from heldernoid/agentic-build-templates

Self-hosted poll creation tool with real-time results. Use when you need to create a poll, check vote counts, close a poll, export results, or get the shareable link for a poll. Triggers include "create poll", "vote", "poll results", "survey", "collect votes", "share poll", or any task involving polling or voting.

Skill: personal-finance

7
from heldernoid/agentic-build-templates

## Overview

Skill: csv-import

7
from heldernoid/agentic-build-templates

## Overview

Skill: Syntax Highlighting

7
from heldernoid/agentic-build-templates

## Purpose

Skill: Pastebin Core

7
from heldernoid/agentic-build-templates

## Purpose