diff --git a/app/formatters/SummaryHtml/Formatter.test.ts b/app/formatters/SummaryHtml/Formatter.test.ts index 471b2e0d..4b3f03dc 100644 --- a/app/formatters/SummaryHtml/Formatter.test.ts +++ b/app/formatters/SummaryHtml/Formatter.test.ts @@ -1,4 +1,100 @@ import { beforeEach, describe, test, expect } from "@jest/globals"; +import * as moment from "moment-timezone"; +import { AppConfigurations, Dates, Tags, WorklogSets } from "../../../tests/entities"; +import { Tag } from "../../models"; +import { IAppConfiguration } from "../../models/AppConfiguration"; +import { SummaryHtmlFormatter, SummaryHtmlFormatterConfiguration } from "."; + +describe('format with filters', () => { + let formatter: SummaryHtmlFormatter; + let appConfiguration: IAppConfiguration; + + beforeEach(() => { + appConfiguration = AppConfigurations.normal(); + }); + + test('filters worklog entries by tag value', async () => { + const configuration = new SummaryHtmlFormatterConfiguration( + [['client', 'project']], + [{ + tagNames: ['client', 'project'], + filter: (worklog) => worklog.getTagValue('project') === 'Project1' + }] + ); + formatter = new SummaryHtmlFormatter(configuration, appConfiguration); + + const worklogSet = WorklogSets.double(); + worklogSet.worklogs[0].addTag(Tags.client.ProCorp()); + worklogSet.worklogs[0].addTag(new Tag('project', 'Project1')); + worklogSet.worklogs[1].addTag(Tags.client.ProCorp()); + worklogSet.worklogs[1].addTag(new Tag('project', 'Project2')); + + const formatted = await formatter.format(worklogSet); + + expect(formatted).toMatch('Project1'); + expect(formatted).not.toMatch('Project2'); + }); + + test('applies multiple filters to grouped entries', async () => { + const configuration = new SummaryHtmlFormatterConfiguration( + [['client', 'project']], + [{ + tagNames: ['client', 'project'], + filter: (worklog) => worklog.getTagValue('project') === 'Project1' + }, { + tagNames: ['client', 'project'], + filter: (worklog) => worklog.getDurationInMinutes() > 60 + }] + ); + formatter = new SummaryHtmlFormatter(configuration, appConfiguration); + + const worklogSet = WorklogSets.double(); + worklogSet.worklogs[0].startDateTime = Dates.pastTwoHours(); + worklogSet.worklogs[0].endDateTime = Dates.now(); + worklogSet.worklogs[0].addTag(Tags.client.ProCorp()); + worklogSet.worklogs[0].addTag(new Tag('project', 'Project1')); + + worklogSet.worklogs[1].startDateTime = Dates.pastHalfHour(); + worklogSet.worklogs[1].endDateTime = Dates.now(); + worklogSet.worklogs[1].addTag(Tags.client.ProCorp()); + worklogSet.worklogs[1].addTag(new Tag('project', 'Project1')); + + const formatted = await formatter.format(worklogSet); + + expect(formatted).toMatch('2hs 0m'); + expect(formatted).not.toMatch('0hs 30m'); + }); + + test('handles empty filters gracefully', async () => { + const configuration = new SummaryHtmlFormatterConfiguration( + [['client', 'project']], + [] + ); + formatter = new SummaryHtmlFormatter(configuration, appConfiguration); + + const worklogSet = WorklogSets.double(); + const formatted = await formatter.format(worklogSet); + + expect(formatted).toMatch('Total time:'); + }); + + test('maintains HTML structure with filtered groups', async () => { + const configuration = new SummaryHtmlFormatterConfiguration( + [['client', 'project']], + [{ + tagNames: ['client', 'project'], + filter: () => false // Filter out everything + }] + ); + formatter = new SummaryHtmlFormatter(configuration, appConfiguration); + + const worklogSet = WorklogSets.double(); + const formatted = await formatter.format(worklogSet); + + expect(formatted).toMatch(/<[^>]+>/); // Contains HTML tags + expect(formatted).toMatch(/

.*<\/p>/); // Has proper HTML structure + }); +});foreEach, describe, test, expect } from "@jest/globals"; import * as moment from "moment-timezone"; import { AppConfigurations, Dates, Tags, WorklogSets } from "../../../tests/entities"; diff --git a/app/formatters/SummaryHtml/Formatter.ts b/app/formatters/SummaryHtml/Formatter.ts index d675adc4..015dc214 100644 --- a/app/formatters/SummaryHtml/Formatter.ts +++ b/app/formatters/SummaryHtml/Formatter.ts @@ -3,6 +3,57 @@ import { WorklogSet } from '../../models/WorklogSet'; import { getLogger, LoggerCategory } from '../../services/Logger'; import { SummaryTextFormatter } from '../SummaryText'; import { SummaryHtmlFormatterConfiguration } from '.'; +import { Worklog } from '../../models/Worklog'; + +const logger = getLogger(LoggerCategory.Formatters); + +export class SummaryHtmlFormatter extends SummaryTextFormatter { + protected _configuration: SummaryHtmlFormatterConfiguration; + + _generateAggregationLines(worklogs: Worklog[], tags: string[], indentationLevel = 1): string[] { + if (!worklogs || !worklogs.length || !tags || !tags.length) return []; + + // Apply filters for this tag group + const filteredWorklogs = this._applyFilters(worklogs, tags); + + return super._generateAggregationLines(filteredWorklogs, tags, indentationLevel); + } + + private _applyFilters(worklogs: Worklog[], tags: string[]): Worklog[] { + const matchingFilters = this._configuration.filters.filter(f => + this._arraysHaveSameElements(f.tagNames, tags)); + + if (!matchingFilters.length) { + return worklogs; + } + + return worklogs.filter(worklog => + matchingFilters.every(filter => filter.filter(worklog)) + ); + } + + private _arraysHaveSameElements(arr1: string[], arr2: string[]): boolean { + if (arr1.length !== arr2.length) return false; + const sorted1 = [...arr1].sort(); + const sorted2 = [...arr2].sort(); + return sorted1.every((element, index) => element === sorted2[index]); + } + + async format(worklogSet: WorklogSet): Promise { + const summaryText = await super.format(worklogSet); + + logger.debug('Initializing markdown converter'); + const markdownConverter = new showdown.Converter(this._configuration); + + logger.debug('Converting summary to html'); + const output = markdownConverter.makeHtml(summaryText); + + logger.debug('SummaryHtmlFormatter output:', output); + return output; + } +} + +export default SummaryHtmlFormatter; const logger = getLogger(LoggerCategory.Formatters); diff --git a/app/formatters/SummaryHtml/SummaryHtmlFormatterConfiguration.ts b/app/formatters/SummaryHtml/SummaryHtmlFormatterConfiguration.ts index 1b7212a4..2567d911 100644 --- a/app/formatters/SummaryHtml/SummaryHtmlFormatterConfiguration.ts +++ b/app/formatters/SummaryHtml/SummaryHtmlFormatterConfiguration.ts @@ -1,6 +1,19 @@ import { SummaryTextFormatterConfiguration } from "../SummaryText"; +import { Worklog } from "../../models/Worklog"; -export class SummaryHtmlFormatterConfiguration extends SummaryTextFormatterConfiguration -{ - public type = 'SummaryHtml'; +export type FilterFunction = (worklog: Worklog) => boolean; +export type TagGroupFilter = { + tagNames: string[]; + filter: FilterFunction; +}; + +export class SummaryHtmlFormatterConfiguration extends SummaryTextFormatterConfiguration { + public type = "SummaryHtml"; + + constructor( + aggregateByTags: string[][], + public filters: TagGroupFilter[] = [], + ) { + super(aggregateByTags); + } }