Habit Streak Tracker
blockproductivityGitHub 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
$pnpm dlx uipkge-ng@latest add habit-streak-tracker$npx uipkge-ng@latest add habit-streak-tracker$yarn dlx uipkge-ng@latest add habit-streak-tracker$bunx uipkge-ng@latest add habit-streak-trackerManifest:
https://uipkge.dev/r/angular/habit-streak-tracker.jsonInstalls to:src/app/components/blocks/habit-streak-tracker/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
HabitIteminterface HabitItem {
id: string
title: string
description: string
category: HabitCategory
icon: 'code' | 'terminal' | 'steps' | 'book' | 'water' | 'target' | string
streakDays: number
completionRate: number
weekHistory: boolean[]
}DayHeatmapCellinterface DayHeatmapCell {
date: string
formattedDate: string
dayName: string
dayIndex: number
weekIndex: number
count: number
level: number
month: string
}MonthLabelinterface MonthLabel {
name: string
weekIndex: number
}npm dependencies
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
export { UiHabitStreakTrackerComponent, generate52WeekHeatmap, type HabitItem, type HabitCategory, type DayHeatmapCell, type MonthLabel, } from './habit-streak-tracker.component'
Raw manifest:https://uipkge.dev/r/angular/habit-streak-tracker.json