ggbro Plugins

Pomodoro Focus

com.ggbro.plugin.pomodoro · v3.0.0 · ui.toast, ui.panel, ui.sound, storage

Focus timer with live progress, focus/break sliders, phase notices, and sounds.

Sandbox example — full pack source. Everything below runs in the plugin iframe via window.ggbro. This is the complete plugin.

manifest.json

Raw
{
  "id": "com.ggbro.plugin.pomodoro",
  "name": "Pomodoro Focus",
  "version": "3.0.0",
  "author": "ggbro",
  "description": "Focus timer with live progress, focus/break sliders, phase notices, and sounds.",
  "main": "plugin.js",
  "styles": "styles.css",
  "permissions": ["ui.toast", "ui.panel", "ui.sound", "storage"]
}

plugin.js

Raw
/**
 * Pomodoro Focus v3 — live progress, sliders, phase notices.
 */
(async function () {
  var workMin = 25;
  var breakMin = 5;
  var phase = 'idle'; // idle | work | break | paused
  var pausedFrom = 'work';
  var cycles = 0;
  var timer = null;
  var tick = null;
  var endsAt = 0;
  var remainingMs = 0;
  var totalMs = 0;
  var soundOn = true;

  try {
    cycles = parseInt((await ggbro.storage.get('cycles')).value || '0', 10) || 0;
    var savedWork = parseInt((await ggbro.storage.get('workMin')).value || '25', 10);
    if (savedWork >= 5 && savedWork <= 60) workMin = savedWork;
    var savedBreak = parseInt((await ggbro.storage.get('breakMin')).value || '5', 10);
    if (savedBreak >= 1 && savedBreak <= 30) breakMin = savedBreak;
    soundOn = ((await ggbro.storage.get('soundOn')).value || '1') !== '0';
  } catch (e) {
    ggbro.log('storage', e && e.message);
  }

  function clearTimer() {
    if (timer) clearTimeout(timer);
    timer = null;
  }

  function clearTick() {
    if (tick) clearInterval(tick);
    tick = null;
  }

  function pctLeft() {
    if (!totalMs || phase === 'idle') return 0;
    var left = phase === 'paused' ? remainingMs : Math.max(0, endsAt - Date.now());
    return Math.round(((totalMs - left) / totalMs) * 100);
  }

  function clockLeft() {
    var left = phase === 'paused' ? remainingMs : Math.max(0, endsAt - Date.now());
    if (phase === 'idle') return '—';
    var s = Math.ceil(left / 1000);
    var m = Math.floor(s / 60);
    var r = s % 60;
    return m + ':' + (r < 10 ? '0' : '') + r;
  }

  function statusLine() {
    if (phase === 'idle') return 'Idle — press Start focus';
    if (phase === 'paused') return 'Paused · ' + clockLeft() + ' left (' + pausedFrom + ')';
    return (phase === 'work' ? 'Focus' : 'Break') + ' · ' + clockLeft() + ' left';
  }

  function phaseNotice() {
    if (phase === 'work') return { text: 'Focus mode — notifications can wait.', tone: 'success' };
    if (phase === 'break') return { text: 'Break time — stretch, water, look away.', tone: 'info' };
    if (phase === 'paused') return { text: 'Paused — resume when you’re ready.', tone: 'warning' };
    return { text: 'Pick a focus length, then Start. Progress updates live.', tone: 'info' };
  }

  async function paint() {
    var notice = phaseNotice();
    await ggbro.ui.registerPanel({
      id: 'timer',
      title: 'Pomodoro Focus',
      schema: [
        { type: 'notice', id: 'phase', text: notice.text, tone: notice.tone },
        { type: 'section', id: 's0', title: 'Status' },
        { type: 'text', id: 'status', label: 'Now', value: statusLine() },
        { type: 'progress', id: 'prog', label: 'Phase progress', value: pctLeft(), max: 100 },
        { type: 'text', id: 'cycles', label: 'Completed focuses', value: String(cycles) },
        { type: 'section', id: 's1', title: 'Durations' },
        {
          type: 'slider',
          id: 'workMin',
          label: 'Focus (minutes)',
          value: workMin,
          min: 5,
          max: 60,
          step: 5,
        },
        {
          type: 'slider',
          id: 'breakMin',
          label: 'Break (minutes)',
          value: breakMin,
          min: 1,
          max: 30,
          step: 1,
        },
        { type: 'toggle', id: 'sound', label: 'Play sound on phase change', value: soundOn },
        { type: 'section', id: 's2', title: 'Controls' },
        { type: 'button', id: 'start', label: phase === 'paused' ? 'Resume' : 'Start focus' },
        { type: 'button', id: 'pause', label: 'Pause' },
        { type: 'button', id: 'skip', label: 'Skip phase' },
        { type: 'button', id: 'reset', label: 'Reset timer' },
      ],
    });
  }

  function ding(id) {
    if (!soundOn) return;
    ggbro.playSound({ id: id || 'notification' }).catch(function () {});
  }

  function armTick() {
    clearTick();
    tick = setInterval(function () {
      if (phase === 'work' || phase === 'break') paint().catch(function () {});
    }, 1000);
  }

  function schedule(ms, fn) {
    clearTimer();
    totalMs = ms;
    endsAt = Date.now() + ms;
    remainingMs = ms;
    timer = setTimeout(fn, ms);
    armTick();
  }

  function startWork() {
    phase = 'work';
    ding('voiceJoin');
    ggbro.toast({ message: 'Focus ' + workMin + ' min — you’ve got this.', type: 'success' });
    schedule(workMin * 60 * 1000, onWorkDone);
    paint();
  }

  function onWorkDone() {
    cycles += 1;
    ggbro.storage.set('cycles', String(cycles)).catch(function () {});
    phase = 'break';
    ding('notification');
    ggbro.toast({
      message: 'Focus #' + cycles + ' done — ' + breakMin + ' min break.',
      type: 'success',
    });
    schedule(breakMin * 60 * 1000, onBreakDone);
    paint();
  }

  function onBreakDone() {
    phase = 'idle';
    clearTimer();
    clearTick();
    remainingMs = 0;
    totalMs = 0;
    ding('boing');
    ggbro.toast({ message: 'Break over — start another focus when ready.', type: 'info' });
    paint();
  }

  await paint();
  await ggbro.toast({
    message: 'Pomodoro v3 ready — live countdown in Settings → Plugins (or Apps).',
    type: 'info',
  });

  ggbro.onEvent(function (event, payload) {
    if (event !== 'panelAction' || !payload) return;
    var id = payload.fieldId;

    if (id === 'workMin') {
      var w = Math.max(5, Math.min(60, Number(payload.value) || 25));
      workMin = Math.round(w / 5) * 5 || 25;
      ggbro.storage.set('workMin', String(workMin)).catch(function () {});
      paint();
      ggbro.toast({ message: 'Focus length set to ' + workMin + ' min', type: 'info' });
      return;
    }

    if (id === 'breakMin') {
      breakMin = Math.max(1, Math.min(30, Number(payload.value) || 5));
      ggbro.storage.set('breakMin', String(breakMin)).catch(function () {});
      paint();
      ggbro.toast({ message: 'Break set to ' + breakMin + ' min', type: 'info' });
      return;
    }

    if (id === 'sound') {
      soundOn = !!payload.value;
      ggbro.storage.set('soundOn', soundOn ? '1' : '0').catch(function () {});
      return;
    }

    if (id === 'start') {
      if (phase === 'paused' && remainingMs > 0) {
        phase = pausedFrom;
        schedule(remainingMs, phase === 'work' ? onWorkDone : onBreakDone);
        ggbro.toast({ message: 'Resumed ' + phase, type: 'info' });
        paint();
        return;
      }
      if (phase === 'work' || phase === 'break') {
        ggbro.toast({ message: 'Already running — Pause or Skip first.', type: 'warning' });
        return;
      }
      startWork();
      return;
    }

    if (id === 'pause') {
      if (phase !== 'work' && phase !== 'break') {
        ggbro.toast({ message: 'Nothing to pause', type: 'warning' });
        return;
      }
      remainingMs = Math.max(0, endsAt - Date.now());
      pausedFrom = phase;
      clearTimer();
      clearTick();
      phase = 'paused';
      ggbro.toast({ message: 'Paused', type: 'warning' });
      paint();
      return;
    }

    if (id === 'skip') {
      clearTimer();
      clearTick();
      if (phase === 'work' || (phase === 'paused' && pausedFrom === 'work')) {
        onWorkDone();
      } else if (phase === 'break' || (phase === 'paused' && pausedFrom === 'break')) {
        onBreakDone();
      } else {
        ggbro.toast({ message: 'Nothing to skip', type: 'warning' });
      }
      return;
    }

    if (id === 'reset') {
      clearTimer();
      clearTick();
      phase = 'idle';
      remainingMs = 0;
      totalMs = 0;
      ggbro.toast({ message: 'Timer reset', type: 'info' });
      paint();
    }
  });
})();

styles.css

Raw
:root {
  --ggbro-plugin-pomodoro: 1;
}

README.md

Raw
# Pomodoro Focus (v2)

Interactive focus timer — does **not** auto-spam a 25m cycle on load.

Use the panel under **Settings → Plugins**:
- Start / Pause / Resume / Skip / Reset
- Focus length 15 / 25 / 45
- Optional phase-change sounds
- Completed focus count persisted locally