PageSourceSearch

https://hackorum.dev/stats

html hackorum.dev collected 2026-09-28 07:00:11 UTC 102,145 bytes, 1,462 lines download raw bytes

1<!DOCTYPE html><html data-theme="light"><head><title>Statistics - Hackorum</title><meta content="PostgreSQL Hackers Archive" name="description" /><meta content="Hackorum" property="og:site_name" /><meta content="Statistics - Hackorum" property="og:title" /><meta content="PostgreSQL Hackers Archive" property="og:description" /><meta content="https://hackorum.dev" property="og:url" /><meta content="https://hackorum.dev/icon.png" property="og:image" /><meta content="Statistics - Hackorum" property="og:image:alt" /><meta content="website" property="og:type" /><meta content="summary" name="twitter:card" /><meta content="Statistics - Hackorum" name="twitter:title" /><meta content="PostgreSQL Hackers Archive" name="twitter:description" /><meta content="https://hackorum.dev/icon.png" name="twitter:image" /><meta content="width=device-width,initial-scale=1" name="viewport" /><meta content="yes" name="apple-mobile-web-app-capable" /><meta content="yes" name="mobile-web-app-capable" /><meta name="csrf-param" content="authenticity_token" />
2<meta name="csrf-token" content="AVjU4FveJ92cgU2QeeqW2vgzi8I_-g1ts-lAjxYdq3lMttDAehQu8Ys8BJbOQChBG3SuyMXMQFcVPJ7Zvx8c6A" /><link href="/icon.png" rel="icon" type="image/png" /><link href="/icon.png" rel="apple-touch-icon" />
2<script>(function() {
3  var storageKey = 'hackorum-theme';
4  var stored = window.localStorage.getItem(storageKey);
5  var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
6  var theme = stored || (prefersDark ? 'dark' : 'light');
7  document.documentElement.dataset.theme = theme;
8})();</script>
8<link rel="stylesheet" href="/assets/application-945ddd41.css" data-turbo-track="reload" /><link rel="stylesheet" href="/assets/fontawesome/all.min-c90a9f69.css" data-turbo-track="reload" />
8<script type="importmap" data-turbo-track="reload">{
9  "imports": {
10    "application": "/assets/application-4710c685.js",
11    "@hotwired/turbo-rails": "/assets/turbo.min-ad2c7b86.js",
12    "@hotwired/stimulus": "/assets/stimulus.min-4b1e420e.js",
13    "@hotwired/stimulus-loading": "/assets/stimulus-loading-1fc53fe7.js",
14    "controllers/activity_filter_controller": "/assets/controllers/activity_filter_controller-a0082aab.js",
15    "controllers/application": "/assets/controllers/application-3affb389.js",
16    "controllers/batch_loader_controller": "/assets/controllers/batch_loader_controller-d1b9c692.js",
17    "controllers/ci_chart_controller": "/assets/controllers/ci_chart_controller-95a7d3ee.js",
18    "controllers/clipboard_controller": "/assets/controllers/clipboard_controller-d8ffaa41.js",
19    "controllers/composer_poll_controller": "/assets/controllers/composer_poll_controller-7f386262.js",
20    "controllers/contrib_calendar_controller": "/assets/controllers/contrib_calendar_controller-827401db.js",
21    "controllers/debounced_submit_controller": "/assets/controllers/debounced_submit_controller-dfc567da.js",
22    "controllers/diff_highlight_controller": "/assets/controllers/diff_highlight_controller-f5f4ce4d.js",
23    "controllers/draft_jump_controller": "/assets/controllers/draft_jump_controller-d0b9be8d.js",
24    "controllers/flash_controller": "/assets/controllers/flash_controller-d883e7ed.js",
25    "controllers/hello_controller": "/assets/controllers/hello_controller-708796bd.js",
26    "controllers/help_outline_controller": "/assets/controllers/help_outline_controller-af17bdb3.js",
27    "controllers/hover_popover_controller": "/assets/controllers/hover_popover_controller-27ed2c5c.js",
28    "controllers": "/assets/controllers/index-ee64e1f1.js",
29    "controllers/message_collapse_controller": "/assets/controllers/message_collapse_controller-1f6dc317.js",
30    "controllers/nav_dropdown_controller": "/assets/controllers/nav_dropdown_controller-f596d556.js",
31    "controllers/nav_overflow_controller": "/assets/controllers/nav_overflow_controller-4ace0a65.js",
32    "controllers/new_topics_banner_controller": "/assets/controllers/new_topics_banner_controller-e65a3edd.js",
33    "controllers/prefetch_frame_controller": "/assets/controllers/prefetch_frame_controller-f709b29e.js",
34    "controllers/read_status_controller": "/assets/controllers/read_status_controller-148b7a8b.js",
35    "controllers/reply_composer_controller": "/assets/controllers/reply_composer_controller-40e329de.js",
36    "controllers/reply_quote_controller": "/assets/controllers/reply_quote_controller-d5b61f14.js",
37    "controllers/save_search_controller": "/assets/controllers/save_search_controller-e7d5b27a.js",
38    "controllers/scroll_to_controller": "/assets/controllers/scroll_to_controller-0d374912.js",
39    "controllers/search_focus_controller": "/assets/controllers/search_focus_controller-75cd587d.js",
40    "controllers/send_confirmation_controller": "/assets/controllers/send_confirmation_controller-27cfb47e.js",
41    "controllers/sidebar_controller": "/assets/controllers/sidebar_controller-e8ea2d62.js",
42    "controllers/swipe_nav_controller": "/assets/controllers/swipe_nav_controller-4c8f687d.js",
43    "controllers/tabs_controller": "/assets/controllers/tabs_controller-36d7b8a1.js",
44    "controllers/tabs_morph_controller": "/assets/controllers/tabs_morph_controller-28b069c3.js",
45    "controllers/theme_controller": "/assets/controllers/theme_controller-75486c88.js",
46    "controllers/thread_actions_controller": "/assets/controllers/thread_actions_controller-3c861613.js",
47    "controllers/thread_outline_controller": "/assets/controllers/thread_outline_controller-725769d4.js",
48    "controllers/topic_states_controller": "/assets/controllers/topic_states_controller-6bcafb17.js",
49    "controllers/visited_topic_controller": "/assets/controllers/visited_topic_controller-4b4a0f6f.js",
50    "controllers/visited_topics": "/assets/controllers/visited_topics-999e77ee.js"
51  }
52}</script>
52
53<link rel="modulepreload" href="/assets/application-4710c685.js">
54<link rel="modulepreload" href="/assets/turbo.min-ad2c7b86.js">
55<link rel="modulepreload" href="/assets/stimulus.min-4b1e420e.js">
56<link rel="modulepreload" href="/assets/stimulus-loading-1fc53fe7.js">
57<link rel="modulepreload" href="/assets/controllers/activity_filter_controller-a0082aab.js">
58<link rel="modulepreload" href="/assets/controllers/application-3affb389.js">
59<link rel="modulepreload" href="/assets/controllers/batch_loader_controller-d1b9c692.js">
60<link rel="modulepreload" href="/assets/controllers/ci_chart_controller-95a7d3ee.js">
61<link rel="modulepreload" href="/assets/controllers/clipboard_controller-d8ffaa41.js">
62<link rel="modulepreload" href="/assets/controllers/composer_poll_controller-7f386262.js">
63<link rel="modulepreload" href="/assets/controllers/contrib_calendar_controller-827401db.js">
64<link rel="modulepreload" href="/assets/controllers/debounced_submit_controller-dfc567da.js">
65<link rel="modulepreload" href="/assets/controllers/diff_highlight_controller-f5f4ce4d.js">
66<link rel="modulepreload" href="/assets/controllers/draft_jump_controller-d0b9be8d.js">
67<link rel="modulepreload" href="/assets/controllers/flash_controller-d883e7ed.js">
68<link rel="modulepreload" href="/assets/controllers/hello_controller-708796bd.js">
69<link rel="modulepreload" href="/assets/controllers/help_outline_controller-af17bdb3.js">
70<link rel="modulepreload" href="/assets/controllers/hover_popover_controller-27ed2c5c.js">
71<link rel="modulepreload" href="/assets/controllers/index-ee64e1f1.js">
72<link rel="modulepreload" href="/assets/controllers/message_collapse_controller-1f6dc317.js">
73<link rel="modulepreload" href="/assets/controllers/nav_dropdown_controller-f596d556.js">
74<link rel="modulepreload" href="/assets/controllers/nav_overflow_controller-4ace0a65.js">
75<link rel="modulepreload" href="/assets/controllers/new_topics_banner_controller-e65a3edd.js">
76<link rel="modulepreload" href="/assets/controllers/prefetch_frame_controller-f709b29e.js">
77<link rel="modulepreload" href="/assets/controllers/read_status_controller-148b7a8b.js">
78<link rel="modulepreload" href="/assets/controllers/reply_composer_controller-40e329de.js">
79<link rel="modulepreload" href="/assets/controllers/reply_quote_controller-d5b61f14.js">
80<link rel="modulepreload" href="/assets/controllers/save_search_controller-e7d5b27a.js">
81<link rel="modulepreload" href="/assets/controllers/scroll_to_controller-0d374912.js">
82<link rel="modulepreload" href="/assets/controllers/search_focus_controller-75cd587d.js">
83<link rel="modulepreload" href="/assets/controllers/send_confirmation_controller-27cfb47e.js">
84<link rel="modulepreload" href="/assets/controllers/sidebar_controller-e8ea2d62.js">
85<link rel="modulepreload" href="/assets/controllers/swipe_nav_controller-4c8f687d.js">
86<link rel="modulepreload" href="/assets/controllers/tabs_controller-36d7b8a1.js">
87<link rel="modulepreload" href="/assets/controllers/tabs_morph_controller-28b069c3.js">
88<link rel="modulepreload" href="/assets/controllers/theme_controller-75486c88.js">
89<link rel="modulepreload" href="/assets/controllers/thread_actions_controller-3c861613.js">
90<link rel="modulepreload" href="/assets/controllers/thread_outline_controller-725769d4.js">
91<link rel="modulepreload" href="/assets/controllers/topic_states_controller-6bcafb17.js">
92<link rel="modulepreload" href="/assets/controllers/visited_topic_controller-4b4a0f6f.js">
93<link rel="modulepreload" href="/assets/controllers/visited_topics-999e77ee.js">
94<script type="module">import "application"</script>
94<script async="" data-website-id="0c9137a7-5fc6-47d5-b3fd-b70fff7ee54e" defer="" src="https://umami.hackorum.dev/script.js"></script>
94</head><body class="has-sidebar" data-controller="sidebar swipe-nav"><nav class="main-navigation"><div class="nav-container"><div class="nav-brand"><button aria-label="Toggle sidebar" class="nav-burger" data-action="click-&gt;sidebar#toggleMobile" type="button"><i class="fa-solid fa-bars"></i></button><a class="brand-link" href="/"><img alt="Hackorum" class="brand-icon" height="24" src="/icon.png" width="24" /><span class="brand-text">Hackorum</span></a><details class="mobile-nav-dropdown"><summary aria-label="Open menu" class="mobile-nav-toggle"><span class="mobile-nav-label">Menu</span><i class="fa-solid fa-caret-down"></i></summary><div class="mobile-nav-menu" data-action="click-&gt;sidebar#closeMenuOnNavigate"><button aria-label="Toggle theme" class="nav-link theme-toggle mobile-nav-theme-control" data-action="click-&gt;theme#toggle" data-controller="theme" type="button"><i aria-hidden="true" class="fa-regular fa-moon" data-theme-target="icon"></i><span>Theme</span></button><a class="nav-link" href="/topics">Topics</a><a class="nav-link" href="/ci">CI</a><a class="nav-link" href="/topics#search">Search</a><a class="nav-link" href="/stats">Statistics</a><a class="nav-link" href="/reports">Reports</a><a class="nav-link" href="/help">Help</a><a class="nav-link" href="/session/new">Sign in</a><a class="nav-link" href="/registration/new">Register</a></div></details><span class="tagline">PostgreSQL Hackers Archive</span></div><div class="nav-menu"><div class="nav-links"><a class="nav-link" href="/topics">Topics</a><a class="nav-link" href="/ci">CI</a><a class="nav-link" href="/topics#search">Search</a><a class="nav-link" href="/reports">Reports</a></div><div class="nav-right"><div class="nav-auth"><a class="nav-link" href="/session/new">Sign in</a><a class="nav-link" href="/registration/new">Register</a><button aria-label="Toggle theme" class="nav-link theme-toggle nav-theme-control" data-action="click-&gt;theme#toggle" data-controller="theme" type="button"><i aria-hidden="true" class="fa-regular fa-moon" data-theme-target="icon"></i></button></div></div><details class="nav-overflow-dropdown" data-controller="nav-dropdown"><summary aria-label="Menu" class="nav-link nav-overflow-toggle" data-action="click-&gt;sidebar#closeMenuOnNavigate"><i class="fa-solid fa-bars"></i></summary><div class="nav-overflow-menu"><a class="nav-link" href="/stats">Statistics</a><a class="nav-link" href="/help">Help</a><a class="nav-link" href="/session/new">Sign in</a><a class="nav-link" href="/registration/new">Register</a></div></details></div></div></nav><div class="page-layout with-sidebar" data-sidebar-target="layout"><div class="layout-sidebar" data-action="click-&gt;sidebar#closeOnNavigate" data-sidebar-target="sidebar" id="layout-sidebar"><div class="stats-sidebar"><details class="stats-sidebar-section" open=""><summary class="stats-sidebar-summary">Settings</summary><div class="stats-controls"><label><span class="label-text">Granularity</span><select id="stats-granularity"><option value="day">Day</option><option value="week">Week</option><option selected="" value="month">Month</option></select></label><label><span class="label-text">Retention granularity</span><select id="stats-retention-granularity"><option value="month">Monthly</option><option selected="" value="quarter">Quarterly</option></select></label><label><span class="label-text">Retention cohort</span><select id="stats-retention-segment"><option selected="" value="all">All new users</option></select></label><option value="replied_to_others">New participants who replied to others</option><label><span class="label-text">Range</span><select id="stats-range"><option value="last_30">Last 30 days</option><option value="last_90">Last 90 days</option><option value="last_365">Last 365 days</option><option value="last_3650">Last 10 years</option><option selected="" value="after_2000">After 2000</option><option value="all_time">All time</option><option value="custom">Custom range</option></select></label><label class="stats-custom-range" hidden=""><span class="label-text">From</span><input id="stats-from" type="date" /></label><label class="stats-custom-range" hidden=""><span class="label-text">To</span><input id="stats-to" type="date" /></label><button class="button-secondary" id="stats-apply-range" type="button">Apply</button></div></details>
94<details class="stats-sidebar-section" open=""><summary class="stats-sidebar-summary">Graphs</summary><div class="stats-toc"><ul><li><a href="#stats-chart-main">Activity overview</a></li><li><a href="#stats-chart-message-breakdown">Messages</a></li><li><a href="#stats-chart-attachments">New topics by attachments/commitfest status</a></li><li><a href="#stats-chart-new-topic-attachments">New topics by user tenure and attachments</a></li><li><a href="#stats-chart-depth">Per topic activity within interval</a></li><li><a href="#stats-chart-created-depth">Topic length</a></li><li><a href="#stats-chart-longevity">Topic longevity</a></li><li><a href="#stats-chart-longevity-heatmap">Topic longevity heatmap</a></li><li><a href="#stats-chart-participant-lifetime">Participant lifetime</a></li><li><a href="#stats-chart-new-participant-rate">New participant daily message rate</a></li><li><a href="#stats-chart-participant-retention">New vs retained participants</a></li><li><a href="#stats-chart-retention-milestones">Retention milestones</a></li><li><a href="#stats-chart-retention-heatmap">Participant retention heatmap</a></li><li><a href="#stats-chart-retention-intensity-heatmap">Participant activity intensity heatmap</a></li></ul></div></details></div></div><div aria-label="Resize sidebar" aria-orientation="vertical" class="layout-sidebar-resizer" data-action="mousedown-&gt;sidebar#startResize touchstart-&gt;sidebar#startResize" data-sidebar-target="resizer" role="separator"></div><div class="layout-sidebar-overlay" data-action="click-&gt;sidebar#closeMobile" data-sidebar-target="overlay"></div><main class="container"><div class="stats-page"><h1>Statistics</h1><p class="stats-intro">A public overview of Hackorum activity over time.</p><div class="stats-charts"><details class="stats-chart-block" data-collapsible="main" open=""><summary class="stats-chart-summary"><h2>Activity overview</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input data-chart="main" data-series="participants_active" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="participants_active"></span>Active participants</label><label><input data-chart="main" data-series="participants_new" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="participants_new"></span>New participants</label><label><input data-chart="main" data-series="retained_365_participants" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="retained_365_participants"></span>Retained participants (365d+)</label><label><input data-chart="main" data-series="new_users_replied_to_others" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="new_users_replied_to_others"></span>New participants replying to others</label><label><input checked="" data-chart="main" data-series="topics_new" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_new"></span>New topics</label><label><input data-chart="main" data-series="topics_new_by_new_users" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_new_by_new_users"></span>New topics by new users</label><label><input data-chart="main" data-series="topics_new_with_attachments_by_new_users" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_new_with_attachments_by_new_users"></span>New topics with attachments by new users</label><label><input checked="" data-chart="main" data-series="topics_active" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_active"></span>Active topics</label><label><input data-chart="main" data-series="messages_total" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="messages_total"></span>Total messages</label><label><input data-chart="main" data-series="messages_committers" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="messages_committers"></span>Messages by committers</label><label><input data-chart="main" data-series="messages_contributors" type="checkbox" />
94<span class="series-swatch" data-chart="main" data-series="messages_contributors"></span>Messages by contributors</label><label><input data-chart="main" data-series="messages_new_participants" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="messages_new_participants"></span>Messages by new participants</label><label><input data-chart="main" data-series="topics_new_with_contributor_activity" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_new_with_contributor_activity"></span>New topics with contributor activity</label><label><input data-chart="main" data-series="topics_new_without_contributor_activity" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_new_without_contributor_activity"></span>New topics without contributor activity</label><label><input data-chart="main" data-series="topics_messages_avg" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_messages_avg"></span>Active topic avg msgs</label><label><input data-chart="main" data-series="topics_messages_median" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_messages_median"></span>Active topic median msgs</label><label><input data-chart="main" data-series="topics_messages_max" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_messages_max"></span>Active topic max msgs</label><label><input data-chart="main" data-series="topics_created_messages_avg" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_created_messages_avg"></span>New topic avg msgs</label><label><input data-chart="main" data-series="topics_created_messages_median" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_created_messages_median"></span>New topic median msgs</label><label><input data-chart="main" data-series="topics_created_messages_max" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_created_messages_max"></span>New topic max msgs</label><label><input data-chart="main" data-series="topic_longevity_avg_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topic_longevity_avg_days"></span>Topic longevity avg days</label><label><input data-chart="main" data-series="topic_longevity_median_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topic_longevity_median_days"></span>Topic longevity median days</label><label><input data-chart="main" data-series="topic_longevity_max_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topic_longevity_max_days"></span>Topic longevity max days</label><label><input data-chart="main" data-series="new_participants_lifetime_avg_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="new_participants_lifetime_avg_days"></span>New participant lifetime avg days</label><label><input data-chart="main" data-series="new_participants_lifetime_median_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="new_participants_lifetime_median_days"></span>New participant lifetime median days</label><label><input data-chart="main" data-series="new_participants_lifetime_max_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="new_participants_lifetime_max_days"></span>New participant lifetime max days</label><label><input data-chart="main" data-series="retained_365_lifetime_avg_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="retained_365_lifetime_avg_days"></span>Retained lifetime avg days</label><label><input data-chart="main" data-series="retained_365_lifetime_median_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="retained_365_lifetime_median_days"></span>Retained lifetime median days</label><label><input data-chart="main" data-series="new_participants_daily_avg_messages" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="new_participants_daily_avg_messages"></span>New participant daily avg msgs</label><label><input data-chart="main" data-series="retained_365_daily_avg_messages" type="checkbox" />
94<span class="series-swatch" data-chart="main" data-series="retained_365_daily_avg_messages"></span>Retained daily avg msgs</label></div></div><div id="stats-chart-main"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-main"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-main" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="message_breakdown" open=""><summary class="stats-chart-summary"><h2>Messages</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="message_breakdown" data-series="messages_committers" type="checkbox" /><span class="series-swatch" data-chart="message_breakdown" data-series="messages_committers"></span>Committers</label><label><input checked="" data-chart="message_breakdown" data-series="messages_contributors_non_committers" type="checkbox" /><span class="series-swatch" data-chart="message_breakdown" data-series="messages_contributors_non_committers"></span>Contributors (non-committers)</label><label><input checked="" data-chart="message_breakdown" data-series="messages_new_participants" type="checkbox" /><span class="series-swatch" data-chart="message_breakdown" data-series="messages_new_participants"></span>New participants</label><label><input checked="" data-chart="message_breakdown" data-series="messages_regular_participants" type="checkbox" /><span class="series-swatch" data-chart="message_breakdown" data-series="messages_regular_participants"></span>Regular participants</label></div></div><div id="stats-chart-message-breakdown"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-message-breakdown"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-message-breakdown" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="attachments" open=""><summary class="stats-chart-summary"><h2>New topics by attachment/commitfest status</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="attachments" data-series="topics_new_no_attachments" type="checkbox" /><span class="series-swatch" data-chart="attachments" data-series="topics_new_no_attachments"></span>No attachments</label><label><input checked="" data-chart="attachments" data-series="topics_new_with_attachments_no_commitfest" type="checkbox" /><span class="series-swatch" data-chart="attachments" data-series="topics_new_with_attachments_no_commitfest"></span>Attachment, not commitfest</label><label><input checked="" data-chart="attachments" data-series="topics_new_commitfest_in_progress" type="checkbox" /><span class="series-swatch" data-chart="attachments" data-series="topics_new_commitfest_in_progress"></span>Commitfest in progress</label><label><input checked="" data-chart="attachments" data-series="topics_new_commitfest_abandoned" type="checkbox" /><span class="series-swatch" data-chart="attachments" data-series="topics_new_commitfest_abandoned"></span>Commitfest rejected</label><label><input checked="" data-chart="attachments" data-series="topics_new_commitfest_committed" type="checkbox" /><span class="series-swatch" data-chart="attachments" data-series="topics_new_commitfest_committed"></span>Commitfest committed</label></div></div><div id="stats-chart-attachments"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-attachments"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-attachments" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="new_topic_attachments" open=""><summary class="stats-chart-summary"><h2>New topics by user tenure and attachments</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="new_topic_attachments" data-series="topics_new_by_new_users_no_attachments" type="checkbox" />
94<span class="series-swatch" data-chart="new_topic_attachments" data-series="topics_new_by_new_users_no_attachments"></span>New users, no attachments</label><label><input checked="" data-chart="new_topic_attachments" data-series="topics_new_by_new_users_with_attachments" type="checkbox" /><span class="series-swatch" data-chart="new_topic_attachments" data-series="topics_new_by_new_users_with_attachments"></span>New users, attachments</label><label><input checked="" data-chart="new_topic_attachments" data-series="topics_new_by_existing_users_no_attachments" type="checkbox" /><span class="series-swatch" data-chart="new_topic_attachments" data-series="topics_new_by_existing_users_no_attachments"></span>Existing users, no attachments</label><label><input checked="" data-chart="new_topic_attachments" data-series="topics_new_by_existing_users_with_attachments" type="checkbox" /><span class="series-swatch" data-chart="new_topic_attachments" data-series="topics_new_by_existing_users_with_attachments"></span>Existing users, attachments</label></div></div><div id="stats-chart-new-topic-attachments"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-new-topic-attachments"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-new-topic-attachments" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="depth" open=""><summary class="stats-chart-summary"><h2>Per topic activity within interval</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="depth" data-series="topics_messages_avg" type="checkbox" /><span class="series-swatch" data-chart="depth" data-series="topics_messages_avg"></span>Avg messages</label><label><input checked="" data-chart="depth" data-series="topics_messages_median" type="checkbox" /><span class="series-swatch" data-chart="depth" data-series="topics_messages_median"></span>Median messages</label><label><input checked="" data-chart="depth" data-series="topics_messages_max" type="checkbox" /><span class="series-swatch" data-chart="depth" data-series="topics_messages_max"></span>Max messages</label></div></div><div id="stats-chart-depth"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-depth"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-depth" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="created_depth" open=""><summary class="stats-chart-summary"><h2>Topic length (topic created within interval, replies possibly later)</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="created_depth" data-series="topics_created_messages_avg" type="checkbox" /><span class="series-swatch" data-chart="created_depth" data-series="topics_created_messages_avg"></span>Avg messages</label><label><input checked="" data-chart="created_depth" data-series="topics_created_messages_median" type="checkbox" /><span class="series-swatch" data-chart="created_depth" data-series="topics_created_messages_median"></span>Median messages</label><label><input checked="" data-chart="created_depth" data-series="topics_created_messages_max" type="checkbox" /><span class="series-swatch" data-chart="created_depth" data-series="topics_created_messages_max"></span>Max messages</label></div></div><div id="stats-chart-created-depth"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-created-depth"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-created-depth" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="longevity" open=""><summary class="stats-chart-summary"><h2>Topic longevity (topic created within interval, replies possibly later)</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="longevity" data-series="topic_longevity_avg_days" type="checkbox" />
94<span class="series-swatch" data-chart="longevity" data-series="topic_longevity_avg_days"></span>Avg days</label><label><input checked="" data-chart="longevity" data-series="topic_longevity_median_days" type="checkbox" /><span class="series-swatch" data-chart="longevity" data-series="topic_longevity_median_days"></span>Median days</label><label><input checked="" data-chart="longevity" data-series="topic_longevity_max_days" type="checkbox" /><span class="series-swatch" data-chart="longevity" data-series="topic_longevity_max_days"></span>Max days</label></div></div><div id="stats-chart-longevity"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-longevity"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-longevity" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="longevity_heatmap" open=""><summary class="stats-chart-summary"><h2>Topic longevity heatmap</h2></summary><div class="stats-chart-body"><div id="stats-chart-longevity-heatmap"></div></div></details><details class="stats-chart-block" data-collapsible="participant_lifetime" open=""><summary class="stats-chart-summary"><h2>Participant lifetime (first to last message)</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="participant_lifetime" data-series="new_participants_lifetime_avg_days" type="checkbox" /><span class="series-swatch" data-chart="participant_lifetime" data-series="new_participants_lifetime_avg_days"></span>New avg days</label><label><input checked="" data-chart="participant_lifetime" data-series="new_participants_lifetime_median_days" type="checkbox" /><span class="series-swatch" data-chart="participant_lifetime" data-series="new_participants_lifetime_median_days"></span>New median days</label><label><input checked="" data-chart="participant_lifetime" data-series="new_participants_lifetime_max_days" type="checkbox" /><span class="series-swatch" data-chart="participant_lifetime" data-series="new_participants_lifetime_max_days"></span>New max days</label><label><input checked="" data-chart="participant_lifetime" data-series="retained_365_lifetime_avg_days" type="checkbox" /><span class="series-swatch" data-chart="participant_lifetime" data-series="retained_365_lifetime_avg_days"></span>Retained avg days</label><label><input checked="" data-chart="participant_lifetime" data-series="retained_365_lifetime_median_days" type="checkbox" /><span class="series-swatch" data-chart="participant_lifetime" data-series="retained_365_lifetime_median_days"></span>Retained median days</label></div></div><div id="stats-chart-participant-lifetime"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-participant-lifetime"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-participant-lifetime" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="new_participant_rate" open=""><summary class="stats-chart-summary"><h2>New participant daily message rate</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="new_participant_rate" data-series="new_participants_daily_avg_messages" type="checkbox" /><span class="series-swatch" data-chart="new_participant_rate" data-series="new_participants_daily_avg_messages"></span>New participants</label><label><input checked="" data-chart="new_participant_rate" data-series="retained_365_daily_avg_messages" type="checkbox" /><span class="series-swatch" data-chart="new_participant_rate" data-series="retained_365_daily_avg_messages"></span>Retained 365d+</label></div></div><div id="stats-chart-new-participant-rate"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-new-participant-rate"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-new-participant-rate" t
94ype="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="participant_retention" open=""><summary class="stats-chart-summary"><h2>New vs retained participants</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="participant_retention" data-series="retained_365_participants" type="checkbox" /><span class="series-swatch" data-chart="participant_retention" data-series="retained_365_participants"></span>Retained 365d+</label><label><input checked="" data-chart="participant_retention" data-series="participants_new_not_retained_365" type="checkbox" /><span class="series-swatch" data-chart="participant_retention" data-series="participants_new_not_retained_365"></span>New (not retained)</label></div></div><div id="stats-chart-participant-retention"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-participant-retention"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-participant-retention" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="retention_milestones" open=""><summary class="stats-chart-summary"><h2>Retention milestones (cumulative)</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="retention_milestones" data-series="retention_q1" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q1"></span>3m+</label><label><input checked="" data-chart="retention_milestones" data-series="retention_q2" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q2"></span>6m+</label><label><input checked="" data-chart="retention_milestones" data-series="retention_q4" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q4"></span>1y+</label><label><input data-chart="retention_milestones" data-series="retention_q6" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q6"></span>1.5y+</label><label><input data-chart="retention_milestones" data-series="retention_q8" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q8"></span>2y+</label><label><input data-chart="retention_milestones" data-series="retention_q10" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q10"></span>2.5y+</label><label><input data-chart="retention_milestones" data-series="retention_q12" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q12"></span>3y+</label><label><input data-chart="retention_milestones" data-series="retention_q16" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q16"></span>4y+</label><label><input data-chart="retention_milestones" data-series="retention_q20" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q20"></span>5y+</label></div></div><div id="stats-chart-retention-milestones"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-retention-milestones"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-retention-milestones" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="retention_heatmap" open=""><summary class="stats-chart-summary"><h2>Participant retention heatmap</h2></summary><div class="stats-chart-body"><p class="stats-heatmap-note">Y-axis: cohort start month. X-axis: months since first message.</p><div id="stats-chart-retention-heatmap"></div></div></details><details class="stats-chart-block" data-collapsible="retention_intensity" open=""><summary class="stats-chart-summary"><h2>Participant activity intensity heatmap</h2></summary><div class="stats-chart-body"><p class="stats-heatmap-note">Y-axis: cohort start month. X-axis: months since first message.</p><div id="stats-chart-retention-intensity-heatmap"></div></div></details></div><details class="stats-explainer"><summary>Metric definitions</summary><div class="stats-explainer-body"><p><strong>Active participants:</strong> People who posted at least once in the selected interval.</p><p><strong>New participants:</strong> People whose first-ever message is in the selected interval.</p><p><strong>New topics:</strong> Topics created in the selected interval.</p><p><strong>Active topics:</strong> Topics with at least one message in the selected interval.</p><p><strong>New topics with contributor activity:</strong> Topics created in the interval that have ever received a contributor reply.</p><p><strong>New topics without contributor activity:</strong> Topics created in the interval that have never received a contributor reply.</p><p><strong>Message roles:</strong> Messages grouped by contributor role and new participant status. Contributor totals exclude committers; regular participants exclude contributors and new participants.</p><p><strong>Active contributors and committers:</strong> People with contributor memberships who posted during the interval, split by contributor or committer membership.</p><p><strong>New participant lifetime:</strong> Average, median, and maximum time between the first and last message for participants whose first message is in the interval.</p><p><strong>New participant daily message rate:</strong> Average of each new participant's total messages to date divided by days since their first message, measured as of today.</p><p><strong>Retained participants (365d+):</strong> New participants who posted at least once 365 days after their first message.</p><p><strong>Retained participant lifetime:</strong> Average and median lifetime for retained participants (first to last message).</p><p><strong>Retained participant daily message rate:</strong> Average of each retained participant's total messages to date divided by days since their first message, measured as of today.</p><p><strong>Participant retention heatmap:</strong> Monthly cohorts on the y-axis, months since first message on the x-axis, and the share of the cohort active in that month.</p><p><strong>Participant activity intensity heatmap:</strong> Monthly cohorts on the y-axis, months since first message on the x-axis, and average messages per active participant in that month.</p><p><strong>Topic depth:</strong> Average, median, and maximum messages per active topic in the interval.</p><p><strong>New topic depth:</strong> Average, median, and maximum messages per topic created in the interval (including future replies).</p><p><strong>Topic longevity:</strong> Average, median, and maximum age of active topics (first to last message).</p></div></details>
94<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
94<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
94<script>(function() {
95  const mainEl = document.getElementById("stats-chart-main");
96  const depthEl = document.getElementById("stats-chart-depth");
97  const createdDepthEl = document.getElementById("stats-chart-created-depth");
98  const messageBreakdownEl = document.getElementById("stats-chart-message-breakdown");
99  const attachmentsEl = document.getElementById("stats-chart-attachments");
100  const newTopicAttachmentsEl = document.getElementById("stats-chart-new-topic-attachments");
101  const longevityEl = document.getElementById("stats-chart-longevity");
102  const participantLifetimeEl = document.getElementById("stats-chart-participant-lifetime");
103  const newParticipantRateEl = document.getElementById("stats-chart-new-participant-rate");
104  const participantRetentionEl = document.getElementById("stats-chart-participant-retention");
105  const retentionMilestonesEl = document.getElementById("stats-chart-retention-milestones");
106  const longevityHeatmapEl = document.getElementById("stats-chart-longevity-heatmap");
107  const retentionHeatmapEl = document.getElementById("stats-chart-retention-heatmap");
108  const retentionIntensityEl = document.getElementById("stats-chart-retention-intensity-heatmap");
109  const granularityEl = document.getElementById("stats-granularity");
110  const retentionGranularityEl = document.getElementById("stats-retention-granularity");
111  const retentionSegmentEl = document.getElementById("stats-retention-segment");
112  const rangeEl = document.getElementById("stats-range");
113  const fromEl = document.getElementById("stats-from");
114  const toEl = document.getElementById("stats-to");
115  const applyRangeEl = document.getElementById("stats-apply-range");
116  const customRangeEls = Array.from(document.querySelectorAll(".stats-custom-range"));
117  function getCSSVar(name) {
118    return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
119  }
120
121  function getChartColors() {
122    return {
123      textColor: getCSSVar('--color-chart-text'),
124      cardBg: getCSSVar('--color-chart-bg'),
125      borderColor: getCSSVar('--color-chart-border')
126    };
127  }
128
129  const chartSeries = {
130    main: [
131      { key: "participants_active", label: "Active participants", color: getCSSVar('--color-chart-blue') },
132      { key: "participants_new", label: "New participants", color: getCSSVar('--color-chart-cyan') },
133      { key: "retained_365_participants", label: "Retained participants (365d+)", color: getCSSVar('--color-chart-green') },
134      { key: "new_users_replied_to_others", label: "New participants replying to others", color: getCSSVar('--color-chart-light-red') },
135      { key: "topics_new", label: "New topics", color: getCSSVar('--color-chart-purple') },
136      { key: "topics_new_by_new_users", label: "New topics by new users", color: getCSSVar('--color-chart-light-purple') },
137      { key: "topics_new_with_attachments_by_new_users", label: "New topics with attachments by new users", color: getCSSVar('--color-chart-light-brown') },
138      { key: "topics_active", label: "Active topics", color: getCSSVar('--color-chart-brown') },
139      { key: "topics_new_with_contributor_activity", label: "New topics with contributor activity", color: getCSSVar('--color-chart-pink') },
140      { key: "topics_new_without_contributor_activity", label: "New topics without contributor activity", color: getCSSVar('--color-chart-gray') },
141      { key: "messages_total", label: "Total messages", color: getCSSVar('--color-chart-orange') },
142      { key: "messages_committers", label: "Messages by committers", color: getCSSVar('--color-chart-red') },
143      { key: "messages_contributors", label: "Messages by contributors", color: getCSSVar('--color-chart-yellow-green') },
144      { key: "messages_new_participants", label: "Messages by new participants", color: getCSSVar('--color-chart-light-blue') },
145      { key: "topics_messages_avg", label: "Active topic avg msgs", color: getCSSVar('--color-chart-blue') },
146      { key: "topics_messages_median", label: "Active topic median msgs", color: getCSSVar('--color-chart-orange') },
147      { key: "topics_messages_max", label: "Active topic max msgs", color: getCSSVar('--color-chart-green') },
148      { key: "topics_created_messages_avg", label: "New topic avg msgs", color: getCSSVar('--color-chart-blue') },
149      { key: "topics_created_messages_median", label: "New topic median msgs", color: getCSSVar('--color-chart-orange') },
150      { key: "topics_created_messages_max", label: "New topic max msgs", color: getCSSVar('--color-chart-green') },
151      { key: "topic_longevity_avg_days", label: "Topic longevity avg days", color: getCSSVar('--color-chart-purple') },
152      { key: "topic_longevity_median_days", label: "Topic longevity median days", color: getCSSVar('--color-chart-brown') },
153      { key: "topic_longevity_max_days", label: "Topic longevity max days", color: getCSSVar('--color-chart-red') },
154      { key: "new_participants_lifetime_avg_days", label: "New participant lifetime avg days", color: getCSSVar('--color-chart-blue') },
155      { key: "new_participants_lifetime_median_days", label: "New participant lifetime median days", color: getCSSVar('--color-chart-orange') },
156      { key: "new_participants_lifetime_max_days", label: "New participant lifetime max days", color: getCSSVar('--color-chart-green') },
157      { key: "retained_365_lifetime_avg_days", label: "Retained lifetime avg days", color: getCSSVar('--color-chart-purple') },
158      { key: "retained_365_lifetime_median_days", label: "Retained lifetime median days", color: getCSSVar('--color-chart-brown') },
159      { key: "new_participants_daily_avg_messages", label: "New participant daily avg msgs", color: getCSSVar('--color-chart-blue') },
160      { key: "retained_365_daily_avg_messages", label: "Retained daily avg msgs", color: getCSSVar('--color-chart-orange') }
161    ],
162    depth: [
163      { key: "topics_messages_avg", label: "Avg messages", color: getCSSVar('--color-chart-blue') },
164      { key: "topics_messages_median", label: "Median messages", color: getCSSVar('--color-chart-orange') },
165      { key: "topics_messages_max", label: "Max messages", color: getCSSVar('--color-chart-green') }
166    ],
167    created_depth: [
168      { key: "topics_created_messages_avg", label: "Avg messages", color: getCSSVar('--color-chart-blue') },
169      { key: "topics_created_messages_median", label: "Median messages", color: getCSSVar('--color-chart-orange') },
170      { key: "topics_created_messages_max", label: "Max messages", color: getCSSVar('--color-chart-green') }
171    ],
172    message_breakdown: [
173      { key: "messages_committers", label: "Committers", color: getCSSVar('--color-chart-red') },
174      { key: "messages_contributors_non_committers", label: "Contributors (non-committers)", color: getCSSVar('--color-chart-yellow-green') },
175      { key: "messages_new_participants", label: "New participants", color: getCSSVar('--color-chart-light-blue') },
176      { key: "messages_regular_participants", label: "Regular participants", color: getCSSVar('--color-chart-gray') }
177    ],
178    attachments: [
179      { key: "topics_new_no_attachments", label: "No attachments", color: getCSSVar('--color-chart-light-gray') },
180      { key: "topics_new_with_attachments_no_commitfest", label: "Attachment, not commitfest", color: getCSSVar('--color-chart-blue') },
181      { key: "topics_new_commitfest_in_progress", label: "Commitfest in progress", color: getCSSVar('--color-chart-orange') },
182      { key: "topics_new_commitfest_abandoned", label: "Commitfest rejected", color: getCSSVar('--color-chart-red') },
183      { key: "topics_new_commitfest_committed", label: "Commitfest committed", color: getCSSVar('--color-chart-green') }
184    ],
185    new_topic_attachments: [
186      { key: "topics_new_by_new_users_no_attachments", label: "New users, no attachments", color: getCSSVar('--color-chart-silver') },
187      { key: "topics_new_by_new_users_with_attachments", label: "New users, attachments", color: getCSSVar('--color-chart-steel-blue') },
188      { key: "topics_new_by_existing_users_no_attachments", label: "Existing users, no attachments", color: getCSSVar('--color-chart-dark-gray') },
189      { key: "topics_new_by_existing_users_with_attachments", label: "Existing users, attachments", color: getCSSVar('--color-chart-medium-blue') }
190    ],
191    longevity: [
192      { key: "topic_longevity_avg_days", label: "Avg days", color: getCSSVar('--color-chart-purple') },
193      { key: "topic_longevity_median_days", label: "Median days", color: getCSSVar('--color-chart-brown') },
194      { key: "topic_longevity_max_days", label: "Max days", color: getCSSVar('--color-chart-red') }
195    ],
196    participant_lifetime: [
197      { key: "new_participants_lifetime_avg_days", label: "New avg days", color: getCSSVar('--color-chart-blue') },
198      { key: "new_participants_lifetime_median_days", label: "New median days", color: getCSSVar('--color-chart-orange') },
199      { key: "new_participants_lifetime_max_days", label: "New max days", color: getCSSVar('--color-chart-green') },
200      { key: "retained_365_lifetime_avg_days", label: "Retained avg days", color: getCSSVar('--color-chart-purple') },
201      { key: "retained_365_lifetime_median_days", label: "Retained median days", color: getCSSVar('--color-chart-brown') }
202    ],
203    new_participant_rate: [
204      { key: "new_participants_daily_avg_messages", label: "New participants", color: getCSSVar('--color-chart-blue') },
205      { key: "retained_365_daily_avg_messages", label: "Retained 365d+", color: getCSSVar('--color-chart-orange') }
206    ],
207    participant_retention: [
208      { key: "retained_365_participants", label: "Retained 365d+", color: getCSSVar('--color-chart-orange') },
209      { key: "participants_new_not_retained_365", label: "New (not retained)", color: getCSSVar('--color-chart-blue') }
210    ],
211    retention_milestones: [
212      { key: "retention_q1", label: "3m+", color: getCSSVar('--color-chart-blue') },
213      { key: "retention_q2", label: "6m+", color: getCSSVar('--color-chart-orange') },
214      { key: "retention_q4", label: "1y+", color: getCSSVar('--color-chart-green') },
215      { key: "retention_q6", label: "1.5y+", color: getCSSVar('--color-chart-red') },
216      { key: "retention_q8", label: "2y+", color: getCSSVar('--color-chart-purple') },
217      { key: "retention_q10", label: "2.5y+", color: getCSSVar('--color-chart-brown') },
218      { key: "retention_q12", label: "3y+", color: getCSSVar('--color-chart-pink') },
219      { key: "retention_q16", label: "4y+", color: getCSSVar('--color-chart-gray') },
220      { key: "retention_q20", label: "5y+", color: getCSSVar('--color-chart-cyan') }
221    ]
222  };
223
224  const chartConfigs = {
225    main: {
226      key: "main",
227      el: mainEl,
228      editorEl: document.getElementById("stats-editor-main"),
229      applyEl: document.getElementById("stats-editor-apply-main"),
230      height: 320
231    },
232    depth: {
233      key: "depth",
234      el: depthEl,
235      editorEl: document.getElementById("stats-editor-depth"),
236      applyEl: document.getElementById("stats-editor-apply-depth"),
237      height: 220
238    },
239    created_depth: {
240      key: "created_depth",
241      el: createdDepthEl,
242      editorEl: document.getElementById("stats-editor-created-depth"),
243      applyEl: document.getElementById("stats-editor-apply-created-depth"),
244      height: 220
245    },
246    message_breakdown: {
247      key: "message_breakdown",
248      el: messageBreakdownEl,
249      editorEl: document.getElementById("stats-editor-message-breakdown"),
250      applyEl: document.getElementById("stats-editor-apply-message-breakdown"),
251      height: 260,
252      type: "stacked_bar"
253    },
254    attachments: {
255      key: "attachments",
256      el: attachmentsEl,
257      editorEl: document.getElementById("stats-editor-attachments"),
258      applyEl: document.getElementById("stats-editor-apply-attachments"),
259      height: 260,
260      type: "stacked_bar"
261    },
262    new_topic_attachments: {
263      key: "new_topic_attachments",
264      el: newTopicAttachmentsEl,
265      editorEl: document.getElementById("stats-editor-new-topic-attachments"),
266      applyEl: document.getElementById("stats-editor-apply-new-topic-attachments"),
267      height: 260,
268      type: "stacked_bar"
269    },
270    longevity: {
271      key: "longevity",
272      el: longevityEl,
273      editorEl: document.getElementById("stats-editor-longevity"),
274      applyEl: document.getElementById("stats-editor-apply-longevity"),
275      height: 220
276    },
277    participant_lifetime: {
278      key: "participant_lifetime",
279      el: participantLifetimeEl,
280      editorEl: document.getElementById("stats-editor-participant-lifetime"),
281      applyEl: document.getElementById("stats-editor-apply-participant-lifetime"),
282      height: 220
283    },
284    new_participant_rate: {
285      key: "new_participant_rate",
286      el: newParticipantRateEl,
287      editorEl: document.getElementById("stats-editor-new-participant-rate"),
288      applyEl: document.getElementById("stats-editor-apply-new-participant-rate"),
289      height: 220
290    },
291    participant_retention: {
292      key: "participant_retention",
293      el: participantRetentionEl,
294      editorEl: document.getElementById("stats-editor-participant-retention"),
295      applyEl: document.getElementById("stats-editor-apply-participant-retention"),
296      height: 240,
297      type: "stacked_bar"
298    },
299    retention_milestones: {
300      key: "retention_milestones",
301      el: retentionMilestonesEl,
302      editorEl: document.getElementById("stats-editor-retention-milestones"),
303      applyEl: document.getElementById("stats-editor-apply-retention-milestones"),
304      height: 260,
305      type: "overlay_bar"
306    }
307  };
308
309  const collapsibleKeys = [
310    "main",
311    "depth",
312    "created_depth",
313    "message_breakdown",
314    "attachments",
315    "new_topic_attachments",
316    "longevity",
317    "longevity_heatmap",
318    "participant_lifetime",
319    "new_participant_rate",
320    "participant_retention",
321    "retention_milestones",
322    "retention_heatmap",
323    "retention_intensity"
324  ];
325
326  function applySeriesSwatches() {
327    const swatches = Array.from(document.querySelectorAll(".series-swatch[data-chart][data-series]"));
328    swatches.forEach(swatch => {
329      const chartKey = swatch.dataset.chart;
330      const seriesKey = swatch.dataset.series;
331      const series = chartSeries[chartKey]?.find(entry => entry.key === seriesKey);
332      if (!series) return;
333      swatch.style.display = "inline-block";
334      swatch.style.width = "10px";
335      swatch.style.height = "10px";
336      swatch.style.marginRight = "6px";
337      swatch.style.borderRadius = "2px";
338      swatch.style.backgroundColor = series.color || "#666";
339      swatch.style.border = "1px solid rgba(0, 0, 0, 0.2)";
340      swatch.style.verticalAlign = "middle";
341    });
342  }
343
344  Object.values(chartConfigs).forEach(config => {
345    config.seriesInputs = Array.from(document.querySelectorAll(`input[type="checkbox"][data-chart="${config.key}"][data-series]`));
346  });
347
348  const collapsibleByKey = new Map();
349  document.querySelectorAll("[data-collapsible]").forEach(el => {
350    const key = el.dataset.collapsible;
351    if (key) collapsibleByKey.set(key, el);
352  });
353
354  function setCollapsedState(key, collapsed) {
355    const container = collapsibleByKey.get(key);
356    if (!container) return;
357    container.open = !collapsed;
358  }
359
360  function encodeCollapseFlags() {
361    return collapsibleKeys.map(key => {
362      const container = collapsibleByKey.get(key);
363      return container && !container.open ? "1" : "0";
364    }).join("");
365  }
366
367  function applyCollapseFlags(flags) {
368    if (!flags || flags.length !== collapsibleKeys.length) return;
369    collapsibleKeys.forEach((key, idx) => {
370      setCollapsedState(key, flags[idx] === "1");
371    });
372  }
373
374  function seriesKeysFor(chartKey) {
375    return chartSeries[chartKey].map(series => series.key);
376  }
377
378  function selectedSeriesFor(chartKey) {
379    const inputs = chartConfigs[chartKey].seriesInputs;
380    const selectedKeys = inputs.filter(input => input.checked).map(input => input.dataset.series);
381    return chartSeries[chartKey].filter(series => selectedKeys.includes(series.key));
382  }
383
384  function encodeSeriesFlags(keys, selectedKeys) {
385    return keys.map(key => (selectedKeys.includes(key) ? "1" : "0")).join("");
386  }
387
388  function encodeSeriesFlagsFor(chartKey) {
389    const keys = seriesKeysFor(chartKey);
390    const selectedKeys = selectedSeriesFor(chartKey).map(series => series.key);
391    return encodeSeriesFlags(keys, selectedKeys);
392  }
393
394  function applySeriesFlags(flags, chartKey) {
395    const keys = seriesKeysFor(chartKey);
396    const inputs = chartConfigs[chartKey].seriesInputs;
397    if (!flags || flags.length !== keys.length) return;
398    inputs.forEach((input, idx) => {
399      input.checked = flags[idx] === "1";
400    });
401  }
402
403  function formatDate(value) {
404    return new Date(value).toISOString().slice(0, 10);
405  }
406
407  function fetchData() {
408    const granularity = granularityEl.value;
409    const range = rangeEl.value;
410    const retentionGranularity = retentionGranularityEl.value;
411    const retentionSegment = retentionSegmentEl.value;
412    let url = `/stats/data?granularity=${granularity}&range=${range}`;
413    url += `&retention_granularity=${retentionGranularity}`;
414    url += `&retention_segment=${retentionSegment}`;
415    if (range === "custom") {
416      const from = fromEl.value;
417      const to = toEl.value;
418      if (from && to) {
419        url += `&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
420      }
421    }
422    return fetch(url)
423      .then(resp => resp.json());
424  }
425
426  function syncUrl() {
427    const granularity = granularityEl.value;
428    const range = rangeEl.value;
429    const retentionGranularity = retentionGranularityEl.value;
430    const retentionSegment = retentionSegmentEl.value;
431    const params = new URLSearchParams();
432    params.set("granularity", granularity);
433    params.set("range", range);
434    params.set("retention_granularity", retentionGranularity);
435    params.set("retention_segment", retentionSegment);
436    params.set("collapsed", encodeCollapseFlags());
437    params.set("series_main", encodeSeriesFlagsFor("main"));
438    params.set("series_depth", encodeSeriesFlagsFor("depth"));
439    params.set("series_created_depth", encodeSeriesFlagsFor("created_depth"));
440    params.set("series_message_breakdown", encodeSeriesFlagsFor("message_breakdown"));
441    params.set("series_attachments", encodeSeriesFlagsFor("attachments"));
442    params.set("series_new_topic_attachments", encodeSeriesFlagsFor("new_topic_attachments"));
443    params.set("series_longevity", encodeSeriesFlagsFor("longevity"));
444    params.set("series_participant_lifetime", encodeSeriesFlagsFor("participant_lifetime"));
445    params.set("series_new_participant_rate", encodeSeriesFlagsFor("new_participant_rate"));
446    params.set("series_participant_retention", encodeSeriesFlagsFor("participant_retention"));
447    params.set("series_retention_milestones", encodeSeriesFlagsFor("retention_milestones"));
448    if (range === "custom") {
449      if (fromEl.value) params.set("from", fromEl.value);
450      if (toEl.value) params.set("to", toEl.value);
451    }
452    const next = `${window.location.pathname}?${params.toString()}`;
453    window.history.replaceState({}, "", next);
454  }
455
456  function hydrateControlsFromUrl() {
457    const params = new URLSearchParams(window.location.search);
458    const granularity = params.get("granularity");
459    const range = params.get("range");
460    const retentionGranularity = params.get("retention_granularity");
461    const retentionSegment = params.get("retention_segment");
462    const from = params.get("from");
463    const to = params.get("to");
464    const collapsed = params.get("collapsed");
465    const seriesMain = params.get("series_main") || params.get("series");
466    const seriesDepth = params.get("series_depth");
467    const seriesCreatedDepth = params.get("series_created_depth");
468    const seriesMessageBreakdown = params.get("series_message_breakdown");
469    const seriesAttachments = params.get("series_attachments");
470    const seriesNewTopicAttachments = params.get("series_new_topic_attachments");
471    const seriesLongevity = params.get("series_longevity");
472    const seriesParticipantLifetime = params.get("series_participant_lifetime");
473    const seriesNewParticipantRate = params.get("series_new_participant_rate");
474    const seriesParticipantRetention = params.get("series_participant_retention");
475    const seriesRetentionMilestones = params.get("series_retention_milestones");
476
477    if (granularity) granularityEl.value = granularity;
478    if (range) rangeEl.value = range;
479    if (retentionGranularity) retentionGranularityEl.value = retentionGranularity;
480    if (retentionSegment) retentionSegmentEl.value = retentionSegment;
481    if (from) fromEl.value = from;
482    if (to) toEl.value = to;
483    applyCollapseFlags(collapsed);
484    applySeriesFlags(seriesMain, "main");
485    applySeriesFlags(seriesDepth, "depth");
486    applySeriesFlags(seriesCreatedDepth, "created_depth");
487    applySeriesFlags(seriesMessageBreakdown, "message_breakdown");
488    applySeriesFlags(seriesAttachments, "attachments");
489    applySeriesFlags(seriesNewTopicAttachments, "new_topic_attachments");
490    applySeriesFlags(seriesLongevity, "longevity");
491    applySeriesFlags(seriesParticipantLifetime, "participant_lifetime");
492    applySeriesFlags(seriesNewParticipantRate, "new_participant_rate");
493    applySeriesFlags(seriesParticipantRetention, "participant_retention");
494    if (seriesRetentionMilestones) {
495      applySeriesFlags(seriesRetentionMilestones, "retention_milestones");
496    }
497    toggleCustomRange();
498  }
499
500  function decorateIntervals(intervals) {
501    return intervals.map(row => {
502    const intervalKey = row.interval_start;
503    const intervalTs = Date.parse(`${intervalKey}T00:00:00Z`);
504    const messages_total = row.messages_total || 0;
505    const messages_committers = row.messages_committers || 0;
506    const messages_contributors = row.messages_contributors || 0;
507    const contributors_non_committers = Math.max(messages_contributors - messages_committers, 0
507);
508    const messages_new = row.messages_new_participants || 0;
509    const messages_regular = Math.max(
510        messages_total - messages_committers - contributors_non_committers - messages_new,
511        0
512      );
513    const topics_new = row.topics_new || 0;
514    const participants_new = row.participants_new || 0;
515    const retained_365_participants = row.retained_365_participants || 0;
516    const participants_new_not_retained_365 = Math.max(participants_new - retained_365_participants, 0);
517    const topics_new_no_attachments = row.topics_new_no_attachments || 0;
518    const topics_new_commitfest_in_progress = row.topics_new_commitfest_in_progress || 0;
519    const topics_new_commitfest_abandoned = row.topics_new_commitfest_abandoned || 0;
520    const topics_new_commitfest_committed = row.topics_new_commitfest_committed || 0;
521    const topics_new_with_attachments = Math.max(topics_new - topics_new_no_attachments, 0);
522    const topics_new_commitfest_total = (
523      topics_new_commitfest_in_progress +
524      topics_new_commitfest_abandoned +
525      topics_new_commitfest_committed
526    );
527    const topics_new_with_attachments_no_commitfest = row.topics_new_with_attachments_no_commitfest ?? Math.max(
528      topics_new_with_attachments - topics_new_commitfest_total,
529      0
530    );
531    const topics_new_by_new_users = row.topics_new_by_new_users || 0;
532    const topics_new_by_new_users_with_attachments = row.topics_new_with_attachments_by_new_users || 0;
533    const topics_new_by_new_users_no_attachments = Math.max(
534      topics_new_by_new_users - topics_new_by_new_users_with_attachments,
535      0
536    );
537    const topics_new_by_existing_users = Math.max(topics_new - topics_new_by_new_users, 0);
538    const topics_new_by_existing_users_with_attachments = Math.max(
539      topics_new_with_attachments - topics_new_by_new_users_with_attachments,
540      0
541    );
542    const topics_new_by_existing_users_no_attachments = Math.max(
543      topics_new_by_existing_users - topics_new_by_existing_users_with_attachments,
544      0
545    );
546      return Object.assign({}, row, {
547        interval_key: intervalKey,
548        interval_ts: intervalTs,
549        messages_contributors_non_committers: contributors_non_committers,
550        messages_regular_participants: messages_regular,
551        pct_messages_committers: messages_total ? row.messages_committers / messages_total : 0,
552        pct_messages_contributors: messages_total ? row.messages_contributors / messages_total : 0,
553        pct_topics_new_without_contributor_activity: topics_new ? row.topics_new_without_contributor_activity / topics_new : 0,
554        participants_new_not_retained_365: participants_new_not_retained_365,
555        topics_new_no_attachments: topics_new_no_attachments,
556        topics_new_with_attachments_no_commitfest: topics_new_with_attachments_no_commitfest,
557        topics_new_commitfest_in_progress: topics_new_commitfest_in_progress,
558        topics_new_commitfest_abandoned: topics_new_commitfest_abandoned,
559        topics_new_commitfest_committed: topics_new_commitfest_committed,
560        topics_new_by_new_users_no_attachments: topics_new_by_new_users_no_attachments,
561        topics_new_by_new_users_with_attachments: topics_new_by_new_users_with_attachments,
562        topics_new_by_existing_users_no_attachments: topics_new_by_existing_users_no_attachments,
563        topics_new_by_existing_users_with_attachments: topics_new_by_existing_users_with_attachments
564      });
565    });
566  }
567
568  function chartWidthFor(el) {
569    if (!el?.getBoundingClientRect) return 320;
570    const container = el.parentElement || el;
571    const containerWidth = container.getBoundingClientRect().width;
572    const style = window.getComputedStyle(el);
573    const padding =
574      (parseFloat(style.paddingLeft) || 0) +
575      (parseFloat(style.paddingRight) || 0);
576    const border =
577      (parseFloat(style.borderLeftWidth) || 0) +
578      (parseFloat(style.borderRightWidth) || 0);
579    const width = containerWidth - padding - border - 20;
580    return Math.max(320, Math.floor(width));
581  }
582
583  function buildLineSpec(intervals, width, height, enabledSeries) {
584    const chartColors = getChartColors();
585    const points = intervals.flatMap(row => (
586      enabledSeries.map(series => ({
587        interval_ts: row.interval_ts,
588        series: series.key,
589        series_label: series.label,
590        value: row[series.key] || 0
591      }))
592    ));
593    const marks = enabledSeries.map(series => ({
594      type: "line",
595      from: { data: "stats" },
596      encode: {
597        enter: {
598          x: { scale: "x", field: "interval_ts" },
599          y: { scale: "y", field: series.key },
600          stroke: { value: series.color || "#666" },
601          strokeWidth: { value: 2 },
602          interpolate: { value: "step-after" }
603        }
604      }
605    }));
606
607    const spec = {
608      $schema: "https://vega.github.io/schema/vega/v5.json",
609      width: width,
610      height: height,
611      padding: 10,
612      autosize: { type: "fit", contains: "padding" },
613      scales: [
614        { name: "x", type: "time", domain: { data: "stats", field: "interval_ts" }, range: "width" },
615        { name: "y", type: "linear", domain: { data: "stats", fields: enabledSeries.map(series => series.key) }, nice: true, range: "height" },
616        { name: "color", type: "ordinal", domain: enabledSeries.map(series => series.key), range: enabledSeries.map(series => series.color || "#666") }
617      ],
618      axes: [
619        { orient: "bottom", scale: "x", format: "%Y-%m-%d", formatType: "utc", labelOverlap: true, labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor },
620        { orient: "left", scale: "y", labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor }
621      ],
622      signals: [
623        { name: "hoverTs", value: null, on: [
624          { events: "mousemove", update: "clamp(invert('x', x()), domain('x')[0], domain('x')[1])" },
625          { events: "mouseout", update: "null" }
626        ] }
627      ],
628      data: [
629        { name: "stats", values: intervals },
630        { name: "points", values: points },
631        {
632          name: "hover_points",
633          source: "points",
634          transform: [
635            { type: "formula", as: "dist", expr: "hoverTs == null ? null : abs(datum.interval_ts - hoverTs)" },
636            { type: "window", sort: { field: "dist", order: "ascending" }, groupby: ["series"], ops: ["row_number"], as: ["rank"] },
637            { type: "filter", expr: "hoverTs != null && datum.rank == 1" },
638            { type: "window", sort: { field: "series_label", order: "ascending" }, ops: ["row_number"], as: ["idx"] }
639          ]
640        }
641      ],
642      marks: marks.concat([
643        {
644          type: "rule",
645          encode: {
646            update: {
647              x: { scale: "x", signal: "hoverTs" },
648              y: { value: 0 },
649              y2: { signal: "height" },
650              stroke: { value: "#c7c7c7" },
651              strokeWidth: { value: 1 },
652              opacity: { signal: "hoverTs == null ? 0 : 1" }
653            }
654          }
655        },
656        {
657          type: "symbol",
658          from: { data: "hover_points" },
659          encode: {
660            enter: {
661              x: { scale: "x", field: "interval_ts" },
662              y: { scale: "y", field: "value" },
663              size: { value: 64 },
664              fill: { scale: "color", field: "series" },
665              opacity: { value: 1 }
666            }
667          }
668        },
669        {
670          type: "group",
671          encode: {
672            enter: {
673              x: { signal: "clamp(scale('x', hoverTs) + 10, 10, width - 240)" },
674              y: { value: 10 }
675            }
676          },
677          marks: [
678            {
679              type: "rect",
680              encode: {
681                update: {
682                  x: { value: 0 },
683                  y: { value: 0 },
684                  width: { value: 230 },
685                  height: { signal: "32 + data('hover_points').length * 18" },
686                  fill: { value: "white" },
687                  stroke: { value: "#d0d0d0" },
688                  cornerRadius: { value: 6 },
689                  opacity: { signal: "hoverTs == null ? 0 : 1" }
690                }
691              }
692            },
693            {
694              type: "text",
695              encode: {
696                update: {
697                  x: { value: 10 },
698                  y: { value: 16 },
699                  text: { signal: "hoverTs == null ? '' : timeFormat(hoverTs, '%Y-%m-%d')" },
700                  fontWeight: { value: "600" },
701                  fill: { value: "#111" },
702                  opacity: { signal: "hoverTs == null ? 0 : 1" }
703                }
704              }
705            },
706            {
707              type: "text",
708              from: { data: "hover_points" },
709              encode: {
710                update: {
711                  x: { value: 10 },
712                  y: { signal: "30 + (datum.idx - 1) * 18" },
713                  text: { signal: "datum.series_label + ': ' + datum.value" },
714                  fill: { value: "#111" },
715                  opacity: { signal: "hoverTs == null ? 0 : 1" }
716                }
717              }
718            }
719          ]
720        }
721      ])
722    };
723    return { spec: spec, points: points };
724  }
725
726  function buildStackedBarSpec(intervals, width, height, enabledSeries) {
727    const chartColors = getChartColors();
728    const seriesOrder = new Map(enabledSeries.map((series, idx) => [series.key, idx]));
729    const points = intervals.flatMap(row => (
730      enabledSeries.map(series => ({
731        interval_ts: row.interval_ts,
732        series: series.key,
733        series_label: series.label,
734        series_order: seriesOrder.get(series.key) ?? 0,
735        value: row[series.key] || 0
736      }))
737    ));
738    const barWidth = Math.max(6, Math.floor(width / Math.max(intervals.length, 1) * 0.75
738));
739    const spec = {
740      $schema: "https://vega.github.io/schema/vega/v5.json",
741      width: width,
742      height: height,
743      padding: 10,
744      autosize: { type: "fit", contains: "padding" },
745      signals: [
746        { name: "hoverTs", value: null, on: [
747          { events: "mousemove", update: "clamp(invert('x', x()), domain('x')[0], domain('x')[1])" },
748          { events: "mouseout", update: "null" }
749        ] },
750        { name: "barWidth", value: barWidth }
751      ],
752      data: [
753        { name: "stats", values: intervals },
754        { name: "points", values: points },
755        {
756          name: "stacked",
757          source: "points",
758          transform: [
759            { type: "stack", groupby: ["interval_ts"], field: "value", sort: { field: "series_order", order: "ascending" } }
760          ]
761        },
762        {
763          name: "stack_sums",
764          source: "points",
765          transform: [
766            { type: "aggregate", groupby: ["interval_ts"], fields: ["value"], ops: ["sum"], as: ["total"] }
767          ]
768        },
769        {
770          name: "trend",
771          source: "stack_sums",
772          transform: [
773            { type: "loess", x: "interval_ts", y: "total", bandwidth: 0.3 }
774          ]
775        },
776        {
777          name: "hover_points",
778          source: "points",
779          transform: [
780            { type: "formula", as: "dist", expr: "hoverTs == null ? null : abs(datum.interval_ts - hoverTs)" },
781            { type: "window", sort: { field: "dist", order: "ascending" }, groupby: ["series"], ops: ["row_number"], as: ["rank"] },
782            { type: "filter", expr: "hoverTs != null && datum.rank == 1" },
783            { type: "window", sort: { field: "series_label", order: "ascending" }, ops: ["row_number"], as: ["idx"] }
784          ]
785        }
786      ],
787      scales: [
788        { name: "x", type: "time", domain: { data: "stats", field: "interval_ts" }, range: "width" },
789        { name: "y", type: "linear", domain: { data: "stack_sums", field: "total" }, nice: true, range: "height" },
790        { name: "color", type: "ordinal", domain: enabledSeries.map(series => series.key), range: enabledSeries.map(series => series.color || "#666") }
791      ],
792      axes: [
793        { orient: "bottom", scale: "x", format: "%Y-%m-%d", formatType: "utc", labelOverlap: true, labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor },
794        { orient: "left", scale: "y", labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor }
795      ],
796      marks: [
797        {
798          type: "rect",
799          from: { data: "stacked" },
800          encode: {
801            enter: {
802              x: { signal: "scale('x', datum.interval_ts) - barWidth / 2" },
803              width: { signal: "barWidth" },
804              y: { scale: "y", field: "y1" },
805              y2: { scale: "y", field: "y0" },
806              fill: { scale: "color", field: "series" }
807            }
808          }
809        },
810        {
811          type: "line",
812          from: { data: "trend" },
813          encode: {
814            enter: {
815              x: { scale: "x", field: "interval_ts" },
816              y: { scale: "y", field: "total" },
817              stroke: { value: "#111" },
818              strokeWidth: { value: 2 },
819              opacity: { value: 0.6 }
820            }
821          }
822        },
823        {
824          type: "rule",
825          encode: {
826            update: {
827              x: { scale: "x", signal: "hoverTs" },
828              y: { value: 0 },
829              y2: { signal: "height" },
830              stroke: { value: "#c7c7c7" },
831              strokeWidth: { value: 1 },
832              opacity: { signal: "hoverTs == null ? 0 : 1" }
833            }
834          }
835        },
836        {
837          type: "group",
838          encode: {
839            enter: {
840              x: { signal: "clamp(scale('x', hoverTs) + 10, 10, width - 240)" },
841              y: { value: 10 }
842            }
843          },
844          marks: [
845            {
846              type: "rect",
847              encode: {
848                update: {
849                  x: { value: 0 },
850                  y: { value: 0 },
851                  width: { value: 230 },
852                  height: { signal: "32 + data('hover_points').length * 18" },
853                  fill: { value: "white" },
854                  stroke: { value: "#d0d0d0" },
855                  cornerRadius: { value: 6 },
856                  opacity: { signal: "hoverTs == null ? 0 : 1" }
857                }
858              }
859            },
860            {
861              type: "text",
862              encode: {
863                update: {
864                  x: { value: 10 },
865                  y: { value: 16 },
866                  text: { signal: "hoverTs == null ? '' : timeFormat(hoverTs, '%Y-%m-%d')" },
867                  fontWeight: { value: "600" },
868                  fill: { value: "#111" },
869                  opacity: { signal: "hoverTs == null ? 0 : 1" }
870                }
871              }
872            },
873            {
874              type: "text",
875              from: { data: "hover_points" },
876              encode: {
877                update: {
878                  x: { value: 10 },
879                  y: { signal: "30 + (datum.idx - 1) * 18" },
880                  text: { signal: "datum.series_label + ': ' + datum.value" },
881                  fill: { value: "#111" },
882                  opacity: { signal: "hoverTs == null ? 0 : 1" }
883                }
884              }
885            }
886          ]
887        }
888      ]
889    };
890    return { spec: spec, points: points };
891  }
892
893  function buildOverlayBarSpec(intervals, width, height, enabledSeries
893) {
894    const chartColors = getChartColors();
895    if (!intervals.length) {
896      return { spec: null, points: [] };
897    }
898    const points = intervals.flatMap(row => (
899      enabledSeries.map((series, idx) => ({
900        interval_key: row.interval_key,
901        interval_ts: row.interval_ts,
902        series: series.key,
903        series_label: series.label,
904        value: row[series.key] || 0,
905        color: series.color || "#666",
906        order: idx + 1
907      }))
908    ));
909    const spec = {
910      $schema: "https://vega.github.io/schema/vega/v5.json",
911      width: width,
912      height: height,
913      padding: 10,
914      autosize: { type: "fit", contains: "padding" },
915      signals: [
916        { name: "hoverKey", value: null, on: [
917          { events: "@bands:mousemove", update: "datum.interval_key" },
918          { events: "@bands:mouseout", update: "null" }
919        ] }
920      ],
921      data: [
922        { name: "points", values: points },
923        {
924          name: "hover_points",
925          source: "points",
926          transform: [
927            { type: "filter", expr: "hoverKey != null && datum.interval_key === hoverKey" },
928            { type: "collect", sort: { field: "order", order: "ascending" } },
929            { type: "window", ops: ["row_number"], as: ["idx"] }
930          ]
931        }
932      ],
933      scales: [
934        { name: "x", type: "band", domain: { data: "points", field: "interval_key" }, range: "width", padding: 0.2 },
935        { name: "y", type: "linear", domain: [0, 1], nice: false, range: "height" }
936      ],
937      axes: [
938        { orient: "bottom", scale: "x", labelOverlap: true, labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor },
939        { orient: "left", scale: "y", format: ".0%", labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor }
940      ],
941      marks: [
942        {
943          type: "rect",
944          from: { data: "points" },
945          encode: {
946            enter: {
947              x: { scale: "x", field: "interval_key" },
948              width: { scale: "x", band: 1 },
949              y: { scale: "y", field: "value" },
950              y2: { scale: "y", value: 0 },
951              fill: { field: "color" },
952              opacity: { value: 1 },
953              zindex: { field: "order" }
954            }
955          }
956        },
957        {
958          name: "bands",
959          type: "rect",
960          from: { data: "points" },
961          encode: {
962            enter: {
963              x: { scale: "x", field: "interval_key" },
964              width: { scale: "x", band: 1 },
965              y: { value: 0 },
966              y2: { signal: "height" },
967              fill: { value: "transparent" }
968            }
969          }
970        },
971        {
972          type: "rule",
973          encode: {
974            update: {
975              x: { signal: "hoverKey == null ? 0 : scale('x', hoverKey) + bandwidth('x') / 2" },
976              y: { value: 0 },
977              y2: { signal: "height" },
978              stroke: { value: "#c7c7c7" },
979              strokeWidth: { value: 1 },
980              opacity: { signal: "hoverKey == null ? 0 : 1" }
981            }
982          }
983        },
984        {
985          type: "group",
986          encode: {
987            enter: {
988              x: { signal: "clamp(scale('x', hoverKey) + 10, 10, width - 240)" },
989              y: { value: 10 }
990            }
991          },
992          marks: [
993            {
994              type: "rect",
995              encode: {
996                update: {
997                  x: { value: 0 },
998                  y: { value: 0 },
999                  width: { value: 230 },
1000                  height: { signal: "32 + data('hover_points').length * 18" },
1001                  fill: { value: "white" },
1002                  stroke: { value: "#d0d0d0" },
1003                  cornerRadius: { value: 6 },
1004                  opacity: { signal: "hoverKey == null ? 0 : 1" }
1005                }
1006              }
1007            },
1008            {
1009              type: "text",
1010              encode: {
1011                update: {
1012                  x: { value: 10 },
1013                  y: { value: 16 },
1014                  text: { signal: "hoverKey == null ? '' : timeFormat(toDate(hoverKey), '%Y-%m')" },
1015                  fontWeight: { value: "600" },
1016                  fill: { value: "#111" },
1017                  opacity: { signal: "hoverKey == null ? 0 : 1" }
1018                }
1019              }
1020            },
1021            {
1022              type: "text",
1023              from: { data: "hover_points" },
1024              encode: {
1025                update: {
1026                  x: { value: 10 },
1027                  y: { signal: "30 + (datum.idx - 1) * 18" },
1028                  text: { signal: "datum.series_label + ': ' + format(datum.value, '.1%')" },
1029                  fill: { value: "#111" },
1030                  opacity: { signal: "hoverKey == null ? 0 : 1" }
1031                }
1032              }
1033            }
1034          ]
1035        }
1036      ]
1037    };
1038    return { spec: spec, points: points };
1039  }
1040
1041  function buildLongevityHeatmapSpec(histogram, width) {
1042    const chartColors = getChartColors();
1043    const bucketOrder = ["0-1", "2-7", "8-30", "31-90", "91-180", "181-365", "365+"];
1044    const points = histogram.map(row => ({
1045      interval_ts: Date.parse(`${row.interval_start}T00:00:00Z`),
1046      bucket: row.bucket,
1047      count: row.count || 0
1048    }));
1049    return {
1050      $schema: "https://vega.github.io/schema/vega/v5.json",
1051      width: width,
1052      height: 260,
1053      padding: 10,
1054      autosize: { type: "fit", contains: "padding" },
1055      data: [{ name: "heat", values: points }],
1056      scales: [
1057        { name: "x", type: "time", domain: { data: "heat", field: "interval_ts" }, range: "width" },
1058        { name: "y", type: "band", domain: bucketOrder, range: "height", padding: 0.1 },
1059        { name: "color", type: "linear", domain: { data: "heat", field: "count" }, nice: true, range: ["#f2f0f7", "#54278f"] }
1060      ],
1061      axes: [
1062        { orient: "bottom", scale: "x", format: "%Y-%m-%d", formatType: "utc", labelOverlap: true, labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor },
1063        { orient: "left", scale: "y", labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor }
1064      ],
1065      marks: [
1066        {
1067          type: "rect",
1068          from: { data: "heat" },
1069          encode: {
1070            enter: {
1071              x: { scale: "x", field: "interval_ts" },
1072              width: { value: 6 },
1073              y: { scale: "y", field: "bucket" },
1074              height: { scale: "y", band: 1 },
1075              fill: { signal: "datum.count === 0 ? '#e0e0e0' : scale('color', datum.count)" }
1076            }
1077          }
1078        }
1079      ]
1080    };
1081  }
1082
1083  function monthStart(dateStr) {
1084    const date = new Date(`${dateStr}T00:00:00Z`);
1085    return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
1086  }
1087
1088  function monthsBetween(start, end) {
1089    return (end.getUTCFullYear() - start.getUTCFullYear()) * 12 + (end.getUTCMonth() - start.getUTCMonth());
1090  }
1091
1092  function bucketMonthStart(date, bucketSize) {
1093    const year = date.getUTCFullYear();
1094    const month = date.getUTCMonth();
1095    const bucketMonth = Math.floor(month / bucketSize) * bucketSize;
1096    return new Date(Date.UTC(year, bucketMonth, 1));
1097  }
1098
1099  function buildRetentionHeatmapData(rows, rangeStart, rangeEnd) {
1100    const points = [];
1101    const cohortDomain = [];
1102    const seenCohorts = new Set();
1103    let maxMonths = 0;
1104    let maxIntensity = 0;
1105    let minIntensity = null;
1106
1107    rows.forEach(row => {
1108      const cohortDate = monthStart(row.cohort_start);
1109      if (cohortDate < rangeStart || cohortDate > rangeEnd) return;
1110      const cohortKey = cohortDate.toISOString().slice(0, 10);
1111      if (!seenCohorts.has(cohortKey)) {
1112        cohortDomain.push(cohortKey);
1113        seenCohorts.add(cohortKey);
1114      }
1115      const monthsSince = Number(row.months_since);
1116      const retentionRate = row.cohort_size ? Number(row.active_users || 0) / Number(row.cohort_size) : 0;
1117      const intensity = Number(row.avg_messages_per_active_user || 0);
1118      maxMonths = Math.max(maxMonths, monthsSince);
1119      maxIntensity = Math.max(maxIntensity, intensity);
1120      if (intensity > 0) {
1121        minIntensity = minIntensity === null ? intensity : Math.min(minIntensity, intensity);
1122      }
1123      points.push({
1124        cohort_start: cohortKey,
1125        months_since: monthsSince,
1126        retention_rate: retentionRate,
1127        avg_messages_per_active_user: intensity
1128      });
1129    });
1130
1131    cohortDomain.sort().reverse();
1132
1133    return {
1134      points: points,
1135      cohortDomain: cohortDomain,
1136      maxMonths: maxMonths,
1137      maxIntensity: maxIntensity,
1138      minIntensity: minIntensity
1139    };
1140  }
1141
1142  function buildRetentionHeatmapSpec(points, cohortDomain, width, height, valueField, domainMin, domainMax, colorRange, tooltipSignal, scaleType = "linear") {
1143    return {
1144      $schema: "https://vega.github.io/schema/vega/v5.json",
1145      width: width,
1146      height: height,
1147      padding: 10,
1148      autosize: { type: "fit", contains: "padding" },
1149      data: [{ name: "heat", values: points }],
1150      scales: [
1151        { name: "x", type: "band", domain: { data: "heat", field: "months_since", sort: true }, range: "width", padding: 0.03 },
1152        { name: "y", type: "band", domain: cohortDomain, range: "height", padding: 0.02 },
1153        { name: "color", type: scaleType, domain: [domainMin, domainMax], nice: true, range: colorRange, clamp: true }
1154      ],
1155      axes: [
1156        {
1157          orient: "bottom",
1158          scale: "x",
1159          labels: false,
1160          ticks: false
1161        },
1162        {
1163          orient: "left",
1164          scale: "y",
1165          labels: false,
1166          ticks: false
1167        }
1168      ],
1169      marks: [
1170        {
1171          type: "rect",
1172          from: { data: "heat" },
1173          encode: {
1174            enter: {
1175              x: { scale: "x", field: "months_since" },
1176              width: { scale: "x", band: 1 },
1177              y: { scale: "y", field: "cohort_start" },
1178              height: { scale: "y", band: 1 },
1179              fill: { signal: `datum.${valueField} === 0 ? '#e0e0e0' : scale('color', datum.${valueField})` },
1180              tooltip: { signal: tooltipSignal }
1181            }
1182          }
1183        }
1184      ]
1185    };
1186  }
1187
1188  const retentionMilestoneKeys = new Map([
1189    [3, "retention_q1"],
1190    [6, "retention_q2"],
1191    [12, "retention_q4"],
1192    [18, "retention_q6"],
1193    [24, "retention_q8"],
1194    [30, "retention_q10"],
1195    [36, "retention_q12"],
1196    [48, "retention_q16"],
1197    [60, "retention_q20"]
1198  ]);
1199
1200  function buildRetentionMilestoneIntervals(rows, rangeStart, rangeEnd) {
1201    const byCohort = new Map();
1202    rows.forEach(row => {
1203      const cohortDate = monthStart(row.cohort_start);
1204      if (cohortDate < rangeStart || cohortDate > rangeEnd) return;
1205      const key = cohortDate.toISOString().slice(0, 10);
1206      const horizonKey = retentionMilestoneKeys.get(Number(row.horizon_months));
1207      if (!horizonKey) return;
1208      const entry = byCohort.get(key) || { interval_start: key };
1209      const cohortSize = Number(row.cohort_size || 0);
1210      const retainedUsers = Number(row.retained_users || 0);
1211      entry[horizonKey] = cohortSize ? retainedUsers / cohortSize : 0;
1212      byCohort.set(key, entry);
1213    });
1214
1215    return Array.from(byCohort.values())
1216      .map(entry => Object.assign({}, entry, {
1217        interval_key: entry.interval_start,
1218        interval_ts: Date.parse(`${entry.interval_start}T00:00:00Z`)
1219      }))
1220      .sort((a, b) => a.interval_ts - b.interval_ts);
1221  }
1222
1223  let latestIntervals = [];
1224  const latestIntervalsByChart = {};
1225  const latestPointsByChart = {};
1226
1227  function stripEditorData(spec) {
1228    const clone = JSON.parse(JSON.stringify(spec));
1229    if (Array.isArray(clone.data)) {
1230      clone.data = clone.data.map(entry => {
1231        if (entry && Object.prototype.hasOwnProperty.call(entry, "values")) {
1232          const stripped = Object.assign({}, entry);
1233          delete stripped.values;
1234          return stripped;
1235        }
1236        return entry;
1237      });
1238    }
1239    return clone;
1240  }
1241
1242  function mergeEditorData(spec, intervals, points) {
1243    const clone = JSON.parse(JSON.stringify(spec));
1244    const existing = Array.isArray(clone.data) ? clone.data : [];
1245    const withoutSeries = existing.filter(entry => entry.name !== "stats" && entry.name !== "points");
1246    clone.data = [
1247      { name: "stats", values: intervals },
1248      { name: "points", values: points },
1249      ...withoutSeries
1250    ];
1251    return clone;
1252  }
1253
1254  function renderLineChart(chartKey, intervals, embedOpts) {
1255    const config = chartConfigs[chartKey];
1256    const enabledSeries = selectedSeriesFor(chartKey);
1257    if (!enabledSeries.length) {
1258      config.el.textContent = "Select at least one series.";
1259      return null;
1260    }
1261    const chartWidth = chartWidthFor(config.el);
1262    const result = config.type === "stacked_bar"
1263      ? buildStackedBarSpec(intervals, chartWidth, config.height, enabledSeries
1263)
1264      : (config.type === "overlay_bar"
1265        ? buildOverlayBarSpec(intervals, chartWidth, config.height, enabledSeries)
1266        : buildLineSpec(intervals, chartWidth, config.height, enabledSeries));
1267    if (!result || !result.spec) {
1268      config.el.textContent = "No data for this range yet.";
1269      return null;
1270    }
1271    latestPointsByChart[chartKey] = result.points;
1272    latestIntervalsByChart[chartKey] = intervals;
1273    if (config.editorEl) {
1274      const editorSpec = stripEditorData(result.spec);
1275      config.editorEl.value = JSON.stringify(editorSpec, null, 2);
1276    }
1277    return vegaEmbed(config.el, result.spec, embedOpts);
1278  }
1279
1280  function renderCharts(data) {
1281    const intervals = decorateIntervals(data.intervals || []);
1282    if (!intervals.length || intervals.every(row => !Number.isFinite(row.interval_ts))) {
1283      mainEl.textContent = "No statistics available for this range yet.";
1284      depthEl.textContent = "No statistics available for this range yet.";
1285      createdDepthEl.textContent = "No statistics available for this range yet.";
1286      messageBreakdownEl.textContent = "No statistics available for this range yet.";
1287      attachmentsEl.textContent = "No statistics available for this range yet.";
1288      newTopicAttachmentsEl.textContent = "No statistics available for this range yet.";
1289      longevityEl.textContent = "No statistics available for this range yet.";
1290      participantLifetimeEl.textContent = "No statistics available for this range yet.";
1291      newParticipantRateEl.textContent = "No statistics available for this range yet.";
1292      participantRetentionEl.textContent = "No statistics available for this range yet.";
1293      retentionMilestonesEl.textContent = "No statistics available for this range yet.";
1294      longevityHeatmapEl.textContent = "No statistics available for this range yet.";
1295      retentionHeatmapEl.textContent = "No statistics available for this range yet.";
1296      retentionIntensityEl.textContent = "No statistics available for this range yet.";
1297      return;
1298    }
1299
1300    const histogram = data.longevity_histogram || [];
1301    const heatmapSpec = buildLongevityHeatmapSpec(histogram, chartWidthFor(longevityHeatmapEl));
1302    const retentionRows = data.retention_heatmap || [];
1303    const retentionMilestones = data.retention_milestones || [];
1304    const rangeStart = monthStart(data.from);
1305    const rangeEnd = monthStart(data.to);
1306    const retentionData = buildRetentionHeatmapData(retentionRows, rangeStart, rangeEnd);
1307    const milestoneIntervals = buildRetentionMilestoneIntervals(retentionMilestones, rangeStart, rangeEnd);
1308    const rowHeight = retentionGranularityEl.value === "quarter" ? 8 : 4;
1309    const heatmapHeight = Math.max(240, retentionData.cohortDomain.length * rowHeight);
1310    const monthsLabel = retentionGranularityEl.value === "quarter" ? "q" : "mo";
1311    const retentionSpec = retentionData.points.length
1312      ? buildRetentionHeatmapSpec(
1313          retentionData.points,
1314          retentionData.cohortDomain,
1315          chartWidthFor(retentionHeatmapEl),
1316          heatmapHeight,
1317          "retention_rate",
1318          0,
1319          1,
1320          ["#d7fbff", "#08519c"],
1321          "timeFormat(toDate(datum.cohort_start), '%Y-%m') + ' | ' + format(datum.months_since / " + (retentionGranularityEl.value === "quarter" ? 3 : 1) + ", 'd') + ' " + monthsLabel + ": ' + format(datum.retention_rate, '.1%')",
1322          "linear"
1323        )
1324      : null;
1325    const intensitySpec = retentionData.points.length
1326      ? buildRetentionHeatmapSpec(
1327          retentionData.points,
1328          retentionData.cohortDomain,
1329          chartWidthFor(retentionIntensityEl),
1330          heatmapHeight,
1331          "avg_messages_per_active_user",
1332          Math.max(retentionData.minIntensity || 1, 1),
1333          Math.max(retentionData.maxIntensity, 1),
1334          ["#fff5eb", "#7f2704"],
1335          "timeFormat(toDate(datum.cohort_start), '%Y-%m') + ' | ' + format(datum.months_since / " + (retentionGranularityEl.value === "quarter" ? 3 : 1) + ", 'd') + ' " + monthsLabel + ": ' + format(datum.avg_messages_per_active_user, '.2f') + ' msgs/user'",
1336          "log"
1337        )
1338      : null;
1339
1340    latestIntervals = intervals;
1341    const embedOpts = { actions: false, tooltip: true };
1342    const embeds = [];
1343    const mainEmbed = renderLineChart("main", intervals, embedOpts);
1344    if (mainEmbed) embeds.push(mainEmbed);
1345    const depthEmbed = renderLineChart("depth", intervals, embedOpts);
1346    if (depthEmbed) embeds.push(depthEmbed);
1347    const createdDepthEmbed = renderLineChart("created_depth", intervals, embedOpts);
1348    if (createdDepthEmbed) embeds.push(createdDepthEmbed);
1349    const messageBreakdownEmbed = renderLineChart("message_breakdown", intervals, embedOpts);
1350    if (messageBreakdownEmbed) embeds.push(messageBreakdownEmbed);
1351    const attachmentsEmbed = renderLineChart("attachments", intervals, embedOpts);
1352    if (attachmentsEmbed) embeds.push(attachmentsEmbed);
1353    const newTopicAttachmentsEmbed = renderLineChart("new_topic_attachments", intervals, embedOpts);
1354    if (newTopicAttachmentsEmbed) embeds.push(newTopicAttachmentsEmbed);
1355    const longevityEmbed = renderLineChart("longevity", intervals, embedOpts);
1356    if (longevityEmbed) embeds.push(longevityEmbed);
1357    const participantLifetimeEmbed = renderLineChart("participant_lifetime", intervals, embedOpts);
1358    if (participantLifetimeEmbed) embeds.push(participantLifetimeEmbed);
1359    const newParticipantRateEmbed = renderLineChart("new_participant_rate", intervals, embedOpts);
1360    if (newParticipantRateEmbed) embeds.push(newParticipantRateEmbed);
1361    const participantRetentionEmbed = renderLineChart("participant_retention", intervals, embedOpts);
1362    if (participantRetentionEmbed) embeds.push(participantRetentionEmbed);
1363    if (milestoneIntervals.length) {
1364      const retentionMilestonesEmbed = renderLineChart("retention_milestones", milestoneIntervals, embedOpts);
1365      if (retentionMilestonesEmbed) embeds.push(retentionMilestonesEmbed);
1366    } else {
1367      retentionMilestonesEl.textContent = "No retention milestone data available for this range yet.";
1368    }
1369    embeds.push(vegaEmbed(longevityHeatmapEl, heatmapSpec, embedOpts));
1370    if (retentionSpec) {
1371      embeds.push(vegaEmbed(retentionHeatmapEl, retentionSpec, embedOpts));
1372      embeds.push(vegaEmbed(retentionIntensityEl, intensitySpec, embedOpts));
1373    } else {
1374      retentionHeatmapEl.textContent = "No retention data available for this range yet.";
1375      retentionIntensityEl.textContent = "No retention data available for this range yet.";
1376    }
1377    Promise.all(embeds).catch(err => {
1378      mainEl.textContent = "Failed to render charts.";
1379      depthEl.textContent = "Failed to render charts.";
1380      createdDepthEl.textContent = "Failed to render charts.";
1381      messageBreakdownEl.textContent = "Failed to render charts.";
1382      attachmentsEl.textContent = "Failed to render charts.";
1383      newTopicAttachmentsEl.textContent = "Failed to render charts.";
1384      longevityEl.textContent = "Failed to render charts.";
1385      participantLifetimeEl.textContent = "Failed to render charts.";
1386      newParticipantRateEl.textContent = "Failed to render charts.";
1387      participantRetentionEl.textContent = "Failed to render charts.";
1388      retentionMilestonesEl.textContent = "Failed to render charts.";
1389      longevityHeatmapEl.textContent = "Failed to render charts.";
1390      retentionHeatmapEl.textContent = "Failed to render charts.";
1391      retentionIntensityEl.textContent = "Failed to render charts.";
1392    });
1393  }
1394
1395  function load() {
1396    fetchData().then(renderCharts).catch(() => {
1397      mainEl.textContent = "Failed to load stats.";
1398    });
1399  }
1400
1401  function toggleCustomRange() {
1402    const isCustom = rangeEl.value === "custom";
1403    customRangeEls.forEach(el => { el.hidden = !isCustom; });
1404  }
1405
1406  Object.values(chartConfigs).forEach(config => {
1407    config.seriesInputs.forEach(input => input.addEventListener("change", () => {
1408      syncUrl();
1409      load();
1410    }));
1411  });
1412  collapsibleKeys.forEach(key => {
1413    const container = collapsibleByKey.get(key);
1414    if (!container) return;
1415    container.addEventListener("toggle", () => {
1416      syncUrl();
1417    });
1418  });
1419  granularityEl.addEventListener("change", () => {
1420    syncUrl();
1421    load();
1422  });
1423  retentionGranularityEl.addEventListener("change", () => {
1424    syncUrl();
1425    load();
1426  });
1427  retentionSegmentEl.addEventListener("change", () => {
1428    syncUrl();
1429    load();
1430  });
1431  rangeEl.addEventListener("change", () => {
1432    toggleCustomRange();
1433    if (rangeEl.value !== "custom") {
1434      syncUrl();
1435      load();
1436    }
1437  });
1438  applyRangeEl.addEventListener("click", () => {
1439    syncUrl();
1440    load();
1441  });
1442
1443  Object.values(chartConfigs).forEach(config => {
1444    if (!config.applyEl) return;
1445    config.applyEl.addEventListener("click", () => {
1446      try {
1447        const spec = JSON.parse(config.editorEl.value);
1448        const points = latestPointsByChart[config.key] || [];
1449        const intervals = latestIntervalsByChart[config.key] || latestIntervals;
1450        const merged = mergeEditorData(spec, intervals, points);
1451        vegaEmbed(config.el, merged, { actions: false, tooltip: true });
1452      } catch (e) {
1453        alert("Invalid JSON spec.");
1454      }
1455    });
1456  });
1457
1458  hydrateControlsFromUrl();
1459  applySeriesSwatches();
1460  syncUrl();
1461  load();
1462})();</script>
1462</div></main></div></body></html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.