Live: https://pomodoro.wiloon.com · Source: github.com/wiloon/pomodoro-vue
The Problem
Sitting for long periods is hard on your back and neck. I wanted a simple reminder to stand up and move every 30 minutes.
Most Pomodoro apps I tried were overly complex — they required accounts, had task lists, synced to the cloud, or sent too many notifications. I didn’t need any of that. I just needed a countdown on a screen I could glance at.
My setup: an idle Google Pixel 3 placed next to my monitor, screen always on, running the timer. No distractions.
What It Does
- Countdown timer with configurable focus and break intervals
- Audible alert when an interval ends — user can choose the alert sound
- Optional ambient sound (rain forest) playing throughout each interval
- Screen wake lock — keeps the phone screen on without touching it
- Works fully offline (PWA with Service Worker)
- No account, no sign-in, no data collection
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Vue 3 + TypeScript (Composition API) |
| UI | Vuetify 4 + Material Design Icons |
| Router | Vue Router 5 |
| Build | Vite 8 |
| PWA | vite-plugin-pwa (Workbox, auto-update) |
| Analytics | Umami (self-hosted, no cookies, privacy-first) |
| Hosting | Cloudflare Pages |
| Infrastructure | OpenTofu (Cloudflare provider) |
Key Design Decisions
PWA over native app. A web app avoids app store friction and works across all devices. The Wake Lock API handles keeping the screen on, and the Service Worker enables offline use — the two features that matter most for this use case.
Pinia for shared state. Timer state (current interval type, timestamp, pause status, session counts) lives in a Pinia store (stores/timer.ts) so it survives route navigation — switching to Settings and back no longer resets the countdown. Settings (alert sound, ding interval) are in a separate stores/settings.ts that auto-syncs to localStorage via watch, so changes apply immediately without a Save button. The core timer math stays in a plain TypeScript module (src/utils/pomodoro.ts), keeping the store free of formatting logic.
Vuetify for UI. I was already familiar with it and it ships a solid dark theme out of the box. The auto-import plugin keeps the bundle lean — only used components are included.
Cloudflare Pages for hosting. Static SPA, no backend, no server to maintain. Push to main → Cloudflare builds and deploys globally in under a minute. Infrastructure (DNS, custom domain, Pages project) is managed with OpenTofu for reproducibility.
Battery Management
Running a phone plugged in 24/7 degrades the battery and can cause swelling. I use a smart USB switch paired with Macrodroid to do threshold-based charging:
- Battery drops below 20% → Macrodroid sends a command to the USB switch to start charging
- Battery rises above 80% → Macrodroid cuts power
This keeps the battery healthy long-term without any manual intervention.
Infrastructure
The hosting stack is fully managed as code:
resource "cloudflare_pages_project" "pomodoro" {
name = "pomodoro"
production_branch = "main"
source {
type = "github"
config {
owner = "wiloon"
repo_name = "pomodoro-vue"
}
}
build_config {
build_command = "pnpm build"
destination_dir = "dist"
}
}
A cloudflare_record CNAME points pomodoro.wiloon.com to the Pages subdomain. No manual DNS clicks, no console drift.
Testing
- Unit tests: Vitest covers the core timer logic (
tick,pause,reset, interval transitions) - E2E tests: Playwright tests the full user flow in a real browser
pnpm test # unit tests
pnpm e2e # playwright e2e