Initial commit: timeline-elements-ui library

Angular 19 component library for timelines and event tracking:
timeline, Gantt chart, event cards, date markers, connectors,
and custom event templates. Includes signal-based services,
SCSS design tokens with dark mode, and TypeScript type definitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Giuliano Silvestro
2026-02-13 21:58:25 +10:00
commit a07edd485b
78 changed files with 7681 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dist/
node_modules/
.angular/
*.tgz

194
README.md Normal file
View File

@@ -0,0 +1,194 @@
# Timeline Elements UI
Angular 19 components for timeline, activity feed, audit trail, changelog, and Gantt chart displays powered by @sda/base-ui.
## Features
- **Timeline** — Vertical timeline with alternating layouts, grouping, and sorting
- **Horizontal Timeline** — Scrollable horizontal timeline with nav arrows
- **Activity Feed** — Social-style activity stream with avatars and load-more pagination
- **Audit Trail** — Comprehensive audit log with search, filter, and expandable change diffs
- **Changelog** — Version-based changelog with categorized entries and breaking change badges
- **Gantt Chart** — Interactive Gantt chart with zoom levels, progress bars, and today line
- **Signal API** — Built with Angular 19 signals (`input()`, `output()`, `computed()`, `signal()`)
- **Standalone** — All components are standalone with OnPush change detection
- **Themeable** — CSS custom properties with base-ui token fallbacks
- **Dark Mode** — Full dark mode support via `prefers-color-scheme`
- **Accessible** — ARIA labels, keyboard navigation, focus management
## Installation
```bash
npm install @sda/timeline-elements-ui @sda/base-ui
```
## Quick Start
```typescript
import { Component } from '@angular/core';
import { TlTimelineComponent, type TlTimelineItem } from '@sda/timeline-elements-ui';
@Component({
standalone: true,
imports: [TlTimelineComponent],
template: `
<tl-timeline
[items]="events"
layout="left"
[groupByDate]="true"
(itemClick)="onItemClick($event)"
/>
`
})
export class AppComponent {
events: TlTimelineItem[] = [
{
id: '1',
title: 'Tenancy started',
description: 'John Doe moved in',
date: new Date('2024-01-01'),
icon: 'home',
status: 'completed'
}
];
onItemClick(event: any) {
console.log('Clicked:', event.item);
}
}
```
## Components
### `tl-timeline`
Vertical timeline with markers and connectors.
**Inputs:**
- `items` — Timeline items (required)
- `layout` — 'left' | 'right' | 'alternating'
- `markerSize` — 'sm' | 'md' | 'lg'
- `connectorStyle` — 'solid' | 'dashed' | 'dotted'
- `sortDirection` — 'asc' | 'desc'
- `groupByDate` — Group items by date labels
- `animate` — Animate item entry
- `loading` — Show skeleton loaders
- `emptyMessage` — Message when no items
**Outputs:**
- `itemClick` — Emits when item is clicked
- `itemHover` — Emits when item is hovered
### `tl-horizontal-timeline`
Horizontal scrollable timeline.
**Inputs:**
- `items` — Timeline items (required)
- `markerSize`, `connectorStyle`, `sortDirection` — Same as `tl-timeline`
- `scrollable`, `showNavArrows`, `loading`, `emptyMessage`
**Outputs:**
- `itemClick`
### `tl-activity-feed`
Activity stream with avatars and pagination.
**Inputs:**
- `items` — Activity items (required)
- `pageSize` — Items per page
- `showLoadMore`, `showUnreadIndicator`, `groupByDate`, `loading`, `emptyMessage`
**Outputs:**
- `activityClick`, `actorClick`, `loadMore`
### `tl-audit-trail`
Audit log with search, filter, and expandable diffs.
**Inputs:**
- `entries` — Audit entries (required)
- `pageSize`, `searchable`, `filterable`, `expandable`, `showSeverityIcons`, `loading`, `emptyMessage`, `serverSide`
**Outputs:**
- `entryClick`, `filterChange`, `pageChange`
### `tl-changelog`
Version-based changelog.
**Inputs:**
- `versions` — Changelog versions (required)
- `collapsible`, `expandLatest`, `showCategoryBadges`, `showBreakingBadge`, `loading`, `emptyMessage`
**Outputs:**
- `versionClick`, `entryClick`
### `tl-gantt-chart`
Interactive Gantt chart.
**Inputs:**
- `tasks` — Gantt tasks (required)
- `dependencies`, `zoomLevel`, `rowHeight`, `showDependencies`, `showProgress`, `showTodayLine`, `loading`, `emptyMessage`
**Outputs:**
- `taskClick`, `taskResize`, `taskMove`, `zoomChange`, `scroll`
## Configuration
```typescript
import { provideTimelineConfig } from '@sda/timeline-elements-ui';
export const appConfig: ApplicationConfig = {
providers: [
provideTimelineConfig({
locale: 'en-US',
relativeTime: true,
animate: true,
timeline: {
layout: 'left',
markerSize: 'md',
connectorStyle: 'solid',
sortDirection: 'desc',
groupByDate: false,
},
activity: {
pageSize: 20,
showUnreadIndicator: true,
groupByDate: true,
},
audit: {
pageSize: 25,
searchable: true,
filterable: true,
expandable: true,
showSeverityIcons: true,
},
gantt: {
zoomLevel: 'week',
rowHeight: 40,
showDependencies: true,
showProgress: true,
showTodayLine: true,
},
}),
],
};
```
## Styling
All components use CSS custom properties with base-ui token fallbacks:
```scss
:root {
--tl-bg: var(--color-bg-primary, #ffffff);
--tl-text: var(--color-text-primary, #111827);
--tl-border: var(--color-border-primary, #e5e7eb);
--tl-marker-bg: var(--color-primary-500, #3b82f6);
--tl-status-completed: #10b981;
--tl-status-active: #3b82f6;
--tl-status-error: #ef4444;
// ... more tokens
}
```
## License
MIT

8
build-for-dev.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -e
echo "Building @sda/timeline-elements-ui for local development..."
npm run build
cd dist
npm pack
echo "Done! Package is ready in dist/"

12
ng-package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "src/index.ts",
"styleIncludePaths": ["src/styles"]
},
"dest": "dist",
"deleteDestPath": true,
"assets": [
"src/styles/**/*.scss"
]
}

3785
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "@sda/timeline-elements-ui",
"version": "0.1.0",
"description": "Angular components for timeline, activity feed, audit trail, changelog, and Gantt chart displays powered by @sda/base-ui",
"keywords": [
"angular",
"timeline",
"activity",
"audit",
"changelog",
"gantt",
"components",
"ui"
],
"repository": {
"type": "git",
"url": "https://git.sky-ai.com/ui-core-design/timeline-elements-ui.git"
},
"license": "MIT",
"sideEffects": false,
"scripts": {
"build": "ng-packagr -p ng-package.json",
"build:dev": "./build-for-dev.sh"
},
"peerDependencies": {
"@angular/common": "^19.0.0",
"@angular/core": "^19.0.0",
"@sda/base-ui": "*"
},
"devDependencies": {
"@angular/common": "^19.1.0",
"@angular/compiler": "^19.1.0",
"@angular/compiler-cli": "^19.1.0",
"@angular/core": "^19.1.0",
"@angular/forms": "^19.1.0",
"@sda/base-ui": "file:../../sda-frontend/libs/base-ui/dist",
"ng-packagr": "^19.1.0",
"typescript": "~5.7.2"
}
}

15
src/components/index.ts Normal file
View File

@@ -0,0 +1,15 @@
// Leaf components
export * from './tl-timeline-toolbar';
export * from './tl-timeline-item';
export * from './tl-activity-item';
export * from './tl-audit-entry';
export * from './tl-changelog-version';
export * from './tl-gantt-row';
// Container components
export * from './tl-timeline';
export * from './tl-horizontal-timeline';
export * from './tl-activity-feed';
export * from './tl-audit-trail';
export * from './tl-changelog';
export * from './tl-gantt-chart';

View File

@@ -0,0 +1 @@
export * from './tl-activity-feed.component';

View File

@@ -0,0 +1,59 @@
<div class="tl-activity-feed">
@if (loading()) {
<div class="tl-activity-feed__loading">
@for (i of getLoadingItems(); track i) {
<div class="tl-activity-feed__skeleton">
<ui-skeleton width="36px" height="36px" variant="circle" />
<div class="tl-activity-feed__skeleton-content">
<ui-skeleton width="80%" height="16px" />
<ui-skeleton width="60%" height="14px" />
</div>
</div>
}
</div>
} @else if (items().length === 0) {
<div class="tl-activity-feed__empty">
<p>{{ emptyMessage() }}</p>
</div>
} @else {
@if (groupByDate() && groupedItems()) {
@for (group of groupedItems(); track group.label) {
<div class="tl-activity-feed__group">
<h4 class="tl-activity-feed__group-label">{{ group.label }}</h4>
<div class="tl-activity-feed__items">
@for (item of group.items; track item.id) {
<tl-activity-item
[item]="item"
[showUnreadIndicator]="showUnreadIndicator()"
(activityClick)="onActivityClick($event)"
(actorClick)="onActorClick($event)"
/>
}
</div>
</div>
}
} @else {
<div class="tl-activity-feed__items">
@for (item of displayedItems(); track item.id) {
<tl-activity-item
[item]="item"
[showUnreadIndicator]="showUnreadIndicator()"
(activityClick)="onActivityClick($event)"
(actorClick)="onActorClick($event)"
/>
}
</div>
}
@if (showLoadMore() && hasMore()) {
<div class="tl-activity-feed__load-more">
<ui-button
(click)="onLoadMore()"
>
Load More
</ui-button>
</div>
}
}
</div>

View File

