{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-04",
  "title": "Team activity stream",
  "description": "Who worked on what with the time spent, a timer glyph on each avatar and a range switch that rescales the bars",
  "dependencies": [
    "date-fns",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/avatar.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/tabs.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/activity/activity-04.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion } from 'motion/react';\nimport { useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { PauseIcon, PlayIcon } from '@/registry/ndeko/lib/icons';\nimport { Tabs, TabsList, TabsTab } from '@/registry/ndeko/ui/tabs';\n\nimport { PersonAvatar } from './_parts/glyphs';\nimport { heightPresenceOf, RollingCount } from './_parts/motion-parts';\nimport { personOf, type Person } from './_parts/people';\nimport { durationLabelOf, relativeTimeOf } from './_parts/time';\n\ntype Range = 'today' | 'week';\n\ntype Presence = 'tracking' | 'paused' | 'away';\n\ntype WorkEntry = {\n  personId: string;\n  taskKey: string;\n  taskTitle: string;\n  minutes: number;\n  lastAt: string;\n};\n\nconst PRESENCE: Record<string, Presence> = {\n  leo: 'tracking',\n  tomas: 'tracking',\n  sana: 'paused',\n  mei: 'away',\n  jonah: 'away',\n  amara: 'away',\n};\n\nconst PRESENCE_LABEL: Record<Presence, string> = {\n  tracking: 'tracking time now',\n  paused: 'timer paused',\n  away: 'not tracking',\n};\n\nconst ENTRIES: Record<Range, WorkEntry[]> = {\n  today: [\n    {\n      personId: 'leo',\n      taskKey: 'MOS-214',\n      taskTitle: 'Export invoices as CSV',\n      minutes: 185,\n      lastAt: '2026-11-05T10:58:00',\n    },\n    {\n      personId: 'sana',\n      taskKey: 'MOS-217',\n      taskTitle: 'Filters on the quarterly report',\n      minutes: 140,\n      lastAt: '2026-11-05T10:31:00',\n    },\n    {\n      personId: 'tomas',\n      taskKey: 'MOS-209',\n      taskTitle: 'Review the refund flow copy',\n      minutes: 95,\n      lastAt: '2026-11-05T10:56:00',\n    },\n    {\n      personId: 'mei',\n      taskKey: 'MOS-201',\n      taskTitle: 'Onboarding checklist emails',\n      minutes: 50,\n      lastAt: '2026-11-05T09:20:00',\n    },\n  ],\n  week: [\n    {\n      personId: 'leo',\n      taskKey: 'MOS-214',\n      taskTitle: 'Export invoices as CSV',\n      minutes: 640,\n      lastAt: '2026-11-05T10:58:00',\n    },\n    {\n      personId: 'sana',\n      taskKey: 'MOS-217',\n      taskTitle: 'Filters on the quarterly report',\n      minutes: 515,\n      lastAt: '2026-11-05T10:31:00',\n    },\n    {\n      personId: 'mei',\n      taskKey: 'MOS-201',\n      taskTitle: 'Onboarding checklist emails',\n      minutes: 410,\n      lastAt: '2026-11-05T09:20:00',\n    },\n    {\n      personId: 'tomas',\n      taskKey: 'MOS-209',\n      taskTitle: 'Review the refund flow copy',\n      minutes: 330,\n      lastAt: '2026-11-05T10:56:00',\n    },\n    {\n      personId: 'jonah',\n      taskKey: 'MOS-198',\n      taskTitle: 'Archive old workspaces',\n      minutes: 260,\n      lastAt: '2026-11-04T17:45:00',\n    },\n    {\n      personId: 'amara',\n      taskKey: 'MOS-205',\n      taskTitle: 'Billing settings layout',\n      minutes: 220,\n      lastAt: '2026-11-03T15:10:00',\n    },\n  ],\n};\n\nconst TOTAL_LABEL: Record<Range, string> = {\n  today: 'tracked today',\n  week: 'tracked this week',\n};\n\nfunction isRange(value: unknown): value is Range {\n  const isToday = value === 'today';\n  const isWeek = value === 'week';\n  return isToday || isWeek;\n}\n\nfunction totalOf(entries: WorkEntry[]) {\n  let total = 0;\n  for (const entry of entries) {\n    total += entry.minutes;\n  }\n  return total;\n}\n\nfunction longestOf(entries: WorkEntry[]) {\n  const minutes = entries.map((entry) => entry.minutes);\n  return Math.max(1, ...minutes);\n}\n\nfunction presenceOf(personId: string): Presence {\n  return PRESENCE[personId] ?? 'away';\n}\n\nfunction PresenceAvatar({\n  person,\n  presence,\n}: {\n  person: Person;\n  presence: Presence;\n}) {\n  const isTracking = presence === 'tracking';\n  const isPaused = presence === 'paused';\n\n  return (\n    <span className='relative inline-flex shrink-0'>\n      <PersonAvatar person={person} className='size-8 text-xs' />\n      {isTracking && (\n        <span className='bg-card text-success-foreground ring-card absolute -end-0.5 -bottom-0.5 flex size-3.5 items-center justify-center rounded-full ring-2'>\n          <PlayIcon strokeWidth={2.5} className='size-2' />\n        </span>\n      )}\n      {isPaused && (\n        <span className='bg-card text-warning-foreground ring-card absolute -end-0.5 -bottom-0.5 flex size-3.5 items-center justify-center rounded-full ring-2'>\n          <PauseIcon strokeWidth={2.5} className='size-2' />\n        </span>\n      )}\n    </span>\n  );\n}\n\nfunction layoutOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return false;\n  }\n  return 'position';\n}\n\nfunction WorkRow({ entry, longest }: { entry: WorkEntry; longest: number }) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const person = personOf(entry.personId);\n  const presence = presenceOf(entry.personId);\n  const share = entry.minutes / longest;\n  const barStyle = { transform: `scaleX(${share})` } as React.CSSProperties;\n\n  return (\n    <motion.li\n      {...heightPresenceOf(shouldReduceMotion)}\n      layout={layoutOf(shouldReduceMotion)}\n      className='overflow-hidden'\n    >\n      <div className='flex items-center gap-3 py-2'>\n        <PresenceAvatar person={person} presence={presence} />\n        <div className='min-w-0 flex-1'>\n          <p className='truncate text-[13px] font-medium'>\n            {person.name}\n            <span className='sr-only'>, {PRESENCE_LABEL[presence]}</span>\n          </p>\n          <p className='text-muted-foreground truncate text-xs'>\n            <span className='tabular-nums'>{entry.taskKey}</span>{' '}\n            {entry.taskTitle} · {relativeTimeOf(entry.lastAt)}\n          </p>\n        </div>\n        <div className='flex w-20 shrink-0 flex-col items-end gap-1.5'>\n          <RollingCount\n            value={entry.minutes}\n            format={durationLabelOf}\n            className='text-[13px] font-medium'\n          />\n          <span\n            aria-hidden='true'\n            className='bg-foreground/[0.06] block h-1 w-full overflow-hidden rounded-full'\n          >\n            <span\n              style={barStyle}\n              className='bg-primary block h-full w-full origin-left rounded-full transition-transform duration-300 ease-(--ease-out-strong)'\n            />\n          </span>\n        </div>\n      </div>\n    </motion.li>\n  );\n}\n\nfunction rowKeyOf(entry: WorkEntry) {\n  return `${entry.personId}-${entry.taskKey}`;\n}\n\nexport default function Activity04() {\n  const [range, setRange] = useState<Range>('today');\n  const entries = ENTRIES[range];\n  const total = totalOf(entries);\n  const longest = longestOf(entries);\n\n  function change(value: unknown) {\n    if (isRange(value)) {\n      setRange(value);\n    }\n  }\n\n  return (\n    <section aria-label='Team activity' className='w-full max-w-md'>\n      <header className='flex flex-wrap items-start justify-between gap-3'>\n        <div>\n          <h3 className='text-sm font-medium'>Team activity</h3>\n          <p className='text-muted-foreground mt-0.5 text-xs'>\n            <RollingCount\n              value={total}\n              format={durationLabelOf}\n              className='text-foreground font-medium'\n            />{' '}\n            {TOTAL_LABEL[range]}\n          </p>\n        </div>\n        <Tabs value={range} onValueChange={change}>\n          <TabsList variant='segmented' aria-label='Range'>\n            <TabsTab value='today'>Today</TabsTab>\n            <TabsTab value='week'>This week</TabsTab>\n          </TabsList>\n        </Tabs>\n      </header>\n      <ul className='mt-2'>\n        <AnimatePresence initial={false}>\n          {entries.map((entry) => (\n            <WorkRow key={rowKeyOf(entry)} entry={entry} longest={longest} />\n          ))}\n        </AnimatePresence>\n      </ul>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/activity/activity-04.tsx"
    },
    {
      "path": "registry/ndeko/components/activity/_parts/glyphs.tsx",
      "content": "import { cn } from '@/registry/ndeko/lib/utils';\nimport { Avatar } from '@/registry/ndeko/ui/avatar';\n\nimport type { Person } from './people';\n\nexport type StatusId =\n  'backlog' | 'todo' | 'in_progress' | 'in_review' | 'done';\n\nexport type PriorityId = 'urgent' | 'high' | 'medium' | 'low';\n\nexport const STATUS_NAME: Record<StatusId, string> = {\n  backlog: 'Backlog',\n  todo: 'Todo',\n  in_progress: 'In progress',\n  in_review: 'In review',\n  done: 'Done',\n};\n\nconst STATUS_COLOR: Record<StatusId, string> = {\n  backlog: 'var(--muted-foreground)',\n  todo: 'var(--muted-foreground)',\n  in_progress: 'var(--warning)',\n  in_review: 'var(--primary)',\n  done: 'var(--success)',\n};\n\nexport const PRIORITY_NAME: Record<PriorityId, string> = {\n  urgent: 'Urgent',\n  high: 'High',\n  medium: 'Medium',\n  low: 'Low',\n};\n\nconst PIE_RADIUS = 2;\n\nconst PIE_CIRCUMFERENCE = 2 * Math.PI * PIE_RADIUS;\n\nfunction StartedShape({ share }: { share: number }) {\n  const filled = share * PIE_CIRCUMFERENCE;\n  return (\n    <>\n      <circle cx='7' cy='7' r='6' stroke='currentColor' strokeWidth='1.5' />\n      <circle\n        cx='7'\n        cy='7'\n        r={PIE_RADIUS}\n        stroke='currentColor'\n        strokeWidth={PIE_RADIUS * 2}\n        strokeDasharray={`${filled} ${PIE_CIRCUMFERENCE}`}\n        transform='rotate(-90 7 7)'\n      />\n    </>\n  );\n}\n\nfunction statusShapeOf(status: StatusId) {\n  if (status === 'backlog') {\n    return (\n      <circle\n        cx='7'\n        cy='7'\n        r='6'\n        stroke='currentColor'\n        strokeWidth='1.5'\n        strokeDasharray='1.6 1.9'\n      />\n    );\n  }\n  if (status === 'todo') {\n    return (\n      <circle cx='7' cy='7' r='6' stroke='currentColor' strokeWidth='1.5' />\n    );\n  }\n  if (status === 'in_progress') {\n    return <StartedShape share={0.5} />;\n  }\n  if (status === 'in_review') {\n    return <StartedShape share={0.75} />;\n  }\n  return (\n    <>\n      <circle cx='7' cy='7' r='7' fill='currentColor' />\n      <path\n        d='M4.4 7.2 6.2 9l3.4-3.8'\n        stroke='var(--card)'\n        strokeWidth='1.6'\n        strokeLinecap='round'\n        strokeLinejoin='round'\n      />\n    </>\n  );\n}\n\nexport function StatusGlyph({\n  status,\n  className,\n}: {\n  status: StatusId;\n  className?: string;\n}) {\n  const glyphStyle = { color: STATUS_COLOR[status] } as React.CSSProperties;\n  return (\n    <svg\n      viewBox='0 0 14 14'\n      fill='none'\n      aria-hidden='true'\n      style={glyphStyle}\n      className={cn('size-3.5 shrink-0', className)}\n    >\n      {statusShapeOf(status)}\n    </svg>\n  );\n}\n\nconst BAR_HEIGHTS = [4, 7, 10];\n\nconst FILLED_BARS: Record<PriorityId, number> = {\n  urgent: 3,\n  high: 3,\n  medium: 2,\n  low: 1,\n};\n\nfunction barOpacityOf(index: number, filledCount: number) {\n  if (index < filledCount) {\n    return 0.8;\n  }\n  return 0.2;\n}\n\nexport function PriorityGlyph({\n  priority,\n  className,\n}: {\n  priority: PriorityId;\n  className?: string;\n}) {\n  const isUrgent = priority === 'urgent';\n  const filledCount = FILLED_BARS[priority];\n  return (\n    <svg\n      viewBox='0 0 14 14'\n      aria-hidden='true'\n      className={cn(\n        'text-foreground size-3.5 shrink-0',\n        isUrgent && 'text-destructive-ink',\n        className\n      )}\n    >\n      {BAR_HEIGHTS.map((height, index) => (\n        <rect\n          key={height}\n          x={1.5 + index * 4.25}\n          y={12 - height}\n          width='2.5'\n          height={height}\n          rx='0.75'\n          fill='currentColor'\n          fillOpacity={barOpacityOf(index, filledCount)}\n        />\n      ))}\n    </svg>\n  );\n}\n\nexport function PersonAvatar({\n  person,\n  className,\n}: {\n  person: Person;\n  className?: string;\n}) {\n  return (\n    <Avatar\n      className={cn('size-6 text-[10px]', className)}\n      seed={person.name}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/activity/_parts/glyphs.tsx"
    },
    {
      "path": "registry/ndeko/components/activity/_parts/motion-parts.tsx",
      "content": "'use client';\n\nimport {\n  AnimatePresence,\n  motion,\n  type MotionProps,\n  type Transition,\n  type Variants,\n} from 'motion/react';\nimport { useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nexport const EASE_OUT_STRONG: [number, number, number, number] = [\n  0.23, 1, 0.32, 1,\n];\n\nexport const ENTER: Transition = { duration: 0.2, ease: EASE_OUT_STRONG };\n\nexport const EXIT: Transition = { duration: 0.15, ease: EASE_OUT_STRONG };\n\nexport type PresenceProps = Pick<\n  MotionProps,\n  'initial' | 'animate' | 'exit' | 'transition'\n>;\n\nconst HEIGHT_PRESENCE: PresenceProps = {\n  initial: { height: 0, opacity: 0 },\n  animate: { height: 'auto', opacity: 1 },\n  exit: { height: 0, opacity: 0, transition: EXIT },\n  transition: ENTER,\n};\n\nconst FADE_PRESENCE: PresenceProps = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0, transition: EXIT },\n  transition: ENTER,\n};\n\n/** Rows that arrive or leave a list: their height opens and closes so the rows after them slide. */\nexport function heightPresenceOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_PRESENCE;\n  }\n  return HEIGHT_PRESENCE;\n}\n\nconst SWAP_TRANSITION: Transition = { duration: 0.15, ease: EASE_OUT_STRONG };\n\nconst SWAP_VARIANTS: Variants = {\n  hidden: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\n  shown: { opacity: 1, scale: 1, filter: 'blur(0px)' },\n};\n\nconst FADE_VARIANTS: Variants = {\n  hidden: { opacity: 0 },\n  shown: { opacity: 1 },\n};\n\nfunction swapVariantsOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_VARIANTS;\n  }\n  return SWAP_VARIANTS;\n}\n\ntype SwapProps = {\n  swapKey: string;\n  children: React.ReactNode;\n  className?: string;\n};\n\n/** An icon or a short label that becomes another one in place. */\nexport function Swap({ swapKey, children, className }: SwapProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n\n  return (\n    <span className={cn('relative inline-flex', className)}>\n      <AnimatePresence mode='popLayout' initial={false}>\n        <motion.span\n          key={swapKey}\n          variants={swapVariantsOf(shouldReduceMotion)}\n          initial='hidden'\n          animate='shown'\n          exit='hidden'\n          transition={SWAP_TRANSITION}\n          className='inline-flex'\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nconst COUNT_SLIDE = 8;\n\nconst COUNT_TRANSITION: Transition = { duration: 0.18, ease: EASE_OUT_STRONG };\n\nconst COUNT_VARIANTS: Variants = {\n  enter: (offset: number) => ({ y: offset, opacity: 0 }),\n  center: { y: 0, opacity: 1 },\n  leave: (offset: number) => ({ y: -offset, opacity: 0 }),\n};\n\nfunction directionOf(previous: number, next: number) {\n  if (next < previous) {\n    return -1;\n  }\n  return 1;\n}\n\nfunction offsetOf(direction: number, shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return 0;\n  }\n  return direction * COUNT_SLIDE;\n}\n\ntype RollingCountProps = {\n  value: number;\n  /** Writes the value, such as a duration; the digits still roll on the number. */\n  format?: (value: number) => string;\n  className?: string;\n};\n\nfunction plainNumber(value: number) {\n  return String(value);\n}\n\n/** A figure the person just changed: the new digits slide in from the side of the change. */\nexport function RollingCount({\n  value,\n  format = plainNumber,\n  className,\n}: RollingCountProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const [previous, setPrevious] = useState(value);\n  const [direction, setDirection] = useState(1);\n\n  if (previous !== value) {\n    setDirection(directionOf(previous, value));\n    setPrevious(value);\n  }\n\n  const offset = offsetOf(direction, shouldReduceMotion);\n\n  return (\n    <span\n      className={cn(\n        'relative inline-flex overflow-hidden tabular-nums',\n        className\n      )}\n    >\n      <AnimatePresence mode='popLayout' initial={false} custom={offset}>\n        <motion.span\n          key={value}\n          custom={offset}\n          variants={COUNT_VARIANTS}\n          initial='enter'\n          animate='center'\n          exit='leave'\n          transition={COUNT_TRANSITION}\n          className='inline-block'\n        >\n          {format(value)}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/activity/_parts/motion-parts.tsx"
    },
    {
      "path": "registry/ndeko/components/activity/_parts/people.ts",
      "content": "export type Person = {\n  id: string;\n  name: string;\n  firstName: string;\n};\n\nexport const CURRENT_USER_ID = 'amara';\n\nexport const PEOPLE: Person[] = [\n  {\n    id: 'amara',\n    name: 'Amara Okafor',\n    firstName: 'Amara',\n  },\n  {\n    id: 'leo',\n    name: 'Léo Martin',\n    firstName: 'Léo',\n  },\n  {\n    id: 'sana',\n    name: 'Sana Iqbal',\n    firstName: 'Sana',\n  },\n  {\n    id: 'tomas',\n    name: 'Tomás Rivera',\n    firstName: 'Tomás',\n  },\n  {\n    id: 'mei',\n    name: 'Mei Lin',\n    firstName: 'Mei',\n  },\n  {\n    id: 'jonah',\n    name: 'Jonah Brandt',\n    firstName: 'Jonah',\n  },\n];\n\nexport function personOf(id: string) {\n  const found = PEOPLE.find((person) => person.id === id);\n  if (found) {\n    return found;\n  }\n  return PEOPLE[0];\n}\n\nexport function isCurrentUser(id: string) {\n  return id === CURRENT_USER_ID;\n}\n\nexport function actorNameOf(id: string) {\n  if (isCurrentUser(id)) {\n    return 'You';\n  }\n  return personOf(id).name;\n}\n",
      "type": "registry:component",
      "target": "components/activity/_parts/people.ts"
    },
    {
      "path": "registry/ndeko/components/activity/_parts/time.ts",
      "content": "import {\n  differenceInCalendarDays,\n  differenceInMinutes,\n  format,\n  parseISO,\n} from 'date-fns';\n\n/** The previews read every time against one fixed moment, so the server and the browser print the same labels. */\nexport const NOW = '2026-11-05T11:00:00';\n\nconst MINUTES_PER_HOUR = 60;\n\nfunction daysBeforeNow(iso: string) {\n  return differenceInCalendarDays(parseISO(NOW), parseISO(iso));\n}\n\nexport function relativeTimeOf(iso: string) {\n  const minutes = differenceInMinutes(parseISO(NOW), parseISO(iso));\n  if (minutes < 1) {\n    return 'Just now';\n  }\n  if (minutes < MINUTES_PER_HOUR) {\n    return `${minutes}m ago`;\n  }\n  const days = daysBeforeNow(iso);\n  if (days === 0) {\n    const hours = Math.floor(minutes / MINUTES_PER_HOUR);\n    return `${hours}h ago`;\n  }\n  if (days === 1) {\n    return 'Yesterday';\n  }\n  return format(parseISO(iso), 'MMM d');\n}\n\nexport function exactTimeOf(iso: string) {\n  return format(parseISO(iso), \"EEE, MMM d 'at' HH:mm\");\n}\n\nexport function clockOf(iso: string) {\n  return format(parseISO(iso), 'HH:mm');\n}\n\nexport function dayKeyOf(iso: string) {\n  return iso.slice(0, 10);\n}\n\nexport function dayLabelOf(iso: string) {\n  const days = daysBeforeNow(iso);\n  if (days === 0) {\n    return 'Today';\n  }\n  if (days === 1) {\n    return 'Yesterday';\n  }\n  return format(parseISO(iso), 'EEEE, MMM d');\n}\n\nexport function shortDateOf(iso: string) {\n  return format(parseISO(iso), 'MMM d');\n}\n\nexport function durationLabelOf(minutes: number) {\n  const hours = Math.floor(minutes / MINUTES_PER_HOUR);\n  const rest = minutes % MINUTES_PER_HOUR;\n  if (hours === 0) {\n    return `${rest}m`;\n  }\n  if (rest === 0) {\n    return `${hours}h`;\n  }\n  return `${hours}h ${rest}m`;\n}\n\nexport function countLabel(count: number, singular: string, plural: string) {\n  if (count === 1) {\n    return `${count} ${singular}`;\n  }\n  return `${count} ${plural}`;\n}\n",
      "type": "registry:component",
      "target": "components/activity/_parts/time.ts"
    }
  ],
  "categories": [
    "components",
    "activity"
  ],
  "type": "registry:component"
}