From acdada2ac18a6929a691e19db93e6d3622d8b8b5 Mon Sep 17 00:00:00 2001 From: Anantha Kumaran Date: Sun, 1 Oct 2023 14:07:56 +0530 Subject: [PATCH] add recurring calendar view --- .github/workflows/docker.yml | 2 +- CHANGELOG.md | 1 - docs/stylesheets/extra.css | 2 +- internal/ledger/ledger.go | 79 ++++--- internal/model/posting/posting.go | 1 + internal/model/transaction/transaction.go | 19 +- internal/server/recurring.go | 20 +- package-lock.json | 43 ++++ package.json | 2 + src/app.scss | 36 ++++ src/lib/components/Navbar.svelte | 29 ++- src/lib/components/RecurringCard.svelte | 30 ++- src/lib/components/RecurringDay.svelte | 25 +++ src/lib/components/RecurringSchedule.svelte | 39 ++++ src/lib/expense/monthly.ts | 24 +-- src/lib/import.test.ts | 4 + src/lib/recurring.ts | 67 ++---- src/lib/transaction_sequence.ts | 219 ++++++++++++++++++++ src/lib/utils.ts | 88 +++----- src/routes/+layout.ts | 4 + src/routes/+page.svelte | 44 ++-- src/routes/cash_flow/recurring/+page.svelte | 60 +++++- 22 files changed, 639 insertions(+), 199 deletions(-) create mode 100644 src/lib/components/RecurringDay.svelte create mode 100644 src/lib/components/RecurringSchedule.svelte create mode 100644 src/lib/transaction_sequence.ts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5836f44f..e12a2824 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,4 +1,4 @@ -name: Release Docker Image +name: Docker Image Release on: push: branches: diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2653b9..ff81892b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,6 @@ it again via new version. If you have already upgraded, you can still get the data directly from the db file using the following query `sqlite3 paisa.db "select * from templates";` - ### 0.5.2 (2023-09-22) * Add Desktop app diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index be1fa2a7..832ad58e 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -9,7 +9,7 @@ /* --md-code-hl-name-color */ /* --md-code-hl-operator-color */ /* --md-code-hl-punctuation-color */ - --md-code-hl-comment-color: #940; + --md-code-hl-comment-color: #7a7a7a; /* --md-code-hl-generic-color */ --md-code-hl-variable-color: #085; } diff --git a/internal/ledger/ledger.go b/internal/ledger/ledger.go index 4ed3dab2..6553a616 100644 --- a/internal/ledger/ledger.go +++ b/internal/ledger/ledger.go @@ -258,7 +258,23 @@ func parseAmount(amount string) (string, decimal.Decimal, error) { func execLedgerCommand(journalPath string, flags []string) ([]*posting.Posting, error) { var postings []*posting.Posting - args := append(append([]string{"--args-only", "-f", journalPath}, flags...), "csv", "--csv-format", "%(quoted(date)),%(quoted(payee)),%(quoted(display_account)),%(quoted(commodity(scrub(display_amount)))),%(quoted(quantity(scrub(display_amount)))),%(quoted(scrub(market(amount,date,'"+config.DefaultCurrency()+"') * 100000000))),%(quoted(xact.filename)),%(quoted(xact.id)),%(quoted(cleared ? \"*\" : (pending ? \"!\" : \"\"))),%(quoted(tag('Recurring'))),%(quoted(xact.beg_line)),%(quoted(xact.end_line)),%(quoted(lot_price(amount)))\n") + const ( + Date = iota + Payee + Account + Commodity + Quantity + Amount + FileName + SequenceID + Status + TransactionBeginLine + TransactionEndLine + LotPrice + TagRecurring + TagPeriod + ) + args := append(append([]string{"--args-only", "-f", journalPath}, flags...), "csv", "--csv-format", "%(quoted(date)),%(quoted(payee)),%(quoted(display_account)),%(quoted(commodity(scrub(display_amount)))),%(quoted(quantity(scrub(display_amount)))),%(quoted(scrub(market(amount,date,'"+config.DefaultCurrency()+"') * 100000000))),%(quoted(xact.filename)),%(quoted(xact.id)),%(quoted(cleared ? \"*\" : (pending ? \"!\" : \"\"))),%(quoted(xact.beg_line)),%(quoted(xact.end_line)),%(quoted(lot_price(amount))),%(quoted(tag('Recurring'))),%(quoted(tag('Period')))\n") var output, error bytes.Buffer err := utils.Exec(binary.LedgerBinaryPath(), &output, &error, args...) @@ -278,12 +294,12 @@ func execLedgerCommand(journalPath string, flags []string) ([]*posting.Posting, dir := filepath.Dir(config.GetJournalPath()) for _, record := range records { - date, err := time.ParseInLocation("2006/01/02", record[0], time.Local) + date, err := time.ParseInLocation("2006/01/02", record[Date], time.Local) if err != nil { return nil, err } - quantity, err := decimal.NewFromString(record[4]) + quantity, err := decimal.NewFromString(record[Quantity]) if err != nil { return nil, err } @@ -291,9 +307,9 @@ func execLedgerCommand(journalPath string, flags []string) ([]*posting.Posting, var amount decimal.Decimal amountAvailable := false - lotString := record[12] + lotString := record[LotPrice] if lotString != "" { - lotCurrency, lotAmount, err := parseAmount(record[12]) + lotCurrency, lotAmount, err := parseAmount(record[LotPrice]) if err != nil { return nil, err } @@ -305,14 +321,14 @@ func execLedgerCommand(journalPath string, flags []string) ([]*posting.Posting, } if !amountAvailable { - _, amount, err = parseAmount(record[5]) + _, amount, err = parseAmount(record[Amount]) if err != nil { return nil, err } amount = amount.Div(decimal.NewFromInt(100000000)) } - if record[1] == "Budget transaction" { + if record[Payee] == "Budget transaction" { amount = amount.Neg() } @@ -321,53 +337,59 @@ func execLedgerCommand(journalPath string, flags []string) ([]*posting.Posting, var fileName string var forecast bool - if record[1] == "Budget transaction" || record[1] == "Forecast transaction" { - transactionID = uuid.NewV5(namespace, record[0]+":"+record[1]).String() + if record[Payee] == "Budget transaction" || record[Payee] == "Forecast transaction" { + transactionID = uuid.NewV5(namespace, record[Date]+":"+record[Payee]).String() forecast = true } else { - fileName, err = filepath.Rel(dir, record[6]) + fileName, err = filepath.Rel(dir, record[FileName]) if err != nil { return nil, err } - transactionID = uuid.NewV5(namespace, fileName+":"+record[7]).String() + transactionID = uuid.NewV5(namespace, fileName+":"+record[SequenceID]).String() forecast = false } var status string - if record[8] == "*" { + if record[Status] == "*" { status = "cleared" - } else if record[8] == "!" { + } else if record[Status] == "!" { status = "pending" } else { status = "unmarked" } - var tagRecurring string - if record[9] != "" { - tagRecurring = record[9] - } - - transactionBeginLine, err := strconv.ParseUint(record[10], 10, 64) + transactionBeginLine, err := strconv.ParseUint(record[TransactionBeginLine], 10, 64) if err != nil { return nil, err } - transactionEndLine, err := strconv.ParseUint(record[11], 10, 64) + transactionEndLine, err := strconv.ParseUint(record[TransactionEndLine], 10, 64) if err != nil { return nil, err } + var tagRecurring string + if record[TagRecurring] != "" { + tagRecurring = record[TagRecurring] + } + + var tagPeriod string + if record[TagPeriod] != "" { + tagPeriod = record[TagPeriod] + } + posting := posting.Posting{ Date: date, - Payee: record[1], - Account: record[2], - Commodity: utils.UnQuote(record[3]), + Payee: record[Payee], + Account: record[Account], + Commodity: utils.UnQuote(record[Commodity]), Quantity: quantity, Amount: amount, TransactionID: transactionID, Status: status, TagRecurring: tagRecurring, + TagPeriod: tagPeriod, TransactionBeginLine: transactionBeginLine, TransactionEndLine: transactionEndLine, Forecast: forecast, @@ -463,7 +485,7 @@ func execHLedgerCommand(journalPath string, prices []price.Price, flags []string } } - var tagRecurring string + var tagRecurring, tagPeriod string for _, tag := range t.Tags { if len(tag) == 2 { @@ -471,6 +493,10 @@ func execHLedgerCommand(journalPath string, prices []price.Price, flags []string tagRecurring = tag[1] } + if tag[0] == "Period" { + tagPeriod = tag[1] + } + if tag[0] == "_generated-transaction" { forecast = true } @@ -482,6 +508,10 @@ func execHLedgerCommand(journalPath string, prices []price.Price, flags []string if len(tag) == 2 && tag[0] == "Recurring" { tagRecurring = tag[1] } + + if len(tag) == 2 && tag[0] == "Period" { + tagPeriod = tag[1] + } break } @@ -504,6 +534,7 @@ func execHLedgerCommand(journalPath string, prices []price.Price, flags []string TransactionID: strconv.FormatInt(t.ID, 10), Status: strings.ToLower(t.Status), TagRecurring: tagRecurring, + TagPeriod: tagPeriod, TransactionBeginLine: t.TSourcePos[0].SourceLine, TransactionEndLine: t.TSourcePos[1].SourceLine, Forecast: forecast, diff --git a/internal/model/posting/posting.go b/internal/model/posting/posting.go index 21eebc30..8a874515 100644 --- a/internal/model/posting/posting.go +++ b/internal/model/posting/posting.go @@ -20,6 +20,7 @@ type Posting struct { Amount decimal.Decimal `json:"amount"` Status string `json:"status"` TagRecurring string `json:"tag_recurring"` + TagPeriod string `json:"tag_period"` TransactionBeginLine uint64 `json:"transaction_begin_line"` TransactionEndLine uint64 `json:"transaction_end_line"` FileName string `json:"file_name"` diff --git a/internal/model/transaction/transaction.go b/internal/model/transaction/transaction.go index cc3447ca..fec10bcc 100644 --- a/internal/model/transaction/transaction.go +++ b/internal/model/transaction/transaction.go @@ -13,6 +13,7 @@ type Transaction struct { Payee string `json:"payee"` Postings []posting.Posting `json:"postings"` TagRecurring string `json:"tag_recurring"` + TagPeriod string `json:"tag_period"` BeginLine uint64 `json:"beginLine"` EndLine uint64 `json:"endLine"` FileName string `json:"fileName"` @@ -23,13 +24,27 @@ func Build(postings []posting.Posting) []Transaction { return lo.Map(lo.Values(grouped), func(ps []posting.Posting, _ int) Transaction { sample := ps[0] var tagRecurring string + var tagPeriod string for _, p := range ps { if p.TagRecurring != "" { tagRecurring = p.TagRecurring - break } + + if p.TagPeriod != "" { + tagPeriod = p.TagPeriod + } + } + return Transaction{ + ID: sample.TransactionID, + Date: sample.Date, + Payee: sample.Payee, + Postings: ps, + TagRecurring: tagRecurring, + TagPeriod: tagPeriod, + BeginLine: sample.TransactionBeginLine, + EndLine: sample.TransactionEndLine, + FileName: sample.FileName, } - return Transaction{ID: sample.TransactionID, Date: sample.Date, Payee: sample.Payee, Postings: ps, TagRecurring: tagRecurring, BeginLine: sample.TransactionBeginLine, EndLine: sample.TransactionEndLine, FileName: sample.FileName} }) } diff --git a/internal/server/recurring.go b/internal/server/recurring.go index 581b1693..094e50e3 100644 --- a/internal/server/recurring.go +++ b/internal/server/recurring.go @@ -18,15 +18,12 @@ func GetRecurringTransactions(db *gorm.DB) gin.H { type TransactionSequence struct { Transactions []transaction.Transaction `json:"transactions"` - Key TransactionSequenceKey `json:"key"` + Key string `json:"key"` + Period string `json:"period"` Interval int `json:"interval"` DaysSinceLastTransaction int `json:"days_since_last_transaction"` } -type TransactionSequenceKey struct { - TagRecurring string `json:"tagRecurring"` -} - func ComputeRecurringTransactions(postings []posting.Posting) []TransactionSequence { now := utils.EndOfToday() @@ -38,23 +35,28 @@ func ComputeRecurringTransactions(postings []posting.Posting) []TransactionSeque transactions = lo.Filter(transactions, func(t transaction.Transaction, _ int) bool { return t.TagRecurring != "" }) - transactionsGrouped := lo.GroupBy(transactions, func(t transaction.Transaction) TransactionSequenceKey { - return TransactionSequenceKey{TagRecurring: t.TagRecurring} + transactionsGrouped := lo.GroupBy(transactions, func(t transaction.Transaction) string { + return t.TagRecurring }) - transaction_sequences := lo.MapToSlice(transactionsGrouped, func(key TransactionSequenceKey, ts []transaction.Transaction) TransactionSequence { + transaction_sequences := lo.MapToSlice(transactionsGrouped, func(key string, ts []transaction.Transaction) TransactionSequence { sort.SliceStable(ts, func(i, j int) bool { return ts[i].Date.After(ts[j].Date) }) interval := 0 daysSinceLastTransaction := 0 + var period string + if ts[0].TagPeriod != "" { + period = ts[0].TagPeriod + } + if len(ts) > 1 { interval = int(ts[0].Date.Sub(ts[1].Date).Hours() / 24) daysSinceLastTransaction = int(now.Sub(ts[0].Date).Hours() / 24) } - return TransactionSequence{Transactions: ts, Key: key, Interval: interval, DaysSinceLastTransaction: daysSinceLastTransaction} + return TransactionSequence{Transactions: ts, Key: key, Interval: interval, DaysSinceLastTransaction: daysSinceLastTransaction, Period: period} }) sort.SliceStable(transaction_sequences, func(i, j int) bool { diff --git a/package-lock.json b/package-lock.json index 3c2a38ff..04e5b3ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@cityssm/bulma-sticky-table": "^2.1.0", "@codemirror/autocomplete": "^6.4.2", + "@datasert/cronjs-matcher": "^1.2.0", + "@datasert/cronjs-parser": "^1.2.0", "@egjs/svelte-grid": "^1.14.2", "@fontsource-variable/roboto-flex": "^5.0.8", "@fontsource-variable/roboto-mono": "^5.0.9", @@ -763,6 +765,20 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@datasert/cronjs-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@datasert/cronjs-matcher/-/cronjs-matcher-1.2.0.tgz", + "integrity": "sha512-ht6Vwwa3qssMn/9bphypjG/U8w0DV3GtTS2C6kbAy39rerQFTRzmml9xZNlot1K13gm9K/EEq3DLPEOsH++ICw==", + "dependencies": { + "@datasert/cronjs-parser": "^1.2.0", + "luxon": "^2.1.1" + } + }, + "node_modules/@datasert/cronjs-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@datasert/cronjs-parser/-/cronjs-parser-1.2.0.tgz", + "integrity": "sha512-7kzYh7F5V3ElX+k3W9w6SKS6WdjqJQ2gIY1y0evldnjAwZxnFzR/Yu9Mv9OeDaCQX+mGAq2MvEnJbwu9oj3CXQ==" + }, "node_modules/@egjs/children-differ": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@egjs/children-differ/-/children-differ-1.0.1.tgz", @@ -5407,6 +5423,14 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz", + "integrity": "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA==", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", @@ -8443,6 +8467,20 @@ } } }, + "@datasert/cronjs-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@datasert/cronjs-matcher/-/cronjs-matcher-1.2.0.tgz", + "integrity": "sha512-ht6Vwwa3qssMn/9bphypjG/U8w0DV3GtTS2C6kbAy39rerQFTRzmml9xZNlot1K13gm9K/EEq3DLPEOsH++ICw==", + "requires": { + "@datasert/cronjs-parser": "^1.2.0", + "luxon": "^2.1.1" + } + }, + "@datasert/cronjs-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@datasert/cronjs-parser/-/cronjs-parser-1.2.0.tgz", + "integrity": "sha512-7kzYh7F5V3ElX+k3W9w6SKS6WdjqJQ2gIY1y0evldnjAwZxnFzR/Yu9Mv9OeDaCQX+mGAq2MvEnJbwu9oj3CXQ==" + }, "@egjs/children-differ": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@egjs/children-differ/-/children-differ-1.0.1.tgz", @@ -11828,6 +11866,11 @@ "yallist": "^4.0.0" } }, + "luxon": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz", + "integrity": "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA==" + }, "magic-string": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", diff --git a/package.json b/package.json index 939d8d02..88bbee5a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "dependencies": { "@cityssm/bulma-sticky-table": "^2.1.0", "@codemirror/autocomplete": "^6.4.2", + "@datasert/cronjs-matcher": "^1.2.0", + "@datasert/cronjs-parser": "^1.2.0", "@egjs/svelte-grid": "^1.14.2", "@fontsource-variable/roboto-flex": "^5.0.8", "@fontsource-variable/roboto-mono": "^5.0.9", diff --git a/src/app.scss b/src/app.scss index b01647d4..027e2058 100644 --- a/src/app.scss +++ b/src/app.scss @@ -102,6 +102,10 @@ code { visibility: hidden; } +.is-bordered.is-link { + border: 1px solid rgba($link, 0.4); +} + .is-bordered-bottom { border-bottom: 1px solid $grey-lighter; } @@ -154,6 +158,18 @@ svg text { fill: $grey-dark; } +.svg-text-success { + fill: $success; +} + +.svg-text-danger { + fill: $danger; +} + +.svg-text-warning-dark { + fill: $warning-dark; +} + .svg-grey-light { stroke: $grey-light; } @@ -204,6 +220,12 @@ svg text { shape-rendering: crispEdges; } + &.light-domain { + path.domain { + stroke: $grey-lightest; + } + } + .tick line { stroke: $white-ter; shape-rendering: crispEdges; @@ -315,6 +337,16 @@ svg text { cursor: zoom-in; } +.weekdays-grid { + border-radius: $radius-small; + + div { + padding: 2px 0; + color: $grey-dark; + background: $white-ter; + } +} + .d3-calendar .days, .d3-calendar .months, .d3-calendar .weekdays { @@ -422,6 +454,10 @@ nav.level.grid-2 { color: $link; } + .cm-line .ͼm { + color: $grey; + } + .cm-line .ͼ9 { font-weight: bold; } diff --git a/src/lib/components/Navbar.svelte b/src/lib/components/Navbar.svelte index e82eea23..040a9917 100644 --- a/src/lib/components/Navbar.svelte +++ b/src/lib/components/Navbar.svelte @@ -29,6 +29,13 @@ } }); + const RecurringIcons = [ + { icon: "fa-circle-check", color: "success", label: "Cleared" }, + { icon: "fa-circle-check", color: "warning-dark", label: "Cleared late" }, + { icon: "fa-exclamation-triangle", color: "danger", label: "Past due" }, + { icon: "fa-circle-check", color: "grey", label: "Upcoming" } + ]; + interface Link { label: string; href: string; @@ -40,6 +47,7 @@ cashflowTypePicker?: boolean; financialYearPicker?: boolean; maxDepthSelector?: boolean; + recurringIcons?: boolean; children?: Link[]; } const links: Link[] = [ @@ -56,7 +64,13 @@ financialYearPicker: true, maxDepthSelector: true }, - { label: "Recurring", href: "/recurring", help: "recurring" } + { + label: "Recurring", + href: "/recurring", + help: "recurring", + monthPicker: true, + recurringIcons: true + } ] }, { @@ -331,6 +345,19 @@ {/if}
+ {#if selectedSubLink?.recurringIcons} +
+ {#each RecurringIcons as icon} +
+ + + + {icon.label} +
+ {/each} +
+ {/if} + {#if selectedSubLink?.cashflowTypePicker} import Carousel from "svelte-carousel"; import Transaction from "$lib/components/Transaction.svelte"; - import { - formatCurrencyCrude, - intervalText, - totalRecurring, - type TransactionSequence - } from "$lib/utils"; + import { intervalText, totalRecurring } from "$lib/transaction_sequence"; + import { formatCurrencyCrude, type TransactionSequence } from "$lib/utils"; import dayjs from "dayjs"; import type { Action } from "svelte/action"; import { renderRecurring } from "$lib/recurring"; @@ -15,6 +11,7 @@ export let ts: TransactionSequence; export let n: dayjs.Dayjs; const now = dayjs(); + const HEIGHT = 50; let carousel: Carousel; let pageSize = _.min([20, ts.transactions.length]); @@ -27,23 +24,24 @@ element, props ) => { - renderRecurring(element, props.ts, props.next, showPage); + renderRecurring(element, props.ts, showPage); return {}; }; -
+
+
+
{ts.key}
+
+
+
- - + + {formatCurrencyCrude(totalRecurring(ts))} due {n.fromNow()} @@ -58,10 +56,10 @@

- +
- {ts.key.tagRecurring} started on + {ts.key} started on {_.last(ts.transactions).date.format("DD MMM YYYY")}, with a total of {ts.transactions.length} transactions so far.
diff --git a/src/lib/components/RecurringDay.svelte b/src/lib/components/RecurringDay.svelte new file mode 100644 index 00000000..aa09b6e3 --- /dev/null +++ b/src/lib/components/RecurringDay.svelte @@ -0,0 +1,25 @@ + + +
+
+ {day.format("D")} +
+ + {#each schedules as schedule (schedule)} + + {/each} +
diff --git a/src/lib/components/RecurringSchedule.svelte b/src/lib/components/RecurringSchedule.svelte new file mode 100644 index 00000000..eff42e01 --- /dev/null +++ b/src/lib/components/RecurringSchedule.svelte @@ -0,0 +1,39 @@ + + +
+
+ + + + + {schedule.key} + +
+
{formatCurrencyCrude(schedule.amount)}
+
diff --git a/src/lib/expense/monthly.ts b/src/lib/expense/monthly.ts index ba62c69e..e35f7866 100644 --- a/src/lib/expense/monthly.ts +++ b/src/lib/expense/monthly.ts @@ -1,6 +1,6 @@ import * as d3 from "d3"; import legend from "d3-svg-legend"; -import dayjs, { Dayjs } from "dayjs"; +import type { Dayjs } from "dayjs"; import chroma from "chroma-js"; import _ from "lodash"; import { @@ -12,7 +12,8 @@ import { skipTicks, tooltip, restName, - firstName + firstName, + monthDays } from "$lib/utils"; import COLORS, { generateColorScheme, white } from "$lib/colors"; import { get, type Readable, type Writable } from "svelte/store"; @@ -26,25 +27,16 @@ export function renderCalendar( groups: string[] ) { const id = "#d3-current-month-expense-calendar"; - const monthStart = dayjs(month, "YYYY-MM"); - const monthEnd = monthStart.endOf("month"); - const weekStart = monthStart.startOf("week"); - const weekEnd = monthEnd.endOf("week"); const alpha = d3.scaleLinear().range([0.3, 1]); - const expensesByDay: Record = {}; - const days: Dayjs[] = []; - let d = weekStart; - while (d.isSameOrBefore(weekEnd)) { - days.push(d); + const { days, monthStart, monthEnd } = monthDays(month); + _.each(days, (d) => { expensesByDay[d.format("YYYY-MM-DD")] = _.filter( expenses, (e) => e.date.isSame(d, "day") && _.includes(groups, expenseGroup(e)) ); - - d = d.add(1, "day"); - } + }); const expensesByDayTotal = _.mapValues(expensesByDay, (ps) => _.sumBy(ps, (p) => p.amount)); @@ -139,7 +131,7 @@ export function renderMonthlyExpensesTimeline( postings: Posting[], groupsStore: Writable, monthStore: Writable, - dateRangeStore: Readable<{ from: dayjs.Dayjs; to: dayjs.Dayjs }> + dateRangeStore: Readable<{ from: Dayjs; to: Dayjs }> ) { const id = "#d3-monthly-expense-timeline"; const timeFormat = "MMM-YYYY"; @@ -270,7 +262,7 @@ export function renderMonthlyExpensesTimeline( let firstRender = true; - const render = (allowedGroups: string[], dateRange: { from: dayjs.Dayjs; to: dayjs.Dayjs }) => { + const render = (allowedGroups: string[], dateRange: { from: Dayjs; to: Dayjs }) => { groupsStore.set(allowedGroups); const allowedPoints = _.filter( points, diff --git a/src/lib/import.test.ts b/src/lib/import.test.ts index 05e03cae..a46726f5 100644 --- a/src/lib/import.test.ts +++ b/src/lib/import.test.ts @@ -10,6 +10,10 @@ import customParseFormat from "dayjs/plugin/customParseFormat"; dayjs.extend(customParseFormat); import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; dayjs.extend(isSameOrBefore); +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; // dependent on utc plugin +dayjs.extend(utc); +dayjs.extend(timezone); Handlebars.registerHelper( _.mapValues(helpers, (helper, name) => { diff --git a/src/lib/recurring.ts b/src/lib/recurring.ts index a9f29fb7..c169f120 100644 --- a/src/lib/recurring.ts +++ b/src/lib/recurring.ts @@ -1,13 +1,12 @@ import * as d3 from "d3"; import dayjs from "dayjs"; import _ from "lodash"; -import COLORS from "./colors"; import { skipTicks, type TransactionSequence } from "./utils"; +import { scheduleIcon } from "./transaction_sequence"; export function renderRecurring( element: Element, transactionSequence: TransactionSequence, - next: dayjs.Dayjs, showPage: (pageIndex: number) => void ) { const svg = d3.select(element).select("svg"), @@ -16,61 +15,37 @@ export function renderRecurring( height = +svg.attr("height") - margin.top - margin.bottom, g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")"); - const transactions = _.reverse(_.take(transactionSequence.transactions, 3)); + let schedules = _.takeRight(transactionSequence.pastSchedules, 3); + schedules = schedules.concat(_.take(transactionSequence.futureSchedules, 1)); - const dates = _.map(transactions, (d) => d.date).concat([next, dayjs()]); + const dates = _.map(schedules, (s) => s.scheduled); const [start, end] = d3.extent(dates); - dates.pop(); const x = d3.scaleTime().domain([start, end]).range([0, width]); g.append("g") - .attr("class", "axis x") + .attr("class", "axis light-domain x") .attr("transform", "translate(0," + height + ")") .call( d3 .axisBottom(x) .tickValues(dates) - .tickFormat(skipTicks(45, x, (d: any) => dayjs(d).format("DD MMM"))) + .tickFormat(skipTicks(45, x, (d: any) => dayjs(d).format("DD MMM YY"))) ); g.append("g") - .selectAll("circle.past") - .data(transactions) - .join("circle") - .attr("class", "past") - .attr("r", 5) - .attr("fill", "#d5d5d5") - .attr("cx", (d) => x(d.date)) - .attr("cy", "10") - .style("cursor", "pointer") - .on("click", (_event, d) => - showPage(_.findIndex(transactionSequence.transactions, (t) => t.id === d.id)) - ); - - function color(d: dayjs.Dayjs) { - return d.isAfter(dayjs()) ? COLORS.success : COLORS.danger; - } - - g.append("g") - .selectAll("circle.next") - .data([next]) - .join("circle") - .attr("class", "next") - .attr("r", 5) - .attr("fill", color) - .attr("cx", (d) => x(d)) - .attr("cy", "10"); - - g.append("g") - .selectAll("line") - .data([next]) - .join("line") - .attr("class", "next") - .attr("stroke", color) - .attr("stroke-linecap", "round") - .attr("stroke-width", 3) - .attr("x1", (d) => x(d)) - .attr("x2", x(dayjs())) - .attr("y1", "10") - .attr("y2", height); + .selectAll("text") + .data(schedules) + .join("text") + .attr("class", (d) => "svg-background-white " + scheduleIcon(d).svgColor) + .attr("dx", "-0.5em") + .attr("dy", "-0.3em") + .attr("x", (d) => x(d.scheduled)) + .attr("y", "10") + .style("cursor", (d) => (d.transaction ? "pointer" : "default")) + .on("click", (_event, d) => { + if (d.transaction) { + showPage(_.findIndex(transactionSequence.transactions, (t) => t.id === d.transaction.id)); + } + }) + .text((d) => scheduleIcon(d).glyph); } diff --git a/src/lib/transaction_sequence.ts b/src/lib/transaction_sequence.ts new file mode 100644 index 00000000..a8d3d34a --- /dev/null +++ b/src/lib/transaction_sequence.ts @@ -0,0 +1,219 @@ +import _ from "lodash"; +import type { Transaction, TransactionSchedule, TransactionSequence } from "./utils"; +import dayjs from "dayjs"; +import { parse, type CronExprs } from "@datasert/cronjs-parser"; +import { getFutureMatches } from "@datasert/cronjs-matcher"; + +const now = dayjs(); +const end = dayjs().add(36, "month"); + +function zip(schedules: dayjs.Dayjs[], transactions: Transaction[], key: string, amount: number) { + let si = 0; + let ti = 0; + const transactionSchedules: TransactionSchedule[] = []; + + while (si < schedules.length) { + const s1 = schedules[si]; + const s2 = schedules[si + 1]; + const t1 = transactions[ti]; + const t2 = transactions[ti + 1]; + + if (!t1) { + transactionSchedules.push({ + key, + amount, + scheduled: s1, + actual: null, + transaction: null + }); + si++; + continue; + } + + const t1s1diff = Math.abs(t1.date.diff(s1, "day")); + let t1s2diff = Number.MAX_VALUE; + if (s2) { + t1s2diff = Math.abs(t1.date.diff(s2, "day")); + } + + let t2s1diff = Number.MAX_VALUE; + if (t2) { + t2s1diff = Math.abs(t2.date.diff(s1, "day")); + } + + if (t1s1diff > t2s1diff) { + transactionSchedules.push({ + key, + amount, + scheduled: t1.date, + actual: t1.date, + transaction: t1 + }); + ti++; + } else if (t1s1diff > t1s2diff) { + transactionSchedules.push({ + key, + amount, + scheduled: s1, + actual: null, + transaction: null + }); + si++; + } else { + transactionSchedules.push({ + key, + amount, + scheduled: s1, + actual: t1.date, + transaction: t1 + }); + si++; + ti++; + } + } + + return transactionSchedules; +} + +function enrich(ts: TransactionSequence) { + const transactions = ts.transactions.slice().reverse(); + const amount = totalRecurring(ts); + const start = transactions[0].date; + let periodAvailable = false; + let cron: CronExprs; + try { + if (ts.period != "") { + cron = parse("0 0 " + ts.period, { hasSeconds: false }); + periodAvailable = true; + } else { + periodAvailable = false; + } + } catch (e) { + periodAvailable = false; + } + + if (periodAvailable) { + const schedules = getFutureMatches(cron, { + startAt: start.toISOString(), + endAt: end.toISOString(), + matchCount: 1000, + timezone: dayjs.tz.guess() + }); + + ts.schedules = zip( + _.map(schedules, (s) => dayjs(s)), + transactions, + ts.key, + amount + ); + } else { + const schedules: dayjs.Dayjs[] = _.map(transactions, (t) => t.date); + let next = _.last(schedules); + do { + next = nextDate(ts, next); + schedules.push(next); + } while (schedules.length < 1000 && end.isAfter(next)); + ts.schedules = zip(schedules, transactions, ts.key, amount); + } + + const [past, future] = _.partition(ts.schedules, (s) => s.scheduled?.isBefore(now)); + ts.pastSchedules = past; + ts.futureSchedules = future; + ts.schedulesByMonth = _.groupBy(ts.schedules, (s) => s.scheduled?.format("YYYY-MM") || "NA"); + ts.interval = _.first(future).scheduled.diff(_.last(past).scheduled, "day"); + return ts; +} + +export function nextUnpaidSchedule(ts: TransactionSequence) { + const last = _.last(ts.pastSchedules); + if (last && !last.actual) { + return last; + } + return _.find(ts.futureSchedules, (s) => !s.actual); +} + +export function scheduleIcon(schedule: TransactionSchedule) { + let icon = "fa-circle-check"; + let glyph = ""; + let color = "has-text-success"; + let svgColor = "svg-text-success"; + + if (!schedule.actual) { + if (schedule.scheduled.isBefore(now)) { + color = "has-text-danger"; + icon = "fa-exclamation-triangle"; + glyph = ""; + svgColor = "svg-text-danger"; + } else { + color = "has-text-grey"; + svgColor = "svg-text-grey"; + } + } else { + if (schedule.actual.isSameOrBefore(schedule.scheduled)) { + color = "has-text-success"; + svgColor = "svg-text-success"; + } else { + color = "has-text-warning-dark"; + svgColor = "svg-text-warning-dark"; + } + } + + return { icon, color, svgColor, glyph }; +} + +export function intervalText(ts: TransactionSequence) { + if (ts.interval >= 7 && ts.interval <= 8) { + return "weekly"; + } + + if (ts.interval >= 14 && ts.interval <= 16) { + return "bi-weekly"; + } + + if (ts.interval >= 28 && ts.interval <= 33) { + return "monthly"; + } + + if (ts.interval >= 87 && ts.interval <= 100) { + return "quarterly"; + } + + if (ts.interval >= 175 && ts.interval <= 190) { + return "half-yearly"; + } + + if (ts.interval >= 350 && ts.interval <= 395) { + return "yearly"; + } + + return `every ${ts.interval} days`; +} + +function nextDate(ts: TransactionSequence, date: dayjs.Dayjs) { + if (ts.interval >= 28 && ts.interval <= 33) { + return date.add(1, "month"); + } + + if (ts.interval >= 360 && ts.interval <= 370) { + return date.add(1, "year"); + } + + return date.add(ts.interval, "day"); +} + +export function totalRecurring(ts: TransactionSequence) { + const lastTransaction = ts.transactions[0]; + return _.sumBy(lastTransaction.postings, (t) => _.max([0, t.amount])); +} + +export function enrichTrantionSequence(transactionSequences: TransactionSequence[]) { + return _.map(transactionSequences, (ts) => enrich(ts)); +} + +export function sortTrantionSequence(transactionSequences: TransactionSequence[]) { + return _.chain(transactionSequences) + .sortBy((ts) => { + return Math.abs(nextUnpaidSchedule(ts).scheduled.diff(now)); + }) + .value(); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 55805043..21e254fd 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,4 +1,4 @@ -import dayjs from "dayjs"; +import dayjs, { Dayjs } from "dayjs"; import { sprintf } from "sprintf-js"; import _ from "lodash"; import * as d3 from "d3"; @@ -51,14 +51,25 @@ export interface CashFlow { balance: number; } -export interface TransactionSequenceKey { - tagRecurring: string; +export interface TransactionSchedule { + actual: dayjs.Dayjs; + scheduled: dayjs.Dayjs; + transaction: Transaction; + key: string; + amount: number; } export interface TransactionSequence { transactions: Transaction[]; - key: TransactionSequenceKey; + period: string; + key: string; interval: number; + + // computed + schedules: TransactionSchedule[]; + pastSchedules: TransactionSchedule[]; + futureSchedules: TransactionSchedule[]; + schedulesByMonth: Record; } export interface Transaction { @@ -872,60 +883,6 @@ export function postingUrl(posting: Posting) { }`; } -export function intervalText(ts: TransactionSequence) { - if (ts.interval >= 7 && ts.interval <= 8) { - return "weekly"; - } - - if (ts.interval >= 14 && ts.interval <= 16) { - return "bi-weekly"; - } - - if (ts.interval >= 28 && ts.interval <= 33) { - return "monthly"; - } - - if (ts.interval >= 87 && ts.interval <= 100) { - return "quarterly"; - } - - if (ts.interval >= 175 && ts.interval <= 190) { - return "half-yearly"; - } - - if (ts.interval >= 350 && ts.interval <= 395) { - return "yearly"; - } - - return `every ${ts.interval} days`; -} - -export function nextDate(ts: TransactionSequence) { - const lastTransaction = ts.transactions[0]; - if (ts.interval >= 28 && ts.interval <= 33) { - return lastTransaction.date.add(1, "month"); - } - - if (ts.interval >= 360 && ts.interval <= 370) { - return lastTransaction.date.add(1, "year"); - } - - return lastTransaction.date.add(ts.interval, "day"); -} - -export function totalRecurring(ts: TransactionSequence) { - const lastTransaction = ts.transactions[0]; - return _.sumBy(lastTransaction.postings, (t) => _.max([0, t.amount])); -} - -export function sortTrantionSequence(transactionSequences: TransactionSequence[]) { - return _.chain(transactionSequences) - .sortBy((ts) => { - return Math.abs(nextDate(ts).diff(dayjs())); - }) - .value(); -} - const storageKey = "theme-preference"; export function getColorPreference() { @@ -947,3 +904,18 @@ export function setColorPreference(theme: string) { export function isZero(n: number) { return n < 0.0001 && n > -0.0001; } + +export function monthDays(month: string) { + const monthStart = dayjs(month, "YYYY-MM"); + const monthEnd = monthStart.endOf("month"); + const weekStart = monthStart.startOf("week"); + const weekEnd = monthEnd.endOf("week"); + + const days: Dayjs[] = []; + let d = weekStart; + while (d.isSameOrBefore(weekEnd)) { + days.push(d); + d = d.add(1, "day"); + } + return { days, monthStart, monthEnd }; +} diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index aac9e86c..8b37c9c1 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -30,6 +30,10 @@ dayjs.extend(relativeTime, { { l: "yy", d: "year" } ] }); +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; // dependent on utc plugin +dayjs.extend(utc); +dayjs.extend(timezone); import * as pdfjs from "pdfjs-dist"; import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.js?url"; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9267da21..64290a5c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,33 +1,36 @@ @@ -253,9 +259,9 @@ style="overflow: hidden; max-height: 190px" > {#each transactionSequences as ts} - {@const n = nextDate(ts)} + {@const n = nextUnpaidSchedule(ts).scheduled}
-
{ts.key.tagRecurring}
+
{ts.key}
{intervalText(ts)}
diff --git a/src/routes/cash_flow/recurring/+page.svelte b/src/routes/cash_flow/recurring/+page.svelte index 48db5b12..08834f77 100644 --- a/src/routes/cash_flow/recurring/+page.svelte +++ b/src/routes/cash_flow/recurring/+page.svelte @@ -1,19 +1,38 @@
-
+
+
+
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
+
+ {#each days as day (day)} + + {/each} +
+
+
+
Oops! You haven't configured any recurring transactions yet. Checkout the docs page to get started. {#each transactionSequences as ts} - + {/each}