Skip to work
Back home
Product / UX / Engineering case study

Desk Wellness Pack

Designing a lightweight behaviour-support system for healthier desk habits

At a glance

What started as a Chrome extension to prompt stretch breaks became a two-module wellness system designed to stay out of your way. Two independent background timers address the two things desk workers forget most easily: moving their body and drinking water.

Walkthrough of the Desk Wellness Pack popup, stretch break screen, and hydration screen
Role
Product designer, UX strategist, and builder: positioning, behavioural logic, interface design, extension and backend implementation, payments, release
Platform
Chrome extension on Manifest V3, plus an installable progressive web app built on the same backend
Stack
  • Chrome Extension APIs
  • Manifest V3
  • Vercel Functions
  • Stripe
  • Google Calendar API
  • HMAC-SHA256
  • VS Code
  • GitHub
01

What it ships with

The product ships as Desk Wellness Pack: two independent background timers that share one engine. Small on the surface, deliberately systemic underneath.

  • Smart Stretch

    Adaptive stretch reminders with session variation, weekly stats, and optional Google Calendar integration.

  • Water Reminder

    Hydration tracking with an animated glass, a daily glass count, and its own independent background timer.

  • Tabbed popup

    One control panel, module-level Pro badges, and two separate upgrade paths that never bleed into each other.

  • Shared badge logic

    A colour-coded countdown on the toolbar icon, a drop when water is active, and MTG while a meeting is running.

  • Stateless licensing

    HMAC-SHA256 tokens. No database, no user accounts, no subscriptions, nothing to leak.

  • Serverless backend

    Vercel functions handle Stripe checkout, payment verification, and license issuance for both modules.

02

The problem

During long work sessions it is easy to stay focused until physical discomfort becomes the first signal that a break was overdue.

The issue was never awareness. Most people already know they should move more and drink more. The issue was behavioural: healthy habits are the easiest thing to postpone when work is flowing.

How might I build a low-friction reminder system that helps people build healthier desk habits without becoming annoying, visually noisy, or easy to dismiss?

03

The product goal

This project was never really about building timers. It was about building a micro-behaviour system that feels calm, clear, and trustworthy. That principle shaped every architectural and UX decision that followed.

  • 01

    Both timers keep working when the popup is closed.

  • 02

    The UI always reflects real state, and never looks inactive while it is running.

  • 03

    Snooze is temporary. Skip returns the user to their preferred rhythm.

  • 04

    The break feels like a guided moment, not a generic interruption.

  • 05

    Water reminders stay independent, so the two modules never interfere.

For a tool this small, those details are not polish. They are the product.

04

Architecture: separating system state from UI state

One of the most important decisions was architectural. The extension has four temporary surfaces - Welcome, Popup, Stretch screen, Water screen - and one persistent engine: background.js.

The background owns both reminder cycles. The popup only reflects them.

That separation prevented the most common failure mode in browser extensions: a UI that looks stopped while the timer is still running. It also made it possible to design water and stretch as genuinely independent modules on one shared engine, without coupling their state.

Storage split
// Local - device only
chrome.storage.local  ->  timer state, stats, glass count, settings, meeting state

// Sync - follows the user across Chrome profiles
chrome.storage.sync   ->  installationId
                         licenseToken       (stretch)
                         waterLicenseToken  (water)

All stats, settings, and timer state live on the device. Only the installation ID and license tokens sync, so user data stays private while Pro status follows the person across Chrome profiles.

Alarm scheduling
// Stretch - repeating alarm
chrome.alarms.create('stretchAlarm', {
  delayInMinutes: selectedMinutes,
  periodInMinutes: selectedMinutes
});

// Water - one-shot, rescheduled after each interaction
chrome.alarms.create('waterAlarm', {
  delayInMinutes: selectedMinutes
});

Both modules use chrome.alarms, so neither depends on the service worker staying alive. When the worker wakes, it reads saved state from storage and recovers. Water uses one-shot alarms deliberately: after a glass is logged or skipped, the next reminder is scheduled fresh from that moment, which preserves a natural hydration rhythm rather than a mechanical one.

05

File structure

The project splits into extension code and a serverless backend. Every surface has its own HTML and JS pair. background.js is the only persistent file, and the backend is a set of small stateless functions.

