ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:45:19 +10:00
commit 1bc2de655a
505 changed files with 56223 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
"use client";
/* Analytics.
*
* Self-hosted Umami on ThreadCount's own infrastructure, served from analytics.threadcount.tech.
* Cookieless: it derives a rotating daily visitor hash server-side and stores nothing on the
* device, which is why there is no consent banner. Nothing leaves for a third party, no advertising
* network is involved, and the browser's Do Not Track setting is honoured.
*
* Two separate Umami sites, on purpose:
* marketing — the public pages anyone can read.
* app — the signed-in linen room, plus the Android shell (which loads /m from the site).
* They are different populations carrying very different privacy weight, and keeping them apart
* means the app's numbers can be reset or deleted without losing the marketing history.
*
* The hard rule here is that no record identifier ever reaches the analytics database. Paths carry
* staff, location and order ids — /m/person/<cuid>, /app/staff/<cuid> — so every path is scrubbed
* before it is sent, automatic page tracking is turned OFF so nothing is reported that hasn't been
* through `scrubPath`, and query strings are dropped whole rather than filtered. Event payloads
* are counts and fixed words only; never a name, a barcode, a facility or a free-text error. */
import { isNative } from "@/lib/nativescan";
/* `pulse.js`, not `script.js`: the shared Umami instance serves the tracker under both names
* (TRACKER_SCRIPT_NAME=script,pulse; posts go to /api/pulse) because the filter lists most
* ad-blockers ship match the default `script.js` + `/api/send` pair and silently drop those
* visitors. Same origin either way, so the CSP is unchanged. (2026-09-13) */
export const UMAMI_SRC = "https://analytics.threadcount.tech/pulse.js";
/** Umami website ids. Overridable, but committed so a fresh clone reports to the right place. */
export const MARKETING_ID = process.env.NEXT_PUBLIC_UMAMI_SITE_ID || "6dfdcd93-6931-4eda-bb0d-0a6278c27613";
export const APP_ID = process.env.NEXT_PUBLIC_UMAMI_APP_ID || "31c1f581-2bcc-48bb-a914-70da40c0359a";
type Payload = Record<string, unknown>;
type Umami = { track: (fn: (p: Payload) => Payload) => void };
declare global { interface Window { umami?: Umami } }
/* Which of the two Umami sites this page belongs to. Set by <Analytics>, read here so that the
app's payloads can be scrubbed harder than the public site's. */
let currentSite: "marketing" | "app" = "marketing";
export function setSite(site: "marketing" | "app") { currentSite = site; }
/** A cuid or a uuid — anything long enough to be a record id rather than a route name. */
const ID_LIKE = /^(?:[a-z0-9]{20,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
/** `/m/person/cmtpldt9f00iab` -> `/m/person/:id`. Query strings are dropped entirely. */
export function scrubPath(path: string): string {
const clean = (path || "/").split("?")[0].split("#")[0];
return clean
.split("/")
.map((seg) => (ID_LIKE.test(seg) ? ":id" : seg))
.join("/") || "/";
}
/** Referrers from our own site can carry ids too; outside referrers are the useful ones, kept whole. */
export function scrubReferrer(ref: string): string {
if (!ref) return "";
try {
const u = new URL(ref);
if (typeof location !== "undefined" && u.origin === location.origin) return scrubPath(u.pathname);
return u.origin + scrubPath(u.pathname);
} catch {
return "";
}
}
/** Android shell or a browser. Without this, Play installs and real usage can never be reconciled. */
export function surface(): "android" | "web" {
try { return isNative() ? "android" : "web"; } catch { return "web"; }
}
/* Umami's own `track(name, data)` helper composes `{...defaultPayload, name, data}`, and that
default payload carries the live `location.pathname + search`. Sending an event from
/m/person/<id> that way posts the staff id regardless of how carefully page views are scrubbed.
The function form hands us the whole payload to rewrite, so everything goes through this. */
function send(extra: Payload) {
const u = typeof window !== "undefined" ? window.umami : undefined;
if (!u) return;
try {
u.track((p: Payload) => {
const url = scrubPath(String(extra.url ?? (typeof location !== "undefined" ? location.pathname : "/")));
const out: Payload = {
...p,
...extra,
url,
referrer: scrubReferrer(String(p.referrer || (typeof document !== "undefined" ? document.referrer : "") || "")),
// The website id, every time. Umami's script initialises once per document and keeps the
// id of the FIRST tag that loaded it, so after a client-side hop from a public page into
// /app (the demo, the auth page's own navigation) every page view under /app went to the
// marketing website — 51 of them by 2026-09-13. Naming the site on the payload makes the
// destination follow the layout, not the load order.
website: currentSite === "app" ? APP_ID : MARKETING_ID,
};
// Page titles are free text. None of the app's carry a name today, but one added later
// would leak silently, so the app reports its path instead. The public site's titles are
// fixed marketing copy and are worth keeping.
if (currentSite === "app") out.title = url;
return out;
});
} catch { /* analytics must never break a page */ }
}
/** A scrubbed page view. Called on every route change; automatic tracking is off. */
export function pageview(path: string) {
send({ url: path });
}
/** A named event. `data` may hold counts and fixed words — never anything identifying. */
export function track(event: string, data?: Record<string, string | number | boolean>) {
send({ name: event, data: { ...data, surface: surface() } });
}
/* Which mutations are worth recording, and what they are called in the dashboard.
*
* A deliberate whitelist rather than every op: there are 57, and most are routine edits whose
* volume would say more about a facility's day than about whether ThreadCount works. Anything not
* named here sends nothing at all. */
export const TRACKED_OPS: Record<string, string> = {
"issue.create": "issue_created",
"issue.exchange": "size_exchanged",
"issue.return": "garment_returned",
"stocktake.apply": "count_committed",
"order.create": "reorder_created",
"order.receive": "delivery_received",
"pickup.pickedUp": "pickup_completed",
"barcode.bind": "barcode_bound",
"barcode.generate": "barcode_generated",
"catalog.removeSize": "size_removed",
"import.rows": "data_imported",
"location.save": "location_saved",
"users.add": "user_invited",
"settings.update": "settings_changed",
"me.deleteAccount": "account_deleted",
// The linen room's half of the staff-app flow (the staff half is below). Without these the
// dashboard could see a request raised and never see it fulfilled.
"request.pick": "request_picked",
"request.round": "request_sent_on_round",
"request.collected": "request_collected",
"request.raise": "request_raised_at_counter",
"approval.add": "approval_recorded",
// Adoption of the staff app starts here: a code handed to a wearer. Compare with staff_activated.
"staff.selfCode": "staff_code_issued",
"order.status": "order_status_changed",
"backup.restore": "backup_restored",
};
/* The staff app's own whitelist, kept separate from the coordinator one because the two surfaces
* answer different questions. This one is here to tell us whether the app is being used at all,
* or whether everything still goes through the counter. Same rule as above: a fixed list, and
* anything not named here sends nothing. */
export const TRACKED_STAFF_OPS: Record<string, string> = {
"request.create": "staff_request_raised",
"request.approve": "staff_request_approved",
"request.decline": "staff_request_declined",
"request.message": "staff_message_sent",
"damage.report": "staff_damage_reported",
"dispute.raise": "staff_record_queried",
"waitlist.join": "staff_waitlist_joined",
"waitlist.accept": "staff_waitlist_accepted",
"kit.answer": "staff_kit_answered",
"round.sign": "staff_round_signed",
};
/** Coarse buckets for a refusal. The server's message is never sent — only which kind it was. */
export function failureKind(error: string): string {
const e = (error || "").toLowerCase();
if (e.includes("needs a reason")) return "variance_reason_required";
if (e.includes("admin only")) return "not_permitted";
if (e.includes("not enough")) return "insufficient_stock";
if (e.includes("already")) return "already_done";
if (e.includes("unknown")) return "unknown_record";
if (e.includes("network")) return "network";
return "other";
}