clincalc 0.2.1

Open, auditable clinical calculators: a pure scoring engine plus the `clincalc` CLI in one crate. The engine is a serde-only leaf (build with default-features = false); the default `cli` feature adds the `clincalc` binary.
Documentation
// SPDX-FileCopyrightText: 2026 Marcus Baw and Baw Medical Ltd
// SPDX-License-Identifier: AGPL-3.0-or-later

import { useEffect, useState, type ReactElement } from "react";
import {
  ActionIcon,
  AppShell,
  Badge,
  Box,
  Center,
  Group,
  Loader,
  NavLink,
  ScrollArea,
  Stack,
  Text,
  TextInput,
  Title,
  useMantineColorScheme,
} from "@mantine/core";
import {
  IconBrandGithub,
  IconMoon,
  IconSearch,
  IconSun,
} from "@tabler/icons-react";

import logoUrl from "/logo.svg";
import { listCalculators, type CalcSummary } from "./api/calc";
import { FeverPainCalculator } from "./calculators/FeverPain";

/**
 * Which calculators have a hand-crafted UI today. As we build more, we add
 * an entry here mapping the machine name to its React component. Anything
 * not in this set shows the "coming soon" placeholder when selected,
 * rather than a broken page.
 */
const IMPLEMENTED: Record<string, () => ReactElement> = {
  feverpain: FeverPainCalculator,
};

/**
 * The shortlist that sits at the top of the sidebar. Ordering reflects
 * the user's stated priorities for the first GUI cut: FeverPAIN as the
 * MVP, then CHA2DS2-VASc and QRISK3 (both politically high-impact - QRISK3
 * is still not implemented in EMIS or SystmOne).
 */
const FEATURED = ["feverpain", "cha2ds2vasc", "qrisk3"];

function ComingSoon({ clincalc }: { calc: CalcSummary }) {
  return (
    <Stack gap="md" maw={620}>
      <Title order={2}>{calc.title}</Title>
      <Text c="dimmed">{calc.description}</Text>
      <Box
        p="lg"
        style={{
          border: "1px dashed var(--mantine-color-default-border)",
          borderRadius: "var(--mantine-radius-md)",
        }}
      >
        <Text fw={600} mb={4}>
          GUI coming soon
        </Text>
        <Text size="sm" c="dimmed">
          The scoring logic for this calculator already ships in the{" "}
          <code>calc</code> CLI - the desktop UI is being built calculator by
          calculator, each one hand-crafted so the form fits the clinical
          question. Try it now with{" "}
          <code>calc {calc.name} --input examples/{calc.name}.json</code>.
        </Text>
      </Box>
    </Stack>
  );
}

function Brand() {
  return (
    <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
      <img
        src={logoUrl}
        alt=""
        aria-hidden
        width={28}
        height={28}
        style={{ flexShrink: 0 }}
      />
      <Box style={{ minWidth: 0 }}>
        <Text className="brand-wordmark" fz="xl" c="teal">
          calc
        </Text>
        <Text size="xs" c="dimmed" lh={1}>
          open clinical calculators
        </Text>
      </Box>
    </Group>
  );
}

function ColorSchemeToggle() {
  const { colorScheme, toggleColorScheme } = useMantineColorScheme();
  const isDark = colorScheme === "dark";
  return (
    <ActionIcon
      variant="subtle"
      color="gray"
      onClick={() => toggleColorScheme()}
      title={isDark ? "Switch to light mode" : "Switch to dark mode"}
    >
      {isDark ? <IconSun size={18} /> : <IconMoon size={18} />}
    </ActionIcon>
  );
}

