The seed packet that thought it was a carrot
My garden app has a seed inventory (about 115 packets) and a planting calendar tuned for the Gulf Coast, where the cool-season and warm-season windows overlap in a way that makes "is it time to sow this?" a genuinely annoying question. The answer lived across three screens: the inventory, the calendar, and whatever was already sitting in a seed tray. Nobody was going to cross-reference that by hand on a Friday night, so nothing got started on time.
The fix is a weekly digest — the seeds you already own whose sowing window opens in the next seven days — and it's mostly a join. The interesting parts are the three places where the naive join is wrong.
1. The calendar was written for someone who buys transplants
The catalog only carried buy-transplant windows for brassicas and onions:
broccoli buy-transplant Set out fall broccoli 08-15 .. 09-30
onion buy-transplant Plant onion sets 10-15 .. 12-01
Useful if you're going to a nursery. I have seed. "Set out fall broccoli Aug 15–Sep 30" actually means "start the seed indoors Jul 11–Aug 26," and that is a fixed offset per crop:
LEAD_WEEKS = {
"onion": 9, # short-day onions from seed: long haul
"pepper": 8, "eggplant": 8, "tomato": 6,
"broccoli": 5, "cabbage": 5, "cauliflower": 5,
"collards": 4, "kale": 4, "lettuce": 3,
}
DEFAULT_LEAD_WEEKS = 5
lead = timedelta(weeks=LEAD_WEEKS.get(key, DEFAULT_LEAD_WEEKS))
consider(key, "seed-indoors", f"Start {name} indoors",
start - lead, end - lead,
sets_out={"start": start, "end": end})
plant and slips windows are deliberately excluded — garlic cloves, seed potatoes and sweet-potato slips are not seed starting, and a derived "start your garlic indoors in August" line would be noise that teaches you to skip the email.
2. Ties in the name matcher were being resolved by iteration order
Packet names are free text, so a keyword matcher maps them to crops: longest keyword wins, which is how "sweet potato" beats "potato" and "malabar spinach" beats "spinach". Then the seed box produced this:
Carrot Bomb Eco Hot Pepper
Bulgarian Carrot Chili Hot Pepper
Both are peppers. Both tie: carrot is six characters, pepper is six characters. The winner was whichever the dict happened to yield first, which is stable, silent, and wrong — a hot pepper filed under "sow carrots now, the window is open."
The signal is position. Seed varieties put the crop noun last:
def match_crop_key(name, aliases):
n = normalize(name)
best = None # (len(kw), end_pos, key)
for kw, key in aliases.items():
pos = n.rfind(kw)
if pos < 0:
continue
cand = (len(kw), pos + len(kw), key)
if best is None or cand[:2] > best[:2]:
best = cand
return best[2] if best else fuzzy(n, aliases)
Longest still wins first — that matters, because "Snow Pea Green Beauty" must not resolve to the generic pea alias, and "Purple Hull Pea" must land on southern peas rather than snow peas. Only ties fall through to position. Misspellings ("Brocolli DeCicco", "Muskemelon Hale's Best") drop to a difflib pass. 113 of 115 packets now match; the holdouts are an ornamental amaranth and a pack of asparagus, which isn't a from-seed crop here anyway.
Writing the digest also surfaced that five things in the box — arugula, bok choy, snow peas, leeks, artichoke — had no calendar entry at all. The report that tells you what you can't answer is worth as much as the answer.
3. The thing with the SMTP credentials holds no opinions
The obvious build is a script that queries the database, decides what to say, renders an email and sends it. That puts the interesting logic in the one place that needs mail credentials, and it drifts from the app the moment the app changes.
Instead the app renders its own email and says who it's for:
GET /api/sowdigest/email
{
"subject": "Garden: 5 things to start from seed this week (Sep 19–25) — 2 last call",
"html": "...",
"text": "...",
"recipients": [{"email": "...", "name": "..."}],
"counts": {"items": 5, "last_call": 2, "already": 4}
}
The cron job fetches that and hands subject + html to the mailer. That's the whole script. Two consequences I like: adding a gardener to the garden subscribes them (there's no address list in the sender to forget to update), and every decision about what the digest says is one deploy away from the data it describes. The same endpoint backs the in-app screen, so the phone, the web app and the email can't disagree about what's sowable this week.
4. The monitor asserts the email, not the cron
My fleet monitor's probe for this is not "did the timer fire."
def check():
sent = state.get("last_sent")
if not sent:
return 1
age = (now() - fromisoformat(sent)).total_seconds()
return 1 if age > 8 * 86400 else 0 # weekly + a day of slack
An empty week still sends — "nothing opens this week, here's what's already going" is a real answer — so silence is always a fault and never "nothing to sow." If I'd asserted the cron run instead, a fetch that 500s or an SMTP password rotation would look perfectly healthy while nobody got an email for a month, which is exactly how you discover in March that you missed onions.
The first real send went out Friday morning with onions flagged last call — ten days left on a window I would otherwise have noticed in November.