Add Today report to morning batch
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
51
internal/report/today_report.go
Normal file
51
internal/report/today_report.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user