Add regional task templates API with climate zone lookup

Adds a new endpoint GET /api/tasks/templates/by-region/?zip= that resolves
ZIP codes to IECC climate regions and returns relevant home maintenance
task templates. Includes climate region model, region lookup service with
tests, seed data for all 8 climate zones with 50+ templates, and OpenAPI spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-05 15:15:30 -06:00
parent 72db9050f8
commit 793e50ce52
14 changed files with 873 additions and 2 deletions

View File

@@ -0,0 +1,157 @@
package services
import (
"strconv"
"strings"
)
// StateToClimateRegion maps US state abbreviations to IECC climate zone IDs.
// Some states span multiple zones — this uses the dominant zone for the most populated areas.
var StateToClimateRegion = map[string]uint{
// Zone 1: Hot-Humid
"HI": 1, "FL": 1, "LA": 1,
// Zone 2: Hot-Dry / Hot-Humid mix
"TX": 2, "AZ": 2, "NV": 2, "NM": 2,
// Zone 3: Mixed-Humid
"GA": 3, "SC": 3, "AL": 3, "MS": 3, "AR": 3, "NC": 3, "TN": 3, "OK": 3, "CA": 3,
// Zone 4: Mixed
"VA": 4, "KY": 4, "MO": 4, "KS": 4, "DE": 4, "MD": 4, "DC": 4, "WV": 4, "OR": 4,
// Zone 5: Cold
"NJ": 5, "PA": 5, "CT": 5, "RI": 5, "MA": 5, "OH": 5, "IN": 5, "IL": 5,
"IA": 5, "NE": 5, "CO": 5, "UT": 5, "WA": 5, "ID": 5, "NY": 5, "MI": 5,
// Zone 6: Very Cold
"WI": 6, "MN": 6, "ND": 6, "SD": 6, "MT": 6, "WY": 6, "VT": 6, "NH": 6, "ME": 6,
// Zone 8: Arctic
"AK": 8,
}
// GetClimateRegionIDByState returns the climate region ID for a US state abbreviation.
// Returns 0 if the state is not found.
func GetClimateRegionIDByState(state string) uint {
regionID, ok := StateToClimateRegion[strings.ToUpper(strings.TrimSpace(state))]
if !ok {
return 0
}
return regionID
}
// ZipToState maps a US ZIP code to a state abbreviation using the 3-digit ZIP prefix.
// Returns empty string if the ZIP is invalid or unrecognized.
func ZipToState(zip string) string {
zip = strings.TrimSpace(zip)
if len(zip) < 3 {
return ""
}
prefix, err := strconv.Atoi(zip[:3])
if err != nil {
return ""
}
// ZIP prefix → state mapping (USPS ranges)
switch {
case prefix >= 10 && prefix <= 27:
return "MA"
case prefix >= 28 && prefix <= 29:
return "RI"
case prefix >= 30 && prefix <= 38:
return "NH"
case prefix >= 39 && prefix <= 49:
return "ME"
case prefix >= 50 && prefix <= 59:
return "VT"
case prefix >= 60 && prefix <= 69:
return "CT"
case prefix >= 70 && prefix <= 89:
return "NJ"
case prefix >= 100 && prefix <= 149:
return "NY"
case prefix >= 150 && prefix <= 196:
return "PA"
case prefix >= 197 && prefix <= 199:
return "DE"
case prefix >= 200 && prefix <= 205:
return "DC"
case prefix >= 206 && prefix <= 219:
return "MD"
case prefix >= 220 && prefix <= 246:
return "VA"
case prefix >= 247 && prefix <= 268:
return "WV"
case prefix >= 270 && prefix <= 289:
return "NC"
case prefix >= 290 && prefix <= 299:
return "SC"
case prefix >= 300 && prefix <= 319:
return "GA"
case prefix >= 320 && prefix <= 349:
return "FL"
case prefix >= 350 && prefix <= 369:
return "AL"
case prefix >= 370 && prefix <= 385:
return "TN"
case prefix >= 386 && prefix <= 397:
return "MS"
case prefix >= 400 && prefix <= 427:
return "KY"
case prefix >= 430 && prefix <= 458:
return "OH"
case prefix >= 460 && prefix <= 479:
return "IN"
case prefix >= 480 && prefix <= 499:
return "MI"
case prefix >= 500 && prefix <= 528:
return "IA"
case prefix >= 530 && prefix <= 549:
return "WI"
case prefix >= 550 && prefix <= 567:
return "MN"
case prefix >= 570 && prefix <= 577:
return "SD"
case prefix >= 580 && prefix <= 588:
return "ND"
case prefix >= 590 && prefix <= 599:
return "MT"
case prefix >= 600 && prefix <= 629:
return "IL"
case prefix >= 630 && prefix <= 658:
return "MO"
case prefix >= 660 && prefix <= 679:
return "KS"
case prefix >= 680 && prefix <= 693:
return "NE"
case prefix >= 700 && prefix <= 714:
return "LA"
case prefix >= 716 && prefix <= 729:
return "AR"
case prefix >= 730 && prefix <= 749:
return "OK"
case prefix >= 750 && prefix <= 799:
return "TX"
case prefix >= 800 && prefix <= 816:
return "CO"
case prefix >= 820 && prefix <= 831:
return "WY"
case prefix >= 832 && prefix <= 838:
return "ID"
case prefix >= 840 && prefix <= 847:
return "UT"
case prefix >= 850 && prefix <= 865:
return "AZ"
case prefix >= 870 && prefix <= 884:
return "NM"
case prefix >= 889 && prefix <= 898:
return "NV"
case prefix >= 900 && prefix <= 966:
return "CA"
case prefix >= 967 && prefix <= 968:
return "HI"
case prefix >= 970 && prefix <= 979:
return "OR"
case prefix >= 980 && prefix <= 994:
return "WA"
case prefix >= 995 && prefix <= 999:
return "AK"
default:
return ""
}
}