export default function App() {
  const [calcs, setCalcs] = useState<CalcSummary[] | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [selected, setSelected] = useState<string>("feverpain");
  const [query, setQuery] = useState("");

  useEffect(() => {
    listCalculators()
      .then(setCalcs)
      .catch((e: unknown) => setError(String(e)));
  }, []);

  const filtered = (calcs ?? []).filter((c) => {
    if (!query.trim()) return true;
    const q = query.toLowerCase();
    return (
      c.name.includes(q) ||
      c.title.toLowerCase().includes(q) ||
      c.tags.some((t) => t.includes(q))
    );
  });

  // Featured calcs at the top (in declared order); everything else
  // alphabetically below.
  const featured = filtered.filter((c) => FEATURED.includes(c.name));
  featured.sort(
    (a, b) => FEATURED.indexOf(a.name) - FEATURED.indexOf(b.name),
  );
  const others = filtered
    .filter((c) => !FEATURED.includes(c.name))
    .sort((a, b) => a.title.localeCompare(b.title));

  const current = calcs?.find((c) => c.name === selected) ?? null;
  const Body = current && IMPLEMENTED[current.name];

  return (
    <AppShell
      header={{ height: 56 }}
      navbar={{ width: 280, breakpoint: 0 }}
      padding="lg"
    >
      <AppShell.Header>
        <Group h="100%" px="lg" justify="space-between">
          <Brand />
          <Group gap="xs">
            <ColorSchemeToggle />
            <ActionIcon
              variant="subtle"
              color="gray"
              component="a"
              href="https://github.com/pacharanero/clincalc"
              target="_blank"
              rel="noreferrer"
              title="View on GitHub"
            >
              <IconBrandGithub size={18} />
            </ActionIcon>
          </Group>
        </Group>
      </AppShell.Header>

      <AppShell.Navbar p="sm">
        <Stack gap="xs" h="100%">
          <TextInput
            placeholder="Filter calculators…"
            leftSection={<IconSearch size={14} />}
            value={query}
            onChange={(e) => setQuery(e.currentTarget.value)}
            size="sm"
          />

          <ScrollArea style={{ flex: 1 }} type="hover">
            <Stack gap={2}>
              {!calcs && !error && (
                <Center py="xl">
                  <Loader size="xs" />
                </Center>
              )}
              {error && (
                <Text size="sm" c="red" p="sm">
                  {error}
                </Text>
              )}

              {featured.length > 0 && (
                <Text size="xs" tt="uppercase" c="dimmed" px="sm" pt={6}>
                  Featured
                </Text>
              )}
              {featured.map((c) => (
                <CalcLink
                  key={c.name}
                  calc={c}
                  active={selected === c.name}
                  implemented={c.name in IMPLEMENTED}
                  onClick={() => setSelected(c.name)}
                />
              ))}

              {others.length > 0 && (
                <Text size="xs" tt="uppercase" c="dimmed" px="sm" pt="md">
                  All calculators ({others.length})
                </Text>
              )}
              {others.map((c) => (
                <CalcLink
                  key={c.name}
                  calc={c}
                  active={selected === c.name}
                  implemented={c.name in IMPLEMENTED}
                  onClick={() => setSelected(c.name)}
                />
              ))}
            </Stack>
          </ScrollArea>

          <Text size="xs" c="dimmed" px="sm">
            {calcs?.length ?? 0} calculators - 1 hand-crafted UI so far
          </Text>
        </Stack>
      </AppShell.Navbar>

      <AppShell.Main>
        {!current && (
          <Center h="60vh">
            <Loader />
          </Center>
        )}
        {current && Body && <Body />}
        {current && !Body && <ComingSoon calc={current} />}
      </AppShell.Main>
    </AppShell>
  );
}

function CalcLink({
  calc,
  active,
  implemented,
  onClick,
}: {
  calc: CalcSummary;
  active: boolean;
  implemented: boolean;
  onClick: () => void;
}) {
  return (
    <NavLink
      active={active}
      onClick={onClick}
      label={
        <Group justify="space-between" wrap="nowrap" gap="xs">
          <Text size="sm" truncate>
            {calc.title}
          </Text>
          {calc.proprietary && (
            <Badge size="xs" color="red" variant="light">
              stub
            </Badge>
          )}
          {!calc.proprietary && implemented && (
            <Badge size="xs" color="teal" variant="light">
              ready
            </Badge>
          )}
        </Group>
      }
      description={
        <Text size="xs" c="dimmed">
          {calc.name}
        </Text>
      }
    />
  );
}