Phase 5: library & stats

- Full library home page: question list with per-user mastery stats,
  search/source filter, sort (A-Z, weakest first, most-seen), source
  breakdown in header, Take a test CTA.
- GET/POST /questions/{id}: view and edit question text, source, answers;
  radio-select correct answer; shows seen×/correct% stat.
- POST /questions/{id}/delete: hard delete (cascades to answers via FK).
- repo: ListQuestions supports SortWeakest/SortMostSeen via LEFT JOIN;
  added CountBySource, UpdateQuestion, UpdateAnswers, DeleteQuestion.
- render: added pct template func (correct*100/seen).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jānis Kacēns
2026-05-11 13:49:22 +03:00
parent 5199c1fa16
commit 177b4e8fd8
7 changed files with 417 additions and 14 deletions
+77 -5
View File
@@ -1,7 +1,79 @@
{{define "content"}}
<h1 class="text-2xl font-bold text-gray-800 mb-2">Library</h1>
<p class="text-gray-500 text-sm">
No questions yet.
<a href="/upload" class="text-blue-600 hover:underline">Upload a document</a> to get started.
</p>
<div class="mb-5 flex items-baseline justify-between">
<h1 class="text-2xl font-bold text-gray-800">Library</h1>
<a href="/upload" class="text-sm text-blue-600 hover:underline">+ Upload</a>
</div>
{{if eq .TotalQ 0}}
<div class="text-center py-16 text-gray-400">
<p class="text-lg">No questions yet.</p>
<a href="/upload" class="mt-4 inline-block text-blue-600 hover:underline text-sm">Upload a document to get started</a>
</div>
{{else}}
<div class="mb-5 text-sm text-gray-500">
{{.TotalQ}} question{{if ne .TotalQ 1}}s{{end}} · {{.TotalA}} answer{{if ne .TotalA 1}}s{{end}}
{{if .SourceStats}}
<span class="mx-1">·</span>
{{range $i, $s := .SourceStats}}{{if $i}}<span class="mx-0.5 text-gray-300">|</span>{{end}}<span>{{$s.Source}} ({{$s.Count}})</span>{{end}}
{{end}}
</div>
<form method="get" action="/" class="flex flex-wrap gap-2 mb-4">
<input type="text" name="q" value="{{.Search}}" placeholder="Search…"
class="flex-1 min-w-0 border border-gray-300 rounded-md px-3 py-1.5 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500">
{{if .SourceStats}}
<select name="source" onchange="this.form.submit()"
class="border border-gray-300 rounded-md px-2 py-1.5 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
<option value="">All sources</option>
{{range .SourceStats}}
<option value="{{.Source}}" {{if eq .Source $.SelectedSource}}selected{{end}}>{{.Source}}</option>
{{end}}
</select>
{{end}}
<select name="sort" onchange="this.form.submit()"
class="border border-gray-300 rounded-md px-2 py-1.5 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
<option value="alpha" {{if eq .Sort "alpha"}}selected{{end}}>AZ</option>
<option value="weakest" {{if eq .Sort "weakest"}}selected{{end}}>Weakest first</option>
<option value="seen" {{if eq .Sort "seen"}}selected{{end}}>Most seen</option>
</select>
<button type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 rounded-md text-sm font-medium">
Search
</button>
</form>
{{if .Questions}}
<div class="space-y-2">
{{range .Questions}}
<a href="/questions/{{.Q.ID}}"
class="block bg-white border border-gray-200 rounded-lg px-4 py-3 hover:border-blue-300 hover:shadow-sm transition-all">
<div class="flex items-start justify-between gap-4">
<p class="text-sm text-gray-800 line-clamp-2">{{.Q.Text}}</p>
<span class="text-xs text-gray-400 whitespace-nowrap mt-0.5 flex-shrink-0">
{{if .Stat}}{{.Stat.TimesSeen}}× · {{pct .Stat.TimesCorrect .Stat.TimesSeen}}%{{else}}—{{end}}
</span>
</div>
{{if .Q.Source}}
<p class="text-xs text-gray-400 mt-1">{{.Q.Source}}</p>
{{end}}
</a>
{{end}}
</div>
{{else}}
<p class="text-center py-8 text-gray-400 text-sm">No questions match your filter.</p>
{{end}}
<div class="mt-6 text-center">
<a href="/test/new"
class="inline-block bg-blue-600 hover:bg-blue-700 text-white px-8 py-2.5 rounded-md
text-sm font-semibold shadow-sm">
Take a test
</a>
</div>
{{end}}
{{end}}
+71
View File
@@ -0,0 +1,71 @@
{{define "content"}}
<div class="mb-5 flex items-center justify-between">
<a href="/" class="text-sm text-gray-500 hover:underline">← Library</a>
{{if .Stat}}
<p class="text-xs text-gray-500">
Seen {{.Stat.TimesSeen}}× · {{.Stat.TimesCorrect}} correct ({{pct .Stat.TimesCorrect .Stat.TimesSeen}}%)
</p>
{{else}}
<p class="text-xs text-gray-400">Not yet seen in a test</p>
{{end}}
</div>
<form method="post" action="/questions/{{.Question.ID}}" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Question</label>
<textarea name="q_text" rows="4" required
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y">{{.Question.Text}}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Source</label>
<input type="text" name="source" value="{{.Question.Source}}"
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Answers</label>
<div class="space-y-2">
{{range $i, $a := .Answers}}
<div class="flex items-center gap-3">
<input type="radio" name="correct" value="{{$i}}"
{{if $a.IsCorrect}}checked{{end}}
class="text-blue-600 focus:ring-blue-500 flex-shrink-0">
<input type="text" name="a_text_{{$i}}" value="{{$a.Text}}" required
class="flex-1 border border-gray-300 rounded-md px-3 py-1.5 text-sm shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
{{end}}
</div>
<p class="text-xs text-gray-400 mt-1">Select the radio button next to the correct answer.</p>
</div>
<div class="flex gap-3 pt-2">
<button type="submit"
class="flex-1 bg-blue-600 hover:bg-blue-700 text-white py-2.5 px-4 rounded-md
text-sm font-semibold shadow-sm">
Save changes
</button>
<a href="/"
class="px-4 py-2.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700
hover:bg-gray-50 text-center">
Cancel
</a>
</div>
</form>
<div class="mt-6 pt-6 border-t border-gray-200">
<form method="post" action="/questions/{{.Question.ID}}/delete">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit"
onclick="return confirm('Delete this question? This cannot be undone.')"
class="text-sm text-red-600 hover:text-red-800 hover:underline">
Delete this question
</button>
</form>
</div>
{{end}}