Compare commits
2
Commits
103a5f9937
...
da68b733e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da68b733e4 | ||
|
|
e22031ed4f |
@@ -35,6 +35,10 @@ source-of-truth and sync between devices.
|
||||
- All data is scoped to the signed-in account (see [Accounts](#accounts)): every
|
||||
event, profile and photo carries a `user_id`, and `localStorage` is namespaced
|
||||
per user so two accounts on one browser never mix.
|
||||
- A day can be marked **not counted** (see [Days that don't
|
||||
count](#days-that-dont-count)). The mark is itself an event
|
||||
(`type: "day-excluded"`, timestamped at noon), so it syncs and un-marks by
|
||||
tombstone like everything else.
|
||||
|
||||
A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` /
|
||||
`sync error` / `offline`. Tap it to force-sync.
|
||||
@@ -49,7 +53,7 @@ puppy-tracker/
|
||||
│ ├── go.mod
|
||||
│ ├── go.sum
|
||||
│ ├── main.go # SQLite store, LWW sync, static file serving
|
||||
│ ├── auth.go # accounts, sessions, invite-gated registration
|
||||
│ ├── auth.go # accounts, sessions, invite-gated registration, guest links
|
||||
│ ├── reminders.go # reminder rules, the evaluation loop, push subscriptions
|
||||
│ ├── webpush.go # VAPID + RFC 8291/8188 message encryption
|
||||
│ ├── pedigree.go # SKK lookup, background crawl, per-dog cache
|
||||
@@ -100,6 +104,85 @@ events, profile and photos.
|
||||
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
|
||||
aren't sent in the clear.
|
||||
|
||||
## Days that don't count
|
||||
|
||||
Not every logged day is equally trustworthy. A day someone else had the puppy —
|
||||
a sitter who forgets half the pees, a stay at kennels — leaves a thin record
|
||||
that reads exactly like a real one, and then drags the averages down and puts a
|
||||
misleading trough in every chart. **Not counted**, in the overview panel's
|
||||
heading, takes the day you're looking at out of the aggregates.
|
||||
|
||||
- **Nothing is deleted or hidden.** The day's overview, history and sleep/wake
|
||||
list are unchanged — just dimmed and labelled. Navigate to it and it is all
|
||||
still there.
|
||||
- **What stops counting** is the behaviour: sleep hours, timeline and trend,
|
||||
walk minutes and patterns, pee/poo/meal counts, food, by-hour, the training
|
||||
grid, and the Timing panel's typical gaps.
|
||||
- **What keeps counting** is weight and notes. A weigh-in and a vet note are
|
||||
records of fact, not behaviour a sparse logger distorts, so they stay on the
|
||||
weight curve and in the Notes log.
|
||||
- **Charts keep the day's slot**, drawn as a hatch rather than a bar. Dropping
|
||||
it would make consecutive bars stop being consecutive days, and an empty bar
|
||||
would read as "the puppy barely slept" — the exact misreading being fixed.
|
||||
- **Gaps that reach across a marked day are discarded, not measured.** With the
|
||||
day's events gone, Tuesday's last pee sits next to Thursday's first, and
|
||||
subtracting invents a thirty-hour gap that would blow out the Timing panel's
|
||||
"longest" far worse than the sparse day did. Sleep and walk durations need no
|
||||
such care — `sleepMsInRange` / `walkMsInRange` already clip to the day being
|
||||
measured, so a nap running in from a marked day contributes only its counted
|
||||
part.
|
||||
- **Owner-only.** A guest can't decide their own thin day shouldn't count, nor
|
||||
take a good one out of the averages; the server drops `day-excluded` events
|
||||
arriving on a guest session and the client hides the control.
|
||||
|
||||
## Guest links
|
||||
|
||||
A dog sitter needs to log a pee; they do not need your password. **Settings →
|
||||
Guest access** mints a link that does exactly the first thing.
|
||||
|
||||
- **It is a session, not an account.** Opening `/guest/<token>` mints an ordinary
|
||||
session row against *your* `user_id`, tagged with the link it came from. Every
|
||||
data path downstream — sync, photos, the profile — is scoped by `user_id` as
|
||||
before, so a guest simply is you as far as the data is concerned. Only the
|
||||
capability checks differ.
|
||||
- **What a guest gets.** The whole app to read: every panel, every chart, all
|
||||
history. They can log new events freely, and edit or delete the ones they
|
||||
logged themselves. What they don't get is anything under Settings that belongs
|
||||
to the account — the puppy profile, the pedigree id, reminders, other guest
|
||||
links, and deleting the account. Those routes are behind `requireOwner` and
|
||||
403 for a guest; the client hides the matching UI. The two device-local
|
||||
preferences (dark mode, confetti) stay, since they are the guest's own browser
|
||||
and not your account.
|
||||
- **A guest cannot change your logs.** The upsert in `Store.sync` only lets a
|
||||
guest update rows carrying their own link's id, so a sitter can fix up their
|
||||
own entries and cannot rewrite or delete a single one of yours — including
|
||||
everything logged before guest links existed. The check is on the link *id*,
|
||||
not its label, because two links can easily both be called "Sitter". You keep
|
||||
full control either way and can edit anything on your own account, theirs
|
||||
included. The exercise library is the owner's for the same reason: a guest
|
||||
logs training sessions against it but the server drops any exercise a guest
|
||||
sends. In the app a guest opening someone else's entry gets a read-only view
|
||||
rather than a form that would throw away what they typed.
|
||||
- **It expires, and you can revoke it.** You pick the last day the link should
|
||||
work; it stops at the end of that day in your own timezone. Sessions minted
|
||||
from a link are capped at the link's own expiry, so one can never outlive it,
|
||||
and every request re-checks that the link is still live — so revoking kicks
|
||||
whoever is already using it out on their very next request, not whenever their
|
||||
session happens to lapse. Revoking also deletes those session rows outright.
|
||||
- **The URL is shown once.** Only a hash of the token is stored, exactly as with
|
||||
session tokens, so a leaked database yields no working links — and the app
|
||||
cannot show you the URL again later. Settings lists each live link by label,
|
||||
expiry and when it was last used.
|
||||
- **Events say who logged them.** An event created through a link carries that
|
||||
link's label (badged in the History log) and its id (which is what authorises
|
||||
changes). The server stamps both from the session on insert and never reads
|
||||
them off the wire, so neither can be forged; both are left out of the update
|
||||
path, so a later edit by anyone keeps the original attribution.
|
||||
- **A link is a bearer token — serve over HTTPS.** Anyone holding the URL can
|
||||
redeem it until it expires. Send it over something private, and run behind TLS
|
||||
(`-secure-cookies`) as above. When a link ends, the guest's browser drops its
|
||||
cached copy of your history rather than keeping it around.
|
||||
|
||||
## Reminders
|
||||
|
||||
Opt-in push notifications for the two things that are easy to lose track of:
|
||||
|
||||
+355
-34
@@ -23,20 +23,50 @@ import (
|
||||
const (
|
||||
sessionCookie = "puppy_session"
|
||||
sessionValidity = 30 * 24 * time.Hour
|
||||
// A guest link's last_used is only refreshed this often, so "last used" can
|
||||
// be shown in Settings without a write on every single request.
|
||||
lastUsedResolution = 5 * time.Minute
|
||||
// How far ahead a guest link may be set to expire. The owner picks the date,
|
||||
// so this is only a backstop against a mistyped year turning a sitter's link
|
||||
// into a permanent credential.
|
||||
maxShareAhead = 365 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// ctxKey is an unexported type so our context values can't collide with any
|
||||
// set elsewhere.
|
||||
type ctxKey int
|
||||
|
||||
const userIDKey ctxKey = 0
|
||||
const sessionKey ctxKey = 0
|
||||
|
||||
// User is the public shape returned to clients — never the password hash.
|
||||
// Role is "owner" for a normal login and "guest" for a session minted from a
|
||||
// share link, in which case Label names the link and Email is blanked (it is
|
||||
// the owner's address, and a guest has no business seeing it).
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
// ShareID is the guest's own link id, which is what decides the events they
|
||||
// are allowed to change (see Store.sync). The client uses it to grey out
|
||||
// everything logged by someone else.
|
||||
ShareID string `json:"shareId,omitempty"`
|
||||
// Expires is the guest session's end, in Unix milliseconds. Owner sessions
|
||||
// leave it zero — they only end by logging out.
|
||||
Expires int64 `json:"expires,omitempty"`
|
||||
}
|
||||
|
||||
// session is a resolved cookie: who the request acts as, and whether it got
|
||||
// there through a guest link. ShareID is empty for an owner session.
|
||||
type session struct {
|
||||
userID string
|
||||
shareID string
|
||||
label string
|
||||
expires int64
|
||||
}
|
||||
|
||||
func (s session) guest() bool { return s.shareID != "" }
|
||||
|
||||
// Auth owns everything account-related: the users/sessions tables, the shared
|
||||
// invite code required to register, and whether session cookies are marked
|
||||
// Secure (on behind TLS/a proxy). photosDir is needed so the first account can
|
||||
@@ -121,8 +151,10 @@ func (a *Auth) verify(email, password string) (User, bool) {
|
||||
}
|
||||
|
||||
// startSession mints a token, stores its hash, and returns the raw token for
|
||||
// the cookie.
|
||||
func (a *Auth) startSession(userID string) (string, error) {
|
||||
// the cookie. shareID is empty for an owner login; for a guest it names the
|
||||
// share link, and expires is capped at that link's own end so the session can
|
||||
// never outlive the link it came from.
|
||||
func (a *Auth) startSession(userID, shareID string, expires int64) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
@@ -130,28 +162,53 @@ func (a *Auth) startSession(userID string) (string, error) {
|
||||
token := hex.EncodeToString(raw)
|
||||
now := time.Now()
|
||||
_, err := a.db.Exec(
|
||||
`INSERT INTO sessions (token, user_id, created, expires) VALUES (?, ?, ?, ?)`,
|
||||
hashToken(token), userID, now.UnixMilli(), now.Add(sessionValidity).UnixMilli())
|
||||
`INSERT INTO sessions (token, user_id, created, expires, share_id) VALUES (?, ?, ?, ?, ?)`,
|
||||
hashToken(token), userID, now.UnixMilli(), expires, shareID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// userForToken resolves a raw cookie token to a user id, honouring expiry.
|
||||
func (a *Auth) userForToken(token string) (string, bool) {
|
||||
// sessionForToken resolves a raw cookie token to the session it stands for,
|
||||
// honouring expiry. A guest session is additionally only valid while its link
|
||||
// is un-revoked and unexpired — checked here, on every request, so revoking a
|
||||
// link kicks its live sessions out immediately rather than whenever their own
|
||||
// row happens to lapse.
|
||||
func (a *Auth) sessionForToken(token string) (session, bool) {
|
||||
if token == "" {
|
||||
return "", false
|
||||
return session{}, false
|
||||
}
|
||||
var userID string
|
||||
var expires int64
|
||||
err := a.db.QueryRow(
|
||||
`SELECT user_id, expires FROM sessions WHERE token = ?`, hashToken(token),
|
||||
).Scan(&userID, &expires)
|
||||
if err != nil || time.Now().UnixMilli() > expires {
|
||||
return "", false
|
||||
var s session
|
||||
var expires, linkRevoked, linkExpires int64
|
||||
err := a.db.QueryRow(`
|
||||
SELECT s.user_id, s.expires, s.share_id,
|
||||
COALESCE(l.revoked, 0), COALESCE(l.expires, 0), COALESCE(l.label, '')
|
||||
FROM sessions s LEFT JOIN share_links l ON l.id = s.share_id
|
||||
WHERE s.token = ?`, hashToken(token),
|
||||
).Scan(&s.userID, &expires, &s.shareID, &linkRevoked, &linkExpires, &s.label)
|
||||
now := time.Now().UnixMilli()
|
||||
if err != nil || now > expires {
|
||||
return session{}, false
|
||||
}
|
||||
if s.guest() {
|
||||
if linkRevoked != 0 || now > linkExpires {
|
||||
return session{}, false
|
||||
}
|
||||
s.expires = expires
|
||||
a.touchShare(s.shareID, now)
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// touchShare records that a link was used, at lastUsedResolution granularity so
|
||||
// an active guest doesn't cause a write per request.
|
||||
func (a *Auth) touchShare(shareID string, now int64) {
|
||||
if _, err := a.db.Exec(
|
||||
`UPDATE share_links SET last_used = ? WHERE id = ? AND last_used < ?`,
|
||||
now, shareID, now-lastUsedResolution.Milliseconds()); err != nil {
|
||||
log.Printf("touch share %s: %v", shareID, err)
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func (a *Auth) endSession(token string) {
|
||||
@@ -209,7 +266,10 @@ func (a *Auth) adoptPhotos(userID string) error {
|
||||
|
||||
// ---------- cookies & middleware ----------
|
||||
|
||||
func (a *Auth) setCookie(w http.ResponseWriter, token string) {
|
||||
// setCookie writes the session cookie. expires mirrors the session row's own
|
||||
// end, so a guest's cookie lapses with the link rather than sitting around for
|
||||
// the full 30 days pointing at a session the server already refuses.
|
||||
func (a *Auth) setCookie(w http.ResponseWriter, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
@@ -217,7 +277,7 @@ func (a *Auth) setCookie(w http.ResponseWriter, token string) {
|
||||
HttpOnly: true,
|
||||
Secure: a.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(sessionValidity),
|
||||
Expires: expires,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -242,26 +302,50 @@ func cookieToken(r *http.Request) string {
|
||||
}
|
||||
|
||||
// requireUser wraps a handler so it only runs for an authenticated request,
|
||||
// stashing the user id in the context. Unauthenticated calls get a 401 that the
|
||||
// client uses as its cue to show the login screen.
|
||||
// stashing the resolved session in the context. Unauthenticated calls get a 401
|
||||
// that the client uses as its cue to show the login screen.
|
||||
func (a *Auth) requireUser(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := a.userForToken(cookieToken(r))
|
||||
s, ok := a.sessionForToken(cookieToken(r))
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userIDKey, userID)))
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), sessionKey, s)))
|
||||
}
|
||||
}
|
||||
|
||||
// userID returns the authenticated user's id; only valid inside a requireUser
|
||||
// handler.
|
||||
func userID(r *http.Request) string {
|
||||
id, _ := r.Context().Value(userIDKey).(string)
|
||||
return id
|
||||
// requireOwner is requireUser plus "and not through a guest link". It guards
|
||||
// everything a temporary helper has no business touching: the profile, the
|
||||
// owner's reminders, the share links themselves, and account deletion.
|
||||
func (a *Auth) requireOwner(next http.HandlerFunc) http.HandlerFunc {
|
||||
return a.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isGuest(r) {
|
||||
http.Error(w, "guest links cannot do this", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// sessionOf returns the request's resolved session; only valid inside a
|
||||
// requireUser handler.
|
||||
func sessionOf(r *http.Request) session {
|
||||
s, _ := r.Context().Value(sessionKey).(session)
|
||||
return s
|
||||
}
|
||||
|
||||
// userID returns the authenticated user's id — the owner's, for a guest
|
||||
// session, which is what keeps all data scoping working unchanged.
|
||||
func userID(r *http.Request) string { return sessionOf(r).userID }
|
||||
|
||||
// isGuest reports whether the request arrived through a share link.
|
||||
func isGuest(r *http.Request) bool { return sessionOf(r).guest() }
|
||||
|
||||
// guestLabel is the share link's label, or empty for the owner. It is what gets
|
||||
// stamped onto events the request creates.
|
||||
func guestLabel(r *http.Request) string { return sessionOf(r).label }
|
||||
|
||||
// ---------- handlers ----------
|
||||
|
||||
type credentials struct {
|
||||
@@ -349,15 +433,17 @@ func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
a.issue(w, u)
|
||||
}
|
||||
|
||||
// issue starts a session, sets the cookie, and returns the user.
|
||||
// issue starts an owner session, sets the cookie, and returns the user.
|
||||
func (a *Auth) issue(w http.ResponseWriter, u User) {
|
||||
token, err := a.startSession(u.ID)
|
||||
expires := time.Now().Add(sessionValidity)
|
||||
token, err := a.startSession(u.ID, "", expires.UnixMilli())
|
||||
if err != nil {
|
||||
log.Printf("start session: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.setCookie(w, token)
|
||||
a.setCookie(w, token, expires)
|
||||
u.Role = "owner"
|
||||
writeUser(w, u)
|
||||
}
|
||||
|
||||
@@ -371,8 +457,10 @@ func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleMe reports the current account. Wrapped in requireUser, so reaching it
|
||||
// means the session is valid.
|
||||
// handleMe reports the current account, and which role the caller holds over
|
||||
// it. Wrapped in requireUser, so reaching it means the session is valid. The id
|
||||
// is the owner's either way — it is what the client namespaces its local cache
|
||||
// by — but a guest is told so, and never told whose account this is.
|
||||
func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
var u User
|
||||
err := a.db.QueryRow(
|
||||
@@ -382,6 +470,15 @@ func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if s := sessionOf(r); s.guest() {
|
||||
u.Email = ""
|
||||
u.Role = "guest"
|
||||
u.Label = s.label
|
||||
u.ShareID = s.shareID
|
||||
u.Expires = s.expires
|
||||
} else {
|
||||
u.Role = "owner"
|
||||
}
|
||||
writeUser(w, u)
|
||||
}
|
||||
|
||||
@@ -395,8 +492,8 @@ func (a *Auth) checkPassword(userID, password string) bool {
|
||||
}
|
||||
|
||||
// deleteAccount removes a user and everything owned by them: events, profile,
|
||||
// reminders, push subscriptions, sessions, the user row, and their photo
|
||||
// directory. The table wipes run in one transaction; photos are best-effort
|
||||
// reminders, push subscriptions, share links, sessions, the user row, and their
|
||||
// photo directory. The table wipes run in one transaction; photos are best-effort
|
||||
// afterwards (orphaned files are harmless).
|
||||
func (a *Auth) deleteAccount(userID string) error {
|
||||
tx, err := a.db.Begin()
|
||||
@@ -410,6 +507,7 @@ func (a *Auth) deleteAccount(userID string) error {
|
||||
`DELETE FROM config WHERE user_id = ?`,
|
||||
`DELETE FROM push_subscriptions WHERE user_id = ?`,
|
||||
`DELETE FROM reminders WHERE user_id = ?`,
|
||||
`DELETE FROM share_links WHERE user_id = ?`,
|
||||
`DELETE FROM sessions WHERE user_id = ?`,
|
||||
`DELETE FROM users WHERE id = ?`,
|
||||
} {
|
||||
@@ -447,3 +545,226 @@ func (a *Auth) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
a.clearCookie(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------- guest links ----------
|
||||
//
|
||||
// A guest link lets the owner hand someone (a dog sitter, family for a weekend)
|
||||
// the ability to log events without handing over their password. Redeeming one
|
||||
// mints an ordinary session row against the *owner's* user_id, tagged with the
|
||||
// link it came from — so every data path downstream (sync, photos, config) keeps
|
||||
// working untouched, and only the capability checks differ by role.
|
||||
//
|
||||
// The raw token is shown exactly once, at creation. Only its hash is stored,
|
||||
// the same way session tokens are, so a leaked database yields no usable links.
|
||||
|
||||
// ShareLink is the public shape of a guest link. Token is set only on the
|
||||
// response to the call that created it, and never stored in the clear.
|
||||
type ShareLink struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Created int64 `json:"created"`
|
||||
Expires int64 `json:"expires"`
|
||||
LastUsed int64 `json:"lastUsed,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// createShare mints a link that stops working at expires (Unix milliseconds)
|
||||
// and returns it with its one-time raw token attached.
|
||||
func (a *Auth) createShare(userID, label string, expires int64) (ShareLink, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return ShareLink{}, err
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
now := time.Now()
|
||||
link := ShareLink{
|
||||
ID: newID(),
|
||||
Label: label,
|
||||
Created: now.UnixMilli(),
|
||||
Expires: expires,
|
||||
Token: token,
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT INTO share_links (id, user_id, token, label, created, expires) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
link.ID, userID, hashToken(token), link.Label, link.Created, link.Expires)
|
||||
if err != nil {
|
||||
return ShareLink{}, err
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
// listShares returns the account's links that are still usable. Revoked and
|
||||
// lapsed ones are of no interest to the UI — the point of the list is "who can
|
||||
// get in right now".
|
||||
func (a *Auth) listShares(userID string) ([]ShareLink, error) {
|
||||
rows, err := a.db.Query(`
|
||||
SELECT id, label, created, expires, last_used
|
||||
FROM share_links
|
||||
WHERE user_id = ? AND revoked = 0 AND expires > ?
|
||||
ORDER BY created DESC`, userID, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]ShareLink, 0)
|
||||
for rows.Next() {
|
||||
var l ShareLink
|
||||
if err := rows.Scan(&l.ID, &l.Label, &l.Created, &l.Expires, &l.LastUsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// revokeShare kills a link and every session already minted from it. The
|
||||
// user_id guard means one account can never revoke another's link.
|
||||
func (a *Auth) revokeShare(userID, id string) error {
|
||||
res, err := a.db.Exec(
|
||||
`UPDATE share_links SET revoked = 1 WHERE id = ? AND user_id = ?`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
// sessionForToken would reject these anyway, on the revoked flag; dropping
|
||||
// the rows means a revoked link leaves nothing behind either way.
|
||||
_, err = a.db.Exec(`DELETE FROM sessions WHERE share_id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// redeemShare exchanges a raw token for a session on the owner's account. The
|
||||
// session is capped at the link's own expiry, so it cannot outlive it.
|
||||
func (a *Auth) redeemShare(token string) (raw string, sessionEnd int64, ok bool) {
|
||||
if token == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
var id, ownerID string
|
||||
var expires, revoked int64
|
||||
err := a.db.QueryRow(
|
||||
`SELECT id, user_id, expires, revoked FROM share_links WHERE token = ?`, hashToken(token),
|
||||
).Scan(&id, &ownerID, &expires, &revoked)
|
||||
now := time.Now()
|
||||
if err != nil || revoked != 0 || now.UnixMilli() > expires {
|
||||
return "", 0, false
|
||||
}
|
||||
sessionEnd = now.Add(sessionValidity).UnixMilli()
|
||||
if expires < sessionEnd {
|
||||
sessionEnd = expires
|
||||
}
|
||||
raw, err = a.startSession(ownerID, id, sessionEnd)
|
||||
if err != nil {
|
||||
log.Printf("redeem share %s: %v", id, err)
|
||||
return "", 0, false
|
||||
}
|
||||
if _, err := a.db.Exec(`UPDATE share_links SET last_used = ? WHERE id = ?`, now.UnixMilli(), id); err != nil {
|
||||
log.Printf("stamp share %s: %v", id, err)
|
||||
}
|
||||
return raw, sessionEnd, true
|
||||
}
|
||||
|
||||
type shareRequest struct {
|
||||
Label string `json:"label"`
|
||||
// Expires is when the link should stop working, in Unix milliseconds. The
|
||||
// client computes it from the date the owner picked — end of that day in
|
||||
// their own timezone, which is the only place that timezone is known.
|
||||
Expires int64 `json:"expires"`
|
||||
}
|
||||
|
||||
// handleShares lists (GET) and creates (POST) guest links. Wrapped in
|
||||
// requireOwner: a guest cannot see, mint or extend links.
|
||||
func (a *Auth) handleShares(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON := func(v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
links, err := a.listShares(userID(r))
|
||||
if err != nil {
|
||||
log.Printf("list shares: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(map[string]any{"links": links})
|
||||
case http.MethodPost:
|
||||
var req shareRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
label := strings.TrimSpace(req.Label)
|
||||
if len(label) > 40 {
|
||||
label = label[:40]
|
||||
}
|
||||
if label == "" {
|
||||
label = "Guest"
|
||||
}
|
||||
now := time.Now()
|
||||
if req.Expires <= now.UnixMilli() {
|
||||
http.Error(w, "pick a date in the future", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Expires > now.Add(maxShareAhead).UnixMilli() {
|
||||
http.Error(w, "that date is too far off", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
link, err := a.createShare(userID(r), label, req.Expires)
|
||||
if err != nil {
|
||||
log.Printf("create share: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(link)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleShare revokes one link: DELETE /api/shares/<id>. Wrapped in
|
||||
// requireOwner.
|
||||
func (a *Auth) handleShare(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/shares/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := a.revokeShare(userID(r), id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "no such link", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("revoke share %s: %v", id, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRedeem is what a guest link actually points at: GET /guest/<token>.
|
||||
// A plain navigation so tapping the link in a message just works — it sets the
|
||||
// session cookie and bounces to the app, which keeps the token out of the
|
||||
// address bar, out of bookmarks and out of the PWA's start URL. SameSite=Lax
|
||||
// permits the cookie on a top-level GET like this one.
|
||||
func (a *Auth) handleRedeem(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
token := strings.TrimPrefix(r.URL.Path, "/guest/")
|
||||
raw, expires, ok := a.redeemShare(token)
|
||||
if !ok {
|
||||
// Nothing usable — send them to the app with a marker it renders as
|
||||
// "this link has ended" rather than a login form they can't fill in.
|
||||
http.Redirect(w, r, "/?guest=expired", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.setCookie(w, raw, time.UnixMilli(expires))
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testAuth(t *testing.T) *Auth {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := openDB(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return newAuth(db, "letmein", false, filepath.Join(dir, "photos"))
|
||||
}
|
||||
|
||||
// testOwner registers an account and returns its id.
|
||||
func testOwner(t *testing.T, a *Auth) string {
|
||||
t.Helper()
|
||||
u, err := a.createUser("owner@example.com", "hunter2hunter2")
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
// hoursAhead is an expiry that many hours from now, in Unix milliseconds —
|
||||
// what the client sends after the owner picks a date.
|
||||
func hoursAhead(h int) int64 {
|
||||
return time.Now().Add(time.Duration(h) * time.Hour).UnixMilli()
|
||||
}
|
||||
|
||||
// guestToken mints a link and redeems it, returning the raw session token a
|
||||
// guest's cookie would carry.
|
||||
func guestToken(t *testing.T, a *Auth, ownerID, label string, hours int) string {
|
||||
t.Helper()
|
||||
link, err := a.createShare(ownerID, label, hoursAhead(hours))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
raw, _, ok := a.redeemShare(link.Token)
|
||||
if !ok {
|
||||
t.Fatal("redeem: fresh link was rejected")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// request builds a request carrying the given session cookie.
|
||||
func request(method, path, cookie, body string) *http.Request {
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRedeemedLinkActsAsTheOwner(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
token := guestToken(t, a, ownerID, "Anna", 24)
|
||||
|
||||
s, ok := a.sessionForToken(token)
|
||||
if !ok {
|
||||
t.Fatal("session for a fresh guest token was rejected")
|
||||
}
|
||||
if s.userID != ownerID {
|
||||
t.Errorf("guest session scoped to %q, want the owner %q", s.userID, ownerID)
|
||||
}
|
||||
if !s.guest() {
|
||||
t.Error("session from a share link does not report itself as a guest")
|
||||
}
|
||||
if s.label != "Anna" {
|
||||
t.Errorf("label = %q, want %q", s.label, "Anna")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerSessionIsNotAGuest(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
raw, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
|
||||
if err != nil {
|
||||
t.Fatalf("start session: %v", err)
|
||||
}
|
||||
s, ok := a.sessionForToken(raw)
|
||||
if !ok {
|
||||
t.Fatal("owner session was rejected")
|
||||
}
|
||||
if s.guest() || s.label != "" {
|
||||
t.Errorf("owner session reports guest=%v label=%q, want false/empty", s.guest(), s.label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnusableTokensAreRejected(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
if _, _, ok := a.redeemShare("not-a-real-token"); ok {
|
||||
t.Error("a garbage token was redeemed")
|
||||
}
|
||||
if _, _, ok := a.redeemShare(""); ok {
|
||||
t.Error("an empty token was redeemed")
|
||||
}
|
||||
|
||||
// An expired link: backdate it past its own end.
|
||||
link, err := a.createShare(ownerID, "Stale", hoursAhead(12))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
if _, err := a.db.Exec(
|
||||
`UPDATE share_links SET expires = ? WHERE id = ?`,
|
||||
time.Now().Add(-time.Minute).UnixMilli(), link.ID); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
if _, _, ok := a.redeemShare(link.Token); ok {
|
||||
t.Error("an expired link was redeemed")
|
||||
}
|
||||
|
||||
// A revoked link.
|
||||
revoked, err := a.createShare(ownerID, "Revoked", hoursAhead(12))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
if err := a.revokeShare(ownerID, revoked.ID); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if _, _, ok := a.redeemShare(revoked.Token); ok {
|
||||
t.Error("a revoked link was redeemed")
|
||||
}
|
||||
}
|
||||
|
||||
// Revocation has to bite on the next request, not whenever the guest's own
|
||||
// session row happens to lapse — that is the whole point of being able to
|
||||
// revoke.
|
||||
func TestRevokingKillsLiveSessions(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
token, _, ok := a.redeemShare(link.Token)
|
||||
if !ok {
|
||||
t.Fatal("redeem: fresh link was rejected")
|
||||
}
|
||||
if _, ok := a.sessionForToken(token); !ok {
|
||||
t.Fatal("session invalid before revoking")
|
||||
}
|
||||
if err := a.revokeShare(ownerID, link.ID); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if _, ok := a.sessionForToken(token); ok {
|
||||
t.Error("session still valid after its link was revoked")
|
||||
}
|
||||
}
|
||||
|
||||
// A guest session must never outlive its link, however long the default
|
||||
// session validity is.
|
||||
func TestGuestSessionIsCappedAtTheLinkExpiry(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
link, err := a.createShare(ownerID, "Anna", hoursAhead(12))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
_, sessionEnd, ok := a.redeemShare(link.Token)
|
||||
if !ok {
|
||||
t.Fatal("redeem: fresh link was rejected")
|
||||
}
|
||||
if sessionEnd != link.Expires {
|
||||
t.Errorf("session ends at %d, want the link's own %d", sessionEnd, link.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeIsScopedToTheOwner(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
other, err := a.createUser("other@example.com", "hunter2hunter2")
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
if err := a.revokeShare(other.ID, link.ID); err == nil {
|
||||
t.Error("another account revoked a link it does not own")
|
||||
}
|
||||
if _, _, ok := a.redeemShare(link.Token); !ok {
|
||||
t.Error("link was revoked by an account that does not own it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSharesHidesRevokedAndExpired(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
live, err := a.createShare(ownerID, "Live", hoursAhead(24))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
gone, err := a.createShare(ownerID, "Gone", hoursAhead(24))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
if err := a.revokeShare(ownerID, gone.ID); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
stale, err := a.createShare(ownerID, "Stale", hoursAhead(24))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
if _, err := a.db.Exec(
|
||||
`UPDATE share_links SET expires = ? WHERE id = ?`,
|
||||
time.Now().Add(-time.Minute).UnixMilli(), stale.ID); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
|
||||
links, err := a.listShares(ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(links) != 1 || links[0].ID != live.ID {
|
||||
t.Fatalf("listed %d link(s), want only the live one", len(links))
|
||||
}
|
||||
if links[0].Token != "" {
|
||||
t.Error("listing leaked a raw token")
|
||||
}
|
||||
}
|
||||
|
||||
// The raw token is only ever handed back once, at creation.
|
||||
func TestOnlyTheTokenHashIsStored(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
var stored string
|
||||
if err := a.db.QueryRow(`SELECT token FROM share_links WHERE id = ?`, link.ID).Scan(&stored); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if stored == link.Token {
|
||||
t.Error("the raw token is stored in the clear")
|
||||
}
|
||||
if stored != hashToken(link.Token) {
|
||||
t.Error("stored token is not the hash of the issued one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletingAnAccountDropsItsLinks(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
if err := a.deleteAccount(ownerID); err != nil {
|
||||
t.Fatalf("delete account: %v", err)
|
||||
}
|
||||
var n int
|
||||
if err := a.db.QueryRow(`SELECT COUNT(*) FROM share_links WHERE id = ?`, link.ID).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("the deleted account's guest links survived")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- capability gating ----------
|
||||
|
||||
func TestRequireOwnerBlocksGuests(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
guest := guestToken(t, a, ownerID, "Anna", 24)
|
||||
owner, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
|
||||
if err != nil {
|
||||
t.Fatalf("start session: %v", err)
|
||||
}
|
||||
|
||||
reached := false
|
||||
h := a.requireOwner(func(w http.ResponseWriter, r *http.Request) { reached = true })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h(w, request(http.MethodPost, "/api/shares", guest, "{}"))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("guest got %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
if reached {
|
||||
t.Error("the guarded handler ran for a guest")
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
h(w, request(http.MethodPost, "/api/shares", owner, "{}"))
|
||||
if w.Code != http.StatusOK || !reached {
|
||||
t.Errorf("owner got %d and reached=%v, want 200 and true", w.Code, reached)
|
||||
}
|
||||
}
|
||||
|
||||
// A guest must still be able to do the thing the link exists for.
|
||||
func TestRequireUserAllowsGuests(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
guest := guestToken(t, a, ownerID, "Anna", 24)
|
||||
|
||||
var sawUser, sawLabel string
|
||||
h := a.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawUser, sawLabel = userID(r), guestLabel(r)
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
h(w, request(http.MethodPost, "/api/events/sync", guest, "{}"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("guest got %d on a shared route, want 200", w.Code)
|
||||
}
|
||||
if sawUser != ownerID {
|
||||
t.Errorf("handler saw user %q, want the owner %q", sawUser, ownerID)
|
||||
}
|
||||
if sawLabel != "Anna" {
|
||||
t.Errorf("handler saw label %q, want %q", sawLabel, "Anna")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMeHidesTheOwnerFromAGuest(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
guest := guestToken(t, a, ownerID, "Anna", 24)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
a.requireUser(a.handleMe)(w, request(http.MethodGet, "/api/me", guest, ""))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", w.Code)
|
||||
}
|
||||
var u User
|
||||
if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if u.Email != "" {
|
||||
t.Errorf("a guest was told the owner's email (%q)", u.Email)
|
||||
}
|
||||
if u.Role != "guest" || u.Label != "Anna" {
|
||||
t.Errorf("role/label = %q/%q, want guest/Anna", u.Role, u.Label)
|
||||
}
|
||||
if u.ID != ownerID {
|
||||
t.Errorf("id = %q, want the owner's %q so the client scopes its cache right", u.ID, ownerID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- attribution ----------
|
||||
|
||||
func loggedBy(t *testing.T, db *sql.DB, id string) string {
|
||||
t.Helper()
|
||||
var by string
|
||||
if err := db.QueryRow(`SELECT logged_by FROM events WHERE id = ?`, id).Scan(&by); err != nil {
|
||||
t.Fatalf("read logged_by: %v", err)
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
func TestAttributionIsStampedFromTheSession(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
store := newStore(a.db)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
|
||||
}); err != nil {
|
||||
t.Fatalf("guest sync: %v", err)
|
||||
}
|
||||
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
|
||||
t.Errorf("logged_by = %q, want %q", got, "Anna")
|
||||
}
|
||||
|
||||
if _, err := store.sync(ownerID, "", "", []Event{
|
||||
{ID: "e2", Type: "poo", At: 2000, UpdatedAt: 2000},
|
||||
}); err != nil {
|
||||
t.Fatalf("owner sync: %v", err)
|
||||
}
|
||||
if got := loggedBy(t, a.db, "e2"); got != "" {
|
||||
t.Errorf("the owner's own event was attributed to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Attribution is decided once, by whoever logged the event. A later edit —
|
||||
// by the owner or by another guest — must not rewrite it.
|
||||
func TestAttributionSurvivesLaterEdits(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
store := newStore(a.db)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
|
||||
}); err != nil {
|
||||
t.Fatalf("guest sync: %v", err)
|
||||
}
|
||||
// The owner edits the note, bumping updatedAt so LWW takes the change.
|
||||
if _, err := store.sync(ownerID, "", "", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "on the walk", UpdatedAt: 2000},
|
||||
}); err != nil {
|
||||
t.Fatalf("owner edit: %v", err)
|
||||
}
|
||||
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
|
||||
t.Errorf("logged_by = %q after an owner edit, want it to stay %q", got, "Anna")
|
||||
}
|
||||
// And the guest re-POSTing their own event leaves it alone too.
|
||||
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "on the walk", UpdatedAt: 3000},
|
||||
}); err != nil {
|
||||
t.Fatalf("guest re-sync: %v", err)
|
||||
}
|
||||
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
|
||||
t.Errorf("logged_by = %q after the guest re-synced, want %q", got, "Anna")
|
||||
}
|
||||
}
|
||||
|
||||
// The value never comes off the wire, so a client cannot claim to be someone
|
||||
// else — or launder its own events into looking like the owner's.
|
||||
func TestAttributionCannotBeSetByTheClient(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
store := newStore(a.db)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000, LoggedBy: ""},
|
||||
{ID: "e2", Type: "poo", At: 2000, UpdatedAt: 2000, LoggedBy: "The Owner", LoggedByShare: "s-other"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
for _, id := range []string{"e1", "e2"} {
|
||||
if got := loggedBy(t, a.db, id); got != "Anna" {
|
||||
t.Errorf("%s: logged_by = %q, want the session's %q", id, got, "Anna")
|
||||
}
|
||||
}
|
||||
// And the server's own answer carries the stamp back, so the client can
|
||||
// render the badge without having to guess.
|
||||
for _, e := range merged {
|
||||
if e.LoggedBy != "Anna" {
|
||||
t.Errorf("%s came back as %q, want %q", e.ID, e.LoggedBy, "Anna")
|
||||
}
|
||||
if e.LoggedByShare != "s1" {
|
||||
t.Errorf("%s came back from link %q, want %q", e.ID, e.LoggedByShare, "s1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- what a guest is allowed to change ----------
|
||||
|
||||
// eventNote reads back one event's note and tombstone flag — enough to tell
|
||||
// whether an attempted edit or delete actually landed.
|
||||
func eventState(t *testing.T, db *sql.DB, id string) (note string, deleted bool) {
|
||||
t.Helper()
|
||||
if err := db.QueryRow(`SELECT note, deleted FROM events WHERE id = ?`, id).Scan(¬e, &deleted); err != nil {
|
||||
t.Fatalf("read event %s: %v", id, err)
|
||||
}
|
||||
return note, deleted
|
||||
}
|
||||
|
||||
// The point of the whole guard: a sitter must not be able to rewrite or delete
|
||||
// what the owner logged, however their client asks.
|
||||
func TestGuestCannotChangeTheOwnersEvents(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
store := newStore(a.db)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
// The owner logs something.
|
||||
if _, err := store.sync(ownerID, "", "", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "mine", UpdatedAt: 1000},
|
||||
}); err != nil {
|
||||
t.Fatalf("owner sync: %v", err)
|
||||
}
|
||||
|
||||
// A guest tries to edit it, with a much newer timestamp so last-write-wins
|
||||
// alone would take the change.
|
||||
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "rewritten by the sitter", UpdatedAt: 9000},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("guest edit: %v", err)
|
||||
}
|
||||
if note, _ := eventState(t, a.db, "e1"); note != "mine" {
|
||||
t.Errorf("a guest rewrote the owner's event: note = %q", note)
|
||||
}
|
||||
// The guest gets the stored version back, so an honest client can heal.
|
||||
for _, e := range merged {
|
||||
if e.ID == "e1" && e.Note != "mine" {
|
||||
t.Errorf("server returned %q for the owner's event, want %q", e.Note, "mine")
|
||||
}
|
||||
}
|
||||
|
||||
// And cannot delete it either — a tombstone is just another update.
|
||||
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "mine", UpdatedAt: 9001, Deleted: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("guest delete: %v", err)
|
||||
}
|
||||
if _, deleted := eventState(t, a.db, "e1"); deleted {
|
||||
t.Error("a guest deleted the owner's event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestCanChangeItsOwnEvents(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
store := newStore(a.db)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "oops", UpdatedAt: 1000},
|
||||
}); err != nil {
|
||||
t.Fatalf("guest sync: %v", err)
|
||||
}
|
||||
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "fixed", UpdatedAt: 2000},
|
||||
}); err != nil {
|
||||
t.Fatalf("guest edit: %v", err)
|
||||
}
|
||||
if note, _ := eventState(t, a.db, "e1"); note != "fixed" {
|
||||
t.Errorf("a guest could not fix up their own entry: note = %q", note)
|
||||
}
|
||||
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "fixed", UpdatedAt: 3000, Deleted: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("guest delete: %v", err)
|
||||
}
|
||||
if _, deleted := eventState(t, a.db, "e1"); !deleted {
|
||||
t.Error("a guest could not delete their own entry")
|
||||
}
|
||||
}
|
||||
|
||||
// Two links can carry the same label ("Sitter"), so the id — not the label —
|
||||
// has to be what authorises the change.
|
||||
func TestGuestCannotChangeAnotherLinksEvents(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
store := newStore(a.db)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
if _, err := store.sync(ownerID, "Sitter", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "anna's", UpdatedAt: 1000},
|
||||
}); err != nil {
|
||||
t.Fatalf("first guest sync: %v", err)
|
||||
}
|
||||
// Same label, different link.
|
||||
if _, err := store.sync(ownerID, "Sitter", "s2", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "bob's", UpdatedAt: 2000},
|
||||
}); err != nil {
|
||||
t.Fatalf("second guest sync: %v", err)
|
||||
}
|
||||
if note, _ := eventState(t, a.db, "e1"); note != "anna's" {
|
||||
t.Errorf("one link's guest edited another's event: note = %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// The owner keeps full control of everything on their account, including what
|
||||
// a guest logged.
|
||||
func TestOwnerCanChangeAGuestsEvents(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
store := newStore(a.db)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "sitter's", UpdatedAt: 1000},
|
||||
}); err != nil {
|
||||
t.Fatalf("guest sync: %v", err)
|
||||
}
|
||||
if _, err := store.sync(ownerID, "", "", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "corrected", UpdatedAt: 2000},
|
||||
}); err != nil {
|
||||
t.Fatalf("owner edit: %v", err)
|
||||
}
|
||||
if note, _ := eventState(t, a.db, "e1"); note != "corrected" {
|
||||
t.Errorf("the owner could not edit a guest's event: note = %q", note)
|
||||
}
|
||||
// And delete it. A tombstone is just another update, so this rides the same
|
||||
// clause — but it is the half that matters if a sitter logs something wrong
|
||||
// and the owner wants it gone rather than fixed.
|
||||
if _, err := store.sync(ownerID, "", "", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, Note: "corrected", UpdatedAt: 3000, Deleted: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("owner delete: %v", err)
|
||||
}
|
||||
if _, deleted := eventState(t, a.db, "e1"); !deleted {
|
||||
t.Error("the owner could not delete a guest's event")
|
||||
}
|
||||
}
|
||||
|
||||
// Marking a day as not counted is a judgment about the record, so it is the
|
||||
// owner's — a sitter cannot decide their own thin day shouldn't count, nor
|
||||
// quietly take a good day out of the averages.
|
||||
func TestGuestCannotExcludeADay(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
store := newStore(a.db)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "mark", Type: eventTypeDayExcluded, At: 1000, UpdatedAt: 1000},
|
||||
{ID: "pee1", Type: "pee", At: 1000, UpdatedAt: 1000},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("guest sync: %v", err)
|
||||
}
|
||||
for _, e := range merged {
|
||||
if e.Type == eventTypeDayExcluded {
|
||||
t.Fatal("a guest marked a day as not counted")
|
||||
}
|
||||
}
|
||||
// The rest of the same sync still lands — the mark is dropped, not the batch.
|
||||
if len(merged) != 1 || merged[0].ID != "pee1" {
|
||||
t.Errorf("dropping the mark cost the guest their other events: %+v", merged)
|
||||
}
|
||||
|
||||
// The owner may, of course.
|
||||
merged, err = store.sync(ownerID, "", "", []Event{
|
||||
{ID: "mark", Type: eventTypeDayExcluded, At: 1000, UpdatedAt: 1000},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("owner sync: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, e := range merged {
|
||||
if e.ID == "mark" && e.Type == eventTypeDayExcluded {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the owner could not mark a day as not counted")
|
||||
}
|
||||
}
|
||||
|
||||
// Guests still log freely — the guard is on changing what already exists.
|
||||
func TestGuestCanStillAddEvents(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
store := newStore(a.db)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
|
||||
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("guest sync: %v", err)
|
||||
}
|
||||
if len(merged) != 1 || merged[0].ID != "e1" {
|
||||
t.Fatalf("guest's new event did not land: %+v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- link expiry ----------
|
||||
|
||||
func TestShareExpiryIsWhateverWasAskedFor(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
want := hoursAhead(53) // an odd span no fixed duration would produce
|
||||
link, err := a.createShare(ownerID, "Anna", want)
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
if link.Expires != want {
|
||||
t.Errorf("link expires at %d, want the requested %d", link.Expires, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateShareRejectsBadDates(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
owner, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
|
||||
if err != nil {
|
||||
t.Fatalf("start session: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
expires int64
|
||||
}{
|
||||
{"in the past", time.Now().Add(-time.Hour).UnixMilli()},
|
||||
{"missing", 0},
|
||||
{"absurdly far off", time.Now().Add(5 * 365 * 24 * time.Hour).UnixMilli()},
|
||||
} {
|
||||
body := `{"label":"Anna","expires":` + strconv.FormatInt(tc.expires, 10) + `}`
|
||||
w := httptest.NewRecorder()
|
||||
a.requireOwner(a.handleShares)(w, request(http.MethodPost, "/api/shares", owner, body))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: got %d, want %d", tc.name, w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
-35
@@ -34,6 +34,15 @@ type Event struct {
|
||||
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
// LoggedBy names the guest link an event was logged through, empty for the
|
||||
// owner's own. It is stamped by the server from the session (see Store.sync)
|
||||
// and never read off the wire, so a client can neither forge nor rewrite it.
|
||||
LoggedBy string `json:"loggedBy,omitempty"`
|
||||
// LoggedByShare is that link's id. LoggedBy is a label the owner typed and
|
||||
// two links may well share one ("Sitter"), so the id — not the label — is
|
||||
// what decides whether a guest may change this event. Sent to the client so
|
||||
// it can grey out what it isn't allowed to touch; opaque and harmless.
|
||||
LoggedByShare string `json:"loggedByShare,omitempty"`
|
||||
}
|
||||
|
||||
// Exercise is a user-defined training exercise (e.g. "Sit", "Leash walking"):
|
||||
@@ -48,6 +57,12 @@ type Exercise struct {
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
// eventTypeDayExcluded marks a day the owner has taken out of the charts and
|
||||
// averages — a sitter's thin day, a stay at kennels. It is an event so it rides
|
||||
// the ordinary sync (per-item last-write-wins, tombstone to un-mark) rather than
|
||||
// needing a table and endpoint of its own; the client reads it in app.js.
|
||||
const eventTypeDayExcluded = "day-excluded"
|
||||
|
||||
var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
|
||||
func validUUID(s string) bool { return uuidRE.MatchString(s) }
|
||||
@@ -131,8 +146,11 @@ func newStore(db *sql.DB) *Store {
|
||||
|
||||
// sync merges one user's client events into the store using last-write-wins by
|
||||
// UpdatedAt, then returns that user's full merged set (tombstones included, as
|
||||
// they must propagate).
|
||||
func (s *Store) sync(userID string, client []Event) ([]Event, error) {
|
||||
// they must propagate). loggedBy/shareID describe the caller's session — the
|
||||
// guest link's label and id, both empty for the owner. They are stamped onto
|
||||
// events this call inserts, and shareID additionally decides which existing
|
||||
// events the caller is allowed to change.
|
||||
func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -141,19 +159,34 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
|
||||
|
||||
// The WHERE clause on the upsert is the last-write-wins rule: an incoming
|
||||
// event only overwrites the stored one when its updatedAt is strictly newer.
|
||||
// The `events.user_id = excluded.user_id` guard means one user can never
|
||||
// clobber another's row even if a client forges a colliding event ID —
|
||||
// the row stays put and, because reads are scoped, stays invisible to them.
|
||||
// Two further guards ride on it:
|
||||
//
|
||||
// - events.user_id = excluded.user_id — one user can never clobber
|
||||
// another's row even if a client forges a colliding event ID; the row
|
||||
// stays put and, because reads are scoped, stays invisible to them.
|
||||
// - the logged_by_share clause — an owner (excluded.logged_by_share = '')
|
||||
// may change anything; a guest may only change events logged through
|
||||
// their own link. So a sitter can fix up their own entries, and cannot
|
||||
// edit or delete a single one of the owner's. A rejected row simply
|
||||
// stays as it was, and the caller gets the stored version back.
|
||||
//
|
||||
// The two attribution columns are deliberately absent from the DO UPDATE SET
|
||||
// list: attribution is decided once, by whoever first inserted the event, and
|
||||
// a later edit by anyone leaves it alone. That is also what makes it
|
||||
// unspoofable — a guest re-POSTs the owner's whole event list on every sync,
|
||||
// but those rows already exist and so keep their stored values.
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id, logged_by, logged_by_share)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type, at = excluded.at, note = excluded.note,
|
||||
photo_id = excluded.photo_id, weight = excluded.weight,
|
||||
grams = excluded.grams, exercise_id = excluded.exercise_id,
|
||||
updated = excluded.updated, deleted = excluded.deleted
|
||||
WHERE excluded.updated > events.updated
|
||||
AND events.user_id = excluded.user_id`)
|
||||
AND events.user_id = excluded.user_id
|
||||
AND (excluded.logged_by_share = ''
|
||||
OR events.logged_by_share = excluded.logged_by_share)`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -163,8 +196,15 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
|
||||
if ce.ID == "" {
|
||||
continue
|
||||
}
|
||||
// Marking a day as not counted is a judgment about the record rather
|
||||
// than something that happened to the puppy, so it belongs to the owner
|
||||
// alongside everything else a guest may not decide. The client hides the
|
||||
// control; this is what enforces it.
|
||||
if shareID != "" && ce.Type == eventTypeDayExcluded {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(
|
||||
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID,
|
||||
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, loggedBy, shareID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -178,7 +218,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
|
||||
// all returns one user's events, tombstones included.
|
||||
func (s *Store) all(userID string) ([]Event, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted
|
||||
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, logged_by, logged_by_share
|
||||
FROM events WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -188,7 +228,7 @@ func (s *Store) all(userID string) ([]Event, error) {
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted,
|
||||
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.LoggedBy, &e.LoggedByShare,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -289,17 +329,19 @@ func openDB(path string) (*sql.DB, error) {
|
||||
// the first account adopts it (see Auth.adopt).
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
at INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
photo_id TEXT NOT NULL DEFAULT '',
|
||||
weight REAL NOT NULL DEFAULT 0,
|
||||
grams REAL NOT NULL DEFAULT 0,
|
||||
exercise_id TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
user_id TEXT NOT NULL DEFAULT ''
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
at INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
photo_id TEXT NOT NULL DEFAULT '',
|
||||
weight REAL NOT NULL DEFAULT 0,
|
||||
grams REAL NOT NULL DEFAULT 0,
|
||||
exercise_id TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
logged_by TEXT NOT NULL DEFAULT '',
|
||||
logged_by_share TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
|
||||
CREATE TABLE IF NOT EXISTS exercises (
|
||||
@@ -325,11 +367,23 @@ func openDB(path string) (*sql.DB, error) {
|
||||
created INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created INTEGER NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created INTEGER NOT NULL,
|
||||
expires INTEGER NOT NULL,
|
||||
share_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created INTEGER NOT NULL,
|
||||
expires INTEGER NOT NULL,
|
||||
last_used INTEGER NOT NULL DEFAULT 0,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_user ON share_links(user_id);
|
||||
CREATE TABLE IF NOT EXISTS pedigree_cache (
|
||||
hundid TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
@@ -401,6 +455,36 @@ func migrateSchema(db *sql.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Guest links (see Auth.createShare). Every column defaults to the empty
|
||||
// string, which is exactly what pre-guest-link rows mean: an event nobody
|
||||
// but the owner logged, and a session that isn't a guest's.
|
||||
hasLoggedBy, err := columnExists(db, "events", "logged_by")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasLoggedBy {
|
||||
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN logged_by TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
hasLoggedByShare, err := columnExists(db, "events", "logged_by_share")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasLoggedByShare {
|
||||
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN logged_by_share TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
hasShareID, err := columnExists(db, "sessions", "share_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasShareID {
|
||||
if _, err := db.Exec(`ALTER TABLE sessions ADD COLUMN share_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
oldConfig, err := columnExists(db, "config", "id")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -494,7 +578,7 @@ func importEvents(db *sql.DB, path string) error {
|
||||
// Imported as ownerless (user_id = ""); the first account to register adopts
|
||||
// them. Mirrors how in-place schema migration parks legacy rows.
|
||||
store := newStore(db)
|
||||
if _, err := store.sync("", evs); err != nil {
|
||||
if _, err := store.sync("", "", "", evs); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("migrated %d events from %s", len(evs), path)
|
||||
@@ -686,12 +770,24 @@ func main() {
|
||||
case http.MethodGet:
|
||||
auth.handleMe(w, r)
|
||||
case http.MethodDelete:
|
||||
// Deleting the account is the owner's alone, so this arm — and only
|
||||
// this arm — is gated; a guest still needs the GET to learn its role.
|
||||
if isGuest(r) {
|
||||
http.Error(w, "guest links cannot do this", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
auth.handleDeleteAccount(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
|
||||
// Guest links: minting, listing and revoking are the owner's, redeeming is
|
||||
// the unauthenticated entry point the link itself points at.
|
||||
mux.HandleFunc("/api/shares", auth.requireOwner(auth.handleShares))
|
||||
mux.HandleFunc("/api/shares/", auth.requireOwner(auth.handleShare))
|
||||
mux.HandleFunc("/guest/", auth.handleRedeem)
|
||||
|
||||
mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -702,7 +798,7 @@ func main() {
|
||||
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
merged, err := store.sync(userID(r), req.Events)
|
||||
merged, err := store.sync(userID(r), guestLabel(r), sessionOf(r).shareID, req.Events)
|
||||
if err != nil {
|
||||
log.Printf("sync: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
@@ -728,7 +824,16 @@ func main() {
|
||||
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
merged, err := exerciseStore.sync(userID(r), req.Exercises)
|
||||
// Exercises are the owner's library, not a log: a guest logs training
|
||||
// sessions against them (ordinary events) but does not get to rename or
|
||||
// delete them. Dropping the incoming list makes this direction-only —
|
||||
// the guest still receives the full set back. The client hides the
|
||||
// editing UI to match; this is the part that enforces it.
|
||||
incoming := req.Exercises
|
||||
if isGuest(r) {
|
||||
incoming = nil
|
||||
}
|
||||
merged, err := exerciseStore.sync(userID(r), incoming)
|
||||
if err != nil {
|
||||
log.Printf("exercises sync: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
@@ -751,6 +856,13 @@ func main() {
|
||||
case http.MethodGet:
|
||||
writeConfig(configStore.get(userID(r)))
|
||||
case http.MethodPut, http.MethodPost:
|
||||
// The profile (name, birthday, pedigree id) is the owner's to set.
|
||||
// The GET above stays open — a guest needs the name and birthday to
|
||||
// render the header at all.
|
||||
if isGuest(r) {
|
||||
http.Error(w, "guest links cannot do this", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var in Config
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil {
|
||||
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
|
||||
@@ -800,13 +912,15 @@ func main() {
|
||||
|
||||
// Push reminders. Registered only when the scheduler came up, so a server
|
||||
// without a usable VAPID key 404s these rather than half-working — which is
|
||||
// also what tells the client to hide the reminder UI entirely.
|
||||
// also what tells the client to hide the reminder UI entirely. Owner-only:
|
||||
// the reminders are the owner's own, and a guest device subscribing would
|
||||
// route them to the sitter's lock screen.
|
||||
if scheduler != nil {
|
||||
mux.HandleFunc("/api/push/key", auth.requireUser(scheduler.handleKey))
|
||||
mux.HandleFunc("/api/push/subscribe", auth.requireUser(scheduler.handleSubscribe))
|
||||
mux.HandleFunc("/api/push/unsubscribe", auth.requireUser(scheduler.handleUnsubscribe))
|
||||
mux.HandleFunc("/api/push/test", auth.requireUser(scheduler.handleTest))
|
||||
mux.HandleFunc("/api/reminders", auth.requireUser(scheduler.handleReminders))
|
||||
mux.HandleFunc("/api/push/key", auth.requireOwner(scheduler.handleKey))
|
||||
mux.HandleFunc("/api/push/subscribe", auth.requireOwner(scheduler.handleSubscribe))
|
||||
mux.HandleFunc("/api/push/unsubscribe", auth.requireOwner(scheduler.handleUnsubscribe))
|
||||
mux.HandleFunc("/api/push/test", auth.requireOwner(scheduler.handleTest))
|
||||
mux.HandleFunc("/api/reminders", auth.requireOwner(scheduler.handleReminders))
|
||||
}
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+579
-86
@@ -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);
|
||||
@@ -441,6 +464,53 @@
|
||||
return d;
|
||||
}
|
||||
|
||||
// ---------- days that don't count ----------
|
||||
// Not every logged day is equally trustworthy: a day someone else had the
|
||||
// puppy produces a thin record that reads exactly like a real one, and then
|
||||
// drags down every average. Marking a day "not counted" keeps its data
|
||||
// untouched and fully visible on the day itself, but takes it out of
|
||||
// everything that aggregates across days.
|
||||
//
|
||||
// A mark is an ordinary event, the way a training session is: it rides the
|
||||
// existing sync with per-item last-write-wins and tombstones (un-marking is
|
||||
// just a delete), so it needs no table, endpoint or contract of its own.
|
||||
// Timestamped at noon so the day it lands on is unambiguous under the same
|
||||
// ymd() bucketing every other event uses.
|
||||
const EXCLUDED_TYPE = "day-excluded";
|
||||
|
||||
// Behaviour is what a sparse logger distorts. A weigh-in and a note are
|
||||
// records of fact, so they keep counting even on a day that doesn't.
|
||||
const ALWAYS_COUNTS = new Set(["weight", "note"]);
|
||||
|
||||
// Refreshed once per render() and read like chartDays() — the chart drawers
|
||||
// need to know which day slots to hatch, not just which events to drop.
|
||||
let excludedSet = new Set();
|
||||
|
||||
function excludedDays(events) {
|
||||
return new Set(
|
||||
events.filter(e => e.type === EXCLUDED_TYPE).map(e => ymd(new Date(e.at)))
|
||||
);
|
||||
}
|
||||
|
||||
const isExcluded = (date) => excludedSet.has(ymd(date));
|
||||
|
||||
// The event list the cross-day panels see.
|
||||
function countedEvents(events) {
|
||||
if (excludedSet.size === 0) return events;
|
||||
return events.filter(e =>
|
||||
ALWAYS_COUNTS.has(e.type) || !excludedSet.has(ymd(new Date(e.at))));
|
||||
}
|
||||
|
||||
// True if any part of [from, to] falls on an excluded day. Used to throw away
|
||||
// measurements that reach across one — see gapsBetween.
|
||||
function spansExcluded(from, to) {
|
||||
if (excludedSet.size === 0) return false;
|
||||
for (let d = startOfDay(new Date(from)); d.getTime() <= to; d.setDate(d.getDate() + 1)) {
|
||||
if (isExcluded(d)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
@@ -726,7 +796,16 @@
|
||||
.map(e => e.at)
|
||||
.sort((a, b) => a - b);
|
||||
const gaps = [];
|
||||
for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]);
|
||||
for (let i = 1; i < times.length; i++) {
|
||||
// A gap reaching across a day that doesn't count is not a real gap: the
|
||||
// events in between were dropped, so Tuesday's last pee now sits next to
|
||||
// Thursday's first and the subtraction invents thirty hours. Discarding
|
||||
// the pair is the only honest answer — measuring it would blow out the
|
||||
// "longest" figure far worse than the sparse day this feature exists to
|
||||
// take out of the numbers.
|
||||
if (spansExcluded(times[i - 1], times[i])) continue;
|
||||
gaps.push(times[i] - times[i - 1]);
|
||||
}
|
||||
return gaps.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
@@ -1086,7 +1165,9 @@
|
||||
|
||||
function renderHistory(events) {
|
||||
const day = selectedDay();
|
||||
const chronological = eventsForDay(events, day);
|
||||
// The "not counted" mark is bookkeeping about the day, not something that
|
||||
// happened to the puppy, so it never appears as a row in the log.
|
||||
const chronological = eventsForDay(events, day).filter(e => e.type !== EXCLUDED_TYPE);
|
||||
const rails = historyRails(events, chronological, day);
|
||||
const dayEvents = [...chronological].reverse();
|
||||
const exNames = exerciseNames();
|
||||
@@ -1113,6 +1194,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);
|
||||
@@ -1268,11 +1358,18 @@
|
||||
d.setDate(d.getDate() - i);
|
||||
const from = startOfDay(d).getTime();
|
||||
const to = (i === 0) ? now : endOfDay(d).getTime();
|
||||
const sleepMs = sleepMsInRange(events, from, to);
|
||||
const dayEvents = eventsForDay(events, d);
|
||||
// A day marked "not counted" keeps its slot on the axis — dropping it
|
||||
// would make consecutive bars stop being consecutive days — but carries
|
||||
// no figures. Zeroing them here rather than in each chart means every
|
||||
// axis maximum, total and tooltip downstream is already right, and the
|
||||
// drawers only have to decide what to paint in the empty slot.
|
||||
const excluded = isExcluded(d);
|
||||
const sleepMs = excluded ? 0 : sleepMsInRange(events, from, to);
|
||||
const dayEvents = excluded ? [] : eventsForDay(events, d);
|
||||
days.push({
|
||||
date: d,
|
||||
ymd: ymd(d),
|
||||
excluded,
|
||||
sleepHours: sleepMs / 3_600_000,
|
||||
pees: dayEvents.filter(e => e.type === "pee").length,
|
||||
poos: dayEvents.filter(e => e.type === "poo").length,
|
||||
@@ -1280,12 +1377,21 @@
|
||||
grams: dayEvents
|
||||
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
|
||||
.reduce((s, e) => s + e.grams, 0),
|
||||
walkMinutes: walkMsInRange(events, from, to) / 60_000,
|
||||
walkMinutes: excluded ? 0 : walkMsInRange(events, from, to) / 60_000,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
// A day that doesn't count gets a hatched column where its bar would be, so
|
||||
// the gap reads as deliberate rather than as a day the puppy barely did
|
||||
// anything. Shared by all four daily bar charts, which differ only in height.
|
||||
function excludedSlot(d, x, barW, top, innerH) {
|
||||
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — not counted`;
|
||||
return `<rect class="bar bar-excluded" data-day="${d.ymd}" x="${x}" y="${top}" ` +
|
||||
`width="${barW}" height="${innerH}" rx="3"><title>${escapeText(title)}</title></rect>`;
|
||||
}
|
||||
|
||||
function dayLabel(date, isToday) {
|
||||
if (isToday) return "Today";
|
||||
return date.toLocaleDateString(undefined, { weekday: "short" });
|
||||
@@ -1389,11 +1495,15 @@
|
||||
if (isSel) {
|
||||
parts.push(`<rect class="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||||
}
|
||||
parts.push(
|
||||
`<rect class="bar bar-sleep ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||||
`<title>${escapeText(title)}</title></rect>`
|
||||
);
|
||||
if (d.excluded) {
|
||||
parts.push(excludedSlot(d, x, barW, MT, innerH));
|
||||
} else {
|
||||
parts.push(
|
||||
`<rect class="bar bar-sleep ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||||
`<title>${escapeText(title)}</title></rect>`
|
||||
);
|
||||
}
|
||||
// The selected day always gets a label (accent-colored), even on wide
|
||||
// windows that would otherwise skip it.
|
||||
if (showDayLabel(i, days.length) || isSel) {
|
||||
@@ -1445,18 +1555,23 @@
|
||||
if (isSel) {
|
||||
parts.push(`<rect class="day-highlight" x="${(groupX - groupGap / 2).toFixed(1)}" y="${MT}" width="${(groupW + groupGap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||||
}
|
||||
series.forEach((s, j) => {
|
||||
const val = d[s.key];
|
||||
const x = groupX + j * (barW + innerBarGap);
|
||||
const h = (val / yMax) * innerH;
|
||||
const y = MT + innerH - h;
|
||||
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${s.label}: ${val}`;
|
||||
parts.push(
|
||||
`<rect class="bar ${s.cls} ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="2">` +
|
||||
`<title>${escapeText(title)}</title></rect>`
|
||||
);
|
||||
});
|
||||
if (d.excluded) {
|
||||
// One hatch across the whole group rather than three empty bars.
|
||||
parts.push(excludedSlot(d, groupX, groupW, MT, innerH));
|
||||
} else {
|
||||
series.forEach((s, j) => {
|
||||
const val = d[s.key];
|
||||
const x = groupX + j * (barW + innerBarGap);
|
||||
const h = (val / yMax) * innerH;
|
||||
const y = MT + innerH - h;
|
||||
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${s.label}: ${val}`;
|
||||
parts.push(
|
||||
`<rect class="bar ${s.cls} ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="2">` +
|
||||
`<title>${escapeText(title)}</title></rect>`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (showDayLabel(i, days.length) || isSel) {
|
||||
parts.push(
|
||||
@@ -1507,11 +1622,15 @@
|
||||
if (isSel) {
|
||||
parts.push(`<rect class="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||||
}
|
||||
parts.push(
|
||||
`<rect class="bar bar-eat ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||||
`<title>${escapeText(title)}</title></rect>`
|
||||
);
|
||||
if (d.excluded) {
|
||||
parts.push(excludedSlot(d, x, barW, MT, innerH));
|
||||
} else {
|
||||
parts.push(
|
||||
`<rect class="bar bar-eat ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||||
`<title>${escapeText(title)}</title></rect>`
|
||||
);
|
||||
}
|
||||
if (showDayLabel(i, days.length) || isSel) {
|
||||
parts.push(
|
||||
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||||
@@ -1561,11 +1680,15 @@
|
||||
if (isSel) {
|
||||
parts.push(`<rect class="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||||
}
|
||||
parts.push(
|
||||
`<rect class="bar bar-walk ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||||
`<title>${escapeText(title)}</title></rect>`
|
||||
);
|
||||
if (d.excluded) {
|
||||
parts.push(excludedSlot(d, x, barW, MT, innerH));
|
||||
} else {
|
||||
parts.push(
|
||||
`<rect class="bar bar-walk ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||||
`<title>${escapeText(title)}</title></rect>`
|
||||
);
|
||||
}
|
||||
if (showDayLabel(i, days.length) || isSel) {
|
||||
parts.push(
|
||||
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||||
@@ -1653,19 +1776,27 @@
|
||||
|
||||
parts.push(`<rect class="stl-track${isSel ? " stl-selected" : ""}" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}" rx="2"/>`);
|
||||
|
||||
for (const wdw of windows) {
|
||||
const s = Math.max(wdw.start, dayStart);
|
||||
const e = Math.min(wdw.end, dayEnd);
|
||||
if (e <= s) continue;
|
||||
const x = xOf((s - dayStart) / dayMs);
|
||||
const wpx = ((e - s) / dayMs) * innerW;
|
||||
parts.push(`<rect class="${barCls}" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${Math.max(0.6, wpx).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5"/>`);
|
||||
// A day that doesn't count keeps its row — the rows are a calendar, so
|
||||
// dropping one would misalign every day above it — but is hatched right
|
||||
// across instead of showing the sparse windows that made it untrustworthy.
|
||||
if (isExcluded(day)) {
|
||||
parts.push(`<rect class="bar-excluded" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}" rx="2"/>`);
|
||||
} else {
|
||||
for (const wdw of windows) {
|
||||
const s = Math.max(wdw.start, dayStart);
|
||||
const e = Math.min(wdw.end, dayEnd);
|
||||
if (e <= s) continue;
|
||||
const x = xOf((s - dayStart) / dayMs);
|
||||
const wpx = ((e - s) / dayMs) * innerW;
|
||||
parts.push(`<rect class="${barCls}" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${Math.max(0.6, wpx).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5"/>`);
|
||||
}
|
||||
}
|
||||
|
||||
const label = isToday ? "Today" : `${day.toLocaleDateString(undefined, { weekday: "short" })} ${day.getDate()}`;
|
||||
parts.push(`<text class="stl-day ${isToday ? "stl-today" : ""}${isSel ? " stl-sel" : ""}" x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${escapeText(label)}</text>`);
|
||||
|
||||
const title = day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
|
||||
const dateText = day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
|
||||
const title = isExcluded(day) ? `${dateText} — not counted` : dateText;
|
||||
parts.push(`<rect class="bar stl-hit" data-day="${ymd(day)}" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}"><title>${escapeText(title)}</title></rect>`);
|
||||
}
|
||||
|
||||
@@ -1719,11 +1850,12 @@
|
||||
};
|
||||
const day = selectedDay();
|
||||
const isToday = ymd(day) === ymd(new Date());
|
||||
const dayStartTs = (daysAgo) => {
|
||||
const dayAgo = (daysAgo) => {
|
||||
const d = startOfDay(day);
|
||||
d.setDate(d.getDate() - daysAgo);
|
||||
return d.getTime();
|
||||
return d;
|
||||
};
|
||||
const dayStartTs = (daysAgo) => dayAgo(daysAgo).getTime();
|
||||
const pointsAt = (start, stops) =>
|
||||
stops.map(t => ({ x: (t - start) / HOUR, y: walkedMs(start, t) / MIN }));
|
||||
|
||||
@@ -1747,14 +1879,17 @@
|
||||
const totalOf = (pts) => pts[pts.length - 1].y;
|
||||
|
||||
const today = curveFor(dayStartTs(0), isToday ? Date.now() : null);
|
||||
// Same as the sleep trend: a day that doesn't count is dropped as a
|
||||
// comparison rather than drawn flat at zero.
|
||||
const prev = curveFor(dayStartTs(1));
|
||||
const yesterday = totalOf(prev) > 0 ? prev : null;
|
||||
const yesterday = (!isExcluded(dayAgo(1)) && totalOf(prev) > 0) ? prev : null;
|
||||
|
||||
// Mean of the last N days, skipping days with no walk at all so a gap in
|
||||
// logging doesn't drag the average toward zero.
|
||||
// Mean of the last N days, skipping days that don't count and days with no
|
||||
// walk at all, so a gap in logging doesn't drag the average toward zero.
|
||||
const avgDays = chartDays();
|
||||
const dayCurves = [];
|
||||
for (let i = 1; i <= avgDays; i++) {
|
||||
if (isExcluded(dayAgo(i))) continue;
|
||||
const c = hourlyFor(dayStartTs(i));
|
||||
if (c[24].y > 0) dayCurves.push(c);
|
||||
}
|
||||
@@ -1963,11 +2098,12 @@
|
||||
};
|
||||
const day = selectedDay();
|
||||
const isToday = ymd(day) === ymd(new Date());
|
||||
const dayStartTs = (daysAgo) => {
|
||||
const dayAgo = (daysAgo) => {
|
||||
const d = startOfDay(day);
|
||||
d.setDate(d.getDate() - daysAgo);
|
||||
return d.getTime();
|
||||
return d;
|
||||
};
|
||||
const dayStartTs = (daysAgo) => dayAgo(daysAgo).getTime();
|
||||
// capTs (today only) truncates the curve at "now" with a final fractional
|
||||
// point, so the line visibly ends where the day currently stands.
|
||||
const curveFor = (start, capTs) => {
|
||||
@@ -1986,14 +2122,22 @@
|
||||
// A past day is complete, so its curve runs the full 24h uncapped.
|
||||
const today = curveFor(dayStartTs(0), isToday ? Date.now() : null);
|
||||
|
||||
// A day that doesn't count is no comparison at all, so it is dropped
|
||||
// outright rather than drawn as a flat line at zero. Checked explicitly
|
||||
// rather than leaning on the "any sleep at all" guard below: a nap running
|
||||
// in from the previous, counted day would give an excluded day a non-zero
|
||||
// total and sneak it back in.
|
||||
const yesterdayCurve = curveFor(dayStartTs(1));
|
||||
const yesterday = yesterdayCurve[24].y > 0 ? yesterdayCurve : null;
|
||||
const yesterday =
|
||||
(!isExcluded(dayAgo(1)) && yesterdayCurve[24].y > 0) ? yesterdayCurve : null;
|
||||
|
||||
// Mean of the last N full days, skipping days with no sleep at all so a
|
||||
// young log (or a tracking gap) doesn't drag the average toward zero.
|
||||
// Mean of the last N full days, skipping days that don't count and days
|
||||
// with no sleep at all, so a young log (or a tracking gap) doesn't drag the
|
||||
// average toward zero.
|
||||
const avgDays = chartDays();
|
||||
const dayCurves = [];
|
||||
for (let i = 1; i <= avgDays; i++) {
|
||||
if (isExcluded(dayAgo(i))) continue;
|
||||
const c = curveFor(dayStartTs(i));
|
||||
if (c[24].y > 0) dayCurves.push(c);
|
||||
}
|
||||
@@ -2395,14 +2539,26 @@
|
||||
const max = Math.max(1, ...counts[r]);
|
||||
for (let c = 0; c < N; c++) {
|
||||
const n = counts[r][c];
|
||||
const op = n === 0 ? 0.06 : 0.35 + 0.65 * (n / max);
|
||||
const d = dayList[c];
|
||||
const title = `${x.name} · ${d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}: ${n}`;
|
||||
const dateText = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
// A day that doesn't count would otherwise show as the palest cell —
|
||||
// indistinguishable from "trained nothing that day", which is the one
|
||||
// reading the mark is there to rule out.
|
||||
if (isExcluded(d)) {
|
||||
parts.push(
|
||||
`<rect class="bar bar-excluded" data-day="${ymd(d)}" ` +
|
||||
`x="${(ML + c * cellW).toFixed(1)}" y="${y.toFixed(1)}" ` +
|
||||
`width="${(cellW - 1.5).toFixed(1)}" height="${rowH}" rx="2">` +
|
||||
`<title>${escapeText(`${x.name} · ${dateText}: not counted`)}</title></rect>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const op = n === 0 ? 0.06 : 0.35 + 0.65 * (n / max);
|
||||
parts.push(
|
||||
`<rect class="bar hm-cell hm-training" data-day="${ymd(d)}" ` +
|
||||
`x="${(ML + c * cellW).toFixed(1)}" y="${y.toFixed(1)}" ` +
|
||||
`width="${(cellW - 1.5).toFixed(1)}" height="${rowH}" rx="2" fill-opacity="${op.toFixed(2)}">` +
|
||||
`<title>${escapeText(title)}</title></rect>`
|
||||
`<title>${escapeText(`${x.name} · ${dateText}: ${n}`)}</title></rect>`
|
||||
);
|
||||
}
|
||||
const name = x.name.length > 12 ? x.name.slice(0, 11) + "…" : x.name;
|
||||
@@ -2422,6 +2578,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 +2635,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);
|
||||
@@ -2512,6 +2677,40 @@
|
||||
? "Today's overview"
|
||||
: day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
|
||||
}
|
||||
renderExcludeToggle(day);
|
||||
}
|
||||
|
||||
// The "not counted" switch, and the overview's own dimmed state while it is
|
||||
// on, so the mark is legible from the day itself and not only from the charts.
|
||||
function renderExcludeToggle(day) {
|
||||
const btn = document.getElementById("exclude-day");
|
||||
const note = document.getElementById("excluded-note");
|
||||
const panel = document.querySelector('[data-panel="overview"]');
|
||||
// Deciding a day doesn't count is a judgment about the owner's own record,
|
||||
// so it sits with the rest of what a guest cannot do.
|
||||
btn.hidden = isGuest();
|
||||
const off = isExcluded(day);
|
||||
btn.setAttribute("aria-pressed", String(off));
|
||||
btn.classList.toggle("active", off);
|
||||
note.hidden = !off;
|
||||
panel.classList.toggle("day-excluded", off);
|
||||
}
|
||||
|
||||
// Marking is adding an event; un-marking is deleting it — so both ride the
|
||||
// ordinary sync, offline included, with no special casing anywhere.
|
||||
function toggleExcludedDay(day) {
|
||||
const key = ymd(day);
|
||||
const existing = live().filter(
|
||||
e => e.type === EXCLUDED_TYPE && ymd(new Date(e.at)) === key);
|
||||
if (existing.length > 0) {
|
||||
// Plural in principle: two devices could each mark the same day offline.
|
||||
for (const e of existing) deleteEvent(e.id);
|
||||
} else {
|
||||
// Noon, so the day it lands on survives any clock or timezone wobble.
|
||||
const at = startOfDay(day);
|
||||
at.setHours(12, 0, 0, 0);
|
||||
addEvent(EXCLUDED_TYPE, "", at.getTime());
|
||||
}
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
@@ -2529,6 +2728,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
|
||||
@@ -2568,25 +2769,34 @@
|
||||
|
||||
function render() {
|
||||
const events = live();
|
||||
// Which days don't count is read straight off the event list, and settles
|
||||
// before anything draws — the chart drawers consult excludedSet directly.
|
||||
excludedSet = excludedDays(events);
|
||||
const counted = countedEvents(events);
|
||||
renderHeader();
|
||||
renderDayBar();
|
||||
renderChartWindow();
|
||||
// Day-scoped panels get the full list: you navigated to this day, so you
|
||||
// should see what is actually on it, marked or not.
|
||||
renderBigClock(events);
|
||||
renderActionHints(events);
|
||||
renderStats(events);
|
||||
renderLasts(events);
|
||||
renderTiming(events);
|
||||
renderSleepWake(events);
|
||||
renderWalks(events);
|
||||
renderWeekly(events);
|
||||
renderSleepTimeline(events);
|
||||
renderWalkPatterns(events);
|
||||
renderSleepTrend(events);
|
||||
renderHourHeatmap(events);
|
||||
renderTraining(events);
|
||||
renderHistory(events);
|
||||
// Weight and notes are records of fact rather than behaviour a sparse
|
||||
// logger distorts, so they count everywhere regardless.
|
||||
renderWeight(events);
|
||||
renderNotes(events);
|
||||
renderHistory(events);
|
||||
// Everything that aggregates across days works from the counted list.
|
||||
renderTiming(counted);
|
||||
renderWeekly(counted);
|
||||
renderSleepTimeline(counted);
|
||||
renderWalkPatterns(counted);
|
||||
renderSleepTrend(counted);
|
||||
renderHourHeatmap(counted);
|
||||
renderTraining(counted);
|
||||
}
|
||||
|
||||
// ---------- sync ----------
|
||||
@@ -2635,7 +2845,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 +2858,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 +2889,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 +3145,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 +3182,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 +3318,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 +3333,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 +3393,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 +3739,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 +4500,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();
|
||||
@@ -4291,6 +4723,9 @@
|
||||
dayPicker.value = ymd(new Date());
|
||||
render();
|
||||
});
|
||||
document.getElementById("exclude-day").addEventListener("click", () => {
|
||||
toggleExcludedDay(selectedDay()); // addEvent/deleteEvent re-render for us
|
||||
});
|
||||
|
||||
// Clicking the status pill forces an immediate sync.
|
||||
statusEl.style.cursor = "pointer";
|
||||
@@ -4419,6 +4854,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 +4915,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 +5013,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 +5027,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 +5049,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,4 +1,6 @@
|
||||
[
|
||||
{ "date": "2026-09-07", "text": "A day can now be left out of the stats. Open the day, tap “⊘ Not counted” next to the overview heading, and it stops feeding the charts and averages — useful when someone else had the puppy and the record is thinner than the day really was, so it isn't fair to count it. Nothing is deleted or hidden: the day's own overview, history and sleep & wake list are exactly as they were, just dimmed and labelled, and you can switch it back at any time. In the day-by-day charts the day keeps its place but is drawn as a hatch instead of a bar, so a deliberate gap can't be misread as a day the puppy barely slept. Weigh-ins and notes still count wherever they fall — those are facts you recorded, not behaviour a sparse day distorts — so the weight curve and the Notes log are untouched. The Timing panel throws away gaps that reach across a skipped day rather than measuring them, which would otherwise turn two normal days into one enormous fake gap" },
|
||||
{ "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" },
|
||||
|
||||
+103
-16
@@ -21,6 +21,18 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Shared SVG defs. Inline SVGs in one document share an id space, so the
|
||||
hatch every chart uses for a "not counted" day is defined once here
|
||||
rather than repeated into each chart's markup. Colour comes from CSS,
|
||||
so it follows the theme. -->
|
||||
<svg width="0" height="0" aria-hidden="true" focusable="false" style="position:absolute">
|
||||
<defs>
|
||||
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||
<line class="hatch-line" x1="0" y1="0" x2="0" y2="6" />
|
||||
</pattern>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<!-- Shown when a newer build's service worker is waiting. "Reload" activates
|
||||
it and refreshes onto the new assets; "Later" dismisses until next time.
|
||||
Suppressed on the very first install (see app.js). -->
|
||||
@@ -40,7 +52,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 +73,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 +101,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
|
||||
@@ -159,7 +187,18 @@
|
||||
</section>
|
||||
|
||||
<section class="overview" data-panel="overview">
|
||||
<h2 id="overview-title">Today's overview</h2>
|
||||
<!-- The toggle lives here rather than in the day bar: that bar is held
|
||||
to one row on small phones and a sixth control would break it,
|
||||
while this panel is the selected day's summary and has the room. -->
|
||||
<div class="overview-head">
|
||||
<h2 id="overview-title">Today's overview</h2>
|
||||
<button type="button" id="exclude-day" class="ghost exclude-btn"
|
||||
aria-pressed="false"
|
||||
title="Leave this day out of the charts and averages">⊘ Not counted</button>
|
||||
</div>
|
||||
<p id="excluded-note" class="muted-note excluded-note" hidden>
|
||||
Not counted in the charts and averages. Everything below is unchanged.
|
||||
</p>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Sleep</div>
|
||||
@@ -414,17 +453,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 +488,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 +604,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" />
|
||||
|
||||
+120
@@ -933,6 +933,126 @@ button.linklike:hover { text-decoration: underline; filter: none; }
|
||||
}
|
||||
.danger-text strong { color: var(--danger); }
|
||||
|
||||
/* ---------- days that don't count ---------- */
|
||||
.overview-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.overview-head h2 { margin: 0; }
|
||||
.exclude-btn {
|
||||
flex: none;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.exclude-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
.exclude-btn[hidden] { display: none; }
|
||||
|
||||
.excluded-note { margin: 8px 0 0; }
|
||||
|
||||
/* The day's own figures stay readable but visibly step back, so "this one is
|
||||
not in the numbers" is legible from the day as well as from the charts. */
|
||||
.overview.day-excluded .stats,
|
||||
.overview.day-excluded .last-row { opacity: 0.55; }
|
||||
|
||||
/* The hatch every chart uses for a day that doesn't count. The pattern itself
|
||||
is defined once in index.html; the stroke is set here so it follows the
|
||||
theme, and the fill sits on a faint wash so the column reads as a marked
|
||||
slot rather than as ink. */
|
||||
.hatch-line {
|
||||
stroke: var(--muted);
|
||||
stroke-width: 2;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.bar-excluded {
|
||||
fill: url(#hatch);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ---------- 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user