mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-12 20:25:28 +02:00
Added user counter and analytics to the kst4contest page
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user