Skip to content

Commit 3ca07c6

Browse files
fehmerMiodec
andauthored
refactor(modal): solid modal for save last signed out result (@fehmer) (#8183)
Co-authored-by: Miodec <jack@monkeytype.com>
1 parent 3838b0a commit 3ca07c6

9 files changed

Lines changed: 219 additions & 280 deletions

File tree

frontend/src/html/popups.html

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,44 +7,6 @@
77
</div>
88
</dialog>
99

10-
<dialog id="lastSignedOutResult" class="modalWrapper hidden">
11-
<div class="modal">
12-
<div class="title">Last signed out result</div>
13-
<div class="question">Would you like to save it?</div>
14-
<div class="divider"></div>
15-
<div class="result">
16-
<div class="group wpm">
17-
<div class="sub">wpm</div>
18-
<div class="val">-</div>
19-
</div>
20-
<div class="group acc">
21-
<div class="sub">accuracy</div>
22-
<div class="val">-</div>
23-
</div>
24-
<div class="group raw">
25-
<div class="sub">raw</div>
26-
<div class="val">-</div>
27-
</div>
28-
<div class="group con">
29-
<div class="sub">consistency</div>
30-
<div class="val">-</div>
31-
</div>
32-
<div class="group chardata">
33-
<div class="sub">characters</div>
34-
<div class="val">-</div>
35-
</div>
36-
<div class="group testType">
37-
<div class="sub">test type</div>
38-
<div class="val">-</div>
39-
</div>
40-
</div>
41-
<div class="buttons">
42-
<button class="save">save</button>
43-
<button class="discard">discard</button>
44-
</div>
45-
</div>
46-
</dialog>
47-
4810
<div id="videoAdPopupWrapper" class="popupWrapper hidden">
4911
<div id="videoAdPopup">
5012
<div class="preloader">

frontend/src/styles/popups.scss

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -162,45 +162,6 @@ body.darkMode {
162162
}
163163
}
164164

