Some checks failed
CI/CD / build-test-deploy (pull_request) Failing after 2s
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.
115 lines
5.3 KiB
Forth
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
|