util_date.js

import { utcMinute, utcHour, utcDay, utcMonth, utcYear } from "d3-time";
import Aggregations from "../domain/aggregation.js";
import { dateTimeParse, dateTimeUrlParse, timestampParse } from "../format/date.js";

/**
 * An object that defines levels of date range configuration.
 *
 * @typedef {Object} module:util~DateRangeConfiguration
 * @property {number} [numHours] the number of hours to use
 * @property {number} [numDays] the number of days to use
 * @property {number} [numMonths] the number of months to use
 * @property {number} [numYears] the number of years to use
 */

/**
 * An object that defines a date range.
 *
 * @typedef {Object} module:util~DateRange
 * @property {Date} start the starting date
 * @property {Date} end the ending date
 * @property {module:domain~Aggregation} timeUnit the time unit used by the date range
 * @property {number} timeCount the number of time units in the date range
 * @property {module:domain~Aggregation} aggregate the aggregate to query with
 */

/**
 * Get a query range appropriate for a given aggregate level.
 *
 * Returns an object with `start` and `end` Date properties, using the given `endDate`
 * parameter as the basis for calculating the start as an offset backwards in time
 * based on the given `aggregate` level.
 *
 * When `aggregateTimeCount` will be treated as a "next higher" aggregate level from
 * `aggregate`, like this:
 *
 *  * < `Hour`: `numHours`
 *  * `Hour` : `numDays`
 *  * `Day` : `numMonths`
 *  * `Month` : `numYears`
 *
 * For example, you might like to render a chart using `TenMinute` aggregate data for the
 * last 24 hours. You'd call this function like this:
 *
 * ```
 * const range = rollingQueryDateRange(Aggregates.TenMinute, 24);
 *
 * // or, passing a DateRangeConfiguration
 * const range = rollingQueryDateRange(Aggregates.TenMinute, {numHours:24});
 * ```
 *
 * @param {module:domain~Aggregation} aggregate the aggregate level to get a query range for
 * @param {number|module:util~DateRangeConfiguration} aggregateTimeCount the number of aggregate time units to use
 * @param {Date} [endDate] the ending date; if not provided the current date will be used
 * @returns {module:util~DateRange} the calculated date range
 * @alias module:util~rollingQueryDateRange
 */
export function rollingQueryDateRange(aggregate, aggregateTimeCount, endDate) {
	endDate = endDate || new Date();

	function exclusiveEndDate(interval, date) {
		var result = interval.ceil(date);
		if (result.getTime() === date.getTime()) {
			// already on exact aggregate, so round up to next
			result = interval.offset(result, 1);
		}
		return result;
	}

	function timeCountValue(propName) {
		var result;
		if (isNaN(Number(aggregateTimeCount))) {
			if (aggregateTimeCount[propName] !== undefined) {
				result = Number(aggregateTimeCount[propName]);
			} else {
				result = 1;
			}
		} else {
			result = aggregateTimeCount;
		}
		if (typeof result !== "number") {
			result = 1;
		}
		return result;
	}

	var end, start, timeUnit, timeCount;

	if (aggregate.compareTo(Aggregations.Hour) < 0) {
		timeCount = timeCountValue("numHours");
		timeUnit = Aggregations.Hour;
		end = exclusiveEndDate(utcMinute, endDate);
		let precision = Math.min(30, aggregate.level / 60);
		end.setUTCMinutes(
			end.getUTCMinutes() + precision - (end.getUTCMinutes() % precision),
			0,
			0,
		);
		start = utcHour.offset(end, -timeCount);
	} else if (Aggregations.Month.equals(aggregate)) {
		timeCount = timeCountValue("numYears");
		timeUnit = Aggregations.Year;
		end = exclusiveEndDate(utcMonth, endDate);
		start = utcYear.offset(utcMonth.floor(endDate), -timeCount);
	} else if (Aggregations.Day.equals(aggregate)) {
		timeCount = timeCountValue("numMonths");
		timeUnit = Aggregations.Month;
		end = exclusiveEndDate(utcDay, endDate);
		start = utcMonth.offset(utcDay.floor(endDate), -timeCount);
	} else {
		// assume Hour
		timeCount = timeCountValue("numDays");
		timeUnit = Aggregations.Day;
		end = exclusiveEndDate(utcHour, endDate);
		start = utcDay.offset(utcHour.floor(end), -timeCount);
	}
	return {
		start: start,
		end: end,
		timeUnit: timeUnit,
		timeCount: timeCount,
		aggregate: aggregate,
	};
}

/**
 * Get a date associated with a "datum" style object.
 *
 * This function will return a `Date` instance found via a property on `d` according to these rules:
 *
 *  1. `date` - assumed to be a `Date` object already and returned directly
 *  2. `localDate` - a string in `yyyy-MM-dd` form, optionally with a string
 *     `localTime` property for an associated time in `HH:mm` form, treated as UTC
 *  3. `created` - a string in `yyyy-MM-dd HH:mm:ss.SSS'Z'` or `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'` form
 *
 * These properties are commonly returned in results from the SolarNetwork API, and thus
 * this method is a handy way to get the dates for those objects.
 *
 * **Note** that the `localDate` and `localTime` values are parsed as UTC. When formatted the
 * date for display they should be formatted in UTC as well to preserve the expected value.
 *
 * @param {Object} d the datum object to extract a date from
 * @returns {Date} the extracted date, or `null` if no date could be extracted
 * @alias module:util~datumDate
 */
export function datumDate(d) {
	if (!d) {
		return null;
	}
	if (d.date) {
		return d.date;
	} else if (d.localDate) {
		return dateTimeParse(d.localDate + (d.localTime ? " " + d.localTime : " 00:00"));
	} else if (d.created) {
		return timestampParse(d.created) || dateTimeUrlParse(d.created);
	}
}

export default Object.freeze({
	datumDate: datumDate,
	rollingQueryDateRange: rollingQueryDateRange,
});