heartwood every commit a ring

Redesign UI and improve property dashboard metrics

c4a96358 by Isaac Bythewood · 25 days ago

Redesign UI and improve property dashboard metrics

Visual overhaul across all pages (templates, base/bootstrap SCSS, registration
flow, error pages, profile, home, changelog, docs). Property listing rebuilt
with consistent meta cells (state · events · views · sessions) and a labeled
property ID line. Dashboard metric tiles get larger percent-change badges.

Cap avg time on page at 1s–30min server-side and switch the collector to track
only document-visible time via the Page Visibility API + pagehide, so idle
tabs no longer inflate the metric.

Add a markdown report export (`?report=md`) alongside the existing PDF report.
modified accounts/templates/accounts/profile.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
@@ -14,32 +14,24 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>    </div>  </div>    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">operator · {{ request.user.username }}</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Account identifiers. Changes save on submit.</p>  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <form method="POST">        {% csrf_token %}        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" name="username" id="id_username" placeholder="Username" value="{{ form.username.value }}" />          <label for="id_username" class="form-label">Username</label>          <label for="id_username">Username</label>        </div>        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="Email" value="{{ form.email.value }}" />          <label for="id_email" class="form-label">Email</label>          <input type="email" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="Email" value="{{ form.email.value }}" />          <label for="id_email">Email</label>        </div>        <div class="row">          <div class="col">            <button type="submit" class="btn btn-primary">              Update profile            </button>            <a href="{% url 'password_change' %}" class="btn btn-secondary">              Password change            </a>          </div>        <div class="d-flex gap-2">          <button type="submit" class="btn btn-primary">Save →</button>          <a href="{% url 'password_change' %}" class="btn btn-outline-light">Change password</a>        </div>      </form>    </div>
modified analytics/static_src/index.js
@@ -1,3 +1,6 @@// fontsimport "@fontsource/monaspace-argon";// scriptsimport "./scripts/bootstrap.js";
added analytics/static_src/styles/_variables.scss
@@ -0,0 +1,138 @@// Analytics — warm-earth dark theme, aligned to status & blog.bythewood.me// Mossy forest green + warm amber on a deep earth background. Quieter than// true cyberpunk — feels like a UNIX operator's console.// ── Typography ──$font-family-sans-serif: 'Monaspace Argon', ui-monospace, 'Cascadia Code', Consolas, 'SF Mono', Menlo, monospace;$font-family-monospace: 'Monaspace Argon', ui-monospace, 'Cascadia Code', Consolas, 'SF Mono', Menlo, monospace;$headings-font-family: null;$headings-font-weight: 700;$font-size-base: 0.92rem;$line-height-base: 1.7;$headings-line-height: 1.3;// ── Core palette — warm earth darks ──$white: #ede8e0;$gray-100: #ddd7cd;$gray-200: #c4bdb2;$gray-300: #a09890;$gray-400: #847c72;$gray-500: #665f56;$gray-600: #4a443c;$gray-700: #332e28;$gray-800: #211e1a;$gray-900: #161310;$black: #0b0a08;$body-bg: #0e0d0a;$body-color: #ddd7cd;// ── Accents — muted forest green + warm amber ──$green: #6b9e78;$green-bright: #7db88c;$green-dim: rgba(107, 158, 120, 0.12);$green-border: rgba(107, 158, 120, 0.22);$amber: #c9a84c;$amber-dim: rgba(201, 168, 76, 0.1);$amber-border: rgba(201, 168, 76, 0.22);$terracotta: #c47055;$terracotta-dim: rgba(196, 112, 85, 0.1);$terracotta-border: rgba(196, 112, 85, 0.22);// ── Theme mapping ──$primary: $green;$secondary: #4a443c;$success: $green;$info: #7eaab8;$warning: $amber;$danger: $terracotta;$light: #211e1a;$dark: #0e0d0a;// ── Links ──$link-color: $green-bright;$link-hover-color: #95cca2;$link-decoration: none;$link-hover-decoration: underline;// ── Borders ──$border-color: rgba(221, 215, 205, 0.06);$border-radius: 0.25rem;$border-radius-sm: 0.2rem;$border-radius-lg: 0.375rem;// ── Cards ──$card-bg: #13120e;$card-border-color: rgba(107, 158, 120, 0.1);$card-color: $body-color;$card-cap-bg: rgba(221, 215, 205, 0.02);// ── Inputs ──$input-bg: #13120e;$input-color: $body-color;$input-border-color: rgba(107, 158, 120, 0.18);$input-focus-bg: #18160f;$input-focus-color: $body-color;$input-focus-border-color: $green;$input-focus-box-shadow: 0 0 0 2px rgba(107, 158, 120, 0.15);$input-placeholder-color: #665f56;// ── Navbar ──$navbar-dark-color: rgba(221, 215, 205, 0.6);$navbar-dark-hover-color: $white;$navbar-dark-active-color: $white;// ── List group ──$list-group-bg: #13120e;$list-group-border-color: rgba(107, 158, 120, 0.08);$list-group-hover-bg: #1a1812;$list-group-action-color: #a09890;$list-group-action-hover-color: $white;$list-group-action-active-bg: #211e1a;$list-group-action-active-color: $white;$list-group-active-bg: $green-dim;$list-group-active-border-color: $green-border;$list-group-active-color: $green-bright;// ── Breadcrumbs ──$breadcrumb-active-color: $white;$breadcrumb-divider-color: #665f56;// ── Dropdowns ──$dropdown-bg: #13120e;$dropdown-border-color: rgba(107, 158, 120, 0.12);$dropdown-color: $body-color;$dropdown-link-color: #a09890;$dropdown-link-hover-color: $white;$dropdown-link-hover-bg: #1a1812;// ── Close button ──$btn-close-color: $body-color;// ── Modal ──$modal-content-bg: #13120e;$modal-content-border-color: rgba(107, 158, 120, 0.15);$modal-header-border-color: rgba(107, 158, 120, 0.08);$modal-footer-border-color: rgba(107, 158, 120, 0.08);$modal-backdrop-bg: #000;$modal-backdrop-opacity: 0.75;// ── Progress ──$progress-bg: rgba(221, 215, 205, 0.05);$progress-bar-bg: $green;// ── Tables ──$table-bg: transparent;$table-color: $body-color;$table-border-color: rgba(107, 158, 120, 0.08);// ── Accordion ──$accordion-bg: #13120e;$accordion-border-color: rgba(107, 158, 120, 0.12);$accordion-button-bg: #13120e;$accordion-button-active-bg: rgba(107, 158, 120, 0.08);$accordion-button-active-color: $green-bright;$accordion-button-focus-border-color: $green;$accordion-button-focus-box-shadow: 0 0 0 2px rgba(107, 158, 120, 0.15);
modified analytics/static_src/styles/base.scss
@@ -1,44 +1,884 @@@use "variables" as *;// ── CSS variable overrides ──// Bootstrap 5.3 writes defaults into `:root,[data-bs-theme=light]`. Use// `html:root` so we outrank the default block and enforce the dark palette.html:root {  --bs-body-bg: #{$body-bg};  --bs-body-color: #{$body-color};  --bs-body-bg-rgb: 14, 13, 10;  --bs-body-color-rgb: 221, 215, 205;  --bs-emphasis-color: #{$white};  --bs-secondary-bg: #13120e;  --bs-tertiary-bg: #18160f;  --bs-border-color: rgba(107, 158, 120, 0.12);  --bs-link-color-rgb: 125, 184, 140;  --bs-link-hover-color-rgb: 149, 204, 162;  --bs-heading-color: #{$white};}// ── Global ──body {    min-height: 100vh;  min-height: 100vh;  display: flex;  flex-direction: column;  -webkit-font-smoothing: antialiased;  -moz-osx-font-smoothing: grayscale;  letter-spacing: 0.01em;  background-color: $body-bg !important;  color: $body-color;}main { flex: 1; }.vh-100 { height: 100vh; }// ── Logo (rising-bars mark) ──// An ascending bar-chart silhouette — evokes growth / aggregate counts, which// is what Analytics is actually for. Rendered inline as SVG so it scales// cleanly and matches the favicon exactly..logo {  display: inline-flex;  align-items: center;  justify-content: center;  width: 28px;  height: 28px;  text-decoration: none !important;  transition: transform 250ms ease;  svg {    width: 100%;    height: 100%;    display: block;  }  &:hover {    transform: translateY(-1px);  }  &.logo-sm {    width: 18px;    height: 18px;  }}// ── Navbar ──.navbar {  height: 52px;  padding: 0;  background: #090806 !important;  border-bottom: 1px solid rgba(107, 158, 120, 0.06);  .navbar-brand {    display: flex;    flex-direction: column;    align-items: center;    gap: 0.6rem;    color: $white;    font-weight: 600;    letter-spacing: 0.1em;    text-transform: uppercase;    font-size: 0.78rem;    text-decoration: none;    .brand-text { color: $white; }    .brand-dot {      display: inline-block;      width: 6px; height: 6px; border-radius: 50%;      background: $green;    }  }  .nav-link {    font-size: 0.8rem;    font-weight: 500;    letter-spacing: 0.05em;    text-transform: lowercase;    padding-left: 1rem !important;    padding-right: 1rem !important;    color: rgba(221, 215, 205, 0.6);    position: relative;    transition: color 200ms ease;    &::after {      content: '';      position: absolute;      bottom: 2px;      left: 1rem;      right: 1rem;      height: 1px;      background: $green;      transform: scaleX(0);      transition: transform 250ms ease;    }    &:hover,    &.active { color: $white; }    &:hover::after,    &.active::after { transform: scaleX(1); }  }}main {    flex: 1;// ── Breadcrumb bar ──.breadcrumb-bar {  background: rgba(107, 158, 120, 0.02);  border-bottom: 1px solid rgba(107, 158, 120, 0.04);  .breadcrumb { font-family: $font-family-monospace; }}// ── Section labels — amber caps, no prompt glyph ──.section-label {  font-size: 0.7rem;  font-weight: 600;  letter-spacing: 0.12em;  text-transform: uppercase;  color: rgba(201, 168, 76, 0.75);  font-family: $font-family-monospace;}.vh-100 {    height: 100vh;// ── Cards (base) ──.card {  background: $card-bg;  border: 1px solid $card-border-color;  transition: border-color 250ms ease;  &:hover { border-color: rgba(107, 158, 120, 0.2); }}.login-hero {  background: var(--background-image);  background-size: cover;  background-position: center;  background-repeat: no-repeat;// ── Status dot ──.status-dot {  display: inline-block;  width: 8px; height: 8px;  border-radius: 50%;  margin-right: 0.55rem;  vertical-align: middle;  flex-shrink: 0;  &.status-dot-lg {    width: 14px; height: 14px;    margin-right: 0;  }  &.is-up { background: $green; box-shadow: 0 0 0 3px rgba(107, 158, 120, 0.12); }  &.is-down { background: $terracotta; box-shadow: 0 0 0 3px rgba(196, 112, 85, 0.12); }  &.is-warn { background: $amber; box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.12); }  &.is-idle { background: #4a443c; }}.logo {  content: "";  display: block;  background-image: linear-gradient(to right, rgb(14, 63, 244) 0px, rgb(132, 43, 255) 100%);  width: 36px;  height: 36px;  margin-top: auto;  margin-bottom: auto;  transition: transform .2s ease-in-out;// ── Visibility toggle (segmented pill) ──.toggle-label {  font-family: $font-family-monospace;  font-size: 0.68rem;  font-weight: 600;  letter-spacing: 0.12em;  text-transform: uppercase;  color: #847c72;  cursor: help;}.toggle-pill {  display: inline-flex;  align-items: stretch;  border: 1px solid rgba(107, 158, 120, 0.22);  border-radius: 999px;  overflow: hidden;  cursor: pointer;  background: #0e0d0a;  margin: 0;  user-select: none;  line-height: 1;  input {    position: absolute;    opacity: 0;    pointer-events: none;    width: 0; height: 0;  }  .toggle-pill-seg {    padding: 0.45rem 0.9rem;    font-family: $font-family-monospace;    font-size: 0.7rem;    font-weight: 600;    letter-spacing: 0.08em;    text-transform: uppercase;    color: #665f56;    transition: background 180ms ease, color 180ms ease;  }  input ~ .toggle-pill-private {    background: rgba(201, 168, 76, 0.12);    color: #ddc06a;  }  input:checked ~ .toggle-pill-private {    background: transparent;    color: #665f56;  }  input:checked ~ .toggle-pill-public {    background: $green-dim;    color: $green-bright;  }  &:hover {    transform: rotate(15deg) scale(1.1);    border-color: rgba(107, 158, 120, 0.35);  }}.navbar {  height: 50px;  padding: 0;// ── Metric tile ──.metric-tile {  background: $card-bg;  border: 1px solid $card-border-color;  border-radius: $border-radius;  padding: 1rem 1.15rem;  height: 100%;  display: flex;  flex-direction: column;  position: relative;  .metric-label {    font-size: 0.68rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: #847c72;  }  .metric-value {    font-size: 1.75rem;    font-weight: 700;    line-height: 1.1;    color: $white;    margin-top: 0.35rem;    letter-spacing: -0.01em;    word-break: break-word;  }  .metric-sub {    font-size: 0.72rem;    color: #847c72;    margin-top: auto;    padding-top: 0.35rem;    letter-spacing: 0.02em;  }  // Small delta badge pinned in the top-right  .metric-delta {    position: absolute;    top: 0.55rem;    right: 0.7rem;    font-family: $font-family-monospace;    font-size: 0.75rem;    font-weight: 600;    letter-spacing: 0.04em;    padding: 0.22rem 0.5rem;    border-radius: 2px;    border: 1px solid transparent;    line-height: 1;    &.is-up {      color: $green-bright;      background: $green-dim;      border-color: $green-border;    }    &.is-down {      color: #e38871;      background: $terracotta-dim;      border-color: $terracotta-border;    }    &.is-flat {      color: #847c72;      background: rgba(221, 215, 205, 0.04);      border-color: rgba(221, 215, 205, 0.08);    }  }  &.metric-accent-green {    border-color: $green-border;    .metric-value { color: $green-bright; }  }  &.metric-accent-amber {    border-color: $amber-border;    .metric-value { color: #ddc06a; }  }  &.metric-accent-danger {    border-color: $terracotta-border;    .metric-value { color: #d88870; }  }}// ── Chips (inline status/score) ──.chip {  display: inline-flex;  align-items: center;  gap: 0.4rem;  font-family: $font-family-monospace;  font-size: 0.7rem;  font-weight: 600;  letter-spacing: 0.05em;  text-transform: uppercase;  padding: 0.3rem 0.6rem;  border-radius: 2px;  border: 1px solid transparent;  line-height: 1;  &.chip-ok {    background: $green-dim;    color: $green-bright;    border-color: $green-border;  }  &.chip-down {    background: $terracotta-dim;    color: #e38871;    border-color: $terracotta-border;  }  &.chip-warn {    background: $amber-dim;    color: #ddc06a;    border-color: $amber-border;  }  &.chip-info {    background: rgba(126, 170, 184, 0.08);    color: #9ec5d2;    border-color: rgba(126, 170, 184, 0.22);  }  &.chip-muted {    background: rgba(221, 215, 205, 0.04);    color: #847c72;    border-color: rgba(221, 215, 205, 0.08);  }}// ── Footer ──footer {  padding: 6rem 0;  padding-top: 4rem;  padding-bottom: 3rem;  position: relative;  background: #090806;  border-top: 1px solid rgba(107, 158, 120, 0.06);  .h5 {    font-size: 0.7rem;    letter-spacing: 0.12em;    text-transform: uppercase;    color: rgba(201, 168, 76, 0.55);    font-weight: 600;  }  p {    color: rgba(221, 215, 205, 0.5);    font-size: 0.85rem;    line-height: 1.75;    a {      color: rgba(221, 215, 205, 0.75);      text-decoration: underline;      text-underline-offset: 2px;      &:hover { color: $white; }    }  }  .links {    padding-top: 2rem;    padding-bottom: 1rem;  }}.footer-bar {  background: #050403;  border-top: 1px solid rgba(107, 158, 120, 0.04);  padding: 0.8rem 0;  color: rgba(221, 215, 205, 0.3);  font-size: 0.78rem;  small { color: rgba(221, 215, 205, 0.3); }  &-link {    color: rgba(221, 215, 205, 0.3);    transition: color 200ms ease;    &:hover { color: $white; }  }}// ── Terminal-style block ──.terminal-block {  background: rgba(9, 8, 6, 0.7);  border: 1px solid rgba(107, 158, 120, 0.15);  border-radius: $border-radius;  font-family: $font-family-monospace;  font-size: 0.8rem;  color: $green-bright;  padding: 1rem 1.2rem;  line-height: 1.8;  .t-line { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }  .t-prompt { color: $amber; margin-right: 0.4rem; }  .t-out { color: $gray-200; }  .t-comment { color: #665f56; }  .t-key { color: $amber; }  .t-val { color: $green-bright; }  .t-cursor {    display: inline-block; width: 7px; height: 0.9em; vertical-align: -2px;    background: $green-bright;    animation: t-blink 1s steps(1) infinite;  }}@keyframes t-blink {  0%, 50% { opacity: 1; }  51%, 100% { opacity: 0; }}// ── Property row (dashboard listing) ──.property-row {  display: grid;  grid-template-columns: minmax(0, 1fr) auto;  gap: 1rem 1.5rem;  background: $card-bg;  border: 1px solid $card-border-color;  border-radius: $border-radius;  padding: 1rem 1.15rem;  margin-bottom: 0.75rem;  transition: border-color 200ms ease;  &:hover { border-color: rgba(107, 158, 120, 0.22); }  &.is-quiet { border-color: rgba(132, 124, 114, 0.25); }  .pr-main {    min-width: 0;    display: flex;    flex-direction: column;    gap: 0.35rem;  }  .pr-title {    font-weight: 600;    color: $white;    display: flex;    align-items: center;    min-width: 0;    a {      color: inherit;      text-decoration: none;      min-width: 0;      overflow: hidden;      text-overflow: ellipsis;      white-space: nowrap;      &:hover { color: $green-bright; }    }  }  .pr-id {    font-size: 0.72rem;    color: #665f56;    font-family: $font-family-monospace;    overflow: hidden;    text-overflow: ellipsis;    white-space: nowrap;    display: flex;    align-items: baseline;    gap: 0.5rem;    .pr-id-k {      text-transform: uppercase;      font-size: 0.6rem;      letter-spacing: 0.1em;      color: #847c72;      flex-shrink: 0;    }  }  .pr-side {    display: flex;    flex-direction: column;    justify-content: space-between;    align-items: flex-end;    gap: 0.75rem;    min-height: 68px;  }  .pr-meta {    display: flex;    gap: 0.85rem;    align-items: flex-end;    font-family: $font-family-monospace;    .pr-meta-cell {      display: flex;      flex-direction: column;      align-items: flex-start;      gap: 0.2rem;      min-width: 0;    }    .pr-meta-k {      color: #665f56;      text-transform: uppercase;      font-size: 0.6rem;      letter-spacing: 0.1em;      line-height: 1;    }    .pr-meta-v {      color: $gray-200;      font-size: 0.88rem;      font-weight: 600;      line-height: 1;    }  }  .pr-actions {    display: flex;    gap: 0.4rem;  }}@media (max-width: 767px) {  .property-row {    grid-template-columns: 1fr;    .pr-side {      min-height: 0;      align-items: stretch;    }    .pr-meta {      flex-wrap: wrap;      justify-content: flex-start;    }    .pr-actions {      justify-content: flex-end;    }  }}// ── Search form ──.search-form {  position: relative;  .search-icon {    position: absolute;    left: 0.9rem;    top: 50%;    transform: translateY(-50%);    color: #665f56;    pointer-events: none;    z-index: 2;  }  .search-input {    padding-left: 2.5rem;    font-size: 0.85rem;    border: 1px solid rgba(107, 158, 120, 0.15);    background: rgba(19, 18, 14, 0.7);    color: $body-color;    transition: border-color 200ms ease;    &::placeholder {      color: #665f56;      font-style: normal;    }    &:focus {      border-color: rgba(107, 158, 120, 0.4);      box-shadow: 0 0 0 2px rgba(107, 158, 120, 0.1);      background: #13120e;    }  }}// ── Dashboard toolbar ──.dashboard-toolbar {  display: flex;  gap: 0.5rem;  align-items: stretch;  .form-control {    height: 38px;    font-size: 0.85rem;  }  .btn {    height: 38px;    padding: 0 1.1rem;    display: inline-flex;    align-items: center;    white-space: nowrap;  }}// ── Chart panel ──.chart-panel {  background: $card-bg;  border: 1px solid $card-border-color;  border-radius: $border-radius;  padding: 1rem 1.15rem;  .chart-panel-header {    display: flex;    align-items: center;    justify-content: space-between;    margin-bottom: 0.75rem;    gap: 0.75rem;  }  .chart-panel-title {    font-size: 0.68rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: rgba(201, 168, 76, 0.75);    font-family: $font-family-monospace;  }  .chart-panel-body {    position: relative;  }}// ── Insight / top-list table ──// Used for "top pages", "referrers", "UTM source" etc. — compact grid rows// that read like a ranked terminal listing rather than a Bootstrap card..rank-list {  background: $card-bg;  border: 1px solid $card-border-color;  border-radius: $border-radius;  overflow: hidden;  height: 100%;  display: flex;  flex-direction: column;  .rank-list-header {    display: flex;    justify-content: space-between;    align-items: center;    background: rgba(107, 158, 120, 0.04);    padding: 0.6rem 1rem;    border-bottom: 1px solid rgba(107, 158, 120, 0.08);    .rank-list-title {      font-size: 0.68rem;      font-weight: 600;      letter-spacing: 0.12em;      text-transform: uppercase;      color: rgba(201, 168, 76, 0.75);      font-family: $font-family-monospace;    }    .rank-list-col {      font-size: 0.62rem;      font-weight: 600;      letter-spacing: 0.1em;      text-transform: uppercase;      color: #665f56;      font-family: $font-family-monospace;    }  }  .rank-list-body {    display: flex;    flex-direction: column;    flex: 1 1 auto;  }  .rank-list-row {    display: grid;    grid-template-columns: minmax(0, 1fr) auto;    gap: 0.75rem;    align-items: center;    padding: 0.5rem 1rem;    border-bottom: 1px solid rgba(107, 158, 120, 0.04);    font-size: 0.85rem;    &:last-child { border-bottom: none; }    .rank-label {      min-width: 0;      overflow: hidden;      text-overflow: ellipsis;      white-space: nowrap;      color: $gray-200;      a {        color: inherit;        text-decoration: none;        &:hover {          color: $green-bright;          text-decoration: underline;        }      }    }    .rank-count {      font-family: $font-family-monospace;      font-size: 0.78rem;      color: $amber;      font-weight: 600;    }  }  .rank-list-empty {    padding: 1.25rem 1rem;    color: #665f56;    font-size: 0.82rem;    text-align: center;  }}// ── Filter chip (active query filter indicator) ──.filter-chip {  display: inline-flex;  align-items: center;  gap: 0.5rem;  background: $amber-dim;  border: 1px solid $amber-border;  color: #ddc06a;  font-family: $font-family-monospace;  font-size: 0.75rem;  padding: 0.3rem 0.6rem;  border-radius: 2px;  max-width: 100%;  .filter-chip-label {    overflow: hidden;    text-overflow: ellipsis;    white-space: nowrap;  }  .filter-chip-clear {    background: transparent;    border: none;    color: #ddc06a;    cursor: pointer;    padding: 0;    line-height: 1;    opacity: 0.7;    transition: opacity 150ms ease;    &:hover { opacity: 1; }  }}// ── Date range form (property dashboard) ──.date-range-form {  display: grid;  grid-template-columns: 1fr 1fr 1fr;  gap: 0.5rem;  @media (max-width: 575px) {    grid-template-columns: 1fr;  }  .form-floating label {    font-size: 0.78rem;  }  .form-control,  .form-select {    font-size: 0.85rem;  }}// ── Auth split view ──.auth-shell {  min-height: calc(100vh - 52px);  display: grid;  grid-template-columns: 1fr 1fr;  @media (max-width: 991px) {    grid-template-columns: 1fr;  }  .auth-form-col {    padding: 3rem 2rem;    display: flex;    align-items: center;    justify-content: center;  }  .auth-visual-col {    position: relative;    overflow: hidden;    background: linear-gradient(145deg, #090806 0%, #0e0d0a 60%, #13120e 100%);    border-left: 1px solid rgba(107, 158, 120, 0.08);    @media (max-width: 991px) { display: none; }  }  .auth-form-wrap {    width: 100%;    max-width: 380px;  }}// ── Site-tag codebox (for the collector snippet modal) ──.codebox {  background: #090806;  border: 1px solid rgba(107, 158, 120, 0.15);  border-radius: $border-radius;  color: $green-bright;  font-family: $font-family-monospace;  font-size: 0.78rem;  line-height: 1.7;  padding: 1rem 1.1rem;  white-space: pre-wrap;  word-break: break-word;  resize: vertical;  min-height: 160px;  width: 100%;  &:focus {    outline: none;    border-color: $green-border;    box-shadow: 0 0 0 2px rgba(107, 158, 120, 0.1);  }}// ── Datamap tweaks ──// Datamaps renders an SVG; force its container styling to feel like a panel// instead of Bootstrap's default .bg-light island.#datamap {  position: relative;  svg path {    stroke: rgba(107, 158, 120, 0.18) !important;  }  .datamaps-hoverover {    background: #13120e !important;    border: 1px solid $green-border !important;    color: $gray-200 !important;    font-family: $font-family-monospace !important;    font-size: 0.75rem !important;    padding: 0.4rem 0.6rem !important;    border-radius: $border-radius !important;  }}
modified analytics/static_src/styles/bootstrap.scss
@@ -1,25 +1,159 @@@use "bootstrap/scss/bootstrap" as *;@use "variables" as *;@use "bootstrap/scss/bootstrap" with (  $font-family-sans-serif: $font-family-sans-serif,  $font-family-monospace: $font-family-monospace,  $headings-font-family: $headings-font-family,  $headings-font-weight: $headings-font-weight,  $font-size-base: $font-size-base,  $line-height-base: $line-height-base,  $headings-line-height: $headings-line-height,  $white: $white,  $gray-100: $gray-100,  $gray-200: $gray-200,  $gray-300: $gray-300,  $gray-400: $gray-400,  $gray-500: $gray-500,  $gray-600: $gray-600,  $gray-700: $gray-700,  $gray-800: $gray-800,  $gray-900: $gray-900,  $black: $black,  $body-bg: $body-bg,  $body-color: $body-color,  $primary: $primary,  $secondary: $secondary,  $success: $success,  $info: $info,  $warning: $warning,  $danger: $danger,  $light: $light,  $dark: $dark,  $link-color: $link-color,  $link-hover-color: $link-hover-color,  $link-decoration: $link-decoration,  $link-hover-decoration: $link-hover-decoration,  $border-color: $border-color,  $border-radius: $border-radius,  $border-radius-sm: $border-radius-sm,  $border-radius-lg: $border-radius-lg,  $card-bg: $card-bg,  $card-border-color: $card-border-color,  $card-color: $card-color,  $card-cap-bg: $card-cap-bg,  $input-bg: $input-bg,  $input-color: $input-color,  $input-border-color: $input-border-color,  $input-focus-bg: $input-focus-bg,  $input-focus-color: $input-focus-color,  $input-focus-border-color: $input-focus-border-color,  $input-focus-box-shadow: $input-focus-box-shadow,  $input-placeholder-color: $input-placeholder-color,  $navbar-dark-color: $navbar-dark-color,  $navbar-dark-hover-color: $navbar-dark-hover-color,  $navbar-dark-active-color: $navbar-dark-active-color,  $list-group-bg: $list-group-bg,  $list-group-border-color: $list-group-border-color,  $list-group-hover-bg: $list-group-hover-bg,  $list-group-action-color: $list-group-action-color,  $list-group-action-hover-color: $list-group-action-hover-color,  $list-group-action-active-bg: $list-group-action-active-bg,  $list-group-action-active-color: $list-group-action-active-color,  $list-group-active-bg: $list-group-active-bg,  $list-group-active-border-color: $list-group-active-border-color,  $list-group-active-color: $list-group-active-color,  $breadcrumb-active-color: $breadcrumb-active-color,  $breadcrumb-divider-color: $breadcrumb-divider-color,  $dropdown-bg: $dropdown-bg,  $dropdown-border-color: $dropdown-border-color,  $dropdown-color: $dropdown-color,  $dropdown-link-color: $dropdown-link-color,  $dropdown-link-hover-color: $dropdown-link-hover-color,  $dropdown-link-hover-bg: $dropdown-link-hover-bg,  $btn-close-color: $btn-close-color,  $modal-content-bg: $modal-content-bg,  $modal-content-border-color: $modal-content-border-color,  $modal-header-border-color: $modal-header-border-color,  $modal-footer-border-color: $modal-footer-border-color,  $modal-backdrop-bg: $modal-backdrop-bg,  $modal-backdrop-opacity: $modal-backdrop-opacity,  $progress-bg: $progress-bg,  $progress-bar-bg: $progress-bar-bg,  $table-bg: $table-bg,  $table-color: $table-color,  $table-border-color: $table-border-color,  $accordion-bg: $accordion-bg,  $accordion-border-color: $accordion-border-color,  $accordion-button-bg: $accordion-button-bg,  $accordion-button-active-bg: $accordion-button-active-bg,  $accordion-button-active-color: $accordion-button-active-color,  $accordion-button-focus-border-color: $accordion-button-focus-border-color,  $accordion-button-focus-box-shadow: $accordion-button-focus-box-shadow,);// ── Surface utilities ──.bg-surface {  background: #13120e !important;}.bg-surface-2 {  background: #18160f !important;}.bg-deep {  background: #09080626 !important;}.bg-deep,.bg-black {  background-color: $black !important;  background-color: #090806 !important;}.border-subtle {  border-color: rgba(107, 158, 120, 0.1) !important;}.text-muted {  color: #9e968a !important;}.bg-purple-900 {  background-color: $purple-900 !important;.text-green {  color: $green-bright !important;}.bg-purple-800 {  background-color: $purple-800 !important;.text-amber {  color: $amber !important;}// Legacy utility aliases used by existing templates.bg-purple-900 { background-color: #090806 !important; }.bg-purple-800 { background-color: #0e0d0a !important; }.link-footer {  color: $gray-500;  color: $gray-400;  text-decoration: none;  transition: color 100ms ease-in-out;  transition: color 200ms ease;  &:hover {    color: $white;    text-decoration: underline;  }}
@@ -27,24 +161,200 @@  display: block;  flex-wrap: nowrap;  white-space: nowrap;  margin-bottom: 0;  .breadcrumb-item {    font-size: 0.9em;    font-size: 0.82em;    white-space: nowrap;    display: inline-block;    a {      color: $gray-500;      color: $gray-400;      text-decoration: none;      transition: color .15s ease-in-out;      transition: color 150ms ease;      &:hover {        color: $gray-200;        color: $green-bright;      }    }    &.active {      color: $white;      color: $gray-200;    }  }}// Buttons — flat, no glow.btn {  letter-spacing: 0.02em;  font-weight: 500;  transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease;}.btn-primary {  color: #0b0a08;  background: $green;  border-color: $green;  &:hover,  &:focus {    color: #0b0a08;    background: $green-bright;    border-color: $green-bright;  }}.btn-success {  color: #0b0a08;  background: $green;  border-color: $green;  &:hover,  &:focus {    color: #0b0a08;    background: $green-bright;    border-color: $green-bright;  }}.btn-secondary {  color: $gray-200;  background: #1a1812;  border-color: rgba(107, 158, 120, 0.15);  &:hover,  &:focus {    color: $white;    background: #211e1a;    border-color: rgba(107, 158, 120, 0.25);  }}.btn-outline-light {  border-color: rgba(221, 215, 205, 0.18);  color: $gray-200;  &:hover,  &:focus {    background: rgba(221, 215, 205, 0.05);    border-color: rgba(221, 215, 205, 0.35);    color: $white;  }}.btn-outline-danger {  color: $terracotta;  border-color: $terracotta-border;  &:hover,  &:focus {    background: $terracotta-dim;    color: #d88870;    border-color: rgba(196, 112, 85, 0.4);  }}.btn-link {  color: $green-bright;}// Alerts for dark.alert { border-radius: $border-radius; }.alert-info {  background: rgba(126, 170, 184, 0.08);  border-color: rgba(126, 170, 184, 0.2);  color: #9ec5d2;}.alert-warning {  background: $amber-dim;  border-color: $amber-border;  color: #ddc06a;}.alert-danger {  background: $terracotta-dim;  border-color: $terracotta-border;  color: #e38871;}.alert-success {  background: $green-dim;  border-color: $green-border;  color: $green-bright;}// Forms.form-control,.form-select {  background-color: $input-bg;  border-color: $input-border-color;  color: $input-color;  &:focus {    background-color: $input-focus-bg;  }}.form-floating > label {  color: #665f56;}// Badges.badge {  font-weight: 500;  letter-spacing: 0.04em;  padding: 0.35em 0.55em;}// Pagination.page-link {  background: $card-bg;  border-color: $card-border-color;  color: $gray-300;  &:hover {    background: #1a1812;    border-color: $green-border;    color: $green-bright;  }}.page-item.active .page-link {  background: $green-dim;  border-color: $green-border;  color: $green-bright;}.page-item.disabled .page-link {  background: $card-bg;  border-color: $card-border-color;  color: #4a443c;}// Accordion tweaks.accordion-button {  font-family: $font-family-monospace;  font-size: 0.85rem;  letter-spacing: 0.02em;  color: $gray-200;  &:not(.collapsed) {    box-shadow: inset 0 -1px 0 rgba(107, 158, 120, 0.12);  }}.accordion-body {  font-size: 0.88rem;  color: $gray-300;  line-height: 1.7;  textarea.form-control {    font-family: $font-family-monospace;    font-size: 0.78rem;    background: #090806;    color: $green-bright;    border-color: rgba(107, 158, 120, 0.15);  }}
modified analytics/templates/403.html
@@ -6,15 +6,17 @@{% block description %}You are not allowed to access this page.{% endblock %}{% block breadcrumbs_wrapper %}{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container">  <div class="row">    <div class="col text-center py-5">      <h1 class="display-1">403</h1>      <p>You are not allowed to access this page.</p>    <div class="col text-center py-5 my-5">      <div class="section-label mb-3" style="justify-content:center;">forbidden</div>      <h1 class="display-1 text-white fw-bolder mb-3" style="letter-spacing: -0.02em;">403</h1>      <p class="text-muted">You are not allowed to access this page.</p>      <a href="/" class="btn btn-outline-light mt-3">Home →</a>    </div>  </div></div>
modified analytics/templates/404.html
@@ -3,18 +3,20 @@{% block title %}404{% endblock %}{% block description %}That means the page you are looking for doesn't exist.{% endblock %}{% block description %}The page you are looking for doesn't exist.{% endblock %}{% block breadcrumbs_wrapper %}{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container">  <div class="row">    <div class="col text-center py-5">      <h1 class="display-1">404</h1>      <p>That means the page you are looking for doesn't exist.</p>    <div class="col text-center py-5 my-5">      <div class="section-label mb-3" style="justify-content:center;">not found</div>      <h1 class="display-1 text-white fw-bolder mb-3" style="letter-spacing: -0.02em;">404</h1>      <p class="text-muted">The page you're looking for doesn't exist — or was never routed.</p>      <a href="/" class="btn btn-outline-light mt-3">Home →</a>    </div>  </div></div>
modified analytics/templates/500.html
@@ -6,15 +6,17 @@{% block description %}Something went wrong.{% endblock %}{% block breadcrumbs_wrapper %}{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container">  <div class="row">    <div class="col text-center py-5">      <h1 class="display-1">500</h1>      <p>Something went wrong.</p>    <div class="col text-center py-5 my-5">      <div class="section-label mb-3" style="justify-content:center;">internal error</div>      <h1 class="display-1 text-white fw-bolder mb-3" style="letter-spacing: -0.02em;">500</h1>      <p class="text-muted">Something went wrong on our end. Try again in a moment.</p>      <a href="/" class="btn btn-outline-light mt-3">Home →</a>    </div>  </div></div>
modified analytics/templates/base.html
@@ -14,6 +14,8 @@  <base href="{{ BASE_URL }}">  {% endif %}  <link rel="icon" type="image/svg+xml" href="{% url 'favicon' %}">  {% block extra_head %}{% endblock %}  <link href="{% static 'base.css' %}" rel="stylesheet">
@@ -26,29 +28,57 @@  {% include 'includes/messages.html' %}  {% block nav %}  <nav class="navbar bg-purple-900 d-print-none">  <nav class="navbar navbar-dark navbar-expand-md d-print-none">    <div class="container">      <a class="navbar-brand text-white" href="/">        <span class="display-1 fs-4 text-white text-decoration-none text-uppercase my-0">Analytics</span>      </a>      {% if user.is_authenticated %}      <form method="post" action="{% url 'logout' %}" class="d-inline">        {% csrf_token %}        <button type="submit" class="btn btn-sm btn-outline-light">          Logout        </button>      </form>      {% else %}      <a href="/accounts/login/" class="btn btn-sm btn-outline-light">        Login      <a class="navbar-brand" href="/">        <span class="logo" aria-hidden="true">          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">            <rect x="6"  y="38" width="10" height="22" rx="1.5" fill="#6b9e78"/>            <rect x="20" y="28" width="10" height="32" rx="1.5" fill="#6b9e78"/>            <rect x="34" y="18" width="10" height="42" rx="1.5" fill="#6b9e78"/>            <rect x="48" y="8"  width="10" height="52" rx="1.5" fill="#6b9e78"/>            <rect x="48" y="8"  width="10" height="6"  rx="1.5" fill="#c9a84c"/>          </svg>        </span>        <span class="brand-text">Analytics</span>        <span class="brand-dot" aria-hidden="true"></span>      </a>      {% endif %}      <button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">        <span class="navbar-toggler-icon"></span>      </button>      <div class="collapse navbar-collapse" id="navMain">        <ul class="navbar-nav ms-3">          {% if user.is_authenticated %}          <li class="nav-item"><a class="nav-link {% if request.path == '/properties/' %}active{% endif %}" href="/properties/">Properties</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/accounts/profile/' %}active{% endif %}" href="/accounts/profile/">Profile</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/documentation/' %}active{% endif %}" href="/documentation/">Docs</a></li>          {% else %}          <li class="nav-item"><a class="nav-link {% if request.path == '/' %}active{% endif %}" href="/">Home</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/documentation/' %}active{% endif %}" href="/documentation/">Docs</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/changelog/' %}active{% endif %}" href="/changelog/">Changelog</a></li>          {% endif %}        </ul>        <ul class="navbar-nav ms-auto">          {% if user.is_authenticated %}          <li class="nav-item d-flex align-items-center">            <form method="post" action="{% url 'logout' %}" class="d-inline">              {% csrf_token %}              <button type="submit" class="btn btn-sm btn-outline-light">Logout</button>            </form>          </li>          {% else %}          <li class="nav-item d-flex align-items-center">            <a href="/accounts/login/" class="btn btn-sm btn-outline-light">Login</a>          </li>          {% endif %}        </ul>      </div>    </div>  </nav>  {% endblock %}  {% block breadcrumb_wrapper %}  <div class="bg-purple-800 py-2 d-print-none">  <div class="breadcrumb-bar py-2 overflow-auto d-print-none">    <div class="container">      {% block breadcrumbs %}{% endblock %}    </div>
@@ -60,23 +90,27 @@  </main>  {% block footer %}  <footer class="mt-4 bg-black text-light d-print-none">  <footer class="d-print-none">    <div class="container">      <div class="row">        <div class="col-12 col-md-12 col-lg-4 mb-5 mb-lg-0">          <div class="h5 font-monospace mb-3">Analytics</div>          <p>Made by <a href="https://isaacbythewood.com/" class="text-light">Isaac Bythewood</a>, simple opinionated analytics for people who want to host their own.</p>      <div class="row links">        <div class="col-12 col-lg-6 mb-4 mb-lg-0">          <div class="h5 mb-3">// Analytics</div>          <p>Self-hosted web analytics by <a href="https://isaacbythewood.com/">Isaac Bythewood</a>.            Page views, clicks, scrolls, sessions, custom events. GeoIP maps,            UTM attribution, PDF reports. No cookies you don't control, no data            pipeline you can't audit.</p>        </div>        <div class="col-6 col-md-4 offset-lg-4 col-lg-2">          <div class="h5 font-monospace mb-3">Pages</div>        <div class="col-6 col-lg-2 offset-lg-2">          <div class="h5 mb-3">// Pages</div>          <ul class="list-unstyled">            <li class="mb-2"><a href="/" class="link-footer">Home</a></li>            <li class="mb-2"><a href="/documentation/" class="link-footer">Documentation</a></li>            <li class="mb-2"><a href="/changelog/" class="link-footer">Changelog</a></li>            <li class="mb-2"><a href="https://github.com/overshard/analytics" class="link-footer" target="_blank">Source</a></li>          </ul>        </div>        <div class="col-6 col-md-4 col-lg-2">          <div class="h5 font-monospace mb-3">Accounts</div>        <div class="col-6 col-lg-2">          <div class="h5 mb-3">// Accounts</div>          <ul class="list-unstyled">            {% if user.is_authenticated %}            <li class="mb-2"><a href="/properties/" class="link-footer">Properties</a></li>
@@ -97,21 +131,27 @@    </div>  </footer>  <div class="bg-dark py-2 d-print-none">  <div class="footer-bar d-print-none">    <div class="container">      <div class="row">        <div class="col-sm-6 d-flex align-items-center justify-content-center justify-content-sm-start mt-3 mt-sm-0 order-1 order-sm-0">          <small class="text-light">            &copy; {% now 'Y' %} Isaac Bythewood. Some rights reserved.          </small>      <div class="row align-items-center">        <div class="col-sm-6 d-flex align-items-center justify-content-center justify-content-sm-start order-1 order-sm-0">          <small>&copy; {% now 'Y' %} Isaac Bythewood · Some rights reserved</small>        </div>        <div class="col-sm-6 d-flex justify-content-center justify-content-sm-end">          <a href="https://github.com/overshard/analytics" target="_blank" class="text-light fs-3 d-flex align-items-center" aria-label="GitHub">            <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="bi bi-github" viewBox="0 0 16 16">        <div class="col-sm-6 d-flex justify-content-center justify-content-sm-end py-3 py-sm-0">          <a href="https://github.com/overshard/analytics" target="_blank" class="footer-bar-link" aria-label="GitHub">            <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">              <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>            </svg>          </a>          <a href="https://isaacbythewood.com/" target="_blank" class="logo shadow ms-4" aria-label="Isaac Bythewood"></a>          <a href="https://isaacbythewood.com/" target="_blank" class="logo logo-sm ms-3" aria-label="Isaac Bythewood">            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">              <rect x="6"  y="38" width="10" height="22" rx="1.5" fill="#6b9e78"/>              <rect x="20" y="28" width="10" height="32" rx="1.5" fill="#6b9e78"/>              <rect x="34" y="18" width="10" height="42" rx="1.5" fill="#6b9e78"/>              <rect x="48" y="8"  width="10" height="52" rx="1.5" fill="#6b9e78"/>              <rect x="48" y="8"  width="10" height="6"  rx="1.5" fill="#c9a84c"/>            </svg>          </a>        </div>      </div>    </div>
modified analytics/templates/registration/logged_out.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
@@ -13,8 +13,13 @@{% block main %}<div class="container my-5">  <h1>{{ title }}</h1>  <p>You've signed out, you can sign in again if you want here:</p>  <a href="{% url 'login' %}" class="btn btn-primary">Login</a>  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">session · terminated</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">You've signed out. Sign in again to get back to your properties.</p>      <a href="{% url 'login' %}" class="btn btn-primary">Login →</a>    </div>  </div></div>{% endblock %}
modified analytics/templates/registration/login.html
@@ -3,7 +3,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
@@ -12,87 +12,62 @@{% endblock %}{% block nav %}{% endblock %}{% block main %}<div class="auth-shell">  <div class="auth-form-col">    <div class="auth-form-wrap">      <div class="section-label mb-2">authenticate</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em; font-size: 1.9rem;">{{ title }}</h1>      <p class="text-muted small mb-4">Access is invite-only. Contact the operator for credentials.</p>      {% if form.errors and not form.non_field_errors %}      <div class="alert alert-warning py-2 small">        {% if form.errors.items|length == 1 %}{% translate "Please correct the error below." %}{% else %}{% translate "Please correct the errors below." %}{% endif %}      </div>      {% endif %}{% block breadcrumb_wrapper %}{% endblock %}      {% if form.non_field_errors %}      {% for error in form.non_field_errors %}      <div class="alert alert-danger py-2 small">{{ error }}</div>      {% endfor %}      {% endif %}      {% if user.is_authenticated %}      <div class="alert alert-warning py-2 small">        {% blocktranslate trimmed %}          You are authenticated as {{ username }}, but are not authorized to access this page.        {% endblocktranslate %}      </div>      {% endif %}{% block main %}<div class="container-fluid">  <div class="row">    <div class="col-12 col-md-8 col-lg-6 vh-100 d-flex align-items-center">      <div class="container my-5">        <div class="row">          <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">            <h1>{{ title }}</h1>            <p>You have to be invited to use this service, please contact the site              owner to get a unique username and password.</p>          </div>      <form method="POST" action="{% url 'login' %}">        {% csrf_token %}        <input type="hidden" name="next" value="{{ next }}" />        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" name="username" id="id_username" placeholder="Username" value="{{ form.username.value|default:'' }}" required autofocus />          <label for="id_username">Username</label>        </div>        <div class="row">          <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">            {% if form.errors and not form.non_field_errors %}              <p class="errornote">                {% if form.errors.items|length == 1 %}                  {% translate "Please correct the error below." %}                {% else %}                  {% translate "Please correct the errors below." %}                {% endif %}              </p>            {% endif %}            {% if form.non_field_errors %}              {% for error in form.non_field_errors %}                <p class="errornote">                  {{ error }}                </p>              {% endfor %}            {% endif %}            {% if user.is_authenticated %}            <p class="errornote">            {% blocktranslate trimmed %}              You are authenticated as {{ username }}, but are not authorized to              access this page. Would you like to login to a different account?            {% endblocktranslate %}            </p>            {% endif %}          </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.password.errors %}is-invalid{% endif %}" name="password" id="id_password" placeholder="Password" required />          <label for="id_password">Password</label>        </div>        <div class="row">          <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">            <form method="POST" action="{% url 'login' %}">              {% csrf_token %}              <input type="hidden" name="next" value="{{ next }}" />              <div class="form-floating mb-3">                <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" name="username" id="id_username" placeholder="Username" value="{{ form.username.value|default:'' }}" required autofocus />                <label for="id_username" class="form-label">Username</label>              </div>              <div class="form-floating mb-3">                <input type="password" class="form-control {% if form.password.errors %}is-invalid{% endif %}" name="password" id="id_password" placeholder="Password" required />                <label for="id_password" class="form-label">Password</label>              </div>              <div class="row">                <div class="col">                  <button type="submit" class="btn  btn-primary">                    Login                  </button>                  <a href="{% url 'password_reset' %}" class="btn btn-link float-end">                    Forgot password?                  </a>                </div>              </div>            </form>          </div>        <div class="d-flex justify-content-between align-items-center mt-4">          <button type="submit" class="btn btn-primary">Login →</button>          <a href="{% url 'password_reset' %}" class="btn btn-link small">Forgot password?</a>        </div>      </form>    </div>  </div>  <div class="auth-visual-col">    <div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; padding: 2rem; z-index: 3;">      <div class="terminal-block" style="max-width: 380px; width: 100%;">        <span class="t-line"><span class="t-comment"># analytics · collector</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">whoami</span></span>        <span class="t-line"><span class="t-val">anonymous</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">login --mode=interactive</span></span>        <span class="t-line"><span class="t-key">awaiting</span>=<span class="t-val">credentials</span> <span class="t-cursor"></span></span>      </div>    </div>    <div class="col-md-4 col-lg-6 d-none d-md-block vh-100 login-hero" style="--background-image: url({% static 'images/home-hero.webp' %});"></div>  </div></div>{% endblock %}{% block footer %}{% endblock %}
modified analytics/templates/registration/password_change_done.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'profile' %}">Profile</a></li>
@@ -15,10 +15,12 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>Your password was changed.</p>      <p><a href="{% url 'profile' %}">Back to profile</a></p>    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · security</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <div class="alert alert-success py-2 small">Your password was changed.</div>      <a href="{% url 'profile' %}" class="btn btn-outline-light">Back to profile →</a>    </div>  </div></div>{% endblock %}
modified analytics/templates/registration/password_change_form.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'profile' %}">Profile</a></li>
@@ -15,50 +15,39 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>Forgotten your password? Enter your email address below, and we'll        email instructions for setting a new one.</p>    </div>  </div>  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · security</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Enter your current password, then your new password twice to confirm.</p>      <form method="POST">        {% csrf_token %}        {% if form.errors %}          <p>{% if form.errors.items|length == 1 %}Please correct the error            below.{% else %}Please correct the errors below.{% endif %}</p>        <div class="alert alert-warning py-2 small">          {% if form.errors.items|length == 1 %}Please correct the error below.{% else %}Please correct the errors below.{% endif %}        </div>        {% endif %}        <p>Please enter your old password, for security's sake, and then enter          your new password twice so we can verify you typed it in          correctly.</p>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.old_password.errors %}is-invalid{% endif %}" name="old_password" id="id_old_password" placeholder="New password" autocomplete="new-password" required autofocus />          <label for="id_old_password" class="form-label">Old password</label>          <input type="password" class="form-control {% if form.old_password.errors %}is-invalid{% endif %}" name="old_password" id="id_old_password" placeholder="Old password" autocomplete="current-password" required autofocus />          <label for="id_old_password">Old password</label>          {{ form.old_password.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password1.errors %}is-invalid{% endif %}" name="new_password1" id="id_new_password1" placeholder="New password" autocomplete="new-password" required />          <label for="id_new_password1" class="form-label">New password</label>          <label for="id_new_password1">New password</label>          {% if form.new_password1.help_text %}          <div class="border py-2 pb-0 rounded rounded-1 mt-1 small">{{ form.new_password1.help_text|safe }}</div>          <div class="alert alert-info py-2 px-3 mt-2 small">{{ form.new_password1.help_text|safe }}</div>          {% endif %}          {{ form.new_password1.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}" name="new_password2" id="id_new_password2" placeholder="New password" autocomplete="new-password" required />          <label for="id_new_password2" class="form-label">New password confirmation</label>          {% if form.new_password2.help_text %}          <div class="border py-2 pb-0 rounded rounded-1 mt-1 small">{{ form.new_password2.help_text|safe }}</div>          {% endif %}          <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}" name="new_password2" id="id_new_password2" placeholder="Confirm new password" autocomplete="new-password" required />          <label for="id_new_password2">Confirm new password</label>          {{ form.new_password2.errors }}        </div>        <button type="submit" class="btn btn-primary">          Change my password        </button>        <button type="submit" class="btn btn-primary">Change password →</button>      </form>    </div>  </div>
modified analytics/templates/registration/password_reset_complete.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>
@@ -15,10 +15,12 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>Your password has been set. You may go ahead and log in now.</p>      <p><a href="{{ login_url }}">Login</a></p>    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · recovery</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <div class="alert alert-success py-2 small">Your password has been set. You can log in now.</div>      <a href="{{ login_url }}" class="btn btn-primary">Login →</a>    </div>  </div></div>{% endblock %}
modified analytics/templates/registration/password_reset_confirm.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>
@@ -15,41 +15,34 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>    </div>  </div>  {% if validlink %}  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <p>Please enter your new password twice so we can verify you typed it in        correctly.</p>    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · recovery</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      {% if validlink %}      <p class="text-muted small mb-4">Enter your new password twice to confirm.</p>      <form method="POST">        {% csrf_token %}        <input type="hidden" autocomplete="username" value="{{ form.user.get_username }}" />        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password1.errors %}is-invalid{% endif %}" name="new_password1" id="id_new_password1" placeholder="New password" autocomplete="new-password" required autofocus />          <label for="id_new_password1" class="form-label">New password</label>          <label for="id_new_password1">New password</label>          {{ form.new_password1.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}" name="new_password2" id="id_new_password2" placeholder="Confirm password" autocomplete="new-password" required />          <label for="id_new_password2" class="form-label">Confirm password</label>          <label for="id_new_password2">Confirm password</label>          {{ form.new_password2.errors }}        </div>        <button type="submit" class="btn btn-primary">          Change my password        </button>        <button type="submit" class="btn btn-primary">Change password →</button>      </form>      {% else %}      <div class="alert alert-danger py-2 small">        This password reset link is invalid — it may have already been used. Request a new link from the        <a href="{% url 'password_reset' %}">password reset form</a>.      </div>      {% endif %}    </div>  </div>  {% else %}  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">    <p>The password reset link was invalid, possibly because it has already been      used. Please request a new password reset.</p>    </div>  </div>  {% endif %}</div>{% endblock %}
modified analytics/templates/registration/password_reset_done.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>
@@ -15,12 +15,10 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>We've emailed you instructions for setting your password, if an account        exists with the email you entered. You should receive them shortly.</p>      <p>If you don't receive an email, please make sure you've entered the        address you registered with, and check your spam folder.</p>    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · recovery</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small">We've emailed reset instructions if an account matched that address. Check your inbox — and your spam folder.</p>    </div>  </div></div>
modified analytics/templates/registration/password_reset_form.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>
@@ -15,23 +15,18 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>Forgotten your password? Enter your email address below, and we'll email        instructions for setting a new one.</p>    </div>  </div>  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · recovery</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Enter your email and we'll send a link to set a new password.</p>      <form method="POST">        {% csrf_token %}        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="Email" value="{{ form.email.value|default:'' }}" required autofocus />          <label for="id_email" class="form-label">Email</label>          <input type="email" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="Email" value="{{ form.email.value|default:'' }}" required autofocus />          <label for="id_email">Email</label>        </div>        <button type="submit" class="btn btn-primary">          Reset my password        </button>        <button type="submit" class="btn btn-primary">Send reset link →</button>      </form>    </div>  </div>
modified bun.lock
@@ -4,6 +4,7 @@  "workspaces": {    "": {      "dependencies": {        "@fontsource/monaspace-argon": "^5.2.5",        "@popperjs/core": "^2.11.5",        "bootstrap": "^5.1.3",        "chart.js": "^4.5.1",
@@ -72,6 +73,8 @@    "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],    "@fontsource/monaspace-argon": ["@fontsource/monaspace-argon@5.2.5", "", {}, "sha512-EJ+jq1Smm3BB+8RK/gwB1uzjrSKdycZkKm8OZGCYvqJiTChKcGe7b2lmj0PmQbb/+lAEdCBIAcFLlPaWhtRANA=="],    "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],    "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
modified collector/static_src/scripts/collector.js
@@ -151,9 +151,28 @@  });  // send page_leave events  // add var for when the page was loaded  var page_loaded = new Date().getTime();  window.addEventListener("beforeunload", function () {  // Track only *visible* time so idle/background tabs don't inflate the metric.  // Accumulate elapsed time in chunks bounded by visibilitychange, then flush  // on pagehide (more reliable than beforeunload on Safari/mobile).  var visible_since = document.visibilityState === "visible" ? new Date().getTime() : null;  var visible_accumulated = 0;  var page_leave_sent = false;  document.addEventListener("visibilitychange", function () {    var now = new Date().getTime();    if (document.visibilityState === "hidden" && visible_since !== null) {      visible_accumulated += now - visible_since;      visible_since = null;    } else if (document.visibilityState === "visible" && visible_since === null) {      visible_since = now;    }  });  function send_page_leave() {    if (page_leave_sent) return;    page_leave_sent = true;    var now = new Date().getTime();    var time_on_page = visible_accumulated + (visible_since !== null ? now - visible_since : 0);    window.collectorQueue.push({      collector_id: window.collectorId,      event: "page_leave",
@@ -161,8 +180,10 @@        user_id: collectorUserId,        url: window.location.pathname,        title: document.title,        time_on_page: new Date().getTime() - page_loaded,        time_on_page: time_on_page,      },    });  });  }  window.addEventListener("pagehide", send_page_leave);})();
modified package.json
@@ -6,6 +6,7 @@    "build": "vite build"  },  "dependencies": {    "@fontsource/monaspace-argon": "^5.2.5",    "@popperjs/core": "^2.11.5",    "bootstrap": "^5.1.3",    "chart.js": "^4.5.1",
deleted pages/static_src/images/home-hero.webp

binary file

modified pages/static_src/index.js
@@ -1,3 +1 @@import "./styles/pages.css";import "./images/home-hero.webp";import "./styles/pages.scss";
deleted pages/static_src/styles/pages.css
@@ -1,10 +0,0 @@.home-hero {  background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)),    var(--background-image);  background-size: cover;  background-position: top center;  background-repeat: no-repeat;  overflow: hidden;  height: calc(100vh - 50px);  max-height: 1080px;}
added pages/static_src/styles/pages.scss
@@ -0,0 +1,205 @@@use "../../../analytics/static_src/styles/variables" as *;// ── Home hero ──.hero {  position: relative;  background: #090806;  border-bottom: 1px solid rgba(107, 158, 120, 0.08);  padding: 4rem 0 4rem;  .hero-eyebrow {    display: inline-flex;    align-items: center;    gap: 0.55rem;    font-family: $font-family-monospace;    font-size: 0.72rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: rgba(201, 168, 76, 0.85);    background: rgba(201, 168, 76, 0.06);    border: 1px solid $amber-border;    padding: 0.35rem 0.75rem;    border-radius: 999px;    .eyebrow-dot {      width: 6px; height: 6px; border-radius: 50%;      background: $green;    }  }  .hero-title {    font-size: clamp(2rem, 5vw, 3.2rem);    font-weight: 700;    line-height: 1.1;    color: $white;    letter-spacing: -0.01em;    margin-top: 1.5rem;    .accent { color: $green-bright; }  }  .hero-sub {    color: #a09890;    font-size: 1rem;    line-height: 1.7;    margin-top: 1.25rem;    max-width: 620px;  }  .hero-ctas {    display: flex;    gap: 0.75rem;    flex-wrap: wrap;    margin-top: 1.75rem;    .btn {      font-size: 0.82rem;      letter-spacing: 0.04em;      padding: 0.6rem 1.1rem;    }  }}// ── Feature tiles ──.feature {  background: #13120e;  border: 1px solid rgba(107, 158, 120, 0.08);  border-radius: $border-radius;  padding: 1.5rem;  height: 100%;  transition: border-color 250ms ease;  &:hover { border-color: rgba(107, 158, 120, 0.22); }  .feature-label {    font-family: $font-family-monospace;    font-size: 0.68rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: rgba(201, 168, 76, 0.75);  }  .feature-title {    color: $white;    font-size: 1.05rem;    font-weight: 600;    margin-top: 0.5rem;  }  .feature-desc {    color: #a09890;    font-size: 0.88rem;    line-height: 1.65;    margin-top: 0.6rem;    margin-bottom: 0;  }}// ── Stat strip ──.stat-strip {  background: #0e0d0a;  border-top: 1px solid rgba(107, 158, 120, 0.06);  border-bottom: 1px solid rgba(107, 158, 120, 0.06);  padding: 1.5rem 0;  .stat { text-align: center; padding: 0 1rem; }  .stat-label {    font-family: $font-family-monospace;    font-size: 0.66rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: #665f56;  }  .stat-value {    font-size: 1.75rem;    font-weight: 700;    color: $green-bright;    letter-spacing: -0.01em;    line-height: 1.2;    margin-top: 0.2rem;  }}// ── Changelog ──.changelog-entry {  display: grid;  grid-template-columns: 140px 1fr;  gap: 1.5rem;  padding: 1rem 0;  border-bottom: 1px solid rgba(107, 158, 120, 0.06);  @media (max-width: 575px) {    grid-template-columns: 1fr;    gap: 0.4rem;  }  .changelog-date {    font-family: $font-family-monospace;    font-size: 0.78rem;    color: rgba(201, 168, 76, 0.75);    letter-spacing: 0.04em;  }  ul { margin: 0; padding-left: 1.1rem; color: $gray-200; font-size: 0.9rem; }  li { margin-bottom: 0.3rem; }  code {    background: rgba(107, 158, 120, 0.08);    color: $green-bright;    padding: 0.05rem 0.35rem;    border-radius: 2px;    font-size: 0.85em;  }}// ── Docs (accordion + copy blocks) ──.docs-lead {  color: #a09890;  font-size: 0.95rem;  line-height: 1.75;  max-width: 720px;}.docs-accordion {  .accordion-item {    background: #13120e;    border: 1px solid rgba(107, 158, 120, 0.12);    margin-bottom: 0.5rem;    border-radius: $border-radius !important;    overflow: hidden;  }  .accordion-button {    background: #13120e;    font-family: $font-family-monospace;    font-size: 0.85rem;    letter-spacing: 0.02em;    color: $gray-200;    &::before {      content: '$ ';      color: $amber;      margin-right: 0.4rem;    }    &:not(.collapsed) {      background: rgba(107, 158, 120, 0.06);      color: $green-bright;      box-shadow: inset 0 -1px 0 rgba(107, 158, 120, 0.12);    }    &:focus {      box-shadow: 0 0 0 2px rgba(107, 158, 120, 0.15);    }  }}
modified pages/templates/pages/changelog.html
@@ -1,4 +1,5 @@{% extends 'base.html' %}{% load static %}{% block extra_head %}
@@ -6,8 +7,13 @@{% endblock %}{% block extra_css %}<link href="{% static 'pages.css' %}" rel="stylesheet">{% endblock %}{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
@@ -19,106 +25,92 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>New features and changes to analytics, sometimes I'll tease something coming soon.</p>    <div class="col-lg-8 offset-lg-2">      <div class="section-label mb-2">release log</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted">Shipped changes, schema tweaks, and the occasional tease for something coming.</p>    </div>  </div>  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2025-10-04</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Swap browser automation to Playwright with the bundled Chromium runtime</li>            <li>Switch Docker base image to Ubuntu 24.04 LTS</li>          </ul>        </div>  <div class="row mt-4">    <div class="col-lg-8 offset-lg-2">      <div class="changelog-entry">        <div class="changelog-date">2026-04-17</div>        <ul>          <li>Redesign the whole dashboard with a warm-earth palette — mossy forest green and amber on deep charcoal, Monaspace Argon throughout, aligned to the <a href="https://status.bythewood.me/">status</a> app</li>          <li>Rework the property dashboard with metric tiles, period-over-period deltas, chart panels, and compact ranked top-lists instead of Bootstrap list groups</li>          <li>Rework the properties listing page with search, live-signal tiles, and a dense row layout</li>          <li>Swap the 📊 emoji favicon for a themed SVG bar-chart mark matching the in-app logo</li>          <li>Add a <code>/documentation/</code> link into the main nav so the collector snippet docs are easier to find</li>          <li>Restyle the collector-tag modal and accordion docs as terminal-style code blocks</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2025-10-04</div>        <ul>          <li>Swap browser automation to Playwright with the bundled Chromium runtime</li>          <li>Switch Docker base image to Ubuntu 24.04 LTS</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-07-26</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Add URL property filtering</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2025-10-03</div>        <ul>          <li>Migrate Python package management from Pipenv/pyenv to <code>uv</code></li>          <li>Switch frontend build from Yarn + Webpack to Bun + Vite</li>          <li>Fix d3/datamaps strict-mode crash under ESM so the US-state map renders again</li>          <li>Fix logout 405 by switching to a POST form + CSRF (Django 5 <code>LogoutView</code> is POST-only)</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-07-06</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Change IPInfo to local MaxMind DB for IP lookup</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-26</div>        <ul>          <li>Add URL property filtering — click any top-page row to scope the whole dashboard</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-06-18</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Change all tracking terminology into collecting</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-06</div>        <ul>          <li>Change IPInfo to local MaxMind DB for IP → geo lookup (no more third-party API)</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-06-05</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Add pdf report generator</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2022-06-18</div>        <ul>          <li>Rename all "tracking" terminology to "collecting" — we're not spyware</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-05-28</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Add chromium screenshot utility</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2022-06-05</div>        <ul>          <li>Add PDF report generator — hit <code>?report</code> on any property page to export</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-05-28</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Add open graph and meta data improvements</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2022-05-28</div>        <ul>          <li>Add headless Chromium screenshot utility</li>          <li>Add Open Graph / Twitter card metadata</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-05-22</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Add previous period comparisons</li>            <li>Add a sharable link to property pages</li>            <li>Add UTM query param collecting</li>            <li>Add platform doughnut graph</li>            <li>Add date range selection</li>            <li>Add custom card selection</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2022-05-22</div>        <ul>          <li>Add previous-period comparisons on every metric card</li>          <li>Add a shareable public-link toggle for property pages</li>          <li>Add UTM query-param collection (source, medium, campaign)</li>          <li>Add platform doughnut graph</li>          <li>Add date-range selection with presets and a custom picker</li>          <li>Add custom event cards — pin any custom event as a headline metric</li>        </ul>      </div>    </div>
modified pages/templates/pages/documentation.html
@@ -1,4 +1,5 @@{% extends 'base.html' %}{% load static %}{% block extra_head %}
@@ -6,8 +7,13 @@{% endblock %}{% block extra_css %}<link href="{% static 'pages.css' %}" rel="stylesheet">{% endblock %}{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
@@ -19,89 +25,109 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>Some examples of how to use analytics to collect various events on        websites, servers, and apps.</p>    <div class="col-lg-8 offset-lg-2">      <div class="section-label mb-2">collector · recipes</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="docs-lead">        All events are sent as JSON to a single <code>POST /collect/</code>        endpoint. The site tag pushes page_view, click, scroll, and page_leave        automatically — everything else is a one-liner on        <code>collectorQueue</code>, or a raw HTTP call from any language.      </p>    </div>  </div>  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <div class="accordion" id="accordionExample">  <div class="row mt-4">    <div class="col-lg-8 offset-lg-2">      <div class="accordion docs-accordion" id="docsAccordion">        <div class="accordion-item">          <h2 class="accordion-header" id="headingOne">            <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">              Collect custom clicks            </button>          </h2>          <div id="collapseOne" class="accordion-collapse collapse show" aria-labelledby="headingOne" data-bs-parent="#accordionExample">          <div id="collapseOne" class="accordion-collapse collapse show" aria-labelledby="headingOne" data-bs-parent="#docsAccordion">            <div class="accordion-body">              <p>To collect a custom click you can add the following to the anchor                tag:</p>              <textarea class="form-control" rows="4" readonly>onclick="collectorQueue.push({event: 'custom_link_click_event_name'})"</textarea>              <p>Attach a handler to any interactive element. The event name is                whatever you want to measure later.</p>              <textarea class="codebox" rows="3" readonly>onclick="collectorQueue.push({event: 'signup_cta_click'})"</textarea>            </div>          </div>        </div>        <div class="accordion-item">          <h2 class="accordion-header" id="headingTwo">            <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTwo" aria-expanded="true" aria-controls="collapseTwo">            <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">              Collect custom page views            </button>          </h2>          <div id="collapseTwo" class="accordion-collapse collapse" aria-labelledby="headingTwo" data-bs-parent="#accordionExample">          <div id="collapseTwo" class="accordion-collapse collapse" aria-labelledby="headingTwo" data-bs-parent="#docsAccordion">            <div class="accordion-body">              <p>If you want to collect a custom page view, like a successful                checkout on the invoice page, you can add anywhere on the page:</p>              <textarea class="form-control" rows="4" readonly><script>collectorQueue.push({event: 'custom_page_view_event_name'});</script></textarea>              <p>Use this for SPA route changes, funnel checkpoints                ("invoice paid"), or any meaningful page-level moment. Push                once per visit:</p>              <textarea class="codebox" rows="3" readonly><script>collectorQueue.push({event: 'checkout_success'});</script></textarea>            </div>          </div>        </div>        <div class="accordion-item">          <h2 class="accordion-header" id="headingThree">            <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseThree" aria-expanded="true" aria-controls="collapseThree">              Collect custom data            <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseThree" aria-expanded="false" aria-controls="collapseThree">              Attach custom data to any event            </button>          </h2>          <div id="collapseThree" class="accordion-collapse collapse" aria-labelledby="headingThree" data-bs-parent="#accordionExample">          <div id="collapseThree" class="accordion-collapse collapse" aria-labelledby="headingThree" data-bs-parent="#docsAccordion">            <div class="accordion-body">              <p>All of our data gets stored in the database in a JSONField called                "data". If you want to send more details with events you can add                more data key-value pairs. As an example:</p>              <textarea class="form-control" rows="4" readonly><script>collectorQueue.push({event: 'admin_page_view', data: {user_segment: 'admins'}});</script></textarea>              <p>Every event is stored as JSON in a <code>data</code> field.                Add whatever keys you need — no migration required, query with                Django ORM <code>data__user_segment</code> lookups later.</p>              <textarea class="codebox" rows="3" readonly><script>collectorQueue.push({event: 'admin_page_view', data: {user_segment: 'admins'}});</script></textarea>            </div>          </div>        </div>        <div class="accordion-item">          <h2 class="accordion-header" id="headingFour">            <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseFour" aria-expanded="true" aria-controls="collapseFour">              Sending events to the server            <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseFour" aria-expanded="false" aria-controls="collapseFour">              Send events over raw HTTP            </button>          </h2>          <div id="collapseFour" class="accordion-collapse collapse" aria-labelledby="headingFour" data-bs-parent="#accordionExample">          <div id="collapseFour" class="accordion-collapse collapse" aria-labelledby="headingFour" data-bs-parent="#docsAccordion">            <div class="accordion-body">              <p>You can technically collect anything from anywhere by sending a                POST request to the server with JSON data in the body. Make sure                you include a valid collectorId, event, and data. As an                example:</p>              <textarea class="form-control" rows="4" readonly>curl -X POST -d '{"collector_id": "PROPERTY_COLLECTOR_ID", "event": "custom_app_event", "data": { "app_id": "4" }}' YOUR_COLLECTOR_SERVER</textarea>              <p>The collector endpoint is plain JSON — any HTTP client                will do. Include a valid <code>collector_id</code>,                <code>event</code>, and optional <code>data</code>.</p>              <textarea class="codebox" rows="4" readonly>curl -X POST -H 'Content-Type: application/json' \  -d '{"collector_id": "PROPERTY_COLLECTOR_ID", "event": "custom_app_event", "data": {"app_id": 4}}' \  YOUR_COLLECTOR_SERVER/collect/</textarea>            </div>          </div>        </div>        <div class="accordion-item">          <h2 class="accordion-header" id="headingFive">            <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseFive" aria-expanded="true" aria-controls="collapseFive">              Server side events            <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseFive" aria-expanded="false" aria-controls="collapseFive">              Server-side events (Python)            </button>          </h2>          <div id="collapseFive" class="accordion-collapse collapse" aria-labelledby="headingFive" data-bs-parent="#accordionExample">          <div id="collapseFive" class="accordion-collapse collapse" aria-labelledby="headingFive" data-bs-parent="#docsAccordion">            <div class="accordion-body">              <p>Sometimes you may want to collect events from a server and you                can send events using any http request library. For example, if                using "requests" on Python:</p>              <textarea class="form-control" rows="4" readonly>requests.post('YOUR_COLLECTOR_SERVER', json={'collector_id': 'PROPERTY_COLLECTOR_ID', 'event': 'custom_app_event', 'data': { 'app_id': '4' }})</textarea>              <p>Any backend, any library, any runtime. Here's a Python                example using <code>requests</code>:</p>              <textarea class="codebox" rows="5" readonly>import requestsrequests.post(    'YOUR_COLLECTOR_SERVER/collect/',    json={'collector_id': 'PROPERTY_COLLECTOR_ID',          'event': 'subscription_renewed',          'data': {'plan': 'pro', 'mrr': 49}})</textarea>            </div>          </div>        </div>      </div>    </div>  </div>
modified pages/templates/pages/home.html
@@ -16,87 +16,154 @@{% block main %}<div class="bg-dark mb-5 home-hero d-flex align-items-center" style="--background-image: url({% static 'images/home-hero.webp' %});"><section class="hero">  <div class="container">    <div class="row">      <div class="col-12 col-lg-10 col-xl-8">        <div class="fs-4 text-light">          <span class="badge bg-warning text-black">Alpha</span> Things are rapidly changing!      <div class="col-12 col-lg-7">        <span class="hero-eyebrow">          <span class="eyebrow-dot" aria-hidden="true"></span>          v0.9 · alpha · self-hosted        </span>        <h1 class="hero-title">          Website analytics for operators who host their <span class="accent">own pipeline</span>.        </h1>        <p class="hero-sub">          Page views, clicks, scrolls, sessions, and custom events. GeoIP maps,          UTM attribution, device and browser breakdowns, period-over-period          comparisons, and PDF reports. Collected through a single POST          endpoint you control — no third-party script, no hidden trackers,          no data leaving your infrastructure.        </p>        <div class="hero-ctas">          <a href="/accounts/login/" class="btn btn-primary">Access dashboard →</a>          <a href="/documentation/" class="btn btn-outline-light">Docs</a>          <a href="https://github.com/overshard/analytics" target="_blank" class="btn btn-outline-light">View source</a>        </div>        <h1 class="display-1 mt-4 text-light fw-bolder">Self-hosted website analytics</h1>        <p class="mb-0 text-white mt-4 fs-4">Made by <a href="https://isaacbythewood.com/" class="text-white">Isaac Bythewood</a>,          simple opinionated analytics for people who want to host their own.</p>        <div class="mt-4">          <a href="/accounts/login/" class="btn btn-light btn-lg me-3">            Login          </a>          <a href="https://github.com/overshard/analytics" target="_blank" class="btn btn-outline-light btn-lg">            Source code          </a>      </div>      <div class="col-12 col-lg-5 mt-5 mt-lg-0 d-flex align-items-center">        <div class="terminal-block w-100">          <span class="t-line"><span class="t-comment"># collector push</span></span>          <span class="t-line"><span class="t-prompt">site$</span><span class="t-out">POST /collect/</span></span>          <span class="t-line"><span class="t-key">event</span>=<span class="t-val">page_view</span> <span class="t-key">url</span>=<span class="t-val">/pricing</span></span>          <span class="t-line"><span class="t-key">referrer</span>=<span class="t-val">bing.com</span></span>          <span class="t-line"><span class="t-key">geo</span>=<span class="t-val">US · CA</span> <span class="t-key">ua</span>=<span class="t-val">firefox · linux</span></span>          <span class="t-line"><span class="t-prompt">site$</span> <span class="t-cursor"></span></span>        </div>      </div>    </div>  </div></div><div class="container mb-2">  <div class="row justify-content-center">    <div class="col-12 col-md-6 col-lg-4">      <div class="card bg-primary text-white text-center mb-3">        <div class="card-body">          <div class="display-5">{{ total_properties }}</div>          <div class="h5">Total properties</div></section><section class="stat-strip">  <div class="container">    <div class="row g-4">      <div class="col-6 col-md-3">        <div class="stat">          <div class="stat-label">properties</div>          <div class="stat-value">{{ total_properties }}</div>        </div>      </div>      <div class="col-6 col-md-3">        <div class="stat">          <div class="stat-label">events logged</div>          <div class="stat-value">{{ total_events }}</div>        </div>      </div>      <div class="col-6 col-md-3">        <div class="stat">          <div class="stat-label">operators</div>          <div class="stat-value">{{ total_users }}</div>        </div>      </div>      <div class="col-6 col-md-3">        <div class="stat">          <div class="stat-label">collecting since</div>          <div class="stat-value" style="font-size: 1.05rem;">            {% if first_event_created_at %}{{ first_event_created_at|date:"M j, Y" }}{% else %}—{% endif %}          </div>        </div>      </div>    </div>  </div></section><section class="container my-5 py-4">  <div class="row mb-4">    <div class="col-12">      <div class="section-label mb-2">capabilities</div>      <h2 class="h3 text-white fw-bolder" style="letter-spacing: -0.01em;">        Everything your marketing and product team needs in one dashboard.      </h2>    </div>  </div>  <div class="row g-4">    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">collection</div>        <div class="feature-title">One-line site tag</div>        <p class="feature-desc">          Drop a tiny async script in your <code>&lt;head&gt;</code>. It fires          session_start, page_view, click, scroll, and page_leave events to          a single POST endpoint. Custom events push onto the queue from any          frontend code.        </p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="card bg-primary text-white text-center mb-3">        <div class="card-body">          <div class="display-5">{{ total_events }}</div>          <div class="h5">Total events</div>        </div>      <div class="feature">        <div class="feature-label">attribution</div>        <div class="feature-title">UTM + referrer tracking</div>        <p class="feature-desc">          Break traffic down by source, medium, and campaign. Top referrers,          top pages, and top custom events are ranked in the dashboard with          period-over-period deltas baked in.        </p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="card bg-primary text-white text-center mb-3">        <div class="card-body">          <div class="display-5">{{ first_event_created_at|date:"M j, Y" }}</div>          <div class="h5">First event logged</div>        </div>      <div class="feature">        <div class="feature-label">geo + device</div>        <div class="feature-title">Maps, browsers, platforms</div>        <p class="feature-desc">          A US state choropleth, plus doughnut breakdowns for device,          browser, platform, and screen size. GeoIP runs against a local          MaxMind DB — IPs never leave your host.        </p>      </div>    </div>  </div></div><div class="container">  <div class="row justify-content-center">    <div class="col-12 col-md-6 col-lg-4 mb-4">      <div class="card bg-light h-100">        <div class="card-body">          <h2 class="card-title h5">Open source</h2>          <p class="card-text">The source code is available on GitHub and is            published under the simplified BSD license, do what you want with            it.</p>        </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">reports</div>        <div class="feature-title">Dashboard + PDF export</div>        <p class="feature-desc">          Flip any date range to a headless-Chromium PDF with one click.          Custom cards let you pin bespoke metrics. Share a property publicly          with one toggle — no login required for viewers.        </p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4 mb-4">      <div class="card bg-light h-100">        <div class="card-body">          <h2 class="card-title h5">Privacy friendly</h2>          <p class="card-text">The analytics data is stored on whatever            hosting service you put it on. You can store as much, or as            little, data as you want.</p>        </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">schema</div>        <div class="feature-title">JSON-first event store</div>        <p class="feature-desc">          Every event lands in a single JSONField. New attributes need no          migrations — send them from the client and query them with Django          ORM lookups. Bot traffic is filtered before it ever hits the DB.        </p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4 mb-4">      <div class="card bg-light h-100">        <div class="card-body">          <h2 class="card-title h5">First class mobile support</h2>          <p class="card-text">A responsive design that should work across all            platforms.</p>        </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">ownership</div>        <div class="feature-title">BSD, single container</div>        <p class="feature-desc">          Django + SQLite + one collector endpoint. Ships as a Docker image          behind your own reverse proxy. Your domain, your retention policy,          your compliance story. No vendor, no pixel, no lock-in.        </p>      </div>    </div>  </div></div></section>{% endblock %}
modified pages/views.py
@@ -43,8 +43,18 @@ def documentation(request):def favicon(request):    icon = "📊"    svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="80">{icon}</text></svg>'    # Rising-bars mark — a four-bar histogram in mossy green with an amber    # cap on the tallest column. Matches the in-app logo, evokes "aggregate    # counts over time" which is what Analytics actually tracks.    svg = (        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'        '<rect x="6"  y="38" width="10" height="22" rx="1.5" fill="#6b9e78"/>'        '<rect x="20" y="28" width="10" height="32" rx="1.5" fill="#6b9e78"/>'        '<rect x="34" y="18" width="10" height="42" rx="1.5" fill="#6b9e78"/>'        '<rect x="48" y="8"  width="10" height="52" rx="1.5" fill="#6b9e78"/>'        '<rect x="48" y="8"  width="10" height="6"  rx="1.5" fill="#c9a84c"/>'        "</svg>"    )    return HttpResponse(svg, content_type="image/svg+xml")
modified properties/queries.py
@@ -111,29 +111,31 @@ def standard_event_cards(events_filtered, events_filtered_prev):        "help_text": "An engaged user is a user more than 10 events collected for your selected date range.",    })    try:        total_time_on_page = events_filtered.filter(event="page_leave").aggregate(            total_time_on_page=models.Avg(                Cast("data__time_on_page", models.FloatField()) / 1000            )        )["total_time_on_page"]        avg_time_on_page = round(total_time_on_page, 2)    except TypeError:        avg_time_on_page = 0    try:        total_time_on_page_prev = events_filtered_prev.filter(event="page_leave").aggregate(            total_time_on_page=models.Avg(                Cast("data__time_on_page", models.FloatField()) / 1000            )        )["total_time_on_page"]        avg_time_on_page_prev = round(total_time_on_page_prev, 2)    except TypeError:        avg_time_on_page_prev = 0    # Cap time-on-page to filter out idle-tab outliers (left open for hours).    # Anything < 1s is likely bot/instant-exit; anything > 30min is almost    # certainly an idle/abandoned tab rather than real engagement.    TIME_ON_PAGE_MIN_S = 1    TIME_ON_PAGE_MAX_S = 30 * 60    def _avg_time_on_page(qs):        try:            avg = qs.filter(event="page_leave").annotate(                time_on_page_s=Cast("data__time_on_page", models.FloatField()) / 1000            ).filter(                time_on_page_s__gte=TIME_ON_PAGE_MIN_S,                time_on_page_s__lte=TIME_ON_PAGE_MAX_S,            ).aggregate(avg=models.Avg("time_on_page_s"))["avg"]            return round(avg, 2) if avg is not None else 0        except TypeError:            return 0    avg_time_on_page = _avg_time_on_page(events_filtered)    avg_time_on_page_prev = _avg_time_on_page(events_filtered_prev)    event_cards.append({        "name": "Avg. time on page",        "value": f"{avg_time_on_page}s",        "percent_change": round((avg_time_on_page - avg_time_on_page_prev) / avg_time_on_page_prev * 100) if avg_time_on_page_prev else 0,        "help_text": "The average amount of time a user spends on each page of your site.",        "help_text": "Average time a user spends on each page. Sessions over 30 minutes are excluded as idle.",    })    return event_cards
modified properties/static_src/scripts/property_graphs.js
@@ -1,27 +1,52 @@import Chart from "chart.js/auto";const backgroundColors = [  "rgba(13, 110, 253, 0.4)",  "rgba(102, 16, 242, 0.4)",  "rgba(111, 66, 193, 0.4)",  "rgba(214, 51, 132, 0.4)",  "rgba(220, 53, 69, 0.4)",  "rgba(253, 126, 20, 0.4)",  "rgba(255, 193, 7, 0.4)",  "rgba(25, 135, 84, 0.4)",  "rgba(32, 201, 151, 0.4)",  "rgba(13, 202, 240, 0.4)",// Palette matches the warm-earth SCSS: mossy green primary, amber secondary,// terracotta accent, plus neutral earth tones for the longer tails of the// doughnut charts. Alpha-blended so adjacent segments stay readable.const palette = [  "rgba(107, 158, 120, 0.75)", // green  "rgba(201, 168, 76, 0.75)",  // amber  "rgba(196, 112, 85, 0.75)",  // terracotta  "rgba(126, 170, 184, 0.75)", // info blue-slate  "rgba(160, 152, 144, 0.75)", // warm grey  "rgba(125, 184, 140, 0.75)", // green bright  "rgba(221, 192, 106, 0.7)",  // amber bright  "rgba(216, 136, 112, 0.7)",  // terracotta bright  "rgba(132, 124, 114, 0.7)",  // gray-400  "rgba(196, 189, 178, 0.7)",  // gray-200];const fontStack = 'Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console", "Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace';const paletteBorders = palette.map((c) => c.replace(/0?\.\d+\)/, "1)"));// Get the max label length from all the datasetsconst fontStack = "'Monaspace Argon', ui-monospace, 'Cascadia Code', Consolas, monospace";// Common axis / grid styling for the dark theme.Chart.defaults.color = "rgba(221, 215, 205, 0.55)";Chart.defaults.borderColor = "rgba(107, 158, 120, 0.08)";Chart.defaults.font.family = fontStack;Chart.defaults.font.size = 11;const tooltipStyle = {  backgroundColor: "rgba(9, 8, 6, 0.95)",  borderColor: "rgba(107, 158, 120, 0.3)",  borderWidth: 1,  titleColor: "#ede8e0",  bodyColor: "#ddd7cd",  padding: 10,  titleFont: { family: fontStack, size: 12 },  bodyFont: { family: fontStack, size: 11 },  cornerRadius: 4,};// Normalize label widths across the four doughnut charts so their legends line// up in the sidebar. Pad with non-breaking spaces so monospace widths match.let maxLabelLength = 0;if (document.getElementById("chart-total-events-by-browser-data")) {  const browserData = JSON.parse(document.getElementById("chart-total-events-by-browser-data").innerHTML);  const deviceData = JSON.parse(document.getElementById("chart-total-events-by-device-data").innerHTML);  const screenSizeData = JSON.parse(document.getElementById("chart-total-events-by-screen-size-data").innerHTML);  const allData = [...browserData, ...deviceData, ...screenSizeData];  const platformData = JSON.parse(document.getElementById("chart-total-events-by-platform-data").innerHTML);  const allData = [...browserData, ...deviceData, ...screenSizeData, ...platformData];  for (let i = 0; i < allData.length; i++) {    if (allData[i].label.length > maxLabelLength) {      maxLabelLength = allData[i].label.length;
@@ -29,24 +54,85 @@ if (document.getElementById("chart-total-events-by-browser-data")) {  }}function padLabels(data) {  for (let i = 0; i < data.length; i++) {    data[i].label = data[i].label + " ".repeat(Math.max(0, maxLabelLength - data[i].label.length));  }  return data;}const doughnutOptions = {  responsive: true,  aspectRatio: 1.8,  animation: { animateRotate: false },  cutout: "60%",  plugins: {    tooltip: tooltipStyle,    legend: {      position: "right",      labels: {        boxWidth: 8,        boxHeight: 8,        padding: 8,        color: "rgba(221, 215, 205, 0.7)",        font: { family: fontStack, size: 11 },      },    },  },};function renderDoughnut(canvasId, dataId) {  document.addEventListener("DOMContentLoaded", function () {    const canvas = document.getElementById(canvasId);    if (!canvas) return;    const raw = JSON.parse(document.getElementById(dataId).innerHTML);    const data = padLabels(raw);    new Chart(canvas.getContext("2d"), {      type: "doughnut",      data: {        labels: data.map((d) => d.label),        datasets: [          {            data: data.map((d) => d.count),            backgroundColor: palette,            borderColor: "rgba(14, 13, 10, 0.9)",            borderWidth: 2,          },        ],      },      options: doughnutOptions,    });  });}document.addEventListener("DOMContentLoaded", function () {  const canvas = document.getElementById("chart-total-events");  if (!canvas) return;  const data = JSON.parse(    document.getElementById("chart-total-events-data").innerHTML  );  const data = JSON.parse(document.getElementById("chart-total-events-data").innerHTML);  const ctx = canvas.getContext("2d");  const chart = new Chart(ctx, {  // Build a subtle vertical gradient fill so the line chart feels lit from  // below without washing out the dark surface.  const gradient = ctx.createLinearGradient(0, 0, 0, 320);  gradient.addColorStop(0, "rgba(107, 158, 120, 0.35)");  gradient.addColorStop(1, "rgba(107, 158, 120, 0.01)");  new Chart(ctx, {    type: "line",    data: {      labels: data.map((d) => d.label),      datasets: [        {          label: "Total events",          label: "events",          data: data.map((d) => d.count),          backgroundColor: "rgba(13, 110, 253, 0.4)",          borderColor: "rgba(13, 110, 253, 0.8)",          borderWidth: 3,          backgroundColor: gradient,          borderColor: "rgba(125, 184, 140, 0.95)",          pointBackgroundColor: "rgba(125, 184, 140, 1)",          pointBorderColor: "rgba(14, 13, 10, 1)",          pointRadius: 3,          pointHoverRadius: 5,          borderWidth: 2,          tension: 0.25,          fill: true,        },      ],
@@ -54,26 +140,11 @@ document.addEventListener("DOMContentLoaded", function () {    options: {      responsive: true,      maintainAspectRatio: false,      animation: {        duration: 0,      },      animation: { duration: 0 },      plugins: {        tooltip: {          mode: "index",          intersect: false,          titleFont: { family: fontStack },          bodyFont: { family: fontStack },        },        tooltip: { ...tooltipStyle, mode: "index", intersect: false },        legend: {          position: "top",          labels: {            boxWidth: 10,            boxHeight: 10,            font: {              size: 12,              family: fontStack,            },          },          display: false,        },      },      scales: {
@@ -81,229 +152,28 @@ document.addEventListener("DOMContentLoaded", function () {          ticks: {            autoSkip: true,            maxTicksLimit: 10,            font: {              size: 12,              family: fontStack,            },            maxRotation: 0,            color: "rgba(132, 124, 114, 0.85)",            font: { family: fontStack, size: 10 },          },          grid: { color: "rgba(107, 158, 120, 0.04)", drawTicks: false },          border: { color: "rgba(107, 158, 120, 0.12)" },        },        y: {          beginAtZero: true,          ticks: {            font: {              size: 12,              family: fontStack,            },          },        },      },    },  });  chart.canvas.parentNode.style.width = "100%";  chart.canvas.parentNode.style.height = "300px";});document.addEventListener("DOMContentLoaded", function () {  const canvas = document.getElementById("chart-total-events-by-browser");  if (!canvas) return;  const data = JSON.parse(    document.getElementById("chart-total-events-by-browser-data").innerHTML  );  // adjust labels to all be maxLabelLength by adding spaces to the end  for (let i = 0; i < data.length; i++) {    data[i].label = data[i].label + " ".repeat(maxLabelLength - data[i].label.length);  }  const ctx = canvas.getContext("2d");  new Chart(ctx, {    type: "doughnut",    data: {      labels: data.map((d) => d.label),      datasets: [        {          data: data.map((d) => d.count),          backgroundColor: backgroundColors,          borderColor: backgroundColors,          borderWidth: 1,        },      ],    },    options: {      responsive: true,      aspectRatio: 2,      animation: {        animateRotate: false,      },      plugins: {        tooltip: {          titleFont: { family: fontStack },          bodyFont: { family: fontStack },        },        legend: {          position: "right",          labels: {            boxWidth: 10,            boxHeight: 10,            font: {              size: 12,              family: fontStack,            },          },        },      },    },  });});document.addEventListener("DOMContentLoaded", function () {  const canvas = document.getElementById("chart-total-events-by-device");  if (!canvas) return;  const data = JSON.parse(    document.getElementById("chart-total-events-by-device-data").innerHTML  );  // adjust labels to all be maxLabelLength by adding spaces to the end  for (let i = 0; i < data.length; i++) {    data[i].label = data[i].label + " ".repeat(maxLabelLength - data[i].label.length);  }  const ctx = canvas.getContext("2d");  new Chart(ctx, {    type: "doughnut",    data: {      labels: data.map((d) => d.label),      datasets: [        {          data: data.map((d) => d.count),          backgroundColor: backgroundColors,          borderColor: backgroundColors,          borderWidth: 1,        },      ],    },    options: {      responsive: true,      aspectRatio: 2,      animation: {        animateRotate: false,      },      plugins: {        tooltip: {          titleFont: { family: fontStack },          bodyFont: { family: fontStack },        },        legend: {          position: "right",          labels: {            boxWidth: 10,            boxHeight: 10,            font: {              size: 12,              family: fontStack,            },          },        },      },    },  });});document.addEventListener("DOMContentLoaded", function () {  const canvas = document.getElementById("chart-total-events-by-screen-size");  if (!canvas) return;  const data = JSON.parse(    document.getElementById("chart-total-events-by-screen-size-data").innerHTML  );  // adjust labels to all be maxLabelLength by adding spaces to the end  for (let i = 0; i < data.length; i++) {    data[i].label = data[i].label + " ".repeat(maxLabelLength - data[i].label.length);  }  const ctx = canvas.getContext("2d");  new Chart(ctx, {    type: "doughnut",    data: {      labels: data.map((d) => d.label),      datasets: [        {          data: data.map((d) => d.count),          backgroundColor: backgroundColors,          borderColor: backgroundColors,          borderWidth: 1,        },      ],    },    options: {      responsive: true,      aspectRatio: 2,      animation: {        animateRotate: false,      },      plugins: {        tooltip: {          titleFont: { family: fontStack },          bodyFont: { family: fontStack },        },        legend: {          position: "right",          labels: {            boxWidth: 10,            boxHeight: 10,            font: {              size: 12,              family: fontStack,            },            color: "rgba(132, 124, 114, 0.85)",            font: { family: fontStack, size: 10 },          },          grid: { color: "rgba(107, 158, 120, 0.06)", drawTicks: false },          border: { display: false },        },      },    },  });});document.addEventListener("DOMContentLoaded", function () {  const canvas = document.getElementById("chart-total-events-by-platform");  if (!canvas) return;  const data = JSON.parse(    document.getElementById("chart-total-events-by-platform-data").innerHTML  );  // adjust labels to all be maxLabelLength by adding spaces to the end  for (let i = 0; i < data.length; i++) {    data[i].label = data[i].label + " ".repeat(maxLabelLength - data[i].label.length);  }  const ctx = canvas.getContext("2d");  new Chart(ctx, {    type: "doughnut",    data: {      labels: data.map((d) => d.label),      datasets: [        {          data: data.map((d) => d.count),          backgroundColor: backgroundColors,          borderColor: backgroundColors,          borderWidth: 1,        },      ],    },    options: {      responsive: true,      aspectRatio: 2,      animation: {        animateRotate: false,      },      plugins: {        tooltip: {          titleFont: { family: fontStack },          bodyFont: { family: fontStack },        },        legend: {          position: "right",          labels: {            boxWidth: 10,            boxHeight: 10,            font: {              size: 12,              family: fontStack,            },          },        },      },    },  });});renderDoughnut("chart-total-events-by-browser", "chart-total-events-by-browser-data");renderDoughnut("chart-total-events-by-device", "chart-total-events-by-device-data");renderDoughnut("chart-total-events-by-screen-size", "chart-total-events-by-screen-size-data");renderDoughnut("chart-total-events-by-platform", "chart-total-events-by-platform-data");
modified properties/static_src/scripts/property_map.js
@@ -15,7 +15,11 @@ document.addEventListener("DOMContentLoaded", function () {      max = data[region].numberOfThings;    }  }  const colorScale = scaleLinear().domain([0, max]).range(["#cfe2ff", "#031633"]);  // Warm-earth choropleth: faint green for low volume, bright green for the  // busiest states. Matches the rest of the dashboard palette.  const colorScale = scaleLinear()    .domain([0, max])    .range(["rgba(107, 158, 120, 0.12)", "rgba(125, 184, 140, 0.95)"]);  for (const region in data) {    data[region].fillColor = colorScale(data[region].numberOfThings);
@@ -26,17 +30,20 @@ document.addEventListener("DOMContentLoaded", function () {    scope: "usa",    responsive: true,    fills: {      defaultFill: "rgba(108, 117, 125, 0.4)",      defaultFill: "rgba(107, 158, 120, 0.06)",    },    geographyConfig: {      highlightFillColor: "#0d6efd",      borderColor: "rgba(107, 158, 120, 0.18)",      borderWidth: 0.6,      highlightFillColor: "#c9a84c",      highlightBorderColor: "rgba(201, 168, 76, 0.6)",      popupTemplate: function (geo, data) {        if (!data || data.numberOfThings == null) return "";        const count = data.numberOfThings;        return (`          <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-style:normal;font-weight:normal;line-height:1.4;padding:4px 10px;background:rgba(0,0,0,0.9);border-radius:4px;font-size:14px;white-space:nowrap;pointer-events:none;">            <span style="display:block;font-weight:bold;color:#fff;-webkit-text-fill-color:#fff;">${geo.properties.name}</span>            <span style="color:#fff;-webkit-text-fill-color:#fff;">${count} session start${count === 1 ? "" : "s"}</span>          <div style="font-family:'Monaspace Argon',ui-monospace,monospace;line-height:1.4;padding:6px 10px;background:#13120e;border:1px solid rgba(107, 158, 120, 0.3);border-radius:4px;font-size:12px;white-space:nowrap;pointer-events:none;color:#ddd7cd;">            <span style="display:block;font-weight:600;color:#ede8e0;-webkit-text-fill-color:#ede8e0;letter-spacing:0.02em;">${geo.properties.name}</span>            <span style="color:#c9a84c;-webkit-text-fill-color:#c9a84c;">${count} session${count === 1 ? "" : "s"}</span>          </div>        `);      },
modified properties/static_src/styles/print.scss
@@ -1,17 +1,29 @@@media print {  @page {    size: A4;    margin: 12mm;  }  html, body {    width: fit-content;    height: fit-content;    background: #fff !important;    color: #000 !important;  }  body {    -webkit-print-color-adjust: exact;    print-color-adjust: exact;  }  .container {    max-width: 100%;  }  .chart-panel {    page-break-inside: avoid;  }  #chart-total-events {    height: 300px;    page-break-after: always;
@@ -30,7 +42,8 @@    margin-bottom: 0;  }  .col-sm-6 {  #top-lists .col-12 {    width: 50%;    page-break-inside: avoid;  }}
modified properties/templates/properties/properties.html
@@ -1,8 +1,9 @@{% extends 'base.html' %}{% load static %}{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
@@ -12,137 +13,131 @@{% block main %}<div class="container">  <div class="row my-3 d-flex align-items-center">    <div class="col-sm-6">      <h1>{{ title }}</h1>      <p>All properties you've created; green indicates healthy traffic, red indicates no traffic.</p><div class="container my-4">  <div class="row align-items-center g-3 mb-4">    <div class="col-md-5">      <div class="section-label mb-1">operator · {{ request.user.username }}</div>      <h1 class="fw-bolder text-white mb-0" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted mb-0 small mt-1">Every site you're collecting events from, event totals, and seven-day activity state.</p>    </div>    <div class="col-sm-6">      <form method="get" class="d-flex">        <div class="form-floating flex-grow-1 rounded-0 rounded-start">          <input type="text" class="form-control" name="q" id="id_search" placeholder="Search" {% if q %}value="{{ q }}"{% endif %} autofocus />          <label for="id_search" class="form-label">Search</label>        </div>        <button type="submit" class="btn btn-secondary px-3 rounded-0 rounded-end">          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">            <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>          </svg>        </button>        <button class="btn btn-primary px-3 ms-4" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePropertyAdd" aria-expanded="false" aria-controls="collapsePropertyAdd">          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-node-plus" viewBox="0 0 16 16">            <path fill-rule="evenodd" d="M11 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM6.025 7.5a5 5 0 1 1 0 1H4A1.5 1.5 0 0 1 2.5 10h-1A1.5 1.5 0 0 1 0 8.5v-1A1.5 1.5 0 0 1 1.5 6h1A1.5 1.5 0 0 1 4 7.5h2.025zM11 5a.5.5 0 0 1 .5.5v2h2a.5.5 0 0 1 0 1h-2v2a.5.5 0 0 1-1 0v-2h-2a.5.5 0 0 1 0-1h2v-2A.5.5 0 0 1 11 5zM1.5 7a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1z"/>          </svg>    <div class="col-md-7">      <div class="dashboard-toolbar">        <form method="get" class="search-form flex-grow-1">          <span class="search-icon">            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">              <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>            </svg>          </span>          <input type="text" class="form-control search-input" name="q" id="id_search" placeholder="grep properties..." {% if q %}value="{{ q }}"{% endif %} />        </form>        <button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePropertyAdd" aria-expanded="false" aria-controls="collapsePropertyAdd" onclick="collectorQueue.push({event: 'add_property_toggle'})">          + new        </button>      </form>      </div>    </div>  </div>  <div class="row mx-0 mb-3 bg-dark py-3 rounded border border-warning collapse" id="collapsePropertyAdd">    <div class="col-sm-6">      <h2 class="text-white">Create a new property</h2>      <p class="text-white">The name helps you identfy the property and can be anything you want.</p>      {{ form.errors }}      <form method="POST" class="d-flex">  <div class="collapse mb-4" id="collapsePropertyAdd">    <div class="bg-surface border-subtle rounded border p-3">      <div class="section-label mb-2">register property</div>      <p class="text-muted small mb-3">Give it any friendly name. You'll get a collector ID to embed right after you save.</p>      <form method="POST" class="dashboard-toolbar">        {% csrf_token %}        <div class="flex-grow-1">          <div class="form-floating">            <input type="text" name="name" id="id_name" class="form-control {% if form.name.errors %}is-invalid{% endif %} rounded-0 rounded-start" placeholder="Name" required>            <label for="id_name">Name</label>          </div>        </div>        <button type="submit" class="btn btn-primary px-3 rounded-0 rounded-end">          <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="currentColor" class="bi bi-plus" viewBox="0 0 16 16">            <path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>          </svg>        </button>        <input type="text" name="name" id="id_name" class="form-control flex-grow-1 {% if form.name.errors %}is-invalid{% endif %}" placeholder="e.g. marketing-site" required>        <button type="submit" class="btn btn-primary">Add →</button>      </form>      {% if form.errors %}<div class="text-danger small mt-2">{{ form.errors }}</div>{% endif %}    </div>  </div></div><div class="container">  <div class="row">    <div class="col">      <div class="card bg-secondary text-white rounded-0 rounded-top">        <div class="row g-0">          <div class="col-6 col-md-3 d-flex align-items-center">            <div class="card-body py-1">              <div class="card-title h3 mb-0">{{ user.total_properties }}</div>              <p class="card-text">Properties</p>            </div>  <div class="row g-3 mb-4">    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-green">        <div class="metric-label">total properties</div>        <div class="metric-value">{{ user.total_properties }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-green">        <div class="metric-label">total events</div>        <div class="metric-value">{{ user.total_events }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-green">        <div class="metric-label">page views</div>        <div class="metric-value">{{ user.total_page_views }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-amber">        <div class="metric-label">sessions</div>        <div class="metric-value">{{ user.total_session_starts }}</div>      </div>    </div>  </div>  <div class="section-label mb-2">registered properties</div>  {% for property in properties %}    <div class="property-row {% if not property.is_active %}is-quiet{% endif %}">      <div class="pr-main">        <div class="pr-title">          <span class="status-dot {% if property.is_active %}is-up{% else %}is-idle{% endif %}" aria-hidden="true"></span>          <a href="{% url 'property' property.id %}" class="text-truncate" onclick="collectorQueue.push({event: 'view_property'})">{{ property.name }}</a>        </div>        <div class="pr-id"><span class="pr-id-k">id</span>{{ property.id }}</div>      </div>      <div class="pr-side">        <div class="pr-meta">          <div class="pr-meta-cell">            <span class="pr-meta-k">state</span>            <span class="pr-meta-v {% if property.is_active %}text-green{% else %}text-muted{% endif %}">{% if property.is_active %}live{% else %}quiet{% endif %}</span>          </div>          <div class="col-6 offset-md-3 col-md-2 d-flex align-items-center">            <div class="card-body py-1">              <div class="card-title h3 mb-0">{{ user.total_events }}</div>              <p class="card-text">Events</p>            </div>          <div class="pr-meta-cell">            <span class="pr-meta-k">events</span>            <span class="pr-meta-v">{{ property.total_events }}</span>          </div>          <div class="col-6 col-md-2 d-flex align-items-center">            <div class="card-body py-1">              <div class="card-title h3 mb-0">{{ user.total_page_views }}</div>              <p class="card-text">Page views</p>            </div>          <div class="pr-meta-cell">            <span class="pr-meta-k">views</span>            <span class="pr-meta-v">{{ property.total_page_views }}</span>          </div>          <div class="col-6 col-md-2 d-flex align-items-center">            <div class="card-body py-1">              <div class="card-title h3 mb-0">{{ user.total_session_starts }}</div>              <p class="card-text">Session starts</p>            </div>          <div class="pr-meta-cell">            <span class="pr-meta-k">sessions</span>            <span class="pr-meta-v">{{ property.total_session_starts }}</span>          </div>        </div>      </div>      {% for property in properties %}        <div class="card border-0">          <div class="row g-0">            <div class="col-12 col-md-6 bg-dark">              <div class="card-body py-2">                <h2 class="card-title text-white h3">{{ property.name }}</h2>                <a href="{% url 'property' property.id %}" class="btn btn-sm px-4 btn-primary" onclick="collectorQueue.push({event: 'view_property'})">View</a>                {% if not property.is_protected %}                <button type="button" class="btn btn-sm px-4 btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}" onclick="collectorQueue.push({event: 'delete_property'})">                  Delete                </button>                <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">                  <div class="modal-dialog">                    <div class="modal-content">                      <div class="modal-header">                        <h5 class="modal-title" id="delete-modal-{{ property.id }}-label">Confirm property delete</h5>                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>                      </div>                      <div class="modal-body">                        Are you sure you want to delete <strong>{{ property.name }}</strong>?                      </div>                      <div class="modal-footer">                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">I've changed my mind</button>                        <a href="{% url 'property_delete' property.id %}" class="btn btn-danger">Confirm</a>                      </div>                    </div>                  </div>        <div class="pr-actions">          {% if not property.is_protected %}          <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}" onclick="collectorQueue.push({event: 'delete_property'})">            Delete          </button>          <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">            <div class="modal-dialog">              <div class="modal-content">                <div class="modal-header">                  <h5 class="modal-title text-white" id="delete-modal-{{ property.id }}-label">Confirm delete</h5>                  <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>                </div>                <div class="modal-body">                  Are you sure you want to delete <strong class="text-white">{{ property.name }}</strong>? All collected events will be removed.                </div>                <div class="modal-footer">                  <button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>                  <a href="{% url 'property_delete' property.id %}" class="btn btn-outline-danger">Delete</a>                </div>                {% endif %}              </div>            </div>            <div class="col-6 col-md-2 d-flex align-items-center {% if property.is_active %}bg-success{% else %}bg-danger{% endif %} text-white">              <div class="card-body py-2">                <div class="card-title h3">{{ property.total_events }}</div>                <p class="card-text text-truncate">Events</p>              </div>            </div>            <div class="col-6 col-md-2 d-flex align-items-center {% if property.is_active %}bg-success{% else %}bg-danger{% endif %} text-white">              <div class="card-body py-2">                <div class="card-title h3">{{ property.total_page_views }}</div>                <p class="card-text text-truncate">Page views</p>              </div>            </div>            <div class="col-6 col-md-2 d-none d-md-flex d-flex align-items-center {% if property.is_active %}bg-success{% else %}bg-danger{% endif %} text-white">              <div class="card-body py-2">                <div class="card-title h3">{{ property.total_session_starts }}</div>                <p class="card-text text-truncate">Session starts</p>              </div>            </div>          </div>          {% endif %}          <a href="{% url 'property' property.id %}" class="btn btn-sm btn-outline-light" onclick="collectorQueue.push({event: 'view_property'})">View →</a>        </div>      {% endfor %}      </div>    </div>  </div>  {% empty %}    <div class="text-center py-5 text-muted">      <div class="section-label mb-3" style="justify-content:center;">no properties yet</div>      <p class="mb-0 small">Click <strong>+ new</strong> above to register your first site.</p>    </div>  {% endfor %}</div>{% endblock %}
modified properties/templates/properties/property.html
@@ -19,7 +19,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="/properties/">Properties</a></li>
@@ -30,96 +30,91 @@{% block main %}<div class="bg-light py-2"><div class="bg-deep border-bottom border-subtle py-4">  <div class="container">    <div class="row">    <div class="row align-items-center g-3">      <div class="col-12 col-lg-6">        <div class="d-flex align-items-center">          <h1 class="me-3 display-5">{{ title }}</h1>          <div class="badge bg-secondary rounded-pill d-print-none">{{ total_live_users }} Live users</div>        </div>        <div class="d-lg-flex my-lg-0 d-print-none">          {% if user.is_authenticated %}          <form id="is-public-form" method="POST">            {% csrf_token %}            <div class="form-check form-switch my-1">              <input class="form-check-input" type="checkbox" role="switch" name="is_public" id="is-public-switch" {% if property.is_public %}checked{% endif %}>              <label class="form-check-label" for="{{ custom_event.event|slugify }}-switch">                <span class="badge bg-dark rounded-pill" data-bs-placement="bottom" data-bs-toggle="tooltip" title="Anyone with the URL will have access to this property, you can disable at anytime.">?</span>                Public Property              </label>            </div>          </form>          {% endif %}          {% if user.is_authenticated %}          <button type="button" class="btn btn-sm btn-primary ms-0 ms-lg-3 my-1" data-bs-toggle="modal" data-bs-target="#exampleModal" onclick="collectorQueue.push({event: 'see_site_tag'})">            Get site tag          </button>          <a href="{% url 'property' property.id %}?report" target="_blank" class="btn btn-sm btn-primary ms-0 ms-lg-3 my-1">            Generate report          </a>          {% endif %}          {% if not property.is_protected and user.is_authenticated %}          <button type="button" class="btn btn-sm btn-outline-danger ms-1 ms-lg-3 my-1" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}" onclick="collectorQueue.push({event: 'delete_property'})">            Delete          </button>          <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">            <div class="modal-dialog">              <div class="modal-content">                <div class="modal-header">                  <h5 class="modal-title" id="delete-modal-{{ property.id }}-label">Confirm property delete</h5>                  <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>                </div>                <div class="modal-body">                  Are you sure you want to delete <strong>{{ property.name }}</strong>?                </div>                <div class="modal-footer">                  <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">I've changed my mind</button>                  <a href="{% url 'property_delete' property.id %}" class="btn btn-danger">Confirm</a>                </div>        <div class="section-label mb-2">property · <span class="text-green">{{ total_live_users }} live</span></div>        <h1 class="mb-1 text-white fw-bolder d-flex align-items-center gap-3" style="letter-spacing: -0.01em; min-width: 0;">          <span class="text-truncate">{{ property.name }}</span>          <span class="status-dot status-dot-lg {% if total_live_users %}is-up{% else %}is-idle{% endif %}" aria-hidden="true"></span>        </h1>        <div class="text-muted small" style="font-family: var(--bs-font-monospace);">{{ property.id }}</div>      </div>      <div class="col-12 col-lg-6 d-flex flex-wrap gap-2 justify-content-lg-end align-items-center d-print-none">        {% if user.is_authenticated %}        <form id="is-public-form" method="POST" class="d-inline-flex align-items-center gap-2">          {% csrf_token %}          <span class="toggle-label" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Public properties are viewable by anyone with the URL — no login required.">Visibility</span>          <label class="toggle-pill">            <input type="checkbox" name="is_public" id="is-public-switch" {% if property.is_public %}checked{% endif %}>            <span class="toggle-pill-seg toggle-pill-private">Private</span>            <span class="toggle-pill-seg toggle-pill-public">Public</span>          </label>        </form>        <button type="button" class="btn btn-sm btn-outline-light" data-bs-toggle="modal" data-bs-target="#siteTagModal" onclick="collectorQueue.push({event: 'see_site_tag'})">          Site tag        </button>        <a href="{% url 'property' property.id %}?report" target="_blank" class="btn btn-sm btn-outline-light">Report · pdf</a>        <a href="{% url 'property' property.id %}?report=md" target="_blank" class="btn btn-sm btn-outline-light">Report · md</a>        {% if not property.is_protected %}        <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}" onclick="collectorQueue.push({event: 'delete_property'})">          Delete        </button>        <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">          <div class="modal-dialog">            <div class="modal-content">              <div class="modal-header">                <h5 class="modal-title text-white" id="delete-modal-{{ property.id }}-label">Confirm delete</h5>                <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>              </div>              <div class="modal-body">                Delete <strong class="text-white">{{ property.name }}</strong>? All collected events will be removed.              </div>              <div class="modal-footer">                <button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>                <a href="{% url 'property_delete' property.id %}" class="btn btn-outline-danger">Delete</a>              </div>            </div>          </div>          {% endif %}        </div>      </div>      <div class="col-12 col-lg-6 d-flex flex-column justify-content-end align-items-xl-end">        {% if filter_url %}        <div class="mb-2 d-flex align-items-center">          <span class="text-muted small me-2">Current filters</span>          <span class="badge bg-success d-flex align-items-center">            {{ filter_url }}            <button type="button" class="btn-filter-clear btn-close btn-sm text-white ms-2" data-filter-key="filter_url" data-filter-value="{{ filter_url }}"></button>          </span>        </div>        {% endif %}        {% endif %}      </div>    </div>    <div class="row mt-3 g-3 align-items-end d-print-none">      {% if filter_url %}      <div class="col-12">        <span class="filter-chip">          <span class="section-label" style="letter-spacing: 0.1em;">filter · url</span>          <span class="filter-chip-label">{{ filter_url }}</span>          <button type="button" class="filter-chip-clear btn-filter-clear" data-filter-key="filter_url" data-filter-value="{{ filter_url }}" aria-label="Clear filter">✕</button>        </span>      </div>      {% endif %}      <div class="col-12 col-lg-8 offset-lg-4">        <form method="GET">          <div class="row g-1">            <div class="col-6 col-md-4">              <div class="form-floating">                <input type="date" name="date_start" id="date-start" class="form-control" value="{{ date_start|default:'' }}" />                <label for="date-start">Date start</label>              </div>          <div class="date-range-form">            <div class="form-floating">              <input type="date" name="date_start" id="date-start" class="form-control" value="{{ date_start|default:'' }}" />              <label for="date-start">Date start</label>            </div>            <div class="col-6 col-md-4">              <div class="form-floating">                <input type="date" name="date_end" id="date-end" class="form-control" value="{{ date_end|default:'' }}" />                <label for="date-end">Date end</label>              </div>            <div class="form-floating">              <input type="date" name="date_end" id="date-end" class="form-control" value="{{ date_end|default:'' }}" />              <label for="date-end">Date end</label>            </div>            <div class="col-12 col-md-4">              <div class="form-floating">                <select name="date_range" id="date-range" class="form-select">                  <option value="custom">Custom</option>                  <option value="7">7 days</option>                  <option value="14">14 days</option>                  <option value="28" selected>28 days</option>                  <option value="90">3 months</option>                  <option value="180">6 months</option>                  <option value="365">1 year</option>                </select>                <label for="date-range">Date range</label>              </div>            <div class="form-floating">              <select name="date_range" id="date-range" class="form-select">                <option value="custom">Custom</option>                <option value="7">7 days</option>                <option value="14">14 days</option>                <option value="28" selected>28 days</option>                <option value="90">3 months</option>                <option value="180">6 months</option>                <option value="365">1 year</option>              </select>              <label for="date-range">Date range</label>            </div>          </div>        </form>
@@ -127,199 +122,258 @@    </div>  </div></div><div class="bg-dark py-5 position-relative">  {% if user.is_authenticated and custom_events|length > 0 %}  <div class="position-absolute top-0 end-0 p-2">    <div class="dropdown">      <button class="btn btn-secondary btn-sm dropdown-toggle" type="button" id="customCards" data-bs-toggle="dropdown" aria-expanded="false"><div class="container my-4 position-relative">  <div class="d-flex align-items-center justify-content-between mb-2">    <div class="section-label">metrics · period vs previous</div>    {% if user.is_authenticated and custom_events|length > 0 %}    <div class="dropdown d-print-none">      <button class="btn btn-sm btn-outline-light dropdown-toggle" type="button" id="customCards" data-bs-toggle="dropdown" aria-expanded="false">        Custom cards      </button>      <div class="dropdown-menu p-3" style="width: 300px;" aria-labelledby="customCards">      <div class="dropdown-menu dropdown-menu-end p-3" style="width: 300px;" aria-labelledby="customCards">        <form id="custom-card-form" method="POST">          {% csrf_token %}          {% for custom_event in custom_events %}          <div class="form-check form-switch">            <input class="form-check-input" type="checkbox" role="switch" name="{{ custom_event.event }}" id="{{ custom_event.event|slugify }}-switch" {% if custom_event.active %}checked{% endif %}>            <label class="form-check-label" for="{{ custom_event.event|slugify }}-switch">{{ custom_event.event }}</label>            <label class="form-check-label small" for="{{ custom_event.event|slugify }}-switch">{{ custom_event.event }}</label>          </div>          {% endfor %}        </form>      </div>    </div>    {% endif %}  </div>  {% endif %}  <div class="container">    <div class="row g-1 g-md-3 mt-3 justify-content-center">      {% for event_card in event_cards %}      <div class="col-6 col-md-6 col-lg-4 col-xl-3 text-center mb-1 mb-md-3 mt-0">        <div class="card bg-primary text-white">          {% if event_card.help_text %}          <span class="badge bg-warning text-dark rounded-pill position-absolute bottom-0 end-0 m-1 d-none d-md-inline" data-bs-placement="bottom" data-bs-toggle="tooltip" title="{{ event_card.help_text }}">?</span>          {% endif %}          <span class="badge {% if event_card.percent_change|make_list|first == '-' %}bg-danger{% else %}bg-success{% endif %} position-absolute top-0 end-0 m-1 d-none d-md-inline">{{ event_card.percent_change }}%</span>          <div class="card-body display-5 p-0">            {{ event_card.value }}          </div>          <div class="fw-bold text-truncate mb-1 px-2">            {{ event_card.name }}          </div>  <div class="row g-3">    {% for event_card in event_cards %}    <div class="col-6 col-md-4 col-lg-3">      <div class="metric-tile">        {% with pc=event_card.percent_change|stringformat:"s" %}        {% if event_card.percent_change == 0 %}        <span class="metric-delta is-flat" title="No change vs previous period">·</span>        {% elif pc|slice:":1" == "-" %}        <span class="metric-delta is-down">{{ event_card.percent_change }}%</span>        {% else %}        <span class="metric-delta is-up">+{{ event_card.percent_change }}%</span>        {% endif %}        {% endwith %}        <div class="metric-label text-truncate" {% if event_card.help_text %}data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ event_card.help_text }}"{% endif %}>          {{ event_card.name }}        </div>        <div class="metric-value">{{ event_card.value }}</div>      </div>      {% endfor %}    </div>    {% endfor %}  </div></div><div class="container my-4">  <div class="row text-center">  <div class="row g-3">    <div class="col-12 col-md-8">      <div class="bg-light mb-4 p-2 rounded">        <canvas id="chart-total-events"></canvas>      <div class="chart-panel mb-3">        <div class="chart-panel-header">          <span class="chart-panel-title">events over time</span>        </div>        <div class="chart-panel-body" style="height: 320px;">          <canvas id="chart-total-events"></canvas>        </div>      </div>      <div class="bg-light mb-4 p-2 rounded">        <div id="datamap"></div>      <div class="chart-panel">        <div class="chart-panel-header">          <span class="chart-panel-title">sessions · US states</span>        </div>        <div class="chart-panel-body">          <div id="datamap" style="min-height: 320px;"></div>        </div>      </div>    </div>    <div id="doughnut-graphs" class="col-12 col-md-4">      <div class="bg-light mb-4 p-2 rounded">        <canvas id="chart-total-events-by-device"></canvas>      <div class="chart-panel mb-3">        <div class="chart-panel-header">          <span class="chart-panel-title">device</span>        </div>        <div class="chart-panel-body">          <canvas id="chart-total-events-by-device"></canvas>        </div>      </div>      <div class="bg-light mb-4 p-2 rounded">        <canvas id="chart-total-events-by-browser"></canvas>      <div class="chart-panel mb-3">        <div class="chart-panel-header">          <span class="chart-panel-title">browser</span>        </div>        <div class="chart-panel-body">          <canvas id="chart-total-events-by-browser"></canvas>        </div>      </div>      <div class="bg-light mb-4 p-2 rounded">        <canvas id="chart-total-events-by-platform"></canvas>      <div class="chart-panel mb-3">        <div class="chart-panel-header">          <span class="chart-panel-title">platform</span>        </div>        <div class="chart-panel-body">          <canvas id="chart-total-events-by-platform"></canvas>        </div>      </div>      <div class="bg-light mb-4 p-2 rounded">        <canvas id="chart-total-events-by-screen-size"></canvas>      <div class="chart-panel mb-3">        <div class="chart-panel-header">          <span class="chart-panel-title">screen size</span>        </div>        <div class="chart-panel-body">          <canvas id="chart-total-events-by-screen-size"></canvas>        </div>      </div>    </div>  </div>  <div id="top-lists" class="row justify-content-center"></div><div class="container my-4">  <div class="section-label mb-2">top lists · ranked</div>  <div id="top-lists" class="row g-3">    {% if total_page_views_by_page_url|length > 0 %}    <div class="col-12 col-sm-6 col-lg-4 mb-5">      <ol class="list-group">        <li class="list-group-item d-flex justify-content-between align-items-start fw-bold bg-dark text-white">          <span>Top pages by page view</span>          <span class="badge bg-primary rounded-pill">Count</span>        </li>        {% for item in total_page_views_by_page_url %}        <li class="list-group-item d-flex justify-content-between align-items-start bg-light">          <a href="{% url 'property' property.id %}?filter_url={{ item.label|urlencode }}" class="text-decoration-none text-dark text-truncate">{{ item.label }}</a>          <span class="badge bg-primary rounded-pill">{{ item.count }}</span>        </li>        {% endfor %}      </ol>    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">top pages · page views</span>          <span class="rank-list-col">count</span>        </div>        <div class="rank-list-body">          {% for item in total_page_views_by_page_url %}          <div class="rank-list-row">            <span class="rank-label"><a href="{% url 'property' property.id %}?filter_url={{ item.label|urlencode }}">{{ item.label }}</a></span>            <span class="rank-count">{{ item.count }}</span>          </div>          {% endfor %}        </div>      </div>    </div>    {% endif %}    {% if total_events_by_page_url|length > 0 %}    <div class="col-12 col-sm-6 col-lg-4 mb-5">      <ol class="list-group">        <li class="list-group-item d-flex justify-content-between align-items-start fw-bold bg-dark text-white">          <span>Top pages by event</span>          <span class="badge bg-primary rounded-pill">Count</span>        </li>        {% for item in total_events_by_page_url %}        <li class="list-group-item d-flex justify-content-between align-items-start bg-light">          <a href="{% url 'property' property.id %}?filter_url={{ item.label|urlencode }}" class="text-decoration-none text-dark text-truncate">{{ item.label }}</a>          <span class="badge bg-primary rounded-pill">{{ item.count }}</span>        </li>        {% endfor %}      </ol>    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">top pages · all events</span>          <span class="rank-list-col">count</span>        </div>        <div class="rank-list-body">          {% for item in total_events_by_page_url %}          <div class="rank-list-row">            <span class="rank-label"><a href="{% url 'property' property.id %}?filter_url={{ item.label|urlencode }}">{{ item.label }}</a></span>            <span class="rank-count">{{ item.count }}</span>          </div>          {% endfor %}        </div>      </div>    </div>    {% endif %}    {% if total_session_starts_by_referrer|length > 0 %}    <div class="col-12 col-sm-6 col-lg-4 mb-5">      <ol class="list-group">        <li class="list-group-item d-flex justify-content-between align-items-start fw-bold bg-dark text-white">          <span>Top referrers</span>          <span class="badge bg-primary rounded-pill">Count</span>        </li>        {% for item in total_session_starts_by_referrer %}        <li class="list-group-item d-flex justify-content-between align-items-start bg-light">          <span class="text-truncate">{{ item.label }}</span>          <span class="badge bg-primary rounded-pill">{{ item.count }}</span>        </li>        {% endfor %}      </ol>    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">top referrers</span>          <span class="rank-list-col">sessions</span>        </div>        <div class="rank-list-body">          {% for item in total_session_starts_by_referrer %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          {% endfor %}        </div>      </div>    </div>    {% endif %}    {% if total_events_by_custom_event|length > 0 %}    <div class="col-12 col-sm-6 col-lg-4 mb-5">      <ol class="list-group">        <li class="list-group-item d-flex justify-content-between align-items-start fw-bold bg-dark text-white">          <span>Top custom events</span>          <span class="badge bg-primary rounded-pill">Count</span>        </li>        {% for item in total_events_by_custom_event %}        <li class="list-group-item d-flex justify-content-between align-items-start bg-light">          <span class="text-truncate">{{ item.label }}</span>          <span class="badge bg-primary rounded-pill">{{ item.count }}</span>        </li>        {% endfor %}      </ol>    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">top custom events</span>          <span class="rank-list-col">count</span>        </div>        <div class="rank-list-body">          {% for item in total_events_by_custom_event %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          {% endfor %}        </div>      </div>    </div>    {% endif %}    {% if total_page_views_by_utm_medium|length > 0 %}    <div class="col-12 col-sm-6 col-lg-4 mb-5">      <ol class="list-group">        <li class="list-group-item d-flex justify-content-between align-items-start fw-bold bg-dark text-white">          <span>UTM Medium</span>          <span class="badge bg-primary rounded-pill">Count</span>        </li>        {% for item in total_page_views_by_utm_medium %}        <li class="list-group-item d-flex justify-content-between align-items-start bg-light">          <span class="text-truncate">{{ item.label }}</span>          <span class="badge bg-primary rounded-pill">{{ item.count }}</span>        </li>        {% endfor %}      </ol>    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">utm medium</span>          <span class="rank-list-col">views</span>        </div>        <div class="rank-list-body">          {% for item in total_page_views_by_utm_medium %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          {% endfor %}        </div>      </div>    </div>    {% endif %}    {% if total_page_views_by_utm_source|length > 0 %}    <div class="col-12 col-sm-6 col-lg-4 mb-5">      <ol class="list-group">        <li class="list-group-item d-flex justify-content-between align-items-start fw-bold bg-dark text-white">          <span>UTM Source</span>          <span class="badge bg-primary rounded-pill">Count</span>        </li>        {% for item in total_page_views_by_utm_source %}        <li class="list-group-item d-flex justify-content-between align-items-start bg-light">          <span class="text-truncate">{{ item.label }}</span>          <span class="badge bg-primary rounded-pill">{{ item.count }}</span>        </li>        {% endfor %}      </ol>    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">utm source</span>          <span class="rank-list-col">views</span>        </div>        <div class="rank-list-body">          {% for item in total_page_views_by_utm_source %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          {% endfor %}        </div>      </div>    </div>    {% endif %}    {% if total_page_views_by_utm_campaign|length > 0 %}    <div class="col-12 col-sm-6 col-lg-4 mb-5">      <ol class="list-group">        <li class="list-group-item d-flex justify-content-between align-items-start fw-bold bg-dark text-white">          <span>UTM Campaign</span>          <span class="badge bg-primary rounded-pill">Count</span>        </li>        {% for item in total_page_views_by_utm_campaign %}        <li class="list-group-item d-flex justify-content-between align-items-start bg-light">          <span class="text-truncate">{{ item.label }}</span>          <span class="badge bg-primary rounded-pill">{{ item.count }}</span>        </li>        {% endfor %}      </ol>    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">utm campaign</span>          <span class="rank-list-col">views</span>        </div>        <div class="rank-list-body">          {% for item in total_page_views_by_utm_campaign %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          {% endfor %}        </div>      </div>    </div>    {% endif %}  </div></div><div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true"><div class="modal fade" id="siteTagModal" tabindex="-1" aria-labelledby="siteTagModalLabel" aria-hidden="true">  <div class="modal-dialog modal-lg">    <div class="modal-content">      <div class="modal-header">        <h5 class="modal-title" id="exampleModalLabel">Site tag</h5>        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>        <h5 class="modal-title text-white" id="siteTagModalLabel">Collector site tag</h5>        <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>      </div>      <div class="modal-body">        <p>Add this to the <strong>&lt;head&gt;</strong> of your HTML file:</p><textarea class="form-control" rows="8" readonly><script>        <p class="text-muted small mb-3">Drop this into the <code class="text-amber">&lt;head&gt;</code> of your site. The collector ID below is scoped to <strong class="text-white">{{ property.name }}</strong>.</p>        <textarea class="codebox" rows="7" readonly><script>  (function(m,e,t,r,i,c,s){m.collectorQueue = m.collectorQueue || r;  m.collectorServer = c; m.collectorId = s; collectorScript = e.createElement(t);  collectorScript.src = c + i; e.head.appendChild(m.collectorScript);
added properties/templates/properties/property_report.md
@@ -0,0 +1,112 @@# {{ property.name }}**Property ID:** `{{ property.id }}`**Operator:** {{ property.user.username }}**Date range:** {{ date_start }} → {{ date_end }} ({{ date_range }} days){% if filter_url %}**Filter · url:** `{{ filter_url }}`{% endif %}**Generated:** {% now "Y-m-d H:i" %}**Live users (last 30m):** {{ total_live_users }}---## Metrics · period vs previous| Metric | Value | Change ||---|---|---|{% for card in event_cards %}| {{ card.name }} | `{{ card.value }}` | {% if card.percent_change > 0 %}+{% endif %}{{ card.percent_change }}% |{% endfor %}## Events over time| Date | Count ||---|---|{% for point in total_events_graph %}| {{ point.label }} | {{ point.count }} |{% endfor %}{% if total_page_views_by_page_url %}## Top pages · page views| URL | Views ||---|---|{% for item in total_page_views_by_page_url %}| `{{ item.label }}` | {{ item.count }} |{% endfor %}{% endif %}{% if total_events_by_page_url %}## Top pages · all events| URL | Events ||---|---|{% for item in total_events_by_page_url %}| `{{ item.label }}` | {{ item.count }} |{% endfor %}{% endif %}{% if total_session_starts_by_referrer %}## Top referrers| Referrer | Sessions ||---|---|{% for item in total_session_starts_by_referrer %}| `{{ item.label }}` | {{ item.count }} |{% endfor %}{% endif %}{% if total_events_by_custom_event %}## Top custom events| Event | Count ||---|---|{% for item in total_events_by_custom_event %}| `{{ item.label }}` | {{ item.count }} |{% endfor %}{% endif %}{% if total_events_by_device %}## Device| Device | Events ||---|---|{% for item in total_events_by_device %}| {{ item.label }} | {{ item.count }} |{% endfor %}{% endif %}{% if total_events_by_browser %}## Browser| Browser | Events ||---|---|{% for item in total_events_by_browser %}| {{ item.label }} | {{ item.count }} |{% endfor %}{% endif %}{% if total_events_by_platform %}## Platform| Platform | Events ||---|---|{% for item in total_events_by_platform %}| {{ item.label }} | {{ item.count }} |{% endfor %}{% endif %}{% if total_events_by_screen_size %}## Screen size| Size | Events ||---|---|{% for item in total_events_by_screen_size %}| {{ item.label }} | {{ item.count }} |{% endfor %}{% endif %}{% if total_session_starts_by_region %}## Sessions by region| Region | Sessions ||---|---|{% for item in total_session_starts_by_region %}| {{ item.label }} | {{ item.count }} |{% endfor %}{% endif %}{% if total_page_views_by_utm_source %}## UTM source| Source | Views ||---|---|{% for item in total_page_views_by_utm_source %}| `{{ item.label }}` | {{ item.count }} |{% endfor %}{% endif %}{% if total_page_views_by_utm_medium %}## UTM medium| Medium | Views ||---|---|{% for item in total_page_views_by_utm_medium %}| `{{ item.label }}` | {{ item.count }} |{% endfor %}{% endif %}{% if total_page_views_by_utm_campaign %}## UTM campaign| Campaign | Views ||---|---|{% for item in total_page_views_by_utm_campaign %}| `{{ item.label }}` | {{ item.count }} |{% endfor %}{% endif %}---_Report generated by Analytics — self-hosted website analytics._
modified properties/views.py
@@ -533,14 +533,24 @@ def property(request, property_id):        "total_session_starts_by_region_chart_data"    ] = total_session_starts_by_region_chart_data    if request.GET.get("report") == "":        context["print"] = True        html = render_to_string("properties/property.html", context)        filename = f"reports/{uuid.uuid4()}.pdf"        generate_pdf_from_html(html, filename)        with open(default_storage.path(filename), "rb") as pdf:            response = HttpResponse(pdf.read(), content_type="application/pdf")            response["Content-Disposition"] = "inline; filename=report.pdf"    # Report formats. `?report` (or `?report=pdf`) returns a printed PDF    # rendered from the dashboard template; `?report=md` returns a plain-text    # Markdown report suited for piping into an LLM.    if "report" in request.GET:        fmt = request.GET.get("report") or "pdf"        if fmt == "md":            md = render_to_string("properties/property_report.md", context)            response = HttpResponse(md, content_type="text/markdown; charset=utf-8")            response["Content-Disposition"] = f'inline; filename="{property_obj.name}.md"'            return response        if fmt == "pdf":            context["print"] = True            html = render_to_string("properties/property.html", context)            filename = f"reports/{uuid.uuid4()}.pdf"            generate_pdf_from_html(html, filename)            with open(default_storage.path(filename), "rb") as pdf:                response = HttpResponse(pdf.read(), content_type="application/pdf")                response["Content-Disposition"] = "inline; filename=report.pdf"                return response    return render(request, "properties/property.html", context)
modified vite.config.js
@@ -23,6 +23,7 @@ const fixDatamapsStrictMode = {export default defineConfig({  plugins: [fixDatamapsStrictMode],  base: "/static/",  build: {    outDir: resolve(__dirname, "analytics/static"),    emptyOutDir: true,