Skip to content
Merged
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ go 1.23.0
require (
github.com/anthropics/anthropic-sdk-go v1.22.0
github.com/mattn/go-sqlite3 v1.14.33
github.com/robfig/cron/v3 v3.0.1
github.com/slack-go/slack v0.17.3
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
Expand Down
5 changes: 5 additions & 0 deletions models.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ func CurrentWeekRangeAt(now time.Time) (time.Time, time.Time) {
return monday, nextMonday
}

// FridayOfWeek returns the Friday of the same week as the given Monday.
func FridayOfWeek(monday time.Time) time.Time {
return monday.AddDate(0, 0, 4)
}

func ReportWeekRange(cfg Config, now time.Time) (time.Time, time.Time) {
hour, min, err := parseClock(cfg.MondayCutoffTime)
if err != nil {
Expand Down
112 changes: 112 additions & 0 deletions models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,115 @@ func TestReportWeekRangeMondayCutoff(t *testing.T) {
t.Fatalf("expected current week for Monday afternoon, got %s -> %s", from.Format("20060102"), to.Format("20060102"))
}
}

func TestFridayOfWeek(t *testing.T) {
loc := time.FixedZone("UTC+0", 0)

tests := []struct {
name string
monday time.Time
expected string
}{
{
name: "basic monday to friday",
monday: time.Date(2026, 2, 9, 0, 0, 0, 0, loc),
expected: "20260213", // Feb 13, 2026 (Friday)
},
{
name: "monday with time component",
monday: time.Date(2026, 2, 9, 14, 30, 45, 0, loc),
expected: "20260213", // Feb 13, 2026 (Friday)
},
{
name: "year boundary - monday in december",
monday: time.Date(2025, 12, 29, 0, 0, 0, 0, loc),
expected: "20260102", // Jan 2, 2026 (Friday)
},
{
name: "year boundary - monday in january",
monday: time.Date(2026, 1, 5, 0, 0, 0, 0, loc),
expected: "20260109", // Jan 9, 2026 (Friday)
},
{
name: "month boundary",
monday: time.Date(2026, 2, 23, 0, 0, 0, 0, loc),
expected: "20260227", // Feb 27, 2026 (Friday)
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
friday := FridayOfWeek(tt.monday)
got := friday.Format("20060102")
if got != tt.expected {
t.Errorf("FridayOfWeek(%s) = %s, want %s",
tt.monday.Format("20060102 15:04:05"), got, tt.expected)
}

// Verify it's actually a Friday
if friday.Weekday() != time.Friday {
t.Errorf("FridayOfWeek(%s) returned %s (weekday: %s), expected Friday",
tt.monday.Format("20060102"), friday.Format("20060102"), friday.Weekday())
}

// Verify time components are preserved
if friday.Hour() != tt.monday.Hour() || friday.Minute() != tt.monday.Minute() || friday.Second() != tt.monday.Second() {
t.Errorf("FridayOfWeek(%s) time component not preserved: got %02d:%02d:%02d, want %02d:%02d:%02d",
tt.monday.Format("20060102 15:04:05"),
friday.Hour(), friday.Minute(), friday.Second(),
tt.monday.Hour(), tt.monday.Minute(), tt.monday.Second())
}

// Verify location is preserved
if friday.Location() != tt.monday.Location() {
t.Errorf("FridayOfWeek(%s) location not preserved: got %v, want %v",
tt.monday.Format("20060102"), friday.Location(), tt.monday.Location())
}
})
}
}

func TestFridayOfWeekWithDifferentTimezones(t *testing.T) {
utc := time.UTC
pst := time.FixedZone("PST", -8*3600)
jst := time.FixedZone("JST", 9*3600)

tests := []struct {
name string
monday time.Time
expected string
}{
{
name: "UTC timezone",
monday: time.Date(2026, 2, 9, 10, 0, 0, 0, utc),
expected: "20260213",
},
{
name: "PST timezone",
monday: time.Date(2026, 2, 9, 10, 0, 0, 0, pst),
expected: "20260213",
},
{
name: "JST timezone",
monday: time.Date(2026, 2, 9, 10, 0, 0, 0, jst),
expected: "20260213",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
friday := FridayOfWeek(tt.monday)
got := friday.Format("20060102")
if got != tt.expected {
t.Errorf("FridayOfWeek(%s) = %s, want %s",
tt.monday.Format("20060102 15:04:05 MST"), got, tt.expected)
}

// Verify location is preserved
if friday.Location() != tt.monday.Location() {
t.Errorf("FridayOfWeek(%s) location not preserved: got %v, want %v",
tt.monday.Format("20060102 15:04:05 MST"), friday.Location(), tt.monday.Location())
}
})
}
}
3 changes: 2 additions & 1 deletion report_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ func findLatestReportBefore(outputDir, teamName string, reportDate time.Time) (s
}

