From b01fd8fe5ba754382193b57e7b93c4fdf87fc71a Mon Sep 17 00:00:00 2001 From: Arthur Cinader <700572+acinader@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:17:22 -0700 Subject: [PATCH 01/13] dev: lib: cell titles, query-safe date spans, and the register's starting balance Additions for report linking in hledger-web. No output changes. - Spreadsheet cells gain a cellTitle field. The HTML writer renders it as the link's title attribute when the cell has an anchor. - showDateSpanForQuery renders a date span as a period expression that parses back to the same span. showDateSpan is for display: it prints inclusive end dates and ISO week names, so a span used as a date: term came back a day short, or, for a week, not at all. - accountTransactionsReportWithStart returns the balance the running total starts from, alongside the report, so a register can show it as a balance brought forward. accountTransactionsReport is unchanged. - transactionRegisterDateExtra is transactionRegisterDate given the account types, so a type: term in the report query matches postings as it does in the report itself. The report sorts its rows with it. AI usage: Claude Fable 5.1, ~8k output tokens. --- hledger-lib/Hledger/Data/Dates.hs | 33 +++++++++++++ .../Reports/AccountTransactionsReport.hs | 48 +++++++++++++++++-- hledger-lib/Hledger/Write/Html.hs | 9 +++- hledger-lib/Hledger/Write/Spreadsheet.hs | 9 +++- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/hledger-lib/Hledger/Data/Dates.hs b/hledger-lib/Hledger/Data/Dates.hs index aa884799333..fdb99d0041a 100644 --- a/hledger-lib/Hledger/Data/Dates.hs +++ b/hledger-lib/Hledger/Data/Dates.hs @@ -46,6 +46,7 @@ module Hledger.Data.Dates ( showDateSpanAbbrev, showDateSpanAbbrevWith, showDateSpanFull, + showDateSpanForQuery, elapsedSeconds, prevday, periodexprp, @@ -170,6 +171,38 @@ showDateSpanFull (DateSpan mb me) = start = maybe "" (formatTime defaultTimeLocale "%F" . fromEFDay) mb end = maybe "" (formatTime defaultTimeLocale "%F" . addDays (-1) . fromEFDay) me +-- | Render a datespan as a period expression that parses back to the +-- same span, for a date: query term or a period parameter. Standard +-- calendar periods take their compact form (2025, 2025Q1, 2025-01, +-- 2025-01-15); any other span is written out with its exclusive end +-- date, which is how the period expression syntax reads one +-- (2025-01-13..2025-01-20); open ends stay open (2025-01-01.., +-- ..2025-02-01); the unbounded span is "". +-- +-- 'showDateSpan' is for display: it prints inclusive end dates and ISO +-- week names, which a query would read as a day short or not at all. +-- +-- >>> showDateSpanForQuery $ DateSpan (Just $ Exact $ fromGregorian 2025 1 1) (Just $ Exact $ fromGregorian 2026 1 1) +-- "2025" +-- >>> showDateSpanForQuery $ DateSpan (Just $ Exact $ fromGregorian 2025 1 13) (Just $ Exact $ fromGregorian 2025 1 20) +-- "2025-01-13..2025-01-20" +-- >>> showDateSpanForQuery $ DateSpan (Just $ Exact $ fromGregorian 2025 1 15) (Just $ Exact $ fromGregorian 2025 2 15) +-- "2025-01-15..2025-02-15" +-- >>> showDateSpanForQuery $ DateSpan (Just $ Exact $ fromGregorian 2025 1 1) Nothing +-- "2025-01-01.." +-- >>> showDateSpanForQuery $ DateSpan Nothing (Just $ Exact $ fromGregorian 2025 2 1) +-- "..2025-02-01" +-- >>> showDateSpanForQuery nulldatespan +-- "" +showDateSpanForQuery :: DateSpan -> Text +showDateSpanForQuery spn = case dateSpanAsPeriod spn of + WeekPeriod b -> T.pack $ iso b <> ".." <> iso (addDays 7 b) + PeriodBetween b e -> T.pack $ iso b <> ".." <> iso e + PeriodTo e -> T.pack $ ".." <> iso e + PeriodAll -> "" + p -> showPeriod p -- a day, month, quarter, or year, or an open end + where iso = formatTime defaultTimeLocale "%F" + -- | Get the current local date. getCurrentDay :: IO Day getCurrentDay = localDay . zonedTimeToLocalTime <$> getZonedTime diff --git a/hledger-lib/Hledger/Reports/AccountTransactionsReport.hs b/hledger-lib/Hledger/Reports/AccountTransactionsReport.hs index 9eb47ffa9ea..97d0c355cf1 100644 --- a/hledger-lib/Hledger/Reports/AccountTransactionsReport.hs +++ b/hledger-lib/Hledger/Reports/AccountTransactionsReport.hs @@ -10,8 +10,10 @@ module Hledger.Reports.AccountTransactionsReport ( AccountTransactionsReport, AccountTransactionsReportItem, accountTransactionsReport, + accountTransactionsReportWithStart, accountTransactionsReportItems, transactionRegisterDate, + transactionRegisterDateExtra, triOrigTransaction, triDate, triAmount, @@ -28,7 +30,7 @@ import Data.List.Extra (nubSort) import Data.Maybe (catMaybes) import Data.Ord (Down(..), comparing) import Data.Text qualified as T -import Data.Time.Calendar (Day) +import Data.Time.Calendar (Day, fromGregorian) import Hledger.Data import Hledger.Query @@ -99,7 +101,15 @@ triCommodityAmount c = filterMixedAmountByCommodity c . triAmount triCommodityBalance c = filterMixedAmountByCommodity c . triBalance accountTransactionsReport :: ReportSpec -> Journal -> Query -> AccountTransactionsReport -accountTransactionsReport rspec@ReportSpec{_rsReportOpts=ropts} j thisacctq = items +accountTransactionsReport rspec j = snd . accountTransactionsReportWithStart rspec j + +-- | The account transactions report, and the balance its running total +-- starts from: zero, or, with historical balances and a start date in +-- the query, the sum of the account's postings before that date (with +-- --average, their average per transaction). A register can show the +-- latter as a balance brought forward. +accountTransactionsReportWithStart :: ReportSpec -> Journal -> Query -> (MixedAmount, AccountTransactionsReport) +accountTransactionsReportWithStart rspec@ReportSpec{_rsReportOpts=ropts} j thisacctq = (startbal, items) where -- A depth limit should not affect the account transactions report; it should show all transactions in/below this account. -- Queries on currency or amount are also ignored at this stage; they are handled earlier, before valuation. @@ -169,7 +179,7 @@ accountTransactionsReport rspec@ReportSpec{_rsReportOpts=ropts} j thisacctq = it -- sort by the transaction's register date, then index, for accurate starting balance . dbg5With (("ts4:\n"++).pshowTransactions.map snd) . sortBy (comparing (Down . fst) <> comparing (Down . tindex . snd)) - . map (\t -> (transactionRegisterDate wd reportq thisacctq t, t)) + . map (\t -> (transactionRegisterDateExtra (journalAccountType j) wd reportq thisacctq t, t)) . map (if invert_ ropts then (\t -> t{tpostings = map postingNegateMainAmount $ tpostings t}) else id) $ jtxns acctJournal @@ -227,11 +237,17 @@ accountTransactionsReportItem reportq thisacctq runningcalc signfn accttypefn (i -- - the transaction date, or its secondary date if --date2 was used. -- transactionRegisterDate :: WhichDate -> Query -> Query -> Transaction -> Day -transactionRegisterDate wd reportq thisacctq t +transactionRegisterDate = transactionRegisterDateExtra (const Nothing) + +-- | Like 'transactionRegisterDate', but given the accounts' types, so +-- that a type: term in the report query matches postings here as it +-- does in the report itself. +transactionRegisterDateExtra :: (AccountName -> Maybe AccountType) -> WhichDate -> Query -> Query -> Transaction -> Day +transactionRegisterDateExtra accttypefn wd reportq thisacctq t | not $ null thisacctps = minimum $ map (postingDateOrDate2 wd) thisacctps | otherwise = transactionDateOrDate2 wd t where - reportps = tpostings $ filterTransactionPostings reportq t + reportps = tpostings $ filterTransactionPostingsExtra accttypefn reportq t thisacctps = filter (matchesPosting thisacctq) reportps -- -- | Generate a short readable summary of some postings, like @@ -290,4 +306,26 @@ filterAccountTransactionsReportByCommodity comm = -- tests tests_AccountTransactionsReport = testGroup "AccountTransactionsReport" [ + testCase "accountTransactionsReportWithStart" $ do + let checking = Acct $ toRegex' "assets:bank:checking" + fromJune = Date $ DateSpan (Just $ Exact $ fromGregorian 2008 6 1) Nothing + rspec accum = defreportspec{_rsQuery=fromJune, _rsReportOpts=defreportopts{balanceaccum_=accum}} + (histstart, histitems) = accountTransactionsReportWithStart (rspec Historical) samplejournal checking + (start, items) = accountTransactionsReportWithStart (rspec PerPeriod) samplejournal checking + -- with historical balances, the running balance starts from the postings before the start date + showMixedAmount histstart @?= "$1.00" + map (showMixedAmount . triBalance) histitems @?= ["$1.00", "$2.00", "$1.00", "$2.00"] + -- otherwise from zero + showMixedAmount start @?= "0" + map (showMixedAmount . triBalance) items @?= ["0", "$1.00", "0", "$1.00"] + + ,testCase "transactionRegisterDateExtra" $ do + let t = nulltransaction{tdate=fromGregorian 2008 1 1, tpostings=[ + ("assets:bank:checking" `post` usd 1){pdate=Just $ fromGregorian 2008 1 5} + ,"income:salary" `post` usd (-1)]} + checking = Acct $ toRegex' "assets:bank:checking" + assettypes a = if "assets" `T.isPrefixOf` a then Just Asset else Nothing + -- a type: term in the report query matches the posting only when the account types are known + transactionRegisterDate PrimaryDate (Type [Asset]) checking t @?= fromGregorian 2008 1 1 + transactionRegisterDateExtra assettypes PrimaryDate (Type [Asset]) checking t @?= fromGregorian 2008 1 5 ] diff --git a/hledger-lib/Hledger/Write/Html.hs b/hledger-lib/Hledger/Write/Html.hs index d32050026d2..dcbaa9c9cc3 100644 --- a/hledger-lib/Hledger/Write/Html.hs +++ b/hledger-lib/Hledger/Write/Html.hs @@ -97,7 +97,10 @@ formatCell cell = let content = if Text.null $ cellAnchor cell then str - else (H.a ! A.href (H.textValue $ cellAnchor cell)) str in + else foldl (!) H.a + (A.href (H.textValue $ cellAnchor cell) : + [A.title (H.textValue $ cellTitle cell) | not $ Text.null $ cellTitle cell]) + str in -- Mark date cells with a "date" class, so eg wrapping within dates -- can be prevented with css; borders are classes too. let class_ = @@ -190,6 +193,10 @@ tests_Hledger_Write_Html = testGroup "Write.Html" [ @?= "