Skip to content
Merged
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
32 changes: 31 additions & 1 deletion src/campaign_actions/overview/overview.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,37 @@ type Overview struct {
Phases []PhaseInfo `json:"phases"` // ordered by sequence
TotalPosts int `json:"totalPosts"`
Distribution Distribution `json:"distribution"`
GeneratedAt time.Time `json:"generatedAt"`
// Goal is the CON-182 post-rate goal progress, or null when the campaign has
// no goal configured (no positive estimated_post_count).
Goal *GoalProgress `json:"goal"`
GeneratedAt time.Time `json:"generatedAt"`
}

// GoalProgress recaps a campaign's post-rate goal (CON-182): the per-period
// target, how many committed posts land in each period, and whether the goal is
// met per period and overall. "Committed" posts are those scheduled or
// published, bucketed by their scheduled_at.
type GoalProgress struct {
Cadence string `json:"cadence"` // "week" | "month"
PostsPerPeriod int `json:"postsPerPeriod"` // = estimated_post_count
Periods int `json:"periods"`
TotalTarget int `json:"totalTarget"` // postsPerPeriod × periods
TotalAchieved int `json:"totalAchieved"`
Reached bool `json:"reached"`
Percent int `json:"percent"` // 0..100, capped
Streak int `json:"streak"` // trailing consecutive reached periods
Buckets []GoalBucket `json:"buckets"`
}

// GoalBucket is one goal period. Start is inclusive, End exclusive.
type GoalBucket struct {
Index int `json:"index"` // 1-based
Label string `json:"label"` // "Week 1" / "Aug 2026"
Start time.Time `json:"start"`
End time.Time `json:"end"`
Target int `json:"target"`
Achieved int `json:"achieved"`
Reached bool `json:"reached"`
}

// Brief recaps the campaign's brief fields.
Expand Down
96 changes: 96 additions & 0 deletions src/campaign_actions/overview/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import (
"database/sql"
"errors"
"fmt"
"math"
"sort"
"time"

"github.com/ogen-app/ogen/src/campaigngoal"
"github.com/ogen-app/ogen/src/models"
"github.com/ogen-app/ogen/src/repository"
"github.com/ogen-app/ogen/src/settings"
)

// statusOrder is the fixed display order for the byStatus breakdown, so the
Expand Down Expand Up @@ -136,6 +139,99 @@ func buildOverview(campaign *models.Campaign, posts []models.Post, platformNames
ByPlatform: platformBuckets(platformCount, platformNames),
ByContentType: slugBuckets(typeCount),
},
Goal: buildGoalProgress(campaign, posts),
}
}

