diff --git a/docs/internal/app-orchestration.md b/docs/internal/app-orchestration.md index 53984a1..b4be855 100644 --- a/docs/internal/app-orchestration.md +++ b/docs/internal/app-orchestration.md @@ -134,7 +134,7 @@ failure context. ## Batch Workflow -`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except +`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except on Sunday. `run evening` resolves Tomorrow Report. Batch output copy names come from report definitions. Batch generation continues independent reports after a failure, records each result, writes compact status lines to stderr, emits a diff --git a/docs/internal/report-registry.md b/docs/internal/report-registry.md index a9a32f5..acdd19a 100644 --- a/docs/internal/report-registry.md +++ b/docs/internal/report-registry.md @@ -28,22 +28,23 @@ Each report definition declares: - default ordered module composition Report-owned helpers map public command names and config keys to report IDs. -The generate command names are `daily`, `tomorrow`, `hourly`, `three-day`, -`weekend`, and `storm`. Config keys also accept underscore and legacy -descriptive aliases such as `daily_today`, `three_day_outlook`, +The generate command names are `daily`, `today`, `tomorrow`, `hourly`, +`three-day`, `weekend`, and `storm`. Config keys also accept underscore and +legacy descriptive aliases such as `daily_today`, `three_day_outlook`, `weekend_outlook`, and `storm_report`. Markdown report definitions use the `scriptorium_markdown` generation mode. -Their template and structured-text schema identifiers are empty. Tomorrow -Report and Hourly Report declare `generated_text_template`; the app uses their -template and schema identifiers to validate generated text and render embedded -Markdown templates. +Their template and structured-text schema identifiers are empty. Today Report, +Tomorrow Report, and Hourly Report declare `generated_text_template`; the app +uses their template and schema identifiers to validate generated text and render +embedded Markdown templates. ## Reports | Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility | | --- | --- | --- | --- | --- | --- | --- | | Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today | +| Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report | | Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report | | Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report | | 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook | @@ -56,6 +57,8 @@ All report definitions are eligible for generation. - Daily Today covers the selected local civil day, or the current local civil day when no date override is supplied. +- Today Report covers the selected local civil day, or the current local civil + day when no date override is supplied. - Tomorrow Report covers the next local civil day from generation time. - Hourly Report covers the half-open six-hour period from generation time in the effective report timezone. The duration is an internal report constant, @@ -84,15 +87,15 @@ report override keys. The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch output path copying uses batch output names from report definitions. Report -module overrides can use short keys such as `tomorrow` and `hourly`, canonical -report IDs such as `daily_today`, or accepted aliases such as +module overrides can use short keys such as `today`, `tomorrow`, and `hourly`, +canonical report IDs such as `daily_today`, or accepted aliases such as `three_day_outlook`. ## Batch Membership -Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except -on Sunday. Evening batches include Tomorrow Report. Hourly Report is not part -of a scheduled batch. +Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook +except on Sunday. Evening batches include Tomorrow Report. Daily Today and +Hourly Report are not part of a scheduled batch. ## State And App Usage diff --git a/docs/internal/state.md b/docs/internal/state.md index 421c076..e54596f 100644 --- a/docs/internal/state.md +++ b/docs/internal/state.md @@ -93,6 +93,8 @@ current report definition. - Daily Today compares with prior Daily Today snapshots for the same valid local date. +- Today Report compares with prior Today Report snapshots for the same valid + local date. - Tomorrow Report compares with prior Tomorrow Report snapshots for the same valid local date. - 3-Day Outlook compares with prior 3-Day snapshots for the same valid local diff --git a/internal/app/app.go b/internal/app/app.go index c7cfc79..fef21dd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1106,7 +1106,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state. PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, } switch reportID { - case report.DailyToday, report.Tomorrow: + case report.DailyToday, report.Today, report.Tomorrow: return changes.CompareDaily(previous, current, thresholds) case report.ThreeDay: return changes.CompareThreeDay(previous, current, thresholds) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index beb9bb2..6899cd1 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1338,6 +1338,93 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) { } } +func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + store, err := state.NewFilesystemStore(cfg.Workspace) + if err != nil { + t.Fatalf("NewFilesystemStore() error = %v", err) + } + priorResolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportKind(report.CommandNameToday), + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T04:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate(prior) error = %v", err) + } + savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved)) + + currentResolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportKind(report.CommandNameToday), + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate(current) error = %v", err) + } + renderer := &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, + structuredRunBody: validTodayGeneratedTextJSON(), + } + + result, err := GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: currentResolved, + Renderer: renderer, + Store: store, + }) + if err != nil { + t.Fatalf("GenerateReport() error = %v", err) + } + if renderer.runCalls != 0 || renderer.structuredRunCalls != 1 { + t.Fatalf("renderer calls run=%d structured=%d, want generated-text flow", renderer.runCalls, renderer.structuredRunCalls) + } + if result.Metadata.ReportID != report.Today || result.Metadata.Variant != "today" || result.Metadata.GeneratedTextSchemaID != "today" { + t.Fatalf("metadata = %#v, want Today generated-text metadata", result.Metadata) + } + if !strings.Contains(result.Metadata.RunID, "_today") { + t.Fatalf("RunID = %q, want Today report ID suffix", result.Metadata.RunID) + } + if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "today", "2026-05-29")) { + t.Fatalf("DataPackagePath = %q, want Today artifact group", result.DataPackagePath) + } + if !strings.Contains(result.ReportPath, filepath.Join("reports", "today")) { + t.Fatalf("ReportPath = %q, want Today report group", result.ReportPath) + } + if _, ok := result.ModuleSnapshot.LookupStanza("today_planning"); !ok { + t.Fatal("today_planning stanza missing") + } + if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); ok { + t.Fatal("tomorrow_planning stanza present, want Today-specific planning") + } + if result.PriorSnapshot == nil || len(result.RecentChanges) == 0 { + t.Fatalf("prior=%#v recentChanges=%#v, want Today daily comparison changes", result.PriorSnapshot, result.RecentChanges) + } + data, err := os.ReadFile(result.DataPackagePath) + if err != nil { + t.Fatalf("read data package: %v", err) + } + for _, want := range []string{"id: today", "prompt_id: weather.today_generated_text", "today_planning:", "recent_changes:"} { + if !strings.Contains(string(data), want) { + t.Fatalf("data package missing %q:\n%s", want, string(data)) + } + } + reportData, err := os.ReadFile(result.ReportPath) + if err != nil { + t.Fatalf("read report: %v", err) + } + for _, want := range []string{"# Today's Weather", "Today starts with showers before improving."} { + if !strings.Contains(string(reportData), want) { + t.Fatalf("today report missing %q:\n%s", want, string(reportData)) + } + } +} + func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() @@ -2072,8 +2159,8 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) { if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 { t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed) } - if renderer.runCalls != 2 { - t.Fatalf("run calls = %d, want successful reports to continue", renderer.runCalls) + if renderer.runCalls != 1 || renderer.structuredRunCalls != 1 { + t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls) } var failedThreeDay bool for _, item := range result.Reports { @@ -2316,6 +2403,10 @@ func validTomorrowGeneratedTextJSON() string { return `{"summary":"Tomorrow starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}` } +func validTodayGeneratedTextJSON() string { + return `{"summary":"Today starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}` +} + func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult { t.Helper() cfg.Workspace.Root = t.TempDir() @@ -2671,6 +2762,9 @@ func (r *selectiveRenderer) Run(_ context.Context, req scriptorium.RunRequest) ( func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) { r.structuredRunCalls++ body := validHourlyGeneratedTextJSON() + if req.PromptID == "weather.today_generated_text" { + body = validTodayGeneratedTextJSON() + } if req.PromptID == "weather.tomorrow_generated_text" { body = validTomorrowGeneratedTextJSON() } diff --git a/internal/briefing/package.go b/internal/briefing/package.go index a564447..ce4892f 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -185,7 +185,7 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus { func variantForReport(id report.ID) string { switch id { - case report.DailyToday: + case report.DailyToday, report.Today: return "today" case report.Tomorrow: return "tomorrow" diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 664c9ed..a8421be 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -196,7 +196,15 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } + _ = oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") _ = oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") + dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")) + if err != nil { + t.Fatalf("glob daily packages: %v", err) + } + if len(dailyPackages) != 0 { + t.Fatalf("daily packages = %#v, want morning batch to use Today only", dailyPackages) + } } func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { @@ -228,7 +236,7 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") { t.Fatalf("stderr missing structured report logs:\n%s", output.stderr) } - _ = oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml") + _ = oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") _ = oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") } @@ -448,7 +456,7 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) { } } -func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) { +func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) @@ -469,16 +477,20 @@ func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")) + todayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml")) if err != nil { - t.Fatalf("glob daily packages: %v", err) + t.Fatalf("glob today packages: %v", err) } threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml")) if err != nil { t.Fatalf("glob 3-day packages: %v", err) } - if len(dailyPackages) != 1 || len(threeDayPackages) != 1 { - t.Fatalf("daily packages = %#v, 3-day packages = %#v; want one each", dailyPackages, threeDayPackages) + dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")) + if err != nil { + t.Fatalf("glob daily packages: %v", err) + } + if len(todayPackages) != 1 || len(threeDayPackages) != 1 || len(dailyPackages) != 0 { + t.Fatalf("today packages = %#v, 3-day packages = %#v, daily packages = %#v; want Today and 3-day only", todayPackages, threeDayPackages, dailyPackages) } } @@ -1107,6 +1119,20 @@ if [ "$1" = "run" ]; then fi shift done + if [ "$prompt" = "weather.today_generated_text" ]; then + cat > "$out" <<'JSON' +{ + "summary": "Today starts with showers before improving.", + "forecast_discussion": [ + "Morning showers should taper as drier air arrives.", + "Afternoon conditions trend quieter." + ], + "precipitation_timing": "The best rain chance is during the morning." +} +JSON + printf 'wrote generated text\n' >&2 + exit 0 + fi if [ "$prompt" = "weather.tomorrow_generated_text" ]; then cat > "$out" <<'JSON' { @@ -1163,6 +1189,20 @@ if [ "$1" = "run" ]; then "precipitation_timing": "A cold front is moving into the region.", "confidence": "Medium" } +JSON + printf 'wrote generated text\n' >&2 + exit 0 + fi + if [ "$prompt" = "weather.today_generated_text" ]; then + cat > "$out" <<'JSON' +{ + "summary": "Today starts with showers before improving.", + "forecast_discussion": [ + "Morning showers should taper as drier air arrives.", + "Afternoon conditions trend quieter." + ], + "precipitation_timing": "The best rain chance is during the morning." +} JSON printf 'wrote generated text\n' >&2 exit 0 @@ -1216,13 +1256,30 @@ if [ "$1" = "render" ]; then fi if [ "$1" = "run" ]; then out="" + prompt="" while [ "$#" -gt 0 ]; do if [ "$1" = "--out" ]; then shift out="$1" + elif [ "$1" = "--prompt" ]; then + shift + prompt="$1" fi shift done + if [ "$prompt" = "weather.today_generated_text" ]; then + cat > "$out" <<'JSON' +{ + "summary": "Today starts with showers before improving.", + "forecast_discussion": [ + "Morning showers should taper as drier air arrives.", + "Afternoon conditions trend quieter." + ], + "precipitation_timing": "The best rain chance is during the morning." +} +JSON + exit 0 + fi printf '# Batch Report\n\nGenerated by fake scriptorium.\n' > "$out" exit 0 fi diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b6b3852..19ee92e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -184,6 +184,47 @@ reports: } } +func TestLoadTodayReportModuleOverrides(t *testing.T) { + path := writeConfig(t, ` +reports: + today: + deterministic_modules: + - metadata + - current_conditions + - derived_daily_summary + - derived_daypart_summaries + - today_planning +`) + + cfg, err := LoadFile(path) + if err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + overrides, err := cfg.ReportModuleOverrides() + if err != nil { + t.Fatalf("ReportModuleOverrides() error = %v", err) + } + items := overrides[report.Today] + if len(items) != 5 { + t.Fatalf("today override length = %d, want 5", len(items)) + } + want := []module.ID{ + module.Metadata, + module.CurrentConditions, + module.DerivedDailySummary, + module.DerivedDaypartSummaries, + module.TodayPlanning, + } + for i, id := range want { + if items[i].ID != id { + t.Fatalf("today override[%d] = %s, want %s", i, items[i].ID, id) + } + } + if _, ok := overrides[report.DailyToday]; ok { + t.Fatalf("daily override = %#v, want today override to stay distinct", overrides[report.DailyToday]) + } +} + func TestLoadHourlyReportModuleOverrides(t *testing.T) { path := writeConfig(t, ` reports: @@ -286,6 +327,13 @@ func TestValidateReportModuleAliasesDirectly(t *testing.T) { }, wantErr: "duplicates report override", }, + { + name: "TodayAndDailyAreDistinct", + reports: map[string]ReportConfig{ + "daily": {}, + "today": {}, + }, + }, { name: "UnknownReport", reports: map[string]ReportConfig{ @@ -299,6 +347,12 @@ func TestValidateReportModuleAliasesDirectly(t *testing.T) { cfg := Defaults() cfg.Reports = tt.reports err := Validate(cfg) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + return + } if err == nil { t.Fatal("Validate() error = nil, want report key error") } diff --git a/internal/facts/facts.go b/internal/facts/facts.go index 3e61b3c..5684537 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -115,7 +115,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) { switch req.Resolved.Definition.ID { case report.Hourly: - case report.DailyToday, report.Tomorrow: + case report.DailyToday, report.Today, report.Tomorrow: summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts) if err != nil { return DerivedFacts{}, err diff --git a/internal/report/names.go b/internal/report/names.go index 9c5af95..dc66b28 100644 --- a/internal/report/names.go +++ b/internal/report/names.go @@ -7,6 +7,7 @@ import ( const ( CommandNameDaily = "daily" + CommandNameToday = "today" CommandNameTomorrow = "tomorrow" CommandNameHourly = "hourly" CommandNameThreeDay = "three-day" @@ -21,6 +22,8 @@ func IDForCommandName(name string) (ID, error) { switch name { case CommandNameDaily: return DailyToday, nil + case CommandNameToday: + return Today, nil case CommandNameTomorrow: return Tomorrow, nil case CommandNameHourly: @@ -39,6 +42,7 @@ func IDForCommandName(name string) (ID, error) { func CommandNames() []string { return []string{ CommandNameDaily, + CommandNameToday, CommandNameTomorrow, CommandNameHourly, CommandNameThreeDay, @@ -52,6 +56,8 @@ func IDForConfigKey(key string) (ID, error) { switch normalized { case "daily", "daily_today": return DailyToday, nil + case "today": + return Today, nil case "tomorrow": return Tomorrow, nil case "hourly": diff --git a/internal/report/period.go b/internal/report/period.go index b00c5da..ecd5c3d 100644 --- a/internal/report/period.go +++ b/internal/report/period.go @@ -26,7 +26,7 @@ func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, err } switch batch { case Morning: - ids := []ID{DailyToday, ThreeDay} + ids := []ID{Today, ThreeDay} if req.Now.In(req.Location).Weekday() != time.Sunday { ids = append(ids, Weekend) } diff --git a/internal/report/period_test.go b/internal/report/period_test.go index 03be4ae..e7b79e9 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -33,6 +33,41 @@ func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) { assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00") } +func TestTodayValidPeriod(t *testing.T) { + location := mustLoadLocation(t) + now := mustParse("2026-05-29T17:45:00-05:00") + + resolved, err := Resolve(Today, ResolveRequest{Now: now, Location: location}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00") + if resolved.Definition.PromptID != "weather.today_generated_text" { + t.Fatalf("PromptID = %q, want weather.today_generated_text", resolved.Definition.PromptID) + } + if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate { + t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode) + } + if resolved.Definition.TemplateID != "today" { + t.Fatalf("TemplateID = %q, want today", resolved.Definition.TemplateID) + } + if resolved.Definition.GeneratedTextSchemaID != "today" { + t.Fatalf("GeneratedTextSchemaID = %q, want today", resolved.Definition.GeneratedTextSchemaID) + } +} + +func TestTodayValidPeriodCanUseExplicitDate(t *testing.T) { + location := mustLoadLocation(t) + now := mustParse("2026-05-29T17:45:00-05:00") + date := mustParse("2026-05-31T12:00:00-05:00") + + resolved, err := Resolve(Today, ResolveRequest{Now: now, Location: location, Date: date}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00") +} + func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) { location := mustLoadLocation(t) now := mustParse("2026-05-29T20:00:00-05:00") @@ -201,8 +236,8 @@ func TestMorningBatchSkipsWeekendOnSunday(t *testing.T) { t.Fatalf("BatchReports() error = %v", err) } ids := resolvedIDs(resolved) - if strings.Join(ids, ",") != "daily_today,three_day" { - t.Fatalf("ids = %v, want daily_today and three_day", ids) + if strings.Join(ids, ",") != "today,three_day" { + t.Fatalf("ids = %v, want today and three_day", ids) } } @@ -246,6 +281,7 @@ func TestIDForCommandName(t *testing.T) { want ID }{ {name: "daily", want: DailyToday}, + {name: "today", want: Today}, {name: "tomorrow", want: Tomorrow}, {name: "hourly", want: Hourly}, {name: "three-day", want: ThreeDay}, @@ -263,9 +299,20 @@ func TestIDForCommandName(t *testing.T) { } }) } - if names := strings.Join(CommandNames(), ","); names != "daily,tomorrow,hourly,three-day,weekend,storm" { + if names := strings.Join(CommandNames(), ","); names != "daily,today,tomorrow,hourly,three-day,weekend,storm" { t.Fatalf("CommandNames() = %s, want stable command names", names) } + dailyID, err := IDForCommandName("daily") + if err != nil { + t.Fatalf("IDForCommandName(daily) error = %v", err) + } + todayID, err := IDForCommandName("today") + if err != nil { + t.Fatalf("IDForCommandName(today) error = %v", err) + } + if dailyID == todayID { + t.Fatalf("daily and today both resolve to %q, want distinct report IDs", dailyID) + } if _, err := IDForCommandName("near-term"); err == nil || !strings.Contains(err.Error(), `unknown report command "near-term"`) { t.Fatalf("IDForCommandName(near-term) error = %v, want unknown command", err) } @@ -278,6 +325,7 @@ func TestIDForConfigKey(t *testing.T) { }{ {key: "daily", want: DailyToday}, {key: "daily_today", want: DailyToday}, + {key: "today", want: Today}, {key: "tomorrow", want: Tomorrow}, {key: "hourly", want: Hourly}, {key: "three_day", want: ThreeDay}, @@ -303,6 +351,17 @@ func TestIDForConfigKey(t *testing.T) { if _, err := IDForConfigKey("daily_tomorrow"); err == nil || !strings.Contains(err.Error(), `report config key "daily_tomorrow" is not a known report`) { t.Fatalf("IDForConfigKey(daily_tomorrow) error = %v, want unknown key", err) } + dailyID, err := IDForConfigKey("daily") + if err != nil { + t.Fatalf("IDForConfigKey(daily) error = %v", err) + } + todayID, err := IDForConfigKey("today") + if err != nil { + t.Fatalf("IDForConfigKey(today) error = %v", err) + } + if dailyID == todayID { + t.Fatalf("daily and today config keys both resolve to %q, want distinct report IDs", dailyID) + } } func TestBatchForCommandName(t *testing.T) { @@ -342,7 +401,7 @@ func TestMorningBatchReportOrder(t *testing.T) { t.Fatalf("BatchReports() error = %v", err) } ids := resolvedIDs(resolved) - if strings.Join(ids, ",") != "daily_today,three_day,weekend" { + if strings.Join(ids, ",") != "today,three_day,weekend" { t.Fatalf("ids = %v, want morning report order", ids) } } @@ -359,7 +418,7 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) { func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) { ids := resolvedDefinitionIDs(DefaultRegistry().All()) - want := []string{"daily_today", "tomorrow", "hourly", "three_day", "weekend", "storm"} + want := []string{"daily_today", "today", "tomorrow", "hourly", "three_day", "weekend", "storm"} if strings.Join(ids, ",") != strings.Join(want, ",") { t.Fatalf("All() ids = %#v, want %#v", ids, want) } @@ -384,7 +443,7 @@ func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) { if !definition.Generated { continue } - if definition.ID == Hourly || definition.ID == Tomorrow { + if definition.ID == Hourly || definition.ID == Today || definition.ID == Tomorrow { wantTemplate := string(definition.ID) if definition.GenerationMode != GenerationModeGeneratedTextTemplate { t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate) @@ -426,6 +485,14 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { compatiblePriorIDs: []ID{DailyToday}, comparisonStrategy: CompareSameValidDate, }, + { + id: Today, + artifactGroup: "today", + batchOutputName: "today.md", + generated: true, + compatiblePriorIDs: []ID{Today}, + comparisonStrategy: CompareSameValidDate, + }, { id: Tomorrow, artifactGroup: "tomorrow", @@ -522,6 +589,25 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { module.HourlyForecast, }, }, + { + id: Today, + want: []module.ID{ + module.Metadata, + module.CurrentConditions, + module.NarrativeForecast, + module.DerivedDailySummary, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.SPCConvectiveOutlooks, + module.AreaForecastDiscussion, + module.SPCConvectiveDiscussion, + module.WeatherStory, + module.OutdoorWindows, + module.HourlyForecast, + module.TodayPlanning, + }, + }, { id: Tomorrow, want: []module.ID{ diff --git a/internal/report/registry.go b/internal/report/registry.go index 48bae35..8897bca 100644 --- a/internal/report/registry.go +++ b/internal/report/registry.go @@ -13,6 +13,7 @@ type Registry struct { func DefaultRegistry() Registry { definitions := []Definition{ dailyTodayDefinition(), + todayDefinition(), tomorrowDefinition(), hourlyDefinition(), threeDayDefinition(), @@ -74,7 +75,7 @@ func (r Registry) MustLookup(id ID) Definition { } func (r Registry) All() []Definition { - ids := []ID{DailyToday, Tomorrow, Hourly, ThreeDay, Weekend, Storm} + ids := []ID{DailyToday, Today, Tomorrow, Hourly, ThreeDay, Weekend, Storm} out := make([]Definition, 0, len(ids)) for _, id := range ids { if definition, ok := r.definitions[id]; ok { diff --git a/internal/report/today_report.go b/internal/report/today_report.go new file mode 100644 index 0000000..813ce91 --- /dev/null +++ b/internal/report/today_report.go @@ -0,0 +1,51 @@ +package report + +import ( + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func todayDefinition() Definition { + return Definition{ + ID: Today, + Name: "Today Report", + PromptID: "weather.today_generated_text", + GenerationMode: GenerationModeGeneratedTextTemplate, + TemplateID: "today", + GeneratedTextSchemaID: "today", + ComparisonStrategy: CompareSameValidDate, + ArtifactGroup: "today", + BatchOutputName: "today.md", + Generated: true, + CompatiblePriorIDs: []ID{Today}, + Modules: todayModules(), + Morning: true, + resolve: resolveToday, + } +} + +func todayModules() []module.ConfigItem { + return moduleItems( + module.Metadata, + module.CurrentConditions, + module.NarrativeForecast, + module.DerivedDailySummary, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.SPCConvectiveOutlooks, + module.AreaForecastDiscussion, + module.SPCConvectiveDiscussion, + module.WeatherStory, + module.OutdoorWindows, + module.HourlyForecast, + module.TodayPlanning, + ) +} + +func resolveToday(req ResolveRequest) (timeutil.Period, error) { + if !req.Date.IsZero() { + return timeutil.CivilDay(req.Date, req.Location), nil + } + return timeutil.CivilDay(req.Now, req.Location), nil +} diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index b2832c9..1a67eed 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -63,6 +63,13 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) { validDate: "2026-05-30", runID: "20260529T230000.000000000Z_tomorrow", }, + { + name: "today", + resolved: resolveTodayAt(t, "2026-05-29T05:00:00-05:00"), + group: "today", + validDate: "2026-05-29", + runID: "20260529T100000.000000000Z_today", + }, } for _, tt := range tests { @@ -403,6 +410,24 @@ func TestFindPriorSnapshot(t *testing.T) { } } +func TestFindPriorSnapshotSupportsToday(t *testing.T) { + store := newTestStore(t) + first := resolveTodayAt(t, "2026-05-29T05:00:00-05:00") + second := resolveTodayAt(t, "2026-05-29T08:00:00-05:00") + savePriorMetadata(t, store, first, stateBriefingMetadata(first)) + + prior, err := store.FindPriorSnapshot(context.Background(), second) + if err != nil { + t.Fatalf("FindPriorSnapshot() error = %v", err) + } + if prior == nil { + t.Fatal("FindPriorSnapshot() = nil, want prior Today snapshot") + } + if prior.Metadata.RunID != first.Metadata().RunID { + t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID) + } +} + func TestFindPriorSnapshotUsesValidDate(t *testing.T) { store := newTestStore(t) previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00") @@ -532,6 +557,26 @@ func resolveDailyAt(t *testing.T, value string) report.Resolved { return resolved } +func resolveTodayAt(t *testing.T, value string) report.Resolved { + t.Helper() + location, err := timeutil.LoadLocation("America/Chicago") + if err != nil { + t.Fatalf("LoadLocation() error = %v", err) + } + now, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatalf("parse time: %v", err) + } + resolved, err := report.DefaultRegistry().Resolve(report.Today, report.ResolveRequest{ + Now: now, + Location: location, + }) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + return resolved +} + func resolveThreeDayAt(t *testing.T, value string) report.Resolved { t.Helper() location, err := timeutil.LoadLocation("America/Chicago")