WorkoutTimer
Intervals of work and rest with a large countdown, felt as well as seen, the screen kept on, and time that stays right when the screen sleeps.
Demo
Hill sprints
2:05 leftWarm up0:053 rounds aheadNext: Work, 0:30
- Warm up0:05
- Work0:30
- Rest0:15
- Work0:30
- Rest0:15
- Work0:30
Nothing yet.
Not finished.
0
Bad plan
0:00 leftNothing to time0:00
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/workout_timer.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/workout_timer.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/workout_timer.qwe
component WorkoutTimerDemoLive() { state kept = null state again = 0 state said = "Nothing yet." state finished = "Not finished." state finishes = 0 <div.demo-stack> <div.demo-phone id="workout-timer-frame"> <WorkoutTimer key={again} title="Hill sprints" warmup={5} work={30} rest={15} rounds={3} saved={kept} on_change={(run) => { kept = run }} on_phase={(s, i) => { said = `${i}: ${s.kind} ${s.name} ${s.ms}` }} on_finish={(s) => { finished = `${s.ms} ${s.rounds} ${s.skipped}` finishes += 1 }}/> </div> <p.demo-note id="workout-timer-said">{said}</p> <p.demo-note id="workout-timer-finished">{finished}</p> <p.demo-note id="workout-timer-finishes">{finishes}</p> <div.demo-row><Button id="workout-timer-remake" size="lg" variant="soft" on_click={() => { again += 1 }}>Make it again from what was kept</Button></div> <div id="workout-timer-bad"><WorkoutTimer key="bad" title="Bad plan" plan={[null, "x", { seconds: "many" }, { kind: "work", seconds: -3 }]} saved={{ at: "soon" }}/></div> </div>}view WorkoutTimerDemo() = <WorkoutTimerDemoLive/>Source
ui/workout_timer.qwe
// WorkoutTimer: intervals of work and rest, for a phone propped against a wall. A large// countdown, felt as well as seen, with the screen kept on. Time is worked out from the// clock and never counted, so a screen that sleeps, or a phone that freezes the page, loses// nothing: on coming back it stands where it should, in whichever interval that is, and// says how long it was away. It says on screen what this phone cannot do.//// <WorkoutTimer work={40} rest={20} rounds={8} warmup={60} title="Tabata"/>//// component Run() {// state kept = load_kept() // a server fn: a phone app's localStorage lasts one launch// <WorkoutTimer plan={[{ name: "Run", kind: "work", seconds: 300 }, { name: "Walk", kind: "rest", seconds: 60 }]}// saved={kept} on_change={(run) => { keep_it(run) }} on_finish={(s) => { log_it(s) }}/>// }//// work, rest, cooldown, warmup: seconds of each (40, 20, 0, 0). rounds: how many times work// comes (8, and 99 at most); no rest follows the last. plan: the intervals by hand, in place of those: each// { kind, seconds, name? }, kind being "warmup", "work", "rest" or "cooldown"; an entry// without seconds is left out. title: above the countdown. saved: what on_change last gave,// to carry on after the page was made again, as a suspended app's can be.// on_change(run): inside one of your components, { at, stopped, over }, or null when it ended; called// on start, pause, resume, skip and the finish, as what is ticking needs no saving. on_phase(seg,// index): each new interval, as { kind, name, ms }. on_finish(summary): once, at the end, with// { ms, rounds, skipped }, where ms is the plan's length less what was skipped. class.//// While it runs a phone app holds the screen with quilloNative.keepAwake and feels each change// with quilloNative.haptic; a browser uses the Wake Lock and navigator.vibrate. iOS's browsers// have no vibration, so there the screen is all there is, and no page can buzz while// it is frozen: what the time did is told on coming back. Nothing here runs behind the lock screen.import ui/theme.qwe use ui_clslet workout_timer_css = css`.ui-wtimer { --t: var(--ui-accent); position: relative; container-type: inline-size; box-sizing: border-box; height: 100%; min-height: min(100dvh, 520px); overflow-y: auto; overscroll-behavior: contain; color: var(--ui-fg); font: 15px/1.4 var(--ui-font); background: color-mix(in srgb, var(--t) 9%, var(--ui-bg)); transition: background .25s; padding: max(14px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }.ui-wtimer[data-kind="work"] { --t: var(--ui-danger); }.ui-wtimer[data-kind="rest"] { --t: var(--ui-success); }.ui-wtimer[data-kind="warmup"], .ui-wtimer[data-kind="cooldown"] { --t: var(--ui-info); }.ui-wtimer-in { display: flex; flex-direction: column; gap: 12px; min-height: 100%; }.ui-wtimer-main { display: flex; flex: 1; flex-direction: column; gap: 12px; min-width: 0; }.ui-wtimer-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }.ui-wtimer-title { margin: 0; font-size: 15px; font-weight: 600; overflow-wrap: anywhere; }.ui-wtimer-left { color: var(--ui-muted); font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-wtimer-face { position: relative; display: grid; flex: 1; place-items: center; min-height: 240px; }.ui-wtimer-ring { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); pointer-events: none; }.ui-wtimer-ring circle { fill: none; stroke-width: 3; }.ui-wtimer-ring-back { stroke: color-mix(in srgb, var(--t) 18%, transparent); }.ui-wtimer-ring-now { stroke: var(--t); stroke-linecap: round; transition: stroke-dasharray .2s linear; }.ui-wtimer-mid { position: relative; display: grid; justify-items: center; gap: 2px; text-align: center; }.ui-wtimer-phase { font-size: 20px; font-weight: 700; color: var(--t); }.ui-wtimer-clock { font-size: clamp(64px, min(24cqw, 22dvh), 170px); font-weight: 700; line-height: 1.05; font-variant-numeric: tabular-nums; letter-spacing: -.02em; }.ui-wtimer-soon .ui-wtimer-clock { color: var(--t); }.ui-wtimer-round { color: var(--ui-muted); font-size: 15px; }.ui-wtimer-next { color: var(--ui-muted); font-size: 14px; }.ui-wtimer-keys { display: grid; grid-template-columns: 1fr 1.6fr 1fr; gap: 10px; }.ui-wtimer-btn { min-height: 64px; padding: 0 8px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 600 16px/1.1 var(--ui-font); cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }.ui-wtimer-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-wtimer-btn:disabled { opacity: .5; cursor: default; }.ui-wtimer-go { border-color: transparent; background: var(--t); color: var(--ui-bg); font-size: 20px; }.ui-wtimer[data-kind="done"] .ui-wtimer-go, .ui-wtimer[data-kind="ready"] .ui-wtimer-go { background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-wtimer-end { grid-column: 1 / -1; min-height: 48px; }.ui-wtimer-note { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-wtimer-note:empty { display: none; }.ui-wtimer-side { display: none; }.ui-wtimer-list { margin: 0; padding: 0; list-style: none; }.ui-wtimer-row { display: flex; justify-content: space-between; gap: 10px; padding: 8px 10px; border-radius: var(--ui-radius-sm); font-variant-numeric: tabular-nums; }.ui-wtimer-row[aria-current] { background: var(--t); color: var(--ui-bg); font-weight: 600; }.ui-wtimer-past { color: var(--ui-muted); text-decoration: line-through; }@media (hover: hover) { .ui-wtimer-btn:not(:disabled):not(.ui-wtimer-go):hover { background: var(--ui-subtle); } }/* Turned, or on a tablet: the face beside the intervals, as a phone on its side has no height for both. */@container (min-width: 620px) { .ui-wtimer-in { flex-direction: row; gap: 20px; } .ui-wtimer-side { position: relative; display: block; flex: none; width: min(36%, 300px); overflow-y: auto; overscroll-behavior: contain; max-height: calc(100dvh - 48px); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); padding: 6px; } .ui-wtimer-clock { font-size: clamp(64px, min(15cqw, 26dvh), 170px); }}@media (prefers-reduced-motion: reduce) { .ui-wtimer, .ui-wtimer-ring-now { transition: none; } }`let workout_timer_kinds = ["warmup", "work", "rest", "cooldown"]// Whole seconds from what was given: 0 for anything that is not a time, a day at most.fn workout_timer_secs(x) -> int { let t = type_of(x) if t != "int" && t != "float" { return 0 } if x != x || x <= 0 { return 0 } x > 86400 ? 86400 : round(x)}fn workout_timer_name(kind: str) -> str = match kind { "warmup" => "Warm up" "rest" => "Rest" "cooldown" => "Cool down" _ => "Work"}// The intervals from the four numbers. No rest after the last round, which would only be waiting.fn workout_timer_rounds(work, rest, rounds, warmup, cooldown) { let n = workout_timer_secs(rounds) let times = n > 99 ? 99 : n let middle = for r in range(0, times) => r < times - 1 ? [{ kind: "work", seconds: work }, { kind: "rest", seconds: rest }] : [{ kind: "work", seconds: work }] [{ kind: "warmup", seconds: warmup }] + flatten(middle) + [{ kind: "cooldown", seconds: cooldown }]}// The plan as it will run: each with its place, its length and where it starts, all in milliseconds.fn workout_timer_plan(plan, work, rest, rounds, warmup, cooldown) { let asked = type_of(plan) == "list" ? take(plan, 300) : workout_timer_rounds(work, rest, rounds, warmup, cooldown) let clean = asked | filter((s) => type_of(s) == "map" && workout_timer_secs(get(s, "seconds")) > 0) let segs = for s in clean => { let kind = contains(workout_timer_kinds, get(s, "kind")) ? s.kind : "work" { kind, name: filled(get(s, "name")) ?? workout_timer_name(kind), ms: workout_timer_secs(s.seconds) * 1000 } } reduce(enumerate(segs), [], (out, p) => { let from = len(out) > 0 ? last(out).from + last(out).ms : 0 out + [{ ...p[1], i: p[0], from }] })}fn workout_timer_total(segs) -> int = len(segs) > 0 ? last(segs).from + last(segs).ms : 0// Where a time falls: which interval, how long is left of it, and whether it is all over.fn workout_timer_at(segs, ms: num) { let e = ms < 0 ? 0 : ms let s = find(segs, (x) => e < x.from + x.ms) s == null ? { i: len(segs), left: 0, done: true } : { i: s.i, left: s.from + s.ms - e, done: false }}// As a clock reads, rounded up: a second that has begun still shows, and 0:00 is the end.fn workout_timer_clock(ms: num) -> str { let all = ceil(ms / 1000) let h = floor(all / 3600) let m = floor((all % 3600) / 60) let s = all % 60 let ss = s < 10 ? `0${s}` : `${s}` if h > 0 { return `${h}:${m < 10 ? "0" : ""}${m}:${ss}` } `${m}:${ss}`}// How long it has run, by the clock: paused, it stands at the pause. A clock set back cannot make it negative.fn workout_timer_elapsed(run, t: num) -> num { if run == null { return 0 } let e = (run.stopped ?? t) - run.at e < 0 ? 0 : e}// What a kept run may be: anything else starts afresh, not with nonsense.fn workout_timer_run(saved) { if type_of(saved) != "map" { return null } let at = get(saved, "at") let stopped = get(saved, "stopped") if type_of(at) != "int" || at <= 0 { return null } if stopped != null && (type_of(stopped) != "int" || stopped < at) { return null } { at, stopped, over: get(saved, "over") == true }}// The time to stand at after Next or Back. Back in the first two seconds goes to the// one before, as on a music player; later it starts this one over.fn workout_timer_skip(segs, e: num, by: int) -> num { let n = len(segs) if n == 0 { return 0 } let at = workout_timer_at(segs, e) if by > 0 { return at.done ? workout_timer_total(segs) : segs[at.i].from + segs[at.i].ms } let i = at.i >= n ? n - 1 : at.i if at.done || e - segs[i].from > 2000 || i == 0 { return segs[i].from } segs[i - 1].from}// Which round this is: a rest belongs to the round before it.fn workout_timer_round(segs, i: int) -> str { let works = segs | filter((s) => s.kind == "work") let upto = works | filter((s) => s.i <= i) if len(works) == 0 { return `Interval ${i + 1} of ${len(segs)}` } if len(upto) == 0 { return `${len(works)} rounds ahead` } `Round ${len(upto)} of ${len(works)}`}fn workout_timer_rounds_done(segs, skipped) -> int { let works = segs | filter((s) => s.kind == "work" && !contains(skipped, s.i)) len(works)}// The sentence for coming back after the page stood still.fn workout_timer_away(ms: num) -> str = `The screen was off for ${workout_timer_clock(ms)}. The time kept going.`component WorkoutTimer(work: int = 40, rest: int = 20, rounds: int = 8, warmup: int = 0, cooldown: int = 0, plan = null, title: str = "Workout", saved = null, on_change = null, on_phase = null, on_finish = null, class: str? = null) { style global workout_timer_css let segs = workout_timer_plan(plan, work, rest, rounds, warmup, cooldown) let total = workout_timer_total(segs) state run = workout_timer_run(saved) state elapsed = workout_timer_elapsed(run, run != null ? run.at : 0) state seen_i = 0 // the interval last felt state seen_s = 0 // the second last felt state looked = 0 // when it last read the clock, to see that the page stood still state skipped = [] // intervals passed with Next state over = run != null && run.over // on_finish has been called, here or before the page was made again state win = null state box = null state lock = null // the browser's WakeLockSentinel state awake = "" // "", "app", "web", "away", "refused", "none" state buzzes = true // false: this browser has no vibration state back_note = "" state said = "" let n = len(segs) let pos = workout_timer_at(segs, elapsed) let status = run == null ? "ready" : pos.done ? "done" : run.stopped != null ? "paused" : "running" let seg = pos.done ? null : segs[pos.i] let shown = n == 0 ? null : (seg ?? last(segs)) let upcoming = pos.i + 1 < n ? segs[pos.i + 1] : null let part = seg != null ? round(pos.left * 100 / seg.ms, 1) : 0 let note = status == "running" ? match awake { "app" => "The screen stays on." "web" => "The screen stays on while this page shows." "refused" => "The screen may dim: this phone said no. The time stays right." "none" => "This browser cannot keep the screen on. The time stays right if it sleeps." _ => "" } : "" let report = () => { on_change && on_change(run) } let buzz = (kind: str, ms) => { if win != null { if win.quilloNative != null { js("quilloNative.haptic", kind) } else { if buzzes && js("navigator.userActivation.hasBeenActive") == true { js("navigator.vibrate", ms) } } } } let lost = (_e = null) => { if lock != null { lock = null awake = "away" } } let hold_screen = () => { if win != null { if win.quilloNative != null { awake = is_err(js("quilloNative.keepAwake", true)) ? "refused" : "app" } else if win.navigator.wakeLock == null { awake = "none" } else { let got = js("navigator.wakeLock.request", "screen") if is_err(got) { awake = "refused" } else if status != "running" { // Paused while the browser was answering. got.release() } else { lock = got got.addEventListener("release", lost) awake = "web" } } } } let let_go = () => { if lock != null { let l = lock lock = null l.removeEventListener("release", lost) l.release() } if awake == "app" { js("quilloNative.keepAwake", false) } awake = "" } let finish = () => { if !over { over = true said = "Workout done." let_go() buzz("success", [120, 80, 120]) run = { ...run, over: true } report() let ran = total - sum(skipped | map((i) => segs[i].ms)) on_finish && on_finish({ ms: ran, rounds: workout_timer_rounds_done(segs, skipped), skipped: len(skipped) }) } } // Everything hangs on this: it reads the clock, and catches up on whatever has gone by. let sync = (t: num) => { if run != null { let gap = looked > 0 ? t - looked : 0 looked = t let e = workout_timer_elapsed(run, t) let at = workout_timer_at(segs, e) let stood_still = gap > 2500 && run.stopped == null elapsed = e > total ? total : e if stood_still { back_note = workout_timer_away(gap) } let secs = ceil(at.left / 1000) if at.done { seen_i = at.i finish() } else if at.i != seen_i { let now_seg = segs[at.i] seen_i = at.i seen_s = secs if !stood_still { back_note = "" } said = `${now_seg.name}, ${workout_timer_clock(at.left)}.` if box != null { let list = box.querySelector(".ui-wtimer-side") js("requestAnimationFrame", (_t = null) => { let row = list.querySelector("[aria-current]") if row != null { list.scrollTop = row.offsetTop - list.clientHeight / 2 } }) } buzz(now_seg.kind == "rest" ? "success" : "warning", now_seg.kind == "rest" ? [160] : [90, 60, 90]) on_phase && on_phase({ kind: now_seg.kind, name: now_seg.name, ms: now_seg.ms }, at.i) } else if secs != seen_s { seen_s = secs if secs <= 3 && !stood_still && run.stopped == null { buzz("light", 12) } } } } let begin = () => { let t = now() run = { at: t, stopped: null, over: false } elapsed = 0 seen_i = 0 seen_s = ceil(segs[0].ms / 1000) looked = t skipped = [] over = false back_note = "" said = `${segs[0].name}, ${workout_timer_clock(segs[0].ms)}.` report() on_phase && on_phase({ kind: segs[0].kind, name: segs[0].name, ms: segs[0].ms }, 0) buzz("medium", 30) hold_screen() } let press = () => { if status == "running" { run = { ...run, stopped: now() } sync(run.stopped) said = "Paused." report() let_go() } else if status == "paused" { let t = now() run = { ...run, at: run.at + t - run.stopped, stopped: null } looked = t said = "Going on." report() hold_screen() } else { if n > 0 { begin() } } } let move = (by: int) => { let t = now() let from = workout_timer_elapsed(run, t) let to = workout_timer_skip(segs, from, by) // Going back to an interval takes it off the list of those skipped. let landing = workout_timer_at(segs, to).i skipped = by > 0 ? (contains(skipped, pos.i) ? skipped : skipped + [pos.i]) : skipped | filter((i) => i < landing) run = { ...run, at: (run.stopped ?? t) - to } looked = t sync(t) // A move inside the same interval has no change of phase to show for it. elapsed = workout_timer_elapsed(run, t) report() } let finish_now = () => { run = null elapsed = 0 seen_i = 0 skipped = [] over = false back_note = "" said = "Ended." report() } if status == "running" { every(200, () => { sync(now()) }) } <section class={ui_cls(["ui-wtimer", status == "running" && pos.left <= 3000 && "ui-wtimer-soon", class])} aria-label={title} data-kind={status == "ready" ? "ready" : status == "done" ? "done" : seg.kind} data-status={status} @mount={(me) => { box = me let w = js("window") let d = js("document") win = w buzzes = w.navigator.vibrate != null let back = (_e = null) => { if d.visibilityState == "visible" { sync(now()) if status == "running" && lock == null && awake != "app" { hold_screen() } } } d.addEventListener("visibilitychange", back) w.addEventListener("pageshow", back) if run != null { // Made again from what was kept: stand where the clock says, with no buzz for the way there. let at = workout_timer_at(segs, workout_timer_elapsed(run, now())) seen_i = at.i seen_s = ceil(at.left / 1000) sync(now()) if status == "running" { hold_screen() } } () => { d.removeEventListener("visibilitychange", back) w.removeEventListener("pageshow", back) let_go() } }}> <div.ui-wtimer-in> <div.ui-wtimer-main> <div.ui-wtimer-head> <h2.ui-wtimer-title>{title}</h2> <span.ui-wtimer-left>{workout_timer_clock(total - elapsed)} left</span> </div> <div.ui-wtimer-face> <svg.ui-wtimer-ring viewBox="0 0 100 100" aria-hidden="true"> <circle.ui-wtimer-ring-back cx="50" cy="50" r="47"/> <circle.ui-wtimer-ring-now cx="50" cy="50" r="47" pathLength="100" style={`stroke-dasharray: ${part} 100`}/> </svg> <div.ui-wtimer-mid> <span.ui-wtimer-phase>{status == "done" ? "Done" : shown != null ? shown.name : "Nothing to time"}</span> <span.ui-wtimer-clock role="timer" aria-label={`Time left in this interval`}>{seg != null ? workout_timer_clock(pos.left) : "0:00"}</span> <span.ui-wtimer-round>{status == "done" ? `${len(segs | filter((s) => s.kind == "work" && !contains(skipped, s.i)))} rounds` : n > 0 ? workout_timer_round(segs, pos.i) : ""}</span> <span.ui-wtimer-next>{upcoming != null ? `Next: ${upcoming.name}, ${workout_timer_clock(upcoming.ms)}` : ""}</span> </div> </div> <div.ui-wtimer-keys> <button.ui-wtimer-btn.ui-wtimer-prev type="button" disabled={status != "running" && status != "paused"} @click={() => { move(-1) }}>Back</button> // One button for all three, as what lies under a finger must not be swapped for another. <button.ui-wtimer-btn.ui-wtimer-go type="button" disabled={n == 0} @click={() => { press() }}>{status == "running" ? "Pause" : status == "paused" ? "Resume" : status == "done" ? "Again" : "Start"}</button> <button.ui-wtimer-btn.ui-wtimer-skip type="button" disabled={status != "running" && status != "paused"} @click={() => { move(1) }}>Next</button> {status == "paused" && <button.ui-wtimer-btn.ui-wtimer-end type="button" @click={() => { finish_now() }}>End workout</button>} </div> <p.ui-wtimer-note.ui-wtimer-back>{back_note}</p> <p.ui-wtimer-note.ui-wtimer-awake>{note}</p> <p.ui-wtimer-note.ui-wtimer-nobuzz>{win != null && win.quilloNative == null && !buzzes ? "No vibration in this browser: keep the screen in sight." : ""}</p> </div> <div.ui-wtimer-side> <ol.ui-wtimer-list aria-label="Intervals"> {for s in segs => <li key={s.i} class={ui_cls(["ui-wtimer-row", s.i < pos.i && "ui-wtimer-past"])} aria-current={s.i == pos.i && status != "ready" ? "step" : null}> <span>{s.name}</span><span>{workout_timer_clock(s.ms)}</span> </li>} </ol> </div> </div> <span.ui-sr role="status">{said}</span> </section>}Related
WaterTrackerTap a glass to add what was drunk, with undo, a goal and a day that starts empty at the phone's own midnight.2 views · 7 downloadsActivityRingsProgress rings inside each other that go round again past 100%, drawn once, with a legend and a sentence each for screen readers.3 views · 6 downloadsFocusTimerSessions with a keep-awake, a streak, time that survives the background, and an end alert.3 views · 8 downloadsSleepChartStages through a night as a chart read by finger, with totals, right across midnight and a change of the clocks.4 views · 8 downloadsWeightTrendA smoothed trend over noisy weigh-ins, in kg or lb, with a goal line, read by finger, and holes left as holes.3 views · 5 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloads
Thread
Thread 0 posts
On WorkoutTimer: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.