Menu

Change language

GitHub repo

Daily Standup Summary

blockproductivity

Slack and Geekbot style async engineering daily standup summary with squad pulse cards, critical blocker alert callout, structured yesterday/today/blockers submission entries, reaction counts, and thread actions.

Installation

$npx uipkge-ng@latest add daily-standup-summary
Manifest:https://uipkge.dev/r/angular/daily-standup-summary.jsonInstalls to:src/app/components/blocks/daily-standup-summary/

Variants

Loading interactive previews…

Schema

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

StandupReaction
interface StandupReaction {
  emoji: string
  count: number
  active?: boolean
}
StandupMember
interface StandupMember {
  id: string
  name: string
  role: string
  avatar: string
  fallback: string
  submittedAt: string
  timezone: string
  status: StandupStatus
  statusLabel: string
  yesterday: string[]
  today: string[]
  blockers?: string | null
  blockerSeverity?: 'critical' | 'moderate' | 'low'
  reactions: StandupReaction[]
  threadCount?: number
}
BlockerAlert
interface BlockerAlert {
  id: string
  title: string
  description: string
  authorName: string
  authorRole: string
  reportedAt: string
  ticketId?: string
  scope: string
  assignedSquad: string
}
StandupPulseMetrics
interface StandupPulseMetrics {
  submissionsCount: number
  totalMembers: number
  pendingMemberName?: string
  activeBlockersCount: number
  blockersHighlight?: string
  goalsOnTrackCount: number
  totalGoals: number
  sprintName?: string
  deploymentsPlannedCount: number
  deploymentsHighlight?: string
}