View File

@@ -0,0 +1,81 @@
package services
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetClimateRegionIDByState(t *testing.T) {
tests := []struct {
name string
state string
expected uint
}{
{"Massachusetts → Cold (5)", "MA", 5},
{"Florida → Hot-Humid (1)", "FL", 1},
{"Arizona → Hot-Dry (2)", "AZ", 2},
{"Alaska → Arctic (8)", "AK", 8},
{"Minnesota → Very Cold (6)", "MN", 6},
{"Tennessee → Mixed-Humid (3)", "TN", 3},
{"Missouri → Mixed (4)", "MO", 4},
{"Unknown state → 0", "XX", 0},
{"Empty string → 0", "", 0},
{"Lowercase input", "ma", 5},
{"Whitespace trimmed", " TX ", 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetClimateRegionIDByState(tt.state)
assert.Equal(t, tt.expected, result)
})
}
}
func TestZipToState(t *testing.T) {
tests := []struct {
name string
zip string
expected string
}{
{"Boston MA", "02101", "MA"},
{"Miami FL", "33101", "FL"},
{"Phoenix AZ", "85001", "AZ"},
{"Anchorage AK", "99501", "AK"},
{"New York NY", "10001", "NY"},
{"Chicago IL", "60601", "IL"},
{"Houston TX", "77001", "TX"},
{"Denver CO", "80201", "CO"},
{"DC", "20001", "DC"},
{"Too short", "12", ""},
{"Empty", "", ""},
{"Non-numeric", "abcde", ""},
{"Unrecognized prefix", "00100", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ZipToState(tt.zip)
assert.Equal(t, tt.expected, result)
})
}
}
func TestStateToClimateRegion_AllStatesPresent(t *testing.T) {
// All 50 states + DC should be in the map
allStates := []string{
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA",
"HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD",
"MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC",
"SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY",
"DC",
}
for _, state := range allStates {
t.Run(state, func(t *testing.T) {
regionID := GetClimateRegionIDByState(state)
assert.Greater(t, regionID, uint(0), "State %s should map to a region", state)
assert.LessOrEqual(t, regionID, uint(8), "State %s region should be 1-8, got %d", state, regionID)
})
}
}

View File

@@ -63,6 +63,26 @@ func (s *TaskTemplateService) GetByID(id uint) (*responses.TaskTemplateResponse,
return &resp, nil
}
// GetByRegion returns templates for a specific climate region.
// Accepts either a state abbreviation or ZIP code (state takes priority).
// ZIP codes are resolved to a state via the ZipToState lookup.
func (s *TaskTemplateService) GetByRegion(state, zip string) ([]responses.TaskTemplateResponse, error) {
// Resolve ZIP to state if no state provided
if state == "" && zip != "" {
state = ZipToState(zip)
}
regionID := GetClimateRegionIDByState(state)
if regionID == 0 {
return []responses.TaskTemplateResponse{}, nil
}
templates, err := s.templateRepo.GetByRegion(regionID)
if err != nil {
return nil, err
}
return responses.NewTaskTemplateListResponse(templates), nil
}
// Count returns the total count of active templates
func (s *TaskTemplateService) Count() (int64, error) {
return s.templateRepo.Count()