Phase 8: history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user