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