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:
23
README.md
23
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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
|
||||
@@ -752,6 +754,7 @@ async function deleteVariety(id) {
|
||||
|
||||
// ===== Batch Modals =====
|
||||
function batchFormHtml(b = {}) {
|
||||
const isNew = !b.id;
|
||||
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>`
|
||||
).join('');
|
||||
@@ -760,7 +763,7 @@ function batchFormHtml(b = {}) {
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<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) {
|
||||
// Load varieties first if not loaded
|
||||
api.get('/varieties/').then(v => { state.varieties = v; showAddBatchModal(); });
|
||||
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>');
|
||||
return;
|
||||
}
|
||||
openModal('Log a Batch', `
|
||||
@@ -834,6 +870,9 @@ function showAddBatchModal() {
|
||||
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
|
||||
</div>
|
||||
`);
|
||||
// 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() {
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Joined</th>
|
||||
<th>Last Login</th>
|
||||
<th>Varieties</th>
|
||||
<th>Batches</th>
|
||||
<th>Status</th>
|
||||
@@ -920,6 +960,7 @@ async function loadAdmin() {
|
||||
<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 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.batch_count}</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 =====
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user