imp: fully support quarter syntax in smart dates and period expressions

This commit is contained in:
Simon Michael 2025-06-23 18:37:08 -07:00
parent afd18a10bf
commit 714b346fdd
3 changed files with 145 additions and 34 deletions

View File

@ -89,7 +89,7 @@ import Control.Applicative (Applicative(..))
import Control.Applicative.Permutations
import Control.Monad (guard, unless)
import qualified Control.Monad.Fail as Fail (MonadFail, fail)
import Data.Char (digitToInt, isDigit, ord)
import Data.Char (digitToInt, isDigit)
import Data.Default (def)
import Data.Foldable (asum)
import Data.Function (on)
@ -910,7 +910,8 @@ smartdate :: TextParser m SmartDate
smartdate = choice'
-- XXX maybe obscures date errors ? see ledgerdate
[ relativeP
, yyyymmdd, ymd
, yyyymmdd
, ymd
, (\(m,d) -> SmartFromReference (Just m) d) <$> md
, failIfInvalidDate . SmartFromReference Nothing =<< decimal
, SmartMonth <$> (month <|> mon)
@ -1141,12 +1142,11 @@ reportingintervalp = choice'
]
periodexprdatespanp :: Day -> TextParser m DateSpan
periodexprdatespanp rdate = choice $ map try [
periodexprdatespanp rdate = choice' [
doubledatespanp rdate,
quarterdatespanp rdate,
fromdatespanp rdate,
todatespanp rdate,
justdatespanp rdate
indatespanp rdate
]
-- |
@ -1162,45 +1162,95 @@ periodexprdatespanp rdate = choice $ map try [
-- Right DateSpan 2017
doubledatespanp :: Day -> TextParser m DateSpan
doubledatespanp rdate = liftA2 fromToSpan
(optional ((string' "from" <|> string' "since") *> skipNonNewlineSpaces) *> smartdate)
(optional ((string' "from" <|> string' "since") *> skipNonNewlineSpaces) *> smartdateorquarterstartp rdate)
(skipNonNewlineSpaces *> choice [string' "to", string "..", string "-"]
*> skipNonNewlineSpaces *> smartdate)
*> skipNonNewlineSpaces *> smartdateorquarterstartp rdate)
where
fromToSpan = DateSpan `on` (Just . fixSmartDate rdate)
-- |
-- >>> parsewith (quarterdatespanp (fromGregorian 2018 01 01) <* eof) "q1"
-- Right DateSpan 2018Q1
-- >>> parsewith (quarterdatespanp (fromGregorian 2018 01 01) <* eof) "Q1"
-- Right DateSpan 2018Q1
-- >>> parsewith (quarterdatespanp (fromGregorian 2018 01 01) <* eof) "2020q4"
-- Right DateSpan 2020Q4
quarterdatespanp :: Day -> TextParser m DateSpan
quarterdatespanp rdate = do
y <- yearp <|> pure (first3 $ toGregorian rdate)
q <- char' 'q' *> satisfy is4Digit
return . periodAsDateSpan $ QuarterPeriod y (digitToInt q)
where
is4Digit c = (fromIntegral (ord c - ord '1') :: Word) <= 3
-- >>> let p = parsewith (fromdatespanp (fromGregorian 2024 02 02) <* eof)
-- >>> p "2025-01-01.."
-- Right DateSpan 2025-01-01..
-- >>> p "2025Q1.."
-- Right DateSpan 2025-01-01..
-- >>> p "from q2"
-- Right DateSpan 2024-04-01..
fromdatespanp :: Day -> TextParser m DateSpan
fromdatespanp rdate = fromSpan <$> choice
[ (string' "from" <|> string' "since") *> skipNonNewlineSpaces *> smartdate
, smartdate <* choice [string "..", string "-"]
]
[ (string' "from" <|> string' "since") *> skipNonNewlineSpaces *> smartdateorquarterstartp rdate
, smartdateorquarterstartp rdate <* choice [string "..", string "-"]
]
where
fromSpan b = DateSpan (Just $ fixSmartDate rdate b) Nothing
-- |
-- >>> let p = parsewith (todatespanp (fromGregorian 2024 02 02) <* eof)
-- >>> p "..2025-01-01"
-- Right DateSpan ..2024-12-31
-- >>> p "..2025Q1"
-- Right DateSpan ..2024-12-31
-- >>> p "to q2"
-- Right DateSpan ..2024-03-31
todatespanp :: Day -> TextParser m DateSpan
todatespanp rdate =
choice [string' "to", string' "until", string "..", string "-"]
*> skipNonNewlineSpaces
*> (DateSpan Nothing . Just . fixSmartDate rdate <$> smartdate)
choice [string' "to", string' "until", string "..", string "-"]
*> skipNonNewlineSpaces
*> (DateSpan Nothing . Just . fixSmartDate rdate <$> smartdateorquarterstartp rdate)
justdatespanp :: Day -> TextParser m DateSpan
justdatespanp rdate =
optional (string' "in" *> skipNonNewlineSpaces)
*> (spanFromSmartDate rdate <$> smartdate)
-- |j
-- >>> let p = parsewith (indatespanp (fromGregorian 2024 02 02) <* eof)
-- >>> p "2025-01-01"
-- Right DateSpan 2025-01-01
-- >>> p "2025q1"
-- Right DateSpan 2025Q1
-- >>> p "in Q2"
-- Right DateSpan 2024Q2
indatespanp :: Day -> TextParser m DateSpan
indatespanp rdate =
optional (string' "in" *> skipNonNewlineSpaces)
*> choice' [
quarterspanp rdate,
spanFromSmartDate rdate <$> smartdate
]
-- Helper: parse a quarter number, optionally preceded by a year.
quarterp :: Day -> TextParser m (Year, Int)
quarterp rdate = do
y <- yearp <|> pure (first3 $ toGregorian rdate)
n <- char' 'q' *> satisfy (`elem` ['1' .. '4']) >>= return . digitToInt
return (y, n)
-- | Parse a single quarter (YYYYqN or qN, case insensitive q) as a date span.
--
-- >>> parsewith (quarterspanp (fromGregorian 2018 01 01) <* eof) "q1"
-- Right DateSpan 2018Q1
-- >>> parsewith (quarterspanp (fromGregorian 2018 01 01) <* eof) "Q1"
-- Right DateSpan 2018Q1
-- >>> parsewith (quarterspanp (fromGregorian 2018 01 01) <* eof) "2020q4"
-- Right DateSpan 2020Q4
quarterspanp :: Day -> TextParser m DateSpan
quarterspanp rdate = do
(y,q) <- quarterp rdate
return . periodAsDateSpan $ QuarterPeriod y q
-- | Parse a quarter (YYYYqN or qN, case insensitive q) as its start date.
--
-- >>> parsewith (quarterstartp (fromGregorian 2025 02 02) <* eof) "q1"
-- Right 2025-01-01
-- >>> parsewith (quarterstartp (fromGregorian 2025 02 02) <* eof) "Q2"
-- Right 2025-04-01
-- >>> parsewith (quarterstartp (fromGregorian 2025 02 02) <* eof) "2025q4"
-- Right 2025-10-01
quarterstartp :: Day -> TextParser m Day
quarterstartp rdate = do
(y,q) <- quarterp rdate
return $
fromMaybe (error' "Hledger.Data.Dates.quarterstartp: invalid date found") $ -- PARTIAL, shouldn't happen
periodStart $ QuarterPeriod y q
smartdateorquarterstartp :: Day -> TextParser m SmartDate
smartdateorquarterstartp rdate = choice' [SmartCompleteDate <$> quarterstartp rdate, smartdate]
nulldatespan :: DateSpan
nulldatespan = DateSpan Nothing Nothing

View File

@ -4954,17 +4954,24 @@ during January 2020 (the smallest common period, with the -p overriding -b and -
In hledger's user interfaces (though not in the journal file), you can optionally use "smart date" syntax.
Smart dates can be written with english words, can be relative, and can have parts omitted.
Missing parts are inferred as 1, when needed.
Smart dates can be interpreted as dates or periods depending on context.
Smart dates can be interpreted as dates or periods depending on the context.
Examples:
`2004-01-01`, `2004/10/1`, `2004.9.1`, `20240504`
`2004-01-01`, `2004/10/1`, `2004.9.1`, `20240504`, `2024Q1`
:\
Exact dates. The year must have at least four digits, the month must be 1-12, the day must be 1-31, the separator can be `-` or `/` or `.` or nothing.
The q can be upper or lower case and the quarter number must be 1-4.
`2004-10`
: start of month
`2004q3`
: start of third quarter of 2004
v`q3`
: start of third quarter of current year
`2004`
: start of year
@ -5047,7 +5054,7 @@ For example, if the journal's last transaction is on february 20th,
- `hledger register` will end the report on february 20th.
- `hledger register --monthly` will end the report at the end of february.
- `hledger register --monthly --end 2/14` also will end the report at the end of february.
- `hledger register --monthly --end 2/14` also will end the report at the end of february (overriding the requested end date).
- `hledger register --monthly --begin 1/5 --end 2/14` will end the report on march 4th [1].
[1] Since hledger 1.29.

View File

@ -247,3 +247,57 @@ $ hledger -f- reg mondays --forecast=2024
2024-01-08 (mondays) 1 1
2024-02-12 (mondays) 1 2
# ** 20. using quarters as start/end dates
<
2025-06-01
(a) 1
2025-07-01
(a) 1
2025-08-01
(a) 1
2025-09-01
(a) 1
2025-10-01
(a) 1
2025-11-01
(a) 1
2025-12-01
(a) 1
2026-01-01
(a) 1
2026-02-01
(a) 1
2026-03-01
(a) 1
2026-04-01
(a) 1
2026-05-01
(a) 1
2026-06-01
(a) 1
$ hledger -f - reg --today=2025 -p q4-2026q2
2025-10-01 (a) 1 1
2025-11-01 (a) 1 2
2025-12-01 (a) 1 3
2026-01-01 (a) 1 4
2026-02-01 (a) 1 5
2026-03-01 (a) 1 6
# ** 21. using a quarter as the period
$ hledger -f - --forecast=2025 reg --today=2025 -p q4
2025-10-01 (a) 1 1
2025-11-01 (a) 1 2
2025-12-01 (a) 1 3