Menu

Change language

GitHub repo

Vitals Tracker Chart

blockhealthcare

Biometric 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

$npx uipkge-ng@latest add vitals-tracker-chart
Manifest:https://uipkge.dev/r/angular/vitals-tracker-chart.jsonInstalls to:src/app/components/blocks/vitals-tracker-chart/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

DailyVitalLog
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
}
SparklinePoint
interface SparklinePoint {
  x: number
  y: number
}
Sparkline
interface 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 &lt; 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 &lt; 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">&gt; 130 mmHg (Elevated)</ui-select-item>
                        <ui-select-item value="140">&gt; 140 mmHg (Stage 2 Alert)</ui-select-item>
                        <ui-select-item value="160">&gt; 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">&gt; 85 mmHg (Elevated)</ui-select-item>
                        <ui-select-item value="90">&gt; 90 mmHg (Stage 2 Alert)</ui-select-item>
                        <ui-select-item value="100">&gt; 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">&lt; 50 or &gt; 100 bpm</ui-select-item>
                        <ui-select-item value="55-110">&lt; 55 or &gt; 110 bpm</ui-select-item>
                        <ui-select-item value="45-120">&lt; 45 or &gt; 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">&gt; 120 mg/dL (Pre-diabetic)</ui-select-item>
                        <ui-select-item value="130">&gt; 130 mg/dL (Clinical Alert)</ui-select-item>
                        <ui-select-item value="150">&gt; 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">
                    &lt; {{ 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">&lt; 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

Raw manifest:https://uipkge.dev/r/angular/vitals-tracker-chart.json