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.Applicative.Permutations
import Control.Monad (guard, unless) import Control.Monad (guard, unless)
import qualified Control.Monad.Fail as Fail (MonadFail, fail) 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.Default (def)
import Data.Foldable (asum) import Data.Foldable (asum)
import Data.Function (on) import Data.Function (on)
@ -910,7 +910,8 @@ smartdate :: TextParser m SmartDate
smartdate = choice' smartdate = choice'
-- XXX maybe obscures date errors ? see ledgerdate -- XXX maybe obscures date errors ? see ledgerdate
[ relativeP [ relativeP
, yyyymmdd, ymd , yyyymmdd
, ymd
, (\(m,d) -> SmartFromReference (Just m) d) <$> md , (\(m,d) -> SmartFromReference (Just m) d) <$> md
, failIfInvalidDate . SmartFromReference Nothing =<< decimal , failIfInvalidDate . SmartFromReference Nothing =<< decimal
, SmartMonth <$> (month <|> mon) , SmartMonth <$> (month <|> mon)
@ -1141,12 +1142,11 @@ reportingintervalp = choice'
] ]
periodexprdatespanp :: Day -> TextParser m DateSpan periodexprdatespanp :: Day -> TextParser m DateSpan
periodexprdatespanp rdate = choice $ map try [ periodexprdatespanp rdate = choice' [
doubledatespanp rdate, doubledatespanp rdate,
quarterdatespanp rdate,
fromdatespanp rdate, fromdatespanp rdate,
todatespanp rdate, todatespanp rdate,
justdatespanp rdate indatespanp rdate
] ]
-- | -- |
@ -1162,45 +1162,95 @@ periodexprdatespanp rdate = choice $ map try [
-- Right DateSpan 2017 -- Right DateSpan 2017
doubledatespanp :: Day -> TextParser m DateSpan doubledatespanp :: Day -> TextParser m DateSpan
doubledatespanp rdate = liftA2 fromToSpan 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 *> choice [string' "to", string "..", string "-"]
*> skipNonNewlineSpaces *> smartdate) *> skipNonNewlineSpaces *> smartdateorquarterstartp rdate)
where where
fromToSpan = DateSpan `on` (Just . fixSmartDate rdate) fromToSpan = DateSpan `on` (Just . fixSmartDate rdate)
-- | -- |
-- >>> parsewith (quarterdatespanp (fromGregorian 2018 01 01) <* eof) "q1" -- >>> let p = parsewith (fromdatespanp (fromGregorian 2024 02 02) <* eof)
-- Right DateSpan 2018Q1 -- >>> p "2025-01-01.."
-- >>> parsewith (quarterdatespanp (fromGregorian 2018 01 01) <* eof) "Q1" -- Right DateSpan 2025-01-01..
-- Right DateSpan 2018Q1 -- >>> p "2025Q1.."
-- >>> parsewith (quarterdatespanp (fromGregorian 2018 01 01) <* eof) "2020q4" -- Right DateSpan 2025-01-01..
-- Right DateSpan 2020Q4 -- >>> p "from q2"
quarterdatespanp :: Day -> TextParser m DateSpan -- Right DateSpan 2024-04-01..
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
fromdatespanp :: Day -> TextParser m DateSpan fromdatespanp :: Day -> TextParser m DateSpan
fromdatespanp rdate = fromSpan <$> choice fromdatespanp rdate = fromSpan <$> choice
[ (string' "from" <|> string' "since") *> skipNonNewlineSpaces *> smartdate [ (string' "from" <|> string' "since") *> skipNonNewlineSpaces *> smartdateorquarterstartp rdate
, smartdate <* choice [string "..", string "-"] , smartdateorquarterstartp rdate <* choice [string "..", string "-"]
] ]
where where
fromSpan b = DateSpan (Just $ fixSmartDate rdate b) Nothing 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 :: Day -> TextParser m DateSpan
todatespanp rdate = todatespanp rdate =
choice [string' "to", string' "until", string "..", string "-"] choice [string' "to", string' "until", string "..", string "-"]
*> skipNonNewlineSpaces *> skipNonNewlineSpaces
*> (DateSpan Nothing . Just . fixSmartDate rdate <$> smartdate) *> (DateSpan Nothing . Just . fixSmartDate rdate <$> smartdateorquarterstartp rdate)
justdatespanp :: Day -> TextParser m DateSpan -- |j
justdatespanp rdate = -- >>> let p = parsewith (indatespanp (fromGregorian 2024 02 02) <* eof)
optional (string' "in" *> skipNonNewlineSpaces) -- >>> p "2025-01-01"
*> (spanFromSmartDate rdate <$> smartdate) -- 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
nulldatespan = DateSpan Nothing Nothing 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. 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. Smart dates can be written with english words, can be relative, and can have parts omitted.
Missing parts are inferred as 1, when needed. 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: 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. 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` `2004-10`
: start of month : start of month
`2004q3`
: start of third quarter of 2004
v`q3`
: start of third quarter of current year
`2004` `2004`
: start of year : 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` will end the report on february 20th.
- `hledger register --monthly` will end the report at the end of february. - `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]. - `hledger register --monthly --begin 1/5 --end 2/14` will end the report on march 4th [1].
[1] Since hledger 1.29. [1] Since hledger 1.29.

View File

@ -247,3 +247,57 @@ $ hledger -f- reg mondays --forecast=2024
2024-01-08 (mondays) 1 1 2024-01-08 (mondays) 1 1
2024-02-12 (mondays) 1 2 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