// buildGoalProgress computes the CON-182 goal recap: per-period buckets of
// committed posts (scheduled/published, keyed by scheduled_at) against the
// per-period target, plus overall reached/percent and a trailing streak.
// Returns nil when the campaign has no positive per-period target.
func buildGoalProgress(campaign *models.Campaign, posts []models.Post) *GoalProgress {
if campaign.EstimatedPostCount == nil || *campaign.EstimatedPostCount <= 0 {
return nil
}
perPeriod := *campaign.EstimatedPostCount
cadence := campaign.GoalCadence

loc, err := settings.ResolveTimezone(campaign.Timezone)
if err != nil || loc == nil {
loc = time.UTC
}

windows := campaigngoal.Windows(cadence, campaign.StartDate, campaign.EndDate, loc)
gp := &GoalProgress{
Cadence: cadence,
PostsPerPeriod: perPeriod,
Periods: campaigngoal.Periods(cadence, campaign.StartDate, campaign.EndDate, loc),
TotalTarget: campaigngoal.EffectiveCount(campaign.EstimatedPostCount, cadence, campaign.StartDate, campaign.EndDate, loc),
}

total := 0
if len(windows) == 0 {
// No datable window (missing/invalid dates): report the committed total
// with no per-period breakdown. Same "committed and dated" rule as the
// windowed branch below, so TotalAchieved counts consistently.
for _, p := range posts {
if isCommitted(p.Status) && p.ScheduledAt != nil {
total++
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
achieved := make([]int, len(windows))
for _, p := range posts {
if !isCommitted(p.Status) || p.ScheduledAt == nil {
continue
}
at := p.ScheduledAt.In(loc)
for i := range windows {
if !at.Before(windows[i].Start) && at.Before(windows[i].End) {
achieved[i]++
total++
break
}
}
}

buckets := make([]GoalBucket, len(windows))
for i, w := range windows {
buckets[i] = GoalBucket{
Index: i + 1,
Label: w.Label,
Start: w.Start,
End: w.End,
Target: perPeriod,
Achieved: achieved[i],
Reached: achieved[i] >= perPeriod,
}
}
// Streak = consecutive reached periods counting back from the last.
for i := len(buckets) - 1; i >= 0 && buckets[i].Reached; i-- {
gp.Streak++
}
gp.Buckets = buckets
}

gp.TotalAchieved = total
if gp.TotalTarget > 0 {
gp.Reached = total >= gp.TotalTarget
if pct := int(math.Round(100 * float64(total) / float64(gp.TotalTarget))); pct > 100 {
gp.Percent = 100
} else {
gp.Percent = pct
}
}
return gp
}

// isCommitted reports whether a post counts toward goal progress: it is
// scheduled (either publish mode) or already published.
func isCommitted(s models.PostStatus) bool {
switch s {
case models.PostStatusScheduled, models.PostStatusScheduledForManualPublish, models.PostStatusPublished:
return true
default:
return false
}
}

Expand Down
101 changes: 101 additions & 0 deletions src/campaign_actions/overview/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ package overview

import (
"testing"
"time"

"github.com/ogen-app/ogen/src/models"
)

func ptr(s string) *string { return &s }

func tptr(t time.Time) *time.Time { return &t }

func dateUTC(y int, m time.Month, day int) time.Time {
return time.Date(y, m, day, 9, 0, 0, 0, time.UTC)
}

func sampleCampaign() *models.Campaign {
return &models.Campaign{
ID: "camp-1",
Expand Down Expand Up @@ -195,3 +202,97 @@ func assertReconciles(t *testing.T, ov *Overview) {
t.Fatalf("phase counts + unassigned = %d, want %d", phaseSum, ov.TotalPosts)
}
}

// goalCampaign builds a minimal campaign carrying only the goal-relevant fields.
func goalCampaign(count int, cadence string, start, end time.Time) *models.Campaign {
return &models.Campaign{
EstimatedPostCount: &count,
GoalCadence: cadence,
StartDate: tptr(start),
EndDate: tptr(end),
}
}

func TestBuildGoalProgress_WeeklyBucketsAndTotals(t *testing.T) {
c := goalCampaign(2, "week",
time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC)) // 2 weeks
posts := []models.Post{
{ID: "1", Status: models.PostStatusScheduled, ScheduledAt: tptr(dateUTC(2026, 6, 2))}, // week 1
{ID: "2", Status: models.PostStatusScheduledForManualPublish, ScheduledAt: tptr(dateUTC(2026, 6, 3))}, // week 1
{ID: "3", Status: models.PostStatusPublished, ScheduledAt: tptr(dateUTC(2026, 6, 9))}, // week 2
{ID: "4", Status: models.PostStatusDraft, ScheduledAt: tptr(dateUTC(2026, 6, 2))}, // not committed
{ID: "5", Status: models.PostStatusScheduled, ScheduledAt: nil}, // no date
}

gp := buildGoalProgress(c, posts)
if gp == nil {
t.Fatal("expected goal progress, got nil")
}
if gp.Cadence != "week" || gp.PostsPerPeriod != 2 || gp.Periods != 2 || gp.TotalTarget != 4 {
t.Fatalf("header = %+v, want week/2/2/4", gp)
}
if gp.TotalAchieved != 3 || gp.Reached || gp.Percent != 75 {
t.Fatalf("totals: achieved=%d reached=%v pct=%d, want 3/false/75", gp.TotalAchieved, gp.Reached, gp.Percent)
}
if len(gp.Buckets) != 2 {
t.Fatalf("buckets = %d, want 2", len(gp.Buckets))
}
if gp.Buckets[0].Achieved != 2 || !gp.Buckets[0].Reached || gp.Buckets[0].Label != "Week 1" {
t.Fatalf("week 1 bucket = %+v, want achieved 2 reached", gp.Buckets[0])
}
if gp.Buckets[1].Achieved != 1 || gp.Buckets[1].Reached {
t.Fatalf("week 2 bucket = %+v, want achieved 1 not reached", gp.Buckets[1])
}
if gp.Streak != 0 {
t.Fatalf("streak = %d, want 0 (last period missed)", gp.Streak)
}
}

func TestBuildGoalProgress_StreakAndReached(t *testing.T) {
c := goalCampaign(2, "week",
time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC))
posts := []models.Post{
{ID: "1", Status: models.PostStatusScheduled, ScheduledAt: tptr(dateUTC(2026, 6, 2))},
{ID: "2", Status: models.PostStatusPublished, ScheduledAt: tptr(dateUTC(2026, 6, 4))},
{ID: "3", Status: models.PostStatusScheduled, ScheduledAt: tptr(dateUTC(2026, 6, 9))},
{ID: "4", Status: models.PostStatusScheduled, ScheduledAt: tptr(dateUTC(2026, 6, 12))},
}
gp := buildGoalProgress(c, posts)
if gp.TotalAchieved != 4 || !gp.Reached || gp.Percent != 100 {
t.Fatalf("totals: achieved=%d reached=%v pct=%d, want 4/true/100", gp.TotalAchieved, gp.Reached, gp.Percent)
}
if gp.Streak != 2 {
t.Fatalf("streak = %d, want 2 (both periods reached)", gp.Streak)
}
}

func TestBuildGoalProgress_MissingDates(t *testing.T) {
count := 3
c := &models.Campaign{EstimatedPostCount: &count, GoalCadence: "month"} // no dates
posts := []models.Post{
{ID: "1", Status: models.PostStatusScheduled, ScheduledAt: tptr(dateUTC(2026, 6, 2))}, // committed + dated → counts
{ID: "2", Status: models.PostStatusPublished, ScheduledAt: nil}, // committed but undated → excluded
{ID: "3", Status: models.PostStatusDraft}, // not committed → ignored
}
gp := buildGoalProgress(c, posts)
if gp == nil {
t.Fatal("expected goal progress with missing dates, got nil")
}
if gp.Periods != 1 || gp.TotalTarget != 3 {
t.Fatalf("periods=%d target=%d, want 1/3", gp.Periods, gp.TotalTarget)
}
if len(gp.Buckets) != 0 {
t.Fatalf("no dates → no buckets, got %d", len(gp.Buckets))
}
if gp.TotalAchieved != 1 || gp.Reached {
t.Fatalf("achieved=%d reached=%v, want 1/false", gp.TotalAchieved, gp.Reached)
}
}

func TestBuildGoalProgress_NoGoalWhenCountUnset(t *testing.T) {
if gp := buildGoalProgress(sampleCampaign(), nil); gp != nil {
t.Fatalf("expected nil goal when estimated_post_count is unset, got %+v", gp)
}
}
Loading