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>
| A | internal/ | 290 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 124 | +++++++++++++ |
@@ -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+}@@ -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+}