├── 15-minesweeper ├── after │ ├── index.html │ ├── minesweeper.js │ ├── script.js │ └── styles.css └── before │ ├── index.html │ └── styles.css ├── 16-17-math-solver ├── after │ ├── index.html │ └── script.js └── before │ └── index.html ├── 26-atm-cli ├── .gitignore ├── Account.js ├── CommandLine.js ├── FileSystem.js └── script.js ├── 27-28-calculator ├── after │ ├── Calculator.js │ ├── index.html │ ├── script.js │ └── styles.css └── before │ ├── index.html │ └── styles.css ├── 35-minesweeper-fp ├── after │ ├── .babelrc │ ├── .gitignore │ ├── index.html │ ├── minesweeper.js │ ├── package-lock.json │ ├── package.json │ ├── script.js │ └── styles.css └── before │ ├── index.html │ ├── minesweeper.js │ ├── script.js │ └── styles.css ├── 40-atm-unit-tests ├── after │ ├── .gitignore │ ├── Account.js │ ├── Account.test.js │ ├── CommandLine.js │ ├── FileSystem.js │ ├── package-lock.json │ ├── package.json │ └── script.js └── before │ ├── Account.js │ ├── CommandLine.js │ ├── FileSystem.js │ └── script.js ├── 41-atm-integration-tests ├── after │ ├── .gitignore │ ├── Account.int.test.js │ ├── Account.js │ ├── Account.unit.test.js │ ├── CommandLine.js │ ├── FileSystem.js │ ├── package-lock.json │ ├── package.json │ └── script.js └── before │ ├── .gitignore │ ├── Account.js │ ├── Account.test.js │ ├── CommandLine.js │ ├── FileSystem.js │ ├── package-lock.json │ ├── package.json │ └── script.js ├── 42-calculator-end-to-end-tests ├── after │ ├── .gitignore │ ├── Calculator.js │ ├── cypress.json │ ├── cypress │ │ ├── integration │ │ │ └── Calculator.test.js │ │ ├── plugins │ │ │ └── index.js │ │ └── support │ │ │ ├── commands.js │ │ │ └── index.js │ ├── index.html │ ├── package-lock.json │ ├── package.json │ ├── script.js │ └── styles.css └── before │ ├── Calculator.js │ ├── index.html │ ├── script.js │ └── styles.css ├── 45-46-math-solver-unit-tests ├── after │ ├── .babelrc │ ├── .gitignore │ ├── index.html │ ├── package-lock.json │ ├── package.json │ ├── parse.js │ ├── parse.test.js │ └── script.js └── before │ ├── .babelrc │ ├── .gitignore │ ├── index.html │ ├── package-lock.json │ ├── package.json │ ├── parse.js │ ├── parse.test.js │ └── script.js ├── 47-48-minesweeper-fp-tests ├── after │ ├── .babelrc │ ├── .gitignore │ ├── cypress.json │ ├── cypress │ │ ├── fixtures │ │ │ └── example.json │ │ ├── integration │ │ │ └── minesweeper.test.js │ │ ├── plugins │ │ │ └── index.js │ │ └── support │ │ │ ├── commands.js │ │ │ └── index.js │ ├── index.html │ ├── minesweeper.js │ ├── minesweeper.test.js │ ├── package-lock.json │ ├── package.json │ ├── script.js │ └── styles.css └── before │ ├── .babelrc │ ├── .gitignore │ ├── index.html │ ├── minesweeper.js │ ├── package-lock.json │ ├── package.json │ ├── script.js │ └── styles.css ├── 55-weather-app ├── after │ ├── client │ │ ├── .gitignore │ │ ├── index.html │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── script.js │ │ └── styles.css │ └── server │ │ ├── .gitignore │ │ ├── example.json │ │ ├── package-lock.json │ │ ├── package.json │ │ └── server.js └── before │ ├── example.json │ ├── index.html │ └── styles.css └── 64-65-color-game ├── after ├── Hex.js ├── Hsl.js ├── Rgb.js ├── index.html ├── script.js ├── styles.css └── utils.js └── before ├── index.html └── styles.css /15-minesweeper/after/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Minesweeper 9 | 10 | 11 |

Minesweeper

12 |
13 | Mines Left: 14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /15-minesweeper/after/minesweeper.js: -------------------------------------------------------------------------------- 1 | // Logic 2 | 3 | export const TILE_STATUSES = { 4 | HIDDEN: "hidden", 5 | MINE: "mine", 6 | NUMBER: "number", 7 | MARKED: "marked", 8 | } 9 | 10 | export function createBoard(boardSize, numberOfMines) { 11 | const board = [] 12 | const minePositions = getMinePositions(boardSize, numberOfMines) 13 | 14 | for (let x = 0; x < boardSize; x++) { 15 | const row = [] 16 | for (let y = 0; y < boardSize; y++) { 17 | const element = document.createElement("div") 18 | element.dataset.status = TILE_STATUSES.HIDDEN 19 | 20 | const tile = { 21 | element, 22 | x, 23 | y, 24 | mine: minePositions.some(positionMatch.bind(null, { x, y })), 25 | get status() { 26 | return this.element.dataset.status 27 | }, 28 | set status(value) { 29 | this.element.dataset.status = value 30 | }, 31 | } 32 | 33 | row.push(tile) 34 | } 35 | board.push(row) 36 | } 37 | 38 | return board 39 | } 40 | 41 | export function markTile(tile) { 42 | if ( 43 | tile.status !== TILE_STATUSES.HIDDEN && 44 | tile.status !== TILE_STATUSES.MARKED 45 | ) { 46 | return 47 | } 48 | 49 | if (tile.status === TILE_STATUSES.MARKED) { 50 | tile.status = TILE_STATUSES.HIDDEN 51 | } else { 52 | tile.status = TILE_STATUSES.MARKED 53 | } 54 | } 55 | 56 | export function revealTile(board, tile) { 57 | if (tile.status !== TILE_STATUSES.HIDDEN) { 58 | return 59 | } 60 | 61 | if (tile.mine) { 62 | tile.status = TILE_STATUSES.MINE 63 | return 64 | } 65 | 66 | tile.status = TILE_STATUSES.NUMBER 67 | const adjacentTiles = nearbyTiles(board, tile) 68 | const mines = adjacentTiles.filter(t => t.mine) 69 | if (mines.length === 0) { 70 | adjacentTiles.forEach(revealTile.bind(null, board)) 71 | } else { 72 | tile.element.textContent = mines.length 73 | } 74 | } 75 | 76 | export function checkWin(board) { 77 | return board.every(row => { 78 | return row.every(tile => { 79 | return ( 80 | tile.status === TILE_STATUSES.NUMBER || 81 | (tile.mine && 82 | (tile.status === TILE_STATUSES.HIDDEN || 83 | tile.status === TILE_STATUSES.MARKED)) 84 | ) 85 | }) 86 | }) 87 | } 88 | 89 | export function checkLose(board) { 90 | return board.some(row => { 91 | return row.some(tile => { 92 | return tile.status === TILE_STATUSES.MINE 93 | }) 94 | }) 95 | } 96 | 97 | function getMinePositions(boardSize, numberOfMines) { 98 | const positions = [] 99 | 100 | while (positions.length < numberOfMines) { 101 | const position = { 102 | x: randomNumber(boardSize), 103 | y: randomNumber(boardSize), 104 | } 105 | 106 | if (!positions.some(positionMatch.bind(null, position))) { 107 | positions.push(position) 108 | } 109 | } 110 | 111 | return positions 112 | } 113 | 114 | function positionMatch(a, b) { 115 | return a.x === b.x && a.y === b.y 116 | } 117 | 118 | function randomNumber(size) { 119 | return Math.floor(Math.random() * size) 120 | } 121 | 122 | function nearbyTiles(board, { x, y }) { 123 | const tiles = [] 124 | 125 | for (let xOffset = -1; xOffset <= 1; xOffset++) { 126 | for (let yOffset = -1; yOffset <= 1; yOffset++) { 127 | const tile = board[x + xOffset]?.[y + yOffset] 128 | if (tile) tiles.push(tile) 129 | } 130 | } 131 | 132 | return tiles 133 | } 134 | -------------------------------------------------------------------------------- /15-minesweeper/after/script.js: -------------------------------------------------------------------------------- 1 | // Display/UI 2 | 3 | import { 4 | TILE_STATUSES, 5 | createBoard, 6 | markTile, 7 | revealTile, 8 | checkWin, 9 | checkLose, 10 | } from "./minesweeper.js" 11 | 12 | const BOARD_SIZE = 10 13 | const NUMBER_OF_MINES = 3 14 | 15 | const board = createBoard(BOARD_SIZE, NUMBER_OF_MINES) 16 | const boardElement = document.querySelector(".board") 17 | const minesLeftText = document.querySelector("[data-mine-count]") 18 | const messageText = document.querySelector(".subtext") 19 | 20 | board.forEach(row => { 21 | row.forEach(tile => { 22 | boardElement.append(tile.element) 23 | tile.element.addEventListener("click", () => { 24 | revealTile(board, tile) 25 | checkGameEnd() 26 | }) 27 | tile.element.addEventListener("contextmenu", e => { 28 | e.preventDefault() 29 | markTile(tile) 30 | listMinesLeft() 31 | }) 32 | }) 33 | }) 34 | boardElement.style.setProperty("--size", BOARD_SIZE) 35 | minesLeftText.textContent = NUMBER_OF_MINES 36 | 37 | function listMinesLeft() { 38 | const markedTilesCount = board.reduce((count, row) => { 39 | return ( 40 | count + row.filter(tile => tile.status === TILE_STATUSES.MARKED).length 41 | ) 42 | }, 0) 43 | 44 | minesLeftText.textContent = NUMBER_OF_MINES - markedTilesCount 45 | } 46 | 47 | function checkGameEnd() { 48 | const win = checkWin(board) 49 | const lose = checkLose(board) 50 | 51 | if (win || lose) { 52 | boardElement.addEventListener("click", stopProp, { capture: true }) 53 | boardElement.addEventListener("contextmenu", stopProp, { capture: true }) 54 | } 55 | 56 | if (win) { 57 | messageText.textContent = "You Win" 58 | } 59 | if (lose) { 60 | messageText.textContent = "You Lose" 61 | board.forEach(row => { 62 | row.forEach(tile => { 63 | if (tile.status === TILE_STATUSES.MARKED) markTile(tile) 64 | if (tile.mine) revealTile(board, tile) 65 | }) 66 | }) 67 | } 68 | } 69 | 70 | function stopProp(e) { 71 | e.stopImmediatePropagation() 72 | } 73 | -------------------------------------------------------------------------------- /15-minesweeper/after/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | background-color: #333; 8 | display: flex; 9 | align-items: center; 10 | font-size: 3rem; 11 | flex-direction: column; 12 | color: white; 13 | } 14 | 15 | .title { 16 | margin: 20px; 17 | } 18 | 19 | .subtext { 20 | color: #CCC; 21 | font-size: 1.5rem; 22 | margin-bottom: 10px; 23 | } 24 | 25 | .board { 26 | display: inline-grid; 27 | padding: 10px; 28 | grid-template-columns: repeat(var(--size), 60px); 29 | grid-template-rows: repeat(var(--size), 60px); 30 | gap: 4px; 31 | background-color: #777; 32 | } 33 | 34 | .board > * { 35 | width: 100%; 36 | height: 100%; 37 | display: flex; 38 | justify-content: center; 39 | align-items: center; 40 | color: white; 41 | border: 2px solid #BBB; 42 | user-select: none; 43 | } 44 | 45 | .board > [data-status="hidden"] { 46 | background-color: #BBB; 47 | cursor: pointer; 48 | } 49 | 50 | .board > [data-status="mine"] { 51 | background-color: red; 52 | } 53 | 54 | .board > [data-status="number"] { 55 | background-color: none; 56 | } 57 | 58 | .board > [data-status="marked"] { 59 | background-color: yellow; 60 | } -------------------------------------------------------------------------------- /15-minesweeper/before/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Minesweeper 8 | 9 | 10 |

Minesweeper

