Add guest links for temporary shared access

Handing a dog sitter the ability to log a pee meant handing them the account
password: permanent, total control, revocable only by changing it. Settings →
Guest access now mints a URL that does the one thing instead.

A link is a session, not an account. Opening /guest/<token> inserts an ordinary
session row against the owner's user_id, tagged with the link it came from, so
every data path downstream — sync, photos, the profile — stays scoped by
user_id exactly as before and needed no changes at all. Only the capability
checks differ by role, which is what kept this from touching the sync contract.
Redemption is a plain GET so tapping the link in a message works, and the 303
to / leaves the token out of the address bar, bookmarks and the PWA start URL.

What a guest cannot change is enforced in the upsert, not in the UI. The WHERE
clause gains a logged_by_share test: an owner (empty share id) may change
anything, a guest only rows carrying their own link's id. A sitter can fix up
their own entries and cannot rewrite or delete one of the owner's, including
everything logged before this existed, since those rows carry the empty id too.
Deletes come along free, being tombstones. The test is on the link id rather
than its label because two links can easily both be "Sitter", and the id is
also why /api/me hands the guest its share id: the client needs it to know what
to grey out. The exercise library is the owner's on the same reasoning — a
guest trains against it but the server drops any exercise a guest sends.

Attribution is stamped from the session on insert and left out of DO UPDATE
SET, so it is decided once by whoever logged the event and survives every later
edit. It never comes off the wire, so it cannot be forged — a guest re-POSTs
the owner's whole event list on every sync, but those rows already exist and
keep their stored values.

Expiry is a date the owner picks; the link dies at the end of that day in their
own timezone, which the client computes because the server has no way to know
it. Sessions are capped at the link's own end, and every request re-checks the
link is live rather than trusting the session row, so revoking kicks a guest
out on their next request instead of whenever their session happens to lapse.
Only the token hash is stored, as with session tokens, so the URL is shown once
at creation and cannot be read back.

