@@ -915,111 +915,6 @@ 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-
1023918def :priv env_str (name : str , fallback : str = " " ) -> str {
1024919 val = os.environ.get(name, fallback);
1025920 if val is None {
@@ -1738,243 +1633,6 @@ walker:pub GetPreview {
17381633}
17391634
17401635
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-
19781636walker :pub GithubOAuthConfig {
19791637 # Hands the browser the OAuth client id so it can build the authorize URL.
19801638 can get with Root entry {
0 commit comments