Skip to content

Commit ac027b1

Browse files
apiadclaude
andcommitted
feat(examples): canonical 04_pwa — pomodoro PWA
Tier 4 of the canonical examples set: an installable, offline-capable pomodoro timer. Stresses @app.view(pwa=Manifest(...)), Service Worker asset caching, violetear.storage cross-reload persistence, an asyncio tick loop on the client side, and @app.local mutation from a non- callback @app.client function (the tick loop). Adds a smoke test (manifest endpoint shape, SW assets list, bundle compile) and an e2e test (load → hydrate → click Start → confirm the countdown actually advances) that exercises the long-running Pyodide asyncio loop and the non-callback reactive write path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cfe053f commit ac027b1

3 files changed

Lines changed: 420 additions & 0 deletions

File tree

examples/04_pwa.py

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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()

tests/test_examples_canonical.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"""
1010

1111
import ast
12+
import hashlib
1213
import importlib.util
1314
import sys
1415
from pathlib import Path
@@ -152,3 +153,69 @@ def test_03_interactive_ssr_bindings_and_rpc_and_bundle():
152153
r = client.get("/_violetear/bundle.py")
153154
assert r.status_code == 200
154155
compile(r.text, "<bundle-03>", "exec", flags=COMPILE_ASYNC)
156+
157+
158+
# -- 04_pwa --------------------------------------------------------------------
159+
160+
161+
def test_04_pwa_manifest_serviceworker_and_bundle():
162+
"""Tier 4: SSR markup carries reactive bindings + a `data-mode` button trio,
163+
the per-route manifest endpoint serves the expected JSON, the Service
164+
Worker script lists the bundle URL in its asset cache, and the bundle
165+
compiles."""
166+
from fastapi.testclient import TestClient
167+
168+
mod = _load("04_pwa.py")
169+
client = TestClient(mod.app.api)
170+
171+
# SSR markup — reactive bindings on the time display, mode label, sessions
172+
# counter; mode buttons carry `data-mode` (not `data_mode` — gap 7.4).
173+
r = client.get("/")
174+
assert r.status_code == 200
175+
html = r.text
176+
assert 'data-bind-text="PomodoroState.time_display"' in html
177+
assert 'data-bind-text="PomodoroState.mode"' in html
178+
assert 'data-bind-text="PomodoroState.sessions"' in html
179+
assert 'data-on-click="start"' in html
180+
assert 'data-on-click="pause"' in html
181+
assert 'data-on-click="reset"' in html
182+
assert 'data-on-click="switch_mode"' in html
183+
assert 'data-mode="work"' in html
184+
assert 'data-mode="short"' in html
185+
assert 'data-mode="long"' in html
186+
# Initial render shows the default work duration.
187+
assert "25:00" in html
188+
# PWA glue: manifest link + SW registration injected into the page.
189+
assert "/manifest.json" in html
190+
assert "/sw.js" in html
191+
192+
# Manifest endpoint — JSON with the values we passed to Manifest(...).
193+
scope_hash = hashlib.md5(b"/").hexdigest()[:8]
194+
r = client.get(f"/_violetear/pwa/{scope_hash}/manifest.json")
195+
assert r.status_code == 200
196+
manifest = r.json()
197+
assert manifest["name"] == "Pomodoro"
198+
assert manifest["short_name"] == "🍅"
199+
assert manifest["theme_color"] == "#dc2626"
200+
assert manifest["display"] == "standalone"
201+
# _register_pwa rewrites start_url + scope to the view's path.
202+
assert manifest["scope"] == "/"
203+
assert manifest["start_url"] == "/"
204+
205+
# Service Worker endpoint — script lists the bundle URL in ASSETS.
206+
r = client.get(f"/_violetear/pwa/{scope_hash}/sw.js")
207+
assert r.status_code == 200
208+
sw = r.text
209+
assert "CACHE_NAME" in sw
210+
assert "/_violetear/bundle.py" in sw
211+
# Pyodide files are pre-cached too so the app loads offline after first visit.
212+
assert "/_violetear/pyodide/pyodide.js" in sw
213+
# Cache name includes the pinned app version (1.0.0) so it stays stable
214+
# across server restarts (the reason we pinned version=).
215+
assert "1.0.0" in sw
216+
217+
# Bundle compiles — same canary as 03 but now exercising the PWA bundle
218+
# too (the long-running `tick()` loop has top-level await semantics).
219+
r = client.get("/_violetear/bundle.py")
220+
assert r.status_code == 200
221+
compile(r.text, "<bundle-04>", "exec", flags=COMPILE_ASYNC)

0 commit comments

Comments
 (0)