diff --git a/package-lock.json b/package-lock.json index 51a5024..aa76217 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", + "luxon": "^3.6.1", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -27,6 +28,7 @@ "@angular/cli": "^19.0.2", "@angular/compiler-cli": "^19.0.0", "@types/jasmine": "~5.1.0", + "@types/luxon": "^3.6.2", "jasmine-core": "~5.4.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", @@ -5054,6 +5056,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz", + "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -9845,6 +9854,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.12", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", diff --git a/package.json b/package.json index 44b57ec..fb16e6b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", + "luxon": "^3.6.1", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -29,6 +30,7 @@ "@angular/cli": "^19.0.2", "@angular/compiler-cli": "^19.0.0", "@types/jasmine": "~5.1.0", + "@types/luxon": "^3.6.2", "jasmine-core": "~5.4.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", @@ -37,4 +39,4 @@ "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.6.2" } -} \ No newline at end of file +} diff --git a/src/app/app.component.html b/src/app/app.component.html index 779fa92..03412db 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -48,4 +48,26 @@ + + +
+ +
+ + + + + \ No newline at end of file diff --git a/src/app/app.component.ts b/src/app/app.component.ts index b65713e..dc8877d 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -12,12 +12,18 @@ import { MatChipsModule } from '@angular/material/chips'; import { AsyncPipe } from '@angular/common'; import { MatSelectModule } from '@angular/material/select'; import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { SelectFilterComponent } from "./select-filter/select-filter.component"; +import { GanttComponent, GanttItem } from "./gantt/gantt.component"; +import { GrossNetCalculatorComponent } from "./gross-net-calculator/gross-net-calculator.component"; +import { GrossNetCalculatorInputDirective } from './gross-net-calculator/gross-net-calculator-input.directive'; +import { GrossNetCalculatorToggleButtonComponent } from "./gross-net-calculator/toggle-button/toggle-button.component"; @Component({ selector: 'app-root', - imports: [RouterOutlet, MatToolbarModule, MatIconModule, CommentBoxComponent, MatFormFieldModule, MatInputModule, ReactiveFormsModule, ProgressStepperComponent, MatOptionModule, MatSelectModule, MatAutocompleteModule, MatChipsModule, AsyncPipe, FormsModule, SelectFilterComponent], + imports: [RouterOutlet, MatToolbarModule, GrossNetCalculatorInputDirective, MatIconModule, MatButtonModule, CommentBoxComponent, MatFormFieldModule, MatInputModule, ReactiveFormsModule, ProgressStepperComponent, MatOptionModule, MatSelectModule, MatAutocompleteModule, MatChipsModule, AsyncPipe, FormsModule, SelectFilterComponent, GanttComponent, GrossNetCalculatorComponent, GrossNetCalculatorToggleButtonComponent], + providers: [GrossNetCalculatorInputDirective], templateUrl: './app.component.html', styleUrl: './app.component.scss' }) @@ -45,4 +51,48 @@ export class AppComponent { unselectAll(): void { this.selectedItemsList.setValue([]); } + + + ganttItems: GanttItem[] = [{ + id: '000', + title: 'Test 1', + items: [{ + title: '124', + start: 1743487875, + duration_sec: 7200, + }, { + title: 'F242', + start: 1743660675, + duration_sec: 16 * 3600, + } + ] + }, + { + id: '001', + title: 'Test 2', + items: [{ + title: '124', + start: 1743487875, + duration_sec: 7200, + }, { + title: 'F242', + start: 1743660675, + duration_sec: 72000, + } + ] + }, + { + id: 'dfaf', + title: 'WeekTest', + items: [ + { title: '1', start: 1744005600, duration_sec: 72000 }, + { title: '2', start: 1744092000, duration_sec: 72000 }, + { title: '3', start: 1744178400, duration_sec: 72000 }, + { title: '4', start: 1744264800, duration_sec: 72000 }, + { title: '5', start: 1744351200, duration_sec: 72000 }, + { title: '6', start: 1744437600, duration_sec: 72000 }, + { title: '7', start: 1744524000, duration_sec: 72000 } + ] + } + ] } diff --git a/src/app/gantt/gantt.component.html b/src/app/gantt/gantt.component.html new file mode 100644 index 0000000..9fdac69 --- /dev/null +++ b/src/app/gantt/gantt.component.html @@ -0,0 +1,214 @@ +
+
+
+
{{month}}
+
KW {{week}}
+
+
+ + @for (item of items; track item.id) { +
{{item.title}}
+ } + +
+
+ + @for(day of [0,1,2,3,4,5,6]; track day) { +
+
{{getDate(day)}}
+
{{getWeekday(day)}}
+
+ } + + @for (item of items; track item.id) { +
+
+ + @for(day of [0,1,2,3,4,5,6]; track day) { + @for (time of getDayWorkTime(day); track $index) { +
+ }} + + @for (bar of item.items; track $index) { +
+ {{bar.title}}
+ } +
+ @for(day of [0,1,2,3,4,5,6]; track day) { +
+ + @for (time of getDayWorkTime(day); track $index) { +
+ } +
+ } +
+ } + + +
+
+ + + +
+
+
+ + +
+
{{month}}
+
+ @for (week of intervalArray; track week.title) { +
{{week.title}}
+ } +
+
+
+ @for (item of items; track item.id) { +
{{item.title}}
+ } +
+
+
+ @for(day of [0,1,2,3,4,5,6,7]; track day) { +
+
+ {{getDate(day)}} + {{getWeekday(day)}} + +
+ @for (hour of hourArray; track hour) { + {{hour}} + } +
+
+
+ } +
+ + + @for (item of items; track item.id) { +
+ +
+ @for(day of [0,1,2,3,4,5,6,7]; track day) { + @for (time of getDayWorkTime(day); track $index) { +
+
+ }} +
+ +
+ @for (bar of item.items; track $index) { +
+ {{bar.title}}
+ } +
+
+ } +
+
+ + + + + + + + + + + + + + + + + +
+
+
+ + + +
+
{{view.month}}
+
+ @for (cell of view.intervalDescriptor; track cell.title) { +
{{cell.title}}
+ } +
+
+
+ @for (item of items; track item.id) { +
{{item.title}}
+ } +
+
+
+ @for(item of view.cellDescriptors; track item.title) { +
+
+
+ {{item.title}} + {{item.subTitle}} + +
+ @for (item of view.tickDescriptors; track item.title) { + {{item.title}} + } +
+
+
+
+ } +
+ + + @for (item of items; track item.id) { +
+ +
+ @for(day of view.cellDescriptors; track day.title) { + @for (time of day.available; track $index) { +
+ }} +
+ +
+ @for (bar of item.items; track $index) { +
+ {{bar.title}}
+ } +
+
+ } +
+
\ No newline at end of file diff --git a/src/app/gantt/gantt.component.scss b/src/app/gantt/gantt.component.scss new file mode 100644 index 0000000..93b4545 --- /dev/null +++ b/src/app/gantt/gantt.component.scss @@ -0,0 +1,308 @@ +.container { + position: relative; + width: 100%; + background-color: #ccc; + + display: grid; + grid-template-columns: max-content 1fr; + // column-gap: 1rem; + + --bar-margin: 5px; + --row-height: 1.5rem; + + --header-height: 4rem; +} + +.header { + height: var(--header-height); + display: flex; + + display: flex; + flex-flow: column; + justify-content: center; + + div { + display: flex; + justify-content: center; + padding: 0.25rem; + } +} + +.info-box { + grid-column-start: 2; + + .interval-descriptor { + display: flex; + border: solid thin #fff; + border-collapse: collapse; + + font-size: 0.8rem; + + div { + background-color: aqua; + border-right: solid thin #fff; + padding: 0.05rem; + box-sizing: border-box; + + display: flex; + justify-content: center; + + &:nth-child(2n) { + background-color: azure; + } + } + + :last-child { + flex-grow: 1; + border-right: none; + } + } +} + +.table { + grid-row-start: 2; + margin-top: var(--header-height); + + .table-row { + display: flex; + align-items: center; + + height: calc(var(--row-height) + 2 * var(--bar-margin)); + border-top: solid thin #fff; + + padding-right: 1rem; + padding-left: 0.5rem; + } +} + +.bars { + position: relative; + width: 100%; + display: grid; + grid-template-columns: repeat(7, 1fr); + + overflow: hidden; + + .header { + border-left: solid thin #fff; + } + + .grid { + position: relative; + border-left: solid thin #fff; + box-sizing: border-box; + + height: calc(var(--row-height) + 2 * var(--bar-margin)); + + .open { + position: absolute; + height: 100%; + background-color: orange; + } + } + + .bar-row { + position: relative; + grid-column: span 7; + + border-top: solid thin #fff; + font-size: 0.75rem; + + .bar-container { + position: absolute; + top: var(--bar-margin); + left: 0; + z-index: 5; + + .bar { + position: absolute; + top: 0; + left: 0; + height: var(--row-height); + background-color: blue; + color: #fff; + box-sizing: border-box; + + border-radius: 0.2rem; + + display: flex; + align-items: center; + justify-content: center; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + border: solid thin #fff; + z-index: 99; + cursor: default; + } + } + } + } +} + +.new { + --bg-color: rgb(247, 247, 247); //#fafafa; + --separator-color: rgb(238, 238, 238); + --bg-weekend-color: #e7dada; + --bg-open-color: #fff; + --bg-closed-color: rgb(208, 208, 208); + + --bg-week-color-even: #98afc7; + --bg-week-color-odd: #8391a1; + + background-color: var(--bg-color); + + border: solid thin black; + box-sizing: border-box; + + .interval-descriptor { + div { + background-color: var(--bg-week-color-even); + color: #fff; + + &:nth-child(2n) { + background-color: var(--bg-week-color-odd); + } + } + } + + .bars { + display: flex; + flex-direction: column; + + padding-top: var(--header-height); + + .open { + height: calc(var(--row-height) + 2 * var(--bar-margin)); + position: absolute; + background-color: var(--bg-open-color); + top: 0; + } + + .bar-row { + height: calc(var(--row-height) + 2 * var(--bar-margin)); + border-color: var(--separator-color); + background-color: var(--bg-closed-color); + } + } + + .calender-head { + display: flex; + height: var(--header-height); + position: absolute; + top: 0; + height: 100%; + + .spacer { + flex-grow: 1; + } + + .cell { + position: relative; + height: 100%; + box-sizing: border-box; + + > div { + position: absolute; + height: 100%; + width: 100%; + border-right: solid thin var(--separator-color); + z-index: 4; + } + + &.weekend { + background-color: var(--bg-weekend-color); + } + + &.sunday { + background-color: var(--bg-weekend-color); + color: red; + } + + &:first-child { + border-left: solid thin var(--separator-color); + // shift first element if necessary + .ticks { + transform: translate(var(--first-interval-shift)); + } + } + + &:last-child { + .tick { + color: red; + &:last-child { + border-right: none; + } + } + } + + &:not(:first-child) { + .content { + border-left: none; + + .ticks { + margin-left: 1px; + } + } + } + + .day-info { + &:first-child { + padding-top: 0.4rem; + } + + padding-top: 0.1rem; + padding-left: 0.25rem; + font-size: 0.9rem; + font-weight: 500; + } + + .content { + height: var(--header-height); + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + z-index: 1; + + // border: solid thin #000; + // border-top: none; + // box-sizing: border-box; + } + + .ticks { + display: flex; + color: #000; + + :first-child { + border-left: none; + } + + .tick { + padding-left: 0.2rem; + box-sizing: border-box; + font-size: 0.7rem; + border-right: solid thin #fff; + border-color: #000; + } + } + } + } + + .table { + margin-top: var(--header-height); + + .table-row { + border-color: var(--separator-color); + } + } + + .month-display { + padding: 0.2rem; + font-weight: 500; + font-size: 0.95rem; + } +} diff --git a/src/app/gantt/gantt.component.spec.ts b/src/app/gantt/gantt.component.spec.ts new file mode 100644 index 0000000..3e2f659 --- /dev/null +++ b/src/app/gantt/gantt.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GanttComponent } from './gantt.component'; + +describe('GanttComponent', () => { + let component: GanttComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GanttComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GanttComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/gantt/gantt.component.ts b/src/app/gantt/gantt.component.ts new file mode 100644 index 0000000..ae3c455 --- /dev/null +++ b/src/app/gantt/gantt.component.ts @@ -0,0 +1,203 @@ +import { ChangeDetectorRef, Component, ElementRef, Input, ViewChild } from '@angular/core'; +import { Observable } from 'rxjs'; +import { DateTime } from 'luxon'; +import { GanttView } from './views/view'; + +export interface GanttItem { + id: string; + title: string; + items: GanttBarElement[]; +} + +export interface GanttBarElement { + title: string, + start: number, + duration_sec: number, +} + +interface WorkTime { + start_day_sec: number, + duration_sec: number, +} + +interface IntervalDescriptor { + title: string; + duration: number, +} + +@Component({ + selector: 'app-gantt', + imports: [], + templateUrl: './gantt.component.html', + styleUrl: './gantt.component.scss' +}) +export class GanttComponent { + @ViewChild('barContainer') _barContainer!: ElementRef; + + @Input() items: GanttItem[] = []; + + view: GanttView; + + globalBaseFontSize: number = 16; + barContainerWidth: number = 0; + + startDate: DateTime; + + weekOpenTime: WorkTime[][] = [ + [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }], + [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }], + [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }], + [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }], + [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }], + [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }], + [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }], + [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }], + ]; + + constructor(private changeDetectorRef: ChangeDetectorRef) { + this.startDate = DateTime.now().minus({ weeks: 1 }).startOf('week').startOf('day').setLocale('de'); + + this.view = new GanttView(); + + let htmlRoot: HTMLElement = document.getElementsByTagName("html")[0]; + } + + ngAfterViewInit(): void { + // get base font size + let fontSize = window.getComputedStyle(this._barContainer.nativeElement).getPropertyValue('font-size'); + this.globalBaseFontSize = parseInt(fontSize.slice(0, fontSize.length - 2)); + this.view.globalBaseFontSize = parseInt(fontSize.slice(0, fontSize.length - 2)); + + // observe resizing of the bar container + new Observable((observer) => { + const resizeObserver = new ResizeObserver(() => { + observer.next(); + }); + resizeObserver.observe(this._barContainer.nativeElement); + }).subscribe((change) => { + this.barContainerWidth = this._barContainer.nativeElement.offsetWidth; + this.view.width = this._barContainer.nativeElement.offsetWidth; + + this.changeDetectorRef.detectChanges(); + }); + } + + changeViewType(value: string): void { + this.view.changeView(value) + } + + getDate(day: number): string { + return this.startDate.plus({ days: day }).toFormat('dd.MM'); + } + + getWeekday(day: number): string { + return this.startDate.plus({ days: day }).toFormat('EEE'); + } + + getDayDuration(day: number): number { + if (day === 0) { + return Math.abs(this.startDate.diff(this.startDate.endOf('day')).as('seconds')); + } else { + return 24 * 3600; + } + } + + getDayWorkTime(day: number): WorkTime[] { + return this.weekOpenTime[day]; + } + + durationToWidth(seconds: number, opts?: { offsetCorrection?: number }): string { + let correction = opts?.offsetCorrection ?? 0; + return `${this.pixelPerSec * seconds + correction}px`; + } + + startToOffset(date: DateTime | number, opts?: { dstCorrection?: boolean, offsetCorrection?: number }): string { + // generate date from timestamp if necessary + if (typeof date === 'number') + date = DateTime.fromSeconds(date); + // calculate offset + let offset = this.dateDiff(date, this.startDate, opts) * this.pixelPerSec; + let correction = opts?.offsetCorrection ?? 0; + + return `${offset + correction}px`; + } + + dateDiff(date1: DateTime, date2: DateTime, opts?: { dstCorrection?: boolean }): number { + // calculate offset + let diff = date1.diff(date2, 'seconds').as('seconds'); + + //modify if DST (Daylight Saving Time) has changed // TODO Check if DST Switch is problematic + if (opts?.dstCorrection) { + if (date1.isInDST && !date2.isInDST) { + diff += 3600; + } else if (date1.isInDST && !date2.isInDST) { + diff -= 3600; + } + } + + return diff; + } + + firstDayIntervalShift(day: number): string { + return `${this.getDayDuration(day) / this.segmentBaseSeconds * 100 - 100}%`; + } + + moveBack(): void { + this.startDate = this.startDate.minus({ days: 3.5 }); + } + moveNext(): void { + this.startDate = this.startDate.plus({ days: 3.5 }); + } + + get segmentBaseSeconds(): number { + return 3600 * 24; + } + + get pixelPerSec(): number { + const SecondsPerWeek = 3600 * 24 * 7; + return this.barContainerWidth / SecondsPerWeek; + } + + get month(): string { + return this.startDate.toFormat('MMMM yyyy') + + } + + get week(): string { + return this.startDate.toFormat('nn') + } + + // TODO do not recalculate for every element but only once when window is resized or forward or backword is pressed + get hourArray(): number[] { + let base = 3600 * 24; + let intervals = base * this.pixelPerSec / (2 * 0.8 * this.globalBaseFontSize); + + // TODO make calculation more sane!!! + let test = Math.floor(24 / Math.ceil((2 * 0.8 * this.globalBaseFontSize) / (base * this.pixelPerSec / 24))); + + return Array.from({ length: test }, (_, i) => i * 24 / test) + // return Array.from({ length: 23 }, (_, i) => i + 1) + } + + get intervalArray(): IntervalDescriptor[] { + let start = this.startDate.get('weekNumber'); + let end = this.startDate.plus({ week: 1 }).minus({ seconds: 10 }).get('weekNumber'); + let arr: IntervalDescriptor[] = []; + + for (let i = start; i <= end; i++) { + if (i === start && start !== end) { + arr.push({ + title: `KW ${i}`, + duration: this.dateDiff(this.startDate.endOf('week'), this.startDate, { dstCorrection: true }) //this.startDate.endOf('week').diff(this.startDate).as('seconds') + }) + } else if (i === end) { + arr.push({ + title: `KW ${i}`, + duration: 0 // last element grows automatically + }) + } else { } + } + + return arr; + } +} diff --git a/src/app/gantt/views/base.ts b/src/app/gantt/views/base.ts new file mode 100644 index 0000000..96e96eb --- /dev/null +++ b/src/app/gantt/views/base.ts @@ -0,0 +1,70 @@ +import { DateTime, Settings } from "luxon"; +import { DescriptorSet } from "./descriptors"; + +export const defaultAvailable = [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600, onSaturday: false, onSunOrHoliday: false }]; + + +export abstract class GanttViewType { + start: DateTime; // start date of this gantt view + viewDuration: number; // duration in seconds between start and end of this gantt view + cellWidth: number; // width of a single cell (e.g. a day in the week view) + + pixelPerSecond: number = 0; // width in pixel of one second of the gantt view + globalBaseFontSize: number = 16; // font size that is used to calculate tick display + + constructor() { + // defining time settings + Settings.defaultZone = 'utc'; + + // default view is week + let date: DateTime = DateTime.now(); + + this.start = date.startOf('week').startOf('day'); + this.viewDuration = this.start.endOf('week').endOf('day').diff(this.start).as('seconds'); + this.cellWidth = this.viewDuration / 7; + } + + // update view (e.g. resizing, interval change) + update(opts?: { width?: number, action?: 'next' | 'back', today?: boolean }): DescriptorSet { + // was panel resized? + if (opts?.width) + this.pixelPerSecond = opts.width / this.viewDuration; + // did the interval change? + if (opts?.action) + this.calculateIntervalShift(opts.action) + + // recreate descriptors + return this.createDescriptorSet(); + } + + // convert duration to width in pixels + durationToWidth(seconds: number, opts?: { offsetCorrection?: number }): string { + let correction = opts?.offsetCorrection ?? 0; + return `${this.pixelPerSecond * seconds + correction}px`; + } + + // get offset in pixels + getOffset(date: DateTime | number, opts?: { dstCorrection?: boolean, offsetCorrection?: number }): string { + // generate date from timestamp if necessary + if (typeof date === 'number') + date = DateTime.fromSeconds(date); + // calculate offset + let offset = date.diff(this.start).as('seconds') * this.pixelPerSecond; + let correction = opts?.offsetCorrection ?? 0; + + return `${offset + correction}px`; + } + + + isDaySunOrHoliday(date: DateTime): boolean { + return date.weekday === 7 + } + isDayOnWeekend(date: DateTime): boolean { + return date.weekday > 5; + } + + createDescriptorSet(): DescriptorSet { return { interval: [], cell: [], tick: [] } }; + calculateIntervalShift(action: 'next' | 'back'): void { } + setToday(): void { } + +} \ No newline at end of file diff --git a/src/app/gantt/views/day.ts b/src/app/gantt/views/day.ts new file mode 100644 index 0000000..07a6abf --- /dev/null +++ b/src/app/gantt/views/day.ts @@ -0,0 +1,85 @@ +import { DateTime } from "luxon"; +import { defaultAvailable, GanttViewType } from "./base"; +import { CellDescriptor, DescriptorSet, IntervalDescriptor, TickDescriptor } from "./descriptors"; + + +export class GanttViewDay extends GanttViewType { + + constructor(date: DateTime) { + super() + + this.start = date.startOf('day'); + this.viewDuration = this.start.endOf('day').diff(this.start).as('seconds'); + this.cellWidth = this.viewDuration / 1; + } + + override setToday(): void { + this.start = DateTime.now().startOf('day'); + } + + override calculateIntervalShift(action: "next" | "back"): void { + if (action === 'next') + this.start = this.start.plus({ days: 0.5 }); + else + this.start = this.start.minus({ days: 0.5 }); + } + + override createDescriptorSet(): DescriptorSet { return { interval: this.createIntervals(), cell: this.createCells(), tick: this.createTicks() } }; + + // get duration of a day in seconds + dayDuration(day?: number): number { + // check if first day is only a fraction of a day + if (day === 0) { + return Math.abs(this.start.diff(this.start.endOf('day')).as('seconds')); + } else { + return 24 * 3600; + } + }; + + private createIntervals(): IntervalDescriptor[] { + let weeks = [this.start.get('weekNumber')]; + let end2 = this.start.plus({ days: 1 }).minus({ seconds: 10 }); + if (!this.start.hasSame(end2, 'week')) + weeks.push(end2.weekNumber) + + return weeks.map((item, index) => { + return { + title: `KW ${item}`, + width: index === weeks.length - 1 ? '0px' : this.durationToWidth(this.start.endOf('week').diff(this.start).as('seconds')) + } + }) + } + + private createCells(): CellDescriptor[] { + return Array.from({ length: 2 }, (_, day) => { + let date = this.start.plus({ days: day }); + const duration = this.dayDuration(day); + const sunOrHoliday = this.isDaySunOrHoliday(date); + const weekend = this.isDayOnWeekend(date); + + return { + title: date.toFormat('dd.MM'), + subTitle: date.toFormat('EEE'), + width: this.durationToWidth(duration), + firstTickOffset: `${duration / this.cellWidth * 100 - 100}%`, + sunOrHoliday, + weekend, + available: defaultAvailable.filter(item => ((!weekend && !sunOrHoliday) || (weekend && !sunOrHoliday) && item.onSaturday) || (sunOrHoliday && item.onSunOrHoliday)).map(item => { + return { offset: this.getOffset(date.startOf('day').plus({ seconds: item.start_day_sec })), duration: this.durationToWidth(item.duration_sec) } + }) + } + }); + } + + private createTicks(): TickDescriptor[] { + // TODO make calculation more sane!!! + let test = Math.floor(24 / Math.ceil((2 * 0.8 * this.globalBaseFontSize) / (this.dayDuration() * this.pixelPerSecond / 24))); + + return Array.from({ length: test }, (_, i) => i * 24 / test).map(item => { + return { + title: item.toString(), + width: this.durationToWidth(24 / test * 3600) + } + }); + } +} \ No newline at end of file diff --git a/src/app/gantt/views/descriptors.ts b/src/app/gantt/views/descriptors.ts new file mode 100644 index 0000000..b00301f --- /dev/null +++ b/src/app/gantt/views/descriptors.ts @@ -0,0 +1,27 @@ +export interface DescriptorSet { + interval: IntervalDescriptor[], + cell: CellDescriptor[], + tick: TickDescriptor[], +} + +interface BasicDescriptor { + title: string; + width: string; +} + +interface AvailableTime { + offset: string, + duration: string, +} + +export interface IntervalDescriptor extends BasicDescriptor { } + +export interface CellDescriptor extends BasicDescriptor { + subTitle: string; + firstTickOffset: string, + sunOrHoliday: boolean; + weekend: boolean; + available: AvailableTime[]; +} + +export interface TickDescriptor extends BasicDescriptor { } \ No newline at end of file diff --git a/src/app/gantt/views/month.ts b/src/app/gantt/views/month.ts new file mode 100644 index 0000000..523dfa7 --- /dev/null +++ b/src/app/gantt/views/month.ts @@ -0,0 +1,75 @@ +import { DateTime } from "luxon"; +import { defaultAvailable, GanttViewType } from "./base"; +import { CellDescriptor, DescriptorSet, IntervalDescriptor, TickDescriptor } from "./descriptors"; + + +export class GanttViewMonth extends GanttViewType { + + constructor(date: DateTime) { + super() + + this.start = date.startOf('week').startOf('day'); + this.viewDuration = this.start.plus({ days: 4 * 7 - 1 }).endOf('day').diff(this.start).as('seconds'); + this.cellWidth = this.viewDuration / (4 * 7); + } + + override setToday(): void { + this.start = DateTime.now().startOf('week').startOf('day'); + } + + override calculateIntervalShift(action: "next" | "back"): void { + if (action === 'next') + this.start = this.start.plus({ weeks: 2 }); + else + this.start = this.start.minus({ weeks: 2 }); + } + + override createDescriptorSet(): DescriptorSet { return { interval: this.createIntervals(), cell: this.createCells(), tick: this.createTicks() } }; + + // get duration of a day in seconds + dayDuration(): number { + return 24 * 3600; + }; + + private createIntervals(): IntervalDescriptor[] { + return Array.from({ length: 4 }, (_, i) => { + return { + title: `KW ${this.start.plus({ weeks: i }).get('weekNumber')}`, + width: i === 3 ? '0px' : this.durationToWidth(this.start.endOf('week').diff(this.start).as('seconds')) + } + }) + } + + private createCells(): CellDescriptor[] { + return Array.from({ length: 4 * 7 }, (_, day) => { + let date = this.start.plus({ days: day }); + const duration = this.dayDuration(); + const sunOrHoliday = this.isDaySunOrHoliday(date); + const weekend = this.isDayOnWeekend(date); + + return { + title: date.toFormat('dd.MM'), + subTitle: date.toFormat('EEE'), + width: this.durationToWidth(duration), + firstTickOffset: '0px', + sunOrHoliday, + weekend, + available: defaultAvailable.filter(item => ((!weekend && !sunOrHoliday) || (weekend && !sunOrHoliday) && item.onSaturday) || (sunOrHoliday && item.onSunOrHoliday)).map(item => { + return { offset: this.getOffset(date.startOf('day').plus({ seconds: item.start_day_sec })), duration: this.durationToWidth(item.duration_sec) } + }) + } + }); + } + + private createTicks(): TickDescriptor[] { + // TODO make calculation more sane!!! + let test = Math.floor(24 / Math.ceil((1.2 * 0.8 * this.globalBaseFontSize) / (this.dayDuration() * this.pixelPerSecond / 24))); + + return Array.from({ length: test }, (_, i) => i * 24 / test).map(item => { + return { + title: item.toString(), + width: this.durationToWidth(24 / test * 3600) + } + }); + } +} \ No newline at end of file diff --git a/src/app/gantt/views/view.ts b/src/app/gantt/views/view.ts new file mode 100644 index 0000000..4d49b78 --- /dev/null +++ b/src/app/gantt/views/view.ts @@ -0,0 +1,84 @@ +import { DateTime, Settings } from "luxon"; +import { GanttViewType } from "./base"; +import { GanttViewWeek } from "./week"; +import { GanttViewDay } from "./day"; +import { GanttViewMonth } from "./month"; +import { CellDescriptor, DescriptorSet, IntervalDescriptor, TickDescriptor } from "./descriptors"; + +// export interface DescriptorSet { +// interval: IntervalDescriptor[], +// cell: IntervalDescriptor[], +// tick: IntervalDescriptor[], +// } + +// export interface IntervalDescriptor { +// title: string; +// subTitle?: string; +// width: string, +// firstTickOffset?: string, +// } + +export class GanttView { + + viewType: GanttViewType; + + private _width: number = 0; // width of this gantt view in pixel + + descriptorSet: DescriptorSet = { + interval: [], + cell: [], + tick: [], + } + + constructor() { + // defining time settings + Settings.defaultZone = 'utc'; + Settings.defaultLocale = "de"; + + // initialize view dates (as week view) + this.viewType = new GanttViewWeek(DateTime.now()); + } + + set width(width: number) { + this._width = width; + this.descriptorSet = this.viewType.update({ width: width }); + } + set globalBaseFontSize(size: number) { + this.viewType.globalBaseFontSize = size; + } + + get month(): string { return this.viewType.start.toFormat('MMMM yyyy') } + get week(): string { return this.viewType.start.toFormat('nn') } + + get intervalDescriptor(): IntervalDescriptor[] { return this.descriptorSet.interval }; + get cellDescriptors(): CellDescriptor[] { return this.descriptorSet.cell }; + get tickDescriptors(): TickDescriptor[] { return this.descriptorSet.tick }; + + durationToWidth(seconds: number, opts?: { offsetCorrection?: number }): string { + return this.viewType.durationToWidth(seconds, opts); + } + + getOffset(date: DateTime | number, opts?: { dstCorrection?: boolean, offsetCorrection?: number }): string { + return this.viewType.getOffset(date, opts); + } + + changeView(view: string): void { + switch (view) { + case 'day': this.viewType = new GanttViewDay(this.viewType.start); break; + case 'week': this.viewType = new GanttViewWeek(this.viewType.start); break; + case 'month': this.viewType = new GanttViewMonth(this.viewType.start); break; + } + this.descriptorSet = this.viewType.update({ width: this._width }); + } + + back(): void { + this.descriptorSet = this.viewType.update({ action: 'back' }) + } + next(): void { + this.descriptorSet = this.viewType.update({ action: 'next' }) + } + today(): void { + this.viewType.setToday() + this.descriptorSet = this.viewType.update({ width: this._width }); + } +} diff --git a/src/app/gantt/views/week.ts b/src/app/gantt/views/week.ts new file mode 100644 index 0000000..87f2818 --- /dev/null +++ b/src/app/gantt/views/week.ts @@ -0,0 +1,85 @@ +import { DateTime } from "luxon"; +import { defaultAvailable, GanttViewType } from "./base"; +import { CellDescriptor, DescriptorSet, IntervalDescriptor, TickDescriptor } from "./descriptors"; + + +export class GanttViewWeek extends GanttViewType { + + constructor(date: DateTime) { + super() + + this.start = date.startOf('week').startOf('day'); + this.viewDuration = this.start.endOf('week').endOf('day').diff(this.start).as('seconds'); + this.cellWidth = this.viewDuration / 7; + } + + override setToday(): void { + this.start = DateTime.now().startOf('week').startOf('day'); + } + + override calculateIntervalShift(action: "next" | "back"): void { + if (action === 'next') + this.start = this.start.plus({ days: 3.5 }); + else + this.start = this.start.minus({ days: 3.5 }); + } + + override createDescriptorSet(): DescriptorSet { return { interval: this.createIntervals(), cell: this.createCells(), tick: this.createTicks() } }; + + // get duration of a day in seconds + dayDuration(day?: number): number { + // check if first day is only a fraction of a day + if (day === 0) { + return Math.abs(this.start.diff(this.start.endOf('day')).as('seconds')); + } else { + return 24 * 3600; + } + }; + + private createIntervals(): IntervalDescriptor[] { + let weeks = [this.start.get('weekNumber')]; + let end = this.start.plus({ week: 1 }).minus({ seconds: 10 }); + if (!this.start.hasSame(end, 'week')) + weeks.push(end.weekNumber) + + return weeks.map((item, index) => { + return { + title: `KW ${item}`, + width: index === weeks.length - 1 ? '0px' : this.durationToWidth(this.start.endOf('week').diff(this.start).as('seconds')) + } + }) + } + + private createCells(): CellDescriptor[] { + return Array.from({ length: 8 }, (_, day) => { + let date = this.start.plus({ days: day }); + const duration = this.dayDuration(day); + const sunOrHoliday = this.isDaySunOrHoliday(date); + const weekend = this.isDayOnWeekend(date); + + return { + title: date.toFormat('dd.MM'), + subTitle: date.toFormat('EEE'), + width: this.durationToWidth(duration), + firstTickOffset: `${duration / this.cellWidth * 100 - 100}%`, + sunOrHoliday, + weekend, + available: defaultAvailable.filter(item => ((!weekend && !sunOrHoliday) || (weekend && !sunOrHoliday) && item.onSaturday) || (sunOrHoliday && item.onSunOrHoliday)).map(item => { + return { offset: this.getOffset(date.startOf('day').plus({ seconds: item.start_day_sec })), duration: this.durationToWidth(item.duration_sec) } + }) + } + }); + } + + private createTicks(): TickDescriptor[] { + // TODO make calculation more sane!!! + let test = Math.floor(24 / Math.ceil((2 * 0.8 * this.globalBaseFontSize) / (this.dayDuration() * this.pixelPerSecond / 24))); + + return Array.from({ length: test }, (_, i) => i * 24 / test).map(item => { + return { + title: item.toString(), + width: this.durationToWidth(24 / test * 3600) + } + }); + } +} \ No newline at end of file