update ga

This commit is contained in:
adueck 2023-07-15 01:03:29 +04:00
parent 0def4b3136
commit 5d21aa142a
7 changed files with 695 additions and 511 deletions

View File

@ -33,7 +33,7 @@
"react-bootstrap": "1.6.4", "react-bootstrap": "1.6.4",
"react-countdown-circle-timer": "3.0.9", "react-countdown-circle-timer": "3.0.9",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-ga": "3.3.0", "react-ga4": "^2.1.0",
"react-media": "1", "react-media": "1",
"react-player": "2.10.1", "react-player": "2.10.1",
"react-rewards": "1.1.2", "react-rewards": "1.1.2",

View File

@ -19,19 +19,19 @@ import LandingPage from "./pages/LandingPage";
import AccountPage from "./pages/AccountPage"; import AccountPage from "./pages/AccountPage";
import { useEffect } from "react"; import { useEffect } from "react";
import { isProd } from "./lib/isProd"; import { isProd } from "./lib/isProd";
import ReactGA from "react-ga"; import ReactGA from "react-ga4";
import { useUser } from "./user-context"; import { useUser } from "./user-context";
import PrivacyPolicy from "./pages/PrivacyPolicy"; import PrivacyPolicy from "./pages/PrivacyPolicy";
import SearchPage from "./pages/SearchPage"; import SearchPage from "./pages/SearchPage";
const chapters = content.reduce((chapters, item) => ( const chapters = content.reduce(
item.content (chapters, item) =>
? [...chapters, item] item.content ? [...chapters, item] : [...chapters, ...item.chapters],
: [...chapters, ...item.chapters] []
), []); );
if (isProd) { if (isProd) {
ReactGA.initialize("UA-196576671-2"); ReactGA.initialize("387148608");
ReactGA.set({ anonymizeIp: true }); ReactGA.set({ anonymizeIp: true });
} }
@ -41,15 +41,18 @@ function App() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useUser(); const { user } = useUser();
function logAnalytics() { function logAnalytics() {
if (isProd && !(user?.admin)) { if (isProd && !user?.admin) {
ReactGA.pageview(window.location.pathname); ReactGA.send({
}; hitType: "pageview",
page: window.location.pathname,
});
}
} }
useEffect(() => { useEffect(() => {
logAnalytics(); logAnalytics();
if (window.location.pathname === "/") { if (window.location.pathname === "/") {
if (localStorage.getItem("visitedOnce")) { if (localStorage.getItem("visitedOnce")) {
navigate("/table-of-contents", { replace: true }) navigate("/table-of-contents", { replace: true });
} else { } else {
localStorage.setItem("visitedOnce", "true"); localStorage.setItem("visitedOnce", "true");
} }
@ -65,7 +68,10 @@ function App() {
<> <>
<Header setNavOpen={setNavOpen} /> <Header setNavOpen={setNavOpen} />
<div className="container-fluid"> <div className="container-fluid">
<div className="main-row row" style={{ minHeight: "calc(100vh - 62px)" }}> <div
className="main-row row"
style={{ minHeight: "calc(100vh - 62px)" }}
>
<Sidebar <Sidebar
content={content} content={content}
navOpen={navOpen} navOpen={navOpen}
@ -73,18 +79,9 @@ function App() {
pathname={window.location.pathname} pathname={window.location.pathname}
/> />
<Routes> <Routes>
<Route <Route path="/" element={<LandingPage />} />
path="/" <Route path="/search" element={<SearchPage />} />
element={<LandingPage />} <Route path="/privacy" element={<PrivacyPolicy />} />
/>
<Route
path="/search"
element={<SearchPage />}
/>
<Route
path="/privacy"
element={<PrivacyPolicy />}
/>
<Route <Route
path="/table-of-contents" path="/table-of-contents"
element={<TableOfContentsPage />} element={<TableOfContentsPage />}
@ -96,10 +93,7 @@ function App() {
element={<Chapter>{chapter}</Chapter>} element={<Chapter>{chapter}</Chapter>}
/> />
))} ))}
<Route <Route path="/account" element={<AccountPage />} />
path="/account"
element={<AccountPage />}
/>
<Route path="*" element={<Page404 />} /> <Route path="*" element={<Page404 />} />
</Routes> </Routes>
</div> </div>

View File

@ -1,64 +1,84 @@
import { import { Types as T, EPDisplay, EPPicker } from "@lingdocs/ps-react";
Types as T,
EPDisplay,
EPPicker,
} from "@lingdocs/ps-react";
import entryFeeder from "../../lib/entry-feeder"; import entryFeeder from "../../lib/entry-feeder";
import { useState } from "react"; import { useState } from "react";
import ReactGA from "react-ga"; import ReactGA from "react-ga4";
import { isProd } from "../../lib/isProd"; import { isProd } from "../../lib/isProd";
import { useUser } from "../../user-context"; import { useUser } from "../../user-context";
export function EditIcon() { export function EditIcon() {
return <i className="fas fa-edit" />; return <i className="fas fa-edit" />;
} }
function EditableEPEx({ children, opts, hideOmitSubject, noEdit }: { children: T.EPSelectionState, opts: T.TextOptions, hideOmitSubject?: boolean, noEdit?: boolean }) { function EditableEPEx({
const [editing, setEditing] = useState<boolean>(false); children,
const [eps, setEps] = useState<T.EPSelectionState>(children); opts,
const { user } = useUser(); hideOmitSubject,
function handleReset() { noEdit,
setEditing(false); }: {
setEps(children); children: T.EPSelectionState;
opts: T.TextOptions;
hideOmitSubject?: boolean;
noEdit?: boolean;
}) {
const [editing, setEditing] = useState<boolean>(false);
const [eps, setEps] = useState<T.EPSelectionState>(children);
const { user } = useUser();
function handleReset() {
setEditing(false);
setEps(children);
}
function logEdit() {
if (isProd && !user?.admin) {
ReactGA.event({
category: "Example",
action: `edit EPex - ${window.location.pathname}`,
label: "edit EPex",
});
} }
function logEdit() { }
if (isProd && !(user?.admin)) { return (
ReactGA.event({ <div className="mt-2 mb-4">
category: "Example", {!noEdit && (
action: `edit EPex - ${window.location.pathname}`, <div
label: "edit EPex" className="text-left clickable"
}); style={{ marginBottom: editing ? "0.5rem" : "-0.5rem" }}
} onClick={
} editing
return <div className="mt-2 mb-4"> ? handleReset
{!noEdit && <div : () => {
className="text-left clickable" setEditing(true);
style={{ marginBottom: editing ? "0.5rem" : "-0.5rem" }} logEdit();
onClick={editing ? handleReset : () => { }
setEditing(true); }
logEdit();
}}
> >
{!editing ? <EditIcon /> : <i className="fas fa-undo" />} {!editing ? <EditIcon /> : <i className="fas fa-undo" />}
</div>} </div>
{editing )}
&& <EPPicker {editing && (
opts={opts} <EPPicker
entryFeeder={entryFeeder} opts={opts}
eps={eps} entryFeeder={entryFeeder}
onChange={setEps} eps={eps}
/>} onChange={setEps}
<EPDisplay
opts={opts}
eps={eps}
setOmitSubject={hideOmitSubject ? false : (value) => setEps(o => ({
...o,
omitSubject: value === "true",
}))}
justify="left"
onlyOne
/> />
</div>; )}
<EPDisplay
opts={opts}
eps={eps}
setOmitSubject={
hideOmitSubject
? false
: (value) =>
setEps((o) => ({
...o,
omitSubject: value === "true",
}))
}
justify="left"
onlyOne
/>
</div>
);
} }
export default EditableEPEx; export default EditableEPEx;

View File

@ -1,84 +1,103 @@
import { import {
Types as T, Types as T,
VPDisplay, VPDisplay,
VPPicker, VPPicker,
vpsReducer, vpsReducer,
} from "@lingdocs/ps-react"; } from "@lingdocs/ps-react";
import entryFeeder from "../../lib/entry-feeder"; import entryFeeder from "../../lib/entry-feeder";
import { useState } from "react"; import { useState } from "react";
import ReactGA from "react-ga"; import ReactGA from "react-ga4";
import { isProd } from "../../lib/isProd"; import { isProd } from "../../lib/isProd";
import { useUser } from "../../user-context"; import { useUser } from "../../user-context";
export function EditIcon() { export function EditIcon() {
return <i className="fas fa-edit" />; return <i className="fas fa-edit" />;
} }
// TODO: Ability to show all variations // TODO: Ability to show all variations
function EditableVPEx({ children, opts, formChoice, noEdit, length, mode, sub, allVariations }: { function EditableVPEx({
children: T.VPSelectionState, children,
opts: T.TextOptions, opts,
formChoice?: boolean, formChoice,
noEdit?: boolean, noEdit,
length?: "long" | "short", length,
mode?: "text" | "blocks", mode,
sub?: string | JSX.Element, sub,
allVariations?: boolean, allVariations,
}: {
children: T.VPSelectionState;
opts: T.TextOptions;
formChoice?: boolean;
noEdit?: boolean;
length?: "long" | "short";
mode?: "text" | "blocks";
sub?: string | JSX.Element;
allVariations?: boolean;
}) { }) {
const [editing, setEditing] = useState<boolean>(false); const [editing, setEditing] = useState<boolean>(false);
const [selectedLength, setSelectedLength] = useState<"long" | "short">(length || "short"); const [selectedLength, setSelectedLength] = useState<"long" | "short">(
const [vps, setVps] = useState<T.VPSelectionState>({ ...children }); length || "short"
const { user } = useUser(); );
function logEdit() { const [vps, setVps] = useState<T.VPSelectionState>({ ...children });
if (isProd && !(user?.admin)) { const { user } = useUser();
ReactGA.event({ function logEdit() {
category: "Example", if (isProd && !user?.admin) {
action: `edit VPex - ${window.location.pathname}`, ReactGA.event({
label: "edit VPex" category: "Example",
}); action: `edit VPex - ${window.location.pathname}`,
} label: "edit VPex",
});
} }
function handleReset() { }
// TODO: this is crazy, how does children get changed after calling setVps ??? function handleReset() {
setVps(children); // TODO: this is crazy, how does children get changed after calling setVps ???
setEditing(false); setVps(children);
} setEditing(false);
function handleSetForm(form: T.FormVersion) { }
setVps(vpsReducer(vps, { type: "set form", payload: form })); function handleSetForm(form: T.FormVersion) {
} setVps(vpsReducer(vps, { type: "set form", payload: form }));
return <div className="mt-2 mb-4"> }
{!noEdit && <div return (
className="text-left clickable mb-2" <div className="mt-2 mb-4">
style={{ marginBottom: editing ? "0.5rem" : "-0.5rem" }} {!noEdit && (
onClick={editing ? handleReset : () => { <div
setEditing(true); className="text-left clickable mb-2"
logEdit(); style={{ marginBottom: editing ? "0.5rem" : "-0.5rem" }}
}} onClick={
editing
? handleReset
: () => {
setEditing(true);
logEdit();
}
}
> >
{!editing ? <EditIcon /> : <i className="fas fa-undo" />} {!editing ? <EditIcon /> : <i className="fas fa-undo" />}
</div>} </div>
{editing )}
&& <VPPicker {editing && (
opts={opts} <VPPicker
entryFeeder={entryFeeder} opts={opts}
vps={vps} entryFeeder={entryFeeder}
onChange={setVps} vps={vps}
/> onChange={setVps}
}
<VPDisplay
opts={opts}
VPS={vps}
justify="left"
onlyOne={allVariations ? false : "concat"}
setForm={handleSetForm}
onLengthChange={setSelectedLength}
length={allVariations ? undefined : selectedLength}
mode={mode}
inlineFormChoice
/> />
{sub && <div className="text-muted small">{sub}</div>} )}
</div>; <VPDisplay
opts={opts}
VPS={vps}
justify="left"
onlyOne={allVariations ? false : "concat"}
setForm={handleSetForm}
onLengthChange={setSelectedLength}
length={allVariations ? undefined : selectedLength}
mode={mode}
inlineFormChoice
/>
{sub && <div className="text-muted small">{sub}</div>}
</div>
);
} }
export default EditableVPEx; export default EditableVPEx;

View File

@ -1,354 +1,452 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { CountdownCircleTimer } from "react-countdown-circle-timer"; import { CountdownCircleTimer } from "react-countdown-circle-timer";
import Reward, { RewardElement } from 'react-rewards'; import Reward, { RewardElement } from "react-rewards";
import Link from "../components/Link"; import Link from "../components/Link";
import { useUser } from "../user-context"; import { useUser } from "../user-context";
import "./timer.css"; import "./timer.css";
import { import { getPercentageDone } from "../lib/game-utils";
getPercentageDone, import { saveResult, postSavedResults } from "../lib/game-results";
} from "../lib/game-utils"; import { AT, getTimestamp } from "@lingdocs/lingdocs-main";
import { import { randFromArray, Types } from "@lingdocs/ps-react";
saveResult, import ReactGA from "react-ga4";
postSavedResults,
} from "../lib/game-results";
import {
AT,
getTimestamp,
} from "@lingdocs/lingdocs-main";
import {
randFromArray,
Types,
} from "@lingdocs/ps-react";
import ReactGA from "react-ga";
import { isProd } from "../lib/isProd"; import { isProd } from "../lib/isProd";
import autoAnimate from "@formkit/auto-animate"; import autoAnimate from "@formkit/auto-animate";
const errorVibration = 200; const errorVibration = 200;
const strikesToFail = 3; const strikesToFail = 3;
type GameState<Question> = ({ type GameState<Question> = (
mode: "practice", | {
showAnswer: boolean, mode: "practice";
} | { showAnswer: boolean;
mode: "intro" | "test" | "fail" | "timeout" | "complete", }
showAnswer: false, | {
}) & { mode: "intro" | "test" | "fail" | "timeout" | "complete";
numberComplete: number, showAnswer: false;
current: Question, }
timerKey: number, ) & {
strikes: number, numberComplete: number;
justStruck: boolean, current: Question;
} timerKey: number;
strikes: number;
justStruck: boolean;
};
type GameReducerAction = { type GameReducerAction =
type: "handle question response", | {
payload: { correct: boolean }, type: "handle question response";
} | { payload: { correct: boolean };
type: "start", }
payload: "practice" | "test", | {
} | { type: "start";
type: "quit", payload: "practice" | "test";
} | { }
type: "timeout", | {
} | { type: "quit";
type: "toggle show answer", }
} | { | {
type: "skip", type: "timeout";
} }
| {
function GameCore<Question>({ inChapter, getQuestion, amount, Display, DisplayCorrectAnswer, timeLimit, Instructions, studyLink, id }: { type: "toggle show answer";
inChapter: boolean, }
id: string, | {
studyLink: string, type: "skip";
Instructions: (props: { opts?: Types.TextOptions }) => JSX.Element,
getQuestion: () => Question,
DisplayCorrectAnswer: (props: { question: Question }) => JSX.Element,
Display: (props: QuestionDisplayProps<Question>) => JSX.Element,
timeLimit: number,
amount: number,
}) {
const initialState: GameState<Question> = {
mode: "intro",
numberComplete: 0,
current: getQuestion(),
timerKey: 0,
strikes: 0,
justStruck: false,
showAnswer: false,
}; };
// TODO: report pass with id to user info
const rewardRef = useRef<RewardElement | null>(null);
const parent = useRef<HTMLDivElement | null>(null);
const { user, pullUser, setUser } = useUser();
const [state, setStateDangerous] = useState<GameState<Question>>(initialState);
useEffect(() => {
parent.current && autoAnimate(parent.current)
}, [parent]);
const gameReducer = (gs: GameState<Question>, action: GameReducerAction): GameState<Question> => { function GameCore<Question>({
if (action.type === "handle question response") { inChapter,
if (gs.mode === "test") { getQuestion,
if (action.payload.correct) { amount,
const numberComplete = gs.numberComplete + 1; Display,
if (numberComplete === amount) { DisplayCorrectAnswer,
logGameEvent("passed"); timeLimit,
rewardRef.current?.rewardMe(); Instructions,
handleResult(true); studyLink,
return { id,
...gs, }: {
numberComplete, inChapter: boolean;
justStruck: false, id: string;
mode: "complete", studyLink: string;
Instructions: (props: { opts?: Types.TextOptions }) => JSX.Element;
getQuestion: () => Question;
DisplayCorrectAnswer: (props: { question: Question }) => JSX.Element;
Display: (props: QuestionDisplayProps<Question>) => JSX.Element;
timeLimit: number;
amount: number;
}) {
const initialState: GameState<Question> = {
mode: "intro",
numberComplete: 0,
current: getQuestion(),
timerKey: 0,
strikes: 0,
justStruck: false,
showAnswer: false,
};
// TODO: report pass with id to user info
const rewardRef = useRef<RewardElement | null>(null);
const parent = useRef<HTMLDivElement | null>(null);
const { user, pullUser, setUser } = useUser();
const [state, setStateDangerous] =
useState<GameState<Question>>(initialState);
useEffect(() => {
parent.current && autoAnimate(parent.current);
}, [parent]);
} const gameReducer = (
} else { gs: GameState<Question>,
return { action: GameReducerAction
...gs, ): GameState<Question> => {
numberComplete, if (action.type === "handle question response") {
current: getQuestion(), if (gs.mode === "test") {
justStruck: false, if (action.payload.correct) {
}; const numberComplete = gs.numberComplete + 1;
} if (numberComplete === amount) {
} else { logGameEvent("passed");
punish(); rewardRef.current?.rewardMe();
const strikes = gs.strikes + 1; handleResult(true);
if (strikes === strikesToFail) {
logGameEvent("fail");
handleResult(false);
return {
...gs,
strikes,
mode: "fail",
justStruck: false,
};
} else {
return {
...gs,
strikes,
justStruck: true,
};
}
}
}
else /* (gs.mode === "practice") */ {
if (action.payload.correct) {
const numberComplete = gs.numberComplete + (!gs.showAnswer ? 1 : 0);
return {
...gs,
numberComplete,
current: getQuestion(),
justStruck: false,
showAnswer: false,
};
} else {
punish();
const strikes = gs.strikes + 1;
return {
...gs,
strikes,
justStruck: true,
showAnswer: false,
};
}
}
}
if (action.type === "start") {
logGameEvent(`started ${action.payload}`);
return { return {
...initialState, ...gs,
mode: action.payload, numberComplete,
current: getQuestion(), justStruck: false,
timerKey: gs.timerKey + 1, mode: "complete",
} };
} } else {
if (action.type === "quit") {
return { return {
...initialState, ...gs,
timerKey: gs.timerKey + 1, numberComplete,
} current: getQuestion(),
} justStruck: false,
if (action.type === "timeout") { };
logGameEvent("timeout"); }
} else {
punish();
const strikes = gs.strikes + 1;
if (strikes === strikesToFail) {
logGameEvent("fail");
handleResult(false); handleResult(false);
return { return {
...gs, ...gs,
mode: "timeout", strikes,
justStruck: false, mode: "fail",
showAnswer: false, justStruck: false,
}; };
} } else {
if (action.type === "toggle show answer") {
if (gs.mode === "practice") {
return {
...gs,
justStruck: false,
showAnswer: !gs.showAnswer,
};
}
return gs;
}
if (action.type === "skip") {
if (gs.mode === "practice") {
return {
...gs,
current: getQuestion(),
justStruck: false,
showAnswer: false,
};
}
return gs;
}
throw new Error("unknown GameReducerAction");
}
function dispatch(action: GameReducerAction) {
setStateDangerous(gs => gameReducer(gs, action));
}
function logGameEvent(action: string) {
if (isProd && !(user?.admin)) {
ReactGA.event({
category: "Game",
action: `${action} - ${id}`,
label: id,
});
}
}
function punish() {
if (navigator.vibrate) {
navigator.vibrate(errorVibration);
}
}
function handleResult(done: boolean) {
const result: AT.TestResult = {
done,
time: getTimestamp(),
id,
};
// add the test to the user object
if (!user) return;
setUser((u) => {
// pure type safety with the prevUser
if (!u) return u;
return { return {
...u, ...gs,
tests: [...u.tests, result], strikes,
justStruck: true,
}; };
}); }
// save the test result in local storage }
saveResult(result, user.userId); } /* (gs.mode === "practice") */ else {
// try to post the result if (action.payload.correct) {
postSavedResults(user.userId).then((r) => { const numberComplete = gs.numberComplete + (!gs.showAnswer ? 1 : 0);
if (r === "sent") pullUser(); return {
}).catch(console.error); ...gs,
numberComplete,
current: getQuestion(),
justStruck: false,
showAnswer: false,
};
} else {
punish();
const strikes = gs.strikes + 1;
return {
...gs,
strikes,
justStruck: true,
showAnswer: false,
};
}
}
} }
function getProgressWidth(): string { if (action.type === "start") {
const num = !state.current logGameEvent(`started ${action.payload}`);
? 0 return {
: (state.mode === "complete") ...initialState,
? 100 mode: action.payload,
: getPercentageDone(state.numberComplete, amount); current: getQuestion(),
return `${num}%`; timerKey: gs.timerKey + 1,
};
} }
const progressColor = state.mode === "complete" if (action.type === "quit") {
? "success" return {
: (state.mode === "fail" || state.mode === "timeout") ...initialState,
? "danger" timerKey: gs.timerKey + 1,
: "primary"; };
const gameRunning = state.mode === "practice" || state.mode === "test";
function ActionButtons() {
return <div>
{!inChapter && <Link to={studyLink}>
<button className="btn btn-danger mt-4 mx-3">Study</button>
</Link>}
<button className="btn btn-warning mt-4 mx-3" onClick={() => dispatch({ type: "start", payload: "practice" })}>Practice</button>
<button className="btn btn-success mt-4 mx-3" onClick={() => dispatch({ type: "start", payload: "test" })}>Test</button>
</div>;
} }
return <> if (action.type === "timeout") {
<div className="text-center" style={{ minHeight: "200px", zIndex: 10, position: "relative" }}> logGameEvent("timeout");
{(state.mode === "test" || state.mode === "intro") && <div className="progress" style={{ height: "5px" }}> handleResult(false);
<div className={`progress-bar bg-${progressColor}`} role="progressbar" style={{ width: getProgressWidth() }} /> return {
</div>} ...gs,
<div className="d-flex flex-row justify-content-between mt-2"> mode: "timeout",
{state.mode === "test" justStruck: false,
? <StrikesDisplay strikes={state.strikes} /> showAnswer: false,
: state.mode === "practice" ? <PracticeStatusDisplay };
correct={state.numberComplete} }
incorrect={state.strikes} if (action.type === "toggle show answer") {
/> if (gs.mode === "practice") {
: <div />} return {
<div className="d-flex flex-row justify-content-right"> ...gs,
{state.mode === "test" && <CountdownCircleTimer justStruck: false,
key={state.timerKey} showAnswer: !gs.showAnswer,
isPlaying={gameRunning} };
size={30} }
colors={["#555555", "#F7B801", "#A30000"]} return gs;
colorsTime={[timeLimit, timeLimit*0.33, 0]} }
strokeWidth={4} if (action.type === "skip") {
strokeLinecap="square" if (gs.mode === "practice") {
duration={timeLimit} return {
onComplete={() => dispatch({ type: "timeout" })} ...gs,
/>} current: getQuestion(),
{state.mode !== "intro" && <button onClick={() => dispatch({ type: "quit" })} className="btn btn-outline-secondary btn-sm ml-2"> justStruck: false,
Quit showAnswer: false,
</button>} };
</div> }
</div> return gs;
<div ref={parent}> }
{state.justStruck && <div className="alert alert-warning my-2" role="alert" style={{ maxWidth: "300px", margin: "0 auto" }}> throw new Error("unknown GameReducerAction");
{getStrikeMessage()} };
</div>}
</div> function dispatch(action: GameReducerAction) {
<Reward ref={rewardRef} config={{ lifetime: 130, spread: 90, elementCount: 150, zIndex: 999999999 }} type="confetti"> setStateDangerous((gs) => gameReducer(gs, action));
<div className="mb-2"> }
{state.mode === "intro" && <div>
<div className="pt-3"> function logGameEvent(action: string) {
{/* TODO: ADD IN TEXT DISPLAY OPTIONS HERE TOO - WHEN WE START USING THEM*/} if (isProd && !user?.admin) {
<Instructions /> ReactGA.event({
</div> category: "Game",
<ActionButtons /> action: `${action} - ${id}`,
</div>} label: id,
{gameRunning && <Display });
question={state.current} }
callback={(correct) => dispatch({ type: "handle question response", payload: { correct }})} }
/>} function punish() {
{(state.mode === "practice" && (state.justStruck || state.showAnswer)) && <div className="my-3"> if (navigator.vibrate) {
<button className="btn btn-sm btn-secondary" onClick={() => dispatch({ type: "toggle show answer" })}> navigator.vibrate(errorVibration);
{state.showAnswer ? "Hide" : "Show"} Answer }
</button> }
</div>} function handleResult(done: boolean) {
{(state.showAnswer && state.mode === "practice") && <div className="my-2"> const result: AT.TestResult = {
<div className="my-1"> done,
<DisplayCorrectAnswer question={state.current} /> time: getTimestamp(),
</div> id,
<button className="btn btn-sm btn-primary my-2" onClick={() => dispatch({ type: "skip" })}> };
Next Question // add the test to the user object
</button> if (!user) return;
</div>} setUser((u) => {
{state.mode === "complete" && <div> // pure type safety with the prevUser
<h4 className="mt-4"> if (!u) return u;
<span role="img" aria-label="celebration">🎉</span> Finished! return {
</h4> ...u,
<button className="btn btn-secondary mt-4" onClick={() => dispatch({ type: "start", payload: "test" })}>Try Again</button> tests: [...u.tests, result],
</div>} };
{(state.mode === "timeout" || state.mode === "fail") && <div className="mb-4"> });
<h4 className="mt-4">{failMessage({ // save the test result in local storage
numberComplete: state.numberComplete, saveResult(result, user.userId);
amount, // try to post the result
type: state.mode, postSavedResults(user.userId)
})}</h4> .then((r) => {
<div>The correct answer was:</div> if (r === "sent") pullUser();
<div className="my-2"> })
<DisplayCorrectAnswer question={state.current} /> .catch(console.error);
</div> }
<div className="my-3"> function getProgressWidth(): string {
<ActionButtons /> const num = !state.current
</div> ? 0
</div>} : state.mode === "complete"
</div> ? 100
</Reward> : getPercentageDone(state.numberComplete, amount);
return `${num}%`;
}
const progressColor =
state.mode === "complete"
? "success"
: state.mode === "fail" || state.mode === "timeout"
? "danger"
: "primary";
const gameRunning = state.mode === "practice" || state.mode === "test";
function ActionButtons() {
return (
<div>
{!inChapter && (
<Link to={studyLink}>
<button className="btn btn-danger mt-4 mx-3">Study</button>
</Link>
)}
<button
className="btn btn-warning mt-4 mx-3"
onClick={() => dispatch({ type: "start", payload: "practice" })}
>
Practice
</button>
<button
className="btn btn-success mt-4 mx-3"
onClick={() => dispatch({ type: "start", payload: "test" })}
>
Test
</button>
</div>
);
}
return (
<>
<div
className="text-center"
style={{ minHeight: "200px", zIndex: 10, position: "relative" }}
>
{(state.mode === "test" || state.mode === "intro") && (
<div className="progress" style={{ height: "5px" }}>
<div
className={`progress-bar bg-${progressColor}`}
role="progressbar"
style={{ width: getProgressWidth() }}
/>
</div>
)}
<div className="d-flex flex-row justify-content-between mt-2">
{state.mode === "test" ? (
<StrikesDisplay strikes={state.strikes} />
) : state.mode === "practice" ? (
<PracticeStatusDisplay
correct={state.numberComplete}
incorrect={state.strikes}
/>
) : (
<div />
)}
<div className="d-flex flex-row justify-content-right">
{state.mode === "test" && (
<CountdownCircleTimer
key={state.timerKey}
isPlaying={gameRunning}
size={30}
colors={["#555555", "#F7B801", "#A30000"]}
colorsTime={[timeLimit, timeLimit * 0.33, 0]}
strokeWidth={4}
strokeLinecap="square"
duration={timeLimit}
onComplete={() => dispatch({ type: "timeout" })}
/>
)}
{state.mode !== "intro" && (
<button
onClick={() => dispatch({ type: "quit" })}
className="btn btn-outline-secondary btn-sm ml-2"
>
Quit
</button>
)}
</div>
</div> </div>
{gameRunning && <div style={{ <div ref={parent}>
{state.justStruck && (
<div
className="alert alert-warning my-2"
role="alert"
style={{ maxWidth: "300px", margin: "0 auto" }}
>
{getStrikeMessage()}
</div>
)}
</div>
<Reward
ref={rewardRef}
config={{
lifetime: 130,
spread: 90,
elementCount: 150,
zIndex: 999999999,
}}
type="confetti"
>
<div className="mb-2">
{state.mode === "intro" && (
<div>
<div className="pt-3">
{/* TODO: ADD IN TEXT DISPLAY OPTIONS HERE TOO - WHEN WE START USING THEM*/}
<Instructions />
</div>
<ActionButtons />
</div>
)}
{gameRunning && (
<Display
question={state.current}
callback={(correct) =>
dispatch({
type: "handle question response",
payload: { correct },
})
}
/>
)}
{state.mode === "practice" &&
(state.justStruck || state.showAnswer) && (
<div className="my-3">
<button
className="btn btn-sm btn-secondary"
onClick={() => dispatch({ type: "toggle show answer" })}
>
{state.showAnswer ? "Hide" : "Show"} Answer
</button>
</div>
)}
{state.showAnswer && state.mode === "practice" && (
<div className="my-2">
<div className="my-1">
<DisplayCorrectAnswer question={state.current} />
</div>
<button
className="btn btn-sm btn-primary my-2"
onClick={() => dispatch({ type: "skip" })}
>
Next Question
</button>
</div>
)}
{state.mode === "complete" && (
<div>
<h4 className="mt-4">
<span role="img" aria-label="celebration">
🎉
</span>{" "}
Finished!
</h4>
<button
className="btn btn-secondary mt-4"
onClick={() => dispatch({ type: "start", payload: "test" })}
>
Try Again
</button>
</div>
)}
{(state.mode === "timeout" || state.mode === "fail") && (
<div className="mb-4">
<h4 className="mt-4">
{failMessage({
numberComplete: state.numberComplete,
amount,
type: state.mode,
})}
</h4>
<div>The correct answer was:</div>
<div className="my-2">
<DisplayCorrectAnswer question={state.current} />
</div>
<div className="my-3">
<ActionButtons />
</div>
</div>
)}
</div>
</Reward>
</div>
{gameRunning && (
<div
style={{
position: "absolute", position: "absolute",
backgroundColor: "rgba(255, 255, 255, 0.3)", backgroundColor: "rgba(255, 255, 255, 0.3)",
backdropFilter: "blur(10px)", backdropFilter: "blur(10px)",
@ -357,51 +455,75 @@ function GameCore<Question>({ inChapter, getQuestion, amount, Display, DisplayCo
width: "100%", width: "100%",
height: "100%", height: "100%",
zIndex: 6, zIndex: 6,
}}></div>} }}
</>; ></div>
)}
</>
);
} }
function PracticeStatusDisplay({ correct, incorrect }: { correct: number, incorrect: number }) { function PracticeStatusDisplay({
return <div className="d-flex flex-row justify-content-between align-items-center small"> correct,
<div className="mr-3"> <samp>Correct: {correct}</samp></div> incorrect,
<div> <samp>Incorrect: {incorrect}</samp></div> }: {
correct: number;
incorrect: number;
}) {
return (
<div className="d-flex flex-row justify-content-between align-items-center small">
<div className="mr-3">
<samp>Correct: {correct}</samp>
</div>
<div>
<samp>Incorrect: {incorrect}</samp>
</div>
</div> </div>
);
} }
function StrikesDisplay({ strikes }: { strikes: number }) { function StrikesDisplay({ strikes }: { strikes: number }) {
return <div> return (
{[...Array(strikes)].map(_ => <span key={Math.random()} className="mr-2"></span>)} <div>
</div>; {[...Array(strikes)].map((_) => (
<span key={Math.random()} className="mr-2">
</span>
))}
</div>
);
} }
function getStrikeMessage() { function getStrikeMessage() {
return randFromArray([ return randFromArray([
"Not quite! Try again.", "Not quite! Try again.",
"No sorry, try again", "No sorry, try again",
"Umm, no, try again", "Umm, no, try again",
"Try again", "Try again",
"Oooooooo, sorry no...", "Oooooooo, sorry no...",
]); ]);
} }
function failMessage({ numberComplete, amount, type }: { function failMessage({
numberComplete: number, numberComplete,
amount: number, amount,
type: "timeout" | "fail", type,
}: {
numberComplete: number;
amount: number;
type: "timeout" | "fail";
}): string { }): string {
const pDone = getPercentageDone(numberComplete, amount); const pDone = getPercentageDone(numberComplete, amount);
const { message, face } = pDone < 20 const { message, face } =
? { message: "No, sorry", face: "😑" } pDone < 20
: pDone < 30 ? { message: "No, sorry", face: "😑" }
? { message: "Oops, that's wrong", face: "😟" } : pDone < 30
: pDone < 55 ? { message: "Oops, that's wrong", face: "😟" }
? { message: "Fail", face: "😕" } : pDone < 55
: pDone < 78 ? { message: "Fail", face: "😕" }
? { message: "You almost got it!", face: "😩" } : pDone < 78
: { message: "Nooo! So close!", face: "😭" }; ? { message: "You almost got it!", face: "😩" }
return type === "fail" : { message: "Nooo! So close!", face: "😭" };
? `${message} ${face}` return type === "fail" ? `${message} ${face}` : `⏳ Time's Up ${face}`;
: `⏳ Time's Up ${face}`;
} }
export default GameCore; export default GameCore;

29
w-grammar.txt Normal file
View File

@ -0,0 +1,29 @@
Metaproductions
PERSON :: 0 .. 11
Hyper-rules
s ()
=> es
=> vs
es (subj: PERSON)
=> np(subj), adj(subj), eq(subj)
es (subj: PERSON, pred: PERSON)
=> np(subj), np(pred), eq(pred)
vs ()
=> vs-intrans
=> vs-trans-past
=> vs-trans-non-past
vs-trans-non-past (subj: PERSON, obj: PERSON)
=> np(subj), np(obj), v-trans-non-past(subj, obj)
=> np(obj), np(subj), v-trans-non-past(subj, obj)
vs-trans-past (subj: PERSON, obj: PERSON)
=> np(subj), np(obj), v-trans-past(obj, obj)
=> np(obj), np(subj), v-trans-past(obj, obj)
vs-intrans-past (subj: PERSON) => np(subj), v-intrans(subj)

View File

@ -4523,10 +4523,10 @@ react-fast-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
react-ga@3.3.0: react-ga4@^2.1.0:
version "3.3.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-3.3.0.tgz#c91f407198adcb3b49e2bc5c12b3fe460039b3ca" resolved "https://registry.yarnpkg.com/react-ga4/-/react-ga4-2.1.0.tgz#56601f59d95c08466ebd6edfbf8dede55c4678f9"
integrity sha512-o8RScHj6Lb8cwy3GMrVH6NJvL+y0zpJvKtc0+wmH7Bt23rszJmnqEQxRbyrqUzk9DTJIHoP42bfO5rswC9SWBQ== integrity sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==
react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0: react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0:
version "16.13.1" version "16.13.1"