Menu

Change language

GitHub repo

Cold Chain Temp Monitor

blocklogistics

Refrigerated pharmaceutical & perishable cargo IoT temperature logger and excursion monitoring with multi-reading time-series trend chart, upper/lower threshold guidelines, probe logging history table, and critical excursion alert protocol.

Installation

$npx uipkge-ng@latest add cold-chain-temp-monitor
Manifest:https://uipkge.dev/r/angular/cold-chain-temp-monitor.jsonInstalls to:src/app/components/blocks/cold-chain-temp-monitor/

Variants

Loading interactive previews…

Schema

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

LogEntry
interface LogEntry {
  id: string
  timestamp: string
  timeAgo: string
  internalTemp: number
  ambientTemp: number
  deltaT: number
  probeLocation: string
  probeId: string
  humidity: number
  status: 'optimal' | 'in-spec' | 'warning' | 'breach'
  isLatest?: boolean
}
ChartReading
interface ChartReading {
  time: string
  hourLabel: string
  internalTemp: number
  ambientTemp: number
  milestone: string
}

Files installed (2)

  • src/app/components/blocks/cold-chain-temp-monitor/cold-chain-temp-monitor.component.ts39 kB
    // Refrigerated pharmaceutical & perishable cargo IoT temperature logger and excursion
    // monitoring with multi-reading time-series trend chart, upper/lower threshold
    // guidelines, probe logging history table, and critical excursion alert protocol.
    // Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, Input, signal } from '@angular/core'
    import {
      Activity,
      Battery,
      Bell,
      Bluetooth,
      CheckCircle2,
      Download,
      Droplets,
      Flame,
      LucideAngularModule,
      PhoneCall,
      ShieldAlert,
      ShieldCheck,
      Snowflake,
      ThermometerSnowflake,
      ThermometerSun,
    } 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 { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export interface LogEntry {
      id: string
      timestamp: string
      timeAgo: string
      internalTemp: number
      ambientTemp: number
      deltaT: number
      probeLocation: string
      probeId: string
      humidity: number
      status: 'optimal' | 'in-spec' | 'warning' | 'breach'
      isLatest?: boolean
    }
    
    export interface ChartReading {
      time: string
      hourLabel: string
      internalTemp: number
      ambientTemp: number
      milestone: string
    }
    
    const defaultChartData: ChartReading[] = [
      { time: '00:00', hourLabel: '00:00 UTC', internalTemp: -19.8, ambientTemp: 18.2, milestone: 'FRA Departure Tarmac' },
      { time: '03:00', hourLabel: '03:00 UTC', internalTemp: -20.4, ambientTemp: -4.5, milestone: 'Flight EK046 Climb' },
      {
        time: '06:00',
        hourLabel: '06:00 UTC',
        internalTemp: -20.8,
        ambientTemp: -6.2,
        milestone: 'Cruise Flight Level 380',
      },
      {
        time: '09:00',
        hourLabel: '09:00 UTC',
        internalTemp: -20.1,
        ambientTemp: 12.4,
        milestone: 'DXB Descent & Approach',
      },
      {
        time: '12:00',
        hourLabel: '12:00 UTC',
        internalTemp: -19.2,
        ambientTemp: 38.6,
        milestone: 'DXB Ramp Tarmac Transfer',
      },
      {
        time: '15:00',
        hourLabel: '15:00 UTC',
        internalTemp: -19.6,
        ambientTemp: 32.0,
        milestone: 'Perishable Hub Staging',
      },
      { time: '18:00', hourLabel: '18:00 UTC', internalTemp: -19.8, ambientTemp: 26.5, milestone: 'Active Reefer Bay #4' },
      {
        time: '21:00',
        hourLabel: '21:00 UTC',
        internalTemp: -19.5,
        ambientTemp: 25.0,
        milestone: 'Pre-Connection QA Inspection',
      },
      { time: '24:00', hourLabel: '14:30 UTC', internalTemp: -19.4, ambientTemp: 24.8, milestone: 'Dubai Hub Terminal 2' },
    ]
    
    const defaultLogs: LogEntry[] = [
      {
        id: 'log-1',
        timestamp: 'Aug 21, 14:30 UTC',
        timeAgo: '24s ago',
        internalTemp: -19.4,
        ambientTemp: 24.8,
        deltaT: 44.2,
        probeLocation: 'Pallet Center Core',
        probeId: 'Probe #A1',
        humidity: 18,
        status: 'optimal',
        isLatest: true,
      },
      {
        id: 'log-2',
        timestamp: 'Aug 21, 14:00 UTC',
        timeAgo: '30m ago',
        internalTemp: -19.5,
        ambientTemp: 25.2,
        deltaT: 44.7,
        probeLocation: 'Upper Cargo Lid',
        probeId: 'Probe #B2',
        humidity: 18,
        status: 'optimal',
      },
      {
        id: 'log-3',
        timestamp: 'Aug 21, 13:30 UTC',
        timeAgo: '1h ago',
        internalTemp: -19.2,
        ambientTemp: 28.4,
        deltaT: 47.6,
        probeLocation: 'Lower Ventral Core',
        probeId: 'Probe #C1',
        humidity: 19,
        status: 'in-spec',
      },
      {
        id: 'log-4',
        timestamp: 'Aug 21, 13:00 UTC',
        timeAgo: '1.5h ago',
        internalTemp: -19.8,
        ambientTemp: 36.1,
        deltaT: 55.9,
        probeLocation: 'Pallet Center Core',
        probeId: 'Probe #A1',
        humidity: 20,
        status: 'optimal',
      },
      {
        id: 'log-5',
        timestamp: 'Aug 21, 12:30 UTC',
        timeAgo: '2h ago',
        internalTemp: -20.1,
        ambientTemp: 38.6,
        deltaT: 58.7,
        probeLocation: 'Outer Thermal Jacket',
        probeId: 'Probe #D4',
        humidity: 19,
        status: 'optimal',
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-cold-chain-temp-monitor, [ui-cold-chain-temp-monitor]',
      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,
        UiProgressComponent,
        UiSeparatorComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="cold-chain-temp-monitor" [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.5">
              <div class="flex flex-wrap items-center gap-2.5">
                <div class="flex items-center gap-2">
                  <div
                    class="border-info/30 bg-info/10 text-info text-info flex size-9 items-center justify-center rounded-lg border shadow-xs"
                  >
                    <lucide-icon [img]="Snowflake" class="size-5" />
                  </div>
                  <h1 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">
                    {{ shipmentTitle }}
                  </h1>
                </div>
                <!-- Temperature Class Badge in Sky Blue -->
                <span
                  ui-badge
                  wrap
                  variant="outline"
                  class="border-info/30 bg-info/10 text-info gap-1.5 text-xs font-medium"
                >
                  <span class="bg-info size-1.5 rounded-full"></span>
                  <span>{{ tempClass }}</span>
                </span>
              </div>
    
              <div class="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
                <div class="flex items-center gap-1.5">
                  <lucide-icon [img]="Bluetooth" class="text-primary size-3.5" />
                  <span class="text-foreground font-medium">{{ beaconId }}</span>
                </div>
                <span>·</span>
                <div class="flex items-center gap-1.5">
                  <lucide-icon [img]="Battery" class="text-success size-3.5" />
                  <span class="text-foreground font-medium tabular-nums">{{ batteryPercent }}%</span>
                  <span>({{ batteryDays }} days remaining)</span>
                </div>
                <span>·</span>
                <div class="flex items-center gap-1.5">
                  <span class="relative flex size-2">
                    <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-foreground font-medium">Live Telemetry</span>
                  <span>· DXB Terminal 2 Transit</span>
                </div>
              </div>
            </div>
    
            <!-- Action Button -->
            <div class="flex flex-wrap items-center gap-2.5">
              <button
                ui-button
                aria-label="Download attachment"
                variant="outline"
                size="sm"
                class="gap-2 text-xs font-medium shadow-xs"
                [disabled]="isExporting()"
                (click)="handleExportCSV()"
              >
                <lucide-icon [img]="Download" [class]="exportIconClass" />
                <span>{{ isExporting() ? 'Exporting...' : 'Export Audit Log CSV' }}</span>
              </button>
            </div>
          </div>
    
          <!-- Export Success Banner -->
          @if (exportNotification()) {
            <div
              class="border-border bg-card text-card-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="text-foreground font-medium">
                  Cold-chain telemetry audit log exported successfully with cryptographic SHA-256 chain-of-custody seal.
                </span>
              </div>
              <button
                ui-button
                aria-label="Dismiss notification"
                variant="ghost"
                size="icon-sm"
                class="text-muted-foreground hover:text-foreground size-6"
                (click)="exportNotification.set(false)"
              >
                <span class="text-xs">✕</span>
              </button>
            </div>
          }
    
          <!-- 4 Telemetry Overview Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <!-- 1. Current Internal Temperature -->
            <div ui-card class="border-border bg-card text-card-foreground shadow-xs">
              <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="border-info/20 bg-info/10 text-info flex size-8 shrink-0 items-center justify-center rounded-lg border"
                    >
                      <lucide-icon [img]="ThermometerSnowflake" class="size-4" />
                    </div>
                    <h3 ui-card-title class="truncate text-sm font-medium">Internal Core Temp</h3>
                  </div>
                  <span ui-badge wrap variant="success" class="shrink-0 text-xs">In Target Zone</span>
                </div>
              </div>
              <div ui-card-content class="space-y-2.5 pt-1">
                <div class="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">
                      {{ formatTemp(currentInternalTemp) }}°C
                    </span>
                    <span class="text-muted-foreground text-xs font-normal">Payload</span>
                  </div>
                  <span class="text-success text-success text-xs font-medium tabular-nums">±0.6°C setpoint</span>
                </div>
    
                <div class="border-border/60 border-t pt-2 text-xs">
                  <div class="text-muted-foreground flex items-center justify-between gap-x-2">
                    <span>Target Corridor:</span>
                    <span class="text-foreground font-medium tabular-nums">-22.0°C to -18.0°C</span>
                  </div>
                  <p class="text-muted-foreground mt-0.5 text-xs">Probe #A1 (Pallet Center Core)</p>
                </div>
              </div>
            </div>
    
            <!-- 2. Ambient External Temperature -->
            <div ui-card class="border-border bg-card text-card-foreground shadow-xs">
              <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="border-warning/20 bg-warning/10 text-warning flex size-8 shrink-0 items-center justify-center rounded-lg border"
                    >
                      <lucide-icon [img]="ThermometerSun" class="size-4" />
                    </div>
                    <h3 ui-card-title class="truncate text-sm font-medium">Ambient External</h3>
                  </div>
                  <span ui-badge wrap variant="outline" class="shrink-0 text-xs">Dubai Tarmac</span>
                </div>
              </div>
              <div ui-card-content class="space-y-2.5 pt-1">
                <div class="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">
                      {{ formatTemp(currentAmbientTemp) }}°C
                    </span>
                    <span class="text-muted-foreground text-xs font-normal">Air Temp</span>
                  </div>
                  <div class="text-muted-foreground flex items-center gap-1 text-xs font-medium tabular-nums">
                    <lucide-icon [img]="Flame" class="text-warning size-3" />
                    <span>ΔT 44.2°C</span>
                  </div>
                </div>
    
                <div class="border-border/60 border-t pt-2 text-xs">
                  <div class="text-muted-foreground flex items-center justify-between gap-x-2">
                    <span>Transfer Node:</span>
                    <span class="text-foreground font-medium">DXB Cargo T2</span>
                  </div>
                  <p class="text-muted-foreground mt-0.5 text-xs">VIP Cryo-Insulation Envelope Active</p>
                </div>
              </div>
            </div>
    
            <!-- 3. Relative Humidity -->
            <div ui-card class="border-border bg-card text-card-foreground shadow-xs">
              <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="flex size-8 shrink-0 items-center justify-center rounded-lg border border-teal-500/20 bg-teal-500/10 text-teal-600 dark:text-teal-400"
                    >
                      <lucide-icon [img]="Droplets" class="size-4" />
                    </div>
                    <h3 ui-card-title class="truncate text-sm font-medium">Cargo Humidity</h3>
                  </div>
                  <span ui-badge wrap variant="success" class="shrink-0 text-xs">Optimal Dry</span>
                </div>
              </div>
              <div ui-card-content class="space-y-2.5 pt-1">
                <div class="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"
                      >{{ currentHumidity }}%</span
                    >
                    <span class="text-muted-foreground text-xs font-normal">RH</span>
                  </div>
                  <span class="text-xs font-medium text-teal-600 dark:text-teal-400">Zero Condensation</span>
                </div>
    
                <div class="space-y-1">
                  <div
                    ui-progress
                    [value]="currentHumidity"
                    class="h-1.5 [&_[data-slot=progress-indicator]]:bg-teal-500"
                  ></div>
                  <div class="text-muted-foreground flex items-center justify-between gap-x-2 text-xs">
                    <span>Dew Point: <span class="text-foreground font-medium tabular-nums">-32.4°C</span></span>
                    <span>Spec &lt; 35%</span>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- 4. Total Excursions / Breach Events -->
            <div ui-card class="border-border bg-card text-card-foreground shadow-xs">
              <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="border-success/20 bg-success/10 text-success flex size-8 shrink-0 items-center justify-center rounded-lg border"
                    >
                      <lucide-icon [img]="ShieldCheck" class="size-4" />
                    </div>
                    <h3 ui-card-title class="truncate text-sm font-medium">Excursion Events</h3>
                  </div>
                  <span ui-badge wrap variant="success" class="shrink-0 text-xs">100% Compliant</span>
                </div>
              </div>
              <div ui-card-content class="space-y-2.5 pt-1">
                <div class="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">{{
                      totalExcursions
                    }}</span>
                    <span class="text-muted-foreground text-xs font-normal">Breaches</span>
                  </div>
                  <span class="text-success text-success text-xs font-medium tabular-nums">
                    {{ monitoringHours }}h in-transit
                  </span>
                </div>
    
                <div class="border-border/60 border-t pt-2 text-xs">
                  <div class="text-muted-foreground flex items-center justify-between gap-x-2">
                    <span>Mean Kinetic Temp (MKT):</span>
                    <span class="text-foreground font-medium tabular-nums">-19.7°C</span>
                  </div>
                  <p class="text-muted-foreground mt-0.5 text-xs">0 cumulative seconds out-of-spec</p>
                </div>
              </div>
            </div>
          </div>
    
          <!-- 24-Hour Temperature Curve / Telemetry Graph -->
          <div ui-card class="border-border bg-card text-card-foreground shadow-xs">
            <div ui-card-header class="pb-3">
              <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                <div class="space-y-1">
                  <div class="flex items-center gap-2">
                    <lucide-icon [img]="Activity" class="text-primary size-4" />
                    <h3 ui-card-title class="text-base font-semibold">24-Hour Payload & Ambient Telemetry Curve</h3>
                  </div>
                  <p ui-card-description class="text-xs">
                    Continuous thermal gradient monitoring comparing internal core payload against ambient flight & tarmac
                    temperatures with strict excursion threshold corridors.
                  </p>
                </div>
    
                <!-- Legend & Timeframe -->
                <div class="flex flex-wrap items-center gap-3">
                  <div class="text-muted-foreground flex flex-wrap items-center gap-3 text-xs">
                    <div class="flex items-center gap-1.5">
                      <span class="bg-info size-2 rounded-full"></span>
                      <span class="text-foreground font-medium">Internal Core</span>
                    </div>
                    <div class="flex items-center gap-1.5">
                      <span class="bg-warning size-2 rounded-full"></span>
                      <span>Ambient Air</span>
                    </div>
                    <div class="flex items-center gap-1.5">
                      <span class="border-destructive bg-destructive h-0.5 w-3 border-b border-dashed"></span>
                      <span>Upper Limit (-18°C)</span>
                    </div>
                    <div class="flex items-center gap-1.5">
                      <span class="border-info bg-info h-0.5 w-3 border-b border-dashed"></span>
                      <span>Lower Limit (-22°C)</span>
                    </div>
                  </div>
    
                  <div class="bg-muted border-border/60 flex items-center rounded-lg border p-0.5 text-xs">
                    @for (tf of timeframes; track tf) {
                      <button type="button" [class]="timeframeClass(tf)" (click)="activeTimeframe.set(tf)">
                        {{ tf }}
                      </button>
                    }
                  </div>
                </div>
              </div>
            </div>
    
            <div ui-card-content class="space-y-4">
              <!-- SVG Chart Area -->
              <div class="bg-muted/20 border-border/60 relative w-full overflow-x-auto rounded-lg border p-2 sm:p-4">
                <svg class="h-64 w-full max-w-[600px] min-w-full" viewBox="0 0 740 220" preserveAspectRatio="none">
                  <defs>
                    <linearGradient id="grad-internal-angular" x1="0" y1="0" x2="0" y2="1">
                      <stop offset="0%" stop-color="var(--color-sky-500, #0ea5e9)" stop-opacity="0.25" />
                      <stop offset="100%" stop-color="var(--color-sky-500, #0ea5e9)" stop-opacity="0.0" />
                    </linearGradient>
                  </defs>
    
                  <!-- Target Corridor Shaded Band (-18°C [y=65] to -22°C [y=155]) -->
                  <rect
                    x="40"
                    y="65"
                    width="660"
                    height="90"
                    fill="var(--color-sky-500, #0ea5e9)"
                    fill-opacity="0.06"
                    rx="4"
                  />
    
                  <!-- Grid Lines -->
                  <line x1="40" y1="30" x2="700" y2="30" stroke="currentColor" class="text-border/40" stroke-width="1" />
                  <line
                    x1="40"
                    y1="65"
                    x2="700"
                    y2="65"
                    stroke="var(--color-rose-500, #f43f5e)"
                    stroke-width="1.5"
                    stroke-dasharray="4 4"
                  />
                  <line
                    x1="40"
                    y1="110"
                    x2="700"
                    y2="110"
                    stroke="var(--color-emerald-500, #10b981)"
                    stroke-width="1"
                    stroke-dasharray="2 3"
                    stroke-opacity="0.6"
                  />
                  <line
                    x1="40"
                    y1="155"
                    x2="700"
                    y2="155"
                    stroke="var(--color-sky-600, #0284c7)"
                    stroke-width="1.5"
                    stroke-dasharray="4 4"
                  />
                  <line x1="40" y1="200" x2="700" y2="200" stroke="currentColor" class="text-border/40" stroke-width="1" />
    
                  <!-- Y-Axis Threshold Labels -->
                  <text x="36" y="69" text-anchor="end" class="fill-destructive text-xs font-medium">-18°C (Max)</text>
                  <text x="36" y="113" text-anchor="end" class="dark:fill-success fill-emerald-600 text-xs">
                    -20°C (Set)
                  </text>
                  <text x="36" y="159" text-anchor="end" class="dark:fill-info fill-sky-600 text-xs font-medium">
                    -22°C (Min)
                  </text>
    
                  <!-- Ambient Temperature Curve (Dashed Amber) -->
                  <path
                    [attr.d]="ambientPath()"
                    fill="none"
                    stroke="var(--color-amber-500, #f59e0b)"
                    stroke-width="2"
                    stroke-dasharray="3 3"
                    stroke-linecap="round"
                  />
    
                  <!-- Internal Temperature Gradient Area & Curve (Solid Sky) -->
                  <path [attr.d]="internalArea()" fill="url(#grad-internal-angular)" />
                  <path
                    [attr.d]="internalPath()"
                    fill="none"
                    stroke="var(--color-sky-500, #0ea5e9)"
                    stroke-width="2.5"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                  />
    
                  <!-- Data Point Markers -->
                  @for (pt of chartPoints(); track $index) {
                    <g>
                      <!-- Internal Point -->
                      <circle
                        [attr.cx]="pt.x"
                        [attr.cy]="pt.yInternal"
                        r="3.5"
                        class="stroke-background fill-info cursor-pointer stroke-2 transition-transform hover:scale-125"
                        (mouseenter)="selectedReadingIndex.set($index)"
                      />
                      <!-- Ambient Point -->
                      <circle
                        [attr.cx]="pt.x"
                        [attr.cy]="pt.yAmbient"
                        r="2.5"
                        class="stroke-background fill-warning stroke-1"
                      />
                    </g>
                  }
                </svg>
    
                <!-- X-Axis Timeline Milestones -->
                <div class="mt-2 grid grid-cols-3 gap-2 text-xs sm:grid-cols-5 lg:grid-cols-9">
                  @for (pt of chartPoints(); track $index) {
                    <div [class]="milestoneClass($index)">
                      <p class="text-foreground font-mono text-xs font-semibold tabular-nums">{{ pt.time }}</p>
                      <p class="text-muted-foreground truncate text-xs">{{ pt.milestone }}</p>
                      <p class="text-info text-info font-mono text-xs font-medium tabular-nums">
                        {{ formatTemp(pt.internalTemp) }}°C
                      </p>
                    </div>
                  }
                </div>
              </div>
    
              <!-- Metric Summary Strip -->
              <div class="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4 lg:grid-cols-5">
                <div class="border-border bg-muted/30 rounded-lg border p-2.5">
                  <span class="text-muted-foreground">Min Internal Temp</span>
                  <p class="text-foreground font-mono text-sm font-semibold tabular-nums">-20.8°C</p>
                  <span class="text-muted-foreground text-xs">At cruise altitude</span>
                </div>
                <div class="border-border bg-muted/30 rounded-lg border p-2.5">
                  <span class="text-muted-foreground">Max Internal Temp</span>
                  <p class="text-foreground font-mono text-sm font-semibold tabular-nums">-19.2°C</p>
                  <span class="text-muted-foreground text-xs">During DXB ramp transfer</span>
                </div>
                <div class="border-border bg-muted/30 rounded-lg border p-2.5">
                  <span class="text-muted-foreground">24h Mean Temp</span>
                  <p class="text-foreground font-mono text-sm font-semibold tabular-nums">-19.7°C</p>
                  <span class="text-success text-xs font-medium">Optimal Setpoint</span>
                </div>
                <div class="border-border bg-muted/30 rounded-lg border p-2.5">
                  <span class="text-muted-foreground">Standard Deviation</span>
                  <p class="text-foreground font-mono text-sm font-semibold tabular-nums">±0.4°C</p>
                  <span class="text-muted-foreground text-xs">Tight thermal hold</span>
                </div>
                <div class="border-border bg-muted/30 col-span-2 rounded-lg border p-2.5 sm:col-span-4 lg:col-span-1">
                  <span class="text-muted-foreground">Peak External Heat</span>
                  <p class="text-warning text-warning font-mono text-sm font-semibold tabular-nums">+38.6°C</p>
                  <span class="text-muted-foreground text-xs">DXB Tarmac 12:00 UTC</span>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Temperature Logging History Table & Protocol Card -->
          <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
            <!-- Table: 2 cols on lg -->
            <div ui-card class="border-border bg-card text-card-foreground shadow-xs lg:col-span-2">
              <div ui-card-header class="pb-3">
                <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
                  <div>
                    <h3 ui-card-title class="text-base font-semibold">Temperature Logging History</h3>
                    <p ui-card-description class="text-xs">
                      Calibrated multi-probe sensor telemetry pings across core pallet positions.
                    </p>
                  </div>
                  <span ui-badge wrap variant="outline" class="w-fit text-xs font-normal tabular-nums">
                    {{ activeLogs().length }} Recent Pings
                  </span>
                </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-[140px]">Timestamp</th>
                        <th ui-table-head class="min-w-[110px]">Internal Temp</th>
                        <th ui-table-head class="min-w-[100px]">Ambient Air</th>
                        <th ui-table-head class="min-w-[90px]">Delta-T</th>
                        <th ui-table-head class="min-w-[150px]">Probe Location</th>
                        <th ui-table-head class="min-w-[70px]">RH %</th>
                        <th ui-table-head class="min-w-[90px] text-right">Status</th>
                      </tr>
                    </thead>
                    <tbody ui-table-body>
                      @for (log of activeLogs(); track log.id) {
                        <tr ui-table-row [class]="log.isLatest ? 'bg-muted/30 font-medium' : ''">
                          <td ui-table-cell class="text-foreground text-xs whitespace-nowrap">
                            <div class="flex items-center gap-1.5">
                              <span class="tabular-nums">{{ log.timestamp }}</span>
                              @if (log.isLatest) {
                                <span ui-badge wrap variant="secondary" class="h-4 px-1 text-xs font-normal">Now</span>
                              }
                            </div>
                          </td>
                          <td ui-table-cell class="text-xs whitespace-nowrap">
                            <span class="text-info text-info font-semibold tabular-nums">
                              {{ formatTemp(log.internalTemp) }}°C
                            </span>
                          </td>
                          <td ui-table-cell class="text-muted-foreground text-xs whitespace-nowrap tabular-nums">
                            {{ formatTemp(log.ambientTemp) }}°C
                          </td>
                          <td ui-table-cell class="text-foreground text-xs font-medium whitespace-nowrap tabular-nums">
                            {{ log.deltaT }}°C
                          </td>
                          <td ui-table-cell class="text-xs whitespace-nowrap">
                            <span class="text-foreground">{{ log.probeLocation }}</span>
                            <span class="text-muted-foreground ml-1 font-mono text-xs">({{ log.probeId }})</span>
                          </td>
                          <td ui-table-cell class="text-muted-foreground text-xs whitespace-nowrap tabular-nums">
                            {{ log.humidity }}%
                          </td>
                          <td ui-table-cell class="text-right">
                            <span
                              ui-badge
                              wrap
                              [variant]="statusVariant(log.status)"
                              class="text-xs capitalize"
                            >
                              {{ log.status }}
                            </span>
                          </td>
                        </tr>
                      }
                    </tbody>
                  </table>
                </div>
              </div>
            </div>
    
            <!-- Critical Excursion Alert Protocol Card -->
            <div ui-card class="border-border bg-card text-card-foreground shadow-xs lg:col-span-1">
              <div ui-card-header class="pb-3">
                <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 class="text-base font-semibold">Excursion Alert Protocol</h3>
                  </div>
                  <span ui-badge wrap variant="success" class="gap-1 text-xs">
                    <lucide-icon [img]="ShieldAlert" class="size-3" />
                    <span>Armed</span>
                  </span>
                </div>
                <p ui-card-description class="text-xs">
                  Automated escalation rules and dry ice reserve endurance monitoring.
                </p>
              </div>
              <div ui-card-content class="space-y-4">
                <!-- Escalation Rule 1 -->
                <div class="border-border/70 bg-muted/30 space-y-1.5 rounded-lg border p-3 text-xs">
                  <div class="flex items-center justify-between gap-x-2">
                    <span class="text-foreground font-semibold">Urgent SMS / Pager Alert Trigger:</span>
                    <span ui-badge wrap variant="destructive" class="font-mono text-xs">&gt; -15.0°C</span>
                  </div>
                  <p class="text-muted-foreground leading-relaxed">
                    Triggers priority emergency dispatch if payload core temperature rises above -15.0°C for ≥ 30
                    consecutive minutes.
                  </p>
                </div>
    
                <!-- Cryogenic Dry Ice Reserve -->
                <div class="space-y-2 text-xs">
                  <div class="flex items-center justify-between gap-x-2">
                    <div class="flex items-center gap-1.5">
                      <lucide-icon [img]="Snowflake" class="text-info size-3.5" />
                      <span class="text-foreground font-medium">Dry Ice Solid CO2 Reserve</span>
                    </div>
                    <span class="text-foreground font-mono font-semibold tabular-nums"
                      >{{ dryIceEnduranceHours() }}h hold</span
                    >
                  </div>
                  <div
                    ui-progress
                    [value]="dryIceCapacityPercent()"
                    class="[&_[data-slot=progress-indicator]]:bg-info h-2"
                  ></div>
                  <div class="text-muted-foreground flex items-center justify-between gap-x-2">
                    <span>Sublimation: 0.38 kg/hr</span>
                    <span>42 kg solid reserve</span>
                  </div>
                </div>
    
                <div ui-separator></div>
    
                <!-- Emergency Response Logistics Desk -->
                <div class="border-border bg-muted/40 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]="PhoneCall" class="text-primary size-3.5" />
                      <span class="text-foreground font-semibold">Rapid Intervention Desk:</span>
                    </div>
                    <span class="text-foreground font-medium">DXB Hub Team</span>
                  </div>
                  <div class="text-muted-foreground flex items-center justify-between gap-x-2">
                    <span>Emergency Pager:</span>
                    <span class="text-foreground font-mono font-medium">+971 4 216 4890</span>
                  </div>
                  <div class="text-muted-foreground flex items-center justify-between gap-x-2">
                    <span>Re-Icing Intervention SLA:</span>
                    <span class="text-success font-medium">&lt; 20 minutes</span>
                  </div>
                  <div
                    class="text-muted-foreground border-border/50 flex items-center justify-between gap-x-2 border-t pt-1.5"
                  >
                    <span>Standard Operating SOP:</span>
                    <span class="text-foreground font-mono">SOP-BIO-904</span>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiColdChainTempMonitorComponent {
      protected readonly Activity = Activity
      protected readonly Battery = Battery
      protected readonly Bell = Bell
      protected readonly Bluetooth = Bluetooth
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Download = Download
      protected readonly Droplets = Droplets
      protected readonly Flame = Flame
      protected readonly PhoneCall = PhoneCall
      protected readonly ShieldAlert = ShieldAlert
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Snowflake = Snowflake
      protected readonly ThermometerSnowflake = ThermometerSnowflake
      protected readonly ThermometerSun = ThermometerSun
    
      protected readonly timeframes = ['24h', '48h', '96h'] as const
    
      @Input() shipmentId = '#CC-9482'
      @Input() shipmentTitle = 'Vaccine Cold-Chain Consignment #CC-9482'
      @Input() tempClass = 'Ultra-Cold Frozen · Target: -20°C ± 2°C'
      @Input() beaconId = 'SensorTag #BT-4892 · BLE 5.3'
      @Input() protocolVersion = 'IATA CEIV / WHO-PQS Standard'
      @Input() batteryPercent = 94
      @Input() batteryDays = 180
      @Input() currentInternalTemp = -19.4
      @Input() currentAmbientTemp = 24.8
      @Input() currentHumidity = 18
      @Input() totalExcursions = 0
      @Input() monitoringHours = 96
      @Input() set logs(v: LogEntry[] | undefined) {
        this.logsOverride.set(v)
      }
      @Input() set chartData(v: ChartReading[] | undefined) {
        this.chartDataOverride.set(v)
      }
      @Input('class') className?: string
    
      private readonly logsOverride = signal<LogEntry[] | undefined>(undefined)
      private readonly chartDataOverride = signal<ChartReading[] | undefined>(undefined)
    
      readonly activeLogs = computed(() => this.logsOverride() ?? defaultLogs)
      readonly activeChartData = computed(() => this.chartDataOverride() ?? defaultChartData)
    
      readonly isExporting = signal(false)
      readonly exportNotification = signal(false)
      readonly activeTimeframe = signal<'24h' | '48h' | '96h'>('24h')
      readonly selectedReadingIndex = signal<number | null>(null)
      readonly dryIceEnduranceHours = signal(72)
      readonly dryIceCapacityPercent = signal(78)
    
      private exportTimers: ReturnType<typeof setTimeout>[] = []
    
      constructor() {
        inject(DestroyRef).onDestroy(() => this.exportTimers.forEach((t) => clearTimeout(t)))
      }
    
      handleExportCSV(): void {
        this.isExporting.set(true)
        this.exportTimers.push(
          setTimeout(() => {
            this.isExporting.set(false)
            this.exportNotification.set(true)
            this.exportTimers.push(
              setTimeout(() => {
                this.exportNotification.set(false)
              }, 4500),
            )
          }, 500),
        )
      }
    
      // Coordinate mapping for SVG telemetry chart
      // Internal Temp scale: -24°C (Y: 200) to -16°C (Y: 20)
      // Setpoint -20°C -> Y: 110
      // Upper threshold -18°C -> Y: 65
      // Lower threshold -22°C -> Y: 155
      readonly chartPoints = computed(() => {
        const data = this.activeChartData()
        const svgWidth = 740
        const paddingX = 40
        const usableWidth = svgWidth - paddingX * 2
    
        return data.map((d, i) => {
          const x = paddingX + (i / (data.length - 1)) * usableWidth
          // Map -24°C..-16°C to 195..25
          // formula: y = 110 - (temp - (-20)) * 22.5
          const yInternal = 110 - (d.internalTemp - -20) * 22.5
          // Map ambient -10°C..+45°C to 200..30
          const yAmbient = 180 - ((d.ambientTemp - -10) / 55) * 150
    
          return {
            x,
            yInternal,
            yAmbient,
            ...d,
          }
        })
      })
    
      readonly internalPath = computed(() => {
        const pts = this.chartPoints()
        if (!pts.length) return ''
        let path = `M ${pts[0].x},${pts[0].yInternal}`
        for (let i = 1; i < pts.length; i++) {
          const prev = pts[i - 1]
          const curr = pts[i]
          const cx = (prev.x + curr.x) / 2
          path += ` C ${cx},${prev.yInternal} ${cx},${curr.yInternal} ${curr.x},${curr.yInternal}`
        }
        return path
      })
    
      readonly internalArea = computed(() => {
        const pts = this.chartPoints()
        if (!pts.length) return ''
        const firstX = pts[0].x
        const lastX = pts[pts.length - 1].x
        return `${this.internalPath()} L ${lastX},210 L ${firstX},210 Z`
      })
    
      readonly ambientPath = computed(() => {
        const pts = this.chartPoints()
        if (!pts.length) return ''
        let path = `M ${pts[0].x},${pts[0].yAmbient}`
        for (let i = 1; i < pts.length; i++) {
          const prev = pts[i - 1]
          const curr = pts[i]
          const cx = (prev.x + curr.x) / 2
          path += ` C ${cx},${prev.yAmbient} ${cx},${curr.yAmbient} ${curr.x},${curr.yAmbient}`
        }
        return path
      })
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-6xl space-y-6', this.className)
      }
    
      get exportIconClass(): string {
        return cn('size-3.5', this.isExporting() && 'animate-bounce')
      }
    
      formatTemp(value: number): string {
        return value > 0 ? `+${value}` : `${value}`
      }
    
      statusVariant(status: LogEntry['status']): 'success' | 'outline' | 'warning' {
        if (status === 'optimal') return 'success'
        if (status === 'in-spec') return 'outline'
        return 'warning'
      }
    
      timeframeClass(tf: '24h' | '48h' | '96h'): string {
        return cn(
          'rounded-md px-2.5 py-1 font-medium transition-colors',
          this.activeTimeframe() === tf
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      milestoneClass(idx: number): string {
        return cn(
          'rounded p-1 text-center transition-colors',
          this.selectedReadingIndex() === idx && 'bg-muted border-border/80 border',
        )
      }
    }
    
  • src/app/components/blocks/cold-chain-temp-monitor/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/cold-chain-temp-monitor.json