Throw-Yo Marketing: Building a Custom BI Dashboard for a One-Person Yo-Yo String Brand
A custom WordPress admin plugin that unifies GA4, WooCommerce, and YNAB into a single business intelligence dashboard. Built because the tools that do this commercially cost more than the business earns in a month. Twenty-three reporting sections covering traffic, sales, profit, and taxes, with no third-party dependencies and no recurring fees.
I run Throw-Yo on the side. It's a one-person handmade yo-yo string brand: I mix the materials, wind every string by hand, package the orders, ship them, and answer the support email. I also do the books.
The data I needed to run the business sensibly lived in three places that didn't talk to each other:
- Google Analytics 4 told me where traffic came from but had no idea what I sold.
- WooCommerce told me what I sold but had no idea where the buyer came from once they crossed the checkout boundary.
- YNAB held the truth about money: what I actually spent on raw materials, what I'd drawn as profit, what Patreon had paid me this month.
The third-party tools that stitch this together start at "more than a handmade yo-yo string brand earns in a month" and climb from there. So I built the plugin.
What It Is
Throw-Yo Marketing is a custom WordPress admin plugin available at WP Admin → TY Marketing. Five tabs — Traffic, Sales, Customers, Financials, Orders — with 23 sections total, plus summary cards that stay visible (Sessions, Total Revenue, Orders, Avg. Order Value, Returning Revenue Share, Sampler Conversion Rate). PHP 8.4, jQuery, no build step, no Composer dependencies for the core logic. Charts are pure HTML and CSS because canvas elements don't render correctly inside hidden tab panels, and I wasn't going to fight that.
It pulls from GA4, WooCommerce, and YNAB, caches everything through WordPress transients, and gives me the answer to the only question that actually matters: is this business making money, and if so, from whom.
A Few Problems Worth Talking About
GA4 Auth Without the Google Client Library
The GA4 Data API needs a service account JWT signed with RS256. The official path is google/apiclient, which drags in Guzzle, a slab of polyfills, and an opinion about your autoloader. For a single API call, I didn't want any of that.
So I sign the JWT directly:
openssl_sign($signing_input, $signature, $private_key, OPENSSL_ALGO_SHA256);
Build the header and claim set, base64url-encode them, sign, exchange the JWT at the OAuth2 token endpoint, and cache the access token until expiry. Roughly forty lines of code instead of a hundred files.
The HPOS Attribution Maze
WooCommerce is mid-migration to High Performance Order Storage. Real stores in the wild are in one of three states, and the plugin has to handle all of them:
- HPOS enabled, with the
wc_order_attributiontable — modern installs that have run the attribution plugin. - HPOS enabled, without that table — UTM source is buried in
wc_orders_metaunder the_wc_order_attribution_*keys. - Legacy
wp_posts— UTM source lives inwp_postmeta, same key pattern, different table.
Every query in the plugin branches on OrderUtil::custom_orders_table_usage_is_enabled() and goes down a different path. get_woo_attribution() has three code paths underneath one signature. It is not pretty. It is correct, which is more important.
The Taxonomy Meta Detour
Throw-Yo organizes products into four families — Comfort, Performance, Responsive, Special — and I wanted "revenue by family" as a dashboard section. The family is an ACF field called ty_family attached to the product_cat taxonomy.
The obvious query is "join wp_postmeta, group by family." The obvious query is wrong. ACF fields on taxonomies are stored in wp_termmeta, not wp_postmeta. So the actual join goes:
wp_posts (or wp_wc_orders)
→ wp_woocommerce_order_items
→ wp_woocommerce_order_itemmeta (_product_id)
→ wp_term_relationships
→ wp_term_taxonomy (filter on product_cat)
→ wp_termmeta (key = ty_family)
Six joins for a chart that says "Performance: 41%." Worth it.
The Lying Transaction
YNAB has a clean API and one quiet sharp edge. I track business profit by drawing from a "Profit" category — a transfer from the business account to my personal account, categorized so I know how much I've paid myself this year. I also track Patreon income as inflows on the same account.
My first version scanned transactions for outflows from the Profit category. It always read low. The reason: YNAB's API strips category_name from transfer transactions. Transfers are special — they have a transfer_account_id and the category is conceptually attached to the budget side, not the transaction side, so it comes back null. Scanning transactions misses every profit draw that's also a transfer, which is all of them.
The fix was to stop scanning transactions and start reading the category directly. The /categories endpoint returns an activity field that is the truth: cumulative outflow for the period, transfers included. Same logic for Patreon: one API call pulls inflows by payee alongside expenses, no second request needed.
$profit_drawn = abs($profit_category['activity']) / 1000;
YNAB stores currency as integer milliunits, hence the divide. I lost an evening to that one before I read the API docs slowly enough.
The Mobile Facebook Problem
GA4's referrer table was reporting m.facebook.com, l.facebook.com, and lm.facebook.com as three separate sources, each with maybe four sessions. Together, they were a meaningful channel. Apart, they were noise that fell below my chart's row limit and disappeared.
A small normalizer collapses any *.facebook.com (and the equivalent for a few other platforms) to the root before grouping. Same idea for the bot traffic: a hardcoded country filter on every GA4 request strips CN and SG, which were inflating session counts with traffic that never reached the cart. Crude, effective, documented in a comment so future-me knows why.
What It Actually Does for the Business
The dashboard answers the questions I used to answer with three browser tabs and a spreadsheet:
Traffic tab: Where visitors come from (GA4 channels), which referring sites matter, how many actually buy, device-level checkout drop-off rates.
Sales tab: Which products and colorways drive revenue, family-level performance (Comfort vs Performance vs Responsive), pack size splits, order timing patterns, current abandoned carts, coupon effectiveness by prefix group.
Customers tab: Sampler-to-full-customer conversion funnel, lifetime value, new vs returning revenue week over week, which product each customer bought first.
Financials tab: Shipping costs, Stripe fees, YNAB expenses by category, Patreon income, actual profit after materials and taxes, quarterly tax liability with Schedule C waterfall and SE tax offset pre-calculated.
Orders tab: WooCommerce order list with tracking, plus a Custom Orders module for off-platform commissions — full CRUD over AJAX, stored in wp_options, invoice generation and sending, propagated into every financial total, so the books match reality and not just what Stripe knows about.
Supporting modules:
- Batch Coupons — generate groups of single-use coupons under a prefix, print coupon cards, track usage per group.
- Invoices — create and send custom order invoices to customers, mark paid, print for records.
And one feature I use more than I expected: Export for Claude. A button that serializes the entire dashboard state into structured markdown. Paste it into a Claude conversation and ask "what's changed this month and what should I do about it." It turns the dashboard from a thing I look at into a thing I can think out loud with.
What I'd Do Differently
The invoices module taught me that WordPress wp_options As a lightweight data store works fine for domain-specific JSON until you hit maybe 100 records. After that, a custom table makes more sense. I'd plan for that upfront next time instead of refactoring the storage layer after adding features on top of it.
Past that: it does what I need it to do, it costs nothing to run, and it's saved me from buying analytics software that would have been overkill for a business where I am the entire org chart. It also gave me a sandbox to practice the GA4 auth pattern, the HPOS maze, and cross-database correlation — skills that have proved useful in client work.