Files
RuvdsTest/src/Client/Features/Quizzes/TakeQuiz/State.fs
danamir ef119dc0dc
Some checks failed
CI/CD / build-test-deploy (pull_request) Failing after 2s
Show one question per card in the take-quiz screen, with prev/next arrows
Replaces the single long scrollable list of every question with one
card at a time plus arrow navigation, so a student's focus stays on
the current question instead of the whole quiz at once.
2026-08-09 00:55:01 +03:00

115 lines
5.3 KiB
Forth

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
| GoToQuestion index ->
let clamped = index |> max 0 |> min (List.length model.Data.Questions - 1)
{ model with CurrentIndex = clamped }, 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