Vitals Tracker Chart
blockhealthcareBiometric vitals monitoring dashboard with multi-day trends, threshold bands, device sync status, 7-day vitals table with risk indicators, clinician alert limits, and quick-entry logging dialog.
Installation
$pnpm dlx uipkge-ng@latest add vitals-tracker-chart$npx uipkge-ng@latest add vitals-tracker-chart$yarn dlx uipkge-ng@latest add vitals-tracker-chart$bunx uipkge-ng@latest add vitals-tracker-chartManifest:
https://uipkge.dev/r/angular/vitals-tracker-chart.jsonInstalls to:src/app/components/blocks/vitals-tracker-chart/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
DailyVitalLoginterface DailyVitalLog {
id: string
date: string
isToday?: boolean
morningBP: string
morningPulse: number
eveningBP: string
eveningPulse: number
avgBP: string
systolicVal: number
diastolicVal: number
fastingGlucose: number
glucoseStatus: 'optimal' | 'normal' | 'elevated'
spo2: number
riskStatus: 'optimal' | 'normal' | 'elevated'
riskLabel: string
notes?: string
}SparklinePointinterface SparklinePoint {
x: number
y: number
}Sparklineinterface Sparkline {
path: string
area: string
lastPoint: SparklinePoint
}Files installed (2)
src/app/components/blocks/vitals-tracker-chart/vitals-tracker-chart.component.ts46 kB
// Patient biometrics and vitals telemetry dashboard: multi-day trend sparklines, // device sync status, 7-day vitals table with risk indicators, configurable clinical // thresholds, automated alert protocol, and a quick-entry logging dialog. // Port of the React block 1:1. import { ChangeDetectionStrategy, Component, Input, signal } from '@angular/core' import { Activity, Bell, CheckCircle2, Droplets, Heart, LucideAngularModule, Minus, Plus, RefreshCw, ShieldCheck, Sliders, Stethoscope, TrendingDown, Wind, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, } from '@/ui/dialog/dialog.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiLabelComponent } from '@/ui/label/label.component' import { UiProgressComponent } from '@/ui/progress/progress.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiSwitchComponent } from '@/ui/switch/switch.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' import { UiTextareaComponent } from '@/ui/textarea/textarea.component' export interface DailyVitalLog { id: string date: string isToday?: boolean morningBP: string morningPulse: number eveningBP: string eveningPulse: number avgBP: string systolicVal: number diastolicVal: number fastingGlucose: number glucoseStatus: 'optimal' | 'normal' | 'elevated' spo2: number riskStatus: 'optimal' | 'normal' | 'elevated' riskLabel: string notes?: string } const initialLogs: DailyVitalLog[] = [ { id: 'vital-1', date: 'Aug 21 (Today)', isToday: true, morningBP: '120/76', morningPulse: 66, eveningBP: '122/78', eveningPulse: 68, avgBP: '121/77', systolicVal: 121, diastolicVal: 77, fastingGlucose: 94, glucoseStatus: 'optimal', spo2: 99, riskStatus: 'optimal', riskLabel: 'Optimal', notes: 'Resting morning reading taken pre-breakfast.', }, { id: 'vital-2', date: 'Aug 20', morningBP: '124/80', morningPulse: 70, eveningBP: '122/79', eveningPulse: 69, avgBP: '123/80', systolicVal: 123, diastolicVal: 80, fastingGlucose: 94, glucoseStatus: 'optimal', spo2: 98, riskStatus: 'normal', riskLabel: 'Normal', }, { id: 'vital-3', date: 'Aug 19', morningBP: '126/82', morningPulse: 71, eveningBP: '125/80', eveningPulse: 70, avgBP: '126/81', systolicVal: 126, diastolicVal: 81, fastingGlucose: 97, glucoseStatus: 'normal', spo2: 99, riskStatus: 'normal', riskLabel: 'Normal', }, { id: 'vital-4', date: 'Aug 18', morningBP: '132/84', morningPulse: 74, eveningBP: '130/82', eveningPulse: 73, avgBP: '131/83', systolicVal: 131, diastolicVal: 83, fastingGlucose: 101, glucoseStatus: 'elevated', spo2: 98, riskStatus: 'elevated', riskLabel: 'Elevated', notes: 'Mild post-work stress reported by patient.', }, { id: 'vital-5', date: 'Aug 17', morningBP: '128/82', morningPulse: 72, eveningBP: '126/80', eveningPulse: 71, avgBP: '127/81', systolicVal: 127, diastolicVal: 81, fastingGlucose: 98, glucoseStatus: 'normal', spo2: 99, riskStatus: 'normal', riskLabel: 'Normal', }, { id: 'vital-6', date: 'Aug 16', morningBP: '122/78', morningPulse: 69, eveningBP: '124/80', eveningPulse: 70, avgBP: '123/79', systolicVal: 123, diastolicVal: 79, fastingGlucose: 95, glucoseStatus: 'optimal', spo2: 99, riskStatus: 'optimal', riskLabel: 'Optimal', }, { id: 'vital-7', date: 'Aug 15', morningBP: '120/78', morningPulse: 68, eveningBP: '121/77', eveningPulse: 67, avgBP: '121/78', systolicVal: 121, diastolicVal: 78, fastingGlucose: 96, glucoseStatus: 'optimal', spo2: 99, riskStatus: 'optimal', riskLabel: 'Optimal', }, ] const bpHistory = [121, 123, 126, 131, 127, 123, 121] const hrHistory = [68, 70, 71, 74, 72, 69, 68] const glucoseHistory = [96, 95, 98, 101, 97, 94, 94] interface SparklinePoint { x: number y: number } export interface Sparkline { path: string area: string lastPoint: SparklinePoint } export function getSparkline(history: number[]): Sparkline { const min = Math.min(...history) const max = Math.max(...history) const range = max - min || 1 const height = 26 const padding = 3 const points = history.map((val, idx) => { const x = (idx / (history.length - 1)) * 100 const y = height - ((val - min) / range) * (height - padding * 2) - padding return { x, y } }) let path = `M ${points[0].x},${points[0].y}` for (let i = 1; i < points.length; i++) { const prev = points[i - 1] const curr = points[i] const cx = (prev.x + curr.x) / 2 path += ` C ${cx},${prev.y} ${cx},${curr.y} ${curr.x},${curr.y}` } const lastPoint = points[points.length - 1] const area = `${path} L 100,32 L 0,32 Z` return { path, area, lastPoint } } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-vitals-tracker-chart, [ui-vitals-tracker-chart]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, UiInputComponent, UiLabelComponent, UiProgressComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiSeparatorComponent, UiSwitchComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, UiTextareaComponent, ], template: ` <div data-slot="vitals-tracker-chart" [class]="rootClass"> <!-- Header Section --> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="space-y-1"> <div class="flex flex-wrap items-center gap-2.5"> <h1 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl"> Patient Biometrics & Vitals Telemetry </h1> <span ui-badge wrap variant="outline" class="gap-1.5 text-xs font-normal"> <span class="bg-success size-1.5 rounded-full"></span> <span class="tabular-nums">MRN-84920</span> </span> </div> <p class="text-muted-foreground text-sm"> Continuous physiological telemetry stream · Eleanor Vance (DOB: 1984-06-12) · Care Lead: Dr. M. Vance, MD </p> </div> <div class="flex flex-wrap items-center gap-2.5"> <!-- Device Sync Telemetry Badge --> <div class="bg-muted/50 border-border/80 text-card-foreground flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs shadow-xs" > <span class="relative flex size-2 shrink-0"> <span class="bg-success absolute inline-flex h-full w-full rounded-full opacity-75"></span> <span class="bg-success relative inline-flex size-2 rounded-full"></span> </span> <span class="text-muted-foreground">Apple Health / Withings Connected · Last sync</span> <span class="text-foreground font-medium tabular-nums">{{ lastSyncText() }}</span> <button ui-button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-foreground size-6 rounded-md p-0" aria-label="Sync vitals telemetry" (click)="handleSyncRefresh()" > <lucide-icon [img]="RefreshCw" [class]="syncIconClass" /> </button> </div> <!-- Timeframe Selector --> <div class="bg-muted border-border/60 flex items-center rounded-lg border p-0.5 text-xs"> <button type="button" [class]="timeframeClass('7d')" (click)="timeframe.set('7d')">7 Days</button> <button type="button" [class]="timeframeClass('30d')" (click)="timeframe.set('30d')">30 Days</button> <button type="button" [class]="timeframeClass('90d')" (click)="timeframe.set('90d')">90 Days</button> </div> <!-- Log Vitals Entry Dialog --> <ui-dialog [open]="isLogDialogOpen()" (openChange)="isLogDialogOpen.set($event)"> <button ui-button size="sm" class="gap-1.5 shadow-xs" (click)="isLogDialogOpen.set(true)"> <lucide-icon [img]="Plus" class="size-4" /> <span>Log Vitals Entry</span> </button> <ui-dialog-content class="sm:max-w-xl"> <div ui-dialog-header> <h2 ui-dialog-title>Log Biometric Vitals Entry</h2> <p ui-dialog-description> Record manual cuff, glucometer, or pulse oximeter measurements into the clinical telemetry stream. </p> </div> <div class="grid grid-cols-1 gap-4 py-2 sm:grid-cols-2"> <div class="space-y-1.5 sm:col-span-2"> <label ui-label for="vital-timestamp-ng" class="text-xs">Measurement Timestamp</label> <ui-input id="vital-timestamp-ng" [value]="formDate()" (valueChange)="formDate.set($event)" type="datetime-local" class="h-9 text-xs" /> </div> <div class="space-y-1.5"> <label ui-label for="vital-period-ng" class="text-xs">Context / Reading Period</label> <ui-select [value]="formPeriod()" (valueChange)="formPeriod.set($event)"> <button ui-select-trigger id="vital-period-ng" class="h-9 text-xs"> <ui-select-value placeholder="Select period" /> </button> <ui-select-content> <ui-select-item value="morning">Morning (Fasting)</ui-select-item> <ui-select-item value="midday">Midday (Pre-lunch)</ui-select-item> <ui-select-item value="evening">Evening (Post-dinner)</ui-select-item> <ui-select-item value="bedtime">Bedtime (Resting)</ui-select-item> </ui-select-content> </ui-select> </div> <div class="space-y-1.5"> <label ui-label for="vital-device-ng" class="text-xs">Telemetry Source Device</label> <ui-select [value]="formDevice()" (valueChange)="formDevice.set($event)"> <button ui-select-trigger id="vital-device-ng" class="h-9 text-xs"> <ui-select-value placeholder="Select device" /> </button> <ui-select-content> <ui-select-item value="withings">Withings BPM Core</ui-select-item> <ui-select-item value="apple-watch">Apple Watch Series 9</ui-select-item> <ui-select-item value="omron">Omron Platinum Cuff</ui-select-item> <ui-select-item value="dexcom">Dexcom G7 CGM</ui-select-item> <ui-select-item value="manual">Manual Clinical Station</ui-select-item> </ui-select-content> </ui-select> </div> <div class="space-y-1.5"> <label ui-label for="systolic-input-ng" class="text-xs">Systolic Pressure (mmHg)</label> <ui-input id="systolic-input-ng" [value]="formSystolic()" (valueChange)="formSystolic.set($event)" type="number" placeholder="120" class="h-9 text-xs tabular-nums" /> </div> <div class="space-y-1.5"> <label ui-label for="diastolic-input-ng" class="text-xs">Diastolic Pressure (mmHg)</label> <ui-input id="diastolic-input-ng" [value]="formDiastolic()" (valueChange)="formDiastolic.set($event)" type="number" placeholder="80" class="h-9 text-xs tabular-nums" /> </div> <div class="space-y-1.5"> <label ui-label for="pulse-input-ng" class="text-xs">Resting Heart Rate (bpm)</label> <ui-input id="pulse-input-ng" [value]="formPulse()" (valueChange)="formPulse.set($event)" type="number" placeholder="68" class="h-9 text-xs tabular-nums" /> </div> <div class="space-y-1.5"> <label ui-label for="glucose-input-ng" class="text-xs">Blood Glucose (mg/dL)</label> <ui-input id="glucose-input-ng" [value]="formGlucose()" (valueChange)="formGlucose.set($event)" type="number" placeholder="94" class="h-9 text-xs tabular-nums" /> </div> <div class="space-y-1.5 sm:col-span-2"> <label ui-label for="spo2-input-ng" class="text-xs">Blood Oxygen SpO2 (%)</label> <ui-input id="spo2-input-ng" [value]="formSpo2()" (valueChange)="formSpo2.set($event)" type="number" placeholder="99" class="h-9 text-xs tabular-nums" /> </div> <div class="space-y-1.5 sm:col-span-2"> <label ui-label for="vital-notes-ng" class="text-xs">Clinical Notes & Symptoms (Optional)</label> <ui-textarea id="vital-notes-ng" [value]="formNotes()" (valueChange)="formNotes.set($event)" placeholder="e.g. Patient rested seated for 5 minutes prior to measurement. No palpitations or dyspnea." [rows]="2" class="text-xs" /> </div> </div> <div ui-dialog-footer class="gap-2 sm:gap-0"> <button ui-button variant="outline" size="sm" (click)="isLogDialogOpen.set(false)">Cancel</button> <button ui-button size="sm" (click)="handleLogSubmit()">Save Reading</button> </div> </ui-dialog-content> </ui-dialog> </div> </div> <!-- Success Toast Notification --> @if (showSuccessNotification()) { <div class="border-success/30 bg-success/10 text-foreground dark:text-foreground flex items-center justify-between gap-x-2 rounded-lg border p-3 text-xs shadow-xs" role="status" > <div class="flex items-center gap-2"> <lucide-icon [img]="CheckCircle2" class="text-success size-4 shrink-0" /> <span class="font-medium">Vitals telemetry entry successfully appended to EHR clinical chart.</span> </div> <button ui-button aria-label="Dismiss notification" variant="ghost" size="icon-sm" class="text-success size-6" (click)="showSuccessNotification.set(false)" > <span class="text-xs">✕</span> </button> </div> } <!-- 4 Primary Vitals Overview Cards --> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <!-- 1. Blood Pressure --> <div ui-card class="flex flex-col justify-between"> <div ui-card-header class="pb-2"> <div class="flex items-center justify-between gap-2"> <div class="flex min-w-0 items-center gap-2.5"> <div class="bg-muted text-muted-foreground border-border flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs" aria-hidden="true" > <lucide-icon [img]="Activity" class="text-success size-4" /> </div> <h3 ui-card-title class="truncate text-sm font-medium">Blood Pressure</h3> </div> <span ui-badge wrap variant="success" class="shrink-0 text-xs">Normal</span> </div> </div> <div ui-card-content class="space-y-3 pt-1"> <div> <div class="mb-1 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5"> <div class="flex items-baseline gap-1.5"> <span class="text-foreground text-3xl font-bold tracking-tight tabular-nums">122/78</span> <span class="text-muted-foreground text-xs">mmHg</span> </div> <span class="text-muted-foreground text-xs tabular-nums">MAP 92 mmHg</span> </div> <!-- Mini Sparkline for BP Trend --> <div class="h-7 w-full overflow-hidden pt-1" aria-label="7-day systolic blood pressure trend"> <svg class="h-full w-full overflow-visible" viewBox="0 0 100 28" preserveAspectRatio="none"> <defs> <linearGradient id="grad-bp-ng" x1="0" y1="0" x2="0" y2="1"> <stop offset="0%" stopColor="#10b981" stopOpacity="0.25"></stop> <stop offset="100%" stopColor="#10b981" stopOpacity="0.0"></stop> </linearGradient> </defs> <path [attr.d]="bpSpark().area" fill="url(#grad-bp-ng)"></path> <path [attr.d]="bpSpark().path" fill="none" stroke="#10b981" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" ></path> <circle [attr.cx]="bpSpark().lastPoint.x" [attr.cy]="bpSpark().lastPoint.y" r="2.5" class="fill-success" ></circle> </svg> </div> </div> <div class="border-border/60 border-t pt-2.5 text-xs"> <div class="flex items-center justify-between gap-x-2"> <span class="text-muted-foreground">7-Day Mean:</span> <span class="text-foreground font-medium tabular-nums">124/80 mmHg</span> </div> <p class="text-muted-foreground mt-0.5 text-xs">Target < 120/80 mmHg · Controlled</p> </div> </div> </div> <!-- 2. Resting Heart Rate --> <div ui-card class="flex flex-col justify-between"> <div ui-card-header class="pb-2"> <div class="flex items-center justify-between gap-2"> <div class="flex min-w-0 items-center gap-2.5"> <div class="bg-muted text-muted-foreground border-border flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs" aria-hidden="true" > <lucide-icon [img]="Heart" class="text-destructive size-4" /> </div> <h3 ui-card-title class="truncate text-sm font-medium">Resting Heart Rate</h3> </div> <span ui-badge wrap variant="success" class="shrink-0 text-xs">Healthy</span> </div> </div> <div ui-card-content class="space-y-3 pt-1"> <div> <div class="mb-1 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5"> <div class="flex items-baseline gap-1.5"> <span class="text-foreground text-3xl font-bold tracking-tight tabular-nums">68</span> <span class="text-muted-foreground text-xs">bpm</span> </div> <div class="text-success flex items-center gap-1 text-xs font-medium tabular-nums"> <lucide-icon [img]="TrendingDown" class="size-3" /> <span>-2 bpm</span> </div> </div> <!-- Mini Sparkline for HR Trend --> <div class="h-7 w-full overflow-hidden pt-1" aria-label="7-day resting heart rate trend"> <svg class="h-full w-full overflow-visible" viewBox="0 0 100 28" preserveAspectRatio="none"> <defs> <linearGradient id="grad-hr-ng" x1="0" y1="0" x2="0" y2="1"> <stop offset="0%" stopColor="#f43f5e" stopOpacity="0.25"></stop> <stop offset="100%" stopColor="#f43f5e" stopOpacity="0.0"></stop> </linearGradient> </defs> <path [attr.d]="hrSpark().area" fill="url(#grad-hr-ng)"></path> <path [attr.d]="hrSpark().path" fill="none" stroke="#f43f5e" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" ></path> <circle [attr.cx]="hrSpark().lastPoint.x" [attr.cy]="hrSpark().lastPoint.y" r="2.5" class="fill-destructive" ></circle> </svg> </div> </div> <div class="border-border/60 border-t pt-2.5 text-xs"> <div class="flex items-center justify-between gap-x-2"> <span class="text-muted-foreground">Normal Range:</span> <span class="text-foreground font-medium tabular-nums">60 – 100 bpm</span> </div> <p class="text-muted-foreground mt-0.5 text-xs">Sinus Rhythm · HRV 58 ms</p> </div> </div> </div> <!-- 3. Blood Glucose / Fasting --> <div ui-card class="flex flex-col justify-between"> <div ui-card-header class="pb-2"> <div class="flex items-center justify-between gap-2"> <div class="flex min-w-0 items-center gap-2.5"> <div class="bg-muted text-muted-foreground border-border flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs" aria-hidden="true" > <lucide-icon [img]="Droplets" class="text-info size-4" /> </div> <h3 ui-card-title class="truncate text-sm font-medium">Blood Glucose</h3> </div> <span ui-badge wrap variant="success" class="shrink-0 text-xs">Fasting < 100</span> </div> </div> <div ui-card-content class="space-y-3 pt-1"> <div> <div class="mb-1 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5"> <div class="flex items-baseline gap-1.5"> <span class="text-foreground text-3xl font-bold tracking-tight tabular-nums">94</span> <span class="text-muted-foreground text-xs">mg/dL</span> </div> <div class="text-muted-foreground flex items-center gap-1 text-xs font-medium tabular-nums"> <lucide-icon [img]="Minus" class="size-3" /> <span>Stable</span> </div> </div> <!-- Mini Sparkline for Glucose Trend --> <div class="h-7 w-full overflow-hidden pt-1" aria-label="7-day blood glucose trend"> <svg class="h-full w-full overflow-visible" viewBox="0 0 100 28" preserveAspectRatio="none"> <defs> <linearGradient id="grad-glu-ng" x1="0" y1="0" x2="0" y2="1"> <stop offset="0%" stopColor="#0ea5e9" stopOpacity="0.25"></stop> <stop offset="100%" stopColor="#0ea5e9" stopOpacity="0.0"></stop> </linearGradient> </defs> <path [attr.d]="gluSpark().area" fill="url(#grad-glu-ng)"></path> <path [attr.d]="gluSpark().path" fill="none" stroke="#0ea5e9" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" ></path> <circle [attr.cx]="gluSpark().lastPoint.x" [attr.cy]="gluSpark().lastPoint.y" r="2.5" class="fill-info" ></circle> </svg> </div> </div> <div class="border-border/60 border-t pt-2.5 text-xs"> <div class="flex items-center justify-between gap-x-2"> <span class="text-muted-foreground">Fasting Window:</span> <span class="text-foreground font-medium tabular-nums">12 hrs</span> </div> <p class="text-muted-foreground mt-0.5 text-xs">Estimated HbA1c: 5.4%</p> </div> </div> </div> <!-- 4. Blood Oxygen SpO2 --> <div ui-card class="flex flex-col justify-between"> <div ui-card-header class="pb-2"> <div class="flex items-center justify-between gap-2"> <div class="flex min-w-0 items-center gap-2.5"> <div class="bg-muted text-muted-foreground border-border flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs" aria-hidden="true" > <lucide-icon [img]="Wind" class="size-4 text-teal-500" /> </div> <h3 ui-card-title class="truncate text-sm font-medium">Blood Oxygen SpO2</h3> </div> <span ui-badge wrap variant="success" class="shrink-0 text-xs">≥ 95% Normal</span> </div> </div> <div ui-card-content class="space-y-3 pt-1"> <div> <div class="mb-1 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5"> <div class="flex items-baseline gap-1.5"> <span class="text-foreground text-3xl font-bold tracking-tight tabular-nums">99%</span> <span class="text-muted-foreground text-xs">SpO2</span> </div> <span class="text-success text-xs font-medium">0 drops detected</span> </div> <ui-progress [value]="99" class="h-2 [&_[data-slot=progress-indicator]]:bg-teal-500" /> <div class="mt-1.5 flex items-center justify-between gap-x-2 text-xs"> <span class="text-muted-foreground">Pulse Oximeter</span> <span class="text-muted-foreground tabular-nums">Target ≥ 95%</span> </div> </div> <div class="border-border/60 border-t pt-2.5 text-xs"> <div class="flex items-center justify-between gap-x-2"> <span class="text-muted-foreground">Sleep O2 Baseline:</span> <span class="text-foreground font-medium tabular-nums">98.4%</span> </div> <p class="text-muted-foreground mt-0.5 text-xs">Continuous Plethysmography</p> </div> </div> </div> </div> <!-- 7-Day Vitals Trend Grid & Daily Telemetry Table --> <div ui-card> <div ui-card-header> <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <div> <h3 ui-card-title>7-Day Vitals Trend & Daily Telemetry Logs</h3> <p ui-card-description> Comprehensive morning & evening multi-parameter biometrics with systolic range markers and risk stratification. </p> </div> <div class="flex items-center gap-2"> <span ui-badge wrap variant="outline" class="w-fit text-xs font-normal tabular-nums"> {{ dailyLogs().length }} readings logged </span> </div> </div> </div> <div ui-card-content class="p-0 sm:p-6 sm:pt-0"> <div class="overflow-x-auto"> <table ui-table> <thead ui-table-header> <tr ui-table-row> <th ui-table-head class="min-w-[120px]">Date</th> <th ui-table-head class="min-w-[150px]">Morning Reading</th> <th ui-table-head class="min-w-[150px]">Evening Reading</th> <th ui-table-head class="min-w-[110px]">Daily Mean BP</th> <th ui-table-head class="min-w-[120px]">Fasting Glucose</th> <th ui-table-head class="min-w-[80px]">SpO2</th> <th ui-table-head class="min-w-[160px]">Systolic Band Indicator</th> <th ui-table-head class="text-right">Risk Status</th> </tr> </thead> <tbody ui-table-body> @for (log of dailyLogs(); track log.id) { <tr ui-table-row [class]="log.isToday ? 'bg-muted/25 font-medium' : undefined"> <td ui-table-cell class="text-foreground text-xs whitespace-nowrap"> <div class="flex items-center gap-1.5"> <span class="tabular-nums">{{ log.date }}</span> @if (log.isToday) { <span ui-badge wrap variant="secondary" class="h-4.5 px-1.5 text-xs font-normal">Now</span> } </div> </td> <td ui-table-cell class="text-xs whitespace-nowrap"> <span class="text-foreground font-medium tabular-nums">{{ log.morningBP }}</span> <span class="text-muted-foreground text-xs"> mmHg · </span> <span class="text-muted-foreground tabular-nums">{{ log.morningPulse }} bpm</span> </td> <td ui-table-cell class="text-xs whitespace-nowrap"> <span class="text-foreground font-medium tabular-nums">{{ log.eveningBP }}</span> <span class="text-muted-foreground text-xs"> mmHg · </span> <span class="text-muted-foreground tabular-nums">{{ log.eveningPulse }} bpm</span> </td> <td ui-table-cell class="text-foreground text-xs font-semibold whitespace-nowrap tabular-nums"> {{ log.avgBP }} <span class="text-muted-foreground text-xs font-normal"> mmHg</span> </td> <td ui-table-cell class="text-xs whitespace-nowrap"> <span class="text-foreground font-medium tabular-nums">{{ log.fastingGlucose }}</span> <span class="text-muted-foreground text-xs"> mg/dL</span> </td> <td ui-table-cell class="text-foreground text-xs font-medium whitespace-nowrap tabular-nums"> {{ log.spo2 }}% </td> <td ui-table-cell class="text-xs"> <!-- Visual Horizontal Range Pill --> <div class="w-36 space-y-1"> <div class="bg-muted border-border/70 relative h-3 w-full overflow-hidden rounded-full border"> <!-- Normal Zone (110 - 129) --> <div class="bg-success/20 absolute inset-y-0 right-[50%] left-[16%]"></div> <!-- Elevated Zone (130 - 139) --> <div class="bg-warning/25 absolute inset-y-0 right-[33%] left-[50%]"></div> <!-- High Zone (>= 140) --> <div class="bg-destructive/25 absolute inset-y-0 right-0 left-[67%]"></div> <!-- Reading Dot Marker --> <div [class]="systolicDotClass(log)" [style.left.%]="systolicDotLeft(log)"></div> </div> <div class="text-muted-foreground flex justify-between text-xs tabular-nums"> <span>100</span> <span class="text-success font-medium">120</span> <span>140+</span> </div> </div> </td> <td ui-table-cell class="text-right"> <span ui-badge wrap [variant]="riskVariant(log)" class="text-xs capitalize"> {{ log.riskLabel }} </span> </td> </tr> } </tbody> </table> </div> </div> </div> <!-- Threshold & Alert Limits Card --> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div ui-card> <div ui-card-header> <div class="flex items-center justify-between gap-x-2"> <div class="flex items-center gap-2"> <lucide-icon [img]="Sliders" class="text-primary size-4" /> <h3 ui-card-title>Configurable Clinical Thresholds</h3> </div> <span ui-badge wrap variant="outline" class="text-xs font-normal">AHA / ACC Standards</span> </div> <p ui-card-description> Custom warning & critical breach boundaries tailored to this patient's clinical care plan. </p> </div> <div ui-card-content class="space-y-4"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="space-y-1.5"> <label ui-label class="text-xs">Systolic Alert Limit</label> <ui-select [value]="systolicLimit()" (valueChange)="systolicLimit.set($event)"> <button ui-select-trigger class="h-9 text-xs"> <ui-select-value placeholder="Select limit" /> </button> <ui-select-content> <ui-select-item value="130">> 130 mmHg (Elevated)</ui-select-item> <ui-select-item value="140">> 140 mmHg (Stage 2 Alert)</ui-select-item> <ui-select-item value="160">> 160 mmHg (Critical Crisis)</ui-select-item> </ui-select-content> </ui-select> </div> <div class="space-y-1.5"> <label ui-label class="text-xs">Diastolic Alert Limit</label> <ui-select [value]="diastolicLimit()" (valueChange)="diastolicLimit.set($event)"> <button ui-select-trigger class="h-9 text-xs"> <ui-select-value placeholder="Select limit" /> </button> <ui-select-content> <ui-select-item value="85">> 85 mmHg (Elevated)</ui-select-item> <ui-select-item value="90">> 90 mmHg (Stage 2 Alert)</ui-select-item> <ui-select-item value="100">> 100 mmHg (Crisis)</ui-select-item> </ui-select-content> </ui-select> </div> <div class="space-y-1.5"> <label ui-label class="text-xs">Heart Rate Alert Range</label> <ui-select [value]="hrAlertRange()" (valueChange)="hrAlertRange.set($event)"> <button ui-select-trigger class="h-9 text-xs"> <ui-select-value placeholder="Select range" /> </button> <ui-select-content> <ui-select-item value="50-100">< 50 or > 100 bpm</ui-select-item> <ui-select-item value="55-110">< 55 or > 110 bpm</ui-select-item> <ui-select-item value="45-120">< 45 or > 120 bpm (Wide)</ui-select-item> </ui-select-content> </ui-select> </div> <div class="space-y-1.5"> <label ui-label class="text-xs">Fasting Glucose Warning</label> <ui-select [value]="glucoseAlertLimit()" (valueChange)="glucoseAlertLimit.set($event)"> <button ui-select-trigger class="h-9 text-xs"> <ui-select-value placeholder="Select limit" /> </button> <ui-select-content> <ui-select-item value="120">> 120 mg/dL (Pre-diabetic)</ui-select-item> <ui-select-item value="130">> 130 mg/dL (Clinical Alert)</ui-select-item> <ui-select-item value="150">> 150 mg/dL (High Trigger)</ui-select-item> </ui-select-content> </ui-select> </div> </div> <ui-separator /> <div class="border-border bg-muted/40 flex items-center justify-between gap-x-2 rounded-lg border p-3"> <div class="space-y-0.5"> <p class="text-foreground text-xs font-medium">Oxygen Desaturation Limit</p> <p class="text-muted-foreground text-xs">Triggers hypoxemia alert if SpO2 drops below bound.</p> </div> <span ui-badge wrap variant="outline" class="text-xs font-medium tabular-nums"> < {{ spo2AlertLimit }}% SpO2 </span> </div> </div> </div> <div ui-card> <div ui-card-header> <div class="flex items-center justify-between gap-x-2"> <div class="flex items-center gap-2"> <lucide-icon [img]="Bell" class="text-primary size-4" /> <h3 ui-card-title>Automated Clinical Alert Protocol</h3> </div> <span ui-badge wrap variant="success" class="gap-1 text-xs"> <lucide-icon [img]="ShieldCheck" class="size-3" /> <span>Active Sentinel</span> </span> </div> <p ui-card-description> Instant escalation rules and clinician notification triggers for threshold violations. </p> </div> <div ui-card-content class="space-y-4"> <div class="flex items-start justify-between gap-4"> <div class="space-y-0.5"> <label ui-label class="text-foreground text-xs font-medium">Clinician Urgent SMS Alert</label> <p class="text-muted-foreground text-xs leading-relaxed"> Immediately dispatch SMS alert to on-call cardiologist when 2 consecutive readings exceed threshold. </p> </div> <ui-switch [checked]="clinicianSmsAlert()" (checkedChange)="clinicianSmsAlert.set($event)" aria-label="Toggle clinician SMS alert" /> </div> <ui-separator /> <div class="flex items-start justify-between gap-4"> <div class="space-y-0.5"> <label ui-label class="text-foreground text-xs font-medium">EHR Auto-Sync (FHIR R4 Stream)</label> <p class="text-muted-foreground text-xs leading-relaxed"> Stream verified biometric readings directly to patient's electronic health record chart. </p> </div> <ui-switch [checked]="ehrAutoSync()" (checkedChange)="ehrAutoSync.set($event)" aria-label="Toggle EHR auto sync" /> </div> <ui-separator /> <div class="bg-muted/40 border-border space-y-2 rounded-lg border p-3 text-xs"> <div class="flex items-center justify-between gap-x-2"> <div class="flex items-center gap-2"> <lucide-icon [img]="Stethoscope" class="text-primary size-3.5" /> <span class="text-foreground font-semibold">On-Call Clinical Lead:</span> </div> <span class="text-foreground font-medium">Dr. Sarah Chen, MD (Cardiology)</span> </div> <div class="text-muted-foreground flex items-center justify-between gap-x-2"> <span>Pager: #4920 · Priority SLA:</span> <span class="text-success font-medium">< 15 min triage</span> </div> </div> </div> </div> </div> </div> `, }) export class UiVitalsTrackerChartComponent { protected readonly Activity = Activity protected readonly Bell = Bell protected readonly CheckCircle2 = CheckCircle2 protected readonly Droplets = Droplets protected readonly Heart = Heart protected readonly Minus = Minus protected readonly Plus = Plus protected readonly RefreshCw = RefreshCw protected readonly ShieldCheck = ShieldCheck protected readonly Sliders = Sliders protected readonly Stethoscope = Stethoscope protected readonly TrendingDown = TrendingDown protected readonly Wind = Wind @Input('class') className?: string readonly timeframe = signal<'7d' | '30d' | '90d'>('7d') readonly isSyncing = signal(false) readonly lastSyncText = signal('4m ago') readonly isLogDialogOpen = signal(false) readonly showSuccessNotification = signal(false) // Configurable Thresholds State readonly systolicLimit = signal('140') readonly diastolicLimit = signal('90') readonly hrAlertRange = signal('50-100') readonly glucoseAlertLimit = signal('130') readonly spo2AlertLimit = '95' // Alert Toggles readonly clinicianSmsAlert = signal(true) readonly ehrAutoSync = signal(true) // New Entry Form State readonly formDate = signal('2026-08-21T08:30') readonly formPeriod = signal('morning') readonly formSystolic = signal('120') readonly formDiastolic = signal('78') readonly formPulse = signal('68') readonly formGlucose = signal('94') readonly formSpo2 = signal('99') readonly formDevice = signal('withings') readonly formNotes = signal('') readonly dailyLogs = signal<DailyVitalLog[]>(initialLogs.map((l) => ({ ...l }))) private syncTimer: ReturnType<typeof setTimeout> | undefined private toastTimer: ReturnType<typeof setTimeout> | undefined readonly bpSpark = () => getSparkline(bpHistory) readonly hrSpark = () => getSparkline(hrHistory) readonly gluSpark = () => getSparkline(glucoseHistory) get rootClass(): string { return cn('mx-auto w-full max-w-6xl space-y-6', this.className) } get syncIconClass(): string { return cn('size-3.5', this.isSyncing() && 'animate-spin') } timeframeClass(tf: '7d' | '30d' | '90d'): string { return cn( 'rounded-md px-2.5 py-1 font-medium transition-colors', this.timeframe() === tf ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground', ) } systolicDotClass(log: DailyVitalLog): string { return cn( 'absolute top-1/2 size-2 -translate-y-1/2 rounded-full shadow-xs', log.systolicVal < 130 ? 'bg-success ring-success/30 ring-2' : log.systolicVal < 140 ? 'bg-warning ring-warning/30 ring-2' : 'bg-destructive ring-destructive/30 ring-2', ) } systolicDotLeft(log: DailyVitalLog): number { return Math.min(96, Math.max(4, ((log.systolicVal - 100) / 60) * 100)) } riskVariant(log: DailyVitalLog): 'success' | 'warning' { return log.riskStatus === 'elevated' ? 'warning' : 'success' } handleSyncRefresh(): void { this.isSyncing.set(true) clearTimeout(this.syncTimer) this.syncTimer = setTimeout(() => { this.isSyncing.set(false) this.lastSyncText.set('just now') }, 600) } handleLogSubmit(): void { const systolic = parseInt(this.formSystolic(), 10) || 120 const diastolic = parseInt(this.formDiastolic(), 10) || 80 const pulse = parseInt(this.formPulse(), 10) || 68 const glucose = parseInt(this.formGlucose(), 10) || 94 const spo2 = parseInt(this.formSpo2(), 10) || 99 let riskStatus: 'optimal' | 'normal' | 'elevated' = 'normal' let riskLabel = 'Normal' if (systolic >= 130 || diastolic >= 85 || glucose >= 100) { riskStatus = 'elevated' riskLabel = 'Elevated' } else if (systolic <= 120 && diastolic <= 80 && glucose < 95) { riskStatus = 'optimal' riskLabel = 'Optimal' } const newLog: DailyVitalLog = { id: `vital-${Date.now()}`, date: 'Aug 21 (Manual)', isToday: true, morningBP: `${systolic}/${diastolic}`, morningPulse: pulse, eveningBP: `${systolic}/${diastolic}`, eveningPulse: pulse, avgBP: `${systolic}/${diastolic}`, systolicVal: systolic, diastolicVal: diastolic, fastingGlucose: glucose, glucoseStatus: glucose < 95 ? 'optimal' : glucose <= 99 ? 'normal' : 'elevated', spo2, riskStatus, riskLabel, notes: this.formNotes().trim() || undefined, } this.dailyLogs.update((prev) => [newLog, ...prev.slice(0, 6)]) this.isLogDialogOpen.set(false) this.showSuccessNotification.set(true) clearTimeout(this.toastTimer) this.toastTimer = setTimeout(() => { this.showSuccessNotification.set(false) }, 4000) } }src/app/components/blocks/vitals-tracker-chart/index.ts0.1 kB
export { UiVitalsTrackerChartComponent, getSparkline, type DailyVitalLog, type Sparkline, } from './vitals-tracker-chart.component'
Raw manifest:https://uipkge.dev/r/angular/vitals-tracker-chart.json