impr: add local storage with schema class to improve type safety (@miodec) (#5763)

!nuf
This commit is contained in:
Jack 2024-08-12 17:04:01 +02:00 committed by GitHub
parent 38a8529808
commit 55e183e7bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 642 additions and 264 deletions

View file

@ -21,7 +21,6 @@ import {
CustomTheme,
DBResult,
MonkeyMail,
ResultFilters,
UserInventory,
UserProfileDetails,
UserQuoteRatings,
@ -33,6 +32,7 @@ import {
PersonalBest,
} from "@monkeytype/contracts/schemas/shared";
import { addImportantLog } from "./logs";
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
const SECONDS_PER_HOUR = 3600;

View file

@ -0,0 +1,73 @@
import defaultResultFilters from "../../../src/ts/constants/default-result-filters";
import { mergeWithDefaultFilters } from "../../../src/ts/elements/account/result-filters";
describe("result-filters.ts", () => {
describe("mergeWithDefaultFilters", () => {
it("should merge with default filters correctly", () => {
const tests = [
{
input: {
pb: {
no: false,
yes: false,
},
},
expected: () => {
const expected = defaultResultFilters;
expected.pb.no = false;
expected.pb.yes = false;
return expected;
},
},
{
input: {
words: {
"10": false,
},
},
expected: () => {
const expected = defaultResultFilters;
expected.words["10"] = false;
return expected;
},
},
{
input: {
blah: true,
},
expected: () => {
return defaultResultFilters;
},
},
{
input: 1,
expected: () => {
return defaultResultFilters;
},
},
{
input: null,
expected: () => {
return defaultResultFilters;
},
},
{
input: undefined,
expected: () => {
return defaultResultFilters;
},
},
{
input: {},
expected: () => {
return defaultResultFilters;
},
},
];
tests.forEach((test) => {
const merged = mergeWithDefaultFilters(test.input as any);
expect(merged).toEqual(test.expected());
});
});
});
});

View file

@ -1,8 +1,11 @@
import { isObject } from "../../src/ts/utils/misc";
import {
getLanguageDisplayString,
removeLanguageSize,
} from "../../src/ts/utils/strings";
//todo this file is in the wrong place
describe("misc.ts", () => {
describe("getLanguageDisplayString", () => {
it("should return correctly formatted strings", () => {
@ -72,4 +75,47 @@ describe("misc.ts", () => {
});
});
});
describe("isObject", () => {
it("should correctly identify objects", () => {
const tests = [
{
input: {},
expected: true,
},
{
input: { a: 1 },
expected: true,
},
{
input: [],
expected: false,
},
{
input: [1, 2, 3],
expected: false,
},
{
input: "string",
expected: false,
},
{
input: 1,
expected: false,
},
{
input: null,
expected: false,
},
{
input: undefined,
expected: false,
},
];
tests.forEach((test) => {
const result = isObject(test.input);
expect(result).toBe(test.expected);
});
});
});
});

View file

@ -0,0 +1,126 @@
import { z } from "zod";
import { LocalStorageWithSchema } from "../../src/ts/utils/local-storage-with-schema";
describe("local-storage-with-schema.ts", () => {
describe("LocalStorageWithSchema", () => {
const objectSchema = z.object({
punctuation: z.boolean(),
mode: z.enum(["words", "time"]),
fontSize: z.number(),
});
const defaultObject: z.infer<typeof objectSchema> = {
punctuation: true,
mode: "words",
fontSize: 16,
};
const ls = new LocalStorageWithSchema({
key: "config",
schema: objectSchema,
fallback: defaultObject,
});
const getItemMock = vi.fn();
const setItemMock = vi.fn();
const removeItemMock = vi.fn();
vi.stubGlobal("localStorage", {
getItem: getItemMock,
setItem: setItemMock,
removeItem: removeItemMock,
});
afterEach(() => {
getItemMock.mockReset();
setItemMock.mockReset();
removeItemMock.mockReset();
});
it("should save to localStorage if schema is correct and return true", () => {
const res = ls.set(defaultObject);
expect(localStorage.setItem).toHaveBeenCalledWith(
"config",
JSON.stringify(defaultObject)
);
expect(res).toBe(true);
});
it("should fail to save to localStorage if schema is incorrect and return false", () => {
const obj = {
hi: "hello",
};
const res = ls.set(obj as any);
expect(localStorage.setItem).not.toHaveBeenCalled();
expect(res).toBe(false);
});
it("should revert to the fallback value if localstorage is null", () => {
getItemMock.mockReturnValue(null);
const res = ls.get();
expect(localStorage.getItem).toHaveBeenCalledWith("config");
expect(res).toEqual(defaultObject);
});
it("should revert to the fallback value and remove if localstorage json is malformed", () => {
getItemMock.mockReturnValue("badjson");
const res = ls.get();
expect(localStorage.getItem).toHaveBeenCalledWith("config");
expect(localStorage.removeItem).toHaveBeenCalledWith("config");
expect(res).toEqual(defaultObject);
});
it("should get from localStorage", () => {
getItemMock.mockReturnValue(JSON.stringify(defaultObject));
const res = ls.get();
expect(localStorage.getItem).toHaveBeenCalledWith("config");
expect(res).toEqual(defaultObject);
});
it("should revert to fallback value if no migrate function and schema failed", () => {
getItemMock.mockReturnValue(JSON.stringify({ hi: "hello" }));
const ls = new LocalStorageWithSchema({
key: "config",
schema: objectSchema,
fallback: defaultObject,
});
const res = ls.get();
expect(localStorage.getItem).toHaveBeenCalledWith("config");
expect(res).toEqual(defaultObject);
});
it("should migrate (when function is provided) if schema failed", () => {
getItemMock.mockReturnValue(JSON.stringify({ hi: "hello" }));
const migrated = {
punctuation: false,
mode: "time",
fontSize: 1,
};
const ls = new LocalStorageWithSchema({
key: "config",
schema: objectSchema,
fallback: defaultObject,
migrate: () => {
return migrated;
},
});
const res = ls.get();
expect(localStorage.getItem).toHaveBeenCalledWith("config");
expect(res).toEqual(migrated);
});
});
});

View file

@ -1,12 +1,12 @@
import {
CountByYearAndDay,
CustomTheme,
ResultFilters,
UserProfile,
UserProfileDetails,
UserTag,
} from "@monkeytype/shared-types";
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
const BASE_PATH = "/users";

View file

@ -16,14 +16,35 @@ import {
canSetConfigWithCurrentFunboxes,
canSetFunboxWithConfig,
} from "./test/funbox/funbox-validation";
import { isDevEnvironment, reloadAfter, typedKeys } from "./utils/misc";
import {
isDevEnvironment,
isObject,
reloadAfter,
typedKeys,
} from "./utils/misc";
import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs";
import { Config } from "@monkeytype/contracts/schemas/configs";
import { roundTo1 } from "./utils/numbers";
import { Mode, ModeSchema } from "@monkeytype/contracts/schemas/shared";
import { Language, LanguageSchema } from "@monkeytype/contracts/schemas/util";
import { LocalStorageWithSchema } from "./utils/local-storage-with-schema";
import { mergeWithDefaultConfig } from "./utils/config";
export let localStorageConfig: Config;
const configLS = new LocalStorageWithSchema({
key: "config",
schema: ConfigSchemas.ConfigSchema,
fallback: DefaultConfig,
migrate: (value, _issues) => {
if (!isObject(value)) {
return DefaultConfig;
}
const configWithoutLegacyValues = replaceLegacyValues(value);
const merged = mergeWithDefaultConfig(configWithoutLegacyValues);
return merged;
},
});
let loadDone: (value?: unknown) => void;
@ -48,29 +69,25 @@ function saveToLocalStorage(
noDbCheck = false
): void {
if (nosave) return;
const localToSave = config;
const localToSaveStringified = JSON.stringify(localToSave);
window.localStorage.setItem("config", localToSaveStringified);
configLS.set(config);
if (!noDbCheck) {
//@ts-expect-error this is fine
configToSend[key] = config[key];
saveToDatabase();
}
const localToSaveStringified = JSON.stringify(config);
ConfigEvent.dispatch("saveToLocalStorage", localToSaveStringified);
}
export function saveFullConfigToLocalStorage(noDbCheck = false): void {
console.log("saving full config to localStorage");
const save = config;
const stringified = JSON.stringify(save);
window.localStorage.setItem("config", stringified);
configLS.set(config);
if (!noDbCheck) {
AccountButton.loading(true);
void DB.saveConfig(save);
void DB.saveConfig(config);
AccountButton.loading(false);
}
const stringified = JSON.stringify(config);
ConfigEvent.dispatch("saveToLocalStorage", stringified);
}
@ -1977,8 +1994,6 @@ export async function apply(
ConfigEvent.dispatch("fullConfigChange");
configToApply = replaceLegacyValues(configToApply);
const configObj = configToApply as Config;
(Object.keys(DefaultConfig) as (keyof Config)[]).forEach((configKey) => {
if (configObj[configKey] === undefined) {
@ -2095,33 +2110,19 @@ export async function reset(): Promise<void> {
export async function loadFromLocalStorage(): Promise<void> {
console.log("loading localStorage config");
const newConfigString = window.localStorage.getItem("config");
let newConfig: Config;
if (
newConfigString !== undefined &&
newConfigString !== null &&
newConfigString !== ""
) {
try {
newConfig = JSON.parse(newConfigString);
} catch (e) {
newConfig = {} as Config;
}
await apply(newConfig);
localStorageConfig = newConfig;
saveFullConfigToLocalStorage(true);
} else {
const newConfig = configLS.get();
if (newConfig === undefined) {
await reset();
} else {
await apply(newConfig);
saveFullConfigToLocalStorage(true);
}
// TestLogic.restart(false, true);
loadDone();
}
function replaceLegacyValues(
configToApply: ConfigSchemas.PartialConfig | MonkeyTypes.ConfigChanges
): ConfigSchemas.Config | MonkeyTypes.ConfigChanges {
const configObj = configToApply as ConfigSchemas.Config;
export function replaceLegacyValues(
configObj: ConfigSchemas.PartialConfig
): ConfigSchemas.PartialConfig {
//@ts-expect-error
if (configObj.quickTab === true) {
configObj.quickRestart = "tab";
@ -2159,7 +2160,7 @@ function replaceLegacyValues(
if (configObj.showLiveWpm === true) {
let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini";
if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") {
val = configObj.timerStyle;
val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle;
}
configObj.liveSpeedStyle = val;
}
@ -2168,7 +2169,7 @@ function replaceLegacyValues(
if (configObj.showLiveBurst === true) {
let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini";
if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") {
val = configObj.timerStyle;
val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle;
}
configObj.liveBurstStyle = val;
}
@ -2177,7 +2178,7 @@ function replaceLegacyValues(
if (configObj.showLiveAcc === true) {
let val: ConfigSchemas.LiveSpeedAccBurstStyle = "mini";
if (configObj.timerStyle !== "bar" && configObj.timerStyle !== "off") {
val = configObj.timerStyle;
val = configObj.timerStyle as ConfigSchemas.LiveSpeedAccBurstStyle;
}
configObj.liveAccStyle = val;
}

View file

@ -1,4 +1,4 @@
import { ResultFilters } from "@monkeytype/shared-types";
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
const object: ResultFilters = {
_id: "default-result-filters-id",

View file

@ -167,7 +167,7 @@ async function getDataAndInit(): Promise<boolean> {
const areConfigsEqual =
JSON.stringify(Config) === JSON.stringify(snapshot.config);
if (UpdateConfig.localStorageConfig === undefined || !areConfigsEqual) {
if (Config === undefined || !areConfigsEqual) {
console.log(
"no local config or local and db configs are different - applying db"
);

View file

@ -1,17 +1,25 @@
import { z } from "zod";
import * as DB from "../db";
import * as ModesNotice from "../elements/modes-notice";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import { IdSchema } from "@monkeytype/contracts/schemas/util";
const activeTagsLS = new LocalStorageWithSchema({
key: "activeTags",
schema: z.array(IdSchema),
fallback: [],
});
export function saveActiveToLocalStorage(): void {
const tags: string[] = [];
try {
DB.getSnapshot()?.tags?.forEach((tag) => {
if (tag.active === true) {
tags.push(tag._id);
}
});
window.localStorage.setItem("activeTags", JSON.stringify(tags));
} catch (e) {}
DB.getSnapshot()?.tags?.forEach((tag) => {
if (tag.active === true) {
tags.push(tag._id);
}
});
activeTagsLS.set(tags);
}
export function clear(nosave = false): void {
@ -61,18 +69,9 @@ export function toggle(tagid: string, nosave = false): void {
}
export function loadActiveFromLocalStorage(): void {
let newTags: string[] | string = window.localStorage.getItem(
"activeTags"
) as string;
if (newTags != undefined && newTags !== "") {
try {
newTags = JSON.parse(newTags) ?? [];
} catch (e) {
newTags = [];
}
(newTags as string[]).forEach((ntag) => {
toggle(ntag, true);
});
saveActiveToLocalStorage();
const newTags = activeTagsLS.get();
for (const tag of newTags) {
toggle(tag, true);
}
saveActiveToLocalStorage();
}

View file

@ -8,10 +8,50 @@ import Ape from "../../ape/index";
import * as Loader from "../loader";
// @ts-expect-error TODO: update slim-select
import SlimSelect from "slim-select";
import { ResultFilters } from "@monkeytype/shared-types";
import { QuoteLength } from "@monkeytype/contracts/schemas/configs";
import {
ResultFilters,
ResultFiltersSchema,
ResultFiltersGroup,
ResultFiltersGroupItem,
} from "@monkeytype/contracts/schemas/users";
import { LocalStorageWithSchema } from "../../utils/local-storage-with-schema";
import defaultResultFilters from "../../constants/default-result-filters";
export function mergeWithDefaultFilters(
filters: Partial<ResultFilters>
): ResultFilters {
try {
const merged = {} as ResultFilters;
for (const groupKey of Misc.typedKeys(defaultResultFilters)) {
if (groupKey === "_id" || groupKey === "name") {
merged[groupKey] = filters[groupKey] ?? defaultResultFilters[groupKey];
} else {
// @ts-expect-error i cant figure this out
merged[groupKey] = {
...defaultResultFilters[groupKey],
...filters[groupKey],
};
}
}
return merged;
} catch (e) {
return defaultResultFilters;
}
}
const resultFiltersLS = new LocalStorageWithSchema({
key: "resultFilters",
schema: ResultFiltersSchema,
fallback: defaultResultFilters,
migrate: (unknown, _issues) => {
if (!Misc.isObject(unknown)) {
return defaultResultFilters;
}
return mergeWithDefaultFilters(unknown as ResultFilters);
},
});
type Option = {
id: string;
value: string;
@ -36,51 +76,14 @@ const groupSelects: Partial<Record<keyof ResultFilters, SlimSelect>> = {};
let filters = defaultResultFilters;
function save(): void {
window.localStorage.setItem("resultFilters", JSON.stringify(filters));
resultFiltersLS.set(filters);
}
export async function load(): Promise<void> {
try {
const newResultFilters = window.localStorage.getItem("resultFilters") ?? "";
if (!newResultFilters) {
filters = defaultResultFilters;
} else {
const newFiltersObject = JSON.parse(newResultFilters);
let reset = false;
for (const key of Object.keys(defaultResultFilters)) {
if (reset) break;
if (newFiltersObject[key] === undefined) {
reset = true;
break;
}
if (
typeof defaultResultFilters[
key as keyof typeof defaultResultFilters
] === "object"
) {
for (const subKey of Object.keys(
defaultResultFilters[key as keyof typeof defaultResultFilters]
)) {
if (newFiltersObject[key][subKey] === undefined) {
reset = true;
break;
}
}
}
}
if (reset) {
filters = defaultResultFilters;
} else {
filters = newFiltersObject;
}
}
const filters = resultFiltersLS.get();
const newTags: Record<string, boolean> = { none: false };
Object.keys(defaultResultFilters.tags).forEach((tag) => {
if (filters.tags[tag] !== undefined) {
newTags[tag] = filters.tags[tag];
@ -90,7 +93,6 @@ export async function load(): Promise<void> {
});
filters.tags = newTags;
// await updateFilterPresets();
save();
} catch {
console.log("error in loading result filters");
@ -226,7 +228,7 @@ function getFilters(): ResultFilters {
return filters;
}
function getGroup<G extends keyof ResultFilters>(group: G): ResultFilters[G] {
function getGroup<G extends ResultFiltersGroup>(group: G): ResultFilters[G] {
return filters[group];
}
@ -234,22 +236,22 @@ function getGroup<G extends keyof ResultFilters>(group: G): ResultFilters[G] {
// filters[group][filter] = value;
// }
export function getFilter<G extends keyof ResultFilters>(
export function getFilter<G extends ResultFiltersGroup>(
group: G,
filter: MonkeyTypes.Filter<G>
): ResultFilters[G][MonkeyTypes.Filter<G>] {
filter: ResultFiltersGroupItem<G>
): ResultFilters[G][ResultFiltersGroupItem<G>] {
return filters[group][filter];
}
function setFilter(
group: keyof ResultFilters,
filter: MonkeyTypes.Filter<typeof group>,
function setFilter<G extends ResultFiltersGroup>(
group: G,
filter: ResultFiltersGroupItem<G>,
value: boolean
): void {
filters[group][filter as keyof typeof filters[typeof group]] = value as never;
filters[group][filter] = value as typeof filters[G][typeof filter];
}
function setAllFilters(group: keyof ResultFilters, value: boolean): void {
function setAllFilters(group: ResultFiltersGroup, value: boolean): void {
Object.keys(getGroup(group)).forEach((filter) => {
filters[group][filter as keyof typeof filters[typeof group]] =
value as never;
@ -268,7 +270,7 @@ export function reset(): void {
}
type AboveChartDisplay = Partial<
Record<keyof ResultFilters, { all: boolean; array?: string[] }>
Record<ResultFiltersGroup, { all: boolean; array?: string[] }>
>;
export function updateActive(): void {
@ -290,7 +292,10 @@ export function updateActive(): void {
if (groupAboveChartDisplay === undefined) continue;
const filterValue = getFilter(group, filter);
const filterValue = getFilter(
group,
filter as ResultFiltersGroupItem<typeof group>
);
if (filterValue === true) {
groupAboveChartDisplay.array?.push(filter);
} else {
@ -330,7 +335,7 @@ export function updateActive(): void {
for (const [id, select] of Object.entries(groupSelects)) {
const ss = select;
const group = getGroup(id as keyof ResultFilters);
const group = getGroup(id as ResultFiltersGroup);
const everythingSelected = Object.values(group).every((v) => v === true);
const newData = ss.store.getData();
@ -374,7 +379,7 @@ export function updateActive(): void {
}, 0);
}
function addText(group: keyof ResultFilters): string {
function addText(group: ResultFiltersGroup): string {
let ret = "";
ret += "<div class='group'>";
if (group === "difficulty") {
@ -472,9 +477,9 @@ export function updateActive(): void {
}, 0);
}
function toggle<G extends keyof ResultFilters>(
function toggle<G extends ResultFiltersGroup>(
group: G,
filter: MonkeyTypes.Filter<G>
filter: ResultFiltersGroupItem<G>
): void {
// user is changing the filters -> current filter is no longer a filter preset
deSelectFilterPreset();
@ -486,7 +491,7 @@ function toggle<G extends keyof ResultFilters>(
const currentValue = filters[group][filter] as unknown as boolean;
const newValue = !currentValue;
filters[group][filter] =
newValue as unknown as ResultFilters[G][MonkeyTypes.Filter<G>];
newValue as ResultFilters[G][ResultFiltersGroupItem<G>];
save();
} catch (e) {
Notifications.add(
@ -505,8 +510,10 @@ $(
).on("click", "button", (e) => {
const group = $(e.target)
.parents(".buttons")
.attr("group") as keyof ResultFilters;
const filter = $(e.target).attr("filter") as MonkeyTypes.Filter<typeof group>;
.attr("group") as ResultFiltersGroup;
const filter = $(e.target).attr("filter") as ResultFiltersGroupItem<
typeof group
>;
if ($(e.target).hasClass("allFilters")) {
Misc.typedKeys(getFilters()).forEach((group) => {
// id and name field do not correspond to any ui elements, no need to update
@ -532,8 +539,8 @@ $(
} else if ($(e.target).is("button")) {
if (e.shiftKey) {
setAllFilters(group, false);
filters[group][filter as keyof typeof filters[typeof group]] =
true as never;
filters[group][filter] =
true as ResultFilters[typeof group][typeof filter];
} else {
toggle(group, filter);
// filters[group][filter] = !filters[group][filter];
@ -596,7 +603,7 @@ $(".pageAccount .topFilters button.currentConfigFilter").on("click", () => {
filters.words.custom = true;
}
} else if (Config.mode === "quote") {
const filterName: MonkeyTypes.Filter<"quoteLength">[] = [
const filterName: ResultFiltersGroupItem<"quoteLength">[] = [
"short",
"medium",
"long",
@ -627,7 +634,7 @@ $(".pageAccount .topFilters button.currentConfigFilter").on("click", () => {
}
if (Config.funbox === "none") {
filters.funbox.none = true;
filters.funbox["none"] = true;
} else {
for (const f of Config.funbox.split("#")) {
filters.funbox[f] = true;
@ -656,7 +663,7 @@ $(".pageAccount .topFilters button.toggleAdvancedFilters").on("click", () => {
});
function adjustScrollposition(
group: keyof ResultFilters,
group: ResultFiltersGroup,
topItem: number = 0
): void {
const slimSelect = groupSelects[group];
@ -668,7 +675,7 @@ function adjustScrollposition(
}
function selectBeforeChangeFn(
group: keyof ResultFilters,
group: ResultFiltersGroup,
selectedOptions: Option[],
oldSelectedOptions: Option[]
): void | boolean {
@ -705,7 +712,11 @@ function selectBeforeChangeFn(
break;
}
setFilter(group, selectedOption.value, true);
setFilter(
group,
selectedOption.value as ResultFiltersGroupItem<typeof group>,
true
);
}
updateActive();
@ -925,7 +936,7 @@ $(".group.presetFilterButtons .filterBtns").on(
function verifyResultFiltersStructure(filterIn: ResultFilters): ResultFilters {
const filter = deepCopyFilter(filterIn);
Object.entries(defaultResultFilters).forEach((entry) => {
const key = entry[0] as keyof ResultFilters;
const key = entry[0] as ResultFiltersGroup;
const value = entry[1];
if (filter[key] === undefined) {
// @ts-expect-error key and value is based on default filter so this is safe to ignore

View file

@ -0,0 +1,24 @@
import { z } from "zod";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import * as Notifications from "./notifications";
const closed = new LocalStorageWithSchema({
key: "merchBannerClosed",
schema: z.boolean(),
fallback: false,
});
export function showIfNotClosedBefore(): void {
if (!closed.get()) {
Notifications.addBanner(
`Check out our merchandise, available at <a target="_blank" rel="noopener" href="https://monkeytype.store/">monkeytype.store</a>`,
1,
"./images/merch2.png",
false,
() => {
closed.set(true);
},
true
);
}
}

View file

@ -5,24 +5,28 @@ import * as Notifications from "./notifications";
import { format } from "date-fns/format";
import * as Alerts from "./alerts";
import { PSA } from "@monkeytype/contracts/schemas/psas";
import { z } from "zod";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import { IdSchema } from "@monkeytype/contracts/schemas/util";
const confirmedPSAs = new LocalStorageWithSchema({
key: "confirmedPSAs",
schema: z.array(IdSchema),
fallback: [],
});
function clearMemory(): void {
window.localStorage.setItem("confirmedPSAs", JSON.stringify([]));
confirmedPSAs.set([]);
}
function getMemory(): string[] {
//TODO verify with zod?
return (
(JSON.parse(
window.localStorage.getItem("confirmedPSAs") ?? "[]"
) as string[]) ?? []
);
return confirmedPSAs.get();
}
function setMemory(id: string): void {
const list = getMemory();
list.push(id);
window.localStorage.setItem("confirmedPSAs", JSON.stringify(list));
confirmedPSAs.set(list);
}
async function getLatest(): Promise<PSA[] | null> {

View file

@ -4,29 +4,30 @@ import { isPopupVisible } from "../utils/misc";
import * as AdController from "../controllers/ad-controller";
import AnimatedModal from "../utils/animated-modal";
import { focusWords } from "../test/test-ui";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import { z } from "zod";
type Accepted = {
security: boolean;
analytics: boolean;
};
const AcceptedSchema = z.object({
security: z.boolean(),
analytics: z.boolean(),
});
type Accepted = z.infer<typeof AcceptedSchema>;
function getAcceptedObject(): Accepted | null {
const acceptedCookies = localStorage.getItem("acceptedCookies") ?? "";
if (acceptedCookies) {
//TODO verify with zod?
return JSON.parse(acceptedCookies) as Accepted;
} else {
return null;
}
}
const acceptedCookiesLS = new LocalStorageWithSchema({
key: "acceptedCookies",
schema: AcceptedSchema,
fallback: {
security: false,
analytics: false,
},
});
function setAcceptedObject(obj: Accepted): void {
localStorage.setItem("acceptedCookies", JSON.stringify(obj));
acceptedCookiesLS.set(obj);
}
export function check(): void {
const accepted = getAcceptedObject();
if (accepted === null) {
if (acceptedCookiesLS.get() === undefined) {
show();
}
}

View file

@ -34,6 +34,7 @@ import {
Mode2Custom,
PersonalBests,
} from "@monkeytype/contracts/schemas/shared";
import { ResultFiltersGroupItem } from "@monkeytype/contracts/schemas/users";
let filterDebug = false;
//toggle filterdebug
@ -386,7 +387,7 @@ async function fillContent(): Promise<void> {
return;
}
let puncfilter: MonkeyTypes.Filter<"punctuation"> = "off";
let puncfilter: ResultFiltersGroupItem<"punctuation"> = "off";
if (result.punctuation) {
puncfilter = "on";
}
@ -397,7 +398,7 @@ async function fillContent(): Promise<void> {
return;
}
let numfilter: MonkeyTypes.Filter<"numbers"> = "off";
let numfilter: ResultFiltersGroupItem<"numbers"> = "off";
if (result.numbers) {
numfilter = "on";
}

View file

@ -1,7 +1,7 @@
import Config from "./config";
import * as Misc from "./utils/misc";
import * as MonkeyPower from "./elements/monkey-power";
import * as Notifications from "./elements/notifications";
import * as MerchBanner from "./elements/merch-banner";
import * as CookiesModal from "./modals/cookies";
import * as ConnectionState from "./states/connection";
import * as FunboxList from "./test/funbox/funbox-list";
@ -18,21 +18,7 @@ $((): void => {
//this line goes back to pretty much the beginning of the project and im pretty sure its here
//to make sure the initial theme application doesnt animate the background color
$("body").css("transition", "background .25s, transform .05s");
const merchBannerClosed =
window.localStorage.getItem("merchbannerclosed") === "true";
if (!merchBannerClosed) {
Notifications.addBanner(
`Check out our merchandise, available at <a target="_blank" rel="noopener" href="https://monkeytype.store/">monkeytype.store</a>`,
1,
"./images/merch2.png",
false,
() => {
window.localStorage.setItem("merchbannerclosed", "true");
},
true
);
}
MerchBanner.showIfNotClosedBefore();
setTimeout(() => {
FunboxList.get(Config.funbox).forEach((it) =>
it.functions?.applyGlobalCSS?.()

View file

@ -1,7 +1,16 @@
import { z } from "zod";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
const ls = new LocalStorageWithSchema({
key: "prefersArabicLazyMode",
schema: z.boolean(),
fallback: true,
});
export function get(): boolean {
return (localStorage.getItem("prefersArabicLazyMode") ?? "true") === "true";
return ls.get();
}
export function set(value: boolean): void {
localStorage.setItem("prefersArabicLazyMode", value ? "true" : "false");
ls.set(value);
}

View file

@ -1,16 +1,22 @@
import { z } from "zod";
import { getLatestReleaseFromGitHub } from "../utils/json-data";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
const LOCALSTORAGE_KEY = "lastSeenVersion";
const memoryLS = new LocalStorageWithSchema({
key: "lastSeenVersion",
schema: z.string(),
fallback: "",
});
let version: null | string = null;
let isVersionNew: null | boolean = null;
function setMemory(v: string): void {
window.localStorage.setItem(LOCALSTORAGE_KEY, v);
memoryLS.set(v);
}
function getMemory(): string {
return window.localStorage.getItem(LOCALSTORAGE_KEY) ?? "";
return memoryLS.get();
}
async function check(): Promise<void> {

View file

@ -4,6 +4,36 @@ import {
CustomTextLimitMode,
CustomTextMode,
} from "@monkeytype/shared-types";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import { z } from "zod";
//zod schema for an object with string keys and string values
const CustomTextObjectSchema = z.record(z.string(), z.string());
type CustomTextObject = z.infer<typeof CustomTextObjectSchema>;
const CustomTextLongObjectSchema = z.record(
z.string(),
z.object({ text: z.string(), progress: z.number() })
);
type CustomTextLongObject = z.infer<typeof CustomTextLongObjectSchema>;
const customTextLS = new LocalStorageWithSchema({
key: "customText",
schema: CustomTextObjectSchema,
fallback: {},
});
//todo maybe add migrations here?
const customTextLongLS = new LocalStorageWithSchema({
key: "customTextLong",
schema: CustomTextLongObjectSchema,
fallback: {},
});
// function setLocalStorage(data: CustomTextObject): void {
// window.localStorage.setItem("customText", JSON.stringify(data));
// }
// function setLocalStorageLong(data: CustomTextLongObject): void {
let text: string[] = [
"The",
@ -79,10 +109,6 @@ export function getData(): CustomTextData {
};
}
type CustomTextObject = Record<string, string>;
type CustomTextLongObject = Record<string, { text: string; progress: number }>;
export function getCustomText(name: string, long = false): string[] {
if (long) {
const customTextLong = getLocalStorageLong();
@ -169,23 +195,19 @@ export function setCustomTextLongProgress(
}
function getLocalStorage(): CustomTextObject {
return JSON.parse(
window.localStorage.getItem("customText") ?? "{}"
) as CustomTextObject;
return customTextLS.get();
}
function getLocalStorageLong(): CustomTextLongObject {
return JSON.parse(
window.localStorage.getItem("customTextLong") ?? "{}"
) as CustomTextLongObject;
return customTextLongLS.get();
}
function setLocalStorage(data: CustomTextObject): void {
window.localStorage.setItem("customText", JSON.stringify(data));
customTextLS.set(data);
}
function setLocalStorageLong(data: CustomTextLongObject): void {
window.localStorage.setItem("customTextLong", JSON.stringify(data));
customTextLongLS.set(data);
}
export function getCustomTextNames(long = false): string[] {

View file

@ -230,7 +230,7 @@ declare namespace MonkeyTypes {
inboxUnreadSize: number;
streak: number;
maxStreak: number;
filterPresets: import("@monkeytype/shared-types").ResultFilters[];
filterPresets: import("@monkeytype/contracts/schemas/users").ResultFilters[];
isPremium: boolean;
streakHourOffset?: number;
config: import("@monkeytype/contracts/schemas/configs").Config;
@ -244,15 +244,6 @@ declare namespace MonkeyTypes {
testActivityByYear?: { [key: string]: TestActivityCalendar };
};
type Group<
G extends keyof import("@monkeytype/shared-types").ResultFilters = keyof import("@monkeytype/shared-types").ResultFilters
> = G extends G ? import("@monkeytype/shared-types").ResultFilters[G] : never;
type Filter<G extends Group = Group> =
G extends keyof import("@monkeytype/shared-types").ResultFilters
? keyof import("@monkeytype/shared-types").ResultFilters[G]
: never;
type TimerStats = {
dateNow: number;
now: number;

View file

@ -0,0 +1,66 @@
import { ZodIssue } from "zod";
export class LocalStorageWithSchema<T> {
private key: string;
private schema: Zod.Schema<T>;
private fallback: T;
private migrate?: (value: unknown, zodIssues: ZodIssue[]) => T;
constructor(options: {
key: string;
schema: Zod.Schema<T>;
fallback: T;
migrate?: (value: unknown, zodIssues: ZodIssue[]) => T;
}) {
this.key = options.key;
this.schema = options.schema;
this.fallback = options.fallback;
this.migrate = options.migrate;
}
public get(): T {
const value = window.localStorage.getItem(this.key);
if (value === null) {
return this.fallback;
}
let jsonParsed;
try {
jsonParsed = JSON.parse(value);
} catch (e) {
console.error(
`Value from localStorage ${this.key} was not a valid JSON, using fallback`,
e
);
window.localStorage.removeItem(this.key);
return this.fallback;
}
const schemaParsed = this.schema.safeParse(jsonParsed);
if (schemaParsed.success) {
return schemaParsed.data;
}
console.error(
`Value from localStorage ${this.key} failed schema validation, migrating`,
schemaParsed.error
);
const newValue =
this.migrate?.(jsonParsed, schemaParsed.error.issues) ?? this.fallback;
window.localStorage.setItem(this.key, JSON.stringify(newValue));
return newValue;
}
public set(data: T): boolean {
try {
const parsed = this.schema.parse(data);
window.localStorage.setItem(this.key, JSON.stringify(parsed));
return true;
} catch (e) {
console.error(`Failed to set ${this.key} in localStorage`, e);
return false;
}
}
}

View file

@ -1,10 +1,18 @@
import { z } from "zod";
import { LocalStorageWithSchema } from "./local-storage-with-schema";
import { isDevEnvironment } from "./misc";
const nativeLog = console.log;
const nativeWarn = console.warn;
const nativeError = console.error;
let debugLogs = localStorage.getItem("debugLogs") === "true";
const debugLogsLS = new LocalStorageWithSchema({
key: "debugLogs",
schema: z.boolean(),
fallback: false,
});
let debugLogs = debugLogsLS.get();
if (isDevEnvironment()) {
debugLogs = true;
@ -14,7 +22,7 @@ if (isDevEnvironment()) {
export function toggleDebugLogs(): void {
debugLogs = !debugLogs;
info(`Debug logs ${debugLogs ? "enabled" : "disabled"}`);
localStorage.setItem("debugLogs", debugLogs.toString());
debugLogsLS.set(debugLogs);
}
function info(...args: unknown[]): void {

View file

@ -676,4 +676,8 @@ export function updateTitle(title?: string): void {
}
}
export function isObject(obj: unknown): obj is Record<string, unknown> {
return typeof obj === "object" && !Array.isArray(obj) && obj !== null;
}
// DO NOT ALTER GLOBAL OBJECTSONSTRUCTOR, IT WILL BREAK RESULT HASHES

View file

@ -1 +1,62 @@
//tbd
import { z } from "zod";
import { IdSchema } from "./util";
import { ModeSchema } from "./shared";
export const ResultFiltersSchema = z.object({
_id: IdSchema,
name: z.string(),
pb: z.object({
no: z.boolean(),
yes: z.boolean(),
}),
difficulty: z.object({
normal: z.boolean(),
expert: z.boolean(),
master: z.boolean(),
}),
mode: z.record(ModeSchema, z.boolean()),
words: z.object({
"10": z.boolean(),
"25": z.boolean(),
"50": z.boolean(),
"100": z.boolean(),
custom: z.boolean(),
}),
time: z.object({
"15": z.boolean(),
"30": z.boolean(),
"60": z.boolean(),
"120": z.boolean(),
custom: z.boolean(),
}),
quoteLength: z.object({
short: z.boolean(),
medium: z.boolean(),
long: z.boolean(),
thicc: z.boolean(),
}),
punctuation: z.object({
on: z.boolean(),
off: z.boolean(),
}),
numbers: z.object({
on: z.boolean(),
off: z.boolean(),
}),
date: z.object({
last_day: z.boolean(),
last_week: z.boolean(),
last_month: z.boolean(),
last_3months: z.boolean(),
all: z.boolean(),
}),
tags: z.record(z.boolean()),
language: z.record(z.boolean()),
funbox: z.record(z.boolean()),
});
export type ResultFilters = z.infer<typeof ResultFiltersSchema>;
export type ResultFiltersGroup = keyof ResultFilters;
export type ResultFiltersGroupItem<T extends ResultFiltersGroup> =
keyof ResultFilters[T];

View file

@ -240,67 +240,6 @@ export type CustomTextDataWithTextLen = Omit<CustomTextData, "text"> & {
textLen: number;
};
export type ResultFilters = {
_id: string;
name: string;
pb: {
no: boolean;
yes: boolean;
};
difficulty: {
normal: boolean;
expert: boolean;
master: boolean;
};
mode: {
words: boolean;
time: boolean;
quote: boolean;
zen: boolean;
custom: boolean;
};
words: {
"10": boolean;
"25": boolean;
"50": boolean;
"100": boolean;
custom: boolean;
};
time: {
"15": boolean;
"30": boolean;
"60": boolean;
"120": boolean;
custom: boolean;
};
quoteLength: {
short: boolean;
medium: boolean;
long: boolean;
thicc: boolean;
};
punctuation: {
on: boolean;
off: boolean;
};
numbers: {
on: boolean;
off: boolean;
};
date: {
last_day: boolean;
last_week: boolean;
last_month: boolean;
last_3months: boolean;
all: boolean;
};
tags: Record<string, boolean>;
language: Record<string, boolean>;
funbox: {
none?: boolean;
} & Record<string, boolean>;
};
export type PostResultResponse = {
isPb: boolean;
tagPbs: string[];
@ -392,7 +331,7 @@ export type User = {
verified?: boolean;
needsToChangeName?: boolean;
quoteMod?: boolean | string;
resultFilterPresets?: ResultFilters[];
resultFilterPresets?: import("@monkeytype/contracts/schemas/users").ResultFilters[];
testActivity?: TestActivity;
};