@@ -915,6 +915,111 @@ node PreviewDoc {
915915}
916916
917917
918+ node PostStat {
919+ # One reading-analytics bucket per (slug, day), hung off the public shared
920+ # root and granted WritePerm so anonymous readers (who run ON root.shared)
921+ # AND signed-in readers (who run on their own root but address the shared
922+ # node) can both increment it. Concurrent first-views can race and create
923+ # duplicate buckets for the same (slug, day); that's harmless — GetAnalytics
924+ # sums across every bucket, so duplicates just add up. No per-reader rows and
925+ # no PII: `visitors` holds short one-way hashes of an anonymous client id.
926+ has slug : str = " " ;
927+ has day : str = " " ; # "YYYY-MM-DD" (UTC)
928+ has views : int = 0 ; # total opens (every mount += 1)
929+ has visitors : list = []; # hashed anon visitor ids (unique opens)
930+ has dwell_ms_sum : float = 0.0 ; # summed active reading time
931+ has dwell_samples : int = 0 ; # number of engagement flushes (one per visit)
932+ has scroll_pct_sum : float = 0.0 ; # summed deepest-scroll % (for a true average)
933+ has scroll_samples : int = 0 ; # number of scroll samples
934+ has scroll_buckets : list = [0 , 0 , 0 , 0 ]; # [0-25), [25-50), [50-75), [75-100] depth-reached counts
935+ }
936+
937+
938+ node LinkClick {
939+ # Outbound/in-article link clicks, one bucket per (slug, href, day) on the
940+ # shared root (same race-tolerant, no-PII model as PostStat). GetAnalytics
941+ # sums across days into per-(slug, href) totals.
942+ has slug : str = " " ;
943+ has href : str = " " ;
944+ has day : str = " " ;
945+ has clicks : int = 0 ;
946+ }
947+
948+
949+ def :priv today_utc () -> str {
950+ # Date bucket key in UTC so all deployments agree regardless of server TZ.
951+ t = time.gmtime();
952+ return f " { t.tm_year:04d } - { t.tm_mon:02d } - { t.tm_mday:02d } " ;
953+ }
954+
955+
956+ def :priv hash_visitor (v : str ) -> str {
957+ # One-way, truncated hash of the anonymous client-side visitor id. We never
958+ # store the raw id, so this can't be reversed to anything identifying.
959+ if not v {
960+ return " " ;
961+ }
962+ return hashlib.sha256(v.encode(" utf-8" )).hexdigest()[:16 ];
963+ }
964+
965+
966+ def :priv scroll_bucket (p : int ) -> int {
967+ if p >= 75 {
968+ return 3 ;
969+ }
970+ if p >= 50 {
971+ return 2 ;
972+ }
973+ if p >= 25 {
974+ return 1 ;
975+ }
976+ return 0 ;
977+ }
978+
979+
980+ def :priv match_day_stat (nodes : list , slug : str , day : str ) -> any {
981+ for n in nodes {
982+ if n.slug == slug and n.day == day {
983+ return n;
984+ }
985+ }
986+ return None ;
987+ }
988+
989+
990+ def :priv match_link_click (nodes : list , slug : str , href : str , day : str ) -> any {
991+ for n in nodes {
992+ if n.slug == slug and n.href == href and n.day == day {
993+ return n;
994+ }
995+ }
996+ return None ;
997+ }
998+
999+
1000+ def :priv median_scroll (buckets : list ) -> int {
1001+ # Bucketed median: walk the cumulative distribution to the halfway point and
1002+ # report that bucket's midpoint. Coarse by design (we only keep 4 buckets).
1003+ total: int = 0 ;
1004+ for b in buckets {
1005+ total + = int (b);
1006+ }
1007+ if total == 0 {
1008+ return 0 ;
1009+ }
1010+ midpoints = [12 , 37 , 62 , 88 ];
1011+ half = total / 2.0 ;
1012+ cum: int = 0 ;
1013+ for i in range (4 ) {
1014+ cum + = int (buckets[i]);
1015+ if cum >= half {
1016+ return midpoints[i];
1017+ }
1018+ }
1019+ return midpoints[3 ];
1020+ }
1021+
1022+
9181023def :priv env_str (name : str , fallback : str = " " ) -> str {
9191024 val = os.environ.get(name, fallback);
9201025 if val is None {
@@ -1633,6 +1738,243 @@ walker:pub GetPreview {
16331738}
16341739
16351740
1741+ # ── Reading analytics ────────────────────────────────────────────────────────
1742+ # Two public collectors (RecordView / RecordEngagement) write anonymous counters
1743+ # to per-(slug, day) PostStat buckets on the shared root; one private reader
1744+ # (GetAnalytics) rolls them up for the reviewer-only dashboard. All three address
1745+ # root.shared explicitly so anonymous and signed-in readers land in the SAME
1746+ # store (a :pub walker runs on root.shared for anon callers but on the caller's
1747+ # own root for signed-in ones — addressing .shared directly keeps data unified).
1748+
1749+ walker :pub RecordView {
1750+ # Fired once per post page mount. Bumps the total open count and records the
1751+ # anonymous visitor id (hashed) toward the unique-opens tally.
1752+ has slug : str = " " ;
1753+ has visitor : str = " " ;
1754+
1755+ can rec with Root entry {
1756+ if not self .slug {
1757+ report {" ok" : False };
1758+ return ;
1759+ }
1760+ try {
1761+ shared_root = root .shared; # jac:ignore[E1030] runtime attr, no static stub
1762+ day = today_utc();
1763+ vh = hash_visitor(self .visitor);
1764+ stat = match_day_stat([shared_root --> ][?:PostStat], self .slug, day);
1765+ if stat is None {
1766+ made = shared_root ++> PostStat(
1767+ slug = self .slug, day = day, views = 1 ,
1768+ visitors = ([vh] if vh else []),
1769+ );
1770+ grant(made[0 ], level = WritePerm);
1771+ } else {
1772+ stat.views + = 1 ;
1773+ if vh and (vh not in stat.visitors) {
1774+ # Reassign (not in-place append) so jac-scale persists the change.
1775+ stat.visitors = stat.visitors + [vh];
1776+ }
1777+ }
1778+ report {" ok" : True };
1779+ } except Exception {
1780+ report {" ok" : False };
1781+ }
1782+ }
1783+ }
1784+
1785+
1786+ walker :pub RecordEngagement {
1787+ # Fired when a reader leaves a post (tab close / SPA navigation). Records one
1788+ # reading sample: active time on the page and the deepest scroll % reached.
1789+ has slug : str = " " ;
1790+ has dwell_ms : float = 0.0 ;
1791+ has scroll_pct : int = 0 ;
1792+
1793+ can rec with Root entry {
1794+ if not self .slug {
1795+ report {" ok" : False };
1796+ return ;
1797+ }
1798+ # Defense-in-depth against the same non-read samples the client filters
1799+ # (sub-second bounces, React Strict-Mode phantom unmounts): ignore a
1800+ # sample with negligible dwell AND no real scroll so it can't pollute
1801+ # the scroll-depth buckets.
1802+ if (self .dwell_ms < 1000 ) and (int (self .scroll_pct) < 25 ) {
1803+ report {" ok" : True , " skipped" : True };
1804+ return ;
1805+ }
1806+ try {
1807+ shared_root = root .shared; # jac:ignore[E1030] runtime attr, no static stub
1808+ day = today_utc();
1809+ stat = match_day_stat([shared_root --> ][?:PostStat], self .slug, day);
1810+ if stat is None {
1811+ made = shared_root ++> PostStat(slug = self .slug, day = day);
1812+ grant(made[0 ], level = WritePerm);
1813+ stat = made[0 ];
1814+ }
1815+ if self .dwell_ms > 0 {
1816+ stat.dwell_ms_sum + = self .dwell_ms;
1817+ stat.dwell_samples + = 1 ;
1818+ }
1819+ pct = int (self .scroll_pct);
1820+ stat.scroll_pct_sum + = float (pct);
1821+ stat.scroll_samples + = 1 ;
1822+ b = scroll_bucket(pct);
1823+ buckets = stat.scroll_buckets;
1824+ buckets[b] + = 1 ;
1825+ stat.scroll_buckets = buckets; # reassign so the mutation persists
1826+ report {" ok" : True };
1827+ } except Exception {
1828+ report {" ok" : False };
1829+ }
1830+ }
1831+ }
1832+
1833+
1834+ walker :pub RecordLinkClick {
1835+ # Fired when a reader clicks a link inside the article body.
1836+ has slug : str = " " ;
1837+ has href : str = " " ;
1838+
1839+ can rec with Root entry {
1840+ if not self .slug or not self .href {
1841+ report {" ok" : False };
1842+ return ;
1843+ }
1844+ try {
1845+ shared_root = root .shared; # jac:ignore[E1030] runtime attr, no static stub
1846+ day = today_utc();
1847+ lc = match_link_click([shared_root --> ][?:LinkClick], self .slug, self .href, day);
1848+ if lc is None {
1849+ made = shared_root ++> LinkClick(slug = self .slug, href = self .href, day = day, clicks = 1 );
1850+ grant(made[0 ], level = WritePerm);
1851+ } else {
1852+ lc.clicks + = 1 ;
1853+ }
1854+ report {" ok" : True };
1855+ } except Exception {
1856+ report {" ok" : False };
1857+ }
1858+ }
1859+ }
1860+
1861+
1862+ walker :priv GetAnalytics {
1863+ # Reviewer-only rollup of every PostStat bucket. Re-verifies repo write
1864+ # access server-side (the client gate is UX only); rolls daily buckets up
1865+ # into per-post totals plus a per-day views series for trend charts.
1866+ has gh_enc : str = " " ;
1867+
1868+ can get with Root entry {
1869+ gh_token = decrypt_gh(self .gh_enc);
1870+ if not has_write_access(gh_token) {
1871+ report {" ok" : False , " error" : " Not authorized." };
1872+ return ;
1873+ }
1874+ shared_root = root .shared; # jac:ignore[E1030] runtime attr, no static stub
1875+ stats = [shared_root --> ][?:PostStat];
1876+ by_slug = {};
1877+ for n in stats {
1878+ s = n.slug;
1879+ if s not in by_slug {
1880+ by_slug[s] = {
1881+ " slug" : s, " views" : 0 , " visitors" : [],
1882+ " dwell_ms_sum" : 0.0 , " dwell_samples" : 0 ,
1883+ " scroll_pct_sum" : 0.0 , " scroll_samples" : 0 ,
1884+ " scroll_buckets" : [0 , 0 , 0 , 0 ], " days" : {},
1885+ };
1886+ }
1887+ agg = by_slug[s];
1888+ agg[" views" ] + = n.views;
1889+ for vh in n.visitors {
1890+ if vh not in agg[" visitors" ] {
1891+ agg[" visitors" ].append(vh);
1892+ }
1893+ }
1894+ agg[" dwell_ms_sum" ] + = float (n.dwell_ms_sum);
1895+ agg[" dwell_samples" ] + = int (n.dwell_samples);
1896+ agg[" scroll_pct_sum" ] + = float (n.scroll_pct_sum);
1897+ agg[" scroll_samples" ] + = int (n.scroll_samples);
1898+ for i in range (4 ) {
1899+ agg[" scroll_buckets" ][i] + = int (n.scroll_buckets[i]);
1900+ }
1901+ agg[" days" ][n.day] = int (agg[" days" ].get(n.day, 0 )) + int (n.views);
1902+ }
1903+ # Roll link clicks up into per-(slug, href) totals.
1904+ links_by_slug = {};
1905+ for lc in [shared_root --> ][?:LinkClick] {
1906+ s = lc.slug;
1907+ if s not in links_by_slug {
1908+ links_by_slug[s] = {};
1909+ }
1910+ m = links_by_slug[s];
1911+ m[lc.href] = int (m.get(lc.href, 0 )) + int (lc.clicks);
1912+ }
1913+ out = [];
1914+ totals = {" views" : 0 , " uniques" : 0 };
1915+ for s in by_slug {
1916+ agg = by_slug[s];
1917+ uniques = len (agg[" visitors" ]);
1918+ avg_dwell_ms = (agg[" dwell_ms_sum" ] / agg[" dwell_samples" ]) if agg[" dwell_samples" ] else 0.0 ;
1919+ avg_scroll = (agg[" scroll_pct_sum" ] / agg[" scroll_samples" ]) if agg[" scroll_samples" ] else 0.0 ;
1920+ totals[" views" ] + = int (agg[" views" ]);
1921+ totals[" uniques" ] + = int (uniques);
1922+ post_links = [];
1923+ link_clicks = 0 ;
1924+ if s in links_by_slug {
1925+ for href in links_by_slug[s] {
1926+ c = int (links_by_slug[s][href]);
1927+ link_clicks + = c;
1928+ post_links.append({" href" : href, " clicks" : c});
1929+ }
1930+ post_links.sort(key = lambda r: dict -> int { return int (r[" clicks" ]); }, reverse = True );
1931+ }
1932+ out.append({
1933+ " slug" : s,
1934+ " views" : agg[" views" ],
1935+ " uniques" : uniques,
1936+ " avg_read_sec" : round (avg_dwell_ms / 1000.0 , 1 ),
1937+ " samples" : agg[" dwell_samples" ],
1938+ " scroll_buckets" : agg[" scroll_buckets" ],
1939+ " avg_scroll" : round (avg_scroll),
1940+ " median_scroll" : median_scroll(agg[" scroll_buckets" ]),
1941+ " links" : post_links,
1942+ " link_clicks" : link_clicks,
1943+ " days" : agg[" days" ],
1944+ });
1945+ }
1946+ out.sort(key = lambda r: dict -> int { return int (r[" views" ]); }, reverse = True );
1947+ report {" ok" : True , " posts" : out, " totals" : totals};
1948+ }
1949+ }
1950+
1951+
1952+ walker :priv ResetAnalytics {
1953+ # Reviewer-only: wipe every reading-analytics bucket and start fresh. Useful
1954+ # after a measurement bug, or to clear test data on a dev deployment.
1955+ has gh_enc : str = " " ;
1956+
1957+ can clear with Root entry {
1958+ gh_token = decrypt_gh(self .gh_enc);
1959+ if not has_write_access(gh_token) {
1960+ report {" ok" : False , " error" : " Not authorized." };
1961+ return ;
1962+ }
1963+ shared_root = root .shared; # jac:ignore[E1030] runtime attr, no static stub
1964+ removed = 0 ;
1965+ for n in [shared_root --> ][?:PostStat] {
1966+ del n;
1967+ removed + = 1 ;
1968+ }
1969+ for lc in [shared_root --> ][?:LinkClick] {
1970+ del lc;
1971+ removed + = 1 ;
1972+ }
1973+ report {" ok" : True , " removed" : removed};
1974+ }
1975+ }
1976+
1977+
16361978walker :pub GithubOAuthConfig {
16371979 # Hands the browser the OAuth client id so it can build the authorize URL.
16381980 can get with Root entry {
0 commit comments