Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
### New feature
* Add new option "Data Gaps" to detect missing days in time-series data and display a warning icon when gaps are found. The icon is shown by default and appears automatically whenever gaps are detected.
* Add Data Gaps controls to toggle the icon, adjust its colors, and set a custom message.
* Add a landing page (with the visual icon) shown when no fields are added, guiding the user to add Date and Values.
* Add empty-state messages for missing Date/Values fields, invalid or blank dates, and data with no valid values.
* Add a "Show start date" option in the KPI card to display the reference date next to the days count (e.g. "545 days (since 1/1/2024)").

### Fixes
* Fix "Change start date": the start date is now matched by calendar day, a missing date falls back to the next available date, and an out-of-range date falls back to the closest available date instead of showing a 0% change. A hint icon explains any adjustment.
## 3.0.1.0
### Fixes
* Add bold, italic and underline to sparkline value
Expand Down
7 changes: 7 additions & 0 deletions capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,11 @@
"bool": true
}
},
"showStartDate": {
"type": {
"bool": true
}
},
"percentCalcDateStr": {
"type": {
"text": true
Expand Down Expand Up @@ -1065,5 +1070,7 @@
]
},
"supportsMultiVisualSelection": true,
"supportsLandingPage": true,
"supportsEmptyDataView": true,
"privileges": []
}
94 changes: 83 additions & 11 deletions specs/common.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
} from "../src/converter/data/dataRepresentation";

import { isValueValid } from "../src/utils/isValueValid";
import { isValidDate } from "../src/utils/isValidDate";
import { DataGapDetector, IDataGapResult } from "../src/utils/dataGapDetector";

import { DataConverter } from "../src/converter/data/dataConverter";
Expand Down Expand Up @@ -1123,6 +1124,25 @@ describe("Multi KPI", () => {
expect(isValueValid(-Infinity)).toBeFalsy();
});
});

describe("isValidDate", () => {
it("should return true for a valid date", () => {
expect(isValidDate(new Date(2018, 0, 1))).toBeTruthy();
});

it("should return false for an invalid date", () => {
expect(isValidDate(new Date("not-a-date"))).toBeFalsy();
});

it("should return false for a non-date value", () => {
expect(isValidDate("2018-01-01")).toBeFalsy();
});

it("should return false for null or undefined", () => {
expect(isValidDate(null)).toBeFalsy();
expect(isValidDate(undefined)).toBeFalsy();
});
});
});

