Skip to contentVibraUI
Shared pagesinstalls at /profile/v2

Teammate profile

A teammate's profile without a cover: the face and the name with Follow and Message, four figures, about, skills with a meter each, the teams they are on with the faces in them, a contribution heatmap over six months, and the latest of what they did.

Open the live page

Installs at /profile/v2, beside the member's own profile, with its own nav.ts — a shared page, whose Account disclosure holds both profiles; only the longest match is current, so this leaf lights and the member's own does not. Whose profile it is is a rule over the rows: the active teammate — never the signed-in member — with the most contributions over the days the heatmap draws, where a contribution is one of the three things the activity card lists: a task they closed (db.tasks), a comment they wrote (db.comments) or a file they added (db.files). Task completions alone give the busiest teammate two days in half a year, which leaves a heatmap blank; the three together draw a working half-year. The header has no cover: the face, the name as the page's h1 in its PageHeader, the role, a city and the month they joined, Follow — the reader's own act for the session, which moves the Followers figure and says so in the status line — and Message, a link to the chat the two already share. Under it four figures: the projects they lead or have tasks in, followers and following (no entity records who follows whom, so both are drawn once from seeded("profile-page-v2")) and the contributions. About is a sentence written from their rows with the facts to reach them by; Skills are the labels on their own tasks, each a badge with a Progress meter against the label they carry most; Teams are those projects with the faces of everyone in them (AvatarGroup); Contributions is the kit's ActivityCalendar over six months ending on REFERENCE_DATE's day, one square a day named by its contributions, which opens on its latest weeks where the card is too narrow for all of them, with the count by kind beside it; the count covers exactly the days the calendar draws — from the Sunday its first week starts on — and the card's description gives the total since that first day and says what it counts: tasks closed, comments written and files added; Recent activity is the newest three of each kind — tasks closed, comments and files — merged newest first on a Timeline, so one busy kind cannot crowd the others off the card. The page is a server component and every card renders on the server; the islands are the header and the calendar, which the kit ships as a client component. Every date is written in UTC. Composes AppShell, PageHeader, Avatar, Button, StatCardGroup, StatCard, Card, DescriptionList, Badge, Progress, AvatarGroup, ActivityCalendar and Timeline.

Preview

Install

npx shadcn@latest add @vibra/profile-page-v2

Needs the @vibra registry in your components.json — set it up once.

Source

app/profile/v2/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"

import { signOut } from "./actions"
import { AboutCard } from "./components/about-card"
import { ActivityCard } from "./components/activity-card"
import { ContributionCard } from "./components/contribution-card"
import { ProfileHeader } from "./components/profile-header"
import { SkillsCard } from "./components/skills-card"
import { TeamsCard } from "./components/teams-card"
import { currentUser, profile, shellNotifications } from "./data"
import { NAV, ROUTE } from "./nav"

/**
 * A teammate's profile, without a cover. The page is a server component: it
 * works out whose profile it is from the rows, reads their projects, tasks,
 * comments and files through `db`, and draws every card on the server. The
 * islands are the header, because Follow moves the Followers figure, and the
 * calendar, which the kit ships as a client component. The teammate's name is
 * the page's h1, in the header's PageHeader.
 */
export default function ProfileV2Page() {
  const person = profile()

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTE}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <ProfileHeader
        profile={{
          name: person.name,
          firstName: person.firstName,
          initials: person.initials,
          avatarUrl: person.avatarUrl,
          role: person.role,
          location: person.location,
          joined: person.joined,
          stats: person.stats,
          chatId: person.chatId,
          since: person.since,
        }}
      />

      <div className="grid items-start gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
        <div className="flex flex-col gap-4">
          <AboutCard profile={person} />
          <TeamsCard profile={person} />
        </div>
        <div className="flex min-w-0 flex-col gap-4">
          <ContributionCard profile={person} />
          <ActivityCard profile={person} />
          <SkillsCard profile={person} />
        </div>
      </div>
    </AppShell>
  )
}
app/profile/v2/nav.ts
import { type NavConfig } from "@/lib/nav-config"

/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/profile/v2"

/**
 * This product's navigation, as plain data — the shared profile's own, with
 * the teammate's profile a leaf of Account beside the member's own. AppShell
 * resolves the icon names and, matching the longest href, marks this page
 * rather than the profile it sits under.
 */