@@ -0,0 +1,70 @@
.tl-activity-feed {
background: var(--color-bg-primary, #ffffff);
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-lg, 0.5rem);
padding: var(--spacing-md, 1rem);
transition: all var(--transition-normal, 200ms ease);
padding: 0;
overflow: hidden;
&__loading {
display: flex;
flex-direction: column;
}
&__skeleton {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--tl-activity-gap);
padding: var(--spacing-md, 1rem);
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
}
&__skeleton-content {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 0.5rem);
}
&__empty {
padding: var(--spacing-xl, 2rem);
text-align: center;
color: var(--color-text-muted, #9ca3af);
p {
margin: 0;
font-size: var(--font-size-base, 1rem);
}
}
&__group {
&:not(:last-child) {
border-bottom: 2px solid var(--color-border-primary, #e5e7eb);
}
}
&__group-label {
margin: 0;
padding: var(--spacing-md, 1rem) var(--spacing-md, 1rem) var(--spacing-sm, 0.75rem);
font-size: var(--font-size-sm, 0.875rem);
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--color-bg-secondary, #f9fafb);
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
}
&__items {
display: flex;
flex-direction: column;
}
&__load-more {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 1rem);
border-top: 1px solid var(--color-border-primary, #e5e7eb);
}
}

View File

@@ -0,0 +1,66 @@
import { Component, ChangeDetectionStrategy, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ButtonComponent, SkeletonComponent } from '@sda/base-ui';
import { TlActivityItemComponent } from '../tl-activity-item';
import type { TlActivityItem } from '../../types/activity.types';
import type { TlActivityClickEvent, TlActorClickEvent, TlActivityLoadMoreEvent } from '../../types/event.types';
import { groupByDate } from '../../utils/date.utils';
@Component({
selector: 'tl-activity-feed',
standalone: true,
imports: [CommonModule, ButtonComponent, SkeletonComponent, TlActivityItemComponent],
templateUrl: './tl-activity-feed.component.html',
styleUrl: './tl-activity-feed.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TlActivityFeedComponent {
readonly items = input.required<TlActivityItem[]>();
readonly pageSize = input(20);
readonly showLoadMore = input(true);
readonly showUnreadIndicator = input(true);
readonly loading = input(false);
readonly emptyMessage = input('No activity');
readonly groupByDate = input(true);
readonly activityClick = output<TlActivityClickEvent>();
readonly actorClick = output<TlActorClickEvent>();
readonly loadMore = output<TlActivityLoadMoreEvent>();
protected readonly displayCount = signal(20);
protected readonly displayedItems = computed(() => {
const count = this.displayCount();
const pageSize = this.pageSize();
const actualCount = Math.max(count, pageSize);
return this.items().slice(0, actualCount);
});
protected readonly hasMore = computed(() => {
return this.displayedItems().length < this.items().length;
});
protected readonly groupedItems = computed(() => {
if (!this.groupByDate()) return null;
return groupByDate(this.displayedItems());
});
protected onLoadMore(): void {
const currentCount = this.displayedItems().length;
const pageSize = this.pageSize();
this.displayCount.update(c => c + pageSize);
this.loadMore.emit({ currentCount, pageSize });
}
protected onActivityClick(event: TlActivityClickEvent): void {
this.activityClick.emit(event);
}
protected onActorClick(event: TlActorClickEvent): void {
this.actorClick.emit(event);
}
protected getLoadingItems(): number[] {
return Array.from({ length: 5 }, (_, i) => i);
}
}

View File

@@ -0,0 +1 @@
export * from './tl-activity-item.component';

View File

@@ -0,0 +1,38 @@
<div class="tl-activity-item" (click)="onActivityClick()">
@if (showUnreadIndicator() && isUnread()) {
<div class="tl-activity-item__unread-dot"></div>
}
<div
class="tl-activity-item__avatar"
(click)="onActorClick($event)"
>
@if (item().actor.avatar) {
<img [src]="item().actor.avatar" [alt]="item().actor.name" />
} @else {
{{ item().actor.initials || item().actor.name.substring(0, 2) }}
}
</div>
<div class="tl-activity-item__content">
<div class="tl-activity-item__header">
<span class="tl-activity-item__actor" (click)="onActorClick($event)">
{{ item().actor.name }}
</span>
<span class="tl-activity-item__action">{{ item().action }}</span>
@if (item().target) {
<span class="tl-activity-item__target">{{ item().target }}</span>
}
</div>
@if (item().description) {
<p class="tl-activity-item__description">{{ item().description }}</p>
}
<time class="tl-activity-item__time">{{ formattedTime() }}</time>
</div>
<div class="tl-activity-item__icon">
<ui-icon [name]="actionIcon()" />
</div>
</div>

View File

@@ -0,0 +1,108 @@
.tl-activity-item {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: var(--spacing-sm, 0.75rem);
padding: var(--spacing-md, 1rem);
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
cursor: pointer;
transition: background var(--transition-normal, 200ms ease);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
&--unread {
background: var(--color-bg-selected, #eff6ff);
}
&__unread-dot {
position: absolute;
left: var(--spacing-xs, 0.5rem);
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tl-activity-unread-dot);
}
&__avatar {
width: var(--tl-activity-avatar-size, 36px);
height: var(--tl-activity-avatar-size, 36px);
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-tertiary, #e5e7eb);
color: var(--color-text-secondary, #6b7280);
font-size: var(--font-size-xs, 0.75rem);
font-weight: 600;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&:hover {
opacity: 0.8;
}
}
&__content {
min-width: 0;
}
&__header {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
font-size: var(--font-size-sm, 0.875rem);
line-height: 1.5;
}
&__actor {
font-weight: 600;
color: var(--color-text-primary, #111827);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__action {
color: var(--color-text-secondary, #6b7280);
}
&__target {
font-weight: 500;
color: var(--color-text-primary, #111827);
}
&__description {
margin: 0.25rem 0 0.25rem;
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-secondary, #6b7280);
line-height: 1.5;
}
&__time {
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-muted, #9ca3af);
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--color-text-muted, #9ca3af);
}
}

View File

@@ -0,0 +1,54 @@
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
import { IconComponent, TooltipDirective } from '@sda/base-ui';
import type { TlActivityItem } from '../../types/activity.types';
import type { TlActivityClickEvent, TlActorClickEvent } from '../../types/event.types';
import { formatSmartTime } from '../../utils/date.utils';
@Component({
selector: 'tl-activity-item',
standalone: true,
imports: [IconComponent, TooltipDirective],
templateUrl: './tl-activity-item.component.html',
styleUrl: './tl-activity-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.tl-activity-item--unread]': 'isUnread()',
},
})
export class TlActivityItemComponent {
readonly item = input.required<TlActivityItem>();
readonly showUnreadIndicator = input(true);
readonly activityClick = output<TlActivityClickEvent>();
readonly actorClick = output<TlActorClickEvent>();
protected readonly formattedTime = computed(() => formatSmartTime(this.item().date));
protected readonly actionIcon = computed(() => {
const item = this.item();
if (item.icon) return item.icon;
switch (item.type) {
case 'create': return 'plus';
case 'update': return 'edit';
case 'delete': return 'trash';
case 'comment': return 'message';
case 'assign': return 'user';
case 'status': return 'activity';
case 'upload': return 'upload';
case 'share': return 'share';
case 'system': return 'settings';
default: return 'activity';
}
});
protected readonly isUnread = computed(() => this.item().unread ?? false);
protected onActivityClick(): void {
this.activityClick.emit({ item: this.item() });
}
protected onActorClick(event: Event): void {
event.stopPropagation();
this.actorClick.emit({ actor: this.item().actor });
}
}

View File

@@ -0,0 +1 @@
export * from './tl-audit-entry.component';

View File

@@ -0,0 +1,71 @@
<div class="tl-audit-entry" (click)="onEntryClick()">
<div class="tl-audit-entry__main">
@if (showSeverityIcon()) {
<div class="tl-audit-entry__icon" [style.color]="severityColor()">
<ui-icon [name]="severityIcon()" />
</div>
}
<div class="tl-audit-entry__content">
<div class="tl-audit-entry__header">
<span class="tl-audit-entry__action">{{ entry().action }}</span>
<ui-badge>{{ entry().category }}</ui-badge>
<ui-badge [style.background-color]="severityColor()">
{{ entry().severity }}
</ui-badge>
</div>
<div class="tl-audit-entry__meta">
<span class="tl-audit-entry__user">{{ entry().user }}</span>
<span class="tl-audit-entry__separator"></span>
<time class="tl-audit-entry__time">{{ formattedTime() }}</time>
@if (entry().resource) {
<span class="tl-audit-entry__separator"></span>
<span class="tl-audit-entry__resource">{{ entry().resource }}</span>
}
@if (entry().ipAddress) {
<span class="tl-audit-entry__separator"></span>
<span class="tl-audit-entry__ip">{{ entry().ipAddress }}</span>
}
</div>
@if (entry().description) {
<p class="tl-audit-entry__description">{{ entry().description }}</p>
}
</div>
@if (expandable() && hasChanges()) {
<div class="tl-audit-entry__expand">
<ui-icon [name]="isExpanded() ? 'chevron-up' : 'chevron-down'" />
</div>
}
</div>
@if (isExpanded() && hasChanges()) {
<div class="tl-audit-entry__changes">
<h5 class="tl-audit-entry__changes-title">Changes</h5>
<table class="tl-audit-entry__changes-table">
<thead>
<tr>
<th>Field</th>
<th>Old Value</th>
<th>New Value</th>
</tr>
</thead>
<tbody>
@for (change of entry().changes; track $index) {
<tr>
<td class="tl-audit-entry__field">{{ change.field }}</td>
<td class="tl-audit-entry__old-value">
{{ change.oldValue ?? '(empty)' }}
</td>
<td class="tl-audit-entry__new-value">
{{ change.newValue ?? '(empty)' }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>

View File

@@ -0,0 +1,130 @@
.tl-audit-entry {
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
transition: background var(--transition-normal, 200ms ease);
&--expandable {
cursor: pointer;
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
}
&__main {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--spacing-md, 1rem);
padding: var(--spacing-md, 1rem);
}
&__icon {
display: flex;
align-items: flex-start;
padding-top: 2px;
}
&__content {
min-width: 0;
}
&__header {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs, 0.5rem);
align-items: center;
margin-bottom: var(--spacing-xs, 0.5rem);
}
&__action {
font-size: var(--font-size-base, 1rem);
font-weight: 600;
color: var(--color-text-primary, #111827);
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs, 0.5rem);
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-muted, #9ca3af);
}
&__user {
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
}
&__separator {
color: var(--color-text-muted, #9ca3af);
}
&__description {
margin: var(--spacing-sm, 0.75rem) 0 0;
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-secondary, #6b7280);
line-height: 1.5;
}
&__expand {
display: flex;
align-items: center;
color: var(--color-text-muted, #9ca3af);
transition: transform var(--transition-normal, 200ms ease);
}
&--expanded &__expand {
transform: rotate(180deg);
}
&__changes {
padding: 0 var(--spacing-md, 1rem) var(--spacing-md, 1rem);
padding-left: calc(var(--spacing-md, 1rem) * 3 + 20px);
}
&__changes-title {
margin: 0 0 var(--spacing-sm, 0.75rem);
font-size: var(--font-size-sm, 0.875rem);
font-weight: 600;
color: var(--color-text-primary, #111827);
}
&__changes-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-xs, 0.75rem);
th {
padding: var(--spacing-xs, 0.5rem) var(--spacing-sm, 0.75rem);
text-align: left;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
background: var(--color-bg-secondary, #f9fafb);
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
}
td {
padding: var(--spacing-xs, 0.5rem) var(--spacing-sm, 0.75rem);
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
color: var(--color-text-secondary, #6b7280);
}
tr:last-child td {
border-bottom: none;
}
}
&__field {
font-weight: 500;
color: var(--color-text-primary, #111827);
}
&__old-value {
color: var(--tl-status-error);
text-decoration: line-through;
}
&__new-value {
color: var(--tl-change-added);
font-weight: 500;
}
}

View File

@@ -0,0 +1,62 @@
import { Component, ChangeDetectionStrategy, input, output, computed, signal } from '@angular/core';
import { IconComponent, BadgeComponent, TooltipDirective } from '@sda/base-ui';
import type { TlAuditEntry } from '../../types/audit.types';
import type { TlAuditEntryClickEvent } from '../../types/event.types';
import { formatSmartTime } from '../../utils/date.utils';
@Component({
selector: 'tl-audit-entry',
standalone: true,
imports: [IconComponent, BadgeComponent, TooltipDirective],
templateUrl: './tl-audit-entry.component.html',
styleUrl: './tl-audit-entry.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.tl-audit-entry--expandable]': 'expandable()',
'[class.tl-audit-entry--expanded]': 'isExpanded()',
},
})
export class TlAuditEntryComponent {
readonly entry = input.required<TlAuditEntry>();
readonly expandable = input(true);
readonly expanded = input(false);
readonly showSeverityIcon = input(true);
readonly entryClick = output<TlAuditEntryClickEvent>();
readonly expandToggle = output<boolean>();
protected readonly isExpanded = signal(false);
protected readonly formattedTime = computed(() => formatSmartTime(this.entry().date));
protected readonly severityIcon = computed(() => {
switch (this.entry().severity) {
case 'critical': return 'alert-circle';
case 'error': return 'x-circle';
case 'warning': return 'alert-triangle';
case 'info': return 'info';
case 'debug': return 'bug';
default: return 'info';
}
});
protected readonly severityColor = computed(() => {
return `var(--tl-severity-${this.entry().severity})`;
});
protected readonly hasChanges = computed(() => {
return (this.entry().changes?.length ?? 0) > 0;
});
protected onEntryClick(): void {
this.entryClick.emit({ entry: this.entry() });
if (this.expandable() && this.hasChanges()) {
this.toggleExpand();
}
}
protected toggleExpand(): void {
this.isExpanded.update(v => !v);
this.expandToggle.emit(this.isExpanded());
}
}

View File

@@ -0,0 +1 @@
export * from './tl-audit-trail.component';

View File

@@ -0,0 +1,50 @@
<div class="tl-audit-trail">
@if (searchable() || filterable()) {
<tl-timeline-toolbar
[searchable]="searchable()"
searchPlaceholder="Search audit logs..."
[showFilter]="filterable()"
[filterCount]="activeFilterCount()"
(searchChange)="onSearchChange($event)"
(clearFilters)="onClearFilters()"
/>
}
<div class="tl-audit-trail__content">
@if (loading()) {
<div class="tl-audit-trail__loading">
@for (i of getLoadingItems(); track i) {
<div class="tl-audit-trail__skeleton">
<ui-skeleton width="20px" height="20px" variant="circle" />
<ui-skeleton width="100%" height="60px" />
</div>
}
</div>
} @else if (pagedEntries().length === 0) {
<div class="tl-audit-trail__empty">
<ui-icon name="file-text" />
<p>{{ emptyMessage() }}</p>
</div>
} @else {
<div class="tl-audit-trail__entries">
@for (entry of pagedEntries(); track entry.id) {
<tl-audit-entry
[entry]="entry"
[expandable]="expandable()"
[showSeverityIcon]="showSeverityIcons()"
(entryClick)="onEntryClick($event)"
/>
}
</div>
@if (totalPages() > 1) {
<div class="tl-audit-trail__pagination">
<span class="tl-audit-trail__pagination-info">
Page {{ currentPage() }} of {{ totalPages() }}
({{ filteredEntries().length }} entries)
</span>
</div>
}
}
</div>
</div>

View File

@@ -0,0 +1,59 @@
.tl-audit-trail {
&__content {
background: var(--color-bg-primary, #ffffff);
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-lg, 0.5rem);
padding: var(--spacing-md, 1rem);
transition: all var(--transition-normal, 200ms ease);
padding: 0;
overflow: hidden;
}
&__loading {
display: flex;
flex-direction: column;
}
&__skeleton {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-md, 1rem);
padding: var(--spacing-md, 1rem);
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
}
&__empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--spacing-md, 1rem);
padding: var(--spacing-xl, 2rem);
color: var(--color-text-muted, #9ca3af);
text-align: center;
p {
margin: 0;
font-size: var(--font-size-base, 1rem);
}
}
&__entries {
display: flex;
flex-direction: column;
}
&__pagination {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 1rem);
border-top: 2px solid var(--color-border-primary, #e5e7eb);
background: var(--color-bg-secondary, #f9fafb);
}
&__pagination-info {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-secondary, #6b7280);
}
}

View File

@@ -0,0 +1,98 @@
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ButtonComponent, IconComponent, BadgeComponent, SkeletonComponent } from '@sda/base-ui';
import { TlAuditEntryComponent } from '../tl-audit-entry';
import { TlTimelineToolbarComponent } from '../tl-timeline-toolbar';
import { TimelineService } from '../../services/timeline.service';
import type { TlAuditEntry } from '../../types/audit.types';
import type { TlAuditEntryClickEvent, TlAuditFilterChangeEvent, TlAuditPageChangeEvent } from '../../types/event.types';
import { filterAuditEntries } from '../../utils/filter.utils';
@Component({
selector: 'tl-audit-trail',
standalone: true,
imports: [
CommonModule,
ButtonComponent,
IconComponent,
BadgeComponent,
SkeletonComponent,
TlAuditEntryComponent,
TlTimelineToolbarComponent,
],
providers: [TimelineService],
templateUrl: './tl-audit-trail.component.html',
styleUrl: './tl-audit-trail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TlAuditTrailComponent {
constructor(private readonly service: TimelineService) {}
readonly entries = input.required<TlAuditEntry[]>();
readonly pageSize = input(25);
readonly searchable = input(true);
readonly filterable = input(true);
readonly expandable = input(true);
readonly showSeverityIcons = input(true);
readonly loading = input(false);
readonly emptyMessage = input('No audit entries');
readonly serverSide = input(false);
readonly entryClick = output<TlAuditEntryClickEvent>();
readonly filterChange = output<TlAuditFilterChangeEvent>();
readonly pageChange = output<TlAuditPageChangeEvent>();
protected readonly currentPage = computed(() => 1); // Simplified for now
protected readonly filteredEntries = computed(() => {
if (this.serverSide()) return this.entries();
return filterAuditEntries(this.entries(), {
searchTerm: this.service.searchTerm(),
categories: this.service.categoryFilter(),
severities: this.service.severityFilter(),
dateRange: this.service.dateRangeFilter(),
});
});
protected readonly pagedEntries = computed(() => {
const filtered = this.filteredEntries();
const pageSize = this.pageSize();
const page = this.currentPage();
const start = (page - 1) * pageSize;
return filtered.slice(start, start + pageSize);
});
protected readonly totalPages = computed(() => {
return Math.ceil(this.filteredEntries().length / this.pageSize());
});
protected readonly activeFilterCount = computed(() => this.service.activeFilterCount());
protected onSearchChange(term: string): void {
this.service.setSearch(term);
this.emitFilterChange();
}
protected onClearFilters(): void {
this.service.clearAll();
this.emitFilterChange();
}
protected onEntryClick(event: TlAuditEntryClickEvent): void {
this.entryClick.emit(event);
}
private emitFilterChange(): void {
this.filterChange.emit({
searchTerm: this.service.searchTerm(),
categories: this.service.categoryFilter(),
severities: this.service.severityFilter(),
dateRange: this.service.dateRangeFilter(),
});
}
protected getLoadingItems(): number[] {
return Array.from({ length: 5 }, (_, i) => i);
}
}

View File

@@ -0,0 +1 @@
export * from './tl-changelog-version.component';

View File

@@ -0,0 +1,73 @@
<div class="tl-changelog-version">
<div class="tl-changelog-version__header" (click)="onVersionClick()">
<div class="tl-changelog-version__title-group">
<h3 class="tl-changelog-version__version">{{ version().version }}</h3>
@if (version().title) {
<span class="tl-changelog-version__title">{{ version().title }}</span>
}
</div>
<time class="tl-changelog-version__date">{{ formattedDate() }}</time>
@if (collapsible()) {
<ui-button variant="ghost" (click)="toggleExpand()">
<ui-icon [name]="isExpanded() ? 'chevron-up' : 'chevron-down'" />
</ui-button>
}
</div>
@if (version().description) {
<p class="tl-changelog-version__description">{{ version().description }}</p>
}
@if (isExpanded()) {
<div class="tl-changelog-version__entries">
@for (group of entriesByCategory(); track group.category) {
<div class="tl-changelog-version__category">
<div class="tl-changelog-version__category-header">
<ui-icon
[name]="categoryIcon()(group.category)"
[style.color]="categoryColor()(group.category)"
/>
<h4 class="tl-changelog-version__category-title">
{{ group.category | titlecase }}
</h4>
@if (showCategoryBadges()) {
<ui-badge
[style.background-color]="categoryColor()(group.category)"
>
{{ group.entries.length }}
</ui-badge>
}
</div>
<ul class="tl-changelog-version__list">
@for (entry of group.entries; track $index) {
<li
class="tl-changelog-version__entry"
(click)="onEntryClick(entry)"
>
<span class="tl-changelog-version__entry-text">
{{ entry.description }}
</span>
@if (showBreakingBadge() && entry.breaking) {
<ui-badge>BREAKING</ui-badge>
}
@if (entry.reference) {
<a
class="tl-changelog-version__reference"
[href]="entry.reference"
target="_blank"
(click)="$event.stopPropagation()"
>
<ui-icon name="external-link" />
</a>
}
</li>
}
</ul>
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,128 @@
.tl-changelog-version {
background: var(--color-bg-primary, #ffffff);
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-lg, 0.5rem);
padding: var(--spacing-md, 1rem);
transition: all var(--transition-normal, 200ms ease);
margin-bottom: var(--spacing-lg, 1.5rem);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md, 1rem);
cursor: pointer;
&:hover .tl-changelog-version__version {
color: var(--color-border-focus, #3b82f6);
}
}
&__title-group {
display: flex;
align-items: baseline;
gap: var(--spacing-sm, 0.75rem);
flex: 1;
}
&__version {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text-primary, #111827);
transition: color var(--transition-normal, 200ms ease);
}
&__title {
font-size: var(--font-size-base, 1rem);
color: var(--color-text-secondary, #6b7280);
}
&__date {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-muted, #9ca3af);
}
&__description {
margin: 0 0 var(--spacing-md, 1rem);
font-size: var(--font-size-base, 1rem);
color: var(--color-text-secondary, #6b7280);
line-height: 1.6;
}
&__entries {
display: flex;
flex-direction: column;
gap: var(--spacing-lg, 1.5rem);
}
&__category {
&:not(:last-child) {
padding-bottom: var(--spacing-md, 1rem);
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
}
}
&__category-header {
display: flex;
align-items: center;
gap: var(--spacing-sm, 0.75rem);
margin-bottom: var(--spacing-sm, 0.75rem);
}
&__category-title {
margin: 0;
font-size: var(--font-size-base, 1rem);
font-weight: 600;
color: var(--color-text-primary, #111827);
}
&__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 0.5rem);
}
&__entry {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm, 0.75rem);
padding: var(--spacing-xs, 0.5rem) 0;
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-secondary, #6b7280);
line-height: 1.5;
&::before {
content: '';
color: var(--color-text-muted, #9ca3af);
font-weight: bold;
margin-right: var(--spacing-xs, 0.5rem);
}
&:hover {
color: var(--color-text-primary, #111827);
}
}
&__entry-text {
flex: 1;
}
&__reference {
display: flex;
align-items: center;
color: var(--color-border-focus, #3b82f6);
text-decoration: none;
&:hover {
opacity: 0.8;
}
}
&--collapsible &__header {
cursor: pointer;
}
}

View File

@@ -0,0 +1,82 @@
import { Component, ChangeDetectionStrategy, input, output, computed, signal } from '@angular/core';
import { TitleCasePipe } from '@angular/common';
import { BadgeComponent, IconComponent, ButtonComponent } from '@sda/base-ui';
import type { TlChangelogVersion, TlChangeCategory } from '../../types/changelog.types';
import type { TlVersionClickEvent, TlChangelogEntryClickEvent } from '../../types/event.types';
import { formatDate } from '../../utils/date.utils';
@Component({
selector: 'tl-changelog-version',
standalone: true,
imports: [BadgeComponent, IconComponent, ButtonComponent, TitleCasePipe],
templateUrl: './tl-changelog-version.component.html',
styleUrl: './tl-changelog-version.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.tl-changelog-version--collapsible]': 'collapsible()',
'[class.tl-changelog-version--expanded]': 'isExpanded()',
},
})
export class TlChangelogVersionComponent {
readonly version = input.required<TlChangelogVersion>();
readonly collapsible = input(false);
readonly expanded = input(true);
readonly showCategoryBadges = input(true);
readonly showBreakingBadge = input(true);
readonly versionClick = output<TlVersionClickEvent>();
readonly entryClick = output<TlChangelogEntryClickEvent>();
readonly expandToggle = output<boolean>();
protected readonly isExpanded = signal(true);
protected readonly formattedDate = computed(() => formatDate(this.version().date));
protected readonly entriesByCategory = computed(() => {
const entries = this.version().entries;
const categories: TlChangeCategory[] = ['added', 'changed', 'fixed', 'removed', 'deprecated', 'security'];
return categories
.map(category => ({
category,
entries: entries.filter(e => e.category === category),
}))
.filter(group => group.entries.length > 0);
});
protected readonly categoryColor = computed(() => {
return (category: TlChangeCategory): string => {
return `var(--tl-change-${category})`;
};
});
protected readonly categoryIcon = computed(() => {
return (category: TlChangeCategory): string => {
switch (category) {
case 'added': return 'plus';
case 'changed': return 'edit';
case 'fixed': return 'check';
case 'removed': return 'minus';
case 'deprecated': return 'alert-triangle';
case 'security': return 'shield';
default: return 'circle';
}
};
});
protected onVersionClick(): void {
this.versionClick.emit({ version: this.version() });
if (this.collapsible()) {
this.toggleExpand();
}
}
protected toggleExpand(): void {
this.isExpanded.update(v => !v);
this.expandToggle.emit(this.isExpanded());
}
protected onEntryClick(entry: any): void {
this.entryClick.emit({ entry, version: this.version() });
}
}

View File

@@ -0,0 +1 @@
export * from './tl-changelog.component';

View File

@@ -0,0 +1,31 @@
<div class="tl-changelog">
@if (loading()) {
<div class="tl-changelog__loading">
@for (i of getLoadingItems(); track i) {
<div class="tl-changelog__skeleton">
<ui-skeleton width="150px" height="32px" />
<ui-skeleton width="100%" height="120px" />
</div>
}
</div>
} @else if (versions().length === 0) {
<div class="tl-changelog__empty">
<p>{{ emptyMessage() }}</p>
</div>
} @else {
<div class="tl-changelog__versions">
@for (version of versions(); track version.version) {
<tl-changelog-version
[version]="version"
[collapsible]="collapsible()"
[expanded]="isExpanded(version.version)"
[showCategoryBadges]="showCategoryBadges()"
[showBreakingBadge]="showBreakingBadge()"
(versionClick)="onVersionClick($event)"
(entryClick)="onEntryClick($event)"
(expandToggle)="onExpandToggle(version.version, $event)"
/>
}
</div>
}
</div>

View File

@@ -0,0 +1,39 @@
.tl-changelog {
&__loading {
display: flex;
flex-direction: column;
gap: var(--spacing-lg, 1.5rem);
}
&__skeleton {
background: var(--color-bg-primary, #ffffff);
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-lg, 0.5rem);
padding: var(--spacing-md, 1rem);
transition: all var(--transition-normal, 200ms ease);
display: flex;
flex-direction: column;
gap: var(--spacing-md, 1rem);
}
&__empty {
background: var(--color-bg-primary, #ffffff);
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-lg, 0.5rem);
padding: var(--spacing-md, 1rem);
transition: all var(--transition-normal, 200ms ease);
padding: var(--spacing-xl, 2rem);
text-align: center;
color: var(--color-text-muted, #9ca3af);
p {
margin: 0;
font-size: var(--font-size-base, 1rem);
}
}
&__versions {
display: flex;
flex-direction: column;
}
}

View File

@@ -0,0 +1,59 @@
import { Component, ChangeDetectionStrategy, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SkeletonComponent } from '@sda/base-ui';
import { TlChangelogVersionComponent } from '../tl-changelog-version';
import type { TlChangelogVersion } from '../../types/changelog.types';
import type { TlVersionClickEvent, TlChangelogEntryClickEvent } from '../../types/event.types';
@Component({
selector: 'tl-changelog',
standalone: true,
imports: [CommonModule, SkeletonComponent, TlChangelogVersionComponent],
templateUrl: './tl-changelog.component.html',
styleUrl: './tl-changelog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TlChangelogComponent {
readonly versions = input.required<TlChangelogVersion[]>();
readonly collapsible = input(false);
readonly expandLatest = input(true);
readonly showCategoryBadges = input(true);
readonly showBreakingBadge = input(true);
readonly loading = input(false);
readonly emptyMessage = input('No changelog entries');
readonly versionClick = output<TlVersionClickEvent>();
readonly entryClick = output<TlChangelogEntryClickEvent>();
protected readonly expandedVersions = signal<Set<string>>(new Set());
protected isExpanded(version: string): boolean {
if (!this.collapsible()) return true;
if (this.expandLatest() && this.versions()[0]?.version === version) return true;
return this.expandedVersions().has(version);
}
protected onVersionClick(event: TlVersionClickEvent): void {
this.versionClick.emit(event);
}
protected onEntryClick(event: TlChangelogEntryClickEvent): void {
this.entryClick.emit(event);
}
protected onExpandToggle(version: string, expanded: boolean): void {
this.expandedVersions.update(set => {
const newSet = new Set(set);
if (expanded) {
newSet.add(version);
} else {
newSet.delete(version);
}
return newSet;
});
}
protected getLoadingItems(): number[] {
return Array.from({ length: 3 }, (_, i) => i);
}
}

View File

@@ -0,0 +1 @@
export * from './tl-gantt-chart.component';

View File

@@ -0,0 +1,73 @@
<div class="tl-gantt-chart">
<tl-timeline-toolbar
[showZoom]="true"
(zoomIn)="onZoomIn()"
(zoomOut)="onZoomOut()"
/>
@if (loading()) {
<div class="tl-gantt-chart__loading">
@for (i of getLoadingItems(); track i) {
<ui-skeleton width="100%" [height]="rowHeight() + 'px'" />
}
</div>
} @else if (tasks().length === 0) {
<div class="tl-gantt-chart__empty">
<ui-icon name="calendar" />
<p>{{ emptyMessage() }}</p>
</div>
} @else {
<div class="tl-gantt-chart__container">
<!-- Sidebar with task names -->
<div class="tl-gantt-chart__sidebar">
<div class="tl-gantt-chart__sidebar-header" [style.height.px]="rowHeight()">
Task Name
</div>
@for (task of sortedTasks(); track task.id) {
<div class="tl-gantt-chart__sidebar-row" [style.height.px]="rowHeight()">
{{ task.name }}
</div>
}
</div>
<!-- Chart area -->
<div class="tl-gantt-chart__chart">
<!-- Date axis -->
<div class="tl-gantt-chart__axis" [style.width.px]="totalWidth()">
@for (tick of dateAxis(); track tick.position) {
<div
class="tl-gantt-chart__tick"
[class.tl-gantt-chart__tick--major]="tick.isMajor"
[class.tl-gantt-chart__tick--weekend]="tick.isWeekend"
[style.left.px]="tick.position"
>
{{ tick.label }}
</div>
}
</div>
<!-- Task rows -->
<div class="tl-gantt-chart__rows" [style.width.px]="totalWidth()">
@for (task of sortedTasks(); track task.id; let idx = $index) {
<tl-gantt-row
[task]="task"
[position]="taskPositions()[idx]"
[showProgress]="showProgress()"
[rowHeight]="rowHeight()"
(taskClick)="onTaskClick($event)"
(taskResize)="onTaskResize($event)"
/>
}
<!-- Today line -->
@if (showTodayLine() && todayPosition() !== null) {
<div
class="tl-gantt-chart__today-line"
[style.left.px]="todayPosition()"
></div>
}
</div>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,165 @@
.tl-gantt-chart {
&__loading {
display: flex;
flex-direction: column;
gap: 1px;
}
&__empty {
background: var(--color-bg-primary, #ffffff);
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-lg, 0.5rem);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--spacing-md, 1rem);
padding: var(--spacing-2xl, 3rem);
color: var(--color-text-muted, #9ca3af);
text-align: center;
p {
margin: 0;
font-size: var(--font-size-sm, 0.875rem);
}
}
&__container {
background: var(--color-bg-primary, #ffffff);
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-lg, 0.5rem);
overflow: hidden;
display: grid;
grid-template-columns: var(--tl-gantt-sidebar-width) 1fr;
}
// ── Sidebar ──
&__sidebar {
border-right: 1px solid var(--color-border-primary, #e5e7eb);
background: var(--color-bg-primary, #ffffff);
}
&__sidebar-header {
display: flex;
align-items: center;
padding: 0 var(--spacing-md, 1rem);
font-weight: 600;
font-size: var(--font-size-xs, 0.75rem);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted, #9ca3af);
background: var(--color-bg-secondary, #f9fafb);
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
}
&__sidebar-row {
display: flex;
align-items: center;
padding: 0 var(--spacing-md, 1rem);
font-size: var(--font-size-sm, 0.875rem);
font-weight: 500;
color: var(--color-text-primary, #111827);
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
background: var(--color-bg-primary, #ffffff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: background var(--transition-fast, 150ms ease);
&:nth-child(even) {
background: var(--color-bg-secondary, #f9fafb);
}
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
}
// ── Chart area ──
&__chart {
overflow-x: auto;
position: relative;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-track {
background: var(--color-bg-secondary, #f9fafb);
}
&::-webkit-scrollbar-thumb {
background: var(--color-border-primary, #e5e7eb);
border-radius: 3px;
&:hover {
background: var(--color-text-muted, #9ca3af);
}
}
}
// ── Date axis ──
&__axis {
position: relative;
height: var(--tl-gantt-axis-height);
background: var(--color-bg-secondary, #f9fafb);
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
}
&__tick {
position: absolute;
top: 0;
height: 100%;
display: flex;
align-items: flex-end;
padding: 0 var(--spacing-xs, 0.5rem) 0.5rem;
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
border-left: 1px solid var(--color-border-primary, #e5e7eb);
&--major {
font-weight: 600;
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-secondary, #6b7280);
border-left: 1px solid var(--color-text-muted, #9ca3af);
}
&--weekend {
background: var(--tl-gantt-weekend-bg);
}
}
// ── Rows ──
&__rows {
position: relative;
min-height: 200px;
}
// ── Today line ──
&__today-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--tl-gantt-today-line);
z-index: 10;
pointer-events: none;
&::before {
content: 'Today';
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-bg-primary, #ffffff);
background: var(--tl-gantt-today-line);
padding: 1px 6px;
border-radius: var(--radius-sm, 0.25rem);
white-space: nowrap;
}
}
}

View File

@@ -0,0 +1,102 @@
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ButtonComponent, IconComponent, TooltipDirective, SkeletonComponent } from '@sda/base-ui';
import { TlGanttRowComponent } from '../tl-gantt-row';
import { TlTimelineToolbarComponent } from '../tl-timeline-toolbar';
import { GanttService } from '../../services/gantt.service';
import type { TlGanttTask, TlGanttDependency, TlDateTick } from '../../types/gantt.types';
import type { TlGanttTaskClickEvent, TlGanttTaskResizeEvent, TlGanttTaskMoveEvent, TlGanttZoomChangeEvent, TlGanttScrollEvent } from '../../types/event.types';
import { calculateDateRange, sortTasksHierarchically, calculateChartWidth } from '../../utils/gantt.utils';
@Component({
selector: 'tl-gantt-chart',
standalone: true,
imports: [
CommonModule,
ButtonComponent,
IconComponent,
TooltipDirective,
SkeletonComponent,
TlGanttRowComponent,
TlTimelineToolbarComponent,
],
providers: [GanttService],
templateUrl: './tl-gantt-chart.component.html',
styleUrl: './tl-gantt-chart.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TlGanttChartComponent {
constructor(protected readonly ganttService: GanttService) {}
readonly tasks = input.required<TlGanttTask[]>();
readonly dependencies = input<TlGanttDependency[]>([]);
readonly zoomLevel = input<'day' | 'week' | 'month' | 'quarter' | 'year'>('week');
readonly rowHeight = input(40);
readonly showDependencies = input(true);
readonly showProgress = input(true);
readonly showTodayLine = input(true);
readonly loading = input(false);
readonly emptyMessage = input('No tasks');
readonly taskClick = output<TlGanttTaskClickEvent>();
readonly taskResize = output<TlGanttTaskResizeEvent>();
readonly taskMove = output<TlGanttTaskMoveEvent>();
readonly zoomChange = output<TlGanttZoomChangeEvent>();
readonly scroll = output<TlGanttScrollEvent>();
protected readonly dateRange = computed(() => {
if (this.tasks().length === 0) {
return { start: new Date(), end: new Date() };
}
return calculateDateRange(this.tasks());
});
protected readonly dateAxis = computed(() => {
const range = this.dateRange();
return this.ganttService.calculateDateAxis(range.start, range.end);
});
protected readonly sortedTasks = computed(() => {
return sortTasksHierarchically(this.tasks());
});
protected readonly taskPositions = computed(() => {
const range = this.dateRange();
return this.sortedTasks().map(task =>
this.ganttService.calculateTaskPosition(task, range.start)
);
});
protected readonly todayPosition = computed(() => {
if (!this.showTodayLine()) return null;
const range = this.dateRange();
return this.ganttService.calculateTodayPosition(range.start);
});
protected readonly totalWidth = computed(() => {
const range = this.dateRange();
return calculateChartWidth(range.start, range.end, this.ganttService.pixelsPerDay());
});
protected onZoomIn(): void {
this.ganttService.zoomIn();
this.zoomChange.emit({ level: this.ganttService.zoomLevel() });
}
protected onZoomOut(): void {
this.ganttService.zoomOut();
this.zoomChange.emit({ level: this.ganttService.zoomLevel() });
}
protected onTaskClick(event: TlGanttTaskClickEvent): void {
this.taskClick.emit(event);
}
protected onTaskResize(event: TlGanttTaskResizeEvent): void {
this.taskResize.emit(event);
}
protected getLoadingItems(): number[] {
return Array.from({ length: 5 }, (_, i) => i);
}
}

View File

@@ -0,0 +1 @@
export * from './tl-gantt-row.component';

View File

@@ -0,0 +1,20 @@
<div
class="tl-gantt-row"
[class]="statusClass()"
[style.height.px]="rowHeight()"
>
<div
class="tl-gantt-row__bar"
[ngStyle]="barStyle()"
[uiTooltip]="tooltipContent()"
(click)="onTaskClick()"
>
@if (showProgress() && task().progress !== undefined) {
<div
class="tl-gantt-row__progress"
[style.width]="progressWidth()"
></div>
}
<span class="tl-gantt-row__label">{{ task().name }}</span>
</div>
</div>

View File

@@ -0,0 +1,79 @@
.tl-gantt-row {
position: relative;
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
background: var(--color-bg-primary, #ffffff);
&:nth-child(even) {
background: var(--color-bg-secondary, #f9fafb);
}
&__bar {
position: absolute;
top: 50%;
transform: translateY(-50%);
height: var(--tl-gantt-bar-height);
border-radius: var(--radius-base, 4px);
cursor: pointer;
overflow: hidden;
transition: box-shadow var(--transition-fast, 150ms ease),
transform var(--transition-fast, 150ms ease);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-50%) scaleY(1.08);
z-index: 2;
}
}
&__progress {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.25);
border-right: 2px solid rgba(255, 255, 255, 0.4);
transition: width var(--transition-normal, 200ms ease);
}
&__label {
position: relative;
display: block;
padding: 0 var(--spacing-sm, 0.75rem);
line-height: var(--tl-gantt-bar-height);
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-inverse, #ffffff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
z-index: 1;
}
// Status modifiers
&--completed &__bar {
opacity: 0.65;
}
&--on-hold &__bar {
opacity: 0.5;
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
rgba(255, 255, 255, 0.1) 4px,
rgba(255, 255, 255, 0.1) 8px
);
}
&--delayed &__bar {
animation: tl-pulse 2s ease-in-out infinite;
}
}
@keyframes tl-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}

View File

@@ -0,0 +1,72 @@
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
import { NgStyle } from '@angular/common';
import { TooltipDirective, IconComponent } from '@sda/base-ui';
import type { TlGanttTask, TlGanttTaskPosition } from '../../types/gantt.types';
import type { TlGanttTaskClickEvent, TlGanttTaskResizeEvent } from '../../types/event.types';
import { formatDate, diffInDays } from '../../utils/date.utils';
@Component({
selector: 'tl-gantt-row',
standalone: true,
imports: [NgStyle, TooltipDirective, IconComponent],
templateUrl: './tl-gantt-row.component.html',
styleUrl: './tl-gantt-row.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.tl-gantt-row--clickable]': 'true',
},
})
export class TlGanttRowComponent {
readonly task = input.required<TlGanttTask>();
readonly position = input.required<TlGanttTaskPosition>();
readonly showProgress = input(true);
readonly rowHeight = input(40);
readonly taskClick = output<TlGanttTaskClickEvent>();
readonly taskResize = output<TlGanttTaskResizeEvent>();
protected readonly statusClass = computed(() => {
const status = this.task().status;
return status ? `tl-gantt-row--${status}` : '';
});
protected readonly progressWidth = computed(() => {
const progress = this.task().progress ?? 0;
return `${Math.min(100, Math.max(0, progress))}%`;
});
protected readonly barColor = computed(() => {
const task = this.task();
if (task.color) return task.color;
if (task.status === 'completed') return 'var(--tl-status-completed)';
if (task.status === 'delayed') return 'var(--tl-status-error)';
if (task.status === 'on-hold') return 'var(--tl-status-warning)';
if (task.status === 'in-progress') return 'var(--tl-status-active)';
return 'var(--tl-status-pending)';
});
protected readonly barStyle = computed(() => {
const pos = this.position();
return {
left: `${pos.left}px`,
width: `${pos.width}px`,
'background-color': this.barColor(),
};
});
protected readonly tooltipContent = computed(() => {
const task = this.task();
const duration = diffInDays(task.startDate, task.endDate);
return `
${task.name}
${formatDate(task.startDate)} - ${formatDate(task.endDate)}
Duration: ${duration} days
${task.progress !== undefined ? `Progress: ${task.progress}%` : ''}
${task.assignee ? `Assignee: ${task.assignee}` : ''}
`.trim();
});
protected onTaskClick(): void {
this.taskClick.emit({ task: this.task() });
}
}

View File

@@ -0,0 +1 @@
export * from './tl-horizontal-timeline.component';

View File

@@ -0,0 +1,75 @@
<div class="tl-horizontal-timeline">
@if (showNavArrows && canScrollLeft) {
<ui-button
class="tl-horizontal-timeline__nav tl-horizontal-timeline__nav--left"
(click)="scrollLeft()"
aria-label="Scroll left"
>
<ui-icon name="chevron-left" />
</ui-button>
}
<div
class="tl-horizontal-timeline__container"
#scrollContainer
(scroll)="updateScrollState()"
>
@if (loading) {
<div class="tl-horizontal-timeline__loading">
@for (i of getLoadingItems(); track i) {
<div class="tl-horizontal-timeline__skeleton">
<ui-skeleton width="32px" height="32px" variant="circle" />
<ui-skeleton width="120px" height="60px" />
</div>
}
</div>
} @else if (items.length === 0) {
<div class="tl-horizontal-timeline__empty">
<ui-icon name="calendar" />
<p>{{ emptyMessage }}</p>
</div>
} @else {
<div class="tl-horizontal-timeline__track">
@for (item of sortedItems; track item.id; let idx = $index) {
<div class="tl-horizontal-timeline__item" (click)="onItemClick(item, idx)">
<div
class="tl-horizontal-timeline__marker"
[style.background-color]="getMarkerColor(item)"
[uiTooltip]="item.title"
>
@if (item.icon) {
<ui-icon [name]="item.icon" />
}
</div>
<div class="tl-horizontal-timeline__content">
<time class="tl-horizontal-timeline__time">{{ formatTime(item) }}</time>
<h4 class="tl-horizontal-timeline__title">{{ item.title }}</h4>
@if (item.description) {
<p class="tl-horizontal-timeline__description">{{ item.description }}</p>
}
</div>
@if (!$last) {
<div class="tl-horizontal-timeline__connector"></div>
}
</div>
}
</div>
}
</div>
@if (showNavArrows && canScrollRight) {
<ui-button
class="tl-horizontal-timeline__nav tl-horizontal-timeline__nav--right"
(click)="scrollRight()"
aria-label="Scroll right"
>
<ui-icon name="chevron-right" />
</ui-button>
}
</div>

View File

@@ -0,0 +1,136 @@
@use '../../styles/mixins' as *;
.tl-horizontal-timeline {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-md, 1rem);
&__nav {
position: absolute;
z-index: 2;
box-shadow: var(--shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
&--left {
left: var(--spacing-md, 1rem);
}
&--right {
right: var(--spacing-md, 1rem);
}
}
&__container {
@include tl-scrollable;
flex: 1;
overflow-x: auto;
padding: var(--spacing-xl, 2rem) var(--spacing-lg, 1.5rem);
}
&__loading,
&__track {
display: flex;
gap: var(--spacing-2xl, 2.5rem);
align-items: flex-start;
min-width: min-content;
}
&__skeleton {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-md, 1rem);
}
&__empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--spacing-lg, 1.5rem);
width: 100%;
padding: var(--spacing-2xl, 3rem);
color: var(--color-text-muted, #9ca3af);
text-align: center;
p {
margin: 0;
font-size: var(--font-size-base, 1rem);
line-height: var(--line-height-relaxed, 1.625);
}
}
&__item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-md, 1rem);
min-width: 160px;
cursor: pointer;
transition: transform var(--transition-normal, 200ms ease);
&:hover {
transform: translateY(-2px);
.tl-horizontal-timeline__content {
background: var(--color-bg-hover, #f3f4f6);
}
}
}
&__marker {
@include tl-marker(md);
}
&__connector {
position: absolute;
top: 16px;
left: 100%;
width: var(--spacing-2xl, 2.5rem);
height: var(--tl-line-width);
background: var(--tl-line-color);
}
&__content {
background: var(--color-bg-primary, #ffffff);
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-lg, 0.5rem);
padding: var(--spacing-md, 1rem) var(--spacing-lg, 1.5rem);
text-align: center;
min-width: 120px;
max-width: 220px;
transition: all var(--transition-normal, 200ms ease);
}
&__time {
display: block;
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-muted, #9ca3af);
margin-bottom: var(--spacing-xs, 0.5rem);
font-weight: var(--font-weight-medium, 500);
}
&__title {
margin: 0 0 var(--spacing-xs, 0.5rem);
font-size: var(--font-size-sm, 0.875rem);
font-weight: var(--font-weight-semibold, 600);
color: var(--color-text-primary, #111827);
line-height: var(--line-height-snug, 1.375);
letter-spacing: var(--letter-spacing-tight, -0.025em);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__description {
margin: 0;
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-secondary, #6b7280);
line-height: var(--line-height-normal, 1.5);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}

View File

@@ -0,0 +1,92 @@
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ButtonComponent, IconComponent, SkeletonComponent, TooltipDirective } from '@sda/base-ui';
import type { TlTimelineItem } from '../../types/timeline.types';
import type { TlMarkerSize, TlConnectorStyle, TlSortDirection } from '../../types/config.types';
import type { TlTimelineItemClickEvent } from '../../types/event.types';
import { formatSmartTime } from '../../utils/date.utils';
@Component({
selector: 'tl-horizontal-timeline',
standalone: true,
imports: [CommonModule, ButtonComponent, IconComponent, SkeletonComponent, TooltipDirective],
templateUrl: './tl-horizontal-timeline.component.html',
styleUrl: './tl-horizontal-timeline.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TlHorizontalTimelineComponent implements AfterViewInit {
@ViewChild('scrollContainer') scrollContainer?: ElementRef<HTMLDivElement>;
@Input({ required: true }) items!: TlTimelineItem[];
@Input() markerSize: TlMarkerSize = 'md';
@Input() connectorStyle: TlConnectorStyle = 'solid';
@Input() sortDirection: TlSortDirection = 'desc';
@Input() scrollable = true;
@Input() showNavArrows = true;
@Input() loading = false;
@Input() emptyMessage = 'No timeline items';
@Output() itemClick = new EventEmitter<TlTimelineItemClickEvent>();
protected canScrollLeft = false;
protected canScrollRight = false;
protected get sortedItems(): TlTimelineItem[] {
const items = [...this.items];
const direction = this.sortDirection;
items.sort((a, b) => {
const aTime = new Date(a.date).getTime();
const bTime = new Date(b.date).getTime();
return direction === 'asc' ? aTime - bTime : bTime - aTime;
});
return items;
}
ngAfterViewInit(): void {
this.updateScrollState();
}
protected formatTime(item: TlTimelineItem): string {
return formatSmartTime(item.date);
}
protected getMarkerColor(item: TlTimelineItem): string {
if (item.iconColor) return item.iconColor;
if (item.status) return `var(--tl-status-${item.status})`;
return 'var(--tl-marker-bg)';
}
protected onItemClick(item: TlTimelineItem, index: number): void {
this.itemClick.emit({ item, index });
}
protected scrollLeft(): void {
const container = this.scrollContainer?.nativeElement;
if (container) {
container.scrollBy({ left: -200, behavior: 'smooth' });
setTimeout(() => this.updateScrollState(), 300);
}
}
protected scrollRight(): void {
const container = this.scrollContainer?.nativeElement;
if (container) {
container.scrollBy({ left: 200, behavior: 'smooth' });
setTimeout(() => this.updateScrollState(), 300);
}
}
protected updateScrollState(): void {
const container = this.scrollContainer?.nativeElement;
if (container) {
this.canScrollLeft = container.scrollLeft > 0;
this.canScrollRight = container.scrollLeft < container.scrollWidth - container.clientWidth - 1;
}
}
protected getLoadingItems(): number[] {
return Array.from({ length: 5 }, (_, i) => i);
}
}

View File

@@ -0,0 +1 @@
export * from './tl-timeline-item.component';

View File

@@ -0,0 +1,34 @@
<div class="tl-timeline-item" [class]="statusClass">
<div class="tl-timeline-item__marker" [style.background-color]="markerColor">
@if (item.icon) {
<ui-icon [name]="item.icon" />
}
</div>
<div class="tl-timeline-item__content">
<ui-card class="tl-timeline-item__card">
<div class="tl-timeline-item__header">
<h4 class="tl-timeline-item__title">{{ item.title }}</h4>
<time class="tl-timeline-item__time">{{ formattedTime }}</time>
</div>
@if (item.description) {
<p class="tl-timeline-item__description">{{ item.description }}</p>
}
@if (item.tags?.length) {
<div class="tl-timeline-item__tags">
@for (tag of item.tags; track tag) {
<ui-badge>{{ tag }}</ui-badge>
}
</div>
}
@if (item.category) {
<div class="tl-timeline-item__category">
<ui-badge>{{ item.category }}</ui-badge>
</div>
}
</ui-card>
</div>
</div>

View File

@@ -0,0 +1,124 @@
@use '../../styles/mixins' as *;
.tl-timeline-item {
position: relative;
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-lg, 1.5rem);
padding: var(--spacing-md, 1rem) 0;
&::after {
@include tl-connector(solid);
left: calc(var(--tl-marker-size-md) / 2 - var(--tl-line-width) / 2);
top: calc(var(--tl-marker-size-md) + var(--spacing-md, 1rem));
bottom: calc(-1 * var(--spacing-md, 1rem));
}
&:last-child::after {
display: none;
}
&--animate {
animation: tl-slide-in 300ms ease-out;
}
&__marker {
@include tl-marker(md);
}
&__content {
min-width: 0;
}
&__card {
background: var(--color-bg-primary, #ffffff);
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-lg, 0.5rem);
padding: var(--spacing-lg, 1.5rem);
transition: all var(--transition-normal, 200ms ease);
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-sm, 0.75rem);
gap: var(--spacing-md, 1rem);
}
&__title {
margin: 0;
font-size: var(--font-size-base, 1rem);
font-weight: var(--font-weight-semibold, 600);
color: var(--color-text-primary, #111827);
line-height: var(--line-height-snug, 1.375);
letter-spacing: var(--letter-spacing-tight, -0.025em);
}
&__time {
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-muted, #9ca3af);
white-space: nowrap;
font-weight: var(--font-weight-medium, 500);
}
&__description {
margin: var(--spacing-xs, 0.5rem) 0 0;
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-secondary, #6b7280);
line-height: var(--line-height-relaxed, 1.625);
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs, 0.5rem);
margin-top: var(--spacing-md, 1rem);
}
&__category {
margin-top: var(--spacing-md, 1rem);
}
// Status colors
&--completed .tl-timeline-item__marker {
background: var(--tl-status-completed);
}
&--active .tl-timeline-item__marker {
background: var(--tl-status-active);
}
&--pending .tl-timeline-item__marker {
background: var(--tl-status-pending);
}
&--error .tl-timeline-item__marker {
background: var(--tl-status-error);
}
&--warning .tl-timeline-item__marker {
background: var(--tl-status-warning);
}
// Alternating layout
.tl-timeline-item--alternating &:nth-child(even) {
grid-template-columns: 1fr auto;
direction: rtl;
.tl-timeline-item__content {
direction: ltr;
}
}
}
@keyframes tl-slide-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,54 @@
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter } from '@angular/core';
import { IconComponent, CardComponent, BadgeComponent, TooltipDirective } from '@sda/base-ui';
import type { TlTimelineItem } from '../../types/timeline.types';
import type { TlTimelineLayout, TlMarkerSize, TlConnectorStyle } from '../../types/config.types';
import type { TlTimelineItemClickEvent, TlTimelineItemHoverEvent } from '../../types/event.types';
import { formatSmartTime } from '../../utils/date.utils';
@Component({
selector: 'tl-timeline-item',
standalone: true,
imports: [IconComponent, CardComponent, BadgeComponent, TooltipDirective],
templateUrl: './tl-timeline-item.component.html',
styleUrl: './tl-timeline-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.tl-timeline-item--left]': 'layout === "left"',
'[class.tl-timeline-item--right]': 'layout === "right"',
'[class.tl-timeline-item--alternating]': 'layout === "alternating"',
'[class.tl-timeline-item--animate]': 'animate',
},
})
export class TlTimelineItemComponent {
@Input({ required: true }) item!: TlTimelineItem;
@Input() layout: TlTimelineLayout = 'left';
@Input() markerSize: TlMarkerSize = 'md';
@Input() connectorStyle: TlConnectorStyle = 'solid';
@Input() animate = false;
@Input() position: 'left' | 'right' = 'left';
@Output() itemClick = new EventEmitter<TlTimelineItemClickEvent>();
@Output() itemHover = new EventEmitter<TlTimelineItemHoverEvent>();
protected get formattedTime(): string {
return formatSmartTime(this.item.date);
}
protected get markerColor(): string {
if (this.item.iconColor) return this.item.iconColor;
if (this.item.status) return `var(--tl-status-${this.item.status})`;
return 'var(--tl-marker-bg)';
}
protected get statusClass(): string {
return this.item.status ? `tl-timeline-item--${this.item.status}` : '';
}
protected onClick(index: number): void {
this.itemClick.emit({ item: this.item, index });
}
protected onHover(index: number): void {
this.itemHover.emit({ item: this.item, index });
}
}

View File

@@ -0,0 +1 @@
export * from './tl-timeline-toolbar.component';

View File

@@ -0,0 +1,56 @@
<div class="tl-timeline-toolbar">
@if (searchable()) {
<input
class="tl-timeline-toolbar__search"
type="text"
[placeholder]="searchPlaceholder()"
#searchInput
(input)="onSearchChange(searchInput.value)"
/>
}
<div class="tl-timeline-toolbar__actions">
@if (showFilter()) {
<ui-button
(click)="onClearFilters()"
>
<ui-icon name="filter" />
@if (filterCount() > 0) {
<ui-badge>{{ filterCount() }}</ui-badge>
}
</ui-button>
}
@if (showZoom()) {
<ui-button
(click)="onZoomOut()"
aria-label="Zoom out"
>
<ui-icon name="zoom-out" />
</ui-button>
<ui-button
(click)="onZoomIn()"
aria-label="Zoom in"
>
<ui-icon name="zoom-in" />
</ui-button>
}
@if (showSort()) {
<ui-button
(click)="onSortToggle()"
aria-label="Toggle sort"
>
<ui-icon name="sort" />
</ui-button>
}
</div>
</div>

View File

@@ -0,0 +1,22 @@
.tl-timeline-toolbar {
display: flex;
align-items: center;
gap: var(--spacing-xs, 0.5rem);
padding: var(--spacing-xs, 0.5rem) var(--spacing-md, 1rem);
background: var(--color-bg-secondary, #f9fafb);
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-lg, 0.5rem);
margin-bottom: var(--spacing-sm, 0.75rem);
&__search {
flex: 1;
min-width: 200px;
}
&__actions {
display: flex;
gap: var(--spacing-xs, 0.5rem);
align-items: center;
margin-left: auto;
}
}

View File

@@ -0,0 +1,45 @@
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
import { ButtonComponent, IconComponent, BadgeComponent, InputComponent } from '@sda/base-ui';
@Component({
selector: 'tl-timeline-toolbar',
standalone: true,
imports: [ButtonComponent, IconComponent, BadgeComponent, InputComponent],
templateUrl: './tl-timeline-toolbar.component.html',
styleUrl: './tl-timeline-toolbar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TlTimelineToolbarComponent {
readonly searchable = input(false);
readonly searchPlaceholder = input('Search...');
readonly showFilter = input(false);
readonly showZoom = input(false);
readonly showSort = input(false);
readonly filterCount = input(0);
readonly searchChange = output<string>();
readonly clearFilters = output<void>();
readonly zoomIn = output<void>();
readonly zoomOut = output<void>();
readonly sortToggle = output<void>();
protected onSearchChange(value: string): void {
this.searchChange.emit(value);
}
protected onClearFilters(): void {
this.clearFilters.emit();
}
protected onZoomIn(): void {
this.zoomIn.emit();
}
protected onZoomOut(): void {
this.zoomOut.emit();
}
protected onSortToggle(): void {
this.sortToggle.emit();
}
}

View File

@@ -0,0 +1 @@
export * from './tl-timeline.component';

View File

@@ -0,0 +1,54 @@
<div class="tl-timeline">
@if (loading) {
<div class="tl-timeline__loading">
@for (i of getLoadingItems(); track i) {
<div class="tl-timeline__skeleton">
<ui-skeleton width="32px" height="32px" variant="circle" />
<ui-skeleton width="100%" height="80px" />
</div>
}
</div>
} @else if (items.length === 0) {
<div class="tl-timeline__empty">
<ui-icon name="calendar" />
<p>{{ emptyMessage }}</p>
</div>
} @else {
@if (groupByDate) {
@for (group of groupedItems; track group.label) {
<div class="tl-timeline__group">
<h3 class="tl-timeline__group-label">{{ group.label }}</h3>
<div class="tl-timeline__items">
@for (item of group.items; track item.id) {
<tl-timeline-item
[item]="item"
[layout]="layout"
[markerSize]="markerSize"
[connectorStyle]="connectorStyle"
[animate]="animate"
[position]="getItemPosition($index)"
(itemClick)="onItemClick($event)"
(itemHover)="onItemHover($event)"
/>
}
</div>
</div>
}
} @else {
<div class="tl-timeline__items">
@for (item of sortedItems; track item.id) {
<tl-timeline-item
[item]="item"
[layout]="layout"
[markerSize]="markerSize"
[connectorStyle]="connectorStyle"
[animate]="animate"
[position]="getItemPosition($index)"
(itemClick)="onItemClick($event)"
(itemHover)="onItemHover($event)"
/>
}
</div>
}
}
</div>

View File

@@ -0,0 +1,57 @@
@use '../../styles/mixins' as *;
.tl-timeline {
&__loading {
display: flex;
flex-direction: column;
gap: var(--spacing-xl, 2rem);
}
&__skeleton {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-lg, 1.5rem);
align-items: start;
}
&__empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--spacing-lg, 1.5rem);
padding: var(--spacing-2xl, 3rem);
color: var(--color-text-muted, #9ca3af);
text-align: center;
p {
margin: 0;
font-size: var(--font-size-base, 1rem);
line-height: var(--line-height-relaxed, 1.625);
}
}
&__group {
margin-bottom: var(--spacing-2xl, 2.5rem);
&:last-child {
margin-bottom: 0;
}
}
&__group-label {
margin: 0 0 var(--spacing-xl, 2rem);
font-size: var(--font-size-lg, 1.125rem);
font-weight: var(--font-weight-semibold, 600);
color: var(--color-text-primary, #111827);
padding-bottom: var(--spacing-md, 1rem);
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
line-height: var(--line-height-tight, 1.25);
letter-spacing: var(--letter-spacing-tight, -0.025em);
}
&__items {
display: flex;
flex-direction: column;
}
}

View File

@@ -0,0 +1,73 @@
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SkeletonComponent, IconComponent } from '@sda/base-ui';
import { TlTimelineItemComponent } from '../tl-timeline-item';
import type { TlTimelineItem } from '../../types/timeline.types';
import type { TlTimelineLayout, TlMarkerSize, TlConnectorStyle, TlSortDirection } from '../../types/config.types';
import type { TlTimelineItemClickEvent, TlTimelineItemHoverEvent } from '../../types/event.types';
import { groupByDate } from '../../utils/date.utils';
@Component({
selector: 'tl-timeline',
standalone: true,
imports: [CommonModule, SkeletonComponent, IconComponent, TlTimelineItemComponent],
templateUrl: './tl-timeline.component.html',
styleUrl: './tl-timeline.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.tl-timeline--left]': 'layout === "left"',
'[class.tl-timeline--right]': 'layout === "right"',
'[class.tl-timeline--alternating]': 'layout === "alternating"',
},
})
export class TlTimelineComponent {
@Input({ required: true }) items!: TlTimelineItem[];
@Input() layout: TlTimelineLayout = 'left';
@Input() markerSize: TlMarkerSize = 'md';
@Input() connectorStyle: TlConnectorStyle = 'solid';
@Input() sortDirection: TlSortDirection = 'desc';
@Input() groupByDate = false;
@Input() animate = false;
@Input() loading = false;
@Input() emptyMessage = 'No timeline items';
@Output() itemClick = new EventEmitter<TlTimelineItemClickEvent>();
@Output() itemHover = new EventEmitter<TlTimelineItemHoverEvent>();
protected get sortedItems(): TlTimelineItem[] {
const items = [...this.items];
const direction = this.sortDirection;
items.sort((a, b) => {
const aTime = new Date(a.date).getTime();
const bTime = new Date(b.date).getTime();
return direction === 'asc' ? aTime - bTime : bTime - aTime;
});
return items;
}
protected get groupedItems() {
if (!this.groupByDate) return null;
return groupByDate(this.sortedItems);
}
protected getLoadingItems(): number[] {
return Array.from({ length: 5 }, (_, i) => i);
}
protected onItemClick(event: TlTimelineItemClickEvent): void {
this.itemClick.emit(event);
}
protected onItemHover(event: TlTimelineItemHoverEvent): void {
this.itemHover.emit(event);
}
protected getItemPosition(index: number): 'left' | 'right' {
if (this.layout === 'alternating') {
return index % 2 === 0 ? 'left' : 'right';
}
return 'left';
}
}

65
src/index.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Timeline Elements UI
* Main library entry point
*
* Angular components for timeline, activity feed, audit trail, changelog,
* and Gantt chart displays powered by @sda/base-ui
*
* @example
* ```typescript
* import { Component } from '@angular/core';
* import { TlTimelineComponent, type TlTimelineItem } from '@sda/timeline-elements-ui';
*
* @Component({
* standalone: true,
* imports: [TlTimelineComponent],
* template: `
* <tl-timeline
* [items]="events"
* layout="left"
* [groupByDate]="true"
* (itemClick)="onItemClick($event)"
* />
* `
* })
* export class AppComponent {
* events: TlTimelineItem[] = [
* {
* id: '1',
* title: 'Tenancy started',
* description: 'John Doe moved in',
* date: new Date('2024-01-01'),
* icon: 'home',
* status: 'completed'
* },
* {
* id: '2',
* title: 'Inspection scheduled',
* description: 'Quarterly property inspection',
* date: new Date('2024-02-15'),
* icon: 'clipboard',
* status: 'pending'
* }
* ];
*
* onItemClick(event: any) {
* console.log('Clicked:', event.item);
* }
* }
* ```
*/
// Types
export * from './types';
// Utils
export * from './utils';
// Providers
export * from './providers';
// Services
export * from './services';
// Components
export * from './components';

1
src/providers/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './timeline-config.provider';

View File

@@ -0,0 +1,56 @@
import { InjectionToken, makeEnvironmentProviders, EnvironmentProviders } from '@angular/core';
import type { TlConfig } from '../types/config.types';
export const DEFAULT_TIMELINE_CONFIG: TlConfig = {
locale: 'en-US',
dateFormat: { year: 'numeric', month: 'short', day: 'numeric' },
timeFormat: { hour: '2-digit', minute: '2-digit' },
relativeTime: true,
animate: true,
timeline: {
layout: 'left',
markerSize: 'md',
connectorStyle: 'solid',
sortDirection: 'desc',
groupByDate: false,
},
activity: {
pageSize: 20,
showUnreadIndicator: true,
groupByDate: true,
},
audit: {
pageSize: 25,
searchable: true,
filterable: true,
expandable: true,
showSeverityIcons: true,
},
gantt: {
zoomLevel: 'week',
rowHeight: 40,
showDependencies: true,
showProgress: true,
showTodayLine: true,
},
};
export const TIMELINE_CONFIG = new InjectionToken<TlConfig>('TIMELINE_CONFIG', {
providedIn: 'root',
factory: () => DEFAULT_TIMELINE_CONFIG,
});
export function provideTimelineConfig(config: Partial<TlConfig> = {}): EnvironmentProviders {
const merged: TlConfig = {
...DEFAULT_TIMELINE_CONFIG,
...config,
timeline: { ...DEFAULT_TIMELINE_CONFIG.timeline, ...config.timeline },
activity: { ...DEFAULT_TIMELINE_CONFIG.activity, ...config.activity },
audit: { ...DEFAULT_TIMELINE_CONFIG.audit, ...config.audit },
gantt: { ...DEFAULT_TIMELINE_CONFIG.gantt, ...config.gantt },
};
return makeEnvironmentProviders([
{ provide: TIMELINE_CONFIG, useValue: merged },
]);
}

View File

@@ -0,0 +1,68 @@
import { Injectable, signal, computed } from '@angular/core';
import type { TlGanttZoomLevel } from '../types/config.types';
import type { TlGanttTask, TlGanttTaskPosition, TlDateTick } from '../types/gantt.types';
import { getPixelsPerDay, generateTicks, calculateTaskPosition, calculateTodayOffset } from '../utils/gantt.utils';
import { toDate } from '../utils/date.utils';
@Injectable()
export class GanttService {
private readonly _zoomLevel = signal<TlGanttZoomLevel>('week');
private readonly _scrollOffset = signal(0);
private readonly _selectedTaskId = signal<string | null>(null);
private readonly _collapsedGroups = signal<Set<string>>(new Set());
readonly zoomLevel = this._zoomLevel.asReadonly();
readonly scrollOffset = this._scrollOffset.asReadonly();
readonly selectedTaskId = this._selectedTaskId.asReadonly();
readonly collapsedGroups = this._collapsedGroups.asReadonly();
readonly pixelsPerDay = computed(() => getPixelsPerDay(this._zoomLevel()));
private readonly zoomLevels: TlGanttZoomLevel[] = ['day', 'week', 'month', 'quarter', 'year'];
zoomIn(): void {
const current = this.zoomLevels.indexOf(this._zoomLevel());
if (current > 0) {
this._zoomLevel.set(this.zoomLevels[current - 1]);
}
}
zoomOut(): void {
const current = this.zoomLevels.indexOf(this._zoomLevel());
if (current < this.zoomLevels.length - 1) {
this._zoomLevel.set(this.zoomLevels[current + 1]);
}
}
setZoom(level: TlGanttZoomLevel): void {
this._zoomLevel.set(level);
}
selectTask(taskId: string | null): void {
this._selectedTaskId.set(taskId);
}
toggleGroupCollapse(groupId: string): void {
this._collapsedGroups.update(groups => {
const newGroups = new Set(groups);
if (newGroups.has(groupId)) {
newGroups.delete(groupId);
} else {
newGroups.add(groupId);
}
return newGroups;
});
}
calculateDateAxis(start: Date, end: Date): TlDateTick[] {
return generateTicks(start, end, this._zoomLevel());
}
calculateTaskPosition(task: TlGanttTask, chartStart: Date): TlGanttTaskPosition {
return calculateTaskPosition(task, chartStart, this.pixelsPerDay());
}
calculateTodayPosition(chartStart: Date): number {
return calculateTodayOffset(chartStart, this.pixelsPerDay());
}
}

2
src/services/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './timeline.service';
export * from './gantt.service';

View File

@@ -0,0 +1,49 @@
import { Injectable, signal, computed } from '@angular/core';
import type { TlAuditSeverity } from '../types/audit.types';
@Injectable()
export class TimelineService {
private readonly _searchTerm = signal('');
private readonly _categoryFilter = signal<string[]>([]);
private readonly _severityFilter = signal<TlAuditSeverity[]>([]);
private readonly _dateRangeFilter = signal<{ start?: Date; end?: Date } | null>(null);
readonly searchTerm = this._searchTerm.asReadonly();
readonly categoryFilter = this._categoryFilter.asReadonly();
readonly severityFilter = this._severityFilter.asReadonly();
readonly dateRangeFilter = this._dateRangeFilter.asReadonly();
readonly activeFilterCount = computed(() => {
let count = 0;
if (this._searchTerm().trim()) count++;
if (this._categoryFilter().length) count++;
if (this._severityFilter().length) count++;
if (this._dateRangeFilter()) count++;
return count;
});
readonly hasFilters = computed(() => this.activeFilterCount() > 0);
setSearch(term: string): void {
this._searchTerm.set(term);
}
setCategoryFilter(categories: string[]): void {
this._categoryFilter.set(categories);
}
setSeverityFilter(severities: TlAuditSeverity[]): void {
this._severityFilter.set(severities);
}
setDateRange(range: { start?: Date; end?: Date } | null): void {
this._dateRangeFilter.set(range);
}
clearAll(): void {
this._searchTerm.set('');
this._categoryFilter.set([]);
this._severityFilter.set([]);
this._dateRangeFilter.set(null);
}
}

2
src/styles/_index.scss Normal file
View File

@@ -0,0 +1,2 @@
@forward './tokens';
@forward './mixins';

88
src/styles/_mixins.scss Normal file
View File

@@ -0,0 +1,88 @@
// =============================================================================
// Timeline Elements UI - Mixins
// Timeline-specific mixins only. General utilities come from @sda/base-ui.
// =============================================================================
// Timeline connector line
@mixin tl-connector($style: solid) {
content: '';
position: absolute;
width: var(--tl-line-width);
background: var(--tl-line-color);
@if $style == dashed {
background: none;
border-left: var(--tl-line-width) dashed var(--tl-line-color);
} @else if $style == dotted {
background: none;
border-left: var(--tl-line-width) dotted var(--tl-line-color);
}
}
// Timeline marker
@mixin tl-marker($size: md) {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--tl-marker-bg);
color: var(--tl-marker-text);
flex-shrink: 0;
z-index: 1;
@if $size == sm {
width: var(--tl-marker-size-sm);
height: var(--tl-marker-size-sm);
font-size: var(--font-size-xs, 0.75rem);
} @else if $size == md {
width: var(--tl-marker-size-md);
height: var(--tl-marker-size-md);
font-size: var(--font-size-xs, 0.75rem);
} @else if $size == lg {
width: var(--tl-marker-size-lg);
height: var(--tl-marker-size-lg);
font-size: var(--font-size-sm, 0.875rem);
}
}
// Scrollable container with styled scrollbar
@mixin tl-scrollable {
overflow: auto;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: var(--color-bg-secondary, #f9fafb);
border-radius: var(--radius-base, 4px);
}
&::-webkit-scrollbar-thumb {
background: var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-base, 4px);
&:hover {
background: var(--color-text-muted, #9ca3af);
}
}
}
// Status color helper
@mixin tl-status-color($status) {
@if $status == completed { color: var(--tl-status-completed); }
@else if $status == active { color: var(--tl-status-active); }
@else if $status == pending { color: var(--tl-status-pending); }
@else if $status == error { color: var(--tl-status-error); }
@else if $status == warning { color: var(--tl-status-warning); }
}
// Severity color helper
@mixin tl-severity-color($severity) {
@if $severity == info { color: var(--tl-severity-info); }
@else if $severity == warning { color: var(--tl-severity-warning); }
@else if $severity == error { color: var(--tl-severity-error); }
@else if $severity == critical { color: var(--tl-severity-critical); }
@else if $severity == debug { color: var(--tl-severity-debug); }
}

57
src/styles/_tokens.scss Normal file
View File

@@ -0,0 +1,57 @@
// =============================================================================
// Timeline Elements UI - Design Tokens
// Only timeline-specific tokens. Everything else comes from @sda/base-ui.
// =============================================================================
:root {
// Timeline-specific: Connector line
--tl-line-color: var(--color-border-primary, #e5e7eb);
--tl-line-width: 2px;
// Timeline-specific: Markers
--tl-marker-size-sm: 24px;
--tl-marker-size-md: 32px;
--tl-marker-size-lg: 40px;
--tl-marker-bg: var(--color-primary-500, #3b82f6);
--tl-marker-text: var(--color-text-inverse, #ffffff);
// Timeline-specific: Status colors
--tl-status-completed: var(--color-status-success, #10b981);
--tl-status-active: var(--color-primary-500, #3b82f6);
--tl-status-pending: var(--color-text-muted, #9ca3af);
--tl-status-error: var(--color-status-error, #ef4444);
--tl-status-warning: var(--color-status-warning, #f59e0b);
// Timeline-specific: Severity colors
--tl-severity-info: var(--color-status-info, #3b82f6);
--tl-severity-warning: var(--color-status-warning, #f59e0b);
--tl-severity-error: var(--color-status-error, #ef4444);
--tl-severity-critical: #dc2626;
--tl-severity-debug: #8b5cf6;
// Timeline-specific: Changelog
--tl-change-added: var(--color-status-success, #10b981);
--tl-change-changed: var(--color-primary-500, #3b82f6);
--tl-change-fixed: #8b5cf6;
--tl-change-removed: var(--color-status-error, #ef4444);
--tl-change-deprecated: var(--color-status-warning, #f59e0b);
--tl-change-security: #dc2626;
// Timeline-specific: Gantt
--tl-gantt-bar-height: 24px;
--tl-gantt-bar-radius: var(--radius-base, 4px);
--tl-gantt-row-height: 40px;
--tl-gantt-row-border: var(--color-border-primary, #e5e7eb);
--tl-gantt-row-bg: var(--color-bg-primary, #ffffff);
--tl-gantt-row-bg-alt: var(--color-bg-secondary, #f9fafb);
--tl-gantt-weekend-bg: rgba(0, 0, 0, 0.02);
--tl-gantt-today-line: var(--color-status-error, #ef4444);
--tl-gantt-sidebar-width: 240px;
--tl-gantt-axis-height: 48px;
--tl-gantt-axis-bg: var(--color-bg-secondary, #f9fafb);
--tl-gantt-axis-border: var(--color-border-primary, #e5e7eb);
// Timeline-specific: Activity
--tl-activity-unread-dot: var(--color-primary-500, #3b82f6);
--tl-activity-avatar-size: 36px;
}

View File

@@ -0,0 +1,30 @@
export interface TlActivityItem {
id: string;
actor: TlActor;
action: string;
target?: string;
description?: string;
date: Date | string;
icon?: string;
type?: TlActivityType;
unread?: boolean;
metadata?: Record<string, unknown>;
}
export interface TlActor {
id: string;
name: string;
avatar?: string;
initials?: string;
}
export type TlActivityType =
| 'create'
| 'update'
| 'delete'
| 'comment'
| 'assign'
| 'status'
| 'upload'
| 'share'
| 'system';

21
src/types/audit.types.ts Normal file
View File

@@ -0,0 +1,21 @@
export interface TlAuditEntry {
id: string;
action: string;
category: string;
user: string;
date: Date | string;
severity: TlAuditSeverity;
description?: string;
resource?: string;
ipAddress?: string;
changes?: TlAuditChange[];
metadata?: Record<string, unknown>;
}
export interface TlAuditChange {
field: string;
oldValue: string | null;
newValue: string | null;
}
export type TlAuditSeverity = 'info' | 'warning' | 'error' | 'critical' | 'debug';

View File

@@ -0,0 +1,22 @@
export interface TlChangelogVersion {
version: string;
date: Date | string;
title?: string;
description?: string;
entries: TlChangelogEntry[];
}
export interface TlChangelogEntry {
description: string;
category: TlChangeCategory;
breaking?: boolean;
reference?: string;
}
export type TlChangeCategory =
| 'added'
| 'changed'
| 'fixed'
| 'removed'
| 'deprecated'
| 'security';

39
src/types/config.types.ts Normal file
View File

@@ -0,0 +1,39 @@
export interface TlConfig {
locale: string;
dateFormat: Intl.DateTimeFormatOptions;
timeFormat: Intl.DateTimeFormatOptions;
relativeTime: boolean;
animate: boolean;
timeline: {
layout: TlTimelineLayout;
markerSize: TlMarkerSize;
connectorStyle: TlConnectorStyle;
sortDirection: TlSortDirection;
groupByDate: boolean;
};
activity: {
pageSize: number;
showUnreadIndicator: boolean;
groupByDate: boolean;
};
audit: {
pageSize: number;
searchable: boolean;
filterable: boolean;
expandable: boolean;
showSeverityIcons: boolean;
};
gantt: {
zoomLevel: TlGanttZoomLevel;
rowHeight: number;
showDependencies: boolean;
showProgress: boolean;
showTodayLine: boolean;
};
}
export type TlTimelineLayout = 'alternating' | 'left' | 'right';
export type TlMarkerSize = 'sm' | 'md' | 'lg';
export type TlConnectorStyle = 'solid' | 'dashed' | 'dotted';
export type TlSortDirection = 'asc' | 'desc';
export type TlGanttZoomLevel = 'day' | 'week' | 'month' | 'quarter' | 'year';

86
src/types/event.types.ts Normal file
View File

@@ -0,0 +1,86 @@
import type { TlTimelineItem } from './timeline.types';
import type { TlActivityItem, TlActor } from './activity.types';
import type { TlAuditEntry } from './audit.types';
import type { TlChangelogVersion, TlChangelogEntry } from './changelog.types';
import type { TlGanttTask } from './gantt.types';
export interface TlTimelineItemClickEvent {
item: TlTimelineItem;
index: number;
}
export interface TlTimelineItemHoverEvent {
item: TlTimelineItem;
index: number;
}
export interface TlActivityClickEvent {
item: TlActivityItem;
}
export interface TlActorClickEvent {
actor: TlActor;
}
export interface TlActivityLoadMoreEvent {
currentCount: number;
pageSize: number;
}
export interface TlAuditEntryClickEvent {
entry: TlAuditEntry;
}
export interface TlAuditFilterChangeEvent {
searchTerm: string;
categories: string[];
severities: string[];
dateRange: { start?: Date; end?: Date } | null;
}
export interface TlAuditPageChangeEvent {
page: number;
pageSize: number;
}
export interface TlVersionClickEvent {
version: TlChangelogVersion;
}
export interface TlChangelogEntryClickEvent {
entry: TlChangelogEntry;
version: TlChangelogVersion;
}
export interface TlGanttTaskClickEvent {
task: TlGanttTask;
}
export interface TlGanttTaskResizeEvent {
task: TlGanttTask;
newStartDate?: Date;
newEndDate?: Date;
}
export interface TlGanttTaskMoveEvent {
task: TlGanttTask;
newStartDate: Date;
newEndDate: Date;
}
export interface TlGanttZoomChangeEvent {
level: string;
}
export interface TlGanttScrollEvent {
scrollLeft: number;
scrollTop: number;
}
export interface TlSearchChangeEvent {
term: string;
}
export interface TlSortToggleEvent {
direction: 'asc' | 'desc';
}

35
src/types/gantt.types.ts Normal file
View File

@@ -0,0 +1,35 @@
export interface TlGanttTask {
id: string;
name: string;
startDate: Date | string;
endDate: Date | string;
progress?: number;
status?: TlGanttTaskStatus;
color?: string;
group?: string;
parentId?: string;
assignee?: string;
metadata?: Record<string, unknown>;
}
export interface TlGanttDependency {
fromTaskId: string;
toTaskId: string;
type?: TlDependencyType;
}
export type TlGanttTaskStatus = 'not-started' | 'in-progress' | 'completed' | 'delayed' | 'on-hold';
export type TlDependencyType = 'finish-to-start' | 'start-to-start' | 'finish-to-finish' | 'start-to-finish';
export interface TlGanttTaskPosition {
left: number;
width: number;
}
export interface TlDateTick {
date: Date;
label: string;
position: number;
isWeekend: boolean;
isMajor: boolean;
}

7
src/types/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export * from './config.types';
export * from './timeline.types';
export * from './activity.types';
export * from './audit.types';
export * from './changelog.types';
export * from './gantt.types';
export * from './event.types';

View File

@@ -0,0 +1,19 @@
export interface TlTimelineItem {
id: string;
title: string;
description?: string;
date: Date | string;
icon?: string;
iconColor?: string;
status?: TlTimelineStatus;
category?: string;
tags?: string[];
metadata?: Record<string, unknown>;
}
export type TlTimelineStatus = 'completed' | 'active' | 'pending' | 'error' | 'warning';
export interface TlTimelineGroup {
label: string;
items: TlTimelineItem[];
}

97
src/utils/date.utils.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { TlTimelineGroup } from '../types/timeline.types';
export function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
export function formatDate(value: Date | string, locale = 'en-US', options?: Intl.DateTimeFormatOptions): string {
const date = toDate(value);
return date.toLocaleDateString(locale, options ?? { year: 'numeric', month: 'short', day: 'numeric' });
}
export function formatTime(value: Date | string, locale = 'en-US', options?: Intl.DateTimeFormatOptions): string {
const date = toDate(value);
return date.toLocaleTimeString(locale, options ?? { hour: '2-digit', minute: '2-digit' });
}
export function formatRelativeTime(value: Date | string): string {
const date = toDate(value);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return formatDate(value);
}
export function formatSmartTime(value: Date | string, locale = 'en-US'): string {
const date = toDate(value);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = diffMs / (1000 * 60 * 60);
if (diffHours < 24) return formatRelativeTime(value);
if (diffHours < 48) return 'Yesterday ' + formatTime(value, locale);
return formatDate(value, locale) + ' ' + formatTime(value, locale);
}
export function getDateLabel(value: Date | string): string {
const date = toDate(value);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diffDays = Math.floor((today.getTime() - target.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return formatDate(value);
}
export function groupByDate<T extends { date: Date | string }>(items: T[]): { label: string; items: T[] }[] {
const groups = new Map<string, T[]>();
for (const item of items) {
const label = getDateLabel(item.date);
const group = groups.get(label);
if (group) {
group.push(item);
} else {
groups.set(label, [item]);
}
}
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
}
export function diffInDays(start: Date | string, end: Date | string): number {
const s = toDate(start);
const e = toDate(end);
return Math.ceil((e.getTime() - s.getTime()) / (1000 * 60 * 60 * 24));
}
export function isWeekend(date: Date | string): boolean {
const d = toDate(date);
const day = d.getDay();
return day === 0 || day === 6;
}
export function isToday(date: Date | string): boolean {
const d = toDate(date);
const now = new Date();
return d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
}
export function addDays(date: Date | string, days: number): Date {
const d = new Date(toDate(date));
d.setDate(d.getDate() + days);
return d;
}

77
src/utils/filter.utils.ts Normal file
View File

@@ -0,0 +1,77 @@
import type { TlAuditEntry, TlAuditSeverity } from '../types/audit.types';
import { toDate } from './date.utils';
export function searchItems<T>(items: T[], term: string, fields: (keyof T)[]): T[] {
if (!term.trim()) return items;
const lower = term.toLowerCase();
return items.filter(item =>
fields.some(field => {
const value = item[field];
if (typeof value === 'string') {
return value.toLowerCase().includes(lower);
}
return false;
})
);
}
export function filterAuditEntries(
entries: TlAuditEntry[],
options: {
searchTerm?: string;
categories?: string[];
severities?: TlAuditSeverity[];
dateRange?: { start?: Date; end?: Date } | null;
}
): TlAuditEntry[] {
let result = entries;
if (options.searchTerm?.trim()) {
result = searchItems(result, options.searchTerm, ['action', 'user', 'description', 'resource', 'category']);
}
if (options.categories?.length) {
result = result.filter(e => options.categories!.includes(e.category));
}
if (options.severities?.length) {
result = result.filter(e => options.severities!.includes(e.severity));
}
if (options.dateRange) {
result = filterByDateRange(result, 'date', options.dateRange.start, options.dateRange.end);
}
return result;
}
export function filterByField<T>(items: T[], field: keyof T, values: unknown[]): T[] {
if (!values.length) return items;
return items.filter(item => values.includes(item[field]));
}
export function filterByDateRange<T extends Record<string, any>>(
items: T[],
dateField: keyof T,
start?: Date,
end?: Date
): T[] {
return items.filter(item => {
const date = toDate(item[dateField] as Date | string);
if (start && date < start) return false;
if (end && date > end) return false;
return true;
});
}
export function extractUniqueValues<T>(items: T[], field: keyof T): string[] {
const values = new Set<string>();
for (const item of items) {
const value = item[field];
if (typeof value === 'string') {
values.add(value);
}
}
return Array.from(values).sort();
}

131
src/utils/gantt.utils.ts Normal file
View File

@@ -0,0 +1,131 @@
import type { TlGanttTask, TlGanttDependency, TlGanttTaskPosition, TlDateTick } from '../types/gantt.types';
import type { TlGanttZoomLevel } from '../types/config.types';
import { toDate, diffInDays, isWeekend, addDays } from './date.utils';
export function calculateDateRange(tasks: TlGanttTask[]): { start: Date; end: Date } {
if (tasks.length === 0) {
const now = new Date();
return { start: now, end: addDays(now, 30) };
}
let minDate = toDate(tasks[0].startDate);
let maxDate = toDate(tasks[0].endDate);
for (const task of tasks) {
const start = toDate(task.startDate);
const end = toDate(task.endDate);
if (start < minDate) minDate = start;
if (end > maxDate) maxDate = end;
}
return {
start: addDays(minDate, -2),
end: addDays(maxDate, 2),
};
}
export function getPixelsPerDay(zoomLevel: TlGanttZoomLevel): number {
switch (zoomLevel) {
case 'day': return 60;
case 'week': return 20;
case 'month': return 6;
case 'quarter': return 2;
case 'year': return 1;
}
}
export function generateTicks(start: Date, end: Date, zoomLevel: TlGanttZoomLevel): TlDateTick[] {
const ticks: TlDateTick[] = [];
const ppd = getPixelsPerDay(zoomLevel);
const current = new Date(start);
let position = 0;
while (current <= end) {
const isMajor = zoomLevel === 'day'
? current.getDay() === 1
: zoomLevel === 'week'
? current.getDate() === 1
: current.getMonth() % 3 === 0 && current.getDate() === 1;
const label = zoomLevel === 'day' || zoomLevel === 'week'
? current.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
: current.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
ticks.push({
date: new Date(current),
label,
position,
isWeekend: isWeekend(current),
isMajor,
});
current.setDate(current.getDate() + 1);
position += ppd;
}
return ticks;
}
export function calculateTaskPosition(
task: TlGanttTask,
chartStart: Date,
pixelsPerDay: number
): TlGanttTaskPosition {
const start = toDate(task.startDate);
const end = toDate(task.endDate);
const left = diffInDays(chartStart, start) * pixelsPerDay;
const width = Math.max(diffInDays(start, end) * pixelsPerDay, pixelsPerDay);
return { left, width };
}
export function calculateChartWidth(start: Date, end: Date, pixelsPerDay: number): number {
return diffInDays(start, end) * pixelsPerDay;
}
export function calculateDependencyPath(
from: TlGanttTaskPosition,
fromRowIndex: number,
to: TlGanttTaskPosition,
toRowIndex: number,
rowHeight: number
): string {
const startX = from.left + from.width;
const startY = fromRowIndex * rowHeight + rowHeight / 2;
const endX = to.left;
const endY = toRowIndex * rowHeight + rowHeight / 2;
const midX = startX + (endX - startX) / 2;
return `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`;
}
export function sortTasksHierarchically(tasks: TlGanttTask[]): TlGanttTask[] {
const roots = tasks.filter(t => !t.parentId);
const childMap = new Map<string, TlGanttTask[]>();
for (const task of tasks) {
if (task.parentId) {
const children = childMap.get(task.parentId) || [];
children.push(task);
childMap.set(task.parentId, children);
}
}
const result: TlGanttTask[] = [];
const addWithChildren = (task: TlGanttTask): void => {
result.push(task);
const children = childMap.get(task.id) || [];
for (const child of children) {
addWithChildren(child);
}
};
for (const root of roots) {
addWithChildren(root);
}
return result;
}
export function calculateTodayOffset(chartStart: Date, pixelsPerDay: number): number {
return diffInDays(chartStart, new Date()) * pixelsPerDay;
}

3
src/utils/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './date.utils';
export * from './gantt.utils';
export * from './filter.utils';

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"],
"useDefineForClassFields": false
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

18
tsconfig.lib.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.stories.ts"
],
"angularCompilerOptions": {
"compilationMode": "partial"
}
}