const {$, doAfterLoad} = (window as any).fwdekker; const {clearInputValidity, showInputInvalid, showInputValid} = (window as any).fwdekker.validation; import {DateTime} from "luxon"; /** * 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: number, max: number) { 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. */ class ValidatableInput { /** * The input that is validatable. */ readonly input: HTMLInputElement; /** * The label of which to update the text. */ readonly titleLabel: HTMLElement; /** * The submission button that activates validation. */ readonly button: HTMLButtonElement; /** * Constructs a new validatable input and registers event listeners. * * @param input the input that is validatable * @param titleLabel the label of which to update the text * @param button the submission button that activates validation */ constructor(input: HTMLInputElement, titleLabel: HTMLElement, button: HTMLButtonElement) { 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(): void { 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 the value of the input to validate * @return `true` if and only if the input is valid */ isValid(value: string): boolean { throw new Error("Implement this method."); } /** * Runs when the user submits a valid input. * * This method **must** be implemented by subclasses. */ onValidInput(): void { throw new Error("Implement this method."); } /** * Runs when a user submits an invalid input. * * This method **must** be implemented by subclasses. */ onInvalidInput(): void { 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(): void { this.input.value = ""; this.showSuccess(); this.updateTitle(); clearInputValidity(this.input); } /** * Marks the input as invalid. */ showError(): void { showInputInvalid(this.input); } /** * Marks the input as valid. */ showSuccess(): void { showInputValid(this.input); } /** * Updates the title label's contents. * * Does nothing by default. Implement this method to make it do something. */ updateTitle(): void { // Do nothing } /** * Focuses the input element. */ selectInput(): void { this.input.select(); } } /** * A wrapper around a `
` element that persists the state in local storage. */ class ToggleableSection { /** * The name to identify this component with in persistent storage. * * @private */ private readonly name: string; /** * The element that can be toggled. * * @private */ private readonly details: HTMLDetailsElement; /** * Constructs a new `ToggleableSection`. * * @param name the name to identify this component with in persistent storage * @param details the element that can be toggled */ constructor(name: string, details: HTMLDetailsElement) { 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 `true` if and only if the component is currently open. */ isOpened(): boolean { return this.details.open; } /** * Opens or closes the component. * * @param isOpened whether to open the component */ setOpened(isOpened: boolean): void { this.details.open = isOpened; } /** * This method is invoked whenever the component is toggled. * * @param isOpened the new state of the component */ onToggle(isOpened: boolean): void { this.storeToggle(); } /** * Persists the state of this component. * * @private */ private storeToggle(): void { localStorage.setItem(`/tools/doomsday//toggle-${this.name}`, "" + this.isOpened()); } /** * Reads the state of this component from persistent storage and applies it. * * @private */ private loadToggle(): void { 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. */ class DoomsdayDate { /** * The underlying date. */ readonly date: DateTime; /** * Wraps a `DoomsdayDate` around the given date. * * @param date the date to be wrapped */ constructor(date: DateTime) { this.date = date; } /** * Returns the number of this `DoomsdayDate`'s century. * * @return the number of this `DoomsdayDate`'s century */ getCentury(): number { return Math.floor(this.date.year / 100); } /** * Returns the day of the week of the anchor of this `DoomsdayDate`'s century. * * @return the day of the week of the anchor of this `DoomsdayDate`'s century */ getCenturyAnchorString(): string { return this.date.set({year: this.getCentury() * 100, month: 4, day: 4}).setLocale("en-US").weekdayLong; }; /** * Returns the day of the week of the anchor day of this `DoomsdayDate`'s year. * * @return the day of the week of the anchor day of this `DoomsdayDate`'s year */ getYearAnchorString(): string { return this.date.set({month: 4, day: 4}).setLocale("en-US").weekdayLong; }; /** * Returns the day of the week of this `DoomsdayDate`. * * @return the day of the week of this `DoomsdayDate` */ getWeekdayString(): string { return this.date.setLocale("en-US").weekdayLong; }; /** * Returns the day of the week corresponding to `dayString`, or an empty string if no day was recognized. * * This is a convenience method for interpreting (incomplete) user inputs. * * @param dayString the day of the week to expand * @return the day of the week corresponding to `dayString`, or an empty string if no day was recognized */ static expandDayString(dayString: string): string { 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 ""; } /** * 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 date range to this method const startDate = DateTime.utc(1, 1, 1); const dayRange = 9999 * 365 + (10000 / 400 * 97) - 1; return new DoomsdayDate(startDate.plus({days: generateRandom(0, dayRange)})) } } doAfterLoad(() => { $("main").classList.remove("hidden"); // Initialize quiz let quizDate: DoomsdayDate; const centuryDetails = new class extends ToggleableSection { onToggle(isOpened: boolean): void { super.onToggle(isOpened); if (isOpened) centuryInput.selectInput(); else if (yearDetails.isOpened()) yearInput.selectInput(); else if (dayDetails.isOpened()) dayInput.selectInput(); centuryInput.updateTitle(); } }("century", $("#century-details")); const yearDetails = new class extends ToggleableSection { onToggle(isOpened: boolean): void { super.onToggle(isOpened); if (isOpened) yearInput.selectInput(); else if (dayDetails.isOpened()) dayInput.selectInput(); else if (centuryDetails.isOpened()) centuryInput.selectInput(); yearInput.updateTitle(); } }("year", $("#year-details")); const dayDetails = new class extends ToggleableSection { onToggle(isOpened: boolean): void { super.onToggle(isOpened); if (isOpened) dayInput.selectInput(); else if (centuryDetails.isOpened()) centuryInput.selectInput(); else if (yearDetails.isOpened()) yearInput.selectInput(); dayInput.updateTitle(); } }("day", $("#day-details")); const centuryInput = new class extends ValidatableInput { isValid(value: string): boolean { 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(): void { this.input.value = DoomsdayDate.expandDayString(this.input.value); if (yearDetails.isOpened()) yearInput.selectInput(); else if (dayDetails.isOpened()) dayInput.selectInput(); else resetButton.focus(); } 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: string): boolean { 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); if (dayDetails.isOpened()) dayInput.selectInput(); else resetButton.focus(); } updateTitle() { if (yearDetails.isOpened()) this.titleLabel.innerText = `Doomsday of year ${quizDate.date.year}?`; else this.titleLabel.innerText = `Year`; } }($("#year-input"), $("#year-title-label"), $("#year-submit")); const dayInput = new class extends ValidatableInput { isValid(value: string): boolean { 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(); } updateTitle() { if (dayDetails.isOpened()) this.titleLabel.innerText = `Weekday of ${quizDate.date.toISODate()}?`; else this.titleLabel.innerText = `Day`; } }($("#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.toISODate()}`); console.log(` ${quizDate.date}`); 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 { dayDetails.setOpened(true); dayInput.selectInput(); } } // Let the fun begin reloadQuiz(); });