Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ Time spent per day:

Note that it accounts correctly for overlapping events.

### Weekly CSV summary

After the daily breakdown, the program prints a CSV table to stdout with one row per ISO week:

```
week_start,hours_per_week,3week_moving_avg,6week_moving_avg,12week_moving_avg
2026-05-04,18.00,18.00,13.17,10.83
2026-05-11,15.50,16.75,14.08,11.38
2026-05-18,18.50,17.33,14.50,12.17
2026-05-25,29.50,21.17,17.42,14.00
2026-06-01,4.50,17.50,17.25,13.96
```

- **`week_start`** — Monday of the ISO week
- **`hours_per_week`** — total hours logged that week
- **`3week_moving_avg`** — rolling average over up to 3 weeks
- **`6week_moving_avg`** — rolling average over up to 6 weeks
- **`12week_moving_avg`** — rolling average over up to 12 weeks

This makes it easy to pipe the output into a spreadsheet or plotting tool for trend analysis.

### Time spent per category

If you provide a configuration file which explains how to group events into
Expand Down
9 changes: 8 additions & 1 deletion internal/core/span.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func newSpan(categories []*Category) *span {
return &span{categories: categories, events: make(map[*calendar.Event]CategoryName)}
}

func (s *span) checkpoint(dayTotals map[civil.Date]time.Duration, categoryTotals map[CategoryName]time.Duration, end time.Time) {
func (s *span) checkpoint(dayTotals map[civil.Date]time.Duration, categoryTotals map[CategoryName]time.Duration, dayCategoryTotals map[civil.Date]map[CategoryName]time.Duration, end time.Time) {
timeSpent := end.Sub(s.start)
eventCount := len(s.events)
var timePerEvent int64
Expand All @@ -27,6 +27,13 @@ func (s *span) checkpoint(dayTotals map[civil.Date]time.Duration, categoryTotals
}
for _, categoryName := range s.events {
categoryTotals[categoryName] += time.Duration(timePerEvent)
if eventCount > 0 {
day := civil.DateOf(s.start)
if dayCategoryTotals[day] == nil {
dayCategoryTotals[day] = make(map[CategoryName]time.Duration)
}
dayCategoryTotals[day][categoryName] += time.Duration(timePerEvent)
}
}
s.start = end
}
Expand Down
15 changes: 9 additions & 6 deletions internal/core/totals.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type thing struct {
newDay *civil.Date
}

func ComputeTotals(events []*calendar.Event, categories []*Category, location *time.Location) (map[civil.Date]time.Duration, map[CategoryName]time.Duration, []*calendar.Event) {
func ComputeTotals(events []*calendar.Event, categories []*Category, location *time.Location) (map[civil.Date]time.Duration, map[CategoryName]time.Duration, map[civil.Date]map[CategoryName]time.Duration, []*calendar.Event) {
moments := computeTimeline(events, location)
return categorizeTime(moments, categories)
}
Expand Down Expand Up @@ -124,17 +124,20 @@ func stretchSpeedyMeetings(evStart, evEnd time.Time) (time.Time, time.Time) {
}
}

// categorizeTime returns three values. A map from civil date to time spent on it,
// a map from category name to time spent on it, and a slice of unrecognized calendar events.
func categorizeTime(t *timeline, categories []*Category) (map[civil.Date]time.Duration, map[CategoryName]time.Duration, []*calendar.Event) {
// categorizeTime returns a map from civil date to time spent on it,
// a map from category name to time spent on it,
// a map from civil date to per-category time spent on it,
// and a slice of unrecognized calendar events.
func categorizeTime(t *timeline, categories []*Category) (map[civil.Date]time.Duration, map[CategoryName]time.Duration, map[civil.Date]map[CategoryName]time.Duration, []*calendar.Event) {
momentTimes := t.sortedMoments()
dayTotals := make(map[civil.Date]time.Duration)
categoryTotals := make(map[CategoryName]time.Duration)
dayCategoryTotals := make(map[civil.Date]map[CategoryName]time.Duration)
unrecognized := []*calendar.Event{}
currentTasks := newSpan(categories)

for _, momentTime := range momentTimes {
currentTasks.checkpoint(dayTotals, categoryTotals, momentTime)
currentTasks.checkpoint(dayTotals, categoryTotals, dayCategoryTotals, momentTime)
for _, thing := range t.thingsAt(momentTime) {
switch thing.what {
case midnight:
Expand All @@ -148,5 +151,5 @@ func categorizeTime(t *timeline, categories []*Category) (map[civil.Date]time.Du
}
}
}
return dayTotals, categoryTotals, unrecognized
return dayTotals, categoryTotals, dayCategoryTotals, unrecognized
}
2 changes: 1 addition & 1 deletion internal/core/totals_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func TestComputeTotals(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotTotals, gotCategories, gotUnrecognized := ComputeTotals(tt.args.events, tt.args.categories, time.UTC)
gotTotals, gotCategories, _, gotUnrecognized := ComputeTotals(tt.args.events, tt.args.categories, time.UTC)
assert.Equal(t, tt.wantTotals, gotTotals)
if tt.wantCategories != nil {
assert.Equal(t, tt.wantCategories, gotCategories)
Expand Down
92 changes: 89 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import (
"fmt"
"log"
"os"
"sort"
"time"

"cloud.google.com/go/civil"
"github.com/porridge/calendar-stats/internal/config"
"github.com/porridge/calendar-stats/internal/core"
"github.com/porridge/calendar-stats/internal/flags"
Expand Down Expand Up @@ -86,7 +88,7 @@ func main() {
log.Fatalf("Could not read config file %q: %s", *configFile, err)
}

unrecognized := analyzeAndPrint(events, categories, *decimalOutput)
unrecognized := analyzeAndPrint(events, categories, *decimalOutput, true)

if *correctionsFileName != "" {
err = io.SaveUnrecognized(*correctionsFileName, unrecognized)
Expand All @@ -96,8 +98,8 @@ func main() {
}
}

func analyzeAndPrint(events []*calendar.Event, categories []*core.Category, decimalOutput bool) []*calendar.Event {
dayTotals, categoryTotals, unrecognized := core.ComputeTotals(events, categories, time.Local)
func analyzeAndPrint(events []*calendar.Event, categories []*core.Category, decimalOutput bool, weeklyCSV bool) []*calendar.Event {
dayTotals, categoryTotals, dayCategoryTotals, unrecognized := core.ComputeTotals(events, categories, time.Local)
days := ordererd.KeysOfMap(dayTotals, ordererd.CivilDates)
var total time.Duration
if len(days) > 0 {
Expand All @@ -108,6 +110,9 @@ func analyzeAndPrint(events []*calendar.Event, categories []*core.Category, deci
value := formatDayTotal(decimalOutput, dayTotals[day])
fmt.Printf("%v: %s\n", day, value)
}
if weeklyCSV && len(days) > 0 {
printWeeklyCSV(days, dayTotals, categories, dayCategoryTotals)
}
if len(categories) == 0 {
return unrecognized
}
Expand Down Expand Up @@ -181,3 +186,84 @@ func formatDayTotal(decimalOutput bool, d time.Duration) string {
return d.String()
}
}

func printWeeklyCSV(days []civil.Date, dayTotals map[civil.Date]time.Duration, categories []*core.Category, dayCategoryTotals map[civil.Date]map[core.CategoryName]time.Duration) {
type weekKey struct {
year, week int
}
weekTotals := make(map[weekKey]time.Duration)
weekCategoryTotals := make(map[weekKey]map[core.CategoryName]time.Duration)
for _, day := range days {
t := day.In(time.Local)
year, week := t.ISOWeek()
k := weekKey{year, week}
weekTotals[k] += dayTotals[day]
if weekCategoryTotals[k] == nil {
weekCategoryTotals[k] = make(map[core.CategoryName]time.Duration)
}
for catName, dur := range dayCategoryTotals[day] {
weekCategoryTotals[k][catName] += dur
}
}

weeks := make([]weekKey, 0, len(weekTotals))
for k := range weekTotals {
weeks = append(weeks, k)
}
sort.Slice(weeks, func(i, j int) bool {
if weeks[i].year != weeks[j].year {
return weeks[i].year < weeks[j].year
}
return weeks[i].week < weeks[j].week
})

movingAvg := func(i, window int) float64 {
start := i - window + 1
if start < 0 {
start = 0
}
var sum float64
for j := start; j <= i; j++ {
sum += float64(weekTotals[weeks[j]]) / float64(time.Hour)
}
return sum / float64(i-start+1)
}

catMovingAvg := func(i, window int, catName core.CategoryName) float64 {
start := i - window + 1
if start < 0 {
start = 0
}
var sum float64
for j := start; j <= i; j++ {
sum += float64(weekCategoryTotals[weeks[j]][catName]) / float64(time.Hour)
}
return sum / float64(i-start+1)
}

header := "week_start,hours_per_week,3week_moving_avg,6week_moving_avg,12week_moving_avg"
for _, cat := range categories {
name := string(cat.Name)
if name == "" {
name = "uncategorized"
}
header += "," + name + "_12week_avg"
}
fmt.Println("\n" + header)

for i, wk := range weeks {
weekStart := isoweek.StartTime(wk.year, wk.week, time.Local)
hours := float64(weekTotals[wk]) / float64(time.Hour)
fmt.Printf("%s,%.2f,%.2f,%.2f,%.2f",
weekStart.Format("2006-01-02"),
hours,
movingAvg(i, 3),
movingAvg(i, 6),
movingAvg(i, 12),
)
for _, cat := range categories {
fmt.Printf(",%.2f", catMovingAvg(i, 12, cat.Name))
}
fmt.Println()
}
}