CohortTable
Signup and activity events to a retention triangle by week or month, heat-coloured, with a weighted average and unfinished periods left blank.
Demo
Weekly retention
8 users in 5 cohorts. 71.4% came back in week 1. Best 19 Jan 2026 at 100.0%, lowest 12 Jan 2026 at 50.0%.
| Cohort | People | Week 0 | Week 1 | Week 2 | Week 3 |
|---|---|---|---|---|---|
| 5 Jan 2026 | 4 | 100.0% | 75.0% | 25.0% | 25.0% |
| 12 Jan 2026 | 2 | 100.0% | 50.0% | 0.0% | Not ended |
| 19 Jan 2026 | 1 | 100.0% | 100.0% | Not ended | Not ended |
| 26 Jan 2026 | 0 | Not ended | Not ended | Not ended | |
| 2 Feb 2026 | 1 | Not ended | Not ended | Not ended | Not ended |
| Weighted average | 8 | 100.0% | 71.4% | 16.7% | 25.0% |
Left out: 4 events with no user or no readable date, 1 event before a signup, 1 event after today, 1 user signing up after today.
By month, in people
9 users in 3 cohorts. 14.3% came back in month 1.
| Cohort | People | Month 0 | Month 1 |
|---|---|---|---|
| Jan 2026 | 7 | 7 | 1 |
| Feb 2026 | 1 | 1 | Not ended |
| Mar 2026 | 1 | Not ended | Not ended |
| All cohorts | 9 | 8 | 1 |
Left out: 4 events with no user or no readable date, 1 event before a signup.
Switch a view to see on_change.
Nothing yet
No signups to show yet.
A bad day
The date given as today cannot be read: "soon".
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/cohort_table.qweimport ui/theme.qwe use theme_cssuse ui/cohort_table.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
let cohort_table_demo_events = [ { user: "a", date: "2026-01-05", event: "signup" }, { user: "a", date: "2026-01-13" }, { user: "a", date: "2026-01-20" }, { user: "a", date: "2026-01-21" }, { user: "a", date: "2026-01-27" }, { user: "a", date: "2026-02-03" }, { user: "a", date: "2026-01-01" }, { user: "b", date: "2026-01-06T09:30:00Z", event: "signup" }, { user: "b", date: "2026-01-14T23:00:00Z" }, { user: "c", date: "2026-01-07" }, { user: "d", date: "2026-01-11", event: "signup" }, { user: "d", date: "2026-01-12" }, { user: "e", date: "2026-01-12" }, { user: "e", date: "2026-01-19" }, { user: 6, date: "2026-01-15" }, { user: "g", date: "2026-01-20" }, { user: "g", date: "2026-01-26" }, { user: "h", date: "2026-02-02" }, { user: "h", date: "2026-02-09" }, { user: "k", date: "2026-03-01", event: "signup" }, { user: "z", date: "not a date" }, { user: "", date: "2026-01-10" }, "not even an event", { user: "y", date: "2026-02-30" },]component CohortTableDemoLive() { state seen = "Switch a view to see on_change." <div.demo-stack> <CohortTable id="cohort-table-demo-live" title="By month, in people" name="monthly" events={cohort_table_demo_events} today="2026-03-10" period="month" show="count" on_change={(v) => { seen = `on_change: ${v.period}, ${v.show}, ${len(v.average)} periods.` }}/> <p.demo-note>{seen}</p> </div>}view CohortTableDemo() = <div.demo-stack> <CohortTable id="cohort-table-demo" title="Weekly retention" name="weekly" events={cohort_table_demo_events} today="2026-02-04"/> <CohortTableDemoLive/> <CohortTable id="cohort-table-demo-empty" title="Nothing yet" name="empty" events={[]} today="2026-02-04"/> <CohortTable id="cohort-table-demo-bad" title="A bad day" name="bad" events={cohort_table_demo_events} today="soon"/> </div>Source
// CohortTable: signup and activity events to a retention triangle: one row for each// week or month of signups, one column for each period after it, heat-coloured, as// percentages or as people, with a weighted average row. A period that has not ended// is blank and not 0, so a young cohort never looks as if everyone left; a real 0 is// written. Cohorts of nobody keep their row, so the months read in order.//// <CohortTable events={events} today="2026-02-04"/>// <CohortTable events={events} today={today} period="month" show="count" on_change={(v) => { seen = v }}/>//// Each user counts once in a period, however often they came. Period 0 is the week// or month of their signup, and holds everyone in the cohort. Weeks start on Monday.// The average weighs cohorts by their size, and leaves out those whose period has// not ended, so that a column's average is over the same people as its cells.//// events: each { user, date, event? }. date: "2026-01-05", or a date-time, of which the// date is taken. A user's signup is their first event with event "signup", else their// first event of any kind. Events with no user or no readable date, and those before// the signup or after today, are left out, and counted in a note under the table; so// are events past the first 20,000. today: the day the table is read, as a date; it is// a prop so that the server and the browser agree (left out, the last event's day).// period: "week" (the default) or "month"; show: "percent" (the default) or "count";// the reader can switch both, from fields named period and show, with name a prefix// for those names. In counts the last row totals each column. limit: the most period// columns (12). max_cohorts: the most rows, the latest kept (24). title: the heading// ("Retention by cohort"). id: a prefix for its ids ("cohort-table"), unique on the// page. on_change(view): inside one of your components, given { period, show, average }// after a switch, average being the fraction kept in each period, null where unknown.//// cohort_table_build(events, unit, today, limit, max_cohorts) is the work, for a// program's own use: { cohorts, periods, average, totals, users, left_out }, each cohort// { start, label, size, cells }, a cell being the people active, null when not ended.import ui/theme.qwe use ui_clslet cohort_table_css = css`.ui-cohort-table { display: grid; gap: 12px; min-width: 0; padding: 20px 22px; 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-cohort-table .ui-cohort-table-title { display: block; margin: 0; font-size: 16px; font-weight: 650; }.ui-cohort-table-bar { display: flex; flex-wrap: wrap; gap: 8px 22px; margin: 0; }.ui-cohort-table-group { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; margin: 0; padding: 0; border: 0; font-size: 13px; }.ui-cohort-table-group legend { float: left; margin-right: 6px; padding: 0; color: var(--ui-muted); }.ui-cohort-table-group label { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }.ui-cohort-table-group input { margin: 0; accent-color: var(--ui-accent); }.ui-cohort-table-scroll { position: relative; min-width: 0; overflow-x: auto; }.ui-cohort-table-scroll:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-cohort-table-table { border-collapse: separate; border-spacing: 2px; font-size: 13px; font-variant-numeric: tabular-nums; }.ui-cohort-table-table th { padding: 5px 8px; color: var(--ui-muted); font-size: 12px; font-weight: 600; text-align: right; white-space: nowrap; }.ui-cohort-table-table th:first-child { position: sticky; left: 0; background: var(--ui-surface); text-align: left; }.ui-cohort-table-table td { min-width: 52px; padding: 5px 8px; border-radius: 4px; text-align: right; white-space: nowrap; }.ui-cohort-table-size { color: var(--ui-muted); }.ui-cohort-table-open { background: repeating-linear-gradient(135deg, transparent 0 4px, color-mix(in srgb, var(--ui-border) 45%, transparent) 4px 5px); }.ui-cohort-table-average th, .ui-cohort-table-average td { border-top: 1px solid var(--ui-strong); font-weight: 600; }.ui-cohort-table-say { margin: 0; font-size: 13px; }.ui-cohort-table-note { margin: 0; color: var(--ui-muted); font-size: 12px; }.ui-cohort-table-warn { margin: 0; padding: 8px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-warning-soft); color: var(--ui-warning); font-size: 13px; }`let cohort_table_cap = 20000// A date as "YYYY-MM-DD", from a date or the start of a date-time; null when it is not one.fn cohort_table_day(v) { let t = slice(trim(str(v ?? "")), 0, 10) re.test(t, "^\\d{4}-\\d{2}-\\d{2}$") && date.valid(t, "date") ? t : null}fn cohort_table_start(day: str, unit: str) -> str = unit == "month" ? date.start_of_month(day) : date.add_days(day, 1 - date.parts(day).weekday)// Which period after a cohort's start a day is in; also how many have ended by that day.fn cohort_table_period(start: str, day: str, unit: str) -> int { if unit != "month" { return floor(date.days_between(start, day) / 7) } let a = date.parts(start) let b = date.parts(day) (b.year - a.year) * 12 + b.month - a.month}fn cohort_table_after(start: str, unit: str, n: int) -> str = unit == "month" ? date.add_months(start, n) : date.add_days(start, 7 * n)fn cohort_table_label(start: str, unit: str) -> str = date.format(start, unit == "month" ? "MMM YYYY" : "D MMM YYYY")// A user's events to { cohort, periods, early, late }, or null when they sign up after today.fn cohort_table_user(days: list, signup_days: list, unit: str, today: str) { let signup = first(sort(len(signup_days) > 0 ? signup_days : days)) if signup > today { return null } let cohort = cohort_table_start(signup, unit) let kept = days | filter((d) => d >= signup && d <= today) let seen = group_by(kept | map((d) => cohort_table_period(cohort, d, unit)), (p) => p) { cohort, periods: keys(seen), early: len(days | filter((d) => d < signup)), late: len(days) - len(kept) - len(days | filter((d) => d < signup)) }}fn cohort_table_build(events: list, unit: str, today: str, limit: int = 12, max_cohorts: int = 24) -> map { let taken = slice(events, 0, cohort_table_cap) let read = taken | map((e) => { let who = type_of(e) == "map" ? filled(trim(str(e.user ?? ""))) : null let day = type_of(e) == "map" ? cohort_table_day(e.date) : null who == null || day == null ? null : { who, day, signup: e.event == "signup" } }) let good = read | filter((r) => r != null) let by_user = group_by(good, (r) => r.who) let users = values(by_user) | map((rs) => cohort_table_user(rs | map((r) => r.day), rs | filter((r) => r.signup) | map((r) => r.day), unit, today)) let placed = users | filter((u) => u != null) let left_out = { unread: len(taken) - len(good), early: sum(placed | map((u) => u.early)), late: sum(placed | map((u) => u.late)), future_users: len(users) - len(placed), over: len(events) - len(taken) } if len(placed) == 0 { return { cohorts: [], periods: 0, average: [], totals: [], users: 0, left_out } } let by_cohort = group_by(placed, (u) => u.cohort) let starts = sort(keys(by_cohort)) let span = cohort_table_period(starts[0], starts[len(starts) - 1], unit) + 1 let every = (span > max_cohorts ? range(span - max_cohorts, span) : range(span)) | map((n) => cohort_table_after(starts[0], unit, n)) let periods = min([limit, cohort_table_period(every[0], today, unit)]) let cohorts = every | map((start) => { let members = by_cohort[start] ?? [] let counts = group_by(flatten(members | map((m) => m.periods)), (p) => p) let ended = cohort_table_period(start, today, unit) { start, label: cohort_table_label(start, unit), size: len(members), cells: for p in range(periods) => p >= ended ? null : len(counts[str(p)] ?? []) } }) let columns = for p in range(periods) => cohorts | filter((c) => c.cells[p] != null && c.size > 0) let totals = for p in range(periods) => sum(columns[p] | map((c) => c.cells[p])) let average = for p in range(periods) { let base = sum(columns[p] | map((c) => c.size)) base == 0 ? null : totals[p] / base } { cohorts, periods, average, totals, users: len(placed), left_out }}// A fraction as a percentage to one decimal, in whole numbers so that the server and// the browser write the same text.fn cohort_table_pct(x: num) -> str { let tenths = round(x * 1000) `${floor(tenths / 10)}.${tenths % 10}%`}fn cohort_table_heat(x: num) -> str = `background: color-mix(in srgb, var(--ui-accent) ${round(x * 50)}%, transparent)`fn cohort_table_plural(n: int, one: str, many: str) -> str = n == 1 ? `1 ${one}` : `${n} ${many}`// What was left out, in a sentence, or null when nothing was.fn cohort_table_left_out(o: map) -> str? { let parts = [ o.unread > 0 ? `${cohort_table_plural(o.unread, "event", "events")} with no user or no readable date` : null, o.early > 0 ? `${cohort_table_plural(o.early, "event", "events")} before a signup` : null, o.late > 0 ? `${cohort_table_plural(o.late, "event", "events")} after today` : null, o.future_users > 0 ? `${cohort_table_plural(o.future_users, "user", "users")} signing up after today` : null, o.over > 0 ? `${cohort_table_plural(o.over, "event", "events")} past the first ${cohort_table_cap}` : null, ] | filter((p) => p != null) len(parts) == 0 ? null : `Left out: ${join(parts, ", ")}.`}// The first period after signup, in words: where most of a product's story is.fn cohort_table_summary(b: map, unit: str) -> str { let word = unit == "month" ? "month" : "week" let base = `${cohort_table_plural(b.users, "user", "users")} in ${cohort_table_plural(len(b.cohorts), "cohort", "cohorts")}.` if b.periods < 2 || b.average[1] == null { return `${base} No ${word} after signup has ended yet.` } let ready = b.cohorts | filter((c) => c.size > 0 && c.cells[1] != null) let best = last(sort_by(ready, (c) => c.cells[1] / c.size)) let worst = first(sort_by(ready, (c) => c.cells[1] / c.size)) let spread = len(ready) < 2 || best.cells[1] * worst.size == worst.cells[1] * best.size ? "" : ` Best ${best.label} at ${cohort_table_pct(best.cells[1] / best.size)}, lowest ${worst.label} at ${cohort_table_pct(worst.cells[1] / worst.size)}.` `${base} ${cohort_table_pct(b.average[1])} came back in ${word} 1.${spread}`}// The last event's day, for when no `today` is given.fn cohort_table_last_day(events: list) -> str? { let days = events | map((e) => type_of(e) == "map" ? cohort_table_day(e.date) : null) | filter((d) => d != null) last(sort(days))}component CohortTable(events: list = [], today: str? = null, period: str = "week", show: str = "percent", limit: int = 12, max_cohorts: int = 24, title: str = "Retention by cohort", id: str = "cohort-table", name: str? = null, on_change = null, class: str? = null) { style global cohort_table_css state unit = period == "month" ? "month" : "week" state shown_as = show == "count" ? "count" : "percent" let named = (n: str) => name != null ? `${name}_${n}` : n let day = today == null ? cohort_table_last_day(events) : cohort_table_day(today) let b = day == null ? null : cohort_table_build(events, unit, day, limit, max_cohorts) let word = unit == "month" ? "Month" : "Week" let has_rows = b != null && len(b.cohorts) > 0 let extra = b == null ? null : cohort_table_left_out(b.left_out) let cell_text = (n: int, size: int) => shown_as == "count" ? str(n) : cohort_table_pct(n / size) let tell = () => { if on_change != null { let d = today == null ? cohort_table_last_day(events) : cohort_table_day(today) on_change({ period: unit, show: shown_as, average: d == null ? [] : cohort_table_build(events, unit, d, limit, max_cohorts).average }) } } <section class={ui_cls(["ui-cohort-table", class])} aria-labelledby={`${id}-title`}> <h3.ui-cohort-table-title id={`${id}-title`}>{title}</h3> <div.ui-cohort-table-bar> <fieldset.ui-cohort-table-group> <legend>Cohorts by</legend> <label><input type="radio" name={named("period")} value="week" checked={unit == "week"} @change={() => { unit = "week"; tell() }}/> Week</label> <label><input type="radio" name={named("period")} value="month" checked={unit == "month"} @change={() => { unit = "month"; tell() }}/> Month</label> </fieldset> <fieldset.ui-cohort-table-group> <legend>Show</legend> <label><input type="radio" name={named("show")} value="percent" checked={shown_as == "percent"} @change={() => { shown_as = "percent"; tell() }}/> Percent</label> <label><input type="radio" name={named("show")} value="count" checked={shown_as == "count"} @change={() => { shown_as = "count"; tell() }}/> People</label> </fieldset> </div> {today != null && day == null && <p.ui-cohort-table-warn role="alert">The date given as today cannot be read: "{today}".</p>} {b != null && !has_rows && <p.ui-cohort-table-say aria-live="polite">No signups to show yet.</p>} {b == null && today == null && <p.ui-cohort-table-say aria-live="polite">No events with a readable date.</p>} {has_rows && <p.ui-cohort-table-say aria-live="polite">{cohort_table_summary(b, unit)}</p>} {has_rows && <div.ui-cohort-table-scroll tabindex="0" role="group" aria-label="Retention table"> <table.ui-cohort-table-table> <thead> <tr><th scope="col">Cohort</th><th scope="col">People</th>{for p in range(b.periods) => <th key={p} scope="col">{word} {p}</th>}</tr> </thead> <tbody> {for c in b.cohorts => <tr key={c.start} data-cohort={c.start}> <th scope="row">{c.label}</th> <td.ui-cohort-table-size data-k="size">{c.size}</td> {for p in range(b.periods) { let n = c.cells[p] n == null || c.size == 0 ? <td key={p} data-p={p} class={n == null ? "ui-cohort-table-open" : ""}>{n == null ? <span.ui-sr>Not ended</span> : ""}</td> : <td key={p} data-p={p} style={cohort_table_heat(n / c.size)}>{cell_text(n, c.size)}</td> }} </tr>} <tr.ui-cohort-table-average data-cohort="average"> <th scope="row">{shown_as == "count" ? "All cohorts" : "Weighted average"}</th> <td.ui-cohort-table-size data-k="size">{b.users}</td> {for p in range(b.periods) { let a = b.average[p] <td key={p} data-p={p}>{a == null ? "" : (shown_as == "count" ? str(b.totals[p]) : cohort_table_pct(a))}</td> }} </tr> </tbody> </table> </div>} {extra != null && <p.ui-cohort-table-note>{extra}</p>} </section>}Related
Thread 0 posts
On CohortTable: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.