├── .prettierignore ├── .gitignore ├── src ├── client │ ├── module.d.ts │ ├── assets │ │ ├── sudoku-icon-64.png │ │ ├── list.svg │ │ └── x.svg │ ├── scss │ │ ├── index.scss │ │ ├── _state.scss │ │ ├── modules │ │ │ ├── _site-info.scss │ │ │ ├── _error-page.scss │ │ │ ├── _login.scss │ │ │ ├── _top-bar.scss │ │ │ ├── _solution-container.scss │ │ │ ├── _saved-puzzle.scss │ │ │ ├── _puzzle-select-menu.scss │ │ │ ├── _side-bar.scss │ │ │ ├── _userNavbar.scss │ │ │ └── _puzzle.scss │ │ ├── _modules.scss │ │ ├── _base.scss │ │ ├── trialstyles.scss │ │ ├── _layouts.scss │ │ ├── _variables.scss │ │ └── styles.scss │ ├── Sample.tsx │ ├── layouts │ │ ├── RootLayout.tsx │ │ ├── WelcomeLayout.tsx │ │ ├── side-bar │ │ │ ├── SettingsToggle.tsx │ │ │ ├── UserSideBar.tsx │ │ │ ├── SideBarSectionContainer.tsx │ │ │ ├── WelcomeNavBar.tsx │ │ │ ├── SideBarContainer.tsx │ │ │ ├── GameSettings.tsx │ │ │ └── UserNavBar.tsx │ │ ├── TopBar.tsx │ │ └── UserLayout.tsx │ ├── shared-components │ │ ├── Loading.tsx │ │ ├── SiteInfo.tsx │ │ └── GameStats.tsx │ ├── index.html │ ├── pages │ │ ├── NotFound.tsx │ │ ├── Puzzle │ │ │ ├── components │ │ │ │ ├── EmptySquareDisplay.tsx │ │ │ │ ├── BoxUnitContainer.tsx │ │ │ │ ├── FilledSquareDisplay.tsx │ │ │ │ ├── PuzzleContainer.tsx │ │ │ │ ├── PencilSquareDisplay.tsx │ │ │ │ ├── SquareContainer.tsx │ │ │ │ ├── NumberSelectBar.tsx │ │ │ │ ├── PuzzleStringDisplay.tsx │ │ │ │ ├── ToolBar.tsx │ │ │ │ └── SolutionContainer.tsx │ │ │ ├── PuzzlePageTest.tsx │ │ │ └── PuzzlePage.tsx │ │ ├── ErrorPage.tsx │ │ ├── PuzzleSelect │ │ │ ├── components │ │ │ │ ├── SavedPuzzleGraphic.tsx │ │ │ │ └── SavedPuzzleSelector.tsx │ │ │ ├── SavedPuzzleMenu.tsx │ │ │ └── PuzzleSelectViaFilters.tsx │ │ └── Welcome │ │ │ ├── Home.tsx │ │ │ ├── SignUp.tsx │ │ │ └── Login.tsx │ ├── index.tsx │ ├── __tests__ │ │ └── Sample.test.tsx │ ├── utils │ │ ├── puzzle-state-management-functions │ │ │ ├── newFilledSquare.ts │ │ │ ├── isPuzzleFinished.ts │ │ │ ├── autofillPencilSquares.ts │ │ │ ├── puzzleStringsFromSquares.ts │ │ │ ├── puzzleStringValidation.ts │ │ │ ├── deepCopySquares.ts │ │ │ ├── updateSquaresDuplicates.ts │ │ │ ├── initialSquareStatePopulation.ts │ │ │ ├── checkForDuplicateUpdates.ts │ │ │ └── makeAllPeers.ts │ │ ├── signInWithSession.ts │ │ ├── populateUserAndPuzzleContext.ts │ │ ├── addPuzzleToUserAndCollection.ts │ │ └── save.ts │ ├── context.ts │ └── App.tsx ├── custom.d.ts ├── globalUtils │ ├── puzzle-solution-functions │ │ ├── sumTwo.ts │ │ ├── xWingSolver.ts │ │ ├── swordfishSolver.ts │ │ ├── forcingChainsSolver.ts │ │ ├── pencilStringSolutionExecuter.ts │ │ ├── updateSolveSquares.ts │ │ ├── solutionDictionary.ts │ │ ├── populateSolveSquaresIfEmpty.ts │ │ ├── singleCandidateSolver.ts │ │ ├── solveSquaresConversion.ts │ │ ├── singlePositionSolver.ts │ │ └── nakedSubsetSolver.ts │ ├── totalPuzzles.ts │ └── __tests__ │ │ ├── sumTwo.test.ts │ │ ├── hiddenQuadSolver.test.ts │ │ ├── hiddenPairSolver.test.ts │ │ └── hiddenTripleSolver.test.ts ├── server │ ├── models │ │ ├── userModel.ts │ │ ├── sessionModel.ts │ │ └── puzzleModel.ts │ ├── routes │ │ ├── puzzleRouter.ts │ │ └── userRouter.ts │ ├── utils │ │ ├── controllerErrorMaker.ts │ │ └── puzzleDBCreate.js │ ├── backendTypes.ts │ ├── controllers │ │ ├── cookieController.ts │ │ └── sessionController.ts │ └── server.ts └── ReadMe.md ├── jest.config.js ├── .prettierrc.json ├── docker-compose-test.yml ├── Dockerfile-dev ├── .github └── workflows │ └── build-tests.yml ├── Dockerfile ├── .eslintrc.json ├── webpack.config.js ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | data 2 | dist 3 | node_modules 4 | src/server/routes -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .vscode 4 | .DS_Store 5 | dist 6 | data 7 | coverage -------------------------------------------------------------------------------- /src/client/module.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const value: any; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /src/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /src/client/assets/sudoku-icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkTeets/LetsPlaySudoku/HEAD/src/client/assets/sudoku-icon-64.png -------------------------------------------------------------------------------- /src/client/scss/index.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "base"; 3 | @import "layouts"; 4 | @import "modules"; 5 | @import "state"; -------------------------------------------------------------------------------- /src/client/scss/_state.scss: -------------------------------------------------------------------------------- 1 | .is-width-collapsed { 2 | width: 0px; 3 | } 4 | 5 | .is-height-collapsed { 6 | max-height: 0px; 7 | } 8 | -------------------------------------------------------------------------------- /src/client/scss/modules/_site-info.scss: -------------------------------------------------------------------------------- 1 | .site-info { 2 | margin: 0px 15px; 3 | 4 | &__par { 5 | margin-bottom: 15px; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/sumTwo.ts: -------------------------------------------------------------------------------- 1 | export const sumTwo = (num1: number, num2: number): number => { 2 | return num1 + num2; 3 | }; 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node' 5 | }; 6 | -------------------------------------------------------------------------------- /src/client/Sample.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Sample = () => { 4 | return
Sample div element
; 5 | }; 6 | 7 | export default Sample; 8 | -------------------------------------------------------------------------------- /src/client/scss/modules/_error-page.scss: -------------------------------------------------------------------------------- 1 | .error-page { 2 | margin: 0 8px; 3 | 4 | &__message { 5 | max-width: 530px; 6 | margin-bottom: 5px; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/client/layouts/RootLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Outlet } from 'react-router-dom'; 3 | 4 | const RootLayout = () => ; 5 | 6 | export default RootLayout; 7 | -------------------------------------------------------------------------------- /src/globalUtils/totalPuzzles.ts: -------------------------------------------------------------------------------- 1 | // This file will be the single source of truth to hold onto how many puzzles are in the database 2 | const totalPuzzles = 501; 3 | 4 | export default totalPuzzles; 5 | -------------------------------------------------------------------------------- /src/globalUtils/__tests__/sumTwo.test.ts: -------------------------------------------------------------------------------- 1 | import { sumTwo } from '../puzzle-solution-functions/sumTwo'; 2 | 3 | it('Should add 1 + 2 to equal 3', () => { 4 | const result: number = sumTwo(1, 2); 5 | expect(result).toBe(3); 6 | }); 7 | -------------------------------------------------------------------------------- /src/client/shared-components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 |

Loading

7 |
8 | ); 9 | }; 10 | 11 | export default Loading; 12 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 100, 4 | "jsxSingleQuote": true, 5 | "trailingComma": "none", 6 | "overrides": [ 7 | { 8 | "files": ["*.html", "*.css", "*.scss", "*.sass", "*.less"], 9 | "options": { 10 | "singleQuote": false 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/client/layouts/WelcomeLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Components 4 | import TopBar from './TopBar'; 5 | import WelcomeNavBar from './side-bar/WelcomeNavBar'; 6 | 7 | // Main Component 8 | const WelcomeLayout = () => { 9 | return ; 10 | }; 11 | 12 | export default WelcomeLayout; 13 | -------------------------------------------------------------------------------- /src/client/scss/_modules.scss: -------------------------------------------------------------------------------- 1 | @import "./modules/top-bar"; 2 | @import "./modules/side-bar"; 3 | @import "./modules/site-info"; 4 | @import "./modules/error-page"; 5 | @import "./modules/login"; 6 | @import "./modules/puzzle-select-menu"; 7 | @import "./modules/saved-puzzle"; 8 | @import "./modules/puzzle"; 9 | @import "./modules/solution-container"; -------------------------------------------------------------------------------- /src/client/assets/list.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/client/scss/modules/_login.scss: -------------------------------------------------------------------------------- 1 | .login { 2 | @include l-flex-column-align-center; 3 | 4 | &__heading { 5 | margin: 0 0 10px 0; 6 | } 7 | 8 | &__form { 9 | @include l-flex-column-align-center; 10 | @include l-menu-border; 11 | padding: 10px; 12 | margin-bottom: 12px; 13 | 14 | label { 15 | margin-bottom: 12px; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3.0" 3 | services: 4 | test: 5 | image: "markteetsdev/sudoku-dev" 6 | container_name: "sudoku-test" 7 | # won't need ports until later 8 | # ports: 9 | # - 3000:3000 10 | volumes: 11 | - .:/usr/src/app 12 | - node_modules:/usr/src/app/node_modules 13 | command: "npm run test" 14 | volumes: 15 | node_modules: 16 | -------------------------------------------------------------------------------- /Dockerfile-dev: -------------------------------------------------------------------------------- 1 | # An image was made with this Dockerfile with the name "markteetsdev/sudoku-dev" 2 | 3 | # The image created from this Dockerfile is essentially an environment to run any 4 | # of the package.json script commands via docker-compose files 5 | FROM node:18.13 6 | RUN npm install -g webpack 7 | WORKDIR /usr/src/app 8 | COPY package*.json /usr/src/app 9 | RUN npm install 10 | EXPOSE 3000 11 | -------------------------------------------------------------------------------- /src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Let's Play Sudoku! 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/client/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const NotFound = () => { 5 | return ( 6 |
7 |

Hey there, you look a little lost. Want some help getting back home?

8 |
9 | Take me home! 10 |
11 |
12 | ); 13 | }; 14 | 15 | export default NotFound; 16 | -------------------------------------------------------------------------------- /src/client/scss/modules/_top-bar.scss: -------------------------------------------------------------------------------- 1 | .top-bar { 2 | display: flex; 3 | justify-content: space-between; 4 | 5 | &__left, &__right { 6 | display: flex; 7 | align-items: center; 8 | } 9 | 10 | &__sudoku-icon { 11 | height: 40px; 12 | width: 40px; 13 | margin: 4px; 14 | } 15 | 16 | &__page-title{ 17 | margin: $sideSpaceMargin; 18 | font-size: max(16px, min(5vw, 24px)); 19 | text-wrap: nowrap; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/client/index.tsx: -------------------------------------------------------------------------------- 1 | import App from './App'; 2 | import React, { StrictMode } from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | 5 | const rootElement = document.getElementById('root'); 6 | 7 | if (rootElement === null) { 8 | throw new Error('html element with id of "root" was not found.'); 9 | } 10 | 11 | const root = createRoot(rootElement); 12 | root.render( 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /.github/workflows/build-tests.yml: -------------------------------------------------------------------------------- 1 | name: Build-tests 2 | 3 | on: 4 | push: 5 | # Runs on pull requests to the default branch 6 | # pull_request: 7 | # branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | unit-testing: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - run: docker-compose -f docker-compose-test.yml up --abort-on-container-exit 18 | -------------------------------------------------------------------------------- /src/client/__tests__/Sample.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import React from 'react'; 5 | // eslint-disable-next-line import/no-unresolved 6 | import '@testing-library/jest-dom'; 7 | import { render, screen } from '@testing-library/react'; 8 | import Sample from '../Sample'; 9 | 10 | it('Renders sample element', () => { 11 | render(); 12 | const sampleElement = screen.getByText(/Sample div element/); 13 | expect(sampleElement).toBeInTheDocument(); 14 | // screen.debug(); 15 | }); 16 | -------------------------------------------------------------------------------- /src/client/pages/Puzzle/components/EmptySquareDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Types 4 | import { SquareProps } from '../../../frontendTypes'; 5 | 6 | // Main Component 7 | const EmptySquareDisplay = (props: SquareProps) => { 8 | const { squareId, squareClasses, onSquareClick } = props; 9 | return ( 10 |
onSquareClick(event)} 14 | >
15 | ); 16 | }; 17 | 18 | export default EmptySquareDisplay; 19 | -------------------------------------------------------------------------------- /src/client/scss/modules/_solution-container.scss: -------------------------------------------------------------------------------- 1 | .solution-container { 2 | @include l-flex-column-align-center; 3 | min-width: 210px; 4 | @include breakpoint(380px) { 5 | @include l-flex-column-align-left; 6 | } 7 | 8 | &__technique-applier { 9 | @include l-flex-row-justify-center; 10 | flex-wrap: wrap; 11 | @include breakpoint(380px) { 12 | @include l-flex-row-justify-start; 13 | } 14 | 15 | label { 16 | margin: 0 10px 5px 0; 17 | } 18 | 19 | button { 20 | margin-bottom: 5px; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/client/layouts/side-bar/SettingsToggle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Types 4 | import { SettingsToggleProps } from '../../frontendTypes'; 5 | 6 | // Main Component 7 | const SettingsToggle = (props: SettingsToggleProps) => { 8 | const { label, state, setState } = props; 9 | return ( 10 |
11 | 19 |
20 | ); 21 | }; 22 | 23 | export default SettingsToggle; 24 | -------------------------------------------------------------------------------- /src/server/models/userModel.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | 3 | // Types 4 | import { UserDocument } from '../backendTypes'; 5 | 6 | const userSchema = new Schema({ 7 | username: { type: String, unique: true, required: true }, 8 | password: { type: String, required: true }, 9 | displayName: { type: String, required: true }, 10 | lastPuzzle: { type: Number, default: 0 }, 11 | allPuzzles: [ 12 | { 13 | puzzleNumber: Number, 14 | progress: String, 15 | pencilProgress: String 16 | } 17 | ] 18 | }); 19 | 20 | const UserModel = model('users', userSchema); 21 | 22 | export default UserModel; 23 | -------------------------------------------------------------------------------- /src/server/models/sessionModel.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | 3 | // Types 4 | import { Session } from '../../server/backendTypes'; 5 | 6 | /** 7 | * `createdAt` uses's Mongo's automatic document expiration service via the `expires` property. 8 | * This automatically be removes the session after the given time in seconds. 9 | * 1 week = 604800 seconds 10 | */ 11 | 12 | const sessionSchema = new Schema({ 13 | cookieId: { type: String, required: true, unique: true }, 14 | createdAt: { type: Date, expires: 604800, default: Date.now } 15 | }); 16 | 17 | const Session = model('Session', sessionSchema); 18 | 19 | export default Session; 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # An image was made with this Dockerfile with the name "markteetsdev/sudoku-prod" 2 | 3 | # When the image created from this Dockerfile run in a docker container, 4 | # the frontend will be built via webpack by the command "RUN npm run build", 5 | # the server will be run via the ENTRYPOINT, and the frontend will be served 6 | # to localhost:3000 via express.static serving dist/index.html. 7 | # The web-based mongoDB database is connected to automatically via the server. 8 | FROM node:18.13 9 | WORKDIR /usr/src/app 10 | COPY . /usr/src/app 11 | RUN npm install 12 | RUN npm run build 13 | EXPOSE 3000 14 | ENTRYPOINT ["npx", "ts-node", "./src/server/server.ts"] 15 | -------------------------------------------------------------------------------- /src/client/scss/modules/_saved-puzzle.scss: -------------------------------------------------------------------------------- 1 | .saved-puzzle-select { 2 | margin-bottom: 15px; 3 | 4 | &__link-and-graphic { 5 | @include l-flex-row-justify-center; 6 | align-items: center; 7 | margin-bottom: 8px; 8 | } 9 | } 10 | 11 | .saved-puzzle-graphic { 12 | display: grid; 13 | grid-template-rows: repeat(9, 1fr); 14 | grid-template-columns: repeat(9, 1fr); 15 | width: max-content; 16 | margin-left: 20px; 17 | border: 1px black solid; 18 | 19 | div { 20 | width: 4px; 21 | height: 4px; 22 | } 23 | 24 | .light-square { 25 | background-color: $squareBackground; 26 | } 27 | 28 | .dark-square { 29 | background-color: $numberColor; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/client/utils/puzzle-state-management-functions/newFilledSquare.ts: -------------------------------------------------------------------------------- 1 | import { FilledSquare, PuzzleVal } from '../../frontendTypes'; 2 | 3 | /** newFilledSquare 4 | * 5 | * Returns a new FilledSquare object based on the parameters with duplicate and numberHighlight 6 | * set to false 7 | * 8 | * @param puzzleVal string from '1' to '9' 9 | * @param fixedVal boolean representing if the square's value was present in the original puzzle 10 | * string 11 | * @returns FilledSquare object 12 | */ 13 | export const newFilledSquare = (puzzleVal: PuzzleVal, fixedVal: boolean): FilledSquare => { 14 | return { 15 | puzzleVal, 16 | duplicate: false, 17 | fixedVal, 18 | numberHighlight: false 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/client/assets/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/xWingSolver.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SolveSquares, SolveTechnique } from '../../types'; 3 | import { FilledSquares, PuzzleVal } from '../../client/frontendTypes'; 4 | 5 | // Utilities 6 | import { allPeers } from '../../client/utils/puzzle-state-management-functions/makeAllPeers'; 7 | import { allSquareIds } from '../../client/utils/puzzle-state-management-functions/squareIdsAndPuzzleVals'; 8 | import { updateSolveSquares } from './updateSolveSquares'; 9 | import { newFilledSquare } from '../../client/utils/puzzle-state-management-functions/newFilledSquare'; 10 | 11 | export const xWingSolver: SolveTechnique = (filledSquares, solveSquares, solutionCache) => { 12 | return false; 13 | }; 14 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/swordfishSolver.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SolveSquares, SolveTechnique } from '../../types'; 3 | import { FilledSquares, PuzzleVal } from '../../client/frontendTypes'; 4 | 5 | // Utilities 6 | import { allPeers } from '../../client/utils/puzzle-state-management-functions/makeAllPeers'; 7 | import { allSquareIds } from '../../client/utils/puzzle-state-management-functions/squareIdsAndPuzzleVals'; 8 | import { updateSolveSquares } from './updateSolveSquares'; 9 | import { newFilledSquare } from '../../client/utils/puzzle-state-management-functions/newFilledSquare'; 10 | 11 | export const swordfishSolver: SolveTechnique = (filledSquares, solveSquares, solutionCache) => { 12 | return false; 13 | }; 14 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/forcingChainsSolver.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SolveSquares, SolveTechnique } from '../../types'; 3 | import { FilledSquares, PuzzleVal } from '../../client/frontendTypes'; 4 | 5 | // Utilities 6 | import { allPeers } from '../../client/utils/puzzle-state-management-functions/makeAllPeers'; 7 | import { allSquareIds } from '../../client/utils/puzzle-state-management-functions/squareIdsAndPuzzleVals'; 8 | import { updateSolveSquares } from './updateSolveSquares'; 9 | import { newFilledSquare } from '../../client/utils/puzzle-state-management-functions/newFilledSquare'; 10 | 11 | export const forcingChainsSolver: SolveTechnique = (filledSquares, solveSquares, solutionCache) => { 12 | return false; 13 | }; 14 | -------------------------------------------------------------------------------- /src/client/utils/puzzle-state-management-functions/isPuzzleFinished.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { FilledSquares } from '../../frontendTypes'; 3 | 4 | // Utils 5 | import { allSquareIds } from './squareIdsAndPuzzleVals'; 6 | 7 | /** isPuzzleFinished 8 | * Checks if a puzzle is complete by checking to see if there are no empty spaces and no duplicates 9 | * in the puzzle 10 | * 11 | * @param filledSquares - FilledSquares object 12 | * @returns boolean 13 | */ 14 | export const isPuzzleFinished = (filledSquares: FilledSquares): boolean => { 15 | if (filledSquares.size !== 81) return false; 16 | for (const squareId of allSquareIds) { 17 | if (filledSquares[squareId]?.duplicate) { 18 | return false; 19 | } 20 | } 21 | return true; 22 | }; 23 | -------------------------------------------------------------------------------- /src/client/pages/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, useRouteError } from 'react-router-dom'; 3 | 4 | const ErrorPage = () => { 5 | const error = useRouteError() as Error; 6 | 7 | return ( 8 |
9 |

Hey there, you look a little lost. Want some help getting back home?

10 |

11 | Error:{' '} 12 | {error 13 | ? error.message 14 | : 'A sample error message that could be much longer than this. It could go on for several lines. It could keep going forever and ever and ever.'} 15 |

16 | Take me home! 17 |
18 | ); 19 | }; 20 | 21 | export default ErrorPage; 22 | -------------------------------------------------------------------------------- /src/client/scss/_base.scss: -------------------------------------------------------------------------------- 1 | *, 2 | ::after, 3 | ::before { 4 | box-sizing: border-box; 5 | } 6 | 7 | // Font import 8 | @import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:ital,wght@0,100;0,300;0,400;1,300;1,400&family=Rubik:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500;1,600&display=swap"); 9 | 10 | html { 11 | background-color: $bodyColorTransparent; 12 | min-width: 250px; 13 | } 14 | 15 | body { 16 | min-height: 100vh; 17 | margin: 0; 18 | background-image: radial-gradient(ellipse at top left, $bodyColor, transparent 100%); 19 | font-family: "Roboto", sans-serif; 20 | // font-family:'Lucinda Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 21 | // font-family: 'Rubik', sans-serif; 22 | color: $textColor; 23 | } 24 | 25 | p { 26 | margin: 0; 27 | } 28 | -------------------------------------------------------------------------------- /src/client/shared-components/SiteInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SiteInfo = () => { 4 | return ( 5 |
6 |

Welcome to your one stop shop for playing Sudoku!

7 |

8 | Well hi there! Thank you so much for coming! This is a site dedicated to facilitating your 9 | Sudoku journey for free! What makes this site special is it's ad-free, and you can make 10 | an account to save your progress on your puzzles and come back to them whenever you like. 11 |

12 |

13 | Many features are in development! Soon you'll be able to choose a puzzle to play by 14 | difficulty or by a specific technique used to solve the puzzle. 15 |

