From 177b4e8fd834a5d72e1dec9f78f3cd4df2c161cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=81nis=20Kac=C4=93ns?= Date: Mon, 11 May 2026 13:49:22 +0300 Subject: [PATCH] Phase 5: library & stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full library home page: question list with per-user mastery stats, search/source filter, sort (A-Z, weakest first, most-seen), source breakdown in header, Take a test CTA. - GET/POST /questions/{id}: view and edit question text, source, answers; radio-select correct answer; shows seen×/correct% stat. - POST /questions/{id}/delete: hard delete (cascades to answers via FK). - repo: ListQuestions supports SortWeakest/SortMostSeen via LEFT JOIN; added CountBySource, UpdateQuestion, UpdateAnswers, DeleteQuestion. - render: added pct template func (correct*100/seen). Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/main.go | 6 +- internal/db/repo.go | 79 ++++++++++++++++++++++-- internal/handlers/home.go | 75 ++++++++++++++++++++++- internal/handlers/question.go | 112 ++++++++++++++++++++++++++++++++++ internal/handlers/render.go | 6 ++ web/templates/home.html | 82 +++++++++++++++++++++++-- web/templates/question.html | 71 +++++++++++++++++++++ 7 files changed, 417 insertions(+), 14 deletions(-) create mode 100644 internal/handlers/question.go create mode 100644 web/templates/question.html diff --git a/cmd/server/main.go b/cmd/server/main.go index 98d36a5..b2d0edb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -56,8 +56,9 @@ func main() { ensureIcons("web/static") authH := handlers.NewAuthHandler(authMgr, repo, renderer) - homeH := handlers.NewHomeHandler(authMgr, renderer) + homeH := handlers.NewHomeHandler(authMgr, repo, renderer) uploadH := handlers.NewUploadHandler(authMgr, repo, llmClient, renderer, cfg.DataDir) + questionH := handlers.NewQuestionHandler(authMgr, repo, renderer) r := chi.NewRouter() r.Use(middleware.RequestID) @@ -85,6 +86,9 @@ func main() { r.Post("/upload", uploadH.UploadPost) r.Get("/import/{id}", uploadH.ImportGet) r.Post("/import/{id}", uploadH.ImportPost) + r.Get("/questions/{id}", questionH.Show) + r.Post("/questions/{id}", questionH.Edit) + r.Post("/questions/{id}/delete", questionH.Delete) }) srv := &http.Server{ diff --git a/internal/db/repo.go b/internal/db/repo.go index 9cedeaa..61a2957 100644 --- a/internal/db/repo.go +++ b/internal/db/repo.go @@ -149,23 +149,37 @@ func (r *Repo) GetQuestion(id string) (*models.Question, []*models.Answer, error } func (r *Repo) ListQuestions(f ListFilter) ([]*models.Question, error) { - var where []string var args []any + join := "" + if f.UserID != 0 && (f.Sort == SortWeakest || f.Sort == SortMostSeen) { + join = " LEFT JOIN user_question_stats s ON s.question_id = q.id AND s.user_id = ?" + args = append(args, f.UserID) + } + + var where []string if f.Source != "" { - where = append(where, "source = ?") + where = append(where, "q.source = ?") args = append(args, f.Source) } if f.Search != "" { - where = append(where, "text LIKE ?") + where = append(where, "q.text LIKE ?") args = append(args, "%"+f.Search+"%") } - query := "SELECT id, text, source, created_at FROM questions" + query := "SELECT q.id, q.text, q.source, q.created_at FROM questions q" + join if len(where) > 0 { query += " WHERE " + strings.Join(where, " AND ") } - query += " ORDER BY text COLLATE NOCASE ASC" + + switch f.Sort { + case SortWeakest: + query += " ORDER BY CASE WHEN s.times_seen IS NULL OR s.times_seen = 0 THEN 0.0 ELSE CAST(s.times_correct AS REAL) / s.times_seen END ASC, q.text COLLATE NOCASE ASC" + case SortMostSeen: + query += " ORDER BY COALESCE(s.times_seen, 0) DESC, q.text COLLATE NOCASE ASC" + default: + query += " ORDER BY q.text COLLATE NOCASE ASC" + } rows, err := r.db.Query(query, args...) if err != nil { @@ -206,6 +220,61 @@ func (r *Repo) ListSources() ([]string, error) { return sources, rows.Err() } +// SourceStat holds a source name with its question count. +type SourceStat struct { + Source string + Count int +} + +func (r *Repo) CountBySource() ([]SourceStat, error) { + rows, err := r.db.Query( + "SELECT source, COUNT(*) FROM questions WHERE source != '' GROUP BY source ORDER BY source", + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var stats []SourceStat + for rows.Next() { + var s SourceStat + if err := rows.Scan(&s.Source, &s.Count); err != nil { + return nil, err + } + stats = append(stats, s) + } + return stats, rows.Err() +} + +func (r *Repo) UpdateQuestion(id, text, source string) error { + _, err := r.db.Exec("UPDATE questions SET text = ?, source = ? WHERE id = ?", text, source, id) + return err +} + +// AnswerUpdate carries the fields to write for a single answer row. +type AnswerUpdate struct { + ID int64 + Text string + IsCorrect bool +} + +func (r *Repo) UpdateAnswers(updates []AnswerUpdate) error { + for _, u := range updates { + if _, err := r.db.Exec( + "UPDATE answers SET text = ?, is_correct = ? WHERE id = ?", + u.Text, u.IsCorrect, u.ID, + ); err != nil { + return err + } + } + return nil +} + +func (r *Repo) DeleteQuestion(id string) error { + _, err := r.db.Exec("DELETE FROM questions WHERE id = ?", id) + return err +} + func (r *Repo) CountQuestions() (int, error) { var n int return n, r.db.QueryRow("SELECT COUNT(*) FROM questions").Scan(&n) diff --git a/internal/handlers/home.go b/internal/handlers/home.go index b08ae98..98eee0c 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -1,20 +1,89 @@ package handlers import ( + "log/slog" "net/http" "qbank/internal/auth" + "qbank/internal/db" + "qbank/internal/models" ) +// QuestionRow pairs a question with its per-user stat for the library display. +type QuestionRow struct { + Q *models.Question + Stat *models.UserQuestionStat // nil = never seen in a test +} + type HomeHandler struct { auth *auth.Manager + repo *db.Repo render *Renderer } -func NewHomeHandler(a *auth.Manager, r *Renderer) *HomeHandler { - return &HomeHandler{auth: a, render: r} +func NewHomeHandler(a *auth.Manager, repo *db.Repo, r *Renderer) *HomeHandler { + return &HomeHandler{auth: a, repo: repo, render: r} } func (h *HomeHandler) Handle(w http.ResponseWriter, r *http.Request) { - h.render.Render(w, http.StatusOK, "home", BaseData(h.auth, r)) + user := auth.UserFromCtx(r.Context()) + + sortParam := r.URL.Query().Get("sort") + search := r.URL.Query().Get("q") + source := r.URL.Query().Get("source") + + var sortOrder db.SortOrder + switch sortParam { + case "weakest": + sortOrder = db.SortWeakest + case "seen": + sortOrder = db.SortMostSeen + default: + sortParam = "alpha" + sortOrder = db.SortAlpha + } + + questions, err := h.repo.ListQuestions(db.ListFilter{ + Source: source, + Search: search, + Sort: sortOrder, + UserID: user.ID, + }) + if err != nil { + slog.Error("list questions", "err", err) + HTTPError(w, http.StatusInternalServerError) + return + } + + ids := make([]string, len(questions)) + for i, q := range questions { + ids[i] = q.ID + } + + stats, err := h.repo.GetStatsForUser(user.ID, ids) + if err != nil { + slog.Error("get stats", "err", err) + HTTPError(w, http.StatusInternalServerError) + return + } + + rows := make([]QuestionRow, len(questions)) + for i, q := range questions { + rows[i] = QuestionRow{Q: q, Stat: stats[q.ID]} + } + + totalQ, _ := h.repo.CountQuestions() + totalA, _ := h.repo.CountAnswers() + sourceCounts, _ := h.repo.CountBySource() + + data := BaseData(h.auth, r) + data["Questions"] = rows + data["TotalQ"] = totalQ + data["TotalA"] = totalA + data["SourceStats"] = sourceCounts + data["Sort"] = sortParam + data["Search"] = search + data["SelectedSource"] = source + + h.render.Render(w, http.StatusOK, "home", data) } diff --git a/internal/handlers/question.go b/internal/handlers/question.go new file mode 100644 index 0000000..dc6baa1 --- /dev/null +++ b/internal/handlers/question.go @@ -0,0 +1,112 @@ +package handlers + +import ( + "fmt" + "log/slog" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "qbank/internal/auth" + "qbank/internal/db" +) + +type QuestionHandler struct { + auth *auth.Manager + repo *db.Repo + render *Renderer +} + +func NewQuestionHandler(a *auth.Manager, repo *db.Repo, r *Renderer) *QuestionHandler { + return &QuestionHandler{auth: a, repo: repo, render: r} +} + +func (h *QuestionHandler) Show(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + user := auth.UserFromCtx(r.Context()) + + q, answers, err := h.repo.GetQuestion(id) + if err != nil { + HTTPError(w, http.StatusNotFound) + return + } + + stats, _ := h.repo.GetStatsForUser(user.ID, []string{id}) + + data := BaseData(h.auth, r) + data["Question"] = q + data["Answers"] = answers + data["Stat"] = stats[id] + + h.render.Render(w, http.StatusOK, "question", data) +} + +func (h *QuestionHandler) Edit(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + if !h.auth.CheckCSRF(r) { + HTTPError(w, http.StatusForbidden) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := r.ParseForm(); err != nil { + HTTPError(w, http.StatusBadRequest) + return + } + + _, answers, err := h.repo.GetQuestion(id) + if err != nil { + HTTPError(w, http.StatusNotFound) + return + } + + text := r.FormValue("q_text") + source := r.FormValue("source") + correctIdx, _ := strconv.Atoi(r.FormValue("correct")) + + if err := h.repo.UpdateQuestion(id, text, source); err != nil { + slog.Error("update question", "err", err) + HTTPError(w, http.StatusInternalServerError) + return + } + + updates := make([]db.AnswerUpdate, len(answers)) + for i, a := range answers { + updates[i] = db.AnswerUpdate{ + ID: a.ID, + Text: r.FormValue(fmt.Sprintf("a_text_%d", i)), + IsCorrect: i == correctIdx, + } + } + if err := h.repo.UpdateAnswers(updates); err != nil { + slog.Error("update answers", "err", err) + HTTPError(w, http.StatusInternalServerError) + return + } + + h.auth.SetFlash(r, "Question saved.") + http.Redirect(w, r, "/questions/"+id, http.StatusSeeOther) +} + +func (h *QuestionHandler) Delete(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + if !h.auth.CheckCSRF(r) { + HTTPError(w, http.StatusForbidden) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 4096) + r.ParseForm() + + if err := h.repo.DeleteQuestion(id); err != nil { + slog.Error("delete question", "err", err) + HTTPError(w, http.StatusInternalServerError) + return + } + + h.auth.SetFlash(r, "Question deleted.") + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/internal/handlers/render.go b/internal/handlers/render.go index 56afc21..8d88e3b 100644 --- a/internal/handlers/render.go +++ b/internal/handlers/render.go @@ -11,6 +11,12 @@ import ( var tmplFuncs = template.FuncMap{ "inc": func(i int) int { return i + 1 }, + "pct": func(correct, seen int) int { + if seen == 0 { + return 0 + } + return correct * 100 / seen + }, } // Renderer parses and executes HTML templates from a directory. diff --git a/web/templates/home.html b/web/templates/home.html index b24e7d5..0dd4e53 100644 --- a/web/templates/home.html +++ b/web/templates/home.html @@ -1,7 +1,79 @@ {{define "content"}} -

Library

-

- No questions yet. - Upload a document to get started. -

+
+

Library

+ + Upload +
+ +{{if eq .TotalQ 0}} +
+

No questions yet.

+ Upload a document to get started +
+{{else}} + +
+ {{.TotalQ}} question{{if ne .TotalQ 1}}s{{end}} · {{.TotalA}} answer{{if ne .TotalA 1}}s{{end}} + {{if .SourceStats}} + · + {{range $i, $s := .SourceStats}}{{if $i}}|{{end}}{{$s.Source}} ({{$s.Count}}){{end}} + {{end}} +
+ +
+ + {{if .SourceStats}} + + {{end}} + + +
+ +{{if .Questions}} + +{{else}} +

No questions match your filter.

+{{end}} + + + +{{end}} {{end}} diff --git a/web/templates/question.html b/web/templates/question.html new file mode 100644 index 0000000..52235cf --- /dev/null +++ b/web/templates/question.html @@ -0,0 +1,71 @@ +{{define "content"}} +
+ ← Library + {{if .Stat}} +

+ Seen {{.Stat.TimesSeen}}× · {{.Stat.TimesCorrect}} correct ({{pct .Stat.TimesCorrect .Stat.TimesSeen}}%) +

+ {{else}} +

Not yet seen in a test

+ {{end}} +
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ +
+ {{range $i, $a := .Answers}} +
+ + +
+ {{end}} +
+

Select the radio button next to the correct answer.

+
+ +
+ + + Cancel + +
+
+ +
+
+ + +
+
+{{end}}