prefix := teamName + "_"
currentFile := fmt.Sprintf("%s_%s.md", teamName, reportDate.Format("20060102"))
friday := FridayOfWeek(reportDate)
currentFile := fmt.Sprintf("%s_%s.md", teamName, friday.Format("20060102"))
type candidate struct {
path string
date time.Time
Expand Down
78 changes: 74 additions & 4 deletions slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,75 @@ func handleGenerateReport(api *slack.Client, db *sql.DB, cfg Config, cmd slack.S
log.Printf("generate-report mode=%s", mode)

monday, nextMonday := ReportWeekRange(cfg, time.Now().In(cfg.Location))
friday := FridayOfWeek(monday)

// Boss mode shortcut: derive from existing team report if available.
if mode == "boss" {
teamReportFile := fmt.Sprintf("%s_%s.md", cfg.TeamName, friday.Format("20060102"))
teamReportPath := filepath.Join(cfg.ReportOutputDir, teamReportFile)
content, readErr := os.ReadFile(teamReportPath)
if readErr != nil {
if !os.IsNotExist(readErr) {
// Unexpected error (permission, I/O, etc.) - surface it and abort
log.Printf("Error reading team report file %s: %v", teamReportPath, readErr)
postEphemeral(api, cmd, fmt.Sprintf("Error reading team report file: %v", readErr))
return
}
// File doesn't exist - fall through to full pipeline below
log.Printf("generate-report boss: no existing team report found, running full pipeline")
} else if len(content) > 0 {
log.Printf("generate-report boss: deriving from existing team report %s", teamReportPath)
template := parseTemplate(string(content))
stripCurrentTeamTitleFromPrefix(template, cfg.TeamName)
bossReport := renderBossMarkdown(template)
filePath, err := WriteEmailDraftFile(bossReport, cfg.ReportOutputDir, friday, cfg.TeamName)
if err != nil {
log.Printf("Error writing boss report file: %v", err)
postEphemeral(api, cmd, fmt.Sprintf("Error writing report file: %v", err))
return
}
fileTitle := fmt.Sprintf("%s report email draft", cfg.TeamName)
log.Printf("generate-report boss-from-team file=%s length=%d", filePath, len(bossReport))

fi, err := os.Stat(filePath)
if err != nil || fi.Size() <= 0 {
log.Printf("Error with boss report file: %v", err)
postEphemeral(api, cmd, "Error: generated boss report file is empty.")
return
}

uploadChannel := cmd.ChannelID
if cfg.ReportPrivate {
ch, _, _, err := api.OpenConversation(&slack.OpenConversationParameters{Users: []string{cmd.UserID}})
if err != nil {
log.Printf("Error opening DM for private report: %v", err)
postEphemeral(api, cmd, "Error opening DM to send private report. Check bot permissions.")
return
}
uploadChannel = ch.ID
}

_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
File: filePath,
FileSize: int(fi.Size()),
Filename: filepath.Base(filePath),
Channel: uploadChannel,
Title: fileTitle,
InitialComment: fmt.Sprintf("Generated boss report (week reference date: %s, derived from team report, tokens used: 0)", friday.Format("2006-01-02")),
})
if err != nil {
log.Printf("Error uploading report file: %v", err)
postEphemeral(api, cmd, "Error uploading report file to channel. Check bot permissions.")
return
}

msg := fmt.Sprintf("Boss report derived from existing team report (no LLM tokens used)\nSaved to: %s", filePath)
postEphemeral(api, cmd, msg)
log.Printf("generate-report done mode=boss derived-from-team")
return
}
}

items, err := GetItemsByDateRange(db, monday, nextMonday)
if err != nil {
postEphemeral(api, cmd, fmt.Sprintf("Error loading items: %v", err))
Expand Down Expand Up @@ -424,12 +493,12 @@ func handleGenerateReport(api *slack.Client, db *sql.DB, cfg Config, cmd slack.S
var fileTitle string
if mode == "boss" {
bossReport := renderBossMarkdown(merged)
filePath, err = WriteEmailDraftFile(bossReport, cfg.ReportOutputDir, monday, cfg.TeamName)
filePath, err = WriteEmailDraftFile(bossReport, cfg.ReportOutputDir, friday, cfg.TeamName)
fileTitle = fmt.Sprintf("%s report email draft", cfg.TeamName)
log.Printf("generate-report boss-report-length=%d file=%s", len(bossReport), filePath)
} else {
teamReport := renderTeamMarkdown(merged)
filePath, err = WriteReportFile(teamReport, cfg.ReportOutputDir, monday, cfg.TeamName)
filePath, err = WriteReportFile(teamReport, cfg.ReportOutputDir, friday, cfg.TeamName)
fileTitle = fmt.Sprintf("%s team report", cfg.TeamName)
log.Printf("generate-report team-report-length=%d file=%s", len(teamReport), filePath)
}
Expand Down Expand Up @@ -472,7 +541,7 @@ func handleGenerateReport(api *slack.Client, db *sql.DB, cfg Config, cmd slack.S
Filename: filepath.Base(filePath),
Channel: uploadChannel,
Title: fileTitle,
InitialComment: fmt.Sprintf("Generated report for week starting %s (mode: %s, tokens used: %s)", monday.Format("2006-01-02"), mode, tokenUsedText),
InitialComment: fmt.Sprintf("Generated report for reporting week containing %s (mode: %s, tokens used: %s)", friday.Format("2006-01-02"), mode, tokenUsedText),
})
if err != nil {
log.Printf("Error uploading report file: %v", err)
Expand Down Expand Up @@ -1557,7 +1626,8 @@ func prReportedAt(pr GitHubPR, loc *time.Location) time.Time {
// --- Correction helpers ---

func loadSectionOptionsForModal(cfg Config) []sectionOption {
template, _, err := loadTemplateForGeneration(cfg.ReportOutputDir, cfg.TeamName, time.Now().In(cfg.Location))
monday, _ := ReportWeekRange(cfg, time.Now().In(cfg.Location))
template, _, err := loadTemplateForGeneration(cfg.ReportOutputDir, cfg.TeamName, monday)
if err != nil {
log.Printf("edit modal load template error (non-fatal): %v", err)
return nil
Expand Down