diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7ea1e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,117 @@ +## Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.pnp.* diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..93e20f9 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,127 @@ +const path = require("path"); + +module.exports = grunt => { + grunt.initConfig({ + pkg: grunt.file.readJSON("package.json"), + clean: { + default: ["dist/"], + }, + copy: { + html: { + files: [{expand: true, cwd: "src/main/", src: "**/*.html", dest: "dist/"}] + }, + css: { + files: [{expand: true, cwd: "src/main/", src: "**/*.css", dest: "dist/"}] + }, + }, + focus: { + dev: { + include: ["css", "html", "js"], + }, + devLink: { + include: ["css", "html", "js", "link"], + } + }, + replace: { + dev: { + src: ["./dist/*.html", "./dist/*.js"], + replacements: [ + { + from: "%%VERSION_NUMBER%%", + to: "<%= pkg.version %>+" + new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "") + } + ], + overwrite: true + }, + deploy: { + src: ["./dist/*.html", "./dist/*.js"], + replacements: [ + { + from: "%%VERSION_NUMBER%%", + to: "<%= pkg.version %>" + } + ], + overwrite: true + }, + }, + webpack: { + options: { + entry: "./src/main/js/index.js", + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + }, + ], + }, + resolve: { + extensions: [".js"], + }, + output: { + filename: "bundle.js", + path: path.resolve(__dirname, "dist/"), + }, + }, + dev: { + mode: "development", + devtool: "inline-source-map", + }, + deploy: { + mode: "production", + }, + }, + watch: { + css: { + files: ["src/main/**/*.css"], + tasks: ["copy:css"], + }, + html: { + files: ["src/main/**/*.html"], + tasks: ["copy:html"], + }, + js: { + files: ["src/main/**/*.js"], + tasks: ["webpack:dev", "replace:dev"], + }, + link: { + files: ["node_modules/@fwdekker/*/dist/**"], + tasks: ["webpack:dev", "replace:dev"], + }, + }, + }); + + grunt.loadNpmTasks("grunt-contrib-clean"); + grunt.loadNpmTasks("grunt-contrib-copy"); + grunt.loadNpmTasks("grunt-contrib-watch"); + grunt.loadNpmTasks("grunt-focus"); + grunt.loadNpmTasks("grunt-text-replace"); + grunt.loadNpmTasks("grunt-webpack"); + + grunt.registerTask("dev", [ + // Pre + "clean", + // Copy files + "copy:html", + "copy:css", + // Compile + "webpack:dev", + // Post + "replace:dev" + ]); + grunt.registerTask("dev:server", ["dev", "focus:dev"]); + grunt.registerTask("dev:server:link", ["dev", "focus:devLink"]); + grunt.registerTask("deploy", [ + // Pre + "clean", + // Copy files + "copy:html", + "copy:css", + // Compile JS + "webpack:deploy", + // Post + "replace:deploy" + ]); + + grunt.registerTask("default", ["dev"]); +}; diff --git a/README.md b/README.md index b4faec1..a60b3a6 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,33 @@ Given a random date, can you determine the weekday of that date? weekday of any date in the Gregorian calendar. This tool will help you practice the algorithm by giving you random dates to test on. Set this as your homepage to get yourself to practice this every day. + +## Development +### Requirements +* [npm](https://www.npmjs.com/) + +### Setting up +```shell script +# Install dependencies (only needed once) +$> npm ci +``` + +### Building +```shell script +# Build the template in `dist/` for development +$> npm run dev +# Same as above, but automatically rerun it whenever files are changed +$> npm run dev:server +# Same as above, but also rerun when linked `@fwdekker` dependencies change +$> npm run dev:server:link +# Build the template in `dist/` for deployment +$> npm run deploy +``` + +### Publishing +```shell script +# Log in to npm +$> npm login +# Push to npm +$> npm publish --access public +``` diff --git a/index.html b/index.html deleted file mode 100644 index 664cb04..0000000 --- a/index.html +++ /dev/null @@ -1,634 +0,0 @@ - - - - - - - - - - - Doomsday | FWDekker - - - - - - -
- -
-
-

Doomsday

- -
-

- Test your mastery of - Conway's Doomsday rule. -

-
-
-
- - - -
-
-
- Century -
-
- -
-
- -
-
-
- -
- Year -
-
- -
-
- -
-
-
- -
- -
-
- -
-
- -
-
-
- -
- -
-
- -
-
-
-
- - - - -
- - - - - - - - diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1da1635 Binary files /dev/null and b/package-lock.json differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..8d87856 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "doomsday", + "version": "1.3.3", + "description": "Test your mastery of Conway's Doomsday rule.", + "author": "Felix W. Dekker", + "browser": "dist/bundle.js", + "repository": { + "type": "git", + "url": "git@git.fwdekker.com:FWDekker/interlanguage-checker.git" + }, + "private": true, + "scripts": { + "clean": "grunt clean", + "dev": "grunt dev", + "dev:server": "grunt dev:server", + "dev:server:link": "grunt dev:server:link", + "deploy": "grunt deploy" + }, + "dependencies": { + "@fwdekker/template": "^0.0.10", + "js-cookie": "^2.2.1" + }, + "devDependencies": { + "@types/js-cookie": "^2.2.6", + "grunt": "^1.1.0", + "grunt-cli": "^1.3.2", + "grunt-contrib-clean": "^2.0.0", + "grunt-contrib-copy": "^1.0.0", + "grunt-contrib-watch": "^1.1.0", + "grunt-focus": "^1.0.0", + "grunt-text-replace": "^0.4.0", + "grunt-webpack": "^3.1.3", + "webpack": "^4.42.1", + "webpack-cli": "^3.3.11" + } +} diff --git a/src/main/css/main.css b/src/main/css/main.css new file mode 100644 index 0000000..6c9aeb5 --- /dev/null +++ b/src/main/css/main.css @@ -0,0 +1,47 @@ +:root { + --error-color: red; + --success-color: green; +} + + +.row .column.quiz-button-column { + display: flex; + align-items: flex-end; +} + +.row .column.quiz-button-column .quiz-button { + margin-bottom: 15px; +} + +label, summary b { + cursor: pointer; +} + + +.success-message { + color: var(--success-color); +} + +.error-message { + color: var(--error-color); +} + +.success-box { + background-color: var(--success-color); + border-color: var(--success-color); +} + +.error-box { + background-color: var(--error-color); + border-color: var(--error-color); +} + +input[data-entered=true]:valid { + border-color: var(--success-color); + color: var(--success-color); +} + +input[data-entered=true]:invalid { + border-color: var(--error-color); + color: var(--error-color); +} diff --git a/src/main/index.html b/src/main/index.html new file mode 100644 index 0000000..4cfd53c --- /dev/null +++ b/src/main/index.html @@ -0,0 +1,87 @@ + + + + + + + + + + + Doomsday | FWDekker + + + + + + +
+ +
+ + + + +
+
+
+ Century +
+
+ +
+
+ +
+
+
+ +
+ Year +
+
+ +
+
+ +
+
+
+ +
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+ + + + + + 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(); +});