import moment = require('moment'); export abstract class BaseNode{ abstract solve():any; } export class NullNode extends BaseNode{ solve() { throw new Error("AST node solved to null"); } } export class NumberNode extends BaseNode { value:number; constructor(value:number) { super() this.value = value } solve() { return this.value } } export class StringNode extends BaseNode { value:string; constructor(value:string) { super() this.value = value } solve() { return this.value } } export class TimestampNode extends NumberNode { constructor(value:number) { super(value) this.value = moment.unix(value).unix() } } export class RelativeTokenNode extends BaseNode{ quantity:number; token:string; constructor(token:string, quantity:number){ super() this.token = token this.quantity = quantity } solve():[number, string]{ return [ this.quantity, this.token ] } } export class RelativeExpressionNode extends BaseNode { anchor:TimestampNode; relative?:RelativeTokenNode; constructor(anchor:TimestampNode, relative?:RelativeTokenNode) { super() this.anchor = anchor this.relative = relative } solve():number { return this.calculateTimestamp().solve() } calculateTimestamp():TimestampNode { var timestamp:number = this.anchor.solve() // If a relative offset is provided, adjust for it if(this.relative && this.relative.token){ var ref = moment.unix(timestamp).utc() switch(this.relative.token){ case "M": var rel = ref.clone().add(this.relative.quantity, "months").utc() break; case "Q": var rel = ref.clone().add(this.relative.quantity, "quarters").utc() break; case "Y": var rel = ref.clone().add(this.relative.quantity, "years").utc() break; default: throw new Error(`Relative token type ${this.relative.token} is not recognized`) } rel = this.correctMonthShift(ref, rel, this.relative.quantity) timestamp = rel.unix() } return new TimestampNode(timestamp) } /** * If adding 'period' time periods caused us to land on a date that does not exist (ex: Feb 30) moment will inconsitantly * shift us one way or anouther to a real date. We want to maintain a consistant behavoir, so we always enforce * that we shift conservatively (back when moving forwards, forward when moving backwards) * * This method detects if such a shift has occurred and returns a new moment that is correctly shifted * * @param ref The reference time before the translation * @param rel The computed time after the translation * @param period The translation period * @return The time after the translation, adjusted for any month shift */ private correctMonthShift(ref:moment.Moment, rel:moment.Moment, period:number):moment.Moment{ // var adjRelMoment:moment.Moment = rel.clone(); if(ref.date() != rel.date()){ if(period >= 0 && rel.date() != rel.clone().endOf('month').date()){ adjRelMoment = rel.clone().subtract(1, 'day') }else if(period < 0 && rel.date() != rel.clone().startOf('month').date()){ adjRelMoment = rel.clone().add(1, 'day') } } return adjRelMoment } } export abstract class FunctionNode extends BaseNode { } export class RangeNode extends FunctionNode { Start:RelativeExpressionNode; End:RelativeExpressionNode; constructor(start:RelativeExpressionNode, end:RelativeExpressionNode) { super() this.Start = start this.End = end } solve() { return [this.Start.solve(), this.End.solve()].sort() } } export class TrailNode extends FunctionNode { End:RelativeExpressionNode; Period:RelativeExpressionNode | RelativeTokenNode; constructor(end:RelativeExpressionNode, period:RelativeExpressionNode | RelativeTokenNode) { super() this.End = end this.Period = period } solve() { var end = this.End.solve() var start:number; switch(this.Period.constructor) { case RelativeTokenNode: // need to reconstruct the relative token to face the opposite direction (to trail rather than lead) var [quantity, token] = (this.Period).solve() var periodTokenNode = new RelativeTokenNode(token, -1*quantity) start = new RelativeExpressionNode(new TimestampNode(this.End.solve()), periodTokenNode).solve() break; case RelativeExpressionNode: start = end - (this.Period).solve() break; default: throw new Error("Something went horribly wrong") } return [start, end].sort() } } export class PeriodNode extends FunctionNode { Start:RelativeExpressionNode; Period:RelativeExpressionNode|RelativeTokenNode; Ref:RelativeExpressionNode; constructor(start:RelativeExpressionNode, period:RelativeExpressionNode|RelativeTokenNode, ref:RelativeExpressionNode) { super() this.Start = start this.Period = period this.Ref = ref } solve() { var period:number; var offset:number; var start:number; var end:number; var anchor = this.Start.solve() var ref:number = this.Ref.solve() var diff = ref - anchor switch(this.Period.constructor) { case RelativeTokenNode: // because relative tokens do not have fixed lengths (the length of a month/quarter/year fluxuates with context) // we first approximate the periods we are incrementing by to find out about how many times to shift by the relative // token. We then perform the shift and step up or down until we reach the correct period var [quantity, token] = (this.Period).solve() var firstBoundary = new RelativeExpressionNode(new TimestampNode(anchor), this.Period).solve() var apprxOffset = Math.floor(diff / (firstBoundary - anchor)) start = new RelativeExpressionNode( new TimestampNode(anchor), new RelativeTokenNode(token, quantity*apprxOffset) ).solve() end = new RelativeExpressionNode( new TimestampNode(start), new RelativeTokenNode(token, quantity*Math.sign(diff)) ).solve() var [start, end] = this.relShiftToCorrectPeriod(start, end, ref, token, quantity) break; case RelativeExpressionNode: period = (this.Period).solve() offset = Math.floor(diff / period) var [start, end] = this.getPeriodBounds(anchor, period, offset, diff) var [start, end] = this.shiftToCorrectPeriod(start, end, ref, period) break; default: throw new Error("Something has gone horribly wrong") } return [start, end].sort() } private getPeriodBounds(anchor:number, period:number, offset:number, diff:number):[number,number]{ var start:number; var end:number; switch( Math.sign(diff) ) { case 1: start = anchor + offset*period end = start + period break; case 0: // Always go back if lying directly on a boundary start = anchor - period end = anchor break; case -1: end = anchor - offset*period start = end - period break; default: throw new Error("Something has gone horribly wrong") } return [start, end] } private shiftToCorrectPeriod(start:number, end:number, ref:number, period:number):[number,number]{ while(!(start < ref && ref <= end)){ if(ref <= start){ start -= period end -= period }else if(ref > end){ start += period end += period } } return [start, end] } private relShiftToCorrectPeriod(start:number, end:number, ref:number, token:string, quantity:number):[number, number]{ quantity = Math.abs(quantity) while(!(start < ref && ref <= end)){ if(ref <= start){ start = new RelativeExpressionNode( new TimestampNode(start), new RelativeTokenNode(token, -quantity) ).solve() end = new RelativeExpressionNode( new TimestampNode(end), new RelativeTokenNode(token, -quantity) ).solve() }else if(ref > end){ start = new RelativeExpressionNode( new TimestampNode(start), new RelativeTokenNode(token, quantity) ).solve() end = new RelativeExpressionNode( new TimestampNode(end), new RelativeTokenNode(token, quantity) ).solve() } } return [start, end] } }