export const NAV: NavConfig = {
  brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
  groups: [
    {
      label: "Workspace",
      items: [
        { title: "Overview", href: "/saas", icon: "layout-dashboard" },
        { title: "Customers", href: "/ecommerce/customers", icon: "users" },
      ],
    },
    {
      label: "Team",
      items: [
        { title: "Members", href: "/people/directory", icon: "users" },
        { title: "Chat", href: "/projects/chat", icon: "message-circle" },
      ],
    },
  ],
  footer: [
    {
      title: "Account",
      href: "/profile",
      icon: "user-round",
      items: [
        { title: "Profile", href: "/profile" },
        { title: "Teammate profile", href: ROUTE },
        { title: "Notifications", href: "/notifications" },
      ],
    },
    { title: "Settings", href: "/settings", icon: "settings" },
    { title: "Support", href: "/support", icon: "life-buoy" },
  ],
}
app/profile/v2/data.ts
/**
 * What the teammate's profile reads. Whose profile it is is a rule over the
 * rows: the active teammate — never the signed-in member — who contributed
 * most over the days the heatmap draws, where a contribution is one of the three things
 * the activity timeline lists: a task they closed (`db.tasks`), a comment
 * they wrote (`db.comments`) or a file they put in the drive (`db.files`).
 * Everything on the page follows from their rows — their projects, the people
 * in them, the labels on their tasks. What no entity records — a city, who
 * follows whom — is drawn once from `seeded("profile-page-v2")`. "Now" is
 * `REFERENCE_DATE`, and every date is written in UTC.
 */
import { formatBytes, formatRelative, getInitials } from "@/lib/format"
import { db, intBetween, pick, REFERENCE_DATE, seeded, type Member } from "@/lib/sample-data"

const DAY_MS = 86_400_000
/** How many months back the heatmap reaches. */
const MONTHS = 6

const monthYear = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" })
const shortDay = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

/** "2h ago" against `REFERENCE_DATE`, and the day itself past a week. */
function since(at: Date): string {
  const days = (REFERENCE_DATE.getTime() - at.getTime()) / DAY_MS
  return days < 7 ? formatRelative(at, REFERENCE_DATE) : shortDay.format(at)
}

/** The signed-in member, the first row of `db.members`, read per call so a rename shows. */
const meRow = () => db.members.all()[0]

/**
 * The first day the heatmap draws, at midnight UTC: six months back from
 * today's UTC day, widened to the Sunday that week starts on — the kit's
 * calendar lays out whole weeks. The count covers exactly those days, so
 * every contribution counted has its square and every square is counted.
 */
function windowStart(): number {
  const month = REFERENCE_DATE.getUTCMonth() - MONTHS
  const lastDay = new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), month + 1, 0)).getUTCDate()
  const from = Date.UTC(REFERENCE_DATE.getUTCFullYear(), month, Math.min(REFERENCE_DATE.getUTCDate(), lastDay))
  return from - new Date(from).getUTCDay() * DAY_MS
}
const inWindow = (at: Date) => at.getTime() >= windowStart() && at.getTime() <= REFERENCE_DATE.getTime()

export type ContributionKind = "task" | "comment" | "file"

export type Contribution = {
  id: string
  kind: ContributionKind
  at: Date
  title: string
  detail: string
}

/** Every contribution a member made in the window, newest first. */
function contributionsOf(member: Member): Contribution[] {
  const projects = new Map(db.projects.all().map((project) => [project.id, project.name]))
  const posts = new Map(db.posts.all().map((post) => [post.id, post.author.name]))
  const folders = new Map(db.files.all().filter((file) => file.kind === "folder").map((folder) => [folder.id, folder.name]))

  const tasks = db.tasks
    .all()
    .filter((task) => task.assignee === member.id && task.status === "done" && inWindow(task.updatedAt))
    .map((task): Contribution => ({
      id: task.id,
      kind: "task",
      at: task.updatedAt,
      title: `Closed “${task.title.charAt(0).toUpperCase()}${task.title.slice(1)}”`,
      detail: projects.get(task.projectId) ?? "A project",
    }))
  const comments = db.comments
    .all()
    .filter((comment) => comment.author.name === member.name && inWindow(comment.at))
    .map((comment): Contribution => ({
      id: comment.id,
      kind: "comment",
      at: comment.at,
      title: `Commented on ${posts.get(comment.postId) === member.name ? "their own post" : `${posts.get(comment.postId) ?? "a"}’s post`}`,
      detail: comment.text,
    }))
  const files = db.files
    .all()
    .filter((file) => file.ownerId === member.id && file.kind !== "folder" && inWindow(file.modifiedAt))
    .map((file): Contribution => ({
      id: file.id,
      kind: "file",
      at: file.modifiedAt,
      title: `Added ${file.name}`,
      detail: `${file.parentId ? (folders.get(file.parentId) ?? "A folder") : "The drive"} · ${formatBytes(file.sizeBytes, 1)}`,
    }))

  return [...tasks, ...comments, ...files].sort((a, b) => b.at.getTime() - a.at.getTime())
}

