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 = `
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() {