How to calculate time span?

I’m trying to get a string describing the interval between to dates and I guess I’m using the wrong tool.

Why, when the interval is greater than 68 years, the result looses 1 year per year?
Here is an example of what I mean:

set inter1 to my getInterval:"1/1/1951" --> 68a 0m 1sem
set inter2 to my getInterval:"1/1/1950" --> -67a 0m 3sem
set inter2 to my getInterval:"1/1/1940" --> -57a 0m 3sem

on getInterval:thedate
	set thedate to current application's NSDate's dateWithTimeInterval:0 sinceDate:(date thedate as date)
	set timeInterval to ((current application's NSDate's new())'s timeIntervalSinceDate:thedate)
	set theFormatter to current application's NSDateComponentsFormatter's new()
	theFormatter's setAllowedUnits:(4 + 8 + 4096)
	return (theFormatter's stringFromTimeInterval:timeInterval) as text
end getInterval:

That looks like a bug in NSDateComponentsFormatter, pure and simple. If you want to log it with Apple, here’s the Objective-C code you can include to demonstrate it:

    NSDateComponentsFormatter *formatter = [[NSDateComponentsFormatter alloc] init];
    formatter.unitsStyle = NSDateComponentsFormatterUnitsStyleFull;
    formatter.includesApproximationPhrase = YES;
    formatter.includesTimeRemainingPhrase = YES;
    formatter.allowedUnits = NSCalendarUnitYear;
    NSTimeInterval interval = 2.14e+9;
    NSString* outputString = [formatter stringFromTimeInterval:interval];
    NSLog(@"%f, %@", interval, outputString); // "2140000000.000000, About 67 years remaining"
    interval = 2.15e+9;
    outputString = [formatter stringFromTimeInterval:interval];
    NSLog(@"%f, %@", interval, outputString); // "2150000000.000000, About -67 years remaining"

Meanwhile, the stringFromDate:toDate: method seems to be OK (in Mojave):

use AppleScript version "2.4"
use framework "Foundation"

set inter1 to my getInterval:"1/1/1951" --> "68y 0mo 1w"
set inter2 to my getInterval:"1/1/1950" --> "69y 0mo 1w"
set inter3 to my getInterval:"1/1/1940" --> "79y 0mo 1w"
set inter4 to my getInterval:"1/1/0001" --> "2,018y 0mo 1w"

on getInterval:thedate
	set thedate to current application's NSDate's dateWithTimeInterval:0 sinceDate:(date thedate as date)
	set theFormatter to current application's NSDateComponentsFormatter's new()
	theFormatter's setAllowedUnits:(4 + 8 + 4096)
	
	return (theFormatter's stringFromDate:thedate toDate:(current application's NSDate's |date|())) as text
end getInterval:
1 Like

@ShaneStanley
Thanks for the objC code.
Bug report done!

@NigelGarvey
The stringFromDate:toDate: method is a perfect workaround.
Thank you too!

:wink:

FWIW a slight generalisation:

use AppleScript version "2.4"
use framework "Foundation"
use framework "JavaScriptCore"
use scripting additions

-- Unit keys: any gapless substring of "ymwdhns" (years, months, month-weeks, days, hours, mins, secs)
-- NB 'n' = minutes
on run
    
    set xs to {"1951-02-01", "1950-02-01", "1940-02-01", "0001-02-01"}
    
    -- "ym" = years months
    set units to "ym"
    
    map(compose(timeDiffString(units)'s |λ|(current date), dateFromISO), xs)
    
    --> {"67y 11mo", "68y 11mo", "78y 11mo", "2,017y 11mo"}
    
    
    -- "ymw" = years months weeks
    set units2 to "ymw"
    
    map(compose(timeDiffString(units2)'s |λ|(current date), dateFromISO), xs)
    
    --> {"67y 11mo 1w", "68y 11mo 1w", "78y 11mo 1w", "2,017y 11mo 1w"}
end run




-- String of Unit keys -> To Date -> From Date -> Interval String

-- timeDiffString :: String -> Date -> Date -> String
on timeDiffString(ks)
    set recUnitOptions to {y:4, m:8, w:4096, d:16, h:32, n:64, s:128}
    set fmtr to current application's NSDateComponentsFormatter's new()
    script unitFlags
        on |λ|(a, k)
            set mb to lookupDict(k, recUnitOptions)
            if Nothing of mb then
                a
            else
                a + (Just of mb)
            end if
        end |λ|
    end script
    fmtr's setAllowedUnits:foldl(unitFlags, 0, characters of ks)
    script
        on |λ|(dteTo)
            script
                on |λ|(dteFrom)
                    (fmtr's stringFromDate:dteFrom toDate:dteTo) as text
                end |λ|
            end script
        end |λ|
    end script
end timeDiffString


-- GENERIC FUNCTIONS -------------------------------------------------
-- https://github.com/RobTrew/prelude-applescript

-- bindJSC (>>=) :: JSC a -> (a -> JSC b) -> JSC b
on bindJSC(mJSC, mf)
    {type:"JSC", jsc:mJSC's jsc, value:¬
        unwrap((mJSC's jsc's ¬
            evaluateScript:(|λ|(value of mJSC) of mReturn(mf)))'s toObject())}
end bindJSC

-- compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
on compose(f, g)
    script
        property mf : mReturn(f)
        property mg : mReturn(g)
        on |λ|(x)
            mf's |λ|(mg's |λ|(x))
        end |λ|
    end script
end compose

-- dateFromISO :: ISO8601 String -> Date
on dateFromISO(s)
    script dateFromString
        on |λ|(v)
            "new Date('" & v & "')"
        end |λ|
    end script
    value of bindJSC(pureJSC(s), dateFromString)
end dateFromISO

-- foldl :: (a -> b -> a) -> a -> [b] -> a
on foldl(f, startValue, xs)
    tell mReturn(f)
        set v to startValue
        set lng to length of xs
        repeat with i from 1 to lng
            set v to |λ|(v, item i of xs, i, xs)
        end repeat
        return v
    end tell
end foldl

-- Just :: a -> Maybe a
on Just(x)
    {type:"Maybe", Nothing:false, Just:x}
end Just

-- lookupDict :: a -> Dict -> Maybe b
on lookupDict(k, dct)
    set ca to current application
    set v to (ca's NSDictionary's dictionaryWithDictionary:dct)'s objectForKey:k
    if v ≠ missing value then
        Just(item 1 of ((ca's NSArray's arrayWithObject:v) as list))
    else
        Nothing()
    end if
end lookupDict

-- map :: (a -> b) -> [a] -> [b]
on map(f, xs)
    tell mReturn(f)
        set lng to length of xs
        set lst to {}
        repeat with i from 1 to lng
            set end of lst to |λ|(item i of xs, i, xs)
        end repeat
        return lst
    end tell
end map

-- Lift 2nd class handler function into 1st class script wrapper 
-- mReturn :: First-class m => (a -> b) -> m (a -> b)
on mReturn(f)
    if class of f is script then
        f
    else
        script
            property |λ| : f
        end script
    end if
end mReturn

-- pureJSC :: a -> {type::String, jsc::JSContext, value::a}
on pureJSC(x)
    {type:"JSC", jsc:current application's JSContext's new(), value:x}
end pureJSC


-- Nothing :: Maybe a
on Nothing()
    {type:"Maybe", Nothing:true}
end Nothing

-- unwrap :: NSObject -> a
on unwrap(objCValue)
    if objCValue is missing value then
        missing value
    else
        set ca to current application
        item 1 of ((ca's NSArray's arrayWithObject:objCValue) as list)
    end if
end unwrap

The bug occurs when timeInterval ≥ (2 ^ 31). There’s no problem up to (2 ^ 31) -1.

So they’re converting the input to a signed integer, but only 32-bit. Pretty weird bug for something only introduced in macOS 10.10.

Yeah. :confused: I presume your Objective-C code demonstrates that it’s a formatter bug and not a scripting bridge one?

Here for fun is a less versatile, but apparently reliable vanilla method. There’s a possible issue with the ASObjC one in that, although the units of interest are years, months, and weeks, the calculation is based on the interval between the dates in seconds. The input dates’ times are always 0, whereas the dates from which they’re subtracted have the times when the script’s run. So if an input date is an exact number of calendar weeks, months, or years into the future, the result will be less than what I assume would be needed (unless the script’s run on the stroke of midnight, of course). So, running the script today, 12th January 2019:

This is getroundable, of course. But the script below is based only on the relevant calendar units and produces the following results anyway:

set inter1 to my getInterval:"1/1/1951" --> "68y 0mo 1w"
set inter2 to my getInterval:"1/1/1950" --> "69y 0mo 1w"
set inter3 to my getInterval:"1/1/1940" --> "79y 0mo 1w"
set inter4 to my getInterval:"1/1/0001" --> "2018y 0mo 1w"
set inter5 to my getInterval:"12/1/2020" --> "-1y 0mo 0w"

on getInterval:theDate
	set theDate to date theDate
	set now to (current date)
	
	set {year:dateYear, month:dateMonth, day:dateDay} to theDate
	set {year:nowYear, month:nowMonth, day:nowDay} to now
	
	if (theDate comes after now) then
		set yearDiff to dateYear - nowYear
		set monthDiff to dateMonth - nowMonth
		set dayDiff to dateDay - nowDay
		set prefix to "-"
	else
		set yearDiff to nowYear - dateYear
		set monthDiff to nowMonth - dateMonth
		set dayDiff to nowDay - dateDay
		set prefix to ""
	end if
	
	if (dayDiff < 0) then
		set dayDiff to ((now - nowDay * days)'s day) - dateDay + nowDay
		set monthDiff to monthDiff - 1
	end if
	set weekDiff to ((dayDiff div 7) as text) & "w"
	if (monthDiff < 0) then
		set monthDiff to 12 + monthDiff
		set yearDiff to yearDiff - 1
	end if
	if ((monthDiff is 0) and (yearDiff is 0)) then
		set monthDiff to ""
	else
		set monthDiff to (monthDiff as text) & "mo "
	end if
	if (yearDiff is 0) then
		set yearDiff to ""
	else
		set yearDiff to (yearDiff as text) & "y "
	end if
	
	return prefix & yearDiff & monthDiff & weekDiff
end getInterval:

@NigelGarvey

Ah, this good old Mister Applescript! Who says that he’s not capable? :wink:

Anyway, I prefer to rely on AppleScriptObjC, because the handler is intended to be part of a script library.
Here is what I was able to do to cover my needs:

use AppleScript version "2.5"
use framework "Foundation"
use scripting additions


set result1 to my dateSpan:"1/1/1950" toDate:(current application's NSDate's |date|()) fullString:true objC:true --> 69 ans, 1 semaine et 3 jours
set result2 to my dateSpan:(current date) toDate:"1/1/2089" fullString:false objC:false --> 69a 11m 2sem 6j
set result3 to my dateSpan:(current date) toDate:((current date) + 45 * days) fullString:true objC:false --> 1 mois et 2 semaines
set result4 to my dateSpan:(current date) toDate:((current date) + 45 * days) fullString:false objC:false --> 1m 2sem 0j
set result5 to my dateSpan:((current date) - 3 * days) toDate:(current date) fullString:true objC:false --> 3 jours
set result6 to my dateSpan:((current date) - 27.45 * hours) toDate:(current date) fullString:true objC:false --> 1 jour, 3 heures et 27 minutes
set result6 to my dateSpan:((current date) - 27.45 * hours) toDate:(current date) fullString:false objC:false --> 1j 03:27:00
set result7 to my dateSpan:((current date) - 3) toDate:(current date) fullString:true objC:false --> 00:00:03
set result8 to my dateSpan:((current date) - 63) toDate:(current date) fullString:false objC:false --> 00:01:03


on dateSpan:theDate1 toDate:theDate2 fullString:wFull objC:wObjc
	if class of theDate1 = text then set theDate1 to current application's NSDate's dateWithTimeInterval:0 sinceDate:((date theDate1) as date)
	if class of theDate1 ≠ class "NSDate" then set theDate1 to current application's NSDate's dateWithTimeInterval:0 sinceDate:theDate1
	if class of theDate2 = text then set theDate2 to current application's NSDate's dateWithTimeInterval:0 sinceDate:((date theDate2) as date)
	if class of theDate2 ≠ class "NSDate" then set theDate2 to current application's NSDate's dateWithTimeInterval:0 sinceDate:theDate2
	set theDate1 to current application's NSDate's dateWithTimeInterval:0 sinceDate:theDate1
	set theDate2 to current application's NSDate's dateWithTimeInterval:0 sinceDate:theDate2
	set theFormatter to current application's NSDateComponentsFormatter's new()
	set timeInterval to (theDate1's timeIntervalSinceDate:theDate2)
	
	if timeInterval < (-1 * days) or timeInterval > (1 * days) then
		set wFull to (wFull as integer) * 3 -- full string if true
		set wZero to ((wFull as integer) * 4) + 2 -- omits all zero values if true
		theFormatter's setUnitsStyle:wFull -- NSDateComponentsFormatterUnitsStyleFull = 3 -- NSDateComponentsFormatterUnitsStyleAbbreviated = 1
		theFormatter's setZeroFormattingBehavior:wZero -- NSDateComponentsFormatterZeroFormattingBehaviorDropAll = 14
		if timeInterval < (-7 * days) or timeInterval > (7 * days) then theFormatter's setAllowedUnits:(4 + 8 + 4096 + 16) --  NSCalendarUnitYear, NSCalendarUnitMonth,NSCalendarUnitWeekOfMonth, NSCalendarUnitDay
	else
		theFormatter's setAllowedUnits:(32 + 64 + 128) -- NSCalendarUnitHour, NSCalendarUnitMinute, NSCalendarUnitSecond
		theFormatter's setZeroFormattingBehavior:65536 -- NSDateComponentsFormatterZeroFormattingBehaviorPad
	end if
	
	if timeInterval < 0 then set theResult to (theFormatter's stringFromDate:theDate1 toDate:theDate2)
	if timeInterval ≥ 0 then set theResult to (theFormatter's stringFromDate:theDate2 toDate:theDate1)
	if wObjc = true then return theResult
	return theResult as text
end dateSpan:toDate:fullString:objC:

… one way being to use NSCalendar to get a version of the current date with its time set to 0:

use AppleScript version "2.4" -- Yosemite (10.10) or later
use framework "Foundation"
use scripting additions

set inter1 to my getInterval:"1/1/1951" --> "68y 0mo 1w"
set inter2 to my getInterval:"1/1/1950" --> "69y 0mo 1w"
set inter3 to my getInterval:"1/1/1940" --> "79y 0mo 1w"
set inter4 to my getInterval:"1/1/0001" --> "2018y 0mo 1w"
set inter5 to my getInterval:"12/1/2020" --> "-1y 0mo 0w"

on getInterval:theDate
	set theDate to current application's NSDate's dateWithTimeInterval:0 sinceDate:(date theDate as date)
	set theFormatter to current application's NSDateComponentsFormatter's new()
	theFormatter's setAllowedUnits:(4 + 8 + 4096)
	set today to current application's NSCalendar's currentCalendar's startOfDayForDate:(current application's NSDate's |date|())
	
	return (theFormatter's stringFromDate:theDate toDate:(today)) as text
end getInterval:

Yep. Although we’re passing a real anyway, I wanted to be doubly (:wink:) sure.