165-
#lastSignedOutResult {
166-
.modal {
167-
max-width: 600px;
168-
169-
.buttons {
170-
display: flex;
171-
flex-direction: row-reverse;
172-
gap: 0.5rem;
173-
button {
174-
flex-grow: 1;
175-
}
176-
}
177-
.result {
178-
display: grid;
179-
gap: 0.5rem;
180-
grid-template-columns: 1fr 1fr;
181-
}
182-
.divider {
183-
background: var(--sub-alt-color);
184-
width: 100%;
185-
height: 0.25rem;
186-
border-radius: var(--roundness);
187-
}
188-
.group {
189-
.sub {
190-
font-size: 0.75em;
191-
color: var(--sub-color);
192-
}
193-
&.testType {
194-
grid-column: 1;
195-
}
196-
&.wpm,
197-
&.acc {
198-
font-size: 2em;
199-
}
200-
}
201-
}
202-
}
203-
204165
#videoAdPopupWrapper {
205166
display: flex;
206167
padding: 2rem;
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { CompletedEvent } from "@monkeytype/schemas/results";
2+
import { Mode } from "@monkeytype/schemas/shared";
3+
import objectHash from "object-hash";
4+
import { createMemo, JSX } from "solid-js";
5+
6+
import Ape from "../../ape";
7+
import { getConfig } from "../../config/store";
8+
import { SnapshotResult } from "../../constants/default-snapshot";
9+
import { saveLocalResult, SaveLocalResultData } from "../../db";
10+
import { authEvent } from "../../events/auth";
11+
import { getAuthenticatedUser } from "../../firebase";
12+
import { hideModal, showModal } from "../../states/modals";
13+
import {
14+
showErrorNotification,
15+
showNoticeNotification,
16+
showSuccessNotification,
17+
} from "../../states/notifications";
18+
import {
19+
getLastSignedOutResult,
20+
setLastSignedOutResult,
21+
} from "../../states/test";
22+
import { cn } from "../../utils/cn";
23+
import { Formatting } from "../../utils/format";
24+
import { AnimatedModal } from "../common/AnimatedModal";
25+
import { Button } from "../common/Button";
26+
import { Separator } from "../common/Separator";
27+
28+
const modalId = "LastSignedOutResult";
29+
30+
export function LastSignedOutResultModal() {
31+
const format = createMemo(
32+
() =>
33+
new Formatting({
34+
alwaysShowDecimalPlaces: getConfig.alwaysShowDecimalPlaces,
35+
typingSpeedUnit: getConfig.typingSpeedUnit,
36+
}),
37+
);
38+
39+
const handleDiscard = () => {
40+
showNoticeNotification("Last test result discarded");
41+
hideModal(modalId);
42+
setTimeout(() => {
43+
setLastSignedOutResult(null);
44+
}, 125);
45+
};
46+
47+
const handleSave = () => {
48+
void syncLastSignedOutResult();
49+
};
50+
51+
return (
52+
<AnimatedModal
53+
id={modalId}
54+
title="Last signed out result"
55+
modalClass="max-w-2xl"
56+
>
57+
<p class="">Would you like to save it?</p>
58+
<Separator />
59+
60+
<div class="grid grid-cols-2 gap-2">
61+
<Value
62+
class="text-2xl"
63+
label={format().typingSpeedUnit}
64+
value={format().typingSpeed(getLastSignedOutResult()?.wpm ?? 0)}
65+
/>
66+
<Value
67+
class="text-2xl"
68+
label="accuracy"
69+
value={format().accuracy(getLastSignedOutResult()?.acc ?? 0)}
70+
/>
71+
<Value
72+
label="raw"
73+
value={format().typingSpeed(getLastSignedOutResult()?.rawWpm ?? 0)}
74+
/>
75+
<Value
76+
label="consistency"
77+
value={format().percentage(
78+
getLastSignedOutResult()?.consistency ?? 0,
79+
)}
80+
/>
81+
<Value
82+
class="col-span-2"
83+
label="characters"
84+
value={getLastSignedOutResult()?.charStats.join("/") ?? "-"}
85+
/>
86+
<Value
87+
label="test type"
88+
class="col-span-2"
89+
value={formatTestType(getLastSignedOutResult())}
90+
/>
91+
</div>
92+
{/*
93+
need two sets of buttons here because on wide screens tab focuses save first
94+
but on small screens tab focuses discard first
95+
*/}
96+
<div class="grid grid-cols-1 gap-2 sm:hidden">
97+
<Button text="save" onClick={handleSave} />
98+
<Button text="discard" onClick={handleDiscard} />
99+
</div>
100+
<div class="hidden grid-cols-2 gap-2 sm:grid">
101+
<Button text="discard" onClick={handleDiscard} />
102+
<Button text="save" onClick={handleSave} />
103+
</div>
104+
</AnimatedModal>
105+
);
106+
}
107+
108+
function Value(props: {
109+
label: string;
110+
value: string | (string | JSX.Element)[];
111+
class?: string;
112+
}) {
113+
return (
114+
<div class={cn("flex flex-col text-sm", props.class)}>
115+
<span class="text-em-xs text-sub">{props.label}</span>
116+
<span>{props.value}</span>
117+
</div>
118+
);
119+
}
120+
121+
function formatTestType(r: CompletedEvent | null): (string | JSX.Element)[] {
122+
if (r === null) return ["-"];
123+
const tt: (string | JSX.Element)[] = [`${r.mode} ${r.mode2}`];
124+
125+
tt.push(<br />, `${r.language}`);
126+
127+
if (r.numbers) tt.push(<br />, "numbers");
128+
if (r.punctuation) tt.push(<br />, "punctuation");
129+
if (r.blindMode) tt.push(<br />, "blind");
130+
if (r.lazyMode) tt.push(<br />, "lazy");
131+
if (r.funbox.length > 0) {
132+
const funboxLabel = r.funbox.map((it) => it.replace(/_/g, " ")).join(",");
133+
tt.push(<br />, funboxLabel);
134+
}
135+
if (r.difficulty !== "normal") tt.push(<br />, `${r.difficulty}`);
136+
if (r.tags.length > 0) tt.push(<br />, `${r.tags.length} tags`);
137+
return tt;
138+
}
139+
140+
async function syncLastSignedOutResult(): Promise<void> {
141+
const user = getAuthenticatedUser();
142+
const lastResult = getLastSignedOutResult();
143+
if (user === null) {
144+
showNoticeNotification(
145+
"Failed to save last test result: user not authenticated",
146+
);
147+
hideModal(modalId);
148+
return;
149+
}
150+
if (lastResult === null) {
151+
showNoticeNotification("Failed to save last test result: no last result");
152+
hideModal(modalId);
153+
return;
154+
}
155+
156+
const updatedResult = updateUidAndHash(user.uid, lastResult);
157+
const response = await Ape.results.add({ body: { result: updatedResult } });
158+
159+
if (response.status !== 200) {
160+
showErrorNotification(`Failed to save last result`, {
161+
response,
162+
});
163+
hideModal(modalId);
164+
return;
165+
}
166+
167+
//TODO - this type cast was not needed before because we were using JSON cloning
168+
// but now with the stronger types it shows that we are forcing completed event
169+
// into a snapshot result - might not cause issues but worth investigating
170+
const result = structuredClone(
171+
updatedResult,
172+
) as unknown as SnapshotResult<Mode>;
173+
174+
const dataToSave: SaveLocalResultData = {
175+
xp: response.body.data.xp,
176+
streak: response.body.data.streak,
177+
result,
178+
isPb: response.body.data.isPb,
179+
};
180+
181+
result._id = response.body.data.insertedId;
182+
if (response.body.data.isPb) {
183+
result.isPb = true;
184+
}
185+
saveLocalResult(dataToSave);
186+
setLastSignedOutResult(null);
187+
showSuccessNotification(
188+
`Last test result saved ${response.body.data.isPb ? `(new pb!)` : ""}`,
189+
);
190+
hideModal(modalId);
191+
}
192+
193+
export function updateUidAndHash(
194+
uid: string,
195+
notSignedInLastResult: CompletedEvent,
196+
): CompletedEvent {
197+
notSignedInLastResult.uid = uid;
198+
//@ts-expect-error really need to delete this
199+
delete notSignedInLastResult.hash;
200+
notSignedInLastResult.hash = objectHash(notSignedInLastResult);
201+
return notSignedInLastResult;
202+
}
203+
204+
authEvent.subscribe((event) => {
205+
if (event.type === "snapshotUpdated" && event.data.isInitial) {
206+
if (getLastSignedOutResult() !== null) {
207+
showModal(modalId);
208+
}
209+
}
210+
});

frontend/src/ts/components/modals/Modals.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { CookiesModal } from "./CookiesModal";
66
import { CustomTestDurationModal } from "./CustomTestDurationModal";
77
import { CustomTextModal } from "./CustomTextModal";
88
import { CustomWordAmountModal } from "./CustomWordAmountModal";
9+
import { LastSignedOutResultModal } from "./LastSignedOutResultModal";
910
import { MobileTestConfigModal } from "./MobileTestConfigModal";
1011
import { AddPresetModal } from "./preset/AddPresetModal";
1112
import { EditPresetModal } from "./preset/EditPresetModal";
@@ -38,6 +39,7 @@ export function Modals(): JSXElement {
3839
<AddPresetModal />
3940
<EditPresetModal />
4041
<ViewApeKeyModal />
42+
<LastSignedOutResultModal />
4143
</>
4244
);
4345
}

frontend/src/ts/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import * as Sentry from "./sentry";
3333
import * as Cookies from "./cookies";
3434
import "./elements/psa";
3535
import "./controllers/url-handler";
36-
import "./modals/last-signed-out-result";
3736
import { applyEngineSettings } from "./anim";
3837
import { qs, qsa, qsr } from "./utils/dom";
3938
import { mountComponents } from "./components/mount";

0 commit comments

Comments
 (0)