diff --git a/frontend/__tests__/components/AsyncContent.spec.tsx b/frontend/__tests__/components/AsyncContent.spec.tsx index 0147be215dd3..2a042e72f841 100644 --- a/frontend/__tests__/components/AsyncContent.spec.tsx +++ b/frontend/__tests__/components/AsyncContent.spec.tsx @@ -3,25 +3,9 @@ import { createResource, Resource } from "solid-js"; import { describe, it, expect } from "vitest"; import AsyncContent from "../../src/ts/components/common/AsyncContent"; +import { AsyncStore, createAsyncStore } from "../../src/ts/hooks/asyncStore"; describe("AsyncContent", () => { - function renderWithResource( - resource: Resource, - errorMessage?: string, - ): { - container: HTMLElement; - } { - const { container } = render(() => ( - - {(data) =>
{String(data)}
} -
- )); - - return { - container, - }; - } - it("renders loading state while resource is pending", () => { const [resource] = createResource(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -76,4 +60,97 @@ describe("AsyncContent", () => { expect(screen.getByText(/An error occurred/)).toBeInTheDocument(); }); }); + + it("renders loading state while asyncStore is pending", () => { + const asyncStore = createAsyncStore<{ data?: string }>({ + name: "test", + fetcher: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return { data: "data" }; + }, + }); + + const { container } = renderWithAsyncStore(asyncStore); + + const preloader = container.querySelector(".preloader"); + expect(preloader).toBeInTheDocument(); + expect(preloader).toHaveClass("preloader"); + expect(preloader?.querySelector("i")).toHaveClass( + "fas", + "fa-fw", + "fa-spin", + "fa-circle-notch", + ); + }); + + it("renders data when asyncStore resolves", async () => { + const asyncStore = createAsyncStore<{ data?: string }>({ + name: "test", + fetcher: async () => { + return { data: "Test Data" }; + }, + autoLoad: () => true, + }); + + renderWithAsyncStore(asyncStore); + + await waitFor(() => { + expect(screen.getByTestId("content")).toHaveTextContent("Test Data"); + }); + }); + + it.skip("renders error message when asyncStore fails", async () => { + const asyncStore = createAsyncStore({ + name: "test", + fetcher: async () => { + throw new Error("Test error"); + }, + }); + + try { + renderWithAsyncStore(asyncStore as any, "Custom error message"); + } catch {} + + await expect(() => asyncStore.ready()).rejects; + await waitFor(() => { + expect( + screen.getByText(/Custom error message: Test error/), + ).toBeInTheDocument(); + }); + }); + + function renderWithResource( + resource: Resource, + errorMessage?: string, + ): { + container: HTMLElement; + } { + const { container } = render(() => ( + + {(data) =>
{String(data)}
} +
+ )); + + return { + container, + }; + } + + function renderWithAsyncStore( + asyncStore: AsyncStore<{ data?: string }>, + errorMessage?: string, + ): { + container: HTMLElement; + } { + asyncStore.load(); + const { container } = render(() => ( + + {(data) => {data?.data}} + + )); + + return { + container, + }; + } }); diff --git a/frontend/__tests__/hooks/asyncStore.spec.tsx b/frontend/__tests__/hooks/asyncStore.spec.tsx new file mode 100644 index 000000000000..2dd2c023863c --- /dev/null +++ b/frontend/__tests__/hooks/asyncStore.spec.tsx @@ -0,0 +1,339 @@ +import { render, waitFor } from "@solidjs/testing-library"; +import { For } from "solid-js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createAsyncArrayStore, + createAsyncStore, +} from "../../src/ts/hooks/asyncStore"; +import { sleep } from "../../src/ts/utils/misc"; + +const fetcher = vi.fn(); +const initialValue = vi.fn(() => ({ data: null })); + +describe("createAsyncStore", () => { + beforeEach(() => { + fetcher.mockClear(); + initialValue.mockClear(); + }); + + it("should initialize with the correct state", () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + + expect(store.state.state).toBe("unresolved"); + expect(store.state.loading).toBe(false); + expect(store.state.ready).toBe(false); + expect(store.state.refreshing).toBe(false); + expect(store.state.error).toBeUndefined(); + expect(store.store).toEqual({ data: null }); + }); + + it("should transition to loading when load is called", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + store.load(); + + expect(store.state.state).toBe("pending"); + expect(store.state.loading).toBe(true); + }); + + it("should enable loading if ready is called", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + + await store.ready(); + }); + + it("should call the fetcher when load is called", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + await store.ready(); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(store.state.state).toBe("ready"); + expect(store.store).toEqual({ data: "test" }); + }); + + it("should handle error when fetcher fails", async () => { + fetcher.mockRejectedValueOnce(new Error("Failed to load")); + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + + store.load(); + + await expect(store.ready()).rejects.toThrow("Failed to load"); + + expect(store.state.state).toBe("errored"); + expect(store.state.error).toEqual(new Error("Failed to load")); + }); + + it("should transition to refreshing state on refresh", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + store.refresh(); // trigger refresh + expect(store.state.state).toBe("refreshing"); + expect(store.state.refreshing).toBe(true); + }); + + it("should trigger load when refresh is called and shouldLoad is false", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + expect(store.state.state).toBe("unresolved"); + + store.refresh(); + expect(store.state.state).toBe("refreshing"); + expect(store.state.refreshing).toBe(true); + + // Wait for the store to be ready after fetching + await store.ready(); + + // Ensure the store's state is 'ready' after the refresh + expect(store.state.state).toBe("ready"); + expect(store.store).toEqual({ data: "test" }); + }); + + it("should reset the store to its initial value on reset", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + await store.ready(); + + expect(store.store).toEqual({ data: "test" }); + + store.reset(); + expect(store.state.state).toBe("unresolved"); + expect(store.state.loading).toBe(false); + expect(store.store).toEqual({ data: null }); + }); + + it("should persist changes", async () => { + const persist = vi.fn(); + persist.mockResolvedValue({}); + const store = createAsyncStore<{ data: string }>({ + name: "test", + fetcher, + persist, + }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + await store.ready(); + + store.update({ data: "newValue" }); + expect(persist).toHaveBeenCalledExactlyOnceWith({ data: "newValue" }); + expect(store.store?.data).toEqual("newValue"); + }); + + it("fails updating when not ready", async () => { + const store = createAsyncStore<{ data: string }>({ + name: "test", + fetcher, + }); + + expect(() => store.update({})).toThrowError( + "Store test cannot update in state unresolved", + ); + }); + + it("should refresh if persist fails", async () => { + const persist = vi.fn(); + persist.mockRejectedValue("no good"); + const store = createAsyncStore<{ data: string }>({ + name: "test", + fetcher, + persist, + }); + fetcher.mockResolvedValue({ data: "oldValue" }); + store.load(); + + await store.ready(); + + fetcher.mockClear().mockResolvedValue({ data: "refetchedValue" }); + store.update({ data: "newValue" }); + expect(persist).toHaveBeenCalledExactlyOnceWith({ data: "newValue" }); + + await sleep(100); + expect(store.store?.data).toEqual("refetchedValue"); + }); + + it("should be reactive", async () => { + const store = createAsyncStore<{ + data: string; + nested?: { number: number }; + list: string[]; + }>({ name: "test", fetcher }); + fetcher.mockResolvedValueOnce({ + data: "test", + nested: { number: 1 }, + list: ["Bob", "Kevin"], + }); + fetcher.mockResolvedValueOnce({ + data: "updated", + nested: { number: 2 }, + list: ["Bob", "Stuart"], + }); + + const { container } = render(() => ( + + State: {store.state.state}
+ Loading: {store.state.loading ? "true" : "false"}
+ Data: {store.store?.data ?? "empty"}
+ Number: {store.store?.nested?.number ?? "no number"}; List:{" "} + + {(item) => {item},} + +
+ )); + + //initial state + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: unresolved"); + expect(container.textContent).toContain("Number: no number"); + expect(container.textContent).toContain("List: no list"); + + //load + store.load(); + expect(container.textContent).toContain("Loading: true"); + expect(container.textContent).toContain("State: pending"); + expect(container.textContent).toContain("Data: empty"); + expect(container.textContent).toContain("Number: no number"); + expect(container.textContent).toContain("List: no list"); + + //resource loaded successful + await store.ready(); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: ready"); + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).toContain("Number: 1"); + expect(container.textContent).toContain("List: Bob,Kevin,"); + + //modify + store.update({ nested: { number: 3 } }); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: ready"); + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).toContain("Number: 3"); + expect(container.textContent).toContain("List: Bob,Kevin,"); + + //refresh + store.refresh(); + await store.ready(); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: ready"); + expect(container.textContent).toContain("Data: updated"); + expect(container.textContent).toContain("Number: 2"); + expect(container.textContent).toContain("List: Bob,Stuart,"); + + //reset back to initial state + store.reset(); + await waitFor(() => + store.state.state === "unresolved" ? true : undefined, + ); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: unresolved"); + expect(container.textContent).toContain("Number: no number"); + expect(container.textContent).toContain("List: no list"); + }); +}); + +describe("createAsyncArrayStore", () => { + beforeEach(() => { + fetcher.mockClear(); + initialValue.mockClear(); + }); + + it("should be reactive", async () => { + const store = createAsyncArrayStore<{ + data: string; + nested?: { number: number }; + list: string[]; + }>({ name: "test", fetcher }); + fetcher.mockResolvedValueOnce([ + { + data: "test", + nested: { number: 1 }, + list: ["Bob", "Kevin"], + }, + ]); + fetcher.mockResolvedValueOnce([ + { + data: "updated", + nested: { number: 2 }, + list: ["Bob", "Stuart"], + }, + ]); + + const { container } = render(() => ( + + State: {store.state.state}
+ Loading: {store.state.loading ? "true" : "false"}
+ + {(item) => ( + <> + Data: {item.data ?? "empty"}
+ Number: {item.nested?.number ?? "no number"}; List:{" "} + + {(li) => {li},} + + + )} +
+
+ )); + + //initial state + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: unresolved"); + expect(container.textContent).toContain("no items"); + + //load + store.load(); + await store.ready(); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: ready"); + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).toContain("Number: 1"); + expect(container.textContent).toContain("List: Bob,Kevin,"); + + //add item + store.addItem({ + data: "new", + nested: { number: 42 }, + list: ["apple", "banana"], + }); + + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).toContain("Number: 1"); + expect(container.textContent).toContain("List: Bob,Kevin,"); + expect(container.textContent).toContain("Data: new"); + expect(container.textContent).toContain("Number: 42"); + expect(container.textContent).toContain("List: apple,banana,"); + + //modify + store.updateItem((it) => it.data === "new", { + list: ["apple", "banana", "cherry"], + }); + expect(container.textContent).toContain("Data: new"); + expect(container.textContent).toContain("Number: 42"); + expect(container.textContent).toContain("List: apple,banana,cherry"); + + //remove + store.removeItem((it) => it.nested?.number === 42); + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).not.toContain("Data: new"); + expect(container.textContent).not.toContain("Number: 42"); + expect(container.textContent).not.toContain("List: apple,banana,cherry"); + + //reset back to initial state + store.reset(); + await waitFor(() => + store.state.state === "unresolved" ? true : undefined, + ); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: unresolved"); + expect(container.textContent).toContain("no items"); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index bac5f695da18..ca75942aa2cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,13 @@ "@sentry/browser": "9.14.0", "@sentry/vite-plugin": "3.3.1", "@solidjs/meta": "0.29.4", + "@tanstack/db": "0.5.23", + "@tanstack/query-core": "5.90.20", + "@tanstack/query-db-collection": "1.0.20", + "@tanstack/solid-db": "0.2.3", + "@tanstack/solid-query": "5.90.23", + "@tanstack/solid-query-devtools": "5.91.3", + "@tanstack/solid-table": "8.21.3", "@ts-rest/core": "3.52.1", "animejs": "4.2.2", "balloon-css": "1.2.0", diff --git a/frontend/src/html/pages/account-settings.html b/frontend/src/html/pages/account-settings.html index 6ef41fa45ad2..accdb2c12c25 100644 --- a/frontend/src/html/pages/account-settings.html +++ b/frontend/src/html/pages/account-settings.html @@ -246,25 +246,7 @@ + + + {(data) => ( + + You don‘t have any friends :( + + } + /> + )} + + + ); +} + +function getColumnDefinitions({ + removeFriend, +}: { + removeFriend: (id: string, name: string) => void; +}): DataTableColumnDef[] { + const defineColumn = createColumnHelper().accessor; + const columns = [ + defineColumn("name", { + header: (props) => ( + + ), + enableSorting: true, + cell: (info) => , + }), + + defineColumn("lastModified", { + header: (props) => ( + + ), + enableSorting: true, + cell: ({ getValue }) => + getValue() === undefined ? "-" : formatAge(getValue(), "short"), + meta: { + cellMeta: ({ value }) => + value === undefined + ? {} + : { + "data-balloon-pos": "down", + "aria-label": `since ${dateFormat(value, "dd MMM yyy HH:mm")}`, + }, + }, + }), + + defineColumn("xp", { + header: (props) => ( + + ), + enableSorting: true, + cell: ({ getValue }) => getXpDetails(getValue() ?? 0).level, + }), + + defineColumn("completedTests", { + header: (props) => ( + + ), + enableSorting: true, + cell: (info) => `${info.getValue()}/${info.row.original.startedTests}`, + meta: { + breakpoint: "lg", + cellMeta: ({ row }) => { + const testStats = formatTypingStatsRatio(row); + + return { + "data-balloon-pos": "down", + "aria-label": `${testStats.completedPercentage}% (${ + testStats.restartRatio + } restarts per completed test)`, + }; + }, + }, + }), + + defineColumn("timeTyping", { + header: (props) => ( + + ), + enableSorting: true, + cell: ({ getValue }) => + secondsToString(Math.round(getValue() ?? 0), true, true), + meta: { + breakpoint: "sm", + }, + }), + + defineColumn("streak.length", { + header: (props) => ( + + ), + enableSorting: true, + cell: ({ getValue }) => formatStreak(getValue()), + meta: { + breakpoint: "sm", + cellMeta: ({ row }) => { + const value = row.streak.maxLength as number | undefined; + return value === undefined + ? {} + : { + "data-balloon-pos": "down", + "aria-label": formatStreak(value, "longest streak"), + }; + }, + }, + }), + + defineColumn("top15.wpm", { + header: (props) => ( + + ), + enableSorting: true, + cell: (info) => { + const pb = formatPb(info.row.original.top15); + return ( + <> + {pb?.wpm ?? "-"} +
{pb?.acc ?? "-"}
+ + ); + }, + meta: { + breakpoint: "lg", + cellMeta: ({ row }) => ({ + "data-balloon-pos": "down", + "data-balloon-break": "", + "aria-label": formatPb(row.top15 as PersonalBest)?.details, + }), + }, + }), + + defineColumn("top60.wpm", { + header: (props) => ( + + ), + enableSorting: true, + cell: (info) => { + const pb = formatPb(info.row.original.top60); + return ( + <> + {pb?.wpm ?? "-"} +
{pb?.acc ?? "-"}
+ + ); + }, + meta: { + breakpoint: "lg", + cellMeta: ({ row }) => ({ + "data-balloon-pos": "down", + "data-balloon-break": "", + "aria-label": formatPb(row.top60)?.details, + }), + }, + }), + + defineColumn("connectionId", { + header: "", + cell: (info) => + //check the row is our own user + info.getValue() !== undefined ? ( +
+ + ); +} diff --git a/frontend/src/ts/constants/default-snapshot.ts b/frontend/src/ts/constants/default-snapshot.ts index 96b54209f405..06ad7340d411 100644 --- a/frontend/src/ts/constants/default-snapshot.ts +++ b/frontend/src/ts/constants/default-snapshot.ts @@ -14,7 +14,6 @@ import { } from "../elements/test-activity-calendar"; import { Preset } from "@monkeytype/schemas/presets"; import { Language } from "@monkeytype/schemas/languages"; -import { ConnectionStatus } from "@monkeytype/schemas/connections"; export type SnapshotUserTag = UserTag & { active?: boolean; @@ -85,7 +84,6 @@ export type Snapshot = Omit< xp: number; testActivity?: ModifiableTestActivityCalendar; testActivityByYear?: { [key: string]: TestActivityCalendar }; - connections: Record; }; export type SnapshotPreset = Preset & { @@ -133,7 +131,6 @@ const defaultSnap = { 60: { english: { count: 0, rank: 0 } }, }, }, - connections: {}, } as Snapshot; export function getDefaultSnapshot(): Snapshot { diff --git a/frontend/src/ts/controllers/badge-controller.ts b/frontend/src/ts/controllers/badge-controller.ts index 2532ab4ed4d8..5b556a868c12 100644 --- a/frontend/src/ts/controllers/badge-controller.ts +++ b/frontend/src/ts/controllers/badge-controller.ts @@ -1,22 +1,28 @@ -type UserBadge = { +import { JSX } from "solid-js/jsx-runtime"; +import { FaSolidIcon } from "../types/font-awesome"; + +export type UserBadge = { id: number; name: string; description: string; - icon?: string; + icon?: FaSolidIcon; background?: string; color?: string; - customStyle?: string; + customStyle?: JSX.CSSProperties; }; -const badges: Record = { +export const badges: Record = { 1: { id: 1, name: "Developer", description: "I made this", icon: "fa-laptop", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%)", + }, }, 2: { id: 2, @@ -24,8 +30,11 @@ const badges: Record = { description: "I helped make this", icon: "fa-code", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%)", + }, }, 3: { id: 3, @@ -33,8 +42,11 @@ const badges: Record = { description: "Discord server moderator", icon: "fa-hammer", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + }, }, 4: { id: 4, @@ -114,8 +126,11 @@ const badges: Record = { description: "Yes, I'm actually this fast", icon: "fa-rocket", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + }, }, 14: { id: 14, @@ -132,8 +147,11 @@ const badges: Record = { icon: "fa-bomb", color: "white", background: "#093d79", - customStyle: - "animation: gold-shimmer 10s cubic-bezier(0.5, 0, 0.5, 1) infinite; background: linear-gradient(90deg, rgb(8 31 84) 0%, rgb(18 134 158) 100%); background-size: 200% 200%;", + customStyle: { + animation: "gold-shimmer 10s cubic-bezier(0.5, 0, 0.5, 1) infinite", + background: + "linear-gradient(90deg, rgb(8 31 84) 0%, rgb(18 134 158) 100%); background-size: 200% 200%;", + }, }, 16: { id: 16, @@ -141,8 +159,12 @@ const badges: Record = { description: "Longest test with zero mistakes - 4 hours and 1 minute", icon: "fa-bullseye", color: "white", - customStyle: - "animation: gold-shimmer 10s cubic-bezier(0.5, -0.15, 0.5, 1.15) infinite; background: linear-gradient(45deg, #b8860b 0%, #daa520 25%, #ffd700 50%, #daa520 75%, #b8860b 100%); background-size: 200% 200%;", + customStyle: { + animation: + "gold-shimmer 10s cubic-bezier(0.5, -0.15, 0.5, 1.15) infinite", + background: + "linear-gradient(45deg, #b8860b 0%, #daa520 25%, #ffd700 50%, #daa520 75%, #b8860b 100%); background-size: 200% 200%;", + }, }, 17: { id: 17, @@ -150,8 +172,11 @@ const badges: Record = { description: "Ferb, I know what we're gonna do today...", icon: "fa-sun", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + }, }, }; @@ -175,7 +200,9 @@ export function getHTMLById( style += `color: ${badge.color};`; } if (badge?.customStyle !== undefined) { - style += badge.customStyle; + style += Object.entries(badge.customStyle) + .map(([key, value]) => `${key}: ${value};`) + .join(";"); } const badgeName = badge?.name ?? "Badge Name Missing"; diff --git a/frontend/src/ts/controllers/page-controller.ts b/frontend/src/ts/controllers/page-controller.ts index 112179c62dd3..3e51da8a9c1c 100644 --- a/frontend/src/ts/controllers/page-controller.ts +++ b/frontend/src/ts/controllers/page-controller.ts @@ -8,7 +8,6 @@ import * as PageLogin from "../pages/login"; import * as PageLoading from "../pages/loading"; import * as PageProfile from "../pages/profile"; import * as PageProfileSearch from "../pages/profile-search"; -import * as Friends from "../pages/friends"; import * as Page404 from "../pages/404"; import * as PageLeaderboards from "../pages/leaderboards"; import * as PageAccountSettings from "../pages/account-settings"; @@ -35,7 +34,7 @@ const pages = { login: PageLogin.page, profile: PageProfile.page, profileSearch: PageProfileSearch.page, - friends: Friends.page, + friends: solidPage("friends"), 404: Page404.page, accountSettings: PageAccountSettings.page, leaderboards: PageLeaderboards.page, @@ -298,7 +297,9 @@ export async function change( function solidPage(id: PageName, props?: { path?: string }): Page { const path = props?.path ?? `/${id}`; const internalId = `page${Strings.capitalizeFirstLetter(id)}`; - onDOMReady(() => Skeleton.save(internalId)); + onDOMReady(() => { + Skeleton.save(internalId); + }); return new Page({ id, path, diff --git a/frontend/src/ts/controllers/user-flag-controller.ts b/frontend/src/ts/controllers/user-flag-controller.ts index 790f83dcd8bc..58d588eb2c64 100644 --- a/frontend/src/ts/controllers/user-flag-controller.ts +++ b/frontend/src/ts/controllers/user-flag-controller.ts @@ -1,3 +1,5 @@ +import { FaSolidIcon } from "../types/font-awesome"; + const flags: UserFlag[] = [ { name: "Prime Ape", @@ -34,17 +36,16 @@ export type SupportsFlags = { isFriend?: boolean; }; -type UserFlag = { +export type UserFlag = { readonly name: string; readonly description: string; - readonly icon: string; + readonly icon: FaSolidIcon; readonly color?: string; readonly background?: string; - readonly customStyle?: string; test(source: SupportsFlags): boolean; }; -type UserFlagOptions = { +export type UserFlagOptions = { iconsOnly?: boolean; isFriend?: boolean; }; @@ -53,7 +54,7 @@ const USER_FLAG_OPTIONS_DEFAULT: UserFlagOptions = { iconsOnly: false, }; -function getMatchingFlags(source: SupportsFlags): UserFlag[] { +export function getMatchingFlags(source: SupportsFlags): UserFlag[] { const result = flags.filter((it) => it.test(source)); return result; } @@ -71,9 +72,6 @@ function toHtml(flag: UserFlag, formatOptions: UserFlagOptions): string { if (flag?.color !== undefined) { style.push(`color: ${flag.color};`); } - if (flag?.customStyle !== undefined) { - style.push(flag.customStyle); - } const balloon = `aria-label="${flag.description}" data-balloon-pos="right"`; diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 0e8d67363e0a..1fb8ba8fff9b 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -1,6 +1,6 @@ import Ape from "./ape"; import * as Notifications from "./elements/notifications"; -import { isAuthenticated, getAuthenticatedUser } from "./firebase"; +import { isAuthenticated } from "./firebase"; import * as ConnectionState from "./states/connection"; import { lastElementFromArray } from "./utils/arrays"; import { migrateConfig } from "./utils/config"; @@ -30,11 +30,7 @@ import { FunboxMetadata } from "../../../packages/funbox/src/types"; import { getFirstDayOfTheWeek } from "./utils/date-and-time"; import { Language } from "@monkeytype/schemas/languages"; import * as AuthEvent from "./observables/auth-event"; -import { - configurationPromise, - get as getServerConfiguration, -} from "./ape/server-configuration"; -import { Connection } from "@monkeytype/schemas/connections"; +import { configurationPromise } from "./ape/server-configuration"; let dbSnapshot: Snapshot | undefined; const firstDayOfTheWeek = getFirstDayOfTheWeek(); @@ -94,17 +90,11 @@ export async function initSnapshot(): Promise { try { if (!isAuthenticated()) return false; - const connectionsRequest = getServerConfiguration()?.connections.enabled - ? Ape.connections.get() - : { status: 200, body: { message: "", data: [] } }; - - const [userResponse, configResponse, presetsResponse, connectionsResponse] = - await Promise.all([ - Ape.users.get(), - Ape.configs.get(), - Ape.presets.get(), - connectionsRequest, - ]); + const [userResponse, configResponse, presetsResponse] = await Promise.all([ + Ape.users.get(), + Ape.configs.get(), + Ape.presets.get(), + ]); if (userResponse.status !== 200) { throw new SnapshotInitError( @@ -124,17 +114,10 @@ export async function initSnapshot(): Promise { presetsResponse.status, ); } - if (connectionsResponse.status !== 200) { - throw new SnapshotInitError( - `${connectionsResponse.body.message} (connections)`, - connectionsResponse.status, - ); - } const userData = userResponse.body.data; const configData = configResponse.body.data; const presetsData = presetsResponse.body.data; - const connectionsData = connectionsResponse.body.data; if (userData === null) { throw new SnapshotInitError( @@ -270,8 +253,6 @@ export async function initSnapshot(): Promise { ); } - snap.connections = convertConnections(connectionsData); - dbSnapshot = snap; return dbSnapshot; } catch (e) { @@ -1139,48 +1120,6 @@ export async function getTestActivityCalendar( return dbSnapshot.testActivityByYear[yearString]; } -export function mergeConnections(connections: Connection[]): void { - const snapshot = getSnapshot(); - if (!snapshot) return; - - const update = convertConnections(connections); - - for (const [key, value] of Object.entries(update)) { - snapshot.connections[key] = value; - } - - setSnapshot(snapshot); -} - -function convertConnections( - connectionsData: Connection[], -): Snapshot["connections"] { - return Object.fromEntries( - connectionsData.map((connection) => { - const isMyRequest = - getAuthenticatedUser()?.uid === connection.initiatorUid; - - return [ - isMyRequest ? connection.receiverUid : connection.initiatorUid, - connection.status === "pending" && !isMyRequest - ? "incoming" - : connection.status, - ]; - }), - ); -} - -export function isFriend(uid: string | undefined): boolean { - if (uid === undefined || uid === getAuthenticatedUser()?.uid) return false; - - const snapshot = getSnapshot(); - if (!snapshot) return false; - - return Object.entries(snapshot.connections).some( - ([receiverUid, status]) => receiverUid === uid && status === "accepted", - ); -} - // export async function DB.getLocalTagPB(tagId) { // function cont() { // let ret = 0; diff --git a/frontend/src/ts/elements/account-button.ts b/frontend/src/ts/elements/account-button.ts index 5d394ce75908..cbe487c76e9a 100644 --- a/frontend/src/ts/elements/account-button.ts +++ b/frontend/src/ts/elements/account-button.ts @@ -9,6 +9,7 @@ import { getAvatarElement } from "../utils/discord-avatar"; import * as AuthEvent from "../observables/auth-event"; import { getSnapshot } from "../db"; import { qsr } from "../utils/dom"; +import { pendingConnectionsQuery } from "../collections/connections"; const nav = qsr("header nav"); const accountButtonAndMenuEl = nav.qsr(".accountButtonAndMenu"); @@ -84,22 +85,15 @@ export function update(): void { } export function updateFriendRequestsIndicator(): void { - const friends = getSnapshot()?.connections; - const bubbleElements = accountButtonAndMenuEl.qsa( ".view-account > .notificationBubble, .goToFriends > .notificationBubble", ); - if (friends !== undefined) { - const pendingFriendRequests = Object.values(friends).filter( - (it) => it === "incoming", - ).length; - if (pendingFriendRequests > 0) { - bubbleElements.show(); - return; - } - } - bubbleElements.hide(); + if (pendingConnectionsQuery().length > 0) { + bubbleElements.show(); + } else { + bubbleElements.hide(); + } } const coarse = window.matchMedia("(pointer:coarse)")?.matches; diff --git a/frontend/src/ts/elements/account-settings/blocked-user-table.ts b/frontend/src/ts/elements/account-settings/blocked-user-table.ts deleted file mode 100644 index 819ed1b77748..000000000000 --- a/frontend/src/ts/elements/account-settings/blocked-user-table.ts +++ /dev/null @@ -1,105 +0,0 @@ -import * as Notifications from "../../elements/notifications"; -import { Connection } from "@monkeytype/schemas/connections"; -import Ape from "../../ape"; -import { format } from "date-fns/format"; -import { isAuthenticated } from "../../firebase"; -import { getReceiverUid } from "../../pages/friends"; -import * as DB from "../../db"; -import { updateFriendRequestsIndicator } from "../account-button"; -import { qsr } from "../../utils/dom"; - -let blockedUsers: Connection[] = []; -const element = qsr("#pageAccountSettings .tab[data-tab='blockedUsers']"); - -async function getData(): Promise { - showLoaderRow(); - - if (!isAuthenticated()) { - blockedUsers = []; - return false; - } - - const response = await Ape.connections.get({ - query: { status: "blocked", type: "incoming" }, - }); - - if (response.status !== 200) { - blockedUsers = []; - Notifications.add("Error getting blocked users", -1, { response }); - return false; - } - - blockedUsers = response.body.data; - return true; -} -export async function update(): Promise { - await getData(); - refreshList(); -} - -function showLoaderRow(): void { - const table = element.qs("table tbody"); - - table?.empty(); - table?.appendHtml( - "", - ); -} - -function refreshList(): void { - const table = element.qs("table tbody"); - table?.empty(); - if (blockedUsers.length === 0) { - table?.appendHtml( - "No blocked users", - ); - return; - } - const content = blockedUsers.map( - (blocked) => ` - - ${blocked.initiatorName} - ${format(new Date(blocked.lastModified), "dd MMM yyyy HH:mm")} - - - - - `, - ); - table?.appendHtml(content.join()); -} - -element.onChild("click", "table button.delete", async (e) => { - const row = (e.childTarget as HTMLElement).closest("tr") as HTMLElement; - const id = row?.dataset["id"]; - - if (id === undefined) { - throw new Error("Cannot find id of target."); - } - - row.querySelectorAll("button").forEach((button) => (button.disabled = true)); - - const response = await Ape.connections.delete({ params: { id } }); - if (response.status !== 200) { - Notifications.add(`Cannot unblock user: ${response.body.message}`, -1); - } else { - blockedUsers = blockedUsers.filter((it) => it._id !== id); - refreshList(); - - const snapshot = DB.getSnapshot(); - if (snapshot) { - const uid = row.dataset["uid"]; - if (uid === undefined) { - throw new Error("Cannot find uid of target."); - } - - // oxlint-disable-next-line no-dynamic-delete, no-unsafe-member-access - delete snapshot.connections[uid]; - updateFriendRequestsIndicator(); - } - } -}); diff --git a/frontend/src/ts/elements/profile.ts b/frontend/src/ts/elements/profile.ts index d45378c2a0c0..8169b2e240bb 100644 --- a/frontend/src/ts/elements/profile.ts +++ b/frontend/src/ts/elements/profile.ts @@ -21,6 +21,7 @@ import { formatXp } from "../utils/levels"; import { formatTopPercentage } from "../utils/misc"; import { get as getServerConfiguration } from "../ape/server-configuration"; import { qs } from "../utils/dom"; +import { findConnectionByUid, isFriend } from "../collections/connections"; type ProfileViewPaths = "profile" | "account"; type UserProfileOrSnapshot = UserProfile | Snapshot; @@ -73,11 +74,12 @@ export async function update( } details?.qs(".name")?.setText(profile.name); - details - ?.qs(".userFlags") - ?.setHtml( - getHtmlByUserFlags({ ...profile, isFriend: DB.isFriend(profile.uid) }), - ); + details?.qs(".userFlags")?.setHtml( + getHtmlByUserFlags({ + ...profile, + isFriend: profile.uid !== undefined && isFriend(profile.uid), + }), + ); if (profile.lbOptOut === true) { if (where === "profile") { @@ -442,7 +444,7 @@ export function updateNameFontSize(where: ProfileViewPaths): void { nameField.native.style.fontSize = `${finalFontSize}px`; } -export function updateFriendRequestButton(): void { +function updateFriendRequestButton(): void { const myUid = getAuthenticatedUser()?.uid; const profileUid = document .querySelector(".profile") @@ -450,7 +452,11 @@ export function updateFriendRequestButton(): void { const button = document.querySelector(".profile .addFriendButton"); const myProfile = myUid === profileUid; - const hasRequest = DB.getSnapshot()?.connections[profileUid] !== undefined; + const existingConnection = findConnectionByUid({ + initiatorUid: profileUid, + receiverUid: profileUid, + }); + const hasRequest = existingConnection !== undefined; const featureEnabled = getServerConfiguration()?.connections.enabled; if (!featureEnabled || myUid === undefined || myProfile) { diff --git a/frontend/src/ts/firebase.ts b/frontend/src/ts/firebase.ts index 85b562673397..26b8d6a7e6fc 100644 --- a/frontend/src/ts/firebase.ts +++ b/frontend/src/ts/firebase.ts @@ -35,6 +35,7 @@ import { import { tryCatch } from "@monkeytype/util/trycatch"; import { dispatch as dispatchSignUpEvent } from "./observables/google-sign-up-event"; import { addBanner } from "./stores/banners"; +import { getUserId, setUserId } from "./signals/core"; let app: FirebaseApp | undefined; let Auth: AuthType | undefined; @@ -75,6 +76,8 @@ export async function init(callback: ReadyCallback): Promise { onAuthStateChanged(Auth, async (user) => { if (!ignoreAuthCallback) { + console.log("###", user?.uid, getUserId()); + setUserId(user?.uid ?? null); await callback(true, user); } }); @@ -82,6 +85,7 @@ export async function init(callback: ReadyCallback): Promise { app = undefined; Auth = undefined; console.error("Firebase failed to initialize", e); + setUserId(null); await callback(false, null); if (isDevEnvironment()) { addBanner({ diff --git a/frontend/src/ts/hooks/asyncStore.ts b/frontend/src/ts/hooks/asyncStore.ts new file mode 100644 index 000000000000..1244ddf54373 --- /dev/null +++ b/frontend/src/ts/hooks/asyncStore.ts @@ -0,0 +1,340 @@ +import type { Accessor, Resource, Setter } from "solid-js"; +import { createEffect, createResource, createSignal } from "solid-js"; +import { + createStore, + produce, + reconcile, + SetStoreFunction, + Store, +} from "solid-js/store"; +import { promiseWithResolvers } from "../utils/misc"; +import { createEffectOn } from "./effects"; + +export type LoadError = Error | { message?: string }; + +type ValueWrapper = { + available: boolean; + value: T | undefined; +}; + +type State = + | { + state: "unresolved"; + loading: false; + ready: false; + refreshing: false; + error?: undefined; + } + | { + state: "pending"; + loading: true; + ready: false; + refreshing: false; + error?: undefined; + } + | { + state: "ready"; + loading: false; + ready: true; + refreshing: false; + error?: undefined; + } + | { + state: "refreshing"; + loading: true; + ready: true; + refreshing: true; + error?: undefined; + } + | { + state: "errored"; + loading: false; + ready: false; + refreshing: false; + error: LoadError; + }; + +export type AsyncStorePropertries = { + name: string; + fetcher: () => Promise; + persist?: (value: T) => Promise; + initialValue?: () => T; + autoLoad?: Accessor; +}; + +export function createAsyncStore( + props: AsyncStorePropertries, +): AsyncStore { + return new AsyncStore(props); +} + +export function createAsyncArrayStore( + props: AsyncStorePropertries, +): AsyncArrayStore { + return new AsyncArrayStore(props); +} + +export class AsyncStore { + private name: string; + private fetcher: () => Promise; + private persist?: (value: T) => Promise; + private initialValue?: () => T; + + private shouldLoad: Accessor; + private setShouldLoad: Setter; + + private getState: Accessor; + private setState: Setter; + + private res: Resource; + private refetch: () => void; + + protected _store: ValueWrapper; + protected setStore: SetStoreFunction>; + + private readyPromise = promiseWithResolvers(); + constructor({ + name, + fetcher, + persist, + initialValue, + autoLoad, + }: AsyncStorePropertries) { + console.debug(`AsyncStore ${name}: created`); + + this.name = name; + this.fetcher = fetcher; + this.persist = persist; + this.initialValue = initialValue; + + [this.shouldLoad, this.setShouldLoad] = createSignal(autoLoad?.() ?? false); + + [this.getState, this.setState] = createSignal({ + state: "unresolved", + loading: false, + ready: false, + refreshing: false, + error: undefined, + }); + + [this.res, { refetch: this.refetch }] = createResource( + this.shouldLoad, + async (load) => { + if (!load) return undefined as unknown as T; + return this.fetcher(); + }, + ); + + const initVal = this.initialValue?.(); + [this._store, this.setStore] = createStore>({ + available: initVal !== undefined, + value: initVal, + }); + + this.setupEffects(autoLoad); + } + + /** + * request store to be loaded + */ + load(): void { + if (!this.shouldLoad()) this.setShouldLoad(true); + } + + /** + * request store to be refreshed + */ + refresh(): void { + if (!this.shouldLoad()) { + this.setShouldLoad(true); + } + this.readyPromise.reset(); + this.updateState("refreshing"); + this.refetch(); + } + + /** + * reset the resource + store + */ + reset(): void { + this.setShouldLoad(false); + this.resetStore(); + this.updateState("unresolved"); + + const oldReady = this.readyPromise; + this.readyPromise = promiseWithResolvers(); + oldReady.promise.catch(() => { + /* */ + }); + oldReady.reject?.(new Error("Reset")); + } + + /** + * update store with the merged value + */ + update(value: Partial): void { + this.checkReady(); + this.setStore( + reconcile( + { + available: value !== undefined, + value: { ...this._store.value, ...value } as T, + }, + { merge: true }, + ), + ); + this.doPersist(); + } + + /** + * promise that resolves when the store is ready. + * rejects if shouldLoad is false + */ + async ready(): Promise { + this.load(); + return this.readyPromise.promise; + } + + get store(): Store { + return this._store.value; + } + get state(): State { + return this.getState(); + } + + get value(): Store { + return this._store.value; + } + + private resetStore(): void { + const fallbackValue = this.initialValue?.(); + this.setStore({ + available: fallbackValue !== undefined, + value: fallbackValue, + }); + } + + private updateState(state: State["state"], error?: LoadError): void { + console.debug(`AsyncStore ${this.name}: update state to ${state}.`); + this.setState({ + state, + loading: state === "pending", + ready: state === "ready", + refreshing: state === "refreshing", + error, + } as State); + } + + protected checkReady(): void { + if (!this.getState().ready) { + throw new Error( + `Store ${this.name} cannot update in state ${this.getState().state}`, + ); + } + } + + protected doPersist(): void { + if (this.persist && this._store.value !== undefined) { + void this.persist(this._store.value) + .then(() => console.debug(`Store ${this.name} persisted.`)) + .catch((error: unknown) => { + console.debug(`AsyncStore ${this.name}: persist failed with`, error); + this.refresh(); + }); + } + } + + private setupEffects(autoLoad?: Accessor): void { + createEffect(() => { + if (!this.shouldLoad()) return; + + if (this.res.error !== undefined) { + this.readyPromise.reject(this.res.error); + this.updateState(this.res.state, this.res.error as LoadError); + this.resetStore(); + return; + } + + const data = this.res(); + if (data !== undefined) { + this.updateState(this.res.state); + this.setStore(reconcile({ available: true, value: data })); + console.debug( + `AsyncStore ${this.name}: updated store to`, + this._store.value, + ); + this.readyPromise.resolve(data); + } + }); + + createEffect(() => { + if (!this.shouldLoad()) { + this.updateState("unresolved"); + return; + } + this.updateState("pending"); + }); + + if (autoLoad) { + createEffectOn(autoLoad, (val) => { + if (val !== undefined) this.setShouldLoad(val); + }); + } + } +} + +class AsyncArrayStore extends AsyncStore { + /** + * add item to the end of the array + * @param item + */ + addItem(item: T): void { + this.checkReady(); + this.setStore( + "value", + produce((items) => { + items?.push(item); + }), + ); + this.doPersist(); + } + + /** + * remove all items from the array matching the predicate + * @param predicate + */ + removeItem(predicate: (item: T) => boolean): void { + this.checkReady(); + + this.setStore( + reconcile({ + available: true, + value: this._store?.value?.filter((item) => !predicate(item)), + }), + ); + this.doPersist(); + } + + /** + * update all items in the array matching the predicate + * @param predicate + * @param updater + */ + updateItem(predicate: (item: T) => boolean, value: Partial): void { + this.checkReady(); + + const items = this._store.value; + if (!items) return; + + const index = items.findIndex(predicate); + if (index === -1) return; + + this.setStore( + "value", + index, + reconcile({ ...items[index], ...value } as T), + ); + + this.doPersist(); + } +} diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index c34f216d2928..bd455a9dc692 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -8,7 +8,6 @@ import Ape from "../ape"; import * as StreakHourOffsetModal from "../modals/streak-hour-offset"; import { showLoaderBar } from "../signals/loader-bar"; import * as ApeKeyTable from "../elements/account-settings/ape-key-table"; -import * as BlockedUserTable from "../elements/account-settings/blocked-user-table"; import * as Notifications from "../elements/notifications"; import { z } from "zod"; import * as AuthEvent from "../observables/auth-event"; @@ -153,7 +152,6 @@ export function updateUI(): void { updateIntegrationSections(); updateAccountSections(); void ApeKeyTable.update(updateUI); - void BlockedUserTable.update(); updateTabs(); page.setUrlParams(state); } diff --git a/frontend/src/ts/pages/friends.ts b/frontend/src/ts/pages/friends.ts deleted file mode 100644 index 0d3157f26899..000000000000 --- a/frontend/src/ts/pages/friends.ts +++ /dev/null @@ -1,577 +0,0 @@ -import Page from "./page"; -import * as Skeleton from "../utils/skeleton"; -import { SimpleModal } from "../utils/simple-modal"; -import Ape from "../ape"; -import { - intervalToDuration, - format as dateFormat, - formatDuration, - formatDistanceToNow, - format, -} from "date-fns"; -import * as Notifications from "../elements/notifications"; -import { isSafeNumber } from "@monkeytype/util/numbers"; -import { getHTMLById as getBadgeHTMLbyId } from "../controllers/badge-controller"; -import { formatXp, getXpDetails } from "../utils/levels"; -import { secondsToString } from "../utils/date-and-time"; -import { PersonalBest } from "@monkeytype/schemas/shared"; -import Format from "../utils/format"; -import { getHtmlByUserFlags } from "../controllers/user-flag-controller"; -import { SortedTable, SortSchema } from "../utils/sorted-table"; -import { getAvatarElement } from "../utils/discord-avatar"; -import { formatTypingStatsRatio } from "../utils/misc"; -import { getLanguageDisplayString } from "../utils/strings"; -import * as DB from "../db"; -import { getAuthenticatedUser } from "../firebase"; -import * as ServerConfiguration from "../ape/server-configuration"; -import * as AuthEvent from "../observables/auth-event"; -import { Connection } from "@monkeytype/schemas/connections"; -import { Friend, UserNameSchema } from "@monkeytype/schemas/users"; - -import { showLoaderBar, hideLoaderBar } from "../signals/loader-bar"; -import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; -import { remoteValidation } from "../utils/remote-validation"; -import { qs, qsr, onDOMReady } from "../utils/dom"; - -let friendsTable: SortedTable | undefined = undefined; - -let pendingRequests: Connection[] | undefined; -let friendsList: Friend[] | undefined; - -export function getReceiverUid( - connection: Pick, -): string { - const me = getAuthenticatedUser(); - if (me === null) { - throw new Error("expected to be authenticated in getReceiverUid"); - } - - if (me.uid === connection.initiatorUid) return connection.receiverUid; - return connection.initiatorUid; -} - -export async function addFriend(receiverName: string): Promise { - const result = await Ape.connections.create({ body: { receiverName } }); - - if (result.status !== 200) { - return `Friend request failed: ${result.body.message}`; - } else { - const snapshot = DB.getSnapshot(); - if (snapshot !== undefined) { - const receiverUid = getReceiverUid(result.body.data); - // oxlint-disable-next-line no-unsafe-member-access - snapshot.connections[receiverUid] = result.body.data.status; - updatePendingConnections(); - } - return true; - } -} - -const addFriendModal = new SimpleModal({ - id: "addFriend", - title: "Add a friend", - inputs: [ - { - placeholder: "user name", - type: "text", - initVal: "", - validation: { - schema: UserNameSchema, - isValid: remoteValidation( - async (name) => Ape.users.getNameAvailability({ params: { name } }), - { check: (data) => !data.available || "Unknown user" }, - ), - debounceDelay: 1000, - }, - }, - ], - buttonText: "request", - onlineOnly: true, - execFn: async (_thisPopup, receiverName) => { - const result = await addFriend(receiverName); - - if (result === true) { - return { status: 1, message: `Request sent to ${receiverName}` }; - } - - let status: -1 | 0 | 1 = -1; - let message: string = "Unknown error"; - - if (result.includes("already exists")) { - status = 0; - message = `You are already friends with ${receiverName}`; - } else if (result.includes("request already sent")) { - status = 0; - message = `You have already sent a friend request to ${receiverName}`; - } else if (result.includes("blocked by initiator")) { - status = 0; - message = `You have blocked ${receiverName}`; - } else if (result.includes("blocked by receiver")) { - status = 0; - message = `${receiverName} has blocked you`; - } - - return { status, message, alwaysHide: true }; - }, -}); - -const removeFriendModal = new SimpleModal({ - id: "confirmUnfriend", - title: "Remove friend", - buttonText: "remove friend", - text: "Are you sure you want to remove as a friend?", - beforeInitFn: (thisPopup) => { - thisPopup.text = `Are you sure you want to remove ${thisPopup.parameters[1]} as a friend?`; - }, - execFn: async (thisPopup) => { - const connectionId = thisPopup.parameters[0] as string; - const result = await Ape.connections.delete({ - params: { id: connectionId }, - }); - if (result.status !== 200) { - return { status: -1, message: result.body.message }; - } else { - friendsList = friendsList?.filter( - (it) => it.connectionId !== connectionId, - ); - friendsTable?.setData(friendsList ?? []); - friendsTable?.updateBody(); - return { status: 1, message: `Friend removed` }; - } - }, -}); - -async function fetchPendingConnections(): Promise { - const result = await Ape.connections.get({ - query: { status: "pending", type: "incoming" }, - }); - - if (result.status !== 200) { - Notifications.add("Error getting connections: " + result.body.message, -1); - pendingRequests = undefined; - } else { - pendingRequests = result.body.data; - DB.mergeConnections(pendingRequests); - } -} - -function updatePendingConnections(): void { - qs(".pageFriends .pendingRequests")?.hide(); - - if (pendingRequests === undefined || pendingRequests.length === 0) { - qs(".pageFriends .pendingRequests")?.hide(); - } else { - qs(".pageFriends .pendingRequests")?.show(); - - const html = pendingRequests - .map( - (item) => ` - ${item.initiatorName} - - - ${formatAge(item.lastModified)} ago - - - - - - - - `, - ) - .join("\n"); - - qs(".pageFriends .pendingRequests tbody")?.setHtml(html); - } -} - -async function fetchFriends(): Promise { - const result = await Ape.users.getFriends(); - if (result.status !== 200) { - Notifications.add("Error getting friends: " + result.body.message, -1); - friendsList = undefined; - } else { - friendsList = result.body.data; - } -} - -function updateFriends(): void { - qs(".pageFriends .friends .nodata")?.hide(); - qs(".pageFriends .friends table")?.hide(); - - qs(".pageFriends .friends .error")?.hide(); - - if (friendsList === undefined || friendsList.length === 0) { - qs(".pageFriends .friends table")?.hide(); - qs(".pageFriends .friends .nodata")?.show(); - } else { - qs(".pageFriends .friends table")?.show(); - qs(".pageFriends .friends .nodata")?.hide(); - - if (friendsTable === undefined) { - friendsTable = new SortedTable({ - table: qsr(".pageFriends .friends table"), - data: friendsList, - buildRow: buildFriendRow, - persistence: new LocalStorageWithSchema({ - key: "friendsListSort", - schema: SortSchema, - fallback: { property: "name", descending: false }, - }), - }); - } else { - friendsTable.setData(friendsList); - } - friendsTable.updateBody(); - } -} - -function buildFriendRow(entry: Friend): HTMLTableRowElement { - const xpDetails = getXpDetails(entry.xp ?? 0); - const testStats = formatTypingStatsRatio(entry); - - const top15 = formatPb(entry.top15); - const top60 = formatPb(entry.top60); - - const element = document.createElement("tr"); - element.dataset["connectionId"] = entry.connectionId; - - const isMe = entry.uid === getAuthenticatedUser()?.uid; - - let actions = ""; - if (isMe) { - element.classList.add("me"); - } else { - actions = ``; - } - element.innerHTML = ` - -
-
- ${ - entry.name - }
- ${getHtmlByUserFlags(entry)} - ${ - isSafeNumber(entry.badgeId) - ? getBadgeHTMLbyId(entry.badgeId) - : "" - } -
-
- - ${ - entry.lastModified !== undefined - ? formatAge(entry.lastModified, "short") - : "-" - } - - ${xpDetails.level} - - ${ - entry.completedTests - }/${entry.startedTests} - ${secondsToString( - Math.round(entry.timeTyping ?? 0), - true, - true, - )} - - ${formatStreak(entry.streak?.length)} - - ${ - top15?.wpm ?? "-" - }
${top15?.acc ?? "-"}
- ${ - top60?.wpm ?? "-" - }
${top60?.acc ?? "-"}
- - ${actions} - - - `; - - element - .querySelector(".avatarPlaceholder") - ?.replaceWith(getAvatarElement(entry)); - return element; -} - -function formatAge( - timestamp: number | undefined, - format?: "short" | "full", -): string { - if (timestamp === undefined) return ""; - let formatted = ""; - const duration = intervalToDuration({ start: timestamp, end: Date.now() }); - - if (format === undefined || format === "full") { - formatted = formatDuration(duration, { - format: ["years", "months", "days", "hours", "minutes"], - }); - } else { - formatted = formatDistanceToNow(timestamp); - } - - return formatted !== "" ? formatted : "less then a minute"; -} - -function formatPb(entry?: PersonalBest): - | { - wpm: string; - acc: string; - raw: string; - con: string; - details: string; - } - | undefined { - if (entry === undefined) { - return undefined; - } - const result = { - wpm: Format.typingSpeed(entry.wpm, { showDecimalPlaces: true }), - acc: Format.percentage(entry.acc, { showDecimalPlaces: true }), - raw: Format.typingSpeed(entry.raw, { showDecimalPlaces: true }), - con: Format.percentage(entry.consistency, { showDecimalPlaces: true }), - details: "", - }; - - const details = [ - `${getLanguageDisplayString(entry.language)}`, - `${result.wpm} wpm`, - ]; - - if (isSafeNumber(entry.acc)) { - details.push(`${result.acc} acc`); - } - if (isSafeNumber(entry.raw)) { - details.push(`${result.raw} raw`); - } - if (isSafeNumber(entry.consistency)) { - details.push(`${result.con} con`); - } - if (isSafeNumber(entry.timestamp)) { - details.push(`${dateFormat(entry.timestamp, "dd MMM yyyy")}`); - } - - result.details = details.join("\n"); - - return result; -} - -function formatStreak(length?: number, prefix?: string): string { - if (length === 1) return "-"; - return isSafeNumber(length) - ? `${prefix !== undefined ? prefix + " " : ""}${length} days` - : "-"; -} - -qs(".pageFriends button.friendAdd")?.on("click", () => { - addFriendModal.show(undefined, {}); -}); - -// need to set the listener for action buttons on the table because the table content is getting replaced -qs(".pageFriends .pendingRequests table")?.on("click", async (e) => { - const target = e.target as HTMLElement; - const action = Array.from(target.classList).find((it) => - ["accepted", "rejected", "blocked"].includes(it), - ) as "accepted" | "rejected" | "blocked"; - - if (action === undefined) return; - - const row = target.closest("tr") as HTMLElement; - const id = row.dataset["id"]; - if (id === undefined) { - throw new Error("Cannot find id of target."); - } - row.querySelectorAll("button").forEach((button) => (button.disabled = true)); - - showLoaderBar(); - const result = - action === "rejected" - ? await Ape.connections.delete({ - params: { id }, - }) - : await Ape.connections.update({ - params: { id }, - body: { status: action }, - }); - hideLoaderBar(); - - if (result.status !== 200) { - Notifications.add( - `Cannot update friend request: ${result.body.message}`, - -1, - ); - } else { - //remove from cache - pendingRequests = pendingRequests?.filter((it) => it._id !== id); - updatePendingConnections(); - - const snapshot = DB.getSnapshot(); - if (snapshot) { - const receiverUid = row.dataset["receiverUid"]; - if (receiverUid === undefined) { - throw new Error("Cannot find receiverUid of target."); - } - - if (action === "rejected") { - // oxlint-disable-next-line no-dynamic-delete, no-unsafe-member-access - delete snapshot.connections[receiverUid]; - } else { - snapshot.connections[receiverUid] = action; - } - DB.setSnapshot(snapshot); - } - - if (action === "blocked") { - Notifications.add(`User has been blocked`, 0); - } - if (action === "accepted") { - Notifications.add(`Request accepted`, 1); - } - if (action === "rejected") { - Notifications.add(`Request rejected`, 0); - } - - if (action === "accepted") { - showSpinner(); - await fetchFriends(); - updateFriends(); - hideSpinner(); - } - } -}); -// need to set the listener for action buttons on the table because the table content is getting replaced -qs(".pageFriends .friends table")?.on("click", async (e) => { - const target = e.target as HTMLElement; - const action = Array.from(target.classList).find((it) => - ["remove"].includes(it), - ); - - if (action === undefined) return; - - const row = target.closest("tr") as HTMLElement; - const connectionId = row.dataset["connectionId"]; - if (connectionId === undefined) { - throw new Error("Cannot find id of target."); - } - - if (action === "remove") { - const name = row.querySelector("a.entryName")?.textContent ?? ""; - - removeFriendModal.show([connectionId, name], {}); - } -}); - -function showSpinner(): void { - document.querySelector(".friends .spinner")?.classList.remove("hidden"); -} - -function hideSpinner(): void { - document.querySelector(".friends .spinner")?.classList.add("hidden"); -} - -function update(): void { - updatePendingConnections(); - updateFriends(); -} - -export const page = new Page({ - id: "friends", - display: "Friends", - element: qsr(".page.pageFriends"), - path: "/friends", - loadingOptions: { - loadingMode: () => { - if (!getAuthenticatedUser()) { - return "none"; - } - const hasCache = - friendsList !== undefined && pendingRequests !== undefined; - - if (hasCache) { - return { - mode: "async", - beforeLoading: showSpinner, - afterLoading: () => { - hideSpinner(); - update(); - }, - }; - } else { - return "sync"; - } - }, - - loadingPromise: async () => { - await ServerConfiguration.configurationPromise; - const serverConfig = ServerConfiguration.get(); - if (!serverConfig?.connections.enabled) { - throw new Error("Connectins are disabled."); - } - - await Promise.all([fetchPendingConnections(), fetchFriends()]); - }, - style: "bar", - keyframes: [ - { percentage: 50, durationMs: 1500, text: "Downloading friends..." }, - { - percentage: 50, - durationMs: 1500, - text: "Downloading friend requests...", - }, - ], - }, - - afterHide: async (): Promise => { - Skeleton.remove("pageFriends"); - }, - beforeShow: async (): Promise => { - Skeleton.append("pageFriends", "main"); - update(); - }, -}); - -onDOMReady(() => { - Skeleton.save("pageFriends"); -}); - -AuthEvent.subscribe((event) => { - if (event.type === "authStateChanged" && !event.data.isUserSignedIn) { - pendingRequests = undefined; - friendsList = undefined; - } -}); diff --git a/frontend/src/ts/pages/leaderboards.ts b/frontend/src/ts/pages/leaderboards.ts index 182e819c1ad6..e3607009f2f0 100644 --- a/frontend/src/ts/pages/leaderboards.ts +++ b/frontend/src/ts/pages/leaderboards.ts @@ -45,6 +45,7 @@ import { Mode, Mode2, ModeSchema } from "@monkeytype/schemas/shared"; import * as ServerConfiguration from "../ape/server-configuration"; import { getAvatarElement } from "../utils/discord-avatar"; import { qs, qsa, qsr, onDOMReady } from "../utils/dom"; +import { isFriend } from "../collections/connections"; const LeaderboardTypeSchema = z.enum(["allTime", "weekly", "daily"]); type LeaderboardType = z.infer; @@ -454,7 +455,7 @@ function buildTableRow(entry: LeaderboardEntry, me = false): HTMLElement {
${getHtmlByUserFlags({ ...entry, - isFriend: DB.isFriend(entry.uid), + isFriend: isFriend(entry.uid), })} ${ isSafeNumber(entry.badgeId) ? getBadgeHTMLbyId(entry.badgeId) : "" @@ -511,7 +512,7 @@ function buildWeeklyTableRow(
${getHtmlByUserFlags({ ...entry, - isFriend: DB.isFriend(entry.uid), + isFriend: isFriend(entry.uid), })} ${ isSafeNumber(entry.badgeId) ? getBadgeHTMLbyId(entry.badgeId) : "" diff --git a/frontend/src/ts/pages/profile.ts b/frontend/src/ts/pages/profile.ts index abe923649a42..9e7eb90370bd 100644 --- a/frontend/src/ts/pages/profile.ts +++ b/frontend/src/ts/pages/profile.ts @@ -11,8 +11,8 @@ import { PersonalBests } from "@monkeytype/schemas/shared"; import * as TestActivity from "../elements/test-activity"; import { TestActivityCalendar } from "../elements/test-activity-calendar"; import { getFirstDayOfTheWeek } from "../utils/date-and-time"; -import { addFriend } from "./friends"; import { onDOMReady, qs, qsr } from "../utils/dom"; +import { addConnection } from "../collections/connections"; const firstDayOfTheWeek = getFirstDayOfTheWeek(); @@ -254,14 +254,9 @@ qs(".page.pageProfile")?.onChild( const friendName = qs(".page.pageProfile .profile")?.getAttribute("name") ?? ""; - const result = await addFriend(friendName); - - if (result === true) { - Notifications.add(`Request sent to ${friendName}`); + await addConnection(friendName).then(() => { qs(".profile .details .addFriendButton")?.disable(); - } else { - Notifications.add(result, -1); - } + }); }, ); diff --git a/frontend/src/ts/signals/breakpoints.ts b/frontend/src/ts/signals/breakpoints.ts index e1664f25718c..d007aa935686 100644 --- a/frontend/src/ts/signals/breakpoints.ts +++ b/frontend/src/ts/signals/breakpoints.ts @@ -1,11 +1,11 @@ import { Accessor, createSignal, onCleanup } from "solid-js"; import { debounce } from "throttle-debounce"; -type BreakpointKeys = "xxl" | "xl" | "lg" | "md" | "sm" | "xs" | "xxs"; -type Breakpoints = Record; +export type BreakpointKey = "xxl" | "xl" | "lg" | "md" | "sm" | "xs" | "xxs"; +type Breakpoints = Record; const styles = getComputedStyle(document.documentElement); -const tw: Record = { +const tw: Record = { xxs: 0, xs: parseInt(styles.getPropertyValue("--breakpoint-xs")), sm: parseInt(styles.getPropertyValue("--breakpoint-sm")), @@ -18,7 +18,7 @@ const tw: Record = { export const bp = createBreakpoints(tw); function createBreakpoints( - breakpoints: Record, + breakpoints: Record, ): Accessor { const queries = Object.fromEntries( Object.entries(breakpoints).map(([key, px]) => [ @@ -49,5 +49,5 @@ function createBreakpoints( } }); - return matches as Accessor>; + return matches as Accessor>; } diff --git a/frontend/src/ts/signals/core.ts b/frontend/src/ts/signals/core.ts index f3c6127b08e0..edfc682c1b3a 100644 --- a/frontend/src/ts/signals/core.ts +++ b/frontend/src/ts/signals/core.ts @@ -1,7 +1,10 @@ -import { createSignal } from "solid-js"; +import { createMemo, createSignal } from "solid-js"; import { PageName } from "../pages/page"; -export const [getActivePage, setActivePage] = createSignal("loading"); +const [activePage, setActivePage] = createSignal("loading"); +export const getActivePage = createMemo(activePage); +export { setActivePage }; + export const [getVersion, setVersion] = createSignal<{ text: string; isNew: boolean; @@ -29,3 +32,8 @@ export const [getCommandlineSubgroup, setCommandlineSubgroup] = createSignal< export const [getFocus, setFocus] = createSignal(false); export const [getGlobalOffsetTop, setGlobalOffsetTop] = createSignal(0); export const [getIsScreenshotting, setIsScreenshotting] = createSignal(false); + +const [userId, setUserId] = createSignal(null); +export { setUserId }; +export const getUserId = createMemo(() => userId()); +export const isLoggedIn = createMemo(() => getUserId() !== null); diff --git a/frontend/src/ts/types/tanstack-table.d.ts b/frontend/src/ts/types/tanstack-table.d.ts new file mode 100644 index 000000000000..4a748e51cc0a --- /dev/null +++ b/frontend/src/ts/types/tanstack-table.d.ts @@ -0,0 +1,26 @@ +import "@tanstack/solid-table"; +import type { JSX } from "solid-js"; +import { BreakpointKey } from "../signals/breakpoints"; + +declare module "@tanstack/solid-table" { + //This needs to be an interface + // oxlint-disable-next-line typescript/consistent-type-definitions + interface ColumnMeta { + /** + * define minimal breakpoint for the column to be visible. + * If not set, the column is always visible + */ + breakpoint?: BreakpointKey; + + /** + * additional attributes to be set on the table cell. + * Can be used to define mouse-overs with `aria-label` and `data-balloon-pos` + */ + cellMeta?: + | JSX.HTMLAttributes + | ((ctx: { + value: TValue; + row: TData; + }) => JSX.HTMLAttributes); + } +} diff --git a/frontend/src/ts/utils/date-and-time.ts b/frontend/src/ts/utils/date-and-time.ts index a3f061b17753..9439e7a994a2 100644 --- a/frontend/src/ts/utils/date-and-time.ts +++ b/frontend/src/ts/utils/date-and-time.ts @@ -1,5 +1,8 @@ import { roundTo2 } from "@monkeytype/util/numbers"; import { Day } from "date-fns"; +import { formatDistanceToNow } from "date-fns/formatDistanceToNow"; +import { formatDuration } from "date-fns/formatDuration"; +import { intervalToDuration } from "date-fns/intervalToDuration"; /** * Converts seconds to a human-readable string representation of time. @@ -251,3 +254,22 @@ export function getFirstDayOfTheWeek(): Day { return 0; //start on sunday } + +export function formatAge( + timestamp: number | undefined, + format?: "short" | "full", +): string { + if (timestamp === undefined) return ""; + let formatted = ""; + const duration = intervalToDuration({ start: timestamp, end: Date.now() }); + + if (format === undefined || format === "full") { + formatted = formatDuration(duration, { + format: ["years", "months", "days", "hours", "minutes"], + }); + } else { + formatted = formatDistanceToNow(timestamp); + } + + return formatted !== "" ? formatted : "less then a minute"; +} diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 8666e9eece3a..f20fe0e6bf18 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -622,7 +622,11 @@ export function promiseWithResolvers(): { }; const reject = (reason?: unknown): void => { - innerReject(reason); + try { + innerReject(reason); + } catch (e) { + //ignore no awaits + } }; return { diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 467adc1eec01..c8efba5ed40c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -22,7 +22,8 @@ "./src/**/*.tsx", "./scripts/**/*.ts", "vite-plugins/**/*.ts", - "vite.config.ts" + "vite.config.ts", + "./src/types/**/*.d.ts" ], "exclude": ["node_modules", "build", "setup-tests.ts", "./__tests__/**/*.*"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25366c8d830d..39fd14f3ac0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,6 +282,27 @@ importers: '@solidjs/meta': specifier: 0.29.4 version: 0.29.4(solid-js@1.9.10) + '@tanstack/db': + specifier: 0.5.23 + version: 0.5.23(typescript@5.9.3) + '@tanstack/query-core': + specifier: 5.90.20 + version: 5.90.20 + '@tanstack/query-db-collection': + specifier: 1.0.20 + version: 1.0.20(@tanstack/query-core@5.90.20)(typescript@5.9.3) + '@tanstack/solid-db': + specifier: 0.2.3 + version: 0.2.3(solid-js@1.9.10)(typescript@5.9.3) + '@tanstack/solid-query': + specifier: 5.90.23 + version: 5.90.23(solid-js@1.9.10) + '@tanstack/solid-query-devtools': + specifier: 5.91.3 + version: 5.91.3(@tanstack/solid-query@5.90.23(solid-js@1.9.10))(solid-js@1.9.10) + '@tanstack/solid-table': + specifier: 8.21.3 + version: 8.21.3(solid-js@1.9.10) '@ts-rest/core': specifier: 3.52.1 version: 3.52.1(@types/node@24.9.1)(zod@3.23.8) @@ -417,7 +438,7 @@ importers: version: 5.0.2 '@vitest/coverage-v8': specifier: 4.0.15 - version: 4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) + version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.4.31) @@ -510,7 +531,7 @@ importers: version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) vitest: specifier: 4.0.15 - version: 4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + version: 4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) packages/contracts: dependencies: @@ -705,7 +726,7 @@ packages: resolution: {integrity: sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==} peerDependencies: openapi3-ts: ^2.0.0 || ^3.0.0 - zod: 3.23.8 + zod: ^3.20.0 '@apideck/better-ajv-errors@0.3.6': resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} @@ -757,10 +778,6 @@ packages: resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.25.0': - resolution: {integrity: sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} @@ -797,8 +814,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-define-polyfill-provider@0.6.5': - resolution: {integrity: sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==} + '@babel/helper-define-polyfill-provider@0.6.6': + resolution: {integrity: sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==} peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 @@ -1319,10 +1336,6 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.25.2': - resolution: {integrity: sha512-s4/r+a7xTnny2O6FcZzqgT6nE4/GHEdcqj4qAeglbUOh0TeglEfmNJFAd/OLoVtGd6ZhAO8GCVvCNUO5t/VJVQ==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.5': resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} @@ -3265,6 +3278,21 @@ packages: engines: {node: '>=8.10'} hasBin: true + '@solid-primitives/map@0.7.2': + resolution: {integrity: sha512-sXK/rS68B4oq3XXNyLrzVhLtT1pnimmMUahd2FqhtYUuyQsCfnW058ptO1s+lWc2k8F/3zQSNVkZ2ifJjlcNbQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/trigger@1.2.2': + resolution: {integrity: sha512-IWoptVc0SWYgmpBPpCMehS5b07+tpFcvw15tOQ3QbXedSYn6KP8zCjPkHNzMxcOvOicTneleeZDP7lqmz+PQ6g==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.3.2': + resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} + peerDependencies: + solid-js: ^1.6.12 + '@solidjs/meta@0.29.4': resolution: {integrity: sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g==} peerDependencies: @@ -3280,8 +3308,8 @@ packages: '@solidjs/router': optional: true - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@surma/rollup-plugin-off-main-thread@2.2.3': resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} @@ -3376,6 +3404,58 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/db-ivm@0.1.17': + resolution: {integrity: sha512-DK7vm56CDxNuRAdsbiPs+gITJ+16tUtYgZg3BRTLYKGIDsy8sdIO7sQFq5zl7Y+aIKAPmMAbVp9UjJ75FTtwgQ==} + peerDependencies: + typescript: '>=4.7' + + '@tanstack/db@0.5.23': + resolution: {integrity: sha512-/zO2K2hjkupL0DYHyPLnzEf3gFBlznUyZ+RLWBSFX4Dsf6jhjoGnAVZe6W25fZYyoZ5adb/PlhedHDqKO1u2rg==} + peerDependencies: + typescript: '>=4.7' + + '@tanstack/pacer-lite@0.2.1': + resolution: {integrity: sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ==} + engines: {node: '>=18'} + + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/query-db-collection@1.0.20': + resolution: {integrity: sha512-QA2Z8DPFPw+yklsevuXePGz46QmAKN1Mkz6lvdYtBwXHPMYM/DYLQH4wgy0Aigbp06Js7SIfHa7xcvWxGP4aUw==} + peerDependencies: + '@tanstack/query-core': ^5.0.0 + typescript: '>=4.7' + + '@tanstack/query-devtools@5.93.0': + resolution: {integrity: sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==} + + '@tanstack/solid-db@0.2.3': + resolution: {integrity: sha512-m0Y2qeKQOBYliXcxN3/oo2YC+cf87EPDA/he6O1oRWQh2Jtey05WZ9A1wuTCKRGk+KtE/fWhZN60kjg86cR25Q==} + peerDependencies: + solid-js: '>=1.9.0' + + '@tanstack/solid-query-devtools@5.91.3': + resolution: {integrity: sha512-xzVwIIxQPbiublZP3RkGp8KVjt8zenv5y1YTSRarP32mLUHJgfdofvjsDvMEhhL/lomz90qa0jCIGsSxoSTyYQ==} + peerDependencies: + '@tanstack/solid-query': ^5.90.23 + solid-js: ^1.6.0 + + '@tanstack/solid-query@5.90.23': + resolution: {integrity: sha512-pbZc4+Kgm7ktzIuu01R3KOWfazQKgNp4AZvW0RSvv+sNMpYoileUDAkXEcjDJe6RJmb3fVvTR4LlcSL5pxDElQ==} + peerDependencies: + solid-js: ^1.6.0 + + '@tanstack/solid-table@8.21.3': + resolution: {integrity: sha512-PmhfSLBxVKiFs01LtYOYrCRhCyTUjxmb4KlxRQiqcALtip8+DOJeeezQM4RSX/GUS0SMVHyH/dNboCpcO++k2A==} + engines: {node: '>=12'} + peerDependencies: + solid-js: '>=1.3' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -4061,8 +4141,8 @@ packages: peerDependencies: '@babel/core': ^7.20.12 - babel-plugin-polyfill-corejs2@0.4.14: - resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==} + babel-plugin-polyfill-corejs2@0.4.15: + resolution: {integrity: sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==} peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 @@ -4071,8 +4151,8 @@ packages: peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - babel-plugin-polyfill-regenerator@0.6.5: - resolution: {integrity: sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==} + babel-plugin-polyfill-regenerator@0.6.6: + resolution: {integrity: sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==} peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 @@ -4132,6 +4212,10 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + basic-auth-connect@1.0.0: resolution: {integrity: sha512-kiV+/DTgVro4aZifY/hwRwALBISViL5NP4aReaR2EVJEObpbUBHIkdJh/YpcoEiYt7nBodZ6U2ajZeZvSxUCCg==} @@ -4222,6 +4306,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + bson@6.8.0: resolution: {integrity: sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ==} engines: {node: '>=16.20.1'} @@ -4333,6 +4422,9 @@ packages: caniuse-lite@1.0.30001762: resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} + caniuse-lite@1.0.30001766: + resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + canvas-confetti@1.5.1: resolution: {integrity: sha512-Ncz+oZJP6OvY7ti4E1slxVlyAV/3g7H7oQtcCDXgwGgARxPnwYY9PW5Oe+I8uvspYNtuHviAdgA0LfcKFWJfpg==} @@ -4713,8 +4805,8 @@ packages: resolution: {integrity: sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==} engines: {node: '>= 0.8'} - core-js-compat@3.47.0: - resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} + core-js-compat@3.48.0: + resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==} core-js@3.37.1: resolution: {integrity: sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==} @@ -5168,6 +5260,9 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-to-chromium@1.5.283: + resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==} + electron-to-chromium@1.5.5: resolution: {integrity: sha512-QR7/A7ZkMS8tZuoftC/jfqNkZLQO779SSW3YuZHP4eXpj3EffGLFcB/Xu9AAZQzLccTiCV+EmUo3ha4mQ9wnlA==} @@ -5659,6 +5754,10 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -5841,10 +5940,6 @@ packages: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} engines: {node: '>=10'} - globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} - globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -6558,11 +6653,6 @@ packages: resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} engines: {node: '>= 10.16.0'} - jsesc@2.5.2: - resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} - engines: {node: '>=4'} - hasBin: true - jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -6899,6 +6989,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -8801,6 +8894,9 @@ packages: sort-any@2.0.0: resolution: {integrity: sha512-T9JoiDewQEmWcnmPn/s9h/PH9t3d/LSWi0RgVmXSuDYeZXTZOZ1/wrK2PHaptuR1VXe3clLLt0pD6sgVOwjNEA==} + sorted-btree@1.8.1: + resolution: {integrity: sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==} + source-map-js@1.2.0: resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} @@ -10205,14 +10301,14 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.27.1 - '@babel/generator': 7.25.0 + '@babel/generator': 7.28.6 '@babel/helper-compilation-targets': 7.25.2 '@babel/helper-module-transforms': 7.25.2(@babel/core@7.25.2) '@babel/helpers': 7.25.0 - '@babel/parser': 7.28.5 + '@babel/parser': 7.28.6 '@babel/template': 7.25.0 - '@babel/traverse': 7.25.2 - '@babel/types': 7.28.5 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 convert-source-map: 2.0.0 debug: 4.4.3 gensync: 1.0.0-beta.2 @@ -10261,17 +10357,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.25.0': - dependencies: - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 2.5.2 - '@babel/generator@7.28.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -10308,7 +10397,7 @@ snapshots: dependencies: '@babel/compat-data': 7.28.6 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.0 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 @@ -10332,7 +10421,7 @@ snapshots: regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.28.6)': + '@babel/helper-define-polyfill-provider@0.6.6(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 '@babel/helper-compilation-targets': 7.28.6 @@ -10354,19 +10443,19 @@ snapshots: '@babel/helper-module-imports@7.18.6': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 '@babel/helper-module-imports@7.24.7': dependencies: - '@babel/traverse': 7.25.2 - '@babel/types': 7.28.5 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.27.1': dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -10383,7 +10472,7 @@ snapshots: '@babel/helper-module-imports': 7.24.7 '@babel/helper-simple-access': 7.24.7 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.25.2 + '@babel/traverse': 7.28.6 transitivePeerDependencies: - supports-color @@ -10392,7 +10481,7 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-module-imports': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.28.6 transitivePeerDependencies: - supports-color @@ -10433,8 +10522,8 @@ snapshots: '@babel/helper-simple-access@7.24.7': dependencies: - '@babel/traverse': 7.25.2 - '@babel/types': 7.28.5 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -10463,13 +10552,13 @@ snapshots: '@babel/helpers@7.25.0': dependencies: - '@babel/template': 7.25.0 - '@babel/types': 7.28.5 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 '@babel/helpers@7.28.4': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 '@babel/helpers@7.28.6': dependencies: @@ -10478,7 +10567,7 @@ snapshots: '@babel/parser@7.28.5': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 '@babel/parser@7.28.6': dependencies: @@ -10945,10 +11034,10 @@ snapshots: '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.28.6) '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.28.6) '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.28.6) - babel-plugin-polyfill-corejs2: 0.4.14(@babel/core@7.28.6) + babel-plugin-polyfill-corejs2: 0.4.15(@babel/core@7.28.6) babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.28.6) - babel-plugin-polyfill-regenerator: 0.6.5(@babel/core@7.28.6) - core-js-compat: 3.47.0 + babel-plugin-polyfill-regenerator: 0.6.6(@babel/core@7.28.6) + core-js-compat: 3.48.0 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -10970,15 +11059,15 @@ snapshots: '@babel/template@7.25.0': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 '@babel/template@7.28.6': dependencies: @@ -10986,26 +11075,14 @@ snapshots: '@babel/parser': 7.28.6 '@babel/types': 7.28.6 - '@babel/traverse@7.25.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.25.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.25.0 - '@babel/types': 7.28.5 - debug: 4.4.3 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 + '@babel/generator': 7.28.6 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 + '@babel/parser': 7.28.6 '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -12978,6 +13055,20 @@ snapshots: ignore: 5.3.2 p-map: 4.0.0 + '@solid-primitives/map@0.7.2(solid-js@1.9.10)': + dependencies: + '@solid-primitives/trigger': 1.2.2(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/trigger@1.2.2(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + '@solidjs/meta@0.29.4(solid-js@1.9.10)': dependencies: solid-js: 1.9.10 @@ -12987,7 +13078,7 @@ snapshots: '@testing-library/dom': 10.4.1 solid-js: 1.9.10 - '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@surma/rollup-plugin-off-main-thread@2.2.3': dependencies: @@ -13064,6 +13155,58 @@ snapshots: tailwindcss: 4.1.18 vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + '@tanstack/db-ivm@0.1.17(typescript@5.9.3)': + dependencies: + fractional-indexing: 3.2.0 + sorted-btree: 1.8.1 + typescript: 5.9.3 + + '@tanstack/db@0.5.23(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db-ivm': 0.1.17(typescript@5.9.3) + '@tanstack/pacer-lite': 0.2.1 + typescript: 5.9.3 + + '@tanstack/pacer-lite@0.2.1': {} + + '@tanstack/query-core@5.90.20': {} + + '@tanstack/query-db-collection@1.0.20(@tanstack/query-core@5.90.20)(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db': 0.5.23(typescript@5.9.3) + '@tanstack/query-core': 5.90.20 + typescript: 5.9.3 + + '@tanstack/query-devtools@5.93.0': {} + + '@tanstack/solid-db@0.2.3(solid-js@1.9.10)(typescript@5.9.3)': + dependencies: + '@solid-primitives/map': 0.7.2(solid-js@1.9.10) + '@tanstack/db': 0.5.23(typescript@5.9.3) + solid-js: 1.9.10 + transitivePeerDependencies: + - typescript + + '@tanstack/solid-query-devtools@5.91.3(@tanstack/solid-query@5.90.23(solid-js@1.9.10))(solid-js@1.9.10)': + dependencies: + '@tanstack/query-devtools': 5.93.0 + '@tanstack/solid-query': 5.90.23(solid-js@1.9.10) + solid-js: 1.9.10 + + '@tanstack/solid-query@5.90.23(solid-js@1.9.10)': + dependencies: + '@tanstack/query-core': 5.90.20 + solid-js: 1.9.10 + + '@tanstack/solid-table@8.21.3(solid-js@1.9.10)': + dependencies: + '@tanstack/table-core': 8.21.3 + solid-js: 1.9.10 + + '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -13146,16 +13289,16 @@ snapshots: '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 '@types/bcrypt@5.0.2': dependencies: @@ -13497,26 +13640,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1))': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.15 - ast-v8-to-istanbul: 0.3.8 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magicast: 0.5.1 - obug: 2.1.1 - std-env: 3.10.0 - tinyrainbow: 3.0.3 - vitest: 4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) - transitivePeerDependencies: - - supports-color - '@vitest/expect@4.0.15': dependencies: - '@standard-schema/spec': 1.0.0 + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 '@vitest/spy': 4.0.15 '@vitest/utils': 4.0.15 @@ -13563,7 +13689,7 @@ snapshots: '@vue/compiler-core@3.4.37': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.28.6 '@vue/shared': 3.4.37 entities: 5.0.0 estree-walker: 2.0.2 @@ -13576,7 +13702,7 @@ snapshots: '@vue/compiler-sfc@3.4.37': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.28.6 '@vue/compiler-core': 3.4.37 '@vue/compiler-dom': 3.4.37 '@vue/compiler-ssr': 3.4.37 @@ -13878,15 +14004,15 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-module-imports': 7.18.6 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 html-entities: 2.3.3 parse5: 7.1.2 - babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.6): + babel-plugin-polyfill-corejs2@0.4.15(@babel/core@7.28.6): dependencies: '@babel/compat-data': 7.28.6 '@babel/core': 7.28.6 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.6) + '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.28.6) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -13894,15 +14020,15 @@ snapshots: babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.28.6): dependencies: '@babel/core': 7.28.6 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.6) - core-js-compat: 3.47.0 + '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.28.6) + core-js-compat: 3.48.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.5(@babel/core@7.28.6): + babel-plugin-polyfill-regenerator@0.6.6(@babel/core@7.28.6): dependencies: '@babel/core': 7.28.6 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.6) + '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.28.6) transitivePeerDependencies: - supports-color @@ -13948,6 +14074,8 @@ snapshots: baseline-browser-mapping@2.9.11: {} + baseline-browser-mapping@2.9.19: {} + basic-auth-connect@1.0.0: {} basic-auth@2.0.1: @@ -14093,6 +14221,14 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.0) + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001766 + electron-to-chromium: 1.5.283 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + bson@6.8.0: {} buffer-crc32@1.0.0: {} @@ -14218,6 +14354,8 @@ snapshots: caniuse-lite@1.0.30001762: {} + caniuse-lite@1.0.30001766: {} + canvas-confetti@1.5.1: {} chai@6.2.1: {} @@ -14609,9 +14747,9 @@ snapshots: depd: 2.0.0 keygrip: 1.1.0 - core-js-compat@3.47.0: + core-js-compat@3.48.0: dependencies: - browserslist: 4.28.0 + browserslist: 4.28.1 core-js@3.37.1: {} @@ -15066,6 +15204,8 @@ snapshots: electron-to-chromium@1.5.267: {} + electron-to-chromium@1.5.283: {} + electron-to-chromium@1.5.5: {} emoji-regex@8.0.0: {} @@ -15927,6 +16067,8 @@ snapshots: fraction.js@4.3.7: {} + fractional-indexing@3.2.0: {} + fresh@0.5.2: {} fresh@2.0.0: {} @@ -16163,8 +16305,6 @@ snapshots: dependencies: ini: 2.0.0 - globals@11.12.0: {} - globals@14.0.0: {} globals@15.15.0: {} @@ -16931,8 +17071,6 @@ snapshots: jsep@1.4.0: {} - jsesc@2.5.2: {} - jsesc@3.1.0: {} json-bigint@1.0.0: @@ -17266,6 +17404,8 @@ snapshots: lodash@4.17.21: {} + lodash@4.17.23: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -18076,7 +18216,7 @@ snapshots: node-source-walk@7.0.0: dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.28.6 nodemailer@7.0.11: {} @@ -18400,14 +18540,14 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.28.6 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 parse-json@8.3.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.28.6 index-to-position: 1.2.0 type-fest: 4.41.0 @@ -19556,6 +19696,8 @@ snapshots: dependencies: lodash: 4.17.21 + sorted-btree@1.8.1: {} + source-map-js@1.2.0: {} source-map-js@1.2.1: {} @@ -20441,6 +20583,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + update-notifier-cjs@5.1.6(encoding@0.1.13): dependencies: boxen: 5.1.2 @@ -20694,45 +20842,6 @@ snapshots: - tsx - yaml - vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1): - dependencies: - '@vitest/expect': 4.0.15 - '@vitest/mocker': 4.0.15(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) - '@vitest/pretty-format': 4.0.15 - '@vitest/runner': 4.0.15 - '@vitest/snapshot': 4.0.15 - '@vitest/spy': 4.0.15 - '@vitest/utils': 4.0.15 - es-module-lexer: 1.7.0 - expect-type: 1.2.2 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.9.1 - happy-dom: 20.0.10 - jsdom: 27.4.0 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - vlq@0.2.3: {} w3c-xmlserializer@5.0.0: @@ -20932,7 +21041,7 @@ snapshots: fast-json-stable-stringify: 2.1.0 fs-extra: 9.1.0 glob: 7.2.3 - lodash: 4.17.21 + lodash: 4.17.23 pretty-bytes: 5.6.0 rollup: 2.79.2 source-map: 0.8.0-beta.0