/** The teammate the page is about: the most contributions in the window, the name breaking a tie. */
function subject(): Member {
  const me = meRow()
  const candidates = db.members.all().filter((member) => member.status === "active" && member.id !== me.id)
  return candidates
    .map((member) => ({ member, count: contributionsOf(member).length }))
    .sort((a, b) => b.count - a.count || a.member.name.localeCompare(b.member.name))[0].member
}

const ROLE_LABELS: Record<Member["role"], string> = {
  owner: "Workspace owner",
  admin: "Administrator",
  member: "Member",
  viewer: "Viewer",
}

const CITIES = ["Lisbon, PT", "Berlin, DE", "Amsterdam, NL", "Toronto, CA", "Melbourne, AU", "Copenhagen, DK", "Dublin, IE", "Oslo, NO"]

/** What a task label is called on a skill badge. */
const SKILL_NAMES: Record<string, string> = {
  api: "APIs",
  backend: "Backend",
  billing: "Billing",
  design: "Design",
  docs: "Documentation",
  infra: "Infrastructure",
  mobile: "Mobile",
  perf: "Performance",
  security: "Security",
  web: "Web",
}

export type Skill = { name: string; tasks: number; level: number }
export type TeamRow = { id: string; name: string; status: string; leads: boolean; people: { name: string; avatarUrl?: string }[] }
export type Profile = {
  id: string
  name: string
  firstName: string
  initials: string
  avatarUrl?: string
  email: string
  role: string
  location: string
  joined: string
  lastActive: string
  about: string
  stats: { projects: number; followers: number; following: number; contributions: number }
  skills: Skill[]
  teams: TeamRow[]
  activity: (Contribution & { when: string })[]
  /** Contributions per UTC day in the window, for the heatmap. */
  calendar: { date: string; count: number }[]
  /** The day the heatmap ends on, as `YYYY-MM-DD`. */
  calendarEnd: string
  /** The dm the signed-in member already shares with them, if there is one. */
  chatId?: string
  /** How many months back the heatmap reaches. */
  months: number
  /** The first day it draws, which the count starts on too: "Mar 1". */
  since: string
  /** The contributions of the window by kind. */
  breakdown: { tasks: number; comments: number; files: number }
}

/** How many of each kind the activity card shows: the newest three tasks closed, comments and files, merged. */
const PER_KIND = 3

