Menu

Change language

GitHub repo

Habit Streak Tracker

blockproductivity

GitHub style 52-week contribution habit heatmap, flame streak counter, consistency metrics, and daily completion tracker with 7-day mini checkbox strip and progress bars.

Installation

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

Variants

Loading interactive previews…

Schema

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

HabitItem
interface HabitItem {
  id: string
  title: string
  description: string
  category: HabitCategory
  icon: 'code' | 'terminal' | 'steps' | 'book' | 'water' | 'target' | string
  streakDays: number
  completionRate: number
  weekHistory: boolean[]
}
DayHeatmapCell
interface DayHeatmapCell {
  date: string
  formattedDate: string
  dayName: string
  dayIndex: number
  weekIndex: number
  count: number
  level: number
  month: string
}
MonthLabel
interface MonthLabel {
  name: string
  weekIndex: number
}

Files installed (2)

  • src/app/components/blocks/habit-streak-tracker/habit-streak-tracker.component.ts34.1 kB
    // GitHub style 52-week contribution habit heatmap, flame streak counter, consistency
    // metrics, and daily completion tracker with 7-day mini checkbox strip and progress bars.
    // Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      BookOpen,
      Calendar,
      Check,
      CheckCircle2,
      CodeXml,
      Droplets,
      Flame,
      Footprints,
      LucideAngularModule,
      Plus,
      Target,
      Terminal,
      TrendingUp,
      Trophy,
      X,
      type LucideIconData,
    } 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'
    
    export type HabitCategory = 'Engineering' | 'Health' | 'Learning' | 'Mindset' | string
    
    export interface HabitItem {
      id: string
      title: string
      description: string
      category: HabitCategory
      icon: 'code' | 'terminal' | 'steps' | 'book' | 'water' | 'target' | string
      streakDays: number
      completionRate: number
      weekHistory: boolean[]
    }
    
    export interface DayHeatmapCell {
      date: string
      formattedDate: string
      dayName: string
      dayIndex: number
      weekIndex: number
      count: number
      level: number
      month: string
    }
    
    export interface MonthLabel {
      name: string
      weekIndex: number
    }
    
    const defaultHabits: HabitItem[] = [
      {
        id: 'habit-1',
        title: 'Ship 1 Component to Registry',
        description: 'Author, test, and register a new UI block or primitive',
        category: 'Engineering',
        icon: 'code',
        streakDays: 24,
        completionRate: 96,
        weekHistory: [true, true, true, true, true, true, true],
      },
      {
        id: 'habit-2',
        title: '1 Hour Deep Work / Code Review',
        description: 'Uninterrupted architecture focus & PR review rounds',
        category: 'Engineering',
        icon: 'terminal',
        streakDays: 19,
        completionRate: 92,
        weekHistory: [true, true, true, true, true, false, true],
      },
      {
        id: 'habit-3',
        title: '10,000 Steps Walking',
        description: 'Daily outdoor walk & cardiovascular activity',
        category: 'Health',
        icon: 'steps',
        streakDays: 24,
        completionRate: 100,
        weekHistory: [true, true, true, true, true, true, true],
      },
      {
        id: 'habit-4',
        title: 'Read 20 Pages Tech Book',
        description: 'Systems design, TypeScript craft, or CS fundamentals',
        category: 'Learning',
        icon: 'book',
        streakDays: 12,
        completionRate: 85,
        weekHistory: [true, true, false, true, true, true, false],
      },
      {
        id: 'habit-5',
        title: 'Drink 2.5L Water',
        description: 'Optimal hydration tracking throughout the working day',
        category: 'Health',
        icon: 'water',
        streakDays: 30,
        completionRate: 98,
        weekHistory: [true, true, true, true, true, true, true],
      },
    ]
    
    export function generate52WeekHeatmap(baseStreakDays = 24): {
      weeks: DayHeatmapCell[][]
      monthLabels: MonthLabel[]
      totalCompletions: number
    } {
      const weeks: DayHeatmapCell[][] = []
      const monthLabels: MonthLabel[] = []
      const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
      const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
      const endDate = new Date(2026, 7, 23)
      let lastMonth = ''
      let totalCompletions = 0
      let seed = 1337
      function pseudoRandom(): number {
        seed = (seed * 9301 + 49297) % 233280
        return seed / 233280
      }
      for (let w = 0; w < 52; w++) {
        const weekDays: DayHeatmapCell[] = []
        for (let d = 0; d < 7; d++) {
          const daysAgo = (51 - w) * 7 + (6 - d)
          const dateObj = new Date(endDate)
          dateObj.setDate(endDate.getDate() - daysAgo)
          const monthName = months[dateObj.getMonth()] ?? 'Jan'
          const formattedDate = `${monthName} ${dateObj.getDate()}, ${dateObj.getFullYear()}`
          const dateStr = dateObj.toISOString().split('T')[0] ?? `w${w}d${d}`
          let count = 0
          if (daysAgo <= baseStreakDays && daysAgo >= 0) {
            count = pseudoRandom() > 0.3 ? 5 : 4
          } else if (daysAgo >= 90 && daysAgo <= 138) {
            count = pseudoRandom() > 0.2 ? 5 : 4
          } else {
            const rand = pseudoRandom()
            if (rand > 0.82) count = 5
            else if (rand > 0.48) count = 4
            else if (rand > 0.22) count = 3
            else if (rand > 0.08) count = 2
            else if (rand > 0.03) count = 1
            else count = 0
          }
          totalCompletions += count
          let level = 0
          if (count >= 5) level = 4
          else if (count === 4) level = 3
          else if (count === 3) level = 2
          else if (count >= 1) level = 1
          else level = 0
          weekDays.push({
            date: dateStr,
            formattedDate,
            dayName: dayNames[d] ?? '',
            dayIndex: d,
            weekIndex: w,
            count,
            level,
            month: monthName,
          })
          if (d === 0 && monthName !== lastMonth) {
            monthLabels.push({ name: monthName, weekIndex: w })
            lastMonth = monthName
          }
        }
        weeks.push(weekDays)
      }
      return { weeks, monthLabels, totalCompletions }
    }
    
    const weekDayColumns = [
      { short: 'Mon', num: '17' },
      { short: 'Tue', num: '18' },
      { short: 'Wed', num: '19' },
      { short: 'Thu', num: '20' },
      { short: 'Fri', num: '21' },
      { short: 'Sat', num: '22' },
      { short: 'Sun', num: '23' },
    ]
    
    const levelClassMap: Record<number, string> = {
      0: 'bg-muted/60 dark:bg-muted/30 border border-border/30 hover:ring-1 hover:ring-foreground/20',
      1: 'bg-success/25 border border-success/20 hover:ring-1 hover:ring-success/50',
      2: 'bg-success/50 border border-success/30 hover:ring-1 hover:ring-success/70',
      3: 'bg-success/75 border border-success/40 hover:ring-1 hover:ring-success/90',
      4: 'bg-success border border-success/60 hover:ring-1 hover:ring-success',
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-habit-streak-tracker, [ui-habit-streak-tracker]',
      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,
      ],
      template: `
        <div data-slot="habit-streak-tracker" [class]="rootClass">
          <!-- Header -->
          <div
            class="border-border/80 bg-card text-card-foreground flex flex-col gap-4 rounded-xl border p-4 shadow-xs sm:p-5 md:flex-row md:items-center md:justify-between"
          >
            <div class="flex items-start gap-3.5 sm:items-center">
              <div
                class="border-warning/30 bg-warning/10 text-warning text-warning flex size-10 shrink-0 items-center justify-center rounded-xl border shadow-xs"
              >
                <lucide-icon [img]="Flame" class="size-5.5 animate-pulse" />
              </div>
              <div class="space-y-1">
                <div class="flex flex-wrap items-center gap-2.5">
                  <h1 class="text-foreground text-base leading-none font-semibold tracking-tight sm:text-lg">
                    Daily Habits & Consistency Matrix
                  </h1>
                  <span
                    ui-badge
                    variant="warning"
                    class="border-warning/30 bg-warning/15 text-warning gap-1 text-xs font-semibold"
                  >
                    <lucide-icon [img]="Flame" class="fill-warning text-warning size-3.5" />
                    <span class="font-mono tabular-nums">🔥 {{ initialStreak() }}-Day Streak · Personal Best!</span>
                  </span>
                </div>
                <p class="text-muted-foreground text-xs">
                  {{ monthTitle() }} · Real-time habit adherence, 52-week GitHub matrix, and routine tracker.
                </p>
              </div>
            </div>
            <div class="flex flex-wrap items-center gap-2.5 self-end sm:self-auto">
              <div
                class="border-border/70 bg-muted/40 text-muted-foreground flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium"
              >
                <lucide-icon [img]="Calendar" class="size-3.5" />
                <span>{{ monthTitle() }}</span>
              </div>
              <button
                ui-button
                aria-label="Close new habit dialog"
                size="sm"
                class="gap-1.5 shadow-xs"
                (click)="toggleModal()"
              >
                <lucide-icon [img]="Plus" class="size-4" />
                <span>New Habit</span>
              </button>
            </div>
          </div>
    
          <!-- Quick Add -->
          @if (showNewHabitModal()) {
            <div class="border-border/80 bg-card text-card-foreground space-y-3 rounded-xl border p-4 shadow-xs sm:p-5">
              <div class="flex items-center justify-between">
                <div class="space-y-0.5">
                  <p class="text-foreground text-sm font-semibold">Create New Habit Routine</p>
                  <p class="text-muted-foreground text-xs">Define a daily target to track against your annual matrix.</p>
                </div>
                <button
                  ui-button
                  variant="ghost"
                  size="icon-sm"
                  class="size-7"
                  aria-label="Close new habit dialog"
                  (click)="showNewHabitModal.set(false)"
                >
                  <lucide-icon [img]="X" class="size-4" />
                </button>
              </div>
              <div class="grid grid-cols-1 gap-3 sm:grid-cols-12">
                <div class="sm:col-span-4">
                  <label class="text-foreground mb-1 block text-xs font-medium">Habit Title</label>
                  <input
                    type="text"
                    [value]="newHabitTitle()"
                    (input)="newHabitTitle.set($any($event.target).value)"
                    (keydown.enter)="handleAddHabit()"
                    placeholder="e.g. Write 500 Words Technical Notes"
                    class="border-border bg-background placeholder:text-muted-foreground focus-visible:ring-ring h-8 w-full rounded-md border px-2.5 text-xs focus-visible:ring-2 focus-visible:outline-none"
                  />
                </div>
                <div class="sm:col-span-4">
                  <label class="text-foreground mb-1 block text-xs font-medium">Description</label>
                  <input
                    type="text"
                    [value]="newHabitDescription()"
                    (input)="newHabitDescription.set($any($event.target).value)"
                    (keydown.enter)="handleAddHabit()"
                    placeholder="e.g. Daily engineering documentation"
                    class="border-border bg-background placeholder:text-muted-foreground focus-visible:ring-ring h-8 w-full rounded-md border px-2.5 text-xs focus-visible:ring-2 focus-visible:outline-none"
                  />
                </div>
                <div class="sm:col-span-2">
                  <label class="text-foreground mb-1 block text-xs font-medium">Category</label>
                  <select
                    [value]="newHabitCategory()"
                    (change)="newHabitCategory.set($any($event.target).value)"
                    class="border-border bg-background text-foreground focus-visible:ring-ring h-8 w-full rounded-md border px-2 text-xs focus-visible:ring-2 focus-visible:outline-none"
                  >
                    <option value="Engineering">Engineering</option>
                    <option value="Health">Health</option>
                    <option value="Learning">Learning</option>
                    <option value="Mindset">Mindset</option>
                  </select>
                </div>
                <div class="sm:col-span-2">
                  <label class="text-foreground mb-1 block text-xs font-medium">Icon</label>
                  <select
                    [value]="newHabitIcon()"
                    (change)="newHabitIcon.set($any($event.target).value)"
                    class="border-border bg-background text-foreground focus-visible:ring-ring h-8 w-full rounded-md border px-2 text-xs focus-visible:ring-2 focus-visible:outline-none"
                  >
                    <option value="code">Code</option>
                    <option value="terminal">Terminal</option>
                    <option value="steps">Steps</option>
                    <option value="book">Book</option>
                    <option value="water">Water</option>
                    <option value="target">Target</option>
                  </select>
                </div>
              </div>
              <div class="flex items-center justify-end gap-2 pt-1">
                <button
                  ui-button
                  aria-label="Close new habit dialog"
                  variant="ghost"
                  size="sm"
                  class="text-xs"
                  (click)="showNewHabitModal.set(false)"
                >
                  Cancel
                </button>
                <button ui-button size="sm" class="text-xs" [disabled]="!newHabitTitle().trim()" (click)="handleAddHabit()">
                  Add Routine
                </button>
              </div>
            </div>
          }
    
          <!-- 4 Metric Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <div ui-card class="border-border/80 bg-card text-card-foreground shadow-xs">
              <div ui-card-header class="p-4 pb-2">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium">Current Streak</span>
                  <div
                    class="border-warning/25 bg-warning/10 text-warning flex size-7 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="Flame" class="fill-warning text-warning size-4" />
                  </div>
                </div>
              </div>
              <div ui-card-content class="space-y-1 p-4 pt-1">
                <div class="flex items-baseline gap-2">
                  <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums"
                    >{{ initialStreak() }} Days</span
                  >
                  <span ui-badge variant="warning" class="text-xs font-medium">Active</span>
                </div>
                <p class="text-muted-foreground text-xs">Active since Jul 29 · On track today</p>
              </div>
            </div>
            <div ui-card class="border-border/80 bg-card text-card-foreground shadow-xs">
              <div ui-card-header class="p-4 pb-2">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium">Longest Streak</span>
                  <div
                    class="border-chart-2/25 bg-chart-2/10 text-chart-2 flex size-7 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="Trophy" class="size-4" />
                  </div>
                </div>
              </div>
              <div ui-card-content class="space-y-1 p-4 pt-1">
                <div class="flex items-baseline gap-2">
                  <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">{{
                    longestStreak()
                  }}</span>
                  <span ui-badge variant="secondary" class="text-xs font-medium">Record</span>
                </div>
                <p class="text-muted-foreground text-xs">48 continuous days set May 14, 2026</p>
              </div>
            </div>
            <div ui-card class="border-border/80 bg-card text-card-foreground shadow-xs">
              <div ui-card-header class="p-4 pb-2">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium">Overall Consistency</span>
                  <div
                    class="border-success/25 bg-success/10 text-success flex size-7 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="TrendingUp" class="size-4" />
                  </div>
                </div>
              </div>
              <div ui-card-content class="space-y-1 p-4 pt-1">
                <div class="flex items-baseline gap-2">
                  <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums"
                    >{{ consistencyRate() }}%</span
                  >
                  <span ui-badge variant="success" class="text-xs font-medium">+3.1% MoM</span>
                </div>
                <p class="text-muted-foreground text-xs">
                  {{ heatmapData().totalCompletions }} targets completed in 52 weeks
                </p>
              </div>
            </div>
            <div ui-card class="border-border/80 bg-card text-card-foreground shadow-xs">
              <div ui-card-header class="p-4 pb-2">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium">Total Habits Active</span>
                  <div
                    class="border-info/25 bg-info/10 text-info flex size-7 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="Target" class="size-4" />
                  </div>
                </div>
              </div>
              <div ui-card-content class="space-y-1 p-4 pt-1">
                <div class="flex items-baseline gap-2">
                  <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums"
                    >{{ totalHabitsCount() }} Daily Habits</span
                  >
                  <span
                    ui-badge
                    [variant]="todayCompletedCount() === totalHabitsCount() ? 'success' : 'secondary'"
                    class="text-xs font-medium"
                    >{{ todayCompletedCount() }}/{{ totalHabitsCount() }} Done</span
                  >
                </div>
                <p class="text-muted-foreground text-xs">{{ remainingLabel() }}</p>
              </div>
            </div>
          </div>
    
          <!-- Heatmap -->
          <div ui-card class="border-border/80 bg-card text-card-foreground shadow-xs">
            <div ui-card-header class="p-4 pb-3 sm:p-5 sm:pb-3">
              <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                <div>
                  <h3 ui-card-title class="text-sm font-semibold tracking-tight">52-Week Habit Consistency Matrix</h3>
                  <p ui-card-description class="text-xs">
                    Annual contribution heatmap across all daily registered routines.
                  </p>
                </div>
                <div class="text-muted-foreground flex items-center gap-2 text-xs">
                  @if (hoveredCell(); as hovered) {
                    <div
                      class="text-foreground bg-muted/60 border-border flex items-center gap-1.5 rounded-md border px-2 py-1 font-medium"
                    >
                      <span class="bg-success size-2 rounded-full"></span>
                      <span>{{ hovered.formattedDate }} · </span>
                      <span class="font-mono font-semibold">{{ hovered.count }}/5 habits</span>
                    </div>
                  } @else {
                    <div class="flex items-center gap-1.5">
                      <lucide-icon [img]="CheckCircle2" class="text-success size-3.5" />
                      <span class="text-foreground font-mono font-medium tabular-nums">{{
                        heatmapData().totalCompletions
                      }}</span>
                      <span>completions logged</span>
                    </div>
                  }
                </div>
              </div>
            </div>
            <div ui-card-content class="space-y-4 p-4 pt-1 sm:p-5 sm:pt-1">
              <div class="overflow-x-auto pb-2">
                <div class="max-w-[740px] min-w-full space-y-1.5">
                  <div class="text-muted-foreground flex pl-8 text-xs font-medium select-none">
                    @for (label of heatmapData().monthLabels; track label.name + '-' + label.weekIndex) {
                      <div class="truncate" style="width: 59.8px">{{ label.name }}</div>
                    }
                  </div>
                  <div class="flex gap-2">
                    <div
                      class="text-muted-foreground flex w-6 shrink-0 flex-col justify-between py-[1px] text-xs font-medium select-none"
                    >
                      <span>Mon</span><span class="opacity-0">Tue</span><span>Wed</span><span class="opacity-0">Thu</span
                      ><span>Fri</span><span class="opacity-0">Sat</span><span>Sun</span>
                    </div>
                    <div class="flex flex-1 gap-[3px]">
                      @for (week of heatmapData().weeks; track $index) {
                        <div class="flex flex-1 flex-col gap-[3px]">
                          @for (cell of week; track cell.date) {
                            <button
                              type="button"
                              [attr.aria-label]="cell.formattedDate + ': ' + cell.count + ' habits completed'"
                              [attr.title]="cell.formattedDate + ': ' + cell.count + ' habits completed'"
                              [class]="heatmapCellClass(cell.level)"
                              (mouseenter)="hoveredCell.set(cell)"
                              (mouseleave)="hoveredCell.set(null)"
                              (focus)="hoveredCell.set(cell)"
                              (blur)="hoveredCell.set(null)"
                            ></button>
                          }
                        </div>
                      }
                    </div>
                  </div>
                </div>
              </div>
              <div
                class="border-border/50 text-muted-foreground flex flex-col gap-2 border-t pt-3 text-xs sm:flex-row sm:items-center sm:justify-between"
              >
                <div class="flex items-center gap-1.5">
                  <span class="text-foreground font-mono font-semibold tabular-nums">94.2%</span>
                  <span>consistency baseline · 52 continuous weeks tracked</span>
                </div>
                <div class="flex items-center gap-2 self-end sm:self-auto">
                  <span>Less</span>
                  <div class="flex gap-1">
                    <span
                      class="bg-muted/60 dark:bg-muted/30 border-border/40 size-3 rounded-[2px] border"
                      title="0 habits"
                    ></span>
                    <span class="border-success/20 bg-success/25 size-3 rounded-[2px] border" title="1-2 habits"></span>
                    <span class="border-success/30 bg-success/50 size-3 rounded-[2px] border" title="3 habits"></span>
                    <span class="border-success/40 bg-success/75 size-3 rounded-[2px] border" title="4 habits"></span>
                    <span class="border-success/60 bg-success size-3 rounded-[2px] border" title="5 habits"></span>
                  </div>
                  <span>More</span>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Checklist -->
          <div ui-card class="border-border/80 bg-card text-card-foreground shadow-xs">
            <div ui-card-header class="p-4 pb-3 sm:p-5 sm:pb-3">
              <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                <div class="space-y-0.5">
                  <h3 ui-card-title class="text-sm font-semibold tracking-tight">Today's Habit Checklist</h3>
                  <p ui-card-description class="text-xs">
                    Interactive 7-day strip. Mark today or previous days to maintain your active flame streaks.
                  </p>
                </div>
                <div class="flex flex-wrap items-center gap-1.5">
                  @for (cat of categories(); track cat) {
                    <button
                      ui-button
                      [variant]="activeCategory() === cat ? 'default' : 'ghost'"
                      size="sm"
                      class="h-7 px-2.5 text-xs font-medium"
                      (click)="activeCategory.set(cat)"
                    >
                      {{ cat }}
                    </button>
                  }
                </div>
              </div>
            </div>
            <div ui-card-content class="p-0">
              <div class="divide-border/60 divide-y">
                @for (habit of filteredHabits(); track habit.id) {
                  <div
                    class="hover:bg-muted/20 flex flex-col gap-4 p-4 transition-colors sm:p-5 lg:flex-row lg:items-center lg:justify-between"
                  >
                    <div class="flex items-start gap-3 lg:w-1/3">
                      <div
                        class="bg-muted text-muted-foreground border-border/60 flex size-9 shrink-0 items-center justify-center rounded-lg border shadow-xs"
                      >
                        <lucide-icon [img]="habitIcon(habit.icon)" class="size-4.5" />
                      </div>
                      <div class="space-y-1">
                        <div class="flex flex-wrap items-center gap-2">
                          <p class="text-foreground text-sm leading-none font-semibold">{{ habit.title }}</p>
                          <span ui-badge [class]="categoryBadgeClass(habit.category) + ' border text-xs font-medium'">{{
                            habit.category
                          }}</span>
                        </div>
                        <p class="text-muted-foreground text-xs">{{ habit.description }}</p>
                      </div>
                    </div>
                    <div class="flex items-center gap-2 lg:w-28">
                      <div
                        class="border-warning/30 bg-warning/10 text-warning flex size-6.5 shrink-0 items-center justify-center rounded-md border"
                      >
                        <lucide-icon [img]="Flame" class="fill-warning text-warning size-3.5" />
                      </div>
                      <div>
                        <p class="text-foreground font-mono text-xs font-bold tabular-nums">
                          {{ habit.streakDays }}d streak
                        </p>
                        <p class="text-muted-foreground text-xs">unbroken</p>
                      </div>
                    </div>
                    <div class="space-y-1 lg:w-56">
                      <div class="text-muted-foreground flex items-center justify-between px-0.5 text-xs font-medium">
                        @for (day of weekDays; track day.short; let dIdx = $index) {
                          <span [class]="dayLabelClass(dIdx)">{{ day.short[0] }}</span>
                        }
                      </div>
                      <div class="flex items-center justify-between gap-1">
                        @for (day of weekDays; track day.short; let dIdx = $index) {
                          <button
                            type="button"
                            [attr.aria-label]="
                              habit.title + ' on ' + day.short + ': ' + (habit.weekHistory[dIdx] ? 'Completed' : 'Incomplete')
                            "
                            [class]="dayCellClass(habit, dIdx)"
                            (click)="toggleDay(habit.id, dIdx)"
                          >
                            @if (habit.weekHistory[dIdx]) {
                              <lucide-icon [img]="Check" class="size-3.5 stroke-[2.5]" />
                            } @else {
                              <span class="text-xs">{{ day.num }}</span>
                            }
                          </button>
                        }
                      </div>
                    </div>
                    <div class="space-y-1.5 lg:w-36">
                      <div class="flex items-center justify-between text-xs">
                        <span class="text-muted-foreground">Rate</span>
                        <span class="text-foreground font-mono font-semibold tabular-nums"
                          >{{ habit.completionRate }}%</span
                        >
                      </div>
                      <ui-progress [value]="habit.completionRate" class="h-1.5" />
                    </div>
                    <div class="flex items-center justify-end lg:w-32">
                      @if (habit.weekHistory[6]) {
                        <button
                          ui-button
                          variant="outline"
                          size="sm"
                          class="border-success/30 bg-success/10 text-success hover:bg-success/20 text-success h-8 w-full gap-1.5 text-xs font-medium shadow-xs sm:w-auto"
                          (click)="toggleToday(habit.id)"
                        >
                          <lucide-icon [img]="CheckCircle2" class="text-success size-3.5" />
                          <span>Done Today</span>
                        </button>
                      } @else {
                        <button
                          ui-button
                          variant="default"
                          size="sm"
                          class="h-8 w-full gap-1.5 text-xs font-medium shadow-xs sm:w-auto"
                          (click)="toggleToday(habit.id)"
                        >
                          <lucide-icon [img]="Check" class="size-3.5" />
                          <span>Check Today</span>
                        </button>
                      }
                    </div>
                  </div>
                }
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiHabitStreakTrackerComponent {
      protected readonly BookOpen = BookOpen
      protected readonly Calendar = Calendar
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Flame = Flame
      protected readonly Plus = Plus
      protected readonly Target = Target
      protected readonly TrendingUp = TrendingUp
      protected readonly Trophy = Trophy
      protected readonly X = X
    
      protected readonly weekDays = weekDayColumns
    
      @Input('initialStreak') set initialStreakInput(v: number) {
        if (v !== undefined && v !== null) this.initialStreak.set(v)
      }
      @Input('longestStreak') set longestStreakInput(v: string) {
        if (v !== undefined) this.longestStreak.set(v)
      }
      @Input('consistencyRate') set consistencyRateInput(v: number) {
        if (v !== undefined && v !== null) this.consistencyRate.set(v)
      }
      @Input('activeHabitsCount') set activeHabitsCountInput(v: number) {
        if (v !== undefined && v !== null) this.activeHabitsCount.set(v)
      }
      @Input('monthTitle') set monthTitleInput(v: string) {
        if (v !== undefined) this.monthTitle.set(v)
      }
      @Input() set habits(v: HabitItem[] | undefined) {
        if (v) this.habitsList.set(v.map((h) => ({ ...h, weekHistory: [...h.weekHistory] })))
      }
      @Input('class') className?: string
    
      readonly initialStreak = signal(24)
      readonly longestStreak = signal('48 Days in Q2')
      readonly consistencyRate = signal(94.2)
      readonly activeHabitsCount = signal(5)
      readonly monthTitle = signal('August 2026')
      readonly habitsList = signal<HabitItem[]>(defaultHabits.map((h) => ({ ...h, weekHistory: [...h.weekHistory] })))
    
      readonly activeCategory = signal('All')
      readonly showNewHabitModal = signal(false)
      readonly hoveredCell = signal<DayHeatmapCell | null>(null)
      readonly newHabitTitle = signal('')
      readonly newHabitDescription = signal('')
      readonly newHabitCategory = signal<HabitCategory>('Engineering')
      readonly newHabitIcon = signal('code')
    
      readonly heatmapData = computed(() => generate52WeekHeatmap(this.initialStreak()))
      readonly categories = computed(() => {
        const cats = ['All']
        for (const h of this.habitsList()) if (!cats.includes(h.category)) cats.push(h.category)
        return cats
      })
      readonly filteredHabits = computed(() => {
        if (this.activeCategory() === 'All') return this.habitsList()
        return this.habitsList().filter((h) => h.category === this.activeCategory())
      })
      readonly todayCompletedCount = computed(() => this.habitsList().filter((h) => h.weekHistory[6]).length)
      readonly totalHabitsCount = computed(() => this.habitsList().length)
      readonly remainingLabel = computed(() => {
        const done = this.todayCompletedCount()
        const total = this.totalHabitsCount()
        return done === total ? 'All daily targets logged!' : `${total - done} remaining for today`
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      habitIcon(icon: string): LucideIconData {
        switch (icon) {
          case 'code':
            return CodeXml
          case 'terminal':
            return Terminal
          case 'steps':
            return Footprints
          case 'book':
            return BookOpen
          case 'water':
            return Droplets
          default:
            return Target
        }
      }
    
      categoryBadgeClass(category: HabitCategory): string {
        switch (category) {
          case 'Engineering':
            return 'bg-info/10 text-info border-info/25'
          case 'Health':
            return 'bg-success/10 text-success border-success/25'
          case 'Learning':
            return 'bg-chart-1/10 text-chart-1 border-chart-1/25'
          case 'Mindset':
            return 'bg-warning/10 text-warning border-warning/25'
          default:
            return 'bg-muted text-muted-foreground border-border'
        }
      }
    
      heatmapCellClass(level: number): string {
        return cn(
          'size-3 cursor-pointer rounded-[2px] transition-colors duration-150 sm:size-3.5',
          levelClassMap[level] ?? levelClassMap[0],
        )
      }
    
      dayLabelClass(dIdx: number): string {
        return cn('w-6 text-center', dIdx === 6 && 'text-foreground font-semibold')
      }
    
      dayCellClass(habit: HabitItem, dIdx: number): string {
        return cn(
          'focus-visible:ring-ring flex size-6 cursor-pointer items-center justify-center rounded-md text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none',
          habit.weekHistory[dIdx]
            ? 'bg-success hover:bg-success text-white shadow-xs'
            : 'border-border/80 bg-muted/40 text-muted-foreground/30 hover:bg-muted/80 hover:text-muted-foreground border',
          dIdx === 6 && !habit.weekHistory[dIdx] && 'border-warning/50 bg-warning/5',
        )
      }
    
      toggleModal(): void {
        this.showNewHabitModal.set(!this.showNewHabitModal())
      }
    
      toggleDay(habitId: string, dayIndex: number): void {
        this.habitsList.update((prev) =>
          prev.map((habit) => {
            if (habit.id !== habitId) return habit
            const nextWeekHistory = [...habit.weekHistory]
            nextWeekHistory[dayIndex] = !nextWeekHistory[dayIndex]
            const completedInWeek = nextWeekHistory.filter(Boolean).length
            const completionRate = Math.min(100, Math.round((completedInWeek / 7) * 100))
            let streakDays = habit.streakDays
            if (dayIndex === 6) streakDays = nextWeekHistory[6] ? streakDays + 1 : Math.max(0, streakDays - 1)
            return { ...habit, weekHistory: nextWeekHistory, completionRate, streakDays }
          }),
        )
      }
    
      toggleToday(habitId: string): void {
        this.toggleDay(habitId, 6)
      }
    
      handleAddHabit(): void {
        if (!this.newHabitTitle().trim()) return
        const newHabit: HabitItem = {
          id: `habit-${Date.now()}`,
          title: this.newHabitTitle().trim(),
          description: this.newHabitDescription().trim() || 'Daily tracked routine',
          category: this.newHabitCategory(),
          icon: this.newHabitIcon(),
          streakDays: 1,
          completionRate: 100,
          weekHistory: [false, false, false, false, false, false, true],
        }
        this.habitsList.update((prev) => [...prev, newHabit])
        this.newHabitTitle.set('')
        this.newHabitDescription.set('')
        this.showNewHabitModal.set(false)
      }
    }
    
  • src/app/components/blocks/habit-streak-tracker/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/habit-streak-tracker.json