ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Gettimefromstore (#244)

* initial codex run

* go mod tidy

* use store location in mail

* error don't warn

* need zip code for email

authored by

Paul Miller and committed by
GitHub
6dca4d8b 243b5d5b

+209 -19
+6 -1
cmd/careme/mail.go
··· 118 118 return 119 119 } 120 120 121 - p := recipes.DefaultParams(l, time.Now().Add(-6*time.Hour)) // how do we get the timezone of the user? 121 + date, err := recipes.StoreToDate(ctx, time.Now(), l) 122 + if err != nil { 123 + slog.ErrorContext(ctx, "error getting location timezone", "location", user.FavoriteStore, "error", err.Error()) 124 + return 125 + } 126 + p := recipes.DefaultParams(l, date) 122 127 // p.UserID = user.ID 123 128 for _, last := range user.LastRecipes { 124 129 if last.CreatedAt.Before(time.Now().AddDate(0, 0, -14)) {
+1 -1
cmd/careme/web_e2e_test.go
··· 222 222 m := &mailer{ 223 223 cache: fc, 224 224 locServer: &fakeMailLocServer{ 225 - location: &locations.Location{ID: "123", Name: "Test Store", Address: "123 Test St"}, 225 + location: &locations.Location{ID: "123", Name: "Test Store", Address: "123 Test St", ZipCode: "98005"}, 226 226 }, 227 227 client: &fakeMailClient{ 228 228 response: &rest.Response{StatusCode: 202, Body: "accepted"},
+2 -2
go.mod
··· 7 7 require github.com/samber/lo v1.51.0 8 8 9 9 require ( 10 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 10 11 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 11 12 github.com/alpkeskin/gotoon v0.1.1 13 + github.com/clerk/clerk-sdk-go/v2 v2.5.0 12 14 github.com/invopop/jsonschema v0.13.0 13 15 github.com/openai/openai-go/v3 v3.21.0 14 16 github.com/samber/slog-multi v1.5.0 ··· 18 20 ) 19 21 20 22 require ( 21 - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 // indirect 22 23 github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect 23 24 github.com/bahlo/generic-list-go v0.2.0 // indirect 24 25 github.com/buger/jsonparser v1.1.1 // indirect 25 - github.com/clerk/clerk-sdk-go/v2 v2.5.0 // indirect 26 26 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 27 27 github.com/samber/slog-common v0.19.0 // indirect 28 28 github.com/stretchr/testify v1.11.1 // indirect
-2
go.sum
··· 109 109 github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= 110 110 github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= 111 111 github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= 112 - github.com/openai/openai-go/v3 v3.14.0 h1:3wB4dbYslrUl8PE2OPFUxAkFEYn55yvY65wClt4gSbY= 113 - github.com/openai/openai-go/v3 v3.14.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= 114 112 github.com/openai/openai-go/v3 v3.21.0 h1:3GpIR/W4q/v1uUOVuK3zYtQiF3DnRrZag/sxbtvEdtc= 115 113 github.com/openai/openai-go/v3 v3.21.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= 116 114 github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
+34 -6
internal/locations/locations.go
··· 84 84 return nil, fmt.Errorf("no data found for location ID %s", locationID) 85 85 } 86 86 87 + data := resp.JSON200.Data 88 + address := "" 89 + state := "" 90 + zipCode := "" 91 + if data.Address != nil { 92 + address = stringValue(data.Address.AddressLine1) 93 + state = stringValue(data.Address.State) 94 + zipCode = stringValue(data.Address.ZipCode) 95 + } 87 96 loc := Location{ 88 97 ID: locationID, 89 - Name: *resp.JSON200.Data.Name, 90 - Address: *resp.JSON200.Data.Address.AddressLine1, 98 + Name: stringValue(data.Name), 99 + Address: address, 100 + State: state, 101 + ZipCode: zipCode, 91 102 } 92 103 l.cacheLock.Lock() 93 104 defer l.cacheLock.Unlock() ··· 100 111 Name string `json:"name"` 101 112 Address string `json:"address"` 102 113 State string `json:"state"` 114 + ZipCode string `json:"zip_code"` 103 115 } 104 116 105 117 func (l *locationStorage) GetLocationsByZip(ctx context.Context, zipcode string) ([]Location, error) { ··· 119 131 l.cacheLock.Lock() 120 132 defer l.cacheLock.Unlock() 121 133 for _, loc := range *resp.JSON200.Data { 134 + address := "" 135 + state := "" 136 + zipCode := "" 137 + if loc.Address != nil { 138 + address = stringValue(loc.Address.AddressLine1) 139 + state = stringValue(loc.Address.State) 140 + zipCode = stringValue(loc.Address.ZipCode) 141 + } 122 142 loc := Location{ 123 - ID: *loc.LocationId, 124 - Name: *loc.Name, 125 - Address: *loc.Address.AddressLine1, 126 - State: *loc.Address.State, 143 + ID: stringValue(loc.LocationId), 144 + Name: stringValue(loc.Name), 145 + Address: address, 146 + State: state, 147 + ZipCode: zipCode, 127 148 } 128 149 l.locationCache[loc.ID] = loc 129 150 locations = append(locations, loc) 130 151 } 131 152 return locations, nil 153 + } 154 + 155 + func stringValue(p *string) string { 156 + if p == nil { 157 + return "" 158 + } 159 + return *p 132 160 } 133 161 134 162 func (l *locationServer) Ready(ctx context.Context) error {
+11 -2
internal/locations/locations_test.go
··· 13 13 14 14 func TestGetLocationByIDUsesCache(t *testing.T) { 15 15 client := newFakeKrogerClient() 16 - client.setDetailResponse("12345", http.StatusOK, `{"data":{"name":"Friendly Market","address":{"addressLine1":"123 Main St"}}}`) 16 + client.setDetailResponse("12345", http.StatusOK, `{"data":{"name":"Friendly Market","address":{"addressLine1":"123 Main St","zipCode":"10001"}}}`) 17 17 18 18 server := newTestLocationServer(client) 19 19 ··· 24 24 } 25 25 if got.Name != "Friendly Market" || got.Address != "123 Main St" { 26 26 t.Fatalf("unexpected location returned: %+v", got) 27 + } 28 + if got.ZipCode != "10001" { 29 + t.Fatalf("unexpected zip code: %q", got.ZipCode) 27 30 } 28 31 29 32 _, err = server.GetLocationByID(ctx, "12345") ··· 41 44 42 45 func TestGetLocationsByZipCachesLocations(t *testing.T) { 43 46 client := newFakeKrogerClient() 44 - client.setListResponse("30301", http.StatusOK, `{"data":[{"locationId":"111","name":"Store 111","address":{"addressLine1":"1 North Ave","state":"GA"}},{"locationId":"222","name":"Store 222","address":{"addressLine1":"2 South St","state":"GA"}}]}`) 47 + client.setListResponse("30301", http.StatusOK, `{"data":[{"locationId":"111","name":"Store 111","address":{"addressLine1":"1 North Ave","state":"GA","zipCode":"30301"}},{"locationId":"222","name":"Store 222","address":{"addressLine1":"2 South St","state":"GA","zipCode":"60601"}}]}`) 45 48 46 49 server := newTestLocationServer(client) 47 50 ··· 56 59 if locs[0].ID != "111" || locs[0].State != "GA" { 57 60 t.Fatalf("unexpected first location: %+v", locs[0]) 58 61 } 62 + if locs[0].ZipCode != "30301" { 63 + t.Fatalf("unexpected first location zip code: %+v", locs[0]) 64 + } 59 65 if locs[1].ID != "222" || locs[1].Address != "2 South St" { 60 66 t.Fatalf("unexpected second location: %+v", locs[1]) 67 + } 68 + if locs[1].ZipCode != "60601" { 69 + t.Fatalf("unexpected second location zip code: %+v", locs[1]) 61 70 } 62 71 63 72 server.cacheLock.Lock()
+2
internal/locations/mock.go
··· 20 20 Name: "Big Willys", 21 21 Address: "1 willy ave", 22 22 State: "North Dakota", 23 + ZipCode: "58102", 23 24 }, 24 25 "5000": { 25 26 ID: "5000", 26 27 Name: "Piggly Wiggly", 27 28 Address: "20 somewhere st", 28 29 State: "North Carolina", 30 + ZipCode: "28104", 29 31 }, 30 32 } 31 33
+64 -5
internal/recipes/params.go
··· 22 22 const ( 23 23 legacyRecipeHashSeed = "recipe" 24 24 legacyIngredientsHashSeed = "ingredients" 25 + storeDayStartHour = 9 25 26 ) 27 + 28 + var nowFn = time.Now 26 29 27 30 type generatorParams struct { 28 31 Location *locations.Location `json:"location,omitempty"` ··· 130 133 return nil, err 131 134 } 132 135 133 - dateStr := r.URL.Query().Get("date") 134 - if dateStr == "" { 135 - dateStr = time.Now().Format("2006-01-02") 136 - } 137 - date, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC) 136 + storeLoc, err := resolveStoreTimeLocation(ctx, l) 138 137 if err != nil { 139 138 return nil, err 139 + } 140 + dateStr := r.URL.Query().Get("date") 141 + date := defaultRecipeDate(nowFn(), storeLoc) 142 + if dateStr != "" { 143 + parsedDate, err := time.ParseInLocation("2006-01-02", dateStr, storeLoc) 144 + if err != nil { 145 + return nil, err 146 + } 147 + date = parsedDate 140 148 } 141 149 142 150 p := DefaultParams(l, date) ··· 213 221 }, 214 222 } 215 223 } 224 + 225 + func resolveStoreTimeLocation(ctx context.Context, l *locations.Location) (*time.Location, error) { 226 + if l == nil { 227 + return nil, fmt.Errorf("nil location") 228 + } 229 + tzName, ok := timezoneNameForZip(l.ZipCode) 230 + if !ok { 231 + return nil, fmt.Errorf("unable to infer timezone from zipcode %s", l.ZipCode) 232 + } 233 + storeLoc, err := time.LoadLocation(tzName) 234 + if err != nil { 235 + slog.ErrorContext(ctx, "invalid inferred timezone; falling back to UTC", "location_id", l.ID, "zipcode", l.ZipCode, "timezone", tzName, "error", err) 236 + return nil, err 237 + } 238 + return storeLoc, nil 239 + } 240 + 241 + func timezoneNameForZip(zip string) (string, bool) { 242 + trimmed := strings.TrimSpace(zip) 243 + if trimmed == "" { 244 + return "", false 245 + } 246 + switch first := trimmed[0]; { 247 + case first >= '0' && first <= '3': 248 + return "America/New_York", true 249 + case first >= '4' && first <= '7': 250 + return "America/Chicago", true 251 + case first == '8': 252 + return "America/Denver", true 253 + case first == '9': 254 + return "America/Los_Angeles", true 255 + default: 256 + return "", false 257 + } 258 + } 259 + 260 + func StoreToDate(ctx context.Context, now time.Time, l *locations.Location) (time.Time, error) { 261 + tz, err := resolveStoreTimeLocation(ctx, l) 262 + if err != nil { 263 + return now, err 264 + } 265 + return defaultRecipeDate(now, tz), nil 266 + } 267 + 268 + func defaultRecipeDate(now time.Time, storeLoc *time.Location) time.Time { 269 + localNow := now.In(storeLoc) 270 + if localNow.Hour() < storeDayStartHour { 271 + localNow = localNow.AddDate(0, 0, -1) 272 + } 273 + return time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, storeLoc) 274 + }
+89
internal/recipes/params_test.go
··· 1 + package recipes 2 + 3 + import ( 4 + "careme/internal/locations" 5 + "context" 6 + "net/http/httptest" 7 + "testing" 8 + "time" 9 + ) 10 + 11 + type staticLocationLookup struct { 12 + location *locations.Location 13 + } 14 + 15 + func (s staticLocationLookup) GetLocationByID(_ context.Context, _ string) (*locations.Location, error) { 16 + return s.location, nil 17 + } 18 + 19 + func TestDefaultRecipeDate_Uses9AMStoreBoundary(t *testing.T) { 20 + storeLoc, err := time.LoadLocation("America/New_York") 21 + if err != nil { 22 + t.Fatalf("failed to load timezone: %v", err) 23 + } 24 + 25 + beforeBoundary := time.Date(2026, 1, 15, 13, 59, 0, 0, time.UTC) // 08:59 in New York 26 + before := defaultRecipeDate(beforeBoundary, storeLoc) 27 + if got, want := before.Format("2006-01-02"), "2026-01-14"; got != want { 28 + t.Fatalf("expected previous day before 9AM boundary, got %s", got) 29 + } 30 + 31 + atBoundary := time.Date(2026, 1, 15, 14, 0, 0, 0, time.UTC) // 09:00 in New York 32 + after := defaultRecipeDate(atBoundary, storeLoc) 33 + if got, want := after.Format("2006-01-02"), "2026-01-15"; got != want { 34 + t.Fatalf("expected same day at 9AM boundary, got %s", got) 35 + } 36 + } 37 + 38 + func TestParseQueryArgs_DefaultDateUsesStoreZipHeuristic(t *testing.T) { 39 + oldNowFn := nowFn 40 + nowFn = func() time.Time { 41 + return time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC) // 05:30 in New York 42 + } 43 + defer func() { 44 + nowFn = oldNowFn 45 + }() 46 + 47 + s := &server{ 48 + locServer: staticLocationLookup{ 49 + location: &locations.Location{ 50 + ID: "store-1", 51 + Name: "Test Store", 52 + ZipCode: "10001", 53 + }, 54 + }, 55 + } 56 + 57 + req := httptest.NewRequest("GET", "/recipes?location=store-1", nil) 58 + p, err := s.ParseQueryArgs(context.Background(), req) 59 + if err != nil { 60 + t.Fatalf("ParseQueryArgs returned error: %v", err) 61 + } 62 + 63 + if got, want := p.Date.Format("2006-01-02"), "2026-01-14"; got != want { 64 + t.Fatalf("expected default date %s, got %s", want, got) 65 + } 66 + if got, want := p.Date.Location().String(), "America/New_York"; got != want { 67 + t.Fatalf("expected date location %s, got %s", want, got) 68 + } 69 + } 70 + 71 + func TestTimezoneNameForZip(t *testing.T) { 72 + cases := []struct { 73 + zip string 74 + wantName string 75 + wantOK bool 76 + }{ 77 + {zip: "10001", wantName: "America/New_York", wantOK: true}, 78 + {zip: "60601", wantName: "America/Chicago", wantOK: true}, 79 + {zip: "80202", wantName: "America/Denver", wantOK: true}, 80 + {zip: "94105", wantName: "America/Los_Angeles", wantOK: true}, 81 + {zip: "abcde", wantName: "", wantOK: false}, 82 + } 83 + for _, tc := range cases { 84 + gotName, gotOK := timezoneNameForZip(tc.zip) 85 + if gotName != tc.wantName || gotOK != tc.wantOK { 86 + t.Fatalf("zip %q: got (%q,%t), want (%q,%t)", tc.zip, gotName, gotOK, tc.wantName, tc.wantOK) 87 + } 88 + } 89 + }