Phase 7: results & review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jānis Kacēns
2026-05-11 16:47:01 +03:00
parent 968479ff51
commit 715c1e4fe5
2 changed files with 148 additions and 14 deletions
+79
View File
@@ -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) 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) { func (h *TestHandler) ResultsGet(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromCtx(r.Context()) user := auth.UserFromCtx(r.Context())
testID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) 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) HTTPError(w, http.StatusNotFound)
return 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 := BaseData(h.auth, r)
data["Test"] = test data["Test"] = test
data["Items"] = items
data["NCorrect"] = nCorrect
data["TimeTaken"] = timeTaken
h.render.Render(w, http.StatusOK, "test_results", data) h.render.Render(w, http.StatusOK, "test_results", data)
} }
+64 -9
View File
@@ -1,18 +1,73 @@
{{define "content"}} {{define "content"}}
<div class="text-center py-12"> {{$n := .NCorrect}}
<h1 class="text-2xl font-bold text-gray-800 mb-3">Test Complete!</h1> {{$total := .Test.NQuestions}}
<p class="text-gray-500 mb-8">Detailed results coming in the next phase.</p>
<div class="flex justify-center gap-3"> <!-- 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}}&nbsp;{{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" <a href="/test/new"
class="inline-block bg-blue-600 hover:bg-blue-700 text-white px-6 py-2.5 rounded-md class="flex-1 text-center bg-blue-600 hover:bg-blue-700 text-white py-3 px-4
text-sm font-semibold shadow-sm"> rounded-xl text-sm font-semibold shadow-sm">
Take another test Take another test
</a> </a>
<a href="/" <a href="/"
class="inline-block border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2.5 class="flex-1 text-center border border-gray-300 text-gray-700 hover:bg-gray-50
rounded-md text-sm font-medium"> py-3 px-4 rounded-xl text-sm font-medium">
Library Library
</a> </a>
</div> </div>
</div>
{{end}} {{end}}