diff --git a/__tests__/components/user/brain/UserPageBrainActivity.test.tsx b/__tests__/components/user/brain/UserPageBrainActivity.test.tsx new file mode 100644 index 0000000000..9f7e380267 --- /dev/null +++ b/__tests__/components/user/brain/UserPageBrainActivity.test.tsx @@ -0,0 +1,158 @@ +import { render, screen } from "@testing-library/react"; +import UserPageBrainActivity from "@/components/user/brain/UserPageBrainActivity"; +import { useIdentityActivity } from "@/hooks/useIdentityActivity"; +import { Time } from "@/helpers/time"; + +jest.mock("@/hooks/useIdentityActivity", () => ({ + useIdentityActivity: jest.fn(), +})); + +const mockedUseIdentityActivity = useIdentityActivity as jest.MockedFunction< + typeof useIdentityActivity +>; +const DAY_MS = Time.days(1).toMillis(); + +function buildActivityResponse() { + const lastDate = new Date(Date.UTC(2026, 2, 16)); + const startMs = lastDate.getTime() - 364 * DAY_MS; + + return { + last_date: "16.03.2026", + date_samples: Array.from({ length: 365 }, (_, index) => { + const isoDate = new Date(startMs + index * DAY_MS) + .toISOString() + .slice(0, 10); + + switch (isoDate) { + case "2026-01-01": + return 1; + case "2026-02-01": + return 2; + case "2026-03-03": + return 3; + case "2026-03-04": + return 4; + case "2026-03-16": + return 2; + default: + return 0; + } + }), + }; +} + +describe("UserPageBrainActivity", () => { + beforeEach(() => { + mockedUseIdentityActivity.mockReset(); + }); + + it("renders a loading skeleton", () => { + mockedUseIdentityActivity.mockReturnValue({ + status: "pending", + data: undefined, + } as any); + + render( + + ); + + expect(screen.getByTestId("brain-activity-card")).toBeInTheDocument(); + expect( + screen.getByLabelText("Loading activity heatmap") + ).toBeInTheDocument(); + }); + + it("renders a compact error state", () => { + mockedUseIdentityActivity.mockReturnValue({ + status: "error", + error: new Error("boom"), + data: undefined, + } as any); + + render( + + ); + + expect(screen.getByText("Unable to load activity.")).toBeInTheDocument(); + }); + + it("renders the activity bars and summary", () => { + mockedUseIdentityActivity.mockReturnValue({ + status: "success", + data: buildActivityResponse(), + } as any); + + render( + + ); + + expect(mockedUseIdentityActivity).toHaveBeenCalledWith({ + identity: "alice", + enabled: true, + }); + expect( + screen.getByText("12 public posts in the last 12 months") + ).toBeInTheDocument(); + expect( + screen.getByRole("img", { + name: "Public activity heatmap for the last 12 months", + }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("img", { name: "Jan 1, 2026: 1 public post" }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("img", { name: "Mar 16, 2026: 2 public posts" }) + ).not.toBeInTheDocument(); + expect(screen.getByText("Jan")).toBeInTheDocument(); + expect(screen.getByText("Feb")).toBeInTheDocument(); + expect(screen.getAllByText("Mar")).not.toHaveLength(0); + }); + + it("renders the empty activity state", () => { + mockedUseIdentityActivity.mockReturnValue({ + status: "success", + data: { + last_date: "16.03.2026", + date_samples: Array.from({ length: 365 }, () => 0), + }, + } as any); + + render( + + ); + + expect( + screen.getByText("No activity in last 12 months.") + ).toBeInTheDocument(); + expect( + screen.getByText("0 public posts in the last 12 months") + ).toBeInTheDocument(); + }); + + it("falls back to the primary wallet when the handle is missing", () => { + mockedUseIdentityActivity.mockReturnValue({ + status: "success", + data: buildActivityResponse(), + } as any); + + render( + + ); + + expect(mockedUseIdentityActivity).toHaveBeenCalledWith({ + identity: "0xabc", + enabled: true, + }); + }); +}); diff --git a/__tests__/components/user/brain/UserPageDrops.test.tsx b/__tests__/components/user/brain/UserPageDrops.test.tsx index 7ab13f3541..1bfb5c7345 100644 --- a/__tests__/components/user/brain/UserPageDrops.test.tsx +++ b/__tests__/components/user/brain/UserPageDrops.test.tsx @@ -5,6 +5,10 @@ jest.mock("@/components/drops/view/Drops", () => ({ __esModule: true, default: () =>
, })); +jest.mock("@/components/user/brain/UserPageBrainActivity", () => ({ + __esModule: true, + default: () =>
, +})); jest.mock("@/components/user/brain/UserPageBrainSidebar", () => ({ __esModule: true, default: () =>
, @@ -15,12 +19,14 @@ describe("UserPageDrops", () => { const { getByTestId } = render( ); + expect(getByTestId("brain-activity-card")).toBeInTheDocument(); expect(getByTestId("drops")).toBeInTheDocument(); expect(getByTestId("brain-sidebar")).toBeInTheDocument(); }); it("hides Drops when no profile handle", () => { const { queryByTestId } = render(); + expect(queryByTestId("brain-activity-card")).toBeNull(); expect(queryByTestId("drops")).toBeNull(); expect(queryByTestId("brain-sidebar")).toBeNull(); }); diff --git a/__tests__/components/user/brain/userPageBrainActivity.helpers.test.ts b/__tests__/components/user/brain/userPageBrainActivity.helpers.test.ts new file mode 100644 index 0000000000..35384accdd --- /dev/null +++ b/__tests__/components/user/brain/userPageBrainActivity.helpers.test.ts @@ -0,0 +1,218 @@ +import { + buildUserPageBrainActivityViewModel, + type UserPageBrainActivityResponse, +} from "@/components/user/brain/userPageBrainActivity.helpers"; +import { Time } from "@/helpers/time"; + +const DAY_MS = Time.days(1).toMillis(); + +function formatBackendDate(date: Date): string { + return [ + date.getUTCDate().toString().padStart(2, "0"), + (date.getUTCMonth() + 1).toString().padStart(2, "0"), + date.getUTCFullYear().toString(), + ].join("."); +} + +function buildResponse({ + lastDate, + countsByIsoDate, +}: { + lastDate: Date; + countsByIsoDate?: Record | undefined; +}): UserPageBrainActivityResponse { + const startMs = lastDate.getTime() - 364 * DAY_MS; + + return { + last_date: formatBackendDate(lastDate), + date_samples: Array.from({ length: 365 }, (_, index) => { + const isoDate = new Date(startMs + index * DAY_MS) + .toISOString() + .slice(0, 10); + return countsByIsoDate?.[isoDate] ?? 0; + }), + }; +} + +describe("buildUserPageBrainActivityViewModel", () => { + it("builds a heatmap view model from the activity response", () => { + const activity = buildUserPageBrainActivityViewModel( + buildResponse({ + lastDate: new Date(Date.UTC(2026, 2, 16)), + countsByIsoDate: { + "2026-01-01": 1, + "2026-01-15": 3, + "2026-02-01": 8, + "2026-03-03": 20, + "2026-03-04": 50, + }, + }) + ); + + expect(activity).not.toBeNull(); + expect(activity?.periodLabel).toBe("the last 12 months"); + expect(activity?.totalDrops).toBe(82); + expect(activity?.weekCount).toBeGreaterThan(0); + expect(activity?.monthLabels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: "Jan" }), + expect.objectContaining({ label: "Feb" }), + expect.objectContaining({ label: "Mar" }), + ]) + ); + expect(activity?.cells.length).toBe((activity?.weekCount ?? 0) * 7); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-01-01") + ).toMatchObject({ + count: 1, + state: "active", + intensity: 1, + ariaLabel: "Jan 1, 2026: 1 public post", + }); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-01-15") + ).toMatchObject({ + count: 3, + intensity: 1, + }); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-02-01") + ).toMatchObject({ + count: 8, + intensity: 2, + }); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-03") + ).toMatchObject({ + count: 20, + intensity: 3, + }); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-04") + ).toMatchObject({ + count: 50, + intensity: 4, + }); + }); + + it("uses per-profile quantile buckets for active-day intensity", () => { + const activity = buildUserPageBrainActivityViewModel( + buildResponse({ + lastDate: new Date(Date.UTC(2026, 2, 16)), + countsByIsoDate: { + "2026-03-03": 1, + "2026-03-04": 2, + "2026-03-05": 3, + "2026-03-06": 7, + "2026-03-07": 8, + "2026-03-08": 19, + "2026-03-09": 20, + "2026-03-10": 49, + }, + }) + ); + + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-03")?.intensity + ).toBe(1); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-04")?.intensity + ).toBe(1); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-05")?.intensity + ).toBe(2); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-06")?.intensity + ).toBe(2); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-07")?.intensity + ).toBe(3); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-08")?.intensity + ).toBe(3); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-09")?.intensity + ).toBe(4); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-10")?.intensity + ).toBe(4); + }); + + it("scales intensity relative to each profile rather than global thresholds", () => { + const activity = buildUserPageBrainActivityViewModel( + buildResponse({ + lastDate: new Date(Date.UTC(2026, 2, 16)), + countsByIsoDate: { + "2026-03-10": 1, + "2026-03-11": 20, + "2026-03-12": 155, + "2026-03-13": 500, + }, + }) + ); + + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-10")?.intensity + ).toBe(1); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-11")?.intensity + ).toBe(2); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-12")?.intensity + ).toBe(3); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-13")?.intensity + ).toBe(4); + }); + + it("keeps the brightest bucket reachable for sparse profiles", () => { + const activity = buildUserPageBrainActivityViewModel( + buildResponse({ + lastDate: new Date(Date.UTC(2026, 2, 16)), + countsByIsoDate: { + "2026-03-14": 1, + "2026-03-15": 20, + "2026-03-16": 500, + }, + }) + ); + + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-14")?.intensity + ).toBe(1); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-15")?.intensity + ).toBe(2); + expect( + activity?.cells.find((cell) => cell.isoDate === "2026-03-16")?.intensity + ).toBe(4); + }); + + it("returns null for an invalid anchor date", () => { + expect( + buildUserPageBrainActivityViewModel({ + last_date: "2026-03-16", + date_samples: [1, 2, 3], + }) + ).toBeNull(); + }); + + it("keeps empty years renderable", () => { + const activity = buildUserPageBrainActivityViewModel( + buildResponse({ + lastDate: new Date(Date.UTC(2026, 1, 28)), + }) + ); + + expect(activity?.totalDrops).toBe(0); + expect(activity?.weekCount).toBeGreaterThan(0); + expect(activity?.cells.length).toBe( + activity?.weekCount ? activity.weekCount * 7 : 0 + ); + expect( + activity?.cells.every((cell) => + cell.state === "padding" ? true : cell.state === "empty" + ) + ).toBe(true); + }); +}); diff --git a/__tests__/components/user/brain/userPageBrainActivityHeatmap.viewport.test.tsx b/__tests__/components/user/brain/userPageBrainActivityHeatmap.viewport.test.tsx new file mode 100644 index 0000000000..2c275bb490 --- /dev/null +++ b/__tests__/components/user/brain/userPageBrainActivityHeatmap.viewport.test.tsx @@ -0,0 +1,35 @@ +import { act, renderHook } from "@testing-library/react"; +import { useHeatmapViewport } from "@/components/user/brain/userPageBrainActivityHeatmap.viewport"; + +describe("useHeatmapViewport", () => { + it("snaps to the true max scroll position so the latest column stays visible", () => { + const { result, rerender } = renderHook( + ({ resetKey }: { resetKey: string }) => useHeatmapViewport(resetKey), + { + initialProps: { + resetKey: "profile-a", + }, + } + ); + + const viewport = document.createElement("div"); + const content = document.createElement("div"); + viewport.appendChild(content); + + Object.defineProperty(viewport, "clientWidth", { + configurable: true, + value: 101, + }); + Object.defineProperty(viewport, "scrollWidth", { + configurable: true, + value: 140, + }); + + act(() => { + result.current.viewportRef(viewport); + rerender({ resetKey: "profile-b" }); + }); + + expect(viewport.scrollLeft).toBe(39); + }); +}); diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx index 3a187c50dc..cdf0a7d694 100644 --- a/components/react-query-wrapper/ReactQueryWrapper.tsx +++ b/components/react-query-wrapper/ReactQueryWrapper.tsx @@ -56,6 +56,7 @@ export enum QueryKey { IDENTITY_TDH_STATS = "IDENTITY_TDH_STATS", GLOBAL_TDH_STATS = "GLOBAL_TDH_STATS", IDENTITY_AVAILABLE_CREDIT = "IDENTITY_AVAILABLE_CREDIT", + IDENTITY_ACTIVITY = "IDENTITY_ACTIVITY", IDENTITY_FOLLOWING_ACTIONS = "IDENTITY_FOLLOWING_ACTIONS", IDENTITY_FOLLOWERS = "IDENTITY_FOLLOWERS", IDENTITY_NOTIFICATIONS = "IDENTITY_NOTIFICATIONS", diff --git a/components/user/brain/UserPageBrainActivity.tsx b/components/user/brain/UserPageBrainActivity.tsx new file mode 100644 index 0000000000..26d050b4c9 --- /dev/null +++ b/components/user/brain/UserPageBrainActivity.tsx @@ -0,0 +1,106 @@ +"use client"; + +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import { numberWithCommas } from "@/helpers/Helpers"; +import { useIdentityActivity } from "@/hooks/useIdentityActivity"; +import type { ReactNode } from "react"; +import { buildUserPageBrainActivityViewModel } from "./userPageBrainActivity.helpers"; +import { + UserPageBrainActivityHeatmap, + UserPageBrainActivityHeatmapLoading, +} from "./UserPageBrainActivityHeatmap"; +import { getProfileWaveIdentity } from "./userPageBrainSidebar.helpers"; + +function ActivityCardFrame({ + children, +}: Readonly<{ + children: ReactNode; +}>) { + return ( +
+ {children} +
+ ); +} + +function ActivityCardHeader({ + periodLabel, + totalDrops, +}: Readonly<{ + periodLabel?: string | undefined; + totalDrops?: number | undefined; +}>) { + const totalDropsLabel = totalDrops === 0 ? "0" : numberWithCommas(totalDrops); + + return ( +
+
+

+ Activity +

+
+ {periodLabel && totalDrops !== undefined && ( +
+ {totalDropsLabel} public post + {totalDrops === 1 ? "" : "s"} in {periodLabel} +
+ )} +
+ ); +} + +export default function UserPageBrainActivity({ + profile, +}: { + readonly profile: ApiIdentity; +}) { + const identity = getProfileWaveIdentity(profile); + const activityQuery = useIdentityActivity({ + identity, + enabled: identity.length > 0, + }); + + if (!identity) { + return null; + } + + const activity = activityQuery.data + ? buildUserPageBrainActivityViewModel(activityQuery.data) + : null; + const periodLabel = activity?.periodLabel; + const totalDrops = activity?.totalDrops; + + let content: ReactNode; + + if (activityQuery.status === "pending") { + content = ; + } else if (activityQuery.status === "error") { + content = ( +

+ Unable to load activity. +

+ ); + } else if (activity && activity.totalDrops > 0) { + content = ; + } else { + content = ( +

+ No activity in last 12 months. +

+ ); + } + + return ( + + + {content} + + ); +} diff --git a/components/user/brain/UserPageBrainActivityHeatmap.tsx b/components/user/brain/UserPageBrainActivityHeatmap.tsx new file mode 100644 index 0000000000..61ee421cdc --- /dev/null +++ b/components/user/brain/UserPageBrainActivityHeatmap.tsx @@ -0,0 +1,225 @@ +"use client"; + +import clsx from "clsx"; +import { useId } from "react"; +import { Tooltip } from "react-tooltip"; +import type { + UserPageBrainActivityCell, + UserPageBrainActivityViewModel, +} from "./userPageBrainActivity.helpers"; +import { + CELL_GAP_PX, + CELL_FRAME_STYLE, + CELL_SIZE_PX, + DAY_LABEL_COLUMN_WIDTH_PX, + DAY_LABEL_TOP_OFFSET_PX, + DAY_LABELS, + HEATMAP_CONTENT_CLASS_NAME, + HEATMAP_GRID_HEIGHT_PX, + HEATMAP_GRID_STYLE, + HEATMAP_VIEWPORT_CLASS_NAME, + LOADING_MONTH_HEADER_SEGMENTS, + MONTH_LABEL_MIN_SPACING_PX, + MONTH_LABEL_ROW_HEIGHT_PX, + TOOLTIP_DATE_FORMATTER, + TOOLTIP_STYLE, + type HeatmapTooltipData, + getCellFrameClassName, + getCellNodeClassName, + getCellTooltipAnchorProps, + getHeatmapTooltipData, + getPlacedMonthLabels, + getTooltipCountLabel, +} from "./userPageBrainActivityHeatmap.helpers"; +import { useHeatmapViewport } from "./userPageBrainActivityHeatmap.viewport"; + +export function UserPageBrainActivityHeatmapLoading() { + return ( +
+ +
+ + +
+ ); +} + +export function UserPageBrainActivityHeatmap({ + activity, +}: Readonly<{ + activity: UserPageBrainActivityViewModel; +}>) { + const tooltipId = useId(); + const { viewportRef, viewportMetrics } = useHeatmapViewport( + activity.resetKey + ); + + return ( +
+ +
+ +
+
+
+ {activity.cells.map((cell) => ( + + ))} +
+ { + const tooltipData = getHeatmapTooltipData(activeAnchor); + return tooltipData ? ( + + ) : null; + }} + /> +
+
+
+
+ ); +} + +function HeatmapDayLabels() { + return ( + + ); +} + +function HeatmapCell({ + cell, + tooltipId, +}: Readonly<{ + cell: UserPageBrainActivityCell; + tooltipId: string; +}>) { + return ( +
+ {cell.state !== "padding" && ( +
+ )} +
+ ); +} + +function HeatmapTooltipContent({ + count, + isoDate, + intensity, +}: HeatmapTooltipData) { + const indicatorCell: UserPageBrainActivityCell = { + key: "tooltip", + isoDate, + count, + ariaLabel: null, + state: count > 0 ? "active" : "empty", + intensity, + }; + + return ( +
+ + + + + {getTooltipCountLabel(count)} + + + + {TOOLTIP_DATE_FORMATTER.format(new Date(`${isoDate}T00:00:00.000Z`))} + +
+ ); +} + +function HeatmapMonthLabels({ + activity, + scrollLeft, + clientWidth, +}: Readonly<{ + activity: UserPageBrainActivityViewModel; + scrollLeft: number; + clientWidth: number; +}>) { + const placedMonthLabels = getPlacedMonthLabels( + activity.monthLabels, + scrollLeft, + clientWidth + ); + + return ( + + ); +} diff --git a/components/user/brain/UserPageBrainSidebarWaveItem.tsx b/components/user/brain/UserPageBrainSidebarWaveItem.tsx index 9fa26f6a90..8d914ce4e9 100644 --- a/components/user/brain/UserPageBrainSidebarWaveItem.tsx +++ b/components/user/brain/UserPageBrainSidebarWaveItem.tsx @@ -69,7 +69,7 @@ export default function UserPageBrainSidebarWaveItem({
diff --git a/components/user/brain/UserPageDrops.tsx b/components/user/brain/UserPageDrops.tsx index ab647f7fc8..3b2b688b9d 100644 --- a/components/user/brain/UserPageDrops.tsx +++ b/components/user/brain/UserPageDrops.tsx @@ -1,6 +1,7 @@ import Drops from "@/components/drops/view/Drops"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import type { ReactNode } from "react"; +import UserPageBrainActivity from "./UserPageBrainActivity"; import UserPageBrainSidebar from "./UserPageBrainSidebar"; export default function UserPageDrops({ @@ -16,7 +17,8 @@ export default function UserPageDrops({ content = (
-
+
+ {haveProfile && }
diff --git a/components/user/brain/userPageBrainActivity.helpers.ts b/components/user/brain/userPageBrainActivity.helpers.ts new file mode 100644 index 0000000000..be8f4023cb --- /dev/null +++ b/components/user/brain/userPageBrainActivity.helpers.ts @@ -0,0 +1,267 @@ +import { numberWithCommas } from "@/helpers/Helpers"; +import { Time } from "@/helpers/time"; + +const DAY_MS = Time.days(1).toMillis(); +const WEEKDAY_COUNT = 7; +const MONTH_LABEL_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "short", + timeZone: "UTC", +}); +const DAY_LABEL_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", +}); + +export interface UserPageBrainActivityResponse { + readonly last_date: string; + readonly date_samples: readonly number[]; +} + +type UserPageBrainActivityCellState = "padding" | "empty" | "active"; +type UserPageBrainActivityIntensity = 0 | 1 | 2 | 3 | 4; + +export interface UserPageBrainActivityCell { + readonly key: string; + readonly isoDate: string | null; + readonly count: number; + readonly ariaLabel: string | null; + readonly state: UserPageBrainActivityCellState; + readonly intensity: UserPageBrainActivityIntensity; +} + +interface UserPageBrainActivityMonthLabel { + readonly key: string; + readonly label: string; + readonly labelColumn: number; + readonly firstVisibleColumn: number; + readonly lastVisibleColumn: number; +} + +export interface UserPageBrainActivityViewModel { + readonly resetKey: string; + readonly periodLabel: string; + readonly totalDrops: number; + readonly weekCount: number; + readonly monthLabels: readonly UserPageBrainActivityMonthLabel[]; + readonly cells: readonly UserPageBrainActivityCell[]; +} + +function parseUtcDate(value: string): Date | null { + const [dayPart, monthPart, yearPart] = value.split("."); + const day = Number(dayPart); + const month = Number(monthPart); + const year = Number(yearPart); + + if ( + !Number.isInteger(day) || + !Number.isInteger(month) || + !Number.isInteger(year) || + day <= 0 || + month <= 0 || + month > 12 + ) { + return null; + } + + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + return null; + } + + return date; +} + +function toIsoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function getCountLabel(count: number): string { + return count === 1 + ? "1 public post" + : `${numberWithCommas(count)} public posts`; +} + +function getQuantileValue( + sortedCounts: readonly number[], + percentile: number +): number { + if (sortedCounts.length === 0) { + return 0; + } + + const clampedPercentile = Math.min(1, Math.max(0, percentile)); + const rank = Math.max( + 0, + Math.ceil(clampedPercentile * sortedCounts.length) - 1 + ); + return sortedCounts[rank] ?? 0; +} + +function getIntensity( + count: number, + thresholds: Readonly<{ + first: number; + second: number; + third: number; + max: number; + }> +): UserPageBrainActivityIntensity { + if (count <= 0) { + return 0; + } + + if (count >= thresholds.max) { + return 4; + } + + if (count <= thresholds.first) { + return 1; + } + if (count <= thresholds.second) { + return 2; + } + if (count <= thresholds.third) { + return 3; + } + return 4; +} + +function getSamplesSignature(samples: readonly number[]): string { + let hash = 0; + + for (const sample of samples) { + hash = (hash * 31 + sample) >>> 0; + } + + return hash.toString(36); +} + +export function buildUserPageBrainActivityViewModel( + activity: UserPageBrainActivityResponse +): UserPageBrainActivityViewModel | null { + if ( + !Array.isArray(activity.date_samples) || + activity.date_samples.length === 0 + ) { + return null; + } + + const lastDate = parseUtcDate(activity.last_date); + if (!lastDate) { + return null; + } + + const normalizedSamples = activity.date_samples.map((sample) => + typeof sample === "number" && Number.isFinite(sample) && sample >= 0 + ? Math.floor(sample) + : 0 + ); + const activeCounts = normalizedSamples + .filter((sample) => sample > 0) + .sort((left, right) => left - right); + const intensityThresholds = { + first: getQuantileValue(activeCounts, 0.25), + second: getQuantileValue(activeCounts, 0.5), + third: getQuantileValue(activeCounts, 0.75), + max: activeCounts.at(-1) ?? 0, + } as const; + const startDate = new Date( + lastDate.getTime() - (normalizedSamples.length - 1) * DAY_MS + ); + const samples = normalizedSamples.map((count, index) => ({ + date: new Date(startDate.getTime() + index * DAY_MS), + count, + })); + + const totalDrops = samples.reduce((sum, sample) => sum + sample.count, 0); + const leadingPaddingCount = startDate.getUTCDay(); + const trailingPaddingCount = + (WEEKDAY_COUNT - ((leadingPaddingCount + samples.length) % WEEKDAY_COUNT)) % + WEEKDAY_COUNT; + const monthColumns = new Map< + string, + { + readonly key: string; + readonly label: string; + preferredLabelColumn: number; + firstVisibleColumn: number; + lastVisibleColumn: number; + } + >(); + + const cells: UserPageBrainActivityCell[] = Array.from( + { length: leadingPaddingCount }, + (_, index) => ({ + key: `padding-start-${index}`, + isoDate: null, + count: 0, + ariaLabel: null, + state: "padding", + intensity: 0, + }) + ); + + samples.forEach(({ date, count }, index) => { + const isoDate = toIsoDate(date); + const monthKey = `${date.getUTCFullYear()}-${date.getUTCMonth()}`; + const weekIndex = Math.floor((leadingPaddingCount + index) / WEEKDAY_COUNT); + const labelStartColumn = weekIndex + (date.getUTCDay() === 0 ? 0 : 1); + + const existingMonth = monthColumns.get(monthKey); + if (existingMonth) { + existingMonth.lastVisibleColumn = weekIndex; + } else { + monthColumns.set(monthKey, { + key: `month-${monthKey}`, + label: MONTH_LABEL_FORMATTER.format(date), + preferredLabelColumn: labelStartColumn, + firstVisibleColumn: weekIndex, + lastVisibleColumn: weekIndex, + }); + } + + const ariaLabel = `${DAY_LABEL_FORMATTER.format(date)}: ${getCountLabel(count)}`; + cells.push({ + key: isoDate, + isoDate, + count, + ariaLabel, + state: count > 0 ? "active" : "empty", + intensity: getIntensity(count, intensityThresholds), + }); + }); + + for (let index = 0; index < trailingPaddingCount; index += 1) { + cells.push({ + key: `padding-end-${index}`, + isoDate: null, + count: 0, + ariaLabel: null, + state: "padding", + intensity: 0, + }); + } + + const monthLabels = Array.from(monthColumns.values()).map((month) => ({ + key: month.key, + label: month.label, + labelColumn: Math.min(month.preferredLabelColumn, month.lastVisibleColumn), + firstVisibleColumn: month.firstVisibleColumn, + lastVisibleColumn: month.lastVisibleColumn, + })); + + return { + resetKey: `${toIsoDate(lastDate)}:${getSamplesSignature(normalizedSamples)}`, + periodLabel: "the last 12 months", + totalDrops, + weekCount: cells.length / WEEKDAY_COUNT, + monthLabels, + cells, + }; +} diff --git a/components/user/brain/userPageBrainActivityHeatmap.helpers.ts b/components/user/brain/userPageBrainActivityHeatmap.helpers.ts new file mode 100644 index 0000000000..e26b4a38de --- /dev/null +++ b/components/user/brain/userPageBrainActivityHeatmap.helpers.ts @@ -0,0 +1,237 @@ +import clsx from "clsx"; +import type { + UserPageBrainActivityCell, + UserPageBrainActivityViewModel, +} from "./userPageBrainActivity.helpers"; + +export type HeatmapTooltipData = Readonly<{ + count: number; + isoDate: string; + intensity: UserPageBrainActivityCell["intensity"]; +}>; + +type HeatmapMonthLabel = UserPageBrainActivityViewModel["monthLabels"][number]; +type HeatmapNodeVariant = "grid" | "tooltip"; +type PlacedMonthLabel = Readonly<{ + key: string; + label: string; + leftPx: number; +}>; + +// Layout and sizing +export const CELL_SIZE_PX = 14; +export const CELL_GAP_PX = 2; +export const DAY_LABEL_COLUMN_WIDTH_PX = 30; +export const MONTH_LABEL_ROW_HEIGHT_PX = 12; +const HEADER_TO_HEATMAP_GAP_PX = 4; +const DAY_LABEL_EXTRA_OFFSET_PX = 2; +export const DAY_LABEL_TOP_OFFSET_PX = + MONTH_LABEL_ROW_HEIGHT_PX + + HEADER_TO_HEATMAP_GAP_PX + + DAY_LABEL_EXTRA_OFFSET_PX; +export const HEATMAP_GRID_HEIGHT_PX = CELL_SIZE_PX * 7 + CELL_GAP_PX * 6; +const COLUMN_STRIDE_PX = CELL_SIZE_PX + CELL_GAP_PX; +export const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""] as const; +export const LOADING_MONTH_HEADER_SEGMENTS = [ + { key: "start", widthPx: 18 }, + { key: "spring", widthPx: 24 }, + { key: "early-summer", widthPx: 16 }, + { key: "late-summer", widthPx: 22 }, + { key: "autumn", widthPx: 20 }, + { key: "winter-start", widthPx: 18 }, + { key: "winter-mid", widthPx: 24 }, + { key: "end", widthPx: 16 }, +] as const; +export const MONTH_LABEL_MIN_SPACING_PX = 34; +const MONTH_LABEL_OVERFLOW_TOLERANCE_PX = 18; +export const TOOLTIP_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", +}); +export const TOOLTIP_STYLE = { + padding: "0", + background: "transparent", + boxShadow: "none", + zIndex: 99999, + pointerEvents: "none", +} as const; +export const HEATMAP_VIEWPORT_CLASS_NAME = + "tw-[scrollbar-gutter:stable] tw-flex-1 tw-overflow-x-auto tw-overflow-y-hidden tw-pb-3 tw-scrollbar-thin tw-scrollbar-track-transparent tw-scrollbar-thumb-iron-700/60 desktop-hover:hover:tw-scrollbar-thumb-iron-600/80"; +export const HEATMAP_CONTENT_CLASS_NAME = "tw-inline-flex tw-pr-2"; +export const HEATMAP_GRID_STYLE = { + gridTemplateRows: `repeat(7, ${CELL_SIZE_PX}px)`, + gridAutoColumns: `${CELL_SIZE_PX}px`, + gap: `${CELL_GAP_PX}px`, +} as const; +export const CELL_FRAME_STYLE = { + width: `${CELL_SIZE_PX}px`, + height: `${CELL_SIZE_PX}px`, +} as const; + +// Cell presentation +const CELL_NODE_CLASS_NAMES: Readonly< + Record< + HeatmapNodeVariant, + Readonly> + > +> = { + grid: { + 0: "tw-h-1 tw-w-1 tw-rounded-[1px] tw-bg-[#1d2229] desktop-hover:group-hover/cell:tw-bg-[#272d35]", + 1: "tw-h-[6px] tw-w-[6px] tw-rounded-[1px] tw-bg-[#064e3b] desktop-hover:group-hover/cell:tw-scale-110 desktop-hover:group-hover/cell:tw-brightness-105", + 2: "tw-h-[7px] tw-w-[7px] tw-rounded-[1px] tw-bg-[#047857] desktop-hover:group-hover/cell:tw-scale-110 desktop-hover:group-hover/cell:tw-brightness-105", + 3: "tw-h-[9px] tw-w-[9px] tw-rounded-sm tw-bg-[#059669] desktop-hover:group-hover/cell:tw-scale-105 desktop-hover:group-hover/cell:tw-brightness-105", + 4: "tw-h-[11px] tw-w-[11px] tw-rounded-sm tw-bg-[#34d399] tw-shadow-[0_0_10px_rgba(52,211,153,0.22)] desktop-hover:group-hover/cell:tw-scale-105 desktop-hover:group-hover/cell:tw-brightness-105", + }, + tooltip: { + 0: "tw-h-1 tw-w-1 tw-rounded-[1px] tw-bg-iron-500/55", + 1: "tw-h-[6px] tw-w-[6px] tw-rounded-[1px] tw-bg-[#064e3b]", + 2: "tw-h-[6px] tw-w-[6px] tw-rounded-[1px] tw-bg-[#047857]", + 3: "tw-h-[7px] tw-w-[7px] tw-rounded-sm tw-bg-[#059669]", + 4: "tw-h-[8px] tw-w-[8px] tw-rounded-sm tw-bg-[#34d399]", + }, +} as const; + +// Tooltip +export function getTooltipCountLabel(count: number): string { + if (count <= 0) { + return "No posts"; + } + + return count === 1 ? "1 post" : `${count} posts`; +} + +function getTooltipIntensity( + value: number +): UserPageBrainActivityCell["intensity"] { + const normalizedValue = Math.max(0, Math.min(4, Math.floor(value))); + + switch (normalizedValue) { + case 4: + return 4; + case 3: + return 3; + case 2: + return 2; + case 1: + return 1; + default: + return 0; + } +} + +export function getCellTooltipAnchorProps( + cell: UserPageBrainActivityCell, + tooltipId: string +): + | Readonly<{ + "aria-hidden": true; + "data-tooltip-id": string; + "data-tooltip-count": string; + "data-tooltip-date": string; + "data-tooltip-intensity": string; + }> + | Readonly<{ + "aria-hidden": true; + }> { + if (!cell.isoDate) { + return { "aria-hidden": true }; + } + + return { + "aria-hidden": true, + "data-tooltip-id": tooltipId, + "data-tooltip-count": String(cell.count), + "data-tooltip-date": cell.isoDate, + "data-tooltip-intensity": String(cell.intensity), + }; +} + +export function getHeatmapTooltipData( + activeAnchor: Element | null +): HeatmapTooltipData | null { + if (!(activeAnchor instanceof HTMLElement)) { + return null; + } + + const { dataset } = activeAnchor; + const countValue = Number(dataset["tooltipCount"]); + const isoDate = dataset["tooltipDate"]; + + if (!isoDate || Number.isNaN(countValue)) { + return null; + } + + const intensityValue = Number(dataset["tooltipIntensity"]); + + return { + count: countValue, + isoDate, + intensity: getTooltipIntensity(intensityValue), + }; +} + +// Cell presentation +export function getCellFrameClassName(cell: UserPageBrainActivityCell): string { + if (cell.state === "padding") { + return "tw-bg-transparent"; + } + + return clsx( + "tw-flex tw-items-center tw-justify-center tw-rounded tw-transition-colors tw-duration-200", + cell.ariaLabel && + "tw-cursor-pointer desktop-hover:hover:tw-z-20 desktop-hover:hover:tw-bg-[#171b21]" + ); +} + +export function getCellNodeClassName( + cell: UserPageBrainActivityCell, + variant: HeatmapNodeVariant = "grid" +): string { + return clsx( + variant === "grid" && + "tw-transform-gpu tw-transition-all tw-duration-200 tw-ease-out", + CELL_NODE_CLASS_NAMES[variant][cell.intensity] + ); +} + +// Month label placement +export function getPlacedMonthLabels( + monthLabels: readonly HeatmapMonthLabel[], + scrollLeft: number, + clientWidth: number +): readonly PlacedMonthLabel[] { + const placedMonthLabels: PlacedMonthLabel[] = []; + let nextMinimumLeftPx = 0; + + for (const label of monthLabels) { + const naturalLeftPx = label.labelColumn * COLUMN_STRIDE_PX - scrollLeft; + + if ( + clientWidth > 0 && + (naturalLeftPx < -MONTH_LABEL_OVERFLOW_TOLERANCE_PX || + naturalLeftPx > clientWidth) + ) { + continue; + } + + const leftPx = Math.max(-MONTH_LABEL_OVERFLOW_TOLERANCE_PX, naturalLeftPx); + + if (clientWidth > 0 && leftPx >= clientWidth) { + continue; + } + if (leftPx < nextMinimumLeftPx) { + continue; + } + + placedMonthLabels.push({ + key: label.key, + label: label.label, + leftPx, + }); + nextMinimumLeftPx = leftPx + MONTH_LABEL_MIN_SPACING_PX; + } + + return placedMonthLabels; +} diff --git a/components/user/brain/userPageBrainActivityHeatmap.viewport.ts b/components/user/brain/userPageBrainActivityHeatmap.viewport.ts new file mode 100644 index 0000000000..3af3a60f79 --- /dev/null +++ b/components/user/brain/userPageBrainActivityHeatmap.viewport.ts @@ -0,0 +1,135 @@ +import { + useCallback, + useLayoutEffect, + useRef, + useSyncExternalStore, +} from "react"; + +type ViewportMetrics = Readonly<{ + scrollLeft: number; + clientWidth: number; + scrollWidth: number; +}>; + +type MutableValueRef = { + current: T; +}; + +const EMPTY_VIEWPORT_METRICS: ViewportMetrics = { + scrollLeft: 0, + clientWidth: 0, + scrollWidth: 0, +}; + +function snapViewportToLatest(viewport: HTMLDivElement | null) { + if (!viewport) { + return; + } + + const maxScrollLeft = Math.max( + 0, + viewport.scrollWidth - viewport.clientWidth + ); + // Show the latest column fully, even when the viewport width is not aligned to a column stride. + viewport.scrollLeft = maxScrollLeft; +} + +function readViewportMetrics( + viewport: HTMLDivElement | null, + cachedMetricsRef: MutableValueRef +): ViewportMetrics { + if (!viewport) { + return EMPTY_VIEWPORT_METRICS; + } + + const nextMetrics = { + scrollLeft: viewport.scrollLeft, + clientWidth: viewport.clientWidth, + scrollWidth: viewport.scrollWidth, + } as const; + const previousMetrics = cachedMetricsRef.current; + + if ( + previousMetrics.scrollLeft === nextMetrics.scrollLeft && + previousMetrics.clientWidth === nextMetrics.clientWidth && + previousMetrics.scrollWidth === nextMetrics.scrollWidth + ) { + return previousMetrics; + } + + cachedMetricsRef.current = nextMetrics; + return nextMetrics; +} + +function subscribeToViewportMetrics( + viewport: HTMLDivElement | null, + onStoreChange: () => void +) { + if (!viewport) { + return () => {}; + } + + const content = viewport.firstElementChild; + let frameId = 0; + + const notify = () => { + cancelAnimationFrame(frameId); + frameId = requestAnimationFrame(onStoreChange); + }; + + notify(); + viewport.addEventListener("scroll", notify, { passive: true }); + + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", notify); + + return () => { + cancelAnimationFrame(frameId); + viewport.removeEventListener("scroll", notify); + window.removeEventListener("resize", notify); + }; + } + + const resizeObserver = new ResizeObserver(notify); + resizeObserver.observe(viewport); + if (content) { + resizeObserver.observe(content); + } + + return () => { + cancelAnimationFrame(frameId); + viewport.removeEventListener("scroll", notify); + resizeObserver.disconnect(); + }; +} + +export function useHeatmapViewport(resetKey?: string) { + const viewportRef = useRef(null); + const cachedMetricsRef = useRef(EMPTY_VIEWPORT_METRICS); + + const setViewportRef = useCallback((node: HTMLDivElement | null) => { + viewportRef.current = node; + cachedMetricsRef.current = EMPTY_VIEWPORT_METRICS; + }, []); + + useLayoutEffect(() => { + cachedMetricsRef.current = EMPTY_VIEWPORT_METRICS; + snapViewportToLatest(viewportRef.current); + }, [resetKey]); + + const getViewportSnapshot = useCallback(() => { + return readViewportMetrics(viewportRef.current, cachedMetricsRef); + }, []); + + const subscribeToViewport = useCallback((onStoreChange: () => void) => { + return subscribeToViewportMetrics(viewportRef.current, onStoreChange); + }, []); + + const viewportMetrics = useSyncExternalStore( + subscribeToViewport, + getViewportSnapshot, + () => EMPTY_VIEWPORT_METRICS + ); + + return { viewportRef: setViewportRef, viewportMetrics }; +} diff --git a/components/waves/drops/WaveDrop.tsx b/components/waves/drops/WaveDrop.tsx index 8f93530aae..f06ab5269f 100644 --- a/components/waves/drops/WaveDrop.tsx +++ b/components/waves/drops/WaveDrop.tsx @@ -89,7 +89,7 @@ const getColorClasses = ({ const ringClasses = isWaveView ? "" : "tw-ring-1 tw-ring-inset tw-ring-iron-800"; - const bgClass = isWaveView ? "" : "tw-bg-iron-950"; + const bgClass = isWaveView ? "" : "tw-bg-iron-950/80"; return `${bgClass} ${ringClasses} ${hoverClass}`.trim(); } diff --git a/hooks/useIdentityActivity.ts b/hooks/useIdentityActivity.ts new file mode 100644 index 0000000000..c24cc2d25f --- /dev/null +++ b/hooks/useIdentityActivity.ts @@ -0,0 +1,37 @@ +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { getDefaultQueryRetry } from "@/components/react-query-wrapper/utils/query-utils"; +import type { UserPageBrainActivityResponse } from "@/components/user/brain/userPageBrainActivity.helpers"; +import { Time } from "@/helpers/time"; +import { commonApiFetch } from "@/services/api/common-api"; +import { useQuery } from "@tanstack/react-query"; + +interface UseIdentityActivityOptions { + readonly identity: string | null | undefined; + readonly enabled?: boolean | undefined; +} + +export function useIdentityActivity({ + identity, + enabled = true, +}: Readonly) { + const normalizedIdentity = identity?.trim() ?? ""; + const canonicalIdentity = normalizedIdentity.toLowerCase(); + + return useQuery({ + queryKey: [QueryKey.IDENTITY_ACTIVITY, canonicalIdentity], + queryFn: async () => { + if (!canonicalIdentity) { + throw new Error("Identity is required to fetch brain activity"); + } + + return await commonApiFetch({ + endpoint: `identities/${encodeURIComponent(canonicalIdentity)}/activity`, + }); + }, + enabled: enabled && canonicalIdentity.length > 0, + staleTime: Time.minutes(15).toMillis(), + gcTime: Time.hours(1).toMillis(), + refetchOnWindowFocus: false, + ...getDefaultQueryRetry(), + }); +}