Phase 7: results & review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -214,6 +214,20 @@ func (h *TestHandler) QuestionPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("/test/%d/results", test.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ResultItem holds per-question data for the results page.
|
||||
type ResultItem struct {
|
||||
Question *models.Question
|
||||
Answers []*ResultAnswer
|
||||
UserRight bool // user selected the correct answer
|
||||
Unanswered bool // user skipped without selecting
|
||||
}
|
||||
|
||||
// ResultAnswer annotates each answer with display markers.
|
||||
type ResultAnswer struct {
|
||||
*models.Answer
|
||||
UserPicked bool // user selected this answer
|
||||
}
|
||||
|
||||
func (h *TestHandler) ResultsGet(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromCtx(r.Context())
|
||||
testID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
@@ -226,8 +240,73 @@ func (h *TestHandler) ResultsGet(w http.ResponseWriter, r *http.Request) {
|
||||
HTTPError(w, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
testAnswers, err := h.repo.GetTestAnswers(testID)
|
||||
if err != nil {
|
||||
slog.Error("get test answers", "err", err)
|
||||
HTTPError(w, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Index test answers by question ID for quick lookup.
|
||||
taByQ := make(map[string]*models.TestAnswer, len(testAnswers))
|
||||
for _, ta := range testAnswers {
|
||||
taByQ[ta.QuestionID] = ta
|
||||
}
|
||||
|
||||
var items []ResultItem
|
||||
nCorrect := 0
|
||||
for _, qid := range test.QuestionIDs {
|
||||
q, answers, err := h.repo.GetQuestion(qid)
|
||||
if err != nil {
|
||||
slog.Error("get question for results", "qid", qid, "err", err)
|
||||
HTTPError(w, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ta := taByQ[qid]
|
||||
var selectedID int64
|
||||
unanswered := ta == nil || !ta.SelectedAnswerID.Valid
|
||||
if !unanswered {
|
||||
selectedID = ta.SelectedAnswerID.Int64
|
||||
}
|
||||
userRight := ta != nil && ta.IsCorrect.Valid && ta.IsCorrect.Bool
|
||||
if userRight {
|
||||
nCorrect++
|
||||
}
|
||||
ra := make([]*ResultAnswer, len(answers))
|
||||
for i, a := range answers {
|
||||
ra[i] = &ResultAnswer{
|
||||
Answer: a,
|
||||
UserPicked: !unanswered && a.ID == selectedID,
|
||||
}
|
||||
}
|
||||
items = append(items, ResultItem{
|
||||
Question: q,
|
||||
Answers: ra,
|
||||
UserRight: userRight,
|
||||
Unanswered: unanswered,
|
||||
})
|
||||
}
|
||||
|
||||
var timeTaken string
|
||||
if test.CompletedAt.Valid {
|
||||
d := test.CompletedAt.Time.Sub(test.CreatedAt).Round(time.Second)
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
s := int(d.Seconds()) % 60
|
||||
if h > 0 {
|
||||
timeTaken = fmt.Sprintf("%dh %dm %ds", h, m, s)
|
||||
} else if m > 0 {
|
||||
timeTaken = fmt.Sprintf("%dm %ds", m, s)
|
||||
} else {
|
||||
timeTaken = fmt.Sprintf("%ds", s)
|
||||
}
|
||||
}
|
||||
|
||||
data := BaseData(h.auth, r)
|
||||
data["Test"] = test
|
||||
data["Items"] = items
|
||||
data["NCorrect"] = nCorrect
|
||||
data["TimeTaken"] = timeTaken
|
||||
h.render.Render(w, http.StatusOK, "test_results", data)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,73 @@
|
||||
{{define "content"}}
|
||||
<div class="text-center py-12">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-3">Test Complete!</h1>
|
||||
<p class="text-gray-500 mb-8">Detailed results coming in the next phase.</p>
|
||||
<div class="flex justify-center gap-3">
|
||||
<a href="/test/new"
|
||||
class="inline-block bg-blue-600 hover:bg-blue-700 text-white px-6 py-2.5 rounded-md
|
||||
text-sm font-semibold shadow-sm">
|
||||
Take another test
|
||||
</a>
|
||||
<a href="/"
|
||||
class="inline-block border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2.5
|
||||
rounded-md text-sm font-medium">
|
||||
Library
|
||||
</a>
|
||||
{{$n := .NCorrect}}
|
||||
{{$total := .Test.NQuestions}}
|
||||
|
||||
<!-- Score summary -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-6 mb-6 shadow-sm text-center">
|
||||
<p class="text-4xl font-bold text-gray-900 mb-1">{{$n}} / {{$total}}</p>
|
||||
<p class="text-lg text-gray-500 mb-1">{{pct $n $total}}%</p>
|
||||
{{if .TimeTaken}}
|
||||
<p class="text-sm text-gray-400">Time: {{.TimeTaken}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Per-question review -->
|
||||
<div class="space-y-5 mb-8">
|
||||
{{range $i, $item := .Items}}
|
||||
<div class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
|
||||
<!-- Question header -->
|
||||
<div class="px-5 pt-5 pb-3 border-b border-gray-100">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="mt-0.5 flex-shrink-0 text-lg leading-none">
|
||||
{{if $item.Unanswered}}⬜{{else if $item.UserRight}}✅{{else}}❌{{end}}
|
||||
</span>
|
||||
<p class="text-gray-800 text-sm leading-relaxed font-medium">{{$item.Question.Text}}</p>
|
||||
</div>
|
||||
{{if $item.Question.Source}}
|
||||
<p class="text-xs text-gray-400 mt-2 pl-8">{{$item.Question.Source}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Answers -->
|
||||
<ul class="divide-y divide-gray-100">
|
||||
{{range $item.Answers}}
|
||||
<li class="px-5 py-3 flex items-start gap-3
|
||||
{{if and .IsCorrect .UserPicked}}bg-green-50
|
||||
{{else if .IsCorrect}}bg-green-50
|
||||
{{else if .UserPicked}}bg-red-50
|
||||
{{end}}">
|
||||
<span class="flex-shrink-0 w-5 text-base leading-none mt-0.5">
|
||||
{{if and .IsCorrect .UserPicked}}✅
|
||||
{{else if .IsCorrect}}✅
|
||||
{{else if .UserPicked}}❌
|
||||
{{else}} {{end}}
|
||||
</span>
|
||||
<span class="text-sm leading-relaxed
|
||||
{{if .IsCorrect}}text-green-800 font-medium
|
||||
{{else if .UserPicked}}text-red-700
|
||||
{{else}}text-gray-600{{end}}">
|
||||
{{.Text}}
|
||||
</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<a href="/test/new"
|
||||
class="flex-1 text-center bg-blue-600 hover:bg-blue-700 text-white py-3 px-4
|
||||
rounded-xl text-sm font-semibold shadow-sm">
|
||||
Take another test
|
||||
</a>
|
||||
<a href="/"
|
||||
class="flex-1 text-center border border-gray-300 text-gray-700 hover:bg-gray-50
|
||||
py-3 px-4 rounded-xl text-sm font-medium">
|
||||
Library
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user