Time Tracker Timesheet
blockproductivityToggl/Harvest-style live time tracker with active running stopwatch, project rate selection, billable status toggle, 4 weekly summary metric cards, and daily grouped timesheet activity ledger with resume actions.
Installation
$pnpm dlx uipkge-ng@latest add time-tracker-timesheet$npx uipkge-ng@latest add time-tracker-timesheet$yarn dlx uipkge-ng@latest add time-tracker-timesheet$bunx uipkge-ng@latest add time-tracker-timesheetManifest:
https://uipkge.dev/r/angular/time-tracker-timesheet.jsonInstalls to:src/app/components/blocks/time-tracker-timesheet/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.
ProjectOptioninterface ProjectOption {
id: string
name: string
client: string
rate: number
rateLabel: string
color: string
badgeClass: string
dotClass: string
}TimeEntryinterface TimeEntry {
id: string
task: string
projectId: string
projectName: string
clientName: string
projectBadgeClass: string
date: string
dateLabel: string
startTime: string
endTime: string
durationSeconds: number
isBillable: boolean
rate: number
amount: number
}Files installed (2)
src/app/components/blocks/time-tracker-timesheet/time-tracker-timesheet.component.ts27.7 kB
// Toggl/Harvest-style live time tracker: running stopwatch hero bar with project rate // select, billable switch, 4 weekly summary cards, and a daily grouped timesheet ledger // with resume/delete actions. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core' import { Briefcase, Calendar, CircleDollarSign, Clock, DollarSign, Download, LucideAngularModule, Play, Plus, Square, Trash2, TrendingUp, } 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 { UiInputComponent } from '@/ui/input/input.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiSwitchComponent } from '@/ui/switch/switch.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' export interface ProjectOption { id: string name: string client: string rate: number rateLabel: string color: string badgeClass: string dotClass: string } export interface TimeEntry { id: string task: string projectId: string projectName: string clientName: string projectBadgeClass: string date: string dateLabel: string startTime: string endTime: string durationSeconds: number isBillable: boolean rate: number amount: number } const projects: ProjectOption[] = [ { id: 'acme', name: 'Acme Corp', client: 'Acme Enterprises', rate: 150, rateLabel: '$150/hr', color: 'blue', badgeClass: 'bg-info/10 text-info border-info/20', dotClass: 'bg-info', }, { id: 'stripe', name: 'Stripe Retainer', client: 'Stripe, Inc.', rate: 175, rateLabel: '$175/hr', color: 'chart-2', badgeClass: 'bg-chart-2/10 text-chart-2 border-chart-2/20', dotClass: 'bg-chart-2', }, { id: 'linear', name: 'Linear Sync', client: 'Linear Orbit', rate: 160, rateLabel: '$160/hr', color: 'chart-3', badgeClass: 'bg-chart-3/10 text-chart-3 border-chart-3/20', dotClass: 'bg-chart-3', }, { id: 'uipkge', name: 'UIPKGE Core', client: 'Internal OSS', rate: 0, rateLabel: 'Internal · $0/hr', color: 'emerald', badgeClass: 'bg-success/10 text-success border-success/20', dotClass: 'bg-success', }, ] const initialEntries: TimeEntry[] = [ { id: 'entry-1', task: 'Refactoring OKLCH Color Engine & AST Parser', projectId: 'acme', projectName: 'Acme Corp', clientName: 'Acme Enterprises', projectBadgeClass: 'bg-info/10 text-info border-info/20', date: '2026-08-21', dateLabel: 'Today · Friday, Aug 21', startTime: '09:00', endTime: '11:30', durationSeconds: 9000, isBillable: true, rate: 150, amount: 375, }, { id: 'entry-2', task: 'Webhook idempotency retry queue architecture', projectId: 'stripe', projectName: 'Stripe Retainer', clientName: 'Stripe, Inc.', projectBadgeClass: 'bg-chart-2/10 text-chart-2 border-chart-2/20', date: '2026-08-21', dateLabel: 'Today · Friday, Aug 21', startTime: '13:00', endTime: '15:45', durationSeconds: 9900, isBillable: true, rate: 175, amount: 481.25, }, { id: 'entry-3', task: 'Weekly sprint retrospective & design token review', projectId: 'uipkge', projectName: 'UIPKGE Core', clientName: 'Internal OSS', projectBadgeClass: 'bg-success/10 text-success border-success/20', date: '2026-08-21', dateLabel: 'Today · Friday, Aug 21', startTime: '16:00', endTime: '17:30', durationSeconds: 5400, isBillable: false, rate: 0, amount: 0, }, { id: 'entry-4', task: 'Bidirectional GraphQL sync engine & schema migrations', projectId: 'linear', projectName: 'Linear Sync', clientName: 'Linear Orbit', projectBadgeClass: 'bg-chart-3/10 text-chart-3 border-chart-3/20', date: '2026-08-20', dateLabel: 'Yesterday · Thursday, Aug 20', startTime: '09:30', endTime: '14:00', durationSeconds: 16200, isBillable: true, rate: 160, amount: 720, }, { id: 'entry-5', task: 'Design system Figma token export automation', projectId: 'acme', projectName: 'Acme Corp', clientName: 'Acme Enterprises', projectBadgeClass: 'bg-info/10 text-info border-info/20', date: '2026-08-20', dateLabel: 'Yesterday · Thursday, Aug 20', startTime: '14:45', endTime: '17:30', durationSeconds: 9900, isBillable: true, rate: 150, amount: 412.5, }, ] export function formatDuration(totalSeconds: number): string { const hours = Math.floor(totalSeconds / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) const seconds = totalSeconds % 60 return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` } export function formatCurrency(val: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, }).format(val) } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-time-tracker-timesheet, [ui-time-tracker-timesheet]', 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, UiInputComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiSeparatorComponent, UiSwitchComponent, UiTableComponent, UiTableBodyComponent, UiTableCellComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, ], template: ` <div data-slot="time-tracker-timesheet" [class]="rootClass"> <!-- Header Section --> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-1"> <div class="flex items-center gap-2.5"> <h2 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">{{ title() }}</h2> <span ui-badge variant="outline" class="border-border hidden text-xs font-medium sm:inline-flex"> Toggl Engine </span> </div> <p class="text-muted-foreground text-sm">{{ subtitle() }}</p> </div> <div class="flex items-center gap-2.5"> <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="h-9 gap-1.5 shadow-xs"> <lucide-icon [img]="Download" class="size-4" /> Export CSV </button> <button ui-button size="sm" class="h-9 gap-1.5 shadow-xs"> <lucide-icon [img]="Plus" class="size-4" /> Manual Entry </button> </div> </div> <!-- Live Running Stopwatch Hero Bar --> <div ui-card class="border-border bg-card overflow-hidden shadow-xs"> <div ui-card-content class="p-4 sm:p-5"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <!-- Task description & Project Tag Select --> <div class="flex flex-1 flex-col gap-3 sm:flex-row sm:items-center"> <div class="relative flex-1"> <ui-input [value]="activeTask()" (valueChange)="activeTask.set($event)" type="text" placeholder="What are you working on?" class="h-10 text-sm font-medium focus-visible:ring-2" /> </div> <div class="w-full shrink-0 sm:w-64"> <ui-select [value]="selectedProjectId()" (valueChange)="selectedProjectId.set($event)"> <button ui-select-trigger class="h-10 text-xs sm:text-sm"> <ui-select-value placeholder="Select client/project" /> </button> <ui-select-content> @for (proj of projectOptions; track proj.id) { <ui-select-item [value]="proj.id"> <div class="flex items-center gap-2"> <span [class]="cn('size-2 shrink-0 rounded-full', proj.dotClass)"></span> <span class="text-foreground font-medium">{{ proj.name }}</span> <span class="text-muted-foreground text-xs">· {{ proj.rateLabel }}</span> </div> </ui-select-item> } </ui-select-content> </ui-select> </div> </div> <!-- Controls: Billable Switch, Live Stopwatch, Primary Action Button --> <div class="border-border/60 flex flex-wrap items-center justify-between gap-3 border-t pt-3 sm:border-t-0 sm:pt-0 lg:justify-end" > <div class="flex items-center gap-2"> <button ui-switch id="billable-toggle-angular" size="sm" [checked]="isBillable()" (checkedChange)="isBillable.set($event)" aria-label="Billable toggle" ></button> <label for="billable-toggle-angular" class="flex cursor-pointer items-center gap-1.5 text-xs font-medium select-none" > <lucide-icon [img]="DollarSign" [class]="cn('size-3.5', isBillable() ? 'text-success font-semibold' : 'text-muted-foreground')" /> <span [class]="isBillable() ? 'text-foreground font-medium' : 'text-muted-foreground'">Billable</span> </label> </div> <ui-separator orientation="vertical" class="hidden h-7 sm:block" /> <!-- Live Running Stopwatch Timer --> <div class="flex items-center gap-2.5"> <div class="border-border/80 bg-muted/50 dark:bg-muted/30 flex items-center gap-2.5 rounded-lg border px-3.5 py-1.5 shadow-xs" > <div class="relative flex size-2.5 items-center justify-center"> @if (isRunning()) { <span class="bg-success absolute inline-flex h-full w-full rounded-full opacity-75"></span> } <span [class]=" cn( 'relative inline-flex size-2.5 rounded-full', isRunning() ? 'bg-success' : 'bg-muted-foreground/40' ) " ></span> </div> <span class="text-foreground font-mono text-xl font-bold tracking-tight tabular-nums sm:text-2xl"> {{ formattedTimer() }} </span> </div> <button ui-button [variant]="isRunning() ? 'destructive' : 'default'" size="default" class="h-10 gap-2 px-4 font-medium shadow-xs" (click)="toggleTimer()" > @if (isRunning()) { <lucide-icon [img]="Square" class="size-4 fill-current" /> } @else { <lucide-icon [img]="Play" class="size-4 fill-current" /> } <span>{{ isRunning() ? 'Stop' : 'Start' }}</span> </button> </div> </div> </div> </div> </div> <!-- 4 Timesheet 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-content class="space-y-2 p-5"> <div class="flex items-center justify-between"> <p class="text-muted-foreground text-sm font-medium">Total Hours This Week</p> <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg"> <lucide-icon [img]="Clock" class="size-4" /> </div> </div> <div class="flex items-baseline gap-2"> <p class="text-foreground text-2xl font-bold tracking-tight tabular-nums">38.5 hrs</p> <span class="text-muted-foreground text-xs tabular-nums">/ 40.0 target</span> </div> <div class="text-muted-foreground flex items-center gap-1.5 text-xs"> <span class="text-success inline-flex items-center font-medium"> <lucide-icon [img]="TrendingUp" class="mr-0.5 size-3.5" /> 96% </span> <span>of weekly target logged</span> </div> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-content class="space-y-2 p-5"> <div class="flex items-center justify-between"> <p class="text-muted-foreground text-sm font-medium">Billable Hours</p> <div class="bg-success/10 text-success flex size-8 items-center justify-center rounded-lg"> <lucide-icon [img]="CircleDollarSign" class="size-4" /> </div> </div> <p class="text-foreground text-2xl font-bold tracking-tight tabular-nums">32.0 hrs</p> <div class="text-muted-foreground flex items-center justify-between text-xs"> <span class="text-success font-medium">83% billable ratio</span> <span>6.5 hrs internal</span> </div> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-content class="space-y-2 p-5"> <div class="flex items-center justify-between"> <p class="text-muted-foreground text-sm font-medium">Total Earnings</p> <div class="bg-chart-2/10 text-chart-2 flex size-8 items-center justify-center rounded-lg"> <lucide-icon [img]="DollarSign" class="size-4" /> </div> </div> <p class="text-foreground text-2xl font-bold tracking-tight tabular-nums">$4,800.00</p> <p class="text-muted-foreground text-xs">Avg $150.00/hr effective rate</p> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-content class="space-y-2 p-5"> <div class="flex items-center justify-between"> <p class="text-muted-foreground text-sm font-medium">Active Clients</p> <div class="bg-info/10 text-info flex size-8 items-center justify-center rounded-lg"> <lucide-icon [img]="Briefcase" class="size-4" /> </div> </div> <p class="text-foreground text-2xl font-bold tracking-tight tabular-nums">3 active clients</p> <p class="text-muted-foreground truncate text-xs">Acme Corp · Stripe · Linear</p> </div> </div> </div> <!-- Weekly Timesheet & Daily Activity Table --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="border-border/60 border-b pb-3"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div> <h3 ui-card-title class="text-base font-semibold">Weekly Timesheet & Daily Activity</h3> <p ui-card-description class="text-xs"> Detailed time entries for the current billing cycle (Aug 17 – Aug 23, 2026). </p> </div> <div class="flex items-center gap-2"> <span ui-badge variant="outline" class="border-border font-mono text-xs font-medium"> Week 34 · 38h 30m · $4,800.00 </span> </div> </div> </div> <div ui-card-content class="p-0"> <div class="overflow-x-auto"> <table ui-table> <thead ui-table-header class="bg-muted/40"> <tr ui-table-row> <th ui-table-head class="min-w-[160px]">Project & Client</th> <th ui-table-head class="min-w-[280px]">Task Description</th> <th ui-table-head class="min-w-[130px]">Billable</th> <th ui-table-head class="min-w-[130px]">Time Range</th> <th ui-table-head class="min-w-[110px] text-right">Duration</th> <th ui-table-head class="min-w-[110px] text-right">Amount</th> <th ui-table-head class="w-24 text-right"> <span class="sr-only">Actions</span> </th> </tr> </thead> <tbody ui-table-body> @for (group of groupedEntries(); track group.date) { <tr ui-table-row class="border-border bg-muted/20 hover:bg-muted/30 border-y font-medium"> <td ui-table-cell colspan="4" class="text-foreground py-2.5 text-xs font-semibold"> <div class="flex items-center gap-2"> <lucide-icon [img]="Calendar" class="text-muted-foreground size-3.5" /> <span>{{ group.label }}</span> </div> </td> <td ui-table-cell class="text-foreground py-2.5 text-right font-mono text-xs font-semibold tabular-nums" > {{ formatDuration(group.totalSeconds) }} </td> <td ui-table-cell class="text-foreground py-2.5 text-right font-mono text-xs font-semibold tabular-nums" > {{ formatCurrency(group.totalAmount) }} </td> <td ui-table-cell class="py-2.5"></td> </tr> @for (entry of group.entries; track entry.id) { <tr ui-table-row class="hover:bg-muted/50 transition-colors"> <td ui-table-cell> <div class="flex flex-col gap-1"> <span ui-badge variant="outline" [class]="cn('w-fit text-xs font-normal', entry.projectBadgeClass)" > {{ entry.projectName }} </span> <span class="text-muted-foreground text-xs">{{ entry.clientName }}</span> </div> </td> <td ui-table-cell> <p class="text-foreground text-sm font-medium">{{ entry.task }}</p> </td> <td ui-table-cell> <div class="flex items-center gap-1.5"> @if (entry.isBillable) { <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success text-xs font-medium" > <lucide-icon [img]="DollarSign" class="mr-0.5 size-3" /> Billable ({{ '$' + entry.rate }}/h) </span> } @else { <span ui-badge variant="outline" class="border-border bg-muted/60 text-muted-foreground text-xs font-normal" > Non-billable </span> } </div> </td> <td ui-table-cell class="text-muted-foreground font-mono text-xs whitespace-nowrap tabular-nums"> {{ entry.startTime }} – {{ entry.endTime }} </td> <td ui-table-cell class="text-foreground text-right font-mono text-sm font-medium whitespace-nowrap tabular-nums" > {{ formatDuration(entry.durationSeconds) }} </td> <td ui-table-cell class="text-right font-mono text-sm font-semibold whitespace-nowrap tabular-nums" > <span [class]="entry.isBillable ? 'text-foreground' : 'text-muted-foreground'"> {{ formatCurrency(entry.amount) }} </span> </td> <td ui-table-cell class="text-right"> <div class="flex items-center justify-end gap-1"> <button ui-button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-foreground size-8" title="Resume Timer" [attr.aria-label]="'Resume timer for ' + entry.task" (click)="resumeEntry(entry)" > <lucide-icon [img]="Play" class="size-3.5 fill-current" /> </button> <button ui-button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-destructive size-8" title="Delete Entry" aria-label="Delete entry" (click)="deleteEntry(entry.id)" > <lucide-icon [img]="Trash2" class="size-3.5" /> </button> </div> </td> </tr> } } @if (entries().length === 0) { <tr ui-table-row> <td ui-table-cell [attr.colspan]="7" class="h-32 text-center"> <div class="text-muted-foreground flex flex-col items-center justify-center gap-1.5"> <lucide-icon [img]="Clock" class="text-muted-foreground/60 size-6" /> <p class="text-foreground text-sm font-medium">No time entries recorded for this week</p> <p class="text-xs">Start the live timer above to log your hours.</p> </div> </td> </tr> } </tbody> </table> </div> <!-- Timesheet Table Footer Strip --> <div class="border-border bg-card text-muted-foreground flex flex-col items-center justify-between gap-3 border-t px-4 py-3 text-xs sm:flex-row" > <p class="tabular-nums"> Showing <span class="text-foreground font-medium">{{ entries().length }}</span> time entries across <span class="text-foreground font-medium">{{ groupedEntries().length }}</span> days </p> <div class="flex items-center gap-3"> <span class="text-foreground font-mono text-xs font-medium tabular-nums"> Total: {{ formatDuration(totalWeeklySeconds()) }} ({{ formatCurrency(totalWeeklyAmount()) }}) </span> </div> </div> </div> </div> </div> `, }) export class UiTimeTrackerTimesheetComponent { protected readonly Briefcase = Briefcase protected readonly Calendar = Calendar protected readonly CircleDollarSign = CircleDollarSign protected readonly Clock = Clock protected readonly DollarSign = DollarSign protected readonly Download = Download protected readonly Play = Play protected readonly Plus = Plus protected readonly Square = Square protected readonly Trash2 = Trash2 protected readonly TrendingUp = TrendingUp protected readonly projectOptions = projects protected readonly cn = cn readonly title = signal('Time Tracker & Timesheet') readonly subtitle = signal('Track live project hours, calculate client billing rates, and review weekly activity.') @Input('title') set heading(v: string | undefined) { if (v !== undefined) this.title.set(v) } @Input('subtitle') set subheading(v: string | undefined) { if (v !== undefined) this.subtitle.set(v) } @Input() set initialEntries(v: TimeEntry[] | undefined) { if (v !== undefined) this.entries.set([...v]) } @Input() set initialRunning(v: boolean) { this.isRunning.set(!!v) } @Input('class') set classNameInput(v: string | undefined) { this.className.set(v ?? '') } readonly className = signal('') readonly entries = signal<TimeEntry[]>([...initialEntries]) readonly activeTask = signal('Refactoring OKLCH Color Engine & AST Parser') readonly selectedProjectId = signal('acme') readonly isBillable = signal(true) readonly isRunning = signal(true) readonly timerSeconds = signal(6138) // 01:42:18 constructor() { const timer = setInterval(() => { if (this.isRunning()) this.timerSeconds.update((v) => v + 1) }, 1000) // Keep the suite hermetic under fake timers: unref when available (node/jsdom). if (typeof (timer as unknown as { unref?: () => void }).unref === 'function') { ;(timer as unknown as { unref: () => void }).unref() } inject(DestroyRef).onDestroy(() => clearInterval(timer)) } readonly formattedTimer = computed(() => formatDuration(this.timerSeconds())) readonly groupedEntries = computed(() => { const groups: Record< string, { label: string; date: string; entries: TimeEntry[]; totalSeconds: number; totalAmount: number } > = {} for (const entry of this.entries()) { if (!groups[entry.date]) { groups[entry.date] = { label: entry.dateLabel, date: entry.date, entries: [], totalSeconds: 0, totalAmount: 0 } } groups[entry.date].entries.push(entry) groups[entry.date].totalSeconds += entry.durationSeconds groups[entry.date].totalAmount += entry.amount } return Object.values(groups) }) readonly totalWeeklySeconds = computed(() => this.entries().reduce((acc, e) => acc + e.durationSeconds, 0)) readonly totalWeeklyAmount = computed(() => this.entries().reduce((acc, e) => acc + e.amount, 0)) get rootClass(): string { return cn('w-full space-y-6', this.className()) } formatDuration(totalSeconds: number): string { return formatDuration(totalSeconds) } formatCurrency(val: number): string { return formatCurrency(val) } toggleTimer(): void { this.isRunning.update((v) => !v) } resumeEntry(entry: TimeEntry): void { this.activeTask.set(entry.task) this.selectedProjectId.set(entry.projectId) this.isBillable.set(entry.isBillable) this.isRunning.set(true) } deleteEntry(id: string): void { this.entries.update((prev) => prev.filter((e) => e.id !== id)) } }src/app/components/blocks/time-tracker-timesheet/index.ts0.2 kB
export { UiTimeTrackerTimesheetComponent, formatCurrency, formatDuration, type ProjectOption, type TimeEntry, } from './time-tracker-timesheet.component'
Raw manifest:https://uipkge.dev/r/angular/time-tracker-timesheet.json