// noinspection JSUnresolvedVariable const {$, doAfterLoad, footer, header, nav} = window.fwdekker; /** * Returns a number between `min` (inclusive) and `max` (inclusive). * * @param min the lower bound of permissible values * @param max the upper bound of permissible values */ function generateRandom(min, max) { return Math.floor(Math.random() * (max - min + 1) + min); } /** * An input that can be validated. * * In particular, the century, year, and day inputs of the Doomsday test. * * @property input {HTMLInputElement} the input that is validatable * @property titleLabel {HTMLElement} the label of which to update the text * @property button {HTMLButtonElement} the submission button that activates validation */ class ValidatableInput { /** * Constructs a new validatable input and registers event listeners. * * @param input {HTMLInputElement} the input that is validatable * @param titleLabel {HTMLElement} the label of which to update the text * @param button {HTMLButtonElement} the submission button that activates validation */ constructor(input, titleLabel, button) { this.input = input; this.titleLabel = titleLabel; this.button = button; this.button.addEventListener("click", () => this.onSubmit()); this.input.addEventListener("keydown", event => { if (event.key !== "Enter") return; this.onSubmit(); event.preventDefault(); }); } /** * Handles the user submitting the input. */ onSubmit() { this.input.dataset["entered"] = "true"; if (this.isValid(this.input.value)) { this.showSuccess(); this.onValidInput(); } else { this.showError(); this.selectInput(); this.onInvalidInput(); } } /** * Returns `true` if and only if the input is valid. * * This method **must** be implemented by subclasses. * * @param value {string} the value of the input to validate * @return {boolean} `true` if and only if the input is valid */ isValid(value) { throw new Error("Implement this method."); } /** * Runs when the user submits a valid input. * * This method **must** be implemented by subclasses. */ onValidInput() { throw new Error("Implement this method."); } /** * Runs when a user submits an invalid input. * * This method **must** be implemented by subclasses. */ onInvalidInput() { throw new Error("Implement this method."); } /** * Resets the input, title, and error message to their initial state, and removes the value from the input. */ reset() { this.input.value = ""; this.input.dataset["entered"] = "false"; this.showSuccess(); this.updateTitle(); this.titleLabel.classList.remove("success-message"); this.button.classList.remove("success-box"); } /** * Marks the input as invalid. */ showError() { this.input.setCustomValidity("Incorrect"); this.titleLabel.classList.remove("success-message"); this.titleLabel.classList.add("error-message"); this.button.classList.remove("success-box"); this.button.classList.add("error-box"); } /** * Marks the input as valid. */ showSuccess() { this.input.setCustomValidity(""); this.titleLabel.classList.remove("error-message"); this.titleLabel.classList.add("success-message"); this.button.classList.remove("error-box"); this.button.classList.add("success-box"); } /** * Updates the title label's contents. * * Does nothing by default. Implement this method to make it do something. */ updateTitle() { // Do nothing } /** * Focuses the input element. */ selectInput() { this.input.select(); } } /** * A wrapper around a `
` element that persists the state in local storage. */ class ToggleableSection { /** * Constructs a new `ToggleableSection`. * * @param name {string} the name to identify this component with in persistent storage * @param details {HTMLDetailsElement} the element that can be toggled */ constructor(name, details) { this._name = name; this._details = details; this._details.addEventListener("toggle", () => this.onToggle(this.isOpened())); this._loadToggle(); } /** * Returns `true` if and only if the component is currently open. * * @return {boolean} `true` if and only if the component is currently open. */ isOpened() { return !!this._details.open; } /** * Opens or closes the component. * * @param isOpened {boolean} whether to open the component */ setOpened(isOpened) { this._details.open = isOpened; } /** * This method is invoked whenever the component is toggled. * * @param isOpened {boolean} the new state of the component */ onToggle(isOpened) { this._storeToggle(); } /** * Persists the state of this component. * * @private */ _storeToggle() { localStorage.setItem(`/tools/doomsday//toggle-${this._name}`, "" + this.isOpened()); } /** * Reads the state of this component from persistent storage and applies it. * * @private */ _loadToggle() { const target = localStorage.getItem(`/tools/doomsday//toggle-${this._name}`); if (target === null) { this.setOpened(true); this._storeToggle(); return; } this.setOpened(target === "true"); } } /** * A wrapper around the good ol' `Date` class that provides a bunch of useful Doomsday-specific methods. * * @property {Date} the underlying date */ class DoomsdayDate { /** * Wraps a `DoomsdayDate` around the given date. * * @param date {Date} the date to be wrapped */ constructor(date) { this.date = date; } /** * Returns the number of this `DoomsdayDate`'s century. * * @return {number} the number of this `DoomsdayDate`'s century */ getCentury() { return Math.floor(this.date.getFullYear() / 100); } /** * Returns the day of the week of the anchor of this `DoomsdayDate`'s century. * * @return {string} the day of the week of the anchor of this `DoomsdayDate`'s century */ getCenturyAnchorString() { const centuryAnchorNumber = (5 * (this.getCentury() % 4)) % 7 + 2; return DoomsdayDate.dayNumberToString(centuryAnchorNumber); }; /** * Returns the day of the week of the anchor day of this `DoomsdayDate`'s year. * * @return {string} the day of the week of the anchor day of this `DoomsdayDate`'s year */ getYearAnchorString() { const anchorDate = new Date(this.date); anchorDate.setDate(4); // 4th anchorDate.setMonth(3); // April return DoomsdayDate.dayNumberToString(anchorDate.getDay()); }; /** * Returns the day of the week of this `DoomsdayDate`. * * @return {string} the day of the week of this `DoomsdayDate` */ getWeekdayString() { return DoomsdayDate.dayNumberToString(this.date.getDay()); }; /** * Returns the name of the day given its 0-based index, where 0 is `Sunday`. * * @param dayNumber {number} the number of the day, as returned by `Date`'s `#getDay` function. * @return {string} the name of the day given its 0-based index, where 0 is `Sunday` */ static dayNumberToString(dayNumber) { switch (dayNumber % 7) { case 0: return "Sunday"; case 1: return "Monday"; case 2: return "Tuesday"; case 3: return "Wednesday"; case 4: return "Thursday"; case 5: return "Friday"; case 6: return "Saturday"; } }; /** * Returns the day of the week corresponding to the given string. * * This is a convenience method for interpreting (incomplete) user inputs. * * @param dayString {string} the day of the week to expand * @return {string} the day of the week corresponding to the given string */ static expandDayString(dayString) { dayString = dayString.toLowerCase(); if (dayString.startsWith("m")) return "Monday"; else if (dayString.startsWith("tu")) return "Tuesday"; else if (dayString.startsWith("w")) return "Wednesday"; else if (dayString.startsWith("th")) return "Thursday"; else if (dayString.startsWith("f")) return "Friday"; else if (dayString.startsWith("sa")) return "Saturday"; else if (dayString.startsWith("su")) return "Sunday"; else return undefined; } /** * Returns a random date in the range `0001-01-01` (inclusive) to `9999-12-31` (inclusive), wrapped inside a * `DoomsdayDate` object. * * @return {DoomsdayDate} a random date */ static random() { // TODO Give custom dates to this method const minDate = new Date("0001-01-01").getTime() / 86400000; const maxDate = new Date("9999-12-31").getTime() / 86400000; return new DoomsdayDate(new Date(generateRandom(minDate, maxDate) * 86400000)); } } doAfterLoad(() => { // Initialize template $("#nav").appendChild(nav("/Tools/Doomsday/")); $("#header").appendChild(header({ title: "Doomsday", description: ` Test your mastery of \ ⎋ Conway's Doomsday rule ` })); $("#footer").appendChild(footer({ vcsURL: "https://git.fwdekker.com/FWDekker/doomsday/", version: "v%%VERSION_NUMBER%%" })); $("main").classList.remove("hidden"); // Initialize quiz let quizDate; const centuryDetails = new class extends ToggleableSection { onToggle(isOpened) { super.onToggle(isOpened); if (isOpened) centuryInput.selectInput(); centuryInput.updateTitle(); } }("century", $("#century-details")); const yearDetails = new class extends ToggleableSection { onToggle(isOpened) { super.onToggle(isOpened); if (isOpened) yearInput.selectInput(); yearInput.updateTitle(); } }("year", $("#year-details")); const centuryInput = new class extends ValidatableInput { isValid(value) { console.log("# Validate century"); console.log(`Input: ${value}`); console.log(`Expanded: ${DoomsdayDate.expandDayString(value)}`); console.log(`Expected: ${quizDate.getCenturyAnchorString()}`); return DoomsdayDate.expandDayString(value) === quizDate.getCenturyAnchorString(); } onValidInput() { this.input.value = DoomsdayDate.expandDayString(this.input.value); if (yearDetails.isOpened()) yearInput.selectInput(); else dayInput.selectInput(); } onInvalidInput() { // Do nothing } updateTitle() { if (centuryDetails.isOpened()) this.titleLabel.innerText = `Anchor day of century starting in ${quizDate.getCentury() * 100}?`; else this.titleLabel.innerText = `Century`; } }($("#century-input"), $("#century-title-label"), $("#century-submit")); const yearInput = new class extends ValidatableInput { isValid(value) { console.log("# Validate year"); console.log(`Input: ${value}`); console.log(`Expanded: ${DoomsdayDate.expandDayString(value)}`); console.log(`Expected: ${quizDate.getYearAnchorString()}`); return DoomsdayDate.expandDayString(value) === quizDate.getYearAnchorString(); } onValidInput() { this.input.value = DoomsdayDate.expandDayString(this.input.value); dayInput.selectInput(); } onInvalidInput() { // Do nothing } updateTitle() { if (yearDetails.isOpened()) this.titleLabel.innerText = `Doomsday of year ${quizDate.date.getFullYear()}?`; else this.titleLabel.innerText = `Year`; } }($("#year-input"), $("#year-title-label"), $("#year-submit")); const dayInput = new class extends ValidatableInput { isValid(value) { console.log("# Validate day"); console.log(`Input: ${value}`); console.log(`Expanded: ${DoomsdayDate.expandDayString(value)}`); console.log(`Expected: ${quizDate.getWeekdayString()}`); return DoomsdayDate.expandDayString(value) === quizDate.getWeekdayString(); } onValidInput() { this.input.value = DoomsdayDate.expandDayString(this.input.value); resetButton.focus(); } onInvalidInput() { // Do nothing } updateTitle() { this.titleLabel.innerText = `Weekday of ${quizDate.date.toISOString().substr(0, 10)}?`; } }($("#day-input"), $("#day-title-label"), $("#day-submit")); const resetButton = $("#reset-button"); resetButton.addEventListener("click", () => { console.log(" "); console.log(" "); reloadQuiz(); }); /** * Generates a new date for the quiz and resets the inputs to reflect this. */ function reloadQuiz() { quizDate = DoomsdayDate.random(); console.log("# Reset"); console.log(`New date: ${quizDate.date.toISOString().substr(0, 10)}`); console.log(`Century#: ${quizDate.getCentury()}`); console.log(`Century: ${quizDate.getCenturyAnchorString()}`); console.log(`Year: ${quizDate.getYearAnchorString()}`); console.log(`Day: ${quizDate.getWeekdayString()}`); centuryInput.reset(); yearInput.reset(); dayInput.reset(); if (centuryDetails.isOpened()) centuryInput.selectInput(); else if (yearDetails.isOpened()) yearInput.selectInput(); else dayInput.selectInput(); } // Let the fun begin reloadQuiz(); });