// Package briefing builds report-specific structured briefing packages. package briefing import ( "fmt" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) const SchemaVersion = "weatherreporter.briefing.v1" type Package struct { Metadata Metadata `json:"metadata"` CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"` Daily *Daily `json:"daily,omitempty"` ThreeDay *ThreeDay `json:"threeDay,omitempty"` Weekend *Weekend `json:"weekend,omitempty"` Storm *Storm `json:"storm,omitempty"` } type Metadata struct { SchemaVersion string `json:"schemaVersion"` RunID string `json:"runId"` ReportID report.ID `json:"reportId"` Variant string `json:"variant,omitempty"` PromptID string `json:"promptId"` GeneratedAt time.Time `json:"generatedAt"` Units string `json:"units"` Timezone string `json:"timezone"` ValidPeriod timeutil.Period `json:"validPeriod"` Location *LocationContext `json:"location,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"` Sources []SourceMetadata `json:"sources,omitempty"` SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` Alerts *AlertStatus `json:"alerts,omitempty"` } type LocationContext struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Region string `json:"region,omitempty"` Timezone string `json:"timezone,omitempty"` } type CurrentConditionsContext struct { ConditionText string `json:"conditionText,omitempty"` IsDay *bool `json:"isDay,omitempty"` TemperatureC *float64 `json:"temperatureC,omitempty"` TemperatureF *float64 `json:"temperatureF,omitempty"` ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"` ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"` DewpointC *float64 `json:"dewpointC,omitempty"` DewpointF *float64 `json:"dewpointF,omitempty"` RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"` WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"` WindSpeedMph *float64 `json:"windSpeedMph,omitempty"` WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"` } type SourceMetadata struct { Name string `json:"name"` Endpoint string `json:"endpoint,omitempty"` FetchedAt time.Time `json:"fetchedAt"` IssuedAt *time.Time `json:"issuedAt,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"` DataSHA256 string `json:"dataSha256,omitempty"` Missing bool `json:"missing,omitempty"` Warnings []forecast.SourceWarning `json:"warnings,omitempty"` } type AlertStatus struct { Checked bool `json:"checked"` ActiveCount int `json:"activeCount"` RelevantCount int `json:"relevantCount"` Missing bool `json:"missing,omitempty"` } type BuildContext struct { Resolved report.Resolved Bundle *forecast.Bundle Units string Timezone string Location *LocationContext } func BuildMetadata(ctx BuildContext) Metadata { metadata := ctx.Resolved.Metadata() sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle) return Metadata{ SchemaVersion: SchemaVersion, RunID: metadata.RunID, ReportID: metadata.ReportID, Variant: variantForReport(metadata.ReportID), PromptID: metadata.PromptID, GeneratedAt: metadata.GeneratedAt, Units: ctx.Units, Timezone: ctx.Timezone, ValidPeriod: metadata.ValidPeriod, Location: copyLocation(ctx.Location), SourceLocationID: sourceLocationID, SourceLocation: sourceLocation, Sources: sourceMetadata(ctx.Bundle), SourceWarnings: sourceWarnings(ctx.Bundle), Alerts: alertStatus(ctx.Bundle), } } func buildPackage(ctx BuildContext) Package { return Package{ Metadata: BuildMetadata(ctx), CurrentConditions: currentConditions(ctx.Bundle), } } func copyLocation(location *LocationContext) *LocationContext { if location == nil { return nil } copied := *location return &copied } func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext { if bundle == nil || bundle.Current == nil { return nil } current := bundle.Current context := CurrentConditionsContext{ ConditionText: current.ConditionText, IsDay: copyBool(current.IsDay), TemperatureC: copyFloat(current.TemperatureC), TemperatureF: copyFloat(current.TemperatureF), ApparentTemperatureC: copyFloat(current.ApparentTemperatureC), ApparentTemperatureF: copyFloat(current.ApparentTemperatureF), DewpointC: copyFloat(current.DewpointC), DewpointF: copyFloat(current.DewpointF), RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent), WindSpeedKmh: copyFloat(current.WindSpeedKmh), WindSpeedMph: copyFloat(current.WindSpeedMph), WindDirectionDegrees: copyFloat(current.WindDirectionDegrees), } if context.ConditionText == "" && context.IsDay == nil && context.TemperatureC == nil && context.TemperatureF == nil && context.ApparentTemperatureC == nil && context.ApparentTemperatureF == nil && context.DewpointC == nil && context.DewpointF == nil && context.RelativeHumidityPercent == nil && context.WindSpeedKmh == nil && context.WindSpeedMph == nil && context.WindDirectionDegrees == nil { return nil } return &context } func copyBool(value *bool) *bool { if value == nil { return nil } copied := *value return &copied } func copyFloat(value *float64) *float64 { if value == nil { return nil } copied := *value return &copied } func Save(path string, pkg Package) error { if err := fileutil.WriteJSONAtomic(path, pkg); err != nil { return fmt.Errorf("save briefing package: %w", err) } return nil } func sourceLocation(bundle *forecast.Bundle) (string, string) { if bundle == nil { return "", "" } for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} { if run == nil { continue } if run.LocationID != "" || run.LocationName != "" { return run.LocationID, run.LocationName } } return "", "" } func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata { if bundle == nil { return nil } out := make([]SourceMetadata, 0, len(bundle.Sources)) for _, source := range bundle.Sources { out = append(out, SourceMetadata{ Name: source.Name, Endpoint: source.Endpoint, FetchedAt: source.FetchedAt, IssuedAt: source.IssuedAt, UpdatedAt: source.UpdatedAt, DataSHA256: source.DataSHA256, Missing: source.Missing, Warnings: source.Warnings, }) } return out } func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning { if bundle == nil { return nil } return bundle.Warnings } func alertStatus(bundle *forecast.Bundle) *AlertStatus { if bundle == nil { return nil } status := &AlertStatus{} if bundle.Alerts != nil { status.Checked = true status.ActiveCount = len(bundle.Alerts.Alerts) } for _, source := range bundle.Sources { if source.Name == "alerts" && source.Missing { status.Missing = true break } } if !status.Checked && !status.Missing { return nil } return status } func setRelevantAlertCount(metadata *Metadata, count int) { if metadata.Alerts == nil { if count == 0 { return } metadata.Alerts = &AlertStatus{} } metadata.Alerts.RelevantCount = count } func variantForReport(id report.ID) string { switch id { case report.DailyToday: return "today" case report.DailyTomorrow: return "tomorrow" default: return "" } }