From dbc2ff7be20862ededba2ca8648e8b2390c6a4e6 Mon Sep 17 00:00:00 2001 From: smakosh Date: Sat, 9 May 2026 11:59:22 +0200 Subject: [PATCH 1/4] fix(chat): stop autoFocus stealing on virtualized rows Follow-up to #2215. Replaces autoFocus on the inline rename Input with a parent-controlled pendingFocusChatId so scroll-induced remounts no longer re-steal focus mid-edit, memoizes rowProps, hard-codes row heights through ROW_HEIGHT_* constants and stretches the row content to fill the slot, and drops the unused formatDate prop. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/playground/chat-sidebar.tsx | 229 ++++++++++++------ 1 file changed, 160 insertions(+), 69 deletions(-) diff --git a/apps/playground/src/components/playground/chat-sidebar.tsx b/apps/playground/src/components/playground/chat-sidebar.tsx index fad0eaa653..23e1abec3d 100644 --- a/apps/playground/src/components/playground/chat-sidebar.tsx +++ b/apps/playground/src/components/playground/chat-sidebar.tsx @@ -20,7 +20,7 @@ import { import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { usePostHog } from "posthog-js/react"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { List, type RowComponentProps } from "react-window"; import { toast } from "sonner"; @@ -89,19 +89,24 @@ type ChatHistoryRow = | { type: "chat"; key: string; chat: Chat } | { type: "spacer"; key: string }; +const ROW_HEIGHT_HEADER = 32; +const ROW_HEIGHT_SPACER = 16; +const ROW_HEIGHT_CHAT = 60; + interface ChatHistoryRowProps { rows: ChatHistoryRow[]; currentChatId?: string; editingId: string | null; editTitle: string; + pendingFocusChatId: string | null; isPageLoading: boolean; - formatDate: (dateString: string) => string; onChatSelect?: (chatId: string) => void; onEditTitleChange: (value: string) => void; onSaveTitle: (chatId: string) => void; onCancelEdit: () => void; onDeleteChat: (chatId: string) => void; onStartEdit: (chat: Chat) => void; + onEditFocused: () => void; } function getChatHistoryRowHeight( @@ -111,14 +116,69 @@ function getChatHistoryRowHeight( const row = rows[index]; if (row?.type === "header") { - return 32; + return ROW_HEIGHT_HEADER; } if (row?.type === "spacer") { - return 16; + return ROW_HEIGHT_SPACER; } - return 52; + return ROW_HEIGHT_CHAT; +} + +function EditChatTitleInput({ + chatId, + value, + shouldFocus, + onChange, + onSave, + onCancel, + onFocused, +}: { + chatId: string; + value: string; + shouldFocus: boolean; + onChange: (value: string) => void; + onSave: (chatId: string) => void; + onCancel: () => void; + onFocused: () => void; +}) { + const inputRef = useRef(null); + + useEffect(() => { + if ( + !shouldFocus || + !inputRef.current || + document.activeElement === inputRef.current + ) { + return; + } + const el = inputRef.current; + el.focus(); + const len = el.value.length; + el.setSelectionRange(len, len); + onFocused(); + }, [shouldFocus, onFocused]); + + return ( + onChange(e.target.value)} + onBlur={() => onSave(chatId)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + className="h-7 text-sm border-none px-1 focus-visible:ring-0 bg-transparent" + /> + ); } function ChatHistoryRowComponent({ @@ -129,14 +189,15 @@ function ChatHistoryRowComponent({ currentChatId, editingId, editTitle, + pendingFocusChatId, isPageLoading, - formatDate, onChatSelect, onEditTitleChange, onSaveTitle, onCancelEdit, onDeleteChat, onStartEdit, + onEditFocused, }: RowComponentProps) { const row = rows[index]; @@ -159,37 +220,30 @@ function ChatHistoryRowComponent({ } const { chat } = row; + const isEditing = editingId === chat.id; return (
-
-
- {editingId === chat.id ? ( -
+
+
+ {isEditing ? ( +
- onEditTitleChange(e.target.value)} - onBlur={() => onSaveTitle(chat.id)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - e.currentTarget.blur(); - } - if (e.key === "Escape") { - e.preventDefault(); - onCancelEdit(); - } - }} - className="h-7 text-sm border-none px-1 focus-visible:ring-0 bg-transparent" - autoFocus + shouldFocus={pendingFocusChatId === chat.id} + onChange={onEditTitleChange} + onSave={onSaveTitle} + onCancel={onCancelEdit} + onFocused={onEditFocused} />
) : ( onChatSelect?.(chat.id)} - className="w-full justify-start gap-3 group relative pr-10 py-6" + className="h-full! w-full justify-start gap-3 group relative pr-10" type="button" disabled={isPageLoading} > @@ -204,8 +258,8 @@ function ChatHistoryRowComponent({
)} - {currentChatId === chat.id && editingId !== chat.id && ( -
+ {currentChatId === chat.id && !isEditing && ( +
(null); const [editTitle, setEditTitle] = useState(""); + // chatId that needs initial focus. Cleared once the row delivers focus, + // so re-mounting the row on scroll never re-steals focus mid-edit. + const [pendingFocusChatId, setPendingFocusChatId] = useState( + null, + ); const chats = chatsData?.chats ?? []; @@ -353,40 +412,53 @@ export function ChatSidebar({ }); }; - const handleEditTitle = (chat: Chat) => { + const handleEditTitle = useCallback((chat: Chat) => { setEditingId(chat.id); setEditTitle(chat.title); - }; + setPendingFocusChatId(chat.id); + }, []); + + const saveTitle = useCallback( + (chatId: string) => { + if (editTitle.trim()) { + updateChat.mutate({ + params: { + path: { id: chatId }, + }, + body: { title: editTitle.trim() }, + }); + } + setEditingId(null); + setEditTitle(""); + setPendingFocusChatId(null); + }, + [editTitle, updateChat], + ); - const saveTitle = (chatId: string) => { - if (editTitle.trim()) { - updateChat.mutate({ + const cancelEditTitle = useCallback(() => { + setEditingId(null); + setEditTitle(""); + setPendingFocusChatId(null); + }, []); + + const handleDeleteChat = useCallback( + (chatId: string) => { + deleteChat.mutate({ params: { path: { id: chatId }, }, - body: { title: editTitle.trim() }, }); - } - setEditingId(null); - setEditTitle(""); - }; - - const cancelEditTitle = () => { - setEditingId(null); - setEditTitle(""); - }; + if (currentChatId === chatId) { + clearMessages(); + onChatSelect?.(""); + } + }, + [deleteChat, currentChatId, clearMessages, onChatSelect], + ); - const handleDeleteChat = (chatId: string) => { - deleteChat.mutate({ - params: { - path: { id: chatId }, - }, - }); - if (currentChatId === chatId) { - clearMessages(); - onChatSelect?.(""); - } - }; + const onEditFocused = useCallback(() => { + setPendingFocusChatId(null); + }, []); const chatGroups = useMemo( () => @@ -431,6 +503,38 @@ export function ChatSidebar({ return rows; }, [chatGroups]); + const rowProps = useMemo( + () => ({ + rows: historyRows, + currentChatId, + editingId, + editTitle, + pendingFocusChatId, + isPageLoading, + onChatSelect, + onEditTitleChange: setEditTitle, + onSaveTitle: saveTitle, + onCancelEdit: cancelEditTitle, + onDeleteChat: handleDeleteChat, + onStartEdit: handleEditTitle, + onEditFocused, + }), + [ + historyRows, + currentChatId, + editingId, + editTitle, + pendingFocusChatId, + isPageLoading, + onChatSelect, + saveTitle, + cancelEditTitle, + handleDeleteChat, + handleEditTitle, + onEditFocused, + ], + ); + const isAuthenticated = !!user; // Loading auth state → show lightweight skeleton to avoid hydration issues @@ -588,7 +692,7 @@ export function ChatSidebar({ )} */} - {chats.length === 0 && !isChatsLoading ? ( + {chats.length === 0 ? (

@@ -605,20 +709,7 @@ export function ChatSidebar({ rowComponent={ChatHistoryRowComponent} rowCount={historyRows.length} rowHeight={getChatHistoryRowHeight} - rowProps={{ - rows: historyRows, - currentChatId, - editingId, - editTitle, - isPageLoading, - formatDate, - onChatSelect, - onEditTitleChange: setEditTitle, - onSaveTitle: saveTitle, - onCancelEdit: cancelEditTitle, - onDeleteChat: handleDeleteChat, - onStartEdit: handleEditTitle, - }} + rowProps={rowProps} overscanCount={8} /> )} From 9c4fecfc45484ec50b992f1bd6c5e9e3ba3a1be1 Mon Sep 17 00:00:00 2001 From: smakosh Date: Sat, 9 May 2026 11:59:35 +0200 Subject: [PATCH 2/4] feat(admin): devpass margin %, ID copy, and timeseries chart Adds marginPct to /admin/devpass list and detail responses. Renders % margin in the Cycle margin KPI subtitle on /devpass. Introduces GET /admin/devpass/timeseries (revenue, cost, margin per day) wired into a new DevpassTimeseriesChart on /devpass with the existing DateRangePicker (30d, 90d, 365d, all time, custom). Adds an ID column with click-to-copy on the per-org subscription history and payment failure tables for easier debugging. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/routes/admin.ts | 213 +++++++++++++++++- ee/admin/src/app/devpass/[orgId]/page.tsx | 13 +- ee/admin/src/app/devpass/page.tsx | 41 +++- ee/admin/src/components/copyable-id.tsx | 47 ++++ .../components/devpass-timeseries-chart.tsx | 173 ++++++++++++++ ee/admin/src/lib/api/v1.d.ts | 57 +++++ 6 files changed, 534 insertions(+), 10 deletions(-) create mode 100644 ee/admin/src/components/copyable-id.tsx create mode 100644 ee/admin/src/components/devpass-timeseries-chart.tsx diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index f5cd39c718..04e1fd2344 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -7393,6 +7393,7 @@ const devpassSubscriberSchema = z.object({ mrr: z.number(), realCost: z.number(), margin: z.number(), + marginPct: z.number().nullable(), subscribedSince: z.string().nullable(), tierChanges: z.number(), lastPaymentFailureAt: z.string().nullable(), @@ -7416,6 +7417,7 @@ const devpassKpisSchema = z.object({ totalRealCostCycle: z.number(), totalMrrCycle: z.number(), totalMargin: z.number(), + marginPct: z.number().nullable(), }); const devpassListSchema = z.object({ @@ -7522,6 +7524,47 @@ const getDevpassSubscriber = createRoute({ }, }); +const devpassTimeseriesPointSchema = z.object({ + date: z.string(), + revenue: z.number(), + cost: z.number(), + margin: z.number(), +}); + +const devpassTimeseriesSchema = z.object({ + data: z.array(devpassTimeseriesPointSchema), + totals: z.object({ + revenue: z.number(), + cost: z.number(), + margin: z.number(), + }), + range: z.object({ + from: z.string(), + to: z.string(), + }), +}); + +const getDevpassTimeseries = createRoute({ + method: "get", + path: "/devpass/timeseries", + request: { + query: z.object({ + from: z.string().optional(), + to: z.string().optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: devpassTimeseriesSchema.openapi({}), + }, + }, + description: "DevPass revenue/cost/margin per day.", + }, + }, +}); + function tierPriceOf(tier: string): number { if (tier === "lite" || tier === "pro" || tier === "max") { return DEV_PLAN_PRICES[tier]; @@ -7973,6 +8016,10 @@ admin.openapi(getDevpassSubscribers, async (c) => { : null; const hasPaymentIssue = (row.paymentFailureCount ?? 0) > 0; + const mrrNum = Number(row.mrr ?? 0); + const marginNum = Number(row.margin ?? 0); + const marginPct = mrrNum > 0 ? (marginNum / mrrNum) * 100 : null; + return { id: row.id, name: row.name, @@ -7991,9 +8038,10 @@ admin.openapi(getDevpassSubscribers, async (c) => { expiresAt: expiresAt ? expiresAt.toISOString() : null, cancelled, allowAllModels: row.allowAllModels, - mrr: Number(row.mrr ?? 0), + mrr: mrrNum, realCost: Number(row.realCost ?? 0), - margin: Number(row.margin ?? 0), + margin: marginNum, + marginPct, subscribedSince: row.subscribedSince ? new Date(row.subscribedSince).toISOString() : null, @@ -8003,6 +8051,9 @@ admin.openapi(getDevpassSubscribers, async (c) => { }; }); + const kpiMarginPct = + totalMrrCycle > 0 ? (totalMargin / totalMrrCycle) * 100 : null; + return c.json({ subscribers, total, @@ -8019,6 +8070,7 @@ admin.openapi(getDevpassSubscribers, async (c) => { totalRealCostCycle, totalMrrCycle, totalMargin, + marginPct: kpiMarginPct, }, limit, offset, @@ -8135,6 +8187,8 @@ admin.openapi(getDevpassSubscriber, async (c) => { const hasPaymentIssue = (org.paymentFailureCount ?? 0) > 0; + const marginPct = mrr > 0 ? (margin / mrr) * 100 : null; + const subscriber = { id: org.id, name: org.name, @@ -8158,6 +8212,7 @@ admin.openapi(getDevpassSubscriber, async (c) => { mrr, realCost, margin, + marginPct, subscribedSince: firstStartRow?.firstStart ? new Date(firstStartRow.firstStart).toISOString() : null, @@ -8235,4 +8290,158 @@ admin.openapi(getDevpassSubscriber, async (c) => { }); }); +const DEV_PLAN_TX_TYPES = [ + "dev_plan_start", + "dev_plan_upgrade", + "dev_plan_downgrade", + "dev_plan_renewal", +] as const; + +admin.openapi(getDevpassTimeseries, async (c) => { + const query = c.req.valid("query"); + const now = new Date(); + + // Resolve range. When no from/to is provided, default to all-time + // (anchored to the earliest dev_plan_start, falling back to today). + let startDate: Date; + let endDate: Date; + if (query.from && query.to) { + startDate = new Date(query.from + "T00:00:00.000Z"); + endDate = new Date(query.to + "T23:59:59.999Z"); + } else { + const [oldest] = await db + .select({ + minDate: sql`MIN(${tables.transaction.createdAt})`.as( + "min_date", + ), + }) + .from(tables.transaction) + .where(eq(tables.transaction.type, "dev_plan_start")); + startDate = oldest?.minDate ? new Date(oldest.minDate) : now; + startDate.setUTCHours(0, 0, 0, 0); + endDate = new Date(now); + endDate.setUTCHours(23, 59, 59, 999); + } + + if (endDate.getTime() < startDate.getTime()) { + endDate = new Date(startDate); + endDate.setUTCHours(23, 59, 59, 999); + } + + // Revenue per day from completed DevPass transactions. + const revenuePerDay = await db + .select({ + date: sql`DATE(${tables.transaction.createdAt})`.as("date"), + total: + sql`COALESCE(SUM(CAST(${tables.transaction.creditAmount} AS NUMERIC)), 0)`.as( + "total", + ), + }) + .from(tables.transaction) + .where( + and( + eq(tables.transaction.status, "completed"), + inArray(tables.transaction.type, [...DEV_PLAN_TX_TYPES]), + gte(tables.transaction.createdAt, startDate), + lte(tables.transaction.createdAt, endDate), + ), + ) + .groupBy(sql`DATE(${tables.transaction.createdAt})`) + .orderBy(asc(sql`DATE(${tables.transaction.createdAt})`)); + + // Provider cost per day for projects belonging to orgs that are or were + // ever on a DevPass plan (i.e. currently devPlan != 'none' OR have a + // historical dev_plan_start). This approximates "DevPass usage" without + // reconstructing daily plan membership. + const costPerDay = await db + .select({ + date: sql`DATE(${projectHourlyStats.hourTimestamp})`.as("date"), + total: + sql`COALESCE(SUM(CAST(${projectHourlyStats.cost} AS NUMERIC)), 0)`.as( + "total", + ), + }) + .from(projectHourlyStats) + .innerJoin( + tables.project, + eq(projectHourlyStats.projectId, tables.project.id), + ) + .innerJoin( + tables.organization, + eq(tables.project.organizationId, tables.organization.id), + ) + .where( + and( + gte(projectHourlyStats.hourTimestamp, startDate), + lte(projectHourlyStats.hourTimestamp, endDate), + or( + ne(tables.organization.devPlan, "none"), + sql`EXISTS ( + SELECT 1 FROM ${tables.transaction} t + WHERE t.organization_id = ${tables.organization.id} + AND t.type = 'dev_plan_start' + )`, + )!, + ), + ) + .groupBy(sql`DATE(${projectHourlyStats.hourTimestamp})`) + .orderBy(asc(sql`DATE(${projectHourlyStats.hourTimestamp})`)); + + const revenueMap = new Map(); + for (const row of revenuePerDay) { + revenueMap.set(row.date, Number(row.total)); + } + const costMap = new Map(); + for (const row of costPerDay) { + costMap.set(row.date, Number(row.total)); + } + + const data: Array<{ + date: string; + revenue: number; + cost: number; + margin: number; + }> = []; + + const cursor = new Date( + Date.UTC( + startDate.getUTCFullYear(), + startDate.getUTCMonth(), + startDate.getUTCDate(), + ), + ); + const lastDay = Date.UTC( + endDate.getUTCFullYear(), + endDate.getUTCMonth(), + endDate.getUTCDate(), + ); + + let totalRevenue = 0; + let totalCost = 0; + + while (cursor.getTime() <= lastDay) { + const iso = cursor.toISOString().slice(0, 10); + const revenue = revenueMap.get(iso) ?? 0; + const cost = costMap.get(iso) ?? 0; + const margin = revenue - cost; + data.push({ date: iso, revenue, cost, margin }); + totalRevenue += revenue; + totalCost += cost; + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + return c.json({ + data, + totals: { + revenue: totalRevenue, + cost: totalCost, + margin: totalRevenue - totalCost, + }, + range: { + from: startDate.toISOString().slice(0, 10), + to: endDate.toISOString().slice(0, 10), + }, + }); +}); + export default admin; diff --git a/ee/admin/src/app/devpass/[orgId]/page.tsx b/ee/admin/src/app/devpass/[orgId]/page.tsx index a20dbed67b..915691299a 100644 --- a/ee/admin/src/app/devpass/[orgId]/page.tsx +++ b/ee/admin/src/app/devpass/[orgId]/page.tsx @@ -8,6 +8,7 @@ import { import Link from "next/link"; import { notFound } from "next/navigation"; +import { CopyableId } from "@/components/copyable-id"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -310,6 +311,7 @@ export default async function DevpassDetailPage({ + ID Date Event Amount @@ -322,7 +324,7 @@ export default async function DevpassDetailPage({ {data.transactions.length === 0 ? ( No subscription events recorded @@ -331,6 +333,9 @@ export default async function DevpassDetailPage({ ) : ( data.transactions.map((t) => ( + + + {formatDateTime(t.createdAt)} @@ -378,6 +383,7 @@ export default async function DevpassDetailPage({
+ ID Date Amount Decline code @@ -389,7 +395,7 @@ export default async function DevpassDetailPage({ {data.paymentFailures.length === 0 ? ( No payment failures @@ -398,6 +404,9 @@ export default async function DevpassDetailPage({ ) : ( data.paymentFailures.map((p) => ( + + + {formatDateTime(p.createdAt)} diff --git a/ee/admin/src/app/devpass/page.tsx b/ee/admin/src/app/devpass/page.tsx index e069865aaa..78a1da3bbe 100644 --- a/ee/admin/src/app/devpass/page.tsx +++ b/ee/admin/src/app/devpass/page.tsx @@ -13,7 +13,10 @@ import { } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { DateRangePicker } from "@/components/date-range-picker"; +import { DevpassTimeseriesChart } from "@/components/devpass-timeseries-chart"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -307,11 +310,15 @@ export default async function DevpassPage({ utilization?: string; marginNegative?: string; showChurned?: string; + from?: string; + to?: string; }>; }) { await requireSession(); const params = await searchParams; + const from = typeof params?.from === "string" ? params?.from : undefined; + const to = typeof params?.to === "string" ? params?.to : undefined; const rawPage = parseInt(params?.page ?? "1", 10); const page = Number.isFinite(rawPage) && rawPage >= 1 ? rawPage : 1; const search = params?.search ?? ""; @@ -419,12 +426,17 @@ export default async function DevpassPage({ return (
-
-

DevPass

-

- Subscribers across Lite, Pro and Max — current cycle utilization, real - provider cost, and margin. -

+
+
+

DevPass

+

+ Subscribers across Lite, Pro and Max — current cycle utilization, + real provider cost, and margin. +

+
+ + +
@@ -497,12 +509,29 @@ export default async function DevpassPage({ {currencyFormatter.format(kpis.totalMargin)}
+ {kpis.marginPct !== null && kpis.marginPct !== undefined ? ( + <> + + {kpis.marginPct.toFixed(1)}% margin + {" "} + ·{" "} + + ) : null} {currencyFormatter.format(kpis.totalRealCostCycle)} provider cost this cycle
+ +
{ + try { + await navigator.clipboard.writeText(id); + setCopied(true); + toast.success("ID copied"); + setTimeout(() => setCopied(false), 1500); + } catch { + toast.error("Failed to copy ID"); + } + }; + + return ( + + ); +} diff --git a/ee/admin/src/components/devpass-timeseries-chart.tsx b/ee/admin/src/components/devpass-timeseries-chart.tsx new file mode 100644 index 0000000000..6d496cbcd2 --- /dev/null +++ b/ee/admin/src/components/devpass-timeseries-chart.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { format, parseISO } from "date-fns"; +import { useState } from "react"; +import { CartesianGrid, Line, LineChart, XAxis } from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { useApi } from "@/lib/fetch-client"; +import { cn } from "@/lib/utils"; + +import type { ChartConfig } from "@/components/ui/chart"; + +const chartConfig = { + revenue: { + label: "Revenue", + color: "hsl(142 71% 45%)", + }, + cost: { + label: "Provider cost", + color: "hsl(32 95% 44%)", + }, + margin: { + label: "Margin", + color: "hsl(221 83% 53%)", + }, +} satisfies ChartConfig; + +type ActiveSeries = keyof typeof chartConfig; + +const compactCurrency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + notation: "compact", + compactDisplay: "short", + maximumFractionDigits: 1, +}); + +const fullCurrency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, +}); + +export function DevpassTimeseriesChart({ + from, + to, +}: { + from?: string; + to?: string; +}) { + const [activeSeries, setActiveSeries] = useState("revenue"); + const $api = useApi(); + const { data, isLoading, isError } = $api.useQuery( + "get", + "/admin/devpass/timeseries", + { + params: { query: { from, to } }, + }, + ); + + const chartData = data?.data ?? []; + const totals = data?.totals; + + return ( + + +
+ DevPass revenue & usage + + Daily revenue from DevPass transactions, real provider cost across + current and former subscribers, and the resulting margin. + +
+
+ {(["revenue", "cost", "margin"] as const).map((key) => { + const value = totals?.[key] ?? 0; + return ( + + ); + })} +
+
+ + {isError ? ( +
+ Failed to load DevPass timeseries. +
+ ) : isLoading ? ( +
+ Loading… +
+ ) : chartData.length === 0 ? ( +
+ No data for the selected range. +
+ ) : ( + + + + { + const date = parseISO(value); + return format(date, "MMM d"); + }} + /> + { + const date = parseISO(value); + return format(date, "MMM d, yyyy"); + }} + formatter={(value) => fullCurrency.format(Number(value))} + /> + } + /> + + + + )} +
+
+ ); +} diff --git a/ee/admin/src/lib/api/v1.d.ts b/ee/admin/src/lib/api/v1.d.ts index 1a7bf515ce..be2462ff53 100644 --- a/ee/admin/src/lib/api/v1.d.ts +++ b/ee/admin/src/lib/api/v1.d.ts @@ -4559,6 +4559,7 @@ export interface paths { mrr: number; realCost: number; margin: number; + marginPct: number | null; subscribedSince: string | null; tierChanges: number; lastPaymentFailureAt: string | null; @@ -4582,6 +4583,7 @@ export interface paths { totalRealCostCycle: number; totalMrrCycle: number; totalMargin: number; + marginPct: number | null; }; limit: number; offset: number; @@ -4646,6 +4648,7 @@ export interface paths { mrr: number; realCost: number; margin: number; + marginPct: number | null; subscribedSince: string | null; tierChanges: number; lastPaymentFailureAt: string | null; @@ -4690,6 +4693,60 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/devpass/timeseries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + from?: string; + to?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description DevPass revenue/cost/margin per day. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + date: string; + revenue: number; + cost: number; + margin: number; + }[]; + totals: { + revenue: number; + cost: number; + margin: number; + }; + range: { + from: string; + to: string; + }; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/keys/api": { parameters: { query?: never; From df7ffc1b82d4efe15e33161280be566e1b644503 Mon Sep 17 00:00:00 2001 From: "Luca Steeb (bot)" Date: Sat, 9 May 2026 10:06:27 +0000 Subject: [PATCH 3/4] chore(autofix): apply diff --- apps/code/src/lib/api/v1.d.ts | 57 +++++++++++++++++++++++++++++ apps/playground/src/lib/api/v1.d.ts | 57 +++++++++++++++++++++++++++++ apps/ui/src/lib/api/v1.d.ts | 57 +++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+) diff --git a/apps/code/src/lib/api/v1.d.ts b/apps/code/src/lib/api/v1.d.ts index 1a7bf515ce..be2462ff53 100644 --- a/apps/code/src/lib/api/v1.d.ts +++ b/apps/code/src/lib/api/v1.d.ts @@ -4559,6 +4559,7 @@ export interface paths { mrr: number; realCost: number; margin: number; + marginPct: number | null; subscribedSince: string | null; tierChanges: number; lastPaymentFailureAt: string | null; @@ -4582,6 +4583,7 @@ export interface paths { totalRealCostCycle: number; totalMrrCycle: number; totalMargin: number; + marginPct: number | null; }; limit: number; offset: number; @@ -4646,6 +4648,7 @@ export interface paths { mrr: number; realCost: number; margin: number; + marginPct: number | null; subscribedSince: string | null; tierChanges: number; lastPaymentFailureAt: string | null; @@ -4690,6 +4693,60 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/devpass/timeseries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + from?: string; + to?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description DevPass revenue/cost/margin per day. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + date: string; + revenue: number; + cost: number; + margin: number; + }[]; + totals: { + revenue: number; + cost: number; + margin: number; + }; + range: { + from: string; + to: string; + }; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/keys/api": { parameters: { query?: never; diff --git a/apps/playground/src/lib/api/v1.d.ts b/apps/playground/src/lib/api/v1.d.ts index 1a7bf515ce..be2462ff53 100644 --- a/apps/playground/src/lib/api/v1.d.ts +++ b/apps/playground/src/lib/api/v1.d.ts @@ -4559,6 +4559,7 @@ export interface paths { mrr: number; realCost: number; margin: number; + marginPct: number | null; subscribedSince: string | null; tierChanges: number; lastPaymentFailureAt: string | null; @@ -4582,6 +4583,7 @@ export interface paths { totalRealCostCycle: number; totalMrrCycle: number; totalMargin: number; + marginPct: number | null; }; limit: number; offset: number; @@ -4646,6 +4648,7 @@ export interface paths { mrr: number; realCost: number; margin: number; + marginPct: number | null; subscribedSince: string | null; tierChanges: number; lastPaymentFailureAt: string | null; @@ -4690,6 +4693,60 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/devpass/timeseries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + from?: string; + to?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description DevPass revenue/cost/margin per day. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + date: string; + revenue: number; + cost: number; + margin: number; + }[]; + totals: { + revenue: number; + cost: number; + margin: number; + }; + range: { + from: string; + to: string; + }; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/keys/api": { parameters: { query?: never; diff --git a/apps/ui/src/lib/api/v1.d.ts b/apps/ui/src/lib/api/v1.d.ts index 1a7bf515ce..be2462ff53 100644 --- a/apps/ui/src/lib/api/v1.d.ts +++ b/apps/ui/src/lib/api/v1.d.ts @@ -4559,6 +4559,7 @@ export interface paths { mrr: number; realCost: number; margin: number; + marginPct: number | null; subscribedSince: string | null; tierChanges: number; lastPaymentFailureAt: string | null; @@ -4582,6 +4583,7 @@ export interface paths { totalRealCostCycle: number; totalMrrCycle: number; totalMargin: number; + marginPct: number | null; }; limit: number; offset: number; @@ -4646,6 +4648,7 @@ export interface paths { mrr: number; realCost: number; margin: number; + marginPct: number | null; subscribedSince: string | null; tierChanges: number; lastPaymentFailureAt: string | null; @@ -4690,6 +4693,60 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/devpass/timeseries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + from?: string; + to?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description DevPass revenue/cost/margin per day. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + date: string; + revenue: number; + cost: number; + margin: number; + }[]; + totals: { + revenue: number; + cost: number; + margin: number; + }; + range: { + from: string; + to: string; + }; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/keys/api": { parameters: { query?: never; From 635ad9d1bd587423b46fa590bd1cce8e75b4c679 Mon Sep 17 00:00:00 2001 From: smakosh Date: Sat, 9 May 2026 12:18:43 +0200 Subject: [PATCH 4/4] fix(admin): preserve date range in tables, harden copy + a11y Forward `from`/`to` through the devpass table query-string and form action so chart range survives sort/filter/page interactions. Cancel the CopyableId reset timeout on unmount to avoid late state updates. Add aria-pressed to the timeseries chart's segmented control. Co-Authored-By: Claude Opus 4.7 (1M context) --- ee/admin/src/app/devpass/page.tsx | 16 ++++++++++++++++ ee/admin/src/components/copyable-id.tsx | 19 +++++++++++++++++-- .../components/devpass-timeseries-chart.tsx | 1 + 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/ee/admin/src/app/devpass/page.tsx b/ee/admin/src/app/devpass/page.tsx index 78a1da3bbe..b3b158d03a 100644 --- a/ee/admin/src/app/devpass/page.tsx +++ b/ee/admin/src/app/devpass/page.tsx @@ -383,6 +383,12 @@ export default async function DevpassPage({ if (showChurned) { queryParams.set("showChurned", "true"); } + if (from) { + queryParams.set("from", from); + } + if (to) { + queryParams.set("to", to); + } queryParams.set("sortBy", sortBy); queryParams.set("sortOrder", sortOrder); const queryString = queryParams.toString(); @@ -397,6 +403,8 @@ export default async function DevpassPage({ const utilValue = formData.get("utilization") as string; const marginValue = formData.get("marginNegative") as string; const churnValue = formData.get("showChurned") as string; + const fromValue = formData.get("from") as string; + const toValue = formData.get("to") as string; const sp = new URLSearchParams(); if (searchValue) { sp.set("search", searchValue); @@ -416,6 +424,12 @@ export default async function DevpassPage({ if (churnValue) { sp.set("showChurned", "true"); } + if (fromValue) { + sp.set("from", fromValue); + } + if (toValue) { + sp.set("to", toValue); + } sp.set("sortBy", sortByValue); sp.set("sortOrder", sortOrderValue); sp.set("page", "1"); @@ -551,6 +565,8 @@ export default async function DevpassPage({ name="showChurned" value={showChurned ? "true" : ""} /> + +
diff --git a/ee/admin/src/components/copyable-id.tsx b/ee/admin/src/components/copyable-id.tsx index d24ac3f5ed..a70db080f9 100644 --- a/ee/admin/src/components/copyable-id.tsx +++ b/ee/admin/src/components/copyable-id.tsx @@ -1,7 +1,7 @@ "use client"; import { Check, Copy } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -14,13 +14,28 @@ export function CopyableId({ className?: string; }) { const [copied, setCopied] = useState(false); + const resetTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (resetTimerRef.current !== null) { + clearTimeout(resetTimerRef.current); + } + }; + }, []); const handleCopy = async () => { try { await navigator.clipboard.writeText(id); setCopied(true); toast.success("ID copied"); - setTimeout(() => setCopied(false), 1500); + if (resetTimerRef.current !== null) { + clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = setTimeout(() => { + resetTimerRef.current = null; + setCopied(false); + }, 1500); } catch { toast.error("Failed to copy ID"); } diff --git a/ee/admin/src/components/devpass-timeseries-chart.tsx b/ee/admin/src/components/devpass-timeseries-chart.tsx index 6d496cbcd2..ea3e03bd31 100644 --- a/ee/admin/src/components/devpass-timeseries-chart.tsx +++ b/ee/admin/src/components/devpass-timeseries-chart.tsx @@ -89,6 +89,7 @@ export function DevpassTimeseriesChart({