All files / src/app/student-page/page/assignments/take-assignment/components Timer.jsx

100% Statements 62/62
100% Branches 26/26
100% Functions 19/19
100% Lines 53/53

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168                                                    1x 137x 137x   173x 173x       137x       137x   137x     137x   137x   137x 137x 137x   137x 34x     137x 105x           137x 34x   30x 34x   30x     137x 34x 34x 33x       137x 16x   16x         16x 16x         12x 11x               4x 4x       137x 34x   30x 341x 326x   175x 16x 16x 16x     175x       30x     137x 2x 2x 2x     137x                                     1x         2x                    
/**
 * Modified by Allan Ninal — 2026-08-11
 *
 * Scoped the saved countdown to its own assignment and stopped persisting it for
 * unlimited assignments; parsing/formatting moved to takeAssignmentTimer.js (PR #207).
 */
 
import { Box, Button, Typography } from "@mui/material";
import React, { useEffect, useState, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { _updateShowTimer } from "@/store/slices/student/StudentQuestionPage";
import { API_ENDPOINTS } from "@/api/endpoints";
import axios from "axios";
import { enqueueSnackbar } from "notistack";
import { submitFailureMessage } from "./AnswerTracker";
import { useNavigate } from "react-router-dom";
import OKOption from "@/utils/components/dialogs/OKOption";
import {
    clearSavedSeconds,
    formatSecondsToTime,
    parseTimeStringToSeconds,
    readSavedSeconds,
    resolveInitialSeconds,
    saveSeconds,
} from "./takeAssignmentTimer";
 
const Timer = () => {
    const dispatch = useDispatch();
    const navigate = useNavigate();
 
    const { specificAssignmentData } = useSelector((state) => state.StudentSpecificAssignment);
    const { myAnswers, settings, remainingTime } = useSelector((state) => state.StudentQuestionPage);
 
    // Main.jsx renders nothing until specificAssignmentData is loaded, so the id
    // is available on this component's first render.
    const assignmentId = specificAssignmentData?.details?._id;
 
    // A falsy time_allowed on the assignment's own settings (not just the fetched
    // remaining_time, which is also null before load) means no time limit was set.
    const isUnlimited = !specificAssignmentData?.details?.settings?.time_allowed;
 
    const [totalSeconds, setTotalSeconds] = useState(() =>
        resolveInitialSeconds({ saved: readSavedSeconds(sessionStorage, assignmentId), remainingTime })
    );
    const [timeOver, setTimeOver] = useState(false);
    // Modified by Allan Ninal — see the dialog copy below.
    const [submitFailed, setSubmitFailed] = useState(false);
 
    const answersRef = useRef(myAnswers);
    const hasSubmittedRef = useRef(false);
    const totalSecondsRef = useRef(totalSeconds);
 
    useEffect(() => {
        answersRef.current = myAnswers;
    }, [myAnswers]);
 
    useEffect(() => {
        totalSecondsRef.current = totalSeconds;
    }, [totalSeconds]);
 
    // Nothing is persisted for an unlimited assignment: totalSeconds never
    // decrements on that path, so saving would leave the default 3600 in a shared
    // key for the next assignment to pick up as its own remaining time.
    useEffect(() => {
        if (isUnlimited) return;
 
        const saveInterval = setInterval(() => {
            saveSeconds(sessionStorage, assignmentId, totalSecondsRef.current);
        }, 10000);
        return () => clearInterval(saveInterval);
    }, [isUnlimited, assignmentId]);
 
    useEffect(() => {
        const seconds = parseTimeStringToSeconds(remainingTime);
        if (seconds !== null && readSavedSeconds(sessionStorage, assignmentId) === null) {
            setTotalSeconds(seconds);
        }
    }, [remainingTime, assignmentId]);
 
    const handleTimeUp = async () => {
        clearSavedSeconds(sessionStorage);
 
        const answers = answersRef.current.data.map(({ questionType, answer, ...rest }) => ({
            ...rest,
            studentAnswer: answer ?? "",
        }));
 
        try {
            const { data, status } = await axios.post(
                API_ENDPOINTS.student.assignment.submitAnswers(specificAssignmentData.details._id),
                { answers }
            );
 
            if (status === 200) {
                enqueueSnackbar(data.message, { variant: "success" });
            }
        } catch (error) {
            // Modified by Allan Ninal — a submission that failed when the clock ran out
            // told the student nothing. Their time is gone and their answers did not
            // arrive; saying so is the only chance they have to raise it.
            // The dialog below is shown either way, so without this it keeps telling
            // the student their work "has been submitted automatically" when it has not.
            setSubmitFailed(true);
            enqueueSnackbar(submitFailureMessage(error), { variant: "error" });
        }
    };
 
    useEffect(() => {
        if (isUnlimited) return;
 
        const intervalId = setInterval(() => {
            setTotalSeconds((prev) => {
                if (prev > 1) return prev - 1;
 
                if (prev === 1 && !hasSubmittedRef.current) {
                    hasSubmittedRef.current = true;
                    handleTimeUp();
                    setTimeOver(true);
                }
 
                return 0;
            });
        }, 1000);
 
        return () => clearInterval(intervalId);
    }, [isUnlimited]);
 
    const handleDialogClose = () => {
        clearSavedSeconds(sessionStorage);
        setTimeOver(false);
        navigate(-1);
    };
 
    return (
        <>
            <OKOption
                isDialog={timeOver}
                setDialog={handleDialogClose}
                title={submitFailed ? "Not Submitted" : "Time Expired"}
                message={
                    submitFailed
                        ? "Time has expired, but your answers could NOT be submitted. Please tell your teacher before leaving this page."
                        : "Time has expired for this assignment. Your work has been submitted automatically."
                }
            />
 
            <Box sx={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
                {settings.showTimer ? (
                    <>
                        <Typography>
                            {isUnlimited ? "Unlimited time" : `Remaining time: ${formatSecondsToTime(totalSeconds)}`}
                        </Typography>
                        <Button variant="text" onClick={() => dispatch(_updateShowTimer())}>
                            Hide
                        </Button>
                    </>
                ) : (
                    <Button variant="text" onClick={() => dispatch(_updateShowTimer())}>
                        Show Timer
                    </Button>
                )}
            </Box>
        </>
    );
};
 
export default Timer;