Blogging

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.

#blogging#dashboard#chart#analytics#no-js

Demo

Preview
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
ViewsReadsa day, Oct 17 to Oct 30; the most, 2,860 views, on Oct 22
Views and reads a day
DayViewsReads
Oct 17420170
Oct 18380150
Oct 19510220
Oct 20460190
Oct 211,240560
Oct 222,8601,310
Oct 231,930860
Oct 241,180480
Oct 25840330
Oct 26720280
Oct 27690270
Oct 28560210
Oct 29610230
Oct 30480160

Where readers came from

  1. Hacker News5,12039.8%
  2. Newsletter2,31017.9%
  3. Search1,90414.8%
  4. Mastodon1,28810.0%
  5. Bluesky9407.3%
  • 318 likes
  • 42 comments
  • 1 share

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.qwe
main.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>}
Thread
86Useful? Like it, or add to its thread below.

Thread 0 posts

On PostStats: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.