the spoken plan reads the clock on his wall (V-614)

FormatRU printed a plan item's At raw. An event and a reminder come off the
store as UTC — a calendar fact's Ts, a reminder's FireTs — while a checklist
line is built in the asking clock's zone, so one spoken sentence named two
zones. This is the voice path, so it is what he actually heard; the same
defect on /morning and /events was V-612.

Every hour is now read in the plan's own zone, Date's, which BuildPlan sets
from the asking clock. The rest-of-day path in queryDayPlan rebuilds a plan
off the wire, where nothing had put the instants in that frame, so it does
now.

formatTime is the same bug in the same daemon: "когда я это сделал?" names a
fact's Ts, and the branch that prints a wall clock printed the store's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 04:43:38 +04:00
parent 0b1efe4911
commit 46acf3cba0
6 changed files with 108 additions and 4 deletions
+9 -2
View File
@@ -254,10 +254,17 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
}
// Rebuild the pure plan so the rest-of-day rendering is the same code that
// rendered the whole day — one formatter, one persona.
p := morning.Plan{Date: plan.Date}
//
// The instants are put back in the asking clock's zone on the way in. They
// arrive carrying whatever zone the core read them in — a calendar fact's Ts
// and a reminder's FireTs are UTC out of the store — and FormatRU reads the
// hours in the plan's own frame, so setting that frame here is what makes
// the recital name his clock rather than the store's (V-614).
zone := h.now().Location()
p := morning.Plan{Date: plan.Date.In(zone)}
for _, it := range plan.Items {
p.Items = append(p.Items, morning.PlanEntry{
At: it.At,
At: it.At.In(zone),
Text: it.Text,
Kind: morning.PlanKind(it.Kind),
Uncertain: it.Uncertain,
+31
View File
@@ -111,6 +111,37 @@ func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
}
}
// "что дальше?" rebuilds the plan off the wire and renders it here, and the
// instants on it carry the zone the core read them in — a calendar fact's Ts
// and a reminder's FireTs are UTC out of the store. Read raw, the recital named
// the store's clock instead of his (V-614). The asking clock is three hours off
// whatever this machine runs in, so the assertion holds under TZ=UTC too.
func TestQueryDayPlanRestOfDayReadsHisClock(t *testing.T) {
_, off := time.Now().Zone()
away := time.FixedZone("away", off+3*60*60)
stored := time.Date(2026, 8, 3, 8, 0, 0, 0, time.UTC)
h := &reactiveHandler{
api: &planAPI{plan: ipc.DayPlan{
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
Items: []ipc.DayPlanItem{{At: stored, Text: "позвонить маме", Kind: "reminder"}},
}},
now: func() time.Time { return time.Date(2026, 8, 3, 9, 0, 0, 0, away) },
}
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
})
if !ok {
t.Fatal("expected the plan source to claim it")
}
if want := stored.In(away).Format("15:04"); !strings.Contains(reply, want) {
t.Errorf("the reminder is not read in his clock (%s): %q", want, reply)
}
if bad := stored.Format("15:04"); strings.Contains(reply, bad) {
t.Errorf("the reminder is read in the store's zone (%s): %q", bad, reply)
}
}
// A question that is not about the plan must fall through, or the plan buries
// the calendar listing and the weather behind it.
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
+5
View File
@@ -154,6 +154,11 @@ func hasDurationWords(u string) bool {
// Used by the query handler when answering "когда я это сделал?"-style questions.
func formatTime(t time.Time) string {
now := time.Now()
// The argument is a fact's Ts, which the store hands back as UTC. Only the
// last branch names a wall clock, and it named the store's until V-614: an
// answer to "когда я это сделал?" read hours off, in the same sentence
// shape the plan reads a day in.
t = t.Local()
if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) {
return "только что"
}
+23 -1
View File
@@ -1,6 +1,28 @@
package main
import "testing"
import (
"strings"
"testing"
"time"
)
// "когда я это сделал?" answers off a fact's Ts, which the store hands back as
// UTC, and the branch that names a wall clock printed it in whatever zone it
// arrived in (V-614). The instant here is built three hours off this machine's
// zone, so the assertion holds under TZ=UTC as well.
func TestFormatTimeReadsHisClock(t *testing.T) {
_, off := time.Now().Zone()
away := time.FixedZone("away", off+3*60*60)
stored := time.Now().Add(-72 * time.Hour).In(away)
got := formatTime(stored)
if want := stored.Local().Format("15:04"); !strings.Contains(got, want) {
t.Errorf("formatTime = %q, want the hour on his clock (%s)", got, want)
}
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
t.Errorf("formatTime = %q reads the zone the fact arrived in (%s)", got, bad)
}
}
// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The
// weekday half of this guard was a list of stems matched with strings.Contains,
+9 -1
View File
@@ -159,7 +159,15 @@ func (p Plan) After(now time.Time) Plan {
// FormatRU renders the plan as maven says it. Feminine self-reference,
// informal address, no pet names — and no exhortation: she reads the day back,
// she does not tell him to get on with it.
//
// Every hour is read in the plan's own zone — Date's, which BuildPlan sets from
// the asking clock. Printed raw, an hour read whatever zone its instant arrived
// in: an event or a reminder comes off the store as UTC, while a checklist line
// is built local, so one spoken sentence named two zones. This is the voice
// path, so that is what the owner heard (V-614); the same defect on the two web
// pages was V-612.
func (p Plan) FormatRU() string {
zone := p.Date.Location()
if len(p.Items) == 0 {
// "что дальше?" after the last item of the day. The day was not empty,
// it is over, and saying it was empty is a false statement about a day
@@ -171,7 +179,7 @@ func (p Plan) FormatRU() string {
}
parts := make([]string, len(p.Items))
for i, it := range p.Items {
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
line := fmt.Sprintf("%s — %s", it.At.In(zone).Format("15:04"), it.Text)
if it.Uncertain {
line = say.S(say.PlanUncertain, map[string]string{"line": line})
}
+31
View File
@@ -151,6 +151,37 @@ func TestPlanFormatRU(t *testing.T) {
}
}
// One spoken sentence names one clock. An event and a reminder come off the
// store as UTC and a checklist line is built in the asking clock's zone, so the
// raw Format printed the two halves of one sentence in two zones (V-614). The
// zones here are three hours off whatever this machine runs in, so the test
// tells "read in his clock" apart from "read in the zone the instant arrived
// in" under TZ=UTC as well.
func TestPlanFormatRUReadsEveryHourInThePlansZone(t *testing.T) {
_, off := time.Now().Zone()
away := time.FixedZone("away", off+3*60*60)
stored := time.Date(2026, 8, 3, 14, 0, 0, 0, time.UTC)
p := Plan{
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, away),
Items: []PlanEntry{
{At: stored, Text: "Планёрка", Kind: PlanEvent},
{At: planAt(time.Date(2026, 8, 3, 0, 0, 0, 0, away), 10, 30),
Text: "утро — осталось: витамины", Kind: PlanChecklist},
},
}
got := p.FormatRU()
if want := stored.In(away).Format("15:04"); !strings.Contains(got, want) {
t.Errorf("the event is not read in the plan's zone (%s): %q", want, got)
}
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
t.Errorf("the event is read in the zone it was stored in (%s): %q", bad, got)
}
if !strings.Contains(got, "10:30") {
t.Errorf("the checklist line moved zone: %q", got)
}
}
func TestPlanAfter(t *testing.T) {
p, now := planFixture(t)
rest := p.After(planAt(now, 11, 0))