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:
111
src/Client/Features/Quizzes/TakeQuiz/State.fs
Normal file
111
src/Client/Features/Quizzes/TakeQuiz/State.fs
Normal 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
|
||||
Reference in New Issue
Block a user