/** The teammate, with every card's rows. */
export function profile(): Profile {
  const me = meRow()
  const member = subject()
  const rand = seeded("profile-page-v2")
  const first = member.name.split(" ")[0]
  const contributions = contributionsOf(member)
  const own = db.tasks.all().filter((task) => task.assignee === member.id)

  const projectIds = new Set([
    ...own.map((task) => task.projectId),
    ...db.projects.all().filter((project) => project.lead === member.id).map((project) => project.id),
  ])
  const people = new Map(db.members.all().map((row) => [row.id, row]))
  const teams = db.projects
    .all()
    .filter((project) => projectIds.has(project.id))
    .map((project): TeamRow => {
      const ids = new Set([project.lead, ...db.tasks.all().filter((task) => task.projectId === project.id).map((task) => task.assignee)])
      return {
        id: project.id,
        name: project.name,
        status: project.status,
        leads: project.lead === member.id,
        people: [...ids].flatMap((id) => (people.get(id) ? [{ name: people.get(id)!.name, avatarUrl: people.get(id)!.avatarUrl }] : [])),
      }
    })
    .sort((a, b) => Number(b.leads) - Number(a.leads) || b.people.length - a.people.length || a.name.localeCompare(b.name))

  const labels = new Map<string, number>()
  for (const task of own) for (const label of task.labels) if (SKILL_NAMES[label]) labels.set(label, (labels.get(label) ?? 0) + 1)
  const most = Math.max(1, ...labels.values())
  const skills = [...labels]
    .map(([label, tasks]) => ({ name: SKILL_NAMES[label], tasks, level: Math.round((tasks / most) * 100) }))
    .sort((a, b) => b.tasks - a.tasks || a.name.localeCompare(b.name))
    .slice(0, 6)

  const byDay = new Map<string, number>()
  for (const entry of contributions) {
    const key = entry.at.toISOString().slice(0, 10)
    byDay.set(key, (byDay.get(key) ?? 0) + 1)
  }

  const led = teams.filter((team) => team.leads).map((team) => team.name)
  const about = [
    `${first} is ${member.role === "admin" ? "an administrator" : member.role === "owner" ? "the owner" : member.role === "viewer" ? "a viewer" : "a member"} of the workspace and works across ${teams.length} ${teams.length === 1 ? "project" : "projects"}${led.length ? `, leading ${led.join(" and ")}` : ""}.`,
    skills.length ? `Most of ${first}’s tasks are ${skills.slice(0, 2).map((skill) => skill.name.toLowerCase()).join(" and ")} work.` : "",
  ]
    .filter(Boolean)
    .join(" ")

  const dm = db.chats.all().find((chat) => chat.kind === "dm" && chat.memberIds.includes(me.id) && chat.memberIds.includes(member.id))

  return {
    id: member.id,
    name: member.name,
    firstName: first,
    initials: getInitials(member.name),
    avatarUrl: member.avatarUrl,
    email: member.email,
    role: ROLE_LABELS[member.role],
    location: pick(rand, CITIES),
    joined: `Joined ${monthYear.format(member.joinedAt)}`,
    lastActive: since(member.lastActiveAt),
    about,
    stats: {
      projects: teams.length,
      followers: intBetween(rand, 12, 40),
      following: intBetween(rand, 8, 30),
      contributions: contributions.length,
    },
    skills,
    teams,
    activity: (["task", "comment", "file"] as const)
      .flatMap((kind) => contributions.filter((entry) => entry.kind === kind).slice(0, PER_KIND))
      .sort((a, b) => b.at.getTime() - a.at.getTime())
      .map((entry) => ({ ...entry, when: since(entry.at) })),
    breakdown: {
      tasks: contributions.filter((entry) => entry.kind === "task").length,
      comments: contributions.filter((entry) => entry.kind === "comment").length,
      files: contributions.filter((entry) => entry.kind === "file").length,
    },
    calendar: [...byDay].map(([date, count]) => ({ date, count })),
    calendarEnd: REFERENCE_DATE.toISOString().slice(0, 10),
    ...(dm ? { chatId: dm.id } : {}),
    months: MONTHS,
    since: shortDay.format(windowStart()),
  }
}

/** The person looking at the page: the signed-in member, not the one it is about. */
export function currentUser() {
  const me = meRow()
  return { name: me.name, email: me.email, initials: getInitials(me.name), avatarUrl: me.avatarUrl }
}

export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/profile/v2/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/** What this page changes: only who is signed in. Following a teammate is the reader's own, held for the session. */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/profile/v2/components/about-card.tsx
import * as React from "react"

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { DescriptionList } from "@/components/ui/description-list"

import { type Profile } from "../data"

/** Who the teammate is, in a sentence written from their rows, and the facts to reach them by. */
export function AboutCard({ profile }: { profile: Profile }) {
  const titleId = React.useId()
  return (
    <Card data-slot="profile-about" role="region" aria-labelledby={titleId}>
      <CardHeader>
        <CardTitle>
          <h2 id={titleId}>About</h2>
        </CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <p className="text-sm leading-6 text-pretty text-foreground">{profile.about}</p>
        <DescriptionList
          size="sm"
          orientation="vertical"
          items={[
            { term: "Email", description: <span className="break-all">{profile.email}</span> },
            { term: "Location", description: profile.location },
            { term: "Role", description: profile.role },
            { term: "Last active", description: profile.lastActive },
          ]}
        />
      </CardContent>
    </Card>
  )
}
app/profile/v2/components/activity-card.tsx
import * as React from "react"
import { CircleCheckIcon, FileUpIcon, MessageCircleIcon } from "lucide-react"

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Timeline, TimelineItem } from "@/components/ui/timeline"

import { type ContributionKind, type Profile } from "../data"

const ICONS: Record<ContributionKind, React.ReactNode> = {
  task: <CircleCheckIcon aria-hidden="true" className="size-3.5" />,
  comment: <MessageCircleIcon aria-hidden="true" className="size-3.5" />,
  file: <FileUpIcon aria-hidden="true" className="size-3.5" />,
}

