{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timesheet-1",
  "title": "Timesheet 1: weekly hours",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A weekly timesheet: a row per project and a box per day, time typed the way people say it (1.5, 1:30, 90m), totals worked out as you type, daily bars that say when a day runs over, weeks to page through, and submit that locks the week.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/select.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/timesheet-1/timesheet-1.tsx",
      "content": "// Ballmac UI: Timesheet 1. https://ui.ballmac.com/blocks/timesheet-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight, CircleAlert, CircleCheck, Clock, Loader2, Plus, Send, Trash2 } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Timesheet1Project = { id: string; name: string; client?: string; /** Minutes by ISO date. */ entries: Record<string, number> }\n\ntype Timesheet1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    projects?: Timesheet1Project[]\n    /** Projects that can be added as a row. */\n    available?: { id: string; name: string; client?: string }[]\n    /** ISO date of the Monday of the week shown first. */\n    weekStart?: string\n    /** Hours a full day is, for the daily bars. */\n    dayTarget?: number\n    /** ISO dates of weeks already submitted, and so locked. */\n    submittedWeeks?: string[]\n    locale?: string\n    title?: string\n    /** Called when the person submits a week. Return a promise; reject to show the reason and stay open. */\n    onSubmit?: (week: { weekStart: string; minutes: number; entries: Record<string, Record<string, number>> }) => void | Promise<void>\n    onChange?: (projectId: string, date: string, minutes: number) => void\n  }\n\nconst DAY = 86_400_000\nconst toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`)\nconst iso = (t: number) => new Date(t).toISOString().slice(0, 10)\n\nconst sample: Timesheet1Project[] = [\n  { id: \"p1\", name: \"Website redesign\", client: \"Northwind\", entries: { \"2026-10-12\": 240, \"2026-10-13\": 300, \"2026-10-14\": 270, \"2026-10-15\": 180, \"2026-10-16\": 120, \"2026-10-19\": 270, \"2026-10-20\": 240 } },\n  { id: \"p2\", name: \"Design system\", client: \"Internal\", entries: { \"2026-10-12\": 120, \"2026-10-13\": 90, \"2026-10-14\": 150, \"2026-10-15\": 240, \"2026-10-16\": 180, \"2026-10-19\": 120 } },\n  { id: \"p3\", name: \"Customer calls\", client: \"Various\", entries: { \"2026-10-12\": 60, \"2026-10-14\": 90, \"2026-10-16\": 120, \"2026-10-19\": 60, \"2026-10-20\": 90 } },\n]\nconst sampleAvailable = [{ id: \"p4\", name: \"Brand workshop\", client: \"Orbit Labs\" }, { id: \"p5\", name: \"Accessibility audit\", client: \"Lumen Health\" }, { id: \"p6\", name: \"Training\", client: \"Internal\" }]\n\n// Accepts \"1.5\", \"1,5\", \"1:30\", \"90m\" and \"2h\", the ways people really type time.\nfunction parse(text: string): number | null {\n  const t = text.trim().toLowerCase()\n  if (!t) return 0\n  let m: RegExpMatchArray | null\n  if ((m = t.match(/^(\\d+):(\\d{1,2})$/))) return Number(m[1]) * 60 + Number(m[2])\n  if ((m = t.match(/^(\\d+(?:[.,]\\d+)?)\\s*m(?:in)?$/))) return Math.round(Number(m[1].replace(\",\", \".\")))\n  if ((m = t.match(/^(\\d+(?:[.,]\\d+)?)\\s*h?$/))) return Math.round(Number(m[1].replace(\",\", \".\")) * 60)\n  return null\n}\nconst show = (min: number) => (min ? `${Math.floor(min / 60)}:${String(min % 60).padStart(2, \"0\")}` : \"\")\nconst hours = (min: number) => `${Math.floor(min / 60)} h${min % 60 ? ` ${min % 60} min` : \"\"}`\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"bg-card rounded-2xl border p-5\">\n      <Skeleton className=\"h-8 w-60\" />\n      <Skeleton className=\"mt-6 h-64\" />\n    </div>\n  )\n}\n\n/**\n * A weekly timesheet: a row for each project, a box for each day, and the totals worked out as you type. Time can be typed the way people say\n * it (1.5, 1:30, 90m, 2h). Each day has a bar against a full day and says so when it runs over, a day above 24 hours is refused, weeks can be\n * paged through, and submitting locks a week so it cannot be changed afterwards.\n */\nfunction Timesheet1({\n  projects: projectsProp = sample,\n  available = sampleAvailable,\n  weekStart = \"2026-10-12\",\n  dayTarget = 8,\n  submittedWeeks = [],\n  locale = \"en\",\n  title = \"Timesheet\",\n  onSubmit,\n  onChange,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Timesheet1Props) {\n  const id = React.useId()\n  const [rows, setRows] = React.useState<Timesheet1Project[]>(projectsProp)\n  React.useEffect(() => setRows(projectsProp), [projectsProp])\n  const [start, setStart] = React.useState(weekStart)\n  const [locked, setLocked] = React.useState<string[]>(submittedWeeks)\n  const [drafts, setDrafts] = React.useState<Record<string, string>>({})\n  const [errors, setErrors] = React.useState<Record<string, string>>({})\n  const [submitting, setSubmitting] = React.useState(false)\n  const [submitError, setSubmitError] = React.useState<string>()\n  const [announce, setAnnounce] = React.useState(\"\")\n  const list = state === \"empty\" ? [] : rows\n\n  const days = Array.from({ length: 7 }, (_, i) => iso(toT(start) + i * DAY))\n  const dayFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"short\", timeZone: \"UTC\" }), [locale])\n  const numFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", timeZone: \"UTC\" }), [locale])\n  const isLocked = locked.includes(start)\n  const target = dayTarget * 60\n  const colTotal = (d: string) => list.reduce((s, p) => s + (p.entries[d] ?? 0), 0)\n  const rowTotal = (p: Timesheet1Project) => days.reduce((s, d) => s + (p.entries[d] ?? 0), 0)\n  const week = days.reduce((s, d) => s + colTotal(d), 0)\n  const weekTarget = target * 5\n  const free = available.filter((a) => !rows.some((r) => r.id === a.id))\n\n  function commit(p: Timesheet1Project, d: string) {\n    const key = `${p.id}|${d}`\n    const text = drafts[key]\n    if (text === undefined) return\n    const min = parse(text)\n    const others = colTotal(d) - (p.entries[d] ?? 0)\n    setDrafts((x) => { const { [key]: _gone, ...rest } = x; return rest })\n    if (min === null) return setErrors((e) => ({ ...e, [key]: \"Type time like 1.5, 1:30 or 90m.\" }))\n    if (others + min > 24 * 60) return setErrors((e) => ({ ...e, [key]: \"A day has 24 hours. Take some off another project first.\" }))\n    setErrors((e) => { const { [key]: _gone, ...rest } = e; return rest })\n    setRows((rs) => rs.map((r) => (r.id === p.id ? { ...r, entries: { ...r.entries, [d]: min } } : r)))\n    onChange?.(p.id, d, min)\n  }\n  async function submit() {\n    setSubmitError(undefined)\n    setSubmitting(true)\n    try {\n      await onSubmit?.({ weekStart: start, minutes: week, entries: Object.fromEntries(list.map((p) => [p.id, Object.fromEntries(days.filter((d) => p.entries[d]).map((d) => [d, p.entries[d]!]))])) })\n      setLocked((l) => [...l, start])\n      setAnnounce(`Week of ${numFmt.format(toT(start))} submitted`)\n    } catch (e) {\n      setSubmitError(e instanceof Error ? e.message : \"The week could not be submitted. Nothing was lost, so try again.\")\n    } finally {\n      setSubmitting(false)\n    }\n  }\n  const go = (d: number) => {\n    setStart(iso(toT(start) + d * 7 * DAY))\n    setDrafts({})\n    setErrors({})\n    setSubmitError(undefined)\n  }\n\n  const content = !list.length ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <Clock className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No projects on this timesheet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty\">{emptyState?.description ?? \"Add a project to start recording the time you spend.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"bg-card rounded-2xl border\">\n      <div className=\"flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3.5 sm:px-5\">\n        <div className=\"flex items-center gap-1\">\n          <Button variant=\"outline\" size=\"icon\" aria-label=\"Previous week\" onClick={() => go(-1)}>\n            <ChevronLeft aria-hidden=\"true\" className=\"rtl:rotate-180\" />\n          </Button>\n          <h2 className=\"min-w-48 px-3 text-center text-base font-semibold tracking-tight tabular-nums\">\n            {numFmt.format(toT(days[0]!))} to {numFmt.format(toT(days[6]!))}\n          </h2>\n          <Button variant=\"outline\" size=\"icon\" aria-label=\"Next week\" onClick={() => go(1)}>\n            <ChevronRight aria-hidden=\"true\" className=\"rtl:rotate-180\" />\n          </Button>\n        </div>\n        <p className={cn(\"flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium\", isLocked && \"bg-chart-2/10 border-chart-2/30\")}>\n          {isLocked ? <CircleCheck aria-hidden=\"true\" className=\"size-4\" /> : <Clock aria-hidden=\"true\" className=\"size-4\" />}\n          {isLocked ? \"Submitted\" : \"Draft\"}\n        </p>\n      </div>\n\n      <div role=\"region\" tabIndex={0} aria-label=\"Hours by project and day\" className=\"focus-visible:ring-ring/50 overflow-x-auto outline-none focus-visible:ring-[3px] focus-visible:ring-inset\">\n        <table className=\"w-full min-w-[46rem] text-sm\">\n          <caption className=\"sr-only\">Hours worked on each project for the week of {numFmt.format(toT(start))}</caption>\n          <thead>\n            <tr className=\"text-muted-foreground\">\n              <th scope=\"col\" className=\"w-56 px-5 py-3 text-start font-medium\">Project</th>\n              {days.map((d, i) => (\n                <th key={d} scope=\"col\" className=\"px-1 py-3 text-center font-medium\">\n                  <span className=\"block text-xs\">{dayFmt.format(toT(d))}</span>\n                  <span className=\"text-foreground block tabular-nums\">{numFmt.format(toT(d))}</span>\n                </th>\n              ))}\n              <th scope=\"col\" className=\"px-5 py-3 text-end font-medium\">Total</th>\n            </tr>\n          </thead>\n          <tbody className=\"divide-y border-t\">\n            {list.map((p) => (\n              <tr key={p.id}>\n                <th scope=\"row\" className=\"px-5 py-3 text-start font-normal\">\n                  <span className=\"block font-medium\">{p.name}</span>\n                  {p.client ? <span className=\"text-muted-foreground block text-xs\">{p.client}</span> : null}\n                </th>\n                {days.map((d, i) => {\n                  const key = `${p.id}|${d}`\n                  const err = errors[key]\n                  return (\n                    <td key={d} className=\"px-1 py-2 text-center align-top\">\n                      <input\n                        inputMode=\"decimal\"\n                        aria-label={`${p.name}, ${dayFmt.format(toT(d))} ${numFmt.format(toT(d))}`}\n                        aria-invalid={!!err}\n                        disabled={isLocked}\n                        value={drafts[key] ?? show(p.entries[d] ?? 0)}\n                        placeholder=\"0:00\"\n                        onChange={(e) => setDrafts((x) => ({ ...x, [key]: e.target.value }))}\n                        onBlur={() => commit(p, d)}\n                        onKeyDown={(e) => { if (e.key === \"Enter\") { e.preventDefault(); commit(p, d) } }}\n                        className={cn(\"focus-visible:ring-ring/50 h-10 w-14 rounded-lg border bg-transparent text-center tabular-nums outline-none placeholder:text-muted-foreground focus-visible:ring-[3px] disabled:opacity-60\", i > 4 && \"bg-muted/40\", err && \"border-destructive\")}\n                      />\n                      {err ? <span role=\"alert\" className=\"text-destructive mt-1 block text-[11px] leading-tight text-pretty\">{err}</span> : null}\n                    </td>\n                  )\n                })}\n                <td className=\"px-5 py-3 text-end font-semibold tabular-nums\">\n                  {show(rowTotal(p)) || \"0:00\"}\n                  {!isLocked ? (\n                    <Button variant=\"ghost\" size=\"icon\" className=\"ms-1 align-middle\" aria-label={`Remove ${p.name}`} onClick={() => setRows((rs) => rs.filter((r) => r.id !== p.id))}>\n                      <Trash2 aria-hidden=\"true\" />\n                    </Button>\n                  ) : null}\n                </td>\n              </tr>\n            ))}\n          </tbody>\n          <tfoot>\n            <tr className=\"border-t\">\n              <th scope=\"row\" className=\"px-5 py-3 text-start font-semibold\">Day total</th>\n              {days.map((d, i) => {\n                const t = colTotal(d)\n                const over = i < 5 && t > target\n                return (\n                  <td key={d} className=\"px-1 py-3 text-center\">\n                    <span className={cn(\"block font-semibold tabular-nums\", t === 0 && \"text-muted-foreground\")}>{show(t) || \"0:00\"}</span>\n                    <span aria-hidden=\"true\" className=\"bg-muted mx-auto mt-1.5 block h-1.5 w-12 overflow-hidden rounded-full\">\n                      <span className={cn(\"block h-full rounded-full\", over ? \"bg-amber-500\" : \"bg-foreground\")} style={{ width: `${Math.min(100, (t / target) * 100)}%` }} />\n                    </span>\n                    {over ? <span className=\"mt-1 block text-[11px] font-medium text-amber-700 dark:text-amber-400\">+{show(t - target)} over</span> : null}\n                  </td>\n                )\n              })}\n              <td className=\"px-5 py-3 text-end text-base font-semibold tabular-nums\">{show(week) || \"0:00\"}</td>\n            </tr>\n          </tfoot>\n        </table>\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-between gap-4 border-t px-4 py-4 sm:px-5\">\n        <div className=\"flex flex-wrap items-center gap-3\">\n          {!isLocked && free.length ? (\n            <Select value=\"\" onValueChange={(v) => { const a = available.find((x) => x.id === v); if (a) setRows((rs) => [...rs, { ...a, entries: {} }]) }}>\n              <SelectTrigger aria-label=\"Add a project\" className=\"w-52\">\n                <Plus aria-hidden=\"true\" className=\"size-4\" />\n                <SelectValue placeholder=\"Add a project\" />\n              </SelectTrigger>\n              <SelectContent>\n                {free.map((a) => (\n                  <SelectItem key={a.id} value={a.id}>\n                    {a.name}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n          ) : null}\n          <p className=\"text-muted-foreground text-sm tabular-nums\">\n            {hours(week)} of {hours(weekTarget)}\n            {week > weekTarget ? ` · ${hours(week - weekTarget)} over` : week < weekTarget ? ` · ${hours(weekTarget - week)} to go` : \" · on target\"}\n          </p>\n        </div>\n        {isLocked ? (\n          <p className=\"text-muted-foreground text-sm\">This week is submitted and locked.</p>\n        ) : (\n          <Button onClick={() => void submit()} disabled={submitting || week === 0}>\n            {submitting ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : <Send aria-hidden=\"true\" className=\"rtl:-scale-x-100\" />}\n            {submitting ? \"Submitting\" : \"Submit week\"}\n          </Button>\n        )}\n      </div>\n      {submitError ? (\n        <p role=\"alert\" className=\"text-destructive flex items-start gap-2 border-t px-5 py-3 text-sm\">\n          <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n          {submitError}\n        </p>\n      ) : null}\n    </div>\n  )\n\n  return (\n    <section data-slot=\"timesheet-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <h1 id={`${id}-title`} className=\"mb-6 text-3xl font-semibold tracking-[-0.035em] text-balance\">\n        {title}\n      </h1>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the timesheet\" loading={<Skel />} error={{ title: errorState?.title ?? \"The timesheet could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </section>\n  )\n}\n\nexport { Timesheet1, type Timesheet1Props, type Timesheet1Project }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/timesheet-1/timesheet-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "timesheet",
      "time tracking",
      "hours",
      "weekly",
      "projects",
      "billable",
      "payroll",
      "work log",
      "submit"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A weekly timesheet. <Timesheet1 projects={[{ id, name, client, entries: { '2026-10-12': 240 } /* minutes by ISO date */ }]} available={[{ id, name, client }]} weekStart='2026-10-12' /* a Monday */ dayTarget={8} submittedWeeks={['2026-10-05']} locale onChange={(projectId, date, minutes) => ...} onSubmit={({ weekStart, minutes, entries }) => Promise} />. Typed time accepts 1.5, 1,5, 1:30, 90m and 2h; entries are stored as minutes. A day over 24 hours is refused with the reason; a weekday over dayTarget shows how much over. Submitting locks that week. Reject onSubmit to show the reason and stay open.",
      "whenToUse": [
        "Time tracking for agencies, consultants and teams",
        "A weekly hours entry before payroll or invoicing"
      ],
      "whenNotToUse": [
        "A start/stop timer",
        "A calendar of meetings (use calendar-1)"
      ],
      "composesWith": [
        "invoice-1",
        "goals-1",
        "tasks-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter",
          "action": "Every cell is a text field named for its project and day ('Website redesign, Mon 12 Oct'); Enter or leaving the field saves it"
        },
        {
          "keys": "Over time",
          "action": "A day over the target says '+1:15 over' in words beside its bar; a refused entry is an alert in the cell"
        },
        {
          "keys": "Table",
          "action": "A real table with a caption, column and row headers and totals in the footer; it scrolls sideways on a phone and is itself a focusable region"
        }
      ],
      "customization": [
        "dayTarget sets what a full day is (weekends are not counted against the week target)",
        "Weeks page with Previous and Next; the data for every week lives in the entries by date",
        "Day names and dates follow the locale"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "timesheet-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton grid under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No projects",
        "example": "timesheet-1-empty",
        "trigger": "state=\"empty\" (or projects={[]})",
        "note": "Says to add a project; emptyState sets the copy and can add a button."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "timesheet-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A failed submit says nothing was lost, in place under the table."
      }
    ],
    "examples": [
      "timesheet-1-demo",
      "timesheet-1-submitted",
      "timesheet-1-loading",
      "timesheet-1-empty",
      "timesheet-1-error"
    ],
    "url": "https://ui.ballmac.com/components/timesheet-1"
  },
  "categories": [
    "blocks",
    "timesheet"
  ],
  "type": "registry:block"
}