Phase 8: history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,7 @@ func main() {
|
|||||||
uploadH := handlers.NewUploadHandler(authMgr, repo, llmClient, renderer, cfg.DataDir)
|
uploadH := handlers.NewUploadHandler(authMgr, repo, llmClient, renderer, cfg.DataDir)
|
||||||
questionH := handlers.NewQuestionHandler(authMgr, repo, renderer)
|
questionH := handlers.NewQuestionHandler(authMgr, repo, renderer)
|
||||||
testH := handlers.NewTestHandler(authMgr, repo, renderer)
|
testH := handlers.NewTestHandler(authMgr, repo, renderer)
|
||||||
|
historyH := handlers.NewHistoryHandler(authMgr, repo, renderer)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
@@ -95,6 +96,7 @@ func main() {
|
|||||||
r.Get("/test/{id}/q/{n}", testH.QuestionGet)
|
r.Get("/test/{id}/q/{n}", testH.QuestionGet)
|
||||||
r.Post("/test/{id}/q/{n}", testH.QuestionPost)
|
r.Post("/test/{id}/q/{n}", testH.QuestionPost)
|
||||||
r.Get("/test/{id}/results", testH.ResultsGet)
|
r.Get("/test/{id}/results", testH.ResultsGet)
|
||||||
|
r.Get("/history", historyH.Handle)
|
||||||
})
|
})
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -445,6 +445,81 @@ func (r *Repo) GetStatsForUser(userID int64, questionIDs []string) (map[string]*
|
|||||||
return result, rows.Err()
|
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) ────────────────────────────────────────────────────
|
// ── Draft (import review) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
func newDraftID() string {
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<h1 class="text-xl font-bold text-gray-800">Test History</h1>
|
||||||
|
<a href="/test/new"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold
|
||||||
|
px-4 py-2 rounded-lg shadow-sm">
|
||||||
|
Take a test
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aggregate stat -->
|
||||||
|
{{if gt .TotalAnswered 0}}
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl p-5 mb-6 shadow-sm flex items-center gap-4">
|
||||||
|
<div class="text-3xl font-bold text-gray-900">{{pct .TotalCorrect .TotalAnswered}}%</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700">Overall accuracy</p>
|
||||||
|
<p class="text-xs text-gray-400">{{.TotalCorrect}} correct out of {{.TotalAnswered}} answered</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Past tests -->
|
||||||
|
{{if .Items}}
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden mb-6">
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
{{range .Items}}
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 hover:bg-gray-50 transition-colors">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800">
|
||||||
|
{{.NCorrect}} / {{.NQuestions}}
|
||||||
|
<span class="text-gray-400 font-normal">({{pct .NCorrect .NQuestions}}%)</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">{{.CreatedAt.Format "2 Jan 2006, 15:04"}}</p>
|
||||||
|
</div>
|
||||||
|
<a href="/test/{{.ID}}/results"
|
||||||
|
class="text-xs text-blue-600 hover:text-blue-800 font-medium">
|
||||||
|
Review →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="text-center py-12 text-gray-400">
|
||||||
|
<p class="text-sm">No completed tests yet.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Weak spots -->
|
||||||
|
{{if .WeakSpots}}
|
||||||
|
<h2 class="text-base font-semibold text-gray-700 mb-3">Weak spots</h2>
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
{{range .WeakSpots}}
|
||||||
|
<a href="/questions/{{.QuestionID}}"
|
||||||
|
class="flex items-start justify-between px-5 py-4 hover:bg-gray-50 transition-colors gap-4">
|
||||||
|
<p class="text-sm text-gray-800 leading-relaxed line-clamp-2">{{.QuestionText}}</p>
|
||||||
|
<span class="flex-shrink-0 text-xs text-red-500 font-medium whitespace-nowrap">
|
||||||
|
{{.TimesWrong}}✗ / {{.TimesSeen}}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user