16 |
17 | ); 18 | }; 19 | 20 | export default SiteInfo; 21 | -------------------------------------------------------------------------------- /src/server/routes/puzzleRouter.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | const puzzleRouter = express.Router(); 3 | import userController from '../controllers/userController'; 4 | import puzzleController from '../controllers/puzzleController'; 5 | 6 | puzzleRouter.get('/:puzzleNumber', 7 | puzzleController.getPuzzleByNumber, 8 | (req: Request, res: Response) => { 9 | res.status(200).json(res.locals.frontendData); 10 | } 11 | ); 12 | 13 | puzzleRouter.post('/get-next-puzzle-for-user', 14 | userController.getUser, 15 | puzzleController.getNextPuzzle, 16 | (req: Request, res: Response) => { 17 | res.status(200).json(res.locals.frontendData); 18 | } 19 | ); 20 | 21 | puzzleRouter.post('/get-next-puzzle-for-guest', 22 | puzzleController.getNextPuzzle, 23 | (req: Request, res: Response) => { 24 | res.status(200).json(res.locals.frontendData); 25 | } 26 | ); 27 | 28 | export default puzzleRouter; 29 | -------------------------------------------------------------------------------- /src/client/scss/modules/_puzzle-select-menu.scss: -------------------------------------------------------------------------------- 1 | .puzzle-select-menu { 2 | @include l-flex-column-align-center; 3 | @include l-menu-border; 4 | margin: 0 8px; 5 | padding: 0 10px 10px; 6 | 7 | &__section { 8 | @include l-flex-column-align-center; 9 | margin: 0 10px; 10 | max-width: 265px; 11 | 12 | h3 { 13 | margin-bottom: 10px; 14 | text-align: center; 15 | } 16 | 17 | p { 18 | text-align: center; 19 | } 20 | 21 | button { 22 | font-size: 15px; 23 | border-radius: 3px; 24 | border-width: 1px; 25 | } 26 | } 27 | } 28 | 29 | .puzzle-select-by-number { 30 | display: flex; 31 | flex-wrap: wrap; 32 | justify-content: center; 33 | 34 | input { 35 | margin: 5px; 36 | height: 25px; 37 | width: 120px; 38 | text-align: center; 39 | border-radius: 5px; 40 | border-width: 1px; 41 | } 42 | 43 | button { 44 | margin: 5px; 45 | } 46 | } -------------------------------------------------------------------------------- /src/client/pages/PuzzleSelect/components/SavedPuzzleGraphic.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | // Types 4 | import { SavedPuzzleGraphicProps } from '../../../frontendTypes'; 5 | 6 | // Main Component 7 | const SavedPuzzleGraphic = ({ progress }: SavedPuzzleGraphicProps) => { 8 | const graphic = useMemo(() => makeGraphic(progress), [progress]); 9 | return
{graphic}
; 10 | }; 11 | 12 | export default SavedPuzzleGraphic; 13 | const makeGraphic = (progress: string): React.JSX.Element[] => { 14 | const graphicSquares: React.JSX.Element[] = []; 15 | for (let i = 0; i < progress.length; i++) { 16 | if (progress[i] === '0') { 17 | graphicSquares.push(
); 18 | } else { 19 | graphicSquares.push(
); 20 | } 21 | } 22 | return graphicSquares; 23 | }; 24 | -------------------------------------------------------------------------------- /src/client/utils/signInWithSession.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SignInWithSession } from '../frontendTypes'; 3 | import { SignInResponse } from '../../types'; 4 | 5 | // Utils 6 | import populateUserAndPuzzleContext from './populateUserAndPuzzleContext'; 7 | 8 | const signInWithSession: SignInWithSession = async ( 9 | setUser, 10 | setPuzzleCollection 11 | ): Promise => { 12 | const res: Response = await fetch('/api/user/resume-session'); 13 | if (!res.ok) return false; 14 | 15 | const sessionData = (await res.json()) as SignInResponse; 16 | 17 | if (sessionData.status === 'valid' && sessionData.user && sessionData.puzzleCollection) { 18 | populateUserAndPuzzleContext( 19 | sessionData.user, 20 | setUser, 21 | sessionData.puzzleCollection, 22 | setPuzzleCollection 23 | ); 24 | // console.log('signed in with session'); 25 | return true; 26 | } else { 27 | return false; 28 | } 29 | }; 30 | 31 | export default signInWithSession; 32 | -------------------------------------------------------------------------------- /src/globalUtils/__tests__/hiddenQuadSolver.test.ts: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import { pencilStringSolutionExecuter } from '../puzzle-solution-functions/pencilStringSolutionExecuter'; 3 | import { hiddenQuadSolver } from '../puzzle-solution-functions/hiddenSubsetSolver'; 4 | 5 | const hiddenQuadPencilStringExample1 = 6 | 'A1568A356A628A845A9245C258C628C757C835C92357D657D957E1589E21458E345E412E5259E6567E7457E83456E93457F159F214F416F559F946G656G856H235H436H745H9456I156I2345I3456I423I525'; 7 | const hiddenQuadPencilStringResult1 = 8 | 'A1568A356A628A845A9245C258C628C757C835C92357D657D957E189E218E345E412E529E6567E7457E83456E93457F159F214F416F559F946G656G856H235H436H745H9456I156I2345I3456I423I525'; 9 | 10 | describe('Hidden quad solver solves test cases', () => { 11 | it('should remove extra penciled in values from row example 1', () => { 12 | expect(pencilStringSolutionExecuter(hiddenQuadSolver, hiddenQuadPencilStringExample1)).toBe( 13 | hiddenQuadPencilStringResult1 14 | ); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/client/layouts/side-bar/UserSideBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Types 4 | import { SideBarProps } from '../../frontendTypes'; 5 | 6 | // Components 7 | import SideBarSectionContainer from './SideBarSectionContainer'; 8 | import UserNavBar from './UserNavBar'; 9 | import GameSettings from './GameSettings'; 10 | import GameStats from '../../shared-components/GameStats'; 11 | 12 | const UserSideBar = (props: SideBarProps) => { 13 | return ( 14 | <> 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default UserSideBar; 29 | -------------------------------------------------------------------------------- /src/client/pages/Puzzle/components/BoxUnitContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | // Types 4 | import { SquareId, BoxUnitContainerProps, SquareContainerProps } from '../../../frontendTypes'; 5 | 6 | // Components 7 | import SquareContainer from './SquareContainer'; 8 | 9 | // Main Component 10 | const BoxUnitContainer = ({ boxUnit }: BoxUnitContainerProps) => { 11 | const generatedSquares = useMemo(() => generateSquares(boxUnit), [boxUnit]); 12 | return
{generatedSquares}
; 13 | }; 14 | 15 | export default BoxUnitContainer; 16 | 17 | // Helper Functions 18 | function generateSquares(boxUnit: Set): React.JSX.Element[] { 19 | const squares = [] as React.JSX.Element[]; 20 | boxUnit.forEach((squareId) => { 21 | const squareContainerProps: SquareContainerProps = { 22 | squareId 23 | }; 24 | squares.push(); 25 | }); 26 | return squares; 27 | } 28 | -------------------------------------------------------------------------------- /src/client/layouts/side-bar/SideBarSectionContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | // Types 4 | import { SideBarSectionContainerProps } from '../../frontendTypes'; 5 | 6 | // Main Component 7 | const SideBarSectionContainer = ({ 8 | children, 9 | title, 10 | defaultExpanded 11 | }: SideBarSectionContainerProps) => { 12 | const [isSectionExpanded, setIsSectionExpanded] = useState( 13 | defaultExpanded === undefined ? false : defaultExpanded 14 | ); 15 | 16 | const switchSectionExpanded = () => { 17 | setIsSectionExpanded(!isSectionExpanded); 18 | }; 19 | 20 | return ( 21 |
22 | 25 |
26 | {children} 27 |
28 |
29 | ); 30 | }; 31 | 32 | export default SideBarSectionContainer; 33 | -------------------------------------------------------------------------------- /src/client/layouts/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Outlet } from 'react-router-dom'; 3 | 4 | // Types 5 | import { SideBarContainerProps } from '../frontendTypes'; 6 | 7 | // Component 8 | import SideBarContainer from './side-bar/SideBarContainer'; 9 | 10 | const TopBar = (props: SideBarContainerProps) => { 11 | return ( 12 | <> 13 | 28 |
29 | 30 |
31 | 32 | ); 33 | }; 34 | 35 | export default TopBar; 36 | -------------------------------------------------------------------------------- /src/server/utils/controllerErrorMaker.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { CustomErrorOutput, CustomErrorGenerator } from '../backendTypes'; 3 | 4 | const controllerErrorMaker = (controllerName: string) => { 5 | const customErrorGenerator: CustomErrorGenerator = ({ method, overview, status, err }) => { 6 | const errorObj: CustomErrorOutput = { 7 | log: `${controllerName}.${method} ${overview}: ERROR: `, 8 | message: { 9 | err: `Error occurred in ${controllerName}.${method}. Check server logs for more details.` 10 | } 11 | }; 12 | 13 | if (status) { 14 | errorObj.status = status; 15 | } 16 | 17 | // Type validation and handling for CustomErrorInput 18 | if (typeof err === 'string') { 19 | errorObj.log += err; 20 | } else if (err instanceof Error) { 21 | errorObj.log += err.message; 22 | } else { 23 | errorObj.log += `unknown type found. Investigate try-catch blocks in ${method}`; 24 | } 25 | 26 | return errorObj; 27 | }; 28 | return customErrorGenerator; 29 | }; 30 | 31 | export default controllerErrorMaker; 32 | -------------------------------------------------------------------------------- /src/globalUtils/__tests__/hiddenPairSolver.test.ts: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import { pencilStringSolutionExecuter } from '../puzzle-solution-functions/pencilStringSolutionExecuter'; 3 | import { hiddenPairSolver } from '../puzzle-solution-functions/hiddenSubsetSolver'; 4 | 5 | const hiddenPairPencilStringExample1 = 'C346C424C613C826C9123'; 6 | const hiddenPairPencilStringResult1 = 'C346C424C613C826C913'; 7 | 8 | const hiddenPairPencilStringExample2 = 'E238E338E417E525E6125E879E919'; 9 | const hiddenPairPencilStringResult2 = 'E238E338E417E525E625E879E919'; 10 | 11 | describe('Hidden pair solver solves test cases', () => { 12 | it('should remove extra penciled in values from row example 1', () => { 13 | expect(pencilStringSolutionExecuter(hiddenPairSolver, hiddenPairPencilStringExample1)).toBe( 14 | hiddenPairPencilStringResult1 15 | ); 16 | }); 17 | 18 | it('should remove extra penciled in values from row example 2', () => { 19 | expect(pencilStringSolutionExecuter(hiddenPairSolver, hiddenPairPencilStringExample2)).toBe( 20 | hiddenPairPencilStringResult2 21 | ); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/client/layouts/UserLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | // Types 5 | import { UserContextValue, PuzzleCollectionContextValue } from '../frontendTypes'; 6 | 7 | // Components 8 | import TopBar from './TopBar'; 9 | import UserSideBar from './side-bar/UserSideBar'; 10 | 11 | // Context 12 | import { userContext, puzzleCollectionContext } from '../context'; 13 | 14 | // Utils 15 | import signInWithSession from '../utils/signInWithSession'; 16 | 17 | // Main Component 18 | const UserLayout = () => { 19 | const navigate = useNavigate(); 20 | const { user, setUser } = useContext(userContext); 21 | const { setPuzzleCollection } = useContext(puzzleCollectionContext); 22 | 23 | useEffect(() => { 24 | if (!user) { 25 | signInWithSession(setUser, setPuzzleCollection).then((successfulSignIn) => { 26 | if (!successfulSignIn) { 27 | navigate('/'); 28 | } 29 | }); 30 | } 31 | }, [user, setUser, setPuzzleCollection, navigate]); 32 | 33 | return ; 34 | }; 35 | 36 | export default UserLayout; 37 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/pencilStringSolutionExecuter.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SolveTechnique } from '../../types'; 3 | import { FilledSquares } from '../../client/frontendTypes'; 4 | 5 | // Utilities 6 | import { pencilSquaresToSolveSquares, solveSquareToPencilSquares } from './solveSquaresConversion'; 7 | import { pencilSquaresFromString } from '../../client/utils/puzzle-state-management-functions/squaresFromPuzzleStrings'; 8 | import { createPencilProgressString } from '../../client/utils/puzzle-state-management-functions/puzzleStringsFromSquares'; 9 | import { newSolutionCache } from './solutionFramework'; 10 | 11 | export const pencilStringSolutionExecuter = ( 12 | technique: SolveTechnique, 13 | pencilString: string 14 | ): string => { 15 | const filledSquares: FilledSquares = { size: 0 }; 16 | const pencilSquares = pencilSquaresFromString(pencilString); 17 | const solveSquares = pencilSquaresToSolveSquares(pencilSquares); 18 | const solutionCache = newSolutionCache(); 19 | technique(filledSquares, solveSquares, solutionCache); 20 | const pencilSquaresResult = solveSquareToPencilSquares(solveSquares); 21 | const pencilStringResult = createPencilProgressString(pencilSquaresResult); 22 | return pencilStringResult; 23 | }; 24 | -------------------------------------------------------------------------------- /src/client/utils/populateUserAndPuzzleContext.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SetUser, SetPuzzleCollection } from '../frontendTypes'; 3 | import { User, PuzzleCollection } from '../../types'; 4 | 5 | /** populateUserAndPuzzleContext 6 | * 7 | * Given successful user data found either via loading session data or a user successfully logging 8 | * in, this function updates the global context with this user data and updates the page information 9 | * so the component navigates to the next destination via the useEffect in the Login component. 10 | * 11 | * @param newUser - User object - user data to be assigned to global context 12 | * @param setUser - Dispatch function used to assign state of user stored in global context 13 | * @param newPuzzleCollection - PuzzleCollection object - puzzle data to be assigned to global 14 | * context 15 | * @param setPuzzleCollection - Dispatch function used to assign state of puzzleCollection stored in 16 | * global context 17 | */ 18 | const populateUserAndPuzzleContext = ( 19 | newUser: User, 20 | setUser: SetUser, 21 | newPuzzleCollection: PuzzleCollection, 22 | setPuzzleCollection: SetPuzzleCollection 23 | ) => { 24 | setUser(newUser); 25 | setPuzzleCollection(newPuzzleCollection); 26 | }; 27 | 28 | export default populateUserAndPuzzleContext; 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2021": true, 6 | "node": true 7 | }, 8 | "plugins": ["import", "react", "jsx-a11y", "react-hooks", "@typescript-eslint", "prettier"], 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:import/errors", 12 | "plugin:react/recommended", 13 | "plugin:jsx-a11y/recommended", 14 | "plugin:react-hooks/recommended", 15 | "plugin:@typescript-eslint/eslint-recommended", 16 | "plugin:@typescript-eslint/recommended", 17 | "prettier" 18 | ], 19 | "parser": "@typescript-eslint/parser", 20 | "parserOptions": { 21 | "ecmaFeatures": { 22 | "jsx": true 23 | }, 24 | "sourceType": "module" 25 | }, 26 | "rules": { 27 | "max-len": ["warn", {"code": 260, "comments": 100 }], 28 | "no-unused-vars": ["off", { "vars": "local" }], 29 | "prefer-const": "warn", 30 | "no-console": "warn", 31 | "jsx-a11y/click-events-have-key-events": "off", 32 | "jsx-a11y/no-noninteractive-tabindex": "off", 33 | "jsx-a11y/no-static-element-interactions": "off", 34 | "react/jsx-pascal-case": 2, 35 | "@typescript-eslint/no-unused-vars": "off", 36 | "prettier/prettier": 1 37 | }, 38 | "settings": { 39 | "react": { 40 | "version": "detect" 41 | }, 42 | "import/resolver": "webpack" 43 | }, 44 | "ignorePatterns":["node_modules", "dist", "data"] 45 | } 46 | -------------------------------------------------------------------------------- /src/ReadMe.md: -------------------------------------------------------------------------------- 1 | # Developer Read Me 2 | 3 | Welcome to the developer read me! This is where I record notes on the technologies I use and how they're used in the application. 4 | 5 | ## Docker & GitHub Actions 6 | 7 | A Docker image built from Dockerfile-dev named markteetsdev/sudoku-dev includes a copy of package.json and package-lock.json from 10/24/23. Any future updates to package.json and/or package-lock.json will require the image to be built again to be reflected in the image. 8 | 9 | The file .github/workflows/build-tests.yml defines a GitHub action such that every time a commit is pushed to GitHub, the file docker-compose-test.yml is spun up, which in turn uses the markteetsdev/sudoku-dev image to create a container. This container acts as a runtime environment which includes every package from the package.json. The docker-compose-test.yml executes the command "npm run test" in that container, which runs all of the jest tests in the github repository. 10 | 11 | The results of these tests can be found in the GitHub actions tab on GitHub. This way, any failed tests can be tracked back to a specific commit and the problem can be found and fixed. 12 | 13 | I also created a docker image from Dockerfile called markteetsdev/sudoku-prod which contains the webpack bundled frontend and runs the server within the container. The server serves the frontend at localhost 3000. This version of the code could be deployed, but isn't in use at the moment. -------------------------------------------------------------------------------- /src/client/utils/puzzle-state-management-functions/autofillPencilSquares.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { AutofillPencilSquares } from '../../frontendTypes'; 3 | 4 | // Utilities 5 | import { newSolveSquares } from '../../../globalUtils/puzzle-solution-functions/solutionFramework'; 6 | import { updateSolveSquares } from '../../../globalUtils/puzzle-solution-functions/updateSolveSquares'; 7 | import { updatePencilSquaresDuplicates } from './updateSquaresDuplicates'; 8 | import { solveSquareToPencilSquares } from '../../../globalUtils/puzzle-solution-functions/solveSquaresConversion'; 9 | 10 | /** autofillPencilSquares 11 | * 12 | * Generates a pencilSquares object based on the current filledSquares object and uses the 13 | * dispatch function to set pencilSquares on PuzzlePage.tsx to this new pencilSquare object. 14 | * Penciled in values are based solely on filled in squares without applying advanced techniques. 15 | * 16 | * @param filledSquares - FilledSquares object 17 | * @param setPencilSquares - dispatch action for setting pencilSquares in PuzzlePage.tsx 18 | */ 19 | export const autofillPencilSquares: AutofillPencilSquares = (filledSquares, setPencilSquares) => { 20 | const solveSquares = newSolveSquares(); 21 | updateSolveSquares(filledSquares, solveSquares); 22 | const pencilSquares = solveSquareToPencilSquares(solveSquares); 23 | updatePencilSquaresDuplicates(filledSquares, pencilSquares); 24 | setPencilSquares(pencilSquares); 25 | }; 26 | -------------------------------------------------------------------------------- /src/client/scss/trialstyles.scss: -------------------------------------------------------------------------------- 1 | // $bs-body-bg-rgb: rgb(13, 13, 104); 2 | // $bs-primary-rgb: rgb(23, 56, 23); 3 | // $bd-accent-rgb: yellow; 4 | // $bd-violet-rgb: purple; 5 | // $bd-pink-rgb: pink; 6 | 7 | // body { 8 | // height: 100%; 9 | // // background-image: linear-gradient(180deg, rgba($bs-body-bg-rgb, 0.01), rgba($bs-body-bg-rgb, 1) 85%); 10 | // background-image: radial-gradient(ellipse at top left, rgba($bs-primary-rgb, 0.5), transparent 100%); 11 | // // background-image: linear-gradient(180deg, rgba($bs-body-bg-rgb, 0.01), rgba($bs-body-bg-rgb, 1) 85%),radial-gradient(ellipse at top left, rgba($bs-primary-rgb, 0.5), transparent 50%),radial-gradient(ellipse at top right, rgba($bd-accent-rgb, 0.5), transparent 50%),radial-gradient(ellipse at center right, rgba($bd-violet-rgb, 0.5), transparent 50%),radial-gradient(ellipse at center left, rgba($bd-pink-rgb, 0.5), transparent 50%); 12 | // // background-image: linear-gradient(180deg, rgba($bs-body-bg-rgb, 0.01), rgba($bs-body-bg-rgb, 1) 85%),radial-gradient(ellipse at top left, rgba($bs-primary-rgb, 0.5), transparent 50%),radial-gradient(ellipse at top right, rgba($bd-accent-rgb, 0.5), transparent 50%),radial-gradient(ellipse at center right, rgba($bd-violet-rgb, 0.5), transparent 50%),radial-gradient(ellipse at center left, rgba($bd-pink-rgb, 0.5), transparent 50%); 13 | // } 14 | 15 | // .style-test { 16 | // // height: 500px; 17 | // // height: 100vw; 18 | // display: flex; 19 | // flex-direction: column; 20 | // align-items: center; 21 | // flex-grow: 1; 22 | // } -------------------------------------------------------------------------------- /src/client/pages/Puzzle/components/FilledSquareDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from 'react'; 2 | 3 | // Types 4 | import { 5 | FilledSquare, 6 | SquareContextValue, 7 | SquareProps, 8 | GameSettingContextValue 9 | } from '../../../frontendTypes'; 10 | 11 | // Context 12 | import { squareContext, gameSettingsContext } from '../../../context'; 13 | 14 | // Main Component 15 | const FilledSquareDisplay = (props: SquareProps) => { 16 | const { squareId, squareClasses, onSquareClick } = props; 17 | const { filledSquares } = useContext(squareContext); 18 | const { showDuplicates } = useContext(gameSettingsContext); 19 | const classes = useMemo( 20 | () => 21 | makeFilledSquareClasses( 22 | filledSquares[squareId] as FilledSquare, 23 | squareClasses, 24 | showDuplicates 25 | ), 26 | [filledSquares, squareId, squareClasses, showDuplicates] 27 | ); 28 | 29 | return ( 30 |
onSquareClick(event)}> 31 | {(filledSquares[squareId] as FilledSquare).puzzleVal} 32 |
33 | ); 34 | }; 35 | 36 | export default FilledSquareDisplay; 37 | 38 | const makeFilledSquareClasses = ( 39 | square: FilledSquare, 40 | squareClasses: string, 41 | showDuplicates: boolean 42 | ) => { 43 | let classes = `${squareClasses} filled-square`; 44 | if (square.fixedVal) classes += ' fixed-val'; 45 | if (showDuplicates && square.duplicate) classes += ' duplicate-number'; 46 | return classes; 47 | }; 48 | -------------------------------------------------------------------------------- /src/server/backendTypes.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { RequestHandler } from 'express'; 3 | import { UserPuzzleObj } from '../types'; 4 | import { Types } from 'mongoose'; 5 | 6 | export type UserController = { 7 | getUser: RequestHandler; 8 | cleanUser: RequestHandler; 9 | createUser: RequestHandler; 10 | verifyUser: RequestHandler; 11 | savePuzzle: RequestHandler; 12 | saveUser: RequestHandler; 13 | }; 14 | 15 | export type PuzzleController = { 16 | getPuzzleByNumber: RequestHandler; 17 | getUserPuzzles: RequestHandler; 18 | getNextPuzzle: RequestHandler; 19 | }; 20 | 21 | export type CookieController = { 22 | setSSIDCookie: RequestHandler; 23 | deleteSSIDCookie: RequestHandler; 24 | }; 25 | 26 | export type SessionController = { 27 | startSession: RequestHandler; 28 | findSession: RequestHandler; 29 | deleteSession: RequestHandler; 30 | }; 31 | 32 | export type UserDocument = { 33 | username: string; 34 | password: string; 35 | displayName: string; 36 | lastPuzzle: number; 37 | allPuzzles: UserPuzzleObj[]; 38 | _id: Types.ObjectId; 39 | }; 40 | 41 | export type Session = { 42 | cookieId: string; 43 | createdAt: Date; 44 | }; 45 | 46 | export type CustomErrorInput = { 47 | method: string; 48 | overview: string; 49 | status?: number; 50 | err: string | Error | unknown; 51 | }; 52 | 53 | export type CustomErrorOutput = { 54 | log: string; 55 | message: { 56 | err: string; 57 | }; 58 | status?: number; 59 | }; 60 | 61 | export type CustomErrorGenerator = (customErrorInput: CustomErrorInput) => CustomErrorOutput; 62 | 63 | export type BackendStatus = 'validStatus'; 64 | -------------------------------------------------------------------------------- /src/client/utils/puzzle-state-management-functions/puzzleStringsFromSquares.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { FilledSquares, PencilSquares } from '../../frontendTypes'; 3 | import { allSquareIds, puzzleVals } from './squareIdsAndPuzzleVals'; 4 | 5 | /** createProgressString 6 | * Takes an filledSquares object and creates a string representing the current state of the puzzle 7 | * 8 | * @param filledSquares current filledSquares object 9 | * @returns string representing the current state of the puzzle 10 | */ 11 | export const createProgressString = (filledSquares: FilledSquares): string => { 12 | let progress = ''; 13 | 14 | for (const squareId of allSquareIds) { 15 | if (filledSquares[squareId]) progress += filledSquares[squareId]?.puzzleVal; 16 | else progress += '0'; 17 | } 18 | 19 | return progress; 20 | }; 21 | 22 | /** createPencilProgressString 23 | * 24 | * Takes a pencilSquares object and returns a pencil string. Pencil strings are comprised of 25 | * squareIds followed by whatever numbers are present in for that square. For example, a pencil 26 | * square string representing pencilled in numbers 1 and 4 at square A1 and 5 and 8 at G6 is 27 | * "A114G658". 28 | * 29 | * @param pencilSquares - PencilSquares object 30 | * @returns 31 | */ 32 | export const createPencilProgressString = (pencilSquares: PencilSquares) => { 33 | let pencilProgress = ''; 34 | 35 | for (const squareId of allSquareIds) { 36 | if (pencilSquares[squareId]) { 37 | pencilProgress += squareId; 38 | for (const puzzleVal of puzzleVals) { 39 | if (pencilSquares[squareId]?.[puzzleVal]) pencilProgress += puzzleVal; 40 | } 41 | } 42 | } 43 | return pencilProgress; 44 | }; 45 | -------------------------------------------------------------------------------- /src/client/layouts/side-bar/WelcomeNavBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | // Types 5 | import { UserContextValue, SideBarProps } from '../../frontendTypes'; 6 | 7 | // Context 8 | import { userContext } from '../../context'; 9 | 10 | // Main Component 11 | const WelcomeNavBar = ({ collapseSideBar }: SideBarProps) => { 12 | const { setUser } = useContext(userContext); 13 | 14 | const handleGuest = () => { 15 | setUser({ 16 | username: 'guest', 17 | displayName: 'Guest', 18 | lastPuzzle: 0, 19 | allPuzzles: {} 20 | }); 21 | }; 22 | 23 | return ( 24 | <> 25 | {/*
*/} 26 | 52 | {/*
*/} 53 | 54 | ); 55 | }; 56 | 57 | export default WelcomeNavBar; 58 | -------------------------------------------------------------------------------- /src/client/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | // Types 4 | import { 5 | UserContextValue, 6 | PuzzleCollectionContextValue, 7 | PageContextValue, 8 | SquareContextValue, 9 | FilledSquares, 10 | PencilSquares, 11 | GameSettingContextValue 12 | } from './frontendTypes'; 13 | 14 | // Context 15 | export const userContext = createContext({ 16 | user: null, 17 | setUser: () => {} 18 | }); 19 | 20 | export const puzzleCollectionContext = createContext({ 21 | puzzleCollection: {}, 22 | setPuzzleCollection: () => {} 23 | }); 24 | 25 | export const pageContext = createContext({ 26 | pageInfo: { current: 'index' } 27 | }); 28 | 29 | export const squareContext = createContext({ 30 | puzzleNumber: 0, 31 | clickedSquare: null, 32 | setClickedSquare: () => {}, 33 | initialSquares: { 34 | originalPuzzleFilledSquares: {} as FilledSquares, 35 | filledSquares: {} as FilledSquares, 36 | pencilSquares: {} as PencilSquares 37 | }, 38 | pencilMode: false, 39 | setPencilMode: () => {}, 40 | filledSquares: {} as FilledSquares, 41 | setFilledSquares: () => {}, 42 | pencilSquares: {} as PencilSquares, 43 | setPencilSquares: () => {} 44 | }); 45 | 46 | export const gameSettingsContext = createContext({ 47 | darkMode: false, 48 | setDarkMode: () => {}, 49 | autoSave: false, 50 | setAutoSave: () => {}, 51 | highlightPeers: true, 52 | setHighlightPeers: () => {}, 53 | showDuplicates: true, 54 | setShowDuplicates: () => {}, 55 | trackMistakes: false, 56 | setTrackMistakes: () => {}, 57 | showMistakesOnPuzzlePage: false, 58 | setShowMistakesOnPuzzlePage: () => {} 59 | }); 60 | -------------------------------------------------------------------------------- /src/client/pages/PuzzleSelect/SavedPuzzleMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react'; 2 | 3 | // Types 4 | import { UserContextValue, PageContextValue, PuzzleNumberProp } from '../../frontendTypes'; 5 | import { User } from '../../../types'; 6 | 7 | // Contexts 8 | import { userContext, pageContext } from '../../context'; 9 | 10 | //Components 11 | import SavedPuzzleSelector from './components/SavedPuzzleSelector'; 12 | import Loading from '../../shared-components/Loading'; 13 | 14 | // Main Component 15 | const SavedPuzzleMenu = () => { 16 | const { user } = useContext(userContext); 17 | const { pageInfo } = useContext(pageContext); 18 | 19 | useEffect(() => { 20 | pageInfo.current = 'SavedPuzzleMenu'; 21 | }, [pageInfo]); 22 | 23 | return ( 24 | <> 25 | {!user ? ( 26 | 27 | ) : ( 28 | <> 29 |
30 | {

{user?.displayName}'s Saved Games

} 31 |
32 |

Choose a saved puzzle

