Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/__tests__/components/ActivityCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
21 changes: 21 additions & 0 deletions apps/web/__tests__/components/ShareMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ShareMenu post={makePost() as any} />);
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 () => {
Expand Down
66 changes: 66 additions & 0 deletions apps/web/__tests__/lib/share-moments.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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"
);
});
});
97 changes: 57 additions & 40 deletions apps/web/components/app/feed/ActivityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -397,50 +399,65 @@ export function ActivityCard({ post, userId, hideShareMenu }: { post: Post; user

{/* Stats grid */}
{usage && (
<div className={cn("mt-4 grid gap-4", usage.is_verified ? "grid-cols-3" : "grid-cols-2")}>
{usage.is_verified ? (
isUnpricedSession(usage) ? (
<div>
<p className="text-[0.7rem] uppercase tracking-widest text-muted">Cost</p>
<p
className="font-[family-name:var(--font-mono)] text-[1.1rem] font-medium tabular-nums text-muted"
title="Cost tracking for non-Claude/GPT models is coming soon."
>
&mdash;
</p>
<p className="mt-0.5 text-[0.65rem] uppercase tracking-wider text-muted">
Pricing soon
</p>
</div>
<>
<div className={cn("mt-4 grid gap-4", usage.is_verified ? "grid-cols-3" : "grid-cols-2")}>
{usage.is_verified ? (
isUnpricedSession(usage) ? (
<div>
<p className="text-[0.7rem] uppercase tracking-widest text-muted">Cost</p>
<p
className="font-[family-name:var(--font-mono)] text-[1.1rem] font-medium tabular-nums text-muted"
title="Cost tracking for non-Claude/GPT models is coming soon."
>
&mdash;
</p>
<p className="mt-0.5 text-[0.65rem] uppercase tracking-wider text-muted">
Pricing soon
</p>
</div>
) : (
<div>
<p className="text-[0.7rem] uppercase tracking-widest text-muted">Cost</p>
<p className="font-[family-name:var(--font-mono)] text-[1.1rem] font-medium tabular-nums text-accent">
${formatCurrency(usage.cost_usd)}
</p>
</div>
)
) : (
<div>
<p className="text-[0.7rem] uppercase tracking-widest text-muted">Cost</p>
<p className="font-[family-name:var(--font-mono)] text-[1.1rem] font-medium tabular-nums text-accent">
${formatCurrency(usage.cost_usd)}
</p>
</div>
)
) : (
<p className="col-span-2 text-xs text-muted">
Uploaded by the user via JSON
{modelSummary && (
<> &middot; {modelSummary}</>
)}
</p>
)}
<div>
<p className="text-[0.7rem] uppercase tracking-widest text-muted">Input</p>
<p className="font-[family-name:var(--font-mono)] text-[1.1rem] font-medium tabular-nums">
{formatTokens(Number(usage.input_tokens))}
</p>
<p className="col-span-2 text-xs text-muted">
Uploaded by the user via JSON
{modelSummary && (
<> &middot; {modelSummary}</>
)}
</p>
)}
<div>
<p className="text-[0.7rem] uppercase tracking-widest text-muted">Input</p>
<p className="font-[family-name:var(--font-mono)] text-[1.1rem] font-medium tabular-nums">
{formatTokens(Number(usage.input_tokens))}
</p>
</div>
<div>
<p className="text-[0.7rem] uppercase tracking-widest text-muted">Output</p>
<p className="font-[family-name:var(--font-mono)] text-[1.1rem] font-medium tabular-nums">
{formatTokens(Number(usage.output_tokens))}
</p>
</div>
</div>
<div>
<p className="text-[0.7rem] uppercase tracking-widest text-muted">Output</p>
<p className="font-[family-name:var(--font-mono)] text-[1.1rem] font-medium tabular-nums">
{formatTokens(Number(usage.output_tokens))}
<div className="mt-4 flex flex-col gap-1 rounded-md border border-accent/25 bg-accent/5 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-accent">
{shareMoment.label}
</p>
<p className="truncate text-sm font-medium text-foreground">
{shareMoment.headline}
</p>
</div>
<p className="text-xs text-muted sm:max-w-[13rem] sm:text-right">
{shareMoment.inviteText}
</p>
</div>
</div>
</>
)}
</div>

Expand Down
Loading
Loading