number-master

web application to practice korean numbers
git clone git://brookjeynes.dev/bjeynes/number-master.git
Log | Files | Refs | README | LICENSE

store.js (7248B)


      1 import { nativeStringFromNumber } from "./native.js";
      2 import { sinoStringFromNumber } from "./sino.js";
      3 import { hourStringFromNumber } from "./time.js";
      4 
      5 /**
      6  * @typedef {
      7  * 	| "number"
      8  * 	| "hangeul"
      9  * } Mode
     10  */
     11 
     12 /**
     13  * @typedef {
     14  * 	| "sino"
     15  * 	| "native"
     16  * 	| "time"
     17  * 	| "date"
     18  * 	| "counter"
     19  * } Tab
     20  */
     21 
     22 /**
     23  * @typedef {
     24  * 	| "correct"
     25  * 	| "incorrect"
     26  * 	| "undecided"
     27  * } QuestionState
     28  */
     29 
     30 /**
     31  * @typedef {Object} Config
     32  * @property {Mode} mode
     33  * @property {number} min
     34  * @property {number} max
     35  */
     36 
     37 /**
     38  * @typedef {Object} Time
     39  * @property {number} hour
     40  * @property {number} minute
     41  */
     42 
     43 /** @type {number} */
     44 const HINT_THRESHOLD = 3;
     45 
     46 const DEFAULT_CONFIG = {
     47 	mode: "number",
     48 	min: 1,
     49 	max: 10000,
     50 };
     51 
     52 window.Alpine.store("app", {
     53 	/** @type {number} */
     54 	num: 0,
     55 	/** @type {number} */
     56 	min: DEFAULT_CONFIG.min,
     57 	/** @type {number} */
     58 	max: DEFAULT_CONFIG.max,
     59 	/** @type {Mode} */
     60 	mode: DEFAULT_CONFIG.mode,
     61 	/** @type {Tab} */
     62 	tab: "sino",
     63 	/** @type {QuestionState} */
     64 	questionState: "undecided",
     65 	/** @type {string | null} */
     66 	rangeError: null,
     67 	/** @type {boolean} */
     68 	configSaved: false,
     69 	/** @type {Time} */
     70 	time: {
     71 		hour: 12,
     72 		minute: 30,
     73 	},
     74 	/** @type {Date} */
     75 	date: new Date(),
     76 	/** @type {number} */
     77 	wrongCount: 0,
     78 	/** @type {string[] | null} */
     79 	hint: null,
     80 
     81 	init() {
     82 		this.configSaved = false;
     83 		this.rangeError = null;
     84 		this.parseConfig();
     85 		this.onRangeChange();
     86 		this.newQuestion();
     87 	},
     88 
     89 	parseConfig() {
     90 		const persistentConfig = localStorage.getItem("numbermasterconfig");
     91 		if (!persistentConfig) return
     92 
     93 		/** @type {Config} */
     94 		const config = JSON.parse(persistentConfig);
     95 
     96 		/** @type {number} */
     97 		const min = parseInt(config.min, 10);
     98 		/** @type {number} */
     99 		const max = parseInt(config.max, 10);
    100 		switch (config.mode) {
    101 			case "hangeul":
    102 				this.mode = "hangeul";
    103 				break;
    104 			case "number":
    105 				this.mode = "number";
    106 				break;
    107 			default:
    108 				this.mode = DEFAULT_CONFIG.mode;
    109 				console.error("unknown mode selected");
    110 		}
    111 
    112 		this.min = isNaN(min) ? DEFAULT_CONFIG.min : min;
    113 		this.max = isNaN(max) ? DEFAULT_CONFIG.max : max;
    114 	},
    115 
    116 	/** @returns {string} */
    117 	get question() {
    118 		switch (this.mode) {
    119 			case "number":
    120 				return this.number;
    121 			case "hangeul":
    122 				return this.string;
    123 		}
    124 	},
    125 
    126 	/** @returns {string} */
    127 	get answer() {
    128 		switch (this.mode) {
    129 			case "number":
    130 				return this.string;
    131 			case "hangeul":
    132 				return this.number;
    133 		}
    134 	},
    135 
    136 	/** @returns {string} */
    137 	get string() {
    138 		switch (this.tab) {
    139 			case "sino":
    140 				return sinoStringFromNumber(this.num);
    141 			case "native":
    142 				return nativeStringFromNumber(this.num);
    143 			case "time":
    144 				return `${hourStringFromNumber(this.time.hour)}시 ${sinoStringFromNumber(this.time.minute)}분`;
    145 			case "date":
    146 				let year = sinoStringFromNumber(this.date.getFullYear());
    147 				if (year.startsWith("일")) year = year.slice(1);
    148 
    149 				// getMonth returns 0 for January
    150 				let month = sinoStringFromNumber(this.date.getMonth() + 1);
    151 				if (month === "십") month = "시";
    152 				if (month === "육") month = "유";
    153 
    154 				const day = sinoStringFromNumber(this.date.getDate());
    155 				return `${year}년 ${month}월 ${day}일`;
    156 			default:
    157 				throw new Error("unknown tab selected");
    158 		}
    159 	},
    160 
    161 	/** @returns {string} */
    162 	get number() {
    163 		switch (this.tab) {
    164 			case "sino":
    165 			case "native":
    166 				return this.num.toString();
    167 			case "time":
    168 				return `${this.time.hour.toString().padStart(2, "0")}:${this.time.minute.toString().padStart(2, "0")}`;
    169 			case "date":
    170 				const year = this.date.getFullYear();
    171 				// getMonth returns 0 for January
    172 				const month = this.date.getMonth() + 1;
    173 				const day = this.date.getDate();
    174 				return `${year}년 ${month}월 ${day}일`;
    175 			default:
    176 				throw new Error("unknown tab selected");
    177 		}
    178 	},
    179 
    180 	/** @returns {string} */
    181 	get questionHint() {
    182 		switch (this.tab) {
    183 			case "sino":
    184 			case "native":
    185 				return null;
    186 			case "time":
    187 				return [{
    188 					label: "시간 불규칙",
    189 					items: [
    190 						"하나시 → 한시",
    191 						"둘시 → 두시",
    192 						"셋시 → 세시",
    193 						"넷시 → 네시",
    194 						"열하나시 → 열한시",
    195 						"열둘시 → 열두시"
    196 					]
    197 				}];
    198 			case "date":
    199 				return [
    200 					{ label: "연도 불규칙", items: ["'천' 앞에서는 '일'이 생략됩니다"] },
    201 					{ label: "월 불규칙", items: ["십월 → 시월", "육월 → 유월"] }
    202 				];
    203 			default:
    204 				throw new Error("unknown tab selected");
    205 		}
    206 	},
    207 
    208 	/** @param {Event} event */
    209 	submit(event) {
    210 		event.preventDefault();
    211 		/** @type {string} */
    212 		let input = event.target.input.value;
    213 		/** @type {string} */
    214 		const sanitised = input.trim().split(" ").join("");
    215 
    216 		if (this.questionState !== "undecided") {
    217 			this.newQuestion();
    218 			event.target.input.value = "";
    219 			this.wrongCount = 0;
    220 			this.hint = null;
    221 			this.questionState = "undecided";
    222 			return
    223 		}
    224 
    225 		/** @type {string} */
    226 		let answer = this.answer.split(" ").join("");
    227 
    228 		if (
    229 			answer === sanitised ||
    230 			(this.tab === "time" && answer.slice(1) === sanitised) // "07:52".slice(1) == "7:52"
    231 		) {
    232 			this.questionState = "correct";
    233 		} else {
    234 			this.wrongCount++;
    235 			if (this.wrongCount === HINT_THRESHOLD) this.hint = this.questionHint;
    236 
    237 			const el = document.getElementById('input');
    238 			el.classList.add('-wrong-flash');
    239 			el.addEventListener('animationend', () => el.classList.remove('-wrong-flash'), { once: true });
    240 		}
    241 	},
    242 
    243 	skip() {
    244 		if (this.questionState !== "undecided") return
    245 		this.hint = this.questionHint;
    246 		this.questionState = "incorrect";
    247 	},
    248 
    249 	onRangeChange() {
    250 		if (this.min >= this.max) {
    251 			this.rangeError = "최소값은 최대값보다 작아야 합니다";
    252 			return;
    253 		}
    254 
    255 		this.rangeError = null;
    256 		this.newQuestion();
    257 		this.questionState = "undecided";
    258 	},
    259 
    260 	saveConfig() {
    261 		/** @type{Config} */
    262 		const config = { min: this.min, max: this.max, mode: this.mode };
    263 		localStorage.setItem("numbermasterconfig", JSON.stringify(config));
    264 		this.configSaved = true;
    265 
    266 		setTimeout(() => {
    267 			this.configSaved = false;
    268 		}, 2000)
    269 	},
    270 
    271 	/**
    272 	 * @param {Tab} tab
    273 	 */
    274 	changeTab(tab) {
    275 		this.questionState = "undecided";
    276 		this.hint = null;
    277 		this.wrontCount = 0;
    278 		this.tab = tab;
    279 		this.newQuestion();
    280 	},
    281 
    282 	newQuestion() {
    283 		switch (this.tab) {
    284 			case "sino":
    285 			case "native":
    286 				this.num = this.randomNumber;
    287 				break;
    288 			case "time":
    289 				this.time = this.randomNumber;
    290 				break;
    291 			case "date":
    292 				this.date = this.randomNumber;
    293 				break;
    294 			default:
    295 				throw new Error("unknown tab selected");
    296 		}
    297 	},
    298 
    299 	/** @returns {number | Time} */
    300 	get randomNumber() {
    301 		switch (this.tab) {
    302 			case "sino":
    303 				return random(this.min, this.max * 10);
    304 			case "native":
    305 				return random(1, 100);
    306 			case "time":
    307 				return {
    308 					hour: random(1, 13),
    309 					minute: random(0, 60),
    310 				};
    311 			case "date":
    312 				return randomDate(new Date(1960, 0, 1), new Date());
    313 			default:
    314 				throw new Error("unknown tab selected");
    315 		}
    316 	},
    317 });
    318 
    319 /**
    320  * @param {number} min
    321  * @param {number} max
    322  * @returns {number}
    323  */
    324 function random(min, max) {
    325 	return Math.floor(Math.random() * (max - min) + min);
    326 };
    327 
    328 /**
    329  * @param {Date} start
    330  * @param {Date} end
    331  * @returns {Date}
    332  */
    333 function randomDate(start, end) {
    334 	return new Date(+start + Math.random() * (end - start));
    335 }
    336