From c51ad80dc6accd7725af2705913db7b8d30fde66 Mon Sep 17 00:00:00 2001 From: Angel Beltran Date: Sat, 18 Jul 2026 18:24:37 -0600 Subject: [PATCH] prototyped svg reports --- ...00029_mock_event_report_prototype.down.sql | 32 + .../000029_mock_event_report_prototype.up.sql | 1227 +++++++++++++++++ domains/reports/reports.go | 227 +++ domains/reports/store_with_context.go | 8 + favicon/old-icons/android-chrome-192x192.png | Bin 0 -> 10514 bytes favicon/old-icons/android-chrome-512x512.png | Bin 0 -> 36568 bytes favicon/old-icons/apple-touch-icon.png | Bin 0 -> 9537 bytes favicon/old-icons/favicon-16x16.png | Bin 0 -> 507 bytes favicon/old-icons/favicon-32x32.png | Bin 0 -> 1115 bytes favicon/old-icons/favicon.ico | Bin 0 -> 15406 bytes favicon/old-icons/site.webmanifest | 1 + .../sync-download/android-chrome-192x192.png | Bin 0 -> 6858 bytes .../sync-download/android-chrome-512x512.png | Bin 0 -> 20088 bytes favicon/sync-download/apple-touch-icon.png | Bin 0 -> 6450 bytes favicon/sync-download/favicon-16x16.png | Bin 0 -> 368 bytes favicon/sync-download/favicon-32x32.png | Bin 0 -> 756 bytes favicon/sync-download/favicon.ico | Bin 0 -> 15406 bytes favicon/sync-download/site.webmanifest | 1 + favicon/sync-icons/sync-128x128.png | Bin 0 -> 2356 bytes favicon/sync-icons/sync-16x16.png | Bin 0 -> 385 bytes favicon/sync-icons/sync-192x192.png | Bin 0 -> 5391 bytes favicon/sync-icons/sync-24x24.png | Bin 0 -> 539 bytes favicon/sync-icons/sync-256x256.png | Bin 0 -> 5065 bytes favicon/sync-icons/sync-32x32.png | Bin 0 -> 675 bytes favicon/sync-icons/sync-512x512.png | Bin 0 -> 11510 bytes favicon/sync-icons/sync-64x64.png | Bin 0 -> 1233 bytes scripts/hx-resize.js | 108 ++ server/api/accounts/router.go | 151 +- server/api/apis.go | 3 + server/param/params.go | 14 + server/param/spec.go | 4 +- server/response/body.go | 4 + server/response/cookie.go | 4 + server/response/header.go | 4 + server/response/html.go | 26 +- server/response/json.go | 4 + server/response/redirect.go | 4 + server/response/response.go | 1 + server/response/status.go | 4 + server/server.go | 1 + server/ui/charts/bar.go | 159 +++ server/ui/charts/bar_test.go | 95 ++ server/ui/charts/line.go | 236 ++++ server/ui/charts/line_test.go | 40 + server/ui/charts/orientation.go | 32 + server/ui/charts/test.svg | 1 + server/ui/charts/time_line.go | 425 ++++++ server/ui/router.go | 35 +- server/ui/svg/attributes.go | 24 + server/ui/svg/circle.go | 49 + server/ui/svg/class.go | 18 + server/ui/svg/cx.go | 24 + server/ui/svg/cy.go | 24 + server/ui/svg/d.go | 217 +++ server/ui/svg/dominant_baseline.go | 50 + server/ui/svg/dx.go | 24 + server/ui/svg/dy.go | 24 + server/ui/svg/element.go | 57 + server/ui/svg/element_reader.go | 110 ++ server/ui/svg/ellipse.go | 58 + server/ui/svg/fill.go | 15 + server/ui/svg/font_size.go | 24 + server/ui/svg/g.go | 37 + server/ui/svg/height.go | 24 + server/ui/svg/length.go | 241 ++++ server/ui/svg/length_adjust.go | 23 + server/ui/svg/line.go | 31 + server/ui/svg/markup.go | 19 + server/ui/svg/path.go | 22 + server/ui/svg/path_length.go | 15 + server/ui/svg/percentage.go | 22 + server/ui/svg/points.go | 26 + server/ui/svg/polygon.go | 22 + server/ui/svg/polyline.go | 37 + server/ui/svg/preserve_aspect_ratio.go | 70 + server/ui/svg/r.go | 24 + server/ui/svg/rect.go | 71 + server/ui/svg/rotate.go | 33 + server/ui/svg/rx.go | 24 + server/ui/svg/ry.go | 24 + server/ui/svg/stroke.go | 15 + server/ui/svg/stroke_width.go | 24 + server/ui/svg/style.go | 29 + server/ui/svg/svg.go | 99 ++ server/ui/svg/svg_test.go | 37 + server/ui/svg/tag.go | 7 + server/ui/svg/text.go | 130 ++ server/ui/svg/text_anchor.go | 26 + server/ui/svg/text_length.go | 24 + server/ui/svg/transform.go | 155 +++ server/ui/svg/vector_effect.go | 35 + server/ui/svg/version.go | 18 + server/ui/svg/view_box.go | 20 + server/ui/svg/void_element.go | 42 + server/ui/svg/width.go | 24 + server/ui/svg/x.go | 24 + server/ui/svg/x1.go | 24 + server/ui/svg/x2.go | 24 + server/ui/svg/xmlns.go | 19 + server/ui/svg/y.go | 24 + server/ui/svg/y1.go | 24 + server/ui/svg/y2.go | 24 + styles/index.css | 104 +- styles/source.css | 4 + templates/layout.html.tmpl | 3 +- .../{acctID.int64}/reports/mock.html.tmpl | 203 ++- 106 files changed, 5450 insertions(+), 46 deletions(-) create mode 100644 database_migrations/000029_mock_event_report_prototype.down.sql create mode 100644 database_migrations/000029_mock_event_report_prototype.up.sql create mode 100644 favicon/old-icons/android-chrome-192x192.png create mode 100644 favicon/old-icons/android-chrome-512x512.png create mode 100644 favicon/old-icons/apple-touch-icon.png create mode 100644 favicon/old-icons/favicon-16x16.png create mode 100644 favicon/old-icons/favicon-32x32.png create mode 100644 favicon/old-icons/favicon.ico create mode 100644 favicon/old-icons/site.webmanifest create mode 100644 favicon/sync-download/android-chrome-192x192.png create mode 100644 favicon/sync-download/android-chrome-512x512.png create mode 100644 favicon/sync-download/apple-touch-icon.png create mode 100644 favicon/sync-download/favicon-16x16.png create mode 100644 favicon/sync-download/favicon-32x32.png create mode 100644 favicon/sync-download/favicon.ico create mode 100644 favicon/sync-download/site.webmanifest create mode 100644 favicon/sync-icons/sync-128x128.png create mode 100644 favicon/sync-icons/sync-16x16.png create mode 100644 favicon/sync-icons/sync-192x192.png create mode 100644 favicon/sync-icons/sync-24x24.png create mode 100644 favicon/sync-icons/sync-256x256.png create mode 100644 favicon/sync-icons/sync-32x32.png create mode 100644 favicon/sync-icons/sync-512x512.png create mode 100644 favicon/sync-icons/sync-64x64.png create mode 100644 scripts/hx-resize.js create mode 100644 server/ui/charts/bar.go create mode 100644 server/ui/charts/bar_test.go create mode 100644 server/ui/charts/line.go create mode 100644 server/ui/charts/line_test.go create mode 100644 server/ui/charts/orientation.go create mode 100644 server/ui/charts/test.svg create mode 100644 server/ui/charts/time_line.go create mode 100644 server/ui/svg/attributes.go create mode 100644 server/ui/svg/circle.go create mode 100644 server/ui/svg/class.go create mode 100644 server/ui/svg/cx.go create mode 100644 server/ui/svg/cy.go create mode 100644 server/ui/svg/d.go create mode 100644 server/ui/svg/dominant_baseline.go create mode 100644 server/ui/svg/dx.go create mode 100644 server/ui/svg/dy.go create mode 100644 server/ui/svg/element.go create mode 100644 server/ui/svg/element_reader.go create mode 100644 server/ui/svg/ellipse.go create mode 100644 server/ui/svg/fill.go create mode 100644 server/ui/svg/font_size.go create mode 100644 server/ui/svg/g.go create mode 100644 server/ui/svg/height.go create mode 100644 server/ui/svg/length.go create mode 100644 server/ui/svg/length_adjust.go create mode 100644 server/ui/svg/line.go create mode 100644 server/ui/svg/markup.go create mode 100644 server/ui/svg/path.go create mode 100644 server/ui/svg/path_length.go create mode 100644 server/ui/svg/percentage.go create mode 100644 server/ui/svg/points.go create mode 100644 server/ui/svg/polygon.go create mode 100644 server/ui/svg/polyline.go create mode 100644 server/ui/svg/preserve_aspect_ratio.go create mode 100644 server/ui/svg/r.go create mode 100644 server/ui/svg/rect.go create mode 100644 server/ui/svg/rotate.go create mode 100644 server/ui/svg/rx.go create mode 100644 server/ui/svg/ry.go create mode 100644 server/ui/svg/stroke.go create mode 100644 server/ui/svg/stroke_width.go create mode 100644 server/ui/svg/style.go create mode 100644 server/ui/svg/svg.go create mode 100644 server/ui/svg/svg_test.go create mode 100644 server/ui/svg/tag.go create mode 100644 server/ui/svg/text.go create mode 100644 server/ui/svg/text_anchor.go create mode 100644 server/ui/svg/text_length.go create mode 100644 server/ui/svg/transform.go create mode 100644 server/ui/svg/vector_effect.go create mode 100644 server/ui/svg/version.go create mode 100644 server/ui/svg/view_box.go create mode 100644 server/ui/svg/void_element.go create mode 100644 server/ui/svg/width.go create mode 100644 server/ui/svg/x.go create mode 100644 server/ui/svg/x1.go create mode 100644 server/ui/svg/x2.go create mode 100644 server/ui/svg/xmlns.go create mode 100644 server/ui/svg/y.go create mode 100644 server/ui/svg/y1.go create mode 100644 server/ui/svg/y2.go diff --git a/database_migrations/000029_mock_event_report_prototype.down.sql b/database_migrations/000029_mock_event_report_prototype.down.sql new file mode 100644 index 0000000..e884010 --- /dev/null +++ b/database_migrations/000029_mock_event_report_prototype.down.sql @@ -0,0 +1,32 @@ +BEGIN; + + +DROP VIEW mock.shop_amazon_listing_counts; +DROP VIEW mock.shop_amazon_listing_event_sequence; +DROP VIEW mock.shop_big_cartel_listing_counts; +DROP VIEW mock.shop_big_cartel_listing_event_sequence; +DROP VIEW mock.shop_ebay_listing_counts; +DROP VIEW mock.shop_ebay_listing_event_sequence; +DROP VIEW mock.shop_ecwid_listing_counts; +DROP VIEW mock.shop_ecwid_listing_event_sequence; +DROP VIEW mock.shop_etsy_listing_counts; +DROP VIEW mock.shop_etsy_listing_event_sequence; +DROP VIEW mock.shop_shopify_listing_counts; +DROP VIEW mock.shop_shopify_listing_event_sequence; +DROP VIEW mock.shop_square_online_listing_counts; +DROP VIEW mock.shop_square_online_listing_event_sequence; +DROP VIEW mock.shop_squarespace_listing_counts; +DROP VIEW mock.shop_squarespace_listing_event_sequence; +DROP VIEW mock.shop_tiktok_listing_counts; +DROP VIEW mock.shop_tiktok_listing_event_sequence; +DROP VIEW mock.shop_walmart_marketplace_listing_counts; +DROP VIEW mock.shop_walmart_marketplace_listing_event_sequence; +DROP VIEW mock.shop_wix_listing_counts; +DROP VIEW mock.shop_wix_listing_event_sequence; +DROP VIEW mock.shop_woo_commerce_listing_counts; +DROP VIEW mock.shop_woo_commerce_listing_event_sequence; +DROP VIEW mock.shop_zoho_listing_counts; +DROP VIEW mock.shop_zoho_listing_event_sequence; + + +COMMIT; diff --git a/database_migrations/000029_mock_event_report_prototype.up.sql b/database_migrations/000029_mock_event_report_prototype.up.sql new file mode 100644 index 0000000..c3d2242 --- /dev/null +++ b/database_migrations/000029_mock_event_report_prototype.up.sql @@ -0,0 +1,1227 @@ +BEGIN; + + +CREATE OR REPLACE VIEW mock.shop_amazon_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'amazon' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_amazon_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_amazon_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_amazon_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_big_cartel_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'big_cartel' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_big_cartel_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_big_cartel_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_big_cartel_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_ebay_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'ebay' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_ebay_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_ebay_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_ebay_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_ecwid_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'ecwid' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_ecwid_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_ecwid_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_ecwid_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_etsy_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'Etsy' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_etsy_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_etsy_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_etsy_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_shopify_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'shopify' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_shopify_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_shopify_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_shopify_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_square_online_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'square_online' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_square_online_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_square_online_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_square_online_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_squarespace_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'squarespace' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_squarespace_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_squarespace_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_squarespace_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_tiktok_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'Tiktok' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_tiktok_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_tiktok_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_tiktok_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_walmart_marketplace_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'walmart_marketplace' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_walmart_marketplace_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_walmart_marketplace_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_walmart_marketplace_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_wix_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'Wix' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_wix_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_wix_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_wix_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_woo_commerce_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'woo_commerce' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_woo_commerce_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_woo_commerce_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_woo_commerce_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + +CREATE OR REPLACE VIEW mock.shop_zoho_listing_event_sequence AS + WITH event_sequence AS ( + SELECT + shop_id, + raw_payload ->> 'listingID' as listing_id, + event_timestamp, + event_id, + CASE + WHEN ((raw_payload ->> 'type') = 'sale') + THEN (raw_payload ->> 'count')::SMALLINT + WHEN ((raw_payload ->> 'type') = 'refund') + THEN (-1 * (raw_payload ->> 'count')::SMALLINT) + ELSE + NULL::SMALLINT + END AS delta, + CASE + WHEN ((raw_payload ->> 'type') = 'inventory-reset') + THEN (raw_payload ->> 'count')::SMALLINT + ELSE + NULL::SMALLINT + END AS "count" + FROM + mock.raw_shop_events + WHERE + platform = 'zoho' + ) + SELECT + shop_id, + listing_id, + event_timestamp, + event_id, + delta, + "count", + ROW_NUMBER(*) OVER ( + PARTITION BY shop_id, listing_id + ORDER BY event_timestamp, event_id + ) + FROM + event_sequence + ORDER BY + shop_id, listing_id, event_timestamp, event_id; + + +CREATE OR REPLACE VIEW mock.shop_zoho_listing_counts( + shop_id, + listing_id, + event_timestamp, + "count" +) AS + WITH RECURSIVE cumulative_sums( + shop_id, + listing_id, + event_timestamp, + event_id, + "count", + row_number + ) AS ( + SELECT + shop_id, + listing_id, + NULL::timestamptz AS event_timestamp, + NULL::text AS event_id, + "count", + 0::BIGINT AS row_number + FROM + mock.shop_zoho_listings + UNION + SELECT + sums.shop_id, + sums.listing_id, + evnts.event_timestamp, + evnts.event_id, + COALESCE(evnts."count", sums."count" + evnts.delta) AS "count", + evnts.row_number + FROM + cumulative_sums AS sums + JOIN + mock.shop_zoho_listing_event_sequence AS evnts + ON + sums.shop_id = evnts.shop_id + AND sums.listing_id = evnts.listing_id + AND sums.row_number + 1 = evnts.row_number + ) + SELECT + shop_id, + listing_id, + event_timestamp, + "count" + FROM + cumulative_sums + ORDER BY + shop_id, listing_id, event_timestamp NULLS FIRST, event_id; + + + +COMMIT; diff --git a/domains/reports/reports.go b/domains/reports/reports.go index 0feca73..92551d6 100644 --- a/domains/reports/reports.go +++ b/domains/reports/reports.go @@ -2,9 +2,14 @@ package reports import ( "context" + "fmt" + "slices" + "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "ruben/inventory2/consts" "ruben/inventory2/domains/accounts" "ruben/inventory2/logging" ) @@ -30,3 +35,225 @@ func NewStore(logger *logging.Logger, db *pgxpool.Pool, accts *accounts.Store) * func (db *Store) WithContext(ctx context.Context) *StoreWithContext { return NewStoreWithContext(ctx, db) } + +// TODO: prefix all these names with 'mock' since they're all mock stuff + +type ( + ListingCountsOverTimeReport struct { + AccountID int64 + Platform accounts.Platform + ShopID string + ListingID string + Counts []ListingCountAtTime + } + + ListingCountAtTime struct { + EventTimestamp *time.Time + Count int64 + } +) + +func (r ListingCountsOverTimeReport) MaxCount() ListingCountAtTime { + if len(r.Counts) == 0 { + return ListingCountAtTime{} + } + return slices.MaxFunc(r.Counts, func(a, b ListingCountAtTime) int { + return int(a.Count - b.Count) + }) +} + +func (r ListingCountsOverTimeReport) MinCount() ListingCountAtTime { + if len(r.Counts) == 0 { + return ListingCountAtTime{} + } + return slices.MinFunc(r.Counts, func(a, b ListingCountAtTime) int { + return int(a.Count - b.Count) + }) +} + +func (db *Store) GetListingCountsReport(ctx context.Context, acctID int64, platform accounts.Platform, shopID, listingID string) (*ListingCountsOverTimeReport, error) { + counts, err := db.GetListingCountsOverTime(ctx, acctID, platform, shopID, listingID) + if err != nil { + return nil, err + } + + return &ListingCountsOverTimeReport{ + AccountID: acctID, + Platform: platform, + ShopID: shopID, + ListingID: listingID, + Counts: counts, + }, nil +} + +func (db *Store) GetListingCountsOverTime(ctx context.Context, acctID int64, platform accounts.Platform, shopID, listingID string) ([]ListingCountAtTime, error) { + info, ok := getMockShopSchemaInfo(platform) + if !ok { + return nil, consts.ErrNotFound + } + + var counts []ListingCountAtTime + + err := pgx.BeginTxFunc(ctx, db.db, pgx.TxOptions{ + AccessMode: pgx.ReadOnly, + }, func(tx pgx.Tx) error { + rows, err := tx.Query( + ctx, + fmt.Sprintf( + ` + SELECT + 1 + FROM + %s + WHERE + shop_id = @shop_id + AND listing_id = @listing_id + `, + info.listingsTable, + ), + pgx.NamedArgs{ + "shop_id": shopID, + "listing_id": listingID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query to check the existing of the listing: %w", err) + } + + if _, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]); err != nil { + return fmt.Errorf("listing not found: %w", consts.ErrNotFound) + } + + rows, err = tx.Query( + ctx, + fmt.Sprintf( + ` + SELECT + event_timestamp AS eventTimestamp, + "count" + FROM + %s + WHERE + shop_id = @shop_id + AND listing_id = @listing_id + `, + info.listingCountsView, + ), + pgx.NamedArgs{ + "shop_id": shopID, + "listing_id": listingID, + }, + ) + if err != nil { + return fmt.Errorf("failed to perform query: %w", err) + } + + if counts, err = pgx.CollectRows(rows, pgx.RowToStructByNameLax[ListingCountAtTime]); err != nil { + return fmt.Errorf("failed to scan rows: %w", err) + } + + return nil + }) + + return counts, err +} + +type mockShopSchemaInfo struct { + platform accounts.Platform + shopTable string + listingsTable string + listingCountsView string +} + +func getMockShopSchemaInfo(platform accounts.Platform) (mockShopSchemaInfo, bool) { + for _, in := range getAllMockShopSchemaInfos() { + if in.platform == platform { + return in, true + } + } + + return mockShopSchemaInfo{}, false +} + +func getAllMockShopSchemaInfos() []mockShopSchemaInfo { + return []mockShopSchemaInfo{ + { + platform: accounts.Amazon, + shopTable: "mock.shop_amazon", + listingsTable: "mock.shop_amazon_listings", + listingCountsView: "mock.shop_amazon_listing_counts", + }, + { + platform: accounts.BigCartel, + shopTable: "mock.shop_big_cartel", + listingsTable: "mock.shop_big_cartel_listings", + listingCountsView: "mock.shop_big_cartel_listing_counts", + }, + { + platform: accounts.Ebay, + shopTable: "mock.shop_ebay", + listingsTable: "mock.shop_ebay_listings", + listingCountsView: "mock.shop_ebay_listing_counts", + }, + { + platform: accounts.Ecwid, + shopTable: "mock.shop_ecwid", + listingsTable: "mock.shop_ecwid_listings", + listingCountsView: "mock.shop_ecwid_listing_counts", + }, + { + platform: accounts.Etsy, + shopTable: "mock.shop_etsy", + listingsTable: "mock.shop_etsy_listings", + listingCountsView: "mock.shop_etsy_listing_counts", + }, + { + platform: accounts.Shopify, + shopTable: "mock.shop_shopify", + listingsTable: "mock.shop_shopify_listings", + listingCountsView: "mock.shop_shopify_listing_counts", + }, + { + platform: accounts.SquareOnline, + shopTable: "mock.shop_square_online", + listingsTable: "mock.shop_square_online_listings", + listingCountsView: "mock.shop_square_online_listing_counts", + }, + { + platform: accounts.Squarespace, + shopTable: "mock.shop_squarespace", + listingsTable: "mock.shop_squarespace_listings", + listingCountsView: "mock.shop_squarespace_listing_counts", + }, + { + platform: accounts.Tiktok, + shopTable: "mock.shop_tiktok", + listingsTable: "mock.shop_tiktok_listings", + listingCountsView: "mock.shop_tiktok_listing_counts", + }, + { + platform: accounts.WalmartMarketplace, + shopTable: "mock.shop_walmart_marketplace", + listingsTable: "mock.shop_walmart_marketplace_listings", + listingCountsView: "mock.shop_walmart_marketplace_listing_counts", + }, + { + platform: accounts.Wix, + shopTable: "mock.shop_wix", + listingsTable: "mock.shop_wix_listings", + listingCountsView: "mock.shop_wix_listing_counts", + }, + { + platform: accounts.WooCommerce, + shopTable: "mock.shop_woo_commerce", + listingsTable: "mock.shop_woo_commerce_listings", + listingCountsView: "mock.shop_woo_commerce_listing_counts", + }, + { + platform: accounts.Zoho, + shopTable: "mock.shop_zoho", + listingsTable: "mock.shop_zoho_listings", + listingCountsView: "mock.shop_zoho_listing_counts", + }, + } +} diff --git a/domains/reports/store_with_context.go b/domains/reports/store_with_context.go index 6a8b555..5264cee 100644 --- a/domains/reports/store_with_context.go +++ b/domains/reports/store_with_context.go @@ -23,3 +23,11 @@ func NewStoreWithContext(ctx context.Context, v *Store) *StoreWithContext { func (v_ctx *StoreWithContext) GetRawShopEvents(acctID int64, platform accounts.Platform, shopID string) ([]RawShopEvent, error) { return v_ctx.Store.GetRawShopEvents(v_ctx.ctx, acctID, platform, shopID) } + +func (v_ctx *StoreWithContext) GetListingCountsReport(acctID int64, platform accounts.Platform, shopID string, listingID string) (*ListingCountsOverTimeReport, error) { + return v_ctx.Store.GetListingCountsReport(v_ctx.ctx, acctID, platform, shopID, listingID) +} + +func (v_ctx *StoreWithContext) GetListingCountsOverTime(acctID int64, platform accounts.Platform, shopID string, listingID string) ([]ListingCountAtTime, error) { + return v_ctx.Store.GetListingCountsOverTime(v_ctx.ctx, acctID, platform, shopID, listingID) +} diff --git a/favicon/old-icons/android-chrome-192x192.png b/favicon/old-icons/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..70dd800788e37b77be4fd35636cebea0dafa1fe1 GIT binary patch literal 10514 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 literal 0 HcmV?d00001 diff --git a/favicon/old-icons/favicon-16x16.png b/favicon/old-icons/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..a27391f8bc8a584ccfe76386926fbed0297453e3 GIT binary patch literal 507 zcmVDQP4^x z3L@6d^WNAyOI9LGGRe%mng9Fx4Tv8`BsSY1l{^HpyRL4_rc>uZSJ`fcW!a_S3Q@J5 zwy${~9k+X>#N7`o_GOlmW<3#w=I#|RA435s!g-X-~EhSCu1;uOs@Sp3qSGRt1ZT_m5LUbY?VRcVL zpb#fQ>wTjjWhY&1_SCei^+wkkeZ1Ai6g|hfj1^R(yaMC-12sf#HextN=** zY|sA}7#N#s42MHCly|=X+5MXosEs)+q)-UQUqoUdvGLm?76gLYh=_ZpRRCT7HZpAF zAX{uB;|U5c{|u64S%h+xL75>Tiq51`(;(1883<+9hcXh%@)Knah5v&rf2(FZdH>UA xV(j%}==RHJ3hKF6)I+v8j?=!ix|k2T_YOlK@3l)$)fxZ*002ovPDHLkV1n?j=5qi5 literal 0 HcmV?d00001 diff --git a/favicon/old-icons/favicon-32x32.png b/favicon/old-icons/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..a6368fe94604a3b07df2cd57dac6d15a6e807d06 GIT binary patch literal 1115 zcmV-h1f=_kP)~ZZut?sZ^17m+nd-oX>&}_FQfT4kG&)2p3s|!FpnYzev zH#iTYLOz?`_g(-rwrZcMiu{65{4$u+?zx?|B)Tskg6lAU0I`I&7D;X`sp8u3G3~M2 zmnQ@9)}tFhjf`-F3xMbqNx65RKl77i*r;hUqc7bR2Y`5@>kyzg>2=N^2n0J*L)Dn5#G@fOkhh1w3kbi> z=d@G40E~JGkiXechdMCqCeCJA(w3qA-n+g4%n@tr{2Yb26as`KAfv7Z4;UpA)p2k? z>CS$t*!rwW^KkuqQb+`MIJ?>6%hHW=r;kk-<_8WQng7u&b-MzXVBqWBE}0`HLD%=# zn>jE8#*5~T&Xo}<{;=1acyVSj6OZoq6bc314|qdEgEDvVs&0;|6M5Mhxx`JpW~eVS z>bCREKueODj&ayGOQPPvTsjq|+_6L|EfBlxdxMzL1Hhc~qI~WcHKN-SPabXxirfv# z$i)1`tA2NsgP=G$8yDGm!t8;{#Uk7Ian2h@^Ei19lF>(%qpXb(T zLomXb_`9-){wk&F9h%ax)X|2Ox-wd4@CpJb73b6;D!W z?eOs*{tQLM)h;8Bbo9#l^(E}~X)HfW>Te)OGFz$0sA-@VQ$ygh* z0Yp7vP!&nm*m{ITY1AW13FTfLVT?7xVe@+cAwZNuk#k)D5`BPjDIZ>&C^gVFu`E~B zC=&(+%5t?xSKT)zn|frqy`PwbULcmX<+I-h%W^ASu|($*B=MzA^s`++TJLN5Fllb< zS{D(@Rte>6b~$eOs_WCN764PVa$NxUnjK)rF}w5s_2aau{kzqw61_t5&F4A7IF)JF hz>mH6r;6D9{x!XH7fYSfySV@W002ovPDHLkV1nij5s3f* literal 0 HcmV?d00001 diff --git a/favicon/old-icons/favicon.ico b/favicon/old-icons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c5369b71785eb97e3ef29cd6baa9244c4b9f6d00 GIT binary patch literal 15406 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 QZXFh%t8}hvU>F+sKLo=QZ2$lO literal 0 HcmV?d00001 diff --git a/favicon/old-icons/site.webmanifest b/favicon/old-icons/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/favicon/old-icons/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/favicon/sync-download/android-chrome-192x192.png b/favicon/sync-download/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..33f815d1e805f90072960f715d43cbf2ab372ca4 GIT binary patch literal 6858 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 J7j{^}{{XY@R4V`g literal 0 HcmV?d00001 diff --git a/favicon/sync-download/apple-touch-icon.png b/favicon/sync-download/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1c5185545c94adde3fddc77b91e9028a71098126 GIT binary patch literal 6450 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 M07*qoM6N<$f?J?0b^rhX literal 0 HcmV?d00001 diff --git a/favicon/sync-download/favicon-16x16.png b/favicon/sync-download/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..c29f69fe50d78b621f89fe0a22749313b1783553 GIT binary patch literal 368 zcmV-$0gwKPP)l;)&h zM$!$


YAgk|G2va@ueJhESNdQ2KK1s-wst<$`j<#`E6o|YixIobpdQb7>~xsKKV O0000)@(M%BYvPf@ zh!J`I{$FR`mSunE?C+c-yZ(La>~F3A`tNo2*=u(+%8%28t_L5>Q#2YK6@q_d$tB`U zS)O(UOd-)cpx>D$sRWd&Mv4IWtOVP@_?(q~;J*#+R-;J37zmGnsR<>P09D~^f^V-& zo`BgI+2901ZAh&?wjn1nFOC~Ao>l_p;CvYja6vzLz6maX!ys?firo&jf*)W$xaLK~ z1dPM!AsFPGwStY{aW;P{XVt^`?<$yJI6h-f0?#e47tn`4?}5q2NuS#eunWje9oi2E zL0=Q_09*S>m23_#U@db;Tn6p_J*ioppF|vTbL)6M8WSKf&%js{a1nc9$>bqSuWGsB zc!Avon$@%wn89<8ar}y{{qVY?fDyzi=|IENjeW}t*vG_X<8%^R2T&d3I3%kK(SGbL zUchZ8rWvQD*yrlV4##50b%*EaUch@MhM0t-YAAo88;SACaO?aS>IHmbqMvcxrP;O!X-GIuSr1CCiI`-36ig_NkGzP=ntYV zB-g3CtJC+fnu-Z#QMOvNn5FgnuJ?suoSAd}%emJJKJeT*GxKgU=bh#JZJ`bkDA(%9Xuq8i9jHm@2* z>5LJ#ivNkL#F=6bF@*-F)78fY{8hZtk#QfdQsrN9w%Dbm!{Q&U`T`LfJ4Iw&9IXnr z>7VL$c+b;XWROM2FiQ{UzHSYP@{JZ*bPTKYfc|i6NR-GRi;iKt9%wtj8WJTk$f9GY zRo~(tVFnXrD1Mv2Hxqi+#T*mAJAdx`n{I-El6Q-)|NamqC&&%@7m62(W4{_NNkI zYq>-hQPZ{;?TSj2(_v$_T%!C~d{i7Jb`Z;gjoJ0>KYtbPyu77 z_FvAiTEQOf`i||t_z9gqch?XAXP4`n=h*p!n~vgg(bd_d`u$?)%usU__~z_(ZDAW9 zDTMAH*!W01*->O(xqF1bUB$jV&3}>mhuPx3=AT*E;Xd)9c#BAVtH>QJ1nz8)i0o_F zDtiBtXfw{yU+#_;f2vH)1$S8Pven=ODJ>OKzO64c7uEPS9rEs4agAucLM8sM5X;0F zGR`|)B)Tz_{yiNS6#l;#%N}#Ub#qf&j5!udyKU!pk^$lkV#&Xg$U_pWGxpx+l2UtT zC%;`@FRlQlILkT1n1i+g_oa0r>ohO!P(Sy$ z2gL4T8R(Q_w37S@#ZMb+`~#79k#`@KNMW2R$pzvm1vJ<*M~UmjSe&VL#;{2oF4hX> zV5sq#o#NqQ9(qfF`!9K6bNi5<7mMGBd3pAeWJZX!yoVvuUn{!vwL^8jA4iLM z2%TRCN$E^+rno^&t#{r7F;s)DQd%U2?DLL)7B3a^z`AG<=Xipwr{hJ=1b2SiXZ#RD z53v&9bDD_0YmaBz$fw)IdSS8*kuNcS537%uih}iTdF#hd`*^XQXU=}IH$h|!zCY@} ziNnPrAZK}JAv?5j{&yDZ4aUH43o*aT)puY4rx!`a_8ZsMPPIQGQvu6I04{>N{3;{5_BOiHX?Aj~I?n2X}RU?EF@8pU9q3HrVf{i|o^W z9K_a3jNv48{4M%=cpZZ;iAYO{- z+>_8LtLu(PEUzUTu8Qc4Na&Q+H7pX#>V(7C`BB!LJP}HER3v_4j3IU>Y>j=#j|rcl z{AVwWA+%>xzhVzYoVgyu4}ew!jX4;FD) zXPK#ySYAo^nHbSoruKT?mlGj0cX58bFNWqnb$<1e4rjEl$GYH~ptUeqj<$$?9K?2d zA_%_AKMUxwug(y;pOy{wI`TEN7ZO`TA^^KTht7d@(&K(tHi(bAxLa!yV68G< z-v;&H#VKMv$H4dWIFav+kWaqfIS-10-zxZhgq-p1(Z=s^Pl~LydSH@_Y!hSt?p0qU zzn>7(VdA@DC>L!^OVrQ1^4|sj!|W^aEtrRMCGfQv@=4*@i zlz55A{tNKSx9W|eKQ=Y(0q*+q#qFZ&W0UH{P%U_8{l%@KYuA5f9OUSiqWcZ^r|OHu zK4KmYk-&HnJJ{#U93)nnH&WRHjA4x!Iv4!0gz6imJ4PhNJdk%y;?O+73i_D?&h59v z&>9QH?6>hQo)MXgyg6lG$di#s#W++NH;ccBetbphtc%SedD4~`DCexVZ^cS-iuk#Q zTqm|QI4{o<9}_?Pbp0`F&B92C>Nf!PVRMyl)0tbq4+iu+y=w literal 0 HcmV?d00001 diff --git a/favicon/sync-download/site.webmanifest b/favicon/sync-download/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/favicon/sync-download/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/favicon/sync-icons/sync-128x128.png b/favicon/sync-icons/sync-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..9d05283463826c342d2ccbafec66569757c9a239 GIT binary patch literal 2356 zcmV-43Cs40P)pt zX+jIMkU`M`(PETQV6+ejeW*Sdv>FCQlC4^rmSh>yZkw13rKY{x?Zb5I_P+PtGv_|f z%$#L@FyGGH`=4i?dCr_!q4+ETMuip;2}c#!23!+bNF)r^x)h^9390r=F&dPN{tghM zLCI+505KYrgjNm^qd^IN3dnxInbSzH089jaB7B9$Rv&lpQ>$;37I@U9%8mG~^basbJ*=OOytVp9=?>2t#mvIbc>We( zK5#?}EqQG9{Zk)sg-5L-P6u}5w&H&Tt_AkB(396z-#>K$=X%zinnQt&xU2Xbm`|i3 zDKv#_^%cyF;SO}lzX>?pqyALv1-ys5%3nl#l^3ejkH(EXmSbj5GsgqCsra9P83J9Q z#_)#%Z{Wrz^E{Tric>M~DD8s2gL$pwl@8!u%vXx0U3!2?9?Q{^!N59n3)lxdN=8Fn z8%#rgLF)!!AD@M2hW1Eb4{(*&68K>Z@C&-On(ws)4LA|q37*}+Os^&I%ZZqtyq3MX zFpd=r%|#u1fb;yy55n>2u7_CaSN?xYwiIZSdV$OR$`8sI;CJ-wcY$B|W`JXrg78|p z!LR&GFay1>gEe@bHM~Sgcolfcr|^t%H+puQ?o;@ON}|`2ZdkWyRDi|k*m9LG(Pt0 zKTNYdTw|ne5oQ)E(wOhi>BwEI@EZeIgWNMGxDZw_#v@-)t3D=hF!Eu`kF-S<80i;5 zFJ?tjBDSx9b5cCpj4}`TrsV};{tnD!oz;UjoYTG*z%24uEc}f!u^96jkT69l`CpQEc_fwTS?OG3WS5cvYCcjJzbwKOVDGU(8tKz5j6Pk@SF#1MCrsusO*q(io`&>`Z#V#sLz&D1pRJkw{Yx z5av(pfZI60Z$c4HH!2jIBZc`@UI9`EkQSoMj27nKn)HB;1NC6HV)8@d{xghVg52AJuh(;7NfYfW6jdNW>b-mYL$thSnlj; z!3Q=E**d^G?dt%{aBz`N0M%wX@^g}xYY3?^4Ec`LxI4(6705S-9;7Wwx;?xPvs)$7 znp2Q(2w7?SXKCh}rJFk|z^%gHC<7X&roO1~8v_`J+%tQKX9Kq4NX+M()Xvf&jX6y$DB4%YZUavD zS^_^zM)$73DsZ3I5;S25X5px|6I|%C5RRFH?mNEP+;C(z}LPUMG5H%jm0e+|K9{CAgM zS~`K-&=2|b_Q*`=@6Sl|ZpQu-V@RLZ%hg(ay^b`TkGT!iMa5TvAIQOW`?HX*YFUB* z%cbgDeOd>Y0KC~CuV&g!2H*S5m`!`Dh<9Ob6!qEhYt-s(9H0X;oGU%o-zN2u!|C%o z$ZxA}x!?Xn>7pVlLh3n)z_yHm@D(11irG&qnYk#2e{si8NxG>*y>xQUBC<{mM9^4 zYV}pj0NO++mM9r{YxPykAX13=j>3_*R^NqL6r1N6&rFcVR=*ZAs1y;Bw^qLsGsz`A z0YrsNh!C;|^XZfaUIVTJehe+7XdDbIBz)^`mX-o%IxBtTU#4TuHV$3k^%XBY?K6^+ z&>g~Uz~jKlt_vDzjqhl%8n}jdLrSJ_**Dk;EWuRz;l2tKX%CH0#czR^fIBgpJc{rI zRVv!;8%)8$p^K4lr0p9-3P_s1L8QRgM1x4dNs0!MlF%#~L`p=XXb>qO_0b?wQl#6% abHU%ai8)izgasY|00002?- zG7x6;t5)LxG9*h}BT9nv(@M${i&7cN%ggmL^RkPR6AM!H@{7`Ezq647D*ET?;uvDl z+k1JV7jvKh>xIoqOEzre?D`?uv^7CrtUG@3iW?pB3+_({3*x@Eaf8^JHB$sccDr1A zboNp5*|hQm$r(GppR3M)x5?_=#>y_4j7l?4tD{8!cqS}__oAUlAM?YI!+tD23!5{ljDW7NQ)sRc8Zk@Q^ zI5Tt2#FW0BDbpO)Y`X3_PY|Cbv9j;ZvZX1H?_3L(dD`-9_5Q6K{!8pXi^=C+p6*)P cdhG}6oj>+ljb1I41%?EJr>mdKI;Vst03}kPfB*mh literal 0 HcmV?d00001 diff --git a/favicon/sync-icons/sync-192x192.png b/favicon/sync-icons/sync-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..eaebfef546a1e25f548512054c5eb477cc563c76 GIT binary patch literal 5391 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 literal 0 HcmV?d00001 diff --git a/favicon/sync-icons/sync-24x24.png b/favicon/sync-icons/sync-24x24.png new file mode 100644 index 0000000000000000000000000000000000000000..fc93cc19018910b00a5c23fbb1c770ff150a0c03 GIT binary patch literal 539 zcmV+$0_6RPP)4VUn$ zLr!a)#bb={6m$PW^f*@WArp@63x;h(rfS9)@CfH7o@NDZVPA2^zwk-@7R}=s7MsMc z<987LfrmJWyQ;V4Hg+=p9&Xf$pTK4i-;fTs2?MN$ymy$XBHjz6@94J>&(X(r$iH7j z+6sg_9mI1ih5Xm$;Dr$Us%D7|n00g(qfApT3tkDt=QtRnz#d+N??Qo7A+R}yc#f^m zKOGDF4T0$i3j7KEUM%o21O^inh`FC*;G(0$*_Q4^SIppB&^ccwya~ajF$ydzUebT? zKyelQ4&rB|%bDk|R*|Ohwls@2ZaPr3^bN`CE3tr|8F5E4vNDE}@#XJDw1@*-ZJN=d zWWDpH@j1z=N7Dbk8-#O9zu6>ShoSU}m0z~Jt>ao%Vy|kj1Kwi=m+^8!t-=;P(eAAn d3;7|p!Xb7#eUe9T?^OT*002ovPDHLkV1imZ?UDch literal 0 HcmV?d00001 diff --git a/favicon/sync-icons/sync-256x256.png b/favicon/sync-icons/sync-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..75ff55cd766f1a55afc44f55e873746845c77047 GIT binary patch literal 5065 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 literal 0 HcmV?d00001 diff --git a/favicon/sync-icons/sync-32x32.png b/favicon/sync-icons/sync-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..d00e455246aa145adc6637a4a5bf9bbbd3ae819f GIT binary patch literal 675 zcmV;U0$lxxP);o!jbc+-zCB}#!xU!NIn#u*Y ziABKPCWN4{RsIV90zpNhqFeY;NJ!Gj76yVv2!cYgDFPbTWK-N7nB3g?c(ZT!z!Woc z-Z}Su%zI}bI~IYnU9|5SZD1RC)KAkfU>1B1OX%>h+lRhjDRH&AgEKbi1; z2zUmRU4?z%2CzeXuhUGyWuWA0ejC^|j1GX0z+;k!y#Oo#E#MDuC$0GqvbweTXW&wZ z7uV}^Ktr|P0fv*z^T1cbyat>|+XH0b){qek>ntVdR1Nc|4#pa@hTe2s)!!8PL+5!` z zZjshGI!r!>Kfpz${nH}pjpvld0t!m|Z}1{K1p}(lzQ+Qz_CKqDrXrp3Sios@|H~?1 zO_8p6EI=>1O{;)qMT*?9GGI#Gf3iq7Il55(0~b7)7du*E?J8YvtB&M> zNVw!Y7pwpefC(fIc!xCw?~vcN(Owuv;U03fxBF?Tp+7?>90HeZ*oWxnR0se7002ov JPDHLkV1jr^Bwqjk literal 0 HcmV?d00001 diff --git a/favicon/sync-icons/sync-512x512.png b/favicon/sync-icons/sync-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..c3170c5f841892523307a976badf7513d2a633b4 GIT binary patch literal 11510 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℞yfCh1@FA;_;9U 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 literal 0 HcmV?d00001 diff --git a/scripts/hx-resize.js b/scripts/hx-resize.js new file mode 100644 index 0000000..034ba2a --- /dev/null +++ b/scripts/hx-resize.js @@ -0,0 +1,108 @@ +(function(){ + const defaultDebounceTimeInMs = 500; + + let isReady = false + document.addEventListener('DOMContentLoaded', function() { + isReady = true + }) + function onReady(fn) { + if (isReady || document.readyState === 'complete') { + fn() + } else { + document.addEventListener('DOMContentLoaded', fn) + } + } + + htmx.defineExtension("resize", { + init: (api) => { + function hasResizeTrigger(elt) { + for (spec of api.getTriggerSpecs(elt)) { + if (spec.trigger === 'resize') { + return true; + } + } + return false; + } + + function getDebounceTimeInMs(elt) { + const amountAttr = elt.attributes['hx-resize-debounce']; + if (!amountAttr) { + return defaultDebounceTimeInMs + } + const amountStr = amountAttr.value + if (!amountStr) { + return defaultDebounceTimeInMs + } + + try { + return parseInt(amountStr); + } catch (err) { + console.warn('invalid debounce time on trigger:', trigger); + console.error(err); + return defaultDebounceTimeInMs; + } + } + + let nextID = 1; + let timeoutIDs = {}; + let observers = {}; + function processNode(elt) { + if (!hasResizeTrigger(elt)) { + return; + } + + let data = elt['hx-resize-internal-data']; + if (!data) { + data = { + id: nextID, + }; + nextID += 1; + + elt['hx-resize-internal-data'] = data; + } + + const obs = new ResizeObserver(() => { + if (timeoutIDs[data.id]) { + return; + } + + const debounceTime = getDebounceTimeInMs(elt); + timeoutIDs[data.id] = setTimeout(() => { + elt.dispatchEvent(new Event('resize')); + delete timeoutIDs[data.id]; + }, debounceTime); + }); + + obs.observe(elt); + + observers[data.id] = obs; + } + + function cleanupNode(elt) { + if (!hasResizeTrigger(elt)) { + return; + } + + let data = elt['hx-resize-internal-data']; + if (!data) { + return; + } + + const obs = observers[data.id]; + if (obs) { + obs.disconnect(); + } + + delete observers[data.id]; + delete timeoutIDs[data.id]; + } + + // process nodes + htmx.on('htmx:beforeProcessNode', evt => processNode(evt.target)); + htmx.on('htmx:beforeCleanupElement', evt => cleanupNode(evt.target)); + onReady(function() { + document.querySelectorAll('[hx-trigger]').forEach(processNode); + }) + } + }); +})() diff --git a/server/api/accounts/router.go b/server/api/accounts/router.go index 2579e36..4f1a32f 100644 --- a/server/api/accounts/router.go +++ b/server/api/accounts/router.go @@ -3,36 +3,43 @@ package accounts import ( "errors" "fmt" + "slices" "strings" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "ruben/inventory2/consts" "ruben/inventory2/domains/accounts" + "ruben/inventory2/domains/reports" "ruben/inventory2/logging" "ruben/inventory2/server/auth" "ruben/inventory2/server/param" "ruben/inventory2/server/response" "ruben/inventory2/server/sse" + "ruben/inventory2/server/ui/charts" ) type accountSubrouter struct { - log *logging.Logger - accts *accounts.Store - pub *sse.UpdateNotificationPublisher + log *logging.Logger + accts *accounts.Store + reports *reports.Store + pub *sse.UpdateNotificationPublisher } func Routes( r *gin.RouterGroup, logger *logging.Logger, accts *accounts.Store, + reports *reports.Store, pub *sse.UpdateNotificationPublisher, ) { as := &accountSubrouter{ - log: logger, - accts: accts, - pub: pub, + log: logger, + accts: accts, + reports: reports, + pub: pub, } r.POST("", response.Handler(as.createAccount)) @@ -46,6 +53,9 @@ func Routes( mockShops.PUT("/listings/:listing-id", response.Handler(as.updateMockListing)) mockShops.DELETE("/listings/:listing-id", response.Handler(as.deleteMockListing)) + mockListingCharts := r.Group("/:acctID/platforms/:platform/shops/mocks/:shop-id/listings/:listing-id/charts") + mockListingCharts.GET("/counts", response.Handler(as.getCountsChartForMockListing)) + syncGroups := r.Group("/:acctID/inventory/sync-groups", pub.Publish("/:acctID/inventory/sync-groups")) syncGroups.POST("", response.Handler(as.saveNewSyncGroup)) @@ -421,6 +431,135 @@ func (s *accountSubrouter) deleteMockListing(c *gin.Context) (response.Response, return response.StatusNoContent(), nil } +// TODO: accept dimensions +// GET /:acctID/platforms/:platform/shops/mocks/:shop-id/charts/counts +func (s *accountSubrouter) getCountsChartForMockListing(c *gin.Context) (response.Response, error) { + s.log.Debug("ENDPOINT HIT") + acctID := auth.GetIdentity(c).Account.AccountID + + var ( + platform accounts.Platform + shopID string + listingID string + width float64 + height float64 + timezone string + ) + + err := param.Path("platform", param.Platform(&platform)). + Path("shop-id", param.Text(&shopID)). + Path("listing-id", param.Text(&listingID)). + Form("width", param.Float64(&width)). + Form("height", param.Float64(&height)). + Form("timezone", param.Text(&timezone)). + Unmarshal(c) + if err != nil { + return nil, err + } + + if width <= 0 { + return nil, response.BadRequest().Msg("width must be positive") + } + if height <= 0 { + return nil, response.BadRequest().Msg("height must be positive") + } + + report, err := s.reports.GetListingCountsReport(c, acctID, platform, shopID, listingID) + if err != nil { + return nil, fmt.Errorf("failed to get report: %w", err) + } + + /* + bvs := make([]charts.BarValue, len(report.Counts)) + for i, v := range report.Counts { + label := "start" + if v.EventTimestamp != nil { + label = v.EventTimestamp.Format(time.RFC822) + } + bvs[i] = charts.BarValue{ + Label: label, + Value: float64(v.Count), + } + } + + return response.StatusOK(). + HTMLReader(charts.NewBar(bvs...).SVG().GetMarkupReader()), nil + */ + + /* + lvs := make([]charts.LineValue, len(report.Counts)) + for i, v := range report.Counts { + label := "initial" + if v.EventTimestamp != nil { + label = v.EventTimestamp.Format(time.RFC822) + } + lvs[i] = charts.LineValue{ + Label: label, + Value: float64(v.Count), + } + } + */ + + l, err := time.LoadLocation(timezone) + if err != nil { + s.log.Errorf("failed to parse time zone %s: %v; defaulting to UTC", timezone, err) + l = time.UTC + } + + var times []time.Time + for _, v := range report.Counts { + if v.EventTimestamp == nil { + continue + } + times = append(times, *v.EventTimestamp) + } + var ( + minTime time.Time + maxTime time.Time + initTime time.Time + ) + if len(times) > 0 { + minTime = slices.MinFunc(times, func(a, b time.Time) int { + return int(a.UnixNano() - b.UnixNano()) + }).In(l) + maxTime = slices.MaxFunc(times, func(a, b time.Time) int { + return int(a.UnixNano() - b.UnixNano()) + }).In(l) + + if len(times) > 1 { + avgIntervalPerUpdate := time.Duration((maxTime.UnixNano() - minTime.UnixNano())) / time.Duration(len(times)-1) + initTime = minTime.Add(-avgIntervalPerUpdate) + } else { + initTime = minTime.Add(-time.Minute) + } + } + + lvs := make([]charts.TimeLineValue, len(report.Counts)) + for i, v := range report.Counts { + ts := initTime + label := fmt.Sprint(v.Count) + if v.EventTimestamp != nil { + ts = v.EventTimestamp.In(l) + } + + lvs[i] = charts.TimeLineValue{ + Label: label, + Time: ts, + Value: float64(v.Count), + } + } + + return response.StatusOK(). + HTMLReader( + //charts.NewLineChart(width, height, lvs...). + charts.NewTimeLineChart(width, height, lvs...). + Foreground("var(--foreground)"). + Background("var(--muted)"). + SVG(). + GetMarkupReader(), + ), nil +} + // POST /:acctID/inventory/sync-groups/mock/draft/listings func (s *accountSubrouter) createMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) { r := c.Request diff --git a/server/api/apis.go b/server/api/apis.go index b4b6458..59c404b 100644 --- a/server/api/apis.go +++ b/server/api/apis.go @@ -6,6 +6,7 @@ import ( "ruben/inventory2/domains/accounts" etsy_platform "ruben/inventory2/domains/platforms/etsy" "ruben/inventory2/domains/raw_events" + "ruben/inventory2/domains/reports" "ruben/inventory2/logging" accounts_api "ruben/inventory2/server/api/accounts" auth_api "ruben/inventory2/server/api/auth" @@ -22,6 +23,7 @@ func Routes( auth *auth.Service, sq *sse.Queue, accts *accounts.Store, + reps *reports.Store, unp *sse.UpdateNotificationPublisher, rawEvents *raw_events.Store, etsy *etsy_platform.Platform, @@ -40,6 +42,7 @@ func Routes( r.Group("/accounts", auth.Authenticate()), logger.WithGroup("/accounts"), accts, + reps, unp.Group("/accounts"), ) webhooks.Routes( diff --git a/server/param/params.go b/server/param/params.go index 9c2a25d..42d565d 100644 --- a/server/param/params.go +++ b/server/param/params.go @@ -21,6 +21,10 @@ func Int64(dst *int64) encoding.TextUnmarshaler { return (*int64Text)(dst) } +func Float64(dst *float64) encoding.TextUnmarshaler { + return (*float64Text)(dst) +} + func Bool(dst *bool) encoding.TextUnmarshaler { return (*boolText)(dst) } @@ -33,6 +37,7 @@ type ( rawText string intText int int64Text int64 + float64Text float64 boolText bool platformText accounts.Platform ) @@ -60,6 +65,15 @@ func (n *int64Text) UnmarshalText(text []byte) error { return nil } +func (n *float64Text) UnmarshalText(text []byte) error { + i, err := strconv.ParseFloat(string(text), 64) + if err != nil { + return err + } + *n = float64Text(i) + return nil +} + func (b *boolText) UnmarshalText(text []byte) error { v, err := strconv.ParseBool(string(text)) if err != nil { diff --git a/server/param/spec.go b/server/param/spec.go index d8a7828..6db6123 100644 --- a/server/param/spec.go +++ b/server/param/spec.go @@ -65,7 +65,9 @@ func (s Spec) Unmarshal(c *gin.Context) error { for k, dst := range s.form { v, ok := c.GetPostForm(k) if !ok || v == "" { - return response.BadRequest().Msgf("no %s provided", k) + if v, ok = c.GetQuery(k); !ok { + return response.BadRequest().Msgf("no %s provided", k) + } } if err := dst.UnmarshalText([]byte(v)); err != nil { return response.BadRequest().Wrap(err).Msgf("invalid %s provided", k) diff --git a/server/response/body.go b/server/response/body.go index 9a43b96..b157752 100644 --- a/server/response/body.go +++ b/server/response/body.go @@ -47,6 +47,10 @@ func (b bodyRes) HTML(body []byte) Response { return HTML(body).wrap(b) } +func (b bodyRes) HTMLReader(body io.Reader) Response { + return HTMLReader(body).wrap(b) +} + func (b bodyRes) JSON(body any) Response { return JSON(body).wrap(b) } diff --git a/server/response/cookie.go b/server/response/cookie.go index e60a16f..4cd472a 100644 --- a/server/response/cookie.go +++ b/server/response/cookie.go @@ -47,6 +47,10 @@ func (c cookieRes) HTML(body []byte) Response { return HTML(body).wrap(c) } +func (c cookieRes) HTMLReader(body io.Reader) Response { + return HTMLReader(body).wrap(c) +} + func (c cookieRes) JSON(body any) Response { return JSON(body).wrap(c) } diff --git a/server/response/header.go b/server/response/header.go index 730b568..cf8c2dc 100644 --- a/server/response/header.go +++ b/server/response/header.go @@ -103,6 +103,10 @@ func (h headerRes) HTML(body []byte) Response { return HTML(body).wrap(h) } +func (h headerRes) HTMLReader(body io.Reader) Response { + return HTMLReader(body).wrap(h) +} + func (h headerRes) JSON(body any) Response { return JSON(body).wrap(h) } diff --git a/server/response/html.go b/server/response/html.go index 1550a45..a268fed 100644 --- a/server/response/html.go +++ b/server/response/html.go @@ -11,8 +11,9 @@ import ( type ( htmlRes struct { - body []byte - res Response + body []byte + reader io.Reader + res Response } ) @@ -24,6 +25,12 @@ func HTML(body []byte) Response { } } +func HTMLReader(body io.Reader) Response { + return htmlRes{ + reader: body, + } +} + func (h htmlRes) String() string { if h.res != nil { return fmt.Sprintf(`{"body": %q, "nested": %s}`, string(h.body), h.res) @@ -46,6 +53,13 @@ func (h htmlRes) Redirect(code redirect.Code, to string) Response { func (h htmlRes) HTML(body []byte) Response { h.body = body + h.reader = nil + return h +} + +func (h htmlRes) HTMLReader(body io.Reader) Response { + h.body = nil + h.reader = body return h } @@ -133,7 +147,13 @@ func (h htmlRes) GetRedirect() (code redirect.Code, to string, ok bool) { } func (h htmlRes) getBody() (body io.ReadCloser, ok bool, err error) { - return io.NopCloser(bytes.NewBuffer(h.body)), true, nil + var r io.Reader + if h.reader != nil { + r = h.reader + } else { + r = bytes.NewBuffer(h.body) + } + return io.NopCloser(r), true, nil } func (h htmlRes) getCookies() []http.Cookie { diff --git a/server/response/json.go b/server/response/json.go index 36f2484..e33991f 100644 --- a/server/response/json.go +++ b/server/response/json.go @@ -49,6 +49,10 @@ func (j jsonRes) HTML(body []byte) Response { return HTML(body).wrap(j) } +func (j jsonRes) HTMLReader(body io.Reader) Response { + return HTMLReader(body).wrap(j) +} + func (j jsonRes) JSON(body any) Response { j.body = body return j diff --git a/server/response/redirect.go b/server/response/redirect.go index e8e5e62..a169771 100644 --- a/server/response/redirect.go +++ b/server/response/redirect.go @@ -88,6 +88,10 @@ func (r redirectRes) HTML(body []byte) Response { return HTML(body).wrap(r) } +func (r redirectRes) HTMLReader(body io.Reader) Response { + return HTMLReader(body).wrap(r) +} + func (r redirectRes) JSON(body any) Response { return JSON(body).wrap(r) } diff --git a/server/response/response.go b/server/response/response.go index 7d05c1d..0c59e51 100644 --- a/server/response/response.go +++ b/server/response/response.go @@ -25,6 +25,7 @@ type ( Redirect(code redirect.Code, to string) Response Body(io.ReadCloser) Response HTML([]byte) Response + HTMLReader(io.Reader) Response JSON(any) Response Cookie(http.Cookie) Response diff --git a/server/response/status.go b/server/response/status.go index 91edf99..c4229bf 100644 --- a/server/response/status.go +++ b/server/response/status.go @@ -64,6 +64,10 @@ func (s statusRes) HTML(body []byte) Response { return HTML(body).wrap(s) } +func (s statusRes) HTMLReader(body io.Reader) Response { + return HTMLReader(body).wrap(s) +} + func (s statusRes) JSON(body any) Response { return JSON(body).wrap(s) } diff --git a/server/server.go b/server/server.go index 89ba9e4..330f5fe 100644 --- a/server/server.go +++ b/server/server.go @@ -95,6 +95,7 @@ func NewRouter( authM, sq, accts, + reps, unp, rawEvents, etsy, diff --git a/server/ui/charts/bar.go b/server/ui/charts/bar.go new file mode 100644 index 0000000..8f06a3f --- /dev/null +++ b/server/ui/charts/bar.go @@ -0,0 +1,159 @@ +package charts + +import ( + "fmt" + "slices" + "strings" + + "ruben/inventory2/server/ui/svg" +) + +type ( + // TODO: rename to BarChart + Bar struct { + values []BarValue + classes barChartClasses + } + + BarValue struct { + Label string + Value float64 + } + + barChartClasses struct { + svg []string + bar []string + } +) + +func NewBar(vs ...BarValue) *Bar { + return &Bar{ + values: vs, + } +} + +func (c *Bar) WithSVGClass(classes ...string) *Bar { + c.classes.svg = append(c.classes.svg, classes...) + return c +} + +func (c *Bar) WithBarClass(classes ...string) *Bar { + c.classes.bar = append(c.classes.bar, classes...) + return c +} + +func (c *Bar) SVG() svg.MarshalerReader { + const ( + minX = 0 + minY = 0 + maxX = 100 + maxY = 100 + rangeX = maxX - minX + rangeY = maxY - minY + ) + + s := svg.NewSVG(). + Attr( + svg.Style{ + "width": "100%", + "height": "auto", + "padding": "1em", + "border-width": "2px", + }, + svg.ViewBox{ + X: minX, + Y: minY, + Width: maxX - minX, + Height: maxY - minY, + }, + svg.Class( + strings.Join(append( + []string{"rounded-lg", "border-border"}, + c.classes.bar..., + ), " "), + ), + ) + + if len(c.classes.svg) != 0 { + s = s.Attr(svg.Class(strings.Join(c.classes.svg, " "))) + } + + if len(c.values) > 0 { + minV := slices.MinFunc(c.values, func(a, b BarValue) int { + if a.Value < b.Value { + return -1 + } + if a.Value > b.Value { + return 1 + } + return 0 + }) + maxV := slices.MaxFunc(c.values, func(a, b BarValue) int { + if a.Value < b.Value { + return -1 + } + if a.Value > b.Value { + return 1 + } + return 0 + }) + rangeV := maxV.Value - minV.Value + + spacePerBar := float64(rangeX) / float64(len(c.values)) + barPadding := 0.025 * spacePerBar + barWidth := spacePerBar - (2 * barPadding) + fontSize := barWidth / 2 + + for i, v := range c.values { + scaledV := (v.Value - minV.Value) * rangeY / rangeV + barX := float64(i)*spacePerBar + barPadding + s = s.Child( + new(svg.Rect). + Attr( + svg.NewLength(barX).AsX(), + svg.NewLength(maxY-scaledV).AsY(), + svg.NewLength(barWidth).AsWidth(), + svg.NewLength(scaledV).AsHeight(), + svg.Class( + strings.Join(append( + []string{"fill-accent"}, + c.classes.bar..., + ), " "), + ), + ), + svg.NewText(fmt.Sprint(v.Value)). + Attr( + svg.NewLength(barX+(barWidth/2)).AsX(), + svg.NewLength(maxY-scaledV+fontSize).AsY(), + svg.Fill("var(--accent-foreground)"), + svg.TextAnchorMiddle, + // svg.NewLength(fontSize).AsFontSize(), + svg.Style{ + "font-size": fmt.Sprintf("%vpx", fontSize), + }, + ), + ) + + if v.Label != "" { + x := barX + (barWidth / 2) + y := maxY - fontSize + s = s.Child( + svg.NewText(v.Label). + Attr( + svg.NewLength(x).AsX(), + svg.NewLength(y).AsY(), + svg.Fill("var(--accent-foreground)"), + //svg.NewLength(fontSize).AsFontSize(), + svg.Style{ + "font-size": fmt.Sprintf("%vpx", fontSize), + }, + svg.DominantBaselineCentral, + svg.TransformRotate(-90).About(x, y), + ), + ) + } + } + } + + return s +} diff --git a/server/ui/charts/bar_test.go b/server/ui/charts/bar_test.go new file mode 100644 index 0000000..bf56aab --- /dev/null +++ b/server/ui/charts/bar_test.go @@ -0,0 +1,95 @@ +package charts + +import ( + "fmt" + "os" +) + +func ExampleBar() { + mu := NewBar( + BarValue{ + Label: "label: 101", + Value: 101, + }, + BarValue{ + Label: "label: -09", + Value: -99, + }, + BarValue{ + Label: "label: 201", + Value: 201, + }, + BarValue{ + Label: "label: -199", + Value: -199, + }, + BarValue{ + Label: "label: 301", + Value: 301, + }, + BarValue{ + Label: "label: -299", + Value: -299, + }, + BarValue{ + Label: "label: 401", + Value: 401, + }, + BarValue{ + Label: "label: -399", + Value: -399, + }, + BarValue{ + Label: "label: 501", + Value: 501, + }, + BarValue{ + Label: "label: -499", + Value: -499, + }, + BarValue{ + Label: "label: 601", + Value: 601, + }, + BarValue{ + Label: "label: -599", + Value: -599, + }, + BarValue{ + Label: "label: 701", + Value: 701, + }, + BarValue{ + Label: "label: -699", + Value: -699, + }, + BarValue{ + Label: "label: 801", + Value: 801, + }, + BarValue{ + Label: "label: -799", + Value: -799, + }, + BarValue{ + Label: "label: 901", + Value: 901, + }, + BarValue{ + Label: "label: -899", + Value: -899, + }, + BarValue{ + Label: "label: 1001", + Value: 1001, + }, + BarValue{ + Label: "label: -999", + Value: -999, + }, + ).SVG().GetMarkup() + + os.WriteFile("./test.svg", []byte(mu), 666) + fmt.Println(mu) + // Output: +} diff --git a/server/ui/charts/line.go b/server/ui/charts/line.go new file mode 100644 index 0000000..6044774 --- /dev/null +++ b/server/ui/charts/line.go @@ -0,0 +1,236 @@ +package charts + +import ( + "ruben/inventory2/server/ui/svg" + "slices" +) + +type ( + LineChart struct { + width float64 + height float64 + foreground string + background string + values []LineValue + max *float64 + min *float64 + } + LineValue struct { + Label string + Value float64 + } +) + +func NewLineChart(width, height float64, vs ...LineValue) *LineChart { + return &LineChart{ + width: width, + height: height, + values: vs, + } +} + +func (c *LineChart) Max(n float64) *LineChart { + c.max = &n + return c +} + +func (c *LineChart) Min(n float64) *LineChart { + c.min = &n + return c +} + +func (c *LineChart) Foreground(fg string) *LineChart { + c.foreground = fg + return c +} + +func (c *LineChart) Background(bg string) *LineChart { + c.background = bg + return c +} + +func (c *LineChart) SVG() svg.MarshalerReader { + var ( + minVal float64 + maxVal float64 + ) + + fg := "black" + if c.foreground != "" { + fg = c.foreground + } + bg := c.background + + if c.min != nil { + minVal = *c.min + } else if len(c.values) > 0 { + minVal = slices.MinFunc(c.values, compareLineValues).Value + } + + if c.max != nil { + maxVal = *c.max + } else if len(c.values) > 0 { + maxVal = slices.MaxFunc(c.values, compareLineValues).Value + } + + svgBg := bg + if svgBg == "" { + svgBg = "auto" + } + + s := svg.NewSVG(). + Attr( + svg.NewLength(c.width). + AsWidth(), + svg.NewLength(c.height). + AsHeight(), + svg.Style{ + // "width": "100%", + // "height": "100%", + "background": svgBg, + }, + /* + svg.ViewBox{ + X: 0, + Y: minVal, + Width: float64(len(c.values) - 1), + Height: maxVal - minVal, + }, + */ + svg.ViewBox{ + X: 0, + Y: 0, + Width: c.width, + Height: c.height, + }, + svg.PreserveAspectRatio{}, + ) + + g := new(svg.G). + Attr( + // getChartOrientationTransform(c.height), + svg.Transform{ + // scale down to provide padding + svg.TransformTranslate{ + X: 0.05 * c.width, + Y: 0.05 * c.height, + }, + svg.TransformScale{ + X: 0.9, + Y: 0.9, + }, + + // flip + svg.TransformScale{ + X: 1, + Y: -1, + }, + svg.TransformTranslate{ + Y: -c.height, + }, + }, + ) + + // lines + + pts := make(svg.Points, len(c.values)) + for i, p := range c.values { + pts[i] = svg.Point{ + X: (c.width / float64(len(c.values)-1)) * float64(i), + Y: ((p.Value - minVal) * c.height) / (maxVal - minVal), + } + } + + g = g.Child( + new(svg.Polyline).Attr( + pts, + svg.NewLength(2). + Unit(svg.Px). + AsStrokeWidth(), + svg.VectorEffectNonScalingStroke, + svg.Stroke(fg), + svg.Fill("none"), + ), + ) + + // points + + if len(c.values) > 0 { + r := 1.0 / float64(len(c.values)) + rx := r + ry := r + + for i, p := range c.values { + // dot on the line graph + + x := pts[i].X + y := pts[i].Y + + ell := new(svg.Ellipse). + Attr( + svg.NewLength(x). + AsCX(), + svg.NewLength(y). + AsCY(), + svg.Percentage(rx). + AsRX(), + svg.Percentage(ry). + AsRY(), + svg.Stroke(fg), + svg.NewLength(2). + Unit(svg.Px). + AsStrokeWidth(), + svg.VectorEffectNonScalingStroke, + ) + + if bg != "" { + ell = ell.Attr( + svg.Fill(bg), + ) + } else { + ell = ell.Attr( + svg.Fill(fg), + ) + } + + g = g.Child( + ell, + + // label + upsideDownCenteredText(p.Label, x, y). + Attr( + svg.NewLength(c.height*0.05). + Unit(svg.Px). + AsFontSize(), + ), + ) + } + } + + return s.Child(g) +} + +func compareLineValues(a, b LineValue) int { + if a.Value < b.Value { + return -1 + } + if a.Value > b.Value { + return 1 + } + return 0 +} + +func upsideDownCenteredText(txt string, x, y float64) *svg.Text { + return svg.NewText(txt). + Attr( + svg.TextAnchorMiddle, + svg.DominantBaselineMiddle, + + svg.NewLength(x). + AsX(), + svg.NewLength(y). + AsY(), + + upsideDownTransform(x, y), + ) +} diff --git a/server/ui/charts/line_test.go b/server/ui/charts/line_test.go new file mode 100644 index 0000000..460c1b1 --- /dev/null +++ b/server/ui/charts/line_test.go @@ -0,0 +1,40 @@ +package charts + +import ( + "fmt" + "os" +) + +func ExampleLineChart() { + c := NewLineChart(100, 100, + LineValue{ + Value: 1, + }, + LineValue{ + Value: 2, + }, + LineValue{ + Value: 4, + }, + LineValue{ + Value: 8, + }, + LineValue{ + Value: 16, + }, + LineValue{ + Value: 32, + }, + ). + Min(0). + Max(40). + Foreground("purple"). + Background("pink"). + SVG(). + GetMarkup() + + output := []byte(`` + c + ``) + os.WriteFile("./line_chart.html", []byte(output), 0666) + fmt.Println(c) + // Output: +} diff --git a/server/ui/charts/orientation.go b/server/ui/charts/orientation.go new file mode 100644 index 0000000..b5fb60f --- /dev/null +++ b/server/ui/charts/orientation.go @@ -0,0 +1,32 @@ +package charts + +import "ruben/inventory2/server/ui/svg" + +func getChartOrientationTransform(height float64) svg.Transform { + return svg.Transform{ + svg.TransformScale{ + X: 1, + Y: -1, + }, + svg.TransformTranslate{ + Y: -height, + }, + } +} + +func upsideDownTransform(x, y float64) svg.Transform { + return svg.Transform{ + svg.TransformTranslate{ + X: x, + Y: y, + }, + svg.TransformScale{ + X: 1, + Y: -1, + }, + svg.TransformTranslate{ + X: -x, + Y: -y, + }, + } +} diff --git a/server/ui/charts/test.svg b/server/ui/charts/test.svg new file mode 100644 index 0000000..27fb2be --- /dev/null +++ b/server/ui/charts/test.svg @@ -0,0 +1 @@ + diff --git a/server/ui/charts/time_line.go b/server/ui/charts/time_line.go new file mode 100644 index 0000000..0defeee --- /dev/null +++ b/server/ui/charts/time_line.go @@ -0,0 +1,425 @@ +package charts + +import ( + "ruben/inventory2/server/ui/svg" + "slices" + "time" +) + +type ( + TimeLineChart struct { + width float64 + height float64 + foreground string + background string + values []TimeLineValue + max *float64 + min *float64 + } + TimeLineValue struct { + Label string + Time time.Time + Value float64 + } +) + +func NewTimeLineChart(width, height float64, vs ...TimeLineValue) *TimeLineChart { + return &TimeLineChart{ + width: width, + height: height, + values: validValues(vs), + } +} + +func validValues(vs []TimeLineValue) []TimeLineValue { + values := make([]TimeLineValue, 0, len(vs)) + for _, v := range vs { + if !v.Time.IsZero() { + values = append(values, v) + } + } + return values +} + +func (c *TimeLineChart) Max(n float64) *TimeLineChart { + c.max = &n + return c +} + +func (c *TimeLineChart) Min(n float64) *TimeLineChart { + c.min = &n + return c +} + +func (c *TimeLineChart) Foreground(fg string) *TimeLineChart { + c.foreground = fg + return c +} + +func (c *TimeLineChart) Background(bg string) *TimeLineChart { + c.background = bg + return c +} + +func (c *TimeLineChart) SVG() svg.MarshalerReader { + return svg.NewSVG(). + Attr( + svg.NewLength(c.width). + AsWidth(), + svg.NewLength(c.height). + AsHeight(), + svg.Style{ + "background": c.getOuterSVGBackground(), + }, + svg.ViewBox{ + X: 0, + Y: 0, + Width: c.width, + Height: c.height, + }, + svg.PreserveAspectRatio{}, + ). + Child( + new(svg.G). + Attr( + svg.Transform{ + // scale down to provide padding + svg.TransformTranslate{ + X: 0.05 * c.width, + Y: 0.05 * c.height, + }, + svg.TransformScale{ + X: 0.9, + Y: 0.85, // a little more padding on the bottom + }, + + // flip + svg.TransformScale{ + X: 1, + Y: -1, + }, + svg.TransformTranslate{ + Y: -c.height, + }, + }, + ). + Child(c.getChildren()...), + ) +} + +func (c *TimeLineChart) getOuterSVGBackground() string { + if c.background != "" { + return c.background + } + return "auto" +} + +func (c *TimeLineChart) getChildren() []svg.GChildren { + pts := c.getPoints() + + return append( + append( + []svg.GChildren{ + new(svg.Polyline).Attr( + pts, + svg.NewLength(2). + Unit(svg.Px). + AsStrokeWidth(), + svg.VectorEffectNonScalingStroke, + svg.Stroke(c.getStroke()), + svg.Fill("none"), + ), + }, + c.buildDots(pts)..., + ), + c.buildLabels(pts)..., + ) +} + +func (c *TimeLineChart) getPoints() svg.Points { + minVal, _ := c.getMinVal() + maxVal, _ := c.getMaxVal() + + minTime, maxTime := c.getMinAndMaxUnixTimes() + + pts := make(svg.Points, len(c.values)) + for i, p := range c.values { + pts[i] = svg.Point{ + X: c.width * float64(p.Time.UnixNano()-minTime) / float64(maxTime-minTime), + Y: ((p.Value - minVal) * c.height) / (maxVal - minVal), + } + } + + return pts +} + +func (c *TimeLineChart) getMinVal() (float64, bool) { + if c.min != nil { + return *c.min, true + } + if values := c.values; len(values) > 0 { + return slices.MinFunc(values, compareTimeLineValues).Value, true + } + return 0, false +} + +func (c *TimeLineChart) getMaxVal() (float64, bool) { + if c.max != nil { + return *c.max, true + } + if len(c.values) > 0 { + return slices.MaxFunc(c.values, compareTimeLineValues).Value, true + } + return 0, false +} + +func compareTimeLineValues(a, b TimeLineValue) int { + if a.Value < b.Value { + return -1 + } + if a.Value > b.Value { + return 1 + } + return 0 +} + +func (c *TimeLineChart) getMinAndMaxUnixTimes() (minTime, maxTime int64) { + if len(c.values) > 0 { + return slices.MinFunc(c.values, compareTimeLineTimeValues).Time.UnixNano(), + slices.MaxFunc(c.values, compareTimeLineTimeValues).Time.UnixNano() + } + return 0, 0 +} + +func compareTimeLineTimeValues(a, b TimeLineValue) int { + at := a.Time + bt := b.Time + an := at.UnixNano() + bn := bt.UnixNano() + if at.IsZero() { + an = 0 + } + if bt.IsZero() { + bn = 0 + } + return int(an - bn) +} + +func (c *TimeLineChart) buildDots(pts svg.Points) []svg.GChildren { + if len(c.values) == 0 { + return nil + } + + chn := make([]svg.GChildren, len(c.values)) + for i, p := range pts { + chn[i] = c.buildDot(p) + } + + return chn +} + +func (c *TimeLineChart) buildDot(p svg.Point) *svg.Circle { + return new(svg.Circle). + Attr( + svg.NewLength(p.X). + AsCX(), + svg.NewLength(p.Y). + AsCY(), + svg.NewLength(0.01*min(c.width, c.height)). + AsR(), + svg.Stroke(c.getStroke()), + svg.NewLength(2). + Unit(svg.Px). + AsStrokeWidth(), + svg.VectorEffectNonScalingStroke, + c.getDotFill(), + ) +} + +func (c *TimeLineChart) getDotFill() svg.Fill { + if c.background != "" { + return svg.Fill(c.background) + } else { + return svg.Fill(c.getStroke()) + } +} + +func (c *TimeLineChart) buildLabels(pts svg.Points) []svg.GChildren { + if len(c.values) == 0 { + return nil + } + + chn := make([]svg.GChildren, len(c.values)) + for i, p := range pts { + chn[i] = c.buildLabel(c.values[i], p) + } + + return chn +} + +func (c *TimeLineChart) buildLabel(v TimeLineValue, p svg.Point) *svg.Element[svg.SVGTag, svg.SVGAttribute, svg.SVGChildren] { + return svg.NewSVG(). + Attr( + svg.NewLength(p.X). + AsX(), + svg.NewLength(p.Y). + AsY(), + svg.NewLength(24). + Unit(svg.Px). + AsWidth(), + svg.NewLength(24). + Unit(svg.Px). + AsHeight(), + svg.Class("group"), + svg.Style{ + "overflow": "visible", + "fill": "var(--accent-foreground)", + }, + svg.ViewBox{ + X: 0, + Y: 0, + Width: 1, + Height: 1, + }, + svg.PreserveAspectRatio{ + Align: &svg.AlignValue{ + X: svg.AlignMid, + Y: svg.AlignMid, + }, + }, + ). + Child( + c.buildAlwaysDisplayedLabelText(v, p), + new(svg.Rect). + Attr( + svg.NewLength(0.1). + AsStrokeWidth(), + svg.Fill("var(--muted)"), + svg.Stroke("var(--accent-foreground)"), + svg.NewLength(2.5). + AsHeight(), + svg.NewLength(-5). + AsX(), + svg.NewLength(-3). + AsY(), + svg.NewLength(10). + AsWidth(), + svg.Class("not-group-hover:hidden"), + ), + c.buildHoverLabelText(v, p), + ) +} + +func (c *TimeLineChart) buildAlwaysDisplayedLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] { + return c.buildLabelText(v, p) +} + +func (c *TimeLineChart) buildHoverLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] { + labelText := c.buildLabelText(v, p). + Attr( + svg.Class("not-group-hover:hidden"), + ) + + if v.Time.IsZero() { + return labelText + } + + return labelText.Child( + svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")). + Attr( + svg.TextAnchorMiddle, + svg.DominantBaselineMiddle, + svg.Style{ + "font-size": "1px", + }, + svg.Class("not-group-hover:hidden"), + + svg.NewLength(0). + AsX(), + svg.NewLength(2.5). + AsY(), + ), + ) +} + +func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] { + return new(svg.G). + Attr( + svg.NewLength(0). + AsX(), + svg.NewLength(0). + AsY(), + upsideDownTransform(0, 0), + ). + Child( + svg.NewText(v.Label). + Attr( + svg.TextAnchorMiddle, + svg.DominantBaselineMiddle, + svg.Style{ + "font-size": "1px", + }, + + svg.NewLength(0). + AsX(), + svg.NewLength(1.25). + AsY(), + ), + ) +} + +/* +func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] { + labelText := new(svg.G). + Attr( + svg.NewLength(0). + AsX(), + svg.NewLength(0). + AsY(), + upsideDownTransform(0, 0), + ). + Child( + svg.NewText(v.Label). + Attr( + svg.TextAnchorMiddle, + svg.DominantBaselineMiddle, + svg.Style{ + "font-size": "1px", + }, + + svg.NewLength(0). + AsX(), + svg.NewLength(1.25). + AsY(), + ), + ) + + if v.Time.IsZero() { + return labelText + } + + return labelText.Child( + svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")). + Attr( + svg.TextAnchorMiddle, + svg.DominantBaselineMiddle, + svg.Style{ + "font-size": "1px", + }, + svg.Class("not-group-hover:hidden"), + + svg.NewLength(0). + AsX(), + svg.NewLength(2.5). + AsY(), + ), + ) +} +*/ + +func (c *TimeLineChart) getStroke() string { + if c.foreground != "" { + return c.foreground + } + return "black" +} diff --git a/server/ui/router.go b/server/ui/router.go index 25e312b..7813ad7 100644 --- a/server/ui/router.go +++ b/server/ui/router.go @@ -42,7 +42,7 @@ type ( func Routes( logger *logging.Logger, - r gin.IRoutes, + r gin.IRouter, uiPath string, rawEvents *raw_events.Store, accts *accounts.Store, @@ -101,6 +101,39 @@ func Routes( "multInt": func(a, b int) int { return a * b }, + "divInt": func(a, b int) int { + return a / b + }, + "intToFloat": func(n int) float64 { + return float64(n) + }, + "addInt64": func(a, b int64) int64 { + return a + b + }, + "subInt64": func(a, b int64) int64 { + return a - b + }, + "multInt64": func(a, b int64) int64 { + return a * b + }, + "divInt64": func(a, b int64) int64 { + return a / b + }, + "int64ToFloat": func(n int64) float64 { + return float64(n) + }, + "addFloat": func(a, b float64) float64 { + return a + b + }, + "subFloat": func(a, b float64) float64 { + return a - b + }, + "multFloat": func(a, b float64) float64 { + return a * b + }, + "divFloat": func(a, b float64) float64 { + return a / b + }, // html "rawHTML": func(s string) template.HTML { diff --git a/server/ui/svg/attributes.go b/server/ui/svg/attributes.go new file mode 100644 index 0000000..d3243b9 --- /dev/null +++ b/server/ui/svg/attributes.go @@ -0,0 +1,24 @@ +package svg + +import ( + "fmt" +) + +type ( + // Attribute is the minimum method set of of an svg attribute. + Attribute interface { + // PrintKey must return the svg attribute key + PrintKey() string + // PrintVAlue must return the svg attribute value string, if applicable + PrintValue() (string, bool) + } +) + +func PrintAttribute(a Attribute) string { + v, ok := a.PrintValue() + if !ok { + return a.PrintKey() + } + + return fmt.Sprintf("%s=%s", a.PrintKey(), v) +} diff --git a/server/ui/svg/circle.go b/server/ui/svg/circle.go new file mode 100644 index 0000000..1bb8dee --- /dev/null +++ b/server/ui/svg/circle.go @@ -0,0 +1,49 @@ +package svg + +type ( + Circle = VoidElement[CircleTag, CircleAttribute] + + CircleAttribute interface { + Attribute + IsCircleAttribute() + } + + CircleTag struct{} +) + +func (CircleTag) PrintTag() string { + return "circle" +} + +func (CX) IsCircleAttribute() { +} + +func (CXP) IsCircleAttribute() { +} + +func (CY) IsCircleAttribute() { +} + +func (CYP) IsCircleAttribute() { +} + +func (R) IsCircleAttribute() { +} + +func (RP) IsCircleAttribute() { +} + +func (PathLength) IsCircleAttribute() { +} + +func (Fill) IsCircleAttribute() { +} + +func (Stroke) IsCircleAttribute() { +} + +func (StrokeWidth) IsCircleAttribute() { +} + +func (VectorEffect) IsCircleAttribute() { +} diff --git a/server/ui/svg/class.go b/server/ui/svg/class.go new file mode 100644 index 0000000..6e5307a --- /dev/null +++ b/server/ui/svg/class.go @@ -0,0 +1,18 @@ +package svg + +import ( + "fmt" + "html" +) + +type ( + Class string +) + +func (Class) PrintKey() string { + return "class" +} + +func (c Class) PrintValue() (string, bool) { + return fmt.Sprintf(`"%s"`, html.EscapeString(string(c))), true +} diff --git a/server/ui/svg/cx.go b/server/ui/svg/cx.go new file mode 100644 index 0000000..df25d3f --- /dev/null +++ b/server/ui/svg/cx.go @@ -0,0 +1,24 @@ +package svg + +type ( + CX struct { + *LengthAttr[CXTag] + } + CXP struct { + PercentageAttr[CXTag] + } + + CXTag struct{} +) + +func (l *Length) AsCX() CX { + return CX{LengthAttr: (*LengthAttr[CXTag])(l)} +} + +func (p Percentage) AsCX() CXP { + return CXP{PercentageAttr: (PercentageAttr[CXTag])(p)} +} + +func (CXTag) PrintTag() string { + return "cx" +} diff --git a/server/ui/svg/cy.go b/server/ui/svg/cy.go new file mode 100644 index 0000000..227be59 --- /dev/null +++ b/server/ui/svg/cy.go @@ -0,0 +1,24 @@ +package svg + +type ( + CY struct { + *LengthAttr[CYTag] + } + CYP struct { + PercentageAttr[CYTag] + } + + CYTag struct{} +) + +func (l *Length) AsCY() CY { + return CY{LengthAttr: (*LengthAttr[CYTag])(l)} +} + +func (p Percentage) AsCY() CYP { + return CYP{PercentageAttr: (PercentageAttr[CYTag])(p)} +} + +func (CYTag) PrintTag() string { + return "cy" +} diff --git a/server/ui/svg/d.go b/server/ui/svg/d.go new file mode 100644 index 0000000..413abdd --- /dev/null +++ b/server/ui/svg/d.go @@ -0,0 +1,217 @@ +package svg + +import ( + "fmt" + "strings" +) + +type ( + D []PathCommand + + PathCommand interface { + PrintPathCommand() (string, string, bool) + PrintPathCommandParameters() string + } + + // PathCommand implementations + + // PathRelative makes any PathCommand and converts it into a relative command (makes lowercase) + PathRelative[C PathCommand] struct { + Command C + } + + PathMoveTo struct { + X, Y float64 + } + + PathLineTo struct { + X, Y float64 + } + PathHorizontalLine float64 + PathVerticalLine float64 + + PathCubicBezier []PathCubicBezierParameterTuple + PathCubicBezierParameterTuple struct { + X1, Y1 float64 + X2, Y2 float64 + X, Y float64 + } + PathSmoothCubicBezier []PathSmoothCubicBezierParameterTuple + PathSmoothCubicBezierParameterTuple struct { + X2, Y2 float64 + X, Y float64 + } + + PathQuadraticBezier []PathQuadraticBezierParameterTuple + PathQuadraticBezierParameterTuple struct { + X1, Y1 float64 + X, Y float64 + } + PathSmoothQuadraticBezier []PathSmoothQuadraticBezierParameterTuple + PathSmoothQuadraticBezierParameterTuple struct { + X, Y float64 + } + + PathElliptical []PathEllipticalParameterTuple + PathEllipticalParameterTuple struct { + RX, RY float64 + Angle float64 + LargeArc bool + Clockwise bool + X, Y float64 + } + + PathClose struct{} +) + +func PrintPathCommand(c PathCommand) (string, bool) { + cmd, params, ok := c.PrintPathCommand() + if !ok { + return "", false + } + return fmt.Sprintf("%s %s", cmd, params), true +} + +func (c PathRelative[C]) PrintPathCommand() (string, string, bool) { + cmd, params, ok := c.Command.PrintPathCommand() + return strings.ToLower(cmd), params, ok +} + +func (c PathMoveTo) PrintPathCommand() (string, string, bool) { + return "M", fmt.Sprintf("%v,%v", c.X, c.Y), true +} + +func (c PathLineTo) PrintPathCommand() (string, string, bool) { + return "L", fmt.Sprintf("%v,%v", c.X, c.Y), true +} + +func (c PathHorizontalLine) PrintPathCommand() (string, string, bool) { + return "H", fmt.Sprintf("%v", float64(c)), true +} + +func (c PathVerticalLine) PrintPathCommand() (string, string, bool) { + return "V", fmt.Sprintf("%v", float64(c)), false +} + +func (c PathCubicBezier) PrintPathCommand() (string, string, bool) { + if len(c) == 0 { + return "", "", false + } + return "C", printTuples(c), true +} + +func (c PathCubicBezier) Append(x1, y1, x2, y2, x, y float64) PathCubicBezier { + return append(c, PathCubicBezierParameterTuple{ + X1: x1, + Y1: y1, + X2: x2, + Y2: y2, + X: x, + Y: y, + }) +} + +func (t PathCubicBezierParameterTuple) String() string { + return fmt.Sprintf("%v,%v %v,%v %v,%v", t.X1, t.Y1, t.X2, t.Y2, t.X, t.Y) +} + +func (c PathSmoothCubicBezier) PrintPathCommand() (string, string, bool) { + if len(c) == 0 { + return "", "", false + } + return "S", printTuples(c), true +} + +func (c PathSmoothCubicBezier) Append(x2, y2, x, y float64) PathSmoothCubicBezier { + return append(c, PathSmoothCubicBezierParameterTuple{ + X2: x2, + Y2: y2, + X: x, + Y: y, + }) +} + +func (t PathSmoothCubicBezierParameterTuple) String() string { + return fmt.Sprintf("%v,%v %v,%v", t.X2, t.Y2, t.X, t.Y) +} + +func (c PathQuadraticBezier) PrintPathCommand() (string, string, bool) { + if len(c) == 0 { + return "", "", false + } + return "Q", printTuples(c), true +} + +func (c PathQuadraticBezier) Append(x1, y1, x, y float64) PathQuadraticBezier { + return append(c, PathQuadraticBezierParameterTuple{ + X1: x1, + Y1: y1, + X: x, + Y: y, + }) +} + +func (t PathQuadraticBezierParameterTuple) String() string { + return fmt.Sprintf("%v,%v %v,%v", t.X1, t.Y1, t.X, t.Y) +} + +func (c PathSmoothQuadraticBezier) PrintPathCommand() (string, string, bool) { + if len(c) == 0 { + return "", "", false + } + return "T", printTuples(c), true +} + +func (c PathSmoothQuadraticBezier) Append(x, y float64) PathSmoothQuadraticBezier { + return append(c, PathSmoothQuadraticBezierParameterTuple{ + X: x, + Y: y, + }) +} + +func (t PathSmoothQuadraticBezierParameterTuple) String() string { + return fmt.Sprintf("%v,%v", t.X, t.Y) +} + +func (c PathElliptical) PrintPathCommand() (string, string, bool) { + if len(c) == 0 { + return "", "", false + } + return "A", printTuples(c), true +} + +func (c PathElliptical) Append(rx, ry, angle float64, largeArc, clockwise bool, x, y float64) PathElliptical { + return append(c, PathEllipticalParameterTuple{ + RX: rx, + RY: ry, + Angle: angle, + LargeArc: largeArc, + Clockwise: clockwise, + X: x, + Y: y, + }) +} + +func (t PathEllipticalParameterTuple) String() string { + var ( + largeArcFlag int + sweepFlag int + ) + + if t.LargeArc { + largeArcFlag = 1 + } + if t.Clockwise { + sweepFlag = 1 + } + + return fmt.Sprintf("%v,%v,%v,%d,%d,%v,%v", t.RX, t.RY, t.Angle, largeArcFlag, sweepFlag, t.X, t.Y) +} + +func printTuples[S fmt.Stringer](vs []S) string { + ss := make([]string, len(vs)) + for i, v := range vs { + ss[i] = fmt.Sprint(v) + } + return strings.Join(ss, " ") +} diff --git a/server/ui/svg/dominant_baseline.go b/server/ui/svg/dominant_baseline.go new file mode 100644 index 0000000..96bebea --- /dev/null +++ b/server/ui/svg/dominant_baseline.go @@ -0,0 +1,50 @@ +package svg + +import "fmt" + +type ( + DominantBaseline int +) + +const ( + DominantBaselineAuto DominantBaseline = iota + DominantBaselineTextBottom + DominantBaselineAlphabetic + DominantBaselineIdeographic + DominantBaselineMiddle + DominantBaselineCentral + DominantBaselineMathematical + DominantBaselineHanging + DominantBaselineTextTop +) + +func (a DominantBaseline) PrintValue() (string, bool) { + return fmt.Sprintf("%q", a), true +} + +func (a DominantBaseline) PrintKey() string { + return "dominant-baseline" +} + +func (a DominantBaseline) String() string { + switch a { + case DominantBaselineTextBottom: + return "text-bottom" + case DominantBaselineAlphabetic: + return "alphabetic" + case DominantBaselineIdeographic: + return "ideographic" + case DominantBaselineMiddle: + return "middle" + case DominantBaselineCentral: + return "central" + case DominantBaselineMathematical: + return "mathematical" + case DominantBaselineHanging: + return "hanging" + case DominantBaselineTextTop: + return "text-top" + default: + return "auto" + } +} diff --git a/server/ui/svg/dx.go b/server/ui/svg/dx.go new file mode 100644 index 0000000..9340ffd --- /dev/null +++ b/server/ui/svg/dx.go @@ -0,0 +1,24 @@ +package svg + +type ( + DX struct { + *LengthAttr[DXTag] + } + DXP struct { + PercentageAttr[DXTag] + } + + DXTag struct{} +) + +func (l *Length) AsDX() DX { + return DX{LengthAttr: (*LengthAttr[DXTag])(l)} +} + +func (p Percentage) AsDX() DXP { + return DXP{PercentageAttr: (PercentageAttr[DXTag])(p)} +} + +func (DXTag) PrintTag() string { + return "dx" +} diff --git a/server/ui/svg/dy.go b/server/ui/svg/dy.go new file mode 100644 index 0000000..8623bd2 --- /dev/null +++ b/server/ui/svg/dy.go @@ -0,0 +1,24 @@ +package svg + +type ( + DY struct { + *LengthAttr[DYTag] + } + DYP struct { + PercentageAttr[DYTag] + } + + DYTag struct{} +) + +func (l *Length) AsDY() DY { + return DY{LengthAttr: (*LengthAttr[DYTag])(l)} +} + +func (p Percentage) AsDY() DYP { + return DYP{PercentageAttr: (PercentageAttr[DYTag])(p)} +} + +func (DYTag) PrintTag() string { + return "dy" +} diff --git a/server/ui/svg/element.go b/server/ui/svg/element.go new file mode 100644 index 0000000..f3d8fd2 --- /dev/null +++ b/server/ui/svg/element.go @@ -0,0 +1,57 @@ +package svg + +import ( + "fmt" + "io" + "strings" +) + +type ( + // Element is a convenience for implementing SVG element models. + // Element implements MarshalerReader. + Element[T Tag, A Attribute, C MarshalerReader] struct { + Attributes []A + Children []C + } +) + +func (e *Element[T, A, C]) Attr(as ...A) *Element[T, A, C] { + e.Attributes = append(e.Attributes, as...) + return e +} + +func (e *Element[T, A, C]) Child(cs ...C) *Element[T, A, C] { + e.Children = append(e.Children, cs...) + return e +} + +func (e Element[T, A, C]) GetMarkup() string { + attrs := make([]string, len(e.Attributes)) + for i, a := range e.Attributes { + attrs[i] = PrintAttribute(a) + } + + children := make([]string, len(e.Children)) + for i, c := range e.Children { + children[i] = c.GetMarkup() + } + + tag := e.PrintTag() + + return fmt.Sprintf( + `<%s %s>%s`, + tag, + strings.Join(attrs, " "), + strings.Join(children, ""), + tag, + ) +} + +func (e Element[T, A, C]) GetMarkupReader() io.Reader { + return newElementReader(e) +} + +func (e Element[T, _, _]) PrintTag() string { + var t T + return t.PrintTag() +} diff --git a/server/ui/svg/element_reader.go b/server/ui/svg/element_reader.go new file mode 100644 index 0000000..5fabb02 --- /dev/null +++ b/server/ui/svg/element_reader.go @@ -0,0 +1,110 @@ +package svg + +import ( + "bytes" + "errors" + "io" +) + +type ( + elementReader[T Tag, A Attribute, C MarshalerReader] struct { + element Element[T, A, C] + buf *bytes.Buffer + + openTagRead bool + numChildrenRead int + childrenRead bool + childReader io.Reader + closeTagRead bool + } +) + +func newElementReader[T Tag, A Attribute, C MarshalerReader](e Element[T, A, C]) *elementReader[T, A, C] { + return &elementReader[T, A, C]{ + element: e, + } +} + +func (e *elementReader[T, A, C]) Read(p []byte) (totalRead int, err error) { + if e.buf == nil { + // just started reading. + // buffer the open tag + + e.buf = new(bytes.Buffer) + + // bytes.Buffer never returns an error on Write() + e.buf.WriteByte('<') + e.buf.WriteString(e.element.PrintTag()) + for _, a := range e.element.Attributes { + e.buf.WriteByte(' ') + e.buf.WriteString(PrintAttribute(a)) + } + e.buf.WriteByte('>') + } + + if !e.openTagRead { + // read the open tag + + n, err := e.buf.Read(p) + totalRead += n + if (err != nil && !errors.Is(err, io.EOF)) || n >= len(p) { + return totalRead, err + } + + // done reading the open tag + + e.openTagRead = true + e.buf.Reset() + + p = p[n:] + } + + // read the children + + for ; e.numChildrenRead < len(e.element.Children); e.numChildrenRead += 1 { + if e.childReader == nil { + e.childReader = e.element.Children[e.numChildrenRead].GetMarkupReader() + } + + // read the child + + n, err := e.childReader.Read(p) + totalRead += n + if (err != nil && !errors.Is(err, io.EOF)) || n >= len(p) { + return totalRead, err + } + + // done reading the child + + e.childReader = nil + + p = p[n:] + } + + if !e.childrenRead { + // done reading the children + + e.childrenRead = true + + // buffer the close tag + + e.buf.Write([]byte("') + } + + if !e.closeTagRead { + n, err := e.buf.Read(p) + totalRead += n + if (err != nil && !errors.Is(err, io.EOF)) || n >= len(p) { + return totalRead, err + } + + // done writing the close tag + + e.closeTagRead = true + return totalRead, err + } + + return 0, io.EOF +} diff --git a/server/ui/svg/ellipse.go b/server/ui/svg/ellipse.go new file mode 100644 index 0000000..48daf8b --- /dev/null +++ b/server/ui/svg/ellipse.go @@ -0,0 +1,58 @@ +package svg + +type ( + Ellipse = VoidElement[EllipseTag, EllipseAttribute] + + EllipseTag struct{} + + EllipseAttribute interface { + Attribute + IsEllipseAttribute() + } +) + +func (EllipseTag) PrintTag() string { + return "ellipse" +} + +func (RX) IsEllipseAttribute() { +} + +func (RXP) IsEllipseAttribute() { +} + +func (RY) IsEllipseAttribute() { +} + +func (RYP) IsEllipseAttribute() { +} + +func (CX) IsEllipseAttribute() { +} + +func (CXP) IsEllipseAttribute() { +} + +func (CY) IsEllipseAttribute() { +} + +func (CYP) IsEllipseAttribute() { +} + +func (PathLength) IsEllipseAttribute() { +} + +func (Stroke) IsEllipseAttribute() { +} + +func (StrokeWidth) IsEllipseAttribute() { +} + +func (Fill) IsEllipseAttribute() { +} + +func (Style) IsEllipseAttribute() { +} + +func (VectorEffect) IsEllipseAttribute() { +} diff --git a/server/ui/svg/fill.go b/server/ui/svg/fill.go new file mode 100644 index 0000000..76f50f2 --- /dev/null +++ b/server/ui/svg/fill.go @@ -0,0 +1,15 @@ +package svg + +import "fmt" + +type ( + Fill string +) + +func (Fill) PrintKey() string { + return "fill" +} + +func (f Fill) PrintValue() (string, bool) { + return fmt.Sprintf(`%q`, string(f)), true +} diff --git a/server/ui/svg/font_size.go b/server/ui/svg/font_size.go new file mode 100644 index 0000000..58b4dfd --- /dev/null +++ b/server/ui/svg/font_size.go @@ -0,0 +1,24 @@ +package svg + +type ( + FontSize struct { + *LengthAttr[FontSizeTag] + } + FontSizeP struct { + PercentageAttr[FontSizeTag] + } + + FontSizeTag struct{} +) + +func (l *Length) AsFontSize() FontSize { + return FontSize{LengthAttr: (*LengthAttr[FontSizeTag])(l)} +} + +func (p Percentage) AsFontSize() FontSizeP { + return FontSizeP{PercentageAttr: (PercentageAttr[FontSizeTag])(p)} +} + +func (FontSizeTag) PrintTag() string { + return "font-size" +} diff --git a/server/ui/svg/g.go b/server/ui/svg/g.go new file mode 100644 index 0000000..18f82a4 --- /dev/null +++ b/server/ui/svg/g.go @@ -0,0 +1,37 @@ +package svg + +type ( + G struct { + Element[GTag, GAttribute, GChildren] + } + GTag struct{} + + GAttribute interface { + Attribute + IsGAttribute() + } + + GChildren = MarshalerReader +) + +func (GTag) PrintTag() string { + return "g" +} + +func (Transform) IsGAttribute() { +} + +func (X) IsGAttribute() { +} + +func (XP) IsGAttribute() { +} + +func (Y) IsGAttribute() { +} + +func (YP) IsGAttribute() { +} + +func (Class) IsGAttribute() { +} diff --git a/server/ui/svg/height.go b/server/ui/svg/height.go new file mode 100644 index 0000000..aacbfe9 --- /dev/null +++ b/server/ui/svg/height.go @@ -0,0 +1,24 @@ +package svg + +type ( + Height struct { + *LengthAttr[HeightTag] + } + HeightP struct { + PercentageAttr[HeightTag] + } + + HeightTag struct{} +) + +func (l *Length) AsHeight() Height { + return Height{LengthAttr: (*LengthAttr[HeightTag])(l)} +} + +func (p Percentage) AsHeightP() HeightP { + return HeightP{PercentageAttr: (PercentageAttr[HeightTag])(p)} +} + +func (HeightTag) PrintTag() string { + return "height" +} diff --git a/server/ui/svg/length.go b/server/ui/svg/length.go new file mode 100644 index 0000000..723e957 --- /dev/null +++ b/server/ui/svg/length.go @@ -0,0 +1,241 @@ +package svg + +import "fmt" + +type ( + Length struct { + number float64 + absUnit AbsoluteLengthUnit + relUnit RelativeLengthUnit + } + + LengthAttr[T Tag] Length +) + +func NewLength(n float64) *Length { + return &Length{ + number: n, + } +} + +func (l *Length) Number(n float64) *Length { + l.number = n + return l +} + +func (l *Length) Unit(u AbsoluteLengthUnit) *Length { + l.absUnit = u + l.relUnit = 0 + return l +} + +func (l *Length) RUnit(u RelativeLengthUnit) *Length { + l.relUnit = u + l.absUnit = 0 + return l +} + +func (l Length) String() string { + if l.absUnit != 0 { + return fmt.Sprintf("%v%s", l.number, l.absUnit) + } + if l.relUnit != 0 { + return fmt.Sprintf("%v%s", l.number, l.relUnit) + } + return fmt.Sprintf("%v", l.number) +} + +func (LengthAttr[T]) PrintKey() string { + var tag T + return tag.PrintTag() +} + +func (a LengthAttr[T]) PrintValue() (string, bool) { + return fmt.Sprintf(`"%s"`, Length(a)), true +} + +// Relative length units +type RelativeLengthUnit int + +const ( + _ RelativeLengthUnit = iota + + // based on font + Cap + Ch + Em + Ex + Ic + Lh + + // based on root element's font + Rcap + Rch + Rem + Rex + Ric + Rlh + + // based on viewport + Vh + Vw + Vmax + Vmin + Vb + Vi + // small viewport + Svh + Svw + Svmax + Svmin + Svb + Svi + // large viewport + Lvh + Lvw + Lvmax + Lvmin + Lvb + Lvi + // dynamic viewport + Dvh + Dvw + Dvmax + Dvmin + Dvb + Dvi + + // container query + Cqw + Cqh + Cqi + Cqb + Cqmin + Cqmax +) + +func (u RelativeLengthUnit) String() string { + switch u { + case Cap: + return "cap" + case Ch: + return "ch" + case Em: + return "em" + case Ex: + return "ex" + case Ic: + return "ic" + case Lh: + return "lh" + case Rcap: + return "rcap" + case Rch: + return "rch" + case Rem: + return "rem" + case Rex: + return "rex" + case Ric: + return "ric" + case Rlh: + return "rlh" + case Vh: + return "vh" + case Vw: + return "vw" + case Vmax: + return "vmax" + case Vmin: + return "vmin" + case Vb: + return "vb" + case Vi: + return "vi" + case Svh: + return "svh" + case Svw: + return "svw" + case Svmax: + return "svmax" + case Svmin: + return "svmin" + case Svb: + return "svb" + case Svi: + return "svi" + case Lvh: + return "lvh" + case Lvw: + return "lvw" + case Lvmax: + return "lvmax" + case Lvmin: + return "lvmin" + case Lvb: + return "lvb" + case Lvi: + return "lvi" + case Dvh: + return "dvh" + case Dvw: + return "dvw" + case Dvmax: + return "dvmax" + case Dvmin: + return "dvmin" + case Dvb: + return "dvb" + case Dvi: + return "dvi" + case Cqw: + return "cqw" + case Cqh: + return "cqh" + case Cqi: + return "cqi" + case Cqb: + return "cqb" + case Cqmin: + return "cqmin" + case Cqmax: + return "cqmax" + default: + return "" + } +} + +// Absolute length units +type AbsoluteLengthUnit int + +const ( + _ AbsoluteLengthUnit = iota + Px + Cm + Mm + Q + In + Pc + Pt +) + +func (u AbsoluteLengthUnit) String() string { + switch u { + case Px: + return "px" + case Cm: + return "cm" + case Mm: + return "mm" + case Q: + return "q" + case In: + return "in" + case Pc: + return "pc" + case Pt: + return "pt" + default: + return "" + } +} diff --git a/server/ui/svg/length_adjust.go b/server/ui/svg/length_adjust.go new file mode 100644 index 0000000..774a56b --- /dev/null +++ b/server/ui/svg/length_adjust.go @@ -0,0 +1,23 @@ +package svg + +type ( + LengthAdjust int +) + +const ( + LengthAdjustSpacing LengthAdjust = iota + LengthAdjustSpacingAndGlyph +) + +func (LengthAdjust) PrintTag() string { + return "lengthAdjust" +} + +func (l LengthAdjust) PrintValue() (string, bool) { + switch l { + case LengthAdjustSpacingAndGlyph: + return "spacingAndGlyphs", true + default: + return "spacing", true + } +} diff --git a/server/ui/svg/line.go b/server/ui/svg/line.go new file mode 100644 index 0000000..2754090 --- /dev/null +++ b/server/ui/svg/line.go @@ -0,0 +1,31 @@ +package svg + +type ( + Line = VoidElement[LineTag, LineAttribute] + + LineTag struct{} + + LineAttribute interface { + Attribute + IsLineAttribute() + } +) + +func (LineTag) PrintTag() string { + return "line" +} + +func (X1) IsLineAttribute() { +} + +func (X2) IsLineAttribute() { +} + +func (Y1) IsLineAttribute() { +} + +func (Y2) IsLineAttribute() { +} + +func (PathLength) IsLineAttribute() { +} diff --git a/server/ui/svg/markup.go b/server/ui/svg/markup.go new file mode 100644 index 0000000..f860f28 --- /dev/null +++ b/server/ui/svg/markup.go @@ -0,0 +1,19 @@ +package svg + +import "io" + +type ( + // All SVG element models must satisfy the Marshaler interface. + Marshaler interface { + GetMarkup() string + } + + Reader interface { + GetMarkupReader() io.Reader + } + + MarshalerReader interface { + Marshaler + Reader + } +) diff --git a/server/ui/svg/path.go b/server/ui/svg/path.go new file mode 100644 index 0000000..51b0dc5 --- /dev/null +++ b/server/ui/svg/path.go @@ -0,0 +1,22 @@ +package svg + +type ( + Path = VoidElement[PathTag, PathAttribute] + + PathTag struct{} + + PathAttribute interface { + Attribute + IsPathAttribute() + } +) + +func (PathTag) PrintTag() string { + return "path" +} + +func (D) IsPathAttribute() { +} + +func (PathLength) IsPathAttribute() { +} diff --git a/server/ui/svg/path_length.go b/server/ui/svg/path_length.go new file mode 100644 index 0000000..8746797 --- /dev/null +++ b/server/ui/svg/path_length.go @@ -0,0 +1,15 @@ +package svg + +import "fmt" + +type ( + PathLength float64 +) + +func (PathLength) PrintKey() string { + return "pathLength" +} + +func (l PathLength) PrintValue() (string, bool) { + return fmt.Sprintf(`"%v"`, l), true +} diff --git a/server/ui/svg/percentage.go b/server/ui/svg/percentage.go new file mode 100644 index 0000000..5e932f3 --- /dev/null +++ b/server/ui/svg/percentage.go @@ -0,0 +1,22 @@ +package svg + +import "fmt" + +type ( + Percentage float64 + + PercentageAttr[T Tag] Percentage +) + +func (p Percentage) String() string { + return fmt.Sprintf("%v%%", float64(p)) +} + +func (PercentageAttr[T]) PrintKey() string { + var t T + return t.PrintTag() +} + +func (a PercentageAttr[T]) PrintValue() (string, bool) { + return fmt.Sprintf("%q", Percentage(a)), true +} diff --git a/server/ui/svg/points.go b/server/ui/svg/points.go new file mode 100644 index 0000000..57e35e2 --- /dev/null +++ b/server/ui/svg/points.go @@ -0,0 +1,26 @@ +package svg + +import ( + "fmt" + "strings" +) + +type ( + Points []Point + Point struct { + X float64 + Y float64 + } +) + +func (Points) PrintKey() string { + return "points" +} + +func (ps Points) PrintValue() (string, bool) { + ss := make([]string, len(ps)) + for i, p := range ps { + ss[i] = fmt.Sprintf("%v,%v", p.X, p.Y) + } + return fmt.Sprintf("%q", strings.Join(ss, " ")), true +} diff --git a/server/ui/svg/polygon.go b/server/ui/svg/polygon.go new file mode 100644 index 0000000..6c2552f --- /dev/null +++ b/server/ui/svg/polygon.go @@ -0,0 +1,22 @@ +package svg + +type ( + Polygon = VoidElement[PolygonTag, PolygonAttribute] + + PolygonTag struct{} + + PolygonAttribute interface { + Attribute + IsPolygonAttribute() + } +) + +func (PolygonTag) PrintTag() string { + return "path" +} + +func (Points) IsPolygonAttribute() { +} + +func (PathLength) IsPolygonAttribute() { +} diff --git a/server/ui/svg/polyline.go b/server/ui/svg/polyline.go new file mode 100644 index 0000000..4ee4798 --- /dev/null +++ b/server/ui/svg/polyline.go @@ -0,0 +1,37 @@ +package svg + +type ( + Polyline = VoidElement[PolylineTag, PolylineAttribute] + + PolylineTag struct{} + + PolylineAttribute interface { + Attribute + IsPolylineAttribute() + } +) + +func (PolylineTag) PrintTag() string { + return "polyline" +} + +func (PathLength) IsPolylineAttribute() { +} + +func (Points) IsPolylineAttribute() { +} + +func (Stroke) IsPolylineAttribute() { +} + +func (Fill) IsPolylineAttribute() { +} + +func (StrokeWidth) IsPolylineAttribute() { +} + +func (StrokeWidthP) IsPolylineAttribute() { +} + +func (VectorEffect) IsPolylineAttribute() { +} diff --git a/server/ui/svg/preserve_aspect_ratio.go b/server/ui/svg/preserve_aspect_ratio.go new file mode 100644 index 0000000..723d8d2 --- /dev/null +++ b/server/ui/svg/preserve_aspect_ratio.go @@ -0,0 +1,70 @@ +package svg + +import "fmt" + +// TODO: consider reworking this WHOLE API/PACKAGE to make it more of a chaining API. + +type ( + PreserveAspectRatio struct { + Align *AlignValue + MeetOrSlice MeetOrSliceValue + } + + AlignValue struct { + X AlignValueComponent + Y AlignValueComponent + } + AlignValueComponent int + MeetOrSliceValue int +) + +const ( + AlignMid AlignValueComponent = iota + AlignMin + AlignMax + + _ MeetOrSliceValue = iota + Meet + Slice +) + +func (r PreserveAspectRatio) PrintKey() string { + return "preserveAspectRatio" +} + +func (r PreserveAspectRatio) PrintValue() (string, bool) { + if mos := r.MeetOrSlice.String(); mos != "" { + return fmt.Sprintf(`"%s %s"`, r.Align, mos), true + } + + return fmt.Sprintf("%q", r.Align), true +} + +func (v *AlignValue) String() string { + if v == nil { + return "none" + } + return fmt.Sprintf("x%sY%s", v.X, v.Y) +} + +func (v AlignValueComponent) String() string { + switch v { + case AlignMin: + return "Min" + case AlignMax: + return "Max" + default: + return "Mid" + } +} + +func (v MeetOrSliceValue) String() string { + switch v { + case Meet: + return "meet" + case Slice: + return "slice" + default: + return "" + } +} diff --git a/server/ui/svg/r.go b/server/ui/svg/r.go new file mode 100644 index 0000000..d73f3c6 --- /dev/null +++ b/server/ui/svg/r.go @@ -0,0 +1,24 @@ +package svg + +type ( + R struct { + *LengthAttr[RTag] + } + RP struct { + PercentageAttr[RTag] + } + + RTag struct{} +) + +func (l *Length) AsR() R { + return R{LengthAttr: (*LengthAttr[RTag])(l)} +} + +func (p Percentage) AsR() RP { + return RP{PercentageAttr: (PercentageAttr[RTag])(p)} +} + +func (RTag) PrintTag() string { + return "r" +} diff --git a/server/ui/svg/rect.go b/server/ui/svg/rect.go new file mode 100644 index 0000000..f33f53c --- /dev/null +++ b/server/ui/svg/rect.go @@ -0,0 +1,71 @@ +package svg + +type ( + // Rect models a element + Rect = VoidElement[RectTag, RectAttribute] + RectTag struct{} + + // SVGAttributes are Attributes allowed on SVGs + RectAttribute interface { + Attribute + IsRectAttribute() + } +) + +func (RectTag) PrintTag() string { + return "rect" +} + +func (X) IsRectAttribute() { +} + +func (XP) IsRectAttribute() { +} + +func (Y) IsRectAttribute() { +} + +func (YP) IsRectAttribute() { +} + +func (Width) IsRectAttribute() { +} + +func (WidthP) IsRectAttribute() { +} + +func (Height) IsRectAttribute() { +} + +func (HeightP) IsRectAttribute() { +} + +func (RX) IsRectAttribute() { +} + +func (RXP) IsRectAttribute() { +} + +func (RY) IsRectAttribute() { +} + +func (RYP) IsRectAttribute() { +} + +func (PathLength) IsRectAttribute() { +} + +func (Class) IsRectAttribute() { +} + +func (Fill) IsRectAttribute() { +} + +func (Stroke) IsRectAttribute() { +} + +func (StrokeWidth) IsRectAttribute() { +} + +func (Style) IsRectAttribute() { +} diff --git a/server/ui/svg/rotate.go b/server/ui/svg/rotate.go new file mode 100644 index 0000000..6ef579c --- /dev/null +++ b/server/ui/svg/rotate.go @@ -0,0 +1,33 @@ +package svg + +import "fmt" + +type ( + Rotate float64 + RotateAuto struct{} + RotateAutoReverse struct{} +) + +func (r Rotate) PrintKey() string { + return "rotate" +} + +func (r Rotate) PrintValue() (string, bool) { + return fmt.Sprintf(`"%v"`, float64(r)), true +} + +func (r RotateAuto) PrintKey() string { + return "rotate" +} + +func (r RotateAuto) PrintValue() (string, bool) { + return `"auto"`, true +} + +func (r RotateAutoReverse) PrintKey() string { + return "rotate" +} + +func (r RotateAutoReverse) PrintValue() (string, bool) { + return `"auto-reverse"`, true +} diff --git a/server/ui/svg/rx.go b/server/ui/svg/rx.go new file mode 100644 index 0000000..ffcac8b --- /dev/null +++ b/server/ui/svg/rx.go @@ -0,0 +1,24 @@ +package svg + +type ( + RX struct { + *LengthAttr[RXTag] + } + RXP struct { + PercentageAttr[RXTag] + } + + RXTag struct{} +) + +func (l *Length) AsRX() RX { + return RX{LengthAttr: (*LengthAttr[RXTag])(l)} +} + +func (p Percentage) AsRX() RXP { + return RXP{PercentageAttr: (PercentageAttr[RXTag])(p)} +} + +func (RXTag) PrintTag() string { + return "rx" +} diff --git a/server/ui/svg/ry.go b/server/ui/svg/ry.go new file mode 100644 index 0000000..5fda320 --- /dev/null +++ b/server/ui/svg/ry.go @@ -0,0 +1,24 @@ +package svg + +type ( + RY struct { + *LengthAttr[RYTag] + } + RYP struct { + PercentageAttr[RYTag] + } + + RYTag struct{} +) + +func (l *Length) AsRY() RY { + return RY{LengthAttr: (*LengthAttr[RYTag])(l)} +} + +func (p Percentage) AsRY() RYP { + return RYP{PercentageAttr: (PercentageAttr[RYTag])(p)} +} + +func (RYTag) PrintTag() string { + return "ry" +} diff --git a/server/ui/svg/stroke.go b/server/ui/svg/stroke.go new file mode 100644 index 0000000..2e53579 --- /dev/null +++ b/server/ui/svg/stroke.go @@ -0,0 +1,15 @@ +package svg + +import "fmt" + +type ( + Stroke string +) + +func (Stroke) PrintKey() string { + return "stroke" +} + +func (s Stroke) PrintValue() (string, bool) { + return fmt.Sprintf(`%q`, string(s)), true +} diff --git a/server/ui/svg/stroke_width.go b/server/ui/svg/stroke_width.go new file mode 100644 index 0000000..39349f9 --- /dev/null +++ b/server/ui/svg/stroke_width.go @@ -0,0 +1,24 @@ +package svg + +type ( + StrokeWidth struct { + *LengthAttr[StrokeWidthTag] + } + StrokeWidthP struct { + PercentageAttr[StrokeWidthTag] + } + + StrokeWidthTag struct{} +) + +func (l *Length) AsStrokeWidth() StrokeWidth { + return StrokeWidth{LengthAttr: (*LengthAttr[StrokeWidthTag])(l)} +} + +func (p Percentage) AsStrokeWidth() StrokeWidthP { + return StrokeWidthP{PercentageAttr: (PercentageAttr[StrokeWidthTag])(p)} +} + +func (StrokeWidthTag) PrintTag() string { + return "stroke-width" +} diff --git a/server/ui/svg/style.go b/server/ui/svg/style.go new file mode 100644 index 0000000..a659c23 --- /dev/null +++ b/server/ui/svg/style.go @@ -0,0 +1,29 @@ +package svg + +import ( + "fmt" + "html" + "maps" + "slices" + "strings" +) + +type ( + Style map[string]string +) + +func (m Style) PrintKey() string { + return "style" +} + +func (m Style) PrintValue() (string, bool) { + keys := slices.Collect(maps.Keys(m)) + slices.Sort(keys) + + parts := make([]string, len(m)) + for i, k := range keys { + parts[i] = fmt.Sprintf(`%s: %s`, k, html.EscapeString(m[k])) + } + + return fmt.Sprintf("%q", strings.Join(parts, "; ")), true +} diff --git a/server/ui/svg/svg.go b/server/ui/svg/svg.go new file mode 100644 index 0000000..06e0ae0 --- /dev/null +++ b/server/ui/svg/svg.go @@ -0,0 +1,99 @@ +package svg + +type ( + // SVG models an element + SVG struct { + Element[SVGTag, SVGAttribute, SVGChildren] + } + SVGTag struct{} + + // SVGAttributes are Attributes allowed on SVGs + SVGAttribute interface { + Attribute + IsSVGAttribute() + } + + SVGChildren = MarshalerReader +) + +// NewSVG create a SVG with the common version 1.1 and svg namespace atributes +func NewSVG() *SVG { + return &SVG{ + Element: Element[SVGTag, SVGAttribute, MarshalerReader]{ + Attributes: []SVGAttribute{ + SVGVersion{ + Major: 1, + Minor: 1, + }, + XMLNSW32000SVG, + }, + }, + } +} + +func (t SVGTag) PrintTag() string { + return "svg" +} + +func (X) IsSVGAttribute() { +} + +func (XP) IsSVGAttribute() { +} + +func (Y) IsSVGAttribute() { +} + +func (YP) IsSVGAttribute() { +} + +func (Width) IsSVGAttribute() { +} + +func (WidthP) IsSVGAttribute() { +} + +func (Height) IsSVGAttribute() { +} + +func (HeightP) IsSVGAttribute() { +} + +func (ViewBox) IsSVGAttribute() { +} + +func (Style) IsSVGAttribute() { +} + +func (Class) IsSVGAttribute() { +} + +func (PreserveAspectRatio) IsSVGAttribute() { +} + +func (SVGVersion) IsSVGAttribute() { +} + +func (XMLNS) IsSVGAttribute() { +} + +func (Transform) IsSVGAttribute() { +} + +func (TransformRotate) IsSVGAttribute() { +} + +func (TransformRotateAbout) IsSVGAttribute() { +} + +func (TransformTranslate) IsSVGAttribute() { +} + +func (TransformSkewX) IsSVGAttribute() { +} + +func (TransformSkewY) IsSVGAttribute() { +} + +func (TransformScale) IsSVGAttribute() { +} diff --git a/server/ui/svg/svg_test.go b/server/ui/svg/svg_test.go new file mode 100644 index 0000000..38189e3 --- /dev/null +++ b/server/ui/svg/svg_test.go @@ -0,0 +1,37 @@ +package svg + +import ( + "fmt" + "io" +) + +func ExampleSVG() { + s := NewSVG(). + Attr( + NewLength(10). + AsX(), + NewLength(20). + AsY(), + Style{ + "font-size": "8px", + }, + ). + Child( + Rect{ + Attributes: []RectAttribute{ + NewLength(30). + AsX(), + Percentage(40). + AsY(), + }, + }, + NewText("abc 123"), + ) + + b, err := io.ReadAll(s.GetMarkupReader()) + if err != nil { + panic(err) + } + fmt.Println(string(b)) + // Output: abc 123 +} diff --git a/server/ui/svg/tag.go b/server/ui/svg/tag.go new file mode 100644 index 0000000..60bf940 --- /dev/null +++ b/server/ui/svg/tag.go @@ -0,0 +1,7 @@ +package svg + +type ( + Tag interface { + PrintTag() string + } +) diff --git a/server/ui/svg/text.go b/server/ui/svg/text.go new file mode 100644 index 0000000..72ff478 --- /dev/null +++ b/server/ui/svg/text.go @@ -0,0 +1,130 @@ +package svg + +import ( + "bytes" + "html" + "io" +) + +type ( + // Text models an element + Text = Element[TextTag, TextAttribute, TextChild] + TextTag struct{} + TextAttribute interface { + Attribute + IsTextAttribute() + } + TextChild interface { + MarshalerReader + IsTextChild() + } + + RawText string +) + +func NewText(t string) *Text { + return &Text{ + Children: []TextChild{ + RawText(t), + }, + } +} + +func (t TextTag) PrintTag() string { + return "text" +} + +func (t RawText) GetMarkup() string { + return html.EscapeString(string(t)) +} + +func (t RawText) GetMarkupReader() io.Reader { + return bytes.NewReader([]byte(t.GetMarkup())) +} + +func (t RawText) IsTextChild() { +} + +func (X) IsTextAttribute() { +} + +func (XP) IsTextAttribute() { +} + +func (Y) IsTextAttribute() { +} + +func (YP) IsTextAttribute() { +} + +func (DX) IsTextAttribute() { +} + +func (DXP) IsTextAttribute() { +} + +func (DY) IsTextAttribute() { +} + +func (DYP) IsTextAttribute() { +} + +func (Rotate) IsTextAttribute() { +} + +func (RotateAuto) IsTextAttribute() { +} + +func (RotateAutoReverse) IsTextAttribute() { +} + +func (Fill) IsTextAttribute() { +} + +func (TextLength) IsTextAttribute() { +} + +func (TextLengthP) IsTextAttribute() { +} + +func (LengthAdjust) IsTextAttribute() { +} + +func (TextAnchor) IsTextAttribute() { +} + +func (FontSize) IsTextAttribute() { +} + +func (FontSizeP) IsTextAttribute() { +} + +func (Transform) IsTextAttribute() { +} + +func (TransformRotate) IsTextAttribute() { +} + +func (TransformRotateAbout) IsTextAttribute() { +} + +func (TransformTranslate) IsTextAttribute() { +} + +func (TransformSkewX) IsTextAttribute() { +} + +func (TransformSkewY) IsTextAttribute() { +} + +func (TransformScale) IsTextAttribute() { +} + +func (DominantBaseline) IsTextAttribute() { +} + +func (Style) IsTextAttribute() { +} + +func (Class) IsTextAttribute() { +} diff --git a/server/ui/svg/text_anchor.go b/server/ui/svg/text_anchor.go new file mode 100644 index 0000000..9096fc0 --- /dev/null +++ b/server/ui/svg/text_anchor.go @@ -0,0 +1,26 @@ +package svg + +type ( + TextAnchor int +) + +const ( + TextAnchorStart TextAnchor = iota + TextAnchorMiddle + TextAnchorEnd +) + +func (TextAnchor) PrintKey() string { + return "text-anchor" +} + +func (t TextAnchor) PrintValue() (string, bool) { + switch t { + case TextAnchorMiddle: + return `"middle"`, true + case TextAnchorEnd: + return `"end"`, true + default: + return `"start"`, true + } +} diff --git a/server/ui/svg/text_length.go b/server/ui/svg/text_length.go new file mode 100644 index 0000000..e4b66a9 --- /dev/null +++ b/server/ui/svg/text_length.go @@ -0,0 +1,24 @@ +package svg + +type ( + TextLength struct { + *LengthAttr[TextLengthTag] + } + TextLengthP struct { + PercentageAttr[TextLengthTag] + } + + TextLengthTag struct{} +) + +func (l *Length) AsTextLength() TextLength { + return TextLength{LengthAttr: (*LengthAttr[TextLengthTag])(l)} +} + +func (p Percentage) AsTextLength() TextLengthP { + return TextLengthP{PercentageAttr: (PercentageAttr[TextLengthTag])(p)} +} + +func (TextLengthTag) PrintTag() string { + return "textLength" +} diff --git a/server/ui/svg/transform.go b/server/ui/svg/transform.go new file mode 100644 index 0000000..0e87d85 --- /dev/null +++ b/server/ui/svg/transform.go @@ -0,0 +1,155 @@ +package svg + +import ( + "fmt" + "strings" +) + +// TODO consider replacing the Transform matrices with a single 'matrix' type with different constructors and a builder/chaining api. + +type ( + Transform []TransformExpression + + // Transform is itself a Transform, meaning you could nest them, if you wanted. + TransformExpression interface { + PrintTransform() string + } + + // TransformExpression implementations + // They can tehemselves be used as standalone 'transform' attributes. + + TransformRotate float64 + TransformRotateAbout struct { + A float64 + X, Y float64 + } + TransformTranslate struct { + X, Y float64 + } + TransformSkewX float64 + TransformSkewY float64 + TransformScale struct { + X, Y float64 + } +) + +func (Transform) PrintKey() string { + return "transform" +} + +func (ts Transform) PrintValue() (string, bool) { + return fmt.Sprintf("%q", ts.PrintTransform()), true +} + +func (ts Transform) PrintTransform() string { + ss := make([]string, len(ts)) + for i, t := range ts { + ss[i] = t.PrintTransform() + } + + return strings.Join(ss, " ") +} + +func (ts Transform) Rotate(a float64) Transform { + return append(ts, TransformRotate(a)) +} + +func (ts Transform) RotateAbout(a, x, y float64) Transform { + return append(ts, TransformRotateAbout{A: a, X: x, Y: y}) +} + +func (ts Transform) Translate(x, y float64) Transform { + return append(ts, TransformTranslate{X: x, Y: y}) +} + +func (ts Transform) SkewX(x float64) Transform { + return append(ts, TransformSkewX(x)) +} + +func (ts Transform) SkewY(y float64) Transform { + return append(ts, TransformSkewY(y)) +} + +func (ts Transform) Scale(x, y float64) Transform { + return append(ts, TransformScale{X: x, Y: y}) +} + +func (TransformRotate) PrintKey() string { + return "transform" +} + +func (t TransformRotate) PrintValue() (string, bool) { + return fmt.Sprintf("%q", t.PrintTransform()), true +} + +func (t TransformRotate) PrintTransform() string { + return fmt.Sprintf("rotate(%v)", float64(t)) +} + +func (t TransformRotate) About(x, y float64) TransformRotateAbout { + return TransformRotateAbout{ + A: float64(t), + X: x, + Y: y, + } +} + +func (TransformRotateAbout) PrintKey() string { + return "transform" +} + +func (t TransformRotateAbout) PrintValue() (string, bool) { + return fmt.Sprintf("%q", t.PrintTransform()), true +} + +func (t TransformRotateAbout) PrintTransform() string { + return fmt.Sprintf("rotate(%v %v %v)", t.A, t.X, t.Y) +} + +func (TransformTranslate) PrintKey() string { + return "transform" +} + +func (t TransformTranslate) PrintValue() (string, bool) { + return fmt.Sprintf("%q", t.PrintTransform()), true +} + +func (t TransformTranslate) PrintTransform() string { + return fmt.Sprintf("translate(%v %v)", t.X, t.Y) +} + +func (TransformSkewX) PrintKey() string { + return "transform" +} + +func (t TransformSkewX) PrintValue() (string, bool) { + return fmt.Sprintf("%q", t.PrintTransform()), true +} + +func (t TransformSkewX) PrintTransform() string { + return fmt.Sprintf("skewX(%v)", float64(t)) +} + +func (TransformSkewY) PrintKey() string { + return "transform" +} + +func (t TransformSkewY) PrintValue() (string, bool) { + return fmt.Sprintf("%q", t.PrintTransform()), true +} + +func (t TransformSkewY) PrintTransform() string { + return fmt.Sprintf("skewY(%v)", float64(t)) +} + +func (TransformScale) PrintKey() string { + return "transform" +} + +func (t TransformScale) PrintValue() (string, bool) { + return fmt.Sprintf("%q", t.PrintTransform()), true +} + +func (t TransformScale) PrintTransform() string { + return fmt.Sprintf("scale(%v %v)", t.X, t.Y) +} diff --git a/server/ui/svg/vector_effect.go b/server/ui/svg/vector_effect.go new file mode 100644 index 0000000..8291a02 --- /dev/null +++ b/server/ui/svg/vector_effect.go @@ -0,0 +1,35 @@ +package svg + +type ( + VectorEffect int +) + +const ( + VectorEffectNone VectorEffect = iota + VectorEffectNonScalingStroke + // DO NOT USE - no browser support yet! + VectorEffectNonScalingSize + // DO NOT USE - no browser support yet! + VectorEffectNonRotation + // DO NOT USE - no browser support yet! + VectorEffectFixedPosition +) + +func (VectorEffect) PrintKey() string { + return "vector-effect" +} + +func (a VectorEffect) PrintValue() (string, bool) { + switch a { + case VectorEffectNonScalingStroke: + return `"non-scaling-stroke"`, true + case VectorEffectNonScalingSize: + return `"non-scaling-size"`, true + case VectorEffectNonRotation: + return `"non-rotation"`, true + case VectorEffectFixedPosition: + return `"fixed-position"`, true + default: + return `"none"`, true + } +} diff --git a/server/ui/svg/version.go b/server/ui/svg/version.go new file mode 100644 index 0000000..a30a391 --- /dev/null +++ b/server/ui/svg/version.go @@ -0,0 +1,18 @@ +package svg + +import "fmt" + +type ( + SVGVersion struct { + Major int + Minor int + } +) + +func (v SVGVersion) PrintKey() string { + return "version" +} + +func (v SVGVersion) PrintValue() (string, bool) { + return fmt.Sprintf(`"%d.%d"`, v.Major, v.Minor), true +} diff --git a/server/ui/svg/view_box.go b/server/ui/svg/view_box.go new file mode 100644 index 0000000..109798b --- /dev/null +++ b/server/ui/svg/view_box.go @@ -0,0 +1,20 @@ +package svg + +import ( + "fmt" +) + +type ( + ViewBox struct { + X, Y float64 + Width, Height float64 + } +) + +func (b ViewBox) PrintKey() string { + return "viewBox" +} + +func (b ViewBox) PrintValue() (string, bool) { + return fmt.Sprintf(`"%v %v %v %v"`, b.X, b.Y, b.Width, b.Height), true +} diff --git a/server/ui/svg/void_element.go b/server/ui/svg/void_element.go new file mode 100644 index 0000000..cb4100c --- /dev/null +++ b/server/ui/svg/void_element.go @@ -0,0 +1,42 @@ +package svg + +import ( + "bytes" + "fmt" + "io" + "strings" +) + +type ( + // VoidElement is like Element but for void elements + VoidElement[T Tag, A Attribute] struct { + Attributes []A + } +) + +func (e *VoidElement[T, A]) Attr(as ...A) *VoidElement[T, A] { + e.Attributes = append(e.Attributes, as...) + return e +} + +func (e VoidElement[T, A]) GetMarkup() string { + attrs := make([]string, len(e.Attributes)) + for i, a := range e.Attributes { + attrs[i] = PrintAttribute(a) + } + + return fmt.Sprintf( + `<%s %s/>`, + e.PrintTag(), + strings.Join(attrs, " "), + ) +} + +func (e VoidElement[T, A]) GetMarkupReader() io.Reader { + return bytes.NewReader([]byte(e.GetMarkup())) +} + +func (e VoidElement[T, _]) PrintTag() string { + var t T + return t.PrintTag() +} diff --git a/server/ui/svg/width.go b/server/ui/svg/width.go new file mode 100644 index 0000000..1448fa3 --- /dev/null +++ b/server/ui/svg/width.go @@ -0,0 +1,24 @@ +package svg + +type ( + Width struct { + *LengthAttr[WidthTag] + } + WidthP struct { + PercentageAttr[WidthTag] + } + + WidthTag struct{} +) + +func (l *Length) AsWidth() Width { + return Width{LengthAttr: (*LengthAttr[WidthTag])(l)} +} + +func (p Percentage) AsWidth() WidthP { + return WidthP{PercentageAttr: (PercentageAttr[WidthTag])(p)} +} + +func (WidthTag) PrintTag() string { + return "width" +} diff --git a/server/ui/svg/x.go b/server/ui/svg/x.go new file mode 100644 index 0000000..fbec903 --- /dev/null +++ b/server/ui/svg/x.go @@ -0,0 +1,24 @@ +package svg + +type ( + X struct { + *LengthAttr[XTag] + } + XP struct { + PercentageAttr[XTag] + } + + XTag struct{} +) + +func (l *Length) AsX() X { + return X{LengthAttr: (*LengthAttr[XTag])(l)} +} + +func (p Percentage) AsX() XP { + return XP{PercentageAttr: (PercentageAttr[XTag])(p)} +} + +func (XTag) PrintTag() string { + return "x" +} diff --git a/server/ui/svg/x1.go b/server/ui/svg/x1.go new file mode 100644 index 0000000..0738d3b --- /dev/null +++ b/server/ui/svg/x1.go @@ -0,0 +1,24 @@ +package svg + +type ( + X1 struct { + *LengthAttr[X1Tag] + } + X1P struct { + PercentageAttr[X1Tag] + } + + X1Tag struct{} +) + +func (l *Length) AsX1() X1 { + return X1{LengthAttr: (*LengthAttr[X1Tag])(l)} +} + +func (p Percentage) AsX1() X1P { + return X1P{PercentageAttr: (PercentageAttr[X1Tag])(p)} +} + +func (X1Tag) PrintTag() string { + return "x1" +} diff --git a/server/ui/svg/x2.go b/server/ui/svg/x2.go new file mode 100644 index 0000000..513db63 --- /dev/null +++ b/server/ui/svg/x2.go @@ -0,0 +1,24 @@ +package svg + +type ( + X2 struct { + *LengthAttr[X2Tag] + } + X2P struct { + PercentageAttr[X2Tag] + } + + X2Tag struct{} +) + +func (l *Length) AsX2() X2 { + return X2{LengthAttr: (*LengthAttr[X2Tag])(l)} +} + +func (p Percentage) AsX2() X2P { + return X2P{PercentageAttr: (PercentageAttr[X2Tag])(p)} +} + +func (X2Tag) PrintTag() string { + return "x2" +} diff --git a/server/ui/svg/xmlns.go b/server/ui/svg/xmlns.go new file mode 100644 index 0000000..27e622a --- /dev/null +++ b/server/ui/svg/xmlns.go @@ -0,0 +1,19 @@ +package svg + +import "fmt" + +const ( + XMLNSW32000SVG XMLNS = "http://www.w3.org/2000/svg" +) + +type ( + XMLNS string +) + +func (v XMLNS) PrintKey() string { + return "xmlns" +} + +func (v XMLNS) PrintValue() (string, bool) { + return fmt.Sprintf("%q", string(v)), true +} diff --git a/server/ui/svg/y.go b/server/ui/svg/y.go new file mode 100644 index 0000000..f5426cf --- /dev/null +++ b/server/ui/svg/y.go @@ -0,0 +1,24 @@ +package svg + +type ( + Y struct { + *LengthAttr[YTag] + } + YP struct { + PercentageAttr[YTag] + } + + YTag struct{} +) + +func (l *Length) AsY() Y { + return Y{LengthAttr: (*LengthAttr[YTag])(l)} +} + +func (p Percentage) AsY() YP { + return YP{PercentageAttr: (PercentageAttr[YTag])(p)} +} + +func (YTag) PrintTag() string { + return "y" +} diff --git a/server/ui/svg/y1.go b/server/ui/svg/y1.go new file mode 100644 index 0000000..e1abd46 --- /dev/null +++ b/server/ui/svg/y1.go @@ -0,0 +1,24 @@ +package svg + +type ( + Y1 struct { + *LengthAttr[Y1Tag] + } + Y1P struct { + PercentageAttr[Y1Tag] + } + + Y1Tag struct{} +) + +func (l *Length) AsY1() Y1 { + return Y1{LengthAttr: (*LengthAttr[Y1Tag])(l)} +} + +func (p Percentage) AsY1() Y1P { + return Y1P{PercentageAttr: (PercentageAttr[Y1Tag])(p)} +} + +func (Y1Tag) PrintTag() string { + return "y1" +} diff --git a/server/ui/svg/y2.go b/server/ui/svg/y2.go new file mode 100644 index 0000000..e1b5945 --- /dev/null +++ b/server/ui/svg/y2.go @@ -0,0 +1,24 @@ +package svg + +type ( + Y2 struct { + *LengthAttr[Y2Tag] + } + Y2P struct { + PercentageAttr[Y2Tag] + } + + Y2Tag struct{} +) + +func (l *Length) AsY2() Y2 { + return Y2{LengthAttr: (*LengthAttr[Y2Tag])(l)} +} + +func (p Percentage) AsY2() Y2P { + return Y2P{PercentageAttr: (PercentageAttr[Y2Tag])(p)} +} + +func (Y2Tag) PrintTag() string { + return "y2" +} diff --git a/styles/index.css b/styles/index.css index 0599e09..4736b44 100644 --- a/styles/index.css +++ b/styles/index.css @@ -274,9 +274,6 @@ .my-\[0\.5em\] { margin-block: 0.5em; } - .my-\[1em\] { - margin-block: 1em; - } .my-\[1rem\] { margin-block: 1rem; } @@ -334,9 +331,18 @@ .h-\[2rem\] { height: 2rem; } + .h-\[30em\] { + height: 30em; + } + .h-\[min\(100cqw\,75vh\)\] { + height: min(100cqw, 75vh); + } .h-fit { height: fit-content; } + .h-full { + height: 100%; + } .max-h-\[50vh\] { max-height: 50vh; } @@ -388,6 +394,9 @@ .basis-full { flex-basis: 100%; } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } .animate-pulse { animation: var(--animate-pulse); } @@ -498,6 +507,9 @@ .border-\[gray\] { border-color: gray; } + .border-black { + border-color: var(--color-black); + } .border-border { border-color: var(--border); } @@ -522,6 +534,18 @@ .bg-card { background-color: var(--card); } + .fill-\[pink\] { + fill: pink; + } + .fill-accent { + fill: var(--accent); + } + .fill-white { + fill: var(--color-white); + } + .stroke-2 { + stroke-width: 2; + } .p-\[0\.5em\] { padding: 0.5em; } @@ -540,9 +564,6 @@ .py-\[1em\] { padding-block: 1em; } - .py-\[2em\] { - padding-block: 2em; - } .pt-\[1em\] { padding-top: 1em; } @@ -579,6 +600,9 @@ .text-\[1\.5em\] { font-size: 1.5em; } + .text-\[4px\] { + font-size: 4px; + } .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); @@ -619,6 +643,24 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .not-group-focus-within\:hidden { + &:not(*:is(:where(.group):focus-within *)) { + display: none; + } + } + .not-group-hover\:hidden { + &:not(*:is(:where(.group):hover *)) { + display: none; + } + @media not (hover: hover) { + display: none; + } + } + .not-group-focus\:hidden { + &:not(*:is(:where(.group):focus *)) { + display: none; + } + } .not-open\:mb-\[1em\] { &:not(*:is([open], :popover-open, :open)) { margin-bottom: 1em; @@ -1178,6 +1220,26 @@ } } } +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} @property --tw-border-style { syntax: "*"; inherits: false; @@ -1260,26 +1322,6 @@ inherits: false; initial-value: 1; } -@property --tw-rotate-x { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-y { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-z { - syntax: "*"; - inherits: false; -} -@property --tw-skew-x { - syntax: "*"; - inherits: false; -} -@property --tw-skew-y { - syntax: "*"; - inherits: false; -} @property --tw-content { syntax: "*"; initial-value: ""; @@ -1297,6 +1339,11 @@ @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; --tw-border-style: solid; --tw-font-weight: initial; --tw-outline-style: solid; @@ -1316,11 +1363,6 @@ --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; - --tw-rotate-x: initial; - --tw-rotate-y: initial; - --tw-rotate-z: initial; - --tw-skew-x: initial; - --tw-skew-y: initial; --tw-content: ""; --tw-leading: initial; } diff --git a/styles/source.css b/styles/source.css index bb1036c..1f37a0d 100644 --- a/styles/source.css +++ b/styles/source.css @@ -17,3 +17,7 @@ @import "./components.css"; @source inline("group-open/sync-group:after:content-['-']"); @source inline("not-open:mb-[1em]"); +@source inline("not-group-hover:hidden"); +@source inline("not-group-hover:focus-within"); +@source inline("hover:fill-pink"); +@source inline("fill-[pink]"); diff --git a/templates/layout.html.tmpl b/templates/layout.html.tmpl index f7e7967..4bfc7e7 100644 --- a/templates/layout.html.tmpl +++ b/templates/layout.html.tmpl @@ -24,6 +24,7 @@ +
  • diff --git a/templates/pages/accounts/{acctID.int64}/reports/mock.html.tmpl b/templates/pages/accounts/{acctID.int64}/reports/mock.html.tmpl index 39f0681..84a40aa 100644 --- a/templates/pages/accounts/{acctID.int64}/reports/mock.html.tmpl +++ b/templates/pages/accounts/{acctID.int64}/reports/mock.html.tmpl @@ -1,8 +1,12 @@ {{- $acctID := .Identity.Account.AccountID }} {{- $platform := .Platform }} +{{- $parsedPlatform := "" }} {{- if not $platform }} {{- $platform = .Request.URL.Query.Get "platform" }} + {{- if $platform }} + {{- $parsedPlatform = (parsePlatform $platform) }} + {{- end }} {{- end }} {{- $shopID := .ShopID }} @@ -10,6 +14,15 @@ {{- $shopID = .Request.URL.Query.Get "shop-id" }} {{- end }} +{{- $selectedShop := "" }} + +{{- $listingID := .ListingID }} +{{- if not $listingID }} + {{- $listingID = .Request.URL.Query.Get "listing-id" }} +{{- end }} + +{{- $selectedListing := "" }} + {{/* sse handler */}}
    +{{/* shop selector */}} @@ -37,8 +51,20 @@ hx-push-url="true" hx-target="body" > -{{- range $shop := .Accounts.GetMockShops $acctID }} - {{- $isSelected := and (eq $platform $shop.Platform) (eq $shopID $shop.ShopID) }} +{{- $shops := .Accounts.GetMockShops $acctID }} +{{- $shopOptionIsSelected := false }} +{{- range $shop := $shops }} + {{- $shopOptionIsSelected = and (eq $parsedPlatform $shop.Platform) (eq $shopID $shop.ShopID) }} + {{- if $shopOptionIsSelected }} + {{ $selectedShop = $shop }} + {{ break }} + {{- end }} +{{- end }} + +{{- range $shop := $shops }} + {{- $isSelected := and (eq $parsedPlatform $shop.Platform) (eq $shopID $shop.ShopID) }}