33 | {createPuzzleLinks(user)} 34 |
35 |
36 | 37 | )} 38 | 39 | ); 40 | }; 41 | 42 | export default SavedPuzzleMenu; 43 | 44 | const createPuzzleLinks = (user: User) => { 45 | if (!user) return; 46 | 47 | const puzzleNumbers = Object.keys(user.allPuzzles).map((key) => Number(key)); 48 | puzzleNumbers.sort((a: number, b: number) => a - b); 49 | 50 | return puzzleNumbers.map((puzzleNumber) => { 51 | const props: PuzzleNumberProp = { puzzleNumber }; 52 | return ; 53 | }); 54 | }; 55 | -------------------------------------------------------------------------------- /src/client/pages/Welcome/Home.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | // import house from '../../assets/house.png'; 4 | 5 | // Types 6 | import { 7 | UserContextValue, 8 | PuzzleCollectionContextValue, 9 | PageContextValue 10 | } from '../../frontendTypes'; 11 | 12 | // Components 13 | import SiteInfo from '../../shared-components/SiteInfo'; 14 | 15 | // Context 16 | import { userContext, puzzleCollectionContext, pageContext } from '../../context'; 17 | 18 | // Utils 19 | import signInWithSession from '../../utils/signInWithSession'; 20 | 21 | const Home = () => { 22 | const navigate = useNavigate(); 23 | const { user, setUser } = useContext(userContext); 24 | const { setPuzzleCollection } = useContext(puzzleCollectionContext); 25 | const { pageInfo } = useContext(pageContext); 26 | 27 | useEffect(() => { 28 | // The first time the page renders, pageInfo will be index. In this case, 29 | // we can check for session data and update the user and puzzle collection if it exists 30 | if (pageInfo.current === 'index') { 31 | signInWithSession(setUser, setPuzzleCollection); 32 | pageInfo.current = 'login'; 33 | } 34 | }, [pageInfo, setUser, setPuzzleCollection]); 35 | 36 | useEffect(() => { 37 | // In the case that session data was found and user data stored in global context updated, 38 | // this useEffect will navigate the user to the UserLayout 39 | if (user !== null && pageInfo.current === 'login') { 40 | return navigate(`/${encodeURIComponent(user.username)}`); 41 | } 42 | }, [user, pageInfo, navigate]); 43 | 44 | return ; 45 | }; 46 | 47 | export default Home; 48 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/updateSolveSquares.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SolveSquares } from '../../types'; 3 | import { FilledSquares, PuzzleVal } from '../../client/frontendTypes'; 4 | 5 | // Utilities 6 | import { allPeers } from '../../client/utils/puzzle-state-management-functions/makeAllPeers'; 7 | import { allSquareIds } from '../../client/utils/puzzle-state-management-functions/squareIdsAndPuzzleVals'; 8 | 9 | /** updateSolveSquares 10 | * 11 | * Updates the set of possible values for a square stored on the solveSquares object based on the 12 | * puzzleVals present in the squares of filledSquares. solveSquares square sets are left containing 13 | * only the puzzleVals that could go in that square without causing a duplicate based on 14 | * filledSquares. solveSquares square sets are cleared for squares that are already present in 15 | * filledSquares. 16 | * 17 | * @param filledSquares 18 | * @param solveSquares 19 | * @returns A boolean, true if a change was made to the solveSquares argument 20 | */ 21 | export const updateSolveSquares = ( 22 | filledSquares: FilledSquares, 23 | solveSquares: SolveSquares 24 | ): boolean => { 25 | let changeMade = false; 26 | 27 | allSquareIds.forEach((squareId) => { 28 | if (filledSquares[squareId]) { 29 | solveSquares[squareId].clear(); 30 | } else { 31 | allPeers[squareId].forEach((peer) => { 32 | if (filledSquares[peer]) { 33 | const val = filledSquares[peer]?.puzzleVal as PuzzleVal; 34 | // console.log(`solveSquares[${squareId}]`, solveSquares[squareId]); 35 | // console.log(`typeof solveSquares[${squareId}]`, typeof solveSquares[squareId]); 36 | solveSquares[squareId].delete(val); 37 | changeMade = true; 38 | } 39 | }); 40 | } 41 | }); 42 | 43 | return changeMade; 44 | }; 45 | -------------------------------------------------------------------------------- /src/server/models/puzzleModel.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | 3 | // Types 4 | import { Puzzle } from '../../types'; 5 | 6 | // Each puzzle document will include the following details. The names of different techniques 7 | // denotes if said technique is required to solve the puzzle 8 | 9 | const puzzleSchema = new Schema({ 10 | puzzleNumber: Number, 11 | puzzle: String, 12 | solution: String, 13 | difficultyString: { 14 | type: String, 15 | default: 'easy' 16 | }, 17 | difficultyScore: { 18 | type: Number, 19 | default: 1 20 | }, 21 | uniqueSolution: { 22 | type: Boolean, 23 | default: false 24 | }, 25 | singleCandidate: { 26 | type: Boolean, 27 | default: true 28 | }, 29 | singlePosition: { 30 | type: Boolean, 31 | default: false 32 | }, 33 | candidateLines: { 34 | type: Boolean, 35 | default: false 36 | }, 37 | doublePairs: { 38 | type: Boolean, 39 | default: false 40 | }, 41 | multipleLines: { 42 | type: Boolean, 43 | default: false 44 | }, 45 | nakedPair: { 46 | type: Boolean, 47 | default: false 48 | }, 49 | hiddenPair: { 50 | type: Boolean, 51 | default: false 52 | }, 53 | nakedTriple: { 54 | type: Boolean, 55 | default: false 56 | }, 57 | hiddenTriple: { 58 | type: Boolean, 59 | default: false 60 | }, 61 | xWing: { 62 | type: Boolean, 63 | default: false 64 | }, 65 | forcingChains: { 66 | type: Boolean, 67 | default: false 68 | }, 69 | nakedQuad: { 70 | type: Boolean, 71 | default: false 72 | }, 73 | hiddenQuad: { 74 | type: Boolean, 75 | default: false 76 | }, 77 | swordfish: { 78 | type: Boolean, 79 | default: false 80 | } 81 | }); 82 | 83 | const PuzzleModel = model('puzzles', puzzleSchema); 84 | 85 | export default PuzzleModel; 86 | -------------------------------------------------------------------------------- /src/server/controllers/cookieController.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { RequestHandler } from 'express'; 3 | import { CookieController, CustomErrorGenerator, UserDocument } from '../backendTypes'; 4 | 5 | // Error generation helper function 6 | import controllerErrorMaker from '../utils/controllerErrorMaker'; 7 | const createErr: CustomErrorGenerator = controllerErrorMaker('cookieController'); 8 | 9 | //---SET SSID COOKIE ------------------------------------------------------------------------------- 10 | 11 | //ssid cookie value will be the logged in user's mongodb document id 12 | const setSSIDCookie: RequestHandler = async (req, res, next) => { 13 | // Make sure login/sign-up was successful and userDocument exists 14 | if (res.locals.status !== 'validUser' || res.locals.userDocument === null) { 15 | return next(); 16 | } 17 | 18 | //Extract Mongodb id from getUser middleware userDocument 19 | const userDocument: UserDocument = res.locals.userDocument; 20 | const userId = userDocument._id.toString(); 21 | 22 | try { 23 | //create new cookie with key:value of ssid: Mongo ObjectID 24 | res.cookie('ssid', userId, { 25 | secure: true, 26 | httpOnly: true, 27 | sameSite: 'lax' 28 | }); 29 | 30 | return next(); 31 | } catch (err) { 32 | return next( 33 | createErr({ 34 | method: 'setSSIDCookie', 35 | overview: 'creating setSSIDCookie for user', 36 | status: 400, 37 | err 38 | }) 39 | ); 40 | } 41 | }; 42 | 43 | //---DELETE SSID COOKIE ---------------------------------------------------------------------------- 44 | 45 | const deleteSSIDCookie: RequestHandler = async (req, res, next) => { 46 | res.clearCookie('ssid'); 47 | return next(); 48 | }; 49 | 50 | const cookieController: CookieController = { setSSIDCookie, deleteSSIDCookie }; 51 | 52 | export default cookieController; 53 | -------------------------------------------------------------------------------- /src/client/scss/_layouts.scss: -------------------------------------------------------------------------------- 1 | #header { 2 | background-color: aqua; 3 | width: 100%; 4 | padding: 4px; 5 | margin-bottom: 15px; 6 | } 7 | 8 | @mixin l-flex-column-align-center { 9 | display: flex; 10 | flex-direction: column; 11 | align-items: center; 12 | } 13 | 14 | @mixin l-menu-border { 15 | border: $menuBorderColor groove 7px; 16 | border-radius: 18px; 17 | } 18 | 19 | .centering-div { 20 | @include l-flex-column-align-center; 21 | } 22 | 23 | @mixin l-flex-row-justify-center { 24 | display: flex; 25 | justify-content: center; 26 | } 27 | 28 | @mixin l-flex-row-justify-start { 29 | display: flex; 30 | justify-content: flex-start; 31 | } 32 | 33 | @mixin l-flex-column-align-right { 34 | display: flex; 35 | flex-direction: column; 36 | align-items: flex-end; 37 | } 38 | 39 | @mixin l-flex-column-align-left { 40 | display: flex; 41 | flex-direction: column; 42 | align-items:flex-start; 43 | } 44 | 45 | .l-align-self-left { 46 | align-self: flex-start; 47 | } 48 | 49 | .l-align-left { 50 | align-items: flex-start; 51 | } 52 | 53 | $breakpoints: ( 54 | "xs": 320px, 55 | "sm": 496px, 56 | "md": 720px, 57 | "lg": 960px, 58 | "xl": 1200px 59 | ); 60 | 61 | @mixin xs { 62 | @media (min-width: map-get($breakpoints, "xs")){ 63 | @content; 64 | } 65 | } 66 | 67 | @mixin sm { 68 | @media (min-width: map-get($breakpoints, "sm")){ 69 | @content; 70 | } 71 | } 72 | 73 | @mixin md { 74 | @media (min-width: map-get($breakpoints, "md")){ 75 | @content; 76 | } 77 | } 78 | 79 | @mixin lg { 80 | @media (min-width: map-get($breakpoints, "lg")){ 81 | @content; 82 | } 83 | } 84 | 85 | @mixin xl { 86 | @media (min-width: map-get($breakpoints, "xl")){ 87 | @content; 88 | } 89 | } 90 | 91 | // Variable breakpoint with default of 0: 92 | @mixin breakpoint($bp: 0){ 93 | @media (min-width: $bp) { 94 | @content 95 | } 96 | } -------------------------------------------------------------------------------- /src/client/scss/modules/_side-bar.scss: -------------------------------------------------------------------------------- 1 | .side-bar-button { 2 | padding: 2px 4px; 3 | margin: 6px 10px; 4 | width: 32px; 5 | height: 32px; 6 | 7 | img { 8 | height: 24px; 9 | width: 24px; 10 | margin-left: -2px; 11 | } 12 | } 13 | 14 | .side-bar { 15 | position: absolute; 16 | margin-top: 4px; 17 | right: $sideSpaceMargin; 18 | max-height: max-content; 19 | overflow: hidden; 20 | background-color: #d3d8d7; 21 | border-radius: 5px; 22 | box-shadow: 2px 2px 6px grey; 23 | border: 1px solid grey; 24 | 25 | &.is-height-collapsed { 26 | border: none; 27 | } 28 | 29 | :first-child .side-bar-section-container__button { 30 | border-top: none; 31 | } 32 | } 33 | 34 | .side-bar-section-container { 35 | @include l-flex-column-align-right; 36 | 37 | &__button { 38 | cursor: pointer; 39 | width: 100%; 40 | font-size: 16px; 41 | padding: 4px 0; 42 | border-width: 1px; 43 | border-left: none; 44 | border-right: none; 45 | border-bottom: none; 46 | background-color: #efefef; 47 | 48 | &:hover { 49 | background-color: #e5e2e2; 50 | } 51 | } 52 | } 53 | 54 | .side-bar-section { 55 | @include l-flex-column-align-right; 56 | max-height: 400px; 57 | transition: all 0.75s ease-in-out; 58 | overflow: hidden; 59 | width: 100%; 60 | 61 | &__nav-link { 62 | color: $numberColor; 63 | padding: 4px 5px; 64 | width: 100%; 65 | text-align: right; 66 | text-decoration: none; 67 | 68 | &:hover, 69 | &.active { 70 | background-color: $nav-link-background; 71 | } 72 | } 73 | 74 | &__detail { 75 | color: $numberColor; 76 | padding: 4px 5px; 77 | 78 | input { 79 | vertical-align: middle; 80 | margin: 0 0 0 7px; 81 | } 82 | } 83 | } 84 | 85 | .game-stat-section { 86 | ul { 87 | margin: 0; 88 | padding: 0px 0px 0px 30px; 89 | } 90 | 91 | li { 92 | padding: 2px 0px; 93 | color: $numberColor; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/client/layouts/side-bar/SideBarContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import xSvg from '../../assets/x.svg'; 3 | import listSvg from '../../assets/list.svg'; 4 | 5 | // Types 6 | import { SideBarProps, SideBarContainerProps } from '../../frontendTypes'; 7 | 8 | // Main Component 9 | const SideBarContainer = ({ SideBar }: SideBarContainerProps) => { 10 | const [isSideBarExpanded, setIsSideBarExpanded] = useState(false); 11 | 12 | const switchSideBarExpanded = () => { 13 | setIsSideBarExpanded(!isSideBarExpanded); 14 | }; 15 | 16 | // If user clicks on something other than a navbar child link, collapse the nav bar 17 | // Each navlink also collapses the navbar, but the click has to register before the navbar 18 | // collapses. It's done this way instead of just collapsing on any blur as the onBlur event 19 | // is triggered before the navlink click, which prevented the navlink click from processing 20 | const exteriorBlurCollapseSideBar = (e: React.FocusEvent) => { 21 | if (!e.currentTarget.contains(e.relatedTarget)) { 22 | setIsSideBarExpanded(false); 23 | } 24 | }; 25 | 26 | const sideBarProps: SideBarProps = { 27 | collapseSideBar() { 28 | setIsSideBarExpanded(false); 29 | } 30 | }; 31 | 32 | return ( 33 | <> 34 |
exteriorBlurCollapseSideBar(e)} 39 | > 40 | 47 |
48 | 49 |
50 |
51 | 52 | ); 53 | }; 54 | 55 | export default SideBarContainer; 56 | -------------------------------------------------------------------------------- /src/client/utils/puzzle-state-management-functions/puzzleStringValidation.ts: -------------------------------------------------------------------------------- 1 | /** isValidPuzzleString 2 | * 3 | * Checks input parameter to see if string if exactly 81 characters long and each character is 4 | * a string representation of the numbers 0-9 5 | * 6 | * @param {string} puzzleString A string to be tested to see if it's a valid sudoku puzzle 7 | * @returns boolean 8 | */ 9 | export const isValidPuzzleString = (puzzleString: string): boolean => { 10 | if (puzzleString.length !== 81) { 11 | return false; 12 | } 13 | 14 | const numStringRegex = /[0123456789]/; 15 | let result = true; 16 | 17 | for (let i = 0; i < puzzleString.length; i += 1) { 18 | if (!numStringRegex.test(puzzleString[i])) { 19 | result = false; 20 | } 21 | } 22 | return result; 23 | }; 24 | 25 | /** isValidPencilString 26 | * 27 | * Checks an incoming pencil string to make sure it's a valid string. Pencil strings are comprised 28 | * of squareIds followed by whatever numbers are present in for that square. For example, a pencil 29 | * square string representing pencilled in numbers 1 and 4 at square A1 and 5 and 8 at G6 is 30 | * "A114G658". 31 | * Returns true if the string matches the pattern, and false if not 32 | * 33 | * @param pencilString 34 | * @returns boolean 35 | */ 36 | export const isValidPencilString = (pencilString: string): boolean => { 37 | // First check to see if the general shape is correct 38 | const pencilRegex = /[A-I][1-9]{2,10}/g; 39 | const matches = pencilString.match(pencilRegex); 40 | if (!matches) return false; 41 | 42 | const joinedMatches = matches.join(''); 43 | if (joinedMatches.length !== pencilString.length) return false; 44 | 45 | // Then check to make sure there are no duplicate squareIds 46 | const squareRegex = /[A-I][1-9]/g; 47 | const squareMatches = pencilString.match(squareRegex); 48 | if (!squareMatches) return false; 49 | 50 | const uniqueMatches = new Set(squareMatches); 51 | return squareMatches.length === uniqueMatches.size; 52 | }; 53 | -------------------------------------------------------------------------------- /src/client/utils/puzzle-state-management-functions/deepCopySquares.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { 3 | SquareId, 4 | PuzzleVal, 5 | FilledSquares, 6 | FilledSquare, 7 | PencilSquares, 8 | PencilSquare, 9 | PencilData 10 | } from '../../frontendTypes'; 11 | 12 | /** deepCopyFilledSquares 13 | * 14 | * Returns a deep copy of a filledSquares object so that said deep copy can be altered and used to 15 | * replace the state of filledSquares in PuzzlePage.tsx 16 | * 17 | * @param filledSquares - FilledSquares object 18 | * @returns FilledSquares object 19 | */ 20 | export const deepCopyFilledSquares = (filledSquares: FilledSquares) => { 21 | const newFilledSquares: FilledSquares = { size: filledSquares.size }; 22 | const squareIds = Object.keys(filledSquares).filter((key) => key !== 'size') as SquareId[]; 23 | for (const squareId of squareIds) { 24 | newFilledSquares[squareId] = { ...(filledSquares[squareId] as FilledSquare) }; 25 | } 26 | return newFilledSquares; 27 | }; 28 | 29 | /** deepCopyPencilSquares 30 | * 31 | * Returns a deep copy of a pencilSquares object so that said deep copy can be altered and used to 32 | * replace the state of pencilSquares in PuzzlePage.tsx 33 | * 34 | * @param pencilSquares - PencilSquares object 35 | * @returns PencilSquares object 36 | */ 37 | export const deepCopyPencilSquares = (pencilSquares: PencilSquares) => { 38 | const newPencilSquares: PencilSquares = {}; 39 | const squareIds = Object.keys(pencilSquares) as SquareId[]; 40 | for (const squareId of squareIds) { 41 | const pencilSquare = pencilSquares[squareId] as PencilSquare; 42 | const puzzleVals = Object.keys(pencilSquare).filter((key) => key !== 'size') as PuzzleVal[]; 43 | newPencilSquares[squareId] = { size: pencilSquare.size }; 44 | const newPencilSquare = newPencilSquares[squareId] as PencilSquare; 45 | for (const puzzleVal of puzzleVals) { 46 | newPencilSquare[puzzleVal] = { ...(pencilSquare[puzzleVal] as PencilData) }; 47 | } 48 | } 49 | return newPencilSquares; 50 | }; 51 | -------------------------------------------------------------------------------- /src/client/pages/PuzzleSelect/components/SavedPuzzleSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | // Types 5 | import { PuzzleNumberProp, UserContextValue } from '../../../frontendTypes'; 6 | 7 | // Context 8 | import { userContext } from '../../../context'; 9 | 10 | // Components 11 | import SavedPuzzleGraphic from './SavedPuzzleGraphic'; 12 | import GameStats from '../../../shared-components/GameStats'; 13 | import Loading from '../../../shared-components/Loading'; 14 | 15 | // Main Component 16 | const SavedPuzzleSelector = (props: PuzzleNumberProp) => { 17 | const { puzzleNumber } = props; 18 | const { user } = useContext(userContext); 19 | const [showGameStats, setShowGameStats] = useState(false); 20 | 21 | return ( 22 | <> 23 | {typeof puzzleNumber === 'number' && user?.allPuzzles?.[puzzleNumber]?.progress ? ( 24 |
25 |
29 | 33 | {puzzleNumber} 34 | 35 | 36 |
37 |
38 | 44 |
45 | {showGameStats && } 46 |
47 | ) : ( 48 | 49 | )} 50 | 51 | ); 52 | }; 53 | 54 | export default SavedPuzzleSelector; 55 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/solutionDictionary.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SolutionFunctionDictionary, SolutionStringDictionary } from '../../types'; 3 | 4 | // Utilities 5 | import { singlePositionSolver } from './singlePositionSolver'; 6 | import { singleCandidateSolver } from './singleCandidateSolver'; 7 | import { candidateLinesSolver } from './candidateLinesSolver'; 8 | import { doublePairsSolver } from './doublePairsSolver'; 9 | import { multipleLinesSolver } from './multipleLinesSolver'; 10 | import { nakedPairSolver, nakedTripleSolver, nakedQuadSolver } from './nakedSubsetSolver'; 11 | import { hiddenPairSolver, hiddenTripleSolver, hiddenQuadSolver } from './hiddenSubsetSolver'; 12 | import { xWingSolver } from './xWingSolver'; 13 | import { forcingChainsSolver } from './forcingChainsSolver'; 14 | import { swordfishSolver } from './swordfishSolver'; 15 | 16 | export const solutionFunctionDictionary: SolutionFunctionDictionary = { 17 | singlePosition: singlePositionSolver, 18 | singleCandidate: singleCandidateSolver, 19 | candidateLines: candidateLinesSolver, 20 | doublePairs: doublePairsSolver, 21 | multipleLines: multipleLinesSolver, 22 | nakedPair: nakedPairSolver, 23 | hiddenPair: hiddenPairSolver, 24 | nakedTriple: nakedTripleSolver, 25 | hiddenTriple: hiddenTripleSolver, 26 | xWing: xWingSolver, 27 | forcingChains: forcingChainsSolver, 28 | nakedQuad: nakedQuadSolver, 29 | hiddenQuad: hiddenQuadSolver, 30 | swordfish: swordfishSolver 31 | }; 32 | 33 | export const solutionStringDictionary: SolutionStringDictionary = { 34 | singlePosition: 'Single Position', 35 | singleCandidate: 'Single Candidate', 36 | candidateLines: 'Candidate Lines', 37 | doublePairs: 'Double Pairs', 38 | multipleLines: 'Multiple Lines', 39 | nakedPair: 'Naked Pair', 40 | hiddenPair: 'Hidden Pair', 41 | nakedTriple: 'Naked Triple', 42 | hiddenTriple: 'Hidden Triple', 43 | xWing: 'X-Wing', 44 | forcingChains: 'Forcing Chains', 45 | nakedQuad: 'Naked Quad', 46 | hiddenQuad: 'Hidden Quad', 47 | swordfish: 'Swordfish' 48 | }; 49 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/populateSolveSquaresIfEmpty.ts: -------------------------------------------------------------------------------- 1 | import { SolveSquares } from '../../types'; 2 | import { 3 | puzzleVals, 4 | allSquareIds 5 | } from '../../client/utils/puzzle-state-management-functions/squareIdsAndPuzzleVals'; 6 | import { FilledSquares, PuzzleVal } from '../../client/frontendTypes'; 7 | import { allPeers } from '../../client/utils/puzzle-state-management-functions/makeAllPeers'; 8 | import { updateSolveSquares } from './updateSolveSquares'; 9 | 10 | const isSolveSquaresEmpty = (solveSquares: SolveSquares) => { 11 | let isEmpty = true; 12 | for (const squareId of allSquareIds) { 13 | if (solveSquares[squareId].size > 0) { 14 | isEmpty = false; 15 | break; 16 | } 17 | } 18 | return isEmpty; 19 | }; 20 | 21 | const populateSolveSquares = (filledSquares: FilledSquares, solveSquares: SolveSquares) => { 22 | for (const squareId of allSquareIds) { 23 | if (filledSquares[squareId]) { 24 | solveSquares[squareId] = new Set(); 25 | } else { 26 | solveSquares[squareId] = new Set(puzzleVals); 27 | allPeers[squareId].forEach((peer) => { 28 | if (filledSquares[peer]) { 29 | const val = filledSquares[peer]?.puzzleVal as PuzzleVal; 30 | // console.log(`solveSquares[${squareId}]`, solveSquares[squareId]); 31 | // console.log(`typeof solveSquares[${squareId}]`, typeof solveSquares[squareId]); 32 | solveSquares[squareId].delete(val); 33 | } 34 | }); 35 | } 36 | } 37 | }; 38 | 39 | export const populateSolveSquaresIfEmpty = ( 40 | filledSquares: FilledSquares, 41 | solveSquares: SolveSquares 42 | ) => { 43 | if (!isSolveSquaresEmpty(solveSquares)) return; 44 | populateSolveSquares(filledSquares, solveSquares); 45 | }; 46 | 47 | export const updateOrPopulateSolveSquares = ( 48 | filledSquares: FilledSquares, 49 | solveSquares: SolveSquares 50 | ) => { 51 | if (isSolveSquaresEmpty(solveSquares)) { 52 | populateSolveSquares(filledSquares, solveSquares); 53 | } else { 54 | updateSolveSquares(filledSquares, solveSquares); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /src/client/layouts/side-bar/GameSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from 'react'; 2 | 3 | // Types 4 | import { SettingsToggleProps, GameSettingContextValue } from '../../frontendTypes'; 5 | 6 | // Context 7 | import { gameSettingsContext } from '../../context'; 8 | 9 | // Components 10 | import SettingsToggle from './SettingsToggle'; 11 | 12 | const GameSettings = () => { 13 | const { 14 | // darkMode, 15 | // setDarkMode, 16 | // autoSave, 17 | // setAutoSave, 18 | highlightPeers, 19 | setHighlightPeers, 20 | showDuplicates, 21 | setShowDuplicates 22 | // trackMistakes, 23 | // setTrackMistakes, 24 | // showMistakesOnPuzzlePage, 25 | // setShowMistakesOnPuzzlePage 26 | } = useContext(gameSettingsContext); 27 | 28 | const settingsDetails = useMemo(() => { 29 | const settingsArray: SettingsToggleProps[] = [ 30 | // { label: 'Dark Mode', state: darkMode, setState: setDarkMode }, 31 | // { label: 'Auto-save', state: autoSave, setState: setAutoSave }, 32 | { label: 'Highlight Peers', state: highlightPeers, setState: setHighlightPeers }, 33 | { label: 'Show Duplicates', state: showDuplicates, setState: setShowDuplicates } 34 | // { label: 'Track Mistakes', state: trackMistakes, setState: setTrackMistakes }, 35 | // { 36 | // label: 'Show Mistakes', 37 | // state: showMistakesOnPuzzlePage, 38 | // setState: setShowMistakesOnPuzzlePage 39 | // } 40 | ]; 41 | 42 | return generateSettingsDetails(settingsArray); 43 | }, [highlightPeers, setHighlightPeers, showDuplicates, setShowDuplicates]); 44 | 45 | return
{settingsDetails}
; 46 | }; 47 | 48 | export default GameSettings; 49 | 50 | // Helper Functions 51 | const generateSettingsDetails = (settingsArray: SettingsToggleProps[]) => { 52 | const gameSettingsComponents: React.JSX.Element[] = []; 53 | for (const props of settingsArray) { 54 | gameSettingsComponents.push(); 55 | } 56 | return gameSettingsComponents; 57 | }; 58 | -------------------------------------------------------------------------------- /src/client/pages/Puzzle/components/PuzzleContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | // Components 4 | import BoxUnitContainer from './BoxUnitContainer'; 5 | 6 | // Utilities 7 | import { boxes } from '../../../utils/puzzle-state-management-functions/squareIdsAndPuzzleVals'; 8 | 9 | // Main Component 10 | const PuzzleContainer = () => { 11 | const generatedBoxes = useMemo(generateBoxes, []); 12 | 13 | return ( 14 |
15 | {generatedBoxes} 16 |
17 | ); 18 | }; 19 | 20 | export default PuzzleContainer; 21 | 22 | // Helper Functions 23 | /** generateBoxes 24 | * 25 | * Each set in boxes is a set of the squareIds that go in each of the 9 larger squares of the 26 | * Sudoku puzzle. For reference, each sudoku grid row corresponds to a letter 'A' to 'I' and each 27 | * sudoku grid column corresponds to a number '1' to '9'. 28 | * For example: 29 | * boxes[0] = Set{'A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3'}, 30 | * boxes[1] = Set{'A4', 'A5', 'A6', 'B4', 'B5', 'B6', 'C4', 'C5', 'C6'}, etc. 31 | * 32 | * generateBoxes sequentially passes each boxes Set to one of 9 BoxUnitContainer components, 33 | * which are pushed to an array to be returned from this function. 34 | * This array is meant to be rendered by the PuzzleContainer component with CSS grid styling such 35 | * that the 9 BoxUnitContainer components are located in 3 rows of 3 BoxUnitContainer components. 36 | * Each BoxUnitContainer component will eventually render a 3x3 grid of 9 SquareContainer components 37 | * within it, based on the squareIds from its boxUnit Set. The sequential rendering of these 38 | * boxes and squareIds results in a grid of 81 squares with correctly positioned squareIds. 39 | * 40 | * @returns array of jsx elements to be rendered in PuzzleContainer component 41 | */ 42 | function generateBoxes(): React.JSX.Element[] { 43 | const boxUnitContainers: React.JSX.Element[] = []; 44 | boxes.forEach((boxUnit, i) => { 45 | boxUnitContainers.push(); 46 | }); 47 | 48 | return boxUnitContainers; 49 | } 50 | -------------------------------------------------------------------------------- /src/client/pages/Puzzle/components/PencilSquareDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from 'react'; 2 | 3 | // Types 4 | import { 5 | SquareId, 6 | PuzzleVal, 7 | SquareProps, 8 | PencilSquares, 9 | PencilSquare, 10 | PencilData, 11 | SquareContextValue, 12 | GameSettingContextValue 13 | } from '../../../frontendTypes'; 14 | 15 | // Context 16 | import { squareContext, gameSettingsContext } from '../../../context'; 17 | 18 | // Main Component 19 | const PencilSquareDisplay = (props: SquareProps) => { 20 | const { squareId, squareClasses, onSquareClick } = props; 21 | const { pencilSquares } = useContext(squareContext); 22 | const { showDuplicates } = useContext(gameSettingsContext); 23 | const pencilSquareGrid = useMemo( 24 | () => makePencilGrid(squareId, pencilSquares, showDuplicates), 25 | [squareId, pencilSquares, showDuplicates] 26 | ); 27 | 28 | return ( 29 |
onSquareClick(event)} 33 | > 34 | {pencilSquareGrid} 35 |
36 | ); 37 | }; 38 | 39 | export default PencilSquareDisplay; 40 | 41 | // Helper Functions 42 | const puzzleVals: PuzzleVal[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; 43 | 44 | function makePencilGrid(squareId: SquareId, pencilSquares: PencilSquares, showDuplicates: boolean) { 45 | const pencilSquare = pencilSquares[squareId] as PencilSquare; 46 | const pencilGrid: React.JSX.Element[] = []; 47 | if (pencilSquare.size === 0) { 48 | return pencilGrid; 49 | } 50 | 51 | for (const puzzleVal of puzzleVals) { 52 | let classes = 'pencil-val-div'; 53 | if (pencilSquare[puzzleVal]) { 54 | const pencilData = pencilSquare[puzzleVal] as PencilData; 55 | if (showDuplicates && pencilData.duplicate) { 56 | classes += ' duplicate-number'; 57 | } 58 | pencilGrid.push( 59 |
60 | {puzzleVal} 61 |
62 | ); 63 | } else { 64 | pencilGrid.push(
); 65 | } 66 | } 67 | return pencilGrid; 68 | } 69 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/singleCandidateSolver.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SolveTechnique } from '../../types'; 3 | import { PuzzleVal } from '../../client/frontendTypes'; 4 | 5 | // Utilities 6 | import { allSquareIds } from '../../client/utils/puzzle-state-management-functions/squareIdsAndPuzzleVals'; 7 | import { updateSolveSquares } from './updateSolveSquares'; 8 | import { newFilledSquare } from '../../client/utils/puzzle-state-management-functions/newFilledSquare'; 9 | import { updateOrPopulateSolveSquares } from './populateSolveSquaresIfEmpty'; 10 | 11 | /** singleCandidateSolver 12 | * 13 | * Sequentially checks every square. If a square has a possibleVal set with size 1, it takes the 14 | * value from the set and assigns it to the puzzleVal, then it removes it from the possibleVal set. 15 | * If this happens, the function returns true. If the function iterates over the entire allSquares 16 | * object without finding a possibleVal set of size 1, it returns false. 17 | * 18 | * @param filledSquares 19 | * @param solveSquares 20 | * @param solutionCache object which tracks how many times a particular solution technique is used 21 | * @returns A boolean, true if a change was made to the allSquares argument 22 | */ 23 | 24 | export const singleCandidateSolver: SolveTechnique = ( 25 | filledSquares, 26 | solveSquares, 27 | solutionCache 28 | ) => { 29 | updateOrPopulateSolveSquares(filledSquares, solveSquares); 30 | for (const squareId of allSquareIds) { 31 | if (solveSquares[squareId].size === 1) { 32 | const val = solveSquares[squareId].values().next().value as PuzzleVal; 33 | filledSquares[squareId] = newFilledSquare(val, false); 34 | filledSquares.size += 1; 35 | solutionCache.singleCandidate += 1; 36 | solveSquares[squareId].clear(); 37 | updateSolveSquares(filledSquares, solveSquares); 38 | // console.log( 39 | // 'singleCandidateSolver:', 40 | // squareId, 41 | // 'puzzleVal set to', 42 | // val, 43 | // `, solveSquares[${squareId}] size is now`, 44 | // solveSquares[squareId].size 45 | // ); 46 | return true; 47 | } 48 | } 49 | // console.log('solutionCache.singleCandidate:', solutionCache.singleCandidate); 50 | return false; 51 | }; 52 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import express, { NextFunction, Request, Response } from 'express'; 2 | import cookieParser from 'cookie-parser'; 3 | import mongoose, { Error } from 'mongoose'; 4 | import path from 'path'; 5 | import logger from 'morgan'; 6 | import usersRouter from './routes/userRouter'; 7 | import puzzleRouter from './routes/puzzleRouter'; 8 | import { CustomErrorOutput } from './backendTypes'; 9 | 10 | const app = express(); 11 | const PORT = 3000; 12 | 13 | // const dotenv = require('dotenv'); 14 | // dotenv.config(); 15 | 16 | // Allow guests to access database for now 17 | const MONGO_URI = 18 | 'mongodb+srv://markteets:PV0m4ZjwEg3wZwIT@sudoku-db.ox6sdpn.mongodb.net/?retryWrites=true&w=majority'; 19 | //const MONGO_URI = process.env.SUDOKU_MONGO_URI; 20 | 21 | mongoose 22 | .connect(MONGO_URI, { 23 | dbName: 'sudoku' 24 | }) 25 | .then(() => console.log('Connected to Mongo DB!')) 26 | .catch((err) => console.log('Database error: ', err.message)); 27 | 28 | app.use(logger(':date[web] :method :url :status :response-time ms - :res[content-length]')); 29 | app.use(express.json()); 30 | app.use(express.urlencoded({ extended: true })); 31 | app.use(cookieParser()); 32 | // This first express.static will serve the bundled version of the frontend at dist/index.html when 33 | // a browser request is made to localhost:3000. Used for Docker, so the server can run with this 34 | // saved version of the frontend 35 | app.use(express.static(path.join(__dirname, '../../dist/'))); 36 | app.use('/assets', express.static(path.join(__dirname, '../client/assets'))); 37 | 38 | app.use('/api/user', usersRouter); 39 | 40 | app.use('/api/puzzle', puzzleRouter); 41 | 42 | app.use('/', (req: Request, res: Response) => { 43 | res.status(404).send('Nothing to see here!'); 44 | }); 45 | 46 | //Global error handler 47 | app.use((err: Error | CustomErrorOutput, req: Request, res: Response, next: NextFunction) => { 48 | const defaultErr = { 49 | log: 'Express error handler caught unknown middleware error', 50 | status: 500, 51 | message: { err: 'A server error occurred' } 52 | }; 53 | const errorObj = Object.assign({}, defaultErr, err); 54 | console.log(errorObj.log); 55 | return res.status(errorObj.status).json(errorObj.message); 56 | }); 57 | 58 | app.listen(PORT, () => { 59 | console.log(`Server listening on port: ${PORT}`); 60 | }); 61 | 62 | module.exports = app; 63 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require('path'); 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | const HTMLWebpackPlugin = require('html-webpack-plugin'); 5 | 6 | module.exports = { 7 | entry: './src/client/index.tsx', 8 | 9 | output: { 10 | path: path.join(__dirname, '/dist'), 11 | filename: 'bundle.js', 12 | publicPath: '/' 13 | }, 14 | 15 | plugins: [ 16 | new HTMLWebpackPlugin({ 17 | template: './src/client/index.html' 18 | }) 19 | ], 20 | mode: process.env.NODE_ENV, 21 | 22 | devServer: { 23 | // Required for Docker to work with dev server 24 | host: '0.0.0.0', 25 | // host: 'localhost', 26 | port: 8080, 27 | //enable HMR on the devServer 28 | hot: true, 29 | // for react router 30 | historyApiFallback: true, 31 | 32 | static: { 33 | // match the output path 34 | directory: path.join(__dirname, '/dist'), 35 | //match the output 'publicPath' 36 | publicPath: '/' 37 | }, 38 | 39 | headers: { 'Access-Control-Allow-Origin': '*' }, 40 | 41 | proxy: { 42 | '/api/**': { 43 | target: 'http://localhost:3000/', 44 | secure: false 45 | }, 46 | '/assets/**': { 47 | target: 'http://localhost:3000/', 48 | secure: false 49 | } 50 | } 51 | }, 52 | 53 | module: { 54 | rules: [ 55 | { 56 | test: /.jsx?/, 57 | exclude: /node_modules/, 58 | use: { 59 | loader: 'babel-loader', 60 | options: { 61 | presets: ['@babel/preset-env', '@babel/preset-react'] 62 | } 63 | } 64 | }, 65 | { 66 | test: /\.tsx?/, 67 | exclude: /node_modules/, 68 | use: ['ts-loader'], 69 | type: 'javascript/auto' 70 | }, 71 | { 72 | test: /.(css|s[ac]ss)$/i, 73 | use: [ 74 | // Creates `style` nodes from JS strings 75 | 'style-loader', 76 | // Translates CSS into CommonJS 77 | 'css-loader', 78 | // Compiles Sass to CSS 79 | 'sass-loader' 80 | ] 81 | }, 82 | { 83 | test: /\.(png|jpe?g|gif|svg)$/i, 84 | type: 'asset/resource' 85 | } 86 | ] 87 | }, 88 | resolve: { 89 | extensions: ['.js', '.jsx', '.ts', '.tsx'] 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/client/utils/addPuzzleToUserAndCollection.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SetUser, SetPuzzleCollection } from '../frontendTypes'; 3 | import { User, Puzzle, PuzzleCollection } from '../../types'; 4 | 5 | /**addPuzzleToUserAndCollection 6 | * 7 | * Takes a puzzle document from the database's puzzles collection and adds it to the user's 8 | * allPuzzles object and to the puzzleCollection without mutating existing state. This makes it 9 | * available for use when rendering the PuzzlePage component. 10 | * 11 | * @param puzzleNumber 12 | * @param fetchedPuzzleData - puzzle document from the database's puzzles collection 13 | * @param user - Global context object, holds username, displayName, and allPuzzles object which 14 | * holds a user's progress on each puzzle they've saved 15 | * @param setUser - Function for setting global user object 16 | * @param puzzleCollection - Global context object, holds information for each puzzle 17 | * @param setPuzzleCollection - Function for setting global puzzleCollection object 18 | */ 19 | export const addPuzzleToUserAndCollection = ( 20 | puzzleNumber: number, 21 | fetchedPuzzleData: Puzzle, 22 | user: User, 23 | setUser: SetUser, 24 | puzzleCollection: PuzzleCollection, 25 | setPuzzleCollection: SetPuzzleCollection 26 | ) => { 27 | if (!user) return; 28 | 29 | const newUser = { 30 | ...user, 31 | lastPuzzle: puzzleNumber, 32 | allPuzzles: { ...user.allPuzzles } 33 | }; 34 | 35 | newUser.allPuzzles[puzzleNumber] = { 36 | puzzleNumber, 37 | progress: fetchedPuzzleData.puzzle, 38 | pencilProgress: '' 39 | }; 40 | 41 | setUser(newUser); 42 | 43 | // Every puzzle in a user's allPuzzles object will be added to the puzzleCollection object when 44 | // the user logs in, therefore we only need to add the puzzle to the puzzle collection after 45 | // confirming it's not already in allPuzzles 46 | 47 | // Check to see if the puzzle is already in the puzzleCollection just in case they switched users 48 | // and it's already there. If it's not there, add it 49 | if (!puzzleCollection[puzzleNumber]) { 50 | const newPuzzleCollection = { ...puzzleCollection }; 51 | for (const [number, puzzleObject] of Object.entries(puzzleCollection)) { 52 | newPuzzleCollection[Number(number)] = { ...puzzleObject }; 53 | } 54 | newPuzzleCollection[puzzleNumber] = fetchedPuzzleData; 55 | setPuzzleCollection(newPuzzleCollection); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/server/routes/userRouter.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | const userRouter = express.Router(); 3 | 4 | import userController from '../controllers/userController'; 5 | import puzzleController from '../controllers/puzzleController'; 6 | import cookieController from '../controllers/cookieController'; 7 | import sessionController from '../controllers/sessionController'; 8 | 9 | userRouter.post('/sign-up', 10 | userController.getUser, 11 | userController.createUser, 12 | userController.cleanUser, 13 | cookieController.setSSIDCookie, 14 | sessionController.startSession, 15 | (req: Request, res: Response) => { 16 | res.status(200).json(res.locals.frontendData); 17 | } 18 | ); 19 | 20 | userRouter.post('/login', 21 | userController.getUser, 22 | userController.verifyUser, 23 | userController.cleanUser, 24 | puzzleController.getUserPuzzles, 25 | cookieController.setSSIDCookie, 26 | sessionController.startSession, 27 | (req: Request, res: Response) => { 28 | res.status(200).json(res.locals.frontendData); 29 | } 30 | ); 31 | 32 | userRouter.get('/resume-session', 33 | sessionController.findSession, 34 | userController.getUser, 35 | userController.cleanUser, 36 | puzzleController.getUserPuzzles, 37 | sessionController.startSession, 38 | (req: Request, res: Response) => { 39 | res.status(200).json(res.locals.frontendData); 40 | } 41 | ); 42 | 43 | userRouter.get('/no-session', 44 | (req: Request, res: Response) => { 45 | res.status(200).json({ status: 'noSession' }); 46 | } 47 | ); 48 | 49 | userRouter.delete('/log-out', 50 | userController.getUser, 51 | // Need to play with the statuses here, 52 | // both saveUser and deleteSession set status to valid on res.locals.frontendData 53 | userController.saveUser, 54 | sessionController.deleteSession, 55 | cookieController.deleteSSIDCookie, 56 | (req: Request, res: Response) => { 57 | res.status(200).json(res.locals.frontendData); 58 | } 59 | ); 60 | 61 | userRouter.post('/save-puzzle', 62 | userController.getUser, 63 | userController.savePuzzle, 64 | (req: Request, res: Response) => { 65 | res.status(200).json(res.locals.frontendData); 66 | } 67 | ); 68 | 69 | userRouter.post('/save-user', 70 | userController.getUser, 71 | userController.saveUser, 72 | (req: Request, res: Response) => { 73 | res.status(200).json(res.locals.frontendData); 74 | } 75 | ); 76 | 77 | export default userRouter; 78 | -------------------------------------------------------------------------------- /src/client/scss/modules/_userNavbar.scss: -------------------------------------------------------------------------------- 1 | $nav-link-background: #58c2c6; 2 | 3 | .user-side-bar-container { 4 | width: max-content; 5 | position: absolute; 6 | top: 0px; 7 | right: 0px; 8 | } 9 | 10 | .user-side-bar { 11 | position: absolute; 12 | top: 0px; 13 | right: 0px; 14 | border-radius: 5px; 15 | background-color: $buttonBackground; 16 | transition: all 0.5s ease-in-out; 17 | width: 162px; 18 | min-height: 100vh; 19 | text-wrap: none; 20 | overflow-x: hidden; 21 | } 22 | 23 | #side-bar-button { 24 | padding: 2px 4px; 25 | margin: 6px 10px; 26 | width: 32px; 27 | height: 32px; 28 | 29 | img { 30 | height: 24px; 31 | width: 24px; 32 | margin-left: -2px; 33 | } 34 | } 35 | 36 | .user-side-bar.inactive { 37 | width: 0px; 38 | // border: none; 39 | } 40 | 41 | .user-side-bar-section-container { 42 | padding: 2px; 43 | } 44 | 45 | .user-side-bar-section-container-button { 46 | all: unset; 47 | box-sizing: border-box; 48 | display: inline-block; 49 | text-align: center; 50 | padding: 1px 6px; 51 | width: 156px; 52 | border: 1px solid $bigBorderColor; 53 | border-radius: 5px; 54 | 55 | } 56 | .user-side-bar-section-container-button:focus{ 57 | border: 1px solid $numberColor; 58 | } 59 | 60 | .user-side-bar-section { 61 | transition: all 0.5s ease-in-out; 62 | max-height: 400px; 63 | overflow: hidden; 64 | } 65 | 66 | .user-side-bar-section.inactive { 67 | max-height: 0px; 68 | } 69 | 70 | .flex-column { 71 | display: flex; 72 | flex-direction: column; 73 | } 74 | 75 | .side-bar-section-content { 76 | margin-bottom: 2px; 77 | margin-top: 2px; 78 | } 79 | 80 | .side-bar-section-content .side-bar-detail { 81 | align-items: flex-start; 82 | } 83 | 84 | .side-bar-section-content .nav-link, .side-bar-detail { 85 | width: 156px; 86 | color: $numberColor; 87 | padding: 4px 5px; 88 | } 89 | 90 | .side-bar-section-content .nav-link{ 91 | padding-top: 5px; 92 | padding-bottom: 5px; 93 | } 94 | 95 | .side-bar-section-content .active { 96 | background-color: $nav-link-background; 97 | border-radius: 5px; 98 | } 99 | 100 | .side-bar-detail input { 101 | vertical-align: middle; 102 | margin: 0 0 0 7px; 103 | } 104 | 105 | .side-bar-section-content ul { 106 | margin: 0; 107 | padding: 0px 0px 0px 30px; 108 | } 109 | 110 | .side-bar-section-content li { 111 | padding: 2px 0px; 112 | color: $numberColor; 113 | } -------------------------------------------------------------------------------- /src/client/pages/Puzzle/components/SquareContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from 'react'; 2 | 3 | // Types 4 | import { 5 | SquareId, 6 | ClickedSquare, 7 | OnSquareClick, 8 | SquareContainerProps, 9 | SquareContextValue, 10 | GameSettingContextValue, 11 | SquareProps 12 | } from '../../../frontendTypes'; 13 | 14 | // Components 15 | import FilledSquareDisplay from './FilledSquareDisplay'; 16 | import PencilSquareDisplay from './PencilSquareDisplay'; 17 | import EmptySquareDisplay from './EmptySquareDisplay'; 18 | 19 | // Context 20 | import { squareContext, gameSettingsContext } from '../../../context'; 21 | 22 | // Utilities 23 | import { allPeers } from '../../../utils/puzzle-state-management-functions/makeAllPeers'; 24 | 25 | // Main Component 26 | const SquareContainer = ({ squareId }: SquareContainerProps) => { 27 | const { clickedSquare, setClickedSquare, filledSquares, pencilSquares } = 28 | useContext(squareContext); 29 | const { highlightPeers } = useContext(gameSettingsContext); 30 | const squareClasses = useMemo( 31 | () => generateSquareClasses(squareId, clickedSquare, highlightPeers), 32 | [squareId, clickedSquare, highlightPeers] 33 | ); 34 | 35 | const onSquareClick: OnSquareClick = (event) => { 36 | setClickedSquare(event.currentTarget.dataset.square as SquareId); 37 | // console.log('clickedSquare:', event.currentTarget.dataset.square); 38 | }; 39 | 40 | const squareProps: SquareProps = { 41 | squareId, 42 | squareClasses, 43 | onSquareClick 44 | }; 45 | 46 | if (filledSquares[squareId]) { 47 | return ; 48 | } 49 | 50 | if (pencilSquares[squareId]) { 51 | return ; 52 | } 53 | 54 | return ; 55 | }; 56 | 57 | export default SquareContainer; 58 | 59 | // Helper Function 60 | const generateSquareClasses = ( 61 | squareId: SquareId, 62 | clickedSquare: ClickedSquare, 63 | highlightPeers: boolean 64 | ) => { 65 | let squareClasses = 'square-container'; 66 | if (clickedSquare) { 67 | if (squareId === clickedSquare) { 68 | squareClasses += ' current-square-outline'; 69 | if (highlightPeers) squareClasses += ' current-square-background'; 70 | } 71 | if (highlightPeers && allPeers[squareId].has(clickedSquare)) { 72 | squareClasses += ' current-peer'; 73 | } 74 | } 75 | return squareClasses; 76 | }; 77 | -------------------------------------------------------------------------------- /src/server/utils/puzzleDBCreate.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Puzzle = require('../models/puzzleModel'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const csv = require('csv-parser'); 6 | 7 | const filePath = path.resolve(__dirname, '../../data/sudoku.csv'); 8 | const limit = 500; // Update totalPuzzles after new limit choice successfully populates database 9 | const results = []; 10 | 11 | const MONGO_URI = 12 | 'mongodb+srv://markteets:PV0m4ZjwEg3wZwIT@sudoku-db.ox6sdpn.mongodb.net/?retryWrites=true&w=majority'; 13 | 14 | mongoose 15 | .connect(MONGO_URI, { 16 | dbName: 'sudoku' 17 | }) 18 | .then(() => console.log('Connected to Mongo DB!')) 19 | .catch((err) => console.log('Database error: ', err.message)); 20 | 21 | fs.createReadStream(filePath) 22 | .pipe(csv()) 23 | .on('data', (data) => { 24 | if (results.length <= limit) { 25 | results.push(data); 26 | } 27 | }) 28 | .on('end', () => { 29 | console.log(results); 30 | populatePuzzles(results); 31 | }); 32 | 33 | const populatePuzzles = async (puzzleArray) => { 34 | for (let i = 0; i < puzzleArray.length; i++) { 35 | const puzzleObj = { 36 | puzzleNumber: i + 1, 37 | puzzle: puzzleArray[i].puzzle, 38 | solution: puzzleArray[i].solution 39 | }; 40 | // console.log('i:', i); 41 | // console.log('puzzleObj:', puzzleObj); 42 | try { 43 | const made = await Puzzle.create(puzzleObj); 44 | // console.log('Saved puzzle', made.puzzleNumber); 45 | } catch (err) { 46 | console.log('Error in puzzle creation:', err.message); 47 | } 48 | } 49 | }; 50 | 51 | /* Old code used to manually enter puzzles 52 | const puzzle1 = '070000043040009610800634900094052000358460020000800530080070091902100005007040802'; 53 | const solution1 = '679518243543729618821634957794352186358461729216897534485276391962183475137945862'; 54 | 55 | const puzzle2 = '301086504046521070500000001400800002080347900009050038004090200008734090007208103'; 56 | const solution2 = '371986524846521379592473861463819752285347916719652438634195287128734695957268143'; 57 | 58 | 59 | const firstPuzzle = { 60 | puzzleNumber: 1, 61 | puzzle: puzzle1, 62 | solution: solution1, 63 | }; 64 | 65 | const secondPuzzle = { 66 | puzzleNumber: 2, 67 | puzzle: puzzle2, 68 | solution: solution2, 69 | }; 70 | 71 | const initialPuzzle = async () => { 72 | try { 73 | const made = await Puzzle.create(secondPuzzle); 74 | console.log(made.id); 75 | } catch (err) { 76 | console.log('Error in puzzle creation:', err.message); 77 | } 78 | }; 79 | 80 | initialPuzzle(); 81 | */ 82 | -------------------------------------------------------------------------------- /src/client/pages/Puzzle/components/NumberSelectBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from 'react'; 2 | 3 | // Types 4 | import { PuzzleVal, MakeButtons, SquareContextValue } from '../../../frontendTypes'; 5 | 6 | // Context 7 | import { squareContext } from '../../../context'; 8 | 9 | //Utilities 10 | import { onNumberClick } from '../../../utils/puzzle-state-management-functions/puzzleValueChange'; 11 | 12 | // Main Component 13 | const NumberSelectBar = () => { 14 | const { 15 | pencilMode, 16 | clickedSquare, 17 | filledSquares, 18 | setFilledSquares, 19 | pencilSquares, 20 | setPencilSquares 21 | } = useContext(squareContext); 22 | 23 | const numberButtons = useMemo( 24 | () => 25 | makeButtons( 26 | pencilMode, 27 | clickedSquare, 28 | filledSquares, 29 | setFilledSquares, 30 | pencilSquares, 31 | setPencilSquares 32 | ), 33 | [pencilMode, clickedSquare, filledSquares, setFilledSquares, pencilSquares, setPencilSquares] 34 | ); 35 | 36 | return
{numberButtons}
; 37 | }; 38 | 39 | export default NumberSelectBar; 40 | 41 | // Helper Functions 42 | const makeButtons: MakeButtons = ( 43 | pencilMode, 44 | clickedSquare, 45 | filledSquares, 46 | setFilledSquares, 47 | pencilSquares, 48 | setPencilSquares 49 | ) => { 50 | const buttons = [] as React.JSX.Element[]; 51 | for (let i = 1; i < 10; i++) { 52 | const buttonVal = i.toString() as PuzzleVal; 53 | let isDisabled = false; 54 | let classes = 'number-button'; 55 | 56 | if (pencilMode) classes += ' pencil-mode'; 57 | 58 | if (clickedSquare) { 59 | if (filledSquares[clickedSquare]?.fixedVal) { 60 | isDisabled = true; 61 | } 62 | if (!pencilMode && filledSquares[clickedSquare]?.puzzleVal === buttonVal) { 63 | classes += ' highlight-number-button'; 64 | } else if (pencilMode && pencilSquares[clickedSquare]?.[buttonVal]) { 65 | classes += ' highlight-number-button'; 66 | } 67 | } 68 | buttons.push( 69 | 87 | ); 88 | } 89 | 90 | return buttons; 91 | }; 92 | -------------------------------------------------------------------------------- /src/client/utils/puzzle-state-management-functions/updateSquaresDuplicates.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { 3 | SquareId, 4 | PuzzleVal, 5 | FilledSquares, 6 | FilledSquare, 7 | PencilSquares, 8 | PencilSquare, 9 | PencilData 10 | } from '../../frontendTypes'; 11 | 12 | // Utilities 13 | import { allPeers } from './makeAllPeers'; 14 | 15 | /** updateFilledSquaresDuplicates 16 | * 17 | * Iterates over a filledSquares object and updates the value of each filledSquare's duplicate 18 | * property so that it's accurate based on its filledSquare and pencilSquare peers. It's assumed 19 | * that the filledSquares parameter provided has already been deep copied 20 | * 21 | * @param filledSquares - FilledSquares object 22 | * @param pencilSquares - PencilSquares object 23 | */ 24 | export const updateFilledSquaresDuplicates = ( 25 | filledSquares: FilledSquares, 26 | pencilSquares: PencilSquares 27 | ): void => { 28 | // Iterate over every filledSquare in filledSquares 29 | const squareIds = Object.keys(filledSquares).filter((key) => key !== 'size') as SquareId[]; 30 | // Update each filledSquare's duplicate status based on its peers filledSquare and 31 | // pencilSquare values 32 | for (const squareId of squareIds) { 33 | const square = filledSquares[squareId] as FilledSquare; 34 | square.duplicate = false; 35 | allPeers[squareId].forEach((peerId) => { 36 | if (filledSquares[peerId]?.puzzleVal === square.puzzleVal) square.duplicate = true; 37 | if (pencilSquares[peerId]?.[square.puzzleVal]) square.duplicate = true; 38 | }); 39 | } 40 | }; 41 | 42 | /** updatePencilSquaresDuplicates 43 | * 44 | * Iterates over a pencilSquares object and updates the value of each pencilSquare's duplicate 45 | * property so that it's accurate based on it's filledSquare peers. It's assumed that the 46 | * pencilSquares parameter provided has already been deep copied 47 | * 48 | * @param filledSquares - FilledSquares object 49 | * @param pencilSquares - PencilSquares object 50 | */ 51 | export const updatePencilSquaresDuplicates = ( 52 | filledSquares: FilledSquares, 53 | pencilSquares: PencilSquares 54 | ) => { 55 | const squareIds = Object.keys(pencilSquares) as SquareId[]; 56 | for (const squareId of squareIds) { 57 | const pencilSquare = pencilSquares[squareId] as PencilSquare; 58 | const puzzleVals = Object.keys(pencilSquare).filter((key) => key !== 'size') as PuzzleVal[]; 59 | for (const puzzleVal of puzzleVals) { 60 | const pencilData = pencilSquare[puzzleVal] as PencilData; 61 | pencilData.duplicate = false; 62 | allPeers[squareId].forEach((peerId) => { 63 | if (filledSquares[peerId]?.puzzleVal === puzzleVal) pencilData.duplicate = true; 64 | }); 65 | } 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /src/client/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | // @use 'sass:math'; 2 | 3 | // $bodyColor: #222da9; 4 | $bodyColor: #b59cf0; 5 | $bodyColorTransparent: #b59cf060; 6 | $menuBorderColor: #58c2c6; 7 | $buttonBackground: rgb(194, 202, 201); 8 | $offBlack: rgb(50, 42, 104); 9 | // $textColor: #6b3ee6; 10 | $textColor: #4f18ce; 11 | $buttonText: rgb(57, 50, 50); 12 | $visitedLink: rgb(128, 179, 171); 13 | 14 | 15 | $bigBorderColor: #08153e; 16 | $innerBorderColor: #0d2367; 17 | $squareBackground: #f7f8f8; 18 | $numberColor: #1840b9; 19 | $fixedVal: $bigBorderColor; 20 | $duplicateNumber: rgb(204, 69, 69); 21 | // $pencilNum: $numberColor; 22 | $pencilNum: #4f18ce; 23 | $peerSquareBackground: #d9e1fa; 24 | // $clickedSquareBackground: #cbd6f8; 25 | // $clickedSquareBackground: #eef1fd; 26 | $clickedSquareBackground: #c0cef7; 27 | $clickedSquareBorder: yellow; 28 | 29 | // $puzzleButtonBackground: #3D67E6; 30 | // $puzzleButtonBackground: #305de4; 31 | $puzzleButtonBackground: #3964e4; 32 | $puzzleButtonShadow: rgb(168, 40, 210); 33 | $puzzleButtonText: #b4c4f5; 34 | $puzzleButtonShadow: #8a5fed; 35 | $visitedLink: rgb(128, 179, 171); 36 | 37 | $nav-link-background: #c1c5c5; 38 | $sideSpaceMargin: 8px; 39 | 40 | $minFilledSquareFont: 16px; 41 | $maxFilledSquareFont: 35px; 42 | 43 | $minPencilSquareFont: 8px; 44 | $maxPencilSquareFont: 14px; 45 | 46 | $maxPuzzleWidth: 530px; 47 | $minPuzzleWidth: 320px; 48 | 49 | // @function scaledFontSize($maxFont, $minFont, $maxWidth, $minWidth) { 50 | // $slope: math.div(($maxFont-$minFont), ($maxWidth-$minWidth)); 51 | // $b: $maxFont - ($slope * $maxWidth); 52 | // @return 1vw*100*$slope + $b; 53 | // }; 54 | 55 | // @mixin filledSquareFontSize { 56 | // font-size: scaledFontSize($maxFilledSquareFont, $minFilledSquareFont, $maxPuzzleWidth, $minPuzzleWidth); 57 | // } 58 | 59 | // @mixin filledSquareFontSize { 60 | // font-size: scaledFontSize(35, 16, 530, 320); 61 | // } 62 | 63 | // @mixin filledSquareFontSize { 64 | // font-size: 1vw * 100 * math.div(($maxFilledSquareFont - $minFilledSquareFont), ($maxPuzzleWidth - $minPuzzleWidth)) + ($maxFilledSquareFont - $maxPuzzleWidth * math.div(($maxFilledSquareFont - $minFilledSquareFont), ($maxPuzzleWidth - $minPuzzleWidth))) 65 | // } 66 | 67 | // @function scaledFilledSquareFontSize() { 68 | // @return scaledFontSize($maxFilledSquareFont, $minFilledSquareFont, $maxPuzzleWidth, $minPuzzleWidth); 69 | // }; 70 | 71 | // @function scaledPencilSquareFontSize() { 72 | // @return scaledFontSize($maxPencilSquareFont, $minPencilSquareFont, $maxPuzzleWidth, $minPuzzleWidth); 73 | // }; 74 | 75 | // @function scaledFilledSquareFontSize() { 76 | // $slope: math.div(($maxFilledSquareFont-$minFilledSquareFont), ($maxPuzzleWidth-$minPuzzleWidth)); 77 | // $b: $maxFilledSquareFont - ($slope * $maxPuzzleWidth); 78 | // @return 1vw*100*$slope + $b; 79 | // }; 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sudoku", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/server/server.js", 6 | "scripts": { 7 | "dev": "concurrently \"cross-env NODE_ENV=development webpack serve --open --hot --color \" \" nodemon ./src/server/server.ts\"", 8 | "prod": "concurrently \"cross-env NODE_ENV=production webpack serve --open \" \" ts-node ./src/server/server.ts\"", 9 | "frontend": "NODE_ENV=development webpack serve --open --hot", 10 | "backend": "NODE_ENV=development nodemon ./src/server/server.ts", 11 | "build": "NODE_ENV=production webpack", 12 | "add-puzzle": "node src/server/utils/puzzleDBCreate.js", 13 | "test": "jest --verbose" 14 | }, 15 | "keywords": [], 16 | "author": "Mark Teets", 17 | "license": "ISC", 18 | "dependencies": { 19 | "bcryptjs": "^2.4.3", 20 | "cookie-parser": "^1.4.6", 21 | "express": "^4.18.2", 22 | "mongoose": "^7.5.2", 23 | "react": "^18.2.0", 24 | "react-router-dom": "^6.16.0", 25 | "sass": "^1.67.0", 26 | "ts-node": "^10.9.1" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7.22.20", 30 | "@babel/preset-env": "^7.22.20", 31 | "@babel/preset-react": "^7.22.15", 32 | "@testing-library/jest-dom": "^6.1.4", 33 | "@testing-library/react": "^14.0.0", 34 | "@testing-library/user-event": "^14.5.1", 35 | "@types/bcryptjs": "^2.4.4", 36 | "@types/cookie-parser": "^1.4.4", 37 | "@types/express": "^4.17.17", 38 | "@types/jest": "^29.5.6", 39 | "@types/morgan": "^1.9.7", 40 | "@types/node": "^20.6.3", 41 | "@types/react": "^18.2.22", 42 | "@types/react-dom": "^18.2.7", 43 | "@typescript-eslint/eslint-plugin": "^6.7.2", 44 | "@typescript-eslint/parser": "^6.7.2", 45 | "babel-loader": "^9.1.3", 46 | "concurrently": "^8.2.1", 47 | "copy-webpack-plugin": "^11.0.0", 48 | "cross-env": "^7.0.3", 49 | "css-loader": "^6.8.1", 50 | "csv-parser": "^3.0.0", 51 | "dotenv": "^16.3.1", 52 | "eslint": "^8.49.0", 53 | "eslint-config-prettier": "^9.0.0", 54 | "eslint-import-resolver-webpack": "^0.13.7", 55 | "eslint-plugin-import": "^2.28.1", 56 | "eslint-plugin-jsx-a11y": "^6.7.1", 57 | "eslint-plugin-prettier": "^5.0.0", 58 | "eslint-plugin-react": "^7.33.2", 59 | "eslint-plugin-react-hooks": "^4.6.0", 60 | "file-loader": "^6.2.0", 61 | "html-webpack-plugin": "^5.5.3", 62 | "jest": "^29.7.0", 63 | "jest-environment-jsdom": "^29.7.0", 64 | "morgan": "^1.10.0", 65 | "nodemon": "^3.0.1", 66 | "prettier": "^3.0.3", 67 | "sass-loader": "^13.3.2", 68 | "style-loader": "^3.3.3", 69 | "ts-jest": "^29.1.1", 70 | "ts-loader": "^9.4.4", 71 | "typescript": "^5.2.2", 72 | "webpack": "^5.88.2", 73 | "webpack-cli": "^5.1.4", 74 | "webpack-dev-server": "^4.15.1" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/solveSquaresConversion.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SolveSquares } from '../../types'; 3 | import { FilledSquares, PencilSquares, PencilSquare, PencilData } from '../../client/frontendTypes'; 4 | 5 | // Utilities 6 | import { 7 | puzzleVals, 8 | allSquareIds 9 | } from '../../client/utils/puzzle-state-management-functions/squareIdsAndPuzzleVals'; 10 | import { newSolveSquares } from './solutionFramework'; 11 | import { updateSolveSquares } from './updateSolveSquares'; 12 | 13 | /** solveSquareToPencilSquares 14 | * 15 | * Converts a solveSquares object to a pencilSquares object and returns it. This conversion sets 16 | * duplicate and highlight properties to false, they must be updated after this conversion in a 17 | * different function. solveSquare objects are similar to pencilSquares but are structured for 18 | * efficient puzzle solution functions rather than for displaying numbers on the frontend. 19 | * 20 | * @param solveSquares 21 | * @returns 22 | */ 23 | export const solveSquareToPencilSquares = (solveSquares: SolveSquares) => { 24 | const pencilSquares: PencilSquares = {}; 25 | for (const squareId of allSquareIds) { 26 | if (solveSquares[squareId].size > 0) { 27 | pencilSquares[squareId] = { size: 0 }; 28 | const pencilSquare = pencilSquares[squareId] as PencilSquare; 29 | solveSquares[squareId].forEach((puzzleVal) => { 30 | pencilSquare[puzzleVal] = { 31 | duplicate: false, 32 | highlightNumber: false 33 | }; 34 | pencilSquare.size += 1; 35 | }); 36 | } 37 | } 38 | return pencilSquares; 39 | }; 40 | 41 | /** pencilSquaresToSolveSquares 42 | * 43 | * Converts a pencilSquares object to a solveSquares object and returns it. solveSquare objects are 44 | * similar to pencilSquares but are structured for efficient puzzle solution functions rather than 45 | * for displaying numbers on the frontend. 46 | * 47 | * @param pencilSquares 48 | * @returns 49 | */ 50 | export const pencilSquaresToSolveSquares = (pencilSquares: PencilSquares): SolveSquares => { 51 | const solveSquares: SolveSquares = {}; 52 | for (const squareId of allSquareIds) { 53 | solveSquares[squareId] = new Set(); 54 | if (pencilSquares[squareId]) { 55 | for (const puzzleVal of puzzleVals) { 56 | if (pencilSquares[squareId]?.[puzzleVal]) { 57 | if (!((pencilSquares[squareId] as PencilSquare)[puzzleVal] as PencilData).duplicate) { 58 | solveSquares[squareId].add(puzzleVal); 59 | } 60 | } 61 | } 62 | } 63 | } 64 | return solveSquares; 65 | }; 66 | 67 | export const solveSquaresFromFilledSquares = (filledSquares: FilledSquares) => { 68 | const solveSquares = newSolveSquares(); 69 | updateSolveSquares(filledSquares, solveSquares); 70 | return solveSquares; 71 | }; 72 | -------------------------------------------------------------------------------- /src/client/pages/Puzzle/components/PuzzleStringDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | 3 | // Types 4 | import { SquareContextValue } from '../../../frontendTypes'; 5 | 6 | // Context 7 | import { squareContext } from '../../../context'; 8 | 9 | // Utilities 10 | import { 11 | createProgressString, 12 | createPencilProgressString 13 | } from '../../../utils/puzzle-state-management-functions/puzzleStringsFromSquares'; 14 | 15 | // Main Component 16 | const PuzzleStringDisplay = () => { 17 | const { filledSquares, pencilSquares } = useContext(squareContext); 18 | const [puzzleString, setPuzzleString] = useState(''); 19 | const [showPuzzleString, setShowPuzzleString] = useState(false); 20 | const [pencilString, setPencilString] = useState(''); 21 | const [showPencilString, setShowPencilString] = useState(false); 22 | 23 | const onShowPuzzleStringClick = () => { 24 | if (showPuzzleString) { 25 | setShowPuzzleString(false); 26 | } else { 27 | setPuzzleString(createProgressString(filledSquares)); 28 | setShowPuzzleString(true); 29 | } 30 | }; 31 | 32 | const onShowPencilStringClick = () => { 33 | if (showPencilString) { 34 | setShowPencilString(false); 35 | } else { 36 | setPencilString(createPencilProgressString(pencilSquares)); 37 | setShowPencilString(true); 38 | } 39 | }; 40 | 41 | const buttonClass = ''; //'puzzle-button'; 42 | 43 | return ( 44 | <> 45 |
46 | 49 | {showPuzzleString ? ( 50 | 58 | ) : null} 59 |
60 | {showPuzzleString &&
Puzzle String: {puzzleString ? puzzleString : 'Empty'}
} 61 |
62 | 65 | {showPencilString ? ( 66 | 74 | ) : null} 75 |
76 | {showPencilString &&
Pencil String: {pencilString ? pencilString : 'Empty'}
} 77 | 78 | ); 79 | }; 80 | 81 | export default PuzzleStringDisplay; 82 | -------------------------------------------------------------------------------- /src/client/scss/styles.scss: -------------------------------------------------------------------------------- 1 | // $bodyColor: #222da9; 2 | $bodyColor: #b59cf0; 3 | $bigBorderColor: #58c2c6; 4 | $buttonBackground: rgb(194, 202, 201); 5 | $offBlack: rgb(50, 42, 104); 6 | // $textColor: #6b3ee6; 7 | $textColor: #4f18ce; 8 | $buttonText: rgb(57, 50, 50); 9 | $visitedLink: rgb(128, 179, 171); 10 | 11 | *, 12 | ::after, 13 | ::before { 14 | box-sizing: border-box; 15 | } 16 | 17 | @import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:ital,wght@0,100;0,300;0,400;1,300;1,400&family=Rubik:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500;1,600&display=swap"); 18 | 19 | body { 20 | min-height: 100vh; 21 | margin: 0; 22 | // background-color: $bodyColor; 23 | background-image: radial-gradient(ellipse at top left, $bodyColor, transparent 100%); 24 | font-family: "Roboto", sans-serif; 25 | // font-family:'Lucinda Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; 26 | // font-family: 'Rubik', sans-serif; 27 | color: $textColor; 28 | } 29 | 30 | h1, 31 | h2, 32 | h3, 33 | h5 { 34 | width: max-content; 35 | margin: 10px auto 15px; 36 | } 37 | 38 | #main-nav { 39 | width: max-content; 40 | margin-left: auto; 41 | margin-right: auto; 42 | margin-bottom: 40px; 43 | } 44 | 45 | .nav-link-container { 46 | width: max-content; 47 | margin: 0 auto; 48 | } 49 | 50 | #main-nav .nav-link { 51 | margin: 0 10px; 52 | } 53 | 54 | #main-nav .active { 55 | background-color: $offBlack; 56 | border-radius: 7px; 57 | } 58 | 59 | .centered-div { 60 | width: max-content; 61 | padding: 10px 30px 15px; 62 | margin: 0 auto; 63 | display: flex; 64 | flex-direction: column; 65 | align-items: center; 66 | border: $bigBorderColor groove 7px; 67 | border-radius: 18px; 68 | 69 | a:visited { 70 | color: $visitedLink; 71 | } 72 | 73 | input { 74 | margin: 6px 0; 75 | } 76 | 77 | label { 78 | margin: 8px 0; 79 | } 80 | 81 | .puzzle-select-div { 82 | display: flex; 83 | flex-direction: column; 84 | align-items: center; 85 | } 86 | } 87 | 88 | p { 89 | margin: 0; 90 | } 91 | 92 | .puzzle-select-div { 93 | margin: 10px 0; 94 | } 95 | 96 | #lets-play { 97 | font-family: "Rubik", sans-serif; 98 | font-size: 40px; 99 | height: 55px; 100 | padding: 12px; 101 | margin-bottom: 40px; 102 | font-style: italic; 103 | font-weight: 500; 104 | } 105 | 106 | #house-img { 107 | display: block; 108 | width: max-content; 109 | margin: 0 auto; 110 | border-radius: 4px; 111 | } 112 | 113 | button { 114 | margin: 6px 10px; 115 | background-color: $buttonBackground; 116 | color: $buttonText; 117 | padding: 5px 10px; 118 | border-radius: 5px; 119 | font-family: "Roboto", sans-serif; 120 | font-size: 16px; 121 | } 122 | 123 | .button-container { 124 | width: max-content; 125 | margin: 15px auto; 126 | font-family: "Roboto", sans-serif; 127 | } 128 | 129 | @import "./modules/puzzle.scss"; 130 | @import "./modules/userNavbar.scss"; 131 | -------------------------------------------------------------------------------- /src/client/pages/Puzzle/components/ToolBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | 3 | // Types 4 | import { UserContextValue, SquareContextValue } from '../../../frontendTypes'; 5 | 6 | // Context 7 | import { userContext, squareContext } from '../../../context'; 8 | 9 | // Components 10 | import SolutionContainer from './SolutionContainer'; 11 | 12 | // Utilities 13 | import { pencilSquaresFromString } from '../../../utils/puzzle-state-management-functions/squaresFromPuzzleStrings'; 14 | import { autofillPencilSquares } from '../../../utils/puzzle-state-management-functions/autofillPencilSquares'; 15 | import { savePuzzleAtLeastOnce } from '../../../utils/save'; 16 | const savePuzzle = savePuzzleAtLeastOnce(); 17 | 18 | // Main Component 19 | const ToolBar = () => { 20 | const { user, setUser } = useContext(userContext); 21 | const { 22 | puzzleNumber, 23 | initialSquares, 24 | filledSquares, 25 | setFilledSquares, 26 | pencilSquares, 27 | setPencilSquares, 28 | pencilMode, 29 | setPencilMode, 30 | setClickedSquare 31 | } = useContext(squareContext); 32 | const [showMoreTools, setShowMoreTools] = useState(false); 33 | const [showSolveBar, setShowSolveBar] = useState(false); 34 | 35 | const onSaveClick = (): void => { 36 | if (puzzleNumber > 0) { 37 | savePuzzle(puzzleNumber, filledSquares, pencilSquares, user, setUser); 38 | } 39 | }; 40 | 41 | const resetPuzzle = (): void => { 42 | setFilledSquares(initialSquares.originalPuzzleFilledSquares); 43 | setPencilSquares(pencilSquaresFromString()); 44 | setClickedSquare(null); 45 | setPencilMode(false); 46 | }; 47 | 48 | const puzzleButtonClass = 'puzzle-button'; 49 | let pencilClasses = puzzleButtonClass; 50 | if (pencilMode) { 51 | pencilClasses += ' highlight-number-button'; 52 | } 53 | 54 | let toolButtonClasses = puzzleButtonClass; 55 | if (showMoreTools) { 56 | toolButtonClasses += ' highlight-number-button'; 57 | } 58 | 59 | let solveBarButtonClasses = puzzleButtonClass; 60 | if (showSolveBar) { 61 | solveBarButtonClasses += ' highlight-number-button'; 62 | } 63 | 64 | return ( 65 | <> 66 |
67 | 70 | 76 | 79 | 82 |
83 | {showMoreTools && ( 84 |
85 | 88 | 91 |
92 | )} 93 | {showSolveBar && } 94 | 95 | ); 96 | }; 97 | 98 | export default ToolBar; 99 | -------------------------------------------------------------------------------- /src/globalUtils/__tests__/hiddenTripleSolver.test.ts: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import { pencilStringSolutionExecuter } from '../puzzle-solution-functions/pencilStringSolutionExecuter'; 3 | import { hiddenTripleSolver } from '../puzzle-solution-functions/hiddenSubsetSolver'; 4 | 5 | const hiddenTripleExample1 = 6 | '528600049136490025794205630000100200007826300002509060240300976809702413070904582'; 7 | const hiddenTriplePencilStringExample1 = 8 | 'A5137A6137A717B678B778C518C918D13469D2568D335D5347D637D859D9478E149E215E859E914F134F218F5347F7178F91478G315G5158G618H256H556I136I313I516'; 9 | const hiddenTriplePencilStringResult1 = 10 | 'A537A6137A717B678B778C518C918D13469D2568D335D5347D637D859D9478E149E215E859E914F134F218F5347F7178F91478G315G5158G618H256H556I136I313I516'; 11 | 12 | const hiddenTripleExample2 = 13 | '370408100000903704940100083420000005000504000800000046010049000509600400004200931'; 14 | const hiddenTriplePencilStringExample2 = 15 | 'A3256A5256A82569A929B1126B2568B312568B5256B8256C3256C52567C62567C7256D31367D4378D5136789D6167D738D8179E1167E2369E31367E51236789E7238E81279E92789F2359F31357F437F512379F6127F723G1267G323678G4378G72568G82567G9278H238H51378H617H827H9278I167I268I5578I657'; 16 | const hiddenTriplePencilStringResult2 = 17 | 'A3256A5256A82569A929B1126B2568B312568B5256B8256C3256C52567C62567C7256D31367D4378D5136789D6167D738D8179E1167E2369E31367E51236789E7238E8179E979F2359F31357F437F512379F6127F723G1267G323678G4378G72568G82567G9278H238H51378H617H827H9278I167I268I5578I657'; 18 | 19 | const hiddenTriplePencilStringExample3 = 20 | 'A3256A5256A82569A929B1126B2568B312568B5256B8256C3256C52567C6257C756D317D4378D513789D738D8179E1167E2369E31367E528E728E8179E979F259F3157F437F512379F6127F723G127G32378G4378G756G856G978H238H51378H617H827H9278I167I268I5578I657'; 21 | const hiddenTriplePencilStringResult3 = 22 | 'A3256A5256A82569A929B1126B2568B312568B5256B8256C3256C52567C6257C756D317D4378D5139D738D8179E1167E2369E31367E528E728E8179E979F259F3157F437F5139F6127F723G127G32378G4378G756G856G978H238H513H617H827H9278I167I268I5578I657'; 23 | 24 | const hiddenTriplePencilStringExample4 = 25 | 'A258A512A612A958C158C718C815D126D318D418D826E112568E24578E3578E458E61256E8126E947F156F247F512F656F712F947G2257G3157G415G728G958H1158H358H615I225I825'; 26 | const hiddenTriplePencilStringResult4 = 27 | 'A258A512A612A958C158C718C815D126D318D418D826E1126E24578E3578E458E6126E8126E947F156F247F512F656F712F947G2257G3157G415G728G958H1158H358H615I225I825'; 28 | 29 | describe('Hidden triple solver solves test cases', () => { 30 | it('should remove extra penciled in values from column example 1', () => { 31 | expect(pencilStringSolutionExecuter(hiddenTripleSolver, hiddenTriplePencilStringExample1)).toBe( 32 | hiddenTriplePencilStringResult1 33 | ); 34 | }); 35 | 36 | it('should remove extra penciled in values from column example 2', () => { 37 | expect(pencilStringSolutionExecuter(hiddenTripleSolver, hiddenTriplePencilStringExample3)).toBe( 38 | hiddenTriplePencilStringResult3 39 | ); 40 | }); 41 | 42 | it('should remove extra penciled in values from row example 1', () => { 43 | expect(pencilStringSolutionExecuter(hiddenTripleSolver, hiddenTriplePencilStringExample4)).toBe( 44 | hiddenTriplePencilStringResult4 45 | ); 46 | }); 47 | 48 | it('should remove extra penciled in values from box example 1', () => { 49 | expect(pencilStringSolutionExecuter(hiddenTripleSolver, hiddenTriplePencilStringExample2)).toBe( 50 | hiddenTriplePencilStringResult2 51 | ); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/client/layouts/side-bar/UserNavBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, useEffect } from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | // Types 5 | import { UserContextValue, SideBarProps } from '../../frontendTypes'; 6 | 7 | // Context 8 | import { userContext } from '../../context'; 9 | 10 | // Main Component 11 | const UserNavBar = ({ collapseSideBar }: SideBarProps) => { 12 | const { user, setUser } = useContext(userContext); 13 | const [puzzleSelectMenuURL, setPuzzleSelectMenuURL] = useState( 14 | user === null ? '/' : `/${encodeURIComponent(user.username)}` 15 | ); 16 | const [lastPuzzleURL, setLastPuzzleURL] = useState( 17 | user === null ? '/' : `/${encodeURIComponent(user.username)}/puzzle/${user.lastPuzzle}` 18 | ); 19 | 20 | useEffect(() => { 21 | if (user?.username) { 22 | if (puzzleSelectMenuURL !== `/${encodeURIComponent(user.username)}`) { 23 | setPuzzleSelectMenuURL(`/${encodeURIComponent(user.username)}`); 24 | } 25 | if (lastPuzzleURL !== `/${encodeURIComponent(user.username)}/puzzle/${user.lastPuzzle}`) { 26 | setLastPuzzleURL(`/${encodeURIComponent(user.username)}/puzzle/${user.lastPuzzle}`); 27 | } 28 | } else { 29 | setPuzzleSelectMenuURL('/'); 30 | setLastPuzzleURL('/'); 31 | } 32 | }, [user, puzzleSelectMenuURL, lastPuzzleURL]); 33 | 34 | const logOut = async () => { 35 | if (!user) return; 36 | 37 | if (user.username !== 'guest') { 38 | const res = await fetch('/api/user/log-out', { 39 | method: 'DELETE', 40 | headers: { 'Content-Type': 'application/json' }, 41 | body: JSON.stringify({ 42 | username: user.username, 43 | allPuzzles: user.allPuzzles, 44 | lastPuzzle: user.lastPuzzle 45 | }) 46 | }); 47 | if (res.ok) { 48 | // console.log('Successfully deleted session'); 49 | } 50 | } 51 | setUser(null); 52 | collapseSideBar(); 53 | }; 54 | 55 | return ( 56 | 97 | ); 98 | }; 99 | 100 | export default UserNavBar; 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lets Play Sudoku 2 | 3 | Welcome to "Let's Play Sudoku," a free web application for accessing and playing free Sudoku puzzles. 4 | 5 | This project was made possible via the following technologies: 6 | 7 | 8 | 9 | ![TypeScript](https://img.shields.io/badge/typescript-3178C6?style=for-the-badge&logo=typescript&logoColor=white) 10 | ![React](https://img.shields.io/badge/react-232730?style=for-the-badge&logo=react&logoColor=%2361DAFB) 11 | ![React Router](https://img.shields.io/badge/React Router-235B7D?style=for-the-badge&logo=reactrouter) 12 | ![sass](https://img.shields.io/badge/sass-CC6699?style=for-the-badge&logo=sass&logoColor=white) 13 | ![ts-node](https://img.shields.io/badge/ts–node-3178C6?style=for-the-badge&logo=tsnode&logoColor=white) 14 | ![Express](https://img.shields.io/badge/express-%23404d59.svg?style=for-the-badge&logo=express&logoColor=%2361DAFB) 15 | ![mongoose](https://img.shields.io/badge/mongoose-880000?style=for-the-badge&logo=mongoose&logoColor=white) 16 | ![MongoDB](https://img.shields.io/badge/MongoDB-00684A?style=for-the-badge&logo=mongodb) 17 | ![Webpack](https://img.shields.io/badge/Webpack-2B3A42?style=for-the-badge&logo=webpack) 18 | ![Babel](https://img.shields.io/badge/Babel-F9DC3e?style=for-the-badge&logo=babel&logoColor=black) 19 | ![Git](https://img.shields.io/badge/git-%23F05033.svg?style=for-the-badge&logo=git&logoColor=white) 20 | ![eslint](https://img.shields.io/badge/eslint-4B32C3?style=for-the-badge&logo=eslint) 21 | ![prettier](https://img.shields.io/badge/prettier-F7B93E?style=for-the-badge&logo=prettier&logoColor=black) 22 | ![Jest](https://img.shields.io/badge/-jest-%23C21325?style=for-the-badge&logo=jest&logoColor=white) 23 | ![Testing Library](https://img.shields.io/badge/RTL-E33332?style=for-the-badge&logo=testinglibrary&logoColor=white) 24 | ![Docker](https://img.shields.io/badge/docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) 25 | ![Github Actions](https://img.shields.io/badge/Github Actions-2088FF?style=for-the-badge&logo=githubactions&logoColor=white) 26 | 27 | ## Current features: 28 | 29 | ### Gameplay features: 30 | 31 | - Input numbers using keystrokes or screen interface to allow easy use for both desktop and mobile users 32 | - Pencil mode: allows users to add notes for possible values to a square 33 | - Auto-fill pencil marks: Clicking the auto-fill button places all of the appropriate penciled in numbers automatically 34 | - Duplicate number flagging 35 | - Square highlights: clicking a square highlights it as well as the columns, rows, unit boxes (9 squares) it's a part of for easier number comparison 36 | - Completed puzzle verification 37 | 38 | ### User features: 39 | 40 | - User sign-up and login 41 | - Sessions allow users to automatically log in after first sign-up and/or subsequent logins 42 | - Save progress made on puzzles so users can resume later 43 | - Guest mode, for those who want to play without signing in (can't save in guest mode) 44 | 45 | ## Future features: 46 | 47 | - Choose puzzle based on difficulty and/or techniques required to solve 48 | - Ask for a hint: will highlight numbers that will lead to next definitive number to enter 49 | - Tutorials for each solution technique 50 | 51 | 52 | Resources and credits: 53 | 54 | Icons for the application were sourced from the following websites: 55 | 56 | - Sudoku icons created by Freepik - Flaticon 57 | - Bootstraps Icons -------------------------------------------------------------------------------- /src/client/utils/puzzle-state-management-functions/initialSquareStatePopulation.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { InitialSquares, InitializeSquares, ResetStateOnRefresh } from '../../frontendTypes'; 3 | import { isValidPuzzleString } from './puzzleStringValidation'; 4 | 5 | // Utilities 6 | import { 7 | filledSquaresFromString, 8 | pencilSquaresFromString, 9 | updateFilledSquaresFromProgress 10 | } from './squaresFromPuzzleStrings'; 11 | import { 12 | updateFilledSquaresDuplicates, 13 | updatePencilSquaresDuplicates 14 | } from './updateSquaresDuplicates'; 15 | 16 | /** initializeSquares 17 | * 18 | * Generates an InitialSquares object based on the puzzleNumber and the puzzle associated with 19 | * said number on the user object and the puzzleCollection object. This initialSquares object holds 20 | * a filledSquares object built only from the original puzzle, as well as a filledSquares object 21 | * updated by a users progress string and a pencilSquares object, both having updated duplicate 22 | * properties. 23 | * 24 | * @param puzzleNumber 25 | * @param user 26 | * @param puzzleCollection 27 | * @returns InitialSquares object 28 | */ 29 | export const initializeSquares: InitializeSquares = (puzzleNumber, user, puzzleCollection) => { 30 | const initialSquares: InitialSquares = { 31 | originalPuzzleFilledSquares: filledSquaresFromString(), 32 | filledSquares: filledSquaresFromString(), 33 | pencilSquares: pencilSquaresFromString() 34 | }; 35 | 36 | if (!user) return initialSquares; 37 | 38 | initialSquares.originalPuzzleFilledSquares = filledSquaresFromString( 39 | puzzleCollection[puzzleNumber]?.puzzle 40 | ); 41 | initialSquares.filledSquares = updateFilledSquaresFromProgress( 42 | initialSquares.originalPuzzleFilledSquares, 43 | puzzleNumber, 44 | user, 45 | puzzleCollection 46 | ); 47 | initialSquares.pencilSquares = pencilSquaresFromString( 48 | user?.allPuzzles[puzzleNumber]?.pencilProgress 49 | ); 50 | updateFilledSquaresDuplicates(initialSquares.filledSquares, initialSquares.pencilSquares); 51 | updatePencilSquaresDuplicates(initialSquares.filledSquares, initialSquares.pencilSquares); 52 | return initialSquares; 53 | }; 54 | 55 | export const initializeSquaresForTestPage = (puzzleString: string) => { 56 | const initialSquares: InitialSquares = { 57 | originalPuzzleFilledSquares: filledSquaresFromString(), 58 | filledSquares: filledSquaresFromString(), 59 | pencilSquares: pencilSquaresFromString() 60 | }; 61 | 62 | if (isValidPuzzleString(puzzleString)) { 63 | initialSquares.originalPuzzleFilledSquares = filledSquaresFromString(puzzleString); 64 | initialSquares.filledSquares = initialSquares.originalPuzzleFilledSquares; 65 | updateFilledSquaresDuplicates(initialSquares.filledSquares, initialSquares.pencilSquares); 66 | } 67 | 68 | return initialSquares; 69 | }; 70 | 71 | /** resetStateOnRefresh 72 | * 73 | * When the page is refreshed, the user stored in the state will be lost. Therefore the first render 74 | * will produce an empty Sudoku grid. This method will calculate the Sudoku grid and set its state 75 | * appropriately 76 | * 77 | * @param puzzleNumber 78 | * @param user 79 | * @param puzzleCollection 80 | * @param setInitialSquares 81 | * @param setFilledSquares 82 | * @param setPencilSquares 83 | */ 84 | export const resetStateOnRefresh: ResetStateOnRefresh = ( 85 | puzzleNumber, 86 | user, 87 | puzzleCollection, 88 | setInitialSquares, 89 | setFilledSquares, 90 | setPencilSquares 91 | ) => { 92 | const resetSquares = initializeSquares(puzzleNumber, user, puzzleCollection); 93 | setInitialSquares(resetSquares); 94 | setFilledSquares(resetSquares.filledSquares); 95 | setPencilSquares(resetSquares.pencilSquares); 96 | }; 97 | -------------------------------------------------------------------------------- /src/client/utils/puzzle-state-management-functions/checkForDuplicateUpdates.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { 3 | SquareId, 4 | PuzzleVal, 5 | FilledSquares, 6 | FilledSquare, 7 | PencilSquares, 8 | PencilSquare 9 | } from '../../frontendTypes'; 10 | 11 | // Utilities 12 | import { allPeers } from './makeAllPeers'; 13 | 14 | /** isFilledSquaresDuplicateChange 15 | * 16 | * Iterates over a filledSquares object and returns true if any current duplicate property is 17 | * inconsistent with what it should be based on itself and a corresponding pencilSquares object. 18 | * Returns false if no changes needs to be made. By doing this, we can avoid deep cloning 19 | * filledSquares if no changes need to be made 20 | * 21 | * @param filledSquares - FilledSquares object 22 | * @param pencilSquares - PencilSquares object 23 | * @returns boolean 24 | */ 25 | export const isFilledSquaresDuplicateChange = ( 26 | filledSquares: FilledSquares, 27 | pencilSquares: PencilSquares 28 | ): boolean => { 29 | // Iterate over every filledSquare in filledSquares 30 | const squareIds = Object.keys(filledSquares).filter((key) => key !== 'size') as SquareId[]; 31 | for (const squareId of squareIds) { 32 | const square = filledSquares[squareId] as FilledSquare; 33 | // Find current duplicate status by comparing the puzzleVal of the current square to its peers 34 | let isDuplicate = false; 35 | allPeers[squareId].forEach((peerId) => { 36 | if (filledSquares[peerId]?.puzzleVal === square.puzzleVal) isDuplicate = true; 37 | if (pencilSquares[peerId]?.[square.puzzleVal]) isDuplicate = true; 38 | }); 39 | // Compare whether it was found to be a duplicate against it's current status 40 | // If they're different, break out of the iteration and return true 41 | if (isDuplicate !== square.duplicate) return true; 42 | } 43 | // If a difference in duplicate status is never found, return false 44 | // so that deep copying the filledSquares object can be avoided 45 | return false; 46 | }; 47 | 48 | /** isPencilSquaresDuplicateChange 49 | * 50 | * Iterates over a pencilSquares object and returns true if any current duplicate property is 51 | * inconsistent with what it should be based on a corresponding filledSquares object. Returns false 52 | * if no changes needs to be made. By doing this, we can avoid deep cloning pencilSquares if no 53 | * changes need to be made 54 | * 55 | * @param filledSquares - FilledSquares object 56 | * @param pencilSquares - PencilSquares object 57 | * @returns boolean 58 | */ 59 | export const isPencilSquaresDuplicateChange = ( 60 | filledSquares: FilledSquares, 61 | pencilSquares: PencilSquares 62 | ) => { 63 | // Iterate over every pencilSquare in pencilSquares 64 | const squareIds = Object.keys(pencilSquares) as SquareId[]; 65 | for (const squareId of squareIds) { 66 | const pencilSquare = pencilSquares[squareId] as PencilSquare; 67 | // For each pencilSquare, grab all present puzzle values 68 | const puzzleVals = Object.keys(pencilSquare).filter((key) => key !== 'size') as PuzzleVal[]; 69 | for (const puzzleVal of puzzleVals) { 70 | // For every puzzle value, check to see if it's a duplicate value in a peer's filledSquare 71 | let isDuplicate = false; 72 | allPeers[squareId].forEach((peerId) => { 73 | if (filledSquares[peerId]?.puzzleVal === puzzleVal) isDuplicate = true; 74 | }); 75 | // Compare whether it was found to be a duplicate against it's current status 76 | // If they're different, break out of the iteration and return true 77 | if (isDuplicate !== pencilSquare[puzzleVal]?.duplicate) return true; 78 | } 79 | } 80 | // If a difference in duplicate status is never found, return false 81 | // so that deep copying the pencilSquares object can be avoided 82 | return false; 83 | }; 84 | -------------------------------------------------------------------------------- /src/client/shared-components/GameStats.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from 'react'; 2 | 3 | // Types 4 | import { User, PuzzleCollection, Puzzle } from '../../types'; 5 | import { UserContextValue, PuzzleCollectionContextValue, PuzzleNumberProp } from '../frontendTypes'; 6 | 7 | // Context 8 | import { userContext, puzzleCollectionContext } from '../context'; 9 | 10 | // Utils 11 | import { defaultPuzzleDocument } from '../../globalUtils/puzzle-solution-functions/solutionFramework'; 12 | 13 | const GameStats = ({ puzzleNumber }: PuzzleNumberProp) => { 14 | const { user } = useContext(userContext); 15 | const { puzzleCollection } = useContext(puzzleCollectionContext); 16 | const completePercent = useMemo( 17 | () => calculatePercentage(user, puzzleNumber), 18 | [user, puzzleNumber] 19 | ); 20 | const puzzle = useMemo( 21 | () => retrievePuzzle(user, puzzleCollection, puzzleNumber), 22 | [user, puzzleCollection, puzzleNumber] 23 | ); 24 | 25 | return ( 26 |
27 | {completePercent === 0 ? ( 28 |
29 | Start a puzzle to see its stats 30 |
31 | ) : ( 32 | <> 33 |
Completion: {completePercent}%
34 |
35 | Unique Solution: {puzzle.uniqueSolution ? 'Yes' : 'No'} 36 |
37 |
38 | Difficulty Level: {capitalize(puzzle.difficultyString)} 39 |
40 |
Difficulty Score: {puzzle.difficultyScore}
41 |
42 | Techniques: 43 |
44 |
    45 | {puzzle.singleCandidate &&
  • Single Candidate
  • } 46 | {puzzle.singlePosition &&
  • Single Position
  • } 47 | {puzzle.candidateLines &&
  • Candidate Lines
  • } 48 | {puzzle.doublePairs &&
  • Double Pairs
  • } 49 | {puzzle.multipleLines &&
  • Multiple Lines
  • } 50 | {puzzle.nakedPair &&
  • Naked Pair
  • } 51 | {puzzle.hiddenPair &&
  • Hidden Pair
  • } 52 | {puzzle.nakedTriple &&
  • Naked Triple
  • } 53 | {puzzle.hiddenTriple &&
  • Hidden Triple
  • } 54 | {puzzle.xWing &&
  • X-Wing
  • } 55 | {puzzle.forcingChains &&
  • Forcing Chains
  • } 56 | {puzzle.nakedQuad &&
  • Naked Quad
  • } 57 | {puzzle.hiddenQuad &&
  • Hidden Quad
  • } 58 | {puzzle.swordfish &&
  • Swordfish
  • } 59 |
60 | 61 | )} 62 |
63 | ); 64 | }; 65 | 66 | export default GameStats; 67 | 68 | // Helper Functions 69 | const calculatePercentage = (user: User, puzzleNumber?: number): number => { 70 | if (!user) return 0; 71 | let progress: string | undefined; 72 | if (puzzleNumber) { 73 | progress = user.allPuzzles[puzzleNumber]?.progress; 74 | } else { 75 | progress = user.allPuzzles[user.lastPuzzle]?.progress; 76 | } 77 | if (!progress) return 0; 78 | let count = 0; 79 | for (let i = 0; i < progress.length; i++) { 80 | if (progress[i] !== '0') count++; 81 | } 82 | return Math.round((count / 81) * 100); 83 | }; 84 | 85 | const retrievePuzzle = ( 86 | user: User, 87 | puzzleCollection: PuzzleCollection, 88 | puzzleNumber?: number 89 | ): Puzzle => { 90 | if (!user || !puzzleCollection || (!puzzleNumber && user.lastPuzzle < 1)) { 91 | return defaultPuzzleDocument(0, '', ''); 92 | } 93 | if (puzzleNumber) { 94 | return puzzleCollection[puzzleNumber]; 95 | } 96 | return puzzleCollection[user.lastPuzzle]; 97 | }; 98 | 99 | const capitalize = (string: string): string => { 100 | return string[0].toUpperCase() + string.slice(1); 101 | }; 102 | -------------------------------------------------------------------------------- /src/client/pages/Puzzle/components/SolutionContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | 3 | // Types 4 | import { TechniqueString, SolutionProcedure } from '../../../../types'; 5 | import { SquareContextValue } from '../../../frontendTypes'; 6 | 7 | // Context 8 | import { squareContext } from '../../../context'; 9 | 10 | // Utilities 11 | import { 12 | solutionFunctionDictionary, 13 | solutionStringDictionary 14 | } from '../../../../globalUtils/puzzle-solution-functions/solutionDictionary'; 15 | import { 16 | techniqueStrings, 17 | puzzleSolveOnce, 18 | puzzleSolveAndUpdateState 19 | } from '../../../../globalUtils/puzzle-solution-functions/solutionFramework'; 20 | 21 | // Helper functions 22 | const makeTechniqueOptions = () => { 23 | const options = []; 24 | for (const technique of techniqueStrings) { 25 | options.push( 26 | 29 | ); 30 | } 31 | return options; 32 | }; 33 | const techniqueOptions = makeTechniqueOptions(); 34 | 35 | // Main Component 36 | const SolutionContainer = () => { 37 | const { filledSquares, setFilledSquares, pencilSquares, setPencilSquares } = 38 | useContext(squareContext); 39 | const [selectedTechnique, setSelectedTechnique] = useState<'any' | TechniqueString>('any'); 40 | const [foundTechnique, setFoundTechnique] = useState(null); 41 | 42 | /** 43 | * Applies selected technique once. If 'any' is selected, execute all functions once from easiest 44 | * to hardest until one works. If none work, notifies user via alert 45 | */ 46 | const applyTechniqueOnce = () => { 47 | // console.log('Applying Technique:', selectedTechnique); 48 | if (selectedTechnique === 'any') { 49 | const solveTechnique = puzzleSolveOnce( 50 | filledSquares, 51 | setFilledSquares, 52 | pencilSquares, 53 | setPencilSquares 54 | ); 55 | if (!solveTechnique) { 56 | setFoundTechnique(solveTechnique); 57 | alert( 58 | `No technique available at the moment. If you haven't taken advantage of pencilled in values yet, try auto-filling them in to provide numbers for the single candidate technique` 59 | ); 60 | } else { 61 | setFoundTechnique(solutionStringDictionary[solveTechnique]); 62 | } 63 | } else { 64 | const solutionProcedure: SolutionProcedure = [ 65 | [solutionFunctionDictionary[selectedTechnique], 1] 66 | ]; 67 | if ( 68 | puzzleSolveAndUpdateState( 69 | filledSquares, 70 | setFilledSquares, 71 | pencilSquares, 72 | setPencilSquares, 73 | solutionProcedure 74 | ) 75 | ) { 76 | if (foundTechnique !== solutionStringDictionary[selectedTechnique]) { 77 | setFoundTechnique(solutionStringDictionary[selectedTechnique]); 78 | } 79 | // console.log('Success'); 80 | } else { 81 | alert("Technique couldn't be applied"); 82 | } 83 | } 84 | }; 85 | 86 | return ( 87 |
88 |
89 |
90 | 101 | {/*
Hints
102 | {/* This button will highlight the numbers */} 103 | {/* 104 |
Solve
*/} 105 | 106 |
107 | 108 |

Last technique applied: {foundTechnique ? foundTechnique : 'None'}

109 | {/* */} 110 |
111 |
112 | ); 113 | }; 114 | 115 | export default SolutionContainer; 116 | -------------------------------------------------------------------------------- /src/client/scss/modules/_puzzle.scss: -------------------------------------------------------------------------------- 1 | $bigBorderColor: #08153e; 2 | $innerBorderColor: #0d2367; 3 | $squareBackground: #f7f8f8; 4 | $numberColor: #1840b9; 5 | $fixedVal: $bigBorderColor; 6 | $duplicateNumber: rgb(204, 69, 69); 7 | // $pencilNum: $numberColor; 8 | $pencilNum: #4f18ce; 9 | $peerSquareBackground: #d9e1fa; 10 | // $clickedSquareBackground: #cbd6f8; 11 | // $clickedSquareBackground: #eef1fd; 12 | $clickedSquareBackground: #c0cef7; 13 | $clickedSquareBorder: yellow; 14 | 15 | // $puzzleButtonBackground: #3D67E6; 16 | // $puzzleButtonBackground: #305de4; 17 | $puzzleButtonBackground: #3964e4; 18 | $puzzleButtonShadow: rgb(168, 40, 210); 19 | $puzzleButtonText: #b4c4f5; 20 | $puzzleButtonShadow: #8a5fed; 21 | $visitedLink: rgb(128, 179, 171); 22 | 23 | .puzzle-page-centerer { 24 | width: 100%; 25 | display: flex; 26 | justify-content: center; 27 | 28 | :focus { 29 | outline: none; 30 | } 31 | } 32 | 33 | .puzzle-page-container { 34 | flex-grow: 1; 35 | margin: 0 8px; 36 | min-width: 320px; 37 | max-width: 480px; 38 | } 39 | 40 | .puzzle-container { 41 | display: grid; 42 | grid-template-rows: repeat(3, 1fr); 43 | grid-template-columns: repeat(3, 1fr); 44 | gap: 2px; 45 | border: $bigBorderColor solid 2px; 46 | background-color: $bigBorderColor; 47 | border-radius: 2px; 48 | margin-bottom: 15px; 49 | font-family: "Roboto Mono", monospace; 50 | } 51 | 52 | .box-unit-container { 53 | display: grid; 54 | grid-template-rows: repeat(3, 1fr); 55 | grid-template-columns: repeat(3, 1fr); 56 | gap: 1px; 57 | background-color: $innerBorderColor; 58 | } 59 | 60 | .square-container { 61 | background-color: $squareBackground; 62 | aspect-ratio: 50/61; 63 | padding: 2px; 64 | } 65 | 66 | .filled-square { 67 | display: flex; 68 | justify-content: center; 69 | align-items: center; 70 | font-size: max(20px, min(6vw, 32px)); 71 | font-weight: 300; 72 | color: $numberColor; 73 | } 74 | 75 | .fixed-val { 76 | color: $fixedVal; 77 | } 78 | 79 | .pencil-square { 80 | display: grid; 81 | grid-template-rows: repeat(3, 1fr); 82 | grid-template-columns: repeat(3, 1fr); 83 | align-items: center; 84 | } 85 | 86 | .pencil-val-div { 87 | display: flex; 88 | justify-content: center; 89 | align-items: center; 90 | font-size: max(9px, min(2.8vw, 14px)); 91 | font-weight: 200; 92 | color: $pencilNum; 93 | } 94 | 95 | .duplicate-number { 96 | color: $duplicateNumber; 97 | } 98 | 99 | .fixed-val.duplicate-number { 100 | color: darken($duplicateNumber, 15%); 101 | } 102 | 103 | .current-peer { 104 | background-color: $peerSquareBackground; 105 | } 106 | 107 | .current-square-outline { 108 | padding: 0; 109 | border: lighten($numberColor, 10%) solid 2px; 110 | } 111 | 112 | .current-square-background { 113 | border-color: $clickedSquareBorder; 114 | background-color: $clickedSquareBackground; 115 | } 116 | 117 | // Styles for buttons below puzzle 118 | .number-select-bar { 119 | display: flex; 120 | justify-content: space-between; 121 | margin-bottom: 12px; 122 | } 123 | 124 | .number-button { 125 | flex-grow: 1; 126 | aspect-ratio: 1; 127 | margin: 0 5px; 128 | border-color: rgba(118, 118, 118, 0.3); 129 | border-radius: 5px; 130 | color: $puzzleButtonText; 131 | background-color: $puzzleButtonBackground; 132 | font-size: max(16px, min(5vw, 24px)); 133 | font-weight: 300; 134 | 135 | &.pencil-mode { 136 | font-size: max(14px, min(4vw, 20px)); 137 | font-weight: 300; 138 | } 139 | } 140 | 141 | .puzzle-button-container { 142 | display: flex; 143 | flex-wrap: wrap; 144 | justify-content: center; 145 | } 146 | 147 | .puzzle-button { 148 | margin: 0 4px 12px; 149 | color: $puzzleButtonText; 150 | background-color: $puzzleButtonBackground; 151 | font-size: max(16px, min(4vw, 19px)); 152 | padding: 5px 12px; 153 | border-radius: 5px; 154 | } 155 | 156 | .highlight-number-button { 157 | background-color: darken($puzzleButtonBackground, 8%); 158 | color: darken($puzzleButtonText, 4%); 159 | box-shadow: 3px 4px 3px $puzzleButtonShadow; 160 | } 161 | 162 | .number-button:disabled { 163 | background-color: lighten($puzzleButtonBackground, 6%); 164 | color: lighten($puzzleButtonText, 4%); 165 | } 166 | 167 | .number-button.highlight-number-button:disabled { 168 | background-color: lighten($puzzleButtonBackground, 2%); 169 | color: lighten($puzzleButtonText, 1%); 170 | } 171 | -------------------------------------------------------------------------------- /src/client/pages/Welcome/SignUp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react'; 2 | import { useNavigate, Form, useActionData } from 'react-router-dom'; 3 | import { userContext, pageContext } from '../../context'; 4 | 5 | // Types 6 | import { UserContextValue, PageContextValue } from '../../frontendTypes'; 7 | import { SignInData, SignInResponse } from '../../../types'; 8 | 9 | // Main Component 10 | const SignUp = () => { 11 | const navigate = useNavigate(); 12 | const { user, setUser } = useContext(userContext); 13 | const { pageInfo } = useContext(pageContext); 14 | const newSignUpData = useActionData() as SignInData; 15 | 16 | // Set pageInfo to variable that will prevent automatic page jump if page has just loaded 17 | useEffect(() => { 18 | pageInfo.current = 'JustLoadedSignUp'; 19 | }, [pageInfo]); 20 | 21 | useEffect(() => { 22 | // Make sure the user has been set and they didn't just get to this page before 23 | // navigating to UserHomePage 24 | if (user !== null && pageInfo.current === 'signUp') { 25 | return navigate(`/${encodeURIComponent(user.username)}`); 26 | } 27 | }, [user, pageInfo, navigate]); 28 | 29 | // After a user submits info and a valid response from the backend has been received, 30 | // this useEffect will set the user accordingly 31 | useEffect(() => { 32 | if (newSignUpData?.user !== undefined) { 33 | setUser(newSignUpData.user); 34 | // Update pageInfo to this page on successful submission 35 | pageInfo.current = 'signUp'; 36 | } 37 | }, [newSignUpData, pageInfo, setUser]); 38 | 39 | return ( 40 |
41 |

