cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Add date and time functions

Dami committed c72f0d1
DATE, TIME, TODAY, NOW, YEAR, MONTH, DAY, WEEKDAY, HOUR, MINUTE,
SECOND, EDATE, EOMONTH, DATEDIF, DAYS, NETWORKDAYS, DATEVALUE and
TIMEVALUE on Sheets serial dates. TODAY and NOW are volatile, and
date functions make their cells show dates.
commit
c72f0d1ab0f2d5ff1da79248ddf1deb1e555d202
parent
9d6695f11c9264c53004bff8b9e48c4a6896cfef
author
Dami <dami.etoile@gmail.com>
2 files changed, +414 -0
Ainternal/sheet/functions_date.go290++++++++++++++++++++++++++++++
Ainternal/sheet/functions_date_test.go124+++++++++++++
A internal/sheet/functions_date.go +290 -0
@@ -0,0 +1,290 @@
1+package sheet2+3+import (4+ "math"5+ "strings"6+)7+8+var (9+ dateFormat = returns(Preset(FmtDate))10+ timeFormat = returns(Preset(FmtTime))11+ dateTimeFormat = returns(Preset(FmtDateTime))12+)13+14+func init() {15+ define(16+ &FuncDef{Name: "DATE", Args: "year, month, day", Desc: "A date from its parts; months and days past the end roll over", Min: 3, Max: 3,17+ eval: numeric(func(x []float64) Value {18+ y, m, d := math.Trunc(x[0]), math.Trunc(x[1]), math.Trunc(x[2])19+ if y >= 0 && y < 1900 {20+ y += 1900 // DATE(26, 1, 1) is 1926, as in Sheets21+ }22+ if y < 0 || y >= 10000 || math.Abs(m) > 1e6 || math.Abs(d) > 1e8 {23+ return ErrNum24+ }25+ v := dateSerial(int(y), int(m), int(d))26+ if v < 0 {27+ return ErrNum28+ }29+ return num(v)30+ }), format: dateFormat},31+ &FuncDef{Name: "TIME", Args: "hour, minute, second", Desc: "A time of day from its parts", Min: 3, Max: 3,32+ eval: numeric(func(x []float64) Value {33+ secs := math.Trunc(x[0])*3600 + math.Trunc(x[1])*60 + math.Trunc(x[2])34+ if secs < 0 {35+ return ErrNum36+ }37+ return num(math.Mod(secs, 86400) / 86400)38+ }), format: timeFormat},39+ &FuncDef{Name: "TODAY", Desc: "Today's date, updated on every change", Max: 0, Volatile: true,40+ eval: func([]Node, lookup) Value { return num(math.Floor(serialOf(Now()))) }, format: dateFormat},41+ &FuncDef{Name: "NOW", Desc: "The current date and time, updated on every change", Max: 0, Volatile: true,42+ eval: func([]Node, lookup) Value { return num(serialOf(Now())) }, format: dateTimeFormat},43+ &FuncDef{Name: "YEAR", Args: "date", Desc: "Year of a date", Min: 1, Max: 1,44+ eval: datePart(func(y, _, _ int) int { return y })},45+ &FuncDef{Name: "MONTH", Args: "date", Desc: "Month of a date, 1 to 12", Min: 1, Max: 1,46+ eval: datePart(func(_, m, _ int) int { return m })},47+ &FuncDef{Name: "DAY", Args: "date", Desc: "Day of the month of a date", Min: 1, Max: 1,48+ eval: datePart(func(_, _, d int) int { return d })},49+ &FuncDef{Name: "WEEKDAY", Args: "date, [type]", Desc: "Day of the week: 1 is Sunday, or Monday with type 2", Min: 1, Max: 2,50+ eval: func(args []Node, get lookup) Value {51+ d, err := dateArg(args[0], get)52+ if err != nil {53+ return *err54+ }55+ typ, err := intArg(args, 1, 1, get)56+ if err != nil {57+ return *err58+ }59+ days, _ := splitSerial(d)60+ wd := weekday(days) // 0 = Sunday61+ switch typ {62+ case 1:63+ return num(float64(wd + 1))64+ case 2:65+ return num(float64((wd+6)%7 + 1))66+ case 3:67+ return num(float64((wd + 6) % 7))68+ }69+ return ErrNum70+ }},71+ &FuncDef{Name: "HOUR", Args: "time", Desc: "Hour of a time, 0 to 23", Min: 1, Max: 1,72+ eval: timePart(func(s int) int { return s / 3600 })},73+ &FuncDef{Name: "MINUTE", Args: "time", Desc: "Minute of a time, 0 to 59", Min: 1, Max: 1,74+ eval: timePart(func(s int) int { return s / 60 % 60 })},75+ &FuncDef{Name: "SECOND", Args: "time", Desc: "Second of a time, 0 to 59", Min: 1, Max: 1,76+ eval: timePart(func(s int) int { return s % 60 })},77+ &FuncDef{Name: "EDATE", Args: "start_date, months", Desc: "The same day a number of months away", Min: 2, Max: 2,78+ eval: monthShift(false), format: dateFormat},79+ &FuncDef{Name: "EOMONTH", Args: "start_date, months", Desc: "The last day of the month a number of months away", Min: 2, Max: 2,80+ eval: monthShift(true), format: dateFormat},81+ &FuncDef{Name: "DATEDIF", Args: `start_date, end_date, unit`, Desc: `Time between dates in "Y", "M", "D", "MD", "YM" or "YD"`, Min: 3, Max: 3,82+ eval: datedif},83+ &FuncDef{Name: "DAYS", Args: "end_date, start_date", Desc: "Number of days between two dates", Min: 2, Max: 2,84+ eval: func(args []Node, get lookup) Value {85+ end, err := dateArg(args[0], get)86+ if err != nil {87+ return *err88+ }89+ start, err := dateArg(args[1], get)90+ if err != nil {91+ return *err92+ }93+ return num(math.Floor(end) - math.Floor(start))94+ }},95+ &FuncDef{Name: "NETWORKDAYS", Args: "start_date, end_date, [holidays]", Desc: "Number of weekdays between two dates, counting both", Min: 2, Max: 3,96+ eval: networkdays},97+ &FuncDef{Name: "DATEVALUE", Args: "date_string", Desc: "The date a text such as \"2026-09-26\" stands for", Min: 1, Max: 1,98+ eval: func(args []Node, get lookup) Value {99+ v, ok := parsedText(args[0], get)100+ if ok != nil {101+ return *ok102+ }103+ d, f, parsed := parseDateTime(v)104+ if !parsed || f.Kind == FmtTime || f.Kind == FmtDuration {105+ return ErrValue106+ }107+ return num(math.Floor(d))108+ }, format: dateFormat},109+ &FuncDef{Name: "TIMEVALUE", Args: "time_string", Desc: "The time of day a text such as \"2:30 PM\" stands for", Min: 1, Max: 1,110+ eval: func(args []Node, get lookup) Value {111+ v, ok := parsedText(args[0], get)112+ if ok != nil {113+ return *ok114+ }115+ if d, _, parsed := parseDateTime(v); parsed {116+ return num(d - math.Floor(d))117+ }118+ return ErrValue119+ }, format: timeFormat},120+ )121+}122+123+// parsedText requires a text argument, as DATEVALUE and TIMEVALUE do.124+func parsedText(n Node, get lookup) (string, *Value) {125+ v := eval(n, get)126+ switch v.Kind {127+ case Error:128+ return "", &v129+ case Text:130+ return v.Str, nil131+ }132+ return "", &ErrValue133+}134+135+// dateArg is a date argument: a serial number or text such as136+// "2026-09-26". Negative serials are #NUM!.137+func dateArg(n Node, get lookup) (float64, *Value) {138+ d, err := numArg(n, get)139+ if err != nil {140+ return 0, err141+ }142+ if d < 0 {143+ return 0, &ErrNum144+ }145+ return d, nil146+}147+148+func datePart(part func(y, m, d int) int) func([]Node, lookup) Value {149+ return func(args []Node, get lookup) Value {150+ d, err := dateArg(args[0], get)151+ if err != nil {152+ return *err153+ }154+ days, _ := splitSerial(d)155+ return num(float64(part(civil(days))))156+ }157+}158+159+func timePart(part func(secs int) int) func([]Node, lookup) Value {160+ return func(args []Node, get lookup) Value {161+ d, err := dateArg(args[0], get)162+ if err != nil {163+ return *err164+ }165+ _, secs := splitSerial(d)166+ return num(float64(part(secs)))167+ }168+}169+170+// monthShift builds EDATE and EOMONTH. EDATE keeps the day, pulling it171+// back to the month's last day when it doesn't exist (Jan 31 + 1 month is172+// Feb 28).173+func monthShift(endOfMonth bool) func([]Node, lookup) Value {174+ return func(args []Node, get lookup) Value {175+ d, err := dateArg(args[0], get)176+ if err != nil {177+ return *err178+ }179+ months, err := intArg(args, 1, 0, get)180+ if err != nil {181+ return *err182+ }183+ y, m, day := civil(int64(math.Floor(d)))184+ m += months185+ y += floorDivInt(m-1, 12)186+ m = ((m-1)%12+12)%12 + 1187+ if y < 1 || y > 9999 {188+ return ErrNum189+ }190+ last := daysIn(y, m)191+ if endOfMonth || day > last {192+ day = last193+ }194+ v := dateSerial(y, m, day)195+ if v < 0 {196+ return ErrNum197+ }198+ return num(v)199+ }200+}201+202+func datedif(args []Node, get lookup) Value {203+ start, err := dateArg(args[0], get)204+ if err != nil {205+ return *err206+ }207+ end, err := dateArg(args[1], get)208+ if err != nil {209+ return *err210+ }211+ unit, err := textArg(args[2], get)212+ if err != nil {213+ return *err214+ }215+ s, e := int64(math.Floor(start)), int64(math.Floor(end))216+ if s > e {217+ return ErrNum218+ }219+ sy, sm, sd := civil(s)220+ ey, em, ed := civil(e)221+ months := (ey-sy)*12 + em - sm222+ if ed < sd {223+ months--224+ }225+ switch strings.ToUpper(unit) {226+ case "Y":227+ return num(float64(months / 12))228+ case "M":229+ return num(float64(months))230+ case "D":231+ return num(float64(e - s))232+ case "YM":233+ return num(float64(months % 12))234+ case "MD":235+ if ed >= sd {236+ return num(float64(ed - sd))237+ }238+ // Days from sd in the month before the end date.239+ py, pm := ey, em-1240+ if pm == 0 {241+ py, pm = ey-1, 12242+ }243+ return num(float64(e) - dateSerial(py, pm, sd))244+ case "YD":245+ // Days since the last anniversary of the start date.246+ y := ey247+ if em < sm || em == sm && ed < sd {248+ y--249+ }250+ anniv := dateSerial(y, sm, min(sd, daysIn(y, sm)))251+ return num(float64(e) - anniv)252+ }253+ return ErrNum254+}255+256+func networkdays(args []Node, get lookup) Value {257+ start, err := dateArg(args[0], get)258+ if err != nil {259+ return *err260+ }261+ end, err := dateArg(args[1], get)262+ if err != nil {263+ return *err264+ }265+ holidays := map[int64]bool{}266+ if len(args) > 2 {267+ hs, err := nums(args[2:], get)268+ if err != nil {269+ return *err270+ }271+ for _, h := range hs {272+ holidays[int64(math.Floor(h))] = true273+ }274+ }275+ s, e := int64(math.Floor(start)), int64(math.Floor(end))276+ sign := 1.0277+ if s > e {278+ s, e, sign = e, s, -1279+ }280+ if e-s > 1e7 {281+ return ErrNum282+ }283+ n := 0284+ for d := s; d <= e; d++ {285+ if wd := weekday(d); wd != 0 && wd != 6 && !holidays[d] {286+ n++287+ }288+ }289+ return num(sign * float64(n))290+}
A internal/sheet/functions_date_test.go +124 -0
@@ -0,0 +1,124 @@
1+package sheet2+3+import (4+ "testing"5+ "time"6+)7+8+func TestDateFunctions(t *testing.T) {9+ checkFormulas(t, fixture(t), []fnCase{10+ {"=DATE(2026, 9, 26)", num(46291)},11+ {"=DATE(2026, 14, 1)", num(46419)}, // month 14 rolls into 202712+ {"=DATE(2026, 1, 0)", num(46022)}, // day 0 is the last of December13+ {"=DATE(26, 1, 1)", num(9498)}, // two-digit years are 19xx14+ {"=DATE(-1, 1, 1)", ErrNum},15+ {"=DATE(1899, 12, 30)", num(693961)}, // 1899 counts as 3799, as in Sheets16+ {"=TIME(14, 30, 0)", num(14.5 / 24)},17+ {"=TIME(25, 0, 0)", num(1.0 / 24)},18+ {"=TIME(0, -1, 0)", ErrNum},19+ {"=TODAY()", num(46291)},20+ {"=NOW()", num(46291 + 14.5/24)},21+ {"=YEAR(F1)", num(2026)},22+ {"=MONTH(F1)", num(9)},23+ {"=DAY(F1)", num(26)},24+ {`=YEAR("2026-09-26")`, num(2026)},25+ {"=YEAR(-1)", ErrNum},26+ {"=YEAR(A4)", ErrValue},27+ {"=WEEKDAY(F1)", num(7)}, // Saturday28+ {"=WEEKDAY(F1, 2)", num(6)},29+ {"=WEEKDAY(F1, 3)", num(5)},30+ {"=WEEKDAY(F1, 9)", ErrNum},31+ {"=HOUR(F3)", num(14)},32+ {"=MINUTE(F3)", num(30)},33+ {"=SECOND(TIME(1, 2, 3))", num(3)},34+ {"=HOUR(0.99999999)", num(0)}, // rounds to midnight35+ {"=EDATE(F2, 1)", num(46081)}, // Jan 31 + 1 month is Feb 2836+ {"=EDATE(F1, -12)", num(45926)},37+ {"=EOMONTH(F1, 0)", num(46295)},38+ {"=EOMONTH(F4, 12)", num(45716)},39+ {`=DATEDIF(F4, F1, "Y")`, num(2)},40+ {`=DATEDIF(F4, F1, "M")`, num(30)},41+ {`=DATEDIF(F4, F1, "D")`, num(46291 - 45351)},42+ {`=DATEDIF(F4, F1, "YM")`, num(6)},43+ {`=DATEDIF(F4, F1, "MD")`, num(28)},44+ {`=DATEDIF(F4, F1, "YD")`, num(210)}, // from Feb 28, the leap day's anniversary45+ {`=DATEDIF(F2, F5, "D")`, ErrNum}, // start after end46+ {`=DATEDIF(F4, F1, "Q")`, ErrNum},47+ {"=DAYS(F1, F2)", num(238)},48+ {`=DAYS("2026-01-02", "2026-01-01")`, num(1)},49+ {"=NETWORKDAYS(F5, F2)", num(23)},50+ {"=NETWORKDAYS(F2, F5)", num(-23)},51+ {"=NETWORKDAYS(F5, F2, F5)", num(22)},52+ {`=DATEVALUE("9/26/2026")`, num(46291)},53+ {`=DATEVALUE("Sep 26, 2026 14:30")`, num(46291)},54+ {`=DATEVALUE("hello")`, ErrValue},55+ {"=DATEVALUE(46291)", ErrValue},56+ {`=TIMEVALUE("2:30 PM")`, num(14.5 / 24)},57+ {"=F1+1", num(46292)},58+ {`="2026-09-27"-F1`, num(1)}, // date text coerces in arithmetic59+ })60+}61+62+func TestInferredFormats(t *testing.T) {63+ fixedNow(t)64+ s := New()65+ for a, in := range map[string]string{66+ "A1": "$1,200.00", "A2": "$300.00", "A3": "9/26/2026", "A4": "10%", "A5": "9/1/2026",67+ } {68+ s.Set(at(a), in)69+ }70+ tests := []struct{ in, want string }{71+ {"=A1+A2", "$1,500.00"},72+ {"=SUM(A1:A2)", "$1,500.00"},73+ {"=A1*2", "$2,400.00"},74+ {"=A3+7", "10/3/2026"},75+ {"=A3-A5", "25"},76+ {"=DATE(2026,1,31)", "1/31/2026"},77+ {"=EDATE(A3,1)", "10/26/2026"},78+ {"=TODAY()", "9/26/2026"},79+ {"=NOW()", "9/26/2026 14:30:00"},80+ {"=A1*A4", "$120.00"}, // the first formatted input wins81+ {"=YEAR(A3)", "2026"},82+ {"=MAX(A3,A5)", "9/26/2026"},83+ {"=IF(A1>0,A2,0)", "$300.00"},84+ }85+ for _, tt := range tests {86+ s.Set(at("C1"), tt.in)87+ if got, _ := Display(s.Value(at("C1")), s.DisplayFormat(at("C1")), 30); got != tt.want {88+ t.Errorf("%s shows %q, want %q", tt.in, got, tt.want)89+ }90+ }91+ // Changing an input's format updates formulas that follow it.92+ s.Set(at("C1"), "=A1+A2")93+ s.SetFormat(NewRect(at("A1"), at("A1")), Preset(FmtNumber))94+ if got, _ := Display(s.Value(at("C1")), s.DisplayFormat(at("C1")), 30); got != "1,500.00" {95+ t.Errorf("after reformat C1 shows %q", got)96+ }97+}98+99+func TestVolatileRecalc(t *testing.T) {100+ fixedNow(t)101+ s := New()102+ s.Set(at("A1"), "=TODAY()")103+ s.Set(at("A2"), "=A1+1")104+ if got := s.Value(at("A2")).Num; got != 46292 {105+ t.Fatalf("A2 = %v", got)106+ }107+ Now = func() time.Time { return time.Date(2026, 9, 27, 9, 0, 0, 0, time.Local) }108+ s.Set(at("Z1"), "unrelated") // any change recalculates volatile cells109+ if got := s.Value(at("A2")).Num; got != 46293 {110+ t.Errorf("A2 after a day = %v, want 46293", got)111+ }112+ s.Set(at("A1"), "5")113+ if _, ok := s.volatile[at("A1")]; ok {114+ t.Error("A1 still volatile after replacing the formula")115+ }116+}117+118+func TestVolatileFunctions(t *testing.T) {119+ for _, name := range []string{"TODAY", "NOW", "RAND", "RANDBETWEEN"} {120+ if f, _ := LookupFunc(name); !f.Volatile {121+ t.Errorf("%s not volatile", name)122+ }123+ }124+}