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 0000000..70dd800 Binary files /dev/null and b/favicon/old-icons/android-chrome-192x192.png differ diff --git a/favicon/old-icons/android-chrome-512x512.png b/favicon/old-icons/android-chrome-512x512.png new file mode 100644 index 0000000..e3faae0 Binary files /dev/null and b/favicon/old-icons/android-chrome-512x512.png differ diff --git a/favicon/old-icons/apple-touch-icon.png b/favicon/old-icons/apple-touch-icon.png new file mode 100644 index 0000000..cf75fab Binary files /dev/null and b/favicon/old-icons/apple-touch-icon.png differ diff --git a/favicon/old-icons/favicon-16x16.png b/favicon/old-icons/favicon-16x16.png new file mode 100644 index 0000000..a27391f Binary files /dev/null and b/favicon/old-icons/favicon-16x16.png differ diff --git a/favicon/old-icons/favicon-32x32.png b/favicon/old-icons/favicon-32x32.png new file mode 100644 index 0000000..a6368fe Binary files /dev/null and b/favicon/old-icons/favicon-32x32.png differ diff --git a/favicon/old-icons/favicon.ico b/favicon/old-icons/favicon.ico new file mode 100644 index 0000000..c5369b7 Binary files /dev/null and b/favicon/old-icons/favicon.ico differ 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 0000000..33f815d Binary files /dev/null and b/favicon/sync-download/android-chrome-192x192.png differ diff --git a/favicon/sync-download/android-chrome-512x512.png b/favicon/sync-download/android-chrome-512x512.png new file mode 100644 index 0000000..a725830 Binary files /dev/null and b/favicon/sync-download/android-chrome-512x512.png differ diff --git a/favicon/sync-download/apple-touch-icon.png b/favicon/sync-download/apple-touch-icon.png new file mode 100644 index 0000000..1c51855 Binary files /dev/null and b/favicon/sync-download/apple-touch-icon.png differ diff --git a/favicon/sync-download/favicon-16x16.png b/favicon/sync-download/favicon-16x16.png new file mode 100644 index 0000000..c29f69f Binary files /dev/null and b/favicon/sync-download/favicon-16x16.png differ diff --git a/favicon/sync-download/favicon-32x32.png b/favicon/sync-download/favicon-32x32.png new file mode 100644 index 0000000..5245ee0 Binary files /dev/null and b/favicon/sync-download/favicon-32x32.png differ diff --git a/favicon/sync-download/favicon.ico b/favicon/sync-download/favicon.ico new file mode 100644 index 0000000..1c0510a Binary files /dev/null and b/favicon/sync-download/favicon.ico differ 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 0000000..9d05283 Binary files /dev/null and b/favicon/sync-icons/sync-128x128.png differ diff --git a/favicon/sync-icons/sync-16x16.png b/favicon/sync-icons/sync-16x16.png new file mode 100644 index 0000000..608166e Binary files /dev/null and b/favicon/sync-icons/sync-16x16.png differ diff --git a/favicon/sync-icons/sync-192x192.png b/favicon/sync-icons/sync-192x192.png new file mode 100644 index 0000000..eaebfef Binary files /dev/null and b/favicon/sync-icons/sync-192x192.png differ diff --git a/favicon/sync-icons/sync-24x24.png b/favicon/sync-icons/sync-24x24.png new file mode 100644 index 0000000..fc93cc1 Binary files /dev/null and b/favicon/sync-icons/sync-24x24.png differ diff --git a/favicon/sync-icons/sync-256x256.png b/favicon/sync-icons/sync-256x256.png new file mode 100644 index 0000000..75ff55c Binary files /dev/null and b/favicon/sync-icons/sync-256x256.png differ diff --git a/favicon/sync-icons/sync-32x32.png b/favicon/sync-icons/sync-32x32.png new file mode 100644 index 0000000..d00e455 Binary files /dev/null and b/favicon/sync-icons/sync-32x32.png differ diff --git a/favicon/sync-icons/sync-512x512.png b/favicon/sync-icons/sync-512x512.png new file mode 100644 index 0000000..c3170c5 Binary files /dev/null and b/favicon/sync-icons/sync-512x512.png differ diff --git a/favicon/sync-icons/sync-64x64.png b/favicon/sync-icons/sync-64x64.png new file mode 100644 index 0000000..5c4f0df Binary files /dev/null and b/favicon/sync-icons/sync-64x64.png differ 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%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("")) + e.buf.WriteString(e.element.PrintTag()) + e.buf.WriteByte('>') + } + + 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