/** The latest of what the teammate did — tasks closed, comments, files — newest first. */
export function ActivityCard({ profile }: { profile: Profile }) {
  const titleId = React.useId()
  return (
    <Card data-slot="profile-activity" role="region" aria-labelledby={titleId}>
      <CardHeader>
        <CardTitle>
          <h2 id={titleId}>Recent activity</h2>
        </CardTitle>
      </CardHeader>
      <CardContent>
        <Timeline>
          {profile.activity.map((entry) => (
            <TimelineItem
              key={entry.id}
              data-kind={entry.kind}
              icon={ICONS[entry.kind]}
              tone={entry.kind === "task" ? "success" : "default"}
              title={entry.title}
              time={<time dateTime={entry.at.toISOString()}>{entry.when}</time>}
              description={entry.detail}
            />
          ))}
        </Timeline>
      </CardContent>
    </Card>
  )
}
app/profile/v2/components/contribution-card.tsx
import * as React from "react"

import { ActivityCalendar } from "@/components/ui/activity-calendar"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DescriptionList } from "@/components/ui/description-list"

import { type Profile } from "../data"

/**
 * Contributions — tasks closed, comments written and files added — a square a
 * day: the kit's activity calendar over six months, ending today, each square
 * named by its contributions, and the count over exactly the days it draws,
 * which the description says. Where the card is narrower than the weeks, the
 * calendar opens on the latest of them.
 */
export function ContributionCard({ profile }: { profile: Profile }) {
  const titleId = React.useId()
  const total = profile.stats.contributions
  return (
    <Card data-slot="profile-contributions" role="region" aria-labelledby={titleId}>
      <CardHeader>
        <CardTitle>
          <h2 id={titleId}>Contributions</h2>
        </CardTitle>
        <CardDescription className="text-xs tabular-nums">
          {total} since {profile.since}: tasks closed, comments written and files added
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
        <ActivityCalendar
          data={profile.calendar}
          months={profile.months}
          endDate={profile.calendarEnd}
          countLabel={{ one: "contribution", other: "contributions" }}
          aria-label={`${profile.firstName}’s contributions, ${total} since ${profile.since}`}
        />
        <DescriptionList
          size="sm"
          aria-label="By kind"
          className="shrink-0 xl:w-44"
          items={[
            { term: "Tasks closed", description: <span className="tabular-nums">{profile.breakdown.tasks}</span> },
            { term: "Comments", description: <span className="tabular-nums">{profile.breakdown.comments}</span> },
            { term: "Files added", description: <span className="tabular-nums">{profile.breakdown.files}</span> },
          ]}
        />
      </CardContent>
    </Card>
  )
}
app/profile/v2/components/profile-header.tsx
"use client"

import * as React from "react"
import Link from "next/link"
import { MessageCircleIcon, UserCheckIcon, UserPlusIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button, buttonVariants } from "@/components/ui/button"
import { PageHeader } from "@/components/ui/page-header"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type Profile } from "../data"

export type ProfileHeaderProps = {
  profile: Pick<Profile, "name" | "firstName" | "initials" | "avatarUrl" | "role" | "location" | "joined" | "stats" | "chatId" | "since">
}

/**
 * The top of the profile, with no cover: the face, the name as the page's
 * title, the role, where they are and since when, Follow and Message — and
 * under it the four figures. Following is the reader's own act, held here
 * for the session: the Followers figure moves with it, and the status line
 * says so.
 */
