Menu

Change language

GitHub repo

Time Tracker Timesheet

blockproductivity

Toggl/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

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

Variants

Loading interactive previews…

Schema

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

ProjectOption
interface ProjectOption {
  id: string
  name: string
  client: string
  rate: number
  rateLabel: string
  color: string
  badgeClass: string
  dotClass: string
}
TimeEntry
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
}

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

Raw manifest:https://uipkge.dev/r/angular/time-tracker-timesheet.json