|
| 1 | +"""Tier 4 canonical example — an installable, offline-capable pomodoro timer. |
| 2 | +
|
| 3 | +SSR + Pyodide bundle + PWA. Demonstrates `@app.view(pwa=Manifest(...))`, |
| 4 | +Service Worker asset caching (bundle + stylesheet + Pyodide files cached |
| 5 | +on first load — the app works offline after that), `violetear.storage` |
| 6 | +cross-reload persistence, an `asyncio` tick loop on the client side, and |
| 7 | +`@app.local` mutation from a non-callback client function. |
| 8 | +
|
| 9 | +Run: |
| 10 | +
|
| 11 | + python examples/04_pwa.py |
| 12 | +
|
| 13 | +Then open http://localhost:8000 in a browser. Click "Start" — the timer |
| 14 | +counts down in real time. Switch modes (work / short break / long break) |
| 15 | +to swap the duration. Refresh the page: the last state restores from |
| 16 | +localStorage (paused — you decide when to resume). Open DevTools → |
| 17 | +Application to see the manifest and the registered Service Worker; go |
| 18 | +offline and reload — the app still loads from the SW cache. |
| 19 | +""" |
| 20 | + |
| 21 | +from dataclasses import dataclass |
| 22 | + |
| 23 | +from violetear import App, Document, StyleSheet |
| 24 | +from violetear.color import Colors |
| 25 | +from violetear.dom import Event |
| 26 | +from violetear.pwa import Manifest |
| 27 | +from violetear.storage import store |
| 28 | +from violetear.units import px, rem |
| 29 | + |
| 30 | + |
| 31 | +# Version is pinned (rather than left to default) so the Service Worker's |
| 32 | +# CACHE_NAME stays stable across server restarts — otherwise every restart |
| 33 | +# invalidates the cache and forces a re-download (see violetear/pwa.py). |
| 34 | +app = App(title="Pomodoro", version="1.0.0") |
| 35 | + |
| 36 | + |
| 37 | +# --------------------------------------------------------------------------- |
| 38 | +# Reactive state |
| 39 | +# --------------------------------------------------------------------------- |
| 40 | +# |
| 41 | +# Mode durations live as defaults on the dataclass rather than module-level |
| 42 | +# constants — gap 7.7 means free names referenced from client functions |
| 43 | +# silently NameError in the bundle. Inlining the seconds-per-mode lookup |
| 44 | +# inside each client function (below) is the workaround. |
| 45 | + |
| 46 | + |
| 47 | +@app.local |
| 48 | +@dataclass |
| 49 | +class PomodoroState: |
| 50 | + mode: str = "work" |
| 51 | + seconds_left: int = 1500 # 25 min |
| 52 | + running: bool = False |
| 53 | + sessions: int = 0 |
| 54 | + time_display: str = "25:00" |
| 55 | + |
| 56 | + |
| 57 | +# --------------------------------------------------------------------------- |
| 58 | +# Client-side helpers + callbacks |
| 59 | +# --------------------------------------------------------------------------- |
| 60 | + |
| 61 | + |
| 62 | +@app.client |
| 63 | +async def save_state(): |
| 64 | + store.pomodoro = { |
| 65 | + "mode": str(PomodoroState.mode), |
| 66 | + "seconds_left": int(PomodoroState.seconds_left), |
| 67 | + "sessions": int(PomodoroState.sessions), |
| 68 | + # Intentionally never persist `running=True` — on reload we always |
| 69 | + # pause so the user explicitly resumes. Simpler than wall-time math. |
| 70 | + } |
| 71 | + |
| 72 | + |
| 73 | +@app.client |
| 74 | +async def render_time(): |
| 75 | + # Update the MM:SS string from seconds_left. Called from any code path |
| 76 | + # that mutates seconds_left so the bound span stays in sync. |
| 77 | + s = int(PomodoroState.seconds_left) |
| 78 | + if s < 0: |
| 79 | + s = 0 |
| 80 | + minutes = s // 60 |
| 81 | + seconds = s % 60 |
| 82 | + PomodoroState.time_display = f"{minutes:02d}:{seconds:02d}" |
| 83 | + |
| 84 | + |
| 85 | +@app.client |
| 86 | +async def tick(): |
| 87 | + # Long-running async loop. Plain `@app.client` (not `.callback`) because |
| 88 | + # callbacks are reserved for DOM event handlers — the framework validates |
| 89 | + # callbacks take an `event` arg. `start` awaits tick from its handler; |
| 90 | + # other buttons (pause, reset) fire on their own coroutines and remain |
| 91 | + # responsive even while this loop is mid-await. |
| 92 | + import asyncio # lazy: bundle imports don't include asyncio |
| 93 | + |
| 94 | + # Mode durations inlined (gap 7.7). |
| 95 | + durations = {"work": 1500, "short": 300, "long": 900} |
| 96 | + |
| 97 | + while bool(PomodoroState.running): |
| 98 | + await asyncio.sleep(1) |
| 99 | + s = int(PomodoroState.seconds_left) - 1 |
| 100 | + if s <= 0: |
| 101 | + # Phase complete: bump session counter on a finished "work" leg, |
| 102 | + # then auto-advance to the next mode (every 4th work session |
| 103 | + # earns a long break, otherwise short). |
| 104 | + current_mode = str(PomodoroState.mode) |
| 105 | + if current_mode == "work": |
| 106 | + PomodoroState.sessions = int(PomodoroState.sessions) + 1 |
| 107 | + next_mode = "long" if int(PomodoroState.sessions) % 4 == 0 else "short" |
| 108 | + else: |
| 109 | + next_mode = "work" |
| 110 | + PomodoroState.mode = next_mode |
| 111 | + PomodoroState.seconds_left = durations[next_mode] |
| 112 | + PomodoroState.running = False |
| 113 | + await render_time() |
| 114 | + await save_state() |
| 115 | + return |
| 116 | + PomodoroState.seconds_left = s |
| 117 | + await render_time() |
| 118 | + await save_state() |
| 119 | + |
| 120 | + |
| 121 | +@app.client.callback |
| 122 | +async def start(event: Event): |
| 123 | + if bool(PomodoroState.running): |
| 124 | + return |
| 125 | + PomodoroState.running = True |
| 126 | + await tick() |
| 127 | + |
| 128 | + |
| 129 | +@app.client.callback |
| 130 | +async def pause(event: Event): |
| 131 | + PomodoroState.running = False |
| 132 | + await save_state() |
| 133 | + |
| 134 | + |
| 135 | +@app.client.callback |
| 136 | +async def reset(event: Event): |
| 137 | + # Reset to the full duration of the current mode. Stops the timer. |
| 138 | + durations = {"work": 1500, "short": 300, "long": 900} |
| 139 | + PomodoroState.running = False |
| 140 | + PomodoroState.seconds_left = durations[str(PomodoroState.mode)] |
| 141 | + await render_time() |
| 142 | + await save_state() |
| 143 | + |
| 144 | + |
| 145 | +@app.client.callback |
| 146 | +async def switch_mode(event: Event): |
| 147 | + # Each mode button carries data-mode="work|short|long" — read it off the |
| 148 | + # event target rather than from a module-level lookup table. |
| 149 | + durations = {"work": 1500, "short": 300, "long": 900} |
| 150 | + new_mode = str(event.target.dataset.mode) |
| 151 | + if new_mode not in durations: |
| 152 | + return |
| 153 | + PomodoroState.running = False |
| 154 | + PomodoroState.mode = new_mode |
| 155 | + PomodoroState.seconds_left = durations[new_mode] |
| 156 | + await render_time() |
| 157 | + await save_state() |
| 158 | + |
| 159 | + |
| 160 | +@app.client.on("ready") |
| 161 | +async def restore(): |
| 162 | + saved = store.pomodoro |
| 163 | + if saved is None: |
| 164 | + return |
| 165 | + if saved.mode is not None: |
| 166 | + PomodoroState.mode = str(saved.mode) |
| 167 | + if saved.seconds_left is not None: |
| 168 | + PomodoroState.seconds_left = int(saved.seconds_left) |
| 169 | + if saved.sessions is not None: |
| 170 | + PomodoroState.sessions = int(saved.sessions) |
| 171 | + # Always restore paused — user clicks start to resume. |
| 172 | + PomodoroState.running = False |
| 173 | + await render_time() |
| 174 | + |
| 175 | + |
| 176 | +# --------------------------------------------------------------------------- |
| 177 | +# Stylesheet — flat class selectors only (gap 7.1) |
| 178 | +# --------------------------------------------------------------------------- |
| 179 | + |
| 180 | + |
| 181 | +sheet = StyleSheet(normalize=True) |
| 182 | + |
| 183 | +sheet.select("body").font( |
| 184 | + size=rem(1.0), family="system-ui, -apple-system, 'Segoe UI', sans-serif" |
| 185 | +).color(Colors.DarkSlateGray).background(Colors.WhiteSmoke).padding(rem(2.5)) |
| 186 | + |
| 187 | +sheet.select(".page").rules(max_width="420px").margin("auto").background( |
| 188 | + Colors.White |
| 189 | +).padding(rem(2.5)).rounded(px(12)).border(px(1), Colors.Gainsboro) |
| 190 | + |
| 191 | +sheet.select(".page-title").font(size=rem(1.75), weight=700).color( |
| 192 | + Colors.Firebrick |
| 193 | +).margin(bottom=rem(0.25)) |
| 194 | + |
| 195 | +sheet.select(".subtitle").color(Colors.SlateGray).font(size=rem(0.9)).margin( |
| 196 | + bottom=rem(1.5) |
| 197 | +) |
| 198 | + |
| 199 | +sheet.select(".mode-row").flexbox(direction="row", gap=px(8)).margin(bottom=rem(1.5)) |
| 200 | +sheet.select(".mode-button").rules( |
| 201 | + padding="8px 12px", |
| 202 | + border=f"1px solid {Colors.Gainsboro}", |
| 203 | + cursor="pointer", |
| 204 | + font_size="0.875rem", |
| 205 | + font_weight=600, |
| 206 | + flex="1", |
| 207 | +).background(Colors.White).color(Colors.DarkSlateGray).rounded(px(6)) |
| 208 | + |
| 209 | +sheet.select(".time-display").font( |
| 210 | + size=rem(4.5), weight=700, family="ui-monospace, 'SF Mono', monospace" |
| 211 | +).color(Colors.Firebrick).rules(text_align="center").margin( |
| 212 | + top=rem(1.0), bottom=rem(1.0) |
| 213 | +) |
| 214 | + |
| 215 | +sheet.select(".mode-label").font(size=rem(0.875), weight=600).color( |
| 216 | + Colors.SlateGray |
| 217 | +).rules(text_align="center", text_transform="uppercase", letter_spacing="0.05em") |
| 218 | + |
| 219 | +sheet.select(".controls").flexbox(direction="row", gap=px(8)).margin(top=rem(1.5)) |
| 220 | +sheet.select(".control-primary").rules( |
| 221 | + padding="12px 20px", |
| 222 | + border="none", |
| 223 | + cursor="pointer", |
| 224 | + font_size="1rem", |
| 225 | + font_weight=600, |
| 226 | + flex="1", |
| 227 | +).background(Colors.Firebrick).color(Colors.White).rounded(px(6)) |
| 228 | +sheet.select(".control-secondary").rules( |
| 229 | + padding="12px 20px", |
| 230 | + border=f"1px solid {Colors.Gainsboro}", |
| 231 | + cursor="pointer", |
| 232 | + font_size="1rem", |
| 233 | + font_weight=600, |
| 234 | + flex="1", |
| 235 | +).background(Colors.White).color(Colors.DarkSlateGray).rounded(px(6)) |
| 236 | + |
| 237 | +sheet.select(".footer").margin(top=rem(2.0)).flexbox( |
| 238 | + direction="row", gap=px(6), align="center", justify="center" |
| 239 | +).font(size=rem(0.875)).color(Colors.SlateGray) |
| 240 | +sheet.select(".session-count").font(weight=700).color(Colors.Firebrick) |
| 241 | + |
| 242 | + |
| 243 | +# --------------------------------------------------------------------------- |
| 244 | +# View — PWA enabled via Manifest passed to @app.view |
| 245 | +# --------------------------------------------------------------------------- |
| 246 | + |
| 247 | + |
| 248 | +pomodoro_manifest = Manifest( |
| 249 | + name="Pomodoro", |
| 250 | + short_name="🍅", |
| 251 | + description="A minimal pomodoro timer that works offline.", |
| 252 | + background_color="#ffffff", |
| 253 | + theme_color="#dc2626", |
| 254 | + display="standalone", |
| 255 | +) |
| 256 | + |
| 257 | + |
| 258 | +@app.view("/", pwa=pomodoro_manifest) |
| 259 | +def index(): |
| 260 | + doc = Document(title="Pomodoro") |
| 261 | + doc.style(href="/style.css", sheet=sheet) |
| 262 | + |
| 263 | + with doc.body as body: |
| 264 | + with body.div(classes="page") as page: |
| 265 | + page.h1(text="🍅 Pomodoro").classes("page-title") |
| 266 | + page.p(text="Work in focused bursts. Refresh-safe. Offline-ready.").classes( |
| 267 | + "subtitle" |
| 268 | + ) |
| 269 | + |
| 270 | + # Mode selector — three buttons, each carrying its mode in data-mode. |
| 271 | + # `attrs(**{"data-mode": ...})` because kwargs can't have dashes and |
| 272 | + # `data_mode=` would render literally (gap 7.4 — no underscore strip). |
| 273 | + with page.div(classes="mode-row") as modes: |
| 274 | + modes.button(text="Work", classes="mode-button").attrs( |
| 275 | + type="button", **{"data-mode": "work"} |
| 276 | + ).on("click", switch_mode) |
| 277 | + modes.button(text="Short break", classes="mode-button").attrs( |
| 278 | + type="button", **{"data-mode": "short"} |
| 279 | + ).on("click", switch_mode) |
| 280 | + modes.button(text="Long break", classes="mode-button").attrs( |
| 281 | + type="button", **{"data-mode": "long"} |
| 282 | + ).on("click", switch_mode) |
| 283 | + |
| 284 | + # Big time readout — text bound to time_display so the tick loop |
| 285 | + # mutating PomodoroState.time_display updates the DOM live. |
| 286 | + page.div(classes="time-display").text(PomodoroState.time_display) |
| 287 | + |
| 288 | + # Current mode label below the time. |
| 289 | + page.div(classes="mode-label").text(PomodoroState.mode) |
| 290 | + |
| 291 | + # Primary controls. |
| 292 | + with page.div(classes="controls") as controls: |
| 293 | + controls.button(text="Start", classes="control-primary").attrs( |
| 294 | + type="button" |
| 295 | + ).on("click", start) |
| 296 | + controls.button(text="Pause", classes="control-secondary").attrs( |
| 297 | + type="button" |
| 298 | + ).on("click", pause) |
| 299 | + controls.button(text="Reset", classes="control-secondary").attrs( |
| 300 | + type="button" |
| 301 | + ).on("click", reset) |
| 302 | + |
| 303 | + # Session counter footer. |
| 304 | + with page.div(classes="footer") as footer: |
| 305 | + footer.span(text="Completed work sessions: ") |
| 306 | + footer.span(classes="session-count").text(PomodoroState.sessions) |
| 307 | + |
| 308 | + return doc |
| 309 | + |
| 310 | + |
| 311 | +if __name__ == "__main__": |
| 312 | + app.run() |
0 commit comments