Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions pomodoro/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Pomodoro timer prototype ($200 bounty)

Single-file Chrome-focused pomodoro timer with background-capable chime.

## Files
- `index.html` — UI + timer + Web Audio scheduling

## How background audio works
1. **User gesture unlock**: first **Start** or **Unlock background audio** creates/resumes an `AudioContext` (required by Chrome autoplay policy).
2. **Scheduled chime**: when the timer starts, a short oscillator beep is scheduled at `audioContext.currentTime + remainingSeconds`. Web Audio scheduled nodes continue to fire when the tab is backgrounded for typical short pomodoro durations.
3. **Display accuracy**: `setInterval` updates the clock; on `visibilitychange` remaining time is recomputed from `performance.now()` so throttled tabs do not drift.
4. **Secondary alert**: optional `Notification` when permission is granted.

## Manual test
1. Open `index.html` in Chrome desktop.
2. Click **Unlock background audio** (or Start) once.
3. Set minutes to `1` (or use a short value and temporarily edit for 30s tests).
4. Start, switch to another tab/app, wait for end — chime should play.
5. Pause/resume and confirm countdown + reset after completion.
321 changes: 321 additions & 0 deletions pomodoro/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pomodoro Timer — Background Audio</title>
<style>
:root {
color-scheme: dark light;
--bg: #0f172a;
--card: #1e293b;
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
--danger: #f87171;
--ok: #4ade80;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
background: radial-gradient(circle at top, #1e293b, var(--bg));
color: var(--text);
}
.card {
width: min(420px, 92vw);
background: var(--card);
border: 1px solid rgba(148,163,184,.25);
border-radius: 16px;
padding: 28px 24px 24px;
box-shadow: 0 20px 50px rgba(0,0,0,.35);
}
h1 { margin: 0 0 6px; font-size: 1.25rem; }
p.sub { margin: 0 0 20px; color: var(--muted); font-size: .9rem; line-height: 1.4; }
label { display: block; font-size: .8rem; color: var(--muted); margin-bottom: 6px; }
input[type="number"] {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(148,163,184,.3);
background: #0b1220;
color: var(--text);
font-size: 1rem;
margin-bottom: 18px;
}
.display {
font-variant-numeric: tabular-nums;
font-size: clamp(3rem, 12vw, 4rem);
font-weight: 700;
text-align: center;
letter-spacing: .04em;
margin: 8px 0 20px;
}
.display.warn { color: var(--danger); }
.row { display: flex; gap: 10px; }
button {
flex: 1;
border: 0;
border-radius: 10px;
padding: 12px 14px;
font-size: .95rem;
font-weight: 600;
cursor: pointer;
}
button:disabled { opacity: .45; cursor: not-allowed; }
#start { background: var(--accent); color: #082f49; }
#pause { background: #334155; color: var(--text); }
#unlock {
width: 100%;
margin-top: 12px;
background: transparent;
border: 1px dashed rgba(148,163,184,.45);
color: var(--muted);
font-weight: 500;
}
#unlock.ready { border-color: var(--ok); color: var(--ok); }
.status { margin-top: 14px; font-size: .8rem; color: var(--muted); text-align: center; min-height: 1.2em; }
</style>
</head>
<body>
<main class="card">
<h1>Pomodoro timer</h1>
<p class="sub">
Minimal single-page timer. Notification chime is unlocked on first Start
so Chrome can play audio when this tab is in the background.
</p>

<label for="minutes">Minutes</label>
<input id="minutes" type="number" min="1" max="180" step="1" value="25" />

<div id="display" class="display" aria-live="polite">25:00</div>

<div class="row">
<button id="start" type="button">Start</button>
<button id="pause" type="button" disabled>Pause</button>
</div>
<button id="unlock" type="button">Unlock background audio</button>
<p id="status" class="status" role="status"></p>
</main>

<script>
/**
* Background audio strategy (Chrome):
* 1) Create AudioContext + short beep buffer on a user gesture.
* 2) Keep a looping silent MediaElementSource / oscillator-less path:
* we resume AudioContext on Start and schedule a beep with
* audioContext.currentTime + remainingSeconds so the chime fires
* even if the tab is throttled (Web Audio scheduled events still run
* when the document is backgrounded for short timers).
* 3) Also use the Notification API (when permitted) as a secondary alert.
* 4) Keep a ticking setInterval for display; on visibilitychange recompute
* remaining from performance.now() so inactive tabs stay accurate.
*/
const $ = (id) => document.getElementById(id);
const minutesInput = $("minutes");
const display = $("display");
const startBtn = $("start");
const pauseBtn = $("pause");
const unlockBtn = $("unlock");
const statusEl = $("status");