Extension: root folder.
Backend: /api folder.
06

A critical UX issue: the popup could lie

One of the earliest problems was subtle but serious. The timer was running in the background, yet reopening the popup could show it as inactive or out of sync. That damages trust immediately. Did it stop? Did it reset? Can I rely on this?

The fix was to restore the true runtime state whenever the popup opens, reading from storage rather than from an in-memory variable that may have been lost when the service worker slept.

popup.js and background.js
// popup.js - loadSettings() runs on every open
const res = await sendMessage({ type: 'getSettings' });

// background.js - always reads from storage
const settings = await getLocal([
  'interval', 'startTime', 'smartModeEnabled'
]);
const runtime = await getRuntimeState();
sendResponse({
  stretchInterval:      settings.interval,
  startTime:            settings.startTime,
  stretchReminderState: runtime.stretchReminderState,
  ...
});

The water module follows the same pattern: every popup open sends a getWaterSettings message and rebuilds the whole water display from fresh storage reads. The popup never owns state, it only reflects it.

For habit tools, visible truth matters more than visual activity.

07

A behaviour bug that mattered: Snooze then Skip

The most important logic issue surfaced in a real user flow. Someone sets a 30-minute interval, the reminder fires, they hit Snooze for five minutes, the reminder returns, they hit Skip - and the extension starts another five-minute cycle instead of returning to the original 30-minute rhythm.

That broke the mental model. Snooze means delay this one temporarily. Skip means dismiss this and go back to normal. If Skip behaves like a second snooze, the product feels irrational, and people stop trusting it.

The fix was to separate two concepts that had been conflated: the interval the user chose, and the interval currently active.

background.js
// userInterval - what the user chose
// interval     - what is currently active (may be a 5-min snooze)
async function resumeMainTimerFromUserInterval() {
  const { userInterval } = await getLocal(['userInterval']);
  chrome.alarms.create(ALARM_NAME, {
    delayInMinutes: userInterval,
    periodInMinutes: userInterval
  });
  await setLocal({ interval: userInterval, startTime: Date.now() });
}

Skip and stretch completion both call resumeMainTimerFromUserInterval. Snooze schedules a temporary five-minute alarm. The chosen cadence is never disturbed by a temporary override.

08

Smart Stretch: reducing repetition without pretending to be AI

Every reminder used to feel the same. The session logic was evolving internally, but the visible break defaulted to the same exercise, creating a mismatch users could feel.

The system was becoming smarter. The user was still seeing the same break.

This extension does not use AI or machine learning, and it does not claim to. It uses a lightweight decision layer that reads the last eight interactions and maps them to one of four session types.

chooseSmartSessionType()
const recent = history.slice(-8);
const recentSkipped   = recent.filter(e => e.type === 'skipped').length;
const recentSnoozed   = recent.filter(e => e.type === 'snoozed').length;
const recentCompleted = recent.filter(e => e.type === 'completed').length;

if (recentSkipped + recentSnoozed >= 4) return 'quick_reset';
if (recentSkipped + recentSnoozed >= 2) return 'gentle_stretch';
if (recentCompleted >= 5 && recentSkipped === 0) return 'full_reset';
return 'standard_stretch';
  • quick_reset30s

    Recent skips and snoozes are stacking up

  • gentle_stretch45s

    A couple of recent breaks were postponed

  • standard_stretch60s

    Default rhythm, no strong signal either way

  • full_reset90s

    A long run of completed breaks, no skips

Each type carries its own title, exercise name, and guidance copy. The background picks the session and writes it to local storage, and the stretch screen reads and applies it on open. This was the moment the product stopped replaying the same interruption and started feeling honest about what recent behaviour had actually been.

09

Adding Water Reminder without doubling the complexity

After shipping Smart Stretch, hydration was the natural next step. Dehydration and lack of movement share the same root cause: work has your full attention, and your body does not.

The design challenge was not what the water screen should look like. It was how to add a second independent module to a working system without introducing coupling, complexity, or visual noise.

The temptation was to make water a sub-feature of stretch. I treated it as a separate module instead: its own alarm, state keys, license, badge behaviour, and popup section. Both modules share background.js and one checkCalendar() function, but nothing else. A user can stop stretch without touching water, or buy Water Pro without owning Stretch Pro.

