modified
accounts/templates/accounts/profile.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url("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");" 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"> © {% 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>© {% 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("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");" 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("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");" 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("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");" 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("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");" 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("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");" 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("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");" 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("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");" 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("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");" 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>
@@ -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);})();
@@ -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("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");" 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("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");" 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><head></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 %}
@@ -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("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");" 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("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");" 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><head></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"><head></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)
@@ -23,6 +23,7 @@ const fixDatamapsStrictMode = {export default defineConfig({ plugins: [fixDatamapsStrictMode], base: "/static/", build: { outDir: resolve(__dirname, "analytics/static"), emptyOutDir: true,