Initial commit: standalone quiz-testing system

Full-stack F# (Domain/Server/Client via Fable+Elmish+Feliz), PostgreSQL
persistence via Dapper, Docker Compose deployment. Student quiz-taking flow
with time-limit enforcement and focus-loss tracking, Teacher question bank
and quiz builder with results analytics, Admin user management.
This commit is contained in:
danamir
2026-08-06 12:36:16 +03:00
commit 942dfc9c1a
134 changed files with 10712 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
module Client.Features.Quizzes.TakeQuiz.State
open System
open Elmish
open Fable.Core
open Browser.Dom
open Domain.Contracts
open Client.Features.Quizzes.TakeQuiz.Types
/// Ticks once a second for as long as the browser tab showing this page is
/// open. Nothing ever cancels the underlying `setInterval` (there's no page
/// lifecycle hook to hang it off in this app's minimal Elmish wiring) once
/// the student finishes and the parent switches away from this page, these
/// dispatches just stop matching any case in `App.State.update` and are
/// silently dropped, so the only cost is a harmless once-a-second no-op.
let startTicking () : Cmd<Msg> = Cmd.ofEffect (fun dispatch -> JS.setInterval (fun () -> dispatch Tick) 1000 |> ignore)
/// Listens from the moment the attempt page mounts (matching DESIGN.md §3.6
/// a student who wanders off before answering anything still counts).
/// `blur`/`focus` catch switching to another window over the same tab;
/// `visibilitychange` catches switching tabs or minimizing. Both can fire for
/// the same departure, so `FocusLost`/`FocusRegained` in `update` de-dupe via
/// `AwayFromFocus` rather than reporting on every event.
let attachFocusTracking () : Cmd<Msg> =
Cmd.ofEffect (fun dispatch ->
document.addEventListener (
"visibilitychange",
fun _ -> dispatch (if document.hidden then FocusLost else FocusRegained)
)
window.addEventListener ("blur", fun _ -> dispatch FocusLost)
window.addEventListener ("focus", fun _ -> dispatch FocusRegained))
let rec update (token: string option) (msg: Msg) (model: Model) : Model * Cmd<Msg> =
match msg with
| AnswerChanged(questionId, response) ->
let updated = { model with Answers = model.Answers |> Map.add questionId response }
let request: SubmitAnswerRequest =
{ AttemptId = model.Data.AttemptId
QuestionId = questionId
Response = response }
let cmd =
Cmd.OfAsync.either
(Api.submitAnswer token)
request
(function
| Ok() -> AnswerSaved questionId
| Error err -> AnswerSaveFailed(questionId, err))
(fun ex -> AnswerSaveFailed(questionId, ex.Message))
updated, cmd
| AnswerSaved _ -> { model with Error = None }, Cmd.none
| AnswerSaveFailed(_, err) -> { model with Error = Some err }, Cmd.none
| Tick ->
let updated = { model with RemainingSeconds = remainingSeconds model.Data DateTimeOffset.UtcNow }
// The server is the actual authority (it now rejects any answer
// submitted past the deadline see `SubmitAnswer.fs`); this just
// saves the student a click once their own countdown reaches zero,
// instead of leaving them stuck looking at a page that silently
// stopped accepting changes.
if updated.RemainingSeconds = Some 0 && not updated.IsFinishing then
update token Finish updated
// Backstop for `FocusLost`/`FocusRegained` below: `blur` and
// `visibilitychange` are supposed to fire on every departure, but in
// practice they can be unreliable e.g. switching windows inside a
// remote-desktop session doesn't always deliver them to the page.
// `document.hasFocus()` is a direct, synchronous ground-truth check
// that doesn't depend on any event actually being dispatched, so
// piggybacking it onto the once-a-second tick catches a departure
// within ~1s even when the events themselves go missing.
elif not (document.hasFocus ()) && not updated.AwayFromFocus then
update token FocusLost updated
elif document.hasFocus () && updated.AwayFromFocus then
update token FocusRegained updated
else
updated, Cmd.none
| FocusLost ->
if model.AwayFromFocus then
model, Cmd.none
else
// Best-effort: whether this ping succeeds or fails, the student
// shouldn't see an error banner over something that isn't their
// action the teacher-facing count is server-side and this is
// just one report of it.
let cmd =
Cmd.OfAsync.either
(Api.reportFocusLoss token)
model.Data.AttemptId
(fun _ -> FocusLossReported)
(fun _ -> FocusLossReported)
{ model with AwayFromFocus = true }, cmd
| FocusRegained -> { model with AwayFromFocus = false }, Cmd.none
| FocusLossReported -> model, Cmd.none
| Finish ->
let updated = { model with IsFinishing = true }
let cmd =
Cmd.OfAsync.either
(Api.finishAttempt token)
model.Data.AttemptId
(function
| Ok result -> Finished result
| Error err -> FinishFailed err)
(fun ex -> FinishFailed ex.Message)
updated, cmd
| Finished _ -> model, Cmd.none // handled by the parent, which switches pages
| FinishFailed err -> { model with Error = Some err; IsFinishing = false }, Cmd.none