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
|
## 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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user