diff --git a/README.md b/README.md index 23bf910..7a29762 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ Sproutly takes the guesswork out of seed starting. Enter your plant varieties on ## Features - **Multi-user** — each user has their own account with fully isolated data -- **Admin panel** — manage all user accounts: view content, reset passwords, disable, or delete +- **Admin panel** — manage all user accounts: view content, reset passwords, disable, or delete; shows each user's join date and last login - **Dashboard** — at-a-glance view of overdue, today's, and upcoming tasks with a full year planting timeline - **Seed Library** — manage plant varieties with frost-relative timing, germination days, sun/water requirements - **Garden Tracker** — log growing batches and track status from `planned` → `germinating` → `seedling` → `potted up` → `hardening off` → `garden` → `harvested` +- **Smart date auto-fill** — when logging a new batch, sow date defaults to today and all subsequent dates (germination, greenhouse, transplant) are calculated automatically from the selected variety's timing - **Year Timeline** — visual calendar showing when each variety's stages fall across the year - **Ntfy Notifications** — per-user daily summary push notifications to your phone, configurable time, topic, and authentication - **Settings** — set your last frost date, fall frost date, location, timezone, and notification preferences @@ -48,9 +49,10 @@ Access the app at **http://localhost:8053** — create an account to get started ### First Steps 1. Register an account on the login screen -2. Go to **Settings** and enter your last frost date — this anchors all planting schedule calculations +2. Go to **Settings** and enter your last frost date — this anchors the planting timeline and dashboard calculations 3. Optionally configure an [ntfy](https://ntfy.sh) topic for push notifications -4. Add varieties to your **Seed Library** and start logging batches in **My Garden** +4. Add varieties to your **Seed Library** — each variety stores its frost-relative week offsets and germination days +5. Head to **My Garden** and log a batch — dates auto-fill based on today and the selected variety's timing ## Environment Variables @@ -131,7 +133,7 @@ Key endpoints: Log in with the `ADMIN_EMAIL` / `ADMIN_PASSWORD` credentials from your `.env`. Once logged in, an **Admin** link appears in the sidebar. From there you can: -- View all registered users with their variety and batch counts +- View all registered users with join date, last login, variety count, and batch count - Browse any user's seed library and growing batches - Reset a user's password - Disable or re-enable an account @@ -149,6 +151,19 @@ For private ntfy servers or access-controlled topics, the Settings page supports | Username & Password | ntfy server with basic auth enabled | | API Key / Token | ntfy account access token (generate in ntfy account settings) | +## Batch Date Auto-fill + +When logging a new batch, Sproutly pre-fills all date fields based on the selected variety's timing offsets, using **today as the sow date**: + +| Field | Calculation | +|-------|-------------| +| Sow date | Today | +| Germination date | Sow date + `days_to_germinate` | +| Greenhouse / pot-up date | Sow date + (`weeks_to_start` − `weeks_to_greenhouse`) × 7 | +| Garden transplant date | Sow date + (`weeks_to_start` + `weeks_to_garden`) × 7 | + +All dates remain fully editable. Fields are left blank if the variety is missing the relevant timing value. + ## Status Core infrastructure is functional. UI design and feature set are evolving based on user feedback. diff --git a/backend/models.py b/backend/models.py index 07845e7..acb17b4 100644 --- a/backend/models.py +++ b/backend/models.py @@ -44,6 +44,7 @@ class User(Base): is_admin = Column(Boolean, default=False, nullable=False) is_disabled = Column(Boolean, default=False, nullable=False) created_at = Column(DateTime, server_default=func.now()) + last_login_at = Column(DateTime, nullable=True) varieties = relationship("Variety", back_populates="user", cascade="all, delete-orphan") batches = relationship("Batch", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/routers/admin.py b/backend/routers/admin.py index f438c9b..4384d80 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -23,7 +23,7 @@ def list_users(db: Session = Depends(get_db), _: User = Depends(get_admin_user)) return [ AdminUserOut( id=u.id, email=u.email, is_admin=u.is_admin, is_disabled=u.is_disabled, - created_at=u.created_at, **_user_stats(db, u.id) + created_at=u.created_at, last_login_at=u.last_login_at, **_user_stats(db, u.id) ) for u in users ] diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 46e0db9..0af6acf 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session @@ -48,6 +50,8 @@ def login(data: UserLogin, db: Session = Depends(get_db)): raise HTTPException(status_code=401, detail="Invalid email or password") if user.is_disabled: raise HTTPException(status_code=403, detail="Account has been disabled") + user.last_login_at = datetime.now(timezone.utc) + db.commit() return {"access_token": create_access_token(user.id), "token_type": "bearer"} diff --git a/backend/routers/batches.py b/backend/routers/batches.py index 5577a5c..3e347f6 100644 --- a/backend/routers/batches.py +++ b/backend/routers/batches.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import case from sqlalchemy.orm import Session, joinedload from typing import List @@ -16,7 +17,7 @@ def list_batches(db: Session = Depends(get_db), current_user: User = Depends(get db.query(Batch) .options(joinedload(Batch.variety)) .filter(Batch.user_id == current_user.id) - .order_by(Batch.sow_date.desc().nullslast(), Batch.created_at.desc()) + .order_by(case((Batch.sow_date.is_(None), 1), else_=0), Batch.sow_date.desc(), Batch.created_at.desc()) .all() ) diff --git a/backend/schemas.py b/backend/schemas.py index 3fd0fb5..e70b3eb 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -33,6 +33,7 @@ class AdminUserOut(BaseModel): is_admin: bool is_disabled: bool created_at: Optional[datetime] + last_login_at: Optional[datetime] variety_count: int batch_count: int diff --git a/mysql/init.sql b/mysql/init.sql index e00e1ad..22b2f87 100644 --- a/mysql/init.sql +++ b/mysql/init.sql @@ -6,7 +6,8 @@ CREATE TABLE IF NOT EXISTS users ( hashed_password VARCHAR(255) NOT NULL, is_admin BOOLEAN NOT NULL DEFAULT FALSE, is_disabled BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login_at TIMESTAMP NULL DEFAULT NULL ); CREATE TABLE IF NOT EXISTS varieties ( diff --git a/nginx/html/js/app.js b/nginx/html/js/app.js index 38611a0..768141f 100644 --- a/nginx/html/js/app.js +++ b/nginx/html/js/app.js @@ -133,6 +133,7 @@ const api = { // ===== State ===== let state = { varieties: [], + varietiesLoaded: false, batches: [], settings: {}, }; @@ -150,7 +151,7 @@ function toast(msg, isError = false) { // ===== Utility ===== function fmt(dateStr) { if (!dateStr) return '—'; - const d = new Date(dateStr + 'T00:00:00'); + const d = new Date(dateStr.includes('T') ? dateStr : dateStr + 'T00:00:00'); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } @@ -408,6 +409,7 @@ function batchCard(b, compact = false) { async function loadVarieties() { try { state.varieties = await api.get('/varieties/'); + state.varietiesLoaded = true; renderVarieties(); } catch (e) { document.getElementById('varieties-container').innerHTML = `
Error: ${esc(e.message)}
`; @@ -752,6 +754,7 @@ async function deleteVariety(id) { // ===== Batch Modals ===== function batchFormHtml(b = {}) { + const isNew = !b.id; const varOpts = state.varieties.map(v => `` ).join(''); @@ -760,7 +763,7 @@ function batchFormHtml(b = {}) {
- +
@@ -821,10 +824,43 @@ function collectBatchForm() { }; } -function showAddBatchModal() { +function autofillBatchDates(varietyId) { + const v = state.varieties.find(x => x.id === parseInt(varietyId)); + if (!v) return; + + function addDays(dateStr, days) { + const d = new Date(dateStr + 'T00:00:00'); + d.setDate(d.getDate() + days); + return d.toISOString().slice(0, 10); + } + + const today = new Date().toISOString().slice(0, 10); + + // Sow = today; other dates calculated as offsets from sow using variety's relative week gaps + const sowDate = today; + const germDate = v.days_to_germinate + ? addDays(sowDate, v.days_to_germinate) + : null; + const ghDate = v.weeks_to_start != null && v.weeks_to_greenhouse != null + ? addDays(sowDate, (v.weeks_to_start - v.weeks_to_greenhouse) * 7) + : null; + const gardenDate = v.weeks_to_start != null && v.weeks_to_garden != null + ? addDays(sowDate, (v.weeks_to_start + v.weeks_to_garden) * 7) + : null; + + document.getElementById('bf-sow').value = sowDate; + if (germDate) document.getElementById('bf-germ').value = germDate; + if (ghDate) document.getElementById('bf-gh').value = ghDate; + if (gardenDate) document.getElementById('bf-garden').value = gardenDate; +} + +async function showAddBatchModal() { + if (!state.varietiesLoaded) { + state.varieties = await api.get('/varieties/'); + state.varietiesLoaded = true; + } if (!state.varieties.length) { - // Load varieties first if not loaded - api.get('/varieties/').then(v => { state.varieties = v; showAddBatchModal(); }); + openModal('Log a Batch', '

You need to add at least one seed variety before logging a batch. Go to Seed Library to add varieties.

'); return; } openModal('Log a Batch', ` @@ -834,6 +870,9 @@ function showAddBatchModal() {
`); + // Auto-fill dates for the default selected variety + const defaultVarietyId = document.getElementById('bf-variety')?.value; + if (defaultVarietyId) autofillBatchDates(defaultVarietyId); } async function submitAddBatch() { @@ -851,7 +890,7 @@ async function submitAddBatch() { async function showEditBatchModal(id) { try { - if (!state.varieties.length) state.varieties = await api.get('/varieties/'); + if (!state.varietiesLoaded) { state.varieties = await api.get('/varieties/'); state.varietiesLoaded = true; } const b = state.batches.find(x => x.id === id) || await api.get(`/batches/${id}`); openModal('Edit Batch', ` ${batchFormHtml(b)} @@ -909,6 +948,7 @@ async function loadAdmin() { Email Joined + Last Login Varieties Batches Status @@ -920,6 +960,7 @@ async function loadAdmin() { ${esc(u.email)}${u.is_admin ? ' admin' : ''} ${fmt(u.created_at)} + ${u.last_login_at ? fmt(u.last_login_at) : 'Never'} ${u.variety_count} ${u.batch_count} ${u.is_disabled ? 'Disabled' : 'Active'} @@ -1091,7 +1132,7 @@ async function init() { // ===== Public API ===== window.App = { showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety, - showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch, + showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch, autofillBatchDates, filterVarieties, filterBatches, saveSettings, toggleNtfyAuth, sendTestNotification, sendDailySummary, adminViewUser, adminResetPassword, adminSubmitReset, adminToggleDisable, adminDeleteUser, adminSwitchTab,