Phase 5: per-child dashboard (overdue first), item detail, delete
- internal/dashboard: classify items into overdue / due today / due this week /
no due date / messages; render the plain-text brief (FR-6, FR-7, FR-9).
- internal/store: ItemsForChild, ItemByID, DeleteItemByID, ScanSummaryForChild
and the stored-brief CRUD helpers (FR-10).
- internal/web: routes /?child=, /items/{id}, POST /items/{id}/delete,
POST /summaries/build, POST /summaries/{id}/delete, plus a per-child scan
banner (FR-12) and a phone-first server-rendered template.
- Calendar events are never "overdue": a past lesson is history, not work.
- cmd/school brief build|list; scripts/screenshot-phase5.mjs for phone evidence.
- Tests: bucket classification, stored-brief round-trip, and HTTP routes
including the NO SCAN / SCAN FAILED / healthy banner behaviour.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
223
internal/store/dashboard.go
Normal file
223
internal/store/dashboard.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file holds the read/write helpers the Phase 5 dashboard needs:
|
||||
// per-child item reads, one item, item delete, a per-child scan summary, and
|
||||
// the draft summaries (FR-7, FR-10, FR-12).
|
||||
|
||||
// itemSelect is the shared column list for an item row joined to its child.
|
||||
const itemSelect = `SELECT i.id, i.child_id, COALESCE(ch.name, ''), i.source, i.type,
|
||||
COALESCE(i.subject, ''), COALESCE(i.teacher, ''), i.title,
|
||||
COALESCE(i.body, ''), i.due_at, COALESCE(i.url, ''),
|
||||
COALESCE(i.source_page, ''), COALESCE(i.importance, ''), COALESCE(i.external_ref, '')
|
||||
FROM items i LEFT JOIN children ch ON ch.id = i.child_id`
|
||||
|
||||
// scanItem reads one item row from the shared column list.
|
||||
func scanItem(r rowScanner) (Item, error) {
|
||||
var it Item
|
||||
var due sql.NullString
|
||||
if err := r.Scan(&it.ID, &it.ChildID, &it.Child, &it.Source, &it.Type,
|
||||
&it.Subject, &it.Teacher, &it.Title, &it.Body, &due, &it.URL,
|
||||
&it.SourcePage, &it.Importance, &it.ExternalRef); err != nil {
|
||||
return it, err
|
||||
}
|
||||
if due.Valid {
|
||||
t := parseTime(due.String)
|
||||
it.DueAt = &t
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// ItemsForChild returns every stored item for one child. The dashboard classifies
|
||||
// the rows into overdue / due today / due this week / messages (FR-7); a child
|
||||
// never sees another child's rows.
|
||||
func (s *Store) ItemsForChild(ctx context.Context, childID int64) ([]Item, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
itemSelect+` WHERE i.child_id = ? ORDER BY i.type, i.due_at, i.id`, childID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("items for child %d: %w", childID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Item
|
||||
for rows.Next() {
|
||||
it, err := scanItem(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan item: %w", err)
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ItemByID returns one item, or nil when it does not exist. The item detail
|
||||
// screen uses it (AGENTS.md §5 screen 3).
|
||||
func (s *Store) ItemByID(ctx context.Context, id int64) (*Item, error) {
|
||||
row := s.db.QueryRowContext(ctx, itemSelect+` WHERE i.id = ?`, id)
|
||||
it, err := scanItem(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("item %d: %w", id, err)
|
||||
}
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
// DeleteItemByID removes one item. It is the Delete half of FR-10: a deleted
|
||||
// item returns on the next parse if the school still lists it. It returns the
|
||||
// number of rows removed (0 when the id was already gone).
|
||||
func (s *Store) DeleteItemByID(ctx context.Context, id int64) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete item %d: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete item %d: %w", id, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ChildByID returns one child, or nil when it does not exist.
|
||||
func (s *Store) ChildByID(ctx context.Context, id int64) (*Child, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, COALESCE(year, ''), COALESCE(compass_id, ''), created_at
|
||||
FROM children WHERE id = ?`, id)
|
||||
var c Child
|
||||
var created string
|
||||
if err := row.Scan(&c.ID, &c.Name, &c.Year, &c.CompassID, &created); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("child %d: %w", id, err)
|
||||
}
|
||||
c.CreatedAt = parseTime(created)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ScanSummaryForChild is ScanSummary limited to one child, so a failed scan for
|
||||
// Finn is never hidden by a successful scan for Harry (FR-12).
|
||||
func (s *Store) ScanSummaryForChild(ctx context.Context, childID int64) (ScanStatus, error) {
|
||||
var st ScanStatus
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END), 0)
|
||||
FROM scans WHERE child_id = ?`, childID).
|
||||
Scan(&st.Total, &st.Failed); err != nil {
|
||||
return st, fmt.Errorf("scan summary for child %d: %w", childID, err)
|
||||
}
|
||||
st.EverRun = st.Total > 0
|
||||
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT sc.id, sc.child_id, COALESCE(ch.name, ''), sc.started_at,
|
||||
sc.finished_at, sc.pages_fetched, sc.items_found, sc.ok, COALESCE(sc.error, '')
|
||||
FROM scans sc LEFT JOIN children ch ON ch.id = sc.child_id
|
||||
WHERE sc.child_id = ?
|
||||
ORDER BY sc.started_at DESC, sc.id DESC LIMIT 1`, childID)
|
||||
sc, err := scanRow(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return st, nil
|
||||
}
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.Last = &sc
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Summary is one stored brief (AGENTS.md §7 notifications): either a draft
|
||||
// (SentAt nil) or a delivered brief. It is the summary half of FR-10.
|
||||
type Summary struct {
|
||||
ID int64
|
||||
Recipient string
|
||||
Text string
|
||||
SentAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateSummary stores a brief for one recipient. sent_at stays NULL for a
|
||||
// draft; Phase 7 sets it when the brief is sent.
|
||||
func (s *Store) CreateSummary(ctx context.Context, recipient, text string) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO notifications (recipient, summary, sent_at) VALUES (?, ?, NULL)`,
|
||||
recipient, text)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create summary for %q: %w", recipient, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create summary for %q: %w", recipient, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListSummaries returns recent briefs, newest first.
|
||||
func (s *Store) ListSummaries(ctx context.Context, limit int) ([]Summary, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, recipient, summary, sent_at, created_at
|
||||
FROM notifications ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list summaries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Summary
|
||||
for rows.Next() {
|
||||
var sm Summary
|
||||
var sent sql.NullString
|
||||
var created string
|
||||
if err := rows.Scan(&sm.ID, &sm.Recipient, &sm.Text, &sent, &created); err != nil {
|
||||
return nil, fmt.Errorf("scan summary: %w", err)
|
||||
}
|
||||
if sent.Valid {
|
||||
t := parseTime(sent.String)
|
||||
sm.SentAt = &t
|
||||
}
|
||||
sm.CreatedAt = parseTime(created)
|
||||
out = append(out, sm)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SummaryByID returns one brief, or nil when it does not exist.
|
||||
func (s *Store) SummaryByID(ctx context.Context, id int64) (*Summary, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, recipient, summary, sent_at, created_at FROM notifications WHERE id = ?`, id)
|
||||
var sm Summary
|
||||
var sent sql.NullString
|
||||
var created string
|
||||
if err := row.Scan(&sm.ID, &sm.Recipient, &sm.Text, &sent, &created); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("summary %d: %w", id, err)
|
||||
}
|
||||
if sent.Valid {
|
||||
t := parseTime(sent.String)
|
||||
sm.SentAt = &t
|
||||
}
|
||||
sm.CreatedAt = parseTime(created)
|
||||
return &sm, nil
|
||||
}
|
||||
|
||||
// DeleteSummary removes one stored brief. It returns the number of rows removed.
|
||||
func (s *Store) DeleteSummary(ctx context.Context, id int64) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM notifications WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete summary %d: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete summary %d: %w", id, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
132
internal/store/dashboard_test.go
Normal file
132
internal/store/dashboard_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestItemsForChildAndDelete covers the Phase 5 reads and the Delete half of
|
||||
// FR-10: one child's items are read alone, one item is fetched by id, and
|
||||
// deleting it removes exactly one row.
|
||||
func TestItemsForChildAndDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
finn, _ := st.ChildByName(ctx, "Finn")
|
||||
|
||||
due := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, []Item{
|
||||
{Source: "compass", Type: "task", Subject: "English", Title: "Essay", ExternalRef: "h1", DueAt: &due, Body: "Status: Overdue"},
|
||||
{Source: "compass", Type: "message", Title: "Notice", ExternalRef: "h2"},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert harry: %v", err)
|
||||
}
|
||||
if _, _, err := st.UpsertItems(ctx, finn.ID, []Item{
|
||||
{Source: "compass", Type: "task", Title: "Lab report", ExternalRef: "f1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert finn: %v", err)
|
||||
}
|
||||
|
||||
rows, err := st.ItemsForChild(ctx, harry.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("items for child: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("harry rows = %d, want 2", len(rows))
|
||||
}
|
||||
var essay *Item
|
||||
for i := range rows {
|
||||
if rows[i].ChildID != harry.ID {
|
||||
t.Errorf("row belongs to child %d", rows[i].ChildID)
|
||||
}
|
||||
if rows[i].ExternalRef == "h1" {
|
||||
essay = &rows[i]
|
||||
}
|
||||
}
|
||||
if essay == nil || essay.DueAt == nil || !essay.DueAt.Equal(due) {
|
||||
t.Fatalf("essay due not stored: %+v", essay)
|
||||
}
|
||||
|
||||
got, err := st.ItemByID(ctx, essay.ID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("item by id: %v %v", got, err)
|
||||
}
|
||||
if got.Title != "Essay" || got.Child != "Harry" {
|
||||
t.Errorf("item by id = %+v", got)
|
||||
}
|
||||
|
||||
n, err := st.DeleteItemByID(ctx, essay.ID)
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("delete = %d, %v", n, err)
|
||||
}
|
||||
if got, _ := st.ItemByID(ctx, essay.ID); got != nil {
|
||||
t.Error("item survived delete")
|
||||
}
|
||||
if rows, _ := st.ItemsForChild(ctx, finn.ID); len(rows) != 1 {
|
||||
t.Errorf("finn rows = %d, want 1 (delete must not cross children)", len(rows))
|
||||
}
|
||||
// Deleting a missing id is a quiet no-op, not an error.
|
||||
if n, err := st.DeleteItemByID(ctx, essay.ID); err != nil || n != 0 {
|
||||
t.Errorf("second delete = %d, %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSummariesRoundTrip covers the stored brief: create a draft, list it,
|
||||
// fetch it and delete it (FR-10 summary half).
|
||||
func TestSummariesRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
|
||||
id, err := st.CreateSummary(ctx, "Harry", "Harry — 1 overdue.\n\nOVERDUE\n- Essay\n")
|
||||
if err != nil || id == 0 {
|
||||
t.Fatalf("create: %v id=%d", err, id)
|
||||
}
|
||||
list, err := st.ListSummaries(ctx, 10)
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list: %v len=%d", err, len(list))
|
||||
}
|
||||
if list[0].Recipient != "Harry" || list[0].SentAt != nil {
|
||||
t.Errorf("summary = %+v, want draft for Harry", list[0])
|
||||
}
|
||||
got, err := st.SummaryByID(ctx, id)
|
||||
if err != nil || got == nil || got.Text != list[0].Text {
|
||||
t.Fatalf("by id: %v %+v", err, got)
|
||||
}
|
||||
n, err := st.DeleteSummary(ctx, id)
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("delete = %d, %v", n, err)
|
||||
}
|
||||
if got, _ := st.SummaryByID(ctx, id); got != nil {
|
||||
t.Error("summary survived delete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanSummaryForChild proves a failed scan for one child is visible for
|
||||
// that child even when the other child has not been scanned (FR-12).
|
||||
func TestScanSummaryForChild(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
finn, _ := st.ChildByName(ctx, "Finn")
|
||||
|
||||
if _, err := st.RecordScan(ctx, &finn.ID, 0, 0, false, "login challenge"); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
if _, err := st.RecordScan(ctx, &harry.ID, 3, 12, true, ""); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
|
||||
hs, err := st.ScanSummaryForChild(ctx, harry.ID)
|
||||
if err != nil || !hs.EverRun || hs.Last == nil || !hs.Last.OK {
|
||||
t.Fatalf("harry scan summary = %+v, %v", hs, err)
|
||||
}
|
||||
fs, err := st.ScanSummaryForChild(ctx, finn.ID)
|
||||
if err != nil || !fs.EverRun || fs.Last == nil || fs.Last.OK {
|
||||
t.Fatalf("finn scan summary = %+v, %v", fs, err)
|
||||
}
|
||||
if fs.Failed != 1 {
|
||||
t.Errorf("finn failed = %d, want 1", fs.Failed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user