Migrate to npm, use template package

This commit is contained in:
Florine W. Dekker 2020-05-03 23:50:55 +02:00
parent 89d383247c
commit a608394d38
Signed by: FWDekker
GPG Key ID: B1B567AF58D6EE0F
9 changed files with 944 additions and 634 deletions

117
.gitignore vendored Normal file
View File

@ -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.*

127
Gruntfile.js Normal file
View File

@ -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"]);
};

View File

@ -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
```

View File

@ -1,634 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="author" content="Felix W. Dekker" />
<meta name="application-name" content="Doomsday" />
<meta name="description" content="Test your mastery of Conway's Doomsday rule." />
<meta name="theme-color" content="#0033cc" />
<title>Doomsday | FWDekker</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic"
crossorigin="anonymous" />
<link rel="stylesheet" href="https://static.fwdekker.com/css/milligram-bundle.min.css" crossorigin="anonymous" />
<style>
: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);
}
</style>
</head>
<body>
<main class="wrapper">
<!-- Header -->
<header class="header">
<section class="container">
<h1>Doomsday</h1>
<noscript>
<span style="color: red; font-weight: bold;">
This website does not function if JavaScript is disabled.
Please check the <a href="https://www.enable-javascript.com/">
instructions on how to enable JavaScript in your web browser</a>.
</span>
</noscript>
<blockquote>
<p><em>
Test your mastery of
<a href="https://en.wikipedia.org/wiki/Doomsday_rule">Conway's Doomsday rule</a>.
</em></p>
</blockquote>
</section>
</header>
<!-- Input -->
<section class="container">
<form>
<details open id="century-details">
<summary><b id="century-title-label">Century</b></summary>
<div class="row">
<div class="column column-90">
<input type="text" id="century-input" autocomplete="off" autofocus />
</div>
<div class="column column-10 quiz-button-column">
<button type="button" class="quiz-button" id="century-submit">Check</button>
</div>
</div>
</details>
<details open id="year-details">
<summary><b id="year-title-label">Year</b></summary>
<div class="row">
<div class="column column-90">
<input type="text" id="year-input" autocomplete="off" />
</div>
<div class="column column-10 quiz-button-column">
<button type="button" class="quiz-button" id="year-submit">Check</button>
</div>
</div>
</details>
<div>
<label for="day-input" id="day-title-label" style="margin-left: 17px;">Day</label>
<div class="row">
<div class="column column-90">
<input type="text" id="day-input" autocomplete="off" />
</div>
<div class="column column-10 quiz-button-column">
<button type="button" class="quiz-button" id="day-submit">Check</button>
</div>
</div>
</div>
<div class="row">&#8203;</div>
<div class="row">
<div class="column">
<button type="button" id="reset-button">Reset</button>
</div>
</div>
</form>
</section>
<!-- Footer -->
<footer class="footer">
<section class="container">
Made by <a href="https://fwdekker.com/">Felix W. Dekker</a>.
Licensed under the
<a href="https://git.fwdekker.com/FWDekker/doomsday/src/branch/master/LICENSE">MIT License</a>.
Source code available on <a href="https://git.fwdekker.com/FWDekker/doomsday/">git</a>.
<div style="float: right;">v1.3.2</div>
</section>
</footer>
</main>
<!-- Scripts -->
<script src="https://static.fwdekker.com/js/common.js" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-cookie/2.2.1/js.cookie.min.js"
integrity="sha256-oE03O+I6Pzff4fiMqwEGHbdfcW7a3GRRxlL+U49L5sA=" crossorigin="anonymous"></script>
<script>
/**
* 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 `<details>` 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(() => {
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();
});
</script>
</body>
</html>

BIN
package-lock.json generated Normal file

Binary file not shown.

36
package.json Normal file
View File

@ -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"
}
}

47
src/main/css/main.css Normal file
View File

@ -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);
}

87
src/main/index.html Normal file
View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="author" content="Felix W. Dekker" />
<meta name="application-name" content="Doomsday" />
<meta name="description" content="Test your mastery of Conway's Doomsday rule." />
<meta name="theme-color" content="#0033cc" />
<title>Doomsday | FWDekker</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic"
crossorigin="anonymous" />
<link rel="stylesheet" href="css/main.css" />
</head>
<body>
<noscript>
<span style="color: red; font-weight: bold;">
This website does not function if JavaScript is disabled.
Please check the <a href="https://www.enable-javascript.com/">
instructions on how to enable JavaScript in your web browser</a>.
</span>
</noscript>
<main style="display: none;">
<div id="nav"></div>
<div id="contents">
<div id="header"></div>
<!-- Input -->
<section class="container">
<form>
<details open id="century-details">
<summary><b id="century-title-label">Century</b></summary>
<div class="row">
<div class="column column-90">
<input type="text" id="century-input" autocomplete="off" autofocus />
</div>
<div class="column column-10 quiz-button-column">
<button type="button" class="quiz-button" id="century-submit">Check</button>
</div>
</div>
</details>
<details open id="year-details">
<summary><b id="year-title-label">Year</b></summary>
<div class="row">
<div class="column column-90">
<input type="text" id="year-input" autocomplete="off" />
</div>
<div class="column column-10 quiz-button-column">
<button type="button" class="quiz-button" id="year-submit">Check</button>
</div>
</div>
</details>
<div>
<label for="day-input" id="day-title-label" style="margin-left: 17px;">Day</label>
<div class="row">
<div class="column column-90">
<input type="text" id="day-input" autocomplete="off" />
</div>
<div class="column column-10 quiz-button-column">
<button type="button" class="quiz-button" id="day-submit">Check</button>
</div>
</div>
</div>
<div class="row">&#8203;</div>
<div class="row">
<div class="column">
<button type="button" id="reset-button">Reset</button>
</div>
</div>
</form>
</section>
</div>
<div id="footer"></div>
</main>
<!-- Scripts -->
<script src="bundle.js"></script>
</body>
</html>

500
src/main/js/index.js Normal file
View File

@ -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 `<details>` 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 \
<a href="https://en.wikipedia.org/wiki/Doomsday_rule">Conway's Doomsday rule</a>.
`
}));
$("#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();
});