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

@@ -523,6 +523,39 @@ paths:
items:
$ref: '#/components/schemas/TaskTemplateResponse'
/tasks/templates/by-region/:
get:
tags: [Static Data]
operationId: getTaskTemplatesByRegion
summary: Get task templates for a climate region by state or ZIP code
description: Returns templates matching the climate zone for a given US state abbreviation or ZIP code. At least one parameter is required. If both are provided, state takes priority.
parameters:
- name: state
in: query
required: false
schema:
type: string
example: MA
description: US state abbreviation (e.g., MA, FL, TX)
- name: zip
in: query
required: false
schema:
type: string
example: "02101"
description: US ZIP code (resolved to state on the server)
responses:
'200':
description: Regional templates for the climate zone
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/TaskTemplateResponse'
'400':
$ref: '#/components/responses/BadRequest'
/tasks/templates/{id}/:
get:
tags: [Static Data]

View File

@@ -131,7 +131,9 @@ func Migrate() error {
&models.TaskPriority{},
&models.TaskFrequency{},
&models.ContractorSpecialty{},
&models.TaskTemplate{}, // Task templates reference category and frequency
&models.TaskTemplate{}, // Task templates reference category and frequency
&models.ClimateRegion{}, // IECC climate regions for regional templates
&models.ZipClimateRegion{}, // ZIP to climate region lookup
// User and auth tables
&models.User{},

View File

@@ -21,6 +21,8 @@ type TaskTemplateResponse struct {
Tags []string `json:"tags"`
DisplayOrder int `json:"display_order"`
IsActive bool `json:"is_active"`
RegionID *uint `json:"region_id,omitempty"`
RegionName string `json:"region_name,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -63,6 +65,11 @@ func NewTaskTemplateResponse(t *models.TaskTemplate) TaskTemplateResponse {
resp.Frequency = NewTaskFrequencyResponse(t.Frequency)
}
if len(t.Regions) > 0 {
resp.RegionID = &t.Regions[0].ID
resp.RegionName = t.Regions[0].Name
}
return resp
}

View File

@@ -80,6 +80,24 @@ func (h *TaskTemplateHandler) GetTemplatesByCategory(c echo.Context) error {
return c.JSON(http.StatusOK, templates)
}
// GetTemplatesByRegion handles GET /api/tasks/templates/by-region/?state=XX or ?zip=12345
// Returns templates specific to the user's climate region based on state abbreviation or ZIP code
func (h *TaskTemplateHandler) GetTemplatesByRegion(c echo.Context) error {
state := c.QueryParam("state")
zip := c.QueryParam("zip")
if state == "" && zip == "" {
return apperrors.BadRequest("error.state_or_zip_required")
}
templates, err := h.templateService.GetByRegion(state, zip)
if err != nil {
return err
}
return c.JSON(http.StatusOK, templates)
}
// GetTemplate handles GET /api/tasks/templates/:id/
// Returns a single template by ID
func (h *TaskTemplateHandler) GetTemplate(c echo.Context) error {

View File

@@ -98,6 +98,8 @@ var specEndpointsKMPSkips = map[routeKey]bool{
{Method: "POST", Path: "/notifications/devices/"}: true, // KMP uses /notifications/devices/register/
{Method: "POST", Path: "/notifications/devices/unregister/"}: true, // KMP uses DELETE on device ID
{Method: "PATCH", Path: "/notifications/preferences/"}: true, // KMP uses PUT
// Regional templates — not yet implemented in KMP (planned)
{Method: "GET", Path: "/tasks/templates/by-region/"}: true,
// Stripe web-only and server-to-server endpoints — not implemented in mobile KMP
{Method: "POST", Path: "/subscription/checkout/"}: true, // Web-only (Stripe Checkout)
{Method: "POST", Path: "/subscription/portal/"}: true, // Web-only (Stripe Customer Portal)

View File

@@ -0,0 +1,28 @@
package models
// ClimateRegion represents an IECC climate zone for regional task templates
type ClimateRegion struct {
BaseModel
Name string `gorm:"column:name;size:100;not null;uniqueIndex" json:"name"`
ZoneNumber int `gorm:"column:zone_number;not null;index" json:"zone_number"`
Description string `gorm:"column:description;type:text" json:"description"`
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
}
// TableName returns the table name for GORM
func (ClimateRegion) TableName() string {
return "task_climateregion"
}
// ZipClimateRegion maps ZIP codes to climate regions (static lookup)
type ZipClimateRegion struct {
BaseModel
ZipCode string `gorm:"column:zip_code;size:10;uniqueIndex;not null" json:"zip_code"`
ClimateRegionID uint `gorm:"column:climate_region_id;index;not null" json:"climate_region_id"`
ClimateRegion ClimateRegion `gorm:"foreignKey:ClimateRegionID" json:"climate_region,omitempty"`
}
// TableName returns the table name for GORM
func (ZipClimateRegion) TableName() string {
return "task_zipclimateregion"
}

View File

@@ -13,7 +13,8 @@ type TaskTemplate struct {
IconAndroid string `gorm:"column:icon_android;size:100" json:"icon_android"`
Tags string `gorm:"column:tags;type:text" json:"tags"` // Comma-separated tags for search
DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"`
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
Regions []ClimateRegion `gorm:"many2many:task_tasktemplate_regions;" json:"regions,omitempty"`
}
// TableName returns the table name for GORM

View File

@@ -104,6 +104,20 @@ func (r *TaskTemplateRepository) Count() (int64, error) {
return count, err
}
// GetByRegion returns active templates associated with a specific climate region
func (r *TaskTemplateRepository) GetByRegion(regionID uint) ([]models.TaskTemplate, error) {
var templates []models.TaskTemplate
err := r.db.
Preload("Category").
Preload("Frequency").
Preload("Regions").
Joins("JOIN task_tasktemplate_regions ON task_tasktemplate_regions.task_template_id = task_tasktemplate.id").
Where("task_tasktemplate_regions.climate_region_id = ? AND task_tasktemplate.is_active = ?", regionID, true).
Order("task_tasktemplate.display_order ASC, task_tasktemplate.title ASC").
Find(&templates).Error
return templates, err
}
// GetGroupedByCategory returns templates grouped by category name
func (r *TaskTemplateRepository) GetGroupedByCategory() (map[string][]models.TaskTemplate, error) {
templates, err := r.GetAll()

View File

@@ -339,6 +339,7 @@ func setupPublicDataRoutes(api *echo.Group, residenceHandler *handlers.Residence
templates.GET("/grouped/", taskTemplateHandler.GetTemplatesGrouped)
templates.GET("/search/", taskTemplateHandler.SearchTemplates)
templates.GET("/by-category/:category_id/", taskTemplateHandler.GetTemplatesByCategory)
templates.GET("/by-region/", taskTemplateHandler.GetTemplatesByRegion)
templates.GET("/:id/", taskTemplateHandler.GetTemplate)
}
}

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()

View File

@@ -0,0 +1,21 @@
-- Seed the 8 IECC climate regions for regional task templates
-- Run after 003_task_templates.sql
INSERT INTO task_climateregion (id, created_at, updated_at, name, zone_number, description, is_active)
VALUES
(1, NOW(), NOW(), 'Hot-Humid', 1, 'South Florida, Hawaii, Gulf Coast lowlands', true),
(2, NOW(), NOW(), 'Hot-Dry', 2, 'Arizona, Southern Nevada, West Texas, Southern California deserts', true),
(3, NOW(), NOW(), 'Mixed-Humid', 3, 'Mid-Atlantic, North Carolina, Tennessee, Arkansas', true),
(4, NOW(), NOW(), 'Mixed-Dry', 4, 'Kansas, Missouri, Southern Illinois, Central Virginia', true),
(5, NOW(), NOW(), 'Cold', 5, 'Northern Illinois, Michigan, New York, Southern New England', true),
(6, NOW(), NOW(), 'Very Cold', 6, 'Buffalo NY, Northern Ohio, Northern Wisconsin, Minnesota', true),
(7, NOW(), NOW(), 'Subarctic', 7, 'Upper Peninsula MI, Northern Minnesota, Mountain regions', true),
(8, NOW(), NOW(), 'Arctic', 8, 'Alaska, Far Northern regions', true)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
zone_number = EXCLUDED.zone_number,
description = EXCLUDED.description,
is_active = EXCLUDED.is_active,
updated_at = NOW();
SELECT setval('task_climateregion_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_climateregion), false);

View File

@@ -0,0 +1,486 @@
-- Region-specific task templates
-- Run after 004_climate_regions.sql
-- These extend the existing 66 global templates (IDs 1-66) with region-specific tasks.
-- Existing global templates remain region-agnostic (shown to everyone by default).
--
-- Category IDs (from 001_lookups.sql):
-- 1 = Appliances, 2 = Cleaning, 3 = Electrical, 4 = Exterior
-- 5 = General, 6 = HVAC, 7 = Interior, 8 = Pest Control, 9 = Plumbing, 10 = Safety
--
-- Frequency IDs (from 001_lookups.sql):
-- 1 = Once, 2 = Daily, 3 = Weekly, 4 = Bi-Weekly, 5 = Monthly
-- 6 = Quarterly, 7 = Semi-Annually, 8 = Annually
INSERT INTO task_tasktemplate (id, created_at, updated_at, title, description, category_id, frequency_id, icon_ios, icon_android, tags, display_order, is_active)
VALUES
-- =============================================
-- ZONE 1: Hot-Humid (South FL, Hawaii, Gulf Coast)
-- =============================================
(100, NOW(), NOW(), 'Hurricane Season Prep',
'Install storm shutters, trim deadwood, secure outdoor furniture, photo-document exterior for insurance',
10, 7, 'hurricane', 'Storm', 'hurricane,storm,shutters,prep,tropical', 100, true),
(101, NOW(), NOW(), 'Inspect for Mold & Mildew',
'Check under sinks, attic, crawl space, and bathrooms for mold growth. Address within 24 hours if found',
2, 5, 'humidity.fill', 'WaterDamage', 'mold,mildew,humidity,inspection', 101, true),
(102, NOW(), NOW(), 'Check AC Condensate Drain',
'Clear the AC condensate drain line to prevent water backup and mold growth in humid climate',
6, 5, 'drop.degreesign.fill', 'WaterDamage', 'ac,condensate,drain,humidity', 102, true),
(103, NOW(), NOW(), 'Termite Inspection (Tropical)',
'Professional inspection — tropical climates have year-round termite activity. Check wood structures, attic, crawl space',
10, 7, 'ant.fill', 'BugReport', 'termite,tropical,pest,inspection', 103, true),
(104, NOW(), NOW(), 'Dehumidifier Maintenance',
'Clean filter, check drain, verify indoor humidity stays between 30-50%. Critical in humid climates',
6, 5, 'humidity.fill', 'Air', 'dehumidifier,humidity,filter,maintenance', 104, true),
(105, NOW(), NOW(), 'Storm Shutter Test',
'Test all storm shutters for proper operation. Lubricate tracks, replace damaged hardware',
10, 8, 'shield.lefthalf.filled', 'Shield', 'storm,shutter,hurricane,test', 105, true),
-- =============================================
-- ZONE 2: Hot-Dry (AZ, NV, TX, desert SW)
-- =============================================
(110, NOW(), NOW(), 'Wildfire Defensible Space — Zone 0',
'Clear combustible materials within 5 feet of house. Replace mulch with gravel, remove dead plants, clear roof and gutters of debris',
4, 7, 'flame.fill', 'LocalFireDepartment', 'wildfire,defensible,fire,zone0', 110, true),
(111, NOW(), NOW(), 'Wildfire Defensible Space — Zone 1',
'Within 5-30 feet: space tree canopies 10 ft apart, prune branches 6 ft from ground, clear all dead vegetation',
4, 7, 'flame.fill', 'LocalFireDepartment', 'wildfire,defensible,fire,zone1,trees', 111, true),
(112, NOW(), NOW(), 'Inspect Roof for UV Damage',
'Check shingles for cracks, brittleness, and curling caused by intense sun exposure. Desert sun ages roofs faster',
4, 7, 'sun.max.fill', 'WbSunny', 'roof,uv,sun,shingles,damage', 112, true),
(113, NOW(), NOW(), 'Check for Scorpions & Desert Pests',
'Inspect around exterior lights, under mulch, and entry points for scorpions and spiders. Seal gaps in foundation and doors',
10, 6, 'ant.fill', 'BugReport', 'scorpion,pest,desert,inspection', 113, true),
(114, NOW(), NOW(), 'Dust AC Condenser Unit',
'Clear accumulated dust and sand from outdoor AC unit. Clean condenser fins. Desert environments clog units faster',
6, 5, 'fan', 'AcUnit', 'ac,dust,condenser,desert,sand', 114, true),
(115, NOW(), NOW(), 'Check Foundation for Heat Cracks',
'Inspect foundation for cracks caused by soil expansion and contraction from extreme temperature swings',
4, 7, 'house.circle.fill', 'Foundation', 'foundation,cracks,heat,expansion', 115, true),
(170, NOW(), NOW(), 'Service Evaporative Cooler (Swamp Cooler)',
'Replace pads, clean water reservoir, oil pump and motor bearings. Prepare for cooling season',
6, 8, 'fan.and.light.ceiling.fill', 'AcUnit', 'swamp cooler,evaporative,pads,service', 170, true),
(171, NOW(), NOW(), 'Flush Water Heater (Hard Water)',
'Drain and flush sediment from water heater tank. Desert regions have hard water that accelerates buildup',
9, 7, 'drop.fill', 'WaterDamage', 'water heater,flush,sediment,hard water', 171, true),
(172, NOW(), NOW(), 'Inspect Stucco & Exterior Walls',
'Check stucco or siding for cracks and gaps caused by UV exposure and temperature swings. Patch before monsoon season',
4, 7, 'sun.max.fill', 'WbSunny', 'stucco,exterior,cracks,uv,walls', 172, true),
(173, NOW(), NOW(), 'Clean Solar Panels',
'Remove dust, pollen, and debris from solar panels. Desert dust reduces efficiency by 15-25% if not cleaned regularly',
3, 6, 'sun.max.fill', 'SolarPower', 'solar,panels,clean,dust,efficiency', 173, true),
(174, NOW(), NOW(), 'Check Pool Equipment & Chemistry',
'Inspect pool pump, filter, and chemical levels. High evaporation in hot climates requires more frequent balancing',
4, 5, 'drop.circle.fill', 'Pool', 'pool,pump,filter,chemistry,evaporation', 174, true),
(175, NOW(), NOW(), 'Inspect Irrigation System',
'Check drip irrigation lines and sprinkler heads for leaks, clogs, and UV damage. Adjust watering schedule for season',
4, 6, 'sprinkler.and.droplets.fill', 'Grass', 'irrigation,sprinkler,drip,water,landscape', 175, true),
(176, NOW(), NOW(), 'Replace HVAC Air Filter',
'Replace AC air filter. Desert dust and pollen clog filters faster — change monthly during peak summer',
6, 5, 'aqi.medium', 'Air', 'hvac,filter,air,dust,replacement', 176, true),
(177, NOW(), NOW(), 'Seal Windows & Doors for Heat',
'Check and replace caulk and weather stripping around windows and doors. Prevents cool air loss and dust intrusion',
7, 8, 'wind', 'Air', 'seal,windows,doors,caulk,weatherstrip,heat', 177, true),
(178, NOW(), NOW(), 'Inspect Attic Ventilation',
'Verify attic vents and ridge vents are clear. Proper ventilation prevents attic temps from exceeding 150°F and damaging roof sheathing',
4, 7, 'thermometer.sun.fill', 'Thermostat', 'attic,ventilation,heat,ridge vent', 178, true),
(179, NOW(), NOW(), 'Treat Wood Fence & Deck (UV Protection)',
'Apply UV-resistant stain or sealant to wood fences, decks, and pergolas. Desert sun degrades untreated wood in 1-2 years',
4, 8, 'sun.max.fill', 'Deck', 'fence,deck,wood,uv,stain,seal', 179, true),
(180, NOW(), NOW(), 'Service Garage Door in Extreme Heat',
'Lubricate springs, hinges, and rollers. Heat causes metal expansion — check alignment and weatherseal at bottom',
5, 7, 'door.garage.open', 'Garage', 'garage,door,lubricate,heat,alignment', 180, true),
(181, NOW(), NOW(), 'Test Smoke & CO Detectors',
'Test all smoke and carbon monoxide detectors. Replace batteries. Extreme heat can reduce battery life faster',
10, 7, 'smoke.fill', 'SmokeFree', 'smoke,detector,co,battery,safety', 181, true),
-- =============================================
-- ZONE 1 additional: Hot-Humid extras
-- =============================================
(190, NOW(), NOW(), 'Service Whole-House Dehumidifier',
'Clean evaporator coils, check refrigerant, empty and clean drain pan. Critical for preventing mold in humid climates',
6, 6, 'humidity.fill', 'Air', 'dehumidifier,coils,service,humidity', 190, true),
(191, NOW(), NOW(), 'Check Exterior Paint for Peeling',
'Inspect exterior paint for peeling, bubbling, or mildew. Humidity causes paint failure faster than dry climates',
4, 8, 'paintbrush.fill', 'FormatPaint', 'paint,exterior,peeling,humidity,mildew', 191, true),
(192, NOW(), NOW(), 'Clean Dryer Vent',
'Remove lint buildup from dryer vent duct and exterior flap. Humidity makes lint stick and accumulate faster',
1, 7, 'wind', 'LocalLaundryService', 'dryer,vent,lint,fire,safety', 192, true),
(193, NOW(), NOW(), 'Inspect Siding for Moisture Damage',
'Check wood, vinyl, or fiber cement siding for swelling, warping, or rot. Look behind trim and at ground level',
4, 8, 'house.fill', 'Home', 'siding,moisture,rot,inspection', 193, true),
(194, NOW(), NOW(), 'Flush Water Heater',
'Drain and flush sediment from water heater. Humid climates accelerate mineral buildup',
9, 8, 'drop.fill', 'WaterDamage', 'water heater,flush,sediment,maintenance', 194, true),
(195, NOW(), NOW(), 'Service Pool & Deck (Algae Prevention)',
'Shock-treat pool, scrub deck for algae growth. Warm humid conditions cause rapid algae blooms',
4, 5, 'drop.circle.fill', 'Pool', 'pool,deck,algae,shock,humidity', 195, true),
(196, NOW(), NOW(), 'Inspect Attic for Moisture & Ventilation',
'Check attic for moisture, ensure soffit vents and ridge vents are clear. Look for mold on roof sheathing',
4, 7, 'humidity.fill', 'Roofing', 'attic,moisture,ventilation,mold', 196, true),
(197, NOW(), NOW(), 'Test Sump Pump',
'Test sump pump operation and check discharge line. Heavy tropical rains can overwhelm drainage',
9, 6, 'drop.triangle.fill', 'WaterDamage', 'sump pump,test,flood,drainage', 197, true),
-- =============================================
-- ZONE 3 additional: Mixed-Humid extras
-- =============================================
(200, NOW(), NOW(), 'Clean Dryer Vent',
'Remove lint buildup from dryer vent and exterior flap. Reduce fire risk and improve drying efficiency',
1, 7, 'wind', 'LocalLaundryService', 'dryer,vent,lint,fire,safety', 200, true),
(201, NOW(), NOW(), 'Service Lawn Mower for Season',
'Change oil, replace spark plug, sharpen blade, clean air filter. Prepare for growing season',
5, 8, 'leaf.fill', 'Grass', 'lawn,mower,service,season,maintenance', 201, true),
(202, NOW(), NOW(), 'Check Siding & Trim for Rot',
'Inspect wood siding, fascia, and trim for soft spots and rot. Repair before rainy season',
4, 8, 'house.fill', 'Home', 'siding,trim,rot,wood,repair', 202, true),
(203, NOW(), NOW(), 'Replace HVAC Air Filter',
'Replace furnace/AC air filter. Changed climate means both heating and cooling seasons — replace more often',
6, 6, 'aqi.medium', 'Air', 'hvac,filter,air,replacement', 203, true),
(204, NOW(), NOW(), 'Flush Water Heater',
'Drain and flush sediment from water heater tank. Prevents efficiency loss and extends lifespan',
9, 8, 'drop.fill', 'WaterDamage', 'water heater,flush,sediment,maintenance', 204, true),
(205, NOW(), NOW(), 'Aerate & Overseed Lawn',
'Core aerate lawn and overseed bare patches in early fall. Best recovery window for mixed-humid lawns',
4, 8, 'leaf.fill', 'Grass', 'aerate,overseed,lawn,fall', 205, true),
(206, NOW(), NOW(), 'Test Smoke & CO Detectors',
'Test all smoke and carbon monoxide detectors. Replace batteries annually or when chirping',
10, 7, 'smoke.fill', 'SmokeFree', 'smoke,detector,co,battery,safety', 206, true),
-- =============================================
-- ZONE 4 additional: Mixed-Dry extras
-- =============================================
(210, NOW(), NOW(), 'Replace HVAC Air Filter',
'Replace furnace/AC air filter. Dusty dry conditions and seasonal heating/cooling require frequent changes',
6, 6, 'aqi.medium', 'Air', 'hvac,filter,air,dust,replacement', 210, true),
(211, NOW(), NOW(), 'Clean Dryer Vent',
'Remove lint buildup from dryer vent and exterior flap. Fire prevention and efficiency',
1, 7, 'wind', 'LocalLaundryService', 'dryer,vent,lint,fire,safety', 211, true),
(212, NOW(), NOW(), 'Flush Water Heater',
'Drain and flush sediment from water heater. Hard water common in dry regions',
9, 8, 'drop.fill', 'WaterDamage', 'water heater,flush,sediment,hard water', 212, true),
(213, NOW(), NOW(), 'Service Lawn & Irrigation',
'Check irrigation heads, adjust watering schedule for season, reseed bare patches',
4, 6, 'sprinkler.and.droplets.fill', 'Grass', 'irrigation,lawn,water,sprinkler', 213, true),
(214, NOW(), NOW(), 'Test Smoke & CO Detectors',
'Test all smoke and carbon monoxide detectors. Replace batteries',
10, 7, 'smoke.fill', 'SmokeFree', 'smoke,detector,co,battery,safety', 214, true),
(215, NOW(), NOW(), 'Clean Gutters & Downspouts',
'Clear leaves and debris from gutters. Ensure downspouts direct water 4-6 ft from foundation',
4, 7, 'cloud.rain.fill', 'Water', 'gutters,downspouts,clean,drainage', 215, true),
-- =============================================
-- ZONE 5 additional: Cold extras
-- =============================================
(220, NOW(), NOW(), 'Replace HVAC Air Filter',
'Replace furnace air filter. Long heating season means more frequent changes',
6, 6, 'aqi.medium', 'Air', 'hvac,filter,air,furnace,replacement', 220, true),
(221, NOW(), NOW(), 'Clean Dryer Vent',
'Remove lint buildup from dryer vent. Winter increases dryer use — clean more often',
1, 7, 'wind', 'LocalLaundryService', 'dryer,vent,lint,fire,safety', 221, true),
(222, NOW(), NOW(), 'Flush Water Heater',
'Drain and flush sediment from water heater tank',
9, 8, 'drop.fill', 'WaterDamage', 'water heater,flush,sediment', 222, true),
(223, NOW(), NOW(), 'Test Smoke & CO Detectors',
'Test all smoke and carbon monoxide detectors. Critical during heating season with furnace and fireplace use',
10, 7, 'smoke.fill', 'SmokeFree', 'smoke,detector,co,battery,safety,heating', 223, true),
(224, NOW(), NOW(), 'Inspect & Clean Chimney',
'Professional chimney sweep and inspection before heating season. Check for creosote buildup and flue damage',
10, 8, 'flame.fill', 'Fireplace', 'chimney,sweep,creosote,flue,fireplace', 224, true),
-- =============================================
-- ZONE 6 additional: Very Cold extras
-- =============================================
(230, NOW(), NOW(), 'Flush Water Heater',
'Drain and flush sediment from water heater. Extended heating season means heater works harder',
9, 8, 'drop.fill', 'WaterDamage', 'water heater,flush,sediment', 230, true),
(231, NOW(), NOW(), 'Test Smoke & CO Detectors',
'Test all detectors monthly during heating season. Furnace and fireplace use increases CO risk',
10, 5, 'smoke.fill', 'SmokeFree', 'smoke,detector,co,battery,safety,heating', 231, true),
(232, NOW(), NOW(), 'Replace HVAC Air Filter',
'Replace furnace filter. Extended heating season requires monthly changes November through April',
6, 5, 'aqi.medium', 'Air', 'hvac,filter,air,furnace,winter', 232, true),
(233, NOW(), NOW(), 'Inspect & Clean Chimney',
'Professional sweep before heating season. Heavy use demands annual inspection for creosote and cracks',
10, 8, 'flame.fill', 'Fireplace', 'chimney,sweep,creosote,flue', 233, true),
-- =============================================
-- ZONE 3: Mixed-Humid (Mid-Atlantic, mid-South)
-- =============================================
(120, NOW(), NOW(), 'Blow Out Irrigation System',
'Drain and blow out sprinkler lines before first frost to prevent pipe freeze damage',
4, 8, 'sprinkler.and.droplets.fill', 'Grass', 'irrigation,winterize,sprinkler,frost', 120, true),
(121, NOW(), NOW(), 'Insulate Exposed Pipes',
'Add pipe insulation to exposed pipes under sinks, in crawl spaces, and in unheated areas before winter',
9, 8, 'snowflake', 'AcUnit', 'pipes,insulation,freeze,crawlspace', 121, true),
(122, NOW(), NOW(), 'Check Foundation for Moisture',
'Inspect basement or crawl space for standing water, moisture, and cracks after heavy rain events',
4, 6, 'house.circle.fill', 'Foundation', 'foundation,moisture,basement,cracks', 122, true),
(123, NOW(), NOW(), 'Test Radon Level',
'Use radon test kit or hire professional. Common concern in Mid-Atlantic region basements',
10, 8, 'aqi.medium', 'Air', 'radon,test,safety,basement', 123, true),
(124, NOW(), NOW(), 'Spring Flood Assessment',
'After spring thaw, check for water damage, inspect foundation for standing water, clean and extend downspouts 4-6 ft from foundation',
4, 8, 'cloud.rain.fill', 'Water', 'flood,spring,foundation,downspout', 124, true),
-- =============================================
-- ZONE 4: Mixed (Upper South, lower Midwest)
-- =============================================
(130, NOW(), NOW(), 'Winterize Exterior Faucets & Hoses',
'Disconnect all garden hoses, shut off water supply to exterior faucets, and drain the lines completely',
9, 8, 'snowflake', 'AcUnit', 'winterize,faucet,hose,freeze', 130, true),
(131, NOW(), NOW(), 'Check Attic Insulation Level',
'Verify attic insulation is R-38 or higher. Add insulation if below threshold for energy efficiency and freeze prevention',
7, 8, 'thermometer.snowflake', 'Thermostat', 'insulation,attic,r-value,energy', 131, true),
(132, NOW(), NOW(), 'Inspect Weather Stripping & Caulk',
'Check all exterior doors and windows for worn seals. Replace weather stripping and re-caulk gaps to prevent drafts',
4, 8, 'wind', 'Air', 'weatherstrip,caulk,seal,draft', 132, true),
(133, NOW(), NOW(), 'Seal Foundation Cracks',
'Inspect foundation for cracks and seal with hydraulic cement. Prevents rodent entry and water infiltration',
4, 8, 'house.circle.fill', 'Foundation', 'foundation,cracks,seal,rodent', 133, true),
(134, NOW(), NOW(), 'Test Heating System Before Winter',
'Run heating system before peak demand. Check for strange noises, smells, or uneven heating. Schedule service if needed',
6, 8, 'flame.fill', 'LocalFireDepartment', 'heating,furnace,test,winter', 134, true),
-- =============================================
-- ZONE 5: Cold (Northeast, Great Lakes)
-- =============================================
(140, NOW(), NOW(), 'Roof Snow Raking',
'Rake snow from roof within 24 hours of 12+ inch accumulation to prevent ice dams and structural stress',
4, 3, 'snowflake', 'AcUnit', 'snow,roof,rake,ice dam,winter', 140, true),
(141, NOW(), NOW(), 'Ice Dam Prevention Check',
'Inspect for ice dams forming at roof edge. Apply de-ice products if forming. Check attic ventilation is adequate',
4, 3, 'snowflake.circle.fill', 'AcUnit', 'ice dam,roof,prevention,winter', 141, true),
(142, NOW(), NOW(), 'Test Sump Pump & Backup',
'Test sump pump operation. Verify battery backup is charged. Check discharge line is clear of ice and debris',
9, 6, 'drop.triangle.fill', 'WaterDamage', 'sump pump,backup,basement,flood', 142, true),
(143, NOW(), NOW(), 'Winter Gutter Maintenance',
'Clear gutters of ice and snow buildup 2-3x weekly during snow season. Check downspout extensions are clear',
4, 3, 'cloud.snow.fill', 'AcUnit', 'gutter,ice,snow,winter,maintenance', 143, true),
(144, NOW(), NOW(), 'Check Foundation for Frost Heave',
'Inspect basement and foundation for new cracks, settling, or water intrusion caused by freeze-thaw cycles',
4, 8, 'house.circle.fill', 'Foundation', 'foundation,frost heave,cracks,settling', 144, true),
(145, NOW(), NOW(), 'Winterize Entire Plumbing System',
'Insulate all exterior pipes, disconnect hoses, shut off exterior water valves, open cabinet doors under sinks for warm air circulation',
9, 8, 'snowflake', 'AcUnit', 'winterize,plumbing,pipes,insulate', 145, true),
-- =============================================
-- ZONE 6: Very Cold (Upstate NY, northern Midwest)
-- =============================================
(150, NOW(), NOW(), 'Install/Check Heated Gutter System',
'Verify heated gutter guards are operational before winter. Test heating elements and check for damage from previous season',
4, 8, 'bolt.horizontal.fill', 'ElectricalServices', 'heated gutter,ice dam,winter,prevention', 150, true),
(151, NOW(), NOW(), 'Verify Pipe Routing (Interior Walls)',
'Confirm all water pipes run through heated interior walls. Plan to relocate any exterior-wall pipes before winter',
9, 8, 'wrench.and.screwdriver.fill', 'Build', 'pipes,interior,freeze,prevention', 151, true),
(152, NOW(), NOW(), 'Heavy Snow Roof Raking',
'Mandatory after every 8+ inches of snow. Critical for preventing structural stress, ice dams, and roof collapse',
4, 3, 'snowflake', 'AcUnit', 'snow,roof,rake,heavy,structural', 152, true),
(153, NOW(), NOW(), 'Weekly Ice Dam & Gutter Inspection',
'During winter months, inspect gutters and roof edge weekly for ice dam formation. Apply de-icing as needed',
4, 3, 'eye.fill', 'Visibility', 'ice dam,gutter,weekly,inspection,winter', 153, true),
-- =============================================
-- ZONE 7-8: Subarctic / Arctic
-- =============================================
(160, NOW(), NOW(), 'Test Backup Heating System',
'Test backup/secondary heating system monthly. Dual furnaces are standard in extreme climates — both must be operational',
6, 5, 'flame.fill', 'LocalFireDepartment', 'backup,heating,furnace,test', 160, true),
(161, NOW(), NOW(), 'Maintain Basement Above 55°F',
'Verify basement auxiliary heater maintains minimum 55°F at all times. Prevents pipe freeze and foundation damage',
6, 5, 'thermometer.low', 'Thermostat', 'basement,heating,minimum,temperature', 161, true),
(162, NOW(), NOW(), 'Inspect Heat-Traced Pipes',
'Check heat tape on any exposed or vulnerable pipes. Replace damaged sections. Verify thermostat operation',
9, 6, 'bolt.horizontal.fill', 'ElectricalServices', 'heat trace,pipes,tape,freeze', 162, true),
(163, NOW(), NOW(), 'Verify Roof Structural Integrity for Snow Load',
'Before winter, verify roof pitch supports heavy snow load (6:12 minimum). Check for sagging or previous damage',
4, 8, 'house.fill', 'Roofing', 'roof,structure,snow load,pitch', 163, true)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
category_id = EXCLUDED.category_id,
frequency_id = EXCLUDED.frequency_id,
icon_ios = EXCLUDED.icon_ios,
icon_android = EXCLUDED.icon_android,
tags = EXCLUDED.tags,
display_order = EXCLUDED.display_order,
is_active = EXCLUDED.is_active,
updated_at = NOW();
-- =============================================
-- Associate templates with regions via join table
-- =============================================
-- Zone 1: Hot-Humid templates
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(100, 1), (101, 1), (102, 1), (103, 1), (104, 1), (105, 1),
(190, 1), (191, 1), (192, 1), (193, 1), (194, 1), (195, 1), (196, 1), (197, 1)
ON CONFLICT DO NOTHING;
-- Zone 2: Hot-Dry templates
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(110, 2), (111, 2), (112, 2), (113, 2), (114, 2), (115, 2),
(170, 2), (171, 2), (172, 2), (173, 2), (174, 2), (175, 2), (176, 2), (177, 2), (178, 2), (179, 2), (180, 2), (181, 2)
ON CONFLICT DO NOTHING;
-- Zone 3: Mixed-Humid templates
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(120, 3), (121, 3), (122, 3), (123, 3), (124, 3),
(200, 3), (201, 3), (202, 3), (203, 3), (204, 3), (205, 3), (206, 3)
ON CONFLICT DO NOTHING;
-- Zone 4: Mixed templates
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(130, 4), (131, 4), (132, 4), (133, 4), (134, 4),
(210, 4), (211, 4), (212, 4), (213, 4), (214, 4), (215, 4)
ON CONFLICT DO NOTHING;
-- Zone 5: Cold templates
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(140, 5), (141, 5), (142, 5), (143, 5), (144, 5), (145, 5),
(220, 5), (221, 5), (222, 5), (223, 5), (224, 5)
ON CONFLICT DO NOTHING;
-- Zone 6: Very Cold templates (own + inherit zone 5 cold-weather tasks)
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(150, 6), (151, 6), (152, 6), (153, 6),
(230, 6), (231, 6), (232, 6), (233, 6),
(140, 6), (141, 6), (142, 6), (143, 6), (144, 6), (145, 6),
(220, 6), (221, 6), (222, 6), (223, 6), (224, 6)
ON CONFLICT DO NOTHING;
-- Zone 7: Subarctic (own + inherit zone 5 + 6 tasks)
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(160, 7), (161, 7), (162, 7), (163, 7),
(150, 7), (151, 7), (152, 7), (153, 7),
(230, 7), (231, 7), (232, 7), (233, 7),
(140, 7), (141, 7), (142, 7), (143, 7), (144, 7), (145, 7),
(220, 7), (221, 7), (222, 7), (223, 7), (224, 7)
ON CONFLICT DO NOTHING;
-- Zone 8: Arctic (own + inherit zone 5 + 6 + 7 tasks)
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(160, 8), (161, 8), (162, 8), (163, 8),
(150, 8), (151, 8), (152, 8), (153, 8),
(230, 8), (231, 8), (232, 8), (233, 8),
(140, 8), (141, 8), (142, 8), (143, 8), (144, 8), (145, 8),
(220, 8), (221, 8), (222, 8), (223, 8), (224, 8)
ON CONFLICT DO NOTHING;
-- =============================================
-- Cross-tag existing global templates with regions where especially relevant
-- =============================================
-- "Winterize Outdoor Faucets" (ID 13) — Zones 3-8 (freeze risk)
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(13, 3), (13, 4), (13, 5), (13, 6), (13, 7), (13, 8)
ON CONFLICT DO NOTHING;
-- "Clean Gutters" (ID 57) — All zones (universal)
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(57, 1), (57, 2), (57, 3), (57, 4), (57, 5), (57, 6), (57, 7), (57, 8)
ON CONFLICT DO NOTHING;
-- "Inspect Roof" (ID 59) — All zones (universal)
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(59, 1), (59, 2), (59, 3), (59, 4), (59, 5), (59, 6), (59, 7), (59, 8)
ON CONFLICT DO NOTHING;
-- "Termite Inspection" (ID 23) — Zones 1-3 (warm/humid)
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(23, 1), (23, 2), (23, 3)
ON CONFLICT DO NOTHING;
-- "Pest Control Treatment" (ID 24) — All zones
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(24, 1), (24, 2), (24, 3), (24, 4), (24, 5), (24, 6), (24, 7), (24, 8)
ON CONFLICT DO NOTHING;
-- "Schedule Chimney Cleaning" (ID 22) — Zones 4-8 (heating climates)
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(22, 4), (22, 5), (22, 6), (22, 7), (22, 8)
ON CONFLICT DO NOTHING;
-- "Service Sprinkler System" (ID 64) — Zones 3-6 (freeze winterization)
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(64, 3), (64, 4), (64, 5), (64, 6)
ON CONFLICT DO NOTHING;
-- "Inspect Weather Stripping" (ID 61) — Zones 4-8 (cold/energy efficiency)
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(61, 4), (61, 5), (61, 6), (61, 7), (61, 8)
ON CONFLICT DO NOTHING;
-- "Clean and Reverse Ceiling Fans" (ID 56) — All zones (seasonal direction change)
INSERT INTO task_tasktemplate_regions (task_template_id, climate_region_id) VALUES
(56, 1), (56, 2), (56, 3), (56, 4), (56, 5), (56, 6), (56, 7), (56, 8)
ON CONFLICT DO NOTHING;
-- Reset template sequence
SELECT setval('task_tasktemplate_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_tasktemplate), false);