commit 61a43265656d1530b745606c15615c871d284394
parent cd7ee096333262ace0d8117b4846c171752f4dfa
Author: Christian Grothoff <christian@grothoff.org>
Date: Thu, 1 Jan 2026 13:45:41 +0100
first transaction chart template
Diffstat:
3 files changed, 364 insertions(+), 0 deletions(-)
diff --git a/configure.ac b/configure.ac
@@ -494,6 +494,7 @@ AM_CONDITIONAL([HAVE_EXPERIMENTAL], [test "x$enable_experimental" = "xyes"])
AC_CONFIG_FILES([Makefile
contrib/Makefile
+contrib/typst/Makefile
doc/Makefile
doc/doxygen/Makefile
src/Makefile
diff --git a/contrib/typst/Makefile.am b/contrib/typst/Makefile.am
@@ -0,0 +1,8 @@
+SUBDIRS = .
+
+formdatadir = $(datadir)/taler-merchant/typst-forms/
+dist_formdata_DATA = \
+ transactions.typ
+
+EXTRA_DIST = \
+ $(dist_formdata_DATA)
diff --git a/contrib/typst/transactions.typ b/contrib/typst/transactions.typ
@@ -0,0 +1,354 @@
+#import "@preview/cetz:0.4.2": canvas, draw, palette
+#import "@preview/cetz-plot:0.1.3": plot, chart
+
+#let form(data) = {
+ set page(
+ paper: "a4",
+ margin: (left: 2cm, right: 2cm, top: 2cm, bottom: 2.5cm),
+ footer: context [
+ #grid(
+ columns: (1fr, 1fr),
+ align: (left, right),
+ text(size: 8pt)[],
+ text(size: 8pt)[
+ Page #here().page() of #counter(page).final().first()
+ ]
+ )
+ ]
+ )
+
+ // Helper function to format timeframe
+ let format_timeframe(d_us) = {
+ if d_us == "forever" {
+ "forever"
+ } else {
+ let us = int(d_us)
+ let s = calc.quo(us, 1000000)
+ let m = calc.quo(s, 60)
+ let h = calc.quo(m, 60)
+ let d = calc.quo(h, 24)
+ let w = calc.quo(d, 7)
+
+ if calc.rem(us, 1000000) == 0 {
+ if calc.rem(s, 60) == 0 {
+ if calc.rem(m, 60) == 0 {
+ if calc.rem(h, 24) == 0 {
+ if calc.rem(d, 7) == 0 {
+ str(w) + " week" + if w != 1 { "s" } else { "" }
+ } else {
+ str(d) + " day" + if d != 1 { "s" } else { "" }
+ }
+ } else {
+ str(h) + " hour" + if h != 1 { "s" } else { "" }
+ }
+ } else {
+ str(m) + " minute" + if m != 1 { "s" } else { "" }
+ }
+ } else {
+ str(s) + " s"
+ }
+ } else {
+ str(us) + " μs"
+ }
+ }
+ }
+
+ // Helper function to format timestamp; ignores leap seconds (too variable)
+ let format_timestamp(ts) = {
+ if type(ts) == dictionary and "t_s" in ts {
+ let t_s = ts.t_s
+ if t_s == "never" {
+ "never"
+ } else {
+ // Convert Unix timestamp to human-readable format
+ let seconds = int(t_s)
+ let days_since_epoch = calc.quo(seconds, 86400)
+ let remaining_seconds = calc.rem(seconds, 86400)
+ let hours = calc.quo(remaining_seconds, 3600)
+ let minutes = calc.quo(calc.rem(remaining_seconds, 3600), 60)
+ let secs = calc.rem(remaining_seconds, 60)
+
+ // Helper to check if year is leap year
+ let is_leap(y) = {
+ calc.rem(y, 4) == 0 and (calc.rem(y, 100) != 0 or calc.rem(y, 400) == 0)
+ }
+
+ // Calculate year, month, day
+ let year = 1970
+ let days_left = days_since_epoch
+
+ // Find the year
+ let done = false
+ while not done {
+ let days_in_year = if is_leap(year) { 366 } else { 365 }
+ if days_left >= days_in_year {
+ days_left = days_left - days_in_year
+ year = year + 1
+ } else {
+ done = true
+ }
+ }
+
+ // Days in each month
+ let days_in_months = if is_leap(year) {
+ (31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ } else {
+ (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ }
+
+ // Find month and day
+ let month = 1
+ for days_in_month in days_in_months {
+ if days_left >= days_in_month {
+ days_left = days_left - days_in_month
+ month = month + 1
+ } else {
+ break
+ }
+ }
+ let day = days_left + 1
+
+ // Format with leading zeros
+ let m_str = if month < 10 { "0" + str(month) } else { str(month) }
+ let d_str = if day < 10 { "0" + str(day) } else { str(day) }
+ let h_str = if hours < 10 { "0" + str(hours) } else { str(hours) }
+ let min_str = if minutes < 10 { "0" + str(minutes) } else { str(minutes) }
+ let s_str = if secs < 10 { "0" + str(secs) } else { str(secs) }
+
+ str(year) + "-" + m_str + "-" + d_str + " " + h_str + ":" + min_str + ":" + s_str + " UTC"
+ }
+ } else {
+ str(ts)
+ }
+ }
+
+
+ // Helper function to format timestamp; ignores leap seconds (too variable)
+ // Coarsen based on r_us (a value in milliseconds).
+ // If r_us is a minute, do not show seconds.
+ // If r_us is an hour, do not show minutes or seconds
+ // If r_us is a day, do not show hours, minutes or seconds.
+ // If r_us is a month, do not show days.
+ // If r_us is a year, do not show months.
+ // If mini is true, truly minify the result only showing
+ // the unit around the r_us granularity.
+ let format_round_timestamp(ts, r_us, mini) = {
+ if type(ts) == dictionary and "t_s" in ts {
+ let t_s = ts.t_s
+ if t_s == "never" {
+ "never"
+ } else {
+ // Convert Unix timestamp to human-readable format
+ let seconds = int(t_s)
+ let days_since_epoch = calc.quo(seconds, 86400)
+ let remaining_seconds = calc.rem(seconds, 86400)
+ let hours = calc.quo(remaining_seconds, 3600)
+ let minutes = calc.quo(calc.rem(remaining_seconds, 3600), 60)
+ let secs = calc.rem(remaining_seconds, 60)
+
+ // Helper to check if year is leap year
+ let is_leap(y) = {
+ calc.rem(y, 4) == 0 and (calc.rem(y, 100) != 0 or calc.rem(y, 400) == 0)
+ }
+
+ // Calculate year, month, day
+ let year = 1970
+ let days_left = days_since_epoch
+
+ // Find the year
+ let done = false
+ while not done {
+ let days_in_year = if is_leap(year) { 366 } else { 365 }
+ if days_left >= days_in_year {
+ days_left = days_left - days_in_year
+ year = year + 1
+ } else {
+ done = true
+ }
+ }
+
+ // Days in each month
+ let days_in_months = if is_leap(year) {
+ (31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ } else {
+ (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ }
+
+ // Find month and day
+ let month = 1
+ for days_in_month in days_in_months {
+ if days_left >= days_in_month {
+ days_left = days_left - days_in_month
+ month = month + 1
+ } else {
+ break
+ }
+ }
+ let day = days_left + 1
+
+ // Format with leading zeros
+ let m_str = if month < 10 { "0" + str(month) } else { str(month) }
+ let d_str = if day < 10 { "0" + str(day) } else { str(day) }
+ let h_str = if hours < 10 { "0" + str(hours) } else { str(hours) }
+ let min_str = if minutes < 10 { "0" + str(minutes) } else { str(minutes) }
+ let s_str = if secs < 10 { "0" + str(secs) } else { str(secs) }
+
+ // Define thresholds in microseconds
+ let minute_us = 60 * 1000 * 1000
+ let hour_us = 60 * minute_us
+ let day_us = 24 * hour_us
+ let month_us = 30 * day_us // Approximate: 30 days
+ let year_us = 365 * day_us // Approximate: 365 days
+
+ // Build timestamp string based on r_us thresholds
+ if r_us >= year_us {
+ // Year or more: show only year
+ str(year)
+ } else if r_us >= month_us {
+ // Month or more: show year and month
+ if (mini) { m_str } else { str(year) + "-" + m_str }
+ } else if r_us >= day_us {
+ // Day or more: show year, month, and day
+ if (mini) { d_str + "d" } else { str(year) + "-" + m_str + "-" + d_str }
+ } else if r_us >= hour_us {
+ // Hour or more: show up to hours
+ if (mini) { h_str + "h" } else { str(year) + "-" + m_str + "-" + d_str + " " + h_str + ":00 UTC" }
+ } else if r_us >= minute_us {
+ // Minute or more: show up to minutes
+ if (mini) { min_str + "m" } else { str(year) + "-" + m_str + "-" + d_str + " " + h_str + ":" + min_str + " UTC" }
+ } else {
+ // Less than a minute: show full precision
+ if (mini) { s_str + "s" } else { str(year) + "-" + m_str + "-" + d_str + " " + h_str + ":" + min_str + ":" + s_str + " UTC" }
+ }
+ }
+ } else {
+ str(ts)
+ }
+ }
+
+
+ heading(level: 1)[GNU Taler Merchant Accounting: #data.business]
+
+ [Transaction report from
+ #underline([#format_round_timestamp(data.start_date,data.bucket_period.d_us,false)]) to
+ #underline([#format_round_timestamp(data.end_date,data.bucket_period.d_us,false)])
+ with
+ #text(weight: "bold")[#format_timeframe(data.bucket_period.d_us)]
+ granularity.
+ ]
+
+ let p = palette.new(colors: (blue, green, yellow, orange, purple, lime, teal, gray, red))
+ v(1cm)
+
+ for dchart in data.charts {
+
+ heading(level: 2)[#dchart.chart_name]
+
+ let data_groups = dchart.data_groups.map(dg => (
+ values: dg.values,
+ start_date_str: format_round_timestamp(dg.start_date,data.bucket_period.d_us, false),
+ start_date_mini: format_round_timestamp(dg.start_date,data.bucket_period.d_us, true),
+ ))
+
+ canvas(length: 1cm, {
+ import draw: *
+
+ chart.columnchart(
+ size: (12, 8),
+ label-key: "start_date_mini",
+ value-key: "values",
+ x-label: [Time],
+ y-label: dchart.y-label,
+ x-tick-step: none,
+ y-tick-step: auto,
+ mode : if dchart.cummulative { "stacked" } else { "clustered" },
+ // alternatives: "clustered" or "basic" or "stacked"
+ bar-style: p,
+ data_groups,
+ labels: dchart.labels,
+ legend: "east",
+ )
+ })
+
+ v(1cm)
+
+ let cols = (auto,) + dchart.labels.map(_ => auto)
+ if dchart.cummulative {
+ cols.push(auto)
+ }
+
+ let aligns = (left,) + dchart.labels.map(_ => right)
+ if dchart.cummulative {
+ aligns.push(right)
+ }
+
+ let header = ([*When*],) + dchart.labels.map(label => [*#label*])
+ if dchart.cummulative {
+ header.push([*Total*])
+ }
+
+ let rows = data_groups.map(entry => {
+ let row = (entry.at("start_date_str"),)
+ row += entry.at("values").map(val => str(val))
+ if dchart.cummulative {
+ row.push(str(entry.at("values").sum()))
+ }
+ row
+ }).flatten()
+
+ align(center,
+ table(
+ columns: cols,
+ align: aligns,
+ ..header,
+ ..rows
+ ))
+ v(1cm)
+
+ } // For all charts
+
+}
+
+// Load your JSON data
+#form((
+ business: "Example.com",
+ start_date: (t_s: 1764967786),
+ end_date: (t_s: 1767222000),
+ bucket_period: (d_us: 86400000000),
+ charts: (
+ (chart_name: "Transaction volume",
+ y-label: "Volume",
+ data_groups: (
+ (start_date: (t_s: 1766790000),
+ values: (10, 20, 30)),
+ (start_date: (t_s: 1766876400),
+ values: (10, 20, 30)),
+ (start_date: (t_s: 1766962800),
+ values: (10, 20, 30)),
+ (start_date: (t_s: 1767049200),
+ values: (10, 20, 30)),
+ (start_date: (t_s: 1767135600),
+ values: (10, 20, 30)),
+ ),
+ labels: ([EUR],[CHF],[HUF]),
+ cummulative: false,
+ ),
+ (chart_name: "Transaction rate",
+ y-label: "Rate",
+ data_groups: (
+ (start_date: (t_s: 1764967786),
+ values: (10, 20, 30)),
+ (start_date: (t_s: 1766876400),
+ values: (10, 20, 30)),
+ (start_date: (t_s: 1766962800),
+ values: (10, 20, 30)),
+ (start_date: (t_s: 1767049200),
+ values: (10, 20, 30)),
+ (start_date: (t_s: 1767135600),
+ values: (10, 20, 30)),
+ ),
+ labels: ([Claimed],[Paid],[Settled]),
+ cummulative: true,
+ ),
+ ),
+))
+\ No newline at end of file