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 = 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 = 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 = 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