DEV Community

ANIRUDDHA  ADAK
ANIRUDDHA ADAK Subscriber

Posted on

Most gifted plants die of a bad window, not neglect. So I built one that tells you which window.

Hacktoberfest: Maintainer Spotlight

Most gifted plants do not die of neglect. They die of a bad window.

I built this because a friend of mine has killed roughly a dozen houseplants and could not tell you why. Neither could I. The received wisdom is "water it less often", which is not actually the most common failure mode, and it is certainly not the one you can act on in the ten minutes before you hand someone a plant.

So I built Plant Pact for him, and for anyone else about to give a plant away: it takes the species, the person, the city and the window, reads the real 14-day forecast for that city plus its climate normals, and tells you the odds — including the week it is most likely to die. Then it asks you to come back on day 90 and record what actually happened.

Plant Pact advisor showing 55% survival odds for a fiddle-leaf fig in Reykjavik, with climate strain as the largest risk and survival crossing 50% on 2026-10-27

Source: github.com/aniruddhaadak80/plant-pact (MIT)

The part that made it work

The first version scored a plant against the outdoor forecast. It told me a pothos in a London flat had a 60% chance of surviving, which seemed plausible. Then I looked at the evidence string it printed:

14 of 14 nights put the sill below 13 °C; worst was 4.1 °C outside reading 12.9 °C on the sill

The bug was conceptual: the plant is behind glass. A flat is at 18–21 °C in January while it is 4 °C outside. Judging an indoor plant by the raw outdoor forecast makes every temperate city look arctic, which is both wrong and useless to the person reading the page.

The fix was not a bigger model. It was a three-line, unfitted, completely inspectable transform:

sillCold(outdoor) = 17 + (outdoor - 17) * 0.45
sillHeat(outdoor) = 17 + (outdoor - 17) * 0.60
Enter fullscreen mode Exit fullscreen mode

Cold is damped harder than heat, because radiators hold a flat near 17–20 °C in winter while a sunlit south sill genuinely heats up behind the glass. The base survival rate of the corpus moved from 28% to 58% purely from this. No new features, no extra data, no API key.

The model is real, and it is not a black box

p = σ(bias + Σ wᵢ · featureᵢ) — logistic regression, weights committed in the repo, trained by a script you can run:

npm run train   # refits and rewrites src/lib/model/weights.ts
Enter fullscreen mode Exit fullscreen mode
  • 8,040 placements sampled across 24 real cities, using real Open-Meteo forecasts and ERA5 reanalysis normals. Not synthetic weather.
  • Held-out AUC 0.956, accuracy 0.889.
  • Ten features, every one derived from something a person can actually measure (window light hours, how often they will really water, who cares for the plant) or from the weather above.

Two design decisions I would defend:

Nine of the ten features are risk-shaped, where a larger value always means a worse outcome. That makes one negative weight per feature the correct hypothesis, keeps the fit well conditioned, and lets the UI read each bar as "this is how much this one thing is costing you". The tenth, light headroom, is the only one where more is better.

Labels come from a separate, hand-written risk procedure. I wrote out the decision a careful grower would make — accumulate hazard from every way a placement can fail, call it a survival under 1.6 — and used that to label the corpus. The application never scores with it. The shipped model approximates that procedure. Keeping them apart means the model can be audited against the thing it was fitted to, instead of quietly standing in for it.

Read the weights honestly and one thing jumps out: water dominates. The two watering features carry the largest magnitudes in the model, which matches the two ways gifted plants actually die. Heat is real but almost never the deciding factor once the sill model is applied — its near-zero weight is an empirical finding, not an oversight.

"The week it dies"

The headline probability is one model run. The interesting artifact is the path. I re-evaluate the same weight vector on the forecast prefix available on each day, so cold nights and dry spells accumulate as evidence arrives:

