Added user counter and analytics to the kst4contest page

This commit is contained in:
Marc Froehlich
2026-09-11 21:03:45 +02:00
parent 4ad1cf71dd
commit b23884bde1
23 changed files with 2863 additions and 7 deletions
+3
View File
@@ -81,5 +81,8 @@
{% if heroFx %}
<script src="/assets/js/hero-radio-fx.js?v={{ build.version }}" defer></script>
{% endif %}
{% if visitorCount %}
<script src="/assets/js/visitor-count.js?v={{ build.version }}" defer></script>
{% endif %}
</body>
</html>
+6
View File
@@ -659,6 +659,12 @@ a:hover {
padding: clamp(28px, 5vw, 60px);
}
.visitor-count {
margin: 24px 0 0;
color: var(--soft);
font-size: 0.9rem;
}
.manual-content {
max-width: 980px;
}
+147
View File
@@ -0,0 +1,147 @@
"use strict";
const VISITOR_COUNT_ENDPOINT = "/visitor-count.json";
const VISITOR_COUNT_TIMEOUT_MS = 2500;
function parseIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day) {
return null;
}
return date;
}
function isIsoTimestamp(value) {
if (typeof value !== "string") {
return false;
}
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
if (!match || !parseIsoDate(match[1])) {
return false;
}
const hour = Number(match[2]);
const minute = Number(match[3]);
const second = Number(match[4]);
const offsetHour = match[6] === undefined ? 0 : Number(match[6]);
const offsetMinute = match[7] === undefined ? 0 : Number(match[7]);
return hour <= 23
&& minute <= 59
&& second <= 59
&& offsetHour <= 23
&& offsetMinute <= 59
&& Number.isFinite(Date.parse(value));
}
function validateVisitorCount(data) {
if (!data || typeof data !== "object" || Array.isArray(data)) {
return null;
}
if (data.schemaVersion !== 1
|| !Number.isSafeInteger(data.visits)
|| data.visits < 0
|| !isIsoTimestamp(data.updatedAt)) {
return null;
}
const since = typeof data.since === "string"
? parseIsoDate(data.since)
: null;
return since ? { visits: data.visits, since } : null;
}
function formatVisitorCount(data) {
const valid = validateVisitorCount(data);
if (!valid) {
return null;
}
const date = new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC"
}).format(valid.since);
const visits = new Intl.NumberFormat("en-GB").format(valid.visits);
return `Visits since ${date}: ${visits}`;
}
async function loadVisitorCount({
element,
fetchImpl = globalThis.fetch,
AbortControllerImpl = globalThis.AbortController,
timeoutMs = VISITOR_COUNT_TIMEOUT_MS
}) {
if (!element || typeof fetchImpl !== "function"
|| typeof AbortControllerImpl !== "function") {
return false;
}
element.hidden = true;
element.textContent = "";
const controller = new AbortControllerImpl();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(VISITOR_COUNT_ENDPOINT, {
credentials: "omit",
signal: controller.signal
});
if (!response.ok) {
return false;
}
const text = formatVisitorCount(await response.json());
if (!text) {
return false;
}
element.textContent = text;
element.hidden = false;
return true;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
if (typeof document !== "undefined") {
const element = document.querySelector("[data-visitor-count]");
if (element) {
loadVisitorCount({ element });
}
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
formatVisitorCount,
loadVisitorCount,
parseIsoDate,
validateVisitorCount
};
}
+4 -1
View File
@@ -3,6 +3,7 @@ layout: base.njk
title: KST4Contest ON4KST Contest Client for VHF, UHF and SHF
description: KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration in one desktop client.
heroFx: true
visitorCount: true
---
<section class="hero hero-split">
@@ -150,6 +151,8 @@ heroFx: true
<a class="button secondary" href="/roadmap/">Development status</a>
<a class="button ghost" href="/support/">Support development</a>
</div>
<p class="visitor-count" data-visitor-count hidden aria-live="polite"></p>
</div>
</section>
@@ -165,4 +168,4 @@ heroFx: true
"description": "KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration for VHF, UHF and SHF contest operation.",
"license": "https://github.com/praktimarc/kst4contest/blob/main/LICENSE"
}
</script>
</script>
+39 -5
View File
@@ -7,7 +7,7 @@ description: Privacy policy for the KST4Contest website.
<section class="hero">
<p class="badge">Privacy</p>
<h1>Privacy Policy</h1>
<p class="lead">Information about data processing on this static website.</p>
<p class="lead">Information about data processing on this website.</p>
</section>
<section class="section narrow">
@@ -30,12 +30,46 @@ description: Privacy policy for the KST4Contest website.
<h2>Legal basis</h2>
<p>
The legal basis is Art. 6(1)(f) GDPR: legitimate interest in secure and reliable operation
of the website.
of the website and in understanding the approximate use of its project pages.
</p>
<h2>Cookies and tracking</h2>
<h2>Server-side reach statistics</h2>
<p>
This website currently does not use cookies, analytics tracking, advertising pixels or user profiling.
The website uses server-side reach statistics to understand approximate demand and which
project pages are used. Measurement is based on a dedicated, reduced server access log. It
does not use cookies, an analytics or tracking script, advertising pixels, local storage,
third-party requests or user profiles.
</p>
<p>
The analytics log contains the server name, IP address, time, HTTP method, normalised path
without a query string, protocol, response status, transferred size and user agent. It does
not contain a referrer or authenticated user name. Known crawlers are filtered, and unknown
browsers or operating systems are treated as crawlers.
</p>
<p>
IP addresses are anonymised before data is stored in the statistics aggregates. The dedicated
analytics raw logs are retained for 14 days. Anonymised detailed aggregates are retained on a
rolling basis for 395 days. They include countries, but no city or host statistics. Countries
are derived with a Country database only.
</p>
<p>
A visit is an approximate daily value based on the combination of IP address, date and user
agent used by GoAccess. It must not be read as a number of unique people. Page views and visits
remain separate measures. Bots, monitoring requests, software update checks and static assets
are excluded as far as they can be identified.
</p>
<h2>Public visitor count</h2>
<p>
The home page displays an element containing the total number of these approximate daily visits
since the stated activation date. The display loads a same-origin JSON file and remains hidden
if that file is unavailable or invalid. This request is not included in the statistic.
</p>
<p>
The public file contains only the counter value, activation date, schema version and update time.
It contains no personal data or daily breakdown. The non-personal daily counter values are kept
permanently so that the total does not decrease when detailed aggregates reach the end of their
395-day retention period.
</p>
<h2>External links</h2>
@@ -71,4 +105,4 @@ description: Privacy policy for the KST4Contest website.
You have the right to lodge a complaint with a competent data protection supervisory authority.
</p>
</article>
</section>
</section>