Create New Account

42 |
43 | 47 | 51 | 55 | {newSignUpData?.error &&

{newSignUpData.error}

} 56 | 57 |
58 |
59 | ); 60 | }; 61 | 62 | // Helper Functions 63 | // This action function is called when the Form above is submitted (see router setup in App.jsx). 64 | export const signUpAction = async ({ request }: { request: Request }): Promise => { 65 | // Data from the form submission is available via the following function 66 | const submitData = await request.formData(); 67 | // On form submit, we need to send a post request to the backend with the proposed username 68 | // and password 69 | const body = { 70 | username: submitData.get('username'), 71 | password: submitData.get('password'), 72 | displayName: submitData.get('displayName') 73 | }; 74 | 75 | // Handles case where submissions return null rather than user's info 76 | if (!body.username || !body.password) { 77 | return { error: 'Submission failed, please try again' }; 78 | } 79 | 80 | // This will account for either displayName being an empty string or null 81 | if (!body.displayName) { 82 | body.displayName = body.username; 83 | } 84 | 85 | const res: Response = await fetch('/api/user/sign-up', { 86 | method: 'POST', 87 | headers: { 'Content-Type': 'application/json' }, 88 | body: JSON.stringify(body) 89 | }); 90 | 91 | // If the response status isn't in 200s, tell user submission failed 92 | if (!res.ok) { 93 | return { error: 'Submission failed, please try again' }; 94 | } 95 | 96 | // The request response has status 200, convert the response back to JS from JSON and proceed 97 | const response = (await res.json()) as SignInResponse; 98 | 99 | if (response.status === 'valid') { 100 | // console.log('SignUp was successful!'); 101 | return { 102 | user: response.user 103 | }; 104 | } 105 | 106 | // Alert user to choose a different username if the server flagged that it's already taken 107 | if (response.status === 'userNameExists') { 108 | return { error: 'This username is unavailable, please choose another' }; 109 | } 110 | // Included for dev testing, only appears if response.status string in the frontend and backend 111 | // are misaligned 112 | return { 113 | error: `The status "${response.status}" sent in the response doesn't match the valid cases.` 114 | }; 115 | }; 116 | 117 | export default SignUp; 118 | -------------------------------------------------------------------------------- /src/server/controllers/sessionController.ts: -------------------------------------------------------------------------------- 1 | // Models 2 | import Session from '../models/sessionModel'; 3 | 4 | // Types 5 | import { RequestHandler } from 'express'; 6 | import { SignInResponse } from '../../types'; 7 | import { 8 | SessionController, 9 | CustomErrorGenerator, 10 | UserDocument, 11 | BackendStatus 12 | } from '../backendTypes'; 13 | 14 | // Helper function: createErr will return an object formatted for the global error handler 15 | import controllerErrorMaker from '../utils/controllerErrorMaker'; 16 | const createErr: CustomErrorGenerator = controllerErrorMaker('sessionController'); 17 | 18 | //---START SESSION --------------------------------------------------------------------------------- 19 | 20 | const startSession: RequestHandler = async (req, res, next) => { 21 | // Make sure login/sign-up was successful and userDocument exists 22 | if (res.locals.status !== 'validUser' || res.locals.userDocument === null) { 23 | return next(); 24 | } 25 | 26 | //Extract Mongodb id from getUser or createUser middleware userDocument 27 | const userDocument: UserDocument = res.locals.userDocument; 28 | const userId = userDocument._id.toString(); 29 | 30 | try { 31 | // Upsert session to Session collection 32 | const filter = { cookieId: userId }; 33 | const update = { $currentDate: { createdAt: true } }; 34 | const options = { new: true, upsert: true }; 35 | 36 | // As upsert and new are set to true, this will never return null. 37 | // Failure will result in a thrown error 38 | await Session.findOneAndUpdate(filter, update, options); 39 | 40 | return next(); 41 | } catch (err) { 42 | return next( 43 | createErr({ 44 | method: 'startSession', 45 | overview: 'creating session document for user', 46 | status: 400, 47 | err 48 | }) 49 | ); 50 | } 51 | }; 52 | 53 | //---FIND SESSION ---------------------------------------------------------------------------------- 54 | 55 | const findSession: RequestHandler = async (req, res, next) => { 56 | const cookieId = req.cookies.ssid; 57 | 58 | // See if ssid cookie exists, if not redirect to no session path 59 | if (typeof cookieId !== 'string') { 60 | return res.redirect('/api/user/no-session'); 61 | } 62 | 63 | try { 64 | //find request for session with key cookieId with value matching cookie ssid 65 | const verifiedLogin = await Session.findOne({ cookieId }); 66 | // if it's null, continue through middleware without adding extra info 67 | if (verifiedLogin === null) { 68 | return res.redirect('/api/user/no-session'); 69 | } 70 | 71 | // set res.locals.status so cleanUser will process the userDocument for the frontend 72 | res.locals.status = 'validUser' as BackendStatus; 73 | 74 | return next(); 75 | } catch (err) { 76 | return next( 77 | createErr({ 78 | method: 'findSession', 79 | overview: 'finding session document for user', 80 | status: 400, 81 | err 82 | }) 83 | ); 84 | } 85 | }; 86 | 87 | //---DELETE SESSION -------------------------------------------------------------------------------- 88 | 89 | const deleteSession: RequestHandler = async (req, res, next) => { 90 | // Make sure getUser was successful and userDocument exists 91 | if (res.locals.userDocument === null) { 92 | res.locals.frontendData = { status: 'userNotFound' }; 93 | return next(); 94 | } 95 | 96 | //Extract Mongodb id from getUser or createUser middleware userDocument 97 | const userDocument: UserDocument = res.locals.userDocument; 98 | const userId = userDocument._id.toString(); 99 | 100 | try { 101 | // Find and delete session from session collection 102 | const deletedSession = await Session.findOneAndDelete({ cookieId: userId }); 103 | 104 | if (deletedSession === null) { 105 | return next( 106 | createErr({ 107 | method: 'deleteSession', 108 | overview: 'deleting session document for user', 109 | status: 400, 110 | err: `findOneAndDelete query for user ${res.locals.userDocument.username} returned null` 111 | }) 112 | ); 113 | } 114 | 115 | // Send success status back to frontend 116 | res.locals.frontendData = { status: 'valid' } as SignInResponse; 117 | 118 | return next(); 119 | } catch (err) { 120 | return next( 121 | createErr({ 122 | method: 'deleteSession', 123 | overview: 'deleting session document for user', 124 | status: 400, 125 | err 126 | }) 127 | ); 128 | } 129 | }; 130 | 131 | const sessionController: SessionController = { startSession, findSession, deleteSession }; 132 | 133 | export default sessionController; 134 | -------------------------------------------------------------------------------- /src/client/utils/puzzle-state-management-functions/makeAllPeers.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { AllPeers } from '../../frontendTypes'; 3 | 4 | // Utils 5 | import { rows, cols, boxes, allSquareIds } from './squareIdsAndPuzzleVals'; 6 | 7 | /** makeAllPeers 8 | * 9 | * Two squares are peers if they can't simultaneously hold the same number in a Sudoku puzzle. This 10 | * includes any two numbers in the same row, column, or box (set of 9 squares) are peers. 11 | * makeAllPeers generates an allPeers object that holds key:value pairs where the key is a SquareId 12 | * string (e.g. 'A1') and the value is a set of all of the squareId string peers 13 | * (e.g. 'A2', 'A3',...) of that key. 14 | * This set will be used in the styling of the squares. Other groupings of squares is also returned 15 | * for use in the Sudoku grid. 16 | * 17 | * @returns an allPeers object, a boxes array that holds 9 sets of the squares that form the 18 | * sudoku grid, a rows array that holds 9 sets of squareIds in each row, and a column array that 19 | * holds 9 sets of squareIds in each column. 20 | */ 21 | 22 | const makeAllPeers = (): AllPeers => { 23 | const allPeers: AllPeers = {}; 24 | 25 | // Create a set for every SquareId on the allPeers object 26 | for (let i = 0; i < 81; i++) { 27 | allPeers[allSquareIds[i]] = new Set(); 28 | } 29 | 30 | // Add every grouping of related squareId's to an array 31 | const allUnits = rows.concat(cols).concat(boxes); 32 | 33 | // For every squareId, find every grouping it's a part of and add every other squareId (peer) 34 | // in that grouping to the set in the allPeers object at that squareId's key 35 | for (const squareId of allSquareIds) { 36 | for (const peerUnit of allUnits) { 37 | if (peerUnit.has(squareId)) { 38 | peerUnit.forEach((peer) => { 39 | allPeers[squareId].add(peer); 40 | }); 41 | } 42 | } 43 | //remove this key from my peers set 44 | allPeers[squareId].delete(squareId); 45 | } 46 | return allPeers; 47 | }; 48 | 49 | export const allPeers = makeAllPeers(); 50 | 51 | /** 52 | * Old way of creating rows, cols, and boxes before copying and pasting its results to permanent 53 | */ 54 | // const makeAllPeers = (): { 55 | // rows: Set[]; 56 | // cols: Set[]; 57 | // boxes: Set[]; 58 | // allPeers: AllPeers; 59 | // } => { 60 | // const rows: Set[] = []; 61 | // const cols: Set[] = []; 62 | // const boxes: Set[] = []; 63 | // const allPeers: AllPeers = {}; 64 | 65 | // for (let i = 0; i < 9; i++) { 66 | // rows.push(new Set()); 67 | // cols.push(new Set()); 68 | // boxes.push(new Set()); 69 | // } 70 | 71 | // for (let i = 0; i < 81; i++) { 72 | // // Each row from rows[0] to rows[8] is a set of SquareIds corresponding to grid rows A-I 73 | // // e.g. rows[0] = { 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9' } 74 | // rows[Math.floor(i / 9)].add(allSquareIds[i]); 75 | 76 | // // Each column from cols[0] to cols[8] is a set of SquareIds corresponding to grid columns 1-9 77 | // // e.g. cols[0] = { 'A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1', 'I1' } 78 | // cols[i % 9].add(allSquareIds[i]); 79 | 80 | // // Boxes (big box of 9 squares) are more complicated: 81 | // // e.g. boxes[0] = { 'A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3' } 82 | // // Each index divided by 3 and then modulo 3 is the same for each unit box 83 | // const howFarFromDivisionByThree = Math.floor(i / 3) % 3; 84 | // // Each of the 81 SquareIds are grouped in one of three sections three sections 85 | // // (aka 9 square row), 0, 1, and 2 86 | // const section = Math.floor(i / 27); 87 | // boxes[section * 3 + howFarFromDivisionByThree].add(allSquareIds[i]); 88 | 89 | // allPeers[allSquareIds[i]] = new Set(); 90 | // } 91 | 92 | // // Add every grouping of related squareId's to an array 93 | // const allUnits = rows.concat(cols).concat(boxes); 94 | 95 | // // For every squareId, find every grouping it's a part of and add every other squareId (peer) 96 | // // in that grouping to the set in the allPeers object at that squareId's key 97 | // for (const squareId of allSquareIds) { 98 | // for (const peerUnit of allUnits) { 99 | // if (peerUnit.has(squareId)) { 100 | // peerUnit.forEach((peer) => { 101 | // allPeers[squareId].add(peer); 102 | // }); 103 | // } 104 | // } 105 | // //remove this key from my peers set 106 | // allPeers[squareId].delete(squareId); 107 | // } 108 | 109 | // return { 110 | // rows, 111 | // cols, 112 | // boxes, 113 | // allPeers 114 | // }; 115 | // }; 116 | 117 | // export const { rows, cols, boxes, allPeers } = makeAllPeers(); 118 | -------------------------------------------------------------------------------- /src/client/pages/Welcome/Login.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react'; 2 | import { useNavigate, Form, useActionData, Link } from 'react-router-dom'; 3 | 4 | // Types 5 | import { 6 | UserContextValue, 7 | PuzzleCollectionContextValue, 8 | PageContextValue 9 | } from '../../frontendTypes'; 10 | import { SignInData, SignInResponse } from '../../../types'; 11 | 12 | // Context 13 | import { userContext, puzzleCollectionContext, pageContext } from '../../context'; 14 | 15 | // Utils 16 | import populateUserAndPuzzleContext from '../../utils/populateUserAndPuzzleContext'; 17 | 18 | // Main Component 19 | const Login = () => { 20 | const navigate = useNavigate(); 21 | const { user, setUser } = useContext(userContext); 22 | const { setPuzzleCollection } = useContext(puzzleCollectionContext); 23 | const { pageInfo } = useContext(pageContext); 24 | const newLoginData = useActionData() as SignInData; 25 | 26 | // Set pageInfo to variable that will prevent automatic page jump if page has just loaded 27 | useEffect(() => { 28 | pageInfo.current = 'JustLoadedLogin'; 29 | }, [pageInfo]); 30 | 31 | useEffect(() => { 32 | // Make sure the user has been set and they didn't just get to this page before 33 | // navigating to UserHomePage 34 | if (user !== null && pageInfo.current === 'login') { 35 | return navigate(`/${encodeURIComponent(user.username)}`); 36 | } 37 | }, [user, pageInfo, navigate]); 38 | 39 | useEffect(() => { 40 | if (newLoginData?.user !== undefined && newLoginData.user && newLoginData.puzzleCollection) { 41 | populateUserAndPuzzleContext( 42 | newLoginData.user, 43 | setUser, 44 | newLoginData.puzzleCollection, 45 | setPuzzleCollection 46 | ); 47 | pageInfo.current = 'login'; 48 | } 49 | }, [newLoginData, setUser, setPuzzleCollection, pageInfo]); 50 | 51 | return ( 52 |
53 |