let audioCtx = null;
let audioReady = false;
let configuredSeconds = 25 * 60;
let remainingMs = configuredSeconds * 1000;
let running = false;
let endsAt = 0;
let tickTimer = null;
let scheduledBeep = null;

function fmt(ms) {
const total = Math.max(0, Math.ceil(ms / 1000));
const m = Math.floor(total / 60);
const s = total % 60;
return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
}

function render() {
display.textContent = fmt(remainingMs);
display.classList.toggle("warn", remainingMs <= 5000 && running);
}

function setStatus(msg) {
statusEl.textContent = msg || "";
}

async function ensureAudio() {
if (!audioCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
audioCtx = new AC();
}
if (audioCtx.state === "suspended") {
await audioCtx.resume();
}
audioReady = true;
unlockBtn.classList.add("ready");
unlockBtn.textContent = "Background audio unlocked";
setStatus("Audio unlocked. Chime can play in background.");
return audioCtx;
}

function playBeepNow() {
if (!audioCtx) return;
const t0 = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(880, t0);
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.45);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t0);
osc.stop(t0 + 0.5);
}

function cancelScheduledBeep() {
if (scheduledBeep) {
try { scheduledBeep.stop(); } catch (_) {}
scheduledBeep = null;
}
}

function scheduleBeep(secondsFromNow) {
cancelScheduledBeep();
if (!audioCtx || !audioReady) return;
const t0 = audioCtx.currentTime + Math.max(0.05, secondsFromNow);
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(880, t0);
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(0.28, t0 + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.55);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t0);
osc.stop(t0 + 0.6);
scheduledBeep = osc;
}

async function notifyDone() {
playBeepNow();
if ("Notification" in window && Notification.permission === "granted") {
try {
new Notification("Timer finished", {
body: "Your pomodoro session ended.",
silent: false,
});
} catch (_) {}
}
}

function stopTick() {
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = null;
}
}

function onComplete() {
running = false;
stopTick();
cancelScheduledBeep();
remainingMs = configuredSeconds * 1000;
minutesInput.disabled = false;
startBtn.disabled = false;
pauseBtn.disabled = true;
startBtn.textContent = "Start";
render();
setStatus("Timer finished. Chime played; display reset.");
notifyDone();
}

function tick() {
if (!running) return;
remainingMs = Math.max(0, endsAt - performance.now());
render();
if (remainingMs <= 0) onComplete();
}

function readConfiguredSeconds() {
const mins = Math.max(1, Math.min(180, Number(minutesInput.value) || 25));
minutesInput.value = String(mins);
return mins * 60;
}

minutesInput.addEventListener("change", () => {
if (running) return;
configuredSeconds = readConfiguredSeconds();
remainingMs = configuredSeconds * 1000;
render();
});

unlockBtn.addEventListener("click", async () => {
await ensureAudio();
playBeepNow();
if ("Notification" in window && Notification.permission === "default") {
try { await Notification.requestPermission(); } catch (_) {}
}
});

startBtn.addEventListener("click", async () => {
await ensureAudio();
if ("Notification" in window && Notification.permission === "default") {
try { await Notification.requestPermission(); } catch (_) {}
}

if (!running) {
// Fresh start or resume after pause
if (remainingMs <= 0 || startBtn.textContent === "Start") {
configuredSeconds = readConfiguredSeconds();
remainingMs = configuredSeconds * 1000;
}
endsAt = performance.now() + remainingMs;
running = true;
minutesInput.disabled = true;
startBtn.disabled = true;
pauseBtn.disabled = false;
startBtn.textContent = "Start";
scheduleBeep(remainingMs / 1000);
stopTick();
tickTimer = setInterval(tick, 200);
setStatus("Running. You can background this tab; chime is scheduled.");
tick();
}
});

pauseBtn.addEventListener("click", () => {
if (!running) return;
running = false;
remainingMs = Math.max(0, endsAt - performance.now());
stopTick();
cancelScheduledBeep();
startBtn.disabled = false;
pauseBtn.disabled = true;
startBtn.textContent = "Resume";
setStatus("Paused.");
render();
});

document.addEventListener("visibilitychange", () => {
if (!running) return;
// Re-sync display from wall clock after throttling
remainingMs = Math.max(0, endsAt - performance.now());
render();
if (remainingMs <= 0) onComplete();
else scheduleBeep(remainingMs / 1000);
});

// Init
configuredSeconds = readConfiguredSeconds();
remainingMs = configuredSeconds * 1000;
render();
</script>
</body>
</html>