Water alarms and rhythm
const WATER_ALARM_NAME          = 'waterAlarm';
const WATER_MEETING_CHECK_ALARM = 'waterMeetingCheckAlarm';
const WATER_POST_MEETING_ALARM  = 'waterPostMeetingAlarm';

// After logging a glass - next reminder starts from now
if (request.type === 'logGlass') {
  const newCount = Number(data.waterGlassesToday || 0) + 1;
  await setLocal({ waterGlassesToday: newCount });
  scheduleWaterAlarm(ws.waterInterval); // fresh from now
  sendResponse({ ok: true, waterGlassesToday: newCount });
}

The water screen was designed around one emotional idea: the satisfaction of watching something fill up. An animated glass with a CSS wave surface, rising bubbles, and a smooth fill transition gives visible progress with every glass logged. It is small, but it makes drinking water feel rewarding rather than administrative.

The screen also shows a live countdown to the next reminder. Both it and the popup read the same waterStartTime written when the alarm is scheduled, so the two displays can never disagree.

10

Meetings, badges, and one calendar for two modules

Pro users of either module can enable Skip during meetings. Both reuse the same checkCalendar() call: a single Google Calendar FreeBusy request that returns only busy or free for the next ninety minutes. One shared OAuth token, two separate calendarEnabled flags.

When a water alarm fires during a meeting, the module walks a three-state flow identical in structure to stretch: meeting detected, meeting ended with a five-minute buffer, then the water window opens and the normal alarm is rescheduled. The popup narrates it with a live message and countdown.

The toolbar badge is the only persistent signal between reminders, so with two modules running it needed explicit priority rules.

Priority order inside startBadgeCountdown()
// 1. Stretch shown -> clear badge
// 2. Stretch in meeting -> MTG (blue)
// 3. Stretch post-meeting -> red countdown
// 4. Stretch running -> colour-coded minutes
// 5. No stretch - check water
if (waterData.waterMeetingActive) { setBadgeMeeting(); return; }
if (waterData.waterEnabled && waterData.waterStartTime) {
  chrome.action.setBadgeBackgroundColor({ color: '#5aa9ff' });
  chrome.action.setBadgeText({ text: 'water' });
  return;
}
clearBadge();

A subtle fix worth noting: when stretch stops, stopTimer checks whether water is still running before killing the badge loop. If water is active it restarts the countdown instead of stopping it, so the hydration indicator reappears immediately rather than waiting for a popup refresh.

11

Pro licensing: stateless, secure, no database

Both Pro tiers, Stretch at 3 EUR and Water at 5 EUR, use the same stateless HMAC-SHA256 architecture. The backend issues a license token by hashing a module prefix with the installation ID, and verification is a hash comparison. No user records, no database, no subscriptions.

verify-water-payment.js
const waterLicenseToken = crypto
  .createHmac('sha256', process.env.LICENSE_SECRET)
  .update('water:' + installationId)
  .digest('hex');

// The 'water:' prefix makes this token cryptographically
// distinct from the stretch token, despite one shared secret.

The prefix matters. Without it, someone who bought Stretch Pro could reuse the same token to unlock Water Pro. The installation ID is a stable UUID generated at first install, and the secret never leaves the server.

Verification is cached in memory for an hour and trusted from storage for a day, after which the background silently re-verifies. If the network is unavailable the extension fails open: someone with a valid cached token is never locked out of something they paid for.

12

UX decisions that changed the product

  • A ready state before the countdown

    Version one started the stretch countdown the moment the break screen opened, so people arrived to a timer already running for an exercise they had not chosen. Splitting notification, ready state, and active countdown handed control back: the ready state shows the exercise, the user decides when it starts.

  • Two notification types, two jobs

    The persistent “Stretch active” status notification and the “Time to stretch” prompt do different work. Status is ambient awareness that a session is running. The prompt asks for action. The first version only had prompts, and people dismissed them habitually.

  • Reliability over code elegance

    In the progressive web app, all JS and CSS are inlined in a single index.html. ES module imports fail silently on Android when a referenced file 404s, leaving a permanent loading screen with no error. The single-file build removes that failure mode entirely.