11 |
12 | Mines Left: 10 13 |
14 |
15 | 16 | -------------------------------------------------------------------------------- /15-minesweeper/before/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | background-color: #333; 8 | display: flex; 9 | align-items: center; 10 | font-size: 3rem; 11 | flex-direction: column; 12 | color: white; 13 | } 14 | 15 | .title { 16 | margin: 20px; 17 | } 18 | 19 | .subtext { 20 | color: #CCC; 21 | font-size: 1.5rem; 22 | margin-bottom: 10px; 23 | } 24 | 25 | .board { 26 | display: inline-grid; 27 | padding: 10px; 28 | grid-template-columns: repeat(var(--size), 60px); 29 | grid-template-rows: repeat(var(--size), 60px); 30 | gap: 4px; 31 | background-color: #777; 32 | } 33 | 34 | .board > * { 35 | width: 100%; 36 | height: 100%; 37 | display: flex; 38 | justify-content: center; 39 | align-items: center; 40 | color: white; 41 | border: 2px solid #BBB; 42 | user-select: none; 43 | } 44 | 45 | .board > [data-status="hidden"] { 46 | background-color: #BBB; 47 | cursor: pointer; 48 | } 49 | 50 | .board > [data-status="mine"] { 51 | background-color: red; 52 | } 53 | 54 | .board > [data-status="number"] { 55 | background-color: none; 56 | } 57 | 58 | .board > [data-status="marked"] { 59 | background-color: yellow; 60 | } -------------------------------------------------------------------------------- /16-17-math-solver/after/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Math Solver 9 | 10 | 11 |
12 | 13 | 14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /16-17-math-solver/after/script.js: -------------------------------------------------------------------------------- 1 | const inputElement = document.getElementById("equation") 2 | const outputElement = document.getElementById("results") 3 | const form = document.getElementById("equation-form") 4 | 5 | const PARENTHESIS_REGEX = /\((?[^\(\)]*)\)/ 6 | const MULTIPLY_DIVIDE_REGEX = /(?\S+)\s*(?[\/\*])\s*(?\S+)/ 7 | const EXPONENT_REGEX = /(?\S+)\s*(?\^)\s*(?\S+)/ 8 | const ADD_SUBTRACT_REGEX = /(?\S+)\s*(?(?\S+)/ 9 | 10 | form.addEventListener("submit", e => { 11 | e.preventDefault() 12 | 13 | const result = parse(inputElement.value) 14 | outputElement.textContent = result 15 | }) 16 | 17 | function parse(equation) { 18 | if (equation.match(PARENTHESIS_REGEX)) { 19 | const subEquation = equation.match(PARENTHESIS_REGEX).groups.equation 20 | const result = parse(subEquation) 21 | const newEquation = equation.replace(PARENTHESIS_REGEX, result) 22 | return parse(newEquation) 23 | } else if (equation.match(EXPONENT_REGEX)) { 24 | const result = handleMath(equation.match(EXPONENT_REGEX).groups) 25 | const newEquation = equation.replace(EXPONENT_REGEX, result) 26 | return parse(newEquation) 27 | } else if (equation.match(MULTIPLY_DIVIDE_REGEX)) { 28 | const result = handleMath(equation.match(MULTIPLY_DIVIDE_REGEX).groups) 29 | const newEquation = equation.replace(MULTIPLY_DIVIDE_REGEX, result) 30 | return parse(newEquation) 31 | } else if (equation.match(ADD_SUBTRACT_REGEX)) { 32 | const result = handleMath(equation.match(ADD_SUBTRACT_REGEX).groups) 33 | const newEquation = equation.replace(ADD_SUBTRACT_REGEX, result) 34 | return parse(newEquation) 35 | } else { 36 | return parseFloat(equation) 37 | } 38 | } 39 | 40 | function handleMath({ operand1, operand2, operation }) { 41 | const number1 = parseFloat(operand1) 42 | const number2 = parseFloat(operand2) 43 | 44 | switch (operation) { 45 | case "*": 46 | return number1 * number2 47 | case "/": 48 | return number1 / number2 49 | case "+": 50 | return number1 + number2 51 | case "-": 52 | return number1 - number2 53 | case "^": 54 | return number1 ** number2 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /16-17-math-solver/before/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Math Solver 8 | 9 | 10 |
11 | 12 | 13 |
14 |
15 | 16 | -------------------------------------------------------------------------------- /26-atm-cli/.gitignore: -------------------------------------------------------------------------------- 1 | accounts -------------------------------------------------------------------------------- /26-atm-cli/Account.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("./FileSystem") 2 | 3 | module.exports = class Account { 4 | constructor(name) { 5 | this.#name = name 6 | } 7 | 8 | #name 9 | #balance 10 | 11 | get name() { 12 | return this.#name 13 | } 14 | 15 | get balance() { 16 | return this.#balance 17 | } 18 | 19 | get filePath() { 20 | return `accounts/${this.name}.txt` 21 | } 22 | 23 | async #load() { 24 | this.#balance = parseFloat(await FileSystem.read(this.filePath)) 25 | } 26 | 27 | async withdraw(amount) { 28 | if (this.balance < amount) throw new Error() 29 | await FileSystem.write(this.filePath, this.#balance - amount) 30 | this.#balance = this.#balance - amount 31 | } 32 | 33 | async deposit(amount) { 34 | await FileSystem.write(this.filePath, this.#balance + amount) 35 | this.#balance = this.#balance + amount 36 | } 37 | 38 | static async find(accountName) { 39 | const account = new Account(accountName) 40 | 41 | try { 42 | await account.#load() 43 | return account 44 | } catch (e) { 45 | return 46 | } 47 | } 48 | 49 | static async create(accountName) { 50 | const account = new Account(accountName) 51 | 52 | await FileSystem.write(account.filePath, 0) 53 | account.#balance = 0 54 | 55 | return account 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /26-atm-cli/CommandLine.js: -------------------------------------------------------------------------------- 1 | const readline = require("readline") 2 | 3 | module.exports = class CommandLine { 4 | static ask(question) { 5 | const rl = readline.createInterface({ 6 | input: process.stdin, 7 | output: process.stdout, 8 | }) 9 | 10 | return new Promise(resolve => { 11 | rl.question(`${question} `, answer => { 12 | resolve(answer) 13 | rl.close() 14 | }) 15 | }) 16 | } 17 | 18 | static print(text) { 19 | console.log(text) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /26-atm-cli/FileSystem.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | 3 | module.exports = class FileSystem { 4 | static read(path) { 5 | return new Promise((resolve, reject) => { 6 | fs.readFile(path, (err, data) => { 7 | if (err) return reject(err) 8 | resolve(data) 9 | }) 10 | }) 11 | } 12 | 13 | static write(path, content) { 14 | return new Promise((resolve, reject) => { 15 | fs.writeFile(path, content.toString(), err => { 16 | if (err) return reject(err) 17 | resolve() 18 | }) 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /26-atm-cli/script.js: -------------------------------------------------------------------------------- 1 | const Account = require("./Account") 2 | const CommandLine = require("./CommandLine") 3 | 4 | async function main() { 5 | try { 6 | const accountName = await CommandLine.ask( 7 | "Which account would you like to access?" 8 | ) 9 | const account = await Account.find(accountName) 10 | if (account == null) account = await promptCreateAccount(accountName) 11 | if (account != null) await promptTask(account) 12 | } catch (e) { 13 | CommandLine.print("ERROR: Please try again") 14 | } 15 | } 16 | 17 | async function promptCreateAccount(accountName) { 18 | const response = await CommandLine.ask( 19 | "That account does not exist. Would you like to create it? (yes/no)" 20 | ) 21 | 22 | if (response === "yes") { 23 | return await Account.create(accountName) 24 | } 25 | } 26 | 27 | async function promptTask(account) { 28 | const response = await CommandLine.ask( 29 | "What would you like to do? (view/deposit/withdraw)" 30 | ) 31 | 32 | if (response === "deposit") { 33 | const amount = parseFloat(await CommandLine.ask("How much?")) 34 | await account.deposit(amount) 35 | } else if (response === "withdraw") { 36 | const amount = parseFloat(await CommandLine.ask("How much?")) 37 | try { 38 | await account.withdraw(amount) 39 | } catch (e) { 40 | CommandLine.print( 41 | "We were unable to make the withdrawal. Please ensure you have enough money in your account." 42 | ) 43 | } 44 | } 45 | 46 | CommandLine.print(`Your balance is ${account.balance}`) 47 | } 48 | 49 | main() 50 | -------------------------------------------------------------------------------- /27-28-calculator/after/Calculator.js: -------------------------------------------------------------------------------- 1 | export default class Calculator { 2 | constructor( 3 | primaryOperandDisplay, 4 | secondaryOperandDisplay, 5 | operationDisplay 6 | ) { 7 | this.#primaryOperandDisplay = primaryOperandDisplay 8 | this.#secondaryOperandDisplay = secondaryOperandDisplay 9 | this.#operationDisplay = operationDisplay 10 | 11 | this.clear() 12 | } 13 | 14 | #primaryOperandDisplay 15 | #secondaryOperandDisplay 16 | #operationDisplay 17 | 18 | get primaryOperand() { 19 | return parseFloat(this.#primaryOperandDisplay.dataset.value) 20 | } 21 | 22 | set primaryOperand(value) { 23 | this.#primaryOperandDisplay.dataset.value = value ?? "" 24 | this.#primaryOperandDisplay.textContent = displayNumber(value) 25 | } 26 | 27 | get secondaryOperand() { 28 | return parseFloat(this.#secondaryOperandDisplay.dataset.value) 29 | } 30 | 31 | set secondaryOperand(value) { 32 | this.#secondaryOperandDisplay.dataset.value = value ?? "" 33 | this.#secondaryOperandDisplay.textContent = displayNumber(value) 34 | } 35 | 36 | get operation() { 37 | return this.#operationDisplay.textContent 38 | } 39 | 40 | set operation(value) { 41 | this.#operationDisplay.textContent = value ?? "" 42 | } 43 | 44 | addDigit(digit) { 45 | if ( 46 | digit === "." && 47 | this.#primaryOperandDisplay.dataset.value.includes(".") 48 | ) { 49 | return 50 | } 51 | if (this.primaryOperand === 0) { 52 | this.primaryOperand = digit 53 | return 54 | } 55 | this.primaryOperand = this.#primaryOperandDisplay.dataset.value + digit 56 | } 57 | 58 | removeDigit() { 59 | const numberString = this.#primaryOperandDisplay.dataset.value 60 | if (numberString.length <= 1) { 61 | this.primaryOperand = 0 62 | return 63 | } 64 | 65 | this.primaryOperand = numberString.substring(0, numberString.length - 1) 66 | } 67 | 68 | evaluate() { 69 | let result 70 | switch (this.operation) { 71 | case "*": 72 | result = this.secondaryOperand * this.primaryOperand 73 | break 74 | case "÷": 75 | result = this.secondaryOperand / this.primaryOperand 76 | break 77 | case "+": 78 | result = this.secondaryOperand + this.primaryOperand 79 | break 80 | case "-": 81 | result = this.secondaryOperand - this.primaryOperand 82 | break 83 | default: 84 | return 85 | } 86 | 87 | this.clear() 88 | this.primaryOperand = result 89 | 90 | return result 91 | } 92 | 93 | chooseOperation(operation) { 94 | if (this.operation !== "") return 95 | this.operation = operation 96 | this.secondaryOperand = this.primaryOperand 97 | this.primaryOperand = 0 98 | } 99 | 100 | clear() { 101 | this.primaryOperand = 0 102 | this.secondaryOperand = null 103 | this.operation = null 104 | } 105 | } 106 | 107 | const NUMBER_FORMATTER = new Intl.NumberFormat("en") 108 | 109 | function displayNumber(number) { 110 | const stringNumber = number?.toString() || "" 111 | if (stringNumber === "") return "" 112 | const [integer, decimal] = stringNumber.split(".") 113 | const formattedInteger = NUMBER_FORMATTER.format(integer) 114 | if (decimal == null) return formattedInteger 115 | return formattedInteger + "." + decimal 116 | } 117 | -------------------------------------------------------------------------------- /27-28-calculator/after/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Calculator 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 | 40 | -------------------------------------------------------------------------------- /27-28-calculator/after/script.js: -------------------------------------------------------------------------------- 1 | import Calculator from "./Calculator.js" 2 | 3 | const primaryOperandDisplay = document.querySelector("[data-primary-operand]") 4 | const secondaryOperandDisplay = document.querySelector( 5 | "[data-secondary-operand]" 6 | ) 7 | const operationDisplay = document.querySelector("[data-operation]") 8 | 9 | const calculator = new Calculator( 10 | primaryOperandDisplay, 11 | secondaryOperandDisplay, 12 | operationDisplay 13 | ) 14 | 15 | document.addEventListener("click", e => { 16 | if (e.target.matches("[data-all-clear]")) { 17 | calculator.clear() 18 | } 19 | if (e.target.matches("[data-number]")) { 20 | calculator.addDigit(e.target.textContent) 21 | } 22 | if (e.target.matches("[data-delete]")) { 23 | calculator.removeDigit() 24 | } 25 | if (e.target.matches("[data-operation]")) { 26 | calculator.chooseOperation(e.target.textContent) 27 | } 28 | if (e.target.matches("[data-equals]")) { 29 | calculator.evaluate() 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /27-28-calculator/after/styles.css: -------------------------------------------------------------------------------- 1 | *, *::before, *::after { 2 | box-sizing: border-box; 3 | font-family: Gotham Rounded, sans-serif; 4 | font-weight: normal; 5 | } 6 | 7 | body { 8 | padding: 0; 9 | margin: 0; 10 | background: linear-gradient(to right, #7700ff, #008cff); 11 | } 12 | 13 | .calculator-grid { 14 | display: grid; 15 | justify-content: center; 16 | align-content: center; 17 | min-height: 100vh; 18 | grid-template-columns: repeat(4, 100px); 19 | grid-template-rows: minmax(120px, auto) repeat(5, 100px); 20 | } 21 | 22 | .calculator-grid > button { 23 | cursor: pointer; 24 | font-size: 2rem; 25 | border: 1px solid white; 26 | outline: none; 27 | background-color: rgba(255, 255, 255, .75); 28 | } 29 | 30 | .calculator-grid > button:hover { 31 | background-color: rgba(255, 255, 255, .9); 32 | } 33 | 34 | .span-two { 35 | grid-column: span 2; 36 | } 37 | 38 | .output { 39 | grid-column: 1 / -1; 40 | background-color: rgba(0, 0, 0, .75); 41 | display: flex; 42 | align-items: flex-end; 43 | justify-content: space-around; 44 | flex-direction: column; 45 | padding: 10px; 46 | word-wrap: break-word; 47 | word-break: break-all; 48 | } 49 | 50 | .output .history { 51 | color: rgba(255, 255, 255, .75); 52 | font-size: 1.5rem; 53 | display: flex; 54 | } 55 | 56 | .output .secondary-operand { 57 | margin-right: 7px; 58 | } 59 | 60 | .output .primary-operand { 61 | color: white; 62 | font-size: 2.5rem; 63 | } -------------------------------------------------------------------------------- /27-28-calculator/before/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Calculator 9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 | 39 | -------------------------------------------------------------------------------- /27-28-calculator/before/styles.css: -------------------------------------------------------------------------------- 1 | *, *::before, *::after { 2 | box-sizing: border-box; 3 | font-family: Gotham Rounded, sans-serif; 4 | font-weight: normal; 5 | } 6 | 7 | body { 8 | padding: 0; 9 | margin: 0; 10 | background: linear-gradient(to right, #7700ff, #008cff); 11 | } 12 | 13 | .calculator-grid { 14 | display: grid; 15 | justify-content: center; 16 | align-content: center; 17 | min-height: 100vh; 18 | grid-template-columns: repeat(4, 100px); 19 | grid-template-rows: minmax(120px, auto) repeat(5, 100px); 20 | } 21 | 22 | .calculator-grid > button { 23 | cursor: pointer; 24 | font-size: 2rem; 25 | border: 1px solid white; 26 | outline: none; 27 | background-color: rgba(255, 255, 255, .75); 28 | } 29 | 30 | .calculator-grid > button:hover { 31 | background-color: rgba(255, 255, 255, .9); 32 | } 33 | 34 | .span-two { 35 | grid-column: span 2; 36 | } 37 | 38 | .output { 39 | grid-column: 1 / -1; 40 | background-color: rgba(0, 0, 0, .75); 41 | display: flex; 42 | align-items: flex-end; 43 | justify-content: space-around; 44 | flex-direction: column; 45 | padding: 10px; 46 | word-wrap: break-word; 47 | word-break: break-all; 48 | } 49 | 50 | .output .history { 51 | color: rgba(255, 255, 255, .75); 52 | font-size: 1.5rem; 53 | display: flex; 54 | } 55 | 56 | .output .secondary-operand { 57 | margin-right: 7px; 58 | } 59 | 60 | .output .primary-operand { 61 | color: white; 62 | font-size: 2.5rem; 63 | } -------------------------------------------------------------------------------- /35-minesweeper-fp/after/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@babel/plugin-proposal-optional-chaining" 4 | ] 5 | } -------------------------------------------------------------------------------- /35-minesweeper-fp/after/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .cache -------------------------------------------------------------------------------- /35-minesweeper-fp/after/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Minesweeper 9 | 10 | 11 |

Minesweeper

12 |
13 | Mines Left: 14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /35-minesweeper-fp/after/minesweeper.js: -------------------------------------------------------------------------------- 1 | import { times, range } from "lodash/fp" 2 | 3 | export const TILE_STATUSES = { 4 | HIDDEN: "hidden", 5 | MINE: "mine", 6 | NUMBER: "number", 7 | MARKED: "marked", 8 | } 9 | 10 | export function createBoard(boardSize, minePositions) { 11 | return times(x => { 12 | return times(y => { 13 | return { 14 | x, 15 | y, 16 | mine: minePositions.some(positionMatch.bind(null, { x, y })), 17 | status: TILE_STATUSES.HIDDEN, 18 | } 19 | }, boardSize) 20 | }, boardSize) 21 | } 22 | 23 | export function markedTilesCount(board) { 24 | return board.reduce((count, row) => { 25 | return ( 26 | count + row.filter(tile => tile.status === TILE_STATUSES.MARKED).length 27 | ) 28 | }, 0) 29 | } 30 | 31 | export function markTile(board, { x, y }) { 32 | const tile = board[x][y] 33 | if ( 34 | tile.status !== TILE_STATUSES.HIDDEN && 35 | tile.status !== TILE_STATUSES.MARKED 36 | ) { 37 | return board 38 | } 39 | 40 | if (tile.status === TILE_STATUSES.MARKED) { 41 | return replaceTile( 42 | board, 43 | { x, y }, 44 | { ...tile, status: TILE_STATUSES.HIDDEN } 45 | ) 46 | } else { 47 | return replaceTile( 48 | board, 49 | { x, y }, 50 | { ...tile, status: TILE_STATUSES.MARKED } 51 | ) 52 | } 53 | } 54 | 55 | function replaceTile(board, position, newTile) { 56 | return board.map((row, x) => { 57 | return row.map((tile, y) => { 58 | if (positionMatch(position, { x, y })) { 59 | return newTile 60 | } 61 | return tile 62 | }) 63 | }) 64 | } 65 | 66 | export function revealTile(board, { x, y }) { 67 | const tile = board[x][y] 68 | if (tile.status !== TILE_STATUSES.HIDDEN) { 69 | return board 70 | } 71 | 72 | if (tile.mine) { 73 | return replaceTile(board, { x, y }, { ...tile, status: TILE_STATUSES.MINE }) 74 | } 75 | 76 | const adjacentTiles = nearbyTiles(board, tile) 77 | const mines = adjacentTiles.filter(t => t.mine) 78 | const newBoard = replaceTile( 79 | board, 80 | { x, y }, 81 | { ...tile, status: TILE_STATUSES.NUMBER, adjacentMinesCount: mines.length } 82 | ) 83 | if (mines.length === 0) { 84 | return adjacentTiles.reduce((b, t) => { 85 | return revealTile(b, t) 86 | }, newBoard) 87 | } 88 | return newBoard 89 | } 90 | 91 | export function checkWin(board) { 92 | return board.every(row => { 93 | return row.every(tile => { 94 | return ( 95 | tile.status === TILE_STATUSES.NUMBER || 96 | (tile.mine && 97 | (tile.status === TILE_STATUSES.HIDDEN || 98 | tile.status === TILE_STATUSES.MARKED)) 99 | ) 100 | }) 101 | }) 102 | } 103 | 104 | export function checkLose(board) { 105 | return board.some(row => { 106 | return row.some(tile => { 107 | return tile.status === TILE_STATUSES.MINE 108 | }) 109 | }) 110 | } 111 | 112 | export function positionMatch(a, b) { 113 | return a.x === b.x && a.y === b.y 114 | } 115 | 116 | function nearbyTiles(board, { x, y }) { 117 | const offsets = range(-1, 2) 118 | 119 | return offsets 120 | .flatMap(xOffset => { 121 | return offsets.map(yOffset => { 122 | return board[x + xOffset]?.[y + yOffset] 123 | }) 124 | }) 125 | .filter(tile => tile != null) 126 | } 127 | -------------------------------------------------------------------------------- /35-minesweeper-fp/after/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "current-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "minesweeper.js", 6 | "scripts": { 7 | "build": "parcel build index.html", 8 | "start": "parcel index.html" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "lodash": "^4.17.21" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.13.8", 18 | "@babel/plugin-proposal-optional-chaining": "^7.13.8", 19 | "parcel-bundler": "1.12.3" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /35-minesweeper-fp/after/script.js: -------------------------------------------------------------------------------- 1 | // Display/UI 2 | 3 | import { 4 | TILE_STATUSES, 5 | createBoard, 6 | markTile, 7 | revealTile, 8 | checkWin, 9 | checkLose, 10 | positionMatch, 11 | markedTilesCount, 12 | } from "./minesweeper.js" 13 | 14 | const BOARD_SIZE = 10 15 | const NUMBER_OF_MINES = 3 16 | 17 | let board = createBoard( 18 | BOARD_SIZE, 19 | getMinePositions(BOARD_SIZE, NUMBER_OF_MINES) 20 | ) 21 | const boardElement = document.querySelector(".board") 22 | const minesLeftText = document.querySelector("[data-mine-count]") 23 | const messageText = document.querySelector(".subtext") 24 | 25 | function render() { 26 | boardElement.innerHTML = "" 27 | checkGameEnd() 28 | 29 | getTileElements().forEach(element => { 30 | boardElement.append(element) 31 | }) 32 | 33 | listMinesLeft() 34 | } 35 | 36 | function getTileElements() { 37 | return board.flatMap(row => { 38 | return row.map(tileToElement) 39 | }) 40 | } 41 | 42 | function tileToElement(tile) { 43 | const element = document.createElement("div") 44 | element.dataset.status = tile.status 45 | element.dataset.x = tile.x 46 | element.dataset.y = tile.y 47 | element.textContent = tile.adjacentMinesCount || "" 48 | return element 49 | } 50 | 51 | boardElement.addEventListener("click", e => { 52 | if (!e.target.matches("[data-status]")) return 53 | 54 | board = revealTile(board, { 55 | x: parseInt(e.target.dataset.x), 56 | y: parseInt(e.target.dataset.y), 57 | }) 58 | render() 59 | }) 60 | 61 | boardElement.addEventListener("contextmenu", e => { 62 | if (!e.target.matches("[data-status]")) return 63 | 64 | e.preventDefault() 65 | board = markTile(board, { 66 | x: parseInt(e.target.dataset.x), 67 | y: parseInt(e.target.dataset.y), 68 | }) 69 | render() 70 | }) 71 | 72 | boardElement.style.setProperty("--size", BOARD_SIZE) 73 | render() 74 | 75 | function listMinesLeft() { 76 | minesLeftText.textContent = NUMBER_OF_MINES - markedTilesCount(board) 77 | } 78 | 79 | function checkGameEnd() { 80 | const win = checkWin(board) 81 | const lose = checkLose(board) 82 | 83 | if (win || lose) { 84 | boardElement.addEventListener("click", stopProp, { capture: true }) 85 | boardElement.addEventListener("contextmenu", stopProp, { capture: true }) 86 | } 87 | 88 | if (win) { 89 | messageText.textContent = "You Win" 90 | } 91 | if (lose) { 92 | messageText.textContent = "You Lose" 93 | board.forEach(row => { 94 | row.forEach(tile => { 95 | if (tile.status === TILE_STATUSES.MARKED) board = markTile(board, tile) 96 | if (tile.mine) board = revealTile(board, tile) 97 | }) 98 | }) 99 | } 100 | } 101 | 102 | function stopProp(e) { 103 | e.stopImmediatePropagation() 104 | } 105 | 106 | function getMinePositions(boardSize, numberOfMines) { 107 | const positions = [] 108 | 109 | while (positions.length < numberOfMines) { 110 | const position = { 111 | x: randomNumber(boardSize), 112 | y: randomNumber(boardSize), 113 | } 114 | 115 | if (!positions.some(positionMatch.bind(null, position))) { 116 | positions.push(position) 117 | } 118 | } 119 | 120 | return positions 121 | } 122 | 123 | function randomNumber(size) { 124 | return Math.floor(Math.random() * size) 125 | } 126 | -------------------------------------------------------------------------------- /35-minesweeper-fp/after/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | background-color: #333; 8 | display: flex; 9 | align-items: center; 10 | font-size: 3rem; 11 | flex-direction: column; 12 | color: white; 13 | } 14 | 15 | .title { 16 | margin: 20px; 17 | } 18 | 19 | .subtext { 20 | color: #CCC; 21 | font-size: 1.5rem; 22 | margin-bottom: 10px; 23 | } 24 | 25 | .board { 26 | display: inline-grid; 27 | padding: 10px; 28 | grid-template-columns: repeat(var(--size), 60px); 29 | grid-template-rows: repeat(var(--size), 60px); 30 | gap: 4px; 31 | background-color: #777; 32 | } 33 | 34 | .board > * { 35 | width: 100%; 36 | height: 100%; 37 | display: flex; 38 | justify-content: center; 39 | align-items: center; 40 | color: white; 41 | border: 2px solid #BBB; 42 | user-select: none; 43 | } 44 | 45 | .board > [data-status="hidden"] { 46 | background-color: #BBB; 47 | cursor: pointer; 48 | } 49 | 50 | .board > [data-status="mine"] { 51 | background-color: red; 52 | } 53 | 54 | .board > [data-status="number"] { 55 | background-color: none; 56 | } 57 | 58 | .board > [data-status="marked"] { 59 | background-color: yellow; 60 | } -------------------------------------------------------------------------------- /35-minesweeper-fp/before/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Minesweeper 9 | 10 | 11 |

Minesweeper

12 |
13 | Mines Left: 14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /35-minesweeper-fp/before/minesweeper.js: -------------------------------------------------------------------------------- 1 | // Logic 2 | 3 | export const TILE_STATUSES = { 4 | HIDDEN: "hidden", 5 | MINE: "mine", 6 | NUMBER: "number", 7 | MARKED: "marked", 8 | } 9 | 10 | export function createBoard(boardSize, numberOfMines) { 11 | const board = [] 12 | const minePositions = getMinePositions(boardSize, numberOfMines) 13 | 14 | for (let x = 0; x < boardSize; x++) { 15 | const row = [] 16 | for (let y = 0; y < boardSize; y++) { 17 | const element = document.createElement("div") 18 | element.dataset.status = TILE_STATUSES.HIDDEN 19 | 20 | const tile = { 21 | element, 22 | x, 23 | y, 24 | mine: minePositions.some(positionMatch.bind(null, { x, y })), 25 | get status() { 26 | return this.element.dataset.status 27 | }, 28 | set status(value) { 29 | this.element.dataset.status = value 30 | }, 31 | } 32 | 33 | row.push(tile) 34 | } 35 | board.push(row) 36 | } 37 | 38 | return board 39 | } 40 | 41 | export function markTile(tile) { 42 | if ( 43 | tile.status !== TILE_STATUSES.HIDDEN && 44 | tile.status !== TILE_STATUSES.MARKED 45 | ) { 46 | return 47 | } 48 | 49 | if (tile.status === TILE_STATUSES.MARKED) { 50 | tile.status = TILE_STATUSES.HIDDEN 51 | } else { 52 | tile.status = TILE_STATUSES.MARKED 53 | } 54 | } 55 | 56 | export function revealTile(board, tile) { 57 | if (tile.status !== TILE_STATUSES.HIDDEN) { 58 | return 59 | } 60 | 61 | if (tile.mine) { 62 | tile.status = TILE_STATUSES.MINE 63 | return 64 | } 65 | 66 | tile.status = TILE_STATUSES.NUMBER 67 | const adjacentTiles = nearbyTiles(board, tile) 68 | const mines = adjacentTiles.filter(t => t.mine) 69 | if (mines.length === 0) { 70 | adjacentTiles.forEach(revealTile.bind(null, board)) 71 | } else { 72 | tile.element.textContent = mines.length 73 | } 74 | } 75 | 76 | export function checkWin(board) { 77 | return board.every(row => { 78 | return row.every(tile => { 79 | return ( 80 | tile.status === TILE_STATUSES.NUMBER || 81 | (tile.mine && 82 | (tile.status === TILE_STATUSES.HIDDEN || 83 | tile.status === TILE_STATUSES.MARKED)) 84 | ) 85 | }) 86 | }) 87 | } 88 | 89 | export function checkLose(board) { 90 | return board.some(row => { 91 | return row.some(tile => { 92 | return tile.status === TILE_STATUSES.MINE 93 | }) 94 | }) 95 | } 96 | 97 | function getMinePositions(boardSize, numberOfMines) { 98 | const positions = [] 99 | 100 | while (positions.length < numberOfMines) { 101 | const position = { 102 | x: randomNumber(boardSize), 103 | y: randomNumber(boardSize), 104 | } 105 | 106 | if (!positions.some(positionMatch.bind(null, position))) { 107 | positions.push(position) 108 | } 109 | } 110 | 111 | return positions 112 | } 113 | 114 | function positionMatch(a, b) { 115 | return a.x === b.x && a.y === b.y 116 | } 117 | 118 | function randomNumber(size) { 119 | return Math.floor(Math.random() * size) 120 | } 121 | 122 | function nearbyTiles(board, { x, y }) { 123 | const tiles = [] 124 | 125 | for (let xOffset = -1; xOffset <= 1; xOffset++) { 126 | for (let yOffset = -1; yOffset <= 1; yOffset++) { 127 | const tile = board[x + xOffset]?.[y + yOffset] 128 | if (tile) tiles.push(tile) 129 | } 130 | } 131 | 132 | return tiles 133 | } 134 | -------------------------------------------------------------------------------- /35-minesweeper-fp/before/script.js: -------------------------------------------------------------------------------- 1 | // Display/UI 2 | 3 | import { 4 | TILE_STATUSES, 5 | createBoard, 6 | markTile, 7 | revealTile, 8 | checkWin, 9 | checkLose, 10 | } from "./minesweeper.js" 11 | 12 | const BOARD_SIZE = 10 13 | const NUMBER_OF_MINES = 3 14 | 15 | const board = createBoard(BOARD_SIZE, NUMBER_OF_MINES) 16 | const boardElement = document.querySelector(".board") 17 | const minesLeftText = document.querySelector("[data-mine-count]") 18 | const messageText = document.querySelector(".subtext") 19 | 20 | board.forEach(row => { 21 | row.forEach(tile => { 22 | boardElement.append(tile.element) 23 | tile.element.addEventListener("click", () => { 24 | revealTile(board, tile) 25 | checkGameEnd() 26 | }) 27 | tile.element.addEventListener("contextmenu", e => { 28 | e.preventDefault() 29 | markTile(tile) 30 | listMinesLeft() 31 | }) 32 | }) 33 | }) 34 | boardElement.style.setProperty("--size", BOARD_SIZE) 35 | minesLeftText.textContent = NUMBER_OF_MINES 36 | 37 | function listMinesLeft() { 38 | const markedTilesCount = board.reduce((count, row) => { 39 | return ( 40 | count + row.filter(tile => tile.status === TILE_STATUSES.MARKED).length 41 | ) 42 | }, 0) 43 | 44 | minesLeftText.textContent = NUMBER_OF_MINES - markedTilesCount 45 | } 46 | 47 | function checkGameEnd() { 48 | const win = checkWin(board) 49 | const lose = checkLose(board) 50 | 51 | if (win || lose) { 52 | boardElement.addEventListener("click", stopProp, { capture: true }) 53 | boardElement.addEventListener("contextmenu", stopProp, { capture: true }) 54 | } 55 | 56 | if (win) { 57 | messageText.textContent = "You Win" 58 | } 59 | if (lose) { 60 | messageText.textContent = "You Lose" 61 | board.forEach(row => { 62 | row.forEach(tile => { 63 | if (tile.status === TILE_STATUSES.MARKED) markTile(tile) 64 | if (tile.mine) revealTile(board, tile) 65 | }) 66 | }) 67 | } 68 | } 69 | 70 | function stopProp(e) { 71 | e.stopImmediatePropagation() 72 | } 73 | -------------------------------------------------------------------------------- /35-minesweeper-fp/before/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | background-color: #333; 8 | display: flex; 9 | align-items: center; 10 | font-size: 3rem; 11 | flex-direction: column; 12 | color: white; 13 | } 14 | 15 | .title { 16 | margin: 20px; 17 | } 18 | 19 | .subtext { 20 | color: #CCC; 21 | font-size: 1.5rem; 22 | margin-bottom: 10px; 23 | } 24 | 25 | .board { 26 | display: inline-grid; 27 | padding: 10px; 28 | grid-template-columns: repeat(var(--size), 60px); 29 | grid-template-rows: repeat(var(--size), 60px); 30 | gap: 4px; 31 | background-color: #777; 32 | } 33 | 34 | .board > * { 35 | width: 100%; 36 | height: 100%; 37 | display: flex; 38 | justify-content: center; 39 | align-items: center; 40 | color: white; 41 | border: 2px solid #BBB; 42 | user-select: none; 43 | } 44 | 45 | .board > [data-status="hidden"] { 46 | background-color: #BBB; 47 | cursor: pointer; 48 | } 49 | 50 | .board > [data-status="mine"] { 51 | background-color: red; 52 | } 53 | 54 | .board > [data-status="number"] { 55 | background-color: none; 56 | } 57 | 58 | .board > [data-status="marked"] { 59 | background-color: yellow; 60 | } -------------------------------------------------------------------------------- /40-atm-unit-tests/after/.gitignore: -------------------------------------------------------------------------------- 1 | accounts 2 | node_modules 3 | coverage -------------------------------------------------------------------------------- /40-atm-unit-tests/after/Account.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("./FileSystem") 2 | 3 | module.exports = class Account { 4 | constructor(name) { 5 | this.#name = name 6 | } 7 | 8 | #name 9 | #balance 10 | 11 | get name() { 12 | return this.#name 13 | } 14 | 15 | get balance() { 16 | return this.#balance 17 | } 18 | 19 | get filePath() { 20 | return `accounts/${this.name}.txt` 21 | } 22 | 23 | async #load() { 24 | this.#balance = parseFloat(await FileSystem.read(this.filePath)) 25 | } 26 | 27 | async withdraw(amount) { 28 | if (this.balance < amount) throw new Error() 29 | await FileSystem.write(this.filePath, this.#balance - amount) 30 | this.#balance = this.#balance - amount 31 | } 32 | 33 | async deposit(amount) { 34 | await FileSystem.write(this.filePath, this.#balance + amount) 35 | this.#balance = this.#balance + amount 36 | } 37 | 38 | static async find(accountName) { 39 | const account = new Account(accountName) 40 | 41 | try { 42 | await account.#load() 43 | return account 44 | } catch (e) { 45 | return 46 | } 47 | } 48 | 49 | static async create(accountName) { 50 | const account = new Account(accountName) 51 | 52 | await FileSystem.write(account.filePath, 0) 53 | account.#balance = 0 54 | 55 | return account 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /40-atm-unit-tests/after/Account.test.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("./FileSystem") 2 | const Account = require("./Account") 3 | 4 | beforeEach(() => { 5 | jest.restoreAllMocks() 6 | }) 7 | 8 | describe("#deposit", () => { 9 | test("it adds money to the account", async () => { 10 | const startingBalance = 5 11 | const amount = 10 12 | const account = await createAccount("Kyle", startingBalance) 13 | const spy = jest 14 | .spyOn(FileSystem, "write") 15 | .mockReturnValue(Promise.resolve()) 16 | 17 | await account.deposit(amount) 18 | expect(account.balance).toBe(amount + startingBalance) 19 | expect(spy).toBeCalledWith(account.filePath, amount + startingBalance) 20 | }) 21 | }) 22 | 23 | describe("#withdraw", () => { 24 | test("it removes money from the account", async () => { 25 | const startingBalance = 10 26 | const amount = 5 27 | const account = await createAccount("Kyle", startingBalance) 28 | const spy = jest 29 | .spyOn(FileSystem, "write") 30 | .mockReturnValue(Promise.resolve()) 31 | 32 | await account.withdraw(amount) 33 | expect(account.balance).toBe(startingBalance - amount) 34 | expect(spy).toBeCalledWith(account.filePath, startingBalance - amount) 35 | }) 36 | 37 | describe("with not enough money in the account", () => { 38 | test("it should throw an error", async () => { 39 | const startingBalance = 5 40 | const amount = 10 41 | const account = await createAccount("Kyle", startingBalance) 42 | const spy = jest.spyOn(FileSystem, "write") 43 | 44 | await expect(account.withdraw(amount)).rejects.toThrow() 45 | expect(account.balance).toBe(startingBalance) 46 | expect(spy).not.toBeCalled() 47 | }) 48 | }) 49 | }) 50 | 51 | async function createAccount(name, balance) { 52 | const spy = jest 53 | .spyOn(FileSystem, "read") 54 | .mockReturnValueOnce(Promise.resolve(balance)) 55 | const account = await Account.find(name) 56 | spy.mockRestore() 57 | return account 58 | } 59 | -------------------------------------------------------------------------------- /40-atm-unit-tests/after/CommandLine.js: -------------------------------------------------------------------------------- 1 | const readline = require("readline") 2 | 3 | module.exports = class CommandLine { 4 | static ask(question) { 5 | const rl = readline.createInterface({ 6 | input: process.stdin, 7 | output: process.stdout, 8 | }) 9 | 10 | return new Promise(resolve => { 11 | rl.question(`${question} `, answer => { 12 | resolve(answer) 13 | rl.close() 14 | }) 15 | }) 16 | } 17 | 18 | static print(text) { 19 | console.log(text) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /40-atm-unit-tests/after/FileSystem.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | 3 | module.exports = class FileSystem { 4 | static read(path) { 5 | return new Promise((resolve, reject) => { 6 | fs.readFile(path, (err, data) => { 7 | if (err) return reject(err) 8 | resolve(data) 9 | }) 10 | }) 11 | } 12 | 13 | static write(path, content) { 14 | return new Promise((resolve, reject) => { 15 | fs.writeFile(path, content.toString(), err => { 16 | if (err) return reject(err) 17 | resolve() 18 | }) 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /40-atm-unit-tests/after/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "current-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "Account.js", 6 | "scripts": { 7 | "test": "jest --coverage" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "jest": "^26.6.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /40-atm-unit-tests/after/script.js: -------------------------------------------------------------------------------- 1 | const Account = require("./Account") 2 | const CommandLine = require("./CommandLine") 3 | 4 | async function main() { 5 | try { 6 | const accountName = await CommandLine.ask( 7 | "Which account would you like to access?" 8 | ) 9 | const account = await Account.find(accountName) 10 | if (account == null) account = await promptCreateAccount(accountName) 11 | if (account != null) await promptTask(account) 12 | } catch (e) { 13 | CommandLine.print("ERROR: Please try again") 14 | } 15 | } 16 | 17 | async function promptCreateAccount(accountName) { 18 | const response = await CommandLine.ask( 19 | "That account does not exist. Would you like to create it? (yes/no)" 20 | ) 21 | 22 | if (response === "yes") { 23 | return await Account.create(accountName) 24 | } 25 | } 26 | 27 | async function promptTask(account) { 28 | const response = await CommandLine.ask( 29 | "What would you like to do? (view/deposit/withdraw)" 30 | ) 31 | 32 | if (response === "deposit") { 33 | const amount = parseFloat(await CommandLine.ask("How much?")) 34 | await account.deposit(amount) 35 | } else if (response === "withdraw") { 36 | const amount = parseFloat(await CommandLine.ask("How much?")) 37 | try { 38 | await account.withdraw(amount) 39 | } catch (e) { 40 | CommandLine.print( 41 | "We were unable to make the withdrawal. Please ensure you have enough money in your account." 42 | ) 43 | } 44 | } 45 | 46 | CommandLine.print(`Your balance is ${account.balance}`) 47 | } 48 | 49 | main() 50 | -------------------------------------------------------------------------------- /40-atm-unit-tests/before/Account.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("./FileSystem") 2 | 3 | module.exports = class Account { 4 | constructor(name) { 5 | this.#name = name 6 | } 7 | 8 | #name 9 | #balance 10 | 11 | get name() { 12 | return this.#name 13 | } 14 | 15 | get balance() { 16 | return this.#balance 17 | } 18 | 19 | get filePath() { 20 | return `accounts/${this.name}.txt` 21 | } 22 | 23 | async #load() { 24 | this.#balance = parseFloat(await FileSystem.read(this.filePath)) 25 | } 26 | 27 | async withdraw(amount) { 28 | if (this.balance < amount) throw new Error() 29 | await FileSystem.write(this.filePath, this.#balance - amount) 30 | this.#balance = this.#balance - amount 31 | } 32 | 33 | async deposit(amount) { 34 | await FileSystem.write(this.filePath, this.#balance + amount) 35 | this.#balance = this.#balance + amount 36 | } 37 | 38 | static async find(accountName) { 39 | const account = new Account(accountName) 40 | 41 | try { 42 | await account.#load() 43 | return account 44 | } catch (e) { 45 | return 46 | } 47 | } 48 | 49 | static async create(accountName) { 50 | const account = new Account(accountName) 51 | 52 | await FileSystem.write(account.filePath, 0) 53 | account.#balance = 0 54 | 55 | return account 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /40-atm-unit-tests/before/CommandLine.js: -------------------------------------------------------------------------------- 1 | const readline = require("readline") 2 | 3 | module.exports = class CommandLine { 4 | static ask(question) { 5 | const rl = readline.createInterface({ 6 | input: process.stdin, 7 | output: process.stdout, 8 | }) 9 | 10 | return new Promise(resolve => { 11 | rl.question(`${question} `, answer => { 12 | resolve(answer) 13 | rl.close() 14 | }) 15 | }) 16 | } 17 | 18 | static print(text) { 19 | console.log(text) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /40-atm-unit-tests/before/FileSystem.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | 3 | module.exports = class FileSystem { 4 | static read(path) { 5 | return new Promise((resolve, reject) => { 6 | fs.readFile(path, (err, data) => { 7 | if (err) return reject(err) 8 | resolve(data) 9 | }) 10 | }) 11 | } 12 | 13 | static write(path, content) { 14 | return new Promise((resolve, reject) => { 15 | fs.writeFile(path, content.toString(), err => { 16 | if (err) return reject(err) 17 | resolve() 18 | }) 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /40-atm-unit-tests/before/script.js: -------------------------------------------------------------------------------- 1 | const Account = require("./Account") 2 | const CommandLine = require("./CommandLine") 3 | 4 | async function main() { 5 | try { 6 | const accountName = await CommandLine.ask( 7 | "Which account would you like to access?" 8 | ) 9 | const account = await Account.find(accountName) 10 | if (account == null) account = await promptCreateAccount(accountName) 11 | if (account != null) await promptTask(account) 12 | } catch (e) { 13 | CommandLine.print("ERROR: Please try again") 14 | } 15 | } 16 | 17 | async function promptCreateAccount(accountName) { 18 | const response = await CommandLine.ask( 19 | "That account does not exist. Would you like to create it? (yes/no)" 20 | ) 21 | 22 | if (response === "yes") { 23 | return await Account.create(accountName) 24 | } 25 | } 26 | 27 | async function promptTask(account) { 28 | const response = await CommandLine.ask( 29 | "What would you like to do? (view/deposit/withdraw)" 30 | ) 31 | 32 | if (response === "deposit") { 33 | const amount = parseFloat(await CommandLine.ask("How much?")) 34 | await account.deposit(amount) 35 | } else if (response === "withdraw") { 36 | const amount = parseFloat(await CommandLine.ask("How much?")) 37 | try { 38 | await account.withdraw(amount) 39 | } catch (e) { 40 | CommandLine.print( 41 | "We were unable to make the withdrawal. Please ensure you have enough money in your account." 42 | ) 43 | } 44 | } 45 | 46 | CommandLine.print(`Your balance is ${account.balance}`) 47 | } 48 | 49 | main() 50 | -------------------------------------------------------------------------------- /41-atm-integration-tests/after/.gitignore: -------------------------------------------------------------------------------- 1 | accounts 2 | node_modules 3 | coverage -------------------------------------------------------------------------------- /41-atm-integration-tests/after/Account.int.test.js: -------------------------------------------------------------------------------- 1 | const Account = require("./Account") 2 | const fs = require("fs") 3 | 4 | beforeEach(() => { 5 | try { 6 | fs.mkdirSync("accounts") 7 | } catch { 8 | // Ignore error since folder already exists 9 | } 10 | }) 11 | 12 | afterEach(() => { 13 | fs.rmSync("accounts", { recursive: true, force: true }) 14 | }) 15 | 16 | describe(".create", () => { 17 | test("it creates a new account and file", async () => { 18 | const name = "Kyle" 19 | const account = await Account.create(name) 20 | expect(account.name).toBe(name) 21 | expect(account.balance).toBe(0) 22 | expect(fs.readFileSync(account.filePath).toString()).toBe("0") 23 | }) 24 | }) 25 | 26 | describe(".find", () => { 27 | test("it returns the account", async () => { 28 | const name = "Kyle" 29 | const balance = 10 30 | fs.writeFileSync(`accounts/${name}.txt`, balance.toString()) 31 | const account = await Account.find(name) 32 | expect(account.name).toBe(name) 33 | expect(account.balance).toBe(balance) 34 | }) 35 | 36 | describe("when there is no existing account", () => { 37 | test("it returns undefined", async () => { 38 | const name = "Kyle" 39 | const account = await Account.find(name) 40 | expect(account).toBeUndefined() 41 | }) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /41-atm-integration-tests/after/Account.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("./FileSystem") 2 | 3 | module.exports = class Account { 4 | constructor(name) { 5 | this.#name = name 6 | } 7 | 8 | #name 9 | #balance 10 | 11 | get name() { 12 | return this.#name 13 | } 14 | 15 | get balance() { 16 | return this.#balance 17 | } 18 | 19 | get filePath() { 20 | return `accounts/${this.name}.txt` 21 | } 22 | 23 | async #load() { 24 | this.#balance = parseFloat(await FileSystem.read(this.filePath)) 25 | } 26 | 27 | async withdraw(amount) { 28 | if (this.balance < amount) throw new Error() 29 | await FileSystem.write(this.filePath, this.#balance - amount) 30 | this.#balance = this.#balance - amount 31 | } 32 | 33 | async deposit(amount) { 34 | await FileSystem.write(this.filePath, this.#balance + amount) 35 | this.#balance = this.#balance + amount 36 | } 37 | 38 | static async find(accountName) { 39 | const account = new Account(accountName) 40 | 41 | try { 42 | await account.#load() 43 | return account 44 | } catch (e) { 45 | return 46 | } 47 | } 48 | 49 | static async create(accountName) { 50 | const account = new Account(accountName) 51 | 52 | await FileSystem.write(account.filePath, 0) 53 | account.#balance = 0 54 | 55 | return account 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /41-atm-integration-tests/after/Account.unit.test.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("./FileSystem") 2 | const Account = require("./Account") 3 | 4 | beforeEach(() => { 5 | jest.restoreAllMocks() 6 | }) 7 | 8 | describe("#deposit", () => { 9 | test("it adds money to the account", async () => { 10 | const startingBalance = 5 11 | const amount = 10 12 | const account = await createAccount("Kyle", startingBalance) 13 | const spy = jest 14 | .spyOn(FileSystem, "write") 15 | .mockReturnValue(Promise.resolve()) 16 | 17 | await account.deposit(amount) 18 | expect(account.balance).toBe(amount + startingBalance) 19 | expect(spy).toBeCalledWith(account.filePath, amount + startingBalance) 20 | }) 21 | }) 22 | 23 | describe("#withdraw", () => { 24 | test("it removes money from the account", async () => { 25 | const startingBalance = 10 26 | const amount = 5 27 | const account = await createAccount("Kyle", startingBalance) 28 | const spy = jest 29 | .spyOn(FileSystem, "write") 30 | .mockReturnValue(Promise.resolve()) 31 | 32 | await account.withdraw(amount) 33 | expect(account.balance).toBe(startingBalance - amount) 34 | expect(spy).toBeCalledWith(account.filePath, startingBalance - amount) 35 | }) 36 | 37 | describe("with not enough money in the account", () => { 38 | test("it should throw an error", async () => { 39 | const startingBalance = 5 40 | const amount = 10 41 | const account = await createAccount("Kyle", startingBalance) 42 | const spy = jest.spyOn(FileSystem, "write") 43 | 44 | await expect(account.withdraw(amount)).rejects.toThrow() 45 | expect(account.balance).toBe(startingBalance) 46 | expect(spy).not.toBeCalled() 47 | }) 48 | }) 49 | }) 50 | 51 | async function createAccount(name, balance) { 52 | const spy = jest 53 | .spyOn(FileSystem, "read") 54 | .mockReturnValueOnce(Promise.resolve(balance)) 55 | const account = await Account.find(name) 56 | spy.mockRestore() 57 | return account 58 | } 59 | -------------------------------------------------------------------------------- /41-atm-integration-tests/after/CommandLine.js: -------------------------------------------------------------------------------- 1 | const readline = require("readline") 2 | 3 | module.exports = class CommandLine { 4 | static ask(question) { 5 | const rl = readline.createInterface({ 6 | input: process.stdin, 7 | output: process.stdout, 8 | }) 9 | 10 | return new Promise(resolve => { 11 | rl.question(`${question} `, answer => { 12 | resolve(answer) 13 | rl.close() 14 | }) 15 | }) 16 | } 17 | 18 | static print(text) { 19 | console.log(text) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /41-atm-integration-tests/after/FileSystem.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | 3 | module.exports = class FileSystem { 4 | static read(path) { 5 | return new Promise((resolve, reject) => { 6 | fs.readFile(path, (err, data) => { 7 | if (err) return reject(err) 8 | resolve(data) 9 | }) 10 | }) 11 | } 12 | 13 | static write(path, content) { 14 | return new Promise((resolve, reject) => { 15 | fs.writeFile(path, content.toString(), err => { 16 | if (err) return reject(err) 17 | resolve() 18 | }) 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /41-atm-integration-tests/after/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "current-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "Account.js", 6 | "scripts": { 7 | "test": "jest --coverage", 8 | "test:unit": "jest \\.unit\\. --coverage", 9 | "test:int": "jest \\.int\\. --coverage" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "jest": "^26.6.3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /41-atm-integration-tests/after/script.js: -------------------------------------------------------------------------------- 1 | const Account = require("./Account") 2 | const CommandLine = require("./CommandLine") 3 | 4 | async function main() { 5 | try { 6 | const accountName = await CommandLine.ask( 7 | "Which account would you like to access?" 8 | ) 9 | const account = await Account.find(accountName) 10 | if (account == null) account = await promptCreateAccount(accountName) 11 | if (account != null) await promptTask(account) 12 | } catch (e) { 13 | CommandLine.print("ERROR: Please try again") 14 | } 15 | } 16 | 17 | async function promptCreateAccount(accountName) { 18 | const response = await CommandLine.ask( 19 | "That account does not exist. Would you like to create it? (yes/no)" 20 | ) 21 | 22 | if (response === "yes") { 23 | return await Account.create(accountName) 24 | } 25 | } 26 | 27 | async function promptTask(account) { 28 | const response = await CommandLine.ask( 29 | "What would you like to do? (view/deposit/withdraw)" 30 | ) 31 | 32 | if (response === "deposit") { 33 | const amount = parseFloat(await CommandLine.ask("How much?")) 34 | await account.deposit(amount) 35 | } else if (response === "withdraw") { 36 | const amount = parseFloat(await CommandLine.ask("How much?")) 37 | try { 38 | await account.withdraw(amount) 39 | } catch (e) { 40 | CommandLine.print( 41 | "We were unable to make the withdrawal. Please ensure you have enough money in your account." 42 | ) 43 | } 44 | } 45 | 46 | CommandLine.print(`Your balance is ${account.balance}`) 47 | } 48 | 49 | main() 50 | -------------------------------------------------------------------------------- /41-atm-integration-tests/before/.gitignore: -------------------------------------------------------------------------------- 1 | accounts 2 | node_modules 3 | coverage -------------------------------------------------------------------------------- /41-atm-integration-tests/before/Account.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("./FileSystem") 2 | 3 | module.exports = class Account { 4 | constructor(name) { 5 | this.#name = name 6 | } 7 | 8 | #name 9 | #balance 10 | 11 | get name() { 12 | return this.#name 13 | } 14 | 15 | get balance() { 16 | return this.#balance 17 | } 18 | 19 | get filePath() { 20 | return `accounts/${this.name}.txt` 21 | } 22 | 23 | async #load() { 24 | this.#balance = parseFloat(await FileSystem.read(this.filePath)) 25 | } 26 | 27 | async withdraw(amount) { 28 | if (this.balance < amount) throw new Error() 29 | await FileSystem.write(this.filePath, this.#balance - amount) 30 | this.#balance = this.#balance - amount 31 | } 32 | 33 | async deposit(amount) { 34 | await FileSystem.write(this.filePath, this.#balance + amount) 35 | this.#balance = this.#balance + amount 36 | } 37 | 38 | static async find(accountName) { 39 | const account = new Account(accountName) 40 | 41 | try { 42 | await account.#load() 43 | return account 44 | } catch (e) { 45 | return 46 | } 47 | } 48 | 49 | static async create(accountName) { 50 | const account = new Account(accountName) 51 | 52 | await FileSystem.write(account.filePath, 0) 53 | account.#balance = 0 54 | 55 | return account 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /41-atm-integration-tests/before/Account.test.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("./FileSystem") 2 | const Account = require("./Account") 3 | 4 | beforeEach(() => { 5 | jest.restoreAllMocks() 6 | }) 7 | 8 | describe("#deposit", () => { 9 | test("it adds money to the account", async () => { 10 | const startingBalance = 5 11 | const amount = 10 12 | const account = await createAccount("Kyle", startingBalance) 13 | const spy = jest 14 | .spyOn(FileSystem, "write") 15 | .mockReturnValue(Promise.resolve()) 16 | 17 | await account.deposit(amount) 18 | expect(account.balance).toBe(amount + startingBalance) 19 | expect(spy).toBeCalledWith(account.filePath, amount + startingBalance) 20 | }) 21 | }) 22 | 23 | describe("#withdraw", () => { 24 | test("it removes money from the account", async () => { 25 | const startingBalance = 10 26 | const amount = 5 27 | const account = await createAccount("Kyle", startingBalance) 28 | const spy = jest 29 | .spyOn(FileSystem, "write") 30 | .mockReturnValue(Promise.resolve()) 31 | 32 | await account.withdraw(amount) 33 | expect(account.balance).toBe(startingBalance - amount) 34 | expect(spy).toBeCalledWith(account.filePath, startingBalance - amount) 35 | }) 36 | 37 | describe("with not enough money in the account", () => { 38 | test("it should throw an error", async () => { 39 | const startingBalance = 5 40 | const amount = 10 41 | const account = await createAccount("Kyle", startingBalance) 42 | const spy = jest.spyOn(FileSystem, "write") 43 | 44 | await expect(account.withdraw(amount)).rejects.toThrow() 45 | expect(account.balance).toBe(startingBalance) 46 | expect(spy).not.toBeCalled() 47 | }) 48 | }) 49 | }) 50 | 51 | async function createAccount(name, balance) { 52 | const spy = jest 53 | .spyOn(FileSystem, "read") 54 | .mockReturnValueOnce(Promise.resolve(balance)) 55 | const account = await Account.find(name) 56 | spy.mockRestore() 57 | return account 58 | } 59 | -------------------------------------------------------------------------------- /41-atm-integration-tests/before/CommandLine.js: -------------------------------------------------------------------------------- 1 | const readline = require("readline") 2 | 3 | module.exports = class CommandLine { 4 | static ask(question) { 5 | const rl = readline.createInterface({ 6 | input: process.stdin, 7 | output: process.stdout, 8 | }) 9 | 10 | return new Promise(resolve => { 11 | rl.question(`${question} `, answer => { 12 | resolve(answer) 13 | rl.close() 14 | }) 15 | }) 16 | } 17 | 18 | static print(text) { 19 | console.log(text) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /41-atm-integration-tests/before/FileSystem.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | 3 | module.exports = class FileSystem { 4 | static read(path) { 5 | return new Promise((resolve, reject) => { 6 | fs.readFile(path, (err, data) => { 7 | if (err) return reject(err) 8 | resolve(data) 9 | }) 10 | }) 11 | } 12 | 13 | static write(path, content) { 14 | return new Promise((resolve, reject) => { 15 | fs.writeFile(path, content.toString(), err => { 16 | if (err) return reject(err) 17 | resolve() 18 | }) 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /41-atm-integration-tests/before/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "current-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "Account.js", 6 | "scripts": { 7 | "test": "jest --coverage" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "jest": "^26.6.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /41-atm-integration-tests/before/script.js: -------------------------------------------------------------------------------- 1 | const Account = require("./Account") 2 | const CommandLine = require("./CommandLine") 3 | 4 | async function main() { 5 | try { 6 | const accountName = await CommandLine.ask( 7 | "Which account would you like to access?" 8 | ) 9 | const account = await Account.find(accountName) 10 | if (account == null) account = await promptCreateAccount(accountName) 11 | if (account != null) await promptTask(account) 12 | } catch (e) { 13 | CommandLine.print("ERROR: Please try again") 14 | } 15 | } 16 | 17 | async function promptCreateAccount(accountName) { 18 | const response = await CommandLine.ask( 19 | "That account does not exist. Would you like to create it? (yes/no)" 20 | ) 21 | 22 | if (response === "yes") { 23 | return await Account.create(accountName) 24 | } 25 | } 26 | 27 | async function promptTask(account) { 28 | const response = await CommandLine.ask( 29 | "What would you like to do? (view/deposit/withdraw)" 30 | ) 31 | 32 | if (response === "deposit") { 33 | const amount = parseFloat(await CommandLine.ask("How much?")) 34 | await account.deposit(amount) 35 | } else if (response === "withdraw") { 36 | const amount = parseFloat(await CommandLine.ask("How much?")) 37 | try { 38 | await account.withdraw(amount) 39 | } catch (e) { 40 | CommandLine.print( 41 | "We were unable to make the withdrawal. Please ensure you have enough money in your account." 42 | ) 43 | } 44 | } 45 | 46 | CommandLine.print(`Your balance is ${account.balance}`) 47 | } 48 | 49 | main() 50 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/after/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/after/Calculator.js: -------------------------------------------------------------------------------- 1 | export default class Calculator { 2 | constructor( 3 | primaryOperandDisplay, 4 | secondaryOperandDisplay, 5 | operationDisplay 6 | ) { 7 | this.#primaryOperandDisplay = primaryOperandDisplay 8 | this.#secondaryOperandDisplay = secondaryOperandDisplay 9 | this.#operationDisplay = operationDisplay 10 | 11 | this.clear() 12 | } 13 | 14 | #primaryOperandDisplay 15 | #secondaryOperandDisplay 16 | #operationDisplay 17 | 18 | get primaryOperand() { 19 | return parseFloat(this.#primaryOperandDisplay.dataset.value) 20 | } 21 | 22 | set primaryOperand(value) { 23 | this.#primaryOperandDisplay.dataset.value = value ?? "" 24 | this.#primaryOperandDisplay.textContent = displayNumber(value) 25 | } 26 | 27 | get secondaryOperand() { 28 | return parseFloat(this.#secondaryOperandDisplay.dataset.value) 29 | } 30 | 31 | set secondaryOperand(value) { 32 | this.#secondaryOperandDisplay.dataset.value = value ?? "" 33 | this.#secondaryOperandDisplay.textContent = displayNumber(value) 34 | } 35 | 36 | get operation() { 37 | return this.#operationDisplay.textContent 38 | } 39 | 40 | set operation(value) { 41 | this.#operationDisplay.textContent = value ?? "" 42 | } 43 | 44 | addDigit(digit) { 45 | if ( 46 | digit === "." && 47 | this.#primaryOperandDisplay.dataset.value.includes(".") 48 | ) { 49 | return 50 | } 51 | if (this.primaryOperand === 0) { 52 | this.primaryOperand = digit 53 | return 54 | } 55 | this.primaryOperand = this.#primaryOperandDisplay.dataset.value + digit 56 | } 57 | 58 | removeDigit() { 59 | const numberString = this.#primaryOperandDisplay.dataset.value 60 | if (numberString.length <= 1) { 61 | this.primaryOperand = 0 62 | return 63 | } 64 | 65 | this.primaryOperand = numberString.substring(0, numberString.length - 1) 66 | } 67 | 68 | evaluate() { 69 | let result 70 | switch (this.operation) { 71 | case "*": 72 | result = this.secondaryOperand * this.primaryOperand 73 | break 74 | case "÷": 75 | result = this.secondaryOperand / this.primaryOperand 76 | break 77 | case "+": 78 | result = this.secondaryOperand + this.primaryOperand 79 | break 80 | case "-": 81 | result = this.secondaryOperand - this.primaryOperand 82 | break 83 | default: 84 | return 85 | } 86 | 87 | this.clear() 88 | this.primaryOperand = result 89 | 90 | return result 91 | } 92 | 93 | chooseOperation(operation) { 94 | if (this.operation !== "") return 95 | this.operation = operation 96 | this.secondaryOperand = this.primaryOperand 97 | this.primaryOperand = 0 98 | } 99 | 100 | clear() { 101 | this.primaryOperand = 0 102 | this.secondaryOperand = null 103 | this.operation = null 104 | } 105 | } 106 | 107 | const NUMBER_FORMATTER = new Intl.NumberFormat("en") 108 | 109 | function displayNumber(number) { 110 | const stringNumber = number?.toString() || "" 111 | if (stringNumber === "") return "" 112 | const [integer, decimal] = stringNumber.split(".") 113 | const formattedInteger = NUMBER_FORMATTER.format(integer) 114 | if (decimal == null) return formattedInteger 115 | return formattedInteger + "." + decimal 116 | } 117 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/after/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:5000", 3 | "viewportWidth": 450, 4 | "viewportHeight": 650 5 | } 6 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/after/cypress/integration/Calculator.test.js: -------------------------------------------------------------------------------- 1 | describe("calculator", () => { 2 | beforeEach(() => { 3 | cy.visit("/") 4 | }) 5 | 6 | it("correctly handles normal calculations", () => { 7 | cy.getCalculatorButton("4").click() 8 | cy.getCalculatorButton(".").click() 9 | cy.getCalculatorButton("1").click() 10 | cy.get(".primary-operand").should("have.text", "4.1") 11 | cy.getCalculatorButton("+").click() 12 | cy.get(".primary-operand").should("have.text", "0") 13 | cy.get(".secondary-operand").should("have.text", "4.1") 14 | cy.get(".history > [data-operation]").should("have.text", "+") 15 | cy.getCalculatorButton("6").click() 16 | cy.get(".primary-operand").should("have.text", "6") 17 | cy.getCalculatorButton("=").click() 18 | cy.get(".primary-operand").should("have.text", "10.1") 19 | cy.get(".secondary-operand").should("have.text", "") 20 | cy.get(".history > [data-operation]").should("have.text", "") 21 | }) 22 | 23 | it("correctly handles all clear", () => { 24 | cy.getCalculatorButton("4").click() 25 | cy.getCalculatorButton("+").click() 26 | cy.getCalculatorButton("6").click() 27 | cy.getCalculatorButton("AC").click() 28 | cy.get(".primary-operand").should("have.text", "0") 29 | cy.get(".secondary-operand").should("have.text", "") 30 | cy.get(".history > [data-operation]").should("have.text", "") 31 | }) 32 | 33 | it("correctly handles delete", () => { 34 | cy.getCalculatorButton("4").click() 35 | cy.getCalculatorButton("4").click() 36 | cy.getCalculatorButton("4").click() 37 | cy.getCalculatorButton("DEL").click() 38 | cy.get(".primary-operand").should("have.text", "44") 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/after/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/after/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | Cypress.Commands.add("getCalculatorButton", text => { 28 | cy.contains(".calculator-grid > button", text) 29 | }) 30 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/after/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/after/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Calculator 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 | 40 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/after/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "current-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "Calculator.js", 6 | "scripts": { 7 | "start": "serve", 8 | "cy:open": "cypress open", 9 | "test": "start-server-and-test start http://localhost:5000 cy:open" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "cypress": "^6.6.0", 16 | "serve": "^11.3.2", 17 | "start-server-and-test": "^1.12.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/after/script.js: -------------------------------------------------------------------------------- 1 | import Calculator from "./Calculator.js" 2 | 3 | const primaryOperandDisplay = document.querySelector("[data-primary-operand]") 4 | const secondaryOperandDisplay = document.querySelector( 5 | "[data-secondary-operand]" 6 | ) 7 | const operationDisplay = document.querySelector("[data-operation]") 8 | 9 | const calculator = new Calculator( 10 | primaryOperandDisplay, 11 | secondaryOperandDisplay, 12 | operationDisplay 13 | ) 14 | 15 | document.addEventListener("click", e => { 16 | if (e.target.matches("[data-all-clear]")) { 17 | calculator.clear() 18 | } 19 | if (e.target.matches("[data-number]")) { 20 | calculator.addDigit(e.target.textContent) 21 | } 22 | if (e.target.matches("[data-delete]")) { 23 | calculator.removeDigit() 24 | } 25 | if (e.target.matches("[data-operation]")) { 26 | calculator.chooseOperation(e.target.textContent) 27 | } 28 | if (e.target.matches("[data-equals]")) { 29 | calculator.evaluate() 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/after/styles.css: -------------------------------------------------------------------------------- 1 | *, *::before, *::after { 2 | box-sizing: border-box; 3 | font-family: Gotham Rounded, sans-serif; 4 | font-weight: normal; 5 | } 6 | 7 | body { 8 | padding: 0; 9 | margin: 0; 10 | background: linear-gradient(to right, #7700ff, #008cff); 11 | } 12 | 13 | .calculator-grid { 14 | display: grid; 15 | justify-content: center; 16 | align-content: center; 17 | min-height: 100vh; 18 | grid-template-columns: repeat(4, 100px); 19 | grid-template-rows: minmax(120px, auto) repeat(5, 100px); 20 | } 21 | 22 | .calculator-grid > button { 23 | cursor: pointer; 24 | font-size: 2rem; 25 | border: 1px solid white; 26 | outline: none; 27 | background-color: rgba(255, 255, 255, .75); 28 | } 29 | 30 | .calculator-grid > button:hover { 31 | background-color: rgba(255, 255, 255, .9); 32 | } 33 | 34 | .span-two { 35 | grid-column: span 2; 36 | } 37 | 38 | .output { 39 | grid-column: 1 / -1; 40 | background-color: rgba(0, 0, 0, .75); 41 | display: flex; 42 | align-items: flex-end; 43 | justify-content: space-around; 44 | flex-direction: column; 45 | padding: 10px; 46 | word-wrap: break-word; 47 | word-break: break-all; 48 | } 49 | 50 | .output .history { 51 | color: rgba(255, 255, 255, .75); 52 | font-size: 1.5rem; 53 | display: flex; 54 | } 55 | 56 | .output .secondary-operand { 57 | margin-right: 7px; 58 | } 59 | 60 | .output .primary-operand { 61 | color: white; 62 | font-size: 2.5rem; 63 | } -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/before/Calculator.js: -------------------------------------------------------------------------------- 1 | export default class Calculator { 2 | constructor( 3 | primaryOperandDisplay, 4 | secondaryOperandDisplay, 5 | operationDisplay 6 | ) { 7 | this.#primaryOperandDisplay = primaryOperandDisplay 8 | this.#secondaryOperandDisplay = secondaryOperandDisplay 9 | this.#operationDisplay = operationDisplay 10 | 11 | this.clear() 12 | } 13 | 14 | #primaryOperandDisplay 15 | #secondaryOperandDisplay 16 | #operationDisplay 17 | 18 | get primaryOperand() { 19 | return parseFloat(this.#primaryOperandDisplay.dataset.value) 20 | } 21 | 22 | set primaryOperand(value) { 23 | this.#primaryOperandDisplay.dataset.value = value ?? "" 24 | this.#primaryOperandDisplay.textContent = displayNumber(value) 25 | } 26 | 27 | get secondaryOperand() { 28 | return parseFloat(this.#secondaryOperandDisplay.dataset.value) 29 | } 30 | 31 | set secondaryOperand(value) { 32 | this.#secondaryOperandDisplay.dataset.value = value ?? "" 33 | this.#secondaryOperandDisplay.textContent = displayNumber(value) 34 | } 35 | 36 | get operation() { 37 | return this.#operationDisplay.textContent 38 | } 39 | 40 | set operation(value) { 41 | this.#operationDisplay.textContent = value ?? "" 42 | } 43 | 44 | addDigit(digit) { 45 | if ( 46 | digit === "." && 47 | this.#primaryOperandDisplay.dataset.value.includes(".") 48 | ) { 49 | return 50 | } 51 | if (this.primaryOperand === 0) { 52 | this.primaryOperand = digit 53 | return 54 | } 55 | this.primaryOperand = this.#primaryOperandDisplay.dataset.value + digit 56 | } 57 | 58 | removeDigit() { 59 | const numberString = this.#primaryOperandDisplay.dataset.value 60 | if (numberString.length <= 1) { 61 | this.primaryOperand = 0 62 | return 63 | } 64 | 65 | this.primaryOperand = numberString.substring(0, numberString.length - 1) 66 | } 67 | 68 | evaluate() { 69 | let result 70 | switch (this.operation) { 71 | case "*": 72 | result = this.secondaryOperand * this.primaryOperand 73 | break 74 | case "÷": 75 | result = this.secondaryOperand / this.primaryOperand 76 | break 77 | case "+": 78 | result = this.secondaryOperand + this.primaryOperand 79 | break 80 | case "-": 81 | result = this.secondaryOperand - this.primaryOperand 82 | break 83 | default: 84 | return 85 | } 86 | 87 | this.clear() 88 | this.primaryOperand = result 89 | 90 | return result 91 | } 92 | 93 | chooseOperation(operation) { 94 | if (this.operation !== "") return 95 | this.operation = operation 96 | this.secondaryOperand = this.primaryOperand 97 | this.primaryOperand = 0 98 | } 99 | 100 | clear() { 101 | this.primaryOperand = 0 102 | this.secondaryOperand = null 103 | this.operation = null 104 | } 105 | } 106 | 107 | const NUMBER_FORMATTER = new Intl.NumberFormat("en") 108 | 109 | function displayNumber(number) { 110 | const stringNumber = number?.toString() || "" 111 | if (stringNumber === "") return "" 112 | const [integer, decimal] = stringNumber.split(".") 113 | const formattedInteger = NUMBER_FORMATTER.format(integer) 114 | if (decimal == null) return formattedInteger 115 | return formattedInteger + "." + decimal 116 | } 117 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/before/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Calculator 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 | 40 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/before/script.js: -------------------------------------------------------------------------------- 1 | import Calculator from "./Calculator.js" 2 | 3 | const primaryOperandDisplay = document.querySelector("[data-primary-operand]") 4 | const secondaryOperandDisplay = document.querySelector( 5 | "[data-secondary-operand]" 6 | ) 7 | const operationDisplay = document.querySelector("[data-operation]") 8 | 9 | const calculator = new Calculator( 10 | primaryOperandDisplay, 11 | secondaryOperandDisplay, 12 | operationDisplay 13 | ) 14 | 15 | document.addEventListener("click", e => { 16 | if (e.target.matches("[data-all-clear]")) { 17 | calculator.clear() 18 | } 19 | if (e.target.matches("[data-number]")) { 20 | calculator.addDigit(e.target.textContent) 21 | } 22 | if (e.target.matches("[data-delete]")) { 23 | calculator.removeDigit() 24 | } 25 | if (e.target.matches("[data-operation]")) { 26 | calculator.chooseOperation(e.target.textContent) 27 | } 28 | if (e.target.matches("[data-equals]")) { 29 | calculator.evaluate() 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /42-calculator-end-to-end-tests/before/styles.css: -------------------------------------------------------------------------------- 1 | *, *::before, *::after { 2 | box-sizing: border-box; 3 | font-family: Gotham Rounded, sans-serif; 4 | font-weight: normal; 5 | } 6 | 7 | body { 8 | padding: 0; 9 | margin: 0; 10 | background: linear-gradient(to right, #7700ff, #008cff); 11 | } 12 | 13 | .calculator-grid { 14 | display: grid; 15 | justify-content: center; 16 | align-content: center; 17 | min-height: 100vh; 18 | grid-template-columns: repeat(4, 100px); 19 | grid-template-rows: minmax(120px, auto) repeat(5, 100px); 20 | } 21 | 22 | .calculator-grid > button { 23 | cursor: pointer; 24 | font-size: 2rem; 25 | border: 1px solid white; 26 | outline: none; 27 | background-color: rgba(255, 255, 255, .75); 28 | } 29 | 30 | .calculator-grid > button:hover { 31 | background-color: rgba(255, 255, 255, .9); 32 | } 33 | 34 | .span-two { 35 | grid-column: span 2; 36 | } 37 | 38 | .output { 39 | grid-column: 1 / -1; 40 | background-color: rgba(0, 0, 0, .75); 41 | display: flex; 42 | align-items: flex-end; 43 | justify-content: space-around; 44 | flex-direction: column; 45 | padding: 10px; 46 | word-wrap: break-word; 47 | word-break: break-all; 48 | } 49 | 50 | .output .history { 51 | color: rgba(255, 255, 255, .75); 52 | font-size: 1.5rem; 53 | display: flex; 54 | } 55 | 56 | .output .secondary-operand { 57 | margin-right: 7px; 58 | } 59 | 60 | .output .primary-operand { 61 | color: white; 62 | font-size: 2.5rem; 63 | } -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/after/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/after/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/after/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Math Solver 9 | 10 | 11 |
12 | 13 | 14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/after/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "current-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "script.js", 6 | "scripts": { 7 | "test": "jest --coverage" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@babel/core": "^7.13.10", 14 | "@babel/preset-env": "^7.13.10", 15 | "jest": "^26.6.3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/after/parse.js: -------------------------------------------------------------------------------- 1 | const PARENTHESIS_REGEX = /\((?[^\(\)]*)\)/ 2 | const MULTIPLY_DIVIDE_REGEX = /(?\S+)\s*(?[\/\*])\s*(?\S+)/ 3 | const EXPONENT_REGEX = /(?\S+)\s*(?\^)\s*(?\S+)/ 4 | const ADD_SUBTRACT_REGEX = /(?\S+)\s*(?(?\S+)/ 5 | 6 | export default function parse(equation) { 7 | if (equation.match(PARENTHESIS_REGEX)) { 8 | const subEquation = equation.match(PARENTHESIS_REGEX).groups.equation 9 | const result = parse(subEquation) 10 | const newEquation = equation.replace(PARENTHESIS_REGEX, result) 11 | return parse(newEquation) 12 | } else if (equation.match(EXPONENT_REGEX)) { 13 | const result = handleMath(equation.match(EXPONENT_REGEX).groups) 14 | const newEquation = equation.replace(EXPONENT_REGEX, result) 15 | return parse(newEquation) 16 | } else if (equation.match(MULTIPLY_DIVIDE_REGEX)) { 17 | const result = handleMath(equation.match(MULTIPLY_DIVIDE_REGEX).groups) 18 | const newEquation = equation.replace(MULTIPLY_DIVIDE_REGEX, result) 19 | return parse(newEquation) 20 | } else if (equation.match(ADD_SUBTRACT_REGEX)) { 21 | const result = handleMath(equation.match(ADD_SUBTRACT_REGEX).groups) 22 | const newEquation = equation.replace(ADD_SUBTRACT_REGEX, result) 23 | return parse(newEquation) 24 | } else { 25 | return parseFloat(equation) 26 | } 27 | } 28 | 29 | function handleMath({ operand1, operand2, operation }) { 30 | const number1 = parseFloat(operand1) 31 | const number2 = parseFloat(operand2) 32 | 33 | switch (operation) { 34 | case "*": 35 | return number1 * number2 36 | case "/": 37 | return number1 / number2 38 | case "+": 39 | return number1 + number2 40 | case "-": 41 | return number1 - number2 42 | case "^": 43 | return number1 ** number2 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/after/parse.test.js: -------------------------------------------------------------------------------- 1 | import parse from "./parse.js" 2 | 3 | describe("#parse", () => { 4 | describe("with exponents", () => { 5 | test("it returns the correct result", () => { 6 | expect(parse("3 ^ 2")).toBe(9) 7 | }) 8 | }) 9 | 10 | describe("with addition", () => { 11 | test("it returns the correct result", () => { 12 | expect(parse("3 + 2")).toBe(5) 13 | }) 14 | }) 15 | 16 | describe("with subtraction", () => { 17 | test("it returns the correct result", () => { 18 | expect(parse("2 - 3")).toBe(-1) 19 | }) 20 | }) 21 | 22 | describe("with multiplication", () => { 23 | test("it returns the correct result", () => { 24 | expect(parse("2 * 3")).toBe(6) 25 | }) 26 | }) 27 | 28 | describe("with division", () => { 29 | test("it returns the correct result", () => { 30 | expect(parse("3 / 2")).toBe(1.5) 31 | }) 32 | }) 33 | 34 | describe("with parenthesis", () => { 35 | test("it returns the correct result", () => { 36 | expect(parse("(3 + 2) * 4")).toBe(20) 37 | }) 38 | }) 39 | 40 | describe("with very large numbers", () => { 41 | test("it returns the correct result in scientific notation", () => { 42 | expect(parse("10 ^ 30")).toBe(1e30) 43 | }) 44 | }) 45 | 46 | describe("with very small numbers", () => { 47 | test("it returns the correct result in scientific notation", () => { 48 | expect(parse("10 ^ -30")).toBe(1e-30) 49 | }) 50 | }) 51 | 52 | describe("with a malformed equation", () => { 53 | test("it returns NaN", () => { 54 | expect(parse("abc")).toBeNaN() 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/after/script.js: -------------------------------------------------------------------------------- 1 | import parse from "./parse.js" 2 | 3 | const inputElement = document.getElementById("equation") 4 | const outputElement = document.getElementById("results") 5 | const form = document.getElementById("equation-form") 6 | 7 | form.addEventListener("submit", e => { 8 | e.preventDefault() 9 | 10 | const result = parse(inputElement.value) 11 | outputElement.textContent = result 12 | }) 13 | -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/before/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/before/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/before/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Math Solver 9 | 10 | 11 |
12 | 13 | 14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/before/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "current-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "script.js", 6 | "scripts": { 7 | "test": "jest --coverage" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@babel/core": "^7.13.10", 14 | "@babel/preset-env": "^7.13.10", 15 | "jest": "^26.6.3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/before/parse.js: -------------------------------------------------------------------------------- 1 | const PARENTHESIS_REGEX = /\((?[^\(\)]*)\)/ 2 | const MULTIPLY_DIVIDE_REGEX = /(?\S+)\s*(?[\/\*])\s*(?\S+)/ 3 | const EXPONENT_REGEX = /(?\S+)\s*(?\^)\s*(?\S+)/ 4 | const ADD_SUBTRACT_REGEX = /(?\S+)\s*(?(?\S+)/ 5 | 6 | export default function parse(equation) { 7 | if (equation.match(PARENTHESIS_REGEX)) { 8 | const subEquation = equation.match(PARENTHESIS_REGEX).groups.equation 9 | const result = parse(subEquation) 10 | const newEquation = equation.replace(PARENTHESIS_REGEX, result) 11 | return parse(newEquation) 12 | } else if (equation.match(EXPONENT_REGEX)) { 13 | const result = handleMath(equation.match(EXPONENT_REGEX).groups) 14 | const newEquation = equation.replace(EXPONENT_REGEX, result) 15 | return parse(newEquation) 16 | } else if (equation.match(MULTIPLY_DIVIDE_REGEX)) { 17 | const result = handleMath(equation.match(MULTIPLY_DIVIDE_REGEX).groups) 18 | const newEquation = equation.replace(MULTIPLY_DIVIDE_REGEX, result) 19 | return parse(newEquation) 20 | } else if (equation.match(ADD_SUBTRACT_REGEX)) { 21 | const result = handleMath(equation.match(ADD_SUBTRACT_REGEX).groups) 22 | const newEquation = equation.replace(ADD_SUBTRACT_REGEX, result) 23 | return parse(newEquation) 24 | } else { 25 | return parseFloat(equation) 26 | } 27 | } 28 | 29 | function handleMath({ operand1, operand2, operation }) { 30 | const number1 = parseFloat(operand1) 31 | const number2 = parseFloat(operand2) 32 | 33 | switch (operation) { 34 | case "*": 35 | return number1 * number2 36 | case "/": 37 | return number1 / number2 38 | case "+": 39 | return number1 + number2 40 | case "-": 41 | return number1 - number2 42 | case "^": 43 | return number1 ** number2 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/before/parse.test.js: -------------------------------------------------------------------------------- 1 | import parse from "./parse.js" 2 | 3 | describe("#parse", () => { 4 | test("it works", () => { 5 | expect(parse("3 - 1")).toBe(2) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /45-46-math-solver-unit-tests/before/script.js: -------------------------------------------------------------------------------- 1 | import parse from "./parse.js" 2 | 3 | const inputElement = document.getElementById("equation") 4 | const outputElement = document.getElementById("results") 5 | const form = document.getElementById("equation-form") 6 | 7 | form.addEventListener("submit", e => { 8 | e.preventDefault() 9 | 10 | const result = parse(inputElement.value) 11 | outputElement.textContent = result 12 | }) 13 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": [ 4 | "@babel/plugin-proposal-optional-chaining" 5 | ] 6 | } -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .cache 4 | coverage -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:1234", 3 | "viewportWidth": 900, 4 | "viewportHeight": 900 5 | } 6 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/cypress/integration/minesweeper.test.js: -------------------------------------------------------------------------------- 1 | import { TILE_STATUSES } from "../../minesweeper.js" 2 | 3 | describe("user left clicks on tile", () => { 4 | describe("when the tile is not a mine", () => { 5 | it("reveals itself and displays the number of mines", () => { 6 | cy.visitBoard([ 7 | [ 8 | { x: 0, y: 0, status: TILE_STATUSES.HIDDEN, mine: true }, 9 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 10 | ], 11 | [ 12 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 13 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 14 | ], 15 | ]) 16 | cy.get('[data-x="0"][data-y="1"]').click() 17 | cy.get('[data-x="0"][data-y="1"]').should("have.text", "1") 18 | }) 19 | }) 20 | 21 | describe("when the tile is a mine", () => { 22 | it("reveals itself and all other mines", () => { 23 | cy.visitBoard([ 24 | [ 25 | { x: 0, y: 0, status: TILE_STATUSES.HIDDEN, mine: true }, 26 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 27 | ], 28 | [ 29 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 30 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 31 | ], 32 | ]) 33 | 34 | // Click Mine 35 | cy.get('[data-x="0"][data-y="0"]').click() 36 | cy.get('[data-x="0"][data-y="0"]').should( 37 | "have.attr", 38 | "data-status", 39 | TILE_STATUSES.MINE 40 | ) 41 | 42 | // Reveal Other Mines 43 | cy.get('[data-x="0"][data-y="1"]').should( 44 | "have.attr", 45 | "data-status", 46 | TILE_STATUSES.MINE 47 | ) 48 | 49 | // Lose Text 50 | cy.get(".subtext").should("have.text", "You Lose") 51 | 52 | // Ensure no input allowed 53 | cy.get('[data-x="1"][data-y="0"]').click() 54 | cy.get('[data-x="1"][data-y="0"]').should( 55 | "have.attr", 56 | "data-status", 57 | TILE_STATUSES.HIDDEN 58 | ) 59 | }) 60 | }) 61 | }) 62 | 63 | describe("user right clicks on tile", () => { 64 | describe("when the tile is not marked", () => { 65 | it("marks itself", () => { 66 | cy.visitBoard([ 67 | [ 68 | { x: 0, y: 0, status: TILE_STATUSES.HIDDEN, mine: true }, 69 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 70 | ], 71 | [ 72 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 73 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 74 | ], 75 | ]) 76 | cy.get('[data-x="0"][data-y="0"]').rightclick() 77 | cy.get('[data-x="0"][data-y="0"]').should( 78 | "have.attr", 79 | "data-status", 80 | TILE_STATUSES.MARKED 81 | ) 82 | }) 83 | }) 84 | 85 | describe("when the tile is marked", () => { 86 | it("un-marks itself", () => { 87 | cy.visitBoard([ 88 | [ 89 | { x: 0, y: 0, status: TILE_STATUSES.MARKED, mine: true }, 90 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 91 | ], 92 | [ 93 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 94 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 95 | ], 96 | ]) 97 | cy.get('[data-x="0"][data-y="0"]').rightclick() 98 | cy.get('[data-x="0"][data-y="0"]').should( 99 | "have.attr", 100 | "data-status", 101 | TILE_STATUSES.HIDDEN 102 | ) 103 | }) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | Cypress.Commands.add("visitBoard", board => { 28 | cy.visit("/", { 29 | onBeforeLoad(window) { 30 | window.testBoard = board 31 | }, 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Minesweeper 9 | 10 | 11 |

Minesweeper

12 |
13 | Mines Left: 14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/minesweeper.js: -------------------------------------------------------------------------------- 1 | import { times, range } from "lodash/fp" 2 | 3 | export const TILE_STATUSES = { 4 | HIDDEN: "hidden", 5 | MINE: "mine", 6 | NUMBER: "number", 7 | MARKED: "marked", 8 | } 9 | 10 | export function createBoard(boardSize, minePositions) { 11 | return times(x => { 12 | return times(y => { 13 | return { 14 | x, 15 | y, 16 | mine: minePositions.some(positionMatch.bind(null, { x, y })), 17 | status: TILE_STATUSES.HIDDEN, 18 | } 19 | }, boardSize) 20 | }, boardSize) 21 | } 22 | 23 | export function markedTilesCount(board) { 24 | return board.reduce((count, row) => { 25 | return ( 26 | count + row.filter(tile => tile.status === TILE_STATUSES.MARKED).length 27 | ) 28 | }, 0) 29 | } 30 | 31 | export function markTile(board, { x, y }) { 32 | const tile = board[x][y] 33 | if ( 34 | tile.status !== TILE_STATUSES.HIDDEN && 35 | tile.status !== TILE_STATUSES.MARKED 36 | ) { 37 | return board 38 | } 39 | 40 | if (tile.status === TILE_STATUSES.MARKED) { 41 | return replaceTile( 42 | board, 43 | { x, y }, 44 | { ...tile, status: TILE_STATUSES.HIDDEN } 45 | ) 46 | } else { 47 | return replaceTile( 48 | board, 49 | { x, y }, 50 | { ...tile, status: TILE_STATUSES.MARKED } 51 | ) 52 | } 53 | } 54 | 55 | export function revealTile(board, { x, y }) { 56 | const tile = board[x][y] 57 | if (tile.status !== TILE_STATUSES.HIDDEN) { 58 | return board 59 | } 60 | 61 | if (tile.mine) { 62 | return replaceTile(board, { x, y }, { ...tile, status: TILE_STATUSES.MINE }) 63 | } 64 | 65 | const adjacentTiles = nearbyTiles(board, tile) 66 | const mines = adjacentTiles.filter(t => t.mine) 67 | const newBoard = replaceTile( 68 | board, 69 | { x, y }, 70 | { ...tile, status: TILE_STATUSES.NUMBER, adjacentMinesCount: mines.length } 71 | ) 72 | if (mines.length === 0) { 73 | return adjacentTiles.reduce((b, t) => { 74 | return revealTile(b, t) 75 | }, newBoard) 76 | } 77 | return newBoard 78 | } 79 | 80 | export function checkWin(board) { 81 | return board.every(row => { 82 | return row.every(tile => { 83 | return ( 84 | tile.status === TILE_STATUSES.NUMBER || 85 | (tile.mine && 86 | (tile.status === TILE_STATUSES.HIDDEN || 87 | tile.status === TILE_STATUSES.MARKED)) 88 | ) 89 | }) 90 | }) 91 | } 92 | 93 | export function checkLose(board) { 94 | return board.some(row => { 95 | return row.some(tile => { 96 | return tile.status === TILE_STATUSES.MINE 97 | }) 98 | }) 99 | } 100 | 101 | export function positionMatch(a, b) { 102 | return a.x === b.x && a.y === b.y 103 | } 104 | 105 | function replaceTile(board, position, newTile) { 106 | return board.map((row, x) => { 107 | return row.map((tile, y) => { 108 | if (positionMatch(position, { x, y })) { 109 | return newTile 110 | } 111 | return tile 112 | }) 113 | }) 114 | } 115 | 116 | function nearbyTiles(board, { x, y }) { 117 | const offsets = range(-1, 2) 118 | 119 | return offsets 120 | .flatMap(xOffset => { 121 | return offsets.map(yOffset => { 122 | return board[x + xOffset]?.[y + yOffset] 123 | }) 124 | }) 125 | .filter(tile => tile != null) 126 | } 127 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/minesweeper.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | createBoard, 3 | TILE_STATUSES, 4 | markedTilesCount, 5 | markTile, 6 | revealTile, 7 | checkWin, 8 | checkLose, 9 | positionMatch, 10 | } from "./minesweeper" 11 | 12 | describe("#createBoard", () => { 13 | test("it creates a valid board", () => { 14 | const boardSize = 2 15 | const minePositions = [{ x: 0, y: 1 }] 16 | const expectedBoard = [ 17 | [ 18 | { x: 0, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 19 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 20 | ], 21 | [ 22 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 23 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 24 | ], 25 | ] 26 | const board = createBoard(boardSize, minePositions) 27 | expect(board).toEqual(expectedBoard) 28 | }) 29 | }) 30 | 31 | describe("#markedTilesCount", () => { 32 | test("with some tiles marked", () => { 33 | const board = [ 34 | [ 35 | { x: 0, y: 0, status: TILE_STATUSES.MARKED, mine: false }, 36 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 37 | ], 38 | [ 39 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 40 | { x: 1, y: 1, status: TILE_STATUSES.MARKED, mine: false }, 41 | ], 42 | ] 43 | expect(markedTilesCount(board)).toEqual(2) 44 | }) 45 | 46 | test("with no tiles marked", () => { 47 | const board = [ 48 | [ 49 | { x: 0, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 50 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 51 | ], 52 | [ 53 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 54 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 55 | ], 56 | ] 57 | expect(markedTilesCount(board)).toEqual(0) 58 | }) 59 | 60 | test("with all tiles marked", () => { 61 | const board = [ 62 | [ 63 | { x: 0, y: 0, status: TILE_STATUSES.MARKED, mine: false }, 64 | { x: 0, y: 1, status: TILE_STATUSES.MARKED, mine: true }, 65 | ], 66 | [ 67 | { x: 1, y: 0, status: TILE_STATUSES.MARKED, mine: false }, 68 | { x: 1, y: 1, status: TILE_STATUSES.MARKED, mine: false }, 69 | ], 70 | ] 71 | expect(markedTilesCount(board)).toEqual(4) 72 | }) 73 | }) 74 | 75 | describe("#markTile", () => { 76 | test("with a hidden tile it marks it", () => { 77 | const board = [ 78 | [ 79 | { x: 0, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 80 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 81 | ], 82 | [ 83 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 84 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 85 | ], 86 | ] 87 | const expectedBoard = [ 88 | [ 89 | { x: 0, y: 0, status: TILE_STATUSES.MARKED, mine: false }, 90 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 91 | ], 92 | [ 93 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 94 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 95 | ], 96 | ] 97 | expect(markTile(board, { x: 0, y: 0 })).toEqual(expectedBoard) 98 | }) 99 | 100 | test("with a marked tile it un-marks it", () => { 101 | const board = [ 102 | [ 103 | { x: 0, y: 0, status: TILE_STATUSES.MARKED, mine: false }, 104 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 105 | ], 106 | [ 107 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 108 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 109 | ], 110 | ] 111 | const expectedBoard = [ 112 | [ 113 | { x: 0, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 114 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 115 | ], 116 | [ 117 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 118 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 119 | ], 120 | ] 121 | expect(markTile(board, { x: 0, y: 0 })).toEqual(expectedBoard) 122 | }) 123 | 124 | test("with a mine tile it does nothing", () => { 125 | const board = [ 126 | [ 127 | { x: 0, y: 0, status: TILE_STATUSES.MINE, mine: false }, 128 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 129 | ], 130 | [ 131 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 132 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 133 | ], 134 | ] 135 | 136 | expect(markTile(board, { x: 0, y: 0 })).toEqual(board) 137 | }) 138 | 139 | test("with a number tile it does nothing", () => { 140 | const board = [ 141 | [ 142 | { x: 0, y: 0, status: TILE_STATUSES.NUMBER, mine: false }, 143 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 144 | ], 145 | [ 146 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 147 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 148 | ], 149 | ] 150 | 151 | expect(markTile(board, { x: 0, y: 0 })).toEqual(board) 152 | }) 153 | }) 154 | 155 | describe("#revealTile", () => { 156 | describe("with a hidden tile", () => { 157 | const board = [ 158 | [ 159 | { x: 0, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 160 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 161 | ], 162 | [ 163 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 164 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 165 | ], 166 | ] 167 | 168 | test("when the tile is a mine it sets its status to mine", () => { 169 | const expectedBoard = [ 170 | [ 171 | { x: 0, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 172 | { x: 0, y: 1, status: TILE_STATUSES.MINE, mine: true }, 173 | ], 174 | [ 175 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 176 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 177 | ], 178 | ] 179 | expect(revealTile(board, { x: 0, y: 1 })).toEqual(expectedBoard) 180 | }) 181 | 182 | describe("when the tile is not a mine", () => { 183 | test("when the tile is adjacent to a mine it counts the number of nearby mines", () => { 184 | const expectedBoard = [ 185 | [ 186 | { 187 | x: 0, 188 | y: 0, 189 | status: TILE_STATUSES.NUMBER, 190 | mine: false, 191 | adjacentMinesCount: 1, 192 | }, 193 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 194 | ], 195 | [ 196 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 197 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 198 | ], 199 | ] 200 | expect(revealTile(board, { x: 0, y: 0 })).toEqual(expectedBoard) 201 | }) 202 | 203 | test("when the tile is not adjacent to a mine it reveals nearby tiles", () => { 204 | const board = [ 205 | [ 206 | { x: 0, y: 0, status: TILE_STATUSES.HIDDEN, mine: true }, 207 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 208 | { x: 0, y: 2, status: TILE_STATUSES.HIDDEN, mine: false }, 209 | ], 210 | [ 211 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 212 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 213 | { x: 1, y: 2, status: TILE_STATUSES.HIDDEN, mine: false }, 214 | ], 215 | [ 216 | { x: 2, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 217 | { x: 2, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 218 | { x: 2, y: 2, status: TILE_STATUSES.HIDDEN, mine: false }, 219 | ], 220 | ] 221 | 222 | const expectedBoard = [ 223 | [ 224 | { x: 0, y: 0, status: TILE_STATUSES.HIDDEN, mine: true }, 225 | { 226 | x: 0, 227 | y: 1, 228 | status: TILE_STATUSES.NUMBER, 229 | mine: false, 230 | adjacentMinesCount: 1, 231 | }, 232 | { 233 | x: 0, 234 | y: 2, 235 | status: TILE_STATUSES.NUMBER, 236 | mine: false, 237 | adjacentMinesCount: 0, 238 | }, 239 | ], 240 | [ 241 | { 242 | x: 1, 243 | y: 0, 244 | status: TILE_STATUSES.NUMBER, 245 | mine: false, 246 | adjacentMinesCount: 1, 247 | }, 248 | { 249 | x: 1, 250 | y: 1, 251 | status: TILE_STATUSES.NUMBER, 252 | mine: false, 253 | adjacentMinesCount: 1, 254 | }, 255 | { 256 | x: 1, 257 | y: 2, 258 | status: TILE_STATUSES.NUMBER, 259 | mine: false, 260 | adjacentMinesCount: 0, 261 | }, 262 | ], 263 | [ 264 | { 265 | x: 2, 266 | y: 0, 267 | status: TILE_STATUSES.NUMBER, 268 | mine: false, 269 | adjacentMinesCount: 0, 270 | }, 271 | { 272 | x: 2, 273 | y: 1, 274 | status: TILE_STATUSES.NUMBER, 275 | mine: false, 276 | adjacentMinesCount: 0, 277 | }, 278 | { 279 | x: 2, 280 | y: 2, 281 | status: TILE_STATUSES.NUMBER, 282 | mine: false, 283 | adjacentMinesCount: 0, 284 | }, 285 | ], 286 | ] 287 | 288 | expect(revealTile(board, { x: 2, y: 2 })).toEqual(expectedBoard) 289 | }) 290 | }) 291 | }) 292 | 293 | test("with a marked tile it does nothing", () => { 294 | const board = [ 295 | [ 296 | { x: 0, y: 0, status: TILE_STATUSES.MARKED, mine: false }, 297 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 298 | ], 299 | [ 300 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 301 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 302 | ], 303 | ] 304 | expect(revealTile(board, { x: 0, y: 0 })).toEqual(board) 305 | }) 306 | 307 | test("with a mine tile it does nothing", () => { 308 | const board = [ 309 | [ 310 | { x: 0, y: 0, status: TILE_STATUSES.MINE, mine: false }, 311 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 312 | ], 313 | [ 314 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 315 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 316 | ], 317 | ] 318 | 319 | expect(revealTile(board, { x: 0, y: 0 })).toEqual(board) 320 | }) 321 | 322 | test("with a number tile it does nothing", () => { 323 | const board = [ 324 | [ 325 | { x: 0, y: 0, status: TILE_STATUSES.NUMBER, mine: false }, 326 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 327 | ], 328 | [ 329 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 330 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 331 | ], 332 | ] 333 | 334 | expect(revealTile(board, { x: 0, y: 0 })).toEqual(board) 335 | }) 336 | }) 337 | 338 | describe("#checkWin", () => { 339 | test("with only hidden and marked mine tiles it returns true", () => { 340 | const board = [ 341 | [ 342 | { x: 0, y: 0, status: TILE_STATUSES.MARKED, mine: true }, 343 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 344 | ], 345 | [ 346 | { x: 1, y: 0, status: TILE_STATUSES.NUMBER, mine: false }, 347 | { x: 1, y: 1, status: TILE_STATUSES.NUMBER, mine: false }, 348 | ], 349 | ] 350 | expect(checkWin(board)).toBeTruthy() 351 | }) 352 | 353 | test("with some hidden non-mine tiles it returns false", () => { 354 | const board = [ 355 | [ 356 | { x: 0, y: 0, status: TILE_STATUSES.MARKED, mine: true }, 357 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 358 | ], 359 | [ 360 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 361 | { x: 1, y: 1, status: TILE_STATUSES.NUMBER, mine: false }, 362 | ], 363 | ] 364 | expect(checkWin(board)).toBeFalsy() 365 | }) 366 | }) 367 | 368 | describe("#checkLose", () => { 369 | test("with no mines revealed it returns false", () => { 370 | const board = [ 371 | [ 372 | { x: 0, y: 0, status: TILE_STATUSES.MARKED, mine: true }, 373 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 374 | ], 375 | [ 376 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 377 | { x: 1, y: 1, status: TILE_STATUSES.HIDDEN, mine: false }, 378 | ], 379 | ] 380 | expect(checkLose(board)).toBeFalsy() 381 | }) 382 | 383 | test("with a mine revealed it returns true", () => { 384 | const board = [ 385 | [ 386 | { x: 0, y: 0, status: TILE_STATUSES.MINE, mine: true }, 387 | { x: 0, y: 1, status: TILE_STATUSES.HIDDEN, mine: true }, 388 | ], 389 | [ 390 | { x: 1, y: 0, status: TILE_STATUSES.HIDDEN, mine: false }, 391 | { x: 1, y: 1, status: TILE_STATUSES.NUMBER, mine: false }, 392 | ], 393 | ] 394 | expect(checkLose(board)).toBeTruthy() 395 | }) 396 | }) 397 | 398 | describe("#positionMatch", () => { 399 | test("it returns true when the x and y properties are the same", () => { 400 | const posA = { x: 1, y: 2 } 401 | const posB = { x: 1, y: 2 } 402 | expect(positionMatch(posA, posB)).toBeTruthy() 403 | }) 404 | 405 | test("it returns false when the x or y properties are not the same", () => { 406 | const posA = { x: 1, y: 2 } 407 | const posB = { x: 1, y: 1 } 408 | expect(positionMatch(posA, posB)).toBeFalsy() 409 | }) 410 | }) 411 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "current-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "minesweeper.js", 6 | "scripts": { 7 | "build": "parcel build index.html", 8 | "start": "parcel index.html", 9 | "test": "jest --coverage", 10 | "cy:open": "cypress open", 11 | "test:e2e": "start-server-and-test start http://localhost:1234 cy:open" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "lodash": "^4.17.21" 18 | }, 19 | "devDependencies": { 20 | "@babel/core": "^7.13.8", 21 | "@babel/plugin-proposal-optional-chaining": "^7.13.8", 22 | "@babel/preset-env": "^7.13.10", 23 | "cypress": "^6.6.0", 24 | "jest": "^26.6.3", 25 | "parcel-bundler": "1.12.3", 26 | "start-server-and-test": "^1.12.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/script.js: -------------------------------------------------------------------------------- 1 | // Display/UI 2 | 3 | import { 4 | TILE_STATUSES, 5 | createBoard, 6 | markTile, 7 | revealTile, 8 | checkWin, 9 | checkLose, 10 | positionMatch, 11 | markedTilesCount, 12 | } from "./minesweeper.js" 13 | 14 | let testBoard 15 | if (process.env.NODE_ENV !== "production" && window.testBoard) { 16 | testBoard = window.testBoard 17 | } 18 | 19 | const BOARD_SIZE = testBoard?.length ?? 10 20 | const NUMBER_OF_MINES = testBoard?.flat().filter(t => t.mine).length ?? 3 21 | 22 | let board = 23 | testBoard ?? 24 | createBoard(BOARD_SIZE, getMinePositions(BOARD_SIZE, NUMBER_OF_MINES)) 25 | const boardElement = document.querySelector(".board") 26 | const minesLeftText = document.querySelector("[data-mine-count]") 27 | const messageText = document.querySelector(".subtext") 28 | 29 | function render() { 30 | boardElement.innerHTML = "" 31 | checkGameEnd() 32 | 33 | getTileElements().forEach(element => { 34 | boardElement.append(element) 35 | }) 36 | 37 | listMinesLeft() 38 | } 39 | 40 | function getTileElements() { 41 | return board.flatMap(row => { 42 | return row.map(tileToElement) 43 | }) 44 | } 45 | 46 | function tileToElement(tile) { 47 | const element = document.createElement("div") 48 | element.dataset.status = tile.status 49 | element.dataset.x = tile.x 50 | element.dataset.y = tile.y 51 | element.textContent = tile.adjacentMinesCount || "" 52 | return element 53 | } 54 | 55 | boardElement.addEventListener("click", e => { 56 | if (!e.target.matches("[data-status]")) return 57 | 58 | board = revealTile(board, { 59 | x: parseInt(e.target.dataset.x), 60 | y: parseInt(e.target.dataset.y), 61 | }) 62 | render() 63 | }) 64 | 65 | boardElement.addEventListener("contextmenu", e => { 66 | if (!e.target.matches("[data-status]")) return 67 | 68 | e.preventDefault() 69 | board = markTile(board, { 70 | x: parseInt(e.target.dataset.x), 71 | y: parseInt(e.target.dataset.y), 72 | }) 73 | render() 74 | }) 75 | 76 | boardElement.style.setProperty("--size", BOARD_SIZE) 77 | render() 78 | 79 | function listMinesLeft() { 80 | minesLeftText.textContent = NUMBER_OF_MINES - markedTilesCount(board) 81 | } 82 | 83 | function checkGameEnd() { 84 | const win = checkWin(board) 85 | const lose = checkLose(board) 86 | 87 | if (win || lose) { 88 | boardElement.addEventListener("click", stopProp, { capture: true }) 89 | boardElement.addEventListener("contextmenu", stopProp, { capture: true }) 90 | } 91 | 92 | if (win) { 93 | messageText.textContent = "You Win" 94 | } 95 | if (lose) { 96 | messageText.textContent = "You Lose" 97 | board.forEach(row => { 98 | row.forEach(tile => { 99 | if (tile.status === TILE_STATUSES.MARKED) board = markTile(board, tile) 100 | if (tile.mine) board = revealTile(board, tile) 101 | }) 102 | }) 103 | } 104 | } 105 | 106 | function stopProp(e) { 107 | e.stopImmediatePropagation() 108 | } 109 | 110 | function getMinePositions(boardSize, numberOfMines) { 111 | const positions = [] 112 | 113 | while (positions.length < numberOfMines) { 114 | const position = { 115 | x: randomNumber(boardSize), 116 | y: randomNumber(boardSize), 117 | } 118 | 119 | if (!positions.some(positionMatch.bind(null, position))) { 120 | positions.push(position) 121 | } 122 | } 123 | 124 | return positions 125 | } 126 | 127 | function randomNumber(size) { 128 | return Math.floor(Math.random() * size) 129 | } 130 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/after/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | background-color: #333; 8 | display: flex; 9 | align-items: center; 10 | font-size: 3rem; 11 | flex-direction: column; 12 | color: white; 13 | } 14 | 15 | .title { 16 | margin: 20px; 17 | } 18 | 19 | .subtext { 20 | color: #CCC; 21 | font-size: 1.5rem; 22 | margin-bottom: 10px; 23 | } 24 | 25 | .board { 26 | display: inline-grid; 27 | padding: 10px; 28 | grid-template-columns: repeat(var(--size), 60px); 29 | grid-template-rows: repeat(var(--size), 60px); 30 | gap: 4px; 31 | background-color: #777; 32 | } 33 | 34 | .board > * { 35 | width: 100%; 36 | height: 100%; 37 | display: flex; 38 | justify-content: center; 39 | align-items: center; 40 | color: white; 41 | border: 2px solid #BBB; 42 | user-select: none; 43 | } 44 | 45 | .board > [data-status="hidden"] { 46 | background-color: #BBB; 47 | cursor: pointer; 48 | } 49 | 50 | .board > [data-status="mine"] { 51 | background-color: red; 52 | } 53 | 54 | .board > [data-status="number"] { 55 | background-color: none; 56 | } 57 | 58 | .board > [data-status="marked"] { 59 | background-color: yellow; 60 | } -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/before/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@babel/plugin-proposal-optional-chaining" 4 | ] 5 | } -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/before/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .cache -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/before/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Minesweeper 9 | 10 | 11 |

Minesweeper

12 |
13 | Mines Left: 14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/before/minesweeper.js: -------------------------------------------------------------------------------- 1 | import { times, range } from "lodash/fp" 2 | 3 | export const TILE_STATUSES = { 4 | HIDDEN: "hidden", 5 | MINE: "mine", 6 | NUMBER: "number", 7 | MARKED: "marked", 8 | } 9 | 10 | export function createBoard(boardSize, minePositions) { 11 | return times(x => { 12 | return times(y => { 13 | return { 14 | x, 15 | y, 16 | mine: minePositions.some(positionMatch.bind(null, { x, y })), 17 | status: TILE_STATUSES.HIDDEN, 18 | } 19 | }, boardSize) 20 | }, boardSize) 21 | } 22 | 23 | export function markedTilesCount(board) { 24 | return board.reduce((count, row) => { 25 | return ( 26 | count + row.filter(tile => tile.status === TILE_STATUSES.MARKED).length 27 | ) 28 | }, 0) 29 | } 30 | 31 | export function markTile(board, { x, y }) { 32 | const tile = board[x][y] 33 | if ( 34 | tile.status !== TILE_STATUSES.HIDDEN && 35 | tile.status !== TILE_STATUSES.MARKED 36 | ) { 37 | return board 38 | } 39 | 40 | if (tile.status === TILE_STATUSES.MARKED) { 41 | return replaceTile( 42 | board, 43 | { x, y }, 44 | { ...tile, status: TILE_STATUSES.HIDDEN } 45 | ) 46 | } else { 47 | return replaceTile( 48 | board, 49 | { x, y }, 50 | { ...tile, status: TILE_STATUSES.MARKED } 51 | ) 52 | } 53 | } 54 | 55 | function replaceTile(board, position, newTile) { 56 | return board.map((row, x) => { 57 | return row.map((tile, y) => { 58 | if (positionMatch(position, { x, y })) { 59 | return newTile 60 | } 61 | return tile 62 | }) 63 | }) 64 | } 65 | 66 | export function revealTile(board, { x, y }) { 67 | const tile = board[x][y] 68 | if (tile.status !== TILE_STATUSES.HIDDEN) { 69 | return board 70 | } 71 | 72 | if (tile.mine) { 73 | return replaceTile(board, { x, y }, { ...tile, status: TILE_STATUSES.MINE }) 74 | } 75 | 76 | const adjacentTiles = nearbyTiles(board, tile) 77 | const mines = adjacentTiles.filter(t => t.mine) 78 | const newBoard = replaceTile( 79 | board, 80 | { x, y }, 81 | { ...tile, status: TILE_STATUSES.NUMBER, adjacentMinesCount: mines.length } 82 | ) 83 | if (mines.length === 0) { 84 | return adjacentTiles.reduce((b, t) => { 85 | return revealTile(b, t) 86 | }, newBoard) 87 | } 88 | return newBoard 89 | } 90 | 91 | export function checkWin(board) { 92 | return board.every(row => { 93 | return row.every(tile => { 94 | return ( 95 | tile.status === TILE_STATUSES.NUMBER || 96 | (tile.mine && 97 | (tile.status === TILE_STATUSES.HIDDEN || 98 | tile.status === TILE_STATUSES.MARKED)) 99 | ) 100 | }) 101 | }) 102 | } 103 | 104 | export function checkLose(board) { 105 | return board.some(row => { 106 | return row.some(tile => { 107 | return tile.status === TILE_STATUSES.MINE 108 | }) 109 | }) 110 | } 111 | 112 | export function positionMatch(a, b) { 113 | return a.x === b.x && a.y === b.y 114 | } 115 | 116 | function nearbyTiles(board, { x, y }) { 117 | const offsets = range(-1, 2) 118 | 119 | return offsets 120 | .flatMap(xOffset => { 121 | return offsets.map(yOffset => { 122 | return board[x + xOffset]?.[y + yOffset] 123 | }) 124 | }) 125 | .filter(tile => tile != null) 126 | } 127 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/before/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "current-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "minesweeper.js", 6 | "scripts": { 7 | "build": "parcel build index.html", 8 | "start": "parcel index.html" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "lodash": "^4.17.21" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.13.8", 18 | "@babel/plugin-proposal-optional-chaining": "^7.13.8", 19 | "parcel-bundler": "1.12.3" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/before/script.js: -------------------------------------------------------------------------------- 1 | // Display/UI 2 | 3 | import { 4 | TILE_STATUSES, 5 | createBoard, 6 | markTile, 7 | revealTile, 8 | checkWin, 9 | checkLose, 10 | positionMatch, 11 | markedTilesCount, 12 | } from "./minesweeper.js" 13 | 14 | const BOARD_SIZE = 10 15 | const NUMBER_OF_MINES = 3 16 | 17 | let board = createBoard( 18 | BOARD_SIZE, 19 | getMinePositions(BOARD_SIZE, NUMBER_OF_MINES) 20 | ) 21 | const boardElement = document.querySelector(".board") 22 | const minesLeftText = document.querySelector("[data-mine-count]") 23 | const messageText = document.querySelector(".subtext") 24 | 25 | function render() { 26 | boardElement.innerHTML = "" 27 | checkGameEnd() 28 | 29 | getTileElements().forEach(element => { 30 | boardElement.append(element) 31 | }) 32 | 33 | listMinesLeft() 34 | } 35 | 36 | function getTileElements() { 37 | return board.flatMap(row => { 38 | return row.map(tileToElement) 39 | }) 40 | } 41 | 42 | function tileToElement(tile) { 43 | const element = document.createElement("div") 44 | element.dataset.status = tile.status 45 | element.dataset.x = tile.x 46 | element.dataset.y = tile.y 47 | element.textContent = tile.adjacentMinesCount || "" 48 | return element 49 | } 50 | 51 | boardElement.addEventListener("click", e => { 52 | if (!e.target.matches("[data-status]")) return 53 | 54 | board = revealTile(board, { 55 | x: parseInt(e.target.dataset.x), 56 | y: parseInt(e.target.dataset.y), 57 | }) 58 | render() 59 | }) 60 | 61 | boardElement.addEventListener("contextmenu", e => { 62 | if (!e.target.matches("[data-status]")) return 63 | 64 | e.preventDefault() 65 | board = markTile(board, { 66 | x: parseInt(e.target.dataset.x), 67 | y: parseInt(e.target.dataset.y), 68 | }) 69 | render() 70 | }) 71 | 72 | boardElement.style.setProperty("--size", BOARD_SIZE) 73 | render() 74 | 75 | function listMinesLeft() { 76 | minesLeftText.textContent = NUMBER_OF_MINES - markedTilesCount(board) 77 | } 78 | 79 | function checkGameEnd() { 80 | const win = checkWin(board) 81 | const lose = checkLose(board) 82 | 83 | if (win || lose) { 84 | boardElement.addEventListener("click", stopProp, { capture: true }) 85 | boardElement.addEventListener("contextmenu", stopProp, { capture: true }) 86 | } 87 | 88 | if (win) { 89 | messageText.textContent = "You Win" 90 | } 91 | if (lose) { 92 | messageText.textContent = "You Lose" 93 | board.forEach(row => { 94 | row.forEach(tile => { 95 | if (tile.status === TILE_STATUSES.MARKED) board = markTile(board, tile) 96 | if (tile.mine) board = revealTile(board, tile) 97 | }) 98 | }) 99 | } 100 | } 101 | 102 | function stopProp(e) { 103 | e.stopImmediatePropagation() 104 | } 105 | 106 | function getMinePositions(boardSize, numberOfMines) { 107 | const positions = [] 108 | 109 | while (positions.length < numberOfMines) { 110 | const position = { 111 | x: randomNumber(boardSize), 112 | y: randomNumber(boardSize), 113 | } 114 | 115 | if (!positions.some(positionMatch.bind(null, position))) { 116 | positions.push(position) 117 | } 118 | } 119 | 120 | return positions 121 | } 122 | 123 | function randomNumber(size) { 124 | return Math.floor(Math.random() * size) 125 | } 126 | -------------------------------------------------------------------------------- /47-48-minesweeper-fp-tests/before/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | background-color: #333; 8 | display: flex; 9 | align-items: center; 10 | font-size: 3rem; 11 | flex-direction: column; 12 | color: white; 13 | } 14 | 15 | .title { 16 | margin: 20px; 17 | } 18 | 19 | .subtext { 20 | color: #CCC; 21 | font-size: 1.5rem; 22 | margin-bottom: 10px; 23 | } 24 | 25 | .board { 26 | display: inline-grid; 27 | padding: 10px; 28 | grid-template-columns: repeat(var(--size), 60px); 29 | grid-template-rows: repeat(var(--size), 60px); 30 | gap: 4px; 31 | background-color: #777; 32 | } 33 | 34 | .board > * { 35 | width: 100%; 36 | height: 100%; 37 | display: flex; 38 | justify-content: center; 39 | align-items: center; 40 | color: white; 41 | border: 2px solid #BBB; 42 | user-select: none; 43 | } 44 | 45 | .board > [data-status="hidden"] { 46 | background-color: #BBB; 47 | cursor: pointer; 48 | } 49 | 50 | .board > [data-status="mine"] { 51 | background-color: red; 52 | } 53 | 54 | .board > [data-status="number"] { 55 | background-color: none; 56 | } 57 | 58 | .board > [data-status="marked"] { 59 | background-color: yellow; 60 | } -------------------------------------------------------------------------------- /55-weather-app/after/client/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .cache -------------------------------------------------------------------------------- /55-weather-app/after/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Weather App 10 | 11 | 12 |
13 |
14 | 15 |
16 |
21°
17 |
Sunny
18 |
19 |
20 |
21 |
22 |
High
23 |
23°
24 |
25 |
26 |
FL High
27 |
26°
28 |
29 |
30 |
Wind
31 |
12mph
32 |
33 |
34 |
LOW
35 |
12°
36 |
37 |
38 |
FL Low
39 |
13°
40 |
41 |
42 |
Precip
43 |
14%
44 |
45 |
46 |
47 |
48 |
49 | 50 |
Monday
51 |
23°
52 |
53 |
54 | 55 |
Tuesday
56 |
23°
57 |
58 |
59 | 60 |
Wednesday
61 |
23°
62 |
63 |
64 | 65 |
Thursday
66 |
23°
67 |
68 |
69 | 70 |
Friday
71 |
23°
72 |
73 |
74 | 75 |
Saturday
76 |
23°
77 |
78 |
79 | 80 |
Sunday
81 |
23°
82 |
83 |
84 | 85 | 86 | 87 | 93 | 96 | 102 | 108 | 114 | 120 | 121 | 122 | 128 | 131 | 137 | 143 | 149 | 155 | 156 | 157 | 163 | 166 | 172 | 178 | 184 | 190 | 191 | 192 | 198 | 201 | 207 | 213 | 219 | 225 | 226 | 227 | 233 | 236 | 242 | 248 | 254 | 260 | 261 | 262 | 268 | 271 | 277 | 283 | 289 | 295 | 296 | 297 | 303 | 306 | 312 | 318 | 324 | 330 | 331 | 332 | 338 | 341 | 347 | 353 | 359 | 365 | 366 | 367 |
88 |
89 |
Sunday
90 |
6AM
91 |
92 |
94 | 95 | 97 |
98 |
TEMP
99 |
22°
100 |
101 |
103 |
104 |
FL TEMP
105 |
24°
106 |
107 |
109 |
110 |
WIND
111 |
12mph
112 |
113 |
115 |
116 |
PRECIP
117 |
10%
118 |
119 |
123 |
124 |
Sunday
125 |
7AM
126 |
127 |
129 | 130 | 132 |
133 |
TEMP
134 |
22°
135 |
136 |
138 |
139 |
FL TEMP
140 |
24°
141 |
142 |
144 |
145 |
WIND
146 |
12mph
147 |
148 |
150 |
151 |
PRECIP
152 |
10%
153 |
154 |
158 |
159 |
Sunday
160 |
8AM
161 |
162 |
164 | 165 | 167 |
168 |
TEMP
169 |
22°
170 |
171 |
173 |
174 |
FL TEMP
175 |
24°
176 |
177 |
179 |
180 |
WIND
181 |
12mph
182 |
183 |
185 |
186 |
PRECIP
187 |
10%
188 |
189 |
193 |
194 |
Sunday
195 |
9AM
196 |
197 |
199 | 200 | 202 |
203 |
TEMP
204 |
22°
205 |
206 |
208 |
209 |
FL TEMP
210 |
24°
211 |
212 |
214 |
215 |
WIND
216 |
12mph
217 |
218 |
220 |
221 |
PRECIP
222 |
10%
223 |
224 |
228 |
229 |
Sunday
230 |
10AM
231 |
232 |
234 | 235 | 237 |
238 |
TEMP
239 |
22°
240 |
241 |
243 |
244 |
FL TEMP
245 |
24°
246 |
247 |
249 |
250 |
WIND
251 |
12mph
252 |
253 |
255 |
256 |
PRECIP
257 |
10%
258 |
259 |
263 |
264 |
Sunday
265 |
11AM
266 |
267 |
269 | 270 | 272 |
273 |
TEMP
274 |
22°
275 |
276 |
278 |
279 |
FL TEMP
280 |
24°
281 |
282 |
284 |
285 |
WIND
286 |
12mph
287 |
288 |
290 |
291 |
PRECIP
292 |
10%
293 |
294 |
298 |
299 |
Sunday
300 |
12PM
301 |
302 |
304 | 305 | 307 |
308 |
TEMP
309 |
22°
310 |
311 |
313 |
314 |
FL TEMP
315 |
24°
316 |
317 |
319 |
320 |
WIND
321 |
12mph
322 |
323 |
325 |
326 |
PRECIP
327 |
10%
328 |
329 |
333 |
334 |
Sunday
335 |
1PM
336 |
337 |
339 | 340 | 342 |
343 |
TEMP
344 |
22°
345 |
346 |
348 |
349 |
FL TEMP
350 |
24°
351 |
352 |
354 |
355 |
WIND
356 |
12mph
357 |
358 |
360 |
361 |
PRECIP
362 |
10%
363 |
364 |
368 | 369 | 370 | 377 | 378 | 415 | 416 | -------------------------------------------------------------------------------- /55-weather-app/after/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "parcel index.html" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "axios": "^0.21.1", 14 | "date-fns": "^2.19.0" 15 | }, 16 | "devDependencies": { 17 | "parcel-bundler": "1.12.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /55-weather-app/after/client/script.js: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import { format } from "date-fns" 3 | 4 | navigator.geolocation.getCurrentPosition(positionSuccess, positionError) 5 | 6 | function positionSuccess({ coords }) { 7 | getWeather(coords.latitude, coords.longitude) 8 | } 9 | 10 | function positionError() { 11 | alert( 12 | "There was an error getting your location. Please allow us to use your location and refresh the page." 13 | ) 14 | } 15 | 16 | function getWeather(lat, lon) { 17 | axios 18 | .get("http://localhost:3001/weather", { 19 | params: { lat, lon }, 20 | }) 21 | .then(res => { 22 | renderWeather(res.data) 23 | }) 24 | .catch(e => { 25 | console.log(e) 26 | alert("Error getting weather. Please try again.") 27 | }) 28 | } 29 | 30 | function renderWeather({ current, daily, hourly }) { 31 | document.body.classList.remove("blurred") 32 | renderCurrentWeather(current) 33 | renderDailyWeather(daily) 34 | renderHourlyWeather(hourly) 35 | } 36 | 37 | function setValue(selector, value, { parent = document } = {}) { 38 | parent.querySelector(`[data-${selector}]`).textContent = value 39 | } 40 | 41 | function getIconUrl(icon, { large = false } = {}) { 42 | const size = large ? "@2x" : "" 43 | return `http://openweathermap.org/img/wn/${icon}${size}.png` 44 | } 45 | 46 | function formatDay(timestamp) { 47 | return format(new Date(timestamp), "eeee") 48 | } 49 | 50 | function formatTime(timestamp) { 51 | return format(new Date(timestamp), "ha") 52 | } 53 | 54 | const currentIcon = document.querySelector("[data-current-icon]") 55 | function renderCurrentWeather(current) { 56 | currentIcon.src = getIconUrl(current.icon, { large: true }) 57 | setValue("current-temp", current.currentTemp) 58 | setValue("current-high", current.highTemp) 59 | setValue("current-low", current.lowTemp) 60 | setValue("current-fl-high", current.highFeelsLike) 61 | setValue("current-fl-low", current.lowFeelsLike) 62 | setValue("current-wind", current.windSpeed) 63 | setValue("current-precip", current.precip) 64 | setValue("current-description", current.description) 65 | } 66 | 67 | const dailySection = document.querySelector("[data-day-section]") 68 | const dayCardTemplate = document.getElementById("day-card-template") 69 | function renderDailyWeather(daily) { 70 | dailySection.innerHTML = "" 71 | daily.forEach(day => { 72 | const element = dayCardTemplate.content.cloneNode(true) 73 | setValue("temp", day.temp, { parent: element }) 74 | setValue("date", formatDay(day.timestamp), { parent: element }) 75 | element.querySelector("[data-icon]").src = getIconUrl(day.icon) 76 | dailySection.append(element) 77 | }) 78 | } 79 | 80 | const hourlySection = document.querySelector("[data-hour-section]") 81 | const hourRowTemplate = document.getElementById("hour-row-template") 82 | function renderHourlyWeather(hourly) { 83 | hourlySection.innerHTML = "" 84 | hourly.forEach(hour => { 85 | const element = hourRowTemplate.content.cloneNode(true) 86 | setValue("temp", hour.temp, { parent: element }) 87 | setValue("fl-temp", hour.feelsLike, { parent: element }) 88 | setValue("wind", hour.windSpeed, { parent: element }) 89 | setValue("precip", hour.precip, { parent: element }) 90 | setValue("day", formatDay(hour.timestamp), { parent: element }) 91 | setValue("time", formatTime(hour.timestamp), { parent: element }) 92 | element.querySelector("[data-icon]").src = getIconUrl(hour.icon) 93 | hourlySection.append(element) 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /55-weather-app/after/client/styles.css: -------------------------------------------------------------------------------- 1 | *, *::after, *::before { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | background-color: hsl(200, 100%, 85%); 8 | color: hsl(200, 100%, 10%); 9 | font-family: sans-serif; 10 | } 11 | 12 | .blurred { 13 | filter: blur(3px); 14 | } 15 | 16 | .header { 17 | display: flex; 18 | align-items: center; 19 | } 20 | 21 | .header-current-temp { 22 | font-size: 2rem; 23 | } 24 | 25 | .header-current-description { 26 | text-transform: capitalize; 27 | } 28 | 29 | .header-left-details { 30 | margin-left: 1rem; 31 | } 32 | 33 | .header-left { 34 | display: flex; 35 | align-items: center; 36 | justify-content: center; 37 | width: 50%; 38 | margin: .5rem; 39 | padding: .5rem; 40 | border-right: 2px solid hsl(200, 100%, 10%); 41 | } 42 | 43 | .header-right { 44 | display: grid; 45 | width: 50%; 46 | justify-content: space-around; 47 | gap: .5rem; 48 | grid-template-columns: repeat(3, auto); 49 | grid-template-rows: repeat(2, auto); 50 | } 51 | 52 | .weather-icon { 53 | width: 40px; 54 | height: 40px; 55 | object-fit: none; 56 | } 57 | 58 | .weather-icon.large { 59 | width: 80px; 60 | height: 80px; 61 | object-fit: none; 62 | } 63 | 64 | .info-group { 65 | display: flex; 66 | flex-direction: column; 67 | align-items: center; 68 | } 69 | 70 | .label { 71 | text-transform: uppercase; 72 | font-weight: bold; 73 | font-size: .6rem; 74 | color: hsl(200, 100%, 20%) 75 | } 76 | 77 | .value-sub-info { 78 | font-weight: lighter; 79 | font-size: .75rem; 80 | } 81 | 82 | .day-section { 83 | display: grid; 84 | grid-template-columns: repeat(auto-fit, 75px); 85 | gap: .5rem; 86 | justify-content: center; 87 | flex-wrap: wrap; 88 | padding: 1rem; 89 | } 90 | 91 | .day-card-date { 92 | font-size: .75rem; 93 | color: hsl(200, 100%, 20%) 94 | } 95 | 96 | .day-card { 97 | display: flex; 98 | flex-direction: column; 99 | align-items: center; 100 | border: 1px solid hsl(200, 100%, 10%); 101 | border-radius: .25rem; 102 | padding: .25rem; 103 | } 104 | 105 | .hour-section { 106 | width: 100%; 107 | text-align: center; 108 | border-spacing: 0; 109 | } 110 | 111 | .hour-row { 112 | background-color: hsl(200, 60%, 75%); 113 | } 114 | 115 | .hour-row:nth-child(2n) { 116 | background-color: hsl(200, 60%, 70%); 117 | } 118 | 119 | .hour-row > td { 120 | padding: .25rem .5rem; 121 | } -------------------------------------------------------------------------------- /55-weather-app/after/server/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules -------------------------------------------------------------------------------- /55-weather-app/after/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "devStart": "nodemon server.js", 8 | "start": "node server.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "axios": "^0.21.1", 15 | "cors": "^2.8.5", 16 | "dotenv": "^8.2.0", 17 | "express": "^4.17.1" 18 | }, 19 | "devDependencies": { 20 | "nodemon": "^2.0.7" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /55-weather-app/after/server/server.js: -------------------------------------------------------------------------------- 1 | const express = require("express") 2 | const cors = require("cors") 3 | const axios = require("axios") 4 | require("dotenv").config() 5 | const app = express() 6 | app.use(cors()) 7 | app.use(express.urlencoded({ extended: true })) 8 | 9 | app.get("/weather", (req, res) => { 10 | const { lat, lon } = req.query 11 | axios 12 | .get("https://api.openweathermap.org/data/2.5/onecall", { 13 | params: { 14 | lat, 15 | lon, 16 | appid: process.env.API_KEY, 17 | units: "imperial", 18 | exclude: "minutely,alerts", 19 | }, 20 | }) 21 | .then(({ data }) => { 22 | res.json({ 23 | current: parseCurrentWeather(data), 24 | daily: parseDailyWeather(data), 25 | hourly: parseHourlyWeather(data), 26 | }) 27 | }) 28 | .catch(e => { 29 | console.log(e) 30 | res.sendStatus(500) 31 | }) 32 | }) 33 | 34 | function parseCurrentWeather({ current, daily }) { 35 | const { temp: currentTemp, weather, wind_speed } = current 36 | const { pop, temp, feels_like } = daily[0] 37 | 38 | return { 39 | currentTemp: Math.round(currentTemp), 40 | highTemp: Math.round(temp.max), 41 | lowTemp: Math.round(temp.min), 42 | highFeelsLike: Math.round(Math.max(...Object.values(feels_like))), 43 | lowFeelsLike: Math.round(Math.min(...Object.values(feels_like))), 44 | windSpeed: Math.round(wind_speed), 45 | precip: Math.round(pop * 100), 46 | icon: weather[0].icon, 47 | description: weather[0].description, 48 | } 49 | } 50 | 51 | function parseDailyWeather({ daily }) { 52 | return daily.slice(1).map(day => { 53 | return { 54 | timestamp: day.dt * 1000, 55 | icon: day.weather[0].icon, 56 | temp: Math.round(day.temp.day), 57 | } 58 | }) 59 | } 60 | 61 | const HOUR_IN_SECONDS = 3600 62 | function parseHourlyWeather({ hourly, current }) { 63 | return hourly 64 | .filter(hour => hour.dt > current.dt - HOUR_IN_SECONDS) 65 | .map(hour => { 66 | return { 67 | timestamp: hour.dt * 1000, 68 | icon: hour.weather[0].icon, 69 | temp: Math.round(hour.temp), 70 | feelsLike: Math.round(hour.feels_like), 71 | windSpeed: Math.round(hour.wind_speed), 72 | precip: Math.round(hour.pop * 100), 73 | } 74 | }) 75 | } 76 | 77 | app.listen(3001) 78 | -------------------------------------------------------------------------------- /55-weather-app/before/example.json: -------------------------------------------------------------------------------- 1 | {"lat":51.5074,"lon":-0.1278,"timezone":"Europe/London","timezone_offset":0,"current":{"dt":1615821604,"sunrise":1615788868,"sunset":1615831467,"temp":50.25,"feels_like":44.1,"pressure":1022,"humidity":76,"dew_point":43,"uvi":0.85,"clouds":90,"visibility":10000,"wind_speed":8.05,"wind_deg":300,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}]},"hourly":[{"dt":1615820400,"temp":50.25,"feels_like":41.99,"pressure":1022,"humidity":76,"dew_point":43,"uvi":0.85,"clouds":90,"visibility":10000,"wind_speed":11.79,"wind_deg":319,"wind_gust":18.19,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0.38},{"dt":1615824000,"temp":51.49,"feels_like":43.18,"pressure":1022,"humidity":65,"dew_point":40.14,"uvi":0.32,"clouds":77,"visibility":10000,"wind_speed":10.87,"wind_deg":329,"wind_gust":18.05,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"pop":0.38},{"dt":1615827600,"temp":51.66,"feels_like":43.92,"pressure":1022,"humidity":61,"dew_point":38.66,"uvi":0.1,"clouds":68,"visibility":10000,"wind_speed":9.35,"wind_deg":338,"wind_gust":16.44,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"pop":0.38},{"dt":1615831200,"temp":50.38,"feels_like":44.13,"pressure":1023,"humidity":63,"dew_point":38.28,"uvi":0,"clouds":61,"visibility":10000,"wind_speed":6.58,"wind_deg":348,"wind_gust":13.24,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"pop":0.38},{"dt":1615834800,"temp":50.16,"feels_like":44.37,"pressure":1024,"humidity":63,"dew_point":38.07,"uvi":0,"clouds":100,"visibility":10000,"wind_speed":5.7,"wind_deg":351,"wind_gust":11.56,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0},{"dt":1615838400,"temp":48.99,"feels_like":43.88,"pressure":1025,"humidity":64,"dew_point":37.67,"uvi":0,"clouds":100,"visibility":10000,"wind_speed":4.27,"wind_deg":347,"wind_gust":9.55,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0},{"dt":1615842000,"temp":47.95,"feels_like":42.85,"pressure":1026,"humidity":67,"dew_point":37.6,"uvi":0,"clouds":98,"visibility":10000,"wind_speed":4.27,"wind_deg":336,"wind_gust":8.59,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0},{"dt":1615845600,"temp":47.39,"feels_like":42.48,"pressure":1026,"humidity":69,"dew_point":37.94,"uvi":0,"clouds":98,"visibility":10000,"wind_speed":4.03,"wind_deg":333,"wind_gust":7.99,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0},{"dt":1615849200,"temp":46.9,"feels_like":42.08,"pressure":1026,"humidity":71,"dew_point":38.1,"uvi":0,"clouds":98,"visibility":10000,"wind_speed":3.96,"wind_deg":331,"wind_gust":7.7,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0},{"dt":1615852800,"temp":46.06,"feels_like":41.18,"pressure":1026,"humidity":74,"dew_point":38.28,"uvi":0,"clouds":98,"visibility":10000,"wind_speed":4.12,"wind_deg":317,"wind_gust":8.48,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0},{"dt":1615856400,"temp":45.54,"feels_like":40.39,"pressure":1026,"humidity":76,"dew_point":38.35,"uvi":0,"clouds":93,"visibility":10000,"wind_speed":4.65,"wind_deg":308,"wind_gust":10.02,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0},{"dt":1615860000,"temp":44.98,"feels_like":39.88,"pressure":1027,"humidity":76,"dew_point":38.07,"uvi":0,"clouds":93,"visibility":10000,"wind_speed":4.38,"wind_deg":307,"wind_gust":10.02,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0},{"dt":1615863600,"temp":44.87,"feels_like":39.67,"pressure":1026,"humidity":76,"dew_point":37.72,"uvi":0,"clouds":95,"visibility":10000,"wind_speed":4.54,"wind_deg":295,"wind_gust":10.07,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0},{"dt":1615867200,"temp":45.19,"feels_like":39.54,"pressure":1026,"humidity":75,"dew_point":37.72,"uvi":0,"clouds":96,"visibility":10000,"wind_speed":5.35,"wind_deg":298,"wind_gust":11.48,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0.08},{"dt":1615870800,"temp":44.92,"feels_like":39.51,"pressure":1026,"humidity":79,"dew_point":38.82,"uvi":0,"clouds":97,"visibility":10000,"wind_speed":5.28,"wind_deg":285,"wind_gust":10.87,"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"pop":0.32,"rain":{"1h":0.19}},{"dt":1615874400,"temp":44.73,"feels_like":39.85,"pressure":1026,"humidity":83,"dew_point":40.05,"uvi":0,"clouds":97,"visibility":10000,"wind_speed":4.68,"wind_deg":276,"wind_gust":9.31,"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"pop":0.33,"rain":{"1h":0.28}},{"dt":1615878000,"temp":44.82,"feels_like":40.01,"pressure":1027,"humidity":84,"dew_point":40.26,"uvi":0.02,"clouds":100,"visibility":10000,"wind_speed":4.7,"wind_deg":263,"wind_gust":9.66,"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"pop":0.9,"rain":{"1h":0.28}},{"dt":1615881600,"temp":45.23,"feels_like":40.28,"pressure":1027,"humidity":83,"dew_point":40.5,"uvi":0.07,"clouds":100,"visibility":10000,"wind_speed":4.97,"wind_deg":265,"wind_gust":11.03,"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"pop":0.84,"rain":{"1h":0.13}},{"dt":1615885200,"temp":45.99,"feels_like":40.84,"pressure":1027,"humidity":83,"dew_point":41.16,"uvi":0.16,"clouds":100,"visibility":10000,"wind_speed":5.59,"wind_deg":268,"wind_gust":15.41,"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"pop":0.66,"rain":{"1h":0.12}},{"dt":1615888800,"temp":47.3,"feels_like":43.34,"pressure":1027,"humidity":87,"dew_point":43.65,"uvi":0.53,"clouds":100,"visibility":10000,"wind_speed":4.43,"wind_deg":281,"wind_gust":19.3,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0.66},{"dt":1615892400,"temp":54.21,"feels_like":47.68,"pressure":1027,"humidity":68,"dew_point":43.74,"uvi":0.7,"clouds":95,"visibility":10000,"wind_speed":9.08,"wind_deg":321,"wind_gust":17.43,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0.58},{"dt":1615896000,"temp":55.65,"feels_like":48.16,"pressure":1027,"humidity":63,"dew_point":43.29,"uvi":0.77,"clouds":95,"visibility":10000,"wind_speed":10.54,"wind_deg":324,"wind_gust":16.98,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0.58},{"dt":1615899600,"temp":54.07,"feels_like":46.71,"pressure":1027,"humidity":70,"dew_point":44.38,"uvi":0.79,"clouds":99,"visibility":10000,"wind_speed":10.8,"wind_deg":325,"wind_gust":18.37,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0.14},{"dt":1615903200,"temp":53.15,"feels_like":46.45,"pressure":1027,"humidity":74,"dew_point":45.01,"uvi":0.61,"clouds":100,"visibility":10000,"wind_speed":9.84,"wind_deg":322,"wind_gust":17.72,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0.06},{"dt":1615906800,"temp":54.68,"feels_like":47.79,"pressure":1027,"humidity":70,"dew_point":45.25,"uvi":0.38,"clouds":88,"visibility":10000,"wind_speed":10.2,"wind_deg":332,"wind_gust":17.11,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0.02},{"dt":1615910400,"temp":54.52,"feels_like":48.56,"pressure":1027,"humidity":72,"dew_point":45.77,"uvi":0.5,"clouds":90,"visibility":10000,"wind_speed":8.79,"wind_deg":334,"wind_gust":17.43,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0},{"dt":1615914000,"temp":53.89,"feels_like":49.68,"pressure":1028,"humidity":77,"dew_point":47.05,"uvi":0.15,"clouds":92,"visibility":10000,"wind_speed":6.2,"wind_deg":333,"wind_gust":17.29,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0},{"dt":1615917600,"temp":53.19,"feels_like":49.53,"pressure":1029,"humidity":82,"dew_point":47.86,"uvi":0,"clouds":93,"visibility":10000,"wind_speed":5.61,"wind_deg":343,"wind_gust":18.61,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0},{"dt":1615921200,"temp":52.59,"feels_like":49.17,"pressure":1030,"humidity":84,"dew_point":48.11,"uvi":0,"clouds":100,"visibility":10000,"wind_speed":5.23,"wind_deg":342,"wind_gust":19.01,"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"pop":0.4,"rain":{"1h":0.16}},{"dt":1615924800,"temp":51.73,"feels_like":46.17,"pressure":1031,"humidity":85,"dew_point":47.41,"uvi":0,"clouds":99,"visibility":10000,"wind_speed":8.81,"wind_deg":2,"wind_gust":20.33,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0.24},{"dt":1615928400,"temp":48.92,"feels_like":40.24,"pressure":1031,"humidity":73,"dew_point":40.73,"uvi":0,"clouds":98,"visibility":10000,"wind_speed":11.7,"wind_deg":11,"wind_gust":23.8,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"pop":0.12},{"dt":1615932000,"temp":46.87,"feels_like":38.25,"pressure":1032,"humidity":68,"dew_point":36.88,"uvi":0,"clouds":73,"visibility":10000,"wind_speed":10.36,"wind_deg":9,"wind_gust":26.91,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04n"}],"pop":0},{"dt":1615935600,"temp":45.66,"feels_like":37.22,"pressure":1033,"humidity":66,"dew_point":35.11,"uvi":0,"clouds":58,"visibility":10000,"wind_speed":9.44,"wind_deg":6,"wind_gust":26.37,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04n"}],"pop":0},{"dt":1615939200,"temp":45.21,"feels_like":36.75,"pressure":1033,"humidity":65,"dew_point":34.29,"uvi":0,"clouds":49,"visibility":10000,"wind_speed":9.26,"wind_deg":2,"wind_gust":25.88,"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03n"}],"pop":0},{"dt":1615942800,"temp":44.74,"feels_like":36.3,"pressure":1033,"humidity":66,"dew_point":34.25,"uvi":0,"clouds":0,"visibility":10000,"wind_speed":9.22,"wind_deg":360,"wind_gust":25.19,"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"pop":0},{"dt":1615946400,"temp":44.33,"feels_like":36.32,"pressure":1033,"humidity":69,"dew_point":34.83,"uvi":0,"clouds":8,"visibility":10000,"wind_speed":8.66,"wind_deg":355,"wind_gust":23.85,"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"pop":0},{"dt":1615950000,"temp":44.1,"feels_like":36.28,"pressure":1033,"humidity":69,"dew_point":34.74,"uvi":0,"clouds":39,"visibility":10000,"wind_speed":8.23,"wind_deg":348,"wind_gust":22.44,"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03n"}],"pop":0},{"dt":1615953600,"temp":44.08,"feels_like":36.61,"pressure":1033,"humidity":69,"dew_point":34.77,"uvi":0,"clouds":54,"visibility":10000,"wind_speed":7.63,"wind_deg":341,"wind_gust":21.27,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04n"}],"pop":0},{"dt":1615957200,"temp":43.38,"feels_like":36.43,"pressure":1033,"humidity":72,"dew_point":34.92,"uvi":0,"clouds":63,"visibility":10000,"wind_speed":6.8,"wind_deg":335,"wind_gust":20.11,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04n"}],"pop":0},{"dt":1615960800,"temp":43.05,"feels_like":36.32,"pressure":1032,"humidity":73,"dew_point":34.95,"uvi":0,"clouds":69,"visibility":10000,"wind_speed":6.44,"wind_deg":325,"wind_gust":16.62,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04n"}],"pop":0},{"dt":1615964400,"temp":44.11,"feels_like":36.18,"pressure":1033,"humidity":70,"dew_point":35.01,"uvi":0.15,"clouds":99,"visibility":10000,"wind_speed":8.57,"wind_deg":332,"wind_gust":19.55,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0},{"dt":1615968000,"temp":45.43,"feels_like":36.48,"pressure":1033,"humidity":69,"dew_point":35.8,"uvi":0.48,"clouds":100,"visibility":10000,"wind_speed":10.63,"wind_deg":346,"wind_gust":23.6,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0},{"dt":1615971600,"temp":47.3,"feels_like":36.75,"pressure":1033,"humidity":59,"dew_point":33.73,"uvi":1.04,"clouds":99,"visibility":10000,"wind_speed":12.84,"wind_deg":356,"wind_gust":26.6,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0},{"dt":1615975200,"temp":48.92,"feels_like":37.65,"pressure":1034,"humidity":55,"dew_point":33.66,"uvi":1.83,"clouds":81,"visibility":10000,"wind_speed":14.05,"wind_deg":2,"wind_gust":26.64,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"pop":0},{"dt":1615978800,"temp":50.18,"feels_like":39.2,"pressure":1033,"humidity":55,"dew_point":34.59,"uvi":2.4,"clouds":66,"visibility":10000,"wind_speed":13.87,"wind_deg":4,"wind_gust":23.55,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"pop":0},{"dt":1615982400,"temp":50.4,"feels_like":39.43,"pressure":1033,"humidity":54,"dew_point":34.57,"uvi":2.64,"clouds":68,"visibility":10000,"wind_speed":13.78,"wind_deg":4,"wind_gust":23.38,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"pop":0},{"dt":1615986000,"temp":50.88,"feels_like":39.87,"pressure":1033,"humidity":52,"dew_point":33.91,"uvi":2.16,"clouds":90,"visibility":10000,"wind_speed":13.71,"wind_deg":5,"wind_gust":22.44,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0},{"dt":1615989600,"temp":50.67,"feels_like":39.85,"pressure":1033,"humidity":50,"dew_point":32.97,"uvi":1.67,"clouds":94,"visibility":10000,"wind_speed":13.06,"wind_deg":5,"wind_gust":21.09,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"pop":0}],"daily":[{"dt":1615809600,"sunrise":1615788868,"sunset":1615831467,"temp":{"day":51.44,"min":44.87,"max":52.61,"night":46.9,"eve":50.38,"morn":45.32},"feels_like":{"day":41.76,"night":42.08,"eve":44.13,"morn":37.69},"pressure":1020,"humidity":60,"dew_point":38.12,"wind_speed":12.59,"wind_deg":319,"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":76,"pop":0.58,"rain":0.45,"uvi":2.44},{"dt":1615896000,"sunrise":1615875131,"sunset":1615917969,"temp":{"day":55.65,"min":44.73,"max":55.65,"night":45.66,"eve":53.19,"morn":44.73},"feels_like":{"day":48.16,"night":37.22,"eve":49.53,"morn":39.85},"pressure":1027,"humidity":63,"dew_point":43.29,"wind_speed":10.54,"wind_deg":324,"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":95,"pop":0.9,"rain":1.16,"uvi":0.79},{"dt":1615982400,"sunrise":1615961393,"sunset":1616004470,"temp":{"day":50.4,"min":43.05,"max":50.88,"night":43.63,"eve":47.88,"morn":43.05},"feels_like":{"day":39.43,"night":36.79,"eve":38.12,"morn":36.32},"pressure":1033,"humidity":54,"dew_point":34.57,"wind_speed":13.78,"wind_deg":4,"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"clouds":68,"pop":0,"uvi":2.64},{"dt":1616068800,"sunrise":1616047656,"sunset":1616090971,"temp":{"day":48.67,"min":39.51,"max":48.67,"night":39.51,"eve":43.43,"morn":43.59},"feels_like":{"day":36.27,"night":28.62,"eve":31.86,"morn":34.3},"pressure":1026,"humidity":59,"dew_point":34.93,"wind_speed":16.49,"wind_deg":11,"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":100,"pop":0.04,"uvi":1.49},{"dt":1616155200,"sunrise":1616133918,"sunset":1616177472,"temp":{"day":45.28,"min":36.52,"max":46.67,"night":40.44,"eve":44.28,"morn":36.52},"feels_like":{"day":36,"night":32.56,"eve":35.47,"morn":27.68},"pressure":1030,"humidity":54,"dew_point":29.71,"wind_speed":9.55,"wind_deg":56,"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"clouds":27,"pop":0,"uvi":1.93},{"dt":1616241600,"sunrise":1616220180,"sunset":1616263973,"temp":{"day":48.36,"min":36.18,"max":48.36,"night":44.67,"eve":48.11,"morn":36.18},"feels_like":{"day":41.86,"night":40.98,"eve":43.75,"morn":30.67},"pressure":1033,"humidity":51,"dew_point":31.06,"wind_speed":4.97,"wind_deg":354,"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02d"}],"clouds":18,"pop":0,"uvi":2},{"dt":1616328000,"sunrise":1616306442,"sunset":1616350474,"temp":{"day":51.71,"min":41.29,"max":53.17,"night":49.78,"eve":51.37,"morn":41.29},"feels_like":{"day":43.95,"night":44.15,"eve":46.2,"morn":35.92},"pressure":1029,"humidity":61,"dew_point":38.64,"wind_speed":9.4,"wind_deg":317,"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"clouds":42,"pop":0,"uvi":2},{"dt":1616414400,"sunrise":1616392704,"sunset":1616436974,"temp":{"day":54.07,"min":41.63,"max":57.24,"night":51.85,"eve":53.28,"morn":41.63},"feels_like":{"day":48.13,"night":46.87,"eve":47.62,"morn":37.08},"pressure":1027,"humidity":46,"dew_point":33.8,"wind_speed":4.68,"wind_deg":312,"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"clouds":30,"pop":0,"uvi":2}]} -------------------------------------------------------------------------------- /55-weather-app/before/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Weather App 9 | 10 | 11 |
12 |
13 | 14 |
15 |
21°
16 |
Sunny
17 |
18 |
19 |
20 |
21 |
High
22 |
23°
23 |
24 |
25 |
FL High
26 |
26°
27 |
28 |
29 |
Wind
30 |
12mph
31 |
32 |
33 |
LOW
34 |
12°
35 |
36 |
37 |
FL Low
38 |
13°
39 |
40 |
41 |
Precip
42 |
14%
43 |
44 |
45 |
46 |
47 |
48 | 49 |
Monday
50 |
23°
51 |
52 |
53 | 54 |
Tuesday
55 |
23°
56 |
57 |
58 | 59 |
Wednesday
60 |
23°
61 |
62 |
63 | 64 |
Thursday
65 |
23°
66 |
67 |
68 | 69 |
Friday
70 |
23°
71 |
72 |
73 | 74 |
Saturday
75 |
23°
76 |
77 |
78 | 79 |
Sunday
80 |
23°
81 |
82 |
83 | 84 | 85 | 86 | 92 | 95 | 101 | 107 | 113 | 119 | 120 | 121 | 127 | 130 | 136 | 142 | 148 | 154 | 155 | 156 | 162 | 165 | 171 | 177 | 183 | 189 | 190 | 191 | 197 | 200 | 206 | 212 | 218 | 224 | 225 | 226 | 232 | 235 | 241 | 247 | 253 | 259 | 260 | 261 | 267 | 270 | 276 | 282 | 288 | 294 | 295 | 296 | 302 | 305 | 311 | 317 | 323 | 329 | 330 | 331 | 337 | 340 | 346 | 352 | 358 | 364 | 365 | 366 |
87 |
88 |
Sunday
89 |
6AM
90 |
91 |
93 | 94 | 96 |
97 |
TEMP
98 |
22°
99 |
100 |
102 |
103 |
FL TEMP
104 |
24°
105 |
106 |
108 |
109 |
WIND
110 |
12mph
111 |
112 |
114 |
115 |
PRECIP
116 |
10%
117 |
118 |
122 |
123 |
Sunday
124 |
7AM
125 |
126 |
128 | 129 | 131 |
132 |
TEMP
133 |
22°
134 |
135 |
137 |
138 |
FL TEMP
139 |
24°
140 |
141 |
143 |
144 |
WIND
145 |
12mph
146 |
147 |
149 |
150 |
PRECIP
151 |
10%
152 |
153 |
157 |
158 |
Sunday
159 |
8AM
160 |
161 |
163 | 164 | 166 |
167 |
TEMP
168 |
22°
169 |
170 |
172 |
173 |
FL TEMP
174 |
24°
175 |
176 |
178 |
179 |
WIND
180 |
12mph
181 |
182 |
184 |
185 |
PRECIP
186 |
10%
187 |
188 |
192 |
193 |
Sunday
194 |
9AM
195 |
196 |
198 | 199 | 201 |
202 |
TEMP
203 |
22°
204 |
205 |
207 |
208 |
FL TEMP
209 |
24°
210 |
211 |
213 |
214 |
WIND
215 |
12mph
216 |
217 |
219 |
220 |
PRECIP
221 |
10%
222 |
223 |
227 |
228 |
Sunday
229 |
10AM
230 |
231 |
233 | 234 | 236 |
237 |
TEMP
238 |
22°
239 |
240 |
242 |
243 |
FL TEMP
244 |
24°
245 |
246 |
248 |
249 |
WIND
250 |
12mph
251 |
252 |
254 |
255 |
PRECIP
256 |
10%
257 |
258 |
262 |
263 |
Sunday
264 |
11AM
265 |
266 |
268 | 269 | 271 |
272 |
TEMP
273 |
22°
274 |
275 |
277 |
278 |
FL TEMP
279 |
24°
280 |
281 |
283 |
284 |
WIND
285 |
12mph
286 |
287 |
289 |
290 |
PRECIP
291 |
10%
292 |
293 |
297 |
298 |
Sunday
299 |
12PM
300 |
301 |
303 | 304 | 306 |
307 |
TEMP
308 |
22°
309 |
310 |
312 |
313 |
FL TEMP
314 |
24°
315 |
316 |
318 |
319 |
WIND
320 |
12mph
321 |
322 |
324 |
325 |
PRECIP
326 |
10%
327 |
328 |
332 |
333 |
Sunday
334 |
1PM
335 |
336 |
338 | 339 | 341 |
342 |
TEMP
343 |
22°
344 |
345 |
347 |
348 |
FL TEMP
349 |
24°
350 |
351 |
353 |
354 |
WIND
355 |
12mph
356 |
357 |
359 |
360 |
PRECIP
361 |
10%
362 |
363 |
367 | 368 | -------------------------------------------------------------------------------- /55-weather-app/before/styles.css: -------------------------------------------------------------------------------- 1 | *, *::after, *::before { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | background-color: hsl(200, 100%, 85%); 8 | color: hsl(200, 100%, 10%); 9 | font-family: sans-serif; 10 | } 11 | 12 | .blurred { 13 | filter: blur(3px); 14 | } 15 | 16 | .header { 17 | display: flex; 18 | align-items: center; 19 | } 20 | 21 | .header-current-temp { 22 | font-size: 2rem; 23 | } 24 | 25 | .header-current-description { 26 | text-transform: capitalize; 27 | } 28 | 29 | .header-left-details { 30 | margin-left: 1rem; 31 | } 32 | 33 | .header-left { 34 | display: flex; 35 | align-items: center; 36 | justify-content: center; 37 | width: 50%; 38 | margin: .5rem; 39 | padding: .5rem; 40 | border-right: 2px solid hsl(200, 100%, 10%); 41 | } 42 | 43 | .header-right { 44 | display: grid; 45 | width: 50%; 46 | justify-content: space-around; 47 | gap: .5rem; 48 | grid-template-columns: repeat(3, auto); 49 | grid-template-rows: repeat(2, auto); 50 | } 51 | 52 | .weather-icon { 53 | width: 40px; 54 | height: 40px; 55 | object-fit: none; 56 | } 57 | 58 | .weather-icon.large { 59 | width: 80px; 60 | height: 80px; 61 | object-fit: none; 62 | } 63 | 64 | .info-group { 65 | display: flex; 66 | flex-direction: column; 67 | align-items: center; 68 | } 69 | 70 | .label { 71 | text-transform: uppercase; 72 | font-weight: bold; 73 | font-size: .6rem; 74 | color: hsl(200, 100%, 20%) 75 | } 76 | 77 | .value-sub-info { 78 | font-weight: lighter; 79 | font-size: .75rem; 80 | } 81 | 82 | .day-section { 83 | display: grid; 84 | grid-template-columns: repeat(auto-fit, 75px); 85 | gap: .5rem; 86 | justify-content: center; 87 | flex-wrap: wrap; 88 | padding: 1rem; 89 | } 90 | 91 | .day-card-date { 92 | font-size: .75rem; 93 | color: hsl(200, 100%, 20%) 94 | } 95 | 96 | .day-card { 97 | display: flex; 98 | flex-direction: column; 99 | align-items: center; 100 | border: 1px solid hsl(200, 100%, 10%); 101 | border-radius: .25rem; 102 | padding: .25rem; 103 | } 104 | 105 | .hour-section { 106 | width: 100%; 107 | text-align: center; 108 | border-spacing: 0; 109 | } 110 | 111 | .hour-row { 112 | background-color: hsl(200, 60%, 75%); 113 | } 114 | 115 | .hour-row:nth-child(2n) { 116 | background-color: hsl(200, 60%, 70%); 117 | } 118 | 119 | .hour-row > td { 120 | padding: .25rem .5rem; 121 | } -------------------------------------------------------------------------------- /64-65-color-game/after/Hex.js: -------------------------------------------------------------------------------- 1 | import Rgb from "./Rgb.js" 2 | 3 | export default class Hex extends Rgb { 4 | toCss() { 5 | const rHex = decimalToHex(this.r) 6 | const gHex = decimalToHex(this.g) 7 | const bHex = decimalToHex(this.b) 8 | return `#${rHex}${gHex}${bHex}` 9 | } 10 | } 11 | 12 | function decimalToHex(decimal) { 13 | return decimal.toString(16) 14 | } 15 | -------------------------------------------------------------------------------- /64-65-color-game/after/Hsl.js: -------------------------------------------------------------------------------- 1 | import { randomNumber, randomValueInRange } from "./utils.js" 2 | 3 | const MAX_HUE_VALUE = 360 4 | const MAX_SATURATION_VALUE = 100 5 | const MAX_LIGHTNESS_VALUE = 100 6 | 7 | export default class Hsl { 8 | constructor(h, s, l) { 9 | this.h = h 10 | this.s = s 11 | this.l = l 12 | } 13 | 14 | static generate() { 15 | return new this( 16 | randomNumber({ max: MAX_HUE_VALUE }), 17 | randomNumber({ max: MAX_SATURATION_VALUE }), 18 | randomNumber({ max: MAX_LIGHTNESS_VALUE }) 19 | ) 20 | } 21 | 22 | generateSimilar(options) { 23 | return new this.constructor( 24 | randomValueInRange({ 25 | startingValue: this.h, 26 | maxCutoff: MAX_HUE_VALUE, 27 | ...options, 28 | }), 29 | randomValueInRange({ 30 | startingValue: this.s, 31 | maxCutoff: MAX_SATURATION_VALUE, 32 | ...options, 33 | }), 34 | randomValueInRange({ 35 | startingValue: this.l, 36 | maxCutoff: MAX_LIGHTNESS_VALUE, 37 | ...options, 38 | }) 39 | ) 40 | } 41 | 42 | toCss() { 43 | return `hsl(${this.h}, ${this.s}%, ${this.l}%)` 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /64-65-color-game/after/Rgb.js: -------------------------------------------------------------------------------- 1 | import { randomNumber, randomValueInRange } from "./utils.js" 2 | 3 | const MAX_RGB_VALUE = 255 4 | 5 | export default class Rgb { 6 | constructor(r, g, b) { 7 | this.r = r 8 | this.g = g 9 | this.b = b 10 | } 11 | 12 | static generate() { 13 | return new this( 14 | randomNumber({ max: MAX_RGB_VALUE }), 15 | randomNumber({ max: MAX_RGB_VALUE }), 16 | randomNumber({ max: MAX_RGB_VALUE }) 17 | ) 18 | } 19 | 20 | generateSimilar(options) { 21 | return new this.constructor( 22 | randomValueInRange({ 23 | startingValue: this.r, 24 | maxCutoff: MAX_RGB_VALUE, 25 | ...options, 26 | }), 27 | randomValueInRange({ 28 | startingValue: this.g, 29 | maxCutoff: MAX_RGB_VALUE, 30 | ...options, 31 | }), 32 | randomValueInRange({ 33 | startingValue: this.b, 34 | maxCutoff: MAX_RGB_VALUE, 35 | ...options, 36 | }) 37 | ) 38 | } 39 | 40 | toCss() { 41 | return `rgb(${this.r}, ${this.g}, ${this.b})` 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /64-65-color-game/after/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Color Game 10 | 11 | 12 |
13 |

Color Game

14 |

rgb(255, 0, 0)

15 |
16 |
17 | Format 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 |
28 | Difficulty 29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 |
39 |
40 |
41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 |
49 |
Correct
50 | 51 |
52 | 53 | -------------------------------------------------------------------------------- /64-65-color-game/after/script.js: -------------------------------------------------------------------------------- 1 | import Rgb from "./Rgb.js" 2 | import Hex from "./Hex.js" 3 | import Hsl from "./Hsl.js" 4 | 5 | const COLOR_MAP = { 6 | rgb: Rgb, 7 | hex: Hex, 8 | hsl: Hsl, 9 | } 10 | 11 | const DIFFICULTY_MAP = { 12 | easy: { withinTolerance: 1, outsideTolerance: 0.2 }, 13 | medium: { withinTolerance: 0.5, outsideTolerance: 0.2 }, 14 | hard: { withinTolerance: 0.3, outsideTolerance: 0.2 }, 15 | } 16 | const nextButton = document.querySelector("[data-next-btn]") 17 | nextButton.addEventListener("click", render) 18 | document.addEventListener("change", e => { 19 | if (e.target.matches('input[type="radio"]')) render() 20 | }) 21 | 22 | // Formatter 23 | // Difficulty - Config for the formatter 24 | 25 | // Render - every time page loads, on change of format, on change of difficulty 26 | // 1. Get a formatter 27 | // 2. Configure formatter based on difficulty 28 | // 3. Generate colors 29 | // 4. Render colors 30 | // 5. Handle clicking a color 31 | 32 | // Generate correct color 33 | // Generate similar colors based on difficulty 34 | 35 | const colorGrid = document.querySelector("[data-color-grid]") 36 | const colorStringElement = document.querySelector("[data-color-string]") 37 | const resultsElement = document.querySelector("[data-results]") 38 | const resultsText = document.querySelector("[data-results-text]") 39 | function render() { 40 | const format = document.querySelector('[name="format"]:checked').value 41 | const difficulty = document.querySelector('[name="difficulty"]:checked').value 42 | const { colors, correctColor } = generateColors({ format, difficulty }) 43 | 44 | colorGrid.innerHTML = "" 45 | colorStringElement.textContent = correctColor.toCss() 46 | resultsElement.classList.add("hide") 47 | const colorElements = colors 48 | .sort(() => Math.random() - 0.5) 49 | .map(color => { 50 | const element = document.createElement("button") 51 | element.style.backgroundColor = color.toCss() 52 | return { color, element } 53 | }) 54 | colorElements.forEach(({ color, element }) => { 55 | element.addEventListener("click", () => { 56 | resultsElement.classList.remove("hide") 57 | resultsText.textContent = color === correctColor ? "Correct" : "Wrong" 58 | 59 | colorElements.forEach(({ color: c, element: e }) => { 60 | e.disabled = true 61 | e.classList.toggle("wrong", c !== correctColor) 62 | }) 63 | }) 64 | colorGrid.append(element) 65 | }) 66 | } 67 | render() 68 | 69 | function generateColors({ format, difficulty }) { 70 | const colorClass = COLOR_MAP[format] 71 | const difficultyRules = DIFFICULTY_MAP[difficulty] 72 | const correctColor = colorClass.generate() 73 | const colors = [correctColor] 74 | for (let i = 0; i < 5; i++) { 75 | colors.push(correctColor.generateSimilar(difficultyRules)) 76 | } 77 | return { colors, correctColor } 78 | } 79 | 80 | const rgb = Rgb.generate() 81 | console.log( 82 | rgb, 83 | rgb.generateSimilar({ withinTolerance: 0.3, outsideTolerance: 0.2 }) 84 | ) 85 | -------------------------------------------------------------------------------- /64-65-color-game/after/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | .title, .color-string { 6 | text-align: center; 7 | margin: .5rem; 8 | } 9 | 10 | .title { 11 | color: #777; 12 | } 13 | 14 | .form { 15 | display: flex; 16 | } 17 | 18 | .form fieldset { 19 | flex-grow: 1; 20 | text-align: center; 21 | } 22 | 23 | .radio-group { 24 | display: grid; 25 | justify-content: center; 26 | justify-items: flex-start; 27 | grid-template-columns: auto auto; 28 | gap: .25rem; 29 | } 30 | 31 | .radio-group > * { 32 | cursor: pointer; 33 | } 34 | 35 | .color-grid { 36 | display: grid; 37 | gap: .5rem; 38 | justify-content: center; 39 | max-width: 16rem; 40 | margin: auto; 41 | padding: .5rem; 42 | grid-template-columns: repeat(auto-fill, 5rem); 43 | grid-auto-rows: 5rem; 44 | } 45 | 46 | .color-grid > * { 47 | border-radius: .5rem; 48 | border: 1px solid black; 49 | padding: 0; 50 | margin: 0; 51 | } 52 | 53 | .color-grid > :disabled { 54 | cursor: default; 55 | } 56 | 57 | .color-grid > .wrong { 58 | opacity: .3; 59 | } 60 | 61 | .results { 62 | text-align: center; 63 | font-size: 1.5rem; 64 | } 65 | 66 | .results.hide { 67 | display: none; 68 | } 69 | 70 | button { 71 | cursor: pointer; 72 | } -------------------------------------------------------------------------------- /64-65-color-game/after/utils.js: -------------------------------------------------------------------------------- 1 | export function randomNumber({ min = 0, max }) { 2 | return Math.floor(Math.random() * (max - min + 1)) + min 3 | } 4 | 5 | export function randomValueInRange(options) { 6 | const ranges = validRanges(options) 7 | 8 | const range = ranges[randomNumber({ max: ranges.length - 1 })] 9 | return randomNumber(range) 10 | } 11 | 12 | function validRanges({ 13 | startingValue, 14 | maxCutoff, 15 | withinTolerance, 16 | outsideTolerance, 17 | }) { 18 | const withinToleranceIncrementor = Math.floor(withinTolerance * maxCutoff) 19 | const outsideToleranceIncrementor = Math.ceil(outsideTolerance * maxCutoff) 20 | 21 | const aboveRangeMin = startingValue + outsideToleranceIncrementor 22 | const aboveRangeMax = Math.min( 23 | startingValue + withinToleranceIncrementor, 24 | maxCutoff 25 | ) 26 | 27 | const belowRangeMin = Math.max(startingValue - withinToleranceIncrementor, 0) 28 | const belowRangeMax = startingValue - outsideToleranceIncrementor 29 | 30 | const ranges = [] 31 | if (aboveRangeMax > aboveRangeMin) { 32 | ranges.push({ min: aboveRangeMin, max: aboveRangeMax }) 33 | } 34 | if (belowRangeMax > belowRangeMin) { 35 | ranges.push({ min: belowRangeMin, max: belowRangeMax }) 36 | } 37 | return ranges 38 | } 39 | -------------------------------------------------------------------------------- /64-65-color-game/before/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Color Game 9 | 10 | 11 |
12 |

Color Game

13 |

rgb(255, 0, 0)

14 |
15 |
16 | Format 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 |
27 | Difficulty 28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 |
37 |
38 |
39 |
40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 |
48 |
Correct
49 | 50 |
51 | 52 | -------------------------------------------------------------------------------- /64-65-color-game/before/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | .title, .color-string { 6 | text-align: center; 7 | margin: .5rem; 8 | } 9 | 10 | .title { 11 | color: #777; 12 | } 13 | 14 | .form { 15 | display: flex; 16 | } 17 | 18 | .form fieldset { 19 | flex-grow: 1; 20 | text-align: center; 21 | } 22 | 23 | .radio-group { 24 | display: grid; 25 | justify-content: center; 26 | justify-items: flex-start; 27 | grid-template-columns: auto auto; 28 | gap: .25rem; 29 | } 30 | 31 | .radio-group > * { 32 | cursor: pointer; 33 | } 34 | 35 | .color-grid { 36 | display: grid; 37 | gap: .5rem; 38 | justify-content: center; 39 | max-width: 16rem; 40 | margin: auto; 41 | padding: .5rem; 42 | grid-template-columns: repeat(auto-fill, 5rem); 43 | grid-auto-rows: 5rem; 44 | } 45 | 46 | .color-grid > * { 47 | border-radius: .5rem; 48 | border: 1px solid black; 49 | padding: 0; 50 | margin: 0; 51 | } 52 | 53 | .color-grid > :disabled { 54 | cursor: default; 55 | } 56 | 57 | .color-grid > .wrong { 58 | opacity: .3; 59 | } 60 | 61 | .results { 62 | text-align: center; 63 | font-size: 1.5rem; 64 | } 65 | 66 | .results.hide { 67 | display: none; 68 | } 69 | 70 | button { 71 | cursor: pointer; 72 | } --------------------------------------------------------------------------------