WWhoosh Dashboard Components
GitHubDashboard Components
A set of open-source React and Tailwind CSS components for tracking GLP-1 dosing, weight velocity, and menstrual cycle phases. Designed for clinical clarity, with cycle-phase shading and automatic whoosh detection.
Preview
Weight chart with cycle phase overlay and dose markers
preview.tsx
WeightTrendChart
Zepbound 5 mg · 8-week window
-1.8 lb/wkLuteal
Weight trendLuteal phaseInjection dayWhoosh event
logs.tsx
| Date | Weight | Change | Phase |
|---|---|---|---|
| 2024-03-15 | 183.2 | -3.5 | Follicular |
| 2024-03-14 | 186.7 | -0.1 | Luteal / Follicular |
| 2024-03-13 | 186.8 | +0.3 | Luteal |
| 2024-03-12 | 186.5 | +0.5 | Luteal |
| 2024-03-11 | 186.0 | +0.2 | Luteal |
| 2024-03-10 | 185.8 | +0.1 | Luteal |
| 2024-03-09 | 185.7 | -0.4 | Ovulatory |
| 2024-03-08 | 186.1 | -0.6 | Follicular |
| 2024-03-07 | 186.7 | -0.5 | Follicular |
Metric Progress Cards
Clinical summary metrics with whoosh-aware calculations
Starting Weight195.0lb
Current Weight178.3lb
Total Loss16.7lb
Weight Velocity-1.8lb/wk
Weekly Averages & Delta
Week-over-week weight changes with cycle phase annotations
weekly-averages.tsx
| Week | Avg Weight | Delta |
|---|---|---|
| Week 1 | 193.5 | — |
| Week 2 | 190.2 | -3.3 |
| Week 3 | 186.8 | -3.4 |
| Week 4 | 184.1 | -2.7 |
| Week 5 | 183.8 | -0.3 |
| Week 6 | 184.5 | +0.7 |
| Week 7 | 180.2 | -4.3 |
| Week 8 | 178.3 | -1.9 |
Data-Entry Forms
Interactive input forms for weight, cycle onset, and GLP-1 dose logging
weight-entry.tsx
cycle-onset.tsx
dose-log.tsx
step-function-weight.tsx
Weight (lb)183.2
-0.1|183.2|+0.1
Get the Code
Copy-paste ready React components with Tailwind CSS classes
WeightTrendChart.tsx
import { WeightDataPoint } from "./types";
type WeightChartProps = {
data: WeightDataPoint[];
lutealStart: number;
lutealEnd: number;
doseDays: number[];
};
export function WeightTrendChart({
data,
lutealStart,
lutealEnd,
doseDays,
}: WeightChartProps) {
const w = 600;
const h = 220;
const pad = { top: 20, right: 20, bottom: 36, left: 44 };
const iw = w - pad.left - pad.right;
const ih = h - pad.top - pad.bottom;
const yMin = Math.min(...data.map((d) => d.weight));
const yMax = Math.max(...data.map((d) => d.weight));
const yRange = yMax - yMin || 1;
const x = (i: number) =>
pad.left + (i / (data.length - 1)) * iw;
const y = (v: number) =>
pad.top + ih - ((v - yMin) / yRange) * ih;
const points = data.map((d, i) => `${x(i)},${y(d.weight)}`).join(" ");
return (
<svg
viewBox={`0 0 ${w} ${h}`}
className="w-full h-auto"
role="img"
aria-label="Weight trend chart"
>
{/* Grid */}
{Array.from({ length: 5 }, (_, i) => {
const gy = pad.top + (ih / 5) * i;
return (
<line
key={i}
x1={pad.left}
y1={gy}
x2={w - pad.right}
y2={gy}
stroke="var(--whoosh-chart-grid)"
strokeWidth={1}
/>
);
})}
{/* Luteal phase */}
<rect
x={x(lutealStart)}
y={pad.top}
width={x(lutealEnd) - x(lutealStart)}
height={ih}
fill="var(--whoosh-luteal-fill)"
stroke="var(--whoosh-luteal-border)"
strokeWidth={1}
strokeDasharray="4 3"
rx={4}
/>
{/* Dose markers */}
{doseDays.map((di, i) => (
<g key={i}>
<line
x1={x(di)}
y1={pad.top}
x2={x(di)}
y2={h - pad.bottom}
stroke="var(--whoosh-dose-line)"
strokeWidth={1.5}
strokeDasharray="5 4"
/>
<circle
cx={x(di)}
cy={pad.top - 4}
r={3}
fill="var(--whoosh-green)"
/>
</g>
))}
{/* Area */}
<polygon
points={`${points} ${x(data.length - 1)},${h - pad.bottom} ${x(0)},${h - pad.bottom}`}
fill="var(--whoosh-chart-fill)"
/>
{/* Line */}
<polyline
points={points}
fill="none"
stroke="var(--whoosh-chart-line)"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Data points */}
{data.map((d, i) => (
<circle
key={i}
cx={x(i)}
cy={y(d.weight)}
r={3}
fill="var(--whoosh-chart-line)"
stroke="var(--surface)"
strokeWidth={1.5}
/>
))}
</svg>
);
}WeightLogsTable.tsx
type LogEntry = {
date: string;
weight: number;
change: number;
velocity: string;
phase: "Follicular" | "Ovulatory" | "Luteal";
note: string;
};
type WeightLogsTableProps = {
logs: LogEntry[];
};
export function WeightLogsTable({ logs }: WeightLogsTableProps) {
return (
<div className="overflow-x-auto rounded-xl border border-(--line)">
<table className="w-full border-collapse font-mono text-[12px]">
<thead>
<tr className="border-b border-(--line) bg-(--whoosh-green-surface)">
<th className="text-left px-4 py-3 font-semibold text-(--whoosh-green-dark) text-[11px] uppercase">
Date
</th>
<th className="text-right px-4 py-3 font-semibold text-(--whoosh-green-dark) text-[11px] uppercase">
Weight
</th>
<th className="text-right px-4 py-3 font-semibold text-(--whoosh-green-dark) text-[11px] uppercase">
Change
</th>
<th className="text-right px-4 py-3 font-semibold text-(--whoosh-green-dark) text-[11px] uppercase hidden sm:table-cell">
Velocity
</th>
<th className="text-left px-4 py-3 font-semibold text-(--whoosh-green-dark) text-[11px] uppercase">
Phase
</th>
<th className="text-left px-4 py-3 font-semibold text-(--whoosh-green-dark) text-[11px] uppercase hidden sm:table-cell">
Note
</th>
</tr>
</thead>
<tbody>
{logs.map((entry) => {
const isWhoosh = entry.note === "Post-whoosh recovery";
return (
<tr
key={entry.date}
className={`border-b border-(--line) last:border-0 ${
isWhoosh ? "bg-(--whoosh-amber-light)" : ""
}`}
>
<td className="px-4 py-2.5 text-(--ink) font-medium">
{entry.date}
</td>
<td className="px-4 py-2.5 text-right text-(--ink) tabular-nums">
{entry.weight.toFixed(1)}
</td>
<td
className={`px-4 py-2.5 text-right tabular-nums font-medium ${
entry.change > 0
? "text-[#B45353]"
: "text-(--whoosh-green)"
}`}
>
{entry.change > 0 ? "+" : ""}{entry.change.toFixed(1)}
</td>
<td className="px-4 py-2.5 text-right text-(--muted) tabular-nums hidden sm:table-cell">
{entry.velocity}
</td>
<td className="px-4 py-2.5">
<span
className={`inline-block rounded px-2 py-0.5 font-mono text-[11px] font-medium ${
entry.phase === "Luteal"
? "bg-(--whoosh-lavender-light) text-(--whoosh-lavender-dim)"
: "bg-(--whoosh-green-light) text-(--whoosh-green-dark)"
}`}
>
{entry.phase}
</span>
</td>
<td className="px-4 py-2.5 text-(--muted) hidden sm:table-cell">
{entry.note}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}styles.css
/* Add these CSS custom properties to your root stylesheet
to match the Whoosh design tokens. */
:root {
--whoosh-green: #5B8C6A;
--whoosh-green-light: #EDF4EF;
--whoosh-green-dark: #3D6B4C;
--whoosh-green-surface: #F6FAF7;
--whoosh-lavender: #B8B5E0;
--whoosh-lavender-light: #F0EFF8;
--whoosh-lavender-mid: #D4D1EE;
--whoosh-lavender-dim: #9A97C4;
--whoosh-amber: #D4B896;
--whoosh-amber-light: #F5F0E8;
--whoosh-chart-line: #5B8C6A;
--whoosh-chart-fill: rgba(91, 140, 106, 0.10);
--whoosh-chart-grid: rgba(23, 23, 23, 0.08);
--whoosh-luteal-fill: rgba(180, 181, 224, 0.18);
--whoosh-luteal-border: rgba(180, 181, 224, 0.35);
--whoosh-dose-line: rgba(91, 140, 106, 0.4);
}
/* Tailwind CSS v4 - use these with the @theme directive
or reference via `var(--whoosh-green)` in inline styles. */MetricProgressCards.tsx
type MetricCard = {
label: string;
value: string;
unit: string;
accent?: boolean;
};
type MetricProgressCardsProps = {
cards: MetricCard[];
};
export function MetricProgressCards({ cards }: MetricProgressCardsProps) {
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
{cards.map((card) => (
<div
key={card.label}
className="rounded-xl border border-(--line) bg-(--surface) p-4 sm:p-5 flex flex-col gap-2"
>
<span className="font-mono text-[11px] text-(--muted) uppercase tracking-wider">
{card.label}
</span>
<span
className={`font-mono text-[clamp(1.3rem,3vw,1.8rem)] font-semibold tabular-nums leading-none ${
card.accent
? "text-(--whoosh-green)"
: "text-(--ink)"
}`}
>
{card.value}
</span>
<span className="font-mono text-[11px] text-(--muted)">{card.unit}</span>
</div>
))}
</div>
);
}
// Usage example:
// <MetricProgressCards
// cards={[
// { label: "Starting Weight", value: "195.0", unit: "lb" },
// { label: "Current Weight", value: "178.3", unit: "lb" },
// { label: "Total Loss", value: "16.7", unit: "lb", accent: true },
// { label: "Weight Velocity", value: "-1.8", unit: "lb/wk", accent: true },
// ]}
// />WeeklyAveragesTable.tsx
type WeeklyRow = {
week: string;
avgWeight: number;
delta: number | null;
phase: "Follicular" | "Ovulatory" | "Luteal";
note: string;
};
type WeeklyAveragesTableProps = {
rows: WeeklyRow[];
};
export function WeeklyAveragesTable({ rows }: WeeklyAveragesTableProps) {
return (
<div className="rounded-xl border border-(--line) overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse font-mono text-[12px]">
<thead>
<tr className="border-b border-(--line) bg-(--whoosh-green-surface)">
<th className="text-left px-4 py-3 font-semibold text-(--whoosh-green-dark) text-[11px] uppercase">
Week
</th>
<th className="text-right px-4 py-3 font-semibold text-(--whoosh-green-dark) text-[11px] uppercase">
Avg Weight
</th>
<th className="text-right px-4 py-3 font-semibold text-(--whoosh-green-dark) text-[11px] uppercase">
Delta
</th>
<th className="text-left px-4 py-3 font-semibold text-(--whoosh-green-dark) text-[11px] uppercase">
Phase
</th>
<th className="text-left px-4 py-3 font-semibold text-(--whoosh-green-dark) text-[11px] uppercase">
Note
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const isGain = row.delta !== null && row.delta > 0;
const isWhoosh = row.note === `WHOOSH`;
const isStall = row.note === `Stall begins` || row.note === `Water retention`;
let bgClass = ``;
if (isWhoosh) bgClass = "bg-(--whoosh-amber-light)";
else if (isStall) bgClass = "bg-(--whoosh-lavender-light)";
return (
<tr
key={row.week}
className={`border-b border-(--line) last:border-0 ${bgClass}`}
>
<td className="px-4 py-3 text-(--ink) font-medium">{row.week}</td>
<td className="px-4 py-3 text-right text-(--ink) tabular-nums font-medium">
{row.avgWeight.toFixed(1)}
</td>
<td
className={`px-4 py-3 text-right tabular-nums font-medium ${
isGain
? "text-[#B45353]"
: "text-(--whoosh-green)"
} ${isWhoosh ? "font-bold" : ""}`}
>
{row.delta === null ? "—" : `${row.delta > 0 ? "+" : ""}${row.delta.toFixed(1)}`}
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded px-2 py-0.5 font-mono text-[11px] font-medium ${
row.phase === "Luteal"
? "bg-(--whoosh-lavender-light) text-(--whoosh-lavender-dim)"
: "bg-(--whoosh-green-light) text-(--whoosh-green-dark)"
}`}
>
{row.phase}
</span>
</td>
<td className="px-4 py-3 text-(--muted)">{row.note}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}WeightEntryForm.tsx
import { useState } from "react";
export function WeightEntryForm() {
const [date, setDate] = useState("");
const [weight, setWeight] = useState("");
const [notes, setNotes] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// handle weight log submission
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-md">
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[11px] text-(--muted) uppercase tracking-wider">
Date
</label>
<input
type="date"
className="rounded-lg border border-(--line) bg-(--surface) px-3 py-2 font-mono text-[13px] text-(--ink) outline-none focus:border-(--whoosh-green) transition-colors"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[11px] text-(--muted) uppercase tracking-wider">
Weight (lb)
</label>
<input
type="number"
className="rounded-lg border border-(--line) bg-(--surface) px-3 py-2 font-mono text-[13px] text-(--ink) outline-none focus:border-(--whoosh-green) transition-colors"
value={weight}
onChange={(e) => setWeight(e.target.value)}
step="0.1"
required
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[11px] text-(--muted) uppercase tracking-wider">
Notes
</label>
<textarea
className="rounded-lg border border-(--line) bg-(--surface) px-3 py-2 font-mono text-[13px] text-(--ink) outline-none focus:border-(--whoosh-green) transition-colors resize-none"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
/>
</div>
<button
type="submit"
className="self-start rounded-lg bg-(--whoosh-green) px-5 py-2 font-mono text-[13px] font-semibold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] transition-transform duration-100 active:scale-[0.98] cursor-pointer"
>
Log Weight
</button>
</form>
);
}CycleOnsetForm.tsx
import { useState } from "react";
type FlowIntensity = "light" | "medium" | "heavy";
export function CycleOnsetForm() {
const [startDate, setStartDate] = useState("");
const [flow, setFlow] = useState<FlowIntensity>("medium");
const [notes, setNotes] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// handle cycle onset submission
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-md">
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[11px] text-(--muted) uppercase tracking-wider">
Start Date
</label>
<input
type="date"
className="rounded-lg border border-(--line) bg-(--surface) px-3 py-2 font-mono text-[13px] text-(--ink) outline-none focus:border-(--whoosh-lavender) transition-colors"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[11px] text-(--muted) uppercase tracking-wider">
Flow Intensity
</label>
<select
className="rounded-lg border border-(--line) bg-(--surface) px-3 py-2 font-mono text-[13px] text-(--ink) outline-none focus:border-(--whoosh-lavender) transition-colors"
value={flow}
onChange={(e) => setFlow(e.target.value as FlowIntensity)}
>
<option value="light">Light</option>
<option value="medium">Medium</option>
<option value="heavy">Heavy</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[11px] text-(--muted) uppercase tracking-wider">
Notes
</label>
<textarea
className="rounded-lg border border-(--line) bg-(--surface) px-3 py-2 font-mono text-[13px] text-(--ink) outline-none focus:border-(--whoosh-lavender) transition-colors resize-none"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
/>
</div>
<button
type="submit"
className="self-start rounded-lg bg-(--whoosh-lavender-dim) px-5 py-2 font-mono text-[13px] font-semibold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] transition-transform duration-100 active:scale-[0.98] cursor-pointer"
>
Log Cycle Start
</button>
</form>
);
}DoseLogForm.tsx
import { useState } from "react";
type Medication = "zepbound" | "ozempic" | "wegovy" | "mounjaro";
type Site = "abdomen" | "thigh" | "arm";
type Unit = "mg" | "mL";
export function DoseLogForm() {
const [doseDate, setDoseDate] = useState("");
const [medication, setMedication] = useState<Medication>("zepbound");
const [amount, setAmount] = useState("");
const [unit, setUnit] = useState<Unit>("mg");
const [site, setSite] = useState<Site>("abdomen");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// handle dose log submission
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-md">
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[11px] text-(--muted) uppercase tracking-wider">
Dose Date
</label>
<input
type="date"
className="rounded-lg border border-(--line) bg-(--surface) px-3 py-2 font-mono text-[13px] text-(--ink) outline-none focus:border-(--whoosh-green) transition-colors"
value={doseDate}
onChange={(e) => setDoseDate(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[11px] text-(--muted) uppercase tracking-wider">
Medication
</label>
<select
className="rounded-lg border border-(--line) bg-(--surface) px-3 py-2 font-mono text-[13px] text-(--ink) outline-none focus:border-(--whoosh-green) transition-colors"
value={medication}
onChange={(e) => setMedication(e.target.value as Medication)}
>
<option value="zepbound">Zepbound</option>
<option value="ozempic">Ozempic</option>
<option value="wegovy">Wegovy</option>
<option value="mounjaro">Mounjaro</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[11px] text-(--muted) uppercase tracking-wider">
Dose Amount
</label>
<div className="flex gap-3">
<input
type="number"
className="flex-1 rounded-lg border border-(--line) bg-(--surface) px-3 py-2 font-mono text-[13px] text-(--ink) outline-none focus:border-(--whoosh-green) transition-colors"
value={amount}
onChange={(e) => setAmount(e.target.value)}
step="0.5"
required
/>
<select
className="w-20 rounded-lg border border-(--line) bg-(--surface) px-3 py-2 font-mono text-[13px] text-(--ink) outline-none focus:border-(--whoosh-green) transition-colors"
value={unit}
onChange={(e) => setUnit(e.target.value as Unit)}
>
<option value="mg">mg</option>
<option value="mL">mL</option>
</select>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[11px] text-(--muted) uppercase tracking-wider">
Injection Site
</label>
<select
className="rounded-lg border border-(--line) bg-(--surface) px-3 py-2 font-mono text-[13px] text-(--ink) outline-none focus:border-(--whoosh-green) transition-colors"
value={site}
onChange={(e) => setSite(e.target.value as Site)}
>
<option value="abdomen">Abdomen</option>
<option value="thigh">Thigh</option>
<option value="arm">Arm</option>
</select>
</div>
<button
type="submit"
className="self-start rounded-lg bg-(--whoosh-green) px-5 py-2 font-mono text-[13px] font-semibold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] transition-transform duration-100 active:scale-[0.98] cursor-pointer"
>
Log Dose
</button>
</form>
);
}StepFunctionWeightEntry.tsx
import { useState } from "react";
export function StepFunctionWeightEntry() {
const [weight, setWeight] = useState(183.2);
const step = 0.1;
return (
<div className="flex items-center justify-center gap-5 sm:gap-8 py-4">
<button
type="button"
onClick={() => setWeight((w) => Math.round((w - step) * 10) / 10)}
className="flex size-14 sm:size-16 items-center justify-center rounded-full border-2 border-(--whoosh-lavender-mid) text-(--whoosh-lavender-dim) hover:bg-(--whoosh-lavender-light) hover:border-(--whoosh-lavender) active:scale-[0.95] transition-all duration-100 cursor-pointer"
aria-label="Decrement weight by 0.1 lb"
>
<svg viewBox="0 0 24 24" fill="none" className="size-5 sm:size-6" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</button>
<div className="flex flex-col items-center gap-1.5 min-w-[140px]">
<span className="font-mono text-[11px] text-(--muted)">Weight (lb)</span>
<span className="font-mono text-[clamp(2rem,4.5vw,2.8rem)] font-semibold text-(--ink) tabular-nums leading-none tracking-tight">
{weight.toFixed(1)}
</span>
<div className="flex items-center gap-2 font-mono text-[11px] text-(--muted)">
<span>-0.1</span>
<span className="text-(--whoosh-lavender-dim)">|</span>
<span className="font-medium text-(--whoosh-lavender-dim)">{weight.toFixed(1)}</span>
<span className="text-(--whoosh-lavender-dim)">|</span>
<span>+0.1</span>
</div>
</div>
<button
type="button"
onClick={() => setWeight((w) => Math.round((w + step) * 10) / 10)}
className="flex size-14 sm:size-16 items-center justify-center rounded-full border-2 border-(--whoosh-lavender-mid) text-(--whoosh-lavender-dim) hover:bg-(--whoosh-lavender-light) hover:border-(--whoosh-lavender) active:scale-[0.95] transition-all duration-100 cursor-pointer"
aria-label="Increment weight by 0.1 lb"
>
<svg viewBox="0 0 24 24" fill="none" className="size-5 sm:size-6" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</button>
</div>
);
}