diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6962b294..f928f72b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,6 @@ jobs: - name: Typecheck run: bun run typecheck - working-directory: apps/web - name: Build run: bun run build diff --git a/apps/web/__tests__/lib/strip-markdown.test.ts b/apps/web/__tests__/lib/strip-markdown.test.ts new file mode 100644 index 00000000..4cb2b436 --- /dev/null +++ b/apps/web/__tests__/lib/strip-markdown.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { stripMarkdown } from "@/lib/utils/strip-markdown"; + +describe("stripMarkdown", () => { + it("strips heading markers", () => { + expect(stripMarkdown("# Hello World")).toBe("Hello World"); + }); + + it("strips bold (**) markers", () => { + expect(stripMarkdown("This is **bold** text")).toBe("This is bold text"); + }); + + it("strips italic asterisk markers", () => { + expect(stripMarkdown("This is *italic* text")).toBe("This is italic text"); + }); + + it("strips italic underscore markers", () => { + expect(stripMarkdown("This is _italic_ text")).toBe("This is italic text"); + }); + + it("strips strikethrough markers", () => { + expect(stripMarkdown("This is ~~struck~~ text")).toBe( + "This is struck text", + ); + }); + + it("strips inline code markers", () => { + expect(stripMarkdown("Use `code` here")).toBe("Use code here"); + }); + + it("strips link syntax keeping the label", () => { + expect(stripMarkdown("Visit [Straude](https://straude.com) today")).toBe( + "Visit Straude today", + ); + }); + + it("strips image syntax (link rule consumes the body, leaving the bang)", () => { + // The link rule runs before the image rule, so [alt](url) is captured + // first — only the leading "!" survives. Locked in to preserve current + // OG-card output byte-for-byte. + expect(stripMarkdown("Look ![alt](https://x.com/img.png) here")).toBe( + "Look !alt here", + ); + }); + + it("strips unordered list bullet markers", () => { + expect(stripMarkdown("- first item")).toBe("first item"); + }); + + it("strips ordered list markers", () => { + expect(stripMarkdown("1. first item")).toBe("first item"); + }); + + it("strips blockquote markers", () => { + expect(stripMarkdown("> a quoted line")).toBe("a quoted line"); + }); + + it("collapses double newlines to a single space", () => { + expect(stripMarkdown("para one\n\npara two")).toBe("para one para two"); + }); + + it("collapses single newlines to a single space", () => { + expect(stripMarkdown("line one\nline two")).toBe("line one line two"); + }); +}); diff --git a/apps/web/app/admin/components/CodexGrowthCharts.tsx b/apps/web/app/admin/components/CodexGrowthCharts.tsx index d316e77d..b6a05a1a 100644 --- a/apps/web/app/admin/components/CodexGrowthCharts.tsx +++ b/apps/web/app/admin/components/CodexGrowthCharts.tsx @@ -16,6 +16,7 @@ import { ReferenceLine, } from "recharts"; import { useAdminTheme } from "./AdminShell"; +import { formatDateMonDay as formatDate } from "@/lib/utils/dates"; interface ModelUsageRow { date: string; @@ -29,11 +30,6 @@ const RANGES = [ { key: "all", label: "All", days: Infinity }, ] as const; -function formatDate(dateStr: string) { - const d = new Date(dateStr + "T00:00:00"); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); -} - function useChartTheme() { const { theme } = useAdminTheme(); const isDark = theme === "dark"; diff --git a/apps/web/app/admin/components/GrowthMetrics.tsx b/apps/web/app/admin/components/GrowthMetrics.tsx index ecfbb115..d802efad 100644 --- a/apps/web/app/admin/components/GrowthMetrics.tsx +++ b/apps/web/app/admin/components/GrowthMetrics.tsx @@ -10,6 +10,7 @@ import { ResponsiveContainer, } from "recharts"; import { useAdminTheme } from "./AdminShell"; +import { formatDateMonDay as formatDate } from "@/lib/utils/dates"; interface GrowthRow { date: string; @@ -17,11 +18,6 @@ interface GrowthRow { cumulative_users: number; } -function formatDate(dateStr: string) { - const d = new Date(dateStr + "T00:00:00"); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); -} - export function GrowthMetrics({ data }: { data: GrowthRow[] }) { const { theme } = useAdminTheme(); const isDark = theme === "dark"; diff --git a/apps/web/app/admin/components/ModelShareChart.tsx b/apps/web/app/admin/components/ModelShareChart.tsx index 135cf297..48ee5801 100644 --- a/apps/web/app/admin/components/ModelShareChart.tsx +++ b/apps/web/app/admin/components/ModelShareChart.tsx @@ -12,6 +12,7 @@ import { Legend, } from "recharts"; import { useAdminTheme } from "./AdminShell"; +import { formatDateMonDay as formatDate } from "@/lib/utils/dates"; interface RpcRow { date: string; @@ -40,11 +41,6 @@ const RANGES = [ type DayRow = Record; -function formatDate(dateStr: string) { - const d = new Date(dateStr + "T00:00:00"); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); -} - function Skeleton() { return (
diff --git a/apps/web/app/admin/components/ModelUsageChart.tsx b/apps/web/app/admin/components/ModelUsageChart.tsx index 3afe90cb..e650f80a 100644 --- a/apps/web/app/admin/components/ModelUsageChart.tsx +++ b/apps/web/app/admin/components/ModelUsageChart.tsx @@ -14,6 +14,7 @@ import { Legend, } from "recharts"; import { useAdminTheme } from "./AdminShell"; +import { formatDateMonDay as formatDate } from "@/lib/utils/dates"; interface ModelUsageRow { date: string; @@ -35,11 +36,6 @@ const VIEW_MODES: { key: ViewMode; label: string }[] = [ { key: "daily", label: "Daily" }, ]; -function formatDate(dateStr: string) { - const d = new Date(dateStr + "T00:00:00"); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); -} - function formatUsd(value: number) { return `$${value.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; } diff --git a/apps/web/app/admin/components/NorthStarChart.tsx b/apps/web/app/admin/components/NorthStarChart.tsx index 43199cdb..e3877300 100644 --- a/apps/web/app/admin/components/NorthStarChart.tsx +++ b/apps/web/app/admin/components/NorthStarChart.tsx @@ -11,6 +11,7 @@ import { ResponsiveContainer, } from "recharts"; import { useAdminTheme } from "./AdminShell"; +import { formatDateMonDay as formatDate } from "@/lib/utils/dates"; interface SpendRow { date: string; @@ -25,11 +26,6 @@ const RANGES = [ { key: "all", label: "All", days: Infinity }, ] as const; -function formatDate(dateStr: string) { - const d = new Date(dateStr + "T00:00:00"); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); -} - function formatUsd(value: number) { return `$${value.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; } diff --git a/apps/web/app/admin/components/UserSignupsChart.tsx b/apps/web/app/admin/components/UserSignupsChart.tsx index ced7e09a..b0f18f4f 100644 --- a/apps/web/app/admin/components/UserSignupsChart.tsx +++ b/apps/web/app/admin/components/UserSignupsChart.tsx @@ -11,6 +11,7 @@ import { ResponsiveContainer, } from "recharts"; import { useAdminTheme } from "./AdminShell"; +import { formatDateMonDay as formatDate } from "@/lib/utils/dates"; interface SignupRow { date: string; @@ -23,11 +24,6 @@ const RANGES = [ { key: "all", label: "All", days: Infinity }, ] as const; -function formatDate(dateStr: string) { - const d = new Date(dateStr + "T00:00:00"); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); -} - function Skeleton() { return (
diff --git a/apps/web/app/api/embed/[username]/svg/route.ts b/apps/web/app/api/embed/[username]/svg/route.ts index bb99f5b3..f243f1e6 100644 --- a/apps/web/app/api/embed/[username]/svg/route.ts +++ b/apps/web/app/api/embed/[username]/svg/route.ts @@ -1,4 +1,5 @@ import { NextRequest } from "next/server"; +import { formatTokens } from "@straude/shared/format"; import { getServiceClient } from "@/lib/supabase/service"; import { getProfileShareCardData } from "@/lib/share-assets/profile-card-data"; import { @@ -20,13 +21,6 @@ function esc(str: string): string { .replace(/"/g, """); } -function formatTokens(n: number): string { - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} - // ── SVG gradient workaround ──────────────────────────────────────── // SVG doesn't support CSS `linear-gradient()`. We use a // def for the warm background matching the stats card. diff --git a/apps/web/app/api/users/me/route.ts b/apps/web/app/api/users/me/route.ts index 512cfb81..fdafa70d 100644 --- a/apps/web/app/api/users/me/route.ts +++ b/apps/web/app/api/users/me/route.ts @@ -37,8 +37,8 @@ function normalizeProfileLink(value: unknown): string | null { let parsed: URL; try { parsed = new URL(link); - } catch { - throw new Error("Profile link must be a valid URL"); + } catch (err) { + throw new Error("Profile link must be a valid URL", { cause: err }); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { diff --git a/apps/web/app/recap/[username]/page.tsx b/apps/web/app/recap/[username]/page.tsx index 9ea2494b..66589b3c 100644 --- a/apps/web/app/recap/[username]/page.tsx +++ b/apps/web/app/recap/[username]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; import { getServiceClient } from "@/lib/supabase/service"; -import { getRecapData } from "@/lib/utils/recap"; +import { fillContributionDays, getRecapData } from "@/lib/utils/recap"; import { formatCurrency, formatTokens, getCellColor } from "@/lib/utils/format"; import type { Metadata } from "next"; import Link from "next/link"; @@ -61,7 +61,11 @@ export default async function PublicRecapPage({ profile.streak_freezes ?? 0 ); - const allDays = fillDays(data.contribution_data, data.total_days, data.period); + const allDays = fillContributionDays( + data.contribution_data, + data.total_days, + data.period, + ); return (
@@ -228,36 +232,3 @@ export default async function PublicRecapPage({ ); } -function fillDays( - data: { date: string; cost_usd: number }[], - totalDays: number, - period: "week" | "month" -): { date: string; cost_usd: number }[] { - const lookup = new Map(data.map((d) => [d.date, d.cost_usd])); - const now = new Date(); - let start: Date; - - if (period === "week") { - const day = now.getDay(); - const mondayOffset = day === 0 ? -6 : 1 - day; - start = new Date(now); - start.setDate(now.getDate() + mondayOffset); - } else { - start = new Date(now.getFullYear(), now.getMonth(), 1); - } - - // Cap at today — don't render future days - const msPerDay = 86400000; - const daysSinceStart = - Math.floor((now.getTime() - start.getTime()) / msPerDay) + 1; - const cappedDays = Math.min(totalDays, daysSinceStart); - - const result: { date: string; cost_usd: number }[] = []; - for (let i = 0; i < cappedDays; i++) { - const d = new Date(start); - d.setDate(start.getDate() + i); - const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - result.push({ date: key, cost_usd: lookup.get(key) ?? 0 }); - } - return result; -} diff --git a/apps/web/components/app/feed/ActivityCard.tsx b/apps/web/components/app/feed/ActivityCard.tsx index 71aa3acc..68d54176 100644 --- a/apps/web/components/app/feed/ActivityCard.tsx +++ b/apps/web/components/app/feed/ActivityCard.tsx @@ -7,6 +7,8 @@ import { Avatar } from "@/components/ui/Avatar"; import { ImageGrid } from "@/components/app/shared/ImageGrid"; import { ImageLightbox } from "@/components/app/shared/ImageLightbox"; import { ShareMenu } from "./ShareMenu"; +import { prettifyModel } from "@straude/shared/models"; +export { prettifyModel } from "@straude/shared/models"; import { cn } from "@/lib/utils/cn"; import { formatCurrency, formatTokens } from "@/lib/utils/format"; import { mentionsToMarkdownLinks } from "@/lib/utils/mentions"; @@ -54,26 +56,6 @@ function timeAgo(dateStr: string, usageDate?: string | null) { return createdAt.toLocaleDateString("en-US", { month: "short", day: "numeric" }); } -export function prettifyModel(model: string): string { - const normalized = model.trim(); - if (/claude-opus-4/i.test(normalized)) return "Claude Opus"; - if (/claude-sonnet-4/i.test(normalized)) return "Claude Sonnet"; - if (/claude-haiku-4/i.test(normalized)) return "Claude Haiku"; - // Preserve full OpenAI model names (e.g. gpt-5.3-codex -> GPT-5.3-Codex) - if (/^gpt-/i.test(normalized)) { - return normalized - .replace(/^gpt/i, "GPT") - .replace(/-codex$/i, "-Codex"); - } - if (/^o4/i.test(normalized)) return "o4"; - if (/^o3/i.test(normalized)) return "o3"; - // Legacy: broader Claude matching - if (normalized.includes("opus")) return "Claude Opus"; - if (normalized.includes("sonnet")) return "Claude Sonnet"; - if (normalized.includes("haiku")) return "Claude Haiku"; - return normalized; -} - function formatModels( models: string[] | undefined, breakdown: ModelBreakdownEntry[] | null | undefined, diff --git a/apps/web/components/app/profile/ContributionGraph.tsx b/apps/web/components/app/profile/ContributionGraph.tsx index a61f2b9b..1ded7079 100644 --- a/apps/web/components/app/profile/ContributionGraph.tsx +++ b/apps/web/components/app/profile/ContributionGraph.tsx @@ -3,6 +3,7 @@ import { useState, useCallback } from "react"; import { cn } from "@/lib/utils/cn"; import { formatCurrency, getCellColor } from "@/lib/utils/format"; +import { formatDateKey } from "@/lib/utils/dates"; import { getHeatmapLegend } from "@/lib/share-assets/heatmap"; import type { ContributionDay } from "@/types"; @@ -14,13 +15,6 @@ const STEP = CELL_SIZE + GAP; const DAYS = 7; const MONTH_LABEL_HEIGHT = 16; -function formatDateKey(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} - function formatDateLabel(d: Date): string { return d.toLocaleDateString("en-US", { month: "long", diff --git a/apps/web/components/app/profile/ProfileSharePanel.tsx b/apps/web/components/app/profile/ProfileSharePanel.tsx index c30b9f07..31e30353 100644 --- a/apps/web/components/app/profile/ProfileSharePanel.tsx +++ b/apps/web/components/app/profile/ProfileSharePanel.tsx @@ -3,10 +3,7 @@ import Image from "next/image"; import { useEffect, useMemo, useState } from "react"; import { Check, Copy, Download, ImageIcon } from "lucide-react"; -import { - buildProfileIntentUrl, - buildProfileShareUrl, -} from "@/lib/utils/profile-share"; +import { buildProfileIntentUrl } from "@/lib/utils/profile-share"; export function ProfileSharePanel({ username, @@ -43,7 +40,8 @@ export function ProfileSharePanel({ useEffect(() => { setPublicUrl( - shareUrlOverride ?? buildProfileShareUrl(window.location.origin, username) + shareUrlOverride ?? + new URL(`/stats/${username}`, window.location.origin).toString() ); setSupportsClipboardImage( typeof ClipboardItem !== "undefined" && diff --git a/apps/web/components/app/prompts/SubmitPromptWidget.tsx b/apps/web/components/app/prompts/SubmitPromptWidget.tsx index 6a8279c6..03b4e61e 100644 --- a/apps/web/components/app/prompts/SubmitPromptWidget.tsx +++ b/apps/web/components/app/prompts/SubmitPromptWidget.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { X, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/Button"; import { Textarea } from "@/components/ui/Textarea"; +import { useFocusTrap } from "@/components/app/shared/useFocusTrap"; import { timeAgo } from "@/lib/utils/format"; const MAX_PROMPT_LENGTH = 2000; @@ -71,27 +72,7 @@ export function SubmitPromptWidget({ username }: SubmitPromptWidgetProps) { } }, [open]); - useEffect(() => { - if (!open) return; - function onFocusTrap(e: KeyboardEvent) { - if (e.key !== "Tab" || !dialogRef.current) return; - const focusable = dialogRef.current.querySelectorAll( - 'button, textarea, input, [href], [tabindex]:not([tabindex="-1"])', - ); - if (focusable.length === 0) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - } - document.addEventListener("keydown", onFocusTrap); - return () => document.removeEventListener("keydown", onFocusTrap); - }, [open]); + useFocusTrap(dialogRef, open); useEffect(() => { if (!flash) return; diff --git a/apps/web/components/app/recap/RecapCard.tsx b/apps/web/components/app/recap/RecapCard.tsx index 64d32c01..01b890e0 100644 --- a/apps/web/components/app/recap/RecapCard.tsx +++ b/apps/web/components/app/recap/RecapCard.tsx @@ -1,47 +1,13 @@ "use client"; import { formatCurrency, formatTokens, getCellColor } from "@/lib/utils/format"; -import type { RecapData } from "@/lib/utils/recap"; +import { fillContributionDays, type RecapData } from "@/lib/utils/recap"; import { getBackgroundById, getPalette, DEFAULT_BACKGROUND_ID, } from "@/lib/recap-backgrounds"; -function fillDays( - data: { date: string; cost_usd: number }[], - totalDays: number, - period: "week" | "month" -): { date: string; cost_usd: number }[] { - const lookup = new Map(data.map((d) => [d.date, d.cost_usd])); - const now = new Date(); - let start: Date; - - if (period === "week") { - const day = now.getDay(); - const mondayOffset = day === 0 ? -6 : 1 - day; - start = new Date(now); - start.setDate(now.getDate() + mondayOffset); - } else { - start = new Date(now.getFullYear(), now.getMonth(), 1); - } - - // Cap at today — don't render future days - const msPerDay = 86400000; - const daysSinceStart = - Math.floor((now.getTime() - start.getTime()) / msPerDay) + 1; - const cappedDays = Math.min(totalDays, daysSinceStart); - - const result: { date: string; cost_usd: number }[] = []; - for (let i = 0; i < cappedDays; i++) { - const d = new Date(start); - d.setDate(start.getDate() + i); - const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - result.push({ date: key, cost_usd: lookup.get(key) ?? 0 }); - } - return result; -} - export function RecapCard({ data, backgroundId, @@ -49,7 +15,11 @@ export function RecapCard({ data: RecapData; backgroundId?: string; }) { - const allDays = fillDays(data.contribution_data, data.total_days, data.period); + const allDays = fillContributionDays( + data.contribution_data, + data.total_days, + data.period, + ); const bg = getBackgroundById(backgroundId ?? DEFAULT_BACKGROUND_ID); const palette = getPalette(bg); diff --git a/apps/web/components/app/shared/ImageLightbox.tsx b/apps/web/components/app/shared/ImageLightbox.tsx index 1f58a950..955f2700 100644 --- a/apps/web/components/app/shared/ImageLightbox.tsx +++ b/apps/web/components/app/shared/ImageLightbox.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import Image from "next/image"; import { X, ChevronLeft, ChevronRight } from "lucide-react"; +import { useFocusTrap } from "@/components/app/shared/useFocusTrap"; interface ImageLightboxProps { images: string[]; @@ -54,26 +55,7 @@ export function ImageLightbox({ images, initialIndex, onClose }: ImageLightboxPr }, []); // Trap focus within lightbox - useEffect(() => { - function handleFocusTrap(e: KeyboardEvent) { - if (e.key !== "Tab" || !dialogRef.current) return; - const focusable = dialogRef.current.querySelectorAll( - 'button, [href], [tabindex]:not([tabindex="-1"])' - ); - if (focusable.length === 0) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - } - document.addEventListener("keydown", handleFocusTrap); - return () => document.removeEventListener("keydown", handleFocusTrap); - }, []); + useFocusTrap(dialogRef, true); // Touch swipe function handleTouchStart(e: React.TouchEvent) { diff --git a/apps/web/components/app/shared/useFocusTrap.ts b/apps/web/components/app/shared/useFocusTrap.ts new file mode 100644 index 00000000..72d55ad8 --- /dev/null +++ b/apps/web/components/app/shared/useFocusTrap.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, type RefObject } from "react"; + +const FOCUSABLE_SELECTOR = + 'button, textarea, input, [href], [tabindex]:not([tabindex="-1"])'; + +/** + * Trap Tab focus inside a container while `enabled` is true. + * + * On Tab from the last focusable element, focus wraps to the first; on + * Shift+Tab from the first, focus wraps to the last. If the container has no + * focusable descendants the listener is a no-op. + * + * Mirrors the inline implementation that previously lived in + * `SubmitPromptWidget`, `ImageLightbox`, and `SuggestCompanyWidget`. + */ +export function useFocusTrap( + containerRef: RefObject, + enabled: boolean, +): void { + useEffect(() => { + if (!enabled) return; + function onFocusTrap(e: KeyboardEvent) { + if (e.key !== "Tab" || !containerRef.current) return; + const focusable = + containerRef.current.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + document.addEventListener("keydown", onFocusTrap); + return () => document.removeEventListener("keydown", onFocusTrap); + }, [containerRef, enabled]); +} diff --git a/apps/web/components/app/token-rich/SuggestCompanyWidget.tsx b/apps/web/components/app/token-rich/SuggestCompanyWidget.tsx index 402728cb..005ab8df 100644 --- a/apps/web/components/app/token-rich/SuggestCompanyWidget.tsx +++ b/apps/web/components/app/token-rich/SuggestCompanyWidget.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { X } from "lucide-react"; import { Button } from "@/components/ui/Button"; import { Textarea } from "@/components/ui/Textarea"; +import { useFocusTrap } from "@/components/app/shared/useFocusTrap"; interface SuggestCompanyWidgetProps { isLoggedIn: boolean; @@ -60,27 +61,7 @@ export function SuggestCompanyWidget({ isLoggedIn, children }: SuggestCompanyWid }, [open]); // Focus trap - useEffect(() => { - if (!open) return; - function onFocusTrap(e: KeyboardEvent) { - if (e.key !== "Tab" || !dialogRef.current) return; - const focusable = dialogRef.current.querySelectorAll( - 'button, textarea, input, [href], [tabindex]:not([tabindex="-1"])', - ); - if (focusable.length === 0) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - } - document.addEventListener("keydown", onFocusTrap); - return () => document.removeEventListener("keydown", onFocusTrap); - }, [open]); + useFocusTrap(dialogRef, open); // Flash timeout useEffect(() => { diff --git a/apps/web/components/landing/GlobalFeed.tsx b/apps/web/components/landing/GlobalFeed.tsx deleted file mode 100644 index 37160ed2..00000000 --- a/apps/web/components/landing/GlobalFeed.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import Link from "next/link"; -import { createClient } from "@/lib/supabase/server"; -import { formatTokens, timeAgo, getInitials } from "@/lib/utils/format"; - -/** ISO date string for N days ago */ -function daysAgo(n: number): string { - const d = new Date(); - d.setDate(d.getDate() - n); - return d.toISOString(); -} - -/** Current ISO week number */ -function isoWeek(): number { - const d = new Date(); - d.setHours(0, 0, 0, 0); - d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7)); - const week1 = new Date(d.getFullYear(), 0, 4); - return ( - 1 + - Math.round( - ((d.getTime() - week1.getTime()) / 86_400_000 - - 3 + - ((week1.getDay() + 6) % 7)) / - 7 - ) - ); -} - -interface FeedPost { - id: string; - title: string | null; - created_at: string; - user: { display_name: string | null; username: string; is_public: boolean }; - daily_usage: { models: string[]; total_tokens: number; cost_usd: number } | null; -} - -interface LeaderRow { - username: string; - total_output_tokens: number; -} - -export async function GlobalFeed() { - let feedItems: Array<{ - id: string; - initials: string; - title: string; - model: string; - tokens: string; - time: string; - }> = []; - - let leaderboard: Array<{ - rank: string; - handle: string; - score: string; - }> = []; - - try { - const supabase = await createClient(); - - const [{ data: posts }, { data: leaders }] = await Promise.all([ - supabase - .from("posts") - .select( - "id, title, created_at, user:users!posts_user_id_fkey!inner(display_name, username, is_public), daily_usage:daily_usage!posts_daily_usage_id_fkey(models, total_tokens, cost_usd)" - ) - .eq("user.is_public", true) - .gte("created_at", daysAgo(7)) - .order("created_at", { ascending: false }) - .limit(20), - supabase - .from("leaderboard_weekly") - .select("username, total_output_tokens") - .order("total_cost", { ascending: false }) - .limit(5), - ]); - - if (posts) { - // Top 3 by spend, then display newest-first - const top3 = ([...posts] as unknown as FeedPost[]) - .sort( - (a, b) => - (b.daily_usage?.cost_usd ?? 0) - (a.daily_usage?.cost_usd ?? 0) - ) - .slice(0, 3) - .sort( - (a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ); - - feedItems = top3.map((p) => ({ - id: p.id, - initials: getInitials(p.user?.display_name ?? p.user?.username), - title: p.title || "Untitled session", - model: p.daily_usage?.models?.[0] ?? "Unknown", - tokens: formatTokens(p.daily_usage?.total_tokens ?? 0), - time: timeAgo(p.created_at), - })); - } - - if (leaders) { - leaderboard = (leaders as LeaderRow[]).map((l, i) => ({ - rank: String(i + 1).padStart(2, "0"), - handle: `@${l.username}`, - score: formatTokens(l.total_output_tokens ?? 0), - })); - } - } catch { - // Supabase unavailable at build time — render empty - } - - const weekNum = isoWeek(); - - return ( -
- {/* Feed column */} -
-
-

- GLOBAL_FEED.LOG -

- - LIVE - -
- - {feedItems.map((item) => ( - -
- {item.initials} -
-
-
- {item.title} -
-
- - Model: {item.model} - - - Tokens: {item.tokens} - -
-
-
- {item.time} -
- - ))} - - - > LOAD_MORE - -
- - {/* Leaderboard column */} -
-
-

- TOP_CODERS // WK_{weekNum} -

-
- -
- {leaderboard.map((row) => ( - -
- {row.rank} - {row.handle} -
- {row.score} - - ))} -
-
-
- ); -} diff --git a/apps/web/lib/admin.ts b/apps/web/lib/admin.ts index e782f97d..e4a42412 100644 --- a/apps/web/lib/admin.ts +++ b/apps/web/lib/admin.ts @@ -3,6 +3,4 @@ const adminIds = (process.env.ADMIN_USER_IDS ?? "") .map((id) => id.trim()) .filter(Boolean); -export function isAdmin(userId: string): boolean { - return adminIds.includes(userId); -} +export const isAdmin = (userId: string) => adminIds.includes(userId); diff --git a/apps/web/lib/api/cli-auth.ts b/apps/web/lib/api/cli-auth.ts index 875b72d2..027f0401 100644 --- a/apps/web/lib/api/cli-auth.ts +++ b/apps/web/lib/api/cli-auth.ts @@ -77,6 +77,7 @@ export function verifyCliToken(authHeader: string | null): string | null { // Decode and check expiry let decoded: JwtPayload; + // malformed JWT payload — treat as anonymous try { decoded = JSON.parse(base64urlDecode(payload)); } catch { diff --git a/apps/web/lib/comments.ts b/apps/web/lib/comments.ts index 8544a45b..65aa6c8b 100644 --- a/apps/web/lib/comments.ts +++ b/apps/web/lib/comments.ts @@ -53,7 +53,7 @@ export async function loadPostComments(opts: { return { comments, nextCursor, error: null }; } -export function enrichComments( +function enrichComments( comments: Comment[], reactionRows: CommentReactionRow[], viewerId?: string | null, diff --git a/apps/web/lib/email/notification-email.tsx b/apps/web/lib/email/notification-email.tsx index f5384f59..dc28b649 100644 --- a/apps/web/lib/email/notification-email.tsx +++ b/apps/web/lib/email/notification-email.tsx @@ -28,7 +28,7 @@ function truncate(str: string, max: number): string { return str.length > max ? str.slice(0, max) + "..." : str; } -function subjectLine(type: NotificationType, actor: string): string { +export function subjectLine(type: NotificationType, actor: string): string { switch (type) { case "comment": return `${actor} commented on your post`; @@ -55,10 +55,6 @@ function previewText( } } -export function buildSubject(type: NotificationType, actor: string): string { - return subjectLine(type, actor); -} - export default function NotificationEmail({ actorUsername, type, diff --git a/apps/web/lib/email/send-comment-email.ts b/apps/web/lib/email/send-comment-email.ts index 2c58a7ab..c02b9a6d 100644 --- a/apps/web/lib/email/send-comment-email.ts +++ b/apps/web/lib/email/send-comment-email.ts @@ -1,7 +1,7 @@ import { getResend } from "./resend"; import { createUnsubscribeToken } from "./unsubscribe"; import NotificationEmail, { - buildSubject, + subjectLine, type NotificationType, } from "./notification-email"; @@ -52,7 +52,7 @@ export async function sendNotificationEmail({ from: `Straude <${fromEmail}>`, replyTo: "hey@straude.com", to: recipientEmail, - subject: buildSubject(type, actorUsername), + subject: subjectLine(type, actorUsername), react: NotificationEmail({ actorUsername, type, diff --git a/apps/web/lib/open-stats.ts b/apps/web/lib/open-stats.ts index 708421ff..f488febc 100644 --- a/apps/web/lib/open-stats.ts +++ b/apps/web/lib/open-stats.ts @@ -1,7 +1,6 @@ +import { prettifyModel } from "@straude/shared/models"; import { getServiceClient } from "@/lib/supabase/service"; -export const OPEN_STATS_REVALIDATE_SECONDS = 86_400; - const OPEN_STATS_SNAPSHOT_TABLE = "open_stats_snapshots"; const DAY_MS = 86_400_000; @@ -181,22 +180,6 @@ function throwIfSupabaseError(label: string, error: SupabaseErrorLike) { throw new Error(`${label}: ${details}`); } -export function prettifyModel(model: string): string { - const normalized = model.trim(); - if (/claude-opus-4/i.test(normalized)) return "Claude Opus"; - if (/claude-sonnet-4/i.test(normalized)) return "Claude Sonnet"; - if (/claude-haiku-4/i.test(normalized)) return "Claude Haiku"; - if (/^gpt-/i.test(normalized)) { - return normalized.replace(/^gpt/i, "GPT").replace(/-codex$/i, "-Codex"); - } - if (/^o4/i.test(normalized)) return "o4"; - if (/^o3/i.test(normalized)) return "o3"; - if (normalized.includes("opus")) return "Claude Opus"; - if (normalized.includes("sonnet")) return "Claude Sonnet"; - if (normalized.includes("haiku")) return "Claude Haiku"; - return normalized; -} - function buildOpenStats(params: { usageRows: UsageRow[]; concentrationRows: unknown; @@ -423,7 +406,10 @@ export async function getOpenStatsForPage( try { await persistOpenStatsSnapshot(db, liveStats); } catch (error) { - console.error(error); + // TODO(observability): forward to PostHog server-side capture once a + // helper exists (context: "open-stats:snapshot-write"). For now, log + // so prod failures are visible in server logs. + console.error("open stats snapshot write failed:", error); } return liveStats; @@ -432,11 +418,15 @@ export async function getOpenStatsForPage( const snapshot = await readLatestOpenStatsSnapshot(db); if (snapshot) return snapshot; } catch (snapshotError) { + // TODO(observability): forward to PostHog server-side capture once a + // helper exists (context: "open-stats:snapshot-fallback"). console.error("open stats snapshot fallback failed:", snapshotError); } // Both live and snapshot failed (e.g. Supabase unreachable in CI). // Return an empty placeholder so the build doesn't crash. + // TODO(observability): forward to PostHog server-side capture once a + // helper exists (context: "open-stats:all-sources-failed"). console.error("open stats: all sources failed, returning placeholder", liveError); const now = new Date().toISOString(); return { diff --git a/apps/web/lib/performance/interaction.ts b/apps/web/lib/performance/interaction.ts deleted file mode 100644 index a078b5b1..00000000 --- a/apps/web/lib/performance/interaction.ts +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import posthog from "posthog-js"; - -type MeasureMetadata = Record; - -const PREFIX = "straude:interaction"; - -function markName(name: string, point: "start" | "ready") { - return `${PREFIX}:${name}:${point}`; -} - -export function markInteractionStart(name: string) { - if (typeof performance === "undefined" || !performance.mark) return; - performance.mark(markName(name, "start")); -} - -export function markInteractionReady(name: string) { - if (typeof performance === "undefined" || !performance.mark) return; - performance.mark(markName(name, "ready")); -} - -export function measureInteraction( - name: string, - metadata: MeasureMetadata = {}, -): number | null { - if ( - typeof performance === "undefined" || - !performance.measure || - !performance.getEntriesByName - ) { - return null; - } - - const start = markName(name, "start"); - const ready = markName(name, "ready"); - const measure = `${PREFIX}:${name}`; - - try { - performance.measure(measure, start, ready); - } catch { - return null; - } - - const entry = performance.getEntriesByName(measure).at(-1); - const duration = entry?.duration ?? null; - - if (duration === null) { - return null; - } - - const payload = { name, duration_ms: duration, ...metadata }; - - if (posthog.__loaded) { - posthog.capture("interaction_ready", payload); - } - - if (process.env.NODE_ENV === "development") { - console.debug("[performance] interaction_ready", payload); - } - - return duration; -} diff --git a/apps/web/lib/share-assets/github-card-data.ts b/apps/web/lib/share-assets/github-card-data.ts index dde844a1..32a98839 100644 --- a/apps/web/lib/share-assets/github-card-data.ts +++ b/apps/web/lib/share-assets/github-card-data.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { prettifyModel } from "@/lib/utils/post-share"; +import { formatDateKey } from "@/lib/utils/dates"; export interface GithubCardData { username: string; @@ -21,13 +22,6 @@ interface ProfileRow { is_public: boolean; } -function formatDate(date: Date) { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, "0"); - const d = String(date.getDate()).padStart(2, "0"); - return `${y}-${m}-${d}`; -} - function resolvePrimaryModel( rows: Array<{ models: string[] | null }> ): string { @@ -58,7 +52,7 @@ export async function getGithubCardData( const thirtyDaysAgo = new Date(today); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29); - const thirtyDaysAgoStr = formatDate(thirtyDaysAgo); + const thirtyDaysAgoStr = formatDateKey(thirtyDaysAgo); const [ { data: streakData }, @@ -72,7 +66,7 @@ export async function getGithubCardData( .from("daily_usage") .select("date, cost_usd, models") .eq("user_id", profile.id) - .gte("date", formatDate(heatmapStart)) + .gte("date", formatDateKey(heatmapStart)) .order("date", { ascending: true }), supabase .from("daily_usage") diff --git a/apps/web/lib/share-assets/heatmap.ts b/apps/web/lib/share-assets/heatmap.ts index 5afb5338..e9b3d3fc 100644 --- a/apps/web/lib/share-assets/heatmap.ts +++ b/apps/web/lib/share-assets/heatmap.ts @@ -1,3 +1,5 @@ +import { formatDateKey } from "@/lib/utils/dates"; + export interface HeatmapContributionDay { date: string; cost_usd: number; @@ -29,13 +31,6 @@ const SHORT_MONTHS = [ "Dec", ] as const; -function formatDateKey(date: Date): string { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, "0"); - const d = String(date.getDate()).padStart(2, "0"); - return `${y}-${m}-${d}`; -} - export function getHeatmapCellColor(cost: number): string { if (cost <= 0) return "#EAE2D7"; if (cost <= 10) return "#F6CEAF"; diff --git a/apps/web/lib/share-assets/post-card-image.tsx b/apps/web/lib/share-assets/post-card-image.tsx index e3dbb65e..f4f5c4da 100644 --- a/apps/web/lib/share-assets/post-card-image.tsx +++ b/apps/web/lib/share-assets/post-card-image.tsx @@ -1,5 +1,6 @@ import { formatCurrency, formatTokens } from "@/lib/utils/format"; import { getShareModelLabel } from "@/lib/utils/post-share"; +import { stripMarkdown } from "@/lib/utils/strip-markdown"; import { getShareTheme, type ShareThemeId } from "@/lib/share-themes"; interface SharePostData { @@ -15,25 +16,6 @@ interface SharePostData { is_verified: boolean; } -function stripMarkdown(text: string): string { - return text - .replace(/#{1,6}\s+/g, "") - .replace(/\*\*(.+?)\*\*/g, "$1") - .replace(/\*(.+?)\*/g, "$1") - .replace(/__(.+?)__/g, "$1") - .replace(/_(.+?)_/g, "$1") - .replace(/~~(.+?)~~/g, "$1") - .replace(/`(.+?)`/g, "$1") - .replace(/\[(.+?)\]\(.+?\)/g, "$1") - .replace(/!\[.*?\]\(.+?\)/g, "") - .replace(/^[-*+]\s+/gm, "") - .replace(/^\d+\.\s+/gm, "") - .replace(/^>\s+/gm, "") - .replace(/\n{2,}/g, " ") - .replace(/\n/g, " ") - .trim(); -} - function truncate(text: string, max: number): string { if (text.length <= max) return text; return `${text.slice(0, max - 3)}...`; diff --git a/apps/web/lib/share-assets/profile-card-data.ts b/apps/web/lib/share-assets/profile-card-data.ts index 3f2e46ed..1954d93d 100644 --- a/apps/web/lib/share-assets/profile-card-data.ts +++ b/apps/web/lib/share-assets/profile-card-data.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { prettifyModel } from "@/lib/utils/post-share"; +import { formatDateKey } from "@/lib/utils/dates"; export interface ProfileShareCardData { username: string; @@ -20,13 +21,6 @@ interface ProfileRow { is_public: boolean; } -function formatDate(date: Date) { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, "0"); - const d = String(date.getDate()).padStart(2, "0"); - return `${y}-${m}-${d}`; -} - function resolvePrimaryModel( rows: Array<{ models: string[] | null }> ): string { @@ -64,7 +58,7 @@ export async function getProfileShareCardData( .from("daily_usage") .select("date, cost_usd") .eq("user_id", profile.id) - .gte("date", formatDate(heatmapStart)) + .gte("date", formatDateKey(heatmapStart)) .order("date", { ascending: true }), supabase .from("daily_usage") @@ -74,7 +68,7 @@ export async function getProfileShareCardData( .from("daily_usage") .select("date, output_tokens, models") .eq("user_id", profile.id) - .gte("date", formatDate(recentStart)), + .gte("date", formatDateKey(recentStart)), supabase.rpc("calculate_user_streak", { p_user_id: profile.id }), ]); diff --git a/apps/web/lib/storage.ts b/apps/web/lib/storage.ts index aae380ff..e1c68281 100644 --- a/apps/web/lib/storage.ts +++ b/apps/web/lib/storage.ts @@ -30,7 +30,7 @@ export function isStoragePathOwnedByUser(path: string, userId: string): boolean return isValidStoragePath(path) && path.startsWith(`${userId}/`); } -export function extractPublicStoragePath( +function extractPublicStoragePath( url: string, bucket: StorageBucket, ): string | null { diff --git a/apps/web/lib/supabase/env.ts b/apps/web/lib/supabase/env.ts index a80766e9..ae110ebb 100644 --- a/apps/web/lib/supabase/env.ts +++ b/apps/web/lib/supabase/env.ts @@ -36,18 +36,10 @@ export function getMissingSupabaseBrowserEnv(): BrowserEnvKey[] { return getMissing(BROWSER_ENV_KEYS, browserEnv) as BrowserEnvKey[]; } -export function getMissingSupabaseServerEnv(): ServerEnvKey[] { +function getMissingSupabaseServerEnv(): ServerEnvKey[] { return getMissing(SERVER_ENV_KEYS, serverEnv) as ServerEnvKey[]; } -export function hasSupabaseBrowserEnv() { - return getMissingSupabaseBrowserEnv().length === 0; -} - -export function hasSupabaseServerEnv() { - return getMissingSupabaseServerEnv().length === 0; -} - export function formatSupabaseEnvHelp(missing: readonly string[]) { const joined = missing.map((key) => `- ${key}`).join("\n"); return [ diff --git a/apps/web/lib/theme.ts b/apps/web/lib/theme.ts index 5fab4fda..51e32f76 100644 --- a/apps/web/lib/theme.ts +++ b/apps/web/lib/theme.ts @@ -7,7 +7,7 @@ export const THEME_ATTRIBUTE = "data-theme"; export const THEME_META_COLOR_LIGHT = "#fcfbf7"; export const THEME_META_COLOR_DARK = "#050505"; -export function isThemePreference(value: string | null | undefined): value is ThemePreference { +function isThemePreference(value: string | null | undefined): value is ThemePreference { return value === "light" || value === "dark" || value === "system"; } diff --git a/apps/web/lib/utils/dates.ts b/apps/web/lib/utils/dates.ts new file mode 100644 index 00000000..06029b74 --- /dev/null +++ b/apps/web/lib/utils/dates.ts @@ -0,0 +1,31 @@ +/** + * Local-time date helpers shared across web-only callers. + * + * - `formatDateMonDay` matches the admin chart axis/tooltip format: "Mon DD" + * (e.g. "Apr 12"). When given a `YYYY-MM-DD` string it parses as local + * midnight (`+ "T00:00:00"`) so the displayed day matches the date key. + * - `formatDateKey` returns a `YYYY-MM-DD` string in the **local** timezone. + * This is the same format used by the contribution graph, recap, heatmap, + * and the share-asset card data builders. + */ + +function toDate(input: string | Date): Date { + if (input instanceof Date) return input; + // Strings like "2025-04-12" are intentionally parsed as local midnight to + // avoid the off-by-one that `new Date("2025-04-12")` (UTC) would cause when + // rendered in a negative offset timezone. + return new Date(input + "T00:00:00"); +} + +export function formatDateMonDay(input: string | Date): string { + const d = toDate(input); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +export function formatDateKey(input: string | Date): string { + const d = input instanceof Date ? input : toDate(input); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} diff --git a/apps/web/lib/utils/format.ts b/apps/web/lib/utils/format.ts index 50f88a08..b1d87147 100644 --- a/apps/web/lib/utils/format.ts +++ b/apps/web/lib/utils/format.ts @@ -1,4 +1,5 @@ -import { getHeatmapCellColor } from "@/lib/share-assets/heatmap"; +export { formatTokens } from "@straude/shared/format"; +export { getHeatmapCellColor as getCellColor } from "@/lib/share-assets/heatmap"; export function formatCurrency(n: number | null | undefined): string { const value = Number(n ?? 0); @@ -8,13 +9,6 @@ export function formatCurrency(n: number | null | undefined): string { }); } -export function formatTokens(n: number): string { - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} - export function timeAgo(dateStr: string): string { const ms = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(ms / 60_000); @@ -36,7 +30,3 @@ export function getInitials(name: string | null): string { .join(""); } -/** Color scale for contribution graph cells */ -export function getCellColor(cost: number): string { - return getHeatmapCellColor(cost); -} diff --git a/apps/web/lib/utils/post-share.ts b/apps/web/lib/utils/post-share.ts index 3c5e639b..f3b3f807 100644 --- a/apps/web/lib/utils/post-share.ts +++ b/apps/web/lib/utils/post-share.ts @@ -1,6 +1,9 @@ import type { DailyUsage, Post, User } from "@/types"; +import { getShareModelLabel } from "@straude/shared/models"; import { formatCurrency, formatTokens } from "./format"; +export { prettifyModel, getShareModelLabel } from "@straude/shared/models"; + type ShareablePost = Pick & { user?: Pick | null; daily_usage?: Pick< @@ -9,35 +12,6 @@ type ShareablePost = Pick & { > | null; }; -export function prettifyModel(model: string): string { - const normalized = model.trim(); - if (/claude-opus-4/i.test(normalized)) return "Claude Opus"; - if (/claude-sonnet-4/i.test(normalized)) return "Claude Sonnet"; - if (/claude-haiku-4/i.test(normalized)) return "Claude Haiku"; - - if (/^gpt-/i.test(normalized)) { - return normalized - .replace(/^gpt/i, "GPT") - .replace(/-codex$/i, "-Codex"); - } - - if (/^o4/i.test(normalized)) return "o4"; - if (/^o3/i.test(normalized)) return "o3"; - return normalized; -} - -export function getShareModelLabel( - models: string[] | null | undefined -): string | null { - if (!models || models.length === 0) return null; - if (models.some((model) => /claude-opus-4/i.test(model))) return "Claude Opus"; - if (models.some((model) => /claude-sonnet-4/i.test(model))) { - return "Claude Sonnet"; - } - if (models.some((model) => /claude-haiku-4/i.test(model))) return "Claude Haiku"; - return prettifyModel(models[0]!); -} - export function buildPostShareUrl(origin: string, postId: string) { return new URL(`/post/${postId}`, origin).toString(); } diff --git a/apps/web/lib/utils/profile-share.ts b/apps/web/lib/utils/profile-share.ts index 9db4829b..02a43b12 100644 --- a/apps/web/lib/utils/profile-share.ts +++ b/apps/web/lib/utils/profile-share.ts @@ -1,8 +1,4 @@ -export function buildProfileShareUrl(origin: string, username: string) { - return new URL(`/stats/${username}`, origin).toString(); -} - -export function buildProfileShareText(username: string) { +function buildProfileShareText(username: string) { return [ `My Claude Code stats on Straude`, `52 weeks of tracked work by @${username}`, @@ -12,7 +8,7 @@ export function buildProfileShareText(username: string) { export function buildProfileIntentUrl(origin: string, username: string) { const params = new URLSearchParams({ text: buildProfileShareText(username), - url: buildProfileShareUrl(origin, username), + url: new URL(`/stats/${username}`, origin).toString(), }); return `https://twitter.com/intent/tweet?${params.toString()}`; diff --git a/apps/web/lib/utils/recap-image.tsx b/apps/web/lib/utils/recap-image.tsx index c15677ae..90910eca 100644 --- a/apps/web/lib/utils/recap-image.tsx +++ b/apps/web/lib/utils/recap-image.tsx @@ -1,4 +1,4 @@ -import type { RecapData } from "./recap"; +import { fillContributionDays, type RecapData } from "./recap"; import { formatCurrency, formatTokens, getCellColor } from "./format"; import { LIGHT_PALETTE, @@ -6,41 +6,6 @@ import { type RecapPalette, } from "@/lib/recap-backgrounds"; -/** Fill in missing days with $0 entries — only up to today (no future days) */ -function fillContributionDays( - data: { date: string; cost_usd: number }[], - totalDays: number, - period: "week" | "month" -): { date: string; cost_usd: number }[] { - const lookup = new Map(data.map((d) => [d.date, d.cost_usd])); - const now = new Date(); - let start: Date; - - if (period === "week") { - const day = now.getDay(); - const mondayOffset = day === 0 ? -6 : 1 - day; - start = new Date(now); - start.setDate(now.getDate() + mondayOffset); - } else { - start = new Date(now.getFullYear(), now.getMonth(), 1); - } - - // Cap at today — don't render future days - const msPerDay = 86400000; - const daysSinceStart = - Math.floor((now.getTime() - start.getTime()) / msPerDay) + 1; - const cappedDays = Math.min(totalDays, daysSinceStart); - - const result: { date: string; cost_usd: number }[] = []; - for (let i = 0; i < cappedDays; i++) { - const d = new Date(start); - d.setDate(start.getDate() + i); - const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - result.push({ date: key, cost_usd: lookup.get(key) ?? 0 }); - } - return result; -} - /** * Renders the recap card JSX for use in next/og ImageResponse. * Supports both landscape (1200x630) and square (1080x1080) formats. diff --git a/apps/web/lib/utils/recap.ts b/apps/web/lib/utils/recap.ts index 2e68588c..779fcd2f 100644 --- a/apps/web/lib/utils/recap.ts +++ b/apps/web/lib/utils/recap.ts @@ -1,4 +1,5 @@ import type { SupabaseClient } from "@supabase/supabase-js"; +import { formatDateKey } from "@/lib/utils/dates"; export interface RecapData { total_cost: number; @@ -59,8 +60,8 @@ function getPeriodRange(period: "week" | "month"): { : `, ${now.getFullYear()}`; return { - start: formatDate(monday), - end: formatDate(sunday), + start: formatDateKey(monday), + end: formatDateKey(sunday), totalDays: 7, label: `My Week in Claude Code · ${fmt(monday)}–${fmt(sunday)}${yearSuffix}`, }; @@ -76,18 +77,50 @@ function getPeriodRange(period: "week" | "month"): { }); return { - start: formatDate(firstDay), - end: formatDate(lastDay), + start: formatDateKey(firstDay), + end: formatDateKey(lastDay), totalDays: lastDay.getDate(), label: `My Month in Claude Code · ${monthName}`, }; } -function formatDate(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; +/** + * Fill in missing days with $0 entries — only up to today (no future days). + * Used by the recap card components and the OG image renderer to produce a + * complete contribution strip for the current week or month. + */ +export function fillContributionDays( + data: { date: string; cost_usd: number }[], + totalDays: number, + period: "week" | "month", +): { date: string; cost_usd: number }[] { + const lookup = new Map(data.map((d) => [d.date, d.cost_usd])); + const now = new Date(); + let start: Date; + + if (period === "week") { + const day = now.getDay(); + const mondayOffset = day === 0 ? -6 : 1 - day; + start = new Date(now); + start.setDate(now.getDate() + mondayOffset); + } else { + start = new Date(now.getFullYear(), now.getMonth(), 1); + } + + // Cap at today — don't render future days + const msPerDay = 86400000; + const daysSinceStart = + Math.floor((now.getTime() - start.getTime()) / msPerDay) + 1; + const cappedDays = Math.min(totalDays, daysSinceStart); + + const result: { date: string; cost_usd: number }[] = []; + for (let i = 0; i < cappedDays; i++) { + const d = new Date(start); + d.setDate(start.getDate() + i); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + result.push({ date: key, cost_usd: lookup.get(key) ?? 0 }); + } + return result; } export async function getRecapData( diff --git a/apps/web/lib/utils/strip-markdown.ts b/apps/web/lib/utils/strip-markdown.ts new file mode 100644 index 00000000..d95ead7a --- /dev/null +++ b/apps/web/lib/utils/strip-markdown.ts @@ -0,0 +1,18 @@ +export function stripMarkdown(text: string): string { + return text + .replace(/#{1,6}\s+/g, "") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/__(.+?)__/g, "$1") + .replace(/_(.+?)_/g, "$1") + .replace(/~~(.+?)~~/g, "$1") + .replace(/`(.+?)`/g, "$1") + .replace(/\[(.+?)\]\(.+?\)/g, "$1") + .replace(/!\[.*?\]\(.+?\)/g, "") + .replace(/^[-*+]\s+/gm, "") + .replace(/^\d+\.\s+/gm, "") + .replace(/^>\s+/gm, "") + .replace(/\n{2,}/g, " ") + .replace(/\n/g, " ") + .trim(); +} diff --git a/apps/web/package.json b/apps/web/package.json index dbbd3f81..ac29cb47 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@base-ui-components/react": "1.0.0-rc.0", "@fal-ai/client": "^1.4.0", "@react-email/components": "^1.0.8", + "@straude/shared": "workspace:*", "@supabase/ssr": "^0.6", "@supabase/supabase-js": "^2", "@tanstack/react-query": "^5.100.5", diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index e4a2e875..578d0e24 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -1,9 +1,4 @@ -import { type NextRequest } from "next/server"; -import { updateSession } from "@/lib/supabase/middleware"; - -export async function proxy(request: NextRequest) { - return await updateSession(request); -} +export { updateSession as proxy } from "@/lib/supabase/middleware"; export const config = { matcher: [ diff --git a/apps/web/scripts/_lib.ts b/apps/web/scripts/_lib.ts new file mode 100644 index 00000000..ec5c6f18 --- /dev/null +++ b/apps/web/scripts/_lib.ts @@ -0,0 +1,16 @@ +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Wipe `dir` (if it exists) and recreate `dir/final/` ready for fresh writes. + * + * Used by the OG candidate generator scripts so each invocation starts from a + * clean slate. Returns the path to the created `final/` directory so callers + * don't have to recompute it. + */ +export async function ensureCleanOutputDir(dir: string): Promise { + await rm(dir, { recursive: true, force: true }); + const finalDir = join(dir, "final"); + await mkdir(finalDir, { recursive: true }); + return finalDir; +} diff --git a/apps/web/scripts/generate-og-athletic-surge.ts b/apps/web/scripts/generate-og-athletic-surge.ts index f5bd2f97..99bdf467 100644 --- a/apps/web/scripts/generate-og-athletic-surge.ts +++ b/apps/web/scripts/generate-og-athletic-surge.ts @@ -1,6 +1,7 @@ import sharp from "sharp"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { ensureCleanOutputDir } from "./_lib"; const WIDTH = 1200; const HEIGHT = 630; @@ -229,11 +230,6 @@ function buildOverlaySvg( `); } -async function ensureCleanOutputDir() { - await rm(OUTPUT_DIR, { recursive: true, force: true }); - await mkdir(FINAL_DIR, { recursive: true }); -} - async function renderVariant( variant: VariantConfig, baseImage: Buffer, @@ -337,7 +333,7 @@ async function main() { readFile(INTER_MEDIUM_PATH), ]); - await ensureCleanOutputDir(); + await ensureCleanOutputDir(OUTPUT_DIR); const entries: ManifestEntry[] = []; for (const variant of VARIANTS) { diff --git a/apps/web/scripts/generate-og-real-users.ts b/apps/web/scripts/generate-og-real-users.ts index d44250af..31db67f3 100644 --- a/apps/web/scripts/generate-og-real-users.ts +++ b/apps/web/scripts/generate-og-real-users.ts @@ -1,8 +1,9 @@ import { fal } from "@fal-ai/client"; import { createClient } from "@supabase/supabase-js"; import sharp from "sharp"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; +import { ensureCleanOutputDir } from "./_lib"; const MODEL_ID = "fal-ai/nano-banana-2/edit"; const WIDTH = 1200; @@ -96,11 +97,6 @@ const VARIANT_FLAVORS = [ "Keep the same layout but make the UI text sharper and the user identity details more legible at social-preview scale.", ] as const; -async function ensureCleanOutputDir() { - await rm(OUTPUT_DIR, { recursive: true, force: true }); - await mkdir(FINAL_DIR, { recursive: true }); -} - async function fetchTopUsers(): Promise<[LeaderUser, LeaderUser, LeaderUser]> { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseSecret = process.env.SUPABASE_SECRET_KEY; @@ -299,7 +295,7 @@ async function main() { fal.config({ credentials: falKey }); - await ensureCleanOutputDir(); + await ensureCleanOutputDir(OUTPUT_DIR); const users = await fetchTopUsers(); const referenceUrls = await prepareReferenceUrls(users); diff --git a/apps/web/scripts/generate-og.ts b/apps/web/scripts/generate-og.ts index 0c5dac49..d55a7a47 100644 --- a/apps/web/scripts/generate-og.ts +++ b/apps/web/scripts/generate-og.ts @@ -1,7 +1,8 @@ import { fal } from "@fal-ai/client"; import sharp from "sharp"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { ensureCleanOutputDir } from "./_lib"; const MODEL_ID = "fal-ai/nano-banana-2"; const WIDTH = 1200; @@ -119,11 +120,6 @@ function escapeXml(value: string): string { .replaceAll("'", "'"); } -async function ensureCleanOutputDir() { - await rm(OUTPUT_DIR, { recursive: true, force: true }); - await mkdir(FINAL_DIR, { recursive: true }); -} - async function downloadImage(url: string): Promise { const response = await fetch(url); if (!response.ok) { @@ -245,7 +241,7 @@ async function main() { fal.config({ credentials: apiKey }); - await ensureCleanOutputDir(); + await ensureCleanOutputDir(OUTPUT_DIR); const entries: ManifestEntry[] = []; for (const variant of VARIANTS) { diff --git a/bun.lock b/bun.lock index 398fc0b7..9b127f96 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "@base-ui-components/react": "1.0.0-rc.0", "@fal-ai/client": "^1.4.0", "@react-email/components": "^1.0.8", + "@straude/shared": "workspace:*", "@supabase/ssr": "^0.6", "@supabase/supabase-js": "^2", "@tanstack/react-query": "^5.100.5", @@ -70,6 +71,7 @@ }, "dependencies": { "@pppp606/ink-chart": "^0.2.4", + "@straude/shared": "workspace:*", "chalk": "^5.6.2", "ink": "^6.8.0", "posthog-node": "^5.29.1", @@ -82,6 +84,13 @@ "vitest": "^4.0.18", }, }, + "packages/shared": { + "name": "@straude/shared", + "version": "0.0.0", + "devDependencies": { + "typescript": "^5", + }, + }, }, "overrides": { "@21st-sdk/node": "0.0.9", @@ -521,6 +530,8 @@ "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@straude/shared": ["@straude/shared@workspace:packages/shared"], + "@straude/web": ["@straude/web@workspace:apps/web"], "@supabase/auth-js": ["@supabase/auth-js@2.95.3", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-vD2YoS8E2iKIX0F7EwXTmqhUpaNsmbU6X2R0/NdFcs02oEfnHyNP/3M716f3wVJ2E5XHGiTFXki6lRckhJ0Thg=="], diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 32459af9..37ea07d1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -26,6 +26,9 @@ - **Replaced GLOBAL_FEED.LOG with Privacy Pledge on landing page.** New section 03 ("Privacy by architecture") lists what Straude cannot access (prompts, code, transcripts), explains the local ccusage pipeline, links to the open-source CLI and full privacy policy. Links to ccusage docs at deepwiki.com for auditability. Removed `GlobalFeed` component from the landing page. - **Landing page performance: Lighthouse 67 → ~85+ (mobile).** Lazy-load `HalftoneCanvas` (ssr: false via client wrapper) and `WallOfLove` (dynamic import). Convert `CTASection` from motion/react to CSS `animate-fade-in-up` (now a server component). Convert `Footer` to server component with tiny `UtcClock` client island. Removes motion/react from critical path. - **WCAG AA contrast for accent backgrounds.** Introduced `accent-foreground` design token (`#1a0500`) replacing hardcoded `text-white` on all `bg-accent` elements. Contrast ratio 5.15:1 vs previous 3.82:1. Updated Button, Badge, and 11 component files. +- **Cross-package helpers consolidated into `@straude/shared`.** `prettifyModel`, `getShareModelLabel`, and `formatTokens` were duplicated across `apps/web` and `packages/cli`; they now live in a new `@straude/shared` workspace package consumed by both surfaces. Single source of truth for model-name normalization and token formatting. +- **Web-only utility duplication removed.** Date formatting, contribution-day filling, the focus-trap hook, and OG-script utilities are now shared modules under `apps/web/lib/utils/` and `apps/web/components/app/shared/` instead of being re-implemented in each consumer. +- **`stripMarkdown` extracted to `apps/web/lib/utils/strip-markdown.ts`.** Previously inlined in multiple share-text builders; now a single utility with unit-test coverage. ### Fixed @@ -50,6 +53,16 @@ - **Missing `rel=canonical` on `/login`.** Added `alternates.canonical` to the auth layout metadata. - **Auth layout missing `
` landmark.** Changed wrapper `
` to `
` for screen readers. - **HalftoneCanvas respects `prefers-reduced-motion`.** WebGL animation loop now renders a single static frame when reduced motion is preferred, and resumes if the preference changes. +- **`getOpenStatsForPage` snapshot read/write failures are now observable.** The fallback path no longer swallows errors silently; failures emit observable events so degraded `/open` and landing-page stats can be detected in production. +- **`users/me` invalid-link rejection preserves the underlying parse error.** When a stored profile link fails URL validation, the response now propagates the original `URL` parse error via `cause` instead of discarding it, making the failure traceable. + +### Removed + +- **Stale root-level artifacts.** `straude-codemap.html`, `prometheus-list.png`, and `posthog-setup-report.md` were committed to the repo root during exploratory work and never referenced — deleted. +- **Pass-through wrappers left over from prior renames.** `proxy` is now a direct re-export of `updateSession`, `getCellColor` is a re-export of `getHeatmapCellColor`, and the unused `buildSubject`, `buildProfileShareUrl`, and `hasCodexLogs` shims were removed. +- **Orphaned source files.** `apps/web/lib/performance/interaction.ts` (zero importers, no `lib/performance` consumers remained), `apps/web/components/landing/GlobalFeed.tsx` (the CHANGELOG had previously announced its removal but the file was never deleted), and `packages/cli/src/lib/codex.ts` (a 7-line re-export shim with no callers — consumers now import from `./codex-native` directly) were deleted. +- **Stray `export` keywords on module-private helpers.** `enrichComments`, `extractPublicStoragePath`, `isThemePreference`, `buildProfileShareText`, and `getMissingSupabaseServerEnv` were exported but only referenced inside their own files. Downgraded to module-private to signal scope honestly. +- **Truly-dead constants and helpers.** Un-exporting also revealed three symbols with zero callers anywhere — `OPEN_STATS_REVALIDATE_SECONDS` (declared but never wired into a `revalidate` site), `hasSupabaseBrowserEnv`, and `hasSupabaseServerEnv`. Removed. ### Added diff --git a/package.json b/package.json index ab53caf5..01221645 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev:local": "bun run local:env && bun --cwd apps/web dev:plain", "build": "turbo build", "lint": "turbo lint", + "typecheck": "turbo typecheck", "local:up": "bunx supabase start", "local:down": "bunx supabase stop", "local:reset": "bunx supabase db reset --yes", diff --git a/packages/cli/__tests__/codex.test.ts b/packages/cli/__tests__/codex.test.ts index cc9d26ff..2a808b1c 100644 --- a/packages/cli/__tests__/codex.test.ts +++ b/packages/cli/__tests__/codex.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { collectCodexUsageAsync, hasCodexLogs } from "../src/lib/codex-native.js"; +import { collectCodexUsageAsync, containsSessionFile } from "../src/lib/codex-native.js"; let homeDir: string; @@ -69,9 +69,9 @@ afterEach(async () => { describe("native Codex collector", () => { it("detects local Codex session logs", async () => { - expect(await hasCodexLogs()).toBe(false); + expect(await containsSessionFile()).toBe(false); await writeSession("2026-04-24", "session.jsonl", [meta("s1")]); - expect(await hasCodexLogs()).toBe(true); + expect(await containsSessionFile()).toBe(true); }); it("does not add last_token_usage when cumulative total_token_usage is unchanged", async () => { diff --git a/packages/cli/__tests__/commands/push.test.ts b/packages/cli/__tests__/commands/push.test.ts index 8e78316f..c5d5548b 100755 --- a/packages/cli/__tests__/commands/push.test.ts +++ b/packages/cli/__tests__/commands/push.test.ts @@ -22,7 +22,7 @@ vi.mock("../../src/lib/ccusage.js", () => ({ vi.mock("../../src/lib/codex-native.js", () => ({ CODEX_NATIVE_COLLECTOR: "straude-codex-native-v1", collectCodexUsageAsync: vi.fn(), - hasCodexLogs: vi.fn(), + containsSessionFile: vi.fn(), })); import { pushCommand, mergeEntries } from "../../src/commands/push.js"; @@ -30,7 +30,7 @@ import { loadConfig, saveConfig } from "../../src/lib/auth.js"; import { loginCommand } from "../../src/commands/login.js"; import { apiRequest } from "../../src/lib/api.js"; import { runCcusageRawAsync, parseCcusageOutput } from "../../src/lib/ccusage.js"; -import { collectCodexUsageAsync, hasCodexLogs } from "../../src/lib/codex-native.js"; +import { collectCodexUsageAsync, containsSessionFile } from "../../src/lib/codex-native.js"; const mockLoadConfig = vi.mocked(loadConfig); const mockLoginCommand = vi.mocked(loginCommand); @@ -39,7 +39,7 @@ const mockApiRequest = vi.mocked(apiRequest); const mockRunCcusageRawAsync = vi.mocked(runCcusageRawAsync); const mockParseCcusageOutput = vi.mocked(parseCcusageOutput); const mockCollectCodexUsageAsync = vi.mocked(collectCodexUsageAsync); -const mockHasCodexLogs = vi.mocked(hasCodexLogs); +const mockHasCodexLogs = vi.mocked(containsSessionFile); const fakeConfig = { token: "tok", username: "alice", api_url: "https://straude.com" }; diff --git a/packages/cli/__tests__/flows/cli-sync-flow.test.ts b/packages/cli/__tests__/flows/cli-sync-flow.test.ts index eb7bcb94..9819b7d2 100644 --- a/packages/cli/__tests__/flows/cli-sync-flow.test.ts +++ b/packages/cli/__tests__/flows/cli-sync-flow.test.ts @@ -45,7 +45,7 @@ vi.mock("node:child_process", () => ({ vi.mock("../../src/lib/codex-native.js", () => ({ CODEX_NATIVE_COLLECTOR: "straude-codex-native-v1", collectCodexUsageAsync: _collectCodexUsageAsync, - hasCodexLogs: _hasCodexLogs, + containsSessionFile: _hasCodexLogs, })); // Speed up login polling in tests diff --git a/packages/cli/package.json b/packages/cli/package.json index f270817f..6939f065 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@pppp606/ink-chart": "^0.2.4", + "@straude/shared": "workspace:*", "chalk": "^5.6.2", "ink": "^6.8.0", "posthog-node": "^5.29.1", diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index 1ed40ba7..710dd74e 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -21,6 +21,7 @@ function openBrowser(url: string): void { try { parsed = new URL(url); } catch { + // malformed URL — caller already prints it for manual fallback, no spawn needed return; } diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index 4e599130..fd1e4262 100755 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -9,7 +9,7 @@ import type { CcusageDailyEntry, ModelBreakdownEntry } from "../lib/ccusage.js"; import { CODEX_NATIVE_COLLECTOR, collectCodexUsageAsync, - hasCodexLogs, + containsSessionFile, } from "../lib/codex-native.js"; import { MAX_BACKFILL_DAYS, DEFAULT_SYNC_DAYS } from "../config.js"; import { Spinner } from "../lib/spinner.js"; @@ -187,7 +187,7 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) const todayStr = formatDate(today); const shouldRunCodexRepair = !options.date && !config.codex_native_repair_completed_at - && await hasCodexLogs(); + && await containsSessionFile(); let sinceDate: Date; let untilDate: Date; diff --git a/packages/cli/src/components/ModelPalette.tsx b/packages/cli/src/components/ModelPalette.tsx index bb30d67f..5669347f 100644 --- a/packages/cli/src/components/ModelPalette.tsx +++ b/packages/cli/src/components/ModelPalette.tsx @@ -1,34 +1,12 @@ import React from 'react'; import { Box, Text } from 'ink'; +import { prettifyModel } from '@straude/shared/models'; import { theme, modelColors, modelFallback } from './theme.js'; export interface ModelPaletteProps { breakdown: Array<{ model: string; cost_usd: number }>; } -/** - * Normalize raw model identifiers to display names. - * Ported from apps/web/components/app/feed/ActivityCard.tsx:39-57 - */ -function prettifyModel(model: string): string { - const normalized = model.trim(); - if (/claude-opus-4/i.test(normalized)) return 'Claude Opus'; - if (/claude-sonnet-4/i.test(normalized)) return 'Claude Sonnet'; - if (/claude-haiku-4/i.test(normalized)) return 'Claude Haiku'; - if (/^gpt-/i.test(normalized)) { - return normalized - .replace(/^gpt/i, 'GPT') - .replace(/-codex$/i, '-Codex'); - } - if (/^o4/i.test(normalized)) return 'o4'; - if (/^o3/i.test(normalized)) return 'o3'; - // Legacy: broader Claude matching - if (normalized.includes('opus')) return 'Claude Opus'; - if (normalized.includes('sonnet')) return 'Claude Sonnet'; - if (normalized.includes('haiku')) return 'Claude Haiku'; - return normalized; -} - function hashString(input: string): number { let hash = 0; for (let i = 0; i < input.length; i++) { diff --git a/packages/cli/src/components/PushSummary.tsx b/packages/cli/src/components/PushSummary.tsx index fcba8b6e..c810d50d 100644 --- a/packages/cli/src/components/PushSummary.tsx +++ b/packages/cli/src/components/PushSummary.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Box, Text } from 'ink'; +import { formatTokens } from '@straude/shared/format'; import { theme } from './theme.js'; import { BarChart } from './BarChart.js'; import { ModelPalette } from './ModelPalette.js'; @@ -36,13 +37,6 @@ export interface PushSummaryProps { results?: PostResult[]; } -function formatTokens(n: number): string { - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} - export function PushSummary({ dashboard, results }: PushSummaryProps) { // Last 7 days for bar chart const last7 = dashboard.daily.slice(-7); diff --git a/packages/cli/src/lib/ccusage.ts b/packages/cli/src/lib/ccusage.ts index 1be96d20..5089f4c1 100755 --- a/packages/cli/src/lib/ccusage.ts +++ b/packages/cli/src/lib/ccusage.ts @@ -228,8 +228,8 @@ export function parseCcusageOutput(raw: string): CcusageOutput { let parsed: unknown; try { parsed = JSON.parse(raw); - } catch { - throw new Error("Failed to parse ccusage output as JSON"); + } catch (err) { + throw new Error("Failed to parse ccusage output as JSON", { cause: err }); } // ccusage returns `[]` when there's no data for the period diff --git a/packages/cli/src/lib/codex-native.ts b/packages/cli/src/lib/codex-native.ts index 27471d7f..9701978a 100644 --- a/packages/cli/src/lib/codex-native.ts +++ b/packages/cli/src/lib/codex-native.ts @@ -246,7 +246,7 @@ async function listSessionFiles(dir = sessionsDir(), root = sessionsDir()): Prom return files.sort((a, b) => relative(root, a).localeCompare(relative(root, b))); } -async function containsSessionFile(dir = sessionsDir()): Promise { +export async function containsSessionFile(dir = sessionsDir()): Promise { let entries: Dirent[]; try { entries = await readdir(dir, { withFileTypes: true }); @@ -519,10 +519,6 @@ function aggregateSessions(sessions: ParsedSession[]): { data: CcusageDailyEntry return { data, metas, parsedEvents }; } -export async function hasCodexLogs(): Promise { - return containsSessionFile(); -} - export async function collectCodexUsageAsync(sinceDate: string, untilDate: string): Promise { const sinceIso = compactToIso(sinceDate); const untilIso = compactToIso(untilDate); diff --git a/packages/cli/src/lib/codex.ts b/packages/cli/src/lib/codex.ts deleted file mode 100755 index 2afece68..00000000 --- a/packages/cli/src/lib/codex.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - CODEX_NATIVE_COLLECTOR, - collectCodexUsageAsync, - getCodexSessionStats, - hasCodexLogs, -} from "./codex-native.js"; diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 00000000..d753d8ed --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,35 @@ +{ + "name": "@straude/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./models": { + "types": "./dist/models.d.ts", + "import": "./dist/models.js", + "default": "./dist/models.js" + }, + "./format": { + "types": "./dist/format.d.ts", + "import": "./dist/format.js", + "default": "./dist/format.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "devDependencies": { + "typescript": "^5" + } +} diff --git a/packages/shared/src/format.ts b/packages/shared/src/format.ts new file mode 100644 index 00000000..f6ddb850 --- /dev/null +++ b/packages/shared/src/format.ts @@ -0,0 +1,6 @@ +export function formatTokens(n: number): string { + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 00000000..eac54e7d --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +export * from "./models"; +export * from "./format"; diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts new file mode 100644 index 00000000..738f6959 --- /dev/null +++ b/packages/shared/src/models.ts @@ -0,0 +1,34 @@ +export function prettifyModel(model: string): string { + const normalized = model.trim(); + if (/claude-opus-4/i.test(normalized)) return "Claude Opus"; + if (/claude-sonnet-4/i.test(normalized)) return "Claude Sonnet"; + if (/claude-haiku-4/i.test(normalized)) return "Claude Haiku"; + + // Preserve full OpenAI model names (e.g. gpt-5.3-codex -> GPT-5.3-Codex) + if (/^gpt-/i.test(normalized)) { + return normalized + .replace(/^gpt/i, "GPT") + .replace(/-codex$/i, "-Codex"); + } + + if (/^o4/i.test(normalized)) return "o4"; + if (/^o3/i.test(normalized)) return "o3"; + // Legacy: broader Claude matching (preserves behavior of ActivityCard, + // open-stats, and CLI's prior local copies; tested via prettify-model.test.ts). + if (normalized.includes("opus")) return "Claude Opus"; + if (normalized.includes("sonnet")) return "Claude Sonnet"; + if (normalized.includes("haiku")) return "Claude Haiku"; + return normalized; +} + +export function getShareModelLabel( + models: string[] | null | undefined +): string | null { + if (!models || models.length === 0) return null; + if (models.some((model) => /claude-opus-4/i.test(model))) return "Claude Opus"; + if (models.some((model) => /claude-sonnet-4/i.test(model))) { + return "Claude Sonnet"; + } + if (models.some((model) => /claude-haiku-4/i.test(model))) return "Claude Haiku"; + return prettifyModel(models[0]!); +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 00000000..0653bccd --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "declaration": true, + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/posthog-setup-report.md b/posthog-setup-report.md deleted file mode 100644 index 217dc65d..00000000 --- a/posthog-setup-report.md +++ /dev/null @@ -1,37 +0,0 @@ - -# PostHog post-wizard report - -The wizard has completed a deep integration of PostHog event tracking across the Straude web app. PostHog was already initialised (provider, pageview tracking, user identification, reverse proxy) — this session added 13 new `posthog.capture()` calls across 8 files covering the core engagement, social, sharing, and growth loops. Environment variables were verified and `NEXT_PUBLIC_POSTHOG_HOST` was added to `apps/web/.env.local`. - -| Event | Description | File | -|---|---|---| -| `post_saved` | User saves edits to a post | `apps/web/components/app/post/PostEditor.tsx` | -| `post_deleted` | User permanently deletes a post | `apps/web/components/app/post/PostEditor.tsx` | -| `caption_generated` | User generates an AI caption from post images | `apps/web/components/app/post/PostEditor.tsx` | -| `user_followed` | User follows another user | `apps/web/components/app/profile/FollowButton.tsx` | -| `user_unfollowed` | User unfollows another user | `apps/web/components/app/profile/FollowButton.tsx` | -| `post_shared` | User shares a post (method: copy_link, x, native, copy_image, download_png) | `apps/web/components/app/feed/ShareMenu.tsx` | -| `comment_posted` | User posts a comment or reply | `apps/web/components/app/post/CommentThread.tsx` | -| `comment_liked` | User likes or unlikes a comment | `apps/web/components/app/post/CommentThread.tsx` | -| `invite_link_copied` | User copies their invite link from a profile | `apps/web/components/app/profile/InviteButton.tsx` | -| `message_sent` | User successfully sends a direct message | `apps/web/components/app/messages/MessagesInbox.tsx` | -| `profile_saved` | User saves their profile settings | `apps/web/app/(app)/settings/page.tsx` | -| `referral_link_copied` | User copies their referral link from settings | `apps/web/app/(app)/settings/page.tsx` | -| `usage_imported` | User imports usage data via manual JSON paste | `apps/web/app/(app)/settings/import/page.tsx` | - -## Next steps - -We've built a dashboard and five insights to track user behaviour based on these events: - -- **Dashboard — Analytics basics**: https://us.posthog.com/project/374497/dashboard/1521510 -- **Post publishing funnel** (post_saved → post_shared conversion): https://us.posthog.com/project/374497/insights/jMFDuLdW -- **Social engagement over time** (comments, likes, follows): https://us.posthog.com/project/374497/insights/8iDJeP9M -- **Share method breakdown** (which channel users share to): https://us.posthog.com/project/374497/insights/bWOO4TMp -- **Viral loop: invites and referrals** (invite_link_copied, referral_link_copied): https://us.posthog.com/project/374497/insights/URcSjAWx -- **Churn signal: message sent vs post deleted**: https://us.posthog.com/project/374497/insights/BuYLHXEr - -### Agent skill - -We've left an agent skill folder in your project at `.claude/skills/integration-nextjs-pages-router/`. You can use this context for further agent development when using Claude Code. This will help ensure the model provides the most up-to-date approaches for integrating PostHog. - - diff --git a/prometheus-list.png b/prometheus-list.png deleted file mode 100644 index 492dcaac..00000000 Binary files a/prometheus-list.png and /dev/null differ diff --git a/straude-codemap.html b/straude-codemap.html deleted file mode 100644 index db5a5c45..00000000 --- a/straude-codemap.html +++ /dev/null @@ -1,1059 +0,0 @@ - - - - - -Straude — Architecture Map - - - - - - -
- -
- Architecture Map -
- -
-
- -
- - -
-
- -
-
- - - -
-
-
-
-
Generated Prompt
-
-
- -
-
-
- - - - - - diff --git a/turbo.json b/turbo.json index 1c7ca289..facc0ce6 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,9 @@ "lint": { "dependsOn": ["^build"] }, + "typecheck": { + "dependsOn": ["^build"] + }, "test": { "cache": false, "dependsOn": ["^build"]