PostStats
How a post is doing, for its author: views, reads, read ratio and reading time against the period before, a bar a day, where readers came from.
Demo
Islands, resumed: how a page wakes up
Oct 17 – 30, 2026- Views
- 12,880+27.5% on the period before
- Reads
- 5,420+36.9% on the period before
- Read ratio
- 42.1%+2.9 pts on the period before
- Average reading time
- 3m 42s−5.9% on the period before
| Day | Views | Reads |
|---|---|---|
| Oct 17 | 420 | 170 |
| Oct 18 | 380 | 150 |
| Oct 19 | 510 | 220 |
| Oct 20 | 460 | 190 |
| Oct 21 | 1,240 | 560 |
| Oct 22 | 2,860 | 1,310 |
| Oct 23 | 1,930 | 860 |
| Oct 24 | 1,180 | 480 |
| Oct 25 | 840 | 330 |
| Oct 26 | 720 | 280 |
| Oct 27 | 690 | 270 |
| Oct 28 | 560 | 210 |
| Oct 29 | 610 | 230 |
| Oct 30 | 480 | 160 |
Where readers came from
- Hacker News5,12039.8%
- Newsletter2,31017.9%
- Search1,90414.8%
- Mastodon1,28810.0%
- Bluesky9407.3%
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/post_stats.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/post_stats.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/post_stats.qwe
let post_stats_demo_views = [420, 380, 510, 460, 1240, 2860, 1930, 1180, 840, 720, 690, 560, 610, 480]let post_stats_demo_reads = [170, 150, 220, 190, 560, 1310, 860, 480, 330, 280, 270, 210, 230, 160]view PostStatsDemo() = <div style="max-width: 720px"> <PostStats id="post-stats-demo" title="Islands, resumed: how a page wakes up" href="#post_stats" period="Oct 17 – 30, 2026" views={12880} reads={5420} read_seconds={222} previous={{ views: 10100, reads: 3960, read_seconds: 236 }} daily={range(14) | map((i) => { date: date.add_days("2026-10-17", i), views: post_stats_demo_views[i], reads: post_stats_demo_reads[i] })} referrers={[{ source: "Hacker News", count: 5120, href: "#post_stats-hn" }, { source: "Newsletter", count: 2310 }, { source: "Search", count: 1904 }, { source: "Mastodon", count: 1288 }, { source: "Bluesky", count: 940 }, { source: "Direct", count: 870 }]} likes={318} comments={42} shares={1}/> </div>Source
ui/post_stats.qwe
// PostStats: how one post is doing, for its author: views, reads (views that read to// the end), the read ratio and the average time spent, each against the period before;// a bar for each day (reads inside views); where readers came from; and the likes,// comments and shares. The chart has a table of its numbers for screen readers.//// <PostStats title="Islands, resumed" href="/blog/islands" period="Last 14 days"// views={12480} reads={5210} read_seconds={222} previous={{ views: 10100, reads: 4020, read_seconds: 205 }}// daily={[{ date: "2026-10-17", views: 640, reads: 250 }, { date: "2026-10-18", views: 1240, reads: 610 }]}// referrers={[{ source: "Hacker News", count: 5120 }, { source: "Newsletter", count: 2300 }]}// likes={318} comments={42} shares={77}/>//// title: the post, a link to href when given. period: what the numbers cover ("Last 30// days"). views, reads: counts; read_seconds: the average time a reader spent, in// seconds (none hides it). previous: { views, reads, read_seconds } of the period before,// to show each change as a percentage (the read ratio's in points); none hides them.// daily: maps of { date, views, reads? } in order, date an ISO date: one bar each (two// or more). referrers: maps of { source, count, href? }, biggest first, with each one's// share of views; limit: how many to show (5). likes, comments, shares: counts (none// hides each). locale: how numbers and dates are written ("en-US"). id: the <section>'s// id ("post-stats"); its heading's is id + "-title".import ui/theme.qwe use ui_clslet post_stats_css = css`.ui-bstat { display: grid; gap: 18px; min-width: 0; margin: 0; padding: 18px 20px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); font: 14px/1.4 var(--ui-font); color: var(--ui-fg); font-variant-numeric: tabular-nums; }.ui-bstat-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; }.ui-bstat .ui-bstat-title { margin: 0; font-size: 16px; font-weight: 650; }.ui-bstat-title a { color: inherit; text-decoration: none; border-radius: 3px; }.ui-bstat-title a:hover { color: var(--ui-accent); text-decoration: underline; }.ui-bstat-title a:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-bstat-period { color: var(--ui-muted); font-size: 13px; }.ui-bstat-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin: 0; }.ui-bstat-kpi { display: grid; gap: 2px; padding: 10px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-bstat-kpi dt { color: var(--ui-muted); font-size: 12px; font-weight: 600; }.ui-bstat-kpi dd { margin: 0; }.ui-bstat-value { display: block; font-size: 22px; font-weight: 700; letter-spacing: -.01em; }.ui-bstat-change { font-size: 12px; font-weight: 600; color: var(--ui-muted); }.ui-bstat-up { color: var(--ui-success); } .ui-bstat-down { color: var(--ui-danger); }.ui-bstat-chart { display: grid; gap: 6px; margin: 0; }.ui-bstat-plot { position: relative; padding-left: 40px; }.ui-bstat-max { position: absolute; top: -6px; left: 0; color: var(--ui-muted); font-size: 11px; }.ui-bstat-zero { position: absolute; bottom: -6px; left: 0; color: var(--ui-muted); font-size: 11px; }.ui-bstat-bars { display: block; width: 100%; height: 120px; border-bottom: 1px solid var(--ui-border); }.ui-bstat-bars .ui-bstat-views { fill: color-mix(in srgb, var(--ui-accent) 28%, transparent); }.ui-bstat-bars .ui-bstat-reads { fill: var(--ui-accent); }.ui-bstat-bars g:hover .ui-bstat-views { fill: color-mix(in srgb, var(--ui-accent) 45%, transparent); }.ui-bstat-axis { display: flex; justify-content: space-between; padding-left: 40px; color: var(--ui-muted); font-size: 11px; }.ui-bstat-key { display: flex; gap: 14px; margin: 0; color: var(--ui-muted); font-size: 12px; }.ui-bstat-key span::before { content: ""; display: inline-block; width: 9px; height: 9px; margin-right: 5px; border-radius: 2px; background: color-mix(in srgb, var(--ui-accent) 28%, transparent); }.ui-bstat-key span + span::before { background: var(--ui-accent); }.ui-bstat .ui-bstat-sub { margin: 0 0 8px; color: var(--ui-muted); font-size: 12px; font-weight: 650; letter-spacing: .05em; text-transform: uppercase; }.ui-bstat-refs { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }.ui-bstat-ref { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; align-items: baseline; }.ui-bstat-ref a { color: var(--ui-fg); text-decoration: none; border-radius: 3px; }.ui-bstat-ref a:hover { color: var(--ui-accent); text-decoration: underline; }.ui-bstat-ref a:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-bstat-ref b { font-weight: 600; }.ui-bstat-ref small { margin-left: 6px; color: var(--ui-muted); font-size: 12px; font-weight: 400; }.ui-bstat-track { grid-column: 1 / -1; height: 6px; overflow: hidden; border-radius: 3px; background: var(--ui-subtle); }.ui-bstat-track span { display: block; height: 100%; border-radius: 3px; background: var(--ui-accent); }.ui-bstat-social { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 0; padding: 12px 0 0; list-style: none; border-top: 1px solid var(--ui-border); color: var(--ui-muted); }.ui-bstat-social b { color: var(--ui-fg); font-weight: 650; }`// A change from before to now: { text, mood }: "+23.6%" and "up", or for points "+4.1 pts".fn post_stats_change(now, before, points: bool, locale: str) { if before == null || now == null { return null } let d = points ? now - before : (before == 0 ? null : (now - before) * 100 / before) if d == null { return null } let r = round(d, 1) let sign = r > 0 ? "+" : (r < 0 ? "−" : "") { text: `${sign}${format_number(abs(r), locale, 1)}${points ? " pts" : "%"}`, mood: r > 0 ? "up" : (r < 0 ? "down" : "flat") }}// A coordinate, to two decimals, without a trailing ".0".fn post_stats_at(n) -> str { let r = round(n * 100) / 100 r == floor(r) ? str(floor(r)) : str(r)}// Seconds as "3m 42s".fn post_stats_time(s) -> str { let n = round(s) n >= 60 ? `${floor(n / 60)}m ${n % 60}s` : `${n}s`}view PostStats(title: str, href: str? = null, period: str = "Last 30 days", views: int = 0, reads: int = 0, read_seconds = null, previous = null, daily: list? = null, referrers: list? = null, limit: int = 5, likes: int? = null, comments: int? = null, shares: int? = null, locale: str = "en-US", id: str = "post-stats", class: str? = null) { style global post_stats_css let num = (n) => format_number(n, locale) let ratio = views > 0 ? reads * 100 / views : null let prev_ratio = previous != null && previous.views != null && previous.views > 0 && previous.reads != null ? previous.reads * 100 / previous.views : null let kpis = [ { key: "views", name: "Views", value: num(views), change: post_stats_change(views, previous.views, false, locale) }, { key: "reads", name: "Reads", value: num(reads), change: post_stats_change(reads, previous.reads, false, locale) }, ratio != null ? { key: "ratio", name: "Read ratio", value: `${format_number(round(ratio, 1), locale, 1)}%`, change: post_stats_change(ratio, prev_ratio, true, locale) } : null, read_seconds != null ? { key: "time", name: "Average reading time", value: post_stats_time(read_seconds), change: post_stats_change(read_seconds, previous.read_seconds, false, locale) } : null, ] | filter((k) => k != null) let days = daily ?? [] let n = len(days) let top = n > 0 ? max(days | map((d) => d.views)) : 0 let peak = n > 0 ? days | find((d) => d.views == top) : null let h = (v) => top > 0 ? v * 96 / top : 0 let day = (d: str) => date.format(d, { locale, pattern: "MMM D" }) let refs = take(referrers ?? [], limit) <section id={id} class={ui_cls(["ui-bstat", class])} aria-labelledby={`${id}-title`}> <div.ui-bstat-head> <h3.ui-bstat-title id={`${id}-title`}>{href != null ? <a href={href}>{title}</a> : title}</h3> <span.ui-bstat-period>{period}</span> </div> <dl.ui-bstat-kpis> {for k in kpis => <div.ui-bstat-kpi data-kpi={k.key}> <dt>{k.name}</dt> <dd> <span.ui-bstat-value>{k.value}</span> {k.change != null && <span class={ui_cls(["ui-bstat-change", k.change.mood != "flat" && `ui-bstat-${k.change.mood}`])}> <span aria-hidden="true">{k.change.mood == "up" ? "↑ " : (k.change.mood == "down" ? "↓ " : "")}</span>{k.change.text} <span.ui-sr> on the period before</span> </span>} </dd> </div>} </dl> {n > 1 && <figure.ui-bstat-chart> <div.ui-bstat-plot aria-hidden="true"> <span.ui-bstat-max>{num(top)}</span> <svg.ui-bstat-bars viewBox={`0 0 ${n * 10} 100`} preserveAspectRatio="none"> {for i in range(n) => { let d = days[i] <g key={d.date}> <title>{day(d.date)}: {num(d.views)} views{d.reads != null && `, ${num(d.reads)} reads`}</title> <rect.ui-bstat-views x={post_stats_at(i * 10 + 1.5)} y={post_stats_at(100 - h(d.views))} width="7" height={post_stats_at(h(d.views))}/> {d.reads != null && <rect.ui-bstat-reads x={post_stats_at(i * 10 + 1.5)} y={post_stats_at(100 - h(d.reads))} width="7" height={post_stats_at(h(d.reads))}/>} </g> }} </svg> <span.ui-bstat-zero>0</span> </div> <div.ui-bstat-axis aria-hidden="true"><span>{day(days[0].date)}</span><span>{day(days[n - 1].date)}</span></div> <figcaption.ui-bstat-key><span>Views</span><span>Reads</span> <span.ui-sr>a day, {day(days[0].date)} to {day(days[n - 1].date)}; the most, {num(top)} views, on {day(peak.date)}</span> </figcaption> <table.ui-sr> <caption>Views and reads a day</caption> <thead><tr><th scope="col">Day</th><th scope="col">Views</th><th scope="col">Reads</th></tr></thead> <tbody>{for d in days => <tr key={d.date}><th scope="row">{day(d.date)}</th><td>{num(d.views)}</td><td>{d.reads != null ? num(d.reads) : "—"}</td></tr>}</tbody> </table> </figure>} {len(refs) > 0 && <div> <h4.ui-bstat-sub id={`${id}-refs`}>Where readers came from</h4> <ol.ui-bstat-refs aria-labelledby={`${id}-refs`}> {for r in refs => { let share = views > 0 ? r.count * 100 / views : 0 <li.ui-bstat-ref key={r.source}> <span>{r.href != null ? <a href={r.href}>{r.source}</a> : r.source}</span> <span><b>{num(r.count)}</b><small>{format_number(round(share, 1), locale, 1)}%</small></span> <span.ui-bstat-track aria-hidden="true"><span style={`width: ${post_stats_at(share > 100 ? 100 : share)}%`}></span></span> </li> }} </ol> </div>} {(likes != null || comments != null || shares != null) && <ul.ui-bstat-social aria-label="Responses"> {likes != null && <li data-kpi="likes"><b>{num(likes)}</b> {likes == 1 ? "like" : "likes"}</li>} {comments != null && <li data-kpi="comments"><b>{num(comments)}</b> {comments == 1 ? "comment" : "comments"}</li>} {shares != null && <li data-kpi="shares"><b>{num(shares)}</b> {shares == 1 ? "share" : "shares"}</li>} </ul>} </section>}Related
KpiCardA number with its label, the change since last period coloured by whether that is good, and a small trend as a line or bars.7 views · 7 downloadsScorecardA score out of a maximum as a filled ring, with a letter grade, a verdict and the parts it is made of.8 views · 5 downloadsAreaChartFilled areas for one or more series, overlapping or stacked into a total, with a hover crosshair listing every value.6 views · 8 downloadsBulletChartMeasures as thin bars against a target mark over grey bands from poor to good, a row each, with the band named on hover.5 views · 7 downloadsComboChartBars on a left axis and lines on a right axis over the same labels, for two measures of different size, with every value on hover.6 views · 6 downloadsFunnelChartStages of a process narrowing like a funnel, each with its count and share of the first, and the share that went on between them.5 views · 7 downloads
Thread
Thread 0 posts
On PostStats: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.