Please Log In

54 |
55 | 59 | 63 | {newLoginData?.error &&

{newLoginData.error}

} 64 | 65 |
66 |

67 | No account? Sign up 68 |

69 |
70 | ); 71 | }; 72 | 73 | export default Login; 74 | 75 | // Helper Functions 76 | // This action function is called when the Form above is submitted (see router setup in App.jsx). 77 | export const loginAction = async ({ request }: { request: Request }): Promise => { 78 | // Data from the form submission is available via the following function 79 | const loginInfo = await request.formData(); 80 | // On form submit, we need to send a post request to the backend with the proposed username 81 | // and password 82 | const body = { 83 | username: loginInfo.get('username'), 84 | password: loginInfo.get('password') 85 | }; 86 | 87 | // Handles case where submissions return null rather than user's info 88 | if (!body.username || !body.password) { 89 | return { error: 'Submission failed, please try again' }; 90 | } 91 | 92 | const res: Response = await fetch('/api/user/login', { 93 | method: 'POST', 94 | headers: { 'Content-Type': 'application/json' }, 95 | body: JSON.stringify(body) 96 | }); 97 | 98 | // If the response status isn't in 200s, inform user 99 | if (!res.ok) { 100 | return { error: 'Submission failed, please try again' }; 101 | } 102 | 103 | // The request response has status 200, convert the response back to JS from JSON and proceed 104 | const response = (await res.json()) as SignInResponse; 105 | 106 | if (response.status === 'valid') { 107 | // console.log('Login was successful!'); 108 | return { 109 | user: response.user, 110 | puzzleCollection: response.puzzleCollection 111 | }; 112 | } 113 | 114 | // We don't want the user to see why it failed, but dev's should be able to distinguish 115 | if (response.status === 'incorrectPassword' || response.status === 'userNotFound') { 116 | return { error: 'Username password combination was not valid' }; 117 | } 118 | 119 | // Included for dev testing, only appears if response.status string in the frontend and backend 120 | // are misaligned 121 | return { 122 | error: `The status "${response.status}" sent in the response doesn't match the valid cases` 123 | }; 124 | }; 125 | -------------------------------------------------------------------------------- /src/client/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useMemo } from 'react'; 2 | import { 3 | createBrowserRouter, 4 | createRoutesFromElements, 5 | RouterProvider, 6 | Route 7 | } from 'react-router-dom'; 8 | 9 | // Styles 10 | // import './scss/styles.scss'; 11 | import './scss/index.scss'; 12 | 13 | // Types 14 | import { User, PuzzleCollection } from '../types'; 15 | import { 16 | GameSettingContextValue, 17 | PuzzleCollectionContextValue, 18 | UserContextValue 19 | } from './frontendTypes'; 20 | 21 | // Layouts 22 | import RootLayout from './layouts/RootLayout'; 23 | import WelcomeLayout from './layouts/WelcomeLayout'; 24 | import UserLayout from './layouts/UserLayout'; 25 | 26 | // Pages, Loaders 27 | import Home from './pages/Welcome/Home'; 28 | import SignUp, { signUpAction } from './pages/Welcome/SignUp'; 29 | import Login, { loginAction } from './pages/Welcome/Login'; 30 | import PuzzleSelectMenu from './pages/PuzzleSelect/PuzzleSelectMenu'; 31 | import SavedPuzzleMenu from './pages/PuzzleSelect/SavedPuzzleMenu'; 32 | // import PuzzleSelectViaFilters from './pages/PuzzleSelect/PuzzleSelectViaFilters'; 33 | import PuzzlePage from './pages/Puzzle/PuzzlePage'; 34 | // import PuzzlePageTest, { puzzleTestLoader } from './pages/Puzzle/PuzzlePageTest'; 35 | import SiteInfo from './shared-components/SiteInfo'; 36 | import NotFound from './pages/NotFound'; 37 | import ErrorPage from './pages/ErrorPage'; 38 | 39 | // Context 40 | import { userContext, puzzleCollectionContext, pageContext, gameSettingsContext } from './context'; 41 | 42 | const router = createBrowserRouter( 43 | createRoutesFromElements( 44 | } errorElement={}> 45 | {/* Welcome layout */} 46 | }> 47 | } /> 48 | } 51 | action={loginAction} 52 | errorElement={} 53 | /> 54 | } 57 | action={signUpAction} 58 | errorElement={} 59 | /> 60 | 61 | 62 | {/* User layout */} 63 | }> 64 | } /> 65 | } /> 66 | {/* } 69 | /> */} 70 | {/* } 73 | loader={puzzleTestLoader} 74 | /> */} 75 | } /> 76 | } /> 77 | 78 | 79 | } /> 80 | 81 | ) 82 | ); 83 | 84 | const App = (): JSX.Element => { 85 | const [user, setUser] = useState(null); 86 | const [puzzleCollection, setPuzzleCollection] = useState({}); 87 | const pageInfo = useRef('index'); 88 | const [darkMode, setDarkMode] = useState(false); 89 | const [autoSave, setAutoSave] = useState(false); 90 | const [highlightPeers, setHighlightPeers] = useState(true); 91 | const [showDuplicates, setShowDuplicates] = useState(true); 92 | const [trackMistakes, setTrackMistakes] = useState(false); 93 | const [showMistakesOnPuzzlePage, setShowMistakesOnPuzzlePage] = useState(false); 94 | 95 | const userContextValue: UserContextValue = useMemo( 96 | () => ({ user, setUser }), 97 | [user] 98 | ); 99 | 100 | const puzzleCollectionContextValue: PuzzleCollectionContextValue = 101 | useMemo( 102 | () => ({ puzzleCollection, setPuzzleCollection }), 103 | [puzzleCollection] 104 | ); 105 | 106 | const gameSettingsContextValue: GameSettingContextValue = useMemo( 107 | () => ({ 108 | darkMode, 109 | setDarkMode, 110 | autoSave, 111 | setAutoSave, 112 | highlightPeers, 113 | setHighlightPeers, 114 | showDuplicates, 115 | setShowDuplicates, 116 | trackMistakes, 117 | setTrackMistakes, 118 | showMistakesOnPuzzlePage, 119 | setShowMistakesOnPuzzlePage 120 | }), 121 | [darkMode, autoSave, highlightPeers, showDuplicates, trackMistakes, showMistakesOnPuzzlePage] 122 | ); 123 | 124 | return ( 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | ); 135 | }; 136 | 137 | export default App; 138 | -------------------------------------------------------------------------------- /src/client/pages/PuzzleSelect/PuzzleSelectViaFilters.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | // Types 5 | import { PageContextValue } from '../../frontendTypes'; 6 | 7 | // Context 8 | import { userContext, pageContext } from '../../context'; 9 | 10 | const PuzzleSelectViaFilters = () => { 11 | const navigate = useNavigate(); 12 | const { user, setUser } = useContext(userContext); 13 | const { pageInfo } = useContext(pageContext); 14 | const [difficultyScore, setDifficultyScore] = useState(1); 15 | const [uniqueSolution, setUniqueSolution] = useState(false); 16 | const [singleCandidate, setSingleCandidate] = useState(false); 17 | const [singlePosition, setSinglePosition] = useState(false); 18 | const [candidateLines, setCandidateLines] = useState(false); 19 | const [doublePairs, setDoublePairs] = useState(false); 20 | const [multipleLines, setMultipleLines] = useState(false); 21 | const [nakedPair, setNakedPair] = useState(false); 22 | const [hiddenPair, setHiddenPair] = useState(false); 23 | const [nakedTriple, setNakedTriple] = useState(false); 24 | const [hiddenTriple, setHiddenTriple] = useState(false); 25 | const [xWing, setXWing] = useState(false); 26 | const [forcingChains, setForcingChains] = useState(false); 27 | const [nakedQuad, setNakedQuad] = useState(false); 28 | const [hiddenQuad, setHiddenQuad] = useState(false); 29 | const [swordfish, setSwordfish] = useState(false); 30 | 31 | useEffect(() => { 32 | pageInfo.current = 'PuzzleSelectMenu'; 33 | }, [pageInfo]); 34 | /* 35 | const testClick = () => { 36 | navigate(`/${user.username}/puzzle/2`); 37 | This works using the loaders as they're set-up 38 | 39 | To make the switch from this shortcut to the second puzzle to what I'm looking for I'll have to: 40 | Have a submit button attached to a submit method, wherein the submit method performs a fetch 41 | request that comes back with a new puzzle. I'll then have to update both the user and the 42 | puzzleCollection to include the new puzzle, and then I can't navigate yet because I can't change 43 | state and navigate in the same click. I have to wait for state to be updated and then navigate 44 | using a useEffect. I'll probably update user's lastPuzzle, and then navigate using that state as 45 | my param. Then PuzzlePage will use the state from user and puzzleCollection with said param to 46 | render the puzzle 47 | 48 | }; 49 | */ 50 | return ( 51 | <> 52 |

