Splurge — Track your expenses with a simple Org table (Emacs package)
Below you find the latest version of (1) the package's README and (2) its main source file.
For the git repository and issue tracker, see the project's page on sr.ht.
For more packages, see Software.
README.org
Overview
With Splurge
you can track your annual expenses with a simple Org table.
- Add to the input Org table a line for every expense you had on a calendar year.
- Then run splurge-report and a buffer comes up with a summary org table.
Further below you can see how it looks like.
Installation
See my page Software for the most up-to-date instructions on how to download and install any of my Emacs packages.
Having downloaded and installed the package and its dependencies, adapt the configurations below to your init.el
file.
(use-package splurge ;; Example, if you want to customize some variable here: :custom (splurge-input-extras ;; Column Default Alignment '(("With" "card" left) ("Notes" "---" left))))
Alternatively, if you don’t have ‘use-package’:
(require 'splurge)
Quick usage guide
Customize variables
To start, customize variables to your liking:
M-x customize-group RET splurge RET
Add new table
Then open an org file, narrow to a subtree (e.g. ** Test splurge
), and inside there run:
M-x splurge-table-new
which, if you haven't changed any default variable, should insert something like this:
#+name: expenses-2042 | Date | Amount | What | Type | Subtype | With | Notes | |------+--------+------+------+---------+------+-------| #+tblfm: $2='(format "%.2f" $2);N
The first five columns are mandatory and are used by splurge
.
But you can customize these labels to your liking.
The last two have no influence. They were guessed from the keys
of splurge-input-extras
.
You may delete, or change them, or add more columns for notes.
You may also change the columns order as you prefer.
Add new expenses
Now, with point at the table, let's add an expense:
M-x splurge-expense-new
This will prompt you for the first five columns:
Date
: a calendar will open, as you have withorg-schedule
.Amount
: input how much you spent.What
: add a description of what you spent (you can also select from previous ones).Type and subtype
: choose from those insplurge-expenses-types
, or add a new one.- The rest will autofill with default values, according to
splurge-input-extras
.
Repeat.
It may soon look like this:
#+name: expenses-2042 | Date | Amount | What | Type | Subtype | |------------------+--------+------------------------------+-----------+------------| | [2042-03-03 Mon] | 42.00 | At the End of the Universe | food | restaurant | | [2042-02-24 Mon] | 21.21 | To restaurant | transport | spaceship | | [2042-02-03 Mon] | 42.00 | At the End of the Universe | food | restaurant | | [2042-01-27 Mon] | 21.21 | To restaurant | transport | spaceship | | [2042-01-25 Sat] | 24.00 | From Ursa Minor Beta beaches | transport | taxi | #+tblfm: $2='(format "%.2f" $2);N
New expenses are added to the top, so the table will likely end up sorted by newest first.
(You can always reverse-region
at the end of the year, if you prefer.)
To visually separate months, you may want to manually add hlines
(usually with C-c -
).
Create a report
To see a report, with point at the table, run this:
M-x splurge-report
A new buffer will then show up with an Org table. With our previous example, it'll look like this:
[2042]
Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec | YTD | Avg | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Total | 45 | 63 | 42 | 150 | 12 | |||||||||
Food | 42 | 42 | 84 | 7 | ||||||||||
Transport | 45 | 21 | 66 | 5 | ||||||||||
Food — Restaurant | 42 | 42 | 84 | 7 | ||||||||||
Transport — Spaceship | 21 | 21 | 42 | 3 | ||||||||||
Transport — Taxi | 24 | 24 | 2 |
The monthly average column will consider the whole year — unless the year happens to be the current one, in which case it's able to consider only the number of days elapsed so far. You can tweak this by changing splurge-report-normalize-avg
.
Summary of callables
Here's an overview of this package's callables:
Function | Summary |
---|---|
splurge-table-new | Create a new input Org table at point. |
splurge-expense-new | Add new expense line to Org table at point. |
splurge-expense-edit | Add or edit a value in the current line. |
splurge-expenses-types-update | Update ‘splurge-expenses-types’ with Org table at point. |
splurge-report | Show in a new buffer a report of the expenses Org table at point. |
splurge-see-readme | Open splurge's README.org file. |
splurge-see-news | See the News in splurge's README.org file. |
They're described in more detail below.
Functions
Input table: new
splurge-table-new ()
Create a new input Org table at point.
Expense: new or edit
splurge-expense-new ()
Add new expense line to Org table at point.
Ask for each mandatory value.
splurge-expense-edit (&optional var)
Add or edit a value in the current line.
If VAR is nil, ask which.
Types: update
splurge-expenses-types-update (&optional arg)
Update splurge-expenses-types
with Org table at point.
If optional ARG is
- nil, merge types and subtypes of Org table at point into
splurge-expenses-types
. - non-nil, ask: clear
splurge-expenses-types
before updating?
Report: show
splurge-report ()
Show in a new buffer a report of the expenses Org table at point.
See README
splurge-see-readme (&optional heading narrow)
Open splurge's README.org file.
Search for the file in splurge.el's directory.
If found, open it read-only.
If optional argument HEADING is passed, try to navigate to the
heading after opening it. HEADING should be a string.
If optional argument NARROW is non-nil, narrow to that heading.
This argument has no effect if HEADING is nil or not found.
See News
splurge-see-news ()
See the News in splurge's README.org file.
Workarounds
In its current version, splurge
assumes that all expenses in a given year are in the same currency.
But what if you do sometimes need to spend in another currency?
A workaround is to enter a sexp as the amount.
For example, suppose all your expenses are usually in the currency FOO
.
But today you had to pay 28 BAR
. And 1 BAR = 1.50 FOO
.
When asked for the amount, you can enter:
Amount: (* 1.50 28)
and it will then insert 42.00
.
Optionally, in an extra column (say, Notes
), write 28.00 BAR
.
This will be for your personal reference only.
Contributing
See my page Software for information about how to contribute to any of my Emacs packages.
News
0.2.1
Splurge News
This patch release adds a missing LICENSES directory.
0.2.0
Release
License
This project follows the REUSE Specification (FAQ), which in turn is built upon SPDX.
Therefore, license and copyright information can be found in:
- each file's comment header, or
- an adjacent file of the same name with the additional extension
.license
, or - the
.reuse/dep5
file
The full text of the licenses can be found in the LICENSES
subdirectory.
splurge.el
Structure
;;; splurge.el --- Track your expenses with a simple Org table -*- lexical-binding: t -*- ;;; Commentary: ;;;; For all the details, please do see the README ;;; Code: ;;;; Libraries ;;;; Package metadata ;;;; Customizable variables ;;;;; Input table ;;;;; Report table ;;;;; Types and subtypes ;;;; Functions ;;;;; Input table: new ;;;;;; Internal ;;;;; Expense: new or edit ;;;;;; Internal ;;;;;;; Edit ;;;;;;; Ask ;;;;;;; Org table ;;;;; Types: update ;;;;;; Internal ;;;;; Report: show ;;;;;; Internal ;;;;;;; Display report ;;;;;;; Hash tables ;;;;;;; Calculation ;;;;;;; Whats, types and subtypes ;;;;;;; Dates ;;;;; See README ;;;;; See News ;;;; Wrapping up ;;; splurge.el ends here
Contents
;;; splurge.el --- Track your expenses with a simple Org table -*- lexical-binding: t -*- ;; SPDX-FileCopyrightText: © flandrew <https://flandrew.srht.site/listful> ;;--------------------------------------------------------------------------- ;; Author: flandrew ;; Created: 2023-12-10 ;; Updated: 2025-03-18 ;; Keywords: outlines ;; Homepage: <https://flandrew.srht.site/listful/software.html> ;;--------------------------------------------------------------------------- ;; Package-Version: 0.2.1 ;; Package-Requires: ((emacs "25.1") (xht "1.0.5") (dash "2.18") (org "9.0.3")) ;;--------------------------------------------------------------------------- ;; SPDX-License-Identifier: GPL-3.0-or-later ;; This file is free software; you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published ;; by the Free Software Foundation, either version 3 of the License, ;; or (at your option) any later version. ;; ;; This file is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; ;; You should have received a copy of the GNU General Public License ;; along with this file. If not, see <https://www.gnu.org/licenses/>. ;;; Commentary: ;; ;; With Splurge you can track your annual expenses with a simple Org table. ;; ;; Add to the Org table a line for every expense you have on a calendar year. ;; ;; Then run ‘splurge-report’ and a buffer comes up with a summary table: ;; - Rows: categories (food, transport, etc.) ;; - Cols: months, running total, running average ;; - Cells: the respective sums ;; ;;;; For all the details, please do see the README ;; ;; Open it easily with: ;; (find-file-read-only "README.org") <--- C-x C-e here¹ ;; ;; or from any buffer: ;; M-x splurge-see-readme ;; ;; or read it online: ;; <https://flandrew.srht.site/listful/sw-emacs-splurge.html> ;; ;; ¹ or the key that ‘eval-last-sexp’ is bound to, if not C-x C-e. ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;; Code: ;;;; Libraries (require 'org) (require 'xht) ; <--- also by the author of this package (require 'lisp-mnt) ; ‘lm-summary’, ‘lm-homepage’, ‘lm-version’, ‘lm-header’ ;;;; Package metadata (defvar splurge--name "Splurge") (defvar splurge--dot-el (format "%s.el" (file-name-sans-extension (eval-and-compile (or load-file-name buffer-file-name))))) (defvar splurge--readme-org (expand-file-name "README.org" (file-name-directory splurge--dot-el))) (defvar splurge--summary (lm-summary splurge--dot-el)) (defvar splurge--homepage (lm-homepage splurge--dot-el)) (defvar splurge--version (lm-with-file splurge--dot-el (or (lm-header "package-version") (lm-version)))) ;;;; Customizable variables (defgroup splurge nil (format "%s." splurge--summary) :group 'org :link '(emacs-library-link :tag "Lisp file" "splurge.el") :link `(file-link :tag "README.org" ,splurge--readme-org) :link `(url-link :tag "Homepage" ,splurge--homepage)) ;;;;; Input table (defcustom splurge-input-date "Date" "A string to represent date in Input table. This is a column label for the (org) date when the expense happened. This exact same string must be used in your input table." :package-version '(splurge "0.2.0") :type 'string) (defcustom splurge-input-amount "Amount" "A string to represent amount in Input table. This is a column label for the amount of the expense. This exact same string must be used in your input table." :package-version '(splurge "0.2.0") :type 'string) (defcustom splurge-input-what "What" "A string to represent what in Input table. This is a column label for a description of the expense. This exact same string must be used in your input table." :package-version '(splurge "0.2.0") :type 'string) (defcustom splurge-input-type "Type" "A string to represent type in Input table. This is a column label for the expense type. This exact same string must be used in your input table." :package-version '(splurge "0.2.0") :type 'string) (defcustom splurge-input-subtype "Subtype" "A string to represent subtype in Input table. This is a column label for the expense subtype. This exact same string must be used in your input table." :package-version '(splurge "0.2.0") :type 'string) (defcustom splurge-input-extras '(("With" "card" left) ("Notes" "okay" left)) "An alist of extra columns in Input table. The keys are extra columns that you may have in your Input table. The values are, respectively, the default value and the alignment that you'd like to be automatically added when running ‘splurge-expense-new’. Edit this if you want to have additional columns with your personal notes — which will be ignored, except for autofilling them with these default values when adding a new expense. If you set this to nil, it won't autofill — but you can still add extra columns if you like and it won't affect the results." :package-version '(splurge "0.2.0") :type '(alist :key-type string :value-type (group string (choice (const left) (const right))))) (defcustom splurge-input-name-prefix "expenses" "A string prefix for #+name at the top of the Input table. This is used when creating new Input tables. You can edit it directly on the table later if you want." :package-version '(splurge "0.2.0") :type 'string) (defcustom splurge-input-tblfm-line "#+tblfm: $2='(format \"%.2f\" $2);N" "A string for #+tblfm at the bottom of the Input table. This is used when creating new Input tables. You can edit it directly on the table later if you want." :package-version '(splurge "0.2.0") :type 'string) ;;;;; Report table (defcustom splurge-report-language-months :en "Language for displaying month abbreviations in Report table. Choose :user to use your own abbreviations, which you can specify in ‘splurge-report-language-months-user’." :package-version '(splurge "0.2.0") :type '(choice (const :tag "English" :en) (const :tag "User-defined" :user))) (defcustom splurge-report-language-months-user [jan feb mar apr may jun jul aug sep oct nov dec] "User-defined month abbreviations in Report table. You can define here your own month abbreviations. To use them, change the variable ‘splurge-report-language-months’ to :user." :package-version '(splurge "0.2.0") :type '(vector symbol symbol symbol symbol symbol symbol symbol symbol symbol symbol symbol symbol)) (defcustom splurge-report-separator " — " "Separator between type and subtype in Report table." :package-version '(splurge "0.2.0") :type '(choice (const :tag "—" " — ") (const :tag "·" " · "))) (defcustom splurge-report-total "Total" "A string to represent total in Report table. This is a row label representing the total amount spent in each month." :package-version '(splurge "0.2.0") :type 'string) (defcustom splurge-report-ytd "YTD" "A string to represent year-to-date in Report table. This is a column label representing the total amount spent in the year so far." :package-version '(splurge "0.2.0") :type 'string) (defcustom splurge-report-avg "Avg" "A string to represent average in Report table. This is a column label representing the average monthly amount spent in the year so far." :package-version '(splurge "0.2.0") :type 'string) (defcustom splurge-report-normalize-avg t "Whether to normalize the average when in the same year. If non-nil, and only if the year is the current one, do not normalize using 365 days, but use instead the number of elapsed days in the year until today." :package-version '(splurge "0.2.0") :type 'boolean) ;;;;; Types and subtypes (defvar splurge--expenses-types-example '(("food" "supermarket" "restaurant") ("transport" "taxi" "flights" "subway" "bus" "gasoline") ("utilities" "electricity")) "Alist of types and subtypes of expenses. This is just an example of the format.") (defcustom splurge-expenses-types splurge--expenses-types-example "Alist of types and subtypes of expenses. The first item in each list is the type, and the remaining items are its subtype. Edit it to your liking." :package-version '(splurge "0.2.0") :type '(alist :key-type string :value-type (repeat string))) ;;;; Functions ;;;;; Input table: new ;;;###autoload (defun splurge-table-new () "Create a new input Org table at point." (interactive) (save-excursion (insert (splurge--table-new-str))) (forward-line 1) (orgtbl-ctrl-c-ctrl-c nil)) ;;;;;; Internal (defun splurge--table-new-str () "String for new input Org table." (let* ((year (format-time-string "%Y")) (name (format "#+name: %s-%s\n" splurge-input-name-prefix year)) (xtra (mapcar #'car splurge-input-extras)) (cols (append (splurge--expense-input-colnames) xtra)) (hedr (xht--list-to-orgtbl-row cols)) (hlin "|------+------+------+------+------+------|\n") (tblf splurge-input-tblfm-line)) (concat name hedr hlin tblf "\n"))) ;;;;; Expense: new or edit ;;;###autoload (defun splurge-expense-new () "Add new expense line to Org table at point. Ask for each mandatory value." (interactive) (splurge--orgtbl-new-line) (splurge--expense-edit-date) (splurge--expense-edit-amount) (splurge--expense-edit-what) (splurge--expense-edit-type-and-subtype) (splurge--expense-edit-maybe-fill-rest)) ;;;###autoload (defun splurge-expense-edit (&optional var) "Add or edit a value in the current line. If VAR is nil, ask which." (interactive) (splurge--expense-edit-able) (let ((ops (->> (splurge--expense-input-colnames) (-remove-item splurge-input-subtype)))) (if var (unless (member var ops) (user-error "%s: invalid variable" var)) (setq var (completing-read "Value to edit: " ops))) (-let [(da am wh _) ops] (cond ((equal var da) (splurge--expense-edit-date)) ((equal var am) (splurge--expense-edit-amount)) ((equal var wh) (splurge--expense-edit-what)) (:otherwise (splurge--expense-edit-type-and-subtype)))))) ;;;;;; Internal ;;;;;;; Edit (defun splurge--expense-edit-able () "Whether at editable Org table line, otherwise error." (unless (org-at-table-p) (user-error "Not at a table")) (when (org-at-table-hline-p) (user-error "At a hline")) (if (> (org-table-current-dline) 1) t (user-error "Not at a regular table line"))) (defun splurge--expense-input-colnames () "Currently meaningful input table column names." (list splurge-input-date splurge-input-amount splurge-input-what splurge-input-type splurge-input-subtype)) (defun splurge--expense-edit-date () "Add or edit date in current line." (splurge--expense-edit-variable splurge-input-date (splurge--expense-ask-date) 'left)) (defun splurge--expense-edit-amount () "Add or edit amount in current line." (splurge--expense-edit-variable splurge-input-amount (splurge--expense-ask-amount) 'right)) (defun splurge--expense-edit-what () "Add or edit description in current line." (splurge--expense-edit-variable splurge-input-what (splurge--expense-ask-what) 'left)) (defun splurge--expense-edit-type-and-subtype () "Add or edit type and subtype in current line." (-let [(typ . sub) (splurge--expense-ask-type-and-subtype)] (splurge--expense-edit-variable splurge-input-type typ 'left) (splurge--expense-edit-variable splurge-input-subtype sub 'left))) (defun splurge--expense-edit-maybe-fill-rest () "Maybe autofill extra columns with default values." (let* ((extra (-difference (splurge--orgtbl-header) (splurge--expense-input-colnames))) (args nil)) (--each extra (setq args (assoc it splurge-input-extras)) (when args (apply #'splurge--expense-edit-variable args))))) (defun splurge--expense-edit-variable (varstr varval align) "Add or edit variable in current line." (let* ((varidx (splurge--orgtbl-colname-to-colindex varstr)) (curval (org-no-properties (org-table-get-field varidx))) (lenval (length curval)) (varval (splurge--expense-pad-string (or varval "") lenval align))) (ignore (org-table-get-field varidx varval)))) (defun splurge--expense-pad-string (s len align) "Pad string S to length LEN leaving one blank space. ALIGN is either \\='right or \\='left." (pcase-exhaustive align ('left (s-pad-right len " " (format " %s" s))) ('right (s-pad-left len " " (format "%s " s))))) ;;;;;;; Ask (defun splurge--expense-ask-date () "Prompt for date, return org time string." (let* ((fmts "[%Y-%m-%d %a]") (date (org-read-date)) (time (date-to-time date))) (format-time-string fmts time))) (defun splurge--expense-ask-amount () "Prompt for amount, return string with two decimal digits." (let ((read (--> (format "%s: " splurge-input-amount) (read-from-minibuffer it) (if (s-blank? it) (splurge--expense-ask-amount) (read it))))) (when (consp read) ; e.g. Amount: (+ 20 22) (setq read (eval read))) (if (numberp read) (format "%.2f" read) (splurge--expense-ask-amount)))) (defun splurge--expense-ask-what () "Prompt for description, return it." (let* ((tbl (splurge--ht-from-orgtbl-at-point)) (ops (splurge--list-whats tbl)) (pmp (format "%s: " splurge-input-what))) (completing-read pmp ops))) (defun splurge--expense-ask-type-and-subtype () "Prompt for type–subtype pair, return cons cell." (let* ((sep splurge-report-separator) (new "*NEW*") (str (with-output-to-string (h--each (h<-alist splurge-expenses-types) (--each (-list value) (princ (format "%s%s%s\n" key sep it)))))) (ops (cons new (s-lines (s-chomp str)))) (pmp (concat splurge-input-type sep splurge-input-subtype ": ")) (t-s (completing-read pmp ops nil t))) (if (string= t-s new) (splurge--expense-ask-new-type-and-subtype) (apply #'cons (s-split sep t-s))))) (defun splurge--expense-ask-new-type-and-subtype () "Prompt for new type–subtype pair, return cons cell." (let* ((typ (format "%s: " splurge-input-type)) (typ (format "%s" (read-from-minibuffer typ))) (sub (format "%s: " splurge-input-subtype)) (sub (format "%s" (read-from-minibuffer sub))) (pmp (format "Add %s + %s to ‘splurge-expenses-types’? " typ sub)) (add (y-or-n-p pmp))) (when add (-some--> splurge-expenses-types h<-alist (let* ((cur (h-get it typ)) (new (if cur (cons sub cur) (-list sub)))) (h-put it typ new)) h->alist (setq splurge-expenses-types it))) (cons typ sub))) ;;;;;;; Org table (defun splurge--orgtbl-new-line () "Add a clean entry line below the table header. Does not recalculate" (unless (org-at-table-p) (user-error "Not at a table")) (goto-char (org-table-begin)) ;; ‘org-table-insert-row’ would recalculate (slower) (let* ((line (buffer-substring-no-properties (point) (line-end-position))) (newl (org-table-clean-line line))) (forward-line 1) (unless (org-at-table-hline-p) (org-table-insert-hline 'above)) (while (org-at-table-hline-p) (forward-line 1)) (save-excursion (insert newl ?\n)))) (defun splurge--orgtbl-goto-col (col) "Given COL number, go to that column in this same line." (org-table-goto-column (splurge--orgtbl-colname-to-colindex col))) (defun splurge--orgtbl-colname-to-colindex (colname) "Given COLNAME, return column index." (let ((idx (-elem-index colname (splurge--orgtbl-header)))) (if idx (1+ idx) (error "Could not find column “%s”" colname)))) (defun splurge--orgtbl-header () "Return a list of column names of Org table at point." (unless (org-at-table-p) (user-error "Not at a table")) (let ((orgtbl-str (xht--do-as-string-orgtbl-at-point))) (->> orgtbl-str (s-replace-regexp "\\(\r\n\\|[\n\r]\\).*" "") org-table-to-lisp car))) ;;;;; Types: update ;;;###autoload (defun splurge-expenses-types-update (&optional arg) "Update ‘splurge-expenses-types’ with Org table at point. If optional ARG is - nil, merge types and subtypes of Org table at point into ‘splurge-expenses-types’. - non-nil, ask: clear ‘splurge-expenses-types’ before updating?" (interactive "P") (let* ((poi-ht (splurge--expenses-types-at-point-ht)) (cur-ht (h<-alist splurge-expenses-types)) (curval nil) (newval nil) (msg "Clear ‘splurge-expenses-types’ before update? ") (new-ht (if (or (h-empty? cur-ht) (and arg (y-or-n-p msg))) poi-ht (h--each poi-ht (setq curval (-list (h-get cur-ht key)) newval (append value curval)) (h-put! cur-ht key (-uniq newval))) cur-ht))) (unless (h-empty? new-ht) (let* ((new-al (h->alist new-ht))) (when (setq splurge-expenses-types new-al) (message "%S" new-al)))))) ;;;;;; Internal (defun splurge--expenses-types-at-point-ht () "Hash table of types and subtypes of orgtbl at point." (let* ((htble (splurge--ht-from-orgtbl-at-point)) (types (splurge--list-types htble)) (res-ht (h-new (length types)))) (--each types (h-put! res-ht it (splurge--list-subtypes htble it))) res-ht)) (defun splurge--expenses-types-at-point-alist () "Alist of types and subtypes of orgtbl at point." (h->alist (splurge--expenses-types-at-point-ht))) ;;;;; Report: show ;;;###autoload (defun splurge-report () "Show in a new buffer a report of the expenses Org table at point." (interactive) (let* ((sep splurge-report-separator) (htbl (splurge--ht-from-orgtbl-at-point)) (year (splurge--year htbl)) (types (splurge--list-types htbl)) (res (splurge--ht-populate htbl types sep)) (report (splurge--report-make-str res year sep))) (splurge--report-display report sep))) ;;;;;; Internal ;;;;;;; Display report (defun splurge--report-make-str (res year sep) "Given populated hashtbl RES, YEAR, and separator SEP, make string." (concat "[" year "]" "\n\n" (h->orgtbl ;; The below sorts into a hash table where subtypes ;; appear at the end. That order is then preserved ;; upon conversion to Org table. (h-mix (h--rej (s-contains-p sep key) res) (h--sel (s-contains-p sep key) res))))) (defun splurge--report-display (report sep &optional buffer-name) "Given orgtbl REPORT and separator SEP, display it in a new buffer. Unless optional BUFFER-NAME is given, use default." (unless buffer-name (setq buffer-name "*splurge — expenses report*")) ;;(xht--to-display-buffer buffer-name) (let ((buffer (get-buffer-create buffer-name))) (pop-to-buffer buffer) (text-mode) (goto-char (point-min))) (when (> (point-max) 3) (save-excursion (insert "\n\n\f\n\n"))) (when (fboundp 'olivetti-mode) (let ((pr (make-progress-reporter "Turning off olivetti-mode..."))) (olivetti-mode -1) (progress-reporter-done pr))) (let ((pr (make-progress-reporter "Turning on orgtbl-mode..."))) (ignore-errors (orgtbl-mode)) (progress-reporter-done pr)) (save-excursion (xht--non-aggressively-insert-str-from-here report) (splurge--report-post-process sep)) (forward-line 1) (toggle-truncate-lines 1) (message "Report")) (defun splurge--report-post-process (sep) "Make adjustments to expenses report buffer before displaying it. SEP is the separator between types and subtypes of expenses." (splurge--report-add-hlines sep) (splurge--report-clean-zeroed-cells) (splurge--report-sort-types-reverse-numerically) (splurge--report-sort-subtypes-reverse-numerically)) (defun splurge--report-add-hlines (sep) "Add hlines between total, high-level, detailed." (goto-char (point-min)) (search-forward "|---" nil t 1) (search-forward splurge-report-total nil t 1) (org-table-insert-hline) (search-forward sep nil t 1) (forward-line -1) (org-table-insert-hline)) (defun splurge--report-clean-zeroed-cells () "Clean zeroed cells." (goto-char (point-min)) (while (search-forward " 0 " nil t) (replace-match " "))) (defun splurge--report-sort-types-reverse-numerically () "Sort types reverse-numerically." (splurge--report-sort-table 2 ?N)) (defun splurge--report-sort-subtypes-reverse-numerically () "Sort subtypes reverse-numerically." (splurge--report-sort-table 3 ?N)) (defun splurge--report-sort-table (section sorting-type) "Sort SECTION of table by SORTING-TYPE. SECTION is an integer." (goto-char (point-min)) (search-forward "|---" nil t section) (forward-line 1) (end-of-line) (forward-char -2) (org-table-sort-lines nil sorting-type)) ;;;;;;; Hash tables (defun splurge--ht-populate (htbl types sep) "Given HTBL, TYPES and SEP, output hash table with values." (let ((res (h-new 30))) (h-put! res splurge-report-total (splurge--ht-add-by-month htbl splurge-report-total)) (-each types (lambda (type) (let ((ttbl (splurge--ht-this-type htbl type)) (Type (capitalize type))) (h-put! res Type (splurge--ht-add-by-month ttbl Type)) (let ((subtypes (splurge--list-subtypes ttbl))) (-each subtypes (lambda (subtype) (let ((stbl (splurge--ht-this-subtype ttbl subtype)) (label (capitalize (concat type sep subtype)))) (h-put! res label (splurge--ht-add-by-month stbl label))))))))) res)) (defun splurge--ht-from-orgtbl-at-point () "Convert Org table at point to a hash table. The hash table will have unique numerical keys." (unless (org-at-table-p) (user-error "Not at a table")) (let* ((lol (->> (xht--do-as-string-orgtbl-at-point) (org-table-to-lisp) (-remove-item 'hline))) (len (length lol)) (ids (-iota len))) (h<-lol (-zip-pair ids lol)))) (defun splurge--ht-this-month (htbl month) "New hash table with only the entries of numeric MONTH of HTBL." (h--sel (equal month (splurge--month-from-org-time-string (h-get value splurge-input-date))) htbl)) (defun splurge--ht-this-type (htbl type) "New hash table with only the entries of TYPE in HTBL." (if (equal type splurge-report-total) htbl (splurge--ht-this-type-1 htbl type))) (defun splurge--ht-this-type-1 (htbl type) "Helper for ‘splurge--ht-this-type’ and ‘splurge--list-subtypes’." (h--sel (equal type (h-get value splurge-input-type)) htbl)) (defun splurge--ht-this-subtype (htbl subtype) "New hash table with only the entries of SUBTYPE in HTBL." (h--sel (equal subtype (h-get value splurge-input-subtype)) htbl)) (defun splurge--ht-add-by-month (htbl label) "New hash table with sum of “Expenses” of HTBL for each month of year. LABEL is the description of the item. Also calculate a total for the year, plus a monthly average." (let* ((res (h-new 15)) (YTD (splurge--add htbl)) (Avg (splurge--avg htbl YTD))) (h-put! res "" label) (--each (-iota 12 1) (h-put! res (splurge--month-abbrev it) (splurge--add (splurge--ht-this-month htbl it)))) (h-put! res splurge-report-ytd YTD) (h-put! res splurge-report-avg Avg) res)) ;;;;;;; Calculation (defun splurge--add-1 (htbl) "Add the values of field “Expenses” of hash-table HTBL." (->> (h-2d-col htbl splurge-input-amount) cdr (-map #'string-to-number) -sum)) (defun splurge--add (htbl) "Add the values of field “Expenses” of hash-table HTBL. Round it to the closest integer." (format "%.0f" (splurge--add-1 htbl))) (defun splurge--avg (htbl &optional YTD) "Monthly average of field “Expenses” of hash-table HTBL. It's rounded to the closest integer. If YTD is given, use it instead of redoing ‘splurge--add’'s work." (setq YTD (pcase YTD ("" (splurge--add-1 htbl)) ((pred numberp) YTD) ((pred stringp) (string-to-number YTD)))) (let* (;; If ‘splurge-report-normalize-avg’ is non-nil and expenses are for ;; current year, calculate day of year; else 365. (days (if (and splurge-report-normalize-avg (equal (format-time-string "%Y") ; this-Y (splurge--year htbl))) ; htbl-Y (string-to-number (format-time-string "%j")) 365)) (Avg (-> YTD (* 365) (/ days) (/ 12)))) (format "%.0f" Avg))) ;;;;;;; Whats, types and subtypes (defun splurge--list-var (htbl var) "List all items VAR in expenses hash-table HTBL." (->> (h-2d-col htbl var) -uniq cdr (-sort #'string<))) (defun splurge--list-whats (htbl) "List all whats in expenses hash-table HTBL." (splurge--list-var htbl splurge-input-what)) (defun splurge--list-types (htbl) "List all types in expenses hash-table HTBL." (splurge--list-var htbl splurge-input-type)) (defun splurge--list-subtypes (htbl &optional type) "List all subtypes in expenses hash-table HTBL. If optional arg TYPE is given, filter to only those." (unless (equal type splurge-report-total) (-> (if type (splurge--ht-this-type-1 htbl type) htbl) (splurge--list-var splurge-input-subtype)))) ;;;;;;; Dates (defun splurge--year (htbl) "Year of hash table HTBL." (--> (h-2d-col htbl splurge-input-date) cdr (-map #'splurge--year-from-org-time-string it) -uniq (pcase (length it) (1 (format "%s" (car it))) (0 (error "No year?")) (_ (error "More than one year?"))))) (defun splurge--year-from-org-time-string (s) "Year as number from an org time string S." (nth 5 (org-parse-time-string s))) (defun splurge--month-from-org-time-string (s) "Month as number from an org time string S." (nth 4 (org-parse-time-string s))) (defun splurge--month-abbrev (month &optional lang) "Abbreviation of MONTH-given-as-number in language LANG. If LANG is nil, default to ‘splurge-report-language-months’, which in turn defaults to English." (-> (splurge--months-abbrevs-ht) (h-get (or lang splurge-report-language-months)) (aref (1- month)) symbol-name)) (defun splurge--months-abbrevs-ht () "Hash table of months abbrevs." (h* :en [Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec] :user splurge-report-language-months-user)) ;;;;; See README ;;;###autoload (defun splurge-see-readme (&optional heading narrow) "Open splurge's README.org file. Search for the file in splurge.el's directory. If found, open it read-only. If optional argument HEADING is passed, try to navigate to the heading after opening it. HEADING should be a string. If optional argument NARROW is non-nil, narrow to that heading. This argument has no effect if HEADING is nil or not found." (interactive) (let ((readme splurge--readme-org)) (if (file-exists-p readme) (let ((pr (make-progress-reporter (format "Opening %s ... " (abbreviate-file-name readme))))) (find-file-read-only readme) (when heading (splurge--goto-org-heading heading narrow)) (progress-reporter-done pr)) (message "Couldn't find %s's README.org" splurge--name)))) (defun splurge--goto-org-heading (heading &optional narrow) "Navigate to org HEADING and optionally NARROW to it." (let* ((hrx (format "^[*]+ %s" heading)) (pos (save-match-data (save-excursion (save-restriction (widen) (goto-char (point-max)) (re-search-backward hrx nil t 1)))))) (when pos (widen) (goto-char pos) (if (and narrow (fboundp 'org-narrow-to-subtree)) (org-narrow-to-subtree) (recenter-top-bottom 1)) (when (fboundp 'outline-show-subtree) (outline-show-subtree)) (when (fboundp 'org-flag-drawer) (save-excursion (forward-line 1) (org-flag-drawer t)))))) ;;;;; See News ;;;###autoload (defun splurge-see-news () "See the News in splurge's README.org file." (interactive) (splurge-see-readme "News" 'narrow) (splurge--display-org-subtree)) (defun splurge--display-org-subtree () "Selectively display org subtree." (let ((cmds '(outline-hide-subtree outline-show-children outline-next-heading outline-show-branches))) (and (fboundp (nth 0 cmds)) (fboundp (nth 1 cmds)) (fboundp (nth 2 cmds)) (fboundp (nth 3 cmds)) (mapc #'funcall cmds)))) ;;;; Wrapping up (provide 'splurge) ;; Local Variables: ;; coding: utf-8 ;; indent-tabs-mode: nil ;; sentence-end-double-space: nil ;; outline-regexp: ";;;;* " ;; End: ;;; splurge.el ends here