From 46acf3cba022934f2e28817df6f1761327879473 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 04:43:38 +0400 Subject: [PATCH] the spoken plan reads the clock on his wall (V-614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/mavend/actions_query.go | 11 +++++++++-- cmd/mavend/dayplan_test.go | 31 +++++++++++++++++++++++++++++++ cmd/mavend/ruwords.go | 5 +++++ cmd/mavend/ruwords_test.go | 24 +++++++++++++++++++++++- internal/morning/plan.go | 10 +++++++++- internal/morning/plan_test.go | 31 +++++++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index c41984b..bbad2f2 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -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, diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go index 52a0aa9..58d552e 100644 --- a/cmd/mavend/dayplan_test.go +++ b/cmd/mavend/dayplan_test.go @@ -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) { diff --git a/cmd/mavend/ruwords.go b/cmd/mavend/ruwords.go index 6ceff05..14ed0e2 100644 --- a/cmd/mavend/ruwords.go +++ b/cmd/mavend/ruwords.go @@ -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 "только что" } diff --git a/cmd/mavend/ruwords_test.go b/cmd/mavend/ruwords_test.go index 5b98fc5..94ccbbb 100644 --- a/cmd/mavend/ruwords_test.go +++ b/cmd/mavend/ruwords_test.go @@ -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, diff --git a/internal/morning/plan.go b/internal/morning/plan.go index ee863a2..27732ca 100644 --- a/internal/morning/plan.go +++ b/internal/morning/plan.go @@ -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}) } diff --git a/internal/morning/plan_test.go b/internal/morning/plan_test.go index 3448412..3378df7 100644 --- a/internal/morning/plan_test.go +++ b/internal/morning/plan_test.go @@ -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))