New puzzle select

53 |

Disabled buttons represent features that are on their way

54 |
55 | 56 | 57 | 58 | {/* */} 59 |
60 |

Puzzle Filters:

61 |
62 |
difficultyScore
63 |
64 | {' '} 65 | uniqueSolution{' '} 66 |
67 |
68 | {' '} 69 | singleCandidate{' '} 70 |
71 |
72 | {' '} 73 | singlePosition{' '} 74 |
75 |
76 | {' '} 77 | candidateLines{' '} 78 |
79 |
80 | {' '} 81 | doublePairs{' '} 82 |
83 |
84 | {' '} 85 | multipleLines{' '} 86 |
87 |
88 | {' '} 89 | nakedPair{' '} 90 |
91 |
92 | {' '} 93 | hiddenPair{' '} 94 |
95 |
96 | {' '} 97 | nakedTriple{' '} 98 |
99 |
100 | {' '} 101 | hiddenTriple{' '} 102 |
103 |
104 | {' '} 105 | xWing{' '} 106 |
107 |
108 | {' '} 109 | forcingChains{' '} 110 |
111 |
112 | {' '} 113 | nakedQuad{' '} 114 |
115 |
116 | {' '} 117 | hiddenQuad{' '} 118 |
119 |
120 | {' '} 121 | swordfish{' '} 122 |
123 |
124 | 125 | ); 126 | }; 127 | 128 | export default PuzzleSelectViaFilters; 129 | 130 | // Wrote this before realizing I'd like to use this logic in the backend 131 | // Saving this code until I use it there 132 | 133 | // const puzzleSelectWithoutFilters = (user, puzzleRangeStart, puzzleRangeEnd) => { 134 | // const takenNumbers = new Set(); 135 | // for (const puzzleObj of user.allPuzzles) { 136 | // takenNumbers.add(puzzleObj.puzzleNumber); 137 | // } 138 | // let numberIsValid = false; 139 | // let puzzleNumber; 140 | // while (!numberIsValid) { 141 | // puzzleNumber = 142 | // Math.floor(Math.random() * (puzzleRangeEnd - puzzleRangeStart) + puzzleRangeStart); 143 | // if (!takenNumbers.has(puzzleNumber)) numberIsValid = true; 144 | // } 145 | // return puzzleNumber; 146 | // }; 147 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/singlePositionSolver.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SolveTechnique } from '../../types'; 3 | import { PuzzleVal } from '../../client/frontendTypes'; 4 | 5 | // Utilities 6 | import { 7 | boxes, 8 | allSquareIds 9 | } from '../../client/utils/puzzle-state-management-functions/squareIdsAndPuzzleVals'; 10 | import { newFilledSquare } from '../../client/utils/puzzle-state-management-functions/newFilledSquare'; 11 | import { updateSolveSquares } from './updateSolveSquares'; 12 | import { newSolveSquares } from './solutionFramework'; 13 | import { populateSolveSquaresIfEmpty } from './populateSolveSquaresIfEmpty'; 14 | 15 | /** singlePositionSolver 16 | * 17 | * This is the optimized version of the technique that utilizes a pre-existing solveSquares to 18 | * execute. It was also populate solveSquares if it's empty. Using a pre-existing solveSquares 19 | * allows us to take into account solveSquares altered by other functions like double pairs into 20 | * account 21 | * 22 | * This function takes advantage of a pattern of auto-filled pencil values, wherein a single 23 | * position candidate will be the only square with a particular puzzleVal in a box (set of 3x3 24 | * squares). Every other square in the box will have that puzzleVal excluded either because it has 25 | * a filledSquares value already, or one of its peers prevents it from having said puzzleVal. 26 | * 27 | * @param filledSquares 28 | * @param solveSquares 29 | * @param solutionCache 30 | * @returns 31 | */ 32 | export const singlePositionSolver: SolveTechnique = ( 33 | filledSquares, 34 | solveSquares, 35 | solutionCache 36 | ) => { 37 | // console.log('Executing singlePositionSolver'); 38 | let changeMade = false; 39 | populateSolveSquaresIfEmpty(filledSquares, solveSquares); 40 | // This function takes the current solveSquares rather than calculating a new solveSquares 41 | // Therefore penciled numbers removed via other functions will be considered in this function 42 | for (const box of boxes) { 43 | box.forEach((squareId) => { 44 | if (changeMade || solveSquares[squareId].size === 0) return; 45 | const boxPuzzleValues = new Set(); 46 | 47 | box.forEach((boxSquareId) => { 48 | if (boxSquareId === squareId) return; 49 | solveSquares[boxSquareId].forEach((puzzleVal) => { 50 | boxPuzzleValues.add(puzzleVal); 51 | }); 52 | }); 53 | 54 | const uniqueVals = new Set(); 55 | solveSquares[squareId].forEach((puzzleVal) => { 56 | if (!boxPuzzleValues.has(puzzleVal)) { 57 | uniqueVals.add(puzzleVal); 58 | } 59 | }); 60 | if (uniqueVals.size === 0) return; 61 | 62 | const val = uniqueVals.values().next().value as PuzzleVal; 63 | filledSquares[squareId] = newFilledSquare(val, false); 64 | filledSquares.size += 1; 65 | solutionCache.singlePosition += 1; 66 | solveSquares[squareId].clear(); 67 | updateSolveSquares(filledSquares, solveSquares); 68 | changeMade = true; 69 | return; 70 | }); 71 | if (changeMade) break; 72 | } 73 | return changeMade; 74 | }; 75 | 76 | /** singlePositionSolver2 77 | * 78 | * This is the user friendly version of the method that ignores a users current pencilSquares 79 | * and calculates a solveSquares based on the filledSquares alone. 80 | * 81 | * This function takes advantage of a pattern of auto-filled pencil values, wherein a single 82 | * position candidate will be the only square with a particular puzzleVal in a box (set of 3x3 83 | * squares). Every other square in the box will have that puzzleVal excluded either because it has 84 | * a filledSquares value already, or one of its peers prevents it from having said puzzleVal. 85 | * 86 | * @param filledSquares 87 | * @param solveSquares 88 | * @param solutionCache 89 | * @returns 90 | */ 91 | export const singlePositionSolver2: SolveTechnique = ( 92 | filledSquares, 93 | solveSquares, 94 | solutionCache 95 | ) => { 96 | // console.log('Executing singlePositionSolver'); 97 | let changeMade = false; 98 | // This function will operate separately from any user supplied pencil square values for 99 | // solveSquares, therefore we'll make our own solveSquares here, even though we have one as 100 | // a param to help control pencilSquares in the case of a filledSquares value update 101 | const freshSolveSquares = newSolveSquares(); 102 | updateSolveSquares(filledSquares, freshSolveSquares); 103 | for (const squareId of allSquareIds) { 104 | // If there's already a value for a certain square, skip over it 105 | if (filledSquares[squareId] || freshSolveSquares[squareId].size === 0) continue; 106 | 107 | let currentBox = boxes[0]; 108 | for (const box of boxes) { 109 | if (box.has(squareId)) { 110 | currentBox = box; 111 | } 112 | } 113 | 114 | const boxPuzzleValues = new Set(); 115 | currentBox.forEach((boxSquareId) => { 116 | if (boxSquareId !== squareId) { 117 | freshSolveSquares[boxSquareId].forEach((puzzleVal) => { 118 | boxPuzzleValues.add(puzzleVal); 119 | }); 120 | } 121 | }); 122 | const uniqueVals = new Set(); 123 | freshSolveSquares[squareId].forEach((puzzleVal) => { 124 | if (!boxPuzzleValues.has(puzzleVal)) { 125 | uniqueVals.add(puzzleVal); 126 | } 127 | }); 128 | 129 | if (uniqueVals.size !== 1) continue; 130 | 131 | const val = uniqueVals.values().next().value as PuzzleVal; 132 | filledSquares[squareId] = newFilledSquare(val, false); 133 | filledSquares.size += 1; 134 | solutionCache.singlePosition += 1; 135 | solveSquares[squareId].clear(); 136 | updateSolveSquares(filledSquares, solveSquares); 137 | changeMade = true; 138 | break; 139 | } 140 | return changeMade; 141 | }; 142 | -------------------------------------------------------------------------------- /src/client/utils/save.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { FilledSquares, PencilSquares, SetUser } from '../frontendTypes'; 3 | import { User } from '../../types'; 4 | 5 | // Utils 6 | import { 7 | createProgressString, 8 | createPencilProgressString 9 | } from './puzzle-state-management-functions/puzzleStringsFromSquares'; 10 | 11 | /** savePuzzleAtLeastOnce 12 | * 13 | * Returns a function that allows a user to save their progress on a puzzle. The returned function 14 | * also confirms that there's a difference between the current puzzles state and a user's progress 15 | * string before saving. However, the function utilizes closure to make sure that the first save 16 | * occurs regardless of said difference. This is important as a puzzle isn't saved to a user in the 17 | * database until saved at least once. 18 | * 19 | * @returns function 20 | */ 21 | export const savePuzzleAtLeastOnce = () => { 22 | let firstSave = true; 23 | 24 | return async ( 25 | puzzleNumber: number, 26 | filledSquares: FilledSquares, 27 | pencilSquares: PencilSquares, 28 | user: User, 29 | setUser: SetUser 30 | ) => { 31 | // Don't allow a guest to save 32 | if (!user || user.username === 'guest') { 33 | alert('Please sign up for a free account to save'); 34 | return; 35 | } 36 | 37 | if (puzzleNumber === 0) { 38 | alert('Please choose puzzle before saving'); 39 | } 40 | 41 | // createProgressString generates a puzzle string that reflects the current state of allSquares 42 | const currentProgress = createProgressString(filledSquares); 43 | const currentPencilProgress = createPencilProgressString(pencilSquares); 44 | 45 | // Check to see if there are differences between the current state and a user's progress string 46 | const isPuzzleDifference = currentProgress !== user.allPuzzles[puzzleNumber].progress; 47 | const isPencilSquaresDifference = 48 | currentPencilProgress !== user.allPuzzles[puzzleNumber].pencilProgress; 49 | 50 | // Save only if it's the first time or there's a difference. Otherwise, skip saving 51 | if (firstSave || isPuzzleDifference || isPencilSquaresDifference) { 52 | // Play with optimistic rendering here later. For now, confirm things happened in real time 53 | const res = await fetch('/api/user/save-puzzle', { 54 | method: 'POST', 55 | headers: { 'Content-Type': 'application/json' }, 56 | body: JSON.stringify({ 57 | username: user.username, 58 | puzzleNumber, 59 | progress: currentProgress, 60 | pencilProgress: currentPencilProgress 61 | }) 62 | }); 63 | 64 | if (!res.ok) { 65 | alert('Problem saving updated progress to user document in database, try again later'); 66 | return; 67 | } 68 | 69 | const { status } = await res.json(); 70 | 71 | if (status !== 'valid') { 72 | alert( 73 | 'Problem saving updated progress to user document in database (bad status), try again later' 74 | ); 75 | return; 76 | } 77 | 78 | // If the save was successful, update the user's progress string so that if they navigate away 79 | // from the page and then come back the saved version of the puzzle will be shown 80 | const newUser = { 81 | ...user, 82 | allPuzzles: { ...user.allPuzzles } 83 | }; 84 | 85 | newUser.allPuzzles[puzzleNumber].progress = currentProgress; 86 | newUser.allPuzzles[puzzleNumber].pencilProgress = currentPencilProgress; 87 | 88 | setUser(newUser); 89 | 90 | if (firstSave) { 91 | firstSave = false; 92 | // console.log('First save successful'); 93 | return; 94 | } 95 | 96 | // console.log('Successful save'); 97 | return; 98 | } 99 | 100 | // console.log('No puzzle differences from last save, no save necessary'); 101 | }; 102 | }; 103 | 104 | export const saveToLocalUserOnly = ( 105 | puzzleNumber: number, 106 | filledSquares: FilledSquares, 107 | pencilSquares: PencilSquares, 108 | user: User, 109 | setUser: SetUser 110 | ) => { 111 | if (!user) { 112 | throw new Error('Somehow a local save to user has been attempted without a user'); 113 | } 114 | 115 | if (puzzleNumber === 0) { 116 | alert('Please choose puzzle before saving'); 117 | } 118 | 119 | // createProgressString generates a puzzle string that reflects the current state of allSquares 120 | const currentProgress = createProgressString(filledSquares); 121 | const currentPencilProgress = createPencilProgressString(pencilSquares); 122 | 123 | // Check to see if there are differences between the current state and a user's progress string 124 | const isPuzzleDifference = currentProgress !== user.allPuzzles[puzzleNumber].progress; 125 | const isPencilSquaresDifference = 126 | currentPencilProgress !== user.allPuzzles[puzzleNumber].pencilProgress; 127 | 128 | if (isPuzzleDifference || isPencilSquaresDifference) { 129 | const newUser = { 130 | ...user, 131 | allPuzzles: { ...user.allPuzzles } 132 | }; 133 | newUser.allPuzzles[puzzleNumber].progress = currentProgress; 134 | newUser.allPuzzles[puzzleNumber].pencilProgress = currentPencilProgress; 135 | setUser(newUser); 136 | } 137 | }; 138 | 139 | export const saveUserToDatabase = async (user: User) => { 140 | if (!user || user.username === 'guest') return; 141 | 142 | const res = await fetch('/api/user/save-user', { 143 | method: 'POST', 144 | headers: { 'Content-Type': 'application/json' }, 145 | body: JSON.stringify({ 146 | username: user.username, 147 | lastPuzzle: user.lastPuzzle, 148 | allPuzzles: user.allPuzzles 149 | }) 150 | }); 151 | 152 | if (!res.ok) { 153 | alert('Problem saving updated user to database, try again later'); 154 | return; 155 | } 156 | 157 | const { status } = await res.json(); 158 | 159 | if (status !== 'valid') { 160 | alert('Problem saving updated user to database (bad status), try again later'); 161 | return; 162 | } 163 | }; 164 | -------------------------------------------------------------------------------- /src/globalUtils/puzzle-solution-functions/nakedSubsetSolver.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import { SolveSquares, SolveTechnique, TechniqueString } from '../../types'; 3 | import { PuzzleVal, SquareId } from '../../client/frontendTypes'; 4 | 5 | // Utilities 6 | import { 7 | rows, 8 | cols, 9 | boxes 10 | } from '../../client/utils/puzzle-state-management-functions/squareIdsAndPuzzleVals'; 11 | 12 | const squareCollectionNakedSubsetSolver = ( 13 | numberInSubset: number, 14 | squareIdsCollection: Set[], 15 | solveSquares: SolveSquares 16 | ) => { 17 | // isPuzzleValToRemove will be set to true when the function successfully finds puzzleVals 18 | // to remove 19 | let isPuzzleValToRemove = false; 20 | // foundSquareIds will hold the squareIds of the squares that puzzleVals should be removed from 21 | let foundSquareIds = new Set(); 22 | // foundPuzzleVals will hold the puzzleVals to be removed 23 | let foundPuzzleVals = new Set(); 24 | 25 | // For example, squareIdsCollection will be rows, aka an array of Sets, where each set holds 26 | // the squareIds in a single row 27 | for (const squareIds of squareIdsCollection) { 28 | // for the squareIds in a row, add each squareId that has a solveSquares[squareId].size 29 | // of numberInSubset 30 | const sameSizeSquares: SquareId[] = []; 31 | squareIds.forEach((squareId) => { 32 | if (solveSquares[squareId].size === numberInSubset) { 33 | sameSizeSquares.push(squareId); 34 | } 35 | }); 36 | // If the sameSizeSquares array has length less than the numberInSubset, 37 | // move onto the next row via continue 38 | if (sameSizeSquares.length < numberInSubset) continue; 39 | 40 | // Initialize a matchedSquares object to hold every squareId that has an identical 41 | // solveSquare[squareId] puzzleVal set 42 | const matchedSquares = new Set(); 43 | // Iterate over the sameSizeSquares array set twice with i and j to get every combo 44 | for (let i = 0; i < sameSizeSquares.length; i++) { 45 | // push sameSizeSquares[i] to matchedSquares 46 | matchedSquares.add(sameSizeSquares[i]); 47 | foundPuzzleVals = solveSquares[sameSizeSquares[i]]; 48 | 49 | for (let j = i + 1; j < sameSizeSquares.length; j++) { 50 | let allPuzzleValsSame = true; 51 | // Iterate over every puzzleVal in solveSquares[sameSizeSquares[i]] 52 | solveSquares[sameSizeSquares[i]].forEach((puzzleVal) => { 53 | if (!solveSquares[sameSizeSquares[j]].has(puzzleVal)) { 54 | allPuzzleValsSame = false; 55 | } 56 | }); 57 | // if every puzzleSquare in solveSquares[sameSizeSquares[i]] is in 58 | // solveSquares[sameSizeSquares[j]], add sameSizeSquares[j] to matchedSquares 59 | if (allPuzzleValsSame) matchedSquares.add(sameSizeSquares[j]); 60 | if (matchedSquares.size < numberInSubset) continue; 61 | 62 | foundSquareIds = new Set(squareIds); 63 | // Remove every value in foundRow that's also in matchedSquares 64 | foundSquareIds.forEach((squareId) => { 65 | if (matchedSquares.has(squareId)) { 66 | foundSquareIds.delete(squareId); 67 | } 68 | }); 69 | // Iterate over every square in the foundRow and see if there's a puzzleVal in one of the 70 | // squares that's also in the set of foundPuzzleVals to remove 71 | // If so, set isPuzzleValToRemove to true and break out of iterating. That way only one 72 | // execution of the method will be performed 73 | foundSquareIds.forEach((squareId) => { 74 | if (isPuzzleValToRemove) return; 75 | foundPuzzleVals.forEach((puzzleVal) => { 76 | if (isPuzzleValToRemove) return; 77 | if (solveSquares[squareId].has(puzzleVal)) { 78 | isPuzzleValToRemove = true; 79 | } 80 | }); 81 | }); 82 | // If match found, break out of j loop 83 | if (isPuzzleValToRemove) break; 84 | } 85 | if (isPuzzleValToRemove) { 86 | // If match found, break out of i loop 87 | break; 88 | } else { 89 | // Otherwise clear the matches and move onto next i 90 | matchedSquares.clear(); 91 | } 92 | } 93 | if (isPuzzleValToRemove) break; 94 | } 95 | // If the function successfully found a puzzleVal to remove, remove every puzzleVal in 96 | // foundPuzzleVals from the squares in the row which aren't in the matchedSquares 97 | if (isPuzzleValToRemove) { 98 | foundSquareIds.forEach((squareId) => { 99 | foundPuzzleVals.forEach((puzzleVal) => { 100 | if (solveSquares[squareId].has(puzzleVal)) { 101 | // console.log('Removed', puzzleVal, 'from', squareId); 102 | solveSquares[squareId].delete(puzzleVal); 103 | } 104 | }); 105 | }); 106 | } 107 | 108 | return isPuzzleValToRemove; 109 | }; 110 | 111 | const nakedSubsetSolverFactory = (numberInSubset: number, techniqueString: TechniqueString) => { 112 | const nakedSubsetSolver: SolveTechnique = (filledSquares, solveSquares, solutionCache) => { 113 | let changeMade = squareCollectionNakedSubsetSolver(numberInSubset, boxes, solveSquares); 114 | // if (changeMade) console.log('Removed via boxes'); 115 | if (!changeMade) { 116 | changeMade = squareCollectionNakedSubsetSolver(numberInSubset, rows, solveSquares); 117 | // if (changeMade) console.log('Removed via rows'); 118 | } 119 | if (!changeMade) { 120 | changeMade = squareCollectionNakedSubsetSolver(numberInSubset, cols, solveSquares); 121 | // if (changeMade) console.log('Removed via cols'); 122 | } 123 | if (changeMade) solutionCache[techniqueString] += 1; 124 | // console.log(techniqueString, 'applied'); 125 | return changeMade; 126 | }; 127 | return nakedSubsetSolver; 128 | }; 129 | 130 | export const nakedPairSolver = nakedSubsetSolverFactory(2, 'nakedPair'); 131 | export const nakedTripleSolver = nakedSubsetSolverFactory(3, 'nakedTriple'); 132 | export const nakedQuadSolver = nakedSubsetSolverFactory(4, 'nakedQuad'); 133 | -------------------------------------------------------------------------------- /src/client/pages/Puzzle/PuzzlePageTest.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | import { useLoaderData } from 'react-router-dom'; 3 | 4 | // Types 5 | import { 6 | InitialSquares, 7 | FilledSquares, 8 | PencilSquares, 9 | ClickedSquare, 10 | SquareContextValue, 11 | PageContextValue 12 | } from '../../frontendTypes'; 13 | 14 | // Components 15 | import PuzzleContainer from './components/PuzzleContainer'; 16 | import NumberSelectBar from './components/NumberSelectBar'; 17 | import ToolBar from './components/ToolBar'; 18 | import PuzzleStringDisplay from './components/PuzzleStringDisplay'; 19 | // import SavedPuzzleGraphic from '../PuzzleSelect/components/SavedPuzzleGraphic'; 20 | 21 | // Context 22 | import { squareContext, pageContext } from '../../context'; 23 | 24 | // Utilities 25 | import { initializeSquaresForTestPage } from '../../utils/puzzle-state-management-functions/initialSquareStatePopulation'; 26 | import { isPuzzleFinished } from '../../utils/puzzle-state-management-functions/isPuzzleFinished'; 27 | import { onPuzzleKeyDown } from '../../utils/puzzle-state-management-functions/puzzleValueChange'; 28 | // const lps = '100000000200111100300100100400111100511100111000101000000101110000000001000001110'; 29 | // const lps2 = '100000000100111100100100100100111000111100111000101000000100110000000001000001110' 30 | 31 | // Main Component 32 | const PuzzlePageTest = () => { 33 | const puzzleData = useLoaderData() as { puzzle: string }; 34 | const { pageInfo } = useContext(pageContext); 35 | const [pencilMode, setPencilMode] = useState(false); 36 | const [clickedSquare, setClickedSquare] = useState(null); 37 | 38 | // initialSquares acts as a cache so that all square objects can be simultaneously calculated 39 | // with their duplicates accounted for before the first render without repeating calculations 40 | const [initialSquares, setInitialSquares] = useState(() => 41 | initializeSquaresForTestPage(puzzleData.puzzle) 42 | ); 43 | const [filledSquares, setFilledSquares] = useState(initialSquares.filledSquares); 44 | const [pencilSquares, setPencilSquares] = useState(initialSquares.pencilSquares); 45 | 46 | useEffect(() => { 47 | pageInfo.current = 'PuzzlePage'; 48 | }, [pageInfo]); 49 | 50 | // Checks to see if user has solved puzzle on each allSquares update 51 | useEffect(() => { 52 | // setTimeout is used so the allSquares update is painted before this alert goes out 53 | if (isPuzzleFinished(filledSquares)) { 54 | const clear = setTimeout(() => { 55 | alert('You finished the puzzle!'); 56 | }, 100); 57 | return () => clearTimeout(clear); 58 | } 59 | }, [filledSquares]); 60 | 61 | // For tracking renders: 62 | // const renderCount = useRef(1); 63 | // useEffect(() => { 64 | // console.log('PuzzlePage render:', renderCount.current); 65 | // renderCount.current += 1; 66 | // }); 67 | 68 | /** removeClickedSquareOnPuzzlePageBlur 69 | * 70 | * If a blur event is triggered for the puzzle-page-container, this function ensures that 71 | * clickedSquare is set to null only if the user clicked outside of puzzle-page-container. 72 | * 73 | * @param e - React.FocusEvent 74 | */ 75 | const removeClickedSquareOnPuzzlePageBlur = (e: React.FocusEvent): void => { 76 | /** 77 | * e.currentTarget is the element with the listener attached to it, aka puzzle-page-container. 78 | * e.relatedTarget is the element that triggers the blur event, aka the element clicked on. 79 | * e.currentTarget.contains(e.relatedTarget) will be true changing focus within the 80 | * puzzle-page-container; e.currentTarget === e.relatedTarget will be true if transitioning 81 | * from focusing on a button to the puzzle-page-container 82 | */ 83 | if (!(e.currentTarget === e.relatedTarget || e.currentTarget.contains(e.relatedTarget))) { 84 | setClickedSquare(null); 85 | } 86 | }; 87 | 88 | const squareContextValue: SquareContextValue = { 89 | puzzleNumber: 0, 90 | clickedSquare, 91 | setClickedSquare, 92 | initialSquares, 93 | pencilMode, 94 | setPencilMode, 95 | filledSquares, 96 | setFilledSquares, 97 | pencilSquares, 98 | setPencilSquares 99 | }; 100 | 101 | return ( 102 | 103 |
104 |
109 | onPuzzleKeyDown( 110 | event, 111 | pencilMode, 112 | clickedSquare, 113 | filledSquares, 114 | setFilledSquares, 115 | pencilSquares, 116 | setPencilSquares 117 | ) 118 | } 119 | > 120 | 121 | 122 | 123 | 124 |
125 |
126 |
127 | ); 128 | }; 129 | 130 | export default PuzzlePageTest; 131 | 132 | //---- HELPER FUNCTIONS ---------------------------------------------------------------------------- 133 | 134 | // const samplePuzzle1 = 135 | // '070000043040009610800634900094052000358460020000800530080070091902100005007040802'; 136 | // const samplePuzzle2 = 137 | // '679518243543729618821634957794352186358461729216897534485276391962183475137945860'; 138 | // const sampleSolution1 = 139 | // '679518243543729618821634957794352186358461729216897534485276391962183475137945862'; 140 | // const tripleHiddenExample = 141 | // '528600049136490025794205630000100200007826300002509060240300976809702413070904582'; 142 | const emptyPuzzle = '0'.repeat(81); 143 | 144 | export const puzzleTestLoader = () => { 145 | // return { puzzle: tripleHiddenExample }; 146 | return { puzzle: emptyPuzzle }; 147 | }; 148 | -------------------------------------------------------------------------------- /src/client/pages/Puzzle/PuzzlePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | 4 | // Types 5 | import { 6 | InitialSquares, 7 | FilledSquares, 8 | PencilSquares, 9 | ClickedSquare, 10 | SquareContextValue, 11 | UserContextValue, 12 | PuzzleCollectionContextValue, 13 | PageContextValue 14 | } from '../../frontendTypes'; 15 | 16 | // Components 17 | import PuzzleContainer from './components/PuzzleContainer'; 18 | import NumberSelectBar from './components/NumberSelectBar'; 19 | import ToolBar from './components/ToolBar'; 20 | import Loading from '../../shared-components/Loading'; 21 | 22 | // Context 23 | import { userContext, puzzleCollectionContext, squareContext, pageContext } from '../../context'; 24 | 25 | // Utilities 26 | import { 27 | initializeSquares, 28 | resetStateOnRefresh 29 | } from '../../utils/puzzle-state-management-functions/initialSquareStatePopulation'; 30 | import { isPuzzleFinished } from '../../utils/puzzle-state-management-functions/isPuzzleFinished'; 31 | import { onPuzzleKeyDown } from '../../utils/puzzle-state-management-functions/puzzleValueChange'; 32 | import { saveToLocalUserOnly } from '../../utils/save'; 33 | 34 | // Main Component 35 | const PuzzlePage = () => { 36 | const puzzleNumber = Number(useParams().puzzleNumber); 37 | const { user, setUser } = useContext(userContext); 38 | const { puzzleCollection } = useContext(puzzleCollectionContext); 39 | const { pageInfo } = useContext(pageContext); 40 | const [pencilMode, setPencilMode] = useState(false); 41 | const [clickedSquare, setClickedSquare] = useState(null); 42 | 43 | // initialSquares acts as a cache so that all square objects can be simultaneously calculated 44 | // with their duplicates accounted for before the first render without repeating calculations 45 | const [initialSquares, setInitialSquares] = useState(() => 46 | initializeSquares(puzzleNumber, user, puzzleCollection) 47 | ); 48 | const [filledSquares, setFilledSquares] = useState(initialSquares.filledSquares); 49 | const [pencilSquares, setPencilSquares] = useState(initialSquares.pencilSquares); 50 | 51 | useEffect(() => { 52 | pageInfo.current = 'PuzzlePage'; 53 | }, [pageInfo]); 54 | 55 | useEffect(() => { 56 | // Needs to run every time so lastPuzzle is updated on refresh. At the moment, user isn't saved 57 | // to database until user hits save so they're old last puzzle could be used in navbar without 58 | // this useEffect. If there is a user, this puzzle number will be set as their most recent 59 | // puzzle for navigation purposes 60 | if (user && user.lastPuzzle !== puzzleNumber) { 61 | setUser({ 62 | ...user, 63 | lastPuzzle: puzzleNumber 64 | }); 65 | } 66 | }); 67 | 68 | useEffect(() => { 69 | if (user && filledSquares.size === 0) { 70 | resetStateOnRefresh( 71 | puzzleNumber, 72 | user, 73 | puzzleCollection, 74 | setInitialSquares, 75 | setFilledSquares, 76 | setPencilSquares 77 | ); 78 | } 79 | }, [user, puzzleCollection, filledSquares, puzzleNumber]); 80 | 81 | // Checks to see if user has solved puzzle on each allSquares update 82 | useEffect(() => { 83 | // setTimeout is used so the allSquares update is painted before this alert goes out 84 | if (isPuzzleFinished(filledSquares)) { 85 | const clear = setTimeout(() => { 86 | alert('You finished the puzzle!'); 87 | }, 100); 88 | return () => clearTimeout(clear); 89 | } 90 | }, [filledSquares]); 91 | 92 | // For tracking renders: 93 | // const renderCount = useRef(1); 94 | // useEffect(() => { 95 | // console.log('PuzzlePage render:', renderCount.current); 96 | // renderCount.current += 1; 97 | // }); 98 | 99 | /** removeClickedSquareOnPuzzlePageBlur 100 | * 101 | * If a blur event is triggered for the puzzle-page-container, this function ensures that 102 | * clickedSquare is set to null only if the user clicked outside of puzzle-page-container. 103 | * 104 | * @param e - React.FocusEvent 105 | */ 106 | const removeClickedSquareOnPuzzlePageBlur = (e: React.FocusEvent): void => { 107 | /** 108 | * e.currentTarget is the element with the listener attached to it, aka puzzle-page-container. 109 | * e.relatedTarget is the element that triggers the blur event, aka the element clicked on. 110 | * e.currentTarget.contains(e.relatedTarget) will be true changing focus within the 111 | * puzzle-page-container; e.currentTarget === e.relatedTarget will be true if transitioning 112 | * from focusing on a button to the puzzle-page-container 113 | */ 114 | if (!(e.currentTarget === e.relatedTarget || e.currentTarget.contains(e.relatedTarget))) { 115 | setClickedSquare(null); 116 | saveToLocalUserOnly(puzzleNumber, filledSquares, pencilSquares, user, setUser); 117 | } 118 | }; 119 | 120 | const squareContextValue: SquareContextValue = { 121 | puzzleNumber, 122 | clickedSquare, 123 | setClickedSquare, 124 | initialSquares, 125 | pencilMode, 126 | setPencilMode, 127 | filledSquares, 128 | setFilledSquares, 129 | pencilSquares, 130 | setPencilSquares 131 | }; 132 | 133 | return ( 134 | <> 135 | {!user ? ( 136 | 137 | ) : ( 138 | 139 |
140 |
145 | onPuzzleKeyDown( 146 | event, 147 | pencilMode, 148 | clickedSquare, 149 | filledSquares, 150 | setFilledSquares, 151 | pencilSquares, 152 | setPencilSquares 153 | ) 154 | } 155 | > 156 | 157 | 158 | 159 |
160 |
161 |
162 | )} 163 | 164 | ); 165 | }; 166 | 167 | export default PuzzlePage; 168 | --------------------------------------------------------------------------------