export function ProfileHeader({ profile }: ProfileHeaderProps) {
  const [following, setFollowing] = React.useState(false)
  const [notice, setNotice] = React.useState("")
  const followers = profile.stats.followers + (following ? 1 : 0)
  const chat = profile.chatId ? `/projects/chat?chat=${profile.chatId}` : "/projects/chat"

  function toggle() {
    const next = !following
    setFollowing(next)
    setNotice(next ? `You follow ${profile.name}.` : `You no longer follow ${profile.name}.`)
  }

  return (
    <div data-slot="profile-header" className="flex flex-col gap-4">
      <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
        <Avatar className="size-20 shrink-0 sm:size-24">
          {profile.avatarUrl ? <AvatarImage src={profile.avatarUrl} alt="" /> : null}
          <AvatarFallback className="text-xl">{profile.initials}</AvatarFallback>
        </Avatar>
        <PageHeader
          className="min-w-0 flex-1"
          title={profile.name}
          description={profile.role}
          meta={
            <>
              <span>{profile.location}</span>
              <span>{profile.joined}</span>
            </>
          }
          actions={
            <>
              <Button type="button" aria-pressed={following} onClick={toggle} className="aria-pressed:bg-brand-muted aria-pressed:text-foreground">
                {/* The label stays "Follow"; the pressed state, the fill and the tick say it took. */}
                {following ? <UserCheckIcon data-icon="inline-start" /> : <UserPlusIcon data-icon="inline-start" />}
                Follow
              </Button>
              <Link href={chat} className={cn(buttonVariants({ variant: "outline" }))}>
                <MessageCircleIcon data-icon="inline-start" aria-hidden="true" />
                Message
              </Link>
            </>
          }
        />
      </div>
      <p data-slot="profile-status" role="status" aria-live="polite" className="-mt-2 min-h-5 text-sm text-muted-foreground">
        {notice}
      </p>

      <section aria-label={`${profile.firstName} in numbers`}>
      <StatCardGroup columns={4} divided>
        <StatCard size="sm" label="Projects" value={formatNumber(profile.stats.projects)} />
        <StatCard size="sm" label="Followers" value={formatNumber(followers)} />
        <StatCard size="sm" label="Following" value={formatNumber(profile.stats.following)} />
        <StatCard
          size="sm"
          label="Contributions"
          value={formatNumber(profile.stats.contributions)}
          description={`Since ${profile.since}`}
        />
      </StatCardGroup>
      </section>
    </div>
  )
}
app/profile/v2/components/skills-card.tsx
import * as React from "react"

import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"

import { type Profile } from "../data"

/**
 * What the teammate works on, read off the labels of their own tasks: each a
 * badge, with a meter of how much of their work carries it against the label
 * they carry most.
 */
export function SkillsCard({ profile }: { profile: Profile }) {
  const titleId = React.useId()
  return (
    <Card data-slot="profile-skills" role="region" aria-labelledby={titleId}>
      <CardHeader>
        <CardTitle>
          <h2 id={titleId}>Skills</h2>
        </CardTitle>
        <CardDescription className="text-xs">From the labels on {profile.firstName}’s tasks</CardDescription>
      </CardHeader>
      <CardContent>
        {profile.skills.length === 0 ? (
          <p className="text-sm text-muted-foreground">No labelled tasks yet.</p>
        ) : (
          <ul className="grid gap-x-8 gap-y-3 sm:grid-cols-2">
            {profile.skills.map((skill) => (
              <li key={skill.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1.5">
                <Badge variant="outline" className="w-fit">
                  {skill.name}
                </Badge>
                <span className="text-xs text-muted-foreground tabular-nums">
                  {skill.tasks} {skill.tasks === 1 ? "task" : "tasks"}
                </span>
                <Progress
                  value={skill.level}
                  aria-label={`${skill.name}: ${skill.tasks} ${skill.tasks === 1 ? "task" : "tasks"}`}
                  className="col-span-2"
                />
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  )
}
app/profile/v2/components/teams-card.tsx
import * as React from "react"

import { AvatarGroup } from "@/components/ui/avatar-group"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

import { type Profile } from "../data"

/** The teams the teammate is on: every project they lead or have work in, with the faces of the people in it. */
export function TeamsCard({ profile }: { profile: Profile }) {
  const titleId = React.useId()
  return (
    <Card data-slot="profile-teams" role="region" aria-labelledby={titleId}>
      <CardHeader>
        <CardTitle>
          <h2 id={titleId}>Teams</h2>
        </CardTitle>
      </CardHeader>
      <CardContent className="p-0">
        <ul className="divide-y">
          {profile.teams.map((team) => (
            <li key={team.id} className="flex items-center gap-3 px-4 py-3">
              <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                <span className="flex items-center gap-2">
                  <span className="truncate text-sm font-medium text-foreground">{team.name}</span>
                  {team.leads ? <Badge variant="secondary">Lead</Badge> : null}
                </span>
                <span className="text-xs text-muted-foreground tabular-nums">
                  {team.people.length} {team.people.length === 1 ? "person" : "people"}
                </span>
              </div>
              <AvatarGroup size="sm" max={4} users={team.people.map((person) => ({ name: person.name, src: person.avatarUrl }))} />
            </li>
          ))}
        </ul>
      </CardContent>
    </Card>
  )
}