From 5df98cb178558291fbb66db5a474563f48bbacd6 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Sat, 27 Dec 2025 00:42:47 +0000 Subject: [PATCH 1/2] Duration: support negative durations by prefixing a '-' before the P in ISO format According to [this MDN document](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration) there is an ECMAScript extension to ISO 8601 to allow signed durations by putting a + or - before the ISO duration format. Internally we support signed durations -- we will parse "-60w" or "-60min", which we store in a `time_t` (whose signedness is technically implementation defined, but which is signed on all major compilers). However, when formatting these as ISO 8601 we get get garbed output like PT-1H-56M-9S, which we cannot parse and probably neither can anything else. (Taskwarrior, when asked to assign a negative duration to a duration-typed UDA, will store this garbled output but then reproduce it as PT0S, an unfortunate user experience.) This PR updates `Duration::formatISO` to instead prepend a '-' before negative durations, and updates `Duration::parse_designated` to parse such things. Alternative to #110, which simply blesses the garbled format by extending the parser to support it. --- src/Duration.cpp | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Duration.cpp b/src/Duration.cpp index 55da1dd..66f1fa6 100644 --- a/src/Duration.cpp +++ b/src/Duration.cpp @@ -217,25 +217,28 @@ bool Duration::parse_designated (Pig& pig) { auto checkpoint = pig.cursor (); + // sign = -1 if a '-' is present, else 1 + int sign = !pig.skip ('-') * 2 - 1; + if (pig.skip ('P') && ! pig.eos ()) { long long value; pig.save (); if (pig.getDigits (value) && pig.skip ('Y')) - _year = value; + _year = sign * value; else pig.restore (); pig.save (); if (pig.getDigits (value) && pig.skip ('M')) - _month = value; + _month = sign * value; else pig.restore (); pig.save (); if (pig.getDigits (value) && pig.skip ('D')) - _day = value; + _day = sign * value; else pig.restore (); @@ -244,19 +247,19 @@ bool Duration::parse_designated (Pig& pig) { pig.save (); if (pig.getDigits (value) && pig.skip ('H')) - _hours = value; + _hours = sign * value; else pig.restore (); pig.save (); if (pig.getDigits (value) && pig.skip ('M')) - _minutes = value; + _minutes = sign * value; else pig.restore (); pig.save (); if (pig.getDigits (value) && pig.skip ('S')) - _seconds = value; + _seconds = sign * value; else pig.restore (); } @@ -454,12 +457,18 @@ std::string Duration::formatISO () const if (_period) { time_t t = _period; + + std::stringstream s; + if (t < 0) { + s << '-'; + t *= -1; + } + int seconds = t % 60; t /= 60; int minutes = t % 60; t /= 60; int hours = t % 24; t /= 24; int days = t; - std::stringstream s; s << 'P'; if (days) s << days << 'D'; From b76e66ed0d2b4ca4f95b051adbea16c52de6ba18 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Sat, 27 Dec 2025 00:56:38 +0000 Subject: [PATCH 2/2] Duration: update other format methods to support negative dates This will let us remove some special-case logic from Timewarrior. --- src/Duration.cpp | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/Duration.cpp b/src/Duration.cpp index 66f1fa6..f728f7d 100644 --- a/src/Duration.cpp +++ b/src/Duration.cpp @@ -403,12 +403,18 @@ std::string Duration::format () const if (_period) { time_t t = _period; + + std::stringstream s; + if (t < 0) { + s << '-'; + t *= -1; + } + int seconds = t % 60; t /= 60; int minutes = t % 60; t /= 60; int hours = t % 24; t /= 24; int days = t; - std::stringstream s; if (days) s << days << "d "; @@ -432,11 +438,17 @@ std::string Duration::formatHours () const if (_period) { time_t t = _period; + + std::stringstream s; + if (t < 0) { + s << '-'; + t *= -1; + } + int seconds = t % 60; t /= 60; int minutes = t % 60; t /= 60; int hours = t; - std::stringstream s; s << hours << ':' << std::setw (2) << std::setfill ('0') << minutes @@ -501,16 +513,23 @@ std::string Duration::formatISO () const // std::string Duration::formatVague (bool padding) const { + time_t t = _period; float days = (float) _period / 86400.0; std::stringstream formatted; - if (_period >= 86400 * 365) formatted << std::fixed << std::setprecision (1) << (days / 365) << (padding ? "y " : "y"); - else if (_period >= 86400 * 90) formatted << static_cast (days / 30) << (padding ? "mo " : "mo"); - else if (_period >= 86400 * 14) formatted << static_cast (days / 7) << (padding ? "w " : "w"); - else if (_period >= 86400) formatted << static_cast (days) << (padding ? "d " : "d"); - else if (_period >= 3600) formatted << static_cast (_period / 3600) << (padding ? "h " : "h"); - else if (_period >= 60) formatted << static_cast (_period / 60) << "min"; // Longest suffix - no padding - else if (_period >= 1) formatted << static_cast (_period) << (padding ? "s " : "s"); + if (t < 0) { + formatted << '-'; + t *= -1; + days *= -1.0; + } + + if (t >= 86400 * 365) formatted << std::fixed << std::setprecision (1) << (days / 365) << (padding ? "y " : "y"); + else if (t >= 86400 * 90) formatted << static_cast (days / 30) << (padding ? "mo " : "mo"); + else if (t >= 86400 * 14) formatted << static_cast (days / 7) << (padding ? "w " : "w"); + else if (t >= 86400) formatted << static_cast (days) << (padding ? "d " : "d"); + else if (t >= 3600) formatted << static_cast (t / 3600) << (padding ? "h " : "h"); + else if (t >= 60) formatted << static_cast (t / 60) << "min"; // Longest suffix - no padding + else if (t >= 1) formatted << static_cast (t) << (padding ? "s " : "s"); return formatted.str (); }