13

Manifest V3: platform constraints as product decisions

Several non-obvious platform constraints shaped the architecture directly. Each one was found through testing, not documentation.

  • Inline onclick is blocked by CSP

    The tab switcher between Stretch and Water initially used an inline onclick attribute and failed silently. Every handler is now wired with addEventListener.

  • Service worker audio is unreliable

    Sound plays inside stretch.js and water.js, never from background.js. The alarm handler opens the screen, and the screen owns its own audio.

  • Interactive OAuth needs its own window

    chrome.identity.getAuthToken with interactive true fails from a service worker and from the popup, which closes on focus loss. A dedicated oauth.html popup window is the only place it works reliably.

  • fetch without a timeout stalls the worker

    An unfinished fetch inside an alarm handler can stop the service worker from sleeping. Every backend call goes through a 5-second fetchWithTimeout wrapper using AbortController.

14

Research foundation

The behavioural claims behind the product are not invented. Each design decision traces back to published evidence on sedentary work, hydration, and habit formation.

  • Physical inactivity is the fourth leading risk factor for global mortality.

    WHO, Guidelines on Physical Activity and Sedentary Behaviour, 2020
  • Sitting 8+ hours a day with no activity carries mortality risk comparable to obesity and smoking.

    Ding et al., The Lancet, 2016
  • Brief, frequent breaks reduce musculoskeletal discomfort and sustain cognitive performance.

    Dababneh et al., Applied Ergonomics, 2001
  • 1 to 2% dehydration already impairs mood, concentration, and working memory.

    Armstrong et al., Journal of Nutrition, 2012
  • Office workers drink 30 to 40% less fluid than recommended during work hours.

    Popkin, D'Anci and Rosenberg, Nutrition Reviews, 2010
  • Implementation intentions, tracking discrete actions, improve follow-through over vague goals.

    Gollwitzer, American Psychologist, 1999

The product is intended for general wellness purposes only and does not constitute medical advice.

15

Outcome

The extension shipped with both modules, both payment flows, and one shared engine. Reusing the same backend and Stripe products, the installable progressive web app followed in four weeks, adding server-side push that survives app kill, weekly stats with a Sunday reset, battery-optimisation warnings for Samsung and Xiaomi devices, dark and light mode, and a service worker that caches the whole app shell for offline use.

2
Independent reminder modules, each with break screens, sound, and stats
2
Pro tiers on one-time Stripe payments with HMAC licensing
8
Guided stretch session types with smart rotation and a countdown ring
4
Weeks from the second module's first commit to a shipped build
  • Reliability over novelty

    The most important feature is trust. If timer, badge, and popup disagree, the habit breaks and people stop relying on the tool. Every architectural decision exists to prevent that disagreement.

  • Clear ownership of state

    The background owns both reminder cycles. Temporary surfaces only reflect them. The constraint makes the product easier to reason about and harder to make dishonest.

  • Behaviourally correct controls

    Snooze is temporary. Skip returns to normal. Logging a glass starts the next interval fresh. Small decisions, but they are what make the product feel rational.

  • Independent modules, shared engine

    Stretch and water have separate alarms, state keys, licenses, and UI sections, while sharing one background engine and one calendar integration.

16

What I learned and what's next

Small products still need systems thinking.

Even a lightweight extension fails if the state model is unclear, the mental model is inconsistent, or the UI does not reflect reality. The most valuable work here was not visual polish. It was aligning user expectations, background logic, surface behaviour, and product trust across two modules, two payment flows, and one shared engine.

The product ships as two modules, and the architecture supports more. The goal is not feature growth. It is sustainable, low-friction behaviour support that earns a permanent place in someone's workday.

  • 01

    Posture prompts as a third independent reminder module

  • 02

    Time-of-day stretch suggestions based on hourly patterns

  • 03

    Expanded exercise rotation with session storage to prevent repetition

  • 04

    Accessibility modes: reduced motion and visual-only reminders

  • 05

    User-configurable Smart Stretch thresholds

This started with a personal frustration: working until discomfort was the first signal I had ignored my body too long. What it became is a compact dual-module product - a small system designed to help people work more sustainably, without demanding more attention than the work itself.