diff --git a/Gruntfile.js b/Gruntfile.js index 4a5adcf..166e196 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -16,7 +16,7 @@ module.exports = grunt => { }, focus: { dev: { - include: ["css", "html", "js"], + include: ["css", "html", "ts"], }, }, replace: { @@ -51,23 +51,24 @@ module.exports = grunt => { tasks: ["copy:html"], }, js: { - files: ["src/main/**/*.js"], + files: ["src/main/**/*.ts"], tasks: ["webpack:dev", "replace:dev"], }, }, webpack: { options: { - entry: "./src/main/js/main.js", + entry: "./src/main/js/Main.ts", module: { rules: [ { - test: /\.js$/, + test: /\.ts$/, + use: "ts-loader", exclude: /node_modules/, }, ], }, resolve: { - extensions: [".js"], + extensions: [".ts"], }, output: { filename: "bundle.js", diff --git a/package-lock.json b/package-lock.json index 9aded68..404a828 100644 Binary files a/package-lock.json and b/package-lock.json differ diff --git a/package.json b/package.json index 88fb929..e59b826 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doomsday", - "version": "1.3.19", + "version": "2.0.0", "description": "Test your mastery of Conway's Doomsday rule.", "author": "Florine W. Dekker", "browser": "dist/bundle.js", @@ -24,7 +24,9 @@ "grunt-focus": "^1.0.0", "grunt-text-replace": "^0.4.0", "grunt-webpack": "^5.0.0", - "webpack": "^5.69.1", + "ts-loader": "^9.2.7", + "typescript": "^4.6.2", + "webpack": "^5.70.0", "webpack-cli": "^4.9.2" } } diff --git a/src/main/js/main.js b/src/main/js/Main.ts similarity index 75% rename from src/main/js/main.js rename to src/main/js/Main.ts index f76f6cb..52cf505 100644 --- a/src/main/js/main.js +++ b/src/main/js/Main.ts @@ -1,4 +1,4 @@ -// noinspection JSUnresolvedVariable +// @ts-ignore const {$, doAfterLoad, footer, header, nav} = window.fwdekker; @@ -8,7 +8,7 @@ const {$, doAfterLoad, footer, header, nav} = window.fwdekker; * @param min the lower bound of permissible values * @param max the upper bound of permissible values */ -function generateRandom(min, max) { +function generateRandom(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1) + min); } @@ -17,20 +17,30 @@ function generateRandom(min, max) { * 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 { + /** + * 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 {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 + * @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, titleLabel, button) { + constructor(input: HTMLInputElement, titleLabel: HTMLElement, button: HTMLButtonElement) { this.input = input; this.titleLabel = titleLabel; this.button = button; @@ -49,7 +59,7 @@ class ValidatableInput { /** * Handles the user submitting the input. */ - onSubmit() { + onSubmit(): void { this.input.dataset["entered"] = "true"; if (this.isValid(this.input.value)) { @@ -67,10 +77,10 @@ class ValidatableInput { * * 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 + * @param value the value of the input to validate + * @return `true` if and only if the input is valid */ - isValid(value) { + isValid(value: string): boolean { throw new Error("Implement this method."); } @@ -79,7 +89,7 @@ class ValidatableInput { * * This method **must** be implemented by subclasses. */ - onValidInput() { + onValidInput(): void { throw new Error("Implement this method."); } @@ -88,7 +98,7 @@ class ValidatableInput { * * This method **must** be implemented by subclasses. */ - onInvalidInput() { + onInvalidInput(): void { throw new Error("Implement this method."); } @@ -96,7 +106,7 @@ class ValidatableInput { /** * Resets the input, title, and error message to their initial state, and removes the value from the input. */ - reset() { + reset(): void { this.input.value = ""; this.input.dataset["entered"] = "false"; @@ -110,7 +120,7 @@ class ValidatableInput { /** * Marks the input as invalid. */ - showError() { + showError(): void { this.input.setCustomValidity("Incorrect"); this.titleLabel.classList.remove("success-message"); @@ -123,7 +133,7 @@ class ValidatableInput { /** * Marks the input as valid. */ - showSuccess() { + showSuccess(): void { this.input.setCustomValidity(""); this.titleLabel.classList.remove("error-message"); @@ -138,7 +148,7 @@ class ValidatableInput { * * Does nothing by default. Implement this method to make it do something. */ - updateTitle() { + updateTitle(): void { // Do nothing } @@ -146,7 +156,7 @@ class ValidatableInput { /** * Focuses the input element. */ - selectInput() { + selectInput(): void { this.input.select(); } } @@ -155,46 +165,60 @@ class ValidatableInput { * 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 {string} the name to identify this component with in persistent storage - * @param details {HTMLDetailsElement} the element that can be toggled + * @param name the name to identify this component with in persistent storage + * @param details the element that can be toggled */ - constructor(name, details) { - this._name = name; - this._details = details; - this._details.addEventListener("toggle", () => this.onToggle(this.isOpened())); + constructor(name: string, details: HTMLDetailsElement) { + this.name = name; + this.details = details; + this.details.addEventListener("toggle", () => this.onToggle(this.isOpened())); - this._loadToggle(); + 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. + * @return `true` if and only if the component is currently open. */ - isOpened() { - return !!this._details.open; + isOpened(): boolean { + return this.details.open; } /** * Opens or closes the component. * - * @param isOpened {boolean} whether to open the component + * @param isOpened whether to open the component */ - setOpened(isOpened) { - this._details.open = isOpened; + setOpened(isOpened: boolean): void { + this.details.open = isOpened; } /** * This method is invoked whenever the component is toggled. * - * @param isOpened {boolean} the new state of the component + * @param isOpened the new state of the component */ - onToggle(isOpened) { - this._storeToggle(); + onToggle(isOpened: boolean): void { + this.storeToggle(); } @@ -203,8 +227,8 @@ class ToggleableSection { * * @private */ - _storeToggle() { - localStorage.setItem(`/tools/doomsday//toggle-${this._name}`, "" + this.isOpened()); + private storeToggle(): void { + localStorage.setItem(`/tools/doomsday//toggle-${this.name}`, "" + this.isOpened()); } /** @@ -212,11 +236,11 @@ class ToggleableSection { * * @private */ - _loadToggle() { - const target = localStorage.getItem(`/tools/doomsday//toggle-${this._name}`); + private loadToggle(): void { + const target = localStorage.getItem(`/tools/doomsday//toggle-${this.name}`); if (target === null) { this.setOpened(true); - this._storeToggle(); + this.storeToggle(); return; } @@ -226,16 +250,20 @@ class ToggleableSection { /** * A wrapper around the good ol' `Date` class that provides a bunch of useful Doomsday-specific methods. - * - * @property {Date} date the underlying date */ class DoomsdayDate { + /** + * The underlying date. + */ + readonly date: Date; + + /** * Wraps a `DoomsdayDate` around the given date. * - * @param date {Date} the date to be wrapped + * @param date the date to be wrapped */ - constructor(date) { + constructor(date: Date) { this.date = date; } @@ -243,18 +271,18 @@ class DoomsdayDate { /** * Returns the number of this `DoomsdayDate`'s century. * - * @return {number} the number of this `DoomsdayDate`'s century + * @return the number of this `DoomsdayDate`'s century */ - getCentury() { + getCentury(): number { 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 + * @return the day of the week of the anchor of this `DoomsdayDate`'s century */ - getCenturyAnchorString() { + getCenturyAnchorString(): string { const centuryAnchorNumber = (5 * (this.getCentury() % 4)) % 7 + 2; return DoomsdayDate.getWeekDayOf(centuryAnchorNumber); }; @@ -262,9 +290,9 @@ class DoomsdayDate { /** * 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 + * @return the day of the week of the anchor day of this `DoomsdayDate`'s year */ - getYearAnchorString() { + getYearAnchorString(): string { const anchorDate = new Date(this.date); anchorDate.setDate(4); // 4th anchorDate.setMonth(3); // April @@ -275,9 +303,9 @@ class DoomsdayDate { /** * Returns the day of the week of this `DoomsdayDate`. * - * @return {string} the day of the week of this `DoomsdayDate` + * @return the day of the week of this `DoomsdayDate` */ - getWeekdayString() { + getWeekdayString(): string { return DoomsdayDate.getWeekDayOf(this.date); }; @@ -285,10 +313,10 @@ class DoomsdayDate { /** * Returns the week day of [date]. * - * @param date {Date|number} the date to get the week day of; if it is a `number`, then 0 corresponds to Sunday - * @return {string} the name of the week day corresponding to [date] + * @param date the date to get the week day of; if it is a `number`, then 0 corresponds to Sunday + * @return the name of the week day corresponding to [date] */ - static getWeekDayOf(date) { + static getWeekDayOf(date: Date|number): string { if (date instanceof Date) { return date.toLocaleString("en-US", {weekday: "long"}); } else { @@ -307,19 +335,21 @@ class DoomsdayDate { return "Friday"; case 6: return "Saturday"; + default: + throw new Error(`Unexpected weekday number '${date}'.`); } } }; /** - * Returns the day of the week corresponding to the given string. + * 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 {string} the day of the week to expand - * @return {string} the day of the week corresponding to the given string + * @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) { + static expandDayString(dayString: string): string { dayString = dayString.toLowerCase(); if (dayString.startsWith("m")) return "Monday"; @@ -336,7 +366,7 @@ class DoomsdayDate { else if (dayString.startsWith("su")) return "Sunday"; else - return undefined; + return ""; } /** @@ -372,17 +402,17 @@ doAfterLoad(() => { // Initialize quiz - let quizDate; + let quizDate: DoomsdayDate; const centuryDetails = new class extends ToggleableSection { - onToggle(isOpened) { + onToggle(isOpened: boolean): void { super.onToggle(isOpened); if (isOpened) centuryInput.selectInput(); centuryInput.updateTitle(); } }("century", $("#century-details")); const yearDetails = new class extends ToggleableSection { - onToggle(isOpened) { + onToggle(isOpened: boolean): void { super.onToggle(isOpened); if (isOpened) yearInput.selectInput(); yearInput.updateTitle(); @@ -390,7 +420,7 @@ doAfterLoad(() => { }("year", $("#year-details")); const centuryInput = new class extends ValidatableInput { - isValid(value) { + isValid(value: string): boolean { console.log("# Validate century"); console.log(`Input: ${value}`); console.log(`Expanded: ${DoomsdayDate.expandDayString(value)}`); @@ -398,7 +428,7 @@ doAfterLoad(() => { return DoomsdayDate.expandDayString(value) === quizDate.getCenturyAnchorString(); } - onValidInput() { + onValidInput(): void { this.input.value = DoomsdayDate.expandDayString(this.input.value); if (yearDetails.isOpened()) yearInput.selectInput(); @@ -418,7 +448,7 @@ doAfterLoad(() => { } }($("#century-input"), $("#century-title-label"), $("#century-submit")); const yearInput = new class extends ValidatableInput { - isValid(value) { + isValid(value: string): boolean { console.log("# Validate year"); console.log(`Input: ${value}`); console.log(`Expanded: ${DoomsdayDate.expandDayString(value)}`); @@ -443,7 +473,7 @@ doAfterLoad(() => { } }($("#year-input"), $("#year-title-label"), $("#year-submit")); const dayInput = new class extends ValidatableInput { - isValid(value) { + isValid(value: string): boolean { console.log("# Validate day"); console.log(`Input: ${value}`); console.log(`Expanded: ${DoomsdayDate.expandDayString(value)}`); @@ -461,7 +491,7 @@ doAfterLoad(() => { } updateTitle() { - this.titleLabel.innerText = `Weekday of ${quizDate.date.toISOString().substr(0, 10)}?`; + this.titleLabel.innerText = `Weekday of ${quizDate.date.toISOString().substring(0, 10)}?`; } }($("#day-input"), $("#day-title-label"), $("#day-submit")); @@ -479,7 +509,7 @@ doAfterLoad(() => { function reloadQuiz() { quizDate = DoomsdayDate.random(); console.log("# Reset"); - console.log(`New date: ${quizDate.date.toISOString().substr(0, 10)}`); + console.log(`New date: ${quizDate.date.toISOString().substring(0, 10)}`); console.log(` ${quizDate.date}`); console.log(`Century#: ${quizDate.getCentury()}`); console.log(`Century: ${quizDate.getCenturyAnchorString()}`); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..69c22e6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es6", + "strict": true, + "rootDir": "./src/main/js/", + "outDir": "./dist/js/" + }, + "include": [ + "src/main/js/**/*.ts" + ] +}