Files installed (2)

  • src/app/components/blocks/daily-standup-summary/daily-standup-summary.component.ts35.3 kB
    // Slack and Geekbot style async engineering daily standup summary with squad pulse cards,
    // critical blocker alert callout, structured yesterday/today/blockers submission entries,
    // reaction counts, and thread actions. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      AlertTriangle,
      ArrowRight,
      Bot,
      Calendar,
      Check,
      CheckCircle2,
      Clock,
      Copy,
      Flame,
      Handshake,
      LucideAngularModule,
      MessageSquare,
      Rocket,
      Send,
      ShieldAlert,
      ShieldCheck,
      Smile,
      Target,
      Users,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component'
    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 { UiSeparatorComponent } from '@/ui/separator/separator.component'
    
    export type StandupStatus = 'on-track' | 'blocked' | 'needs-review'
    
    export interface StandupReaction {
      emoji: string
      count: number
      active?: boolean
    }
    
    export interface StandupMember {
      id: string
      name: string
      role: string
      avatar: string
      fallback: string
      submittedAt: string
      timezone: string
      status: StandupStatus
      statusLabel: string
      yesterday: string[]
      today: string[]
      blockers?: string | null
      blockerSeverity?: 'critical' | 'moderate' | 'low'
      reactions: StandupReaction[]
      threadCount?: number
    }
    
    export interface BlockerAlert {
      id: string
      title: string
      description: string
      authorName: string
      authorRole: string
      reportedAt: string
      ticketId?: string
      scope: string
      assignedSquad: string
    }
    
    export interface StandupPulseMetrics {
      submissionsCount: number
      totalMembers: number
      pendingMemberName?: string
      activeBlockersCount: number
      blockersHighlight?: string
      goalsOnTrackCount: number
      totalGoals: number
      sprintName?: string
      deploymentsPlannedCount: number
      deploymentsHighlight?: string
    }
    
    const defaultPulseMetrics: StandupPulseMetrics = {
      submissionsCount: 5,
      totalMembers: 6,
      pendingMemberName: 'Alex Rivera (PTO / Vacation)',
      activeBlockersCount: 1,
      blockersHighlight: 'AWS IAM KMS staging rotation',
      goalsOnTrackCount: 8,
      totalGoals: 10,
      sprintName: 'Sprint 42 · 3 days left',
      deploymentsPlannedCount: 3,
      deploymentsHighlight: '2 staging · 1 production (v2.4.0)',
    }
    
    const defaultBlockerAlert: BlockerAlert = {
      id: 'blk-aws-kms-01',
      title: 'Blocked on AWS IAM permission grant for KMS rotation in staging',
      description:
        'Unable to complete cryptographic secret rotation automated test suite for the v2.4 staging cluster. Ticket SEC-9104 is currently pending approval from the Security Operations squad.',
      authorName: 'Marcus Vance',
      authorRole: 'DevOps & Cloud Architect',
      reportedAt: '42m ago',
      ticketId: 'SEC-9104',
      scope: 'Staging Pipeline',
      assignedSquad: '#sec-ops-triage',
    }
    
    const defaultMembers: StandupMember[] = [
      {
        id: 'mem-1',
        name: 'Elena Rostova',
        role: 'Principal Engineer',
        avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&auto=format&fit=crop&q=80',
        fallback: 'ER',
        submittedAt: '09:15 AM',
        timezone: 'UTC+2 · Berlin',
        status: 'on-track',
        statusLabel: 'On Track',
        yesterday: [
          'Merged RFC #142 for headless multi-token theme engine architecture',
          'Reviewed and approved 6 PRs across core UI primitive components',
          'Mentored frontend team on Reka UI polymorphic asChild slot bindings',
        ],
        today: [
          'Finalizing async tree-shaking benchmarking suite for monorepo components (#PERF-892)',
          'Kicking off Design Tokens v4 migration RFC discussion thread with design systems team',
          'Pairing with Sarah on virtualized combobox keyboard focus trap resolution',
        ],
        blockers: null,
        reactions: [
          { emoji: '👍', count: 4, active: true },
          { emoji: '🚀', count: 2, active: false },
          { emoji: '👀', count: 3, active: false },
          { emoji: '🧠', count: 1, active: false },
        ],
        threadCount: 3,
      },
      {
        id: 'mem-2',
        name: 'Marcus Vance',
        role: 'DevOps & Cloud Architect',
        avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&auto=format&fit=crop&q=80',
        fallback: 'MV',
        submittedAt: '09:28 AM',
        timezone: 'UTC-5 · New York',
        status: 'blocked',
        statusLabel: 'Blocked',
        blockerSeverity: 'critical',
        yesterday: [
          'Provisioned ephemeral preview environments for automated PR test runners',
          'Automated Cloudflare Pages edge cache invalidation hooks in GitHub Actions',
        ],
        today: [
          'Rolling out canary release v2.4.0 to EU cluster once KMS permissions clear (#OPS-419)',
          'Setting up Prometheus edge exporter for SSR latency and cache hit ratios',
        ],
        blockers:
          'Blocked on AWS IAM permission grant for KMS rotation in staging. Escalated to Security Ops in #sec-ops-triage (Ticket SEC-9104).',
        reactions: [
          { emoji: '🚨', count: 3, active: true },
          { emoji: '💬', count: 4, active: false },
          { emoji: '🤝', count: 2, active: false },
          { emoji: '🙏', count: 1, active: false },
        ],
        threadCount: 4,
      },
      {
        id: 'mem-3',
        name: 'David Chen',
        role: 'Full-Stack Engineer',
        avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=150&auto=format&fit=crop&q=80',
        fallback: 'DC',
        submittedAt: '09:42 AM',
        timezone: 'UTC-7 · San Francisco',
        status: 'on-track',
        statusLabel: 'On Track',
        yesterday: [
          'Implemented Nitro rate-limiting telemetry parser and token bucket middleware',
          'Wrote 34 unit tests for zero-allocation token stringifier algorithm',
        ],
        today: [
          'Implementing HMAC-SHA256 webhook signature validation generator (#CORE-331)',
          'Writing OpenAPI 3.1 documentation with interactive curl playground snippets',
        ],
        blockers: null,
        reactions: [
          { emoji: '🔥', count: 5, active: false },
          { emoji: '👍', count: 3, active: true },
          { emoji: '⚡', count: 2, active: false },
        ],
        threadCount: 1,
      },
      {
        id: 'mem-4',
        name: 'Sarah Jenkins',
        role: 'Frontend Specialist',
        avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&auto=format&fit=crop&q=80',
        fallback: 'SJ',
        submittedAt: '09:55 AM',
        timezone: 'UTC+1 · London',
        status: 'needs-review',
        statusLabel: 'Needs Review',
        yesterday: [
          'Completed accessible combobox keyboard navigation and roving tabindex support',
          'Audited dark mode OKLCH contrast ratios on table headers and badges',
        ],
        today: [
          'Benchmarking spring physics motion transition framerates on mobile viewports (#UI-204)',
          'Crafting comprehensive story demos for Daily Standup Summary block',
        ],
        blockers: 'Awaiting PR review on PR #884 (Reka roving focus patch) from Elena or David to merge.',
        reactions: [
          { emoji: '✨', count: 6, active: true },
          { emoji: '👍', count: 4, active: false },
          { emoji: '🚀', count: 1, active: false },
        ],
        threadCount: 2,
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-daily-standup-summary, [ui-daily-standup-summary]',
      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,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiAvatarImageComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiSeparatorComponent,
      ],
      template: `
        <div data-slot="daily-standup-summary" [class]="rootClass">
          <!-- Header Section -->
          <header
            class="bg-card border-border flex flex-col gap-4 rounded-xl border p-5 shadow-xs sm:p-6 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="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                  <lucide-icon [img]="Bot" class="size-4.5" aria-hidden="true" />
                </div>
                <h1 class="text-xl font-bold tracking-tight sm:text-2xl">Daily Engineering Standup</h1>
                <span ui-badge variant="secondary" class="border-info/20 bg-info/10 text-info text-xs font-medium">
                  <span class="bg-info mr-1.5 inline-block size-1.5 animate-pulse rounded-full"></span>
                  Synced with #eng-standups
                </span>
              </div>
              <div class="text-muted-foreground flex flex-wrap items-center gap-2 text-xs sm:text-sm">
                <span class="text-foreground flex items-center gap-1 font-medium">
                  <lucide-icon [img]="Calendar" class="text-muted-foreground size-3.5" aria-hidden="true" />
                  {{ date }}
                </span>
                <span class="flex items-center gap-1">
                  <lucide-icon [img]="Users" class="size-3.5" aria-hidden="true" />
                  {{ teamName }} · {{ metrics().totalMembers }} Members
                </span>
              </div>
            </div>
    
            <div class="flex flex-wrap items-center gap-2.5">
              <button ui-button variant="outline" size="sm" class="h-9 gap-1.5 text-xs font-medium" (click)="copySummary()">
                @if (copiedSummary()) {
                  <lucide-icon [img]="Check" class="text-success size-3.5" aria-hidden="true" />
                } @else {
                  <lucide-icon [img]="Copy" class="text-muted-foreground size-3.5" aria-hidden="true" />
                }
                {{ copiedSummary() ? 'Summary Copied' : 'Copy Summary' }}
              </button>
              <button
                ui-button
                [variant]="standupSubmitted() ? 'outline' : 'default'"
                size="sm"
                class="h-9 gap-1.5 text-xs font-semibold transition"
                (click)="standupSubmitted.set(!standupSubmitted())"
              >
                @if (standupSubmitted()) {
                  <lucide-icon [img]="CheckCircle2" class="text-success size-4" aria-hidden="true" />
                } @else {
                  <lucide-icon [img]="Send" class="size-4" aria-hidden="true" />
                }
                {{ standupSubmitted() ? 'Standup Submitted' : 'Submit My Standup' }}
              </button>
            </div>
          </header>
    
          <!-- 4 Squad Pulse Summary Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <div ui-card class="border-border shadow-xs">
              <div ui-card-header class="pb-2">
                <div class="flex items-center justify-between">
                  <p ui-card-description class="text-xs font-medium">Total Submissions</p>
                  <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md">
                    <lucide-icon [img]="CheckCircle2" class="size-3.5" aria-hidden="true" />
                  </div>
                </div>
                <h3 ui-card-title class="mt-1 flex items-baseline gap-2 text-xl font-bold tabular-nums">
                  {{ metrics().submissionsCount }} / {{ metrics().totalMembers }}
                  <span
                    ui-badge
                    variant="secondary"
                    class="border-success/20 bg-success/10 text-success text-success text-xs font-semibold tabular-nums"
                  >
                    {{ submitPct() }}% In
                  </span>
                </h3>
              </div>
              <div ui-card-content class="pt-0">
                <div class="bg-muted mb-2 h-1.5 w-full overflow-hidden rounded-full">
                  <div class="bg-success h-full transition-[width] duration-500" [style.width.%]="submitPct()"></div>
                </div>
                @if (metrics().pendingMemberName) {
                  <p class="text-muted-foreground truncate text-xs">Pending: {{ metrics().pendingMemberName }}</p>
                }
              </div>
            </div>
    
            <div ui-card [class]="blockerCardClass">
              <div ui-card-header class="pb-2">
                <div class="flex items-center justify-between">
                  <p ui-card-description class="text-xs font-medium">Active Blockers</p>
                  <div [class]="blockerIconClass">
                    <lucide-icon [img]="AlertTriangle" class="size-3.5" aria-hidden="true" />
                  </div>
                </div>
                <h3 ui-card-title class="mt-1 flex items-baseline gap-2 text-xl font-bold tabular-nums">
                  {{ metrics().activeBlockersCount }}
                  {{ metrics().activeBlockersCount === 1 ? 'Critical' : 'Active' }}
                  @if (metrics().activeBlockersCount > 0) {
                    <span ui-badge variant="destructive" class="text-xs font-semibold">
                      <span class="mr-1 inline-block size-1.5 rounded-full bg-white"></span>
                      Action Req.
                    </span>
                  } @else {
                    <span
                      ui-badge
                      variant="secondary"
                      class="border-success/20 bg-success/10 text-success text-xs font-semibold"
                    >
                      Clear
                    </span>
                  }
                </h3>
              </div>
              <div ui-card-content class="pt-0">
                <p class="text-muted-foreground truncate text-xs">
                  {{ metrics().blockersHighlight || 'All squad paths currently unblocked' }}
                </p>
              </div>
            </div>
    
            <div ui-card class="border-border shadow-xs">
              <div ui-card-header class="pb-2">
                <div class="flex items-center justify-between">
                  <p ui-card-description class="text-xs font-medium">Sprint Goals On Track</p>
                  <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md">
                    <lucide-icon [img]="Target" class="size-3.5" aria-hidden="true" />
                  </div>
                </div>
                <h3 ui-card-title class="mt-1 flex items-baseline gap-2 text-xl font-bold tabular-nums">
                  {{ metrics().goalsOnTrackCount }} / {{ metrics().totalGoals }}
                  <span
                    ui-badge
                    variant="secondary"
                    class="bg-primary/10 text-primary border-primary/20 text-xs font-semibold tabular-nums"
                  >
                    {{ goalsPct() }}% Velocity
                  </span>
                </h3>
              </div>
              <div ui-card-content class="pt-0">
                <p class="text-muted-foreground truncate text-xs">
                  {{ metrics().sprintName || 'Sprint on schedule' }}
                </p>
              </div>
            </div>
    
            <div ui-card class="border-border shadow-xs">
              <div ui-card-header class="pb-2">
                <div class="flex items-center justify-between">
                  <p ui-card-description class="text-xs font-medium">Planned Deployments</p>
                  <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md">
                    <lucide-icon [img]="Rocket" class="size-3.5" aria-hidden="true" />
                  </div>
                </div>
                <h3 ui-card-title class="mt-1 flex items-baseline gap-2 text-xl font-bold tabular-nums">
                  {{ metrics().deploymentsPlannedCount }} Releases
                  <span ui-badge variant="outline" class="text-xs font-semibold"> Today </span>
                </h3>
              </div>
              <div ui-card-content class="pt-0">
                <p class="text-muted-foreground truncate text-xs">
                  {{ metrics().deploymentsHighlight || 'Targeting staging & edge production' }}
                </p>
              </div>
            </div>
          </div>
    
          <!-- Active Blockers Alert Card (Highlighted Banner) -->
          @if (alert(); as blocker) {
            <div
              class="border-destructive/30 bg-destructive/5 dark:bg-destructive/10 relative overflow-hidden rounded-xl border p-5 shadow-xs"
            >
              <div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
                <div class="flex items-start gap-3.5">
                  <div
                    class="bg-destructive/15 text-destructive mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg"
                  >
                    <lucide-icon [img]="ShieldAlert" class="size-5" aria-hidden="true" />
                  </div>
                  <div class="space-y-1.5">
                    <div class="flex flex-wrap items-center gap-2">
                      <span ui-badge variant="destructive" class="text-xs font-semibold">
                        <span class="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-white"></span>
                        Critical Blocker
                      </span>
                      <span ui-badge variant="outline" class="font-mono text-xs">
                        {{ blocker.ticketId }}
                      </span>
                      <span class="text-muted-foreground text-xs">
                        Reported by <strong class="text-foreground font-medium">{{ blocker.authorName }}</strong> ({{
                          blocker.authorRole
                        }}) · {{ blocker.reportedAt }}
                      </span>
                    </div>
                    <h2 class="text-foreground text-base leading-snug font-semibold">
                      {{ blocker.title }}
                    </h2>
                    <p class="text-muted-foreground text-xs sm:text-sm">
                      {{ blocker.description }}
                    </p>
                    <div class="flex flex-wrap items-center gap-3 pt-1 text-xs">
                      <span class="text-muted-foreground"
                        >Scope: <span class="text-foreground font-medium">{{ blocker.scope }}</span></span
                      >
                      <span class="text-muted-foreground"
                        >Assigned: <span class="text-primary font-medium">{{ blocker.assignedSquad }}</span></span
                      >
                    </div>
                  </div>
                </div>
    
                <div class="flex shrink-0 flex-wrap items-center gap-2 md:flex-col md:items-end">
                  <button
                    ui-button
                    size="sm"
                    [variant]="escalatedBlocker() ? 'outline' : 'destructive'"
                    class="h-8 text-xs font-semibold"
                    (click)="escalatedBlocker.set(!escalatedBlocker())"
                  >
                    @if (escalatedBlocker()) {
                      <lucide-icon [img]="Check" class="text-success mr-1 size-3.5" />
                    } @else {
                      <lucide-icon [img]="Flame" class="mr-1 size-3.5" />
                    }
                    {{ escalatedBlocker() ? 'Escalated to SecOps' : 'Escalate Blocker' }}
                  </button>
                  <button ui-button variant="outline" size="sm" class="h-8 text-xs font-medium">
                    <lucide-icon [img]="MessageSquare" class="text-muted-foreground mr-1 size-3.5" />
                    Open Thread (4)
                  </button>
                </div>
              </div>
            </div>
          }
    
          <!-- Standup Submissions Section -->
          <div class="space-y-4">
            <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
              <div class="flex items-center gap-2">
                <h2 class="text-base font-semibold tracking-tight">Standup Submissions</h2>
                <span ui-badge variant="secondary" class="font-mono text-xs tabular-nums">
                  {{ filteredMembers().length }}
                </span>
              </div>
    
              <div class="bg-muted border-border flex items-center gap-1 rounded-lg border p-1 text-xs">
                <button type="button" [class]="filterPillClass('all')" (click)="activeFilter.set('all')">
                  All ({{ countAll() }})
                </button>
                <button type="button" [class]="filterPillClass('blocked')" (click)="activeFilter.set('blocked')">
                  Blocked ({{ countBlocked() }})
                </button>
                <button type="button" [class]="filterPillClass('needs-review')" (click)="activeFilter.set('needs-review')">
                  Needs Review ({{ countNeedsReview() }})
                </button>
                <button type="button" [class]="filterPillClass('on-track')" (click)="activeFilter.set('on-track')">
                  On Track ({{ countOnTrack() }})
                </button>
              </div>
            </div>
    
            <div class="space-y-4">
              @for (member of filteredMembers(); track member.id; let mIdx = $index) {
                <div ui-card [class]="memberCardClass(member.status)">
                  <div ui-card-header class="pb-4">
                    <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                      <div class="flex items-center gap-3.5">
                        <ui-avatar class="size-10 border">
                          <ui-avatar-image [src]="member.avatar" [alt]="member.name" />
                          <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-semibold">{{
                            member.fallback
                          }}</ui-avatar-fallback>
                        </ui-avatar>
                        <div>
                          <div class="flex flex-wrap items-center gap-2">
                            <h3 class="text-sm font-semibold tracking-tight">{{ member.name }}</h3>
                            <span
                              ui-badge
                              [variant]="member.status === 'blocked' ? 'destructive' : 'secondary'"
                              [class]="statusBadgeClass(member.status)"
                            >
                              {{ member.statusLabel }}
                            </span>
                          </div>
                          <p class="text-muted-foreground text-xs">
                            {{ member.role }} · <span class="text-muted-foreground/80">{{ member.timezone }}</span>
                          </p>
                        </div>
                      </div>
    
                      <div class="flex items-center gap-2 self-start text-xs sm:self-auto">
                        <span class="text-muted-foreground flex items-center gap-1 tabular-nums">
                          <lucide-icon [img]="Clock" class="text-muted-foreground size-3" aria-hidden="true" />
                          {{ member.submittedAt }}
                        </span>
                      </div>
                    </div>
                  </div>
    
                  <div ui-card-content class="space-y-4 pt-0">
                    <ui-separator />
    
                    <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
                      <div class="bg-muted/30 border-border/60 space-y-2 rounded-lg border p-3.5">
                        <h4
                          class="text-muted-foreground flex items-center gap-1.5 text-xs font-semibold tracking-wider uppercase"
                        >
                          <lucide-icon [img]="CheckCircle2" class="text-success size-3.5 shrink-0" aria-hidden="true" />
                          Yesterday's Output
                        </h4>
                        <ul class="space-y-1.5 text-xs leading-relaxed">
                          @for (item of member.yesterday; track item) {
                            <li class="flex items-start gap-1.5">
                              <span class="text-success mt-0.5 font-bold">✓</span>
                              <span class="text-foreground/90">{{ item }}</span>
                            </li>
                          }
                        </ul>
                      </div>
    
                      <div class="bg-muted/30 border-border/60 space-y-2 rounded-lg border p-3.5">
                        <h4
                          class="text-muted-foreground flex items-center gap-1.5 text-xs font-semibold tracking-wider uppercase"
                        >
                          <lucide-icon [img]="ArrowRight" class="text-primary size-3.5 shrink-0" aria-hidden="true" />
                          Today's Focus
                        </h4>
                        <ul class="space-y-1.5 text-xs leading-relaxed">
                          @for (item of member.today; track item) {
                            <li class="flex items-start gap-1.5">
                              <span class="text-primary mt-0.5 font-bold">→</span>
                              <span class="text-foreground/90">{{ item }}</span>
                            </li>
                          }
                        </ul>
                      </div>
    
                      <div [class]="blockerBoxClass(member)">
                        <h4
                          class="text-muted-foreground flex items-center gap-1.5 text-xs font-semibold tracking-wider uppercase"
                        >
                          @if (member.blockers) {
                            <lucide-icon
                              [img]="AlertTriangle"
                              [class]="
                                member.status === 'blocked'
                                  ? 'text-destructive size-3.5 shrink-0'
                                  : 'text-warning size-3.5 shrink-0'
                              "
                              aria-hidden="true"
                            />
                          } @else {
                            <lucide-icon [img]="ShieldCheck" class="text-success size-3.5 shrink-0" aria-hidden="true" />
                          }
                          Blockers & Risks
                        </h4>
    
                        @if (member.blockers) {
                          <div class="text-xs leading-relaxed">
                            <p
                              [class]="
                                member.status === 'blocked' ? 'text-destructive font-medium' : 'text-warning font-medium'
                              "
                            >
                              {{ member.blockers }}
                            </p>
                          </div>
                        } @else {
                          <div class="text-muted-foreground flex items-center gap-1.5 text-xs">
                            <span class="text-success font-medium">No blockers</span>
                            <span>— Clear path to sprint goals</span>
                          </div>
                        }
                      </div>
                    </div>
    
                    <div class="border-border/60 flex flex-wrap items-center justify-between gap-3 border-t pt-3">
                      <div class="flex flex-wrap items-center gap-1.5">
                        @for (reaction of member.reactions; track reaction.emoji; let rIdx = $index) {
                          <button
                            type="button"
                            [class]="reactionPillClass(reaction.active ?? false)"
                            (click)="toggleReaction(memberIndexOf(member.id), rIdx)"
                          >
                            <span>{{ reaction.emoji }}</span>
                            <span class="font-semibold tabular-nums">{{ reaction.count }}</span>
                          </button>
                        }
                        <button
                          type="button"
                          class="focus-visible:ring-ring text-muted-foreground hover:text-foreground hover:bg-muted flex size-7 items-center justify-center rounded-full border border-dashed transition focus-visible:ring-2 focus-visible:outline-none"
                          title="Add reaction"
                          aria-label="Add reaction"
                          (click)="toggleReaction(memberIndexOf(member.id), 0)"
                        >
                          <lucide-icon [img]="Smile" class="size-3.5" aria-hidden="true" />
                        </button>
                      </div>
    
                      <div class="flex flex-wrap items-center gap-2">
                        <button
                          ui-button
                          variant="ghost"
                          size="sm"
                          class="text-muted-foreground hover:text-foreground h-8 gap-1.5 text-xs"
                        >
                          <lucide-icon [img]="Handshake" class="size-3.5" aria-hidden="true" />
                          Offer Help
                        </button>
                        <button ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs font-medium">
                          <lucide-icon [img]="MessageSquare" class="text-muted-foreground size-3.5" aria-hidden="true" />
                          <span>Reply in Thread</span>
                          @if (member.threadCount) {
                            <span
                              class="bg-muted text-foreground py-0.2 ml-0.5 rounded px-1.5 font-mono text-xs font-bold tabular-nums"
                            >
                              {{ member.threadCount }}
                            </span>
                          }
                        </button>
                      </div>
                    </div>
                  </div>
                </div>
              }
            </div>
          </div>
        </div>
      `,
    })
    export class UiDailyStandupSummaryComponent {
      protected readonly AlertTriangle = AlertTriangle
      protected readonly ArrowRight = ArrowRight
      protected readonly Bot = Bot
      protected readonly Calendar = Calendar
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly Copy = Copy
      protected readonly Flame = Flame
      protected readonly Handshake = Handshake
      protected readonly MessageSquare = MessageSquare
      protected readonly Rocket = Rocket
      protected readonly Send = Send
      protected readonly ShieldAlert = ShieldAlert
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Smile = Smile
      protected readonly Target = Target
      protected readonly Users = Users
    
      @Input() teamName = 'Core Registry Squad'
      @Input() date = 'Today · Friday, Aug 21, 2026'
      @Input() set pulseMetrics(v: StandupPulseMetrics | undefined) {
        if (v) this.pulseMetricsInput.set(v)
      }
      @Input() set blockerAlert(v: BlockerAlert | null | undefined) {
        // `undefined` (absent input) keeps the default; explicit `null` hides the banner.
        if (v !== undefined) this.blockerAlertInput.set(v)
      }
      @Input() set members(v: StandupMember[] | undefined) {
        if (v) this.localMembers.set(v.map((m) => ({ ...m, reactions: m.reactions.map((r) => ({ ...r })) })))
      }
      @Input() set initialFilter(v: 'all' | StandupStatus) {
        if (v) this.activeFilter.set(v)
      }
      @Input('class') className?: string
    
      private readonly pulseMetricsInput = signal<StandupPulseMetrics | null>(null)
      private readonly blockerAlertInput = signal<BlockerAlert | null | undefined>(undefined)
      readonly localMembers = signal<StandupMember[]>(
        defaultMembers.map((m) => ({ ...m, reactions: m.reactions.map((r) => ({ ...r })) })),
      )
    
      readonly activeFilter = signal<'all' | StandupStatus>('all')
      readonly standupSubmitted = signal(false)
      readonly copiedSummary = signal(false)
      readonly escalatedBlocker = signal(false)
      private copyTimer: ReturnType<typeof setTimeout> | undefined
    
      readonly metrics = computed(() => this.pulseMetricsInput() ?? defaultPulseMetrics)
      readonly alert = computed(() =>
        this.blockerAlertInput() !== undefined ? this.blockerAlertInput() : defaultBlockerAlert,
      )
    
      readonly filteredMembers = computed(() => {
        const f = this.activeFilter()
        if (f === 'all') return this.localMembers()
        return this.localMembers().filter((m) => m.status === f)
      })
    
      readonly countAll = computed(() => this.localMembers().length)
      readonly countBlocked = computed(() => this.localMembers().filter((m) => m.status === 'blocked').length)
      readonly countNeedsReview = computed(() => this.localMembers().filter((m) => m.status === 'needs-review').length)
      readonly countOnTrack = computed(() => this.localMembers().filter((m) => m.status === 'on-track').length)
    
      readonly submitPct = computed(() => {
        const m = this.metrics()
        return Math.round((m.submissionsCount / m.totalMembers) * 100)
      })
      readonly goalsPct = computed(() => {
        const m = this.metrics()
        return Math.round((m.goalsOnTrackCount / m.totalGoals) * 100)
      })
    
      get rootClass(): string {
        return cn('bg-background text-foreground w-full space-y-6', this.className)
      }
    
      get blockerCardClass(): string {
        return cn(
          'border-border shadow-xs',
          this.metrics().activeBlockersCount > 0 && 'border-destructive/40 bg-destructive/5',
        )
      }
    
      get blockerIconClass(): string {
        // [class] replaces static classes: the base must live here (matches React's cn(base, active)).
        return cn(
          'flex size-7 items-center justify-center rounded-md',
          this.metrics().activeBlockersCount > 0 ? 'bg-destructive/10 text-destructive' : 'bg-success/10 text-success',
        )
      }
    
      filterPillClass(f: 'all' | StandupStatus): string {
        const active = this.activeFilter() === f
        const base =
          'focus-visible:ring-ring min-h-6 rounded-md px-2.5 py-1 font-medium transition focus-visible:ring-2 focus-visible:outline-none'
        if (!active) {
          return cn(
            base,
            f === 'blocked'
              ? 'text-muted-foreground hover:text-destructive'
              : 'text-muted-foreground hover:text-foreground',
          )
        }
        if (f === 'blocked') return cn(base, 'bg-background text-destructive font-semibold shadow-xs')
        if (f === 'needs-review') return cn(base, 'bg-background text-warning text-warning font-semibold shadow-xs')
        if (f === 'on-track') return cn(base, 'bg-background text-success text-success font-semibold shadow-xs')
        return cn(base, 'bg-background text-foreground font-semibold shadow-xs')
      }
    
      memberCardClass(status: StandupStatus): string {
        return cn(
          'border-border hover:border-border/80 shadow-xs transition',
          status === 'blocked' && 'border-destructive/30',
          status === 'needs-review' && 'border-warning/30',
        )
      }
    
      statusBadgeClass(status: StandupStatus): string {
        return cn(
          'text-xs font-medium',
          status === 'on-track' && 'border-success/20 bg-success/10 text-success',
          status === 'needs-review' && 'border-warning/20 bg-warning/10 text-warning',
        )
      }
    
      blockerBoxClass(member: StandupMember): string {
        return cn(
          'space-y-2 rounded-lg border p-3.5',
          member.blockers
            ? member.status === 'blocked'
              ? 'border-destructive/30 bg-destructive/5 dark:bg-destructive/10'
              : 'border-warning/30 bg-warning/5'
            : 'border-border/60 bg-muted/30',
        )
      }
    
      reactionPillClass(active: boolean): string {
        return cn(
          'focus-visible:ring-ring flex min-h-6 items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition focus-visible:ring-2 focus-visible:outline-none',
          active
            ? 'bg-primary/10 border-primary/40 text-primary'
            : 'bg-muted/40 border-border text-muted-foreground hover:bg-muted hover:text-foreground',
        )
      }
    
      memberIndexOf(id: string): number {
        return this.localMembers().findIndex((m) => m.id === id)
      }
    
      toggleReaction(memberIndex: number, reactionIndex: number): void {
        this.localMembers.update((prev) => {
          const next = [...prev]
          const member = next[memberIndex]
          if (!member) return prev
          const reactions = [...member.reactions]
          const r = reactions[reactionIndex]
          if (!r) return prev
          reactions[reactionIndex] = r.active
            ? { ...r, count: r.count - 1, active: false }
            : { ...r, count: r.count + 1, active: true }
          next[memberIndex] = { ...member, reactions }
          return next
        })
      }
    
      async copySummary(): Promise<void> {
        try {
          const m = this.metrics()
          const text = `${this.teamName} Standup Summary — ${this.date}\n\nSubmissions: ${m.submissionsCount}/${m.totalMembers} (${Math.round((m.submissionsCount / m.totalMembers) * 100)}%)\nActive Blockers: ${m.activeBlockersCount}\nGoals on Track: ${m.goalsOnTrackCount}/${m.totalGoals}\nPlanned Releases: ${m.deploymentsPlannedCount}`
          await navigator.clipboard.writeText(text)
          this.copiedSummary.set(true)
          clearTimeout(this.copyTimer)
          this.copyTimer = setTimeout(() => this.copiedSummary.set(false), 2000)
        } catch {
          // Clipboard unavailable in some contexts.
        }
      }
    }
    
  • src/app/components/blocks/daily-standup-summary/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/daily-standup-summary.json