describe("DataFormatter", () => {
Expand Down Expand Up @@ -1187,34 +1207,86 @@ describe("Multi KPI", () => {
expect(dataPoint).toBe(defaultDataPoint);
});

it("should return defaultDataPoint if there's no the closest dataPoint", () => {
it("should return the first dataPoint if the date is before the first dataPoint", () => {
const firstDataPoint: IDataRepresentationPoint = {
index: 0,
x: new Date(2018, 8, 8),
y: 200,
};

const dataPoint: IDataRepresentationPoint = dataConverter.findClosestDataPointByDate(
[{
index: 0,
x: new Date(2018, 8, 8),
y: 200,
}],
[firstDataPoint],
new Date(2016, 1, 1),
defaultDataPoint,
);

expect(dataPoint).toBe(defaultDataPoint);
expect(dataPoint).toBe(firstDataPoint);
});

it("should return the closest dataPoint by date", () => {
it("should return the next available dataPoint when there's no exact match", () => {
const firstDataPoint: IDataRepresentationPoint = {
index: 0,
x: new Date(2018, 8, 8),
y: 200,
};

const secondDataPoint: IDataRepresentationPoint = {
index: 1,
x: new Date(2018, 8, 20),
y: 300,
};

const dataPoint: IDataRepresentationPoint = dataConverter.findClosestDataPointByDate(
[firstDataPoint],
new Date(2018, 9, 9),
[firstDataPoint, secondDataPoint],
new Date(2018, 8, 15),
defaultDataPoint,
);

expect(dataPoint).toBe(firstDataPoint);
expect(dataPoint).toBe(secondDataPoint);
});

it("should match the dataPoint on the same calendar day ignoring the time component", () => {
const firstDataPoint: IDataRepresentationPoint = {
index: 0,
x: new Date(2018, 8, 8),
y: 200,
};

const secondDataPoint: IDataRepresentationPoint = {
index: 1,
x: new Date(2018, 8, 20),
y: 300,
};

const dataPoint: IDataRepresentationPoint = dataConverter.findClosestDataPointByDate(
[firstDataPoint, secondDataPoint],
new Date(2018, 8, 20, 13, 45),
defaultDataPoint,
);

expect(dataPoint).toBe(secondDataPoint);
});

it("should return the latest dataPoint if the date is after the last dataPoint", () => {
const firstDataPoint: IDataRepresentationPoint = {
index: 0,
x: new Date(2018, 8, 8),
y: 200,
};

const secondDataPoint: IDataRepresentationPoint = {
index: 1,
x: new Date(2018, 8, 20),
y: 300,
};

const dataPoint: IDataRepresentationPoint = dataConverter.findClosestDataPointByDate(
[firstDataPoint, secondDataPoint],
new Date(2019, 0, 1),
defaultDataPoint,
);

expect(dataPoint).toBe(secondDataPoint);
});
});
});
Expand Down
91 changes: 76 additions & 15 deletions src/converter/data/dataConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
} from "../data/dataFormatter";

import { DataGapDetector, IDataGapResult } from "../../utils/dataGapDetector";
import { isValidDate } from "../../utils/isValidDate";

export interface IColumnGroup {
name: string;
Expand Down Expand Up @@ -139,30 +140,82 @@ export class DataConverter implements IConverter<IDataConverterOptions, IDataRep
date: Date,
defaultDataPoint: IDataRepresentationPoint,
): IDataRepresentationPoint {
if (!dataPoints || !date || !(date instanceof Date)) {
if (!dataPoints || !dataPoints.length || !isValidDate(date)) {
return defaultDataPoint;
}

const dateTime: number = date.getTime();

let closestDataPoint: IDataRepresentationPoint;
// Compare on calendar-day granularity so a start date matches the data point of the
// same day regardless of its time/timezone component.
const targetDay: number = this.getDayValue(date);

// Match the exact day, otherwise fall back to the next available date that exists
// (the closest data point on or after the requested date).
for (const dataPoint of dataPoints) {
const currentTime: number = dataPoint.x.getTime();

if (currentTime === dateTime) {
closestDataPoint = dataPoint;
const currentDay: number = this.getDayValue(dataPoint.x);

break;
}
else if (currentTime < dateTime) {
closestDataPoint = dataPoint;
} else {
break;
if (currentDay >= targetDay) {
return dataPoint;
}
}

return closestDataPoint || defaultDataPoint;
// The requested date is after the last available date, so there is no next date.
// Fall back to the closest (latest) available date.
return dataPoints[dataPoints.length - 1];
}

private getDayValue(date: Date): number {
return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
}

// Flags when the user typed a value into the start date input that is not a parseable date,
// so the visual can hint that the entry was ignored instead of silently using the first point.
private detectInvalidStartDateInput(dataRepresentation: IDataRepresentation, settings: Settings): void {
const rawValue: string = settings.kpi.startDate.value;

if (rawValue && !settings.kpi.percentCalcDate && !dataRepresentation.percentCalcDate) {
dataRepresentation.startDateAdjustment = {
isAdjusted: true,
isOutOfRange: false,
isInvalidInput: true,
requestedText: rawValue,
};
}
}

// Flags when a user-selected start date does not match the resolved data point
// (either because that day is missing from the series or it falls outside the range),
// so the visual can surface a hint. The first affected series is reported.
private detectStartDateAdjustment(
dataRepresentation: IDataRepresentation,
series: IDataRepresentationSeries,
startDataPoint: IDataRepresentationPoint,
): void {
const requestedDate: Date = dataRepresentation.percentCalcDate;

if (!isValidDate(requestedDate)) {
return;
}

if (!startDataPoint || !startDataPoint.x || !series.points.length) {
return;
}

const requestedDay: number = this.getDayValue(requestedDate);
const actualDay: number = this.getDayValue(startDataPoint.x);

if (requestedDay === actualDay || dataRepresentation.startDateAdjustment?.isAdjusted) {
return;
}

const firstDay: number = this.getDayValue(series.points[0].x);
const lastDay: number = this.getDayValue(series.points[series.points.length - 1].x);

dataRepresentation.startDateAdjustment = {
isAdjusted: true,
requestedDate,
actualDate: startDataPoint.x,
isOutOfRange: requestedDay < firstDay || requestedDay > lastDay,
};
}

public isDataViewValid(dataView: DataView): boolean {
Expand Down Expand Up @@ -417,6 +470,12 @@ export class DataConverter implements IConverter<IDataConverterOptions, IDataRep
totalMissingDays: 0,
seriesGaps: {}
};
dataRepresentation.startDateAdjustment = {
isAdjusted: false,
isOutOfRange: false,
};

this.detectInvalidStartDateInput(dataRepresentation, settings);

dataRepresentation.series.forEach((series: IDataRepresentationSeries) => {
if (series?.current?.x) {
Expand Down Expand Up @@ -468,6 +527,8 @@ export class DataConverter implements IConverter<IDataConverterOptions, IDataRep
series.points[0],
);

this.detectStartDateAdjustment(dataRepresentation, series, startDataPoint);

const endDataPoint: IDataRepresentationPoint = series.points[series.points.length - 1];

series.variance = createVarianceConverterByType(series.settings.variance.shouldCalculateDifference.value)
Expand Down
10 changes: 10 additions & 0 deletions src/converter/data/dataRepresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,20 @@ export interface IDataRepresentation {
viewport: IViewport;
viewportSize: ViewportSize;
dataGapInfo?: IDataGapInfo;
startDateAdjustment?: IStartDateAdjustment;
}

export interface IDataGapInfo {
hasGaps: boolean;
totalMissingDays: number;
seriesGaps: { [seriesName: string]: { hasGaps: boolean; totalMissingDays: number; } };
}

export interface IStartDateAdjustment {
isAdjusted: boolean;
requestedDate?: Date;
actualDate?: Date;
isOutOfRange: boolean;
isInvalidInput?: boolean;
requestedText?: string;
}
Loading
Loading