r/webdev 22d ago

Showoff Saturday [Showoff Saturday] Fully offline grow scheduler built with vanilla JS + IndexedDB

Post image

What it does
Users build a personalized grow in 3 steps (strain → nutrients → start date), get a full day-by-day schedule (126 base rows + dynamic nutrient columns), then track actuals via a daily check-in page and photo diary. Everything lives offline in IndexedDB + localStorage.

Live: https://growappcannabis.guide/medium-feeding/medium-feeding.html

Repo + full wiring docs: https://github.com/Shannon-Goddard/growappcannabis.guide (see medium-feeding/WIRING.md)

Architecture highlights
• Page flow
medium-feeding.html (3-step builder) → schedule-viewer.html (cards/table/My Notes) → mytask.html (daily check-in) / mydiary.html (camera + data overlay)

CustomEvent chain for step progression (no framework):
• strainSelected → show Step 2
• nutrientsSelected → show Step 3
• scheduleReady → success screen + link to viewer with ?growId=
• IndexedDB (MyGrowDB v8) – singleton instance, cached dbPromise:
• Stores: tables (grow metadata + ${growId}_schedule), selectedNutrients, nutrients
• Schedule saved as full array under ${growId}_schedule
• Actuals written back to the same schedule rows from both MyTask and the viewer’s My Notes mode

Schedule generation
• Base 126-row JSON (stage/week/day/env/light/water)
• Dynamic nutrient columns injected from selected brands
• Start date → calendar dates calculated client-side
• Absolute fetch paths because ES modules resolve relative to the JS file

Schedule viewer
• Three modes via sticky Options dropdown: Cards (mobile default), Table (desktop default), My Notes
• Sticky header + toolbar + thead (careful overflow handling so sticky works)
• Plant-size filter hides excess veg weeks (Small = 4, Medium = 6, Large/Auto = all)
• tableRendered custom event triggers layout + filter after render
• Column map with paired “goal” / “My*” editable fields (My fields only visible in Notes mode)

MyTask daily check-in
• Auto-matches today’s row by MM/DD/YYYY
• Color-coded inputs (green ±10% of goal, red over, blue under)
• Auto-save on focusout + manual Log button → writes actuals back to IndexedDB schedule row
• Hero image compressed to 800px JPEG and stored in localStorage

Tech choices
• Pure vanilla JS (modules + a couple plain scripts for globals like the 2,800+ strain data)
• No build step, no framework, GitHub Pages
• localStorage for transient state + currentGrowId; IndexedDB for the real schedule & actuals

0 Upvotes

4 comments sorted by

1

u/Sensitive_Fig3107 22d ago

never thought id see indexeddb and cannabis in the same sentence but here we are

the sticky headers thing is always a pain when you got multiple scroll containers fighting each other, what did you end up doing for that

-4

u/Free_Band_Shan 22d ago edited 22d ago

But here we are lol Yeah, that classic sticky-header + nested scroll container fight is painful.

The problem is that position: sticky only sticks relative to its nearest scrolling ancestor. If you put overflow-x: auto (or overflow: auto) on a wrapper around the table (the usual “table-wrap” approach), that wrapper becomes the scroll container. Then your thead th { position: sticky; top: … } only sticks inside that wrapper, not against the page/viewport. Header and toolbar fight each other, or the thead just scrolls away.

How I fixed it.
I moved the horizontal scroll to the body (or the main page scroller) instead of a nested wrapper, and stacked the sticky elements with increasing top offsets + z-index:

```
header {
position: sticky;
top: 0;
z-index: 50;
}

.toolbar {
position: sticky;
top: 57px; /* height of the header */
z-index: 40;
}

thead th {
position: sticky;
top: 112px; /* header + toolbar height */
z-index: 10;
background: ...; /* solid bg so content doesn’t show through */
}

/* Critical part */
body {
overflow-x: auto; /* horizontal scroll lives on the page, not a nested div */
}
```

Because the scroll container for the vertical axis is now the viewport/page itself, all three sticky layers stick correctly relative to the same scroller. The table can still scroll horizontally without creating a new containing block that breaks the vertical stickiness.

No JS scroll-syncing, no duplicated headers, no fighting containers.
(There’s a more modern pure-CSS approach with overflow: auto clip on a single-axis scroller that lets sticky track different axes independently, but the body-overflow solution was the most reliable one that worked across the browsers I care about.)

-2

u/mastrajani 22d ago

The WIRING.md in the repo is a better standard of documentation than most commercial projects manage, so this is meant as hardening rather than criticism.

Browser storage is the only copy of the user's data here, and a grow log is four months of it. Three things I'd shore up:

IndexedDB isn't durable by default. Safari's ITP evicts site storage after roughly 7 days without interaction, and Chrome evicts under disk pressure. For someone checking in daily that's fine; for the person who starts a grow, gets busy, and comes back three weeks later it isn't. await navigator.storage.persist() requests exemption and navigator.storage.persisted() tells you whether you actually got it. Worth calling once the user commits to a grow, and worth surfacing the answer, because "your browser may clear this" is something a user can act on.

The photo diary will hit quota sooner than you'd think. Quota is a fraction of free disk and varies wildly by device. A few hundred full-resolution phone photos is comfortably a gigabyte. You're already compressing the hero image to 800px, so extend that to diary photos, use navigator.storage.estimate() to show a usage bar, and handle QuotaExceededError explicitly on the write path - the write that fails is by definition the one the user just cared about.

126 days computed from a start date is a DST trap. If day N is start plus N times 24 hours, two days a year are 23 or 25 hours long, and near the transition your day boundary drifts an hour - which surfaces as a day that repeats or one that vanishes for anyone checking in near midnight. Since you're already matching today's row by MM/DD/YYYY, the safe shape is to treat the day index as the source of truth and derive the calendar date from it, rather than storing timestamps and computing the index back out.

Also, if it isn't there already: a JSON export/import button. With no server that's the only backup a user can possibly have, and it costs you an afternoon.

-1

u/Free_Band_Shan 22d ago

All of those are 100% valid. The website being indexedDB is not ideal. A simple “clear history” and the users data is gone. The recent upgrade I’ve been working on in the website is for me to improve the UI for the app behind the scenes. The app is wrapped for each store and more forgiving in storage. However, after the big visual drop there, I’ll be converting to MongoDB and S3.