Let a day be left out of the stats
Every logged day was treated as equally trustworthy, and they aren't. A day someone else had the puppy leaves a thin record that reads exactly like a real one — five hours of sleep, two pees, no walk — and then drags down the average, widens the longest gap in the Timing panel and puts a trough in every chart that never happened. "Not counted", in the overview panel's heading, takes the day you are looking at out of everything that aggregates across days. Nothing is deleted or hidden. The day's own overview, history and sleep & wake list are exactly as they were, dimmed and labelled; navigate to it and it is all still there. Only the cross-day views stop seeing it, and weight and notes keep counting wherever they fall — a weigh-in and a vet note are facts you recorded, not behaviour a sparse logger distorts. The mark is an ordinary event, the way a training session is. That was the whole reason to do it this way: a set of marks that sync per-item with last-write-wins and tombstones is exactly what the event contract already provides, so un-marking is a delete, offline works, and two devices marking the same day resolve themselves. An excluded_days table would have meant a table, an endpoint, a request/response pair and a client cache to re-derive semantics already in hand. Every renderer selects events by type, so a new type is inert everywhere it isn't wanted; only the History log has to filter it out, being the one view that shows whatever it is handed. render() already computed the event list once and fanned it out, which made the seam a single place: day-scoped panels keep the full list, weight and notes keep it too, and the seven cross-day renderers take a counted one. Filtering alone gets two things wrong, and those are most of the diff. An empty slot lies. A marked day with no events draws a zero bar, which reads as "the puppy barely slept" — precisely the misreading the mark exists to prevent. So weeklyData zeroes the day's figures and flags it, and the four bar charts, both actograms and the training grid paint a hatch in the slot instead. Zeroing centrally rather than in each chart means every axis maximum, total and tooltip downstream is already right. The slot stays: dropping it would make consecutive bars stop being consecutive days. Gaps balloon. gapsBetween subtracts consecutive events, so with a day's events gone Tuesday's last pee sits next to Thursday's first and the subtraction invents thirty hours — worse for the panel than the sparse day ever was. Any gap whose interval touches a marked day is therefore discarded rather than measured. Sleep and walk durations need no such care: sleepMsInRange and walkMsInRange already clip to the day being measured, so a nap running in from a marked day contributes only its counted part. Both trend charts skip marked days explicitly rather than leaning on their existing "any sleep at all" guard, which would have let a nap crossing midnight give a marked day a non-zero total and sneak it back into the average. Owner-only, alongside the rest of what a guest may not decide: a sitter should not be able to rule their own thin day out, nor quietly take a good one out of the averages. The server drops day-excluded events arriving on a guest session; the client hides the control to match.
This commit is contained in:
@@ -35,6 +35,10 @@ source-of-truth and sync between devices.
|
|||||||
- All data is scoped to the signed-in account (see [Accounts](#accounts)): every
|
- 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
|
event, profile and photo carries a `user_id`, and `localStorage` is namespaced
|
||||||
per user so two accounts on one browser never mix.
|
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` /
|
A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` /
|
||||||
`sync error` / `offline`. Tap it to force-sync.
|
`sync error` / `offline`. Tap it to force-sync.
|
||||||
@@ -100,6 +104,37 @@ events, profile and photos.
|
|||||||
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
|
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
|
||||||
aren't sent in the clear.
|
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
|
## Guest links
|
||||||
|
|
||||||
A dog sitter needs to log a pee; they do not need your password. **Settings →
|
A dog sitter needs to log a pee; they do not need your password. **Settings →
|
||||||
|
|||||||
@@ -589,6 +589,49 @@ func TestOwnerCanChangeAGuestsEvents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// Guests still log freely — the guard is on changing what already exists.
|
||||||
func TestGuestCanStillAddEvents(t *testing.T) {
|
func TestGuestCanStillAddEvents(t *testing.T) {
|
||||||
a := testAuth(t)
|
a := testAuth(t)
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ type Exercise struct {
|
|||||||
Deleted bool `json:"deleted,omitempty"`
|
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}$`)
|
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) }
|
func validUUID(s string) bool { return uuidRE.MatchString(s) }
|
||||||
@@ -190,6 +196,13 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
|
|||||||
if ce.ID == "" {
|
if ce.ID == "" {
|
||||||
continue
|
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(
|
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, loggedBy, shareID,
|
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 {
|
); err != nil {
|
||||||
|
|||||||
+197
-27
@@ -464,6 +464,53 @@
|
|||||||
return d;
|
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) {
|
function formatTime(ts) {
|
||||||
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||||
}
|
}
|
||||||
@@ -749,7 +796,16 @@
|
|||||||
.map(e => e.at)
|
.map(e => e.at)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
const gaps = [];
|
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);
|
return gaps.sort((a, b) => a - b);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1109,7 +1165,9 @@
|
|||||||
|
|
||||||
function renderHistory(events) {
|
function renderHistory(events) {
|
||||||
const day = selectedDay();
|
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 rails = historyRails(events, chronological, day);
|
||||||
const dayEvents = [...chronological].reverse();
|
const dayEvents = [...chronological].reverse();
|
||||||
const exNames = exerciseNames();
|
const exNames = exerciseNames();
|
||||||
@@ -1300,11 +1358,18 @@
|
|||||||
d.setDate(d.getDate() - i);
|
d.setDate(d.getDate() - i);
|
||||||
const from = startOfDay(d).getTime();
|
const from = startOfDay(d).getTime();
|
||||||
const to = (i === 0) ? now : endOfDay(d).getTime();
|
const to = (i === 0) ? now : endOfDay(d).getTime();
|
||||||
const sleepMs = sleepMsInRange(events, from, to);
|
// A day marked "not counted" keeps its slot on the axis — dropping it
|
||||||
const dayEvents = eventsForDay(events, d);
|
// 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({
|
days.push({
|
||||||
date: d,
|
date: d,
|
||||||
ymd: ymd(d),
|
ymd: ymd(d),
|
||||||
|
excluded,
|
||||||
sleepHours: sleepMs / 3_600_000,
|
sleepHours: sleepMs / 3_600_000,
|
||||||
pees: dayEvents.filter(e => e.type === "pee").length,
|
pees: dayEvents.filter(e => e.type === "pee").length,
|
||||||
poos: dayEvents.filter(e => e.type === "poo").length,
|
poos: dayEvents.filter(e => e.type === "poo").length,
|
||||||
@@ -1312,12 +1377,21 @@
|
|||||||
grams: dayEvents
|
grams: dayEvents
|
||||||
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
|
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
|
||||||
.reduce((s, e) => s + e.grams, 0),
|
.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;
|
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) {
|
function dayLabel(date, isToday) {
|
||||||
if (isToday) return "Today";
|
if (isToday) return "Today";
|
||||||
return date.toLocaleDateString(undefined, { weekday: "short" });
|
return date.toLocaleDateString(undefined, { weekday: "short" });
|
||||||
@@ -1421,11 +1495,15 @@
|
|||||||
if (isSel) {
|
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="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||||||
}
|
}
|
||||||
|
if (d.excluded) {
|
||||||
|
parts.push(excludedSlot(d, x, barW, MT, innerH));
|
||||||
|
} else {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<rect class="bar bar-sleep ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
`<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">` +
|
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||||||
`<title>${escapeText(title)}</title></rect>`
|
`<title>${escapeText(title)}</title></rect>`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
// The selected day always gets a label (accent-colored), even on wide
|
// The selected day always gets a label (accent-colored), even on wide
|
||||||
// windows that would otherwise skip it.
|
// windows that would otherwise skip it.
|
||||||
if (showDayLabel(i, days.length) || isSel) {
|
if (showDayLabel(i, days.length) || isSel) {
|
||||||
@@ -1477,6 +1555,10 @@
|
|||||||
if (isSel) {
|
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"/>`);
|
parts.push(`<rect class="day-highlight" x="${(groupX - groupGap / 2).toFixed(1)}" y="${MT}" width="${(groupW + groupGap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||||||
}
|
}
|
||||||
|
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) => {
|
series.forEach((s, j) => {
|
||||||
const val = d[s.key];
|
const val = d[s.key];
|
||||||
const x = groupX + j * (barW + innerBarGap);
|
const x = groupX + j * (barW + innerBarGap);
|
||||||
@@ -1489,6 +1571,7 @@
|
|||||||
`<title>${escapeText(title)}</title></rect>`
|
`<title>${escapeText(title)}</title></rect>`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (showDayLabel(i, days.length) || isSel) {
|
if (showDayLabel(i, days.length) || isSel) {
|
||||||
parts.push(
|
parts.push(
|
||||||
@@ -1539,11 +1622,15 @@
|
|||||||
if (isSel) {
|
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="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||||||
}
|
}
|
||||||
|
if (d.excluded) {
|
||||||
|
parts.push(excludedSlot(d, x, barW, MT, innerH));
|
||||||
|
} else {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<rect class="bar bar-eat ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
`<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">` +
|
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||||||
`<title>${escapeText(title)}</title></rect>`
|
`<title>${escapeText(title)}</title></rect>`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if (showDayLabel(i, days.length) || isSel) {
|
if (showDayLabel(i, days.length) || isSel) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||||||
@@ -1593,11 +1680,15 @@
|
|||||||
if (isSel) {
|
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="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||||||
}
|
}
|
||||||
|
if (d.excluded) {
|
||||||
|
parts.push(excludedSlot(d, x, barW, MT, innerH));
|
||||||
|
} else {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<rect class="bar bar-walk ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
`<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">` +
|
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||||||
`<title>${escapeText(title)}</title></rect>`
|
`<title>${escapeText(title)}</title></rect>`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if (showDayLabel(i, days.length) || isSel) {
|
if (showDayLabel(i, days.length) || isSel) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||||||
@@ -1685,6 +1776,12 @@
|
|||||||
|
|
||||||
parts.push(`<rect class="stl-track${isSel ? " stl-selected" : ""}" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}" rx="2"/>`);
|
parts.push(`<rect class="stl-track${isSel ? " stl-selected" : ""}" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}" rx="2"/>`);
|
||||||
|
|
||||||
|
// 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) {
|
for (const wdw of windows) {
|
||||||
const s = Math.max(wdw.start, dayStart);
|
const s = Math.max(wdw.start, dayStart);
|
||||||
const e = Math.min(wdw.end, dayEnd);
|
const e = Math.min(wdw.end, dayEnd);
|
||||||
@@ -1693,11 +1790,13 @@
|
|||||||
const wpx = ((e - s) / dayMs) * innerW;
|
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"/>`);
|
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()}`;
|
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>`);
|
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>`);
|
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>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1751,11 +1850,12 @@
|
|||||||
};
|
};
|
||||||
const day = selectedDay();
|
const day = selectedDay();
|
||||||
const isToday = ymd(day) === ymd(new Date());
|
const isToday = ymd(day) === ymd(new Date());
|
||||||
const dayStartTs = (daysAgo) => {
|
const dayAgo = (daysAgo) => {
|
||||||
const d = startOfDay(day);
|
const d = startOfDay(day);
|
||||||
d.setDate(d.getDate() - daysAgo);
|
d.setDate(d.getDate() - daysAgo);
|
||||||
return d.getTime();
|
return d;
|
||||||
};
|
};
|
||||||
|
const dayStartTs = (daysAgo) => dayAgo(daysAgo).getTime();
|
||||||
const pointsAt = (start, stops) =>
|
const pointsAt = (start, stops) =>
|
||||||
stops.map(t => ({ x: (t - start) / HOUR, y: walkedMs(start, t) / MIN }));
|
stops.map(t => ({ x: (t - start) / HOUR, y: walkedMs(start, t) / MIN }));
|
||||||
|
|
||||||
@@ -1779,14 +1879,17 @@
|
|||||||
const totalOf = (pts) => pts[pts.length - 1].y;
|
const totalOf = (pts) => pts[pts.length - 1].y;
|
||||||
|
|
||||||
const today = curveFor(dayStartTs(0), isToday ? Date.now() : null);
|
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 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
|
// Mean of the last N days, skipping days that don't count and days with no
|
||||||
// logging doesn't drag the average toward zero.
|
// walk at all, so a gap in logging doesn't drag the average toward zero.
|
||||||
const avgDays = chartDays();
|
const avgDays = chartDays();
|
||||||
const dayCurves = [];
|
const dayCurves = [];
|
||||||
for (let i = 1; i <= avgDays; i++) {
|
for (let i = 1; i <= avgDays; i++) {
|
||||||
|
if (isExcluded(dayAgo(i))) continue;
|
||||||
const c = hourlyFor(dayStartTs(i));
|
const c = hourlyFor(dayStartTs(i));
|
||||||
if (c[24].y > 0) dayCurves.push(c);
|
if (c[24].y > 0) dayCurves.push(c);
|
||||||
}
|
}
|
||||||
@@ -1995,11 +2098,12 @@
|
|||||||
};
|
};
|
||||||
const day = selectedDay();
|
const day = selectedDay();
|
||||||
const isToday = ymd(day) === ymd(new Date());
|
const isToday = ymd(day) === ymd(new Date());
|
||||||
const dayStartTs = (daysAgo) => {
|
const dayAgo = (daysAgo) => {
|
||||||
const d = startOfDay(day);
|
const d = startOfDay(day);
|
||||||
d.setDate(d.getDate() - daysAgo);
|
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
|
// capTs (today only) truncates the curve at "now" with a final fractional
|
||||||
// point, so the line visibly ends where the day currently stands.
|
// point, so the line visibly ends where the day currently stands.
|
||||||
const curveFor = (start, capTs) => {
|
const curveFor = (start, capTs) => {
|
||||||
@@ -2018,14 +2122,22 @@
|
|||||||
// A past day is complete, so its curve runs the full 24h uncapped.
|
// A past day is complete, so its curve runs the full 24h uncapped.
|
||||||
const today = curveFor(dayStartTs(0), isToday ? Date.now() : null);
|
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 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
|
// Mean of the last N full days, skipping days that don't count and days
|
||||||
// young log (or a tracking gap) doesn't drag the average toward zero.
|
// with no sleep at all, so a young log (or a tracking gap) doesn't drag the
|
||||||
|
// average toward zero.
|
||||||
const avgDays = chartDays();
|
const avgDays = chartDays();
|
||||||
const dayCurves = [];
|
const dayCurves = [];
|
||||||
for (let i = 1; i <= avgDays; i++) {
|
for (let i = 1; i <= avgDays; i++) {
|
||||||
|
if (isExcluded(dayAgo(i))) continue;
|
||||||
const c = curveFor(dayStartTs(i));
|
const c = curveFor(dayStartTs(i));
|
||||||
if (c[24].y > 0) dayCurves.push(c);
|
if (c[24].y > 0) dayCurves.push(c);
|
||||||
}
|
}
|
||||||
@@ -2427,14 +2539,26 @@
|
|||||||
const max = Math.max(1, ...counts[r]);
|
const max = Math.max(1, ...counts[r]);
|
||||||
for (let c = 0; c < N; c++) {
|
for (let c = 0; c < N; c++) {
|
||||||
const n = counts[r][c];
|
const n = counts[r][c];
|
||||||
const op = n === 0 ? 0.06 : 0.35 + 0.65 * (n / max);
|
|
||||||
const d = dayList[c];
|
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(
|
parts.push(
|
||||||
`<rect class="bar hm-cell hm-training" data-day="${ymd(d)}" ` +
|
`<rect class="bar hm-cell hm-training" data-day="${ymd(d)}" ` +
|
||||||
`x="${(ML + c * cellW).toFixed(1)}" y="${y.toFixed(1)}" ` +
|
`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)}">` +
|
`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;
|
const name = x.name.length > 12 ? x.name.slice(0, 11) + "…" : x.name;
|
||||||
@@ -2553,6 +2677,40 @@
|
|||||||
? "Today's overview"
|
? "Today's overview"
|
||||||
: day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
|
: 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() {
|
function renderHeader() {
|
||||||
@@ -2611,25 +2769,34 @@
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const events = live();
|
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();
|
renderHeader();
|
||||||
renderDayBar();
|
renderDayBar();
|
||||||
renderChartWindow();
|
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);
|
renderBigClock(events);
|
||||||
renderActionHints(events);
|
renderActionHints(events);
|
||||||
renderStats(events);
|
renderStats(events);
|
||||||
renderLasts(events);
|
renderLasts(events);
|
||||||
renderTiming(events);
|
|
||||||
renderSleepWake(events);
|
renderSleepWake(events);
|
||||||
renderWalks(events);
|
renderWalks(events);
|
||||||
renderWeekly(events);
|
renderHistory(events);
|
||||||
renderSleepTimeline(events);
|
// Weight and notes are records of fact rather than behaviour a sparse
|
||||||
renderWalkPatterns(events);
|
// logger distorts, so they count everywhere regardless.
|
||||||
renderSleepTrend(events);
|
|
||||||
renderHourHeatmap(events);
|
|
||||||
renderTraining(events);
|
|
||||||
renderWeight(events);
|
renderWeight(events);
|
||||||
renderNotes(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 ----------
|
// ---------- sync ----------
|
||||||
@@ -4556,6 +4723,9 @@
|
|||||||
dayPicker.value = ymd(new Date());
|
dayPicker.value = ymd(new Date());
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
document.getElementById("exclude-day").addEventListener("click", () => {
|
||||||
|
toggleExcludedDay(selectedDay()); // addEvent/deleteEvent re-render for us
|
||||||
|
});
|
||||||
|
|
||||||
// Clicking the status pill forces an immediate sync.
|
// Clicking the status pill forces an immediate sync.
|
||||||
statusEl.style.cursor = "pointer";
|
statusEl.style.cursor = "pointer";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[
|
[
|
||||||
|
{ "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-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-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": "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" },
|
||||||
|
|||||||
@@ -21,6 +21,18 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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
|
<!-- Shown when a newer build's service worker is waiting. "Reload" activates
|
||||||
it and refreshes onto the new assets; "Later" dismisses until next time.
|
it and refreshes onto the new assets; "Later" dismisses until next time.
|
||||||
Suppressed on the very first install (see app.js). -->
|
Suppressed on the very first install (see app.js). -->
|
||||||
@@ -175,7 +187,18 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="overview" data-panel="overview">
|
<section class="overview" data-panel="overview">
|
||||||
|
<!-- 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>
|
<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="stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Sleep</div>
|
<div class="stat-label">Sleep</div>
|
||||||
|
|||||||
@@ -933,6 +933,48 @@ button.linklike:hover { text-decoration: underline; filter: none; }
|
|||||||
}
|
}
|
||||||
.danger-text strong { color: var(--danger); }
|
.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 ---------- */
|
/* ---------- guest links ---------- */
|
||||||
/* The one-time URL. Shown once and never again, so it gets a box of its own
|
/* 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. */
|
rather than sitting inline where it could be missed. */
|
||||||
|
|||||||
Reference in New Issue
Block a user