diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..375e802 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "tabWidth": 2, + "printWidth": 100, + "singleQuote": true, + "trailingComma": "none", + "bracketSpacing": true, + "semi": true, + "arrowParens": "avoid" +} diff --git a/public/sounds/darn.mp3 b/public/sounds/darn.mp3 new file mode 100644 index 0000000..be6a82b Binary files /dev/null and b/public/sounds/darn.mp3 differ diff --git a/public/sounds/flush.wav b/public/sounds/flush.wav new file mode 100644 index 0000000..9cbc7f6 Binary files /dev/null and b/public/sounds/flush.wav differ diff --git a/public/sounds/starman.mp3 b/public/sounds/starman.mp3 new file mode 100644 index 0000000..873fe8e Binary files /dev/null and b/public/sounds/starman.mp3 differ diff --git a/public/sounds/toot.wav b/public/sounds/toot.wav new file mode 100644 index 0000000..ffa14d8 Binary files /dev/null and b/public/sounds/toot.wav differ diff --git a/public/sounds/woohoo.ogg b/public/sounds/woohoo.ogg new file mode 100644 index 0000000..5763978 Binary files /dev/null and b/public/sounds/woohoo.ogg differ diff --git a/public/sounds/yay.mp3 b/public/sounds/yay.mp3 new file mode 100644 index 0000000..de5174c Binary files /dev/null and b/public/sounds/yay.mp3 differ diff --git a/public/sounds/yipee.mp3 b/public/sounds/yipee.mp3 new file mode 100644 index 0000000..402646c Binary files /dev/null and b/public/sounds/yipee.mp3 differ diff --git a/src/App.tsx b/src/App.tsx index c51634e..67080dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import {useSpring, animated} from '@react-spring/web'; import LoginForm from './LoginForm'; import ScoreCounter from './ScoreCounter'; +import Row from './Row'; import Icon from './Icon'; import { useJSONLocalStorage, useLocalStorage } from './hooks/useLocalStorage'; @@ -11,15 +11,12 @@ import {toot, starman, flush, getCheer} from './sound'; import './App.css'; -const COLUMNS = [1,2,3,4,5,6,7,8,9,10]; interface Stats { attempts: number; scheduledPoops: number; selfPoops: number; cleanUndies: number; - streaks: number[]; - currentStreakSince: number; }; const initialStats:Stats = { @@ -27,16 +24,18 @@ const initialStats:Stats = { scheduledPoops: 1, selfPoops: 1, cleanUndies: 1, +}; + +const initialStreakTracker:StreakTracker = { streaks: [0,0,0,0], currentStreakSince: Date.now() -}; +} function App() { const [stats, setStats] = useJSONLocalStorage("stats", initialStats); const [name] = useLocalStorage("name", {}); const [password] = useLocalStorage("password", {}); - const { attempts, scheduledPoops, selfPoops, cleanUndies, streaks, currentStreakSince } = stats; return !name || !password ? :(
@@ -46,87 +45,10 @@ function App() { Points:
-
{ - setStats({...stats, attempts: attempts + 1 }); - flush.play(); - setTimeout(() => getCheer().play(), 1800) - }}> - {COLUMNS.map(i => - i <= attempts ? - - :
- )} -
{ - e.stopPropagation(); - setStats({...stats, attempts: Math.max(0, attempts - 1)}); - console.log("BLARG"); - }} - > - 🚫 -
-
-
{ - setStats({...stats, scheduledPoops: scheduledPoops + 1 }); - toot.play(); - setTimeout(() => getCheer().play(), 1800) - }}> - {COLUMNS.map(i => - i <= scheduledPoops ? - - :
- )} -
{ - e.stopPropagation(); - setStats({...stats, scheduledPoops: Math.max(0, scheduledPoops - 1)}); - }} - > - 🚫 -
-
-
{ - setStats({...stats, selfPoops: selfPoops + 1 }); - toot.play(); - setTimeout(() => getCheer().play(), 1800) - }}> - {COLUMNS.map(i => - i <= selfPoops ? - - :
- )} -
{ - e.stopPropagation(); - setStats({...stats, selfPoops: Math.max(0, selfPoops - 1)}); - }} - > - 🚫 -
-
-
{ - setStats({...stats, cleanUndies: cleanUndies + 1 }) - starman.play(); - setTimeout(() => getCheer().play(), 1800) - }}> - {COLUMNS.map(i => - i <= cleanUndies ? - - :
- )} -
{ - e.stopPropagation(); - setStats({...stats, cleanUndies: Math.max(0, cleanUndies - 1)}); - }} - > - 🚫 -
-
+ + + +
); diff --git a/src/Icon.tsx b/src/Icon.tsx new file mode 100644 index 0000000..21043b3 --- /dev/null +++ b/src/Icon.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import {useSpring, animated} from '@react-spring/web'; + +const Icon = ({emoji, className}:{emoji: string, className: string}) => { + const springs = useSpring({ + from: {scale: 0.5, rotate: -45}, + to: [ + {scale: 1.3, rotate: 45}, + {scale: 1, rotate: 0} + ]}); + return + {emoji} + +} +export default Icon; diff --git a/src/LoginForm.css b/src/LoginForm.css new file mode 100644 index 0000000..9676383 --- /dev/null +++ b/src/LoginForm.css @@ -0,0 +1,21 @@ +.form-login { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 750px; +} + +.form-login > label { + margin: 15px; + width: 400px; + text-align: right; +} +.form-login input { + margin-left: 10px; +} +.form-login > .button-row { + width: 400px; + text-align: right; +} + diff --git a/src/LoginForm.tsx b/src/LoginForm.tsx new file mode 100644 index 0000000..fe7e3b1 --- /dev/null +++ b/src/LoginForm.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import './LoginForm.css'; + +import { useLocalStorage } from './hooks/useLocalStorage'; + +const LoginForm = () => { + const [, setName] = useLocalStorage('name', {}); + const [, setPassword] = useLocalStorage('password', {}); + + return ( +
{ + e.preventDefault(); + console.log('BLARG'); + const inputs = new FormData(e.target as HTMLFormElement); + setName(inputs.get('name') as string); + setPassword(inputs.get('password') as string); + }} + > + + +
+ +
+
+ ); +}; + +export default LoginForm; diff --git a/src/Row.tsx b/src/Row.tsx new file mode 100644 index 0000000..08e021d --- /dev/null +++ b/src/Row.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { getCheer } from './sound'; + +import Icon from './Icon'; + +const COLUMNS = [1,2,3,4,5,6,7,8,9,10]; +interface RowProps { + stats: Stats; + setStats: (s: Stats) => void; + name: keyof Stats; + emoji: string; + sound: HTMLAudioElement; +} + +const Row = ({stats, setStats, name, emoji, sound}: RowProps) => { + const stat = stats[name]; + return
{ + setStats({...stats, [name]: stat + 1 }); + sound.play(); + setTimeout(() => getCheer().play(), 1800) + }}> + {COLUMNS.map(i => + i <= stat ? + + :
+ )} +
{ + e.stopPropagation(); + setStats({...stats, [name]: Math.max(0, stat - 1)}); + }} + > + 🚫 +
+
+}; + +export default Row; diff --git a/src/ScoreCounter.css b/src/ScoreCounter.css new file mode 100644 index 0000000..e69de29 diff --git a/src/ScoreCounter.tsx b/src/ScoreCounter.tsx new file mode 100644 index 0000000..6199342 --- /dev/null +++ b/src/ScoreCounter.tsx @@ -0,0 +1,72 @@ +import React, {useState, useEffect} from 'react'; +import {useSpring, animated} from '@react-spring/web'; + +import './ScoreCounter.css'; + +let scrollInterval: ReturnType; + +const points = { + attempt: 1, + scheduledPoop: 5, + selfPoop: 10, + cleanUndies: 50, + streakBonus: [50, 60, 80, 100, 120, 140, 150] +}; + +const calcPoints = (stats: Stats) => { + return stats.attempts * points.attempt + + stats.scheduledPoops * points.scheduledPoop + + stats.selfPoops * points.selfPoop + + stats.cleanUndies * points.cleanUndies; + //+ stats.streaks.reduce((p,s,i) => p + s * points.streakBonus[i]); +}; + +const getShakeStyle = () => { + const xr = Math.random(); + const yr = Math.random(); + const rr = Math.random(); + const sr = Math.random(); + return { + x: Math.floor(xr * 30) - 15, + y: Math.floor(yr * 30) - 15, + rotate: Math.floor((rr * 40)) - 20, + scale: sr * 0.3 + .80 + }; +}; + +const ScoreCounter = ({stats}: {stats: Stats}) => { + const score = calcPoints(stats); + const [showing, setShowing] = useState(score); + + const restState = { x: 0, y: 0, rotate: 0, scale: 1}; + + const [springs, api] = useSpring(() => (restState)); + + useEffect(() => { + scrollInterval = setInterval(() => { + setShowing(n => { + const delta = Math.ceil(Math.abs(n - score)/5); + if(n < score) { + api.start(getShakeStyle()); + return n + delta; + } else if( n > score) { + api.start(getShakeStyle()); + return n - delta; + } else { + clearInterval(scrollInterval); + Promise.all(api.start( + {x:0, y: -10, rotate: 0, scale: 1.25})).then(() => { + api.start(restState); + });; + return n; + } + }); + }, 80); + + return () => clearInterval(scrollInterval); + }, [score]); + + return {showing}; +}; + +export default ScoreCounter; diff --git a/src/hooks/localStorage.ts b/src/hooks/localStorage.ts new file mode 100644 index 0000000..f6c82b7 --- /dev/null +++ b/src/hooks/localStorage.ts @@ -0,0 +1,106 @@ +let isLocalStorageEnabled = true; + +interface MemoryStore { + [key: string]: string; +} + +const memoryStorage: MemoryStore = {}; + +const subscriptions = new EventTarget(); + +export interface LocalStorageEvent extends CustomEvent { + key: string; + newValue: string; +} + +export const getLocalStorage = (key: string): string | null => { + if (!isLocalStorageEnabled) { + return Object.prototype.hasOwnProperty.call(memoryStorage, key) ? memoryStorage[key] : null; + } + + try { + const value = window.localStorage.getItem(key); + if (value && memoryStorage[key] !== value) { + memoryStorage[key] = value; + } + return value; + } catch (e) { + // eslint-disable-next-line no-console + console.warn('localStorage is diasabled using memory storage instead.', e); + isLocalStorageEnabled = false; + return getLocalStorage(key); + } +}; + +export const setLocalStorage = (key: string, newValue: string): void => { + const event = new CustomEvent('local-storage-change', { detail: { key, newValue } }); + + if (!isLocalStorageEnabled) { + memoryStorage[key] = newValue; + subscriptions.dispatchEvent(event); + return; + } + try { + window.localStorage.setItem(key, newValue); + memoryStorage[key] = newValue; + subscriptions.dispatchEvent(event); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('localStorage is diasabled using memory storage instead.', e); + isLocalStorageEnabled = false; + return setLocalStorage(key, newValue); + } +}; + +export const isKeySet = (key: string): boolean => { + if (isLocalStorageEnabled) { + try { + for (let i = 0; i < window.localStorage.length; i++) { + if (window.localStorage.key(i) === key) { + return true; + } + } + } catch { + // ignore the error + } + } + return Object.keys(memoryStorage).includes(key); +}; + +// This function allows you to subscribe to localStorage changes. By default +// this includes changes that are made in other tabs/windows of the same +// browser. The 'callback' will be called whenever a change is made to +// localStorage at the given 'key'. An unsubscibe function is returned, which +// you can call when you are ready to unsubscribe. +export const subscribeToLocalStorage = ( + key: string, + callback: (value: string) => void, + options?: { excludeExternal?: boolean } +): (() => void) => { + const { excludeExternal } = options || {}; + const updateHandler = (e: LocalStorageEvent | { detail: { key: string; newValue: string } }) => { + const { key: eventKey, newValue } = 'detail' in e ? e.detail : e; + if (eventKey === key) { + callback(newValue); + } + }; + // subscribe to events for this instance of the page only + subscriptions.addEventListener( + 'local-storage-change', + updateHandler as EventListenerOrEventListenerObject + ); + + // if not excluded, subscribe to events for all instances of the page + !excludeExternal && + window.addEventListener('storage', updateHandler as EventListenerOrEventListenerObject); + + // return a function that will be used to unsubscribe to these events. + return () => { + subscriptions.removeEventListener( + 'local-storage-change', + updateHandler as EventListenerOrEventListenerObject + ); + !excludeExternal && + window.removeEventListener('storage', updateHandler as EventListenerOrEventListenerObject); + }; +}; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..523fe90 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,75 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; + +import { getLocalStorage, subscribeToLocalStorage, setLocalStorage } from './localStorage'; + +export const AVAILABLE_KEYS = [ + "stats", + "name", + "password" +] as const; +export type LocalStorageKey = typeof AVAILABLE_KEYS[number]; +interface LocalStorageOptions { + excludeExternal?: boolean; + defaultValue?: T; +} + +export const useLocalStorage = ( + key: LocalStorageKey, + options: LocalStorageOptions = {} +): [T | null, (value: T) => void] => { + // We track the localStorage value as state so that we can re-render when it + // is updated (see the subscription useEffect for how that works) + const [valueState, setValueState] = useState( + (getLocalStorage(key) as T) || options.defaultValue || null + ); + + // This ref is needed because we don't want to ever redefine the setValue + // function but we want to be able to use whatever the latest key is when we + // do setValue + const keyRef = useRef(key); + useEffect(() => { + keyRef.current = key; + }, [key]); + + // We want this set function to never be redefined, so we use useCallback with + // an empty array and only reference the key by ref + const setValue = useCallback((value: T) => { + setLocalStorage(keyRef.current, value); + }, []); + + // subscribe to localstorage changes and set the `currentValue` state on + // change. + useEffect(() => { + // make sure to return. The subscribe function returns an unsubscribe + // function + return subscribeToLocalStorage(key, setValueState as (val: string) => void, options); + }, [key, options]); + + return [valueState, setValue]; +}; + +export const useJSONLocalStorage = ( + key: LocalStorageKey, + defaultValue: T, + options?: LocalStorageOptions +): [T, (newValue: T) => void] => { + const [stringValue, setStringValue] = useLocalStorage(key, { + ...options, + defaultValue: defaultValue && JSON.stringify(defaultValue) + }); + + const value = useMemo(() => { + try { + return JSON.parse(stringValue as string); + } catch { + + return defaultValue; + } + }, [stringValue, defaultValue]); + + const setValue = (newValue: T) => setStringValue(JSON.stringify(newValue)); + + return [value, setValue]; +}; + +export default useLocalStorage; diff --git a/src/sound.ts b/src/sound.ts new file mode 100644 index 0000000..b9f2ec8 --- /dev/null +++ b/src/sound.ts @@ -0,0 +1,15 @@ +export const toot = new Audio('/sounds/toot.wav'); +export const flush = new Audio('/sounds/flush.wav'); +export const darn = new Audio('/sounds/darn.mp3'); +export const woohoo = new Audio('/sounds/woohoo.ogg'); +export const yay = new Audio('/sounds/yay.mp3'); +export const yipee = new Audio('/sounds/yipee.mp3'); +export const starman = new Audio('/sounds/starman.mp3'); + +let count = 0; +const cheers = [woohoo, yay, yipee]; + +export const getCheer = () => { + count = (count + 1) % 3; + return cheers[count]; +}; diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..42c4ba2 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,11 @@ +interface Stats { + attempts: number; + scheduledPoops: number; + selfPoops: number; + cleanUndies: number; +} + +interface StreakTracker { + streaks: number[]; + currentStreakSince: number; +}