diff --git a/src/campaign_actions/overview/overview.go b/src/campaign_actions/overview/overview.go index bd983bbb..d4aafecd 100644 --- a/src/campaign_actions/overview/overview.go +++ b/src/campaign_actions/overview/overview.go @@ -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. diff --git a/src/campaign_actions/overview/service.go b/src/campaign_actions/overview/service.go index 9d5b1fe8..a7d96328 100644 --- a/src/campaign_actions/overview/service.go +++ b/src/campaign_actions/overview/service.go @@ -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 @@ -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++ + } + } + } 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 } } diff --git a/src/campaign_actions/overview/service_test.go b/src/campaign_actions/overview/service_test.go index e2d7df5a..119e25d7 100644 --- a/src/campaign_actions/overview/service_test.go +++ b/src/campaign_actions/overview/service_test.go @@ -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", @@ -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) + } +} diff --git a/src/campaigngoal/campaigngoal.go b/src/campaigngoal/campaigngoal.go new file mode 100644 index 00000000..8ec41fbb --- /dev/null +++ b/src/campaigngoal/campaigngoal.go @@ -0,0 +1,169 @@ +// Package campaigngoal turns a campaign's post-rate goal — a per-period count +// (estimated_post_count) plus a cadence (week|month) — into the two things the +// rest of the app needs: the effective total number of posts to generate (the +// CON-182 content-plan input) and the per-period progress windows (the campaign +// overview's goal recap). +// +// All calendar math runs in a caller-supplied *time.Location so that a goal of +// "5 posts per week" lines up with how CON-181 stamps each draft's scheduled_at +// in the campaign timezone. The package itself is pure (stdlib only); callers +// resolve the location via settings.ResolveTimezone and pass it in. +package campaigngoal + +import ( + "fmt" + "time" +) + +// Supported goal cadences and the default applied to an unset value. estimated_post_count +// is interpreted as "this many posts per one of these periods". +const ( + CadenceWeek = "week" + CadenceMonth = "month" + DefaultCadence = CadenceMonth +) + +// ValidCadence reports whether s is a supported cadence. +func ValidCadence(s string) bool { + return s == CadenceWeek || s == CadenceMonth +} + +// Normalize returns the effective cadence: DefaultCadence for an empty string, +// the value itself when valid, and a 400-worthy error otherwise. +func Normalize(s string) (string, error) { + switch s { + case "": + return DefaultCadence, nil + case CadenceWeek, CadenceMonth: + return s, nil + default: + return "", fmt.Errorf("goal_cadence must be %q or %q, got %q", CadenceWeek, CadenceMonth, s) + } +} + +// Periods is the number of cadence periods the [start, end] window spans, +// rounding any partial trailing period UP (ceil). Bounds are read as calendar +// days in loc. It returns 1 for a missing/empty window or an unrecognized +// cadence, so a caller can safely treat the per-period count as an absolute +// total in those cases. +func Periods(cadence string, start, end *time.Time, loc *time.Location) int { + if start == nil || end == nil { + return 1 + } + if loc == nil { + loc = time.UTC + } + s := dateOnly(*start, loc) + e := dateOnly(*end, loc) + if e.Before(s) { + return 1 + } + switch cadence { + case CadenceWeek: + return ceilDiv(daysInclusive(s, e), 7) + case CadenceMonth: + return monthIndex(e) - monthIndex(s) + 1 + default: + return 1 + } +} + +// EffectiveCount is the total number of posts the goal implies over the whole +// campaign: perPeriod × Periods. It returns 0 when perPeriod is unset or +// non-positive, letting the content-plan flow fall back to a model-decided +// count (its behavior before goals existed). +func EffectiveCount(perPeriod *int, cadence string, start, end *time.Time, loc *time.Location) int { + if perPeriod == nil || *perPeriod <= 0 { + return 0 + } + return *perPeriod * Periods(cadence, start, end, loc) +} + +// Window is one goal period: a half-open [Start, End) instant range in loc plus +// a display label ("Week 1", "Aug 2026"). +type Window struct { + Start time.Time + End time.Time + Label string +} + +// Windows returns the ordered goal periods that partition [start, end] at the +// given cadence, in loc. Weekly windows are successive 7-day spans anchored at +// start; monthly windows follow calendar months. The first window starts at the +// campaign start and the last ends at the day after the campaign end, so the +// windows exactly tile the campaign span with no gaps or overlap. Returns nil +// when the window is missing/invalid or the cadence is unrecognized. +func Windows(cadence string, start, end *time.Time, loc *time.Location) []Window { + if start == nil || end == nil { + return nil + } + if loc == nil { + loc = time.UTC + } + s := dateOnly(*start, loc) + e := dateOnly(*end, loc) + if e.Before(s) { + return nil + } + endExclusive := e.AddDate(0, 0, 1) // the day after the last campaign day + + switch cadence { + case CadenceWeek: + n := ceilDiv(daysInclusive(s, e), 7) + out := make([]Window, 0, n) + for i := 0; i < n; i++ { + ws := s.AddDate(0, 0, 7*i) + we := s.AddDate(0, 0, 7*(i+1)) + if we.After(endExclusive) { + we = endExclusive // clamp the trailing partial week to the campaign end + } + out = append(out, Window{Start: ws, End: we, Label: fmt.Sprintf("Week %d", i+1)}) + } + return out + case CadenceMonth: + n := monthIndex(e) - monthIndex(s) + 1 + firstOfStartMonth := time.Date(s.Year(), s.Month(), 1, 0, 0, 0, 0, loc) + out := make([]Window, 0, n) + for i := 0; i < n; i++ { + monthStart := firstOfStartMonth.AddDate(0, i, 0) + ws := monthStart + if ws.Before(s) { + ws = s // clamp the first window to the campaign start + } + we := firstOfStartMonth.AddDate(0, i+1, 0) + if we.After(endExclusive) { + we = endExclusive // clamp the last window to the campaign end + } + out = append(out, Window{Start: ws, End: we, Label: monthStart.Format("Jan 2006")}) + } + return out + default: + return nil + } +} + +// dateOnly is the calendar day of t in loc, at midnight. +func dateOnly(t time.Time, loc *time.Location) time.Time { + t = t.In(loc) + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) +} + +// daysInclusive counts calendar days from s to e inclusive (both midnights). +// It measures the span in UTC so DST-shortened/lengthened days don't skew the +// count. +func daysInclusive(s, e time.Time) int { + su := time.Date(s.Year(), s.Month(), s.Day(), 0, 0, 0, 0, time.UTC) + eu := time.Date(e.Year(), e.Month(), e.Day(), 0, 0, 0, 0, time.UTC) + return int(eu.Sub(su).Hours()/24) + 1 +} + +// monthIndex maps a time to a monotonically increasing month number so month +// differences are a simple subtraction. +func monthIndex(t time.Time) int { return t.Year()*12 + int(t.Month()) - 1 } + +func ceilDiv(a, b int) int { + if a <= 0 || b <= 0 { + return 0 + } + return (a + b - 1) / b +} diff --git a/src/campaigngoal/campaigngoal_test.go b/src/campaigngoal/campaigngoal_test.go new file mode 100644 index 00000000..c2f2e545 --- /dev/null +++ b/src/campaigngoal/campaigngoal_test.go @@ -0,0 +1,136 @@ +package campaigngoal + +import ( + "testing" + "time" +) + +func d(y int, m time.Month, day int) *time.Time { + t := time.Date(y, m, day, 0, 0, 0, 0, time.UTC) + return &t +} + +func ip(n int) *int { return &n } + +func TestNormalize(t *testing.T) { + cases := []struct { + in string + want string + wantErr bool + }{ + {"", CadenceMonth, false}, + {"week", CadenceWeek, false}, + {"month", CadenceMonth, false}, + {"day", "", true}, + {"Week", "", true}, // case-sensitive; handler trims but does not lowercase + } + for _, c := range cases { + got, err := Normalize(c.in) + if c.wantErr { + if err == nil { + t.Errorf("Normalize(%q) expected error, got %q", c.in, got) + } + continue + } + if err != nil { + t.Errorf("Normalize(%q) unexpected error: %v", c.in, err) + } + if got != c.want { + t.Errorf("Normalize(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestPeriods(t *testing.T) { + cases := []struct { + name string + cadence string + start *time.Time + end *time.Time + want int + }{ + {"week 2-month span rounds up", CadenceWeek, d(2026, 6, 1), d(2026, 7, 31), 9}, // ceil(61/7) + {"month 2-month span", CadenceMonth, d(2026, 6, 1), d(2026, 7, 31), 2}, + {"week partial month", CadenceWeek, d(2026, 9, 1), d(2026, 9, 20), 3}, // ceil(20/7) + {"month single month", CadenceMonth, d(2026, 9, 1), d(2026, 9, 20), 1}, + {"week single day", CadenceWeek, d(2026, 9, 1), d(2026, 9, 1), 1}, + {"month across year boundary", CadenceMonth, d(2025, 12, 15), d(2026, 2, 3), 3}, + {"missing dates → 1", CadenceWeek, nil, nil, 1}, + {"end before start → 1", CadenceWeek, d(2026, 9, 10), d(2026, 9, 1), 1}, + {"unknown cadence → 1", "day", d(2026, 6, 1), d(2026, 7, 31), 1}, + } + for _, c := range cases { + if got := Periods(c.cadence, c.start, c.end, time.UTC); got != c.want { + t.Errorf("%s: Periods = %d, want %d", c.name, got, c.want) + } + } +} + +func TestEffectiveCount(t *testing.T) { + cases := []struct { + name string + perPeriod *int + cadence string + start *time.Time + end *time.Time + want int + }{ + {"5/week over 9 weeks", ip(5), CadenceWeek, d(2026, 6, 1), d(2026, 7, 31), 45}, + {"12/month over 2 months", ip(12), CadenceMonth, d(2026, 6, 1), d(2026, 7, 31), 24}, + {"nil count → 0", nil, CadenceMonth, d(2026, 6, 1), d(2026, 7, 31), 0}, + {"zero count → 0", ip(0), CadenceMonth, d(2026, 6, 1), d(2026, 7, 31), 0}, + {"missing dates → count×1", ip(10), CadenceMonth, nil, nil, 10}, + } + for _, c := range cases { + if got := EffectiveCount(c.perPeriod, c.cadence, c.start, c.end, time.UTC); got != c.want { + t.Errorf("%s: EffectiveCount = %d, want %d", c.name, got, c.want) + } + } +} + +func TestWindowsWeekly(t *testing.T) { + ws := Windows(CadenceWeek, d(2026, 6, 1), d(2026, 6, 20), time.UTC) // 20 days → 3 weeks + if len(ws) != 3 { + t.Fatalf("got %d windows, want 3", len(ws)) + } + // First window starts at the campaign start; windows tile contiguously; the + // last ends the day after the campaign end. + if !ws[0].Start.Equal(*d(2026, 6, 1)) { + t.Errorf("first window start = %v, want 2026-06-01", ws[0].Start) + } + if !ws[len(ws)-1].End.Equal(*d(2026, 6, 21)) { + t.Errorf("last window end = %v, want 2026-06-21 (end+1)", ws[len(ws)-1].End) + } + for i := 1; i < len(ws); i++ { + if !ws[i].Start.Equal(ws[i-1].End) { + t.Errorf("window %d not contiguous: start %v, prev end %v", i, ws[i].Start, ws[i-1].End) + } + } + if ws[0].Label != "Week 1" || ws[2].Label != "Week 3" { + t.Errorf("labels = %q..%q, want Week 1..Week 3", ws[0].Label, ws[2].Label) + } +} + +func TestWindowsMonthly(t *testing.T) { + // Mid-month start: the first window is clamped to the campaign start, the + // second is a full calendar month clamped to the campaign end. + ws := Windows(CadenceMonth, d(2026, 6, 15), d(2026, 7, 31), time.UTC) + if len(ws) != 2 { + t.Fatalf("got %d windows, want 2", len(ws)) + } + if !ws[0].Start.Equal(*d(2026, 6, 15)) || !ws[0].End.Equal(*d(2026, 7, 1)) { + t.Errorf("window 0 = [%v,%v), want [2026-06-15, 2026-07-01)", ws[0].Start, ws[0].End) + } + if !ws[1].Start.Equal(*d(2026, 7, 1)) || !ws[1].End.Equal(*d(2026, 8, 1)) { + t.Errorf("window 1 = [%v,%v), want [2026-07-01, 2026-08-01)", ws[1].Start, ws[1].End) + } + if ws[0].Label != "Jun 2026" || ws[1].Label != "Jul 2026" { + t.Errorf("labels = %q,%q, want Jun 2026,Jul 2026", ws[0].Label, ws[1].Label) + } +} + +func TestWindowsMissingDates(t *testing.T) { + if ws := Windows(CadenceWeek, nil, d(2026, 7, 31), time.UTC); ws != nil { + t.Errorf("expected nil windows for missing start, got %v", ws) + } +} diff --git a/src/database/migrations/20260808000001_campaign_goals.down.sql b/src/database/migrations/20260808000001_campaign_goals.down.sql new file mode 100644 index 00000000..03705b6e --- /dev/null +++ b/src/database/migrations/20260808000001_campaign_goals.down.sql @@ -0,0 +1,3 @@ +-- CON-182: drop the campaign goal cadence. +ALTER TABLE campaigns + DROP COLUMN IF EXISTS goal_cadence; diff --git a/src/database/migrations/20260808000001_campaign_goals.up.sql b/src/database/migrations/20260808000001_campaign_goals.up.sql new file mode 100644 index 00000000..ccbf14b3 --- /dev/null +++ b/src/database/migrations/20260808000001_campaign_goals.up.sql @@ -0,0 +1,7 @@ +-- CON-182: campaign post-rate goal. estimated_post_count is reinterpreted as +-- the target number of posts PER period; goal_cadence sets the period the count +-- repeats over. Existing rows backfill to 'month' — their estimated_post_count +-- now multiplies by the number of months the campaign spans on the next +-- content-plan run (previously it was an absolute whole-campaign total). +ALTER TABLE campaigns + ADD COLUMN goal_cadence TEXT NOT NULL DEFAULT 'month'; diff --git a/src/genkit/flows/content_plan/generate.go b/src/genkit/flows/content_plan/generate.go index 3e08d4c3..b04db756 100644 --- a/src/genkit/flows/content_plan/generate.go +++ b/src/genkit/flows/content_plan/generate.go @@ -14,6 +14,7 @@ import ( "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" + "github.com/ogen-app/ogen/src/campaigngoal" "github.com/ogen-app/ogen/src/logging" "github.com/ogen-app/ogen/src/models" "github.com/ogen-app/ogen/src/repository" @@ -99,10 +100,15 @@ func generatePosts( onEvent OnEventFunc, tgt *targeting, ) ([]DraftPost, []string, error) { - estCount := 0 - if campaign.EstimatedPostCount != nil { - estCount = *campaign.EstimatedPostCount - } + // CON-182: the full-campaign plan generates estimated_post_count posts PER + // goal_cadence period × the number of periods the campaign spans (0 when no + // per-period count is set → the model decides the count, as before). The + // targeting path below overrides this with its explicit count. + loc, _ := settings.ResolveTimezone(campaign.Timezone) + estCount := campaigngoal.EffectiveCount( + campaign.EstimatedPostCount, campaign.GoalCadence, + campaign.StartDate, campaign.EndDate, loc, + ) startDate, endDate := *campaign.StartDate, *campaign.EndDate phases := make([]resolvedPhase, len(campaign.CampaignType.Phases)) diff --git a/src/handlers/campaigns.go b/src/handlers/campaigns.go index a486265f..7107cd87 100644 --- a/src/handlers/campaigns.go +++ b/src/handlers/campaigns.go @@ -16,6 +16,7 @@ import ( "github.com/ogen-app/ogen/src/activity" "github.com/ogen-app/ogen/src/campaign_actions/overview" "github.com/ogen-app/ogen/src/campaign_actions/summaries" + "github.com/ogen-app/ogen/src/campaigngoal" "github.com/ogen-app/ogen/src/genkit/flows/campaign_assistant" "github.com/ogen-app/ogen/src/genkit/flows/consistency" "github.com/ogen-app/ogen/src/genkit/flows/content_plan" @@ -211,6 +212,9 @@ type campaignRequest struct { Timezone string `json:"timezone"` PublishingDays models.StringSlice `json:"publishing_days"` SpreadMinutes *int `json:"spread_minutes"` + // Goal cadence (CON-182): "week" | "month". Empty falls back to "month". + // estimated_post_count is the target posts per one of these periods. + GoalCadence string `json:"goal_cadence"` } // normalizeScheduling validates the request's scheduling fields and returns the @@ -314,6 +318,10 @@ func (h *CampaignsHandler) Create(c *fiber.Ctx) error { if err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } + goalCadence, err := campaigngoal.Normalize(strings.TrimSpace(req.GoalCadence)) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } session := c.Locals("session").(*models.Session) @@ -346,6 +354,7 @@ func (h *CampaignsHandler) Create(c *fiber.Ctx) error { Timezone: timezone, PublishingDays: publishingDays, SpreadMinutes: spread, + GoalCadence: goalCadence, CreatedBy: session.UserID, } if err := h.repo.Create(c.Context(), campaign); err != nil { @@ -413,6 +422,10 @@ func (h *CampaignsHandler) Update(c *fiber.Ctx) error { if err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } + goalCadence, err := campaigngoal.Normalize(strings.TrimSpace(req.GoalCadence)) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } campaign, err := h.repo.GetByID(c.Context(), c.Params("id")) if err != nil { @@ -447,6 +460,7 @@ func (h *CampaignsHandler) Update(c *fiber.Ctx) error { campaign.Timezone = timezone campaign.PublishingDays = publishingDays campaign.SpreadMinutes = spread + campaign.GoalCadence = goalCadence campaign.UpdatedAt = time.Now().UTC() if err := h.repo.Update(c.Context(), campaign); err != nil { diff --git a/src/models/campaign.go b/src/models/campaign.go index 332309f2..4c46ca39 100644 --- a/src/models/campaign.go +++ b/src/models/campaign.go @@ -48,6 +48,12 @@ type Campaign struct { PublishingDays StringSlice `bun:"publishing_days,notnull,type:jsonb" json:"publishing_days"` SpreadMinutes int `bun:"spread_minutes,notnull,default:15" json:"spread_minutes"` + // Goal (CON-182). estimated_post_count above is the target number of posts + // PER goal_cadence period; the content-plan flow multiplies it by the number + // of week/month periods the campaign's [start_date, end_date] window spans, + // and the campaign overview reports per-period progress against it. + GoalCadence string `bun:"goal_cadence,notnull,default:'month'" json:"goal_cadence"` + // system Status CampaignStatus `bun:"status,notnull" json:"status"` Budget *float64 `bun:"budget" json:"budget"`