HabitTracker
A grid of days per habit that works out streaks, completion and grace days, on chosen weekdays only, and tells streaks apart across a missed day.
Demo
Habits
| Habit | T22 | F23 | S24 | S25 | M26 | T27 | W28 | T29 | F30 | S31 | S1 | M2 | T3 | W4 | T5 | F6 | S7 | S8 | M9 | T10 | W11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Read | |||||||||||||||||||||
| Run | |||||||||||||||||||||
| Stretch |
- ReadCurrent 5 daysLongest 5 days81% of 16 dueBroken on Wed 4 Nov, 2 times in all.Streaks: 26 Oct to 28 Oct (3), 30 Oct to 3 Nov (5), 6 Nov to 10 Nov (5)
- RunCurrent 2 daysLongest 4 days86% of 7 dueBroken on Wed 4 Nov.Streaks: 26 Oct to 2 Nov (4), 6 Nov to 9 Nov (2)1 extra on rest day, not in the streak.
- StretchCurrent 4 daysLongest 4 days80% of 5 due
This fortnight
| Habit | T29 | F30 | S31 | S1 | M2 | T3 | W4 | T5 | F6 | S7 | S8 | M9 | T10 | W11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Water plants |
- Water plantsCurrent 0 daysLongest 1 day50% of 2 dueBroken on Sat 7 Nov.
Tick a day to see on_change.
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/habit_tracker.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/habit_tracker.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/habit_tracker.qwe
component HabitTrackerDemoLive() { state ticks = null <div.demo-stack> <HabitTracker id="habit-tracker-demo-live" title="This fortnight" today="2026-11-11" days={14} name="fortnight" habits={[{ name: "Water plants", weekdays: [3, 6], start: "2026-11-04", done: ["2026-11-04"] }]} on_change={(hs) => { ticks = len(hs[0].done) }}/> <p.demo-note>{ticks == null ? "Tick a day to see on_change." : `on_change: ${ticks} ticked.`}</p> </div>}view HabitTrackerDemo() = <div.demo-stack> <HabitTracker id="habit-tracker-demo" today="2026-11-11" days={21} habits={[ { id: "read", name: "Read", start: "2026-10-26", done: ["2026-10-26", "2026-10-27", "2026-10-28", "2026-10-30", "2026-10-31", "2026-11-01", "2026-11-02", "2026-11-03", "2026-11-06", "2026-11-07", "2026-11-08", "2026-11-09", "2026-11-10"] }, { id: "run", name: "Run", weekdays: [1, 3, 5], start: "2026-10-26", done: ["2026-10-26", "2026-10-28", "2026-10-30", "2026-11-02", "2026-11-06", "2026-11-07", "2026-11-09"] }, { id: "stretch", name: "Stretch", weekdays: [2, 4], grace: 1, start: "2026-10-27", done: ["2026-10-27", "2026-10-29", "2026-11-05", "2026-11-10"] }]}/> <HabitTrackerDemoLive/> </div>Source
ui/habit_tracker.qwe
// HabitTracker: a row of days for each habit, to tick off. It works out the current and// longest streak, the completion rate and every streak there has been, and keeps them// apart properly: a day the habit is not scheduled neither counts nor breaks a streak, a// missed scheduled day ends one, today is not missed until it is over, and grace days// forgive a slip without adding to the count. Ticks on rest days are kept as extras.//// <HabitTracker today="2026-11-11" habits={[// { name: "Read", done: ["2026-11-09", "2026-11-10"] },// { name: "Run", weekdays: [1, 3, 5], start: "2026-10-26", grace: 1, done: ["2026-11-09"] }]}/>//// habits: a list of { id?, name, weekdays?, start?, grace?, done }. weekdays: the days it// is scheduled, 1 for Monday to 7 for Sunday (every day when left out). start: the first// day it counts from (the earliest tick when left out; a tick before it moves it back).// grace: how many scheduled days in a row a streak survives missing (the `grace` prop// when left out). done: the dates ticked ("YYYY-MM-DD"). today: the last day shown;// pass it so the page is the same on the server and in the browser (the server's today,// in UTC, when left out). days: how many days the grid shows (28, from 7 to 366).// grace: the default for habits that do not say (0). title: the heading. id: a prefix// for its ids, unique on the page. name: the field a form gets the habits in, as JSON// ("habits"). on_change(habits): inside one of your components, after each tick, with// the habits as they stand.//// Arrow keys move between cells, Home and End along a row, and Space or Enter ticks.//// habit_tracker_stats(habit, today) is the logic, for a program's own use, on a habit// from habit_tracker_read: { current, longest, scheduled, done, missed, extras, rate,// runs, breaks, marks, pending, on_grace, cut }. rate is a whole percent, or null with// nothing yet due; a run is { from, to, length, graced }; breaks are the days a streak// ended; marks gives each date "done", "extra", "grace", "missed", "off", "pending" or// "before". It looks back at most 3,660 days, and `cut` says when it stopped short.import ui/theme.qwe use ui_clslet habit_tracker_css = css`.ui-habit-tracker { display: grid; gap: 12px; min-width: 0; padding: 18px 20px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-habit-tracker .ui-habit-tracker-title { margin: 0; font-size: 16px; font-weight: 650; }.ui-habit-tracker-problems { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; color: var(--ui-danger); font-size: 13px; }.ui-habit-tracker-wrap { overflow-x: auto; }.ui-habit-tracker-wrap:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-habit-tracker-grid { border-collapse: separate; border-spacing: 2px; font-size: 12px; font-variant-numeric: tabular-nums; }.ui-habit-tracker-grid th { font-weight: 500; color: var(--ui-muted); text-align: center; padding: 0 0 2px; }.ui-habit-tracker-grid th span { display: block; font-size: 10px; }.ui-habit-tracker-grid .ui-habit-tracker-name { position: sticky; left: 0; z-index: 1; min-width: 96px; max-width: 160px; padding-right: 10px; background: var(--ui-surface); color: var(--ui-fg); font-size: 13px; font-weight: 600; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.ui-habit-tracker-grid td { padding: 0; }.ui-habit-tracker-cell { display: block; width: 22px; height: 22px; padding: 0; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-surface); color: var(--ui-accent-fg); font: 700 11px/1 var(--ui-font); cursor: pointer; }.ui-habit-tracker-cell:hover { border-color: var(--ui-accent); }.ui-habit-tracker-cell:focus-visible { outline: none; box-shadow: var(--ui-focus); position: relative; z-index: 2; }.ui-habit-tracker-done { border-color: var(--ui-accent); background: var(--ui-accent); }.ui-habit-tracker-extra { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-habit-tracker-grace { border-color: var(--ui-warning); background: var(--ui-warning-soft); color: var(--ui-warning); }.ui-habit-tracker-missed { border-color: var(--ui-danger); background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-habit-tracker-off, .ui-habit-tracker-before { border-style: dashed; background: var(--ui-subtle); color: var(--ui-muted); }.ui-habit-tracker-pending { border: 2px solid var(--ui-accent); }.ui-habit-tracker-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; margin: 0; padding: 0; list-style: none; }.ui-habit-tracker-card { display: grid; gap: 3px; min-width: 0; padding: 10px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); font-size: 13px; }.ui-habit-tracker-card b { overflow-wrap: anywhere; }.ui-habit-tracker-figures { display: flex; flex-wrap: wrap; gap: 2px 12px; font-variant-numeric: tabular-nums; }.ui-habit-tracker-current, .ui-habit-tracker-longest, .ui-habit-tracker-rate { font-weight: 500; }.ui-habit-tracker-note { color: var(--ui-muted); font-size: 12px; overflow-wrap: anywhere; }.ui-habit-tracker-said { min-height: 1.4em; margin: 0; color: var(--ui-muted); font-size: 13px; }`let habit_tracker_look_back = 3660// A habit made safe to use: { id, name, weekdays, grace, start, done, problems }, with// what could not be used left out and said.fn habit_tracker_read(h, i: int, grace: int, today: str) -> map { let m = type_of(h) == "map" ? h : { name: str(h ?? "") } let label = filled(trim(str(m.name ?? ""))) ?? `Habit ${i + 1}` let problems = [] let given = type_of(m.done) == "list" ? m.done : [] let good = given | filter((d) => type_of(d) == "str" && date.valid(d, "date") && d >= "1000-01-01" && d <= today) for d in given { if type_of(d) != "str" || !date.valid(d, "date") { problems = problems | push(`${label}: "${slice(str(d), 0, 30)}" is not a date.`) } else if d > today { problems = problems | push(`${label}: ${d} is in the future, so it is left out.`) } } let wanted = type_of(m.weekdays) == "list" ? m.weekdays : [1, 2, 3, 4, 5, 6, 7] let weekdays = wanted | filter((w) => type_of(w) == "int" && w >= 1 && w <= 7) | unique | sort for w in wanted { if !contains(weekdays, w) { problems = problems | push(`${label}: weekday ${slice(str(w), 0, 12)} is not from 1 to 7.`) } } if len(weekdays) == 0 { problems = problems | push(`${label}: it is not scheduled on any day.`) } let own = type_of(m.grace) == "int" && m.grace >= 0 ? m.grace : grace if m.grace != null && own != m.grace { problems = problems | push(`${label}: grace must be a whole number of 0 or more.`) } let done = sort(unique(good)) let wrote = type_of(m.start) == "str" && date.valid(m.start, "date") && m.start >= "1000-01-01" ? m.start : null if m.start != null && wrote == null { problems = problems | push(`${label}: "${slice(str(m.start), 0, 30)}" is not a start date.`) } let first_tick = first(done) let start = wrote == null ? first_tick ?? today : (first_tick != null && first_tick < wrote ? first_tick : wrote) { id: str(m.id ?? label), name: label, weekdays, grace: own, start, done, problems }}// Every habit, with no two sharing an id, as each row is keyed by it.fn habit_tracker_read_all(habits, grace: int, today: str) -> list { let seen = {} let out = [] for [i, h] in enumerate(type_of(habits) == "list" ? habits : []) { let r = habit_tracker_read(h, i, grace, today) let n = (get(seen, r.id) ?? 0) + 1 seen = set(seen, r.id, n) out = out | push(n > 1 ? set(r, "id", `${r.id}-${n}`) : r) } out}// Whole percent, half up, in ints so nothing is lost: 13 of 16 is 81.fn habit_tracker_percent(part: int, whole: int) = whole == 0 ? null : floor((part * 200 + whole) / (whole * 2))// See the top of the file. Walking the days in order, misses wait in `gap`: they are// forgiven only if a tick follows while the streak is alive, or if there are still few// enough of them when the walk reaches today.fn habit_tracker_stats(h: map, today: str) -> map { let ticked = {} for d in h.done { ticked = set(ticked, d, true) } let span = date.days_between(h.start, today) let skip = max([0, span - habit_tracker_look_back + 1]) let marks = {} let runs = [] let breaks = [] let run = null let gap = [] let scheduled = 0 let done = 0 let extras = 0 let pending = false let i = skip let wd = date.weekday(date.add_days(h.start, skip)) while i <= span { let d = date.add_days(h.start, i) let due = contains(h.weekdays, wd) let hit = has(ticked, d) if !due { marks = set(marks, d, hit ? "extra" : "off") extras += hit ? 1 : 0 } else if hit { scheduled += 1 done += 1 marks = set(marks, d, "done") if run == null { run = { from: d, to: d, length: 1, graced: 0 } } else { for g in gap { marks = set(marks, g, "grace") } run = { from: run.from, to: d, length: run.length + 1, graced: run.graced + len(gap) } } gap = [] } else if d == today { pending = true marks = set(marks, d, "pending") } else { scheduled += 1 marks = set(marks, d, "missed") gap = gap | push(d) if run != null && len(gap) > h.grace { runs = runs | push(run) breaks = breaks | push(gap[0]) run = null } } i += 1 wd = wd == 7 ? 1 : wd + 1 } // Misses at the very end are forgiven for now, while few enough: the streak is alive. let on_grace = run != null && len(gap) > 0 if on_grace { for g in gap { marks = set(marks, g, "grace") } } let all_runs = run != null ? runs | push(run) : runs { current: run != null ? run.length : 0, longest: all_runs | map((r) => r.length) | max ?? 0, scheduled, done, missed: scheduled - done, extras, rate: habit_tracker_percent(done, scheduled), runs: all_runs, breaks, marks, pending, on_grace, cut: skip > 0, grace_left: on_grace ? h.grace - len(gap) : h.grace }}// The `count` days ending at `today`, oldest first.fn habit_tracker_days(today: str, count: int) -> list = range(count) | map((i) => date.add_days(today, i - count + 1))fn habit_tracker_show(d: str) -> str = date.format(d, "ddd D MMM")// What a cell says to a screen reader, and shows.fn habit_tracker_word(mark) -> str = match mark { "done" => "done" "extra" => "done on a rest day" "grace" => "missed, forgiven by a grace day" "missed" => "missed" "pending" => "due today" "before" => "before this habit started" _ => "rest day"}fn habit_tracker_glyph(mark) -> str = match mark { "done" => "✓" "extra" => "+" "grace" => "~" "missed" => "×" _ => ""}// A streak as it reads: "26 Oct to 28 Oct (3)".fn habit_tracker_run(r: map) -> str { let d = (s: str) => date.format(s, "D MMM") `${r.from == r.to ? d(r.from) : `${d(r.from)} to ${d(r.to)}`} (${r.length})`}fn habit_tracker_days_word(n: int) -> str = `${n} ${n == 1 ? "day" : "days"}`component HabitTracker(habits: list = [], today: str? = null, days: int = 28, grace: int = 0, title: str = "Habits", id: str = "habit-tracker", name: str = "habits", on_change = null, class: str? = null) { style global habit_tracker_css state day = today ?? date.today() let good_day = date.valid(day, "date") && day >= "1000-02-01" state items = good_day ? habit_tracker_read_all(habits, grace, day) : [] state at_row = 0 state at_col = 0 state moved = false state said = "" let count = min([max([days, 7]), 366]) let dates = good_day ? habit_tracker_days(day, count) : [] let stats = items | map((h) => habit_tracker_stats(h, day)) let col = moved ? at_col : count - 1 let data = () => items | map((h) => ({ id: h.id, name: h.name, weekdays: h.weekdays, grace: h.grace, start: h.start, done: h.done })) let flip = (r: int, d: str) => { let h = items[r] let on = contains(h.done, d) let done = on ? remove(h.done, d) : sort(h.done | push(d)) let start = !on && d < h.start ? d : h.start items = set(items, r, merge(h, { done, start })) let s = habit_tracker_stats(items[r], day) said = `${h.name}, ${habit_tracker_show(d)} ${on ? "cleared" : "done"}. Current streak ${habit_tracker_days_word(s.current)}.` on_change && on_change(data()) } let go = (e, r: int, c: int) => { e.preventDefault() at_row = r at_col = c moved = true let t = e.target.closest("table").querySelector(`[data-cell="${r}-${c}"]`) t && t.focus() } let keys = (e, r: int, c: int) => { let last_row = len(items) - 1 match e.key { "ArrowRight" => go(e, r, min([c + 1, count - 1])) "ArrowLeft" => go(e, r, max([c - 1, 0])) "ArrowDown" => go(e, min([r + 1, last_row]), c) "ArrowUp" => go(e, max([r - 1, 0]), c) "Home" => go(e, r, 0) "End" => go(e, r, count - 1) _ => null } } let problems = (good_day ? [] : [`"${slice(str(day), 0, 30)}" is not a date for today.`]) + (items | map((h) => h.problems) | flatten()) <section class={ui_cls(["ui-habit-tracker", class])} aria-labelledby={`${id}-title`}> <h3.ui-habit-tracker-title id={`${id}-title`}>{title}</h3> <input type="hidden" name={name} value={json.stringify(data())}/> {len(problems) > 0 && <ul.ui-habit-tracker-problems role="alert">{for p in take(problems, 6) => <li key={p}>{p}</li>}</ul>} {len(items) == 0 ? <p.ui-habit-tracker-said>No habits yet.</p> : <div.ui-habit-tracker-wrap tabindex="0" role="region" aria-label={`${title}, last ${count} days`}> <table.ui-habit-tracker-grid> <thead><tr> <th scope="col"><span.ui-sr>Habit</span></th> {for d in dates => <th key={d} scope="col" title={habit_tracker_show(d)}>{date.format(d, "ddd")[0]}<span>{date.format(d, "D")}</span></th>} </tr></thead> <tbody> {for [r, h] in enumerate(items) => <tr key={h.id}> <th.ui-habit-tracker-name scope="row">{h.name}</th> {for [c, d] in enumerate(dates) => { let mark = get(stats[r].marks, d) ?? "before" <td key={d}><button type="button" class={`ui-habit-tracker-cell ui-habit-tracker-${mark}`} data-cell={`${r}-${c}`} data-date={d} tabindex={r == at_row && c == col ? 0 : -1} aria-pressed={mark == "done" || mark == "extra" ? "true" : "false"} aria-label={`${h.name}, ${habit_tracker_show(d)}, ${habit_tracker_word(mark)}`} @click={() => { flip(r, d) }} @keydown={(e) => { keys(e, r, c) }}>{habit_tracker_glyph(mark)}</button></td> }} </tr>} </tbody> </table> </div>} {len(items) > 0 && <ul.ui-habit-tracker-list> {for [r, h] in enumerate(items) => { let s = stats[r] <li.ui-habit-tracker-card key={h.id}> <b>{h.name}</b> <span.ui-habit-tracker-figures> <span.ui-habit-tracker-current>Current {habit_tracker_days_word(s.current)}</span> <span.ui-habit-tracker-longest>Longest {habit_tracker_days_word(s.longest)}</span> <span.ui-habit-tracker-rate>{s.rate != null ? `${s.rate}%` : "–"} of {s.scheduled} due</span> </span> {s.on_grace && <span.ui-habit-tracker-note>On grace: the streak ends if the next scheduled {s.grace_left == 0 ? "day is" : `${s.grace_left + 1} days are`} missed.</span>} {len(s.breaks) > 0 && <span.ui-habit-tracker-note>Broken on {habit_tracker_show(last(s.breaks))}{len(s.breaks) > 1 ? `, ${len(s.breaks)} times in all` : ""}.</span>} {len(s.runs) > 1 && <span.ui-habit-tracker-note>Streaks: {slice(s.runs, -4, len(s.runs)) | map(habit_tracker_run) | join(", ")}</span>} {s.extras > 0 && <span.ui-habit-tracker-note>{s.extras} extra on rest {s.extras == 1 ? "day" : "days"}, not in the streak.</span>} {s.cut && <span.ui-habit-tracker-note>Only the last {habit_tracker_look_back} days are counted.</span>} </li> }} </ul>} <p.ui-habit-tracker-said aria-live="polite">{said}</p> </section>}Related
BusinessDaysCalcCounts the working days between two dates, or finds a date N working days away, with your own weekends and holidays, listing each day it skipped.4 views · 8 downloadsSlotFinderFinds common free slots from several people's busy lists, in working hours in a chosen time zone, ranking the closest by who is affected when none suits all.3 views · 6 downloadsAgendaListEvents grouped by day with the date at the left, each with its time, title and place; today and tomorrow named.6 views · 6 downloadsAvailabilityGridDays across and hours down: click or drag across the cells to mark when you are available; the slots go in a hidden input.7 views · 8 downloadsCriticalPathRuns the forward and backward passes over tasks and dependencies: slack, the critical path and real dates with weekends off, naming cycles and missing tasks.3 views · 6 downloadsDayViewOne day as a column of hours with its events drawn at their times, side by side when they overlap, and all-day events above.7 views · 7 downloads
Thread
Thread 0 posts
On HabitTracker: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.