Add last login tracking, batch date auto-fill, and bug fixes

- Track last_login_at on User model, updated on every successful login
- Show last login date in admin panel user table
- Fix admin/garden date display (datetime strings already contain T separator)
- Fix My Garden Internal Server Error (MySQL does not support NULLS LAST syntax)
- Fix Log Batch infinite loop when user has zero varieties
- Auto-fill batch dates from today when creating a new batch, calculated
  from selected variety's week offsets (germination, greenhouse, garden)
- Update README with new features and batch date auto-fill formula table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 00:48:04 -07:00
parent bd2bd43395
commit 84e7b13575
8 changed files with 78 additions and 14 deletions

View File

@@ -9,10 +9,11 @@ Sproutly takes the guesswork out of seed starting. Enter your plant varieties on
## Features ## Features
- **Multi-user** — each user has their own account with fully isolated data - **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 - **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 - **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` - **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 - **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 - **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 - **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 ### First Steps
1. Register an account on the login screen 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 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 ## 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: 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 - Browse any user's seed library and growing batches
- Reset a user's password - Reset a user's password
- Disable or re-enable an account - 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 | | Username & Password | ntfy server with basic auth enabled |
| API Key / Token | ntfy account access token (generate in ntfy account settings) | | 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 ## Status
Core infrastructure is functional. UI design and feature set are evolving based on user feedback. Core infrastructure is functional. UI design and feature set are evolving based on user feedback.

View File

@@ -44,6 +44,7 @@ class User(Base):
is_admin = Column(Boolean, default=False, nullable=False) is_admin = Column(Boolean, default=False, nullable=False)
is_disabled = Column(Boolean, default=False, nullable=False) is_disabled = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, server_default=func.now()) 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") varieties = relationship("Variety", back_populates="user", cascade="all, delete-orphan")
batches = relationship("Batch", back_populates="user", cascade="all, delete-orphan") batches = relationship("Batch", back_populates="user", cascade="all, delete-orphan")

View File

@@ -23,7 +23,7 @@ def list_users(db: Session = Depends(get_db), _: User = Depends(get_admin_user))
return [ return [
AdminUserOut( AdminUserOut(
id=u.id, email=u.email, is_admin=u.is_admin, is_disabled=u.is_disabled, 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 for u in users
] ]

View File

@@ -1,3 +1,5 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session 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") raise HTTPException(status_code=401, detail="Invalid email or password")
if user.is_disabled: if user.is_disabled:
raise HTTPException(status_code=403, detail="Account has been 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"} return {"access_token": create_access_token(user.id), "token_type": "bearer"}

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import case
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from typing import List from typing import List
@@ -16,7 +17,7 @@ def list_batches(db: Session = Depends(get_db), current_user: User = Depends(get
db.query(Batch) db.query(Batch)
.options(joinedload(Batch.variety)) .options(joinedload(Batch.variety))
.filter(Batch.user_id == current_user.id) .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() .all()
) )

View File

@@ -33,6 +33,7 @@ class AdminUserOut(BaseModel):
is_admin: bool is_admin: bool
is_disabled: bool is_disabled: bool
created_at: Optional[datetime] created_at: Optional[datetime]
last_login_at: Optional[datetime]
variety_count: int variety_count: int
batch_count: int batch_count: int

View File

@@ -6,7 +6,8 @@ CREATE TABLE IF NOT EXISTS users (
hashed_password VARCHAR(255) NOT NULL, hashed_password VARCHAR(255) NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE, is_admin BOOLEAN NOT NULL DEFAULT FALSE,
is_disabled 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 ( CREATE TABLE IF NOT EXISTS varieties (

View File

@@ -133,6 +133,7 @@ const api = {
// ===== State ===== // ===== State =====
let state = { let state = {
varieties: [], varieties: [],
varietiesLoaded: false,
batches: [], batches: [],
settings: {}, settings: {},
}; };
@@ -150,7 +151,7 @@ function toast(msg, isError = false) {
// ===== Utility ===== // ===== Utility =====
function fmt(dateStr) { function fmt(dateStr) {
if (!dateStr) return '—'; 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' }); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} }
@@ -408,6 +409,7 @@ function batchCard(b, compact = false) {
async function loadVarieties() { async function loadVarieties() {
try { try {
state.varieties = await api.get('/varieties/'); state.varieties = await api.get('/varieties/');
state.varietiesLoaded = true;
renderVarieties(); renderVarieties();
} catch (e) { } catch (e) {
document.getElementById('varieties-container').innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`; document.getElementById('varieties-container').innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
@@ -752,6 +754,7 @@ async function deleteVariety(id) {
// ===== Batch Modals ===== // ===== Batch Modals =====
function batchFormHtml(b = {}) { function batchFormHtml(b = {}) {
const isNew = !b.id;
const varOpts = state.varieties.map(v => const varOpts = state.varieties.map(v =>
`<option value="${v.id}" ${b.variety_id===v.id?'selected':''}>${v.name}${v.variety_name?' ('+v.variety_name+')':''}</option>` `<option value="${v.id}" ${b.variety_id===v.id?'selected':''}>${v.name}${v.variety_name?' ('+v.variety_name+')':''}</option>`
).join(''); ).join('');
@@ -760,7 +763,7 @@ function batchFormHtml(b = {}) {
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label class="form-label">Plant Variety *</label> <label class="form-label">Plant Variety *</label>
<select id="bf-variety" class="form-select">${varOpts}</select> <select id="bf-variety" class="form-select" ${isNew ? 'onchange="App.autofillBatchDates(this.value)"' : ''}>${varOpts}</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Batch Label</label> <label class="form-label">Batch Label</label>
@@ -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) { if (!state.varieties.length) {
// Load varieties first if not loaded openModal('Log a Batch', '<p style="color:var(--text-muted)">You need to add at least one seed variety before logging a batch. Go to <strong>Seed Library</strong> to add varieties.</p><div class="btn-row" style="margin-top:1rem"><button class="btn btn-secondary" onclick="App.closeModal()">Close</button></div>');
api.get('/varieties/').then(v => { state.varieties = v; showAddBatchModal(); });
return; return;
} }
openModal('Log a Batch', ` openModal('Log a Batch', `
@@ -834,6 +870,9 @@ function showAddBatchModal() {
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button> <button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
</div> </div>
`); `);
// Auto-fill dates for the default selected variety
const defaultVarietyId = document.getElementById('bf-variety')?.value;
if (defaultVarietyId) autofillBatchDates(defaultVarietyId);
} }
async function submitAddBatch() { async function submitAddBatch() {
@@ -851,7 +890,7 @@ async function submitAddBatch() {
async function showEditBatchModal(id) { async function showEditBatchModal(id) {
try { 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}`); const b = state.batches.find(x => x.id === id) || await api.get(`/batches/${id}`);
openModal('Edit Batch', ` openModal('Edit Batch', `
${batchFormHtml(b)} ${batchFormHtml(b)}
@@ -909,6 +948,7 @@ async function loadAdmin() {
<tr> <tr>
<th>Email</th> <th>Email</th>
<th>Joined</th> <th>Joined</th>
<th>Last Login</th>
<th>Varieties</th> <th>Varieties</th>
<th>Batches</th> <th>Batches</th>
<th>Status</th> <th>Status</th>
@@ -920,6 +960,7 @@ async function loadAdmin() {
<tr id="admin-row-${u.id}"> <tr id="admin-row-${u.id}">
<td><span class="admin-email">${esc(u.email)}</span>${u.is_admin ? ' <span class="badge-admin">admin</span>' : ''}</td> <td><span class="admin-email">${esc(u.email)}</span>${u.is_admin ? ' <span class="badge-admin">admin</span>' : ''}</td>
<td class="admin-date">${fmt(u.created_at)}</td> <td class="admin-date">${fmt(u.created_at)}</td>
<td class="admin-date">${u.last_login_at ? fmt(u.last_login_at) : '<span class="text-muted">Never</span>'}</td>
<td class="admin-num">${u.variety_count}</td> <td class="admin-num">${u.variety_count}</td>
<td class="admin-num">${u.batch_count}</td> <td class="admin-num">${u.batch_count}</td>
<td><span class="status-pill ${u.is_disabled ? 'disabled' : 'active'}">${u.is_disabled ? 'Disabled' : 'Active'}</span></td> <td><span class="status-pill ${u.is_disabled ? 'disabled' : 'active'}">${u.is_disabled ? 'Disabled' : 'Active'}</span></td>
@@ -1091,7 +1132,7 @@ async function init() {
// ===== Public API ===== // ===== Public API =====
window.App = { window.App = {
showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety, showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety,
showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch, showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch, autofillBatchDates,
filterVarieties, filterBatches, filterVarieties, filterBatches,
saveSettings, toggleNtfyAuth, sendTestNotification, sendDailySummary, saveSettings, toggleNtfyAuth, sendTestNotification, sendDailySummary,
adminViewUser, adminResetPassword, adminSubmitReset, adminToggleDisable, adminDeleteUser, adminSwitchTab, adminViewUser, adminResetPassword, adminSubmitReset, adminToggleDisable, adminDeleteUser, adminSwitchTab,