The client side follows from that. A guest opening someone else's entry gets
the edit dialog read-only rather than a form that would silently discard what
they typed, and mergeSynced takes the server's copy for anything they may not
change — otherwise a refused write would sit in their cache forever showing an
edit that never happened. An ended link wipes their cached copy of someone
else's history and says so, rather than offering a sign-in form they have no
password for.
This commit is contained in:
Alexander Heldt
2026-09-07 11:19:20 +00:00
parent 103a5f9937
commit e22031ed4f
9 changed files with 1698 additions and 110 deletions
+348 -25
View File
@@ -6,6 +6,21 @@
// cached events/profile. currentUser is set by the auth gate before the app
// boots, so these are only ever called once a user is known.
let currentUser = null;
// A guest is someone here on a share link (see "guest links" in the README):
// the same data as the owner, minus everything under Settings that is the
// owner's to decide. currentUser.id is the owner's either way, which is what
// keeps the cache keys below pointing at the right data.
const isGuest = () => currentUser?.role === "guest";
const guestLabel = () => (isGuest() ? (currentUser.label || "") : "");
const guestShareId = () => (isGuest() ? (currentUser.shareId || "") : "");
// Who may change an already-logged event. The owner may change anything on
// their own account; a guest may only touch what they logged themselves, so a
// sitter can fix up their own entries and cannot rewrite or delete a single
// one of the owner's. Keyed on the link id rather than its label, because two
// links can easily carry the same label ("Sitter"). The server enforces the
// same rule in Store.sync — this is what keeps the UI honest about it.
const canEditEvent = (ev) => !isGuest() || ev.loggedByShare === guestShareId();
const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`;
const exercisesKey = () => `puppy-tracker:${currentUser.id}:exercises:v1`;
@@ -338,6 +353,14 @@
grams: Number.isFinite(grams) ? grams : undefined,
exerciseId: exerciseId || "",
updatedAt: now,
// Only set when logging through a guest link, and only so the badge and
// the "you may edit this" check work before the first sync: the server
// stamps both authoritatively on insert, and mergeSynced keeps our copy
// when updatedAt ties, so without a local stamp neither would settle
// until some later edit. Both sides compute the same values, so the two
// never disagree.
loggedBy: guestLabel(),
loggedByShare: guestShareId(),
};
events.push(ev);
saveAll(events);
@@ -1113,6 +1136,15 @@
<span class="label">${escapeText(label)}</span>
<span class="note"></span>
`;
// Logged through a guest link: say whose, so a row you don't remember
// making has an explanation attached to it.
if (ev.loggedBy) {
const by = document.createElement("span");
by.className = "by";
by.textContent = ev.loggedBy;
by.title = `Logged by ${ev.loggedBy} on a guest link`; // the badge is truncated
li.querySelector(".label").after(by);
}
const noteEl = li.querySelector(".note");
if (ev.type === "weight" && Number.isFinite(ev.weight)) {
noteEl.textContent = ev.note ? `${formatWeight(ev.weight)} · ${ev.note}` : formatWeight(ev.weight);
@@ -2422,6 +2454,8 @@
const exercises = liveExercises();
list.innerHTML = "";
empty.hidden = exercises.length > 0;
// Adding to the library is the owner's, like editing it.
document.getElementById("exercise-add").hidden = isGuest();
for (const ex of exercises) {
const times = events
@@ -2477,17 +2511,24 @@
detail.className = "ex-detail";
const noteEl = document.createElement("p");
noteEl.className = "ex-note";
noteEl.textContent = ex.note || "No instructions yet — tap Edit to add how to train this.";
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "ghost ex-edit";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
openExerciseDialog(ex);
});
noteEl.textContent = ex.note
|| (isGuest() ? "No instructions for this one yet."
: "No instructions yet — tap Edit to add how to train this.");
detail.appendChild(noteEl);
detail.appendChild(editBtn);
// The exercise library is the owner's, not a log: a guest trains against
// it and reads the instructions, but doesn't get to rename or delete
// anything in it. The server drops guest-sent exercises to match.
if (!isGuest()) {
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "ghost ex-edit";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
openExerciseDialog(ex);
});
detail.appendChild(editBtn);
}
li.appendChild(row);
li.appendChild(detail);
@@ -2529,6 +2570,8 @@
if (sleepTitle) sleepTitle.textContent = cfg.name ? `When ${cfg.name} sleeps` : "When sleeping";
const walkTitle = document.getElementById("walk-timeline-title");
if (walkTitle) walkTitle.textContent = cfg.name ? `When ${cfg.name} walks` : "When walking";
// The guest banner names the dog too, so it follows the profile in.
renderGuestBanner();
}
// Dim the quick actions that don't fit the current state — a nudge
@@ -2635,7 +2678,12 @@
// newer updatedAt than the server's copy wins — that covers items the user
// added/edited during the in-flight sync request. Shared by the events and
// exercises collections, which follow the same LWW contract.
function mergeSynced(serverItems, load, save) {
//
// serverWins overrides that for items we are not allowed to change: the
// server will have refused the write, so keeping our newer local copy would
// leave the screen showing an edit that never happened and re-posting it
// forever. Taking the server's version instead makes the client heal itself.
function mergeSynced(serverItems, load, save, serverWins = () => false) {
const localById = new Map(load().map(e => [e.id, e]));
const merged = new Map();
for (const se of serverItems) {
@@ -2643,6 +2691,7 @@
}
for (const [id, le] of localById) {
const se = merged.get(id);
if (se && serverWins(se)) continue;
if (!se || (le.updatedAt || 0) > (se.updatedAt || 0)) {
merged.set(id, le);
}
@@ -2673,17 +2722,21 @@
const exRes = await fetch("api/exercises/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ exercises: loadExercises() }),
// A guest receives the exercise library but never writes to it, so it
// sends nothing rather than posting a list the server would discard.
body: JSON.stringify({ exercises: isGuest() ? [] : loadExercises() }),
});
if (exRes.status === 401) { handleLoggedOut(); return; }
if (!exRes.ok) throw new Error(`HTTP ${exRes.status}`);
const exBody = await exRes.json();
if (Array.isArray(exBody.exercises)) {
mergeSynced(exBody.exercises, loadExercises, saveExercises);
// Same reasoning as the events below: a guest's local exercise edits
// can never land, so the server's copy is always the truth.
mergeSynced(exBody.exercises, loadExercises, saveExercises, isGuest);
}
if (Array.isArray(body.events)) {
mergeSynced(body.events, loadAll, saveAll);
mergeSynced(body.events, loadAll, saveAll, (ev) => !canEditEvent(ev));
}
lastSynced = Date.now();
lastError = null;
@@ -2925,7 +2978,12 @@
const editDate = document.getElementById("edit-date");
const editTime = document.getElementById("edit-time");
const editNote = document.getElementById("edit-note");
const editLoggedBy = document.getElementById("edit-logged-by");
const editReadOnly = document.getElementById("edit-readonly");
const editTitle = document.getElementById("edit-title");
const editDelete = document.getElementById("edit-delete");
const editSave = document.querySelector('#edit-form button[value="save"]');
const editCancel = document.querySelector('#edit-form button[value="cancel"]');
const editPhotoInput = document.getElementById("edit-photo-input");
const editPhotoBtn = document.getElementById("edit-photo-btn");
const editPhotoPreview = document.getElementById("edit-photo-preview");
@@ -2957,8 +3015,26 @@
editPhotoInput.value = "";
}
// Turns the edit dialog into a viewer: every field disabled, Save and Delete
// gone, only Cancel left. Used when a guest opens an entry that isn't theirs
// — the server would refuse the change anyway (Store.sync), and a form that
// silently discards what you typed is worse than one that says it is closed.
function setEditReadOnly(on) {
editReadOnly.hidden = !on;
editTitle.textContent = on ? "Event" : "Edit event";
for (const el of [editDate, editTime, editNote, editWeight, editGrams, editPhotoBtn]) {
el.disabled = on;
}
editDelete.hidden = on;
editSave.hidden = on;
editCancel.textContent = on ? "Close" : "Cancel";
}
async function openEditDialog(ev) {
editingId = ev.id;
editLoggedBy.hidden = !ev.loggedBy;
if (ev.loggedBy) editLoggedBy.textContent = `Logged by ${ev.loggedBy} on a guest link.`;
setEditReadOnly(!canEditEvent(ev));
editDate.value = toDateInput(ev.at);
editTime.value = toTimeInput(ev.at);
editNote.value = ev.note || "";
@@ -3075,6 +3151,8 @@
const settingsPedigree = document.getElementById("settings-pedigree");
const settingsTheme = document.getElementById("settings-theme");
const settingsConfetti = document.getElementById("settings-confetti");
const settingsProfile = document.getElementById("settings-profile");
const settingsDanger = document.getElementById("settings-danger");
// Apply live so the toggle previews immediately (independent of Save/Cancel).
settingsTheme.addEventListener("change", () => {
@@ -3088,15 +3166,34 @@
settingsPedigree.value = cfg.pedigreeId;
settingsTheme.checked = effectiveTheme() === "dark";
settingsConfetti.checked = confettiEnabled();
// A guest keeps the two device-local preferences and loses everything that
// belongs to the account — profile, reminders, guest links, deletion.
const guest = isGuest();
settingsProfile.hidden = guest;
settingsDanger.hidden = guest;
guestAccess.hidden = guest;
settingsDialog.showModal();
refreshRemindersUI();
setTimeout(() => settingsName.focus(), 50);
if (!guest) {
// Re-defaulted per open: the app can sit on screen for days, and a date
// that was "tomorrow" when it launched may be in the past by now.
resetGuestExpiry();
refreshGuestLinks();
}
setTimeout(() => (guest ? settingsTheme : settingsName).focus(), 50);
}
document.getElementById("settings-btn").addEventListener("click", openSettingsDialog);
settingsForm.querySelector('button[value="save"]').addEventListener("click", async (e) => {
e.preventDefault();
// A guest only ever had the two device-local toggles on screen. Saving the
// profile from here would push the blanked-out fields over the owner's.
if (isGuest()) {
setConfettiEnabled(settingsConfetti.checked);
settingsDialog.close();
return;
}
const cfg = {
name: settingsName.value.trim(),
birthday: settingsBirthday.value,
@@ -3129,6 +3226,173 @@
settingsDialog.close();
});
// ---------- guest links ----------
// Hand someone a URL that logs events on this account without giving them the
// password. The server holds only a hash of the token (server/auth.go), so the
// URL is shown once, right after it is minted, and never again.
const guestAccess = document.getElementById("guest-access");
const guestLabelIn = document.getElementById("guest-label");
const guestCreate = document.getElementById("guest-create");
const guestError = document.getElementById("guest-error");
const guestNew = document.getElementById("guest-new");
const guestNewURL = document.getElementById("guest-new-url");
const guestCopy = document.getElementById("guest-copy");
const guestListEl = document.getElementById("guest-list");
const guestEmpty = document.getElementById("guest-empty");
const guestExpires = document.getElementById("guest-expires");
const guestExpHint = document.getElementById("guest-expires-hint");
// The owner picks a date; the link dies at the end of it. Working in whole
// days is what people actually mean ("she has him until Sunday"), and doing
// the conversion here is the only place the guest's timezone is known — the
// server just stores the instant it is handed.
function guestExpiryMs() {
const [y, m, d] = (guestExpires.value || "").split("-").map(Number);
if (!y || !m || !d) return 0;
return endOfDay(new Date(y, m - 1, d)).getTime();
}
function renderGuestExpiryHint() {
const ms = guestExpiryMs();
if (!ms) { guestExpHint.textContent = "Pick the last day the link should work."; return; }
if (ms <= Date.now()) { guestExpHint.textContent = "That date has already passed."; return; }
guestExpHint.textContent = `Stops working ${formatWhen(ms)}.`;
}
// Default to tomorrow: the common case is a sitter for the day, and a link
// that dies at midnight tonight is rarely what anyone wants.
function resetGuestExpiry() {
guestExpires.min = toDateInput(Date.now());
guestExpires.value = toDateInput(Date.now() + 86_400_000);
renderGuestExpiryHint();
}
guestExpires.addEventListener("change", renderGuestExpiryHint);
resetGuestExpiry();
// The server never sees its own public origin (it may sit behind any proxy),
// so the URL is composed here, against wherever this page is actually served.
function guestURL(token) {
return new URL(`guest/${token}`, location.href).href;
}
// Day and month as well as the weekday: a 7-day link expires on the same
// weekday it was made, which "Sun 09:00" alone wouldn't tell apart.
function formatWhen(ms) {
return new Date(ms).toLocaleString(undefined, {
weekday: "short", day: "numeric", month: "short",
hour: "2-digit", minute: "2-digit",
});
}
function renderGuestLinks(links) {
guestListEl.innerHTML = "";
guestEmpty.textContent = "No active links."; // may hold a stale error
guestEmpty.hidden = links.length > 0;
for (const l of links) {
const li = document.createElement("li");
li.className = "guest-item";
const used = l.lastUsed
? `last used ${formatRelative(l.lastUsed)}`
: "never used";
li.innerHTML = `
<span class="guest-item-main">
<span class="guest-item-label"></span>
<span class="guest-item-sub"></span>
</span>
`;
li.querySelector(".guest-item-label").textContent = l.label;
li.querySelector(".guest-item-sub").textContent = `expires ${formatWhen(l.expires)} · ${used}`;
const revoke = document.createElement("button");
revoke.type = "button";
revoke.className = "linklike guest-revoke";
revoke.textContent = "Revoke";
revoke.addEventListener("click", () => revokeGuestLink(l.id, l.label));
li.appendChild(revoke);
guestListEl.appendChild(li);
}
}
async function refreshGuestLinks() {
guestNew.hidden = true;
guestError.hidden = true;
try {
const res = await fetch("api/shares");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
renderGuestLinks(Array.isArray(body.links) ? body.links : []);
} catch (err) {
// Offline is the common case here and not worth an error: the list is
// server state, so it simply isn't knowable right now.
guestListEl.innerHTML = "";
guestEmpty.hidden = false;
guestEmpty.textContent = navigator.onLine
? "Couldn't load your links."
: "Offline — guest links need a connection.";
}
}
guestCreate.addEventListener("click", async () => {
guestError.hidden = true;
const expires = guestExpiryMs();
if (!expires || expires <= Date.now()) {
guestError.textContent = "Pick a date in the future for the link to stop working.";
guestError.hidden = false;
return;
}
guestCreate.disabled = true;
try {
const res = await fetch("api/shares", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: guestLabelIn.value.trim(), expires }),
});
if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`);
const link = await res.json();
guestLabelIn.value = "";
resetGuestExpiry();
// The refresh clears any previously shown URL, so reveal this one after
// it, not before.
await refreshGuestLinks();
guestNewURL.textContent = guestURL(link.token);
guestCopy.textContent = "Copy link";
guestNew.hidden = false;
} catch (err) {
guestError.textContent = err.message || "Couldn't create a link";
guestError.hidden = false;
} finally {
guestCreate.disabled = false;
}
});
guestCopy.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(guestNewURL.textContent);
guestCopy.textContent = "Copied";
setTimeout(() => { guestCopy.textContent = "Copy link"; }, 1500);
} catch {
// Clipboard needs a secure context and can be refused; the URL is on
// screen either way, so select it and let them copy by hand.
const range = document.createRange();
range.selectNodeContents(guestNewURL);
const sel = getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
});
async function revokeGuestLink(id, label) {
if (!confirm(`Turn off the link for “${label}”? Whoever has it loses access straight away.`)) return;
try {
const res = await fetch(`api/shares/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) throw new Error(`HTTP ${res.status}`);
await refreshGuestLinks();
} catch (err) {
guestError.textContent = err.message || "Couldn't revoke that link";
guestError.hidden = false;
}
}
// ---------- reminders ----------
// The server decides when a reminder is due and pushes it (server/reminders.go);
// this side only manages the browser's push subscription and the rule settings.
@@ -3308,6 +3572,11 @@
// available at all (the server may have no VAPID key, the browser may have no
// push support) and refreshes an existing subscription.
async function initReminders() {
// Reminders belong to the account, not the device: a guest subscribing here
// would route the owner's reminders to the sitter's lock screen. The server
// refuses these routes for a guest anyway; bailing early keeps the section
// from flashing up on iOS, where it appears before any request is made.
if (isGuest()) return;
if (!pushSupported()) {
// On iOS this is the Home Screen requirement rather than a missing feature,
// and it's worth saying so — the toggle is otherwise just absent.
@@ -4064,11 +4333,7 @@
}
// Account is gone server-side. Wipe this user's local cache before the
// reload drops us back on the login screen.
try {
localStorage.removeItem(eventsKey());
localStorage.removeItem(configKey());
localStorage.removeItem(exercisesKey());
} catch { /* ignore */ }
clearLocalCache();
clearUser();
deleteAccountDialog.close();
location.reload();
@@ -4419,6 +4684,9 @@
const authSub = document.getElementById("auth-sub");
const authToggleBtn= document.getElementById("auth-toggle-btn");
const authToggleTxt= document.getElementById("auth-toggle-text");
const authCard = document.getElementById("auth-card");
const guestEnded = document.getElementById("guest-ended");
const guestBanner = document.getElementById("guest-banner");
let authMode = "login"; // or "register"
let appStarted = false;
@@ -4477,23 +4745,56 @@
initReminders();
}
function showAuth() {
// ended: show the "guest link has run out" card instead of the sign-in form,
// which a guest has no credentials to fill in anyway.
function showAuth({ ended = false } = {}) {
appEl.hidden = true;
authCard.hidden = ended;
guestEnded.hidden = !ended;
authScreen.hidden = false;
}
function showApp() {
authScreen.hidden = true;
appEl.hidden = false;
renderGuestBanner();
}
// Says whose account this is, under which name the guest's entries will
// appear, and when the link runs out. Owners never see it.
function renderGuestBanner() {
if (!isGuest()) { guestBanner.hidden = true; return; }
const name = loadConfig().name;
const until = currentUser.expires
? ` · access ends ${formatWhen(currentUser.expires)}`
: "";
guestBanner.textContent =
`Guest access${name ? ` to ${name}` : ""} as ${currentUser.label}` +
`${until} — anything you log is tagged with your name.`;
guestBanner.hidden = false;
}
// Called when the server reports we're no longer authenticated (expired or
// revoked session). Drop back to the login screen without wiping the local
// cache — logging back in as the same user picks it straight back up.
// revoked session). An owner just drops back to the login screen with their
// cache intact — signing back in picks it straight back up. A guest's link
// has ended for good, so their copy of someone else's history should not stay
// sitting in their browser: wipe it, and say what happened.
function handleLoggedOut() {
const wasGuest = isGuest();
if (wasGuest) clearLocalCache();
clearUser();
setStatus("offline");
showAuth();
showAuth({ ended: wasGuest });
}
// Drops this account's cached data from localStorage. currentUser must still
// be set, since the keys are namespaced by its id.
function clearLocalCache() {
try {
localStorage.removeItem(eventsKey());
localStorage.removeItem(configKey());
localStorage.removeItem(exercisesKey());
} catch { /* ignore */ }
}
function renderAuthMode() {
@@ -4542,6 +4843,9 @@
});
document.getElementById("logout-btn").addEventListener("click", async () => {
// Leaving as a guest ends this session but not the link — they can tap it
// again. Their cache goes either way; it is someone else's record.
if (isGuest()) clearLocalCache();
try { await fetch("api/logout", { method: "POST" }); } catch { /* ignore */ }
clearUser();
// Full reload is the simplest way to clear in-memory app state and timers.
@@ -4553,10 +4857,19 @@
// last cached session so offline data stays reachable — a later sync will
// 401 and bounce to login if that session has actually gone stale.
(async function bootstrap() {
// /guest/<token> bounces here with this marker when the link was already
// expired or revoked, so we can say so rather than show a sign-in form.
const params = new URLSearchParams(location.search);
const deadLink = params.get("guest") === "expired";
if (params.has("guest")) {
history.replaceState(null, "", location.pathname);
}
try {
const res = await fetch("api/me");
if (res.ok) {
setUser(await res.json());
applyRole();
showApp();
startApp();
return;
@@ -4566,12 +4879,22 @@
const cached = cachedUser();
if (cached) {
currentUser = cached;
applyRole();
showApp();
startApp();
return;
}
}
renderAuthMode();
showAuth();
showAuth({ ended: deadLink });
})();
// One-time, role-dependent chrome. Everything else a guest sees or doesn't is
// decided when the relevant dialog opens.
function applyRole() {
if (!isGuest()) return;
const leave = document.getElementById("logout-btn");
leave.setAttribute("aria-label", "Leave");
leave.title = "Leave";
}
})();
+1
View File
@@ -1,4 +1,5 @@
[
{ "date": "2026-09-06", "text": "You can hand someone temporary access without giving them your login. Settings → “Guest access” creates a link — say who it's for and pick the last day it should work — and whoever opens it lands straight in the app on your dog, able to log events and read all the history and charts. They can't change your entries: a guest may fix up or delete what they logged themselves, but everything you logged is read-only to them, and so is the puppy profile, the pedigree ID, your reminders, the exercise list, other guest links and deleting the account. You can still edit anything on your own account, theirs included. The link is shown once when you make it, so copy it then; every live link is listed in Settings with when it expires and when it was last used, and Revoke cuts access off immediately, mid-session. Anything logged on a link is tagged with that link's name in the History log — “💧 Pee · Anna” — and the tag sticks even if you edit the entry afterwards" },
{ "date": "2026-09-04", "text": "The three day-long charts — “By hour of day”, “When sleeping” and “When walking” — now mark the current time with a small vertical line and caret. On the sleeping and walking rows it also shows where today's row stops, and it lines the same clock position up across every day above it" },
{ "date": "2026-09-01", "text": "Removed the walking goal from the Walk trend — the dashed target line, its legend chip and the ✓ that marked a day as met. It came from the “five-minute rule” (five minutes per month of age, twice a day), which is a widely repeated rule of thumb rather than veterinary guidance, and the app was stating it more confidently than it deserved. The chart is now just a record of what you walked, against yesterday and the average" },
{ "date": "2026-09-01", "text": "The 7d / 14d / 30d buttons have moved out of the Sleep panel onto their own “Charts cover” row, just under the Log event buttons. They always set the window for every chart on the page — training, timing, sleep, walks, pees/poos/meals — but sitting inside the Sleep panel made them look like a sleep setting" },
+79 -15
View File
@@ -40,7 +40,7 @@
<!-- Login / register gate. Shown until the session check succeeds; the app
(#app) stays hidden behind it so no puppy data paints while logged out. -->
<div id="auth-screen" class="auth-screen" hidden>
<div class="auth-card">
<div class="auth-card" id="auth-card">
<h1>🐶 Puppy Tracker</h1>
<p class="auth-sub" id="auth-sub">Sign in to continue</p>
<form id="auth-form">
@@ -61,6 +61,18 @@
<button type="button" id="auth-toggle-btn" class="linklike">Create one</button>
</p>
</div>
<!-- Shown instead of the form when a guest link has run out or been
revoked. A guest has no password to sign in with, so offering them
the form would only be confusing. -->
<div class="auth-card" id="guest-ended" hidden>
<h1>🐶 Puppy Tracker</h1>
<p class="auth-sub">This guest link has ended</p>
<p class="muted-note">
It either expired or was turned off by the owner. Ask them for a new
link to keep logging.
</p>
</div>
</div>
<div id="app" hidden>
@@ -77,6 +89,10 @@
</div>
</header>
<!-- Only ever shown to a guest, so it is obvious whose dog this is, under
which name their entries will appear, and when the link runs out. -->
<p id="guest-banner" class="guest-banner" hidden></p>
<main>
<section class="day-bar">
<!-- Compact twin of the big timer below: invisible (but keeping its
@@ -414,17 +430,22 @@
<dialog id="settings-dialog">
<form method="dialog" id="settings-form">
<h3>Puppy settings</h3>
<label>Name
<input type="text" id="settings-name" placeholder="e.g. Rex" autocomplete="off" />
</label>
<label>Birthday
<input type="date" id="settings-birthday" />
</label>
<label>Pedigree ID
<input type="text" id="settings-pedigree" autocomplete="off" spellcheck="false"
placeholder="SKK chip or reg. number (optional)" />
</label>
<p class="settings-hint">Set your dog's SKK chip or registration number to unlock the 🌳 pedigree page.</p>
<!-- The profile is the owner's to set, so this block is hidden for a
guest; the two toggles below it are device-local preferences and
stay for everyone. -->
<div id="settings-profile">
<label>Name
<input type="text" id="settings-name" placeholder="e.g. Rex" autocomplete="off" />
</label>
<label>Birthday
<input type="date" id="settings-birthday" />
</label>
<label>Pedigree ID
<input type="text" id="settings-pedigree" autocomplete="off" spellcheck="false"
placeholder="SKK chip or reg. number (optional)" />
</label>
<p class="settings-hint">Set your dog's SKK chip or registration number to unlock the 🌳 pedigree page.</p>
</div>
<label class="toggle-row">
<span>Dark mode</span>
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
@@ -444,12 +465,48 @@
<div id="reminders-rules" hidden></div>
<button type="button" id="reminders-test" class="ghost" hidden>Send a test notification</button>
</div>
<!-- Guest links: hand a dog sitter a URL that logs events on this
account without giving them the password. Owner-only. -->
<div id="guest-access">
<hr class="settings-sep" />
<h4 class="settings-subhead">Guest access</h4>
<p class="settings-hint">
A link that lets someone log events on this account — no password,
no account of their own. It stops working on its own, and you can
turn it off at any time.
</p>
<label>Who is it for?
<input type="text" id="guest-label" autocomplete="off" maxlength="40"
placeholder="e.g. Anna (sitter)" />
</label>
<label>Works until
<input type="date" id="guest-expires" />
</label>
<p class="settings-hint" id="guest-expires-hint"></p>
<button type="button" id="guest-create" class="ghost">Create link</button>
<p id="guest-error" class="auth-error" hidden></p>
<!-- The URL is shown once, here, and never again: only its hash is
stored, so it cannot be read back later. -->
<div id="guest-new" class="guest-new" hidden>
<p class="settings-hint">Copy it now — for safety it isn't shown again.</p>
<code id="guest-new-url" class="guest-url"></code>
<button type="button" id="guest-copy" class="ghost">Copy link</button>
</div>
<ul id="guest-list" class="guest-list"></ul>
<p id="guest-empty" class="settings-hint">No active links.</p>
</div>
<menu>
<button value="cancel" class="ghost">Cancel</button>
<button value="save" id="settings-save">Save</button>
</menu>
<hr class="settings-sep" />
<button type="button" id="delete-account-btn" class="danger danger-block">Delete account…</button>
<div id="settings-danger">
<hr class="settings-sep" />
<button type="button" id="delete-account-btn" class="danger danger-block">Delete account…</button>
</div>
</form>
</dialog>
@@ -524,7 +581,14 @@
<dialog id="edit-dialog">
<form method="dialog" id="edit-form">
<h3>Edit event</h3>
<h3 id="edit-title">Edit event</h3>
<p id="edit-logged-by" class="settings-hint" hidden></p>
<!-- Shown to a guest looking at an entry that isn't theirs: the dialog
opens read-only rather than not opening at all, so the details are
still there to read. -->
<p id="edit-readonly" class="settings-hint" hidden>
This was logged on the owner's own account, so only they can change it.
</p>
<label>Time
<div class="time-row">
<input type="date" id="edit-date" />
+78
View File
@@ -933,6 +933,84 @@ button.linklike:hover { text-decoration: underline; filter: none; }
}
.danger-text strong { color: var(--danger); }
/* ---------- guest links ---------- */
/* The one-time URL. Shown once and never again, so it gets a box of its own
rather than sitting inline where it could be missed. */
.guest-new {
background: var(--accent-soft);
border-radius: var(--radius);
padding: 10px 12px;
margin: 12px 0;
}
.guest-new .settings-hint { margin: 0 0 6px; }
.guest-url {
display: block;
font-size: 0.8rem;
word-break: break-all;
margin-bottom: 8px;
line-height: 1.4;
}
.guest-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
}
.guest-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-top: 1px solid var(--border);
}
.guest-item-main {
flex: 1;
min-width: 0;
}
.guest-item-label {
display: block;
font-weight: 600;
overflow-wrap: anywhere;
}
.guest-item-sub {
display: block;
color: var(--muted);
font-size: 0.8rem;
}
button.guest-revoke { color: var(--danger); flex: none; }
/* Only a guest ever sees this, directly under the header. No bottom margin:
main's own top padding provides the gap to the first panel. */
.guest-banner {
margin: 4px 0 0;
padding: 8px 12px;
border-radius: var(--radius);
background: var(--accent-soft);
color: var(--text);
font-size: 0.85rem;
line-height: 1.4;
}
.guest-banner[hidden] { display: none; }
/* Who logged an event, when it came in on a guest link. Small caps so it reads
as a margin note against the event label rather than competing with it.
The row is a single non-wrapping line, so the badge is capped and ellipsised:
a long label ("Anna the neighbour's daughter") must not squeeze the note out. */
.event .by {
flex: none;
max-width: 10ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
background: var(--accent-soft);
border-radius: 999px;
padding: 2px 8px;
}
/* ---------- settings toggle switch ---------- */
.toggle-row {
display: flex;
+5
View File
@@ -80,6 +80,11 @@ self.addEventListener("fetch", (event) => {
// Other API calls: never cache — sync must reflect live server state.
if (url.pathname.includes("/api/")) return;
// Guest links: a one-shot secret URL that must reach the server to be
// redeemed, and that has no business being written into the asset cache
// under a key containing its token.
if (url.pathname.includes("/guest/")) return;
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;