flowchart LR
    SPEC[Species profile] --> F[Ten features]
    MEAS[Window light, watering cadence, care level] --> F
    SKYD[Live forecast + ERA5 normals] --> F
    F --> LIN[Linear logit]
    WEIGHTS[Committed weights] --> LIN
    LIN --> SIG[Sigmoid]
    SIG --> P[Survival probability]
    P --> PATH[14-day projection path]
    PATH --> WEEK[Projected failing week]

The last point of that path is exactly the headline number, which the tests assert. When the curve never crosses the threshold inside the window, it projects the slope forward so you still get a date instead of a shrug — and it labels the date as projected rather than observed.

One bug worth mentioning: my first version computed cold exposure as a mean over the days seen so far, so a single mild day erased the stress of the week before it and the curve wobbled upward. Accumulating instead of averaging fixed the causal story the product is telling.

One service layer, three doors

There is exactly one implementation of every mutation, in src/lib/service.ts. The REST routes, the browser UI and the MCP tools all call it. That is not an architecture boast, it is a testable claim — the live verifier commits a pact through the agent and reads it back through REST:

curl -s https://plant-pact.vercel.app/api/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Enter fullscreen mode Exit fullscreen mode

Nine typed tools: one analysis tool that writes nothing, three mutations (commit_pact takes an idempotency key; delete_pact tombstones), and five reads. Tools are scoped to the caller's HTTP-only session cookie — the same ownership boundary the browser uses, not a parallel world with its own accounts.

A prediction nobody checks is not a prediction

Every create, update, outcome and deletion appends to a per-pact chain:

seal_n = SHA-384( UTF-8(prevSeal) + canonicalJson(event_n) )
Enter fullscreen mode Exit fullscreen mode

canonicalJson recursively sorts keys, preserves array order, normalises -0. Two digests are pinned as known-answer vectors in the test suite, so changing the wire format fails CI rather than silently invalidating every pact already sealed. Deleting a pact tombstones the row and keeps the chain, so the record of your prediction cannot be quietly erased — and you can prove it:

flowchart LR
    G[genesis: 96 zeros] --> E1[created]
    E1 --> S1[seal 1] --> E2[updated]
    E2 --> S2[seal 2] --> E3[outcome_recorded]
    S2 --> S3[seal 3] --> E4[deleted]
    S3 --> E4
    E4 --> RP[Replay recomputes each digest]
    RP --> OK[ok, or the first broken sequence]

Honest limits

  • It is not a survival guarantee. The base rate for randomly sampled placements is 58%. A good placement should read well above that; this is a ranking tool with an explanation attached, not an oracle.
  • Light hours are a guess. The biggest source of user error is a number someone estimates about their own window. Every factor repeats that estimate back at you, which is the least I could do about it.
  • Not veterinary or horticultural advice. Care ranges are approximate published guidelines I compiled, not measurements of a specific cultivar. Toxicity flags are conservative and general.
  • Rate limiting is best-effort and I say so in SECURITY.md: it is an in-memory bucket, and serverless instances each get their own. It stops casual abuse, not a motivated attacker. The honest fix is a shared limiter behind the same interface.
  • The care-card preview is only as good as the estimates. There is no lux meter here.

What I would do next

The single most valuable thing is publishing a calibration curve from real outcomes. The model makes a prediction, the app asks you what actually happened, and once enough pacts are recorded the most honest artifact is not the held-out AUC — it is a chart of how the model's confidence lined up with reality. That is the thing I cannot fake, and the thing that would make me trust the rest of it.

Stack

Next.js 16 App Router, TypeScript strict, Tailwind v4, Neon Postgres (hosted in production, embedded PGlite locally over the same SQL), framer-motion, lucide-react, Vitest, Playwright. No API keys anywhere — Open-Meteo and the GBIF Backbone Taxonomy are both public and keyless, and when they are unreachable the app degrades to a sealed, dated sample and says so on the payload and on the page.

88 unit and integration tests, 79 live HTTP checks, and 14 Playwright journeys across desktop and mobile — all green against the production deployment, not just locally.

Posting this for the Hacktoberfest Weekend Challenge: Build for a Friend.

If you have ever killed a plant and want to know why, that is exactly the question it is trying to answer.

Top comments (0)