oficina / sheets Public

calc: NOW and TODAY, the volatile functions the corpus exclusion exists for

Registers NOW/TODAY as clockFuncs (env.Now(), not time.Now()) returning
a spreadsheet date serial (days since the Excel/LibreOffice epoch,
1899-12-30) -- a plain number, per the "currency is a format, not a
type" rule extended to dates. TODAY truncates to midnight.

These are deliberately the first functions implemented: the next
commit stands up the golden-corpus harness, and its clock-pinning test
(TestClockIsPinnedWithinAPass) is only meaningful once a real NOW
exists to evaluate -- otherwise both calls would resolve to #NAME? and
the test would pass vacuously.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
Paul Campbell 3b893bc129993ea56a99d575fcb73f47e1e0134c parent a8a1031
2 files changed, +105 −0
  • internal/sheets/calc/fn_date.go +51 −0
  • internal/sheets/calc/fn_date_test.go +54 −0
diff --git a/internal/sheets/calc/fn_date.go b/internal/sheets/calc/fn_date.go
new file mode 100644
index 0000000..964e693
--- /dev/null
+++ b/internal/sheets/calc/fn_date.go
@@ -0,0 +1,51 @@
+package calc
+
+import "time"
+
+func init() {
+ registerClock("NOW", fnNow)
+ registerClock("TODAY", fnToday)
+}
+
+// excelEpoch is day zero in spreadsheet date-serial numbering — Dec 30,
+// 1899, the convention both Excel and LibreOffice Calc use by default.
+// A date is not a distinct Value kind in this engine: it is a plain
+// number that a date number format later displays as a calendar date —
+// the same "currency is a format, not a type" rule (global constraint)
+// extended to dates. Cell values are IEEE doubles end to end, so this
+// applies here exactly as everywhere else: no rounding at this boundary.
+var excelEpoch = time.Date(1899, time.December, 30, 0, 0, 0, 0, time.UTC)
+
+// dateSerial converts t to spreadsheet date-serial form: whole days since
+// excelEpoch, with the time of day as a fraction of one day.
+func dateSerial(t time.Time) float64 {
+ return t.UTC().Sub(excelEpoch).Hours() / 24
+}
+
+// fnNow returns the pass's pinned instant (env.Now()) as a date serial
+// with a fractional day for the time of day. It must call env.Now() and
+// nothing else — see corpus_test.go's TestClockIsPinnedWithinAPass,
+// written specifically to catch a version of this function that reached
+// past env for a live clock instead of the one the pass pinned.
+//
+// NOW is volatile — its answer differs on every recalculation pass by
+// design — so it is never checked against testdata/corpus/formulas.json
+// (see testdata/corpus/volatile-excluded.txt); nothing about that
+// exclusion weakens the guarantee this function itself must hold, that
+// two calls within ONE pass agree.
+func fnNow(env Env, args []Value) Value {
+ if len(args) != 0 {
+ return errValue(ErrValue)
+ }
+ return Value{Kind: KindNum, Num: dateSerial(env.Now())}
+}
+
+// fnToday is fnNow with the time of day truncated to midnight.
+func fnToday(env Env, args []Value) Value {
+ if len(args) != 0 {
+ return errValue(ErrValue)
+ }
+ t := env.Now().UTC()
+ midnight := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
+ return Value{Kind: KindNum, Num: dateSerial(midnight)}
+}
diff --git a/internal/sheets/calc/fn_date_test.go b/internal/sheets/calc/fn_date_test.go
new file mode 100644
index 0000000..7cc7161
--- /dev/null
+++ b/internal/sheets/calc/fn_date_test.go
@@ -0,0 +1,54 @@
+package calc
+
+import (
+ "testing"
+ "time"
+)
+
+// pinnedEnv answers every cell as blank and Now() as a fixed instant —
+// the minimal Env a NOW()/TODAY() unit test needs, independent of
+// corpus_test.go's clockEnv (which additionally models advancing a wall
+// clock that Now() ignores).
+type pinnedEnv time.Time
+
+func (p pinnedEnv) CellValue(CellKey) Value { return Value{} }
+func (p pinnedEnv) Now() time.Time { return time.Time(p) }
+
+func TestNowUsesEnvClock(t *testing.T) {
+ env := pinnedEnv(time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC))
+ v := Eval(mustParse(t, "NOW()"), env)
+ if v.Kind != KindNum {
+ t.Fatalf("NOW() Kind = %v, want KindNum", v.Kind)
+ }
+ want := dateSerial(time.Time(env))
+ if v.Num != want {
+ t.Fatalf("NOW() = %v, want %v", v.Num, want)
+ }
+}
+
+// TODAY truncates the time of day to midnight, so it differs from NOW by
+// less than one full day and is itself a whole number of days.
+func TestTodayTruncatesToMidnight(t *testing.T) {
+ env := pinnedEnv(time.Date(2026, 8, 30, 15, 30, 0, 0, time.UTC))
+ v := Eval(mustParse(t, "TODAY()"), env)
+ if v.Kind != KindNum {
+ t.Fatalf("TODAY() Kind = %v, want KindNum", v.Kind)
+ }
+ if v.Num != float64(int64(v.Num)) {
+ t.Fatalf("TODAY() = %v, want a whole number of days", v.Num)
+ }
+ now := Eval(mustParse(t, "NOW()"), env)
+ if now.Num-v.Num >= 1 || now.Num-v.Num < 0 {
+ t.Fatalf("NOW()-TODAY() = %v, want in [0,1)", now.Num-v.Num)
+ }
+}
+
+func TestNowTodayRejectArguments(t *testing.T) {
+ env := pinnedEnv(time.Now())
+ for _, formula := range []string{"NOW(1)", "TODAY(1)"} {
+ v := Eval(mustParse(t, formula), env)
+ if v.Kind != KindErr || v.Err != ErrValue {
+ t.Errorf("with an argument = %v, want #VALUE!", v)
+ }
+ }
+}