Add Today report to morning batch

This commit is contained in:
2026-06-15 14:54:37 +00:00
parent 8ff5c44324
commit 3d5f71e72d
15 changed files with 431 additions and 32 deletions

View File

@@ -134,7 +134,7 @@ failure context.
## Batch Workflow ## 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 on Sunday. `run evening` resolves Tomorrow Report. Batch output copy names come
from report definitions. Batch generation continues independent reports after a from report definitions. Batch generation continues independent reports after a
failure, records each result, writes compact status lines to stderr, emits a failure, records each result, writes compact status lines to stderr, emits a

View File

@@ -28,22 +28,23 @@ Each report definition declares:
- default ordered module composition - default ordered module composition
Report-owned helpers map public command names and config keys to report IDs. Report-owned helpers map public command names and config keys to report IDs.
The generate command names are `daily`, `tomorrow`, `hourly`, `three-day`, The generate command names are `daily`, `today`, `tomorrow`, `hourly`,
`weekend`, and `storm`. Config keys also accept underscore and legacy `three-day`, `weekend`, and `storm`. Config keys also accept underscore and
descriptive aliases such as `daily_today`, `three_day_outlook`, legacy descriptive aliases such as `daily_today`, `three_day_outlook`,
`weekend_outlook`, and `storm_report`. `weekend_outlook`, and `storm_report`.
Markdown report definitions use the `scriptorium_markdown` generation mode. Markdown report definitions use the `scriptorium_markdown` generation mode.
Their template and structured-text schema identifiers are empty. Tomorrow Their template and structured-text schema identifiers are empty. Today Report,
Report and Hourly Report declare `generated_text_template`; the app uses their Tomorrow Report, and Hourly Report declare `generated_text_template`; the app
template and schema identifiers to validate generated text and render embedded uses their template and schema identifiers to validate generated text and render
Markdown templates. embedded Markdown templates.
## Reports ## Reports
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility | | 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 | | 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 | | 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 | | 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 | | 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 - Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied. 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. - Tomorrow Report covers the next local civil day from generation time.
- Hourly Report covers the half-open six-hour period from generation time in - Hourly Report covers the half-open six-hour period from generation time in
the effective report timezone. The duration is an internal report constant, 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 The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions. Report output path copying uses batch output names from report definitions. Report
module overrides can use short keys such as `tomorrow` and `hourly`, canonical module overrides can use short keys such as `today`, `tomorrow`, and `hourly`,
report IDs such as `daily_today`, or accepted aliases such as canonical report IDs such as `daily_today`, or accepted aliases such as
`three_day_outlook`. `three_day_outlook`.
## Batch Membership ## Batch Membership
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook
on Sunday. Evening batches include Tomorrow Report. Hourly Report is not part except on Sunday. Evening batches include Tomorrow Report. Daily Today and
of a scheduled batch. Hourly Report are not part of a scheduled batch.
## State And App Usage ## State And App Usage

View File

@@ -93,6 +93,8 @@ current report definition.
- Daily Today compares with prior Daily Today snapshots for the same valid - Daily Today compares with prior Daily Today snapshots for the same valid
local date. 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 - Tomorrow Report compares with prior Tomorrow Report snapshots for the same
valid local date. valid local date.
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local - 3-Day Outlook compares with prior 3-Day snapshots for the same valid local

View File

@@ -1106,7 +1106,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
} }
switch reportID { switch reportID {
case report.DailyToday, report.Tomorrow: case report.DailyToday, report.Today, report.Tomorrow:
return changes.CompareDaily(previous, current, thresholds) return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay: case report.ThreeDay:
return changes.CompareThreeDay(previous, current, thresholds) return changes.CompareThreeDay(previous, current, thresholds)

View File

@@ -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) { func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := config.Defaults()
@@ -2072,8 +2159,8 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 { 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) t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
} }
if renderer.runCalls != 2 { if renderer.runCalls != 1 || renderer.structuredRunCalls != 1 {
t.Fatalf("run calls = %d, want successful reports to continue", renderer.runCalls) t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls)
} }
var failedThreeDay bool var failedThreeDay bool
for _, item := range result.Reports { 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."}` 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 { func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
t.Helper() t.Helper()
cfg.Workspace.Root = t.TempDir() 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) { func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
r.structuredRunCalls++ r.structuredRunCalls++
body := validHourlyGeneratedTextJSON() body := validHourlyGeneratedTextJSON()
if req.PromptID == "weather.today_generated_text" {
body = validTodayGeneratedTextJSON()
}
if req.PromptID == "weather.tomorrow_generated_text" { if req.PromptID == "weather.tomorrow_generated_text" {
body = validTomorrowGeneratedTextJSON() body = validTomorrowGeneratedTextJSON()
} }

View File

@@ -185,7 +185,7 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
func variantForReport(id report.ID) string { func variantForReport(id report.ID) string {
switch id { switch id {
case report.DailyToday: case report.DailyToday, report.Today:
return "today" return "today"
case report.Tomorrow: case report.Tomorrow:
return "tomorrow" return "tomorrow"

View File

@@ -196,7 +196,15 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) 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") _ = 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) { 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") { if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr) 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") _ = 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) server := dailyServer(t)
tempDir := t.TempDir() tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir) scriptoriumPath := writeFakeScriptorium(t, tempDir)
@@ -469,16 +477,20 @@ func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) 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 { 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")) threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob 3-day packages: %v", err) t.Fatalf("glob 3-day packages: %v", err)
} }
if len(dailyPackages) != 1 || len(threeDayPackages) != 1 { dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml"))
t.Fatalf("daily packages = %#v, 3-day packages = %#v; want one each", dailyPackages, threeDayPackages) 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 fi
shift shift
done 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 if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
cat > "$out" <<'JSON' cat > "$out" <<'JSON'
{ {
@@ -1163,6 +1189,20 @@ if [ "$1" = "run" ]; then
"precipitation_timing": "A cold front is moving into the region.", "precipitation_timing": "A cold front is moving into the region.",
"confidence": "Medium" "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 JSON
printf 'wrote generated text\n' >&2 printf 'wrote generated text\n' >&2
exit 0 exit 0
@@ -1216,13 +1256,30 @@ if [ "$1" = "render" ]; then
fi fi
if [ "$1" = "run" ]; then if [ "$1" = "run" ]; then
out="" out=""
prompt=""
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
if [ "$1" = "--out" ]; then if [ "$1" = "--out" ]; then
shift shift
out="$1" out="$1"
elif [ "$1" = "--prompt" ]; then
shift
prompt="$1"
fi fi
shift shift
done 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" printf '# Batch Report\n\nGenerated by fake scriptorium.\n' > "$out"
exit 0 exit 0
fi fi

View File

@@ -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) { func TestLoadHourlyReportModuleOverrides(t *testing.T) {
path := writeConfig(t, ` path := writeConfig(t, `
reports: reports:
@@ -286,6 +327,13 @@ func TestValidateReportModuleAliasesDirectly(t *testing.T) {
}, },
wantErr: "duplicates report override", wantErr: "duplicates report override",
}, },
{
name: "TodayAndDailyAreDistinct",
reports: map[string]ReportConfig{
"daily": {},
"today": {},
},
},
{ {
name: "UnknownReport", name: "UnknownReport",
reports: map[string]ReportConfig{ reports: map[string]ReportConfig{
@@ -299,6 +347,12 @@ func TestValidateReportModuleAliasesDirectly(t *testing.T) {
cfg := Defaults() cfg := Defaults()
cfg.Reports = tt.reports cfg.Reports = tt.reports
err := Validate(cfg) err := Validate(cfg)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil { if err == nil {
t.Fatal("Validate() error = nil, want report key error") t.Fatal("Validate() error = nil, want report key error")
} }

View File

@@ -115,7 +115,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
switch req.Resolved.Definition.ID { switch req.Resolved.Definition.ID {
case report.Hourly: 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) summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
if err != nil { if err != nil {
return DerivedFacts{}, err return DerivedFacts{}, err

View File

@@ -7,6 +7,7 @@ import (
const ( const (
CommandNameDaily = "daily" CommandNameDaily = "daily"
CommandNameToday = "today"
CommandNameTomorrow = "tomorrow" CommandNameTomorrow = "tomorrow"
CommandNameHourly = "hourly" CommandNameHourly = "hourly"
CommandNameThreeDay = "three-day" CommandNameThreeDay = "three-day"
@@ -21,6 +22,8 @@ func IDForCommandName(name string) (ID, error) {
switch name { switch name {
case CommandNameDaily: case CommandNameDaily:
return DailyToday, nil return DailyToday, nil
case CommandNameToday:
return Today, nil
case CommandNameTomorrow: case CommandNameTomorrow:
return Tomorrow, nil return Tomorrow, nil
case CommandNameHourly: case CommandNameHourly:
@@ -39,6 +42,7 @@ func IDForCommandName(name string) (ID, error) {
func CommandNames() []string { func CommandNames() []string {
return []string{ return []string{
CommandNameDaily, CommandNameDaily,
CommandNameToday,
CommandNameTomorrow, CommandNameTomorrow,
CommandNameHourly, CommandNameHourly,
CommandNameThreeDay, CommandNameThreeDay,
@@ -52,6 +56,8 @@ func IDForConfigKey(key string) (ID, error) {
switch normalized { switch normalized {
case "daily", "daily_today": case "daily", "daily_today":
return DailyToday, nil return DailyToday, nil
case "today":
return Today, nil
case "tomorrow": case "tomorrow":
return Tomorrow, nil return Tomorrow, nil
case "hourly": case "hourly":

View File

@@ -26,7 +26,7 @@ func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, err
} }
switch batch { switch batch {
case Morning: case Morning:
ids := []ID{DailyToday, ThreeDay} ids := []ID{Today, ThreeDay}
if req.Now.In(req.Location).Weekday() != time.Sunday { if req.Now.In(req.Location).Weekday() != time.Sunday {
ids = append(ids, Weekend) ids = append(ids, Weekend)
} }

View File

@@ -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") 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) { func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
now := mustParse("2026-05-29T20:00:00-05:00") now := mustParse("2026-05-29T20:00:00-05:00")
@@ -201,8 +236,8 @@ func TestMorningBatchSkipsWeekendOnSunday(t *testing.T) {
t.Fatalf("BatchReports() error = %v", err) t.Fatalf("BatchReports() error = %v", err)
} }
ids := resolvedIDs(resolved) ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "daily_today,three_day" { if strings.Join(ids, ",") != "today,three_day" {
t.Fatalf("ids = %v, want daily_today and three_day", ids) t.Fatalf("ids = %v, want today and three_day", ids)
} }
} }
@@ -246,6 +281,7 @@ func TestIDForCommandName(t *testing.T) {
want ID want ID
}{ }{
{name: "daily", want: DailyToday}, {name: "daily", want: DailyToday},
{name: "today", want: Today},
{name: "tomorrow", want: Tomorrow}, {name: "tomorrow", want: Tomorrow},
{name: "hourly", want: Hourly}, {name: "hourly", want: Hourly},
{name: "three-day", want: ThreeDay}, {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) 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"`) { 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) 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", want: DailyToday},
{key: "daily_today", want: DailyToday}, {key: "daily_today", want: DailyToday},
{key: "today", want: Today},
{key: "tomorrow", want: Tomorrow}, {key: "tomorrow", want: Tomorrow},
{key: "hourly", want: Hourly}, {key: "hourly", want: Hourly},
{key: "three_day", want: ThreeDay}, {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`) { 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) 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) { func TestBatchForCommandName(t *testing.T) {
@@ -342,7 +401,7 @@ func TestMorningBatchReportOrder(t *testing.T) {
t.Fatalf("BatchReports() error = %v", err) t.Fatalf("BatchReports() error = %v", err)
} }
ids := resolvedIDs(resolved) 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) t.Fatalf("ids = %v, want morning report order", ids)
} }
} }
@@ -359,7 +418,7 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) { func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
ids := resolvedDefinitionIDs(DefaultRegistry().All()) 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, ",") { if strings.Join(ids, ",") != strings.Join(want, ",") {
t.Fatalf("All() ids = %#v, want %#v", ids, want) t.Fatalf("All() ids = %#v, want %#v", ids, want)
} }
@@ -384,7 +443,7 @@ func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) {
if !definition.Generated { if !definition.Generated {
continue continue
} }
if definition.ID == Hourly || definition.ID == Tomorrow { if definition.ID == Hourly || definition.ID == Today || definition.ID == Tomorrow {
wantTemplate := string(definition.ID) wantTemplate := string(definition.ID)
if definition.GenerationMode != GenerationModeGeneratedTextTemplate { if definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, 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}, compatiblePriorIDs: []ID{DailyToday},
comparisonStrategy: CompareSameValidDate, comparisonStrategy: CompareSameValidDate,
}, },
{
id: Today,
artifactGroup: "today",
batchOutputName: "today.md",
generated: true,
compatiblePriorIDs: []ID{Today},
comparisonStrategy: CompareSameValidDate,
},
{ {
id: Tomorrow, id: Tomorrow,
artifactGroup: "tomorrow", artifactGroup: "tomorrow",
@@ -522,6 +589,25 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.HourlyForecast, 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, id: Tomorrow,
want: []module.ID{ want: []module.ID{

View File

@@ -13,6 +13,7 @@ type Registry struct {
func DefaultRegistry() Registry { func DefaultRegistry() Registry {
definitions := []Definition{ definitions := []Definition{
dailyTodayDefinition(), dailyTodayDefinition(),
todayDefinition(),
tomorrowDefinition(), tomorrowDefinition(),
hourlyDefinition(), hourlyDefinition(),
threeDayDefinition(), threeDayDefinition(),
@@ -74,7 +75,7 @@ func (r Registry) MustLookup(id ID) Definition {
} }
func (r Registry) All() []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)) out := make([]Definition, 0, len(ids))
for _, id := range ids { for _, id := range ids {
if definition, ok := r.definitions[id]; ok { if definition, ok := r.definitions[id]; ok {

View File

@@ -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
}

View File

@@ -63,6 +63,13 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
validDate: "2026-05-30", validDate: "2026-05-30",
runID: "20260529T230000.000000000Z_tomorrow", 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 { 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) { func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00") 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 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 { func resolveThreeDayAt(t *testing.T, value string) report.Resolved {
t.Helper() t.Helper()
location, err := timeutil.LoadLocation("America/Chicago") location, err := timeutil.LoadLocation("America/Chicago")