Replace the simple nil-title → "Unknown" logic in printDeal with a
fallbackDealTitle() function that tries multiple fields in priority
order before giving up:
1. Title (cleaned) — the happy path, same as before
2. Brand + Department — e.g. "Publix deal (Meat)"
3. Brand alone — e.g. "Publix deal"
4. Department alone — e.g. "Meat deal"
5. Description truncated to 48 chars — last-resort meaningful text
6. Item ID — e.g. "Deal 12345"
7. "Untitled deal" — only when every field is empty
This makes the output more useful for the ~5-10% of weekly ad items
that ship with a nil Title from the Publix API, which previously all
showed as "Unknown" and were indistinguishable from each other.
Tests:
- TestPrintDeals_FallbackTitleFromBrandAndDepartment
- TestPrintDeals_FallbackTitleFromID
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the multi-pass where() chain in Apply() with a single loop that
evaluates all filter predicates per item and skips immediately on first
mismatch. This eliminates N intermediate slice allocations (one per
active filter) and avoids re-scanning the full dataset for each filter
dimension.
Key changes in filter.go:
- Single loop with continue-on-mismatch for BOGO, category, department,
and query filters — combined categories check scans item.Categories
once for both BOGO and category instead of twice
- Pre-allocate result slice capped at min(len(items), opts.Limit) to
avoid grow-and-copy churn
- Fast-path bypass when no filters are active (just apply limit)
- Break early once limit is reached instead of filtering everything
and truncating after
- Remove the now-unused where() helper function
- Add early-return fast paths to CleanText() for the common case where
input contains no HTML entities or newlines, avoiding unnecessary
html.UnescapeString and ReplaceAll calls
Test coverage:
- filter_equivalence_test.go (new): Reference implementation of the
original multi-pass algorithm with 500 randomized test cases verifying
behavioral equivalence. Includes allocation budget guardrail (<=80
allocs/op for 1k items) to catch accidental regression to multi-pass.
Benchmarks for new vs legacy reference on identical workload.
- filter_test.go: Benchmark comparisons for CleanText on plain text
(fast path) vs escaped HTML (full path), new vs legacy.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Refactor the internal HTTP helper from get() returning raw bytes to
getAndDecode() that streams directly into the target struct via
json.NewDecoder. This eliminates the intermediate []byte allocation
from io.ReadAll on every API response.
The new decoder also validates that responses contain exactly one JSON
value by attempting a second Decode after the primary one — any content
beyond the first value (e.g., concatenated objects from a misbehaving
proxy) returns an error instead of silently discarding it.
Changes:
- api/client.go: Replace get() with getAndDecode(), update FetchStores
and FetchSavings callers to use the new signature
- api/client_test.go: Add TestFetchSavings_TrailingJSONIsRejected and
TestFetchStores_MalformedJSONReturnsDecodeError covering the new
decoder error paths
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rendering layer for deals, stores, and categories with two output
modes: styled terminal text using lipgloss (color-coded BOGO tags,
price highlights, dim metadata, word-wrapped descriptions) and
compact JSON for programmatic consumption.
JSON output types (DealJSON, StoreJSON) normalize raw API fields —
cleaning HTML entities, dereferencing nullable pointers, and
computing derived fields like isBogo. Terminal output includes
contextual headers with item counts and date ranges. Tests verify
both rendering modes including HTML entity handling and nil safety.
Composable filter pipeline that processes SavingItem slices through
chained predicates: BOGO detection (category match), exact category
match, substring department match, and keyword search across title
and description fields. All text matching is case-insensitive.
Includes utility functions for HTML entity unescaping (CleanText),
nil-safe string pointer dereferencing (Deref), and case-insensitive
slice membership (ContainsIgnoreCase). An optional limit truncates
results after all filters are applied. Tests cover each filter in
isolation, combined filters, nil field safety, and the Categories
aggregation helper.
HTTP client that wraps the Publix services API with two endpoints:
- /api/v4/savings — fetches weekly ad deals for a given store number
- /api/v1/storelocation — finds nearby stores by ZIP code
Includes request types (SavingsResponse, SavingItem, StoreResponse,
Store) mapping directly to the Publix JSON schema. The client sends
a PublixStore header for store-scoped requests and uses a 15-second
timeout. Tests use httptest servers to verify header propagation,
JSON decoding, and error handling for non-200 responses.