diff --git a/.claude/skills/review.md b/.claude/skills/review.md index 2609fed8d4ef..41656004c12d 100644 --- a/.claude/skills/review.md +++ b/.claude/skills/review.md @@ -18,4 +18,4 @@ Trigger: user asks to review changes, review code, or uses /review - **Tailwind**: non-canonical classes, inline styles that should be Tailwind, missing responsive variants if siblings have them - **Solid-specific**: broken reactivity, missing cleanup, doing things not the "Solid way" - **Improvements**: any other changes that would make the code more robust, readable, maintainable, better -4. Output a concise list of findings. If nothing found, say "No issues found." +4. Output a concise list of findings. Highlight which issues should be absolutely fixed before merging/committing. If nothing found, say "No issues found." diff --git a/CLAUDE.md b/CLAUDE.md index ba744ec7ca86..5d2b5248e2ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,4 +3,4 @@ Frontend is partially migrated from vanilla JS to SolidJS — new components use Single test file: `pnpm vitest run path/to/test.ts` For styling, use Tailwind CSS, class property, `cn` utility. Do not use classlist. Only colors available are those defined in Tailwind config. In legacy code, use `i` tags with FontAwesome classes. In new code, use `Fa` component. -At the end of plan mode, give me a list of unresolved questions to answer, if any. Make them concise. \ No newline at end of file +In plan mode, before writing up a plan, ask clarifying questions if needed. At the end of plan mode, give me a list of unresolved questions to answer, if any. Make them concise. \ No newline at end of file diff --git a/backend/src/api/controllers/dev.ts b/backend/src/api/controllers/dev.ts index f5cb00e0f55d..6f3e47166125 100644 --- a/backend/src/api/controllers/dev.ts +++ b/backend/src/api/controllers/dev.ts @@ -11,9 +11,11 @@ import MonkeyError from "../../utils/error"; import { Mode, PersonalBest, PersonalBests } from "@monkeytype/schemas/shared"; import { + AddDebugInboxItemRequest, GenerateDataRequest, GenerateDataResponse, } from "@monkeytype/contracts/dev"; +import { buildMonkeyMail } from "../../utils/monkey-mail"; import { roundTo2 } from "@monkeytype/util/numbers"; import { MonkeyRequest } from "../types"; import { DBResult } from "../../utils/result"; @@ -42,6 +44,37 @@ export async function createTestData( return new MonkeyResponse("test data created", { uid, email }); } +export async function addDebugInboxItem( + req: MonkeyRequest, +): Promise { + const { uid } = req.ctx.decodedToken; + const { rewardType } = req.body; + const inboxConfig = req.ctx.configuration.users.inbox; + + const rewards = + rewardType === "xp" + ? [{ type: "xp" as const, item: 1000 }] + : rewardType === "badge" + ? [{ type: "badge" as const, item: { id: 1 } }] + : []; + + const body = + rewardType === "xp" + ? "Here is your 1000 XP reward for debugging." + : rewardType === "badge" + ? "Here is your Developer badge reward." + : "A debug inbox item with no reward."; + + const mail = buildMonkeyMail({ + subject: "Debug Inbox Item", + body, + rewards, + }); + + await UserDal.addToInbox(uid, [mail], inboxConfig); + return new MonkeyResponse("Debug inbox item added", null); +} + async function getOrCreateUser( username: string, password: string, diff --git a/backend/src/api/routes/dev.ts b/backend/src/api/routes/dev.ts index 3b4678a20bc6..a7806b6572e2 100644 --- a/backend/src/api/routes/dev.ts +++ b/backend/src/api/routes/dev.ts @@ -12,4 +12,8 @@ export default s.router(devContract, { middleware: [onlyAvailableOnDev()], handler: async (r) => callController(DevController.createTestData)(r), }, + addDebugInboxItem: { + middleware: [onlyAvailableOnDev()], + handler: async (r) => callController(DevController.addDebugInboxItem)(r), + }, }); diff --git a/frontend/__tests__/components/common/AsyncContent.spec.tsx b/frontend/__tests__/components/common/AsyncContent.spec.tsx index 2c42e2935fc0..5532163a57f9 100644 --- a/frontend/__tests__/components/common/AsyncContent.spec.tsx +++ b/frontend/__tests__/components/common/AsyncContent.spec.tsx @@ -149,13 +149,7 @@ describe("AsyncContent", () => { })); return ( - + )}> {(data: string | undefined) => ( <> foo @@ -341,13 +335,11 @@ describe("AsyncContent", () => { retry: 0, })); + type Q = { first: string | undefined; second: string | undefined }; return ( )} > {(results: { first: string | undefined; diff --git a/frontend/package.json b/frontend/package.json index b7464da15ed6..c19a6515041b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,9 @@ "@solid-primitives/refs": "1.1.2", "@solid-primitives/transition-group": "1.1.2", "@solidjs/meta": "0.29.4", + "@tanstack/pacer-lite": "0.2.1", + "@tanstack/query-db-collection": "1.0.27", + "@tanstack/solid-db": "0.2.10", "@tanstack/solid-query": "5.90.23", "@tanstack/solid-query-devtools": "5.91.3", "@tanstack/solid-table": "8.21.3", diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index b2fa87d71c97..d1d043721ea5 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -51,57 +51,6 @@ - - diff --git a/frontend/src/index.html b/frontend/src/index.html index 4eee886c1687..ed396cf85323 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -15,6 +15,7 @@ +
diff --git a/frontend/src/styles/media-queries-purple.scss b/frontend/src/styles/media-queries-purple.scss index a4c27d0d1052..0ca89061d979 100644 --- a/frontend/src/styles/media-queries-purple.scss +++ b/frontend/src/styles/media-queries-purple.scss @@ -113,9 +113,7 @@ border-radius: 0.3rem; font-size: 0.6rem; } - #alertsPopup .modal { - max-width: calc(100% - 4rem); - } + .popupWrapper .modal, .modalWrapper .modal { padding: 1rem; diff --git a/frontend/src/styles/popups.scss b/frontend/src/styles/popups.scss index e3f05d97cfed..0b4e71fdd0fd 100644 --- a/frontend/src/styles/popups.scss +++ b/frontend/src/styles/popups.scss @@ -1548,186 +1548,3 @@ body.darkMode { } } } - -#alertsPopup { - padding: 0; - justify-content: end; - z-index: 99999999; - overflow-x: hidden; - justify-items: end; - .modal { - background: var(--bg-color); - max-width: calc(350px + 2rem); - right: 0; - // height: calc(100vh - 4rem); - height: 100%; - top: 0; - padding: 2rem calc(1rem - 7px) 2rem 1rem; // -7px for the scrollbar - // padding: 1rem; - // border-radius: var(--roundness); - overflow: hidden; - margin-right: -10rem; - border-radius: var(--roundness) 0 0 var(--roundness); - display: block; - - .mobileClose { - margin-bottom: 2rem; - width: 100%; - display: none; - } - - .separator { - background-color: var(--sub-alt-color); - height: 0.25rem; - width: 100%; - border-radius: calc(var(--roundness) / 2); - } - - .scrollWrapper { - padding: 0 1rem 0 1rem; - overflow-y: scroll; - display: grid; - gap: 2rem; - align-content: baseline; - height: 100%; - grid-auto-columns: 100%; - } - .accountAlerts > .title, - .notificationHistory > .title, - .psas > .title { - font-size: 1.25rem; - margin-bottom: 1rem; - color: var(--sub-color); - -webkit-user-select: none; - user-select: none; - } - .accountAlerts > .claimAll, - .accountAlerts > .deleteAll { - font-size: 0.75em; - margin-bottom: 1rem; - width: 100%; - .fas { - margin-right: 0.25em; - } - } - .list { - display: grid; - gap: 1rem; - grid-template-columns: 100%; - .nothing { - width: 100%; - color: var(--text-color); - font-size: 0.75rem; - text-align: center; - margin: 2rem 0; - } - .preloader { - width: 100%; - color: var(--main-color); - text-align: center; - font-size: 1rem; - margin: 2rem 0; - } - .item { - display: grid; - grid-template-areas: "indicator title buttons" "indicator body buttons"; - grid-template-columns: 0.25rem auto max-content; - gap: 0.25rem 0.5rem; - .indicator { - grid-area: indicator; - background-color: var(--sub-alt-color); - width: 0.25rem; - height: 100%; - border-radius: calc(var(--roundness) / 2); - transition: 0.125s; - &.main { - background-color: var(--main-color); - } - &.error { - background-color: var(--error-color); - } - &.sub { - background-color: var(--sub-color); - } - } - .title { - grid-area: title; - font-size: 0.75rem; - color: var(--sub-color); - } - .body { - grid-area: body; - font-size: 0.75rem; - color: var(--text-color); - transition: 0.125s; - opacity: 1; - word-wrap: break-word; - } - .buttons { - grid-area: buttons; - width: 100%; - display: grid; - grid-auto-flow: row; - gap: 0.5rem; - opacity: 0; - transition: 0.125s; - align-items: center; - align-content: center; - button { - font-size: 0.8em; - height: 100%; - display: grid; - } - } - &:hover, - &:focus-within { - .buttons { - opacity: 1; - } - .body { - opacity: 1; - } - } - } - } - .psas .list .item { - grid-template-areas: "indicator body"; - grid-template-columns: 0.25rem calc(100% - 0.25rem); - .body { - opacity: 1; - } - } - .notificationHistory .list .item { - grid-template-areas: "indicator title buttons" "indicator body buttons"; - .title { - font-size: 0.75rem; - color: var(--sub-color); - } - .body { - opacity: 1; - } - .highlight { - color: var(--main-color) !important; - } - } - .accountAlerts { - .title { - display: grid; - grid-template-columns: 1fr auto; - } - .list .item { - grid-template-areas: "indicator timestamp buttons" "indicator title buttons" "indicator body buttons"; - .timestamp { - grid-area: timestamp; - font-size: 0.6rem; - color: var(--sub-color); - opacity: 0.5; - } - .rewards { - overflow: hidden; - margin-top: 0.35rem; - } - } - } - } -} diff --git a/frontend/src/ts/collections/inbox.ts b/frontend/src/ts/collections/inbox.ts new file mode 100644 index 000000000000..e9958178b482 --- /dev/null +++ b/frontend/src/ts/collections/inbox.ts @@ -0,0 +1,102 @@ +import { MonkeyMail } from "@monkeytype/schemas/users"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import { + createCollection, + eq, + MutationFnParams, + not, + useLiveQuery, +} from "@tanstack/solid-db"; +import { Accessor, createSignal } from "solid-js"; +import Ape from "../ape"; +import { queryClient } from "../queries"; +import { baseKey } from "../queries/utils/keys"; +import { isLoggedIn } from "../signals/core"; +import { flushDebounceStrategy } from "./utils/flushDebounceStrategy"; +import { showErrorNotification } from "../stores/notifications"; + +export const flushStrategy = flushDebounceStrategy({ maxWait: 1000 * 60 * 5 }); + +const queryKeys = { + root: () => [...baseKey("inbox", { isUserSpecific: true })], +}; + +const [maxMailboxSize, setMaxMailboxSize] = createSignal(0); + +export { maxMailboxSize }; + +export type InboxItem = Omit & { + status: "unclaimed" | "unread" | "read" | "deleted"; +}; +export const inboxCollection = createCollection( + queryCollectionOptions({ + staleTime: 1000 * 60 * 5, + queryKey: queryKeys.root(), + + queryFn: async () => { + const addStatus = (item: MonkeyMail): InboxItem => ({ + ...item, + status: (item.rewards.length > 0 && !item.read + ? "unclaimed" + : item.read + ? "read" + : "unread") as InboxItem["status"], + }); + + const response = await Ape.users.getInbox(); + if (response.status !== 200) { + showErrorNotification( + "Error fetching user inbox: " + response.body.message, + ); + throw new Error("Error fetching user inbox: " + response.body.message); + } + setMaxMailboxSize(response.body.data.maxMail); + return response.body.data.inbox.map(addStatus); + }, + queryClient, + getKey: (it) => it.id, + }), +); + +export async function flushPendingChanges({ + transaction, +}: MutationFnParams): Promise { + const updatedStatus = Object.groupBy( + transaction.mutations.map((it) => it.modified), + (it) => it.status, + ); + + const response = await Ape.users.updateInbox({ + body: { + mailIdsToMarkRead: updatedStatus.read?.map((it) => it.id), + mailIdsToDelete: updatedStatus.deleted?.map((it) => it.id), + }, + }); + + if (response.status !== 200) { + showErrorNotification( + "Error updating user inbox: " + response.body.message, + ); + throw new Error("Error updating user inbox: " + response.body.message); + } + + inboxCollection.utils.writeBatch(() => { + updatedStatus.deleted?.forEach((deleted) => + inboxCollection.utils.writeDelete(deleted.id), + ); + }); + + return { refetch: false }; +} + +// oxlint-disable-next-line typescript/explicit-function-return-type +export function useInboxQuery(enabled: Accessor) { + return useLiveQuery((q) => { + if (!isLoggedIn() || !enabled()) return undefined; + return q + .from({ inbox: inboxCollection }) + .where(({ inbox }) => not(eq(inbox.status, "deleted"))) + .orderBy(({ inbox }) => inbox.timestamp, "desc") + .orderBy(({ inbox }) => inbox.subject, "asc"); + }); +} diff --git a/frontend/src/ts/collections/utils/flushDebounceStrategy.ts b/frontend/src/ts/collections/utils/flushDebounceStrategy.ts new file mode 100644 index 000000000000..8b74c1331756 --- /dev/null +++ b/frontend/src/ts/collections/utils/flushDebounceStrategy.ts @@ -0,0 +1,31 @@ +import { DebounceStrategy, Transaction } from "@tanstack/solid-db"; +import { LiteDebouncer } from "@tanstack/pacer-lite/lite-debouncer"; + +export function flushDebounceStrategy(options: { maxWait: number }): { + strategy: DebounceStrategy; + flush: () => void; +} { + const debouncer = new LiteDebouncer( + (callback: () => Transaction) => callback(), + { wait: options.maxWait }, + ); + const strategy: DebounceStrategy = { + _type: `debounce`, + options: { wait: options.maxWait }, + execute: >( + fn: () => Transaction, + ) => { + debouncer.maybeExecute(fn as () => Transaction); + }, + cleanup: () => { + debouncer.cancel(); + }, + }; + + return { + strategy, + flush: () => { + debouncer.flush(); + }, + }; +} diff --git a/frontend/src/ts/components/common/AnimatedModal.tsx b/frontend/src/ts/components/common/AnimatedModal.tsx index 4f820f272b9a..d9a72973981f 100644 --- a/frontend/src/ts/components/common/AnimatedModal.tsx +++ b/frontend/src/ts/components/common/AnimatedModal.tsx @@ -19,6 +19,7 @@ import { applyReducedMotion } from "../../utils/misc"; type AnimationParams = { opacity?: number | [number, number]; marginTop?: string | [string, string]; + marginRight?: string | [string, string]; duration?: number; }; @@ -109,14 +110,38 @@ export function AnimatedModal(props: AnimatedModalProps): JSXElement { // Modal animation if (animMode !== "none") { - modalEl()?.setStyle({ + const customModal = props.customAnimations?.show?.modal; + const initialStyle: Record = { opacity: "0", marginTop: "1rem", - }); - - modalEl()?.animate({ + }; + const animParams: Record = { opacity: [0, 1], marginTop: ["1rem", "0"], + }; + if (customModal) { + if (customModal.opacity !== undefined) { + const v = customModal.opacity; + initialStyle["opacity"] = String(Array.isArray(v) ? v[0] : v); + animParams["opacity"] = v; + } + if (customModal.marginTop !== undefined) { + const v = customModal.marginTop; + initialStyle["marginTop"] = Array.isArray(v) ? v[0] : v; + animParams["marginTop"] = v; + } + if (customModal.marginRight !== undefined) { + const v = customModal.marginRight; + initialStyle["marginRight"] = Array.isArray(v) ? v[0] : v; + animParams["marginRight"] = v; + delete initialStyle["marginTop"]; + delete animParams["marginTop"]; + } + } + modalEl()?.setStyle(initialStyle); + + modalEl()?.animate({ + ...animParams, duration: modalAnimDuration, easing: "ease-out", fill: "forwards", @@ -177,9 +202,25 @@ export function AnimatedModal(props: AnimatedModalProps): JSXElement { // Modal animation if (animMode !== "none") { - modalEl()?.animate({ + const customModal = props.customAnimations?.hide?.modal; + const hideAnimParams: Record = { opacity: [1, 0], marginTop: ["0", "1rem"], + }; + if (customModal) { + if (customModal.opacity !== undefined) { + hideAnimParams["opacity"] = customModal.opacity; + } + if (customModal.marginTop !== undefined) { + hideAnimParams["marginTop"] = customModal.marginTop; + } + if (customModal.marginRight !== undefined) { + hideAnimParams["marginRight"] = customModal.marginRight; + delete hideAnimParams["marginTop"]; + } + } + modalEl()?.animate({ + ...hideAnimParams, duration: modalAnimDuration, }); diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index 259de81d786f..fcc50d0d756e 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -1,5 +1,6 @@ import { UseQueryResult } from "@tanstack/solid-query"; import { + Accessor, createMemo, ErrorBoundary, JSXElement, @@ -20,6 +21,11 @@ type AsyncEntry = { error?: () => unknown; }; +type Collection = Accessor & { + isLoading: boolean; + isError: boolean; +}; + type QueryMapping = Record | unknown; type AsyncMap = { [K in keyof T]: AsyncEntry; @@ -34,12 +40,18 @@ type BaseProps = { type QueryProps = { queries: { [K in keyof T]: UseQueryResult }; - query?: never; }; type SingleQueryProps = { query: UseQueryResult; - queries?: never; +}; + +type CollectionProps = { + collections: { [K in keyof T]: Collection }; +}; + +type SingleCollectionProps = { + collection: Collection; }; type DeferredChildren = { @@ -54,7 +66,12 @@ type EagerChildren = { }; export type Props = BaseProps & - (QueryProps | SingleQueryProps) & + ( + | QueryProps + | SingleQueryProps + | CollectionProps + | SingleCollectionProps + ) & (DeferredChildren | EagerChildren); export default function AsyncContent( @@ -62,10 +79,14 @@ export default function AsyncContent( ): JSXElement { //@ts-expect-error this is fine const source = createMemo>(() => { - if (props.query !== undefined) { + if ("query" in props) { return fromQueries({ defaultQuery: props.query }); - } else { + } else if ("queries" in props) { return fromQueries(props.queries); + } else if ("collection" in props) { + return fromCollections({ defaultQuery: props.collection }); + } else if ("collections" in props) { + return fromCollections(props.collections); } }); @@ -115,7 +136,7 @@ export default function AsyncContent( ?.error?.(); const loader = (): JSXElement => - props.loader ?? ; + props.loader ?? ; const errorText = (err: unknown): JSXElement | undefined => props.ignoreError ? undefined : ( @@ -167,3 +188,17 @@ function fromQueries>(queries: { return acc; }, {} as AsyncMap); } + +function fromCollections>(collections: { + [K in keyof T]: Collection; +}): AsyncMap { + return typedKeys(collections).reduce((acc, key) => { + const q = collections[key]; + acc[key] = { + value: () => q(), + isLoading: () => q.isLoading, + isError: () => q.isError, + }; + return acc; + }, {} as AsyncMap); +} diff --git a/frontend/src/ts/components/common/Headers.tsx b/frontend/src/ts/components/common/Headers.tsx index 95b2900007ea..9d2024453b75 100644 --- a/frontend/src/ts/components/common/Headers.tsx +++ b/frontend/src/ts/components/common/Headers.tsx @@ -13,7 +13,7 @@ export function H2(props: {

@@ -35,7 +35,10 @@ export function H3(props: { return (

{props.text} diff --git a/frontend/src/ts/components/layout/header/Nav.tsx b/frontend/src/ts/components/layout/header/Nav.tsx index 17d0240bdb93..c4284957236f 100644 --- a/frontend/src/ts/components/layout/header/Nav.tsx +++ b/frontend/src/ts/components/layout/header/Nav.tsx @@ -1,7 +1,6 @@ import { useQuery } from "@tanstack/solid-query"; import { createMemo, JSXElement, Show } from "solid-js"; -import { showAlerts } from "../../../elements/alerts"; import { createEffectOn } from "../../../hooks/effects"; import { getServerConfigurationQueryOptions } from "../../../queries/server-configuration"; import { getActivePage, getFocus } from "../../../signals/core"; @@ -10,6 +9,7 @@ import { getAnimatedLevel, setAnimatedLevel, } from "../../../signals/header"; +import { showModal } from "../../../stores/modals"; import { getSnapshot } from "../../../stores/snapshot"; import { restart } from "../../../test/test-logic"; import { cn } from "../../../utils/cn"; @@ -136,7 +136,7 @@ export function Nav(): JSXElement { "data-nav-item": "alerts", }} onClick={() => { - void showAlerts(); + showModal("Alerts"); }} class={cn(buttonClass(), "relative")} > diff --git a/frontend/src/ts/components/modals/DevOptionsModal.tsx b/frontend/src/ts/components/modals/DevOptionsModal.tsx index 3868fdbaa3a3..ed37fb978ab4 100644 --- a/frontend/src/ts/components/modals/DevOptionsModal.tsx +++ b/frontend/src/ts/components/modals/DevOptionsModal.tsx @@ -1,12 +1,14 @@ import { createSignal, For, JSXElement } from "solid-js"; import { envConfig } from "virtual:env-config"; +import Ape from "../../ape"; import { signIn } from "../../auth"; +import { inboxCollection } from "../../collections/inbox"; import { addXp } from "../../db"; import { getInputElement } from "../../input/input-element"; import { showPopup } from "../../modals/simple-modals"; import { showLoaderBar, hideLoaderBar } from "../../signals/loader-bar"; -import { hideModal } from "../../stores/modals"; +import { hideModal, showModal } from "../../stores/modals"; import { showNoticeNotification, showErrorNotification, @@ -139,6 +141,13 @@ export function DevOptionsModal(): JSXElement { hideModal("DevOptions"); }, }, + { + icon: "fa-inbox", + label: () => "Add Debug Inbox Item", + onClick: () => { + showModal("DevInboxPicker"); + }, + }, { icon: "fa-chart-bar", label: () => "Toggle Fake Chart Data", @@ -156,20 +165,60 @@ export function DevOptionsModal(): JSXElement { }, ]; + const addDebugInboxItem = (rewardType: "xp" | "badge" | "none"): void => { + hideModal("DevInboxPicker"); + void Ape.dev + .addDebugInboxItem({ body: { rewardType } }) + .then((response) => { + if (response.status !== 200) { + showErrorNotification("Failed to add inbox item", { + details: response.body, + }); + return; + } + showSuccessNotification("Debug inbox item added"); + void inboxCollection.utils.refetch(); + }); + }; + return ( - -
- - {(btn) => ( -
-
+ <> + +
+ + {(btn) => ( +
+
+ +
+
+
+ ); } diff --git a/frontend/src/ts/components/mount.tsx b/frontend/src/ts/components/mount.tsx index 8045be0f60fe..57da6b278ec3 100644 --- a/frontend/src/ts/components/mount.tsx +++ b/frontend/src/ts/components/mount.tsx @@ -15,6 +15,7 @@ import { MyProfile } from "./pages/account/MyProfile"; import { LeaderboardPage } from "./pages/leaderboard/LeaderboardPage"; import { ProfilePage } from "./pages/profile/ProfilePage"; import { ProfileSearchPage } from "./pages/profile/ProfileSearchPage"; +import { Popups } from "./popups/Popups"; const components: Record JSXElement> = { footer: () =>