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:
Alexander Heldt
2026-09-07 19:01:06 +00:00
parent e22031ed4f
commit da68b733e4
7 changed files with 389 additions and 62 deletions
+43
View File
@@ -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.
func TestGuestCanStillAddEvents(t *testing.T) {
a := testAuth(t)