+
+
+
+
+
+
+
+
diff --git a/src/main/js/index.js b/src/main/js/index.js
new file mode 100644
index 0000000..b92c044
--- /dev/null
+++ b/src/main/js/index.js
@@ -0,0 +1,500 @@
+import {$, doAfterLoad, footer, header, nav} from "@fwdekker/template";
+import Cookies from "js-cookie";
+
+
+/**
+ * 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 a cookie.
+ */
+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() {
+ Cookies.set(`toggle-${this._name}`, this.isOpened(), {expires: 365 * 10});
+ }
+
+ /**
+ * Reads the state of this component from persistent storage and applies it.
+ *
+ * @private
+ */
+ _loadToggle() {
+ const target = Cookies.get(`toggle-${this._name}`);
+ if (target === undefined) {
+ 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());
+ $("#header").appendChild(header({
+ title: "Doomsday",
+ description: `
+ Test your mastery of \
+ Conway's Doomsday rule.
+ `
+ }));
+ $("#footer").appendChild(footer({
+ author: "Felix W. Dekker",
+ authorURL: "https://fwdekker.com/",
+ license: "MIT License",
+ licenseURL: "https://git.fwdekker.com/FWDekker/doomsday/src/branch/master/LICENSE",
+ vcs: "git",
+ vcsURL: "https://git.fwdekker.com/FWDekker/doomsday/",
+ version: "v%%VERSION_NUMBER%%"
+ }));
+ $("main").style.display = null;
+
+
+ // 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();
+});