FocusTimer
Sessions with a keep-awake, a streak, time that survives the background, and an end alert.
Demo
Focus25:00Round 1 of 2
- Streak
- 2 days
- Today
- 0 min
- Best
- 4 days
Focus0:03Round 1 of 1
- Streak
- 0 days
- Today
- 0 min
- Best
- 0 days
Focus25:00Round 1 of 4
- Streak
- 0 days
- Today
- 0 min
- Best
- 0 days
Focus25:00Round 1 of 4
- Streak
- 0 days
- Today
- 0 min
- Best
- 0 days
Focus25:00Round 1 of 4
- Streak
- 0 days
- Today
- 0 min
- Best
- 0 days
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/focus_timer.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/focus_timer.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/focus_timer.qwe
let focus_timer_demo_past = [ { date: "2026-11-03", minutes: 25 }, { date: "2026-11-03", minutes: 45 }, { date: "2026-11-02", minutes: 25 }, { date: "2026-10-30", minutes: 25 }, { date: "2026-10-29", minutes: 25 }, { date: "2026-10-28", minutes: 25 }, "2026-10-27", "not a day", 7,]component FocusTimerDemoLive() { state got = "" state last = "" <div.demo-stack> <FocusTimer today="2026-11-04" sessions={focus_timer_demo_past} rounds={2} on_session={(s) => { got = `${s.date} ${s.minutes} ${s.started_at != null} ${s.ended_at > 0}` }} on_state={(s) => { last = `${s.phase} ${s.status} ${s.round}` }}/> <p.demo-note id="focus-timer-got">{got}</p> <p.demo-note id="focus-timer-state">{last}</p> // A real three seconds, one paused and one that ended long ago, for the test to look at. <div id="focus-timer-short"><FocusTimer today="2026-11-04" focus_minutes={0.05} break_minutes={0.05} rounds={1} choices={[]} keep_awake={false}/></div> <div id="focus-timer-paused"><FocusTimer today="2026-11-04" choices={[]} keep_awake={false} resume={{ phase: "focus", status: "paused", left: 90000, round: 2, minutes: 25 }}/></div> <div id="focus-timer-gone"><FocusTimer today="2026-11-04" choices={[]} keep_awake={false} resume={{ phase: "focus", status: "running", ends_at: 1000, round: 1, minutes: 30 }}/></div> <div id="focus-timer-bad" hidden="true"><FocusTimer today="never" sessions="none" focus_minutes={-5} rounds={0} choices="none" resume="no"/></div> </div>}view FocusTimerDemo() = <div.demo-stack><FocusTimerDemoLive/></div>Source
ui/focus_timer.qwe
// FocusTimer: sessions of work and rest, with a ring, a streak of days, and an end nobody can// miss. Time is kept as the moment the session ends, not as a count, so a phone that sleeps,// a tab the browser slows or an app the system suspends loses nothing: on coming back the// clock is right, and one that ended meanwhile is counted, with when it ended. The screen stays// on while it runs, through the app or the web's Wake Lock, which is taken again on coming// back, and it says when that cannot be done. The end is a sound, a buzz and a line on the// screen; when the app leaves the screen the phone itself is asked to alert at the end, and a// browser that allows it shows a notification. On a tablet, or a phone on its side, the// figures stand beside the ring, with the week.//// component Work() {// <FocusTimer today="2026-11-04" sessions={past} resume={saved}// on_session={(s) => { save_session(s) }} on_state={(s) => { save_state(s) }}/>// }//// What must last is the program's to keep: localStorage comes back empty at each launch of a// phone app. Give past sessions as `sessions`, and what on_state last gave as `resume`.//// today: "YYYY-MM-DD"; left out, the server's when it renders. It moves on with the clock.// sessions: each { date, minutes } or a date, those done before. focus_minutes (25), break_minutes// (5), long_minutes (15): up to 600, and a fraction will do. rounds: focus sessions before the// long break (4). choices: the lengths offered before starting ([15, 25, 45, 60]). keep_awake:// the switch starts on (true). sound: a sound at the end (true). resume: what on_state gave. label: its// name for screen readers. class.// on_session(s): inside one of your components, a focus session was finished: { date, minutes,// started_at, ended_at }, those two in milliseconds, started_at null when it cannot be known. A// session stopped early does not count. on_state(s): at each start, pause and end: { phase, status,// ends_at, left, round, minutes }.//// quilloNative has no way to take a notification back, so one set as the app left the screen// still comes if the session is paused or stopped after; it says so.import ui/theme.qwe use ui_clslet focus_timer_css = css`.ui-focus-timer { container-type: inline-size; color: var(--ui-fg); font: 15px/1.4 var(--ui-font); }.ui-focus-timer-inner { display: grid; gap: 16px; justify-items: center; }.ui-focus-timer-main { display: grid; gap: 12px; justify-items: center; width: 100%; min-width: 0; }.ui-focus-timer-ring { position: relative; display: grid; place-items: center; width: min(72cqw, 280px); aspect-ratio: 1; }.ui-focus-timer-ring svg { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); }.ui-focus-timer-track, .ui-focus-timer-arc { fill: none; stroke-width: 7; }.ui-focus-timer-track { stroke: var(--ui-border); }.ui-focus-timer-arc { stroke: var(--ui-accent); stroke-linecap: round; transition: stroke-dashoffset .3s linear; }.ui-focus-timer[data-phase="break"] .ui-focus-timer-arc, .ui-focus-timer[data-phase="long"] .ui-focus-timer-arc { stroke: var(--ui-success); }.ui-focus-timer-mid { position: relative; display: grid; justify-items: center; gap: 2px; text-align: center; }.ui-focus-timer-phase { color: var(--ui-muted); font-size: 14px; font-weight: 600; }.ui-focus-timer-time { font-size: clamp(40px, 15cqw, 60px); font-weight: 600; line-height: 1.05; font-variant-numeric: tabular-nums; }.ui-focus-timer-round { color: var(--ui-muted); font-size: 13px; }.ui-focus-timer-keys { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; width: 100%; }.ui-focus-timer-go, .ui-focus-timer-off { min-width: 120px; min-height: 52px; padding: 0 20px; border: 0; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 16px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }.ui-focus-timer-off { min-width: 88px; border: 1px solid var(--ui-border); background: var(--ui-surface); color: var(--ui-fg); }.ui-focus-timer-times { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }.ui-focus-timer-time-btn { min-width: 52px; min-height: 44px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-focus-timer-time-btn[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-focus-timer-say { width: 100%; margin: 0; color: var(--ui-muted); text-align: center; overflow-wrap: anywhere; }.ui-focus-timer-say:empty { display: none; }.ui-focus-timer-say[data-done] { padding: 10px 12px; border-radius: var(--ui-radius); background: var(--ui-success-soft); color: var(--ui-success); font-weight: 600; }.ui-focus-timer-side { display: grid; gap: 12px; width: 100%; min-width: 0; }.ui-focus-timer-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }.ui-focus-timer-stat { padding: 8px; border-radius: var(--ui-radius); background: var(--ui-subtle); text-align: center; }.ui-focus-timer-stat dt { color: var(--ui-muted); font-size: 12px; }.ui-focus-timer-stat dd { margin: 0; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-focus-timer-week { display: none; }.ui-focus-timer-row { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 52px; padding: 6px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: inherit; font: inherit; text-align: left; cursor: pointer; touch-action: manipulation; }.ui-focus-timer-row span:first-child { flex: 1; min-width: 0; }.ui-focus-timer-note { display: block; color: var(--ui-muted); font-size: 13px; }.ui-focus-timer-note[data-bad] { color: var(--ui-danger); }.ui-focus-timer-track-sw { position: relative; flex: none; width: 46px; height: 28px; border-radius: 999px; background: var(--ui-strong); transition: background .15s; }.ui-focus-timer-track-sw::after { content: ""; position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .25); transition: transform .15s; }.ui-focus-timer-row[aria-checked="true"] .ui-focus-timer-track-sw { background: var(--ui-accent); }.ui-focus-timer-row[aria-checked="true"] .ui-focus-timer-track-sw::after { transform: translateX(18px); }.ui-focus-timer-link { min-width: 44px; min-height: 44px; padding: 0 10px; border: 0; border-radius: var(--ui-radius-sm); background: var(--ui-accent-soft); color: var(--ui-accent); font: 600 14px/1 var(--ui-font); cursor: pointer; }.ui-focus-timer-line { display: flex; align-items: center; gap: 10px; min-height: 44px; }.ui-focus-timer-line .ui-focus-timer-note { flex: 1; min-width: 0; }.ui-focus-timer :is(.ui-focus-timer-go, .ui-focus-timer-off, .ui-focus-timer-time-btn, .ui-focus-timer-row, .ui-focus-timer-link):focus-visible { outline: none; box-shadow: var(--ui-focus); }/* With the width, on a tablet or a phone on its side, the ring stands beside the figures, and the week shows. */@container (min-width: 560px) { .ui-focus-timer-inner { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: 24px; } .ui-focus-timer-ring { width: min(38cqw, 300px); } .ui-focus-timer-week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; align-items: end; height: 96px; margin: 0; padding: 0; list-style: none; } .ui-focus-timer-day { display: grid; grid-template-rows: 1fr auto; gap: 4px; height: 100%; color: var(--ui-muted); font-size: 12px; text-align: center; } .ui-focus-timer-bar { align-self: end; min-height: 3px; border-radius: 3px 3px 0 0; background: var(--ui-accent); }}@media (prefers-reduced-motion: reduce) { .ui-focus-timer-arc, .ui-focus-timer-track-sw, .ui-focus-timer-track-sw::after { transition: none; }}`fn focus_timer_pad(n: int) -> str = n < 10 ? `0${n}` : str(n)// 25:00, 0:07, 1:05:00.fn focus_timer_clock(secs: num) -> str { let all = int(max([secs, 0])) let h = int(all / 3600) let m = int((all % 3600) / 60) let s = focus_timer_pad(all % 60) h > 0 ? `${h}:${focus_timer_pad(m)}:${s}` : `${m}:${s}`}// A length in minutes that can be used, or the fallback.fn focus_timer_span(x, fallback: num) -> num = (type_of(x) == "int" || type_of(x) == "float") && x > 0 && x <= 600 ? x : fallback// Past sessions as { date, minutes }; plain dates count as 25 minutes, bad ones not at all.fn focus_timer_clean(sessions) -> list { if type_of(sessions) != "list" { return [] } let read = sessions | map((s) => { let d = type_of(s) == "str" ? s : type_of(s) == "map" ? get(s, "date") : null let m = type_of(s) == "map" ? get(s, "minutes") : null type_of(d) == "str" && date.valid(d, "date") ? { date: d, minutes: focus_timer_span(m, 25) } : null }) read | filter((s) => s != null)}// Days in a row with a session, ending today, or yesterday while today has none yet.fn focus_timer_streak(log: list, today: str) -> int { let days = unique(log | map((s) => s.date)) let at = contains(days, today) ? today : date.add_days(today, -1) let n = 0 while contains(days, at) && n < 3660 { n += 1 at = date.add_days(at, -1) } n}// The longest run of days there has been.fn focus_timer_best(log: list) -> int { let days = sort(unique(log | map((s) => s.date))) let best = 0 let run = 0 let last = "" for d in days { run = last != "" && date.days_between(last, d) == 1 ? run + 1 : 1 if run > best { best = run } last = d } best}fn focus_timer_on_day(log: list, day: str) -> list = log | filter((s) => s.date == day)fn focus_timer_sum(log: list) -> num = round(sum(log | map((s) => s.minutes)))// The seven days ending today, with their minutes and a height as a share of the fullest.fn focus_timer_week(log: list, today: str) -> list { let days = range(-6, 1) | map((i) => date.add_days(today, i)) let mins = days | map((d) => sum(focus_timer_on_day(log, d) | map((s) => s.minutes))) let top = max(mins + [1]) enumerate(days) | map((p) => ({ date: p[1], minutes: mins[p[0]], share: mins[p[0]] / top, name: date.format(p[1], "ddd") }))}// What follows a phase.fn focus_timer_next(phase: str, round: int, rounds: int) { if phase != "focus" { return { phase: "focus", round: phase == "long" ? 1 : round + 1 } } round >= rounds ? { phase: "long", round } : { phase: "break", round }}fn focus_timer_word(phase: str) -> str = match phase { "break" => "Short break" "long" => "Long break" _ => "Focus"}fn focus_timer_days(n: int) -> str = n == 1 ? "1 day" : `${n} days`// What a saved state comes to at time t: null when there is none to use, and { over: true }// when it ended while nobody was there. A running end further off than its whole length is// a clock that changed, and starts from the whole again.fn focus_timer_restored(s, t: num) { if type_of(s) != "map" { return null } let status = get(s, "status") let phase = contains(["focus", "break", "long"], get(s, "phase")) ? s.phase : "focus" let mins = focus_timer_span(get(s, "minutes"), 25) let whole = mins * 60000 let turn = type_of(get(s, "round")) == "int" && s.round >= 1 ? s.round : 1 if status == "paused" { let left = get(s, "left") let kept = (type_of(left) == "int" || type_of(left) == "float") && left > 0 ? min([left, whole]) : whole return { phase, status, left: kept, ends_at: 0, round: turn, minutes: mins, over: false } } let end = get(s, "ends_at") if status != "running" || (type_of(end) != "int" && type_of(end) != "float") { return null } if end <= t { return { phase, status: "running", left: 0, ends_at: end, round: turn, minutes: mins, over: true } } let rest = min([end - t, whole]) { phase, status: "running", left: rest, ends_at: t + rest, round: turn, minutes: mins, over: false }}component FocusTimer(today: str = "", sessions = [], focus_minutes: num = 25, break_minutes: num = 5, long_minutes: num = 15, rounds: int = 4, choices = [15, 25, 45, 60], keep_awake: bool = true, sound: bool = true, resume = null, label: str = "Focus timer", on_session = null, on_state = null, class: str? = null) { style global focus_timer_css state day = date.valid(today, "date") ? today : date.today() state base_day = day state base_clock = "" state log = focus_timer_clean(sessions) state phase = "focus" state status = "idle" // idle, running, paused state mins = focus_timer_span(focus_minutes, 25) state left = mins * 60000 state ends_at = 0 state started_at = null state turn = 1 state keep = keep_awake state awake = "off" // off, on, away, none, refused state lock = null state alerts = "wait" // wait, app, ask, on, blocked, none state told = false // the phone has an alert set for this end state said = "" state over = false // said is the end of a session state ctx = null state win = null let long_mins = focus_timer_span(long_minutes, 15) let break_mins = focus_timer_span(break_minutes, 5) let round_count = rounds >= 1 ? rounds : 4 let phase_mins = phase == "focus" ? mins : phase == "long" ? long_mins : break_mins let whole = phase_mins * 60000 let share = whole > 0 ? max([0, min([1, left / whole])]) : 0 let streak = focus_timer_streak(log, day) let best = focus_timer_best(log) let on_today = focus_timer_on_day(log, day) let week = focus_timer_week(log, day) let offers = type_of(choices) == "list" ? choices | filter((c) => focus_timer_span(c, 0) > 0) : [] let main_word = status == "running" ? "Pause" : status == "paused" ? "Resume" : phase == "focus" ? "Start" : "Start break" let other_word = status != "idle" ? "Stop" : phase != "focus" ? "Skip break" : "" let awake_words = match awake { "on" => "The screen stays on." "away" => "It will hold the screen again on coming back." "none" => "This browser cannot hold the screen on, so it will dim as usual." "refused" => "The system refused. Low Power Mode can do this." _ => "While a session runs." } let alert_words = match alerts { "app" => "The phone will alert you at the end if the app is away." "on" => "A notification will come at the end if this page is away." "ask" => "Allow notifications to hear of the end while this page is away." "blocked" => "Notifications are blocked. The end shows here when you come back." "refused" => "Notifications are off for this app. The end shows here when you come back." "none" => "No notifications here. The end shows when you come back." _ => "" } let share_state = () => { on_state && on_state({ phase, status, ends_at, left, round: turn, minutes: phase_mins }) } // The day a moment falls on, counted from `today`, so a `today` given as a prop keeps its place. let day_of = (ms: num) => { let t = js_new("Date", ms) let here = date.of(t.getFullYear(), t.getMonth() + 1, t.getDate()) date.add_days(base_day, date.days_between(base_clock, here)) } let let_screen_go = () => { if lock != null { let l = lock lock = null l.release() } if win.quilloNative != null { js("quilloNative.keepAwake", false) } if awake == "on" || awake == "away" { awake = "off" } } let hold_screen = () => { if !keep { awake = "off" } else if win.quilloNative != null { let r = js("quilloNative.keepAwake", true) awake = is_err(r) ? "refused" : "on" } 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" || !keep { got.release() } else { lock = got awake = "on" got.addEventListener("release", (_e = null) => { // Let go by us clears `lock` first; this is the browser, as the page left. if lock != null { lock = null if status == "running" { awake = "away" } } }) } } } let wake_sound = () => { if sound && ctx == null && win.AudioContext != null { // From the tap: both systems start sound only from one. let c = js("Promise.resolve", js_new("Promise", (done) => { done(js_new("AudioContext")) })) if !is_err(c) { ctx = c } } if ctx != null { ctx.resume() } } let bell = () => { if ctx != null { let t0 = ctx.currentTime for i in range(0, 3) { let osc = ctx.createOscillator() let vol = ctx.createGain() osc.frequency.value = 880 vol.gain.value = 0.2 osc.connect(vol) vol.connect(ctx.destination) osc.start(t0 + i * 0.3) osc.stop(t0 + i * 0.3 + 0.18) } } } let buzz = () => { if win.quilloNative != null { js("quilloNative.haptic", "success") } else if win.navigator.vibrate != null { win.navigator.vibrate([200, 100, 200]) } } let tell_browser = (text: str) => { if alerts == "on" && js("document.visibilityState") == "hidden" { // Android's browsers refuse this and want a service worker; the screen says it all the same. js("Promise.resolve", js_new("Promise", (done) => { done(js_new("Notification", focus_timer_word(phase), { body: text })) })) } } let finish = (end: num) => { let was = phase let done_mins = phase_mins let began = started_at let next = focus_timer_next(was, turn, round_count) status = "idle" told = false phase = next.phase turn = next.round left = (next.phase == "focus" ? mins : next.phase == "long" ? long_mins : break_mins) * 60000 ends_at = 0 started_at = null let t = js("Date.now") let late = t - end let ended = js_new("Date", end) let at = `${ended.getHours()}:${focus_timer_pad(ended.getMinutes())}` let on = day_of(end) let text = was == "focus" ? (next.phase == "long" ? "Session done. Time for a long break." : "Session done. Time for a break.") : "Break over. Ready when you are." over = true said = late >= 5000 ? `${text} It ended at ${at}, while you were away.` : text let_screen_go() let s = { date: on, minutes: done_mins, started_at: began, ended_at: end } if was == "focus" { log = log + [{ date: on, minutes: done_mins }] } if late < 5000 { bell() buzz() tell_browser(text) } if was == "focus" { on_session && on_session(s) } share_state() } let tick = () => { let t = js("Date.now") if status == "running" { let rest = ends_at - t if rest <= 0 { left = 0 finish(ends_at) } else { left = rest } } } let begin = () => { wake_sound() let t = js("Date.now") said = "" over = false if started_at == null { started_at = t } ends_at = t + left status = "running" told = false share_state() hold_screen() } let pause = () => { let t = js("Date.now") left = max([ends_at - t, 1]) status = "paused" said = told ? "The alert the phone was given cannot be taken back, so it will still come." : "" told = false let_screen_go() share_state() } let stop = () => { let was_told = told status = "idle" told = false left = whole ends_at = 0 started_at = null over = false said = was_told ? "Stopped, and not counted. The alert the phone was given will still come." : "Stopped, and not counted." let_screen_go() share_state() } let skip = () => { let next = focus_timer_next(phase, turn, round_count) phase = next.phase turn = next.round left = mins * 60000 said = "" over = false share_state() } // The app goes to sleep with the page, so the phone is told while there is still time. let tell_phone = () => { if win.quilloNative != null && !told && status == "running" { let secs = max([1, int(ceil((ends_at - js("Date.now")) / 1000))]) let text = phase == "focus" ? "Session done. Time for a break." : "Break over. Ready when you are." let r = js("quilloNative.notify", focus_timer_word(phase), text, secs) if is_err(r) { alerts = "refused" } else { told = true } } } let come_back = (_e = null) => { let shown = js("document.visibilityState") == "visible" if status == "running" { if !shown { tell_phone() } tick() if shown && status == "running" && lock == null && keep { hold_screen() } } if shown { let t = js_new("Date") let here = date.of(t.getFullYear(), t.getMonth() + 1, t.getDate()) day = date.add_days(base_day, date.days_between(base_clock, here)) } } if status == "running" { every(250, tick) } <section class={ui_cls(["ui-focus-timer", class])} aria-label={label} data-phase={phase} @mount={(_el) => { // Asked first: nothing here may run with the state half set. let w = js("window") let d = js("document") let t0 = js("Date.now") let t = js_new("Date") let clock_day = date.of(t.getFullYear(), t.getMonth() + 1, t.getDate()) let perm = js("Notification.permission") win = w base_clock = clock_day alerts = w.quilloNative != null ? "app" : is_err(perm) ? "none" : perm == "granted" ? "on" : perm == "denied" ? "blocked" : "ask" d.addEventListener("visibilitychange", come_back) w.addEventListener("pageshow", come_back) w.addEventListener("focus", come_back) let saved = focus_timer_restored(resume, t0) if saved != null { phase = saved.phase turn = saved.round if saved.phase == "focus" { mins = saved.minutes } left = saved.left ends_at = saved.ends_at status = saved.status if saved.over { finish(saved.ends_at) } else if saved.status == "running" { hold_screen() } } () => { d.removeEventListener("visibilitychange", come_back) w.removeEventListener("pageshow", come_back) w.removeEventListener("focus", come_back) let_screen_go() } }}> <div.ui-focus-timer-inner> <div.ui-focus-timer-main> <div.ui-focus-timer-ring> <svg viewBox="0 0 200 200" aria-hidden="true"> <circle.ui-focus-timer-track cx="100" cy="100" r="92"/> <circle.ui-focus-timer-arc cx="100" cy="100" r="92" pathLength="100" stroke-dasharray="100" stroke-dashoffset={100 - share * 100}/> </svg> <div.ui-focus-timer-mid> <span.ui-focus-timer-phase>{focus_timer_word(phase)}</span> <span.ui-focus-timer-time role="timer" aria-label={`${focus_timer_clock(ceil(left / 1000))} left`}>{focus_timer_clock(ceil(left / 1000))}</span> <span.ui-focus-timer-round>Round {turn} of {round_count}</span> </div> </div> <div.ui-focus-timer-keys> <button.ui-focus-timer-go type="button" @click={() => { if status == "running" { pause() } else { begin() } }}>{main_word}</button> {other_word != "" && <button.ui-focus-timer-off type="button" @click={() => { if status == "idle" { skip() } else { stop() } }}>{other_word}</button>} </div> {status == "idle" && phase == "focus" && len(offers) > 0 && <div.ui-focus-timer-times role="group" aria-label="Length of a session"> {for c in offers => <button.ui-focus-timer-time-btn type="button" key={str(c)} aria-pressed={c == mins ? "true" : "false"} aria-label={`${c} minutes`} @click={() => { mins = c left = c * 60000 }}>{c}</button>} </div>} <p.ui-focus-timer-say role="status" data-done={over ? "" : null}>{said}</p> </div> <div.ui-focus-timer-side> <dl.ui-focus-timer-stats> <div.ui-focus-timer-stat><dt>Streak</dt><dd>{focus_timer_days(streak)}</dd></div> <div.ui-focus-timer-stat><dt>Today</dt><dd>{focus_timer_sum(on_today)} min</dd></div> <div.ui-focus-timer-stat><dt>Best</dt><dd>{focus_timer_days(best)}</dd></div> </dl> <ul.ui-focus-timer-week aria-label="The last seven days"> {for d in week => <li.ui-focus-timer-day key={d.date} aria-label={`${d.name}: ${round(d.minutes)} minutes`}> <span.ui-focus-timer-bar style={`height: ${round(d.share * 100)}%`}></span> <span aria-hidden="true">{d.name}</span> </li>} </ul> <button.ui-focus-timer-row type="button" role="switch" aria-checked={keep ? "true" : "false"} @click={() => { keep = !keep if !keep { let_screen_go() } else if status == "running" { hold_screen() } }}> <span>Keep the screen on{" "} <span.ui-focus-timer-note data-bad={awake == "none" || awake == "refused" ? "" : null}>{awake_words}</span> </span> <span.ui-focus-timer-track-sw aria-hidden="true"></span> </button> <div.ui-focus-timer-line> <span.ui-focus-timer-note>{alert_words}</span> {alerts == "ask" && <button.ui-focus-timer-link type="button" @click={() => { let a = js("Notification.requestPermission") alerts = !is_err(a) && a == "granted" ? "on" : !is_err(a) && a == "denied" ? "blocked" : "ask" }}>Allow</button>} {alerts == "refused" && <button.ui-focus-timer-link type="button" @click={() => { js("quilloNative.openSettings") }}>Settings</button>} </div> </div> </div> </section>}Related
PermissionChecklistCamera, location, notifications and the like, each with its reason, a live state, and a route to Settings.3 views · 5 downloadsWorkoutTimerIntervals 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.3 views · 6 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 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 downloadsAdaptiveDialogA sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.3 views · 5 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloads
Thread
Thread 0 posts
On FocusTimer: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.