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:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>No connection — ThreadCount</title>
<style>
/* Bundled with the app, so it has to stand on its own: no web fonts, no network, no images.
Archivo is the brand face; the system stack is what actually renders here. */
:root { --paper:#f3f2f2; --ink:#201e1d; --accent:#ec3013; --n600:#7a7573; --n700:#57534f; }
* { box-sizing:border-box; margin:0; padding:0; }
body {
font-family:"Archivo",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
background:var(--paper); color:var(--ink);
min-height:100dvh; display:flex; flex-direction:column;
padding-top:env(safe-area-inset-top); padding-bottom:env(safe-area-inset-bottom);
}
header { height:56px; flex:0 0 56px; background:var(--ink); color:var(--paper);
display:flex; align-items:center; gap:10px; padding:0 16px; }
.sq { width:15px; height:15px; background:var(--accent); flex:none; }
.wm { font-weight:800; font-size:17px; letter-spacing:-.01em; }
.rule { height:4px; background:var(--accent); }
main { flex:1; padding:36px 20px; }
h1 { font-weight:800; font-size:30px; line-height:1.05; letter-spacing:-.03em; }
.mark { width:56px; height:4px; background:var(--accent); margin:18px 0 0; }
p { font-size:15.5px; line-height:1.65; color:var(--n700); margin-top:20px; max-width:44ch; }
ul { margin-top:18px; padding:0; list-style:none; border-top:2px solid var(--ink); }
li { display:flex; gap:11px; padding:12px 0; border-bottom:1px solid #cfcccb;
font-size:14.5px; line-height:1.5; color:var(--n700); }
li i { width:7px; height:7px; background:var(--accent); flex:none; margin-top:7px; }
button {
width:100%; min-height:64px; border:0; background:var(--accent); color:#fff;
font-family:inherit; font-weight:800; font-size:14px; letter-spacing:.08em;
text-transform:uppercase; text-align:left; padding:0 20px; cursor:pointer;
display:flex; align-items:center; gap:12px;
}
button:active { background:#b8240e; }
.foot { font-size:13px; color:var(--n600); padding:0 20px 22px; }
</style>
</head>
<body>
<header><span class="sq"></span><span class="wm">ThreadCount</span></header>
<div class="rule"></div>
<main>
<h1>No connection.</h1>
<div class="mark"></div>
<p>ThreadCount cant reach the linen rooms records, so it wont let you issue or count
anything right now — nothing is being saved up in the background.</p>
<ul>
<li><i></i>Check the wards wifi, or step somewhere with signal.</li>
<li><i></i>Nothing you had already recorded is lost. A count you hadnt filed is still
on this phone and will be there when you get back.</li>
<li><i></i>If the wifi is fine and this keeps happening, its our end — the contact form
on threadcount.tech reaches the person who wrote it.</li>
</ul>
</main>
<button id="again">Try again <span aria-hidden="true"></span></button>
<div class="foot" id="stamp"></div>
<script>
/* "Try again" has to leave this page. Capacitor serves it from the app's own origin
(https://localhost/error.html), so location.reload() re-requests a local file that always
loads — the same screen comes back no matter how good the signal is, and the only way out is
to force-quit the app. index.html records where it was headed under the same origin, so this
goes back there instead; if there is nothing recorded, the app's home screen will do. */
var SITE = "https://threadcount.tech";
var HOME = "/m";
document.getElementById('again').addEventListener('click', function () {
var path = HOME;
try { path = localStorage.getItem('tc.lastpath') || HOME; } catch (e) { /* private mode is fine */ }
// A stored value only ever came from this app, but it is still concatenated onto a URL, so it
// is checked: one leading slash, and no second one that would make it another host entirely.
if (path.charAt(0) !== '/' || path.charAt(1) === '/') path = HOME;
location.replace(SITE + path);
});
// A timestamp turns "it says no connection" into something answerable over the phone.
try {
document.getElementById('stamp').textContent =
'Last tried ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch (e) { /* nothing to show is fine */ }
</script>
</body>
</html>
+246
View File
@@ -0,0 +1,246 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>ThreadCount</title>
<style>
/* This file ships inside the app, so it stands alone: no web fonts, no network, no framework.
Archivo is the brand face; the system stack is what actually renders. Everything the welcome
screen needs is bundled beside it, which is why the video plays the instant the app opens
instead of crawling over ward wifi on a coordinator's first launch. */
:root {
--ink:#201e1d; --paper:#f3f2f2; --accent:#ec3013; --accent-700:#b8240e;
--n400:#b5b1af; --n300:#d6d3d2;
--font:"Archivo",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
* { box-sizing:border-box; margin:0; padding:0; -webkit-tap-highlight-color:transparent; }
html, body { height:100%; overflow:hidden; background:var(--ink); }
body { font-family:var(--font); color:var(--paper); }
[hidden] { display:none !important; }
/* ---------- 1. Splash: the mark draws itself, so the loading indicator IS the logo. */
#splash { position:fixed; inset:0; background:var(--ink); display:flex; flex-direction:column;
align-items:flex-start; justify-content:center; padding:0 28px; z-index:3; }
.bars { display:flex; flex-direction:column; gap:21px; }
.bar { height:28px; width:0; background:var(--paper); animation:draw 420ms cubic-bezier(.2,.7,.3,1) forwards; }
.bar.a { background:var(--accent); }
/* Widths and the 14-step come from tc-icon.svg (60/48/34/22 in a 100 box) scaled 3.5x. */
.b1 { --w:210px; animation-delay:0ms; }
.b2 { --w:168px; animation-delay:150ms; }
.b3 { --w:119px; animation-delay:300ms; }
.b4 { --w:77px; animation-delay:450ms; }
@keyframes draw { from { width:0; } to { width:var(--w); } }
.rise { opacity:0; transform:translateY(10px); animation:rise 450ms cubic-bezier(.2,.7,.3,1) forwards; }
@keyframes rise { to { opacity:1; transform:translateY(0); } }
#splash .wm { margin-top:52px; width:236px; animation-delay:880ms; }
#splash .wm img { width:100%; display:block; }
#splash .strap { margin-top:18px; animation-delay:1060ms; }
#splash .strap hr { border:0; border-top:2px solid rgba(243,242,242,.28); }
#splash .strap span { display:block; margin-top:14px; font-size:12px; letter-spacing:.14em;
text-transform:uppercase; color:var(--n400); }
/* ---------- 2. Welcome: video, scrim, wordmark, bottom block. */
#welcome { position:fixed; inset:0; z-index:2; }
#welcome video, #welcome .poster {
position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
}
/* The background belongs to the poster, not the video. A background on a replaced element is
painted whether or not there is a frame to draw over it, and the video sits above the poster
— so an opaque one hides the poster at exactly the moment it is meant to stand in. */
#welcome .poster { background:var(--ink); }
/* The scrim is load-bearing: it is what makes the type legible over moving footage. */
#welcome .scrim { position:absolute; inset:0; background:linear-gradient(180deg,
rgba(32,30,29,.62) 0%, rgba(32,30,29,.2) 34%, rgba(32,30,29,.93) 100%); }
#welcome .top { position:absolute; top:calc(24px + env(safe-area-inset-top,0px)); left:24px; }
#welcome .top img { height:19px; display:block; }
#welcome .bottom { position:absolute; left:0; right:0;
bottom:calc(26px + env(safe-area-inset-bottom,0px)); padding:0 24px; }
.kicker { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:var(--n300); }
h1 { font-weight:800; font-size:38px; line-height:1.0; letter-spacing:-.03em; color:var(--paper); margin-top:12px; }
/* The stepped rules return here, in place of a single divider. */
.steps { margin:22px 0 24px; display:flex; flex-direction:column; gap:14px; }
.steps i { display:block; height:4px; }
.steps i:nth-child(1) { width:190px; background:var(--accent); }
.steps i:nth-child(2) { width:132px; background:rgba(243,242,242,.5); }
.steps i:nth-child(3) { width:84px; background:rgba(243,242,242,.3); }
.actions { display:grid; grid-template-columns:1fr 1fr; gap:2px; }
.btn { height:62px; display:flex; align-items:center; padding:0 16px; border:2px solid transparent;
font-family:inherit; font-weight:800; font-size:13px; letter-spacing:.08em;
text-transform:uppercase; text-align:left; cursor:pointer; }
.btn-primary { background:var(--accent); border-color:var(--accent); color:#fff; }
.btn-primary:active { background:var(--accent-700); border-color:var(--accent-700); }
.btn-ghost { background:transparent; border-color:rgba(243,242,242,.85); color:var(--paper); }
.btn-ghost:active { background:rgba(243,242,242,.15); }
/* The footage loops for as long as this screen is up, so there has to be a way to stop it
(WCAG 2.2.2). The chip sits opposite the wordmark and is set like the rest of the type. */
#welcome .motion { position:absolute; top:calc(12px + env(safe-area-inset-top,0px)); right:16px;
min-width:92px; height:44px; padding:0 14px; background:transparent;
border:2px solid rgba(243,242,242,.55); color:var(--paper);
font-family:inherit; font-weight:800; font-size:11px; letter-spacing:.14em;
text-transform:uppercase; cursor:pointer; }
#welcome .motion:active { background:rgba(243,242,242,.15); }
/* Someone who has asked the OS for less movement gets the mark drawn rather than drawing, and a
still welcome screen: the video stays on its poster until they ask for it. */
@media (prefers-reduced-motion: reduce) {
.bar { animation:none; width:var(--w); }
.rise { animation:none; opacity:1; transform:none; }
}
/* ---------- offline, shown instead of sending anyone to a browser error page */
#offline { position:fixed; inset:0; z-index:4; background:var(--paper); color:var(--ink);
padding:calc(32px + env(safe-area-inset-top,0px)) 24px 24px; }
#offline h2 { font-weight:800; font-size:30px; line-height:1.05; letter-spacing:-.03em; }
#offline .mark { width:56px; height:4px; background:var(--accent); margin-top:18px; }
#offline p { font-size:15.5px; line-height:1.65; color:#57534f; margin-top:20px; max-width:44ch; }
#offline button { margin-top:28px; width:100%; }
#offline .btn-primary { border-color:var(--accent); }
</style>
</head>
<body>
<section id="splash" aria-label="ThreadCount">
<div class="bars" aria-hidden="true">
<span class="bar a b1"></span><span class="bar b2"></span><span class="bar b3"></span><span class="bar b4"></span>
</div>
<div class="wm rise"><img src="media/wordmark-white.svg" alt="ThreadCount"></div>
<div class="strap rise"><hr><span>Uniform stock, counted</span></div>
</section>
<section id="welcome" hidden>
<img class="poster" id="poster" src="media/welcome-poster.jpg" alt="">
<!-- No autoplay attribute: playback is started from the script, which is what lets it be held
back for someone who has asked the OS for reduced motion. -->
<video id="vid" src="media/welcome.mp4" muted loop playsinline preload="auto" tabindex="-1" aria-hidden="true"></video>
<div class="scrim"></div>
<div class="top"><img src="media/wordmark-white.svg" alt="ThreadCount"></div>
<button class="motion" id="motion" type="button" aria-label="Pause background video">Pause</button>
<div class="bottom">
<div class="kicker">Stock · Issuing · Counts</div>
<h1>Know what&rsquo;s<br>on the shelf.</h1>
<div class="steps" aria-hidden="true"><i></i><i></i><i></i></div>
<div class="actions">
<button class="btn btn-primary" id="signin">Sign in</button>
<button class="btn btn-ghost" id="signup">Sign up</button>
</div>
</div>
</section>
<section id="offline" hidden>
<h2>No connection.</h2>
<div class="mark"></div>
<p>ThreadCount can&rsquo;t reach your linen room&rsquo;s records. It won&rsquo;t let you issue or
count anything without one, and nothing is being saved up in the background.</p>
<p>Check the ward&rsquo;s wifi, or step somewhere with signal.</p>
<button class="btn btn-primary" id="retry">Try again</button>
</section>
<script>
"use strict";
var SITE = "https://threadcount.tech";
var SPLASH_MS = 1500; // the mark finishes at ~1510ms; holding longer just delays the room
var SEEN = "tc.onboarded";
var LAST = "tc.lastpath"; // read by error.html, which is bundled beside this file and shares its origin
function $(id) { return document.getElementById(id); }
function show(id) { ["splash", "welcome", "offline"].forEach(function (s) { $(s).hidden = s !== id; }); }
function seen() { try { return localStorage.getItem(SEEN) === "1"; } catch (e) { return false; } }
function markSeen() { try { localStorage.setItem(SEEN, "1"); } catch (e) { /* private mode is fine */ } }
/* Reaching the site is checked before navigating, so a coordinator with no signal gets the
offline screen rather than a browser error page they can't do anything with. */
function reachable() {
if (navigator.onLine === false) return Promise.resolve(false);
return new Promise(function (resolve) {
var done = false;
var t = setTimeout(function () { if (!done) { done = true; resolve(false); } }, 6000);
fetch(SITE + "/robots.txt", { method: "GET", cache: "no-store", mode: "no-cors" })
.then(function () { if (!done) { done = true; clearTimeout(t); resolve(true); } })
.catch(function () { if (!done) { done = true; clearTimeout(t); resolve(false); } });
});
}
function go(path) {
markSeen();
// The bundled error page is served from this same origin, so this is the one place that can tell
// it where the app was headed. Without it its "Try again" has nowhere to go but back to itself.
try { localStorage.setItem(LAST, path); } catch (e) { /* private mode is fine */ }
// Fail open. A probe can fail for reasons that have nothing to do with the ward's wifi — a
// proxy that dislikes cross-origin requests, a captive portal, a WebView quirk — and refusing
// to open the app because one HEAD request was awkward is worse than letting it try.
// navigator.onLine reporting false is the one signal worth acting on by itself.
if (navigator.onLine === false) { window.__pending = path; show("offline"); return; }
reachable().then(function (ok) {
if (ok || navigator.onLine !== false) location.replace(SITE + path);
else { window.__pending = path; show("offline"); }
});
}
$("signin").addEventListener("click", function () { go("/m/login"); });
$("signup").addEventListener("click", function () { go("/m/signup"); });
$("retry").addEventListener("click", function () { go(window.__pending || "/m"); });
// The video is bundled, so it is ready immediately; the poster only covers the first frame or two.
var vid = $("vid");
vid.addEventListener("playing", function () { $("poster").style.display = "none"; });
var reduce = false;
try { reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) { /* old WebView: assume not */ }
/* Started when the welcome is actually on screen, not at script load.
For the first 1.5 seconds #welcome is `hidden`, so the element is display:none, and a WebView
is entitled to refuse or stall playback for something that isn't in the layout — the promise
rejects, the poster stays, and it looks like the video is missing. */
function playWelcome() {
// Nobody who has asked the OS for less movement gets a loop started for them. The poster is a
// still from the same footage, and the button turns it on for anyone who does want it.
if (reduce) return;
// On the frame after the welcome is laid out, rather than in the same tick as `hidden = false`.
// Chrome will not start a video it considers off screen, and it does not come back to retry.
requestAnimationFrame(function () {
attempt();
});
}
function attempt() {
var p = vid.play();
if (p && p.catch) p.catch(function () {
// One more try once there is definitely something decoded to show. After that the poster
// stands in, which is a still from the same footage and reads as intended.
vid.addEventListener("canplay", retry);
});
}
function retry() {
vid.removeEventListener("canplay", retry);
var p = vid.play();
if (p && p.catch) p.catch(function () { /* refused: the poster stands in, as designed */ });
}
// The label follows the video's own state rather than the click, so it stays honest when playback
// is refused, or when the loop is stopped by anything other than this button.
function motionLabel() {
var word = vid.paused ? "Play" : "Pause";
$("motion").textContent = word;
$("motion").setAttribute("aria-label", word + " background video");
}
vid.addEventListener("play", motionLabel);
vid.addEventListener("pause", motionLabel);
$("motion").addEventListener("click", function () {
if (vid.paused) attempt();
else vid.pause();
});
motionLabel();
setTimeout(function () {
// Someone who has been here before goes straight to the app; the welcome screen is a first-run
// thing, not a toll gate on every launch.
if (seen()) go("/m");
else { show("welcome"); playWelcome(); }
}, SPLASH_MS);
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB