diff --git a/backend/src/utils/result.ts b/backend/src/utils/result.ts index 2c79813f8b22..2e99fe2e4e87 100644 --- a/backend/src/utils/result.ts +++ b/backend/src/utils/result.ts @@ -122,5 +122,14 @@ export function replaceLegacyValues(result: DBResult): DBResult { }; } + if (typeof result.mode2 === "number") { + result.mode2 = (result.mode2 as number).toString(); + } + + //legacy value for english_1k + if ((result.language as string) === "english_expanded") { + result.language = "english_1k"; + } + return result; } diff --git a/frontend/__tests__/elements/account/result-filters.spec.ts b/frontend/__tests__/elements/account/result-filters.spec.ts deleted file mode 100644 index 3403dda6b256..000000000000 --- a/frontend/__tests__/elements/account/result-filters.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect } from "vitest"; -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()); - }); - }); - }); -}); diff --git a/frontend/__tests__/utils/local-storage-with-schema.spec.ts b/frontend/__tests__/utils/local-storage-with-schema.spec.ts index abe21555d87a..6898b3f395b6 100644 --- a/frontend/__tests__/utils/local-storage-with-schema.spec.ts +++ b/frontend/__tests__/utils/local-storage-with-schema.spec.ts @@ -298,5 +298,16 @@ describe("local-storage-with-schema.ts", () => { expect(getItemMock).toHaveBeenCalledOnce(); }); }); + it("should apply afterParse", () => { + const ls = new LocalStorageWithSchema({ + key: "config", + schema: objectSchema, + fallback: defaultObject, + afterParse: (it) => ({ ...it, fontSize: it.fontSize * 2 }), + }); + + const res = ls.get(); + expect(res.fontSize).toEqual(32); + }); }); }); diff --git a/frontend/src/html/pages/account.html b/frontend/src/html/pages/account.html deleted file mode 100644 index 0797dd993b28..000000000000 --- a/frontend/src/html/pages/account.html +++ /dev/null @@ -1,428 +0,0 @@ - diff --git a/frontend/src/index.html b/frontend/src/index.html index 26f1c442e032..016aa7b2e605 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -37,11 +37,12 @@ + - - diff --git a/frontend/src/styles/account.scss b/frontend/src/styles/account.scss deleted file mode 100644 index 6e779672f650..000000000000 --- a/frontend/src/styles/account.scss +++ /dev/null @@ -1,614 +0,0 @@ -.pageAccount { - height: 100%; - - .accountVerificatinNotice { - background: var(--bg-color); - border-radius: var(--roundness); - box-shadow: 0 0 0 0.2rem var(--sub-alt-color); - // background: color-mix(in srgb, var(--sub-alt-color) 50%, transparent); - // background: var(--sub-alt-color); - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: 1rem; - padding: 1rem; - .icon { - font-size: 2rem; - margin-left: 1rem; - margin-right: 1rem; - color: var(--sub-color); - } - button { - padding: 1rem; - } - } - - .content { - gap: 2rem; - } - - .sendVerificationEmail { - cursor: pointer; - } - - .triplegroup { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 1rem; - - .text { - align-self: center; - color: var(--sub-color); - } - } - - .group { - &.estimatedWordsTyped { - display: flex; - align-items: center; - justify-content: center; - .title { - margin-right: 1rem; - } - } - - &.resultBatches { - display: grid; - grid-template-areas: "bar button" "text text"; - grid-template-columns: 2fr 1fr; - column-gap: 1rem; - .title { - grid-area: title; - margin-bottom: 0; - } - & > .text { - grid-area: text; - text-align: center; - } - button { - grid-area: button; - } - .leftText, - button, - .rightText { - align-self: center; - } - .bars { - display: grid; - grid-template-columns: auto 1fr auto; - gap: 0.25rem 1rem; - } - .rightText { - color: var(--sub-color); - font-size: 0.8em; - line-height: 1.25em; - } - .bar { - height: 0.5rem; - border-radius: var(--roundness); - background: var(--sub-alt-color); - position: relative; - align-self: center; - .fill { - transition: width 0.125s; - height: 100%; - width: 0%; - background: var(--main-color); - border-radius: var(--roundness); - } - - //not used for now - .indicator { - position: absolute; - width: max-content; - bottom: 0; - .line { - width: 0.1em; - height: 1.5em; - background: var(--sub-color); - border-radius: var(--roundness); - right: 0; - position: absolute; - top: 0; - } - .text { - font-size: 0.5em; - color: var(--sub-color); - margin-right: 1em; - } - } - } - } - - &.noDataError { - margin: 20rem 0; - text-align: center; - } - - &.aboveHistory { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - .exportCSV { - grid-column: 3/4; - } - } - - &.createdDate { - text-align: center; - color: var(--sub-color); - } - - &.personalBestTables { - .tables { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2rem; - } - } - - .chart canvas { - width: 100% !important; - } - - &.history { - table td { - -webkit-appearance: unset; - } - - table tr { - border-radius: var(--roundness); - } - - .active { - animation: accountRowHighlight 5s linear 0s 1; - } - - .loadMoreButton { - background: var(--sub-alt-color); - color: var(--text-color); - text-align: center; - padding: 0.5rem; - border-radius: var(--roundness); - cursor: pointer; - -webkit-transition: 0.25s; - transition: 0.25s; - -webkit-user-select: none; - display: -ms-grid; - display: grid; - -ms-flex-line-pack: center; - align-content: center; - margin-top: 1rem; - - &:hover, - &:focus { - color: var(--bg-color); - background: var(--text-color); - } - } - } - - .title { - color: var(--sub-color); - // margin-bottom: 0.25em; - } - - .avgres { - font-size: 0.75em; - } - - .val { - font-size: 3rem; - line-height: 1.1; - } - - .chartjs-render-monitor { - width: 100% !important; - } - - &.chart { - position: relative; - - .above { - display: flex; - justify-content: center; - margin-bottom: 1rem; - color: var(--sub-color); - flex-wrap: wrap; - row-gap: 0.5em; - - .group { - display: flex; - align-items: center; - } - - .fas, - .punc { - margin-right: 0.25rem; - } - - .spacer { - width: 1rem; - } - } - - .below { - text-align: center; - color: var(--sub-color); - margin-top: 1rem; - display: grid; - grid-template-columns: auto 500px; - gap: 1rem; - align-items: center; - .text { - height: min-content; - } - .buttons { - font-size: 0.75rem; - display: grid; - gap: 0.5rem; - grid-template-columns: 1fr 1fr 1fr 1fr; - } - } - .chart { - height: 400px; - } - .chartPreloader { - position: absolute; - width: 100%; - background: rgba(0, 0, 0, 0.5); - height: 100%; - display: grid; - align-items: center; - justify-content: center; - font-size: 5rem; - text-shadow: 0 0 3rem black; - } - } - } - - table { - border-spacing: 0; - border-collapse: collapse; - color: var(--text-color); - - td { - padding: 0.5rem 0.5rem; - } - - thead { - color: var(--sub-color); - font-size: 0.75rem; - } - - tbody tr:nth-child(odd) { - background: var(--sub-alt-color); - } - - tbody td:nth-child(1) { - border-radius: var(--roundness) 0 0 var(--roundness); - } - tbody td:last-child { - border-radius: 0 var(--roundness) var(--roundness) 0; - } - - td.infoIcons span { - margin: 0 0.1rem; - } - .miniResultChartButton { - transition: 0.25s; - cursor: pointer; - color: var(--text-color); - &:hover { - opacity: 1; - } - &.loading { - pointer-events: none; - } - &.disabled .fas { - opacity: 0.5; - color: var(--sub-color); - } - } - } - - td:has(.resultEditTagsButton) { - padding: 0; - } - - .resultEditTagsButton { - opacity: 0.5; - &.active { - color: var(--text-color); - } - &:hover, - &:focus-visible, - &.active { - opacity: 1; - } - } - //remove flashHighlight from .group.History .active - .group.history .resultEditTagsButton { - animation: none; - } - - .group { - .buttonsAndTitle { - height: fit-content; - display: grid; - gap: 0.5rem; - color: var(--sub-color); - - .title { - display: flex; - align-items: baseline; - .fab, - .fas, - .far { - margin-right: 0.5em; - } - } - } - - &.presetFilterButtons { - .buttons { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); - gap: 1rem; - .filterPresets { - display: grid; - grid-template-columns: 1fr auto; - gap: 0.5rem; - } - } - } - - &.topFilters { - .buttons { - display: flex; - justify-content: space-evenly; - gap: 1rem; - button { - width: 100%; - } - } - } - - //advanced filters - &.filterButtons { - gap: 1rem; - display: grid; - grid-template-columns: 1fr 1fr; - - .buttons { - display: grid; - grid-auto-flow: column; - gap: 0.5rem; - } - - .tripleSelectsColumn { - grid-column: 1/-1; - display: flex; - gap: 1rem; - flex-wrap: wrap; - .buttonsAndTitle { - flex-grow: 1; - min-width: 20rem; - } - } - - &.testDate .buttons, - &.languages .buttons, - &.layouts .buttons, - &.funbox .buttons, - &.tags .buttons { - grid-template-columns: repeat(4, 1fr); - grid-auto-flow: unset; - } - } - } -} -.testActivity { - // width: max-content; - // justify-self: center; - background: var(--sub-alt-color); - border-radius: var(--roundness); - padding: 1rem; - display: flex; - justify-content: center; - - --gap-size: 0.25em; - --font-size: 1em; - - .wrapper { - width: 100%; - max-width: 80em; - display: grid; - grid-template-columns: min-content 1fr; - grid-template-rows: min-content 1fr min-content; - gap: 1em 1em; - grid-template-areas: - "top top" - "day chart" - "empty month"; - // grid-template-areas: - // "empty month" - // "day chart" - // "top top"; - // font-size: 0.75rem; - } - - .note { - grid-column: span 2; - text-align: center; - font-size: 0.6em; - color: var(--sub-color); - } - - .top { - grid-area: top; - display: grid; - grid-template-columns: 15rem 1fr max-content; - grid-template-areas: "title title legend"; - gap: 1rem; - &:has(.year) { - grid-template-areas: "year title legend"; - } - } - - .ss-main { - border: 0.2em solid var(--bg-color); - } - - .yearSelect, - .months div, - .days div, - .daysFull div, - .legend { - color: var(--sub-color); - } - - .year { - grid-area: year; - font-size: var(--font-size); - } - - .title { - grid-area: title; - text-align: left; - font-size: var(--font-size); - color: var(--sub-color); - align-self: center; - } - - .months { - grid-area: month; - display: grid; - grid-template-columns: repeat(53, 1fr); - - div { - width: 100%; - text-align: center; - } - font-size: var(--font-size); - } - - .daysFull { - margin-right: 2rem; - } - - .days, - .daysFull { - grid-area: day; - display: grid; - grid-template-rows: repeat(7, 1fr); - align-items: center; - // div { - // display: grid; - // overflow: hidden; - // align-items: center; - // } - .text { - display: flex; - height: 0; - align-items: center; - font-size: var(--font-size); - } - } - - .days { - display: none; - } - - .nodata { - grid-area: chart; - } - - .activity { - grid-area: chart; - display: grid; - grid-auto-flow: column; - grid-template-rows: repeat(7, 1fr); - grid-template-columns: repeat(53, 1fr); - gap: var(--gap-size); - // width: max-content; - // max-width: 100%; - - div { - &:hover { - border: 2px solid var(--text-color); - } - &[data-level="filler"]:hover { - border: none; - } - } - } - .legend { - grid-area: legend; - display: flex; - gap: var(--gap-size); - justify-content: flex-end; - align-self: center; - align-items: center; - - span { - font-size: var(--font-size); - &:first-child { - margin-right: var(--gap-size); - } - &:last-child { - margin-left: var(--gap-size); - } - } - } - .activity div, - .legend div { - width: 100%; - aspect-ratio: 1; - border-radius: var(--gap-size); - place-self: center; - - &[data-level="filler"] { - background: none; - } - &[data-level="0"] { - background-color: color-mix( - in srgb, - var(--bg-color) 50%, - var(--sub-alt-color) - ); - background-color: var(--bg-color); - } - &[data-level="1"] { - background-color: color-mix( - in srgb, - var(--main-color) 20%, - var(--sub-alt-color) - ); - } - - &[data-level="2"] { - background-color: color-mix( - in srgb, - var(--main-color) 50%, - var(--sub-alt-color) - ); - } - - &[data-level="3"] { - background-color: color-mix( - in srgb, - var(--main-color) 75%, - var(--sub-alt-color) - ); - } - - &[data-level="4"] { - background-color: var(--main-color); - } - } - - .legend div { - width: 1em; - height: 1em; - } -} diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss index 7e047a112b5c..634b0a35de20 100644 --- a/frontend/src/styles/index.scss +++ b/frontend/src/styles/index.scss @@ -16,7 +16,7 @@ } @layer custom-styles { - @import "buttons", "404", "ads", "account", "animations", "caret", + @import "buttons", "404", "ads", "test-activity", "animations", "caret", "commandline", "core", "fonts", "inputs", "keymap", "monkey", "popups", "scroll", "settings", "account-settings", "test", "loading", "friends", "media-queries"; diff --git a/frontend/src/styles/media-queries-blue.scss b/frontend/src/styles/media-queries-blue.scss index a19cc3c4c8f3..98ea5c61a32d 100644 --- a/frontend/src/styles/media-queries-blue.scss +++ b/frontend/src/styles/media-queries-blue.scss @@ -38,44 +38,6 @@ } } } - .pageAccount { - .triplegroup.stats { - .title { - font-size: 0.75rem; - } - .val { - font-size: 1.5rem; - } - } - .group.estimatedWordsTyped { - .val { - font-size: 2rem; - } - } - .group.history table thead td:nth-child(7), - .group.history table tbody td:nth-child(7) { - display: none; - } - .group.filterButtons { - grid-template-columns: 1fr; - } - } - .pageAccountSettings { - .main { - grid-template-columns: 1fr; - grid-template-rows: auto auto; - .tabs { - padding: 0; - display: grid; - grid-auto-flow: row; - button { - justify-content: center; - padding: 1em 0.5em; - } - } - } - } - .pageSettings { .accountSettingsNotice { button { diff --git a/frontend/src/styles/media-queries-brown.scss b/frontend/src/styles/media-queries-brown.scss index 9b8ede989328..1e6e97077dba 100644 --- a/frontend/src/styles/media-queries-brown.scss +++ b/frontend/src/styles/media-queries-brown.scss @@ -24,14 +24,6 @@ } } } - .pageAccount { - .group.history table thead td:nth-child(3), - .group.history table tbody td:nth-child(3), - .group.history table thead td:nth-child(5), - .group.history table tbody td:nth-child(5) { - display: none; - } - } #keymap .row { height: 1rem; gap: 0.1rem; diff --git a/frontend/src/styles/media-queries-green.scss b/frontend/src/styles/media-queries-green.scss index 53bfd5534b82..f7b4c766e7ff 100644 --- a/frontend/src/styles/media-queries-green.scss +++ b/frontend/src/styles/media-queries-green.scss @@ -54,49 +54,6 @@ } } } - .pageAccount { - .group.resultBatches { - gap: 1rem; - grid-template-areas: "bar" "button" "text"; - grid-template-columns: 1fr; - } - .group.topFilters { - .buttons { - display: grid; - grid-template-columns: 1fr 1fr; - button[filter="all"] { - grid-column: span 2; - } - &.filterGroup { - margin-top: 2rem; - } - } - } - .group.chart .below { - grid-template-columns: 1fr; - .buttons { - grid-template-columns: 1fr 1fr 1fr 1fr; - } - } - .triplegroup.stats { - .val { - font-size: 2rem; - } - } - .group.history { - font-size: 0.75rem; - } - .group.history table thead td:nth-child(6), - .group.history table tbody td:nth-child(6) { - display: none; - } - // .group.history table thead td:nth-child(8), - // .group.history table thead td:nth-child(9), - // .group.history table tbody td:nth-child(8), - // .group.history table tbody td:nth-child(9) { - // display: none; - // } - } .testActivity { --gap-size: 0.1em; diff --git a/frontend/src/styles/media-queries-purple.scss b/frontend/src/styles/media-queries-purple.scss index b52428e12f6d..ef35fc4107be 100644 --- a/frontend/src/styles/media-queries-purple.scss +++ b/frontend/src/styles/media-queries-purple.scss @@ -56,47 +56,6 @@ } } } - .pageAccount { - .accountVerificatinNotice { - button { - grid-column: -1 / 1; - } - } - .group.resultBatches { - gap: 1rem; - grid-template-areas: "bar" "button" "text"; - grid-template-columns: 1fr; - } - .group.topFilters { - .buttons { - grid-template-columns: 1fr; - button[filter="all"] { - grid-column: span 1; - } - } - } - .group.chart .below { - .buttons { - grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); - } - } - .triplegroup.stats { - grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); - } - .group.aboveHistory { - grid-template-columns: 1fr; - .exportCSV { - grid-column: span 1; - } - } - .group.history table thead td:nth-child(8), - .group.history table tbody td:nth-child(8), - .group.history table thead td:nth-child(9), - .group.history table tbody td:nth-child(9) { - display: none; - } - } - #keymap .row { height: 1.25rem; } diff --git a/frontend/src/styles/media-queries-yellow.scss b/frontend/src/styles/media-queries-yellow.scss index 1d8d9782123b..1ee3d4867899 100644 --- a/frontend/src/styles/media-queries-yellow.scss +++ b/frontend/src/styles/media-queries-yellow.scss @@ -20,14 +20,7 @@ grid-template-columns: 1fr 1fr; } } - .pageAccount { - .group.chart .below { - grid-template-columns: auto 250px; - .buttons { - grid-template-columns: 1fr 1fr; - } - } - } + .pageTest { #liveStatsTextTop, #liveStatsTextBottom { diff --git a/frontend/src/styles/test-activity.scss b/frontend/src/styles/test-activity.scss new file mode 100644 index 000000000000..84d852c3b9c6 --- /dev/null +++ b/frontend/src/styles/test-activity.scss @@ -0,0 +1,205 @@ +.testActivity { + // width: max-content; + // justify-self: center; + background: var(--sub-alt-color); + border-radius: var(--roundness); + padding: 1rem; + display: flex; + justify-content: center; + + --gap-size: 0.25em; + --font-size: 1em; + + .wrapper { + width: 100%; + max-width: 80em; + display: grid; + grid-template-columns: min-content 1fr; + grid-template-rows: min-content 1fr min-content; + gap: 1em 1em; + grid-template-areas: + "top top" + "day chart" + "empty month"; + // grid-template-areas: + // "empty month" + // "day chart" + // "top top"; + // font-size: 0.75rem; + } + + .note { + grid-column: span 2; + text-align: center; + font-size: 0.6em; + color: var(--sub-color); + } + + .top { + grid-area: top; + display: grid; + grid-template-columns: 15rem 1fr max-content; + grid-template-areas: "title title legend"; + gap: 1rem; + &:has(.year) { + grid-template-areas: "year title legend"; + } + } + + .ss-main { + border: 0.2em solid var(--bg-color); + } + + .yearSelect, + .months div, + .days div, + .daysFull div, + .legend { + color: var(--sub-color); + } + + .year { + grid-area: year; + font-size: var(--font-size); + } + + .title { + grid-area: title; + text-align: left; + font-size: var(--font-size); + color: var(--sub-color); + align-self: center; + } + + .months { + grid-area: month; + display: grid; + grid-template-columns: repeat(53, 1fr); + + div { + width: 100%; + text-align: center; + } + font-size: var(--font-size); + } + + .daysFull { + margin-right: 2rem; + } + + .days, + .daysFull { + grid-area: day; + display: grid; + grid-template-rows: repeat(7, 1fr); + align-items: center; + // div { + // display: grid; + // overflow: hidden; + // align-items: center; + // } + .text { + display: flex; + height: 0; + align-items: center; + font-size: var(--font-size); + } + } + + .days { + display: none; + } + + .nodata { + grid-area: chart; + } + + .activity { + grid-area: chart; + display: grid; + grid-auto-flow: column; + grid-template-rows: repeat(7, 1fr); + grid-template-columns: repeat(53, 1fr); + gap: var(--gap-size); + // width: max-content; + // max-width: 100%; + + div { + &:hover { + border: 2px solid var(--text-color); + } + &[data-level="filler"]:hover { + border: none; + } + } + } + .legend { + grid-area: legend; + display: flex; + gap: var(--gap-size); + justify-content: flex-end; + align-self: center; + align-items: center; + + span { + font-size: var(--font-size); + &:first-child { + margin-right: var(--gap-size); + } + &:last-child { + margin-left: var(--gap-size); + } + } + } + .activity div, + .legend div { + width: 100%; + aspect-ratio: 1; + border-radius: var(--gap-size); + place-self: center; + + &[data-level="filler"] { + background: none; + } + &[data-level="0"] { + background-color: color-mix( + in srgb, + var(--bg-color) 50%, + var(--sub-alt-color) + ); + background-color: var(--bg-color); + } + &[data-level="1"] { + background-color: color-mix( + in srgb, + var(--main-color) 20%, + var(--sub-alt-color) + ); + } + + &[data-level="2"] { + background-color: color-mix( + in srgb, + var(--main-color) 50%, + var(--sub-alt-color) + ); + } + + &[data-level="3"] { + background-color: color-mix( + in srgb, + var(--main-color) 75%, + var(--sub-alt-color) + ); + } + + &[data-level="4"] { + background-color: var(--main-color); + } + } + + .legend div { + width: 1em; + height: 1em; + } +} diff --git a/frontend/src/ts/auth.tsx b/frontend/src/ts/auth.tsx index 972b61ff07d7..00bccb0ff1a8 100644 --- a/frontend/src/ts/auth.tsx +++ b/frontend/src/ts/auth.tsx @@ -23,6 +23,7 @@ import { resetIgnoreAuthCallback, } from "./firebase"; import * as Sentry from "./sentry"; +import { setUserId } from "./states/core"; import { showLoaderBar, hideLoaderBar } from "./states/loader-bar"; import { showNoticeNotification, @@ -120,8 +121,10 @@ export async function onAuthStateChanged( if (authInitialisedAndConnected) { console.debug(`auth state changed, user ${user ? "true" : "false"}`); if (user) { + setUserId(user.uid); userPromise = loadUser(user); } else { + setUserId(null); DB.setSnapshot(undefined); } } diff --git a/frontend/src/ts/collections/result-filter-presets.ts b/frontend/src/ts/collections/result-filter-presets.ts new file mode 100644 index 000000000000..4a3003512721 --- /dev/null +++ b/frontend/src/ts/collections/result-filter-presets.ts @@ -0,0 +1,77 @@ +import { ResultFilters } from "@monkeytype/schemas/users"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import { createCollection } from "@tanstack/solid-db"; +import Ape from "../ape"; +import { queryClient } from "../queries"; +import { baseKey } from "../queries/utils/keys"; +import { showErrorNotification } from "../states/notifications"; + +const queryKeys = { + root: () => [...baseKey("resultFilterPresets", { isUserSpecific: true })], +}; + +export const resultFilterPresetsCollection = createCollection( + queryCollectionOptions({ + staleTime: Infinity, + queryKey: queryKeys.root(), + + queryClient, + getKey: (it) => it._id, + queryFn: async () => { + //return emtpy array. We load the user with the snapshot and fill the collection from there + return [] as ResultFilters[]; + }, + onInsert: async ({ transaction }) => { + const newItems = transaction.mutations.map((m) => m.modified); + + const serverItems = await Promise.all( + newItems.map(async (it) => { + const response = await Ape.users.addResultFilterPreset({ body: it }); + if (response.status !== 200) { + showErrorNotification( + `Failed to insert result filter presets: ${response.body.message}`, + ); + throw new Error( + `Failed to insert result filter presets: ${response.body.message}`, + ); + } + return { ...it, _id: response.body.data }; + }), + ); + + resultFilterPresetsCollection.utils.writeBatch(() => { + serverItems.forEach((it) => + resultFilterPresetsCollection.utils.writeInsert(it), + ); + }); + return { refetch: false }; + }, + onDelete: async ({ transaction }) => { + const ids = transaction.mutations.map((it) => it.key as string); + + await Promise.all( + ids.map(async (it) => { + const response = await Ape.users.removeResultFilterPreset({ + params: { presetId: it }, + }); + if (response.status !== 200) { + showErrorNotification( + `Failed to delete result filter presets: ${response.body.message}`, + ); + throw new Error( + `Failed to delete result filter presets: ${response.body.message}`, + ); + } + }), + ); + + resultFilterPresetsCollection.utils.writeBatch(() => { + ids.forEach((it) => + resultFilterPresetsCollection.utils.writeDelete(it), + ); + }); + //don't refetch + return { refetch: false }; + }, + }), +); diff --git a/frontend/src/ts/collections/results.ts b/frontend/src/ts/collections/results.ts new file mode 100644 index 000000000000..7af8a04ab3de --- /dev/null +++ b/frontend/src/ts/collections/results.ts @@ -0,0 +1,484 @@ +import { ResultMinified } from "@monkeytype/schemas/results"; +import { Difficulty, Mode, Mode2 } from "@monkeytype/schemas/shared"; +import { ResultFilters } from "@monkeytype/schemas/users"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import { + avg, + count, + createCollection, + createLiveQueryCollection, + eq, + gte, + inArray, + length, + max, + not, + or, + Query, + sum, + useLiveQuery, +} from "@tanstack/solid-db"; +import { queryOptions } from "@tanstack/solid-query"; +import { Accessor } from "solid-js"; +import Ape from "../ape"; +import { SnapshotResult } from "../constants/default-snapshot"; +import { queryClient } from "../queries"; +import { baseKey } from "../queries/utils/keys"; +import { getSnapshot } from "../states/snapshot"; +import { ExactlyOneTrue } from "../utils/types"; +import { isLoggedIn } from "../states/core"; + +export type ResultsQueryState = { + difficulty: SnapshotResult["difficulty"][]; + pb: SnapshotResult["isPb"][]; + mode: SnapshotResult["mode"][]; + words: ("10" | "25" | "50" | "100" | "custom")[]; + time: ("15" | "30" | "60" | "120" | "custom")[]; + punctuation: SnapshotResult["punctuation"][]; + numbers: SnapshotResult["numbers"][]; + timestamp: SnapshotResult["timestamp"]; + quoteLength: SnapshotResult["quoteLength"][]; + tags: SnapshotResult["tags"]; + funbox: SnapshotResult["funbox"]; + language: SnapshotResult["language"][]; +}; + +const queryKeys = { + root: () => [...baseKey("results", { isUserSpecific: true })], + fullResult: (_id: string) => [...queryKeys.root(), _id], +}; + +export type ResultStats = { + words: number; + restarted: number; + completed: number; + maxWpm: number; + avgWpm: number; + maxRaw: number; + avgRaw: number; + maxAcc: number; + avgAcc: number; + maxConsistency: number; + avgConsistency: number; + timeTyping: number; + dayTimestamp?: number; +}; + +/** + * get aggregated statistics for the current result selection + * @param queryState + * @param options + * @returns + */ +// oxlint-disable-next-line typescript/explicit-function-return-type +export function useResultStatsLiveQuery( + queryState: Accessor, + options?: { lastTen?: true } | { groupByDay?: true }, +) { + return useLiveQuery((q) => { + const state = queryState(); + if (state === undefined) return undefined; + + const isLastTen = + options !== undefined && "lastTen" in options && options.lastTen; + const isGroupByDay = + options !== undefined && "groupByDay" in options && options.groupByDay; + + let query = isLastTen + ? //for lastTen we need a sub-query to apply the sort+limit first and then run the aggregations + q.from({ + r: q + .from({ r: buildResultsQuery(state) }) + .orderBy(({ r }) => r.timestamp, "desc") + .limit(10), + }) + : q.from({ r: buildResultsQuery(state) }); + + if (isGroupByDay) { + query = query.groupBy(({ r }) => r.dayTimestamp); + } + + return query.select(({ r }) => ({ + dayTimestamp: isGroupByDay ? r.dayTimestamp : undefined, + words: sum(r.words), + completed: count(r._id), + restarted: sum(r.restartCount), + timeTyping: sum(r.timeTyping), + maxWpm: max(r.wpm), + avgWpm: avg(r.wpm), + maxRaw: max(r.rawWpm), + avgRaw: avg(r.rawWpm), + maxAcc: max(r.acc), + avgAcc: avg(r.acc), + maxConsistency: max(r.consistency), + avgConsistency: avg(r.consistency), + })); + }); +} + +/** + * get list of SnapshotResults for the current result selection + * @param queryState + * @returns + */ +// oxlint-disable-next-line typescript/explicit-function-return-type +export function useResultsLiveQuery(options: { + queryState: Accessor; + sorting: Accessor<{ + field: keyof SnapshotResult; + direction: "asc" | "desc"; + }>; + limit: Accessor; +}) { + return useLiveQuery((q) => { + const state = options.queryState(); + const sorting = options.sorting(); + const limit = options.limit(); + if (state === undefined) return undefined; + + return q + .from({ r: buildResultsQuery(state) }) + .orderBy(({ r }) => r[sorting.field], sorting.direction) + .limit(limit); + }); +} + +function normalizeResult( + result: ResultMinified | SnapshotResult, + knownTagIds?: Set, +): SnapshotResult { + const resultDate = new Date(result.timestamp); + resultDate.setSeconds(0); + resultDate.setMinutes(0); + resultDate.setHours(0); + resultDate.setMilliseconds(0); + + //@ts-expect-error without this somehow the collections is missing data + result.id = result._id; + //results strip default values, add them back + result.bailedOut ??= false; + result.blindMode ??= false; + result.lazyMode ??= false; + result.difficulty ??= "normal"; + result.funbox ??= []; + result.language ??= "english"; + result.numbers ??= false; + result.punctuation ??= false; + result.numbers ??= false; + result.quoteLength ??= -1; + result.restartCount ??= 0; + result.incompleteTestSeconds ??= 0; + result.afkDuration ??= 0; + result.tags ??= []; + if (knownTagIds !== undefined) { + result.tags = result.tags.filter((tagId) => knownTagIds.has(tagId)); + } + result.isPb ??= false; + return { + ...result, + timeTyping: calcTimeTyping(result), + words: Math.round((result.wpm / 60) * result.testDuration), + dayTimestamp: resultDate.getTime(), + } as SnapshotResult; +} + +export async function insertLocalResult( + result: SnapshotResult, +): Promise { + if (resultsCollection.isReady()) { + resultsCollection.insert(result); + } +} + +export const resultsCollection = createCollection( + queryCollectionOptions({ + staleTime: Infinity, + queryKey: queryKeys.root(), + queryFn: async () => { + if (!isLoggedIn()) return []; + const knownTagIds = new Set(getSnapshot()?.tags.map((it) => it._id)); + //const options = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions); + + const response = await Ape.results.get({ + //query: { limit: options.limit }, + }); + + if (response.status !== 200) { + throw new Error("Error fetching results:" + response.body.message); + } + + return response.body.data.map((result) => + normalizeResult(result, knownTagIds), + ); + }, + onInsert: async ({ transaction }) => { + //call to the backend to post a result is done outside, we just insert the result as we get it + const newItems = transaction.mutations.map((m) => m.modified); + + resultsCollection.utils.writeBatch(() => { + newItems.forEach((item) => { + resultsCollection.utils.writeInsert(normalizeResult(item)); + }); + }); + + //do not refetch after insert + return { refetch: false }; + }, + onUpdate: async ({ transaction }) => { + const updates = transaction.mutations.map((m) => m.modified); + + resultsCollection.utils.writeBatch(() => { + updates.forEach((item) => { + resultsCollection.utils.writeUpdate(normalizeResult(item)); + }); + }); + //we don't sync local changes back + return { refetch: false }; + }, + queryClient, + getKey: (it) => it._id, + }), +); + +// oxlint-disable-next-line typescript/explicit-function-return-type +export function buildResultsQuery(state: ResultsQueryState) { + const applyMode2Filter = ( + key: T, + filter: ResultsQueryState[T], + nonCustomValues: string[], + ): void => { + if (filter.length === 5) return; + const isCustom = filter.includes("custom"); + const selected = filter.filter((it) => it !== "custom"); + query = query.where(({ r }) => + or( + //results not matching the mode pass + not(eq(r.mode, key)), + + //mode2 is matching one of the selected mode2 + inArray(r.mode2, selected), + //or if custom selected are not matching any non-custom value + isCustom ? not(inArray(r.mode2, nonCustomValues)) : false, + ), + ); + }; + + let query = new Query() + .from({ r: resultsCollection }) + .where(({ r }) => gte(r.timestamp, state.timestamp)) + .where(({ r }) => inArray(r.difficulty, state.difficulty)) + .where(({ r }) => inArray(r.isPb, state.pb)) + .where(({ r }) => inArray(r.mode, state.mode)) + .where(({ r }) => inArray(r.punctuation, state.punctuation)) + .where(({ r }) => inArray(r.numbers, state.numbers)) + .where(({ r }) => inArray(r.quoteLength, state.quoteLength)) + .where(({ r }) => inArray(r.language, state.language)) + .where(({ r }) => + or( + false, + false, + ...state.tags.map((tag) => + tag === "none" ? eq(length(r.tags), 0) : inArray(tag, r.tags), + ), + ), + ) + .where(({ r }) => + or( + false, + false, + ...state.funbox.map((fb) => + (fb as string) === "none" + ? eq(length(r.funbox), 0) + : inArray(fb, r.funbox), + ), + ), + ); + applyMode2Filter("time", state.time, ["15", "30", "60", "120"]); + applyMode2Filter("words", state.words, ["10", "25", "50", "100"]); + + return query; +} + +export function createResultsQueryState( + filters: ResultFilters, +): ResultsQueryState { + return { + difficulty: valueFilter(filters.difficulty), + pb: boolFilter(filters.pb), + mode: valueFilter(filters.mode), + words: valueFilter(filters.words), + time: valueFilter(filters.time), + punctuation: boolFilter(filters.punctuation), + numbers: boolFilter(filters.numbers), + timestamp: timestampFilter(filters.date), + quoteLength: [ + ...valueFilter(filters.quoteLength, { + short: 0, + medium: 1, + long: 2, + thicc: 3, + }), + -1, // fallback value for results without quoteLength, set in the collection + ], + tags: valueFilter(filters.tags), + funbox: valueFilter(filters.funbox), + language: valueFilter(filters.language), + }; +} + +function valueFilter( + val: Partial>, + mapping?: Record, +): U[] { + return Object.entries(val) + .filter(([_, v]) => v === true) + .map(([k]) => k as T) + .map((it) => (mapping ? mapping[it] : (it as unknown as U))); +} + +function boolFilter( + val: Record<"on" | "off", boolean> | Record<"yes" | "no", boolean>, +): boolean[] { + return Object.entries(val) + .filter(([_, v]) => v) + .map(([k]) => k === "on" || k === "yes"); +} + +function timestampFilter(val: ResultFilters["date"]): number { + const seconds = + valueFilter(val, { + all: 0, + last_day: 24 * 60 * 60, + last_week: 7 * 24 * 60 * 60, + last_month: 30 * 24 * 60 * 60, + last_3months: 90 * 24 * 60 * 60, + })[0] ?? 0; + + if (seconds === 0) return 0; + return Math.floor(Date.now() - seconds * 1000); +} + +function calcTimeTyping(result: ResultMinified): number { + let tt = 0; + if ( + result.testDuration === undefined && + result.mode2 !== "custom" && + result.mode2 !== "zen" + ) { + //test finished before testDuration field was introduced - estimate + if (result.mode === "time") { + tt = parseInt(result.mode2); + } else if (result.mode === "words") { + tt = (parseInt(result.mode2) / result.wpm) * 60; + } + } else { + tt = parseFloat(result.testDuration as unknown as string); //legacy results could have a string here + } + if (result.incompleteTestSeconds !== undefined) { + tt += result.incompleteTestSeconds; + } else if (result.restartCount !== undefined && result.restartCount > 0) { + tt += (tt / 4) * result.restartCount; + } + return tt; +} + +// oxlint-disable-next-line typescript/explicit-function-return-type +export const getSingleResultQueryOptions = (_id: string) => + queryOptions({ + queryKey: queryKeys.fullResult(_id), + queryFn: async () => { + const response = await Ape.results.getById({ params: { resultId: _id } }); + + if (response.status !== 200) { + throw new Error(`Failed to load result: ${response.body.message}`); + } + return response.body.data; + }, + staleTime: Infinity, + }); + +export type CurrentSettingsFilter = { + mode: Mode; + mode2: Mode2; + punctuation: boolean; + numbers: boolean; + language: string; + difficulty: Difficulty; + lazyMode: boolean; +}; + +export async function getUserAverage( + options: CurrentSettingsFilter & + ExactlyOneTrue<{ + last10Only: boolean; + lastDayOnly: boolean; + }>, +): Promise<{ wpm: number; acc: number }> { + const activeTagIds = getSnapshot() + ?.tags.filter((it) => it.active === true) + .map((it) => it._id); + + const result = await createLiveQueryCollection((q) => { + let query = q + .from({ r: buildSettingsResultsQuery(options) }) + .where(({ r }) => + or( + false, + activeTagIds === undefined || activeTagIds.length === 0, + ...(activeTagIds ?? []).map((it) => inArray(it, r.tags)), + ), + ); + + if (options.lastDayOnly) { + query = query.where(({ r }) => gte(r.timestamp, Date.now() - 86400000)); + } + if (options.last10Only) { + query = query.orderBy(({ r }) => r.timestamp).limit(10); + } + + return query.select(({ r }) => ({ wpm: avg(r.wpm), acc: avg(r.acc) })); + }).toArrayWhenReady(); + + return result.length === 1 && result[0] !== undefined + ? result[0] + : { wpm: 0, acc: 0 }; +} + +export async function findFastestResultByTagId( + options: CurrentSettingsFilter & { tagId: string }, +): Promise | undefined> { + const result = await createLiveQueryCollection((q) => + q + .from({ r: buildSettingsResultsQuery(options) }) + .where(({ r }) => inArray(options.tagId, r.tags)) + .orderBy(({ r }) => r.wpm, "desc") + .limit(1) + .findOne(), + ).toArrayWhenReady(); + return result.length === 1 && result[0] !== undefined + ? (result[0] as SnapshotResult) + : undefined; +} + +// oxlint-disable-next-line typescript/explicit-function-return-type +function buildSettingsResultsQuery(filter: CurrentSettingsFilter) { + return new Query() + .from({ r: resultsCollection }) + .where(({ r }) => eq(r.mode, filter.mode)) + .where(({ r }) => eq(r.mode2, filter.mode2)) + .where(({ r }) => eq(r.punctuation, filter.punctuation)) + .where(({ r }) => eq(r.numbers, filter.numbers)) + .where(({ r }) => eq(r.language, filter.language)) + .where(({ r }) => eq(r.difficulty, filter.difficulty)) + .where(({ r }) => eq(r.lazyMode, filter.lazyMode)); +} + +export function deleteLocalTag(tagId: string): void { + for (const result of resultsCollection.values()) { + if (!result.tags.includes(tagId)) continue; + resultsCollection.update(result._id, (old) => { + const tags = old.tags.filter((it) => it !== tagId); + old.tags = tags; + }); + } +} diff --git a/frontend/src/ts/components/common/Advertisement.tsx b/frontend/src/ts/components/common/Advertisement.tsx new file mode 100644 index 000000000000..9a55f2bd68e7 --- /dev/null +++ b/frontend/src/ts/components/common/Advertisement.tsx @@ -0,0 +1,36 @@ +import { Ads } from "@monkeytype/schemas/configs"; +import { JSXElement, Show } from "solid-js"; + +import { getConfig } from "../../config/store"; + +export function Advertisement(props: { + id: "ad-account-1" | "ad-account-2" | "ad-about-1" | "ad-about-2"; + visible: Ads | Ads[]; +}): JSXElement { + const isVisible = () => + Array.isArray(props.visible) + ? props.visible.includes(getConfig.ads) + : props.visible === getConfig.ads; + return ( + + + + + ); +} diff --git a/frontend/src/ts/components/common/AnimatedModal.tsx b/frontend/src/ts/components/common/AnimatedModal.tsx index df29fe8f7904..87d53b9f4d4c 100644 --- a/frontend/src/ts/components/common/AnimatedModal.tsx +++ b/frontend/src/ts/components/common/AnimatedModal.tsx @@ -1,9 +1,9 @@ import { JSXElement, - createEffect, - onCleanup, ParentProps, Show, + createEffect, + onCleanup, } from "solid-js"; import { useRefWithUtils } from "../../hooks/useRefWithUtils"; diff --git a/frontend/src/ts/components/dev/DevTools.tsx b/frontend/src/ts/components/dev/DevTools.tsx index 189a0a825a98..0ae7c8cac258 100644 --- a/frontend/src/ts/components/dev/DevTools.tsx +++ b/frontend/src/ts/components/dev/DevTools.tsx @@ -14,7 +14,7 @@ if (import.meta.env.DEV) { })), ); - const LazySolidDevtoolsOverlay = lazy(async () => + const _LazySolidDevtoolsOverlay = lazy(async () => import("@solid-devtools/overlay").then((m) => ({ default: () => { onMount(() => { @@ -33,7 +33,9 @@ if (import.meta.env.DEV) { - + {/*. + + */} ); } diff --git a/frontend/src/ts/components/modals/CustomGeneratorModal.tsx b/frontend/src/ts/components/modals/CustomGeneratorModal.tsx index e743fb83dc00..020c61e1d03b 100644 --- a/frontend/src/ts/components/modals/CustomGeneratorModal.tsx +++ b/frontend/src/ts/components/modals/CustomGeneratorModal.tsx @@ -163,6 +163,7 @@ export function CustomGeneratorModal(props: {
presets
field().handleChange(val ?? "")} diff --git a/frontend/src/ts/components/modals/WordFilterModal.tsx b/frontend/src/ts/components/modals/WordFilterModal.tsx index 2cc78942c7f2..9ccb01546a86 100644 --- a/frontend/src/ts/components/modals/WordFilterModal.tsx +++ b/frontend/src/ts/components/modals/WordFilterModal.tsx @@ -231,6 +231,7 @@ export function WordFilterModal(props: {
language
presets
layout
JSXElement> = { footer: () =>