refactor: replace most custom date util functions by lancet ones

refactor: add precision mode to missing intervals function
This commit is contained in:
Ferdinand Mütsch 2022-03-25 12:48:56 +01:00
parent 8a731a252a
commit 5aae18e241
12 changed files with 608 additions and 723 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
package v1
import (
"github.com/duke-git/lancet/v2/datetime"
"net/http"
"time"
@ -65,7 +66,7 @@ func (h *HeartbeatHandler) Get(w http.ResponseWriter, r *http.Request) {
}
timezone := user.TZ()
rangeFrom, rangeTo := utils.StartOfDay(date.In(timezone)), utils.EndOfDay(date.In(timezone))
rangeFrom, rangeTo := datetime.BeginOfDay(date.In(timezone)), datetime.EndOfDay(date.In(timezone))
heartbeats, err := h.heartbeatSrvc.GetAllWithin(rangeFrom, rangeTo, user)
if err != nil {

View File

@ -2,6 +2,7 @@ package v1
import (
"errors"
"github.com/duke-git/lancet/v2/datetime"
"net/http"
"strings"
"time"
@ -120,7 +121,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
// i.e. for wakatime, an interval 2021-04-29 - 2021-04-29 is actually 2021-04-29 - 2021-04-30,
// while for wakapi it would be empty
// see https://github.com/muety/wakapi/issues/192
end = utils.EndOfDay(end).Add(-1 * time.Second)
end = datetime.EndOfDay(end)
overallParams := &models.SummaryParams{
From: start,

View File

@ -2,6 +2,7 @@ package routes
import (
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"github.com/muety/wakapi/views"
"html/template"
"net/http"
@ -28,7 +29,7 @@ func DefaultTemplateFuncs() template.FuncMap {
"simpledate": utils.FormatDate,
"simpledatetime": utils.FormatDateTime,
"duration": utils.FmtWakatimeDuration,
"floordate": utils.FloorDate,
"floordate": datetime.BeginOfDay,
"ceildate": utils.CeilDate,
"title": strings.Title,
"join": strings.Join,

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"net/http"
"time"
@ -13,7 +14,6 @@ import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/utils"
"go.uber.org/atomic"
"golang.org/x/sync/semaphore"
)
@ -295,8 +295,8 @@ func mapHeartbeat(
func generateDays(from, to time.Time) []time.Time {
days := make([]time.Time, 0)
from = utils.StartOfDay(from)
to = utils.StartOfDay(to.AddDate(0, 0, 1))
from = datetime.BeginOfDay(from)
to = datetime.BeginOfDay(to.AddDate(0, 0, 1))
for d := from; d.Before(to); d = d.AddDate(0, 0, 1) {
days = append(days, d)

View File

@ -3,6 +3,7 @@ package services
import (
"errors"
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
@ -113,7 +114,7 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User, filte
}
// Generate missing slots (especially before and after existing summaries) from durations (formerly raw heartbeats)
missingIntervals := srv.getMissingIntervals(from, to, summaries)
missingIntervals := srv.getMissingIntervals(from, to, summaries, false)
for _, interval := range missingIntervals {
if s, err := srv.Summarize(interval.Start, interval.End, user, filters); err == nil {
summaries = append(summaries, s)
@ -368,7 +369,7 @@ func (srv *SummaryService) mergeSummaryItems(existing []*models.SummaryItem, new
return itemList
}
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary) []*models.Interval {
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary, precise bool) []*models.Interval {
if len(summaries) == 0 {
return []*models.Interval{{from, to}}
}
@ -377,37 +378,43 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
// Pre
if from.Before(summaries[0].FromTime.T()) {
intervals = append(intervals, &models.Interval{from, summaries[0].FromTime.T()})
intervals = append(intervals, &models.Interval{Start: from, End: summaries[0].FromTime.T()})
}
// Between
for i := 0; i < len(summaries)-1; i++ {
t1, t2 := summaries[i].ToTime.T(), summaries[i+1].FromTime.T()
if t1.Equal(t2) {
if t1.Equal(t2) || t1.Equal(to) || t1.After(to) {
continue
}
td1 := t1
td2 := t2
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
// we assume that, if summary for any time range within a day is present, no further heartbeats exist on that day before 'from' and after 'to' time of that summary
// this requires that a summary exists for every single day in a year and none is skipped, which shouldn't ever happen
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
// non-precise mode is mainly for speed when fetching summaries over large intervals and trades speed for summary accuracy / comprehensiveness
if !precise {
td1 = datetime.BeginOfDay(t1).AddDate(0, 0, 1)
td2 = datetime.BeginOfDay(t2)
// we always want to jump to beginning of next day
// however, if left summary ends already at midnight, we would instead jump to beginning of second-next day -> go back again
if td1.Sub(t1) == 24*time.Hour {
td1 = td1.Add(-1 * time.Hour)
// we always want to jump to beginning of next day
// however, if left summary ends already at midnight, we would instead jump to beginning of second-next day -> go back again
if td1.Sub(t1) == 24*time.Hour {
td1 = td1.Add(-1 * time.Hour)
}
}
// one or more day missing in between?
if td1.Before(td2) {
intervals = append(intervals, &models.Interval{summaries[i].ToTime.T(), summaries[i+1].FromTime.T()})
intervals = append(intervals, &models.Interval{Start: summaries[i].ToTime.T(), End: summaries[i+1].FromTime.T()})
}
}
// Post
if to.After(summaries[len(summaries)-1].ToTime.T()) {
intervals = append(intervals, &models.Interval{summaries[len(summaries)-1].ToTime.T(), to})
intervals = append(intervals, &models.Interval{Start: summaries[len(summaries)-1].ToTime.T(), End: to})
}
return intervals

View File

@ -485,6 +485,45 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Filters() {
assert.Contains(suite.T(), effectiveFilters.Label, TestProjectLabel3)
}
func (suite *SummaryServiceTestSuite) TestSummaryService_getMissingIntervals() {
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
from1, _ := time.Parse(time.RFC822, "25 Mar 22 11:00 UTC")
to1, _ := time.Parse(time.RFC822, "25 Mar 22 13:00 UTC")
from2, _ := time.Parse(time.RFC822, "25 Mar 22 15:00 UTC")
to2, _ := time.Parse(time.RFC822, "26 Mar 22 00:00 UTC")
summaries := []*models.Summary{
{FromTime: models.CustomTime(from1), ToTime: models.CustomTime(to1)},
{FromTime: models.CustomTime(from2), ToTime: models.CustomTime(to2)},
}
r1 := sut.getMissingIntervals(from1, to1, summaries, true)
assert.Empty(suite.T(), r1)
r2 := sut.getMissingIntervals(from1, from1, summaries, true)
assert.Empty(suite.T(), r2)
// non-precise mode will not return intra-day intervals
// we might want to change this ...
r3 := sut.getMissingIntervals(from1, to2, summaries, false)
assert.Len(suite.T(), r3, 0)
r4 := sut.getMissingIntervals(from1, to2, summaries, true)
assert.Len(suite.T(), r4, 1)
assert.Equal(suite.T(), to1, r4[0].Start)
assert.Equal(suite.T(), from2, r4[0].End)
r5 := sut.getMissingIntervals(from1.Add(-time.Hour), to2.Add(time.Hour), summaries, true)
assert.Len(suite.T(), r5, 3)
assert.Equal(suite.T(), from1.Add(-time.Hour), r5[0].Start)
assert.Equal(suite.T(), from1, r5[0].End)
assert.Equal(suite.T(), to1, r5[1].Start)
assert.Equal(suite.T(), from2, r5[1].End)
assert.Equal(suite.T(), to2, r5[2].Start)
assert.Equal(suite.T(), to2.Add(time.Hour), r5[2].End)
}
func filterDurations(from, to time.Time, durations models.Durations) models.Durations {
filtered := make([]*models.Duration, 0, len(durations))
for _, d := range durations {

View File

@ -2,6 +2,7 @@ package services
import (
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
@ -100,9 +101,9 @@ func (srv *UserService) GetAllByReports(reportsEnabled bool) ([]*models.User, er
}
func (srv *UserService) GetActive(exact bool) ([]*models.User, error) {
minDate := time.Now().Add(-24 * time.Hour * time.Duration(srv.config.App.InactiveDays))
minDate := time.Now().AddDate(0, 0, -1*srv.config.App.InactiveDays)
if !exact {
minDate = utils.FloorDateHour(minDate)
minDate = datetime.BeginOfHour(minDate)
}
cacheKey := fmt.Sprintf("%s--active", minDate.String())

View File

@ -1033,7 +1033,7 @@
" pm.expect(jsonData.timezone).to.eql(pm.collectionVariables.get('TZ'));",
" var date = new Date(\"2022-01-01T00:00:00+0100\")",
" pm.expect(new Date(jsonData.start)).to.eql(date);",
" pm.expect(new Date(jsonData.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24));",
" pm.expect(new Date(jsonData.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24 - 1000));",
" pm.expect(jsonData.data.length).to.eql(2);",
"});"
],

View File

@ -2,99 +2,41 @@ package utils
import (
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"time"
)
// TODO: replace these functions by github.com/duke-git/lancet/v2/datetime
// needs additional thoughts, though, as for "EndOfX" functions, we currently return the discrete next day,
// while the above lib returns the very last nanosecond of the current day, i.e.
// 2022-02-15 23:59:59.999 +0800 CST vs. 2022-02-16 00:00:00.000 +0800 CST
// -> need to revisit comparison logic, etc.
func StartOfDay(date time.Time) time.Time {
return FloorDate(date)
func BeginOfToday(tz *time.Location) time.Time {
return datetime.BeginOfDay(time.Now().In(tz))
}
func StartOfToday(tz *time.Location) time.Time {
return StartOfDay(FloorDate(time.Now().In(tz)))
func BeginOfThisWeek(tz *time.Location) time.Time {
return datetime.BeginOfWeek(time.Now().In(tz))
}
func EndOfDay(date time.Time) time.Time {
floored := FloorDate(date)
if floored == date {
date = date.Add(1 * time.Second)
}
return CeilDate(date)
func BeginOfThisMonth(tz *time.Location) time.Time {
return datetime.BeginOfMonth(time.Now().In(tz))
}
func EndOfToday(tz *time.Location) time.Time {
return EndOfDay(time.Now().In(tz))
}
func StartOfThisWeek(tz *time.Location) time.Time {
return StartOfWeek(time.Now().In(tz))
}
func StartOfWeek(date time.Time) time.Time {
year, week := date.ISOWeek()
return firstDayOfISOWeek(year, week, date.Location())
}
func StartOfThisMonth(tz *time.Location) time.Time {
return StartOfMonth(time.Now().In(tz))
}
func StartOfMonth(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
}
func StartOfThisYear(tz *time.Location) time.Time {
return StartOfYear(time.Now().In(tz))
}
func StartOfYear(date time.Time) time.Time {
return time.Date(date.Year(), time.January, 1, 0, 0, 0, 0, date.Location())
}
// FloorDate rounds date down to the start of the day and keeps the time zone
func FloorDate(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
}
// FloorDateHour rounds date down to the start of the current hour and keeps the time zone
func FloorDateHour(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), date.Hour(), 0, 0, 0, date.Location())
func BeginOfThisYear(tz *time.Location) time.Time {
return datetime.BeginOfYear(time.Now().In(tz))
}
// CeilDate rounds date up to the start of next day if date is not already a start (00:00:00)
func CeilDate(date time.Time) time.Time {
floored := FloorDate(date)
floored := datetime.BeginOfDay(date)
if floored == date {
return floored
}
return floored.AddDate(0, 0, 1)
}
// SetLocation resets the time zone information of a date without converting it, i.e. 19:00 UTC will result in 19:00 CET, for instance
func SetLocation(date time.Time, tz *time.Location) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, tz)
}
// WithOffset adds the time zone difference between Local and tz to a date, i.e. 19:00 UTC will result in 21:00 CET (or 22:00 CEST), for instance
func WithOffset(date time.Time, tz *time.Location) time.Time {
now := time.Now()
_, localOffset := now.Zone()
_, targetOffset := now.In(tz).Zone()
dateTz := date.Add(time.Duration((targetOffset - localOffset) * int(time.Second)))
return time.Date(dateTz.Year(), dateTz.Month(), dateTz.Day(), dateTz.Hour(), dateTz.Minute(), dateTz.Second(), dateTz.Nanosecond(), dateTz.Location()).In(tz)
}
// SplitRangeByDays creates a slice of intervals between from and to, each of which is at max of 24 hours length and has its split at midnight
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
intervals := make([][]time.Time, 0)
for t1 := from; t1.Before(to); {
t2 := StartOfDay(t1).AddDate(0, 0, 1)
t2 := datetime.BeginOfDay(t1).AddDate(0, 0, 1)
if t2.After(to) {
t2 = to
}
@ -118,22 +60,3 @@ func LocalTZOffset() time.Duration {
_, offset := time.Now().Zone()
return time.Duration(offset * int(time.Second))
}
// https://stackoverflow.com/a/18632496
func firstDayOfISOWeek(year int, week int, timezone *time.Location) time.Time {
date := time.Date(year, 0, 0, 0, 0, 0, 0, timezone)
isoYear, isoWeek := date.ISOWeek()
for date.Weekday() != time.Monday { // iterate back to Monday
date = date.AddDate(0, 0, -1)
isoYear, isoWeek = date.ISOWeek()
}
for isoYear < year { // iterate forward to the first day of the first week
date = date.AddDate(0, 0, 1)
isoYear, isoWeek = date.ISOWeek()
}
for isoWeek < week { // iterate forward to the first day of the given week
date = date.AddDate(0, 0, 1)
isoYear, isoWeek = date.ISOWeek()
}
return date
}

View File

@ -1,6 +1,7 @@
package utils
import (
"github.com/duke-git/lancet/v2/datetime"
"github.com/muety/wakapi/config"
"github.com/stretchr/testify/assert"
"testing"
@ -21,100 +22,11 @@ func init() {
tzPst, _ = time.LoadLocation("America/Los_Angeles")
}
func TestDate_Ceil(t *testing.T) {
tests := []struct {
in string
out string
}{
{
"02 Jan 06 15:04 MST",
"03 Jan 06 00:00 MST",
},
{
"03 Jan 06 00:00 MST",
"03 Jan 06 00:00 MST",
},
}
for _, test := range tests {
inDate, _ := time.Parse(time.RFC822, test.in)
outDate, _ := time.Parse(time.RFC822, test.out)
out := CeilDate(inDate)
assert.Equal(t, outDate, out)
}
}
func TestDate_StartOfDay(t *testing.T) {
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzLocal)
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzUtc)
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzPst)
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzCet)
assert.Equal(t, t1, StartOfDay(d1))
assert.Equal(t, t2, StartOfDay(d2))
assert.Equal(t, t3, StartOfDay(d3))
assert.Equal(t, t4, StartOfDay(d4))
assert.Equal(t, tzLocal, StartOfDay(d1).Location())
assert.Equal(t, tzUtc, StartOfDay(d2).Location())
assert.Equal(t, tzPst, StartOfDay(d3).Location())
assert.Equal(t, tzCet, StartOfDay(d4).Location())
}
func TestDate_EndOfDay(t *testing.T) {
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzLocal)
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzUtc)
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzPst)
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzCet)
assert.Equal(t, t1, EndOfDay(d1))
assert.Equal(t, t2, EndOfDay(d2))
assert.Equal(t, t3, EndOfDay(d3))
assert.Equal(t, t4, EndOfDay(d4))
assert.Equal(t, tzLocal, EndOfDay(d1).Location())
assert.Equal(t, tzUtc, EndOfDay(d2).Location())
assert.Equal(t, tzPst, EndOfDay(d3).Location())
assert.Equal(t, tzCet, EndOfDay(d4).Location())
}
func TestDate_StartOfWeek(t *testing.T) {
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzLocal)
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzUtc)
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzPst)
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzCet)
assert.Equal(t, t1, StartOfWeek(d1))
assert.Equal(t, t2, StartOfWeek(d2))
assert.Equal(t, t3, StartOfWeek(d3))
assert.Equal(t, t4, StartOfWeek(d4))
assert.Equal(t, tzLocal, StartOfWeek(d1).Location())
assert.Equal(t, tzUtc, StartOfWeek(d2).Location())
assert.Equal(t, tzPst, StartOfWeek(d3).Location())
assert.Equal(t, tzCet, StartOfWeek(d4).Location())
}
func TestDate_SplitRangeByDays(t *testing.T) {
df1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-25 20:25:00")
dt1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-28 06:45:00")
df2 := df1
dt2 := CeilDate(df1)
dt2 := datetime.EndOfDay(df1)
df3 := df1
dt3 := df1.Add(10 * time.Second)
df4 := df1

View File

@ -35,27 +35,27 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
switch interval {
case models.IntervalToday:
from = StartOfToday(tz)
from = BeginOfToday(tz)
case models.IntervalYesterday:
from = StartOfToday(tz).Add(-24 * time.Hour)
to = StartOfToday(tz)
from = BeginOfToday(tz).Add(-24 * time.Hour)
to = BeginOfToday(tz)
case models.IntervalThisWeek:
from = StartOfThisWeek(tz)
from = BeginOfThisWeek(tz)
case models.IntervalLastWeek:
from = StartOfThisWeek(tz).AddDate(0, 0, -7)
to = StartOfThisWeek(tz)
from = BeginOfThisWeek(tz).AddDate(0, 0, -7)
to = BeginOfThisWeek(tz)
case models.IntervalThisMonth:
from = StartOfThisMonth(tz)
from = BeginOfThisMonth(tz)
case models.IntervalLastMonth:
from = StartOfThisMonth(tz).AddDate(0, -1, 0)
to = StartOfThisMonth(tz)
from = BeginOfThisMonth(tz).AddDate(0, -1, 0)
to = BeginOfThisMonth(tz)
case models.IntervalThisYear:
from = StartOfThisYear(tz)
from = BeginOfThisYear(tz)
case models.IntervalPast7Days:
from = now.AddDate(0, 0, -7)
case models.IntervalPast7DaysYesterday:
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
to = StartOfToday(tz).AddDate(0, 0, -1)
from = BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
to = BeginOfToday(tz).AddDate(0, 0, -1)
case models.IntervalPast14Days:
from = now.AddDate(0, 0, -14)
case models.IntervalPast30Days: