All files / src/utils/helpers assignmentTimeAllowed.js

100% Statements 16/16
100% Branches 21/21
100% Functions 6/6
100% Lines 13/13

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                                                    70x 70x 70x 70x                                     50x 50x 50x                           31x                                       17x                                     61x 88x 59x 6x    
/**
 * The assignment `time_allowed` contract, plus the date-picker's past-date rule.
 *
 * These were module-private inside the 401-line `step-3/Step3.jsx` at 0% coverage,
 * which is awkward because Step 3 is the WRITER of `time_allowed` and the student's
 * countdown is the READER (`take-assignment/components/takeAssignmentTimer.js`).
 * The two must agree on the format or a student gets the wrong duration, so the
 * agreement is asserted directly in the tests for this module.
 *
 * Backend contract: `time_allowed` is an "HH:MM:SS" string, or null for unlimited.
 *
 * Developer: Allan Ninal
 */
 
import dayjs from "dayjs";
 
/**
 * Total minutes -> the backend's "HH:MM:SS" string.
 *
 * Hours are NOT capped at 24: the input accepts up to 9999 minutes (~6.9 days),
 * and the reader parses "166:39:00" correctly.
 *
 * @param {number|string} minutes
 * @returns {string}
 */
export function minutesToTimeAllowed(minutes) {
    const totalMinutes = Math.max(0, parseInt(minutes, 10) || 0);
    const hours = Math.floor(totalMinutes / 60);
    const mins = totalMinutes % 60;
    return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}:00`;
}
 
/**
 * "HH:MM:SS" -> total minutes, for re-populating the form.
 *
 * Falls back to 60 for anything that yields zero. That makes the round trip
 * ASYMMETRIC at the bottom end: `minutesToTimeAllowed(0)` is "00:00:00", but
 * reading "00:00:00" back gives 60, not 0. Deliberate — 60 is the form's default,
 * and a zero-minute assessment would expire the moment a student opened it.
 *
 * Not currently reachable: the minutes field accepts `/^\d{0,4}$/` only and resets
 * to 60 on blur when empty or <= 0, so "00:00:00" is never written. The fallback is
 * the second line of defence, not the first.
 *
 * @param {string|null|undefined} timeAllowed
 * @returns {number} minutes, never 0
 */
export function timeAllowedToMinutes(timeAllowed) {
    const [hours = 0, minutes = 0] = (timeAllowed ?? "").split(":").map(Number);
    const total = (Number(hours) || 0) * 60 + (Number(minutes) || 0);
    return total || 60;
}
 
/**
 * Is this assignment explicitly unlimited?
 *
 * `null` means "saved as unlimited"; `undefined` means "Step 3 was never filled in"
 * and must NOT be treated as unlimited, or a brand-new wizard would default to no
 * time limit. Hence the strict === rather than a falsy check.
 *
 * @param {string|null|undefined} timeAllowed
 * @returns {boolean}
 */
export function isUnlimitedTimeAllowed(timeAllowed) {
    return timeAllowed === null;
}
 
/**
 * Should the date picker disable this day?
 *
 * Day-granular and LOCAL on purpose: the teacher is choosing a calendar day in
 * their own zone (Texas by default), not an instant. Contrast
 * `helpers/assignmentStatus.js`, which compares instants in UTC because it decides
 * whether a window is currently open.
 *
 * Previously `current < moment().startOf("day")`. moment was imported into Step3
 * for this single call and shipped ~428KB into the Step4 chunk; dayjs was already a
 * dependency and antd's picker hands us dayjs objects anyway. Verified identical
 * under America/Chicago, UTC and Asia/Manila, including both 2026 DST boundaries.
 *
 * @param {import("dayjs").Dayjs|null|undefined} current
 * @returns {boolean}
 */
export function isDateBeforeToday(current) {
    return Boolean(current) && current < dayjs().startOf("day");
}
 
/**
 * Should the date picker disable this day, given the range already loaded into the form?
 *
 * Wraps {@link isDateBeforeToday} but carves out an exception for the dates already
 * in `existingRange` (the assignment's currently loaded open/close dates). Without
 * this, editing an already-CLOSED assignment — whose open and close dates are both
 * in the past — disables its own stored dates. antd's RangePicker treats a `value`
 * containing a disabled date as invalid, so opening the picker on a closed
 * assignment cleared BOTH dates back to empty, even when the teacher only meant to
 * change the close date.
 *
 * @param {import("dayjs").Dayjs|null|undefined} current
 * @param {Array<import("dayjs").Dayjs|null|undefined>} existingRange - the currently loaded [open, close] dates
 * @returns {boolean}
 */
export function isDateDisabledForRange(current, existingRange = []) {
    if (!current) return false;
    const isExistingDate = existingRange.some((date) => date && current.isSame(date, "day"));
    if (isExistingDate) return false;
    return isDateBeforeToday(current);
}