{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "news2-chart",
  "title": "NEWS2 chart",
  "description": "Observations over time scored with NEWS2, the UK's National Early Warning Score: each parameter's points, the aggregate, the clinical risk and the response it calls for.",
  "dependencies": [
    "cn@^0.5.0"
  ],
  "registryDependencies": [
    "table"
  ],
  "files": [
    {
      "path": "registry/base-nova/news2-chart/news2-chart.tsx",
      "content": "import * as React from \"react\"\nimport { cn } from \"cn\"\n\nimport {\n  Table,\n  TableBody,\n  TableCaption,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\"\nimport { IconPlaceholder } from \"@/icons/icon-placeholder\"\n\n/** ACVPU: alert, or new confusion, or responding only to voice or pain, or unresponsive. */\nexport type Consciousness =\n  \"alert\" | \"confusion\" | \"voice\" | \"pain\" | \"unresponsive\"\n\nexport type News2Observation = {\n  /** An ISO 8601 date-time. */\n  time: string\n  /** Breaths per minute. */\n  respirationRate?: number\n  /** Percent. */\n  spo2?: number\n  /** Whether the patient is on supplemental oxygen. */\n  onOxygen?: boolean\n  /** mmHg. */\n  systolic?: number\n  /** Beats per minute. */\n  pulse?: number\n  consciousness?: Consciousness\n  /** Degrees Celsius. */\n  temperature?: number\n}\n\nexport type News2Parameter =\n  | \"respirationRate\"\n  | \"spo2\"\n  | \"onOxygen\"\n  | \"systolic\"\n  | \"pulse\"\n  | \"consciousness\"\n  | \"temperature\"\n\n/**\n * Scale 1 for most patients; scale 2 only for those with hypercapnic\n * respiratory failure and a prescribed target of 88–92%.\n */\nexport type Spo2Scale = 1 | 2\n\nexport type News2Risk = \"low\" | \"low-medium\" | \"medium\" | \"high\"\n\nexport type News2Score = {\n  /** Each parameter's points; null when it was not recorded. */\n  points: Record<News2Parameter, number | null>\n  /** The aggregate score; null unless every parameter was recorded. */\n  total: number | null\n  /** Null when the score is incomplete. */\n  risk: News2Risk | null\n}\n\nexport const news2Parameters: readonly News2Parameter[] = [\n  \"respirationRate\",\n  \"spo2\",\n  \"onOxygen\",\n  \"systolic\",\n  \"pulse\",\n  \"consciousness\",\n  \"temperature\",\n]\n\nexport const news2ParameterLabels: Record<News2Parameter, string> = {\n  respirationRate: \"Respirations\",\n  spo2: \"SpO₂\",\n  onOxygen: \"Air or oxygen\",\n  systolic: \"Systolic BP\",\n  pulse: \"Pulse\",\n  consciousness: \"Consciousness\",\n  temperature: \"Temperature\",\n}\n\n/** What the Royal College of Physicians asks for at each level of risk. */\nexport const news2Responses: Record<\n  News2Risk,\n  { label: string; monitoring: string; response: string }\n> = {\n  low: {\n    label: \"Low\",\n    monitoring: \"At least every 4–6 hours\",\n    response:\n      \"A registered nurse assesses and decides whether care or monitoring should change.\",\n  },\n  \"low-medium\": {\n    label: \"Low–medium\",\n    monitoring: \"At least hourly\",\n    response:\n      \"A single parameter scores 3: the nurse informs the medical team for an urgent ward-based review.\",\n  },\n  medium: {\n    label: \"Medium\",\n    monitoring: \"At least hourly\",\n    response:\n      \"Urgent response: the nurse informs the medical team at once for assessment by a clinician skilled in acute illness.\",\n  },\n  high: {\n    label: \"High\",\n    monitoring: \"Continuous\",\n    response:\n      \"Emergency response: assessment by a team with critical care skills, usually with transfer to a higher level of care.\",\n  },\n}\n\n// Points for a value against bands, each [upper limit, points] in order; a\n// value above every limit scores `above`.\nfunction band(value: number, bands: [number, number][], above: number) {\n  return bands.find(([limit]) => value <= limit)?.[1] ?? above\n}\n\nfunction spo2Points(spo2: number, scale: Spo2Scale, onOxygen?: boolean) {\n  if (scale === 1)\n    return band(\n      spo2,\n      [\n        [91, 3],\n        [93, 2],\n        [95, 1],\n      ],\n      0\n    )\n  if (spo2 <= 92 || !onOxygen)\n    return band(\n      spo2,\n      [\n        [83, 3],\n        [85, 2],\n        [87, 1],\n      ],\n      0\n    )\n  // On oxygen above the 88–92% target, scale 2 scores the excess.\n  return band(\n    spo2,\n    [\n      [94, 1],\n      [96, 2],\n    ],\n    3\n  )\n}\n\n/**\n * Scores a set of observations against the Royal College of Physicians'\n * NEWS2 chart (2017). Check it against the chart your organisation uses.\n */\nexport function news2Score(\n  observation: Omit<News2Observation, \"time\">,\n  spo2Scale: Spo2Scale = 1\n): News2Score {\n  const {\n    respirationRate,\n    spo2,\n    onOxygen,\n    systolic,\n    pulse,\n    consciousness,\n    temperature,\n  } = observation\n  const points: Record<News2Parameter, number | null> = {\n    respirationRate:\n      respirationRate === undefined\n        ? null\n        : band(\n            respirationRate,\n            [\n              [8, 3],\n              [11, 1],\n              [20, 0],\n              [24, 2],\n            ],\n            3\n          ),\n    spo2: spo2 === undefined ? null : spo2Points(spo2, spo2Scale, onOxygen),\n    onOxygen: onOxygen === undefined ? null : onOxygen ? 2 : 0,\n    systolic:\n      systolic === undefined\n        ? null\n        : band(\n            systolic,\n            [\n              [90, 3],\n              [100, 2],\n              [110, 1],\n              [219, 0],\n            ],\n            3\n          ),\n    pulse:\n      pulse === undefined\n        ? null\n        : band(\n            pulse,\n            [\n              [40, 3],\n              [50, 1],\n              [90, 0],\n              [110, 1],\n              [130, 2],\n            ],\n            3\n          ),\n    consciousness:\n      consciousness === undefined ? null : consciousness === \"alert\" ? 0 : 3,\n    temperature:\n      temperature === undefined\n        ? null\n        : band(\n            temperature,\n            [\n              [35, 3],\n              [36, 1],\n              [38, 0],\n              [39, 1],\n            ],\n            2\n          ),\n  }\n  const values = Object.values(points)\n  if (values.some((value) => value === null))\n    return { points, total: null, risk: null }\n  const total = (values as number[]).reduce((sum, value) => sum + value, 0)\n  const risk: News2Risk =\n    total >= 7\n      ? \"high\"\n      : total >= 5\n        ? \"medium\"\n        : values.includes(3)\n          ? \"low-medium\"\n          : \"low\"\n  return { points, total, risk }\n}\n\nexport type News2ChartProps = React.ComponentProps<\"div\"> & {\n  /** Sets of observations, in any order; the newest shows first. */\n  observations: readonly News2Observation[]\n  /** The SpO₂ scale the patient is prescribed. Defaults to 1. */\n  spo2Scale?: Spo2Scale\n  /** Names the table for assistive technology and shows below it. */\n  caption?: React.ReactNode\n  /** Defaults to a UTC date and 24-hour time such as \"Oct 25, 14:00\". */\n  formatTime?: (time: string) => string\n}\n\nconst timeFormat = new Intl.DateTimeFormat(\"en-US\", {\n  month: \"short\",\n  day: \"numeric\",\n  hour: \"2-digit\",\n  minute: \"2-digit\",\n  hourCycle: \"h23\",\n  timeZone: \"UTC\",\n})\n\n// A fixed locale and time zone render the same on the server and the client.\nfunction defaultFormatTime(time: string) {\n  const parsed = Date.parse(time)\n  return Number.isNaN(parsed) ? time : timeFormat.format(parsed)\n}\n\nconst consciousnessLabels: Record<Consciousness, string> = {\n  alert: \"Alert\",\n  confusion: \"New confusion\",\n  voice: \"Voice\",\n  pain: \"Pain\",\n  unresponsive: \"Unresponsive\",\n}\n\nfunction formatReading(\n  parameter: News2Parameter,\n  observation: News2Observation\n) {\n  switch (parameter) {\n    case \"onOxygen\":\n      return observation.onOxygen === undefined\n        ? undefined\n        : observation.onOxygen\n          ? \"Oxygen\"\n          : \"Air\"\n    case \"consciousness\":\n      return observation.consciousness\n        ? consciousnessLabels[observation.consciousness]\n        : undefined\n    case \"temperature\":\n      return observation.temperature?.toFixed(1)\n    default:\n      return observation[parameter]?.toString()\n  }\n}\n\nconst units: Partial<Record<News2Parameter, string>> = {\n  respirationRate: \"/min\",\n  spo2: \"%\",\n  systolic: \"mmHg\",\n  pulse: \"/min\",\n  temperature: \"°C\",\n}\n\nfunction Alarm() {\n  return (\n    <IconPlaceholder\n      lucide=\"TriangleAlertIcon\"\n      tabler=\"IconAlertTriangle\"\n      hugeicons=\"Alert02Icon\"\n      phosphor=\"WarningIcon\"\n      remixicon=\"RiErrorWarningLine\"\n      aria-hidden=\"true\"\n      className=\"size-4 shrink-0\"\n    />\n  )\n}\n\nfunction Points({ points }: { points: number }) {\n  return (\n    <span\n      data-slot=\"news2-points\"\n      data-points={points}\n      className={cn(\n        \"ml-1.5 inline-flex min-w-5 items-center justify-center rounded-sm border px-1 text-xs tabular-nums\",\n        points === 0 && \"border-transparent text-muted-foreground\",\n        points > 0 && \"font-semibold\",\n        points === 3 && \"border-destructive text-destructive\"\n      )}\n    >\n      <span className=\"sr-only\">, scores </span>\n      {points}\n    </span>\n  )\n}\n\n/** The latest complete score, its risk and the response it calls for. */\nexport function News2Summary({\n  score,\n  className,\n}: {\n  score: News2Score\n  className?: string\n}) {\n  if (score.total === null || score.risk === null)\n    return (\n      <p\n        data-slot=\"news2-summary\"\n        data-risk=\"incomplete\"\n        className={cn(\"text-sm text-muted-foreground\", className)}\n      >\n        NEWS2 incomplete: every parameter is needed for a score.\n      </p>\n    )\n  const { label, ...advice } = news2Responses[score.risk]\n  // A score of 0 needs only routine observations.\n  const { monitoring, response } =\n    score.total === 0\n      ? {\n          monitoring: \"At least every 12 hours\",\n          response: \"Continue routine care.\",\n        }\n      : advice\n  const critical = score.risk === \"high\"\n  return (\n    <div\n      data-slot=\"news2-summary\"\n      data-risk={score.risk}\n      className={cn(\"flex flex-col gap-1 text-sm\", className)}\n    >\n      <p\n        className={cn(\n          \"inline-flex items-center gap-1.5 font-semibold\",\n          critical && \"text-destructive\"\n        )}\n      >\n        {score.risk !== \"low\" && <Alarm />}\n        NEWS2 {score.total} · {label} risk\n      </p>\n      <p className=\"text-muted-foreground\">\n        {response} Monitoring: {monitoring.toLowerCase()}.\n      </p>\n    </div>\n  )\n}\n\nexport function News2Chart({\n  observations,\n  spo2Scale = 1,\n  caption,\n  formatTime = defaultFormatTime,\n  className,\n  ...props\n}: News2ChartProps) {\n  const columns = [...observations]\n    .sort((a, b) => Date.parse(b.time) - Date.parse(a.time))\n    .map((observation) => ({\n      observation,\n      score: news2Score(observation, spo2Scale),\n    }))\n  const latest = columns[0]\n  const first = columns.at(-1)\n  // The span of times names the scrolling region, so several charts on one\n  // page are told apart.\n  const label = !first\n    ? \"NEWS2 observations\"\n    : first === latest\n      ? `NEWS2 observations, ${formatTime(first.observation.time)}`\n      : `NEWS2 observations, ${formatTime(first.observation.time)} to ${formatTime(latest!.observation.time)}`\n  return (\n    <div\n      data-slot=\"news2-chart\"\n      className={cn(\"flex w-full min-w-0 flex-col gap-3\", className)}\n      {...props}\n    >\n      {latest && <News2Summary score={latest.score} />}\n      {/* A long run of sets scrolls here, where the keyboard can reach it. */}\n      <div\n        role=\"region\"\n        aria-label={label}\n        tabIndex={0}\n        className=\"w-full overflow-x-auto rounded-md focus-visible:outline-2 focus-visible:outline-ring [&_[data-slot=table-container]]:overflow-visible\"\n      >\n        <Table>\n          {caption && <TableCaption>{caption}</TableCaption>}\n          <TableHeader>\n            <TableRow>\n              <TableHead\n                scope=\"col\"\n                className=\"sticky left-0 z-10 bg-background\"\n              >\n                <span className=\"sr-only\">Parameter</span>\n              </TableHead>\n              {columns.map(({ observation }) => (\n                <TableHead\n                  key={observation.time}\n                  scope=\"col\"\n                  className=\"text-right tabular-nums\"\n                >\n                  {formatTime(observation.time)}\n                </TableHead>\n              ))}\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {columns.length === 0 && (\n              <TableRow>\n                <TableCell className=\"h-16 text-center text-muted-foreground\">\n                  No observations.\n                </TableCell>\n              </TableRow>\n            )}\n            {columns.length > 0 &&\n              news2Parameters.map((parameter) => (\n                <TableRow key={parameter} data-parameter={parameter}>\n                  <TableHead\n                    scope=\"row\"\n                    className=\"sticky left-0 z-10 bg-background font-normal\"\n                  >\n                    {news2ParameterLabels[parameter]}\n                    {parameter === \"spo2\" && (\n                      <span className=\"text-muted-foreground\">\n                        {\" \"}\n                        (scale {spo2Scale})\n                      </span>\n                    )}\n                    {units[parameter] && (\n                      <span className=\"text-xs text-muted-foreground\">\n                        {\" \"}\n                        {units[parameter]}\n                      </span>\n                    )}\n                  </TableHead>\n                  {columns.map(({ observation, score }) => {\n                    const reading = formatReading(parameter, observation)\n                    const points = score.points[parameter]\n                    return (\n                      <TableCell\n                        key={observation.time}\n                        className=\"text-right tabular-nums\"\n                      >\n                        {reading === undefined ? (\n                          <>\n                            <span aria-hidden className=\"text-muted-foreground\">\n                              —\n                            </span>\n                            <span className=\"sr-only\">Not recorded</span>\n                          </>\n                        ) : (\n                          <>\n                            {reading}\n                            {points !== null && <Points points={points} />}\n                          </>\n                        )}\n                      </TableCell>\n                    )\n                  })}\n                </TableRow>\n              ))}\n            {columns.length > 0 && (\n              <TableRow className=\"border-t-2\">\n                <TableHead\n                  scope=\"row\"\n                  className=\"sticky left-0 z-10 bg-background font-semibold\"\n                >\n                  NEWS2\n                </TableHead>\n                {columns.map(({ observation, score }) => (\n                  <TableCell\n                    key={observation.time}\n                    data-risk={score.risk ?? \"incomplete\"}\n                    className={cn(\n                      \"text-right font-semibold tabular-nums\",\n                      score.risk === \"high\" && \"text-destructive\"\n                    )}\n                  >\n                    {score.total === null ? (\n                      <span className=\"font-normal text-muted-foreground\">\n                        Incomplete\n                      </span>\n                    ) : (\n                      <span className=\"inline-flex items-center gap-1\">\n                        {score.risk !== \"low\" && <Alarm />}\n                        {score.total}\n                      </span>\n                    )}\n                  </TableCell>\n                ))}\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "NEWS2 chart docs: https://organui.com/docs/components/news2-chart",
  "categories": [
    "clinical"
  ],
  "type": "registry:ui"
}