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:
@@ -1,6 +1,7 @@
|
||||
// Package web is the HTTP surface of the Compass/NHS service: /health, the
|
||||
// service's own session login, and the server-rendered HTMX dashboard
|
||||
// (AGENTS.md §5, §9).
|
||||
// (AGENTS.md §5, §9). Phase 5 adds the per-child dashboard, the item detail
|
||||
// screen, and the Delete actions (FR-7, FR-10, FR-12).
|
||||
package web
|
||||
|
||||
import (
|
||||
@@ -12,11 +13,13 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
@@ -57,7 +60,12 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /login", s.handleLoginPage)
|
||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
|
||||
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
|
||||
mux.HandleFunc("GET /items/{id}", s.requireAuth(s.handleItemDetail))
|
||||
mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete))
|
||||
mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild))
|
||||
mux.HandleFunc("POST /summaries/{id}/delete", s.requireAuth(s.handleSummaryDelete))
|
||||
mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial))
|
||||
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
@@ -130,7 +138,7 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- handlers ----------
|
||||
// ---------- health ----------
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
@@ -164,6 +172,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ---------- login ----------
|
||||
|
||||
type loginData struct {
|
||||
CSRF string
|
||||
Error string
|
||||
@@ -220,19 +230,22 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// scanStatusView is the fail-loudly view of scan health (FR-12). It is shared
|
||||
// by the dashboard and the HTMX partial.
|
||||
// ---------- fail-loudly scan banner (FR-12) ----------
|
||||
|
||||
// scanStatusView is the fail-loudly view of scan health. It is shared by the
|
||||
// dashboard, the item detail page and the HTMX partial. The template never
|
||||
// renders an empty day as "nothing due".
|
||||
type scanStatusView struct {
|
||||
store.ScanStatus
|
||||
// Level is "none", "error", "ok" or "warn". The template never renders an
|
||||
// empty day as "nothing due".
|
||||
// Level is "none", "error", "ok" or "warn".
|
||||
Level string
|
||||
Message string
|
||||
LastAt string
|
||||
}
|
||||
|
||||
func (s *Server) scanStatus(ctx context.Context) scanStatusView {
|
||||
st, err := s.store.ScanSummary(ctx)
|
||||
// scanStatusFor builds the banner from a scan summary. The caller supplies the
|
||||
// summary so the dashboard can show the global view and the per-child view.
|
||||
func (s *Server) scanStatusFor(st store.ScanStatus, err error) scanStatusView {
|
||||
if err != nil {
|
||||
s.log.Error("scan summary", "err", err)
|
||||
return scanStatusView{Level: "error", Message: "Could not read the scan log: " + err.Error()}
|
||||
@@ -246,6 +259,9 @@ func (s *Server) scanStatus(ctx context.Context) scanStatusView {
|
||||
if st.Last != nil && !st.Last.OK {
|
||||
v.Level = "error"
|
||||
v.Message = "SCAN FAILED — the last scan did not complete, so due work is UNKNOWN."
|
||||
if st.Last.Error != "" {
|
||||
v.Message += " " + st.Last.Error
|
||||
}
|
||||
v.LastAt = st.Last.StartedAt.Format("Mon 02 Jan 2006 15:04")
|
||||
return v
|
||||
}
|
||||
@@ -257,50 +273,413 @@ func (s *Server) scanStatus(ctx context.Context) scanStatusView {
|
||||
return v
|
||||
}
|
||||
|
||||
type childView struct {
|
||||
store.Child
|
||||
func (s *Server) scanStatus(ctx context.Context) scanStatusView {
|
||||
st, err := s.store.ScanSummary(ctx)
|
||||
return s.scanStatusFor(st, err)
|
||||
}
|
||||
|
||||
// scanOK reports whether the banner is healthy, so an empty section may say
|
||||
// "nothing due" instead of "unknown".
|
||||
func (v scanStatusView) OK() bool { return v.Level == "ok" }
|
||||
|
||||
// ---------- dashboard (FR-6, FR-7) ----------
|
||||
|
||||
type itemView struct {
|
||||
store.Item
|
||||
DueLabel string
|
||||
StatusLabel string
|
||||
TypeLabel string
|
||||
BodyLines []string
|
||||
}
|
||||
|
||||
type sectionView struct {
|
||||
Key string
|
||||
Title string
|
||||
Tone string
|
||||
Empty string
|
||||
Items []itemView
|
||||
}
|
||||
|
||||
type childPanel struct {
|
||||
ID int64
|
||||
Name string
|
||||
Year string
|
||||
Headline string
|
||||
Overdue int
|
||||
Scan scanStatusView
|
||||
LaterCount int
|
||||
DoneCount int
|
||||
Sections []sectionView
|
||||
}
|
||||
|
||||
type childTab struct {
|
||||
ID int64
|
||||
Name string
|
||||
Overdue int
|
||||
Active bool
|
||||
}
|
||||
|
||||
type summaryView struct {
|
||||
ID int64
|
||||
Recipient string
|
||||
Created string
|
||||
Sent string
|
||||
Draft bool
|
||||
Text string
|
||||
}
|
||||
|
||||
type dashboardData struct {
|
||||
Title string
|
||||
Username string
|
||||
CSRF string
|
||||
Version string
|
||||
AppEnv string
|
||||
Children []childView
|
||||
Scan scanStatusView
|
||||
ServerUp string
|
||||
ErrMsg string
|
||||
Title string
|
||||
Username string
|
||||
CSRF string
|
||||
Version string
|
||||
AppEnv string
|
||||
Scan scanStatusView
|
||||
Tabs []childTab
|
||||
Child childPanel
|
||||
HasChild bool
|
||||
Summaries []summaryView
|
||||
ServerUp string
|
||||
ErrMsg string
|
||||
}
|
||||
|
||||
var serverStarted = time.Now()
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
children, err := s.store.ListChildren(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("list children", "err", err)
|
||||
}
|
||||
views := make([]childView, 0, len(children))
|
||||
for _, c := range children {
|
||||
views = append(views, childView{Child: c})
|
||||
}
|
||||
|
||||
s.render(w, r, "dashboard.html", dashboardData{
|
||||
Title: "Dashboard",
|
||||
loc := s.cfg.Location
|
||||
data := dashboardData{
|
||||
Title: "What is due",
|
||||
Username: s.cfg.AdminUsername,
|
||||
CSRF: s.auth.EnsureCSRF(w, r),
|
||||
Version: s.cfg.Version,
|
||||
AppEnv: s.cfg.AppEnv,
|
||||
Children: views,
|
||||
Scan: s.scanStatus(ctx),
|
||||
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
|
||||
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
|
||||
}
|
||||
|
||||
children, err := s.store.ListChildren(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("list children", "err", err)
|
||||
data.ErrMsg = "Could not read the children: " + err.Error()
|
||||
}
|
||||
|
||||
// Which tab? ?child=ID, else the first child.
|
||||
selected := int64(0)
|
||||
if v, err := strconv.ParseInt(r.URL.Query().Get("child"), 10, 64); err == nil {
|
||||
selected = v
|
||||
}
|
||||
found := false
|
||||
for _, c := range children {
|
||||
if selected == 0 || c.ID == selected {
|
||||
if !found {
|
||||
selected = c.ID
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found && len(children) > 0 {
|
||||
selected = children[0].ID
|
||||
found = true
|
||||
}
|
||||
|
||||
overdueByChild := map[int64]int{}
|
||||
for _, c := range children {
|
||||
items, err := s.store.ItemsForChild(ctx, c.ID)
|
||||
if err != nil {
|
||||
s.log.Error("items for child", "child", c.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
overdueByChild[c.ID] = len(dashboard.Classify(items, time.Now(), loc).Overdue)
|
||||
}
|
||||
|
||||
for _, c := range children {
|
||||
tab := childTab{ID: c.ID, Name: c.Name, Overdue: overdueByChild[c.ID], Active: c.ID == selected}
|
||||
data.Tabs = append(data.Tabs, tab)
|
||||
if c.ID == selected {
|
||||
data.Child = s.buildChildPanel(ctx, c, loc)
|
||||
data.HasChild = true
|
||||
}
|
||||
}
|
||||
|
||||
summaries, err := s.store.ListSummaries(ctx, 10)
|
||||
if err != nil {
|
||||
s.log.Error("list summaries", "err", err)
|
||||
}
|
||||
for _, sm := range summaries {
|
||||
v := summaryView{
|
||||
ID: sm.ID,
|
||||
Recipient: sm.Recipient,
|
||||
Created: sm.CreatedAt.Format("Mon 02 Jan 15:04"),
|
||||
Text: sm.Text,
|
||||
Draft: sm.SentAt == nil,
|
||||
}
|
||||
if sm.SentAt != nil {
|
||||
v.Sent = sm.SentAt.Format("Mon 02 Jan 15:04")
|
||||
} else {
|
||||
v.Sent = "draft"
|
||||
}
|
||||
data.Summaries = append(data.Summaries, v)
|
||||
}
|
||||
|
||||
s.render(w, r, "dashboard.html", data)
|
||||
}
|
||||
|
||||
// buildChildPanel classifies one child's items and prepares the sections.
|
||||
func (s *Server) buildChildPanel(ctx context.Context, c store.Child, loc *time.Location) childPanel {
|
||||
items, err := s.store.ItemsForChild(ctx, c.ID)
|
||||
if err != nil {
|
||||
s.log.Error("items for child", "child", c.Name, "err", err)
|
||||
}
|
||||
b := dashboard.Classify(items, time.Now(), loc)
|
||||
|
||||
sc, scErr := s.store.ScanSummaryForChild(ctx, c.ID)
|
||||
scan := s.scanStatusFor(sc, scErr)
|
||||
|
||||
p := childPanel{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Year: c.Year,
|
||||
Headline: b.Headline(),
|
||||
Overdue: len(b.Overdue),
|
||||
Scan: scan,
|
||||
LaterCount: len(b.Later),
|
||||
DoneCount: len(b.Done),
|
||||
}
|
||||
|
||||
known := scan.OK()
|
||||
p.Sections = []sectionView{
|
||||
{Key: "overdue", Title: "Overdue", Tone: "danger", Items: itemViews(b.Overdue, loc),
|
||||
Empty: emptyText(known, "Nothing overdue.")},
|
||||
{Key: "today", Title: "Due today", Items: itemViews(b.DueToday, loc),
|
||||
Empty: emptyText(known, "Nothing due today.")},
|
||||
{Key: "week", Title: "Due this week", Items: itemViews(b.DueWeek, loc),
|
||||
Empty: emptyText(known, "Nothing else due this week.")},
|
||||
}
|
||||
if len(b.NoDue) > 0 {
|
||||
p.Sections = append(p.Sections, sectionView{Key: "nodue", Title: "No due date",
|
||||
Items: itemViews(b.NoDue, loc)})
|
||||
}
|
||||
p.Sections = append(p.Sections, sectionView{Key: "messages", Title: "Messages",
|
||||
Items: itemViews(b.Messages, loc), Empty: emptyText(known, "No messages.")})
|
||||
return p
|
||||
}
|
||||
|
||||
// emptyText never turns a failed or missing scan into "nothing due" (FR-12).
|
||||
func emptyText(scanKnown bool, ok string) string {
|
||||
if scanKnown {
|
||||
return ok
|
||||
}
|
||||
return "Unknown — the last scan did not complete."
|
||||
}
|
||||
|
||||
func itemViews(items []store.Item, loc *time.Location) []itemView {
|
||||
out := make([]itemView, 0, len(items))
|
||||
for _, it := range items {
|
||||
v := itemView{Item: it, TypeLabel: typeLabel(it.Type)}
|
||||
if it.DueAt != nil {
|
||||
v.DueLabel = it.DueAt.In(loc).Format("Mon 02 Jan 15:04")
|
||||
}
|
||||
if st := dashboard.Status(it.Body); st != "" && st != "pending" {
|
||||
v.StatusLabel = strings.ToUpper(st[:1]) + st[1:]
|
||||
}
|
||||
if strings.TrimSpace(it.Body) != "" {
|
||||
v.BodyLines = strings.Split(strings.TrimSpace(it.Body), "\n")
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func typeLabel(t string) string {
|
||||
switch t {
|
||||
case "task":
|
||||
return "Task"
|
||||
case "event":
|
||||
return "Event"
|
||||
case "message":
|
||||
return "Message"
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- item detail + delete (FR-10, §5 screen 3) ----------
|
||||
|
||||
type itemData struct {
|
||||
Title string
|
||||
Username string
|
||||
CSRF string
|
||||
Version string
|
||||
AppEnv string
|
||||
Item itemView
|
||||
Child store.Child
|
||||
Scan scanStatusView
|
||||
ServerUp string
|
||||
}
|
||||
|
||||
func (s *Server) handleItemDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, ok := pathID(r, "id")
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
it, err := s.store.ItemByID(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Error("item", "id", id, "err", err)
|
||||
http.Error(w, "could not read the item", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
child, err := s.store.ChildByID(ctx, it.ChildID)
|
||||
if err != nil || child == nil {
|
||||
child = &store.Child{ID: it.ChildID, Name: it.Child}
|
||||
}
|
||||
|
||||
s.render(w, r, "item.html", itemData{
|
||||
Title: it.Title,
|
||||
Username: s.cfg.AdminUsername,
|
||||
CSRF: s.auth.EnsureCSRF(w, r),
|
||||
Version: s.cfg.Version,
|
||||
AppEnv: s.cfg.AppEnv,
|
||||
Item: itemViews([]store.Item{*it}, s.cfg.Location)[0],
|
||||
Child: *child,
|
||||
Scan: s.scanStatus(ctx),
|
||||
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleItemDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.auth.CheckCSRF(r) {
|
||||
s.log.Warn("delete rejected: bad csrf token", "path", r.URL.Path)
|
||||
http.Error(w, "session expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id, ok := pathID(r, "id")
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
it, err := s.store.ItemByID(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Error("item", "id", id, "err", err)
|
||||
http.Error(w, "could not read the item", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.DeleteItemByID(ctx, id); err != nil {
|
||||
s.log.Error("delete item", "id", id, "err", err)
|
||||
http.Error(w, "could not delete the item", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.log.Info("item deleted", "id", id, "child", it.Child, "title", it.Title)
|
||||
s.redirectAfterWrite(w, r, "/?child="+strconv.FormatInt(it.ChildID, 10))
|
||||
}
|
||||
|
||||
func (s *Server) handleSummaryBuild(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.auth.CheckCSRF(r) {
|
||||
http.Error(w, "session expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
childID, err := strconv.ParseInt(r.PostFormValue("child"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
child, err := s.store.ChildByID(ctx, childID)
|
||||
if err != nil || child == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
items, err := s.store.ItemsForChild(ctx, childID)
|
||||
if err != nil {
|
||||
s.log.Error("items for child", "child", child.Name, "err", err)
|
||||
http.Error(w, "could not read the items", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
text := dashboard.Render(child.Name, dashboard.Classify(items, time.Now(), s.cfg.Location), s.cfg.Location)
|
||||
id, err := s.store.CreateSummary(ctx, child.Name, text)
|
||||
if err != nil {
|
||||
s.log.Error("create summary", "child", child.Name, "err", err)
|
||||
http.Error(w, "could not store the summary", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.log.Info("summary built", "id", id, "recipient", child.Name)
|
||||
s.redirectAfterWrite(w, r, "/?child="+strconv.FormatInt(childID, 10))
|
||||
}
|
||||
|
||||
func (s *Server) handleSummaryDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.auth.CheckCSRF(r) {
|
||||
http.Error(w, "session expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id, ok := pathID(r, "id")
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.DeleteSummary(ctx, id); err != nil {
|
||||
s.log.Error("delete summary", "id", id, "err", err)
|
||||
http.Error(w, "could not delete the summary", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.log.Info("summary deleted", "id", id)
|
||||
s.redirectAfterWrite(w, r, "/")
|
||||
}
|
||||
|
||||
// redirectAfterWrite answers an HTMX request with 204 so the element can be
|
||||
// removed, and a plain form post with a redirect to the dashboard.
|
||||
func (s *Server) redirectAfterWrite(w http.ResponseWriter, r *http.Request, to string) {
|
||||
if r.Header.Get("HX-Request") != "" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func pathID(r *http.Request, name string) (int64, bool) {
|
||||
v, err := strconv.ParseInt(r.PathValue(name), 10, 64)
|
||||
if err != nil || v <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// ---------- partials ----------
|
||||
|
||||
func (s *Server) handleScanStatusPartial(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
309
internal/web/server_test.go
Normal file
309
internal/web/server_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "time/tzdata"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
testUser = "sam"
|
||||
testPass = "correct horse battery staple"
|
||||
)
|
||||
|
||||
var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
|
||||
|
||||
// newTestServer builds the web handler against a fresh SQLite database.
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation("Australia/Sydney")
|
||||
if err != nil {
|
||||
t.Fatalf("location: %v", err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "school.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
if err := st.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := st.SeedChildren(context.Background(), "Harry", "Finn"); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Addr: ":0",
|
||||
DBPath: "test",
|
||||
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
AdminUsername: testUser,
|
||||
AdminPassword: testPass,
|
||||
SessionTTL: time.Hour,
|
||||
AppEnv: "test",
|
||||
Version: "test",
|
||||
Location: loc,
|
||||
}
|
||||
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("web: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, st
|
||||
}
|
||||
|
||||
// login signs in and returns an HTTP client with the session cookie.
|
||||
func login(t *testing.T, ts *httptest.Server) *http.Client {
|
||||
t.Helper()
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("jar: %v", err)
|
||||
}
|
||||
client := &http.Client{Jar: jar}
|
||||
|
||||
page := getBody(t, client, ts.URL+"/login")
|
||||
token := csrfToken(t, page)
|
||||
form := url.Values{"csrf": {token}, "username": {testUser}, "password": {testPass}}
|
||||
resp, err := client.PostForm(ts.URL+"/login", form)
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
// After login, do not follow redirects: a mutation's 303 is the assertion.
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func getBody(t *testing.T, client *http.Client, url string) string {
|
||||
t.Helper()
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get %s = %d, want 200", url, resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func csrfToken(t *testing.T, html string) string {
|
||||
t.Helper()
|
||||
m := csrfRe.FindStringSubmatch(html)
|
||||
if m == nil {
|
||||
t.Fatalf("no csrf token in page")
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// TestDashboardShowsChildItems proves FR-7: the selected child's items appear,
|
||||
// overdue first, and another child's items do not leak in.
|
||||
func TestDashboardShowsChildItems(t *testing.T) {
|
||||
ts, st := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
finn, _ := st.ChildByName(ctx, "Finn")
|
||||
|
||||
overdue := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)
|
||||
today := time.Now().Add(2 * time.Hour)
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{
|
||||
{Source: "compass", Type: "task", Subject: "11EENG", Title: "Overdue essay", ExternalRef: "h1", DueAt: &overdue, Body: "Status: Overdue"},
|
||||
{Source: "compass", Type: "task", Subject: "11MGMA", Title: "Today chapter", ExternalRef: "h2", DueAt: &today, Body: "Status: Pending"},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
if _, _, err := st.UpsertItems(ctx, finn.ID, []store.Item{
|
||||
{Source: "compass", Type: "task", Title: "Finn secret task", ExternalRef: "f1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert finn: %v", err)
|
||||
}
|
||||
|
||||
client := login(t, ts)
|
||||
body := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
|
||||
|
||||
for _, want := range []string{
|
||||
"Overdue essay", "Today chapter", "Overdue", "Due today", "11EENG",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("dashboard missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "Finn secret task") {
|
||||
t.Error("Harry's dashboard leaked Finn's item")
|
||||
}
|
||||
oi := strings.Index(body, "Overdue essay")
|
||||
ti := strings.Index(body, "Today chapter")
|
||||
if oi < 0 || ti < 0 || oi > ti {
|
||||
t.Error("overdue item must render before the due-today item")
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemDetailAndDelete proves §5 screen 3 and the Delete half of FR-10.
|
||||
func TestItemDetailAndDelete(t *testing.T) {
|
||||
ts, st := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{
|
||||
{Source: "compass", Type: "task", Subject: "11EENG", Teacher: "Ms Lee",
|
||||
Title: "Essay draft", Body: "Status: Overdue\nClass: Class Task", ExternalRef: "h1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
items, _ := st.ItemsForChild(ctx, harry.ID)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1", len(items))
|
||||
}
|
||||
id := items[0].ID
|
||||
|
||||
client := login(t, ts)
|
||||
detail := getBody(t, client, ts.URL+"/items/"+itoa(id))
|
||||
for _, want := range []string{"Essay draft", "11EENG", "Ms Lee", "Class Task"} {
|
||||
if !strings.Contains(detail, want) {
|
||||
t.Errorf("detail missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/delete",
|
||||
url.Values{"csrf": {csrfToken(t, detail)}})
|
||||
if err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("delete status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if got, _ := st.ItemByID(ctx, id); got != nil {
|
||||
t.Error("item survived delete")
|
||||
}
|
||||
|
||||
// The delete without a CSRF token is refused.
|
||||
resp2, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/delete", url.Values{})
|
||||
if err != nil {
|
||||
t.Fatalf("delete2: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("tokenless delete = %d, want 403", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailLoudlyBanner proves FR-12: no scan is shown as NO SCAN, and a failed
|
||||
// scan is shown as SCAN FAILED, never as an empty day.
|
||||
func TestFailLoudlyBanner(t *testing.T) {
|
||||
ts, st := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
client := login(t, ts)
|
||||
|
||||
body := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
|
||||
if !strings.Contains(body, "NO SCAN HAS RUN YET") {
|
||||
t.Error("missing NO SCAN HAS RUN YET banner")
|
||||
}
|
||||
if strings.Contains(body, "Nothing overdue.") {
|
||||
t.Error("an unscanned child must not read as 'nothing overdue'")
|
||||
}
|
||||
if !strings.Contains(body, "Unknown — the last scan did not complete.") {
|
||||
t.Error("missing the unknown empty state for a child with no scan")
|
||||
}
|
||||
|
||||
if _, err := st.RecordScan(ctx, &harry.ID, 1, 0, false, "Cloudflare challenge"); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
body = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
|
||||
if !strings.Contains(body, "SCAN FAILED") {
|
||||
t.Error("missing SCAN FAILED banner")
|
||||
}
|
||||
if !strings.Contains(body, "Cloudflare challenge") {
|
||||
t.Error("failure message not shown")
|
||||
}
|
||||
|
||||
if _, err := st.RecordScan(ctx, &harry.ID, 4, 9, true, ""); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
body = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
|
||||
if !strings.Contains(body, "Last scan completed.") {
|
||||
t.Error("healthy scan banner missing")
|
||||
}
|
||||
if !strings.Contains(body, "Nothing overdue.") {
|
||||
t.Error("a healthy scan should allow the quiet empty state")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSummaryBuildAndDelete proves the stored brief round-trips through the
|
||||
// dashboard (FR-10 summary half).
|
||||
func TestSummaryBuildAndDelete(t *testing.T) {
|
||||
ts, st := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
client := login(t, ts)
|
||||
|
||||
page := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
|
||||
resp, err := client.PostForm(ts.URL+"/summaries/build",
|
||||
url.Values{"csrf": {csrfToken(t, page)}, "child": {itoa(harry.ID)}})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("build status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
summaries, err := st.ListSummaries(ctx, 10)
|
||||
if err != nil || len(summaries) != 1 {
|
||||
t.Fatalf("summaries = %d, %v", len(summaries), err)
|
||||
}
|
||||
|
||||
page = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
|
||||
if !strings.Contains(page, "Harry — 0 overdue") {
|
||||
t.Errorf("brief not shown on dashboard: %s", snippet(page))
|
||||
}
|
||||
resp2, err := client.PostForm(ts.URL+"/summaries/"+itoa(summaries[0].ID)+"/delete",
|
||||
url.Values{"csrf": {csrfToken(t, page)}})
|
||||
if err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("summary delete = %d, want 303", resp2.StatusCode)
|
||||
}
|
||||
if got, _ := st.SummaryByID(ctx, summaries[0].ID); got != nil {
|
||||
t.Error("summary survived delete")
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
|
||||
|
||||
func snippet(s string) string {
|
||||
if len(s) > 400 {
|
||||
return s[:400]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -316,6 +316,173 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
.app-foot__sep { color: var(--ink-faint); }
|
||||
|
||||
/* ---------- Child tabs (Phase 5) ---------- */
|
||||
|
||||
.child-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: var(--space-xxs);
|
||||
}
|
||||
.child-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-height: 44px;
|
||||
padding: 8px 18px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--rounded-full);
|
||||
background: var(--surface);
|
||||
color: var(--ink-secondary);
|
||||
font: var(--type-button);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.child-tab:hover { text-decoration: none; box-shadow: var(--shadow-level-1); }
|
||||
.child-tab--active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
.child-tab__badge {
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: var(--rounded-full);
|
||||
background: var(--sticker-red);
|
||||
color: #ffffff;
|
||||
font: var(--type-eyebrow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heading-3 { font: var(--type-title); color: var(--ink); }
|
||||
.heading-3--danger { color: var(--sticker-red); }
|
||||
.dot--danger { background: var(--sticker-red); }
|
||||
|
||||
/* ---------- Child panel ---------- */
|
||||
|
||||
.child { display: grid; gap: var(--space-lg); }
|
||||
.child__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.child__meta { margin: var(--space-xxs) 0 0; font: var(--type-body-sm); color: var(--ink-muted); }
|
||||
.child__foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
font: var(--type-caption);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.inline-form { margin: 0; }
|
||||
|
||||
.scan__pill--ok { background: color-mix(in srgb, var(--sticker-green) 16%, var(--surface)); color: #0f7a28; }
|
||||
.scan__pill--warn { background: color-mix(in srgb, var(--sticker-orange) 16%, var(--surface)); color: var(--sticker-orange-deep); }
|
||||
.scan__pill--error { background: color-mix(in srgb, var(--sticker-red) 16%, var(--surface)); color: var(--sticker-red); }
|
||||
|
||||
/* ---------- Item list ---------- */
|
||||
|
||||
.item-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--rounded-lg);
|
||||
box-shadow: var(--shadow-level-1);
|
||||
}
|
||||
.item--overdue { border-left: 4px solid var(--sticker-red); }
|
||||
.item__body { min-width: 0; display: grid; gap: var(--space-xxs); }
|
||||
.item__title { margin: 0; font: var(--type-title); color: var(--ink); overflow-wrap: anywhere; }
|
||||
.item__meta {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font: var(--type-caption);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.item__subject { color: var(--ink-secondary); }
|
||||
.item__due { color: var(--ink-secondary); }
|
||||
.item__actions { margin: 0; flex: 0 0 auto; }
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--rounded-full);
|
||||
font: var(--type-eyebrow);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.tag--type { background: var(--surface-2); color: var(--ink-muted); }
|
||||
.tag--status { background: color-mix(in srgb, var(--sticker-red) 14%, var(--surface)); color: var(--sticker-red); }
|
||||
|
||||
.button-utility--danger { color: var(--sticker-red); border-color: color-mix(in srgb, var(--sticker-red) 45%, var(--hairline)); }
|
||||
.button-utility--danger:hover { background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface)); }
|
||||
|
||||
.section__empty { margin: 0; padding: var(--space-sm) var(--space-md); font: var(--type-body-sm); color: var(--ink-muted); }
|
||||
|
||||
.brief {
|
||||
margin: var(--space-xs) 0 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--rounded-sm);
|
||||
font: var(--type-caption);
|
||||
color: var(--ink-secondary);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---------- Item detail ---------- */
|
||||
|
||||
.breadcrumb { margin: 0; font: var(--type-body-sm); }
|
||||
.detail {
|
||||
display: grid;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-xl);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--rounded-lg);
|
||||
box-shadow: var(--shadow-level-1);
|
||||
}
|
||||
.detail__head { display: grid; gap: var(--space-xs); }
|
||||
.detail__meta {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font: var(--type-body-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.detail__body { display: grid; gap: var(--space-xs); }
|
||||
.detail__body p { margin: 0; font: var(--type-body-md); color: var(--ink-secondary); overflow-wrap: anywhere; }
|
||||
.detail__facts {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, auto) 1fr;
|
||||
gap: var(--space-xxs) var(--space-md);
|
||||
margin: 0;
|
||||
font: var(--type-body-sm);
|
||||
}
|
||||
.detail__facts dt { color: var(--ink-muted); }
|
||||
.detail__facts dd { margin: 0; color: var(--ink-secondary); overflow-wrap: anywhere; }
|
||||
.detail__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
|
||||
.detail__actions form { margin: 0; }
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -325,4 +492,12 @@ a:hover { text-decoration: underline; }
|
||||
.nav-bar { padding: var(--space-sm) var(--space-md); }
|
||||
.nav-bar__user { display: none; }
|
||||
.section__head { flex-wrap: wrap; }
|
||||
.detail { padding: var(--space-lg); }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.item { flex-direction: column; align-items: stretch; gap: var(--space-sm); }
|
||||
.item__actions { align-self: flex-end; }
|
||||
.item__actions .button-utility { width: 100%; }
|
||||
.child__head { flex-direction: column; }
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
<main class="container">
|
||||
<header class="page-head">
|
||||
<h1 class="heading-1">What is due</h1>
|
||||
<p class="page-head__sub">Overdue work first. Compass for the schedule, Teams for homework.
|
||||
This is Phase 1: the skeleton is live, the school scan is not connected yet.</p>
|
||||
<p class="page-head__sub">Overdue first. Compass for the schedule and notices,
|
||||
Teams for homework.</p>
|
||||
</header>
|
||||
|
||||
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
|
||||
@@ -39,32 +39,116 @@
|
||||
{{template "scan-status.html" .Scan}}
|
||||
</div>
|
||||
|
||||
<section class="section" aria-labelledby="children-title">
|
||||
<div class="section__head">
|
||||
<h2 id="children-title" class="heading-2">Children</h2>
|
||||
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Children}} on file</span>
|
||||
{{if .Tabs}}
|
||||
<nav class="child-tabs" aria-label="Children">
|
||||
{{range .Tabs}}
|
||||
<a class="child-tab{{if .Active}} child-tab--active{{end}}" href="/?child={{.ID}}"
|
||||
{{if .Active}}aria-current="page"{{end}}>
|
||||
{{.Name}}
|
||||
{{if .Overdue}}<span class="child-tab__badge">{{.Overdue}}</span>{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
{{if .HasChild}}
|
||||
<section class="child" aria-labelledby="child-title">
|
||||
<div class="child__head">
|
||||
<div>
|
||||
<h2 id="child-title" class="heading-2">{{.Child.Name}}</h2>
|
||||
<p class="child__meta">{{if .Child.Year}}Year {{.Child.Year}} · {{end}}{{.Child.Headline}}</p>
|
||||
</div>
|
||||
<span class="category-pill scan__pill scan__pill--{{.Child.Scan.Level}}">
|
||||
{{if eq .Child.Scan.Level "ok"}}scan healthy
|
||||
{{else if eq .Child.Scan.Level "error"}}scan failed
|
||||
{{else}}no scan yet{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{if .Children}}
|
||||
<ul class="child-list">
|
||||
{{range .Children}}
|
||||
<li class="child-card">
|
||||
<p class="child-card__name">{{.Name}}</p>
|
||||
<p class="child-card__meta">
|
||||
{{if .Year}}Year {{.Year}}{{else}}Year not set{{end}}
|
||||
{{if not .CompassID}}· Compass ID not set{{end}}
|
||||
</p>
|
||||
<p class="child-card__body">No items stored yet. The Compass scan arrives in Phase 2.</p>
|
||||
|
||||
{{if not .Child.Scan.OK}}
|
||||
<p class="flash flash--error" role="alert">
|
||||
{{.Child.Scan.Message}} The lists below may be incomplete — this is not a clean
|
||||
“nothing due”.
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
{{range .Child.Sections}}
|
||||
<section class="section" id="sec-{{.Key}}" aria-labelledby="sec-{{.Key}}-title">
|
||||
<div class="section__head">
|
||||
<h3 id="sec-{{.Key}}-title" class="heading-3{{if eq .Tone "danger"}} heading-3--danger{{end}}">{{.Title}}</h3>
|
||||
<span class="category-pill"><span class="dot{{if eq .Tone "danger"}} dot--danger{{end}}"></span>{{len .Items}}</span>
|
||||
</div>
|
||||
{{if .Items}}
|
||||
<ul class="item-list">
|
||||
{{range .Items}}
|
||||
<li class="item{{if eq .StatusLabel "Overdue"}} item--overdue{{end}}" id="item-{{.ID}}">
|
||||
<div class="item__body">
|
||||
<p class="item__title"><a href="/items/{{.ID}}">{{.Title}}</a></p>
|
||||
<p class="item__meta">
|
||||
<span class="tag tag--type">{{.TypeLabel}}</span>
|
||||
{{if .Subject}}<span class="item__subject">{{.Subject}}</span>{{end}}
|
||||
{{if .DueLabel}}<span class="item__due">due {{.DueLabel}}</span>{{end}}
|
||||
{{if .StatusLabel}}<span class="tag tag--status">{{.StatusLabel}}</span>{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<form class="item__actions" method="post" action="/items/{{.ID}}/delete"
|
||||
hx-post="/items/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
|
||||
hx-confirm="Delete this item? It returns on the next scan if the school still lists it.">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="button-utility button-utility--danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p class="empty-state__title">No children on file</p>
|
||||
<p class="empty-state__body">Harry and Finn are seeded on first start. If this is empty,
|
||||
the database was not seeded.</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="section__empty">{{.Empty}}</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<div class="child__foot">
|
||||
{{if .Child.LaterCount}}<span>{{.Child.LaterCount}} due later</span>{{end}}
|
||||
{{if .Child.DoneCount}}<span>{{.Child.DoneCount}} submitted</span>{{end}}
|
||||
<form method="post" action="/summaries/build" class="inline-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="child" value="{{.Child.ID}}">
|
||||
<button class="button-utility" type="submit">Build {{.Child.Name}}'s brief (draft)</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p class="empty-state__title">No children on file</p>
|
||||
<p class="empty-state__body">Harry and Finn are seeded on first start. If this is empty,
|
||||
the database was not seeded.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Summaries}}
|
||||
<section class="section" aria-labelledby="summaries-title">
|
||||
<div class="section__head">
|
||||
<h2 id="summaries-title" class="heading-2">Briefs</h2>
|
||||
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Summaries}}</span>
|
||||
</div>
|
||||
<ul class="item-list">
|
||||
{{range .Summaries}}
|
||||
<li class="item" id="summary-{{.ID}}">
|
||||
<div class="item__body">
|
||||
<p class="item__title">{{.Recipient}} · {{.Sent}}</p>
|
||||
<p class="item__meta"><span class="item__subject">built {{.Created}}</span></p>
|
||||
<pre class="brief">{{.Text}}</pre>
|
||||
</div>
|
||||
<form class="item__actions" method="post" action="/summaries/{{.ID}}/delete"
|
||||
hx-post="/summaries/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
|
||||
hx-confirm="Delete this brief?">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="button-utility button-utility--danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="app-foot">
|
||||
|
||||
81
internal/web/templates/item.html
Normal file
81
internal/web/templates/item.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{{define "item.html"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>{{.Title}} — Compass / Teams</title>
|
||||
<link rel="stylesheet" href="/static/tokens.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/theme.js" defer></script>
|
||||
</head>
|
||||
<body class="app">
|
||||
<nav class="nav-bar">
|
||||
<a class="nav-bar__brand" href="/">
|
||||
<span class="nav-bar__mark" aria-hidden="true"></span>
|
||||
Compass / Teams
|
||||
</a>
|
||||
<div class="nav-bar__right">
|
||||
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
|
||||
<span class="nav-bar__user">{{.Username}}</span>
|
||||
<form method="post" action="/logout" class="nav-bar__logout">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button-utility" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
<p class="breadcrumb"><a href="/?child={{.Child.ID}}">← {{.Child.Name}}</a></p>
|
||||
|
||||
<article class="detail">
|
||||
<header class="detail__head">
|
||||
<h1 class="heading-1">{{.Item.Title}}</h1>
|
||||
<p class="detail__meta">
|
||||
<span class="tag tag--type">{{.Item.TypeLabel}}</span>
|
||||
{{if .Item.Subject}}<span>{{.Item.Subject}}</span>{{end}}
|
||||
{{if .Item.Teacher}}<span>Teacher: {{.Item.Teacher}}</span>{{end}}
|
||||
{{if .Item.DueLabel}}<span class="item__due">due {{.Item.DueLabel}}</span>{{end}}
|
||||
{{if .Item.StatusLabel}}<span class="tag tag--status">{{.Item.StatusLabel}}</span>{{end}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{{if .Item.BodyLines}}
|
||||
<section class="detail__body">
|
||||
{{range .Item.BodyLines}}<p>{{.}}</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<dl class="detail__facts">
|
||||
<dt>Child</dt><dd>{{.Child.Name}}</dd>
|
||||
<dt>Source</dt><dd>{{.Item.Source}}{{if .Item.SourcePage}} · {{.Item.SourcePage}}{{end}}</dd>
|
||||
{{if .Item.Importance}}<dt>Importance</dt><dd>{{.Item.Importance}}</dd>{{end}}
|
||||
{{if .Item.ExternalRef}}<dt>Reference</dt><dd>{{.Item.ExternalRef}}</dd>{{end}}
|
||||
{{if .Item.DueAt}}<dt>Due</dt><dd>{{.Item.DueLabel}}</dd>{{end}}
|
||||
</dl>
|
||||
|
||||
<div class="detail__actions">
|
||||
{{if .Item.URL}}<a class="button-primary" href="{{.Item.URL}}" rel="noreferrer noopener">Open in Compass</a>{{end}}
|
||||
<form method="post" action="/items/{{.Item.ID}}/delete"
|
||||
hx-post="/items/{{.Item.ID}}/delete" hx-confirm="Delete this item? It returns on the next scan if the school still lists it.">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button-utility button-utility--danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{if not .Scan.OK}}
|
||||
<p class="flash flash--error" role="alert">{{.Scan.Message}}</p>
|
||||
{{end}}
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer class="app-foot">
|
||||
<span>Compass / Teams · {{.Version}} · {{.AppEnv}}</span>
|
||||
<span class="app-foot__sep">·</span>
|
||||
<span>up {{.ServerUp}}</span>
|
||||
<span class="app-foot__sep">·</span>
|
||||
<span>{{year}} Family Home Lab</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
Reference in New Issue
Block a user