diff --git a/cmd/server/main.go b/cmd/server/main.go index 895d9dc..ae9c811 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -60,6 +60,7 @@ func main() { uploadH := handlers.NewUploadHandler(authMgr, repo, llmClient, renderer, cfg.DataDir) questionH := handlers.NewQuestionHandler(authMgr, repo, renderer) testH := handlers.NewTestHandler(authMgr, repo, renderer) + historyH := handlers.NewHistoryHandler(authMgr, repo, renderer) r := chi.NewRouter() r.Use(middleware.RequestID) @@ -95,6 +96,7 @@ func main() { r.Get("/test/{id}/q/{n}", testH.QuestionGet) r.Post("/test/{id}/q/{n}", testH.QuestionPost) r.Get("/test/{id}/results", testH.ResultsGet) + r.Get("/history", historyH.Handle) }) srv := &http.Server{ diff --git a/internal/db/repo.go b/internal/db/repo.go index 61a2957..06a3d7d 100644 --- a/internal/db/repo.go +++ b/internal/db/repo.go @@ -445,6 +445,81 @@ func (r *Repo) GetStatsForUser(userID int64, questionIDs []string) (map[string]* return result, rows.Err() } +// ── History ────────────────────────────────────────────────────────────────── + +// GetCorrectCountsForUser returns a map of test_id → correct-answer count for +// all completed tests belonging to userID. +func (r *Repo) GetCorrectCountsForUser(userID int64) (map[int64]int, error) { + rows, err := r.db.Query(` + SELECT ta.test_id, SUM(CASE WHEN ta.is_correct = 1 THEN 1 ELSE 0 END) + FROM test_answers ta + JOIN tests t ON ta.test_id = t.id + WHERE t.user_id = ? AND t.completed_at IS NOT NULL + GROUP BY ta.test_id`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + result := make(map[int64]int) + for rows.Next() { + var testID int64 + var correct int + if err := rows.Scan(&testID, &correct); err != nil { + return nil, err + } + result[testID] = correct + } + return result, rows.Err() +} + +// GetAggregateStats returns total correct and total answered across all +// completed tests for userID. +func (r *Repo) GetAggregateStats(userID int64) (totalCorrect, totalAnswered int, err error) { + err = r.db.QueryRow(` + SELECT + COALESCE(SUM(CASE WHEN ta.is_correct = 1 THEN 1 ELSE 0 END), 0), + COALESCE(COUNT(ta.question_id), 0) + FROM test_answers ta + JOIN tests t ON ta.test_id = t.id + WHERE t.user_id = ? AND t.completed_at IS NOT NULL`, userID, + ).Scan(&totalCorrect, &totalAnswered) + return +} + +// WeakSpot is a question the user has answered incorrectly more than once. +type WeakSpot struct { + QuestionID string + QuestionText string + TimesWrong int + TimesSeen int +} + +// GetWeakSpots returns up to 10 questions the user has gotten wrong more than +// once, ordered by wrong-answer count descending. +func (r *Repo) GetWeakSpots(userID int64) ([]*WeakSpot, error) { + rows, err := r.db.Query(` + SELECT uqs.question_id, q.text, uqs.times_seen, + (uqs.times_seen - uqs.times_correct) AS times_wrong + FROM user_question_stats uqs + JOIN questions q ON uqs.question_id = q.id + WHERE uqs.user_id = ? AND (uqs.times_seen - uqs.times_correct) > 1 + ORDER BY times_wrong DESC, uqs.times_seen DESC + LIMIT 10`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var spots []*WeakSpot + for rows.Next() { + s := &WeakSpot{} + if err := rows.Scan(&s.QuestionID, &s.QuestionText, &s.TimesSeen, &s.TimesWrong); err != nil { + return nil, err + } + spots = append(spots, s) + } + return spots, rows.Err() +} + // ── Draft (import review) ──────────────────────────────────────────────────── func newDraftID() string { diff --git a/internal/handlers/history.go b/internal/handlers/history.go new file mode 100644 index 0000000..30918d6 --- /dev/null +++ b/internal/handlers/history.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "log/slog" + "net/http" + + "qbank/internal/auth" + "qbank/internal/db" + "qbank/internal/models" +) + +type HistoryHandler struct { + auth *auth.Manager + repo *db.Repo + render *Renderer +} + +func NewHistoryHandler(a *auth.Manager, repo *db.Repo, r *Renderer) *HistoryHandler { + return &HistoryHandler{auth: a, repo: repo, render: r} +} + +// TestHistoryItem pairs a test with its correct-answer count. +type TestHistoryItem struct { + *models.Test + NCorrect int +} + +func (h *HistoryHandler) Handle(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromCtx(r.Context()) + + tests, err := h.repo.ListTestsForUser(user.ID) + if err != nil { + slog.Error("list tests for history", "err", err) + HTTPError(w, http.StatusInternalServerError) + return + } + + correctCounts, err := h.repo.GetCorrectCountsForUser(user.ID) + if err != nil { + slog.Error("get correct counts", "err", err) + HTTPError(w, http.StatusInternalServerError) + return + } + + items := make([]TestHistoryItem, 0, len(tests)) + for _, t := range tests { + if !t.CompletedAt.Valid { + continue // skip in-progress tests + } + items = append(items, TestHistoryItem{ + Test: t, + NCorrect: correctCounts[t.ID], + }) + } + + totalCorrect, totalAnswered, err := h.repo.GetAggregateStats(user.ID) + if err != nil { + slog.Error("get aggregate stats", "err", err) + HTTPError(w, http.StatusInternalServerError) + return + } + + weakSpots, err := h.repo.GetWeakSpots(user.ID) + if err != nil { + slog.Error("get weak spots", "err", err) + HTTPError(w, http.StatusInternalServerError) + return + } + + data := BaseData(h.auth, r) + data["Items"] = items + data["TotalCorrect"] = totalCorrect + data["TotalAnswered"] = totalAnswered + data["WeakSpots"] = weakSpots + h.render.Render(w, http.StatusOK, "history", data) +} diff --git a/web/templates/history.html b/web/templates/history.html new file mode 100644 index 0000000..0986507 --- /dev/null +++ b/web/templates/history.html @@ -0,0 +1,66 @@ +{{define "content"}} +
Overall accuracy
+{{.TotalCorrect}} correct out of {{.TotalAnswered}} answered
++ {{.NCorrect}} / {{.NQuestions}} + ({{pct .NCorrect .NQuestions}}%) +
+{{.CreatedAt.Format "2 Jan 2006, 15:04"}}
+No completed tests yet.
+{{.QuestionText}}
+ + {{.TimesWrong}}✗ / {{.TimesSeen}} + + + {{end}} +