diff --git a/apps/web/__tests__/components/ActivityCard.test.tsx b/apps/web/__tests__/components/ActivityCard.test.tsx index ce2ddeed..dfcba6eb 100644 --- a/apps/web/__tests__/components/ActivityCard.test.tsx +++ b/apps/web/__tests__/components/ActivityCard.test.tsx @@ -67,6 +67,8 @@ describe("ActivityCard", () => { expect( screen.getByLabelText("Model usage: 77% Claude Opus, 23% GPT-5-Codex") ).toBeInTheDocument(); + expect(screen.getByText("Build Log")).toBeInTheDocument(); + expect(screen.getByText("1.3k output tracked")).toBeInTheDocument(); }); it("renders Codex-only model with full model name in session summary", () => { diff --git a/apps/web/__tests__/components/ShareMenu.test.tsx b/apps/web/__tests__/components/ShareMenu.test.tsx index b41cae14..19f985cf 100644 --- a/apps/web/__tests__/components/ShareMenu.test.tsx +++ b/apps/web/__tests__/components/ShareMenu.test.tsx @@ -75,6 +75,27 @@ describe("ShareMenu", () => { expect(openedUrl.origin).toBe("https://twitter.com"); expect(openedUrl.pathname).toBe("/intent/tweet"); expect(openedUrl.searchParams.get("url")).toContain("/post/post-1"); + expect(openedUrl.searchParams.get("text")).toContain("Share the receipts with a friend."); + }); + + it("shows a Strava-style share angle and copies the invite link", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(window.navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: /share/i })); + + expect(screen.getByText("Receipts Attached")).toBeInTheDocument(); + expect(screen.getByText(/1 screenshot on the build log/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /share the receipts with a friend/i })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith("http://localhost:3000/join/alice"); + }); }); it("shows an inline error when PNG generation fails", async () => { diff --git a/apps/web/__tests__/lib/share-moments.test.ts b/apps/web/__tests__/lib/share-moments.test.ts new file mode 100644 index 00000000..1dfb165e --- /dev/null +++ b/apps/web/__tests__/lib/share-moments.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { buildInviteUrl, buildShareMoment } from "@/lib/share-moments"; +import { buildPostShareText } from "@/lib/utils/post-share"; + +function makePost(overrides: Record = {}) { + return { + id: "post-1", + title: null, + images: [], + user: { username: "alice" }, + kudos_count: 0, + comment_count: 0, + daily_usage: { + cost_usd: 12.5, + output_tokens: 3400, + models: ["claude-opus-4-20250505"], + is_verified: true, + }, + ...overrides, + }; +} + +describe("buildShareMoment", () => { + it("turns seven-figure output into a shareable progress moment", () => { + const moment = buildShareMoment( + makePost({ + daily_usage: { + cost_usd: 88, + output_tokens: 1_200_000, + models: ["claude-sonnet-4-20250514"], + is_verified: true, + }, + }) as any + ); + + expect(moment.label).toBe("Output PR"); + expect(moment.headline).toBe("1.2M output shipped"); + expect(moment.inviteText).toMatch(/outship/i); + }); + + it("prefers proof screenshots when the session is otherwise routine", () => { + const moment = buildShareMoment( + makePost({ + images: ["https://example.com/one.png", "https://example.com/two.png"], + }) as any + ); + + expect(moment.label).toBe("Receipts Attached"); + expect(moment.headline).toContain("2 screenshots"); + }); +}); + +describe("share copy", () => { + it("adds the invite challenge to generated social text", () => { + const text = buildPostShareText(makePost() as any); + + expect(text).toContain("Share this build log with a peer."); + expect(text).toContain("Tracked on Straude by @alice"); + }); + + it("builds referral-style invite URLs from the sharing user", () => { + expect(buildInviteUrl("https://straude.com", "alice")).toBe( + "https://straude.com/join/alice" + ); + }); +}); diff --git a/apps/web/components/app/feed/ActivityCard.tsx b/apps/web/components/app/feed/ActivityCard.tsx index 71aa3acc..b87e3850 100644 --- a/apps/web/components/app/feed/ActivityCard.tsx +++ b/apps/web/components/app/feed/ActivityCard.tsx @@ -8,6 +8,7 @@ import { ImageGrid } from "@/components/app/shared/ImageGrid"; import { ImageLightbox } from "@/components/app/shared/ImageLightbox"; import { ShareMenu } from "./ShareMenu"; import { cn } from "@/lib/utils/cn"; +import { buildShareMoment } from "@/lib/share-moments"; import { formatCurrency, formatTokens } from "@/lib/utils/format"; import { mentionsToMarkdownLinks } from "@/lib/utils/mentions"; import type { Post, ModelBreakdownEntry } from "@/types"; @@ -269,6 +270,7 @@ export function ActivityCard({ post, userId, hideShareMenu }: { post: Post; user const usage = post.daily_usage; const modelSegments = buildModelUsageSegments(usage?.model_breakdown); const modelSummary = formatModels(usage?.models, usage?.model_breakdown); + const shareMoment = buildShareMoment(post); async function toggleKudos() { const prevKudosed = kudosed; @@ -397,50 +399,65 @@ export function ActivityCard({ post, userId, hideShareMenu }: { post: Post; user {/* Stats grid */} {usage && ( -
- {usage.is_verified ? ( - isUnpricedSession(usage) ? ( -
-

Cost

-

- — -

-

- Pricing soon -

-
+ <> +
+ {usage.is_verified ? ( + isUnpricedSession(usage) ? ( +
+

Cost

+

+ — +

+

+ Pricing soon +

+
+ ) : ( +
+

Cost

+

+ ${formatCurrency(usage.cost_usd)} +

+
+ ) ) : ( -
-

Cost

-

- ${formatCurrency(usage.cost_usd)} -

-
- ) - ) : ( -

- Uploaded by the user via JSON - {modelSummary && ( - <> · {modelSummary} - )} -

- )} -
-

Input

-

- {formatTokens(Number(usage.input_tokens))} -

+

+ Uploaded by the user via JSON + {modelSummary && ( + <> · {modelSummary} + )} +

+ )} +
+

Input

+

+ {formatTokens(Number(usage.input_tokens))} +

+
+
+

Output

+

+ {formatTokens(Number(usage.output_tokens))} +

+
-
-

Output

-

- {formatTokens(Number(usage.output_tokens))} +

+
+

+ {shareMoment.label} +

+

+ {shareMoment.headline} +

+
+

+ {shareMoment.inviteText}

-
+ )}
diff --git a/apps/web/components/app/feed/ShareMenu.tsx b/apps/web/components/app/feed/ShareMenu.tsx index 3853352c..67bc4e09 100644 --- a/apps/web/components/app/feed/ShareMenu.tsx +++ b/apps/web/components/app/feed/ShareMenu.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { usePostHog } from "posthog-js/react"; import { cn } from "@/lib/utils/cn"; +import { buildInviteUrl, buildShareMoment } from "@/lib/share-moments"; import { ShareCardImage } from "@/lib/utils/share-image"; import { buildPostIntentUrl, @@ -37,7 +38,7 @@ export function ShareMenu({ post }: { post: Post }) { const [open, setOpen] = useState(false); const [theme, setTheme] = useState("accent"); const [busyAction, setBusyAction] = useState(null); - const [copiedAction, setCopiedAction] = useState<"link" | "image" | null>(null); + const [copiedAction, setCopiedAction] = useState<"link" | "image" | "invite" | null>(null); const [feedback, setFeedback] = useState<{ tone: "success" | "error"; message: string; @@ -90,7 +91,9 @@ export function ShareMenu({ post }: { post: Post }) { const supportsNativeShare = typeof window !== "undefined" && typeof navigator.share === "function"; - function flashCopied(action: "link" | "image") { + const shareMoment = buildShareMoment(post); + + function flashCopied(action: "link" | "image" | "invite") { setCopiedAction(action); window.setTimeout(() => { setCopiedAction((current) => (current === action ? null : current)); @@ -135,6 +138,26 @@ export function ShareMenu({ post }: { post: Post }) { } } + async function handleCopyInvite() { + setFeedback(null); + try { + if (!supportsClipboardText) { + throw new Error("Clipboard text unsupported"); + } + + const url = buildInviteUrl(window.location.origin, post.user?.username); + await navigator.clipboard.writeText(url); + flashCopied("invite"); + posthog.capture("post_shared", { post_id: post.id, method: "copy_invite", theme }); + } catch (error) { + console.error("Copy invite failed:", error); + setFeedback({ + tone: "error", + message: "Could not copy the invite link on this browser.", + }); + } + } + async function handleCopyImage() { setBusyAction("copy-image"); setFeedback(null); @@ -254,16 +277,33 @@ export function ShareMenu({ post }: { post: Post }) { {open && (
-
+
+
+

+ {shareMoment.label} +

+ + Share angle + +
+

+ {shareMoment.headline} +

+

+ {shareMoment.detail} +

+
+ +

Share Card

@@ -315,7 +355,7 @@ export function ShareMenu({ post }: { post: Post }) { type="button" onClick={handleNativeShare} disabled={busyAction !== null} - className="col-span-2 flex items-center justify-center gap-2 rounded-2xl bg-accent px-4 py-3 text-sm font-semibold text-accent-foreground transition-opacity hover:opacity-90 disabled:opacity-60" + className="col-span-2 flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-foreground transition-opacity hover:opacity-90 disabled:opacity-60" >
{feedback && (
| null; + user?: Pick | null; +}; + +export type ShareMoment = { + label: string; + headline: string; + detail: string; + inviteText: string; +}; + +function hasMeaningfulSpend(cost: number | null | undefined): cost is number { + return typeof cost === "number" && Number.isFinite(cost) && cost > 0; +} + +function getMomentModelLabel(models: string[] | null | undefined): string | null { + if (!models || models.length === 0) return null; + if (models.some((model) => /claude-opus-4|opus/i.test(model))) return "Claude Opus"; + if (models.some((model) => /claude-sonnet-4|sonnet/i.test(model))) return "Claude Sonnet"; + if (models.some((model) => /claude-haiku-4|haiku/i.test(model))) return "Claude Haiku"; + const first = models[0]!; + if (/^gpt-/i.test(first)) { + return first.replace(/^gpt/i, "GPT").replace(/-codex$/i, "-Codex"); + } + if (/^o4/i.test(first)) return "o4"; + if (/^o3/i.test(first)) return "o3"; + return first; +} + +export function buildShareMoment(post: ShareMomentPost): ShareMoment { + const usage = post.daily_usage; + const outputTokens = usage?.output_tokens ?? 0; + const spend = usage?.cost_usd; + const modelLabel = getMomentModelLabel(usage?.models); + const imageCount = post.images?.length ?? 0; + const kudosCount = post.kudos_count ?? 0; + const commentCount = post.comment_count ?? 0; + + if (outputTokens >= 1_000_000) { + return { + label: "Output PR", + headline: `${formatTokens(outputTokens)} output shipped`, + detail: modelLabel + ? `${modelLabel} session with a seven-figure token day.` + : "Seven-figure output day.", + inviteText: "Think you can outship this?", + }; + } + + if (hasMeaningfulSpend(spend) && spend >= 100) { + return { + label: "Big Build Day", + headline: `$${formatCurrency(spend)} verified session`, + detail: modelLabel + ? `${modelLabel} carried the spend.` + : "High-intensity coding session.", + inviteText: "Challenge a teammate to beat this session.", + }; + } + + if ((usage?.models?.length ?? 0) >= 3) { + return { + label: "Toolkit Flex", + headline: `${usage?.models.length} models in one session`, + detail: modelLabel + ? `${modelLabel} led a multi-model build.` + : "Multi-model build log.", + inviteText: "Send this to another multi-model builder.", + }; + } + + if (imageCount > 0) { + return { + label: "Receipts Attached", + headline: `${imageCount} screenshot${imageCount === 1 ? "" : "s"} on the build log`, + detail: outputTokens > 0 + ? `${formatTokens(outputTokens)} output with visible proof-of-work.` + : "Visual proof-of-work ready to share.", + inviteText: "Share the receipts with a friend.", + }; + } + + if (kudosCount + commentCount >= 3) { + return { + label: "Community Pull", + headline: `${kudosCount} kudo${kudosCount === 1 ? "" : "s"} · ${commentCount} comment${commentCount === 1 ? "" : "s"}`, + detail: "This session is already pulling people in.", + inviteText: "Bring another builder into the thread.", + }; + } + + if (outputTokens > 0) { + return { + label: "Build Log", + headline: `${formatTokens(outputTokens)} output tracked`, + detail: modelLabel + ? `${modelLabel} session logged on Straude.` + : "Session logged on Straude.", + inviteText: "Share this build log with a peer.", + }; + } + + return { + label: "Share Ready", + headline: "Session logged on Straude", + detail: "Turn this build into a public proof-of-work card.", + inviteText: "Invite another builder to log theirs.", + }; +} + +export function buildInviteUrl(origin: string, username: string | null | undefined) { + return new URL(username ? `/join/${username}` : "/", origin).toString(); +} diff --git a/apps/web/lib/utils/post-share.ts b/apps/web/lib/utils/post-share.ts index 3c5e639b..4d3d06d2 100644 --- a/apps/web/lib/utils/post-share.ts +++ b/apps/web/lib/utils/post-share.ts @@ -1,5 +1,6 @@ import type { DailyUsage, Post, User } from "@/types"; import { formatCurrency, formatTokens } from "./format"; +import { buildShareMoment } from "@/lib/share-moments"; type ShareablePost = Pick & { user?: Pick | null; @@ -48,12 +49,13 @@ export function getPostShareFilename(postId: string) { export function buildPostShareText(post: ShareablePost) { const title = post.title?.trim(); + const moment = buildShareMoment(post); const lines: string[] = []; if (title) { lines.push(title); } else { - lines.push("Tracked a Claude Code session"); + lines.push(moment.headline); } const details: string[] = []; @@ -85,6 +87,8 @@ export function buildPostShareText(post: ShareablePost) { lines.push(details.join(" · ")); } + lines.push(moment.inviteText); + const username = post.user?.username; lines.push(username ? `Tracked on Straude by @${username}` : "Tracked on Straude"); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 66f75000..096b4d17 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- **Strava-inspired share moments for session cards.** Feed posts now derive a share angle from activity data — Output PR, Big Build Day, Toolkit Flex, Receipts Attached, Community Pull, or Build Log — and surface it on the activity card and share panel. Generated social copy now includes the challenge/invite prompt, and the share panel adds a direct `/join/[username]` invite copy action so broadcast sharing can feed the referral loop. Product rationale captured in `docs/strava-growth-shareability.md`. - **`CONTRIBUTING.md` at repo root.** Lightweight contribution guide inspired by Warp's, scaled down for a smaller project: TL;DR up top, mermaid flow diagram (bug → PR; feature → issue → PR), pointers to `docs/SETUP.md` / `docs/LOCAL_DEV.md` for environment setup, branch/commit conventions (`handle/short-description` prefix), and a testing bar that asks for regression tests on bug fixes and unit/e2e coverage on new behavior — without the heavier readiness-label and spec-PR gating Warp uses. Also covers code style (defers to `CLAUDE.md`), agent-assisted contributions, security disclosure via GitHub's private reporting, and the Contributor Covenant. Issue/repo links use the real `github.com/ohong/straude` remote. - **CLI `--debug` flag (and `STRAUDE_DEBUG=1` env var).** Developer-facing diagnostics like the per-row token-normalization anomaly dump are now hidden by default — `straude push` only prints them when debug mode is on. Debug output goes to `stderr` so it doesn't interfere with piping. The previous unconditional `Warning: normalization anomalies detected …` line surprised users who had no way to act on it; we still surface aggregate anomaly counts (`anomalies_medium_low`, `anomalies_low_confidence`, `anomalies_unresolved`) in the `usage_pushed` PostHog event so we can monitor data quality without noisy CLI output. Shipped as `straude@0.1.23`. - **Comment thread pagination.** Root comments limited to 20 initially with a "Load more comments" button to prevent large DOM renders. diff --git a/docs/strava-growth-shareability.md b/docs/strava-growth-shareability.md new file mode 100644 index 00000000..8aa9018c --- /dev/null +++ b/docs/strava-growth-shareability.md @@ -0,0 +1,53 @@ +# Strava-Inspired Shareability Loop + +Straude should treat each coding session the way Strava treats a run or ride: an activity is not just a log row, it is a social object with status, proof, comparison, and a next action. + +## What Strava Users Share + +Based on Strava's public growth mechanics and the Latterly strategy write-up: + +- **Progress made visible**: routes, segment PRs, streaks, goals, and annual recaps turn private effort into a visible milestone. +- **Social recognition**: kudos, comments, clubs, and leaderboards make the activity feel witnessed. +- **Identity signaling**: sharing says "I am the kind of person who trains consistently," not just "I exercised." +- **Friendly rivalry**: segments and leaderboards create natural challenges without requiring users to write copy. +- **Fresh content loops**: every uploaded activity can become feed content, social content, club content, challenge progress, and lifecycle email content. + +The useful growth-loop framing from Kevin Kwok's Figma analysis is that durable companies sequence loops. For Straude, the individual activity loop should feed the public proof-of-work loop, which should feed the referral loop. + +## Applied To Straude + +Straude already has the raw activity object: daily usage with spend, output tokens, models, screenshots, kudos, comments, and profile identity. The missing layer is packaging those facts into repeatable "why this is worth sharing" moments. + +### Shipped In This Branch + +**Share moments** package every post with a Strava-like share angle: + +- Output PR: seven-figure output days. +- Big Build Day: high-spend verified sessions. +- Toolkit Flex: multi-model sessions. +- Receipts Attached: posts with screenshots. +- Community Pull: posts already receiving kudos/comments. +- Build Log: the default proof-of-work moment. + +Each moment now appears in: + +- Feed activity cards, so users see which part of the session is socially interesting. +- The share panel, so the card has a ready-made angle before choosing a theme or channel. +- Generated share text, so X/native shares carry challenge-oriented copy. +- A direct invite-link action, so a broadcast share can also become a friend/referral loop. + +Loop: + +```text +Code with AI -> push to Straude -> share moment appears -> + user shares proof/invite -> non-user sees comparable artifact -> + joins through /join/[username] -> pushes their own session +``` + +## Next Feature Ideas + +- **Milestone share prompts**: after earning an achievement or breaking an output/spend personal record, open the share panel with that moment preselected. +- **Challenge replies**: add a "Beat this session" link that creates a lightweight challenge page between two users. +- **Weekly club-style recaps**: summarize followed users' biggest output PRs and invite the viewer to catch up. +- **Share conversion dashboard**: track view -> signup -> first push by surface: post card, recap, profile, invite, CLI scorecard. +- **Team segments**: private org leaderboards that make teams feel like Strava clubs.