For the last 18 months I've been quietly building BrikPanel — a 100% free WooCommerce admin dashboard that tries to do what Metorik does, without the $50–$200/month bill. No premium tier, no "pro" upsell, no locked features — the entire plugin is GPL‑2.0+ on the official WordPress.org repository. It's installed on roughly 1,000 client stores through my agency work, and shipping into that many real production environments has taught me a lot about what WooCommerce gets right, what it gets wrong, and where third‑party plugins quietly break things.
This post is the technical brain dump: the architecture decisions, the libraries I picked (and the ones I rejected), the database design, and a few gotchas I wish someone had written down for me before I started.
👉 Install BrikPanel from WordPress.org — fully free, no signup, no API key.
The problem
The default WooCommerce admin is… fine. It works. But the moment a real store owner logs in, they want answers to four questions:
- How much did I make today vs. yesterday?
- Who is on my site right now?
- Which products are actually moving?
- Where are my orders coming from — organic? Trendyol? An influencer link?
Metorik answers all of these beautifully. But Metorik is SaaS, it's expensive for a KOBİ (small/medium business), and a lot of my clients in Turkey simply won't pay monthly USD bills for a dashboard. So I started building what I thought would be a weekend plugin. 18 months later it has ~5,500 lines of code in the main analytics modules alone, custom DB tables, 3D globe rendering, and a dual code path for HPOS.
Here's how it's put together.
1. The HPOS dual code path — the single biggest decision
WooCommerce 8.2 introduced HPOS (High‑Performance Order Storage). Instead of storing every order as a wp_posts row + 30 wp_postmeta rows, it stores them in dedicated wp_wc_orders / wp_wc_order_addresses / wp_wc_order_product_lookup tables. It is dramatically faster and structurally saner.
But here's the catch: most stores in the wild are still on legacy storage, or they have HPOS enabled with sync mode on, or they're mid‑migration. If you target only HPOS, you break thousands of installs. If you target only legacy, you ship a plugin that's already obsolete.
So every analytics query in BrikPanel has two implementations, and they're chosen at runtime:
private function is_hpos() {
if ( $this->is_hpos === null ) {
$this->is_hpos = get_option( 'woocommerce_custom_orders_table_enabled' ) === 'yes';
}
return $this->is_hpos;
}
And then every query branches:
if ( $is_hpos ) {
$query = $wpdb->prepare(
"SELECT p.product_id, SUM(p.product_qty) AS total_sold
FROM {$wpdb->prefix}wc_order_product_lookup p
INNER JOIN {$wpdb->prefix}wc_orders o ON p.order_id = o.id
WHERE o.status IN ({$status_placeholders}){$admin_sql}
AND o.date_created_gmt >= %s AND o.date_created_gmt <= %s
GROUP BY p.product_id ORDER BY total_sold DESC LIMIT 5",
$query_args
);
} else {
// Legacy: posts + postmeta + woocommerce_order_items + woocommerce_order_itemmeta
$query = $wpdb->prepare( "SELECT m2.meta_value AS product_id, SUM(m1.meta_value) ...", $query_args );
}
The legacy version is uglier and slower (four joins to get a product ID and a quantity, because everything lives in EAV‑style postmeta), but it has to exist. I also had to declare HPOS compatibility explicitly so WooCommerce doesn't show the scary "incompatible plugin" warning:
add_action('before_woocommerce_init', function () {
if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables', __FILE__, true
);
}
});
Lesson: if you're building anything that touches orders today, write the HPOS version first, then write the legacy version as a else branch. Don't try to retrofit HPOS later — your query shapes will be wrong.
2. Why I built custom DB tables instead of stuffing everything into wp_options
This is the mistake I see in 80% of analytics plugins on the WP repository. They store visitor counts, page views, and cart events as serialized arrays in wp_options. It "works" until the option grows past a few MB, at which point every page load loads it into memory and the site dies.
I created three dedicated tables on activation:
$sql_visitors = "CREATE TABLE $visitors_table (
id BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
date_column DATE NOT NULL,
visitor_count INT DEFAULT 0,
product_count INT DEFAULT 0,
add_to_cart_count INT DEFAULT 0,
checkout_count INT DEFAULT 0,
KEY idx_date (date_column)
) $charset_collate;";
$sql_cart_tracking = "CREATE TABLE $cart_tracking_table (
id BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id BIGINT(20) UNSIGNED NOT NULL,
cart_count INT DEFAULT 0,
date_column DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY product_id (product_id),
KEY idx_date (date_column),
KEY idx_product_date (product_id, date_column)
) $charset_collate;";
A few things worth pointing out:
-
Composite indexes matter.
idx_product_date (product_id, date_column)is what makes "show me top added‑to‑cart products in the last 30 days" run in milliseconds instead of seconds. The order of columns in a composite index has to match how you actually filter. -
DATEfor daily aggregates,DATETIMEfor events. Visitor counts are pre‑aggregated per day (one row per day, ever). Cart events are stored per‑event because we need precise timestamps for funnel analysis. -
dbDelta()instead of rawCREATE TABLE. dbDelta is finicky (it requires double spaces in some places, no parentheses onKEYdefinitions, etc.) but it handles upgrades correctly when you change the schema in a future version.
This means even on a store doing 10k visitors/day, the visitor table grows by one row per day. After five years it has 1,825 rows. Compare that to the "serialized array in options" approach where the same data would be a 50MB blob loaded on every request.
3. Live visitors with sendBeacon and transients (not cron, not WebSockets)
The "live visitors" widget was the feature clients asked for the most. Everyone wants a Shopify‑style "12 people on your site right now" widget. The naive implementations all suck:
- Cron polling — too slow, you get 10‑minute‑old data.
- WebSockets — would require a separate Node process; not shippable as a drop‑in WP plugin.
- Database row per ping — kills the DB on busy stores.
What I landed on:
Storage: a single transient (brikpanel_live_visitors) holding an associative array keyed by visitor cookie ID. Transients live in the object cache if Redis/Memcached is enabled, otherwise in wp_options — but capped to ~75 seconds of data, so it never bloats.
Client‑side: a 30‑second fetch ping with keepalive: true, plus a pagehide handler that fires navigator.sendBeacon for the explicit "user left" signal:
function sendExitSignal() {
const data = new URLSearchParams();
data.append('action', 'brikpanel_track_live_visitor');
data.append('page_url', window.location.href);
data.append('is_exit', 'true');
if (navigator.sendBeacon) {
navigator.sendBeacon(endpoint, data);
} else {
fetch(endpoint, { method: 'POST', body: data, keepalive: true }).catch(() => {});
}
}
window.addEventListener("pagehide", sendExitSignal);
sendBeacon is the unsung hero here. It's the only browser API that guarantees the request will be delivered even as the page is being torn down. fetch({ keepalive: true }) mostly works but isn't universally reliable.
Server‑side cleanup: on every ping, I prune entries older than BRIKPANEL_VISITOR_TIMEOUT (75s = 2.5× the ping interval, so a single dropped packet doesn't kick a real visitor off the list):
$limit_time = time() - BRIKPANEL_VISITOR_TIMEOUT;
foreach ( $visitors as $vid => $data ) {
if ( $data['last_active'] < $limit_time ) {
unset( $visitors[ $vid ] );
}
}
set_transient( 'brikpanel_live_visitors', $visitors, 120 );
Polling pause: the admin dashboard stops polling when the tab is hidden:
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') {
stopLivePolling();
} else {
startLivePolling();
fetchDashboardData();
}
});
This is the kind of detail that nobody notices until they leave a tab open overnight and wake up to 8,000 wasted AJAX requests.
4. Why Cobe.js for the globe and Chart.js for charts
I auditioned a lot of libraries. Here's what I picked and why.
Cobe.js (~6KB) for the 3D order locations globe
I wanted that "Stripe homepage" interactive globe that shows where orders are coming from. The contenders:
- Three.js — 600KB+, way too heavy for an admin widget.
- D3 + d3-geo + d3-geo-projection — beautiful, but you have to ship country GeoJSON, which is 200–500KB depending on resolution.
- globe.gl — wraps Three.js, still huge.
- Cobe — WebGL, ships in ~6KB, no external assets, GPU‑rendered. Won.
Cobe gives me a smooth rotating globe with marker dots for each city, and the entire library is smaller than a single PNG would have been.
Chart.js for everything 2D
This was less of a contest. The alternatives:
- D3 — too low‑level for the use case. I don't need bespoke visualizations, I need a line chart that doesn't break.
- ECharts — fantastic, but ~900KB minified. Overkill.
- ApexCharts — nice API but the bundle is heavy and the legacy SVG renderer is slow with lots of points.
- Chart.js — Canvas‑based (fast even with hundreds of points), tree‑shakeable, ~70KB, the API is mature, and I can override defaults globally to match my design system in three lines:
Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
Chart.defaults.font.size = 12;
Chart.defaults.color = '#616161';
Flatpickr for the date range picker
WordPress ships jQuery UI Datepicker by default and it looks like it was designed in 2008. Flatpickr is ~17KB, supports range mode out of the box, has zero dependencies, and looks modern. No contest.
Total third‑party JS payload for the dashboard: Chart.js + Cobe + Flatpickr ≈ 95KB. That's roughly one product image.
5. The "batch endpoint vs. many endpoints" decision
The dashboard has 15+ widgets: total sales, orders count, AOV, visitors, conversion rate, sales over time chart, conversion funnel chart, order status rates, top products, top countries, top cities, recent orders, most viewed pages, most added to cart, live visitors.
The lazy approach: 15 separate AJAX endpoints, each fetching one widget. The dashboard makes 15 requests on load.
The right approach for an admin dashboard: one batch endpoint that runs all the queries on the server side and returns one JSON blob. Everything except live visitors goes through a single wp_ajax_brikpanel_dashboard_data action:
wp_send_json_success( [
'total_sales' => wc_price( $total_sales ),
'order_count' => $order_count,
'aov' => wc_price( $aov ),
'visitor_count' => $visitor_count,
'conversion_rate' => $conversion,
'funnel' => [ /* … */ ],
'order_rates' => $order_rates,
'top_products' => $top_products,
'sales_over_time' => $sales_over_time,
'recent_orders' => $recent_orders,
'order_locations' => $order_locations,
'deltas' => $deltas,
] );
Live visitors stay separate because they poll on a different cadence (every 10s instead of on date‑range change). One endpoint for "rare expensive batch", one endpoint for "frequent cheap poll". This is the sweet spot.
6. Period‑over‑period deltas, done right
Every store owner wants "today vs. yesterday, +12.4% ↑". The math is trivial; the gotcha is edge cases:
private function calc_delta( $current, $previous ) {
if ( $previous == 0 && $current == 0 ) {
return 0;
}
if ( $previous == 0 ) {
return 100; // can't divide by zero — show 100% as "new"
}
return round( ( ( $current - $previous ) / $previous ) * 100, 1 );
}
The "previous period" is calculated as the same span immediately before the current one. If you're looking at the last 7 days, the comparison is the 7 days before that. Sounds obvious, but I've seen plugins that compare "last 7 days" to "the same 7 days last year", which is meaningless for a small store.
I also had to deal with timezone hell. WooCommerce stores all order dates in GMT (date_created_gmt). The visitor table stores local dates because that's what the user sees ("today's visitors" should mean today in Istanbul, not today in UTC). So the date calculation does both:
$start_local = wp_date( 'Y-m-d 00:00:00' );
$end_local = wp_date( 'Y-m-d 23:59:59' );
$start_gmt = get_gmt_from_date( $start_local );
$end_gmt = get_gmt_from_date( $end_local );
Order queries use *_gmt, visitor queries use *_local. Mixing them up gives you the kind of off‑by‑one bug that only shows up at 11 PM Istanbul time and then disappears at midnight. Don't ask me how I know.
7. Excluding admin orders from analytics (the boring detail that matters)
Every dev who tests their own store places test orders. Every store owner places test orders. If you don't filter them out, the dashboard reports wildly inflated numbers on day one and the store owner concludes the plugin is broken.
function brikpanel_admin_order_exclusion_sql( $hpos, $id_column = '' ) {
$admin_ids = brikpanel_get_admin_user_ids();
if ( empty( $admin_ids ) ) {
return [ 'sql' => '', 'args' => [] ];
}
$placeholders = implode( ', ', array_fill( 0, count( $admin_ids ), '%d' ) );
if ( $hpos ) {
return [
'sql' => " AND customer_id NOT IN ({$placeholders})",
'args' => $admin_ids,
];
}
global $wpdb;
$col = $id_column ?: 'ID';
return [
'sql' => " AND {$col} NOT IN (SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_customer_user' AND meta_value IN ({$placeholders}))",
'args' => $admin_ids,
];
}
The admin user list is cached in object cache for 5 minutes, so this isn't a per‑query lookup:
function brikpanel_get_admin_user_ids() {
$cached = wp_cache_get( 'brikpanel_admin_user_ids' );
if ( false !== $cached ) return $cached;
$admins = get_users( [ 'capability' => 'manage_options', 'fields' => 'ID' ] );
$admin_ids = array_map( 'intval', $admins );
wp_cache_set( 'brikpanel_admin_user_ids', $admin_ids, '', 300 );
return $admin_ids;
}
8. Order source detection across marketplaces
Most of my client base is in Turkey, where stores often sell on Trendyol, Hepsiburada, N11, and Amazon simultaneously, syncing back into WooCommerce via marketplace plugins. Knowing which orders came from which channel is huge.
There's no standard for this — every marketplace plugin uses its own meta key. I just hard‑coded a priority list:
$marketplace_keys = [
'_amz_order_id' => [ 'id' => 'amazon', 'label' => 'Amazon', 'color' => '#ff9900' ],
'_brksoft_trendyol_order_number' => [ 'id' => 'trendyol', 'label' => 'Trendyol', 'color' => '#f27a1a' ],
'_ty_order_number' => [ 'id' => 'trendyol', 'label' => 'Trendyol', 'color' => '#f27a1a' ],
'_hb_order_number' => [ 'id' => 'hepsiburada', 'label' => 'Hepsiburada', 'color' => '#ff6000' ],
'_n11_order_id' => [ 'id' => 'n11', 'label' => 'N11', 'color' => '#00b900' ],
'_ozon_posting_number' => [ 'id' => 'ozon', 'label' => 'Ozon', 'color' => '#005bff' ],
];
foreach ( $marketplace_keys as $meta_key => $config ) {
if ( ! empty( $order->get_meta( $meta_key ) ) ) {
return [ 'type' => 'marketplace', /* … */ ];
}
}
If none of those match, fall back to WC Order Attribution (WooCommerce 8.4+), which captures _wc_order_attribution_source_type (organic / referral / utm / typein / admin). That gives reasonable coverage for the rest of the orders.
It's not glamorous, but it's the kind of code that makes a store owner go "oh wow, it knows which orders are from Trendyol!"
9. i18n from day one — even if you only speak two languages
I wrote the plugin in English (the codebase, the strings, everything) and ran it through WordPress's standard __() / esc_html_e() functions with a brikpanel text domain. Then I localize it for the JS side via wp_localize_script:
wp_localize_script('brikpanel_dashboard_scripts', 'brikpanelDashboard', [
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('brikpanel_dashboard_nonce'),
'currency' => get_woocommerce_currency_symbol(),
'i18n' => [
'revenue' => __('Revenue', 'brikpanel'),
'orders' => __('Orders', 'brikpanel'),
// …
],
]);
Doing this from day one means the same plugin works on a Turkish store, an English store, and a German store with zero code changes — just a .po file. It also forces you to write English‑first code, which is a massive quality multiplier when you're working with collaborators or AI assistants. (Turkish comments in PHP that talk about gmdate() vs wp_date() confuse everyone, including future me.)
10. Security: nonces, capabilities, and $wpdb->prepare everywhere
This isn't glamorous either, but it's where most WP plugins fall down. Every AJAX handler in BrikPanel does three things, in this order:
public function ajax_dashboard_data() {
if ( ! check_ajax_referer( 'brikpanel_dashboard_nonce', 'security', false ) ) {
wp_send_json_error( [ 'message' => 'Invalid nonce.' ] );
wp_die();
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized.' ] );
wp_die();
}
$range = isset( $_POST['range'] ) ? sanitize_key( $_POST['range'] ) : 'today';
// …
}
- Nonce check — proves the request came from a legit admin page, not CSRF.
-
Capability check —
manage_woocommercefor analytics,manage_optionsfor live visitor data. Neveris_admin(), which only checks if you're on an admin page, not whether you're authorized to be. -
$wpdb->preparewith placeholders — every single SQL query, no exceptions. String interpolation into SQL is the #1 way WP plugins get owned.
The cost of doing this consistently is small. The cost of not doing it once is a CVE entry with your plugin slug in it. Worth it.
What I'd do differently
If I were starting over today:
- HPOS first, legacy as a fallback — I built it the other way around and had to refactor every query.
-
Custom tables from day one — I started with
wp_optionsarrays for visitor counts, hit the wall around 10k stores, migrated. Painful. - One batch endpoint, not 15 — same story. Started with 15, consolidated.
- Cobe.js immediately — I tried Three.js first, deleted ~400KB of bundle when I switched.
- English‑first, i18n‑first — non‑negotiable. Even if you only ship in one language.
What I learned about WooCommerce as a platform
WooCommerce is enormous, inconsistent, and it absolutely makes sense why it dominates self‑hosted ecommerce. You get a gigantic API surface for free: products, orders, customers, taxes, shipping, coupons, attribution, marketplaces, the works. You also get a 15‑year‑old codebase with two parallel data models, dozens of legacy meta keys, and the kind of edge cases that only emerge after a few thousand real installs.
If you're thinking about building something on top of it: assume nothing, test on real stores early, and always write the HPOS path first.
Try it
The whole plugin is GPL‑2.0+ and 100% free — there is no premium version, no paid add‑on, and no feature gated behind a license key. Everything in this post (HPOS dual queries, custom DB tables, the live globe, marketplace detection, the batch endpoint) ships in the free download. If you find a bug or want a feature, the support forum on WordPress.org is the fastest way to reach me.
Thanks for reading. If anything in this post saved you from a mistake I already made, that's a win for both of us.
Top comments (0)