diff --git a/docs/metrics/customers/customer_count.yml b/docs/metrics/customers/customer_count.yml new file mode 100644 index 0000000..df127f5 --- /dev/null +++ b/docs/metrics/customers/customer_count.yml @@ -0,0 +1,39 @@ +- name: customer_count + display_name: Customer Count + category: customers + type: count_distinct + unit: customers + grain: monthly + time_column: created_at + table: customers + expression: "COUNT(DISTINCT customer_id)" + description: "Total number of unique customers. Tracks customer base growth over time. Counts distinct customer records based on registration date." + dimensions: + - segment + - region + - acquisition_channel + notes: + - "Counts only active customers (not deleted or merged)" + - "A customer is counted in the month of their first registration" + - "Segment is assigned based on lifetime spend thresholds" + synonyms: + - total_customers + - customer_base + - active_customers + sql: | + SELECT + DATE_TRUNC('month', created_at) AS month, + COUNT(DISTINCT customer_id) AS new_customers + FROM customers + WHERE status = 'active' + GROUP BY 1 + ORDER BY 1 + sql_by_segment: | + SELECT + segment, + COUNT(DISTINCT customer_id) AS customer_count, + AVG(lifetime_value) AS avg_ltv + FROM customers + WHERE status = 'active' + GROUP BY 1 + ORDER BY 2 DESC diff --git a/docs/metrics/customers/repeat_purchase_rate.yml b/docs/metrics/customers/repeat_purchase_rate.yml new file mode 100644 index 0000000..68e5ee0 --- /dev/null +++ b/docs/metrics/customers/repeat_purchase_rate.yml @@ -0,0 +1,66 @@ +- name: repeat_purchase_rate + display_name: Repeat Purchase Rate + category: customers + type: ratio + unit: "%" + grain: monthly + time_column: order_date + table: orders + tables: + - orders + - customers + expression: "COUNT(DISTINCT CASE WHEN order_number > 1 THEN customer_id END) / COUNT(DISTINCT customer_id)" + description: "Percentage of customers who made more than one purchase. Key loyalty and retention indicator. Higher rates signal strong product-market fit and customer satisfaction." + dimensions: + - customer_segment + - acquisition_channel + - product_category + notes: + - "Calculated over a rolling 12-month window by default" + - "Joins orders to customers via customer_id" + - "Order numbering is based on chronological order per customer" + - "Excludes cancelled and fully refunded orders" + synonyms: + - retention_rate + - repurchase_rate + - customer_loyalty_rate + sql: | + WITH customer_orders AS ( + SELECT + customer_id, + COUNT(*) AS order_count + FROM orders + WHERE status = 'completed' + AND order_date >= CURRENT_DATE - INTERVAL '12 months' + GROUP BY 1 + ) + SELECT + ROUND( + COUNT(CASE WHEN order_count > 1 THEN 1 END) * 100.0 + / COUNT(*), 2 + ) AS repeat_purchase_rate_pct, + COUNT(*) AS total_customers, + COUNT(CASE WHEN order_count > 1 THEN 1 END) AS repeat_customers + FROM customer_orders + sql_by_channel: | + WITH customer_orders AS ( + SELECT + o.customer_id, + c.acquisition_channel, + COUNT(*) AS order_count + FROM orders o + JOIN customers c ON o.customer_id = c.customer_id + WHERE o.status = 'completed' + AND o.order_date >= CURRENT_DATE - INTERVAL '12 months' + GROUP BY 1, 2 + ) + SELECT + acquisition_channel, + ROUND( + COUNT(CASE WHEN order_count > 1 THEN 1 END) * 100.0 + / COUNT(*), 2 + ) AS repeat_rate_pct, + COUNT(*) AS total_customers + FROM customer_orders + GROUP BY 1 + ORDER BY 2 DESC diff --git a/docs/metrics/marketing/campaign_roi.yml b/docs/metrics/marketing/campaign_roi.yml new file mode 100644 index 0000000..0df188d --- /dev/null +++ b/docs/metrics/marketing/campaign_roi.yml @@ -0,0 +1,55 @@ +- name: campaign_roi + display_name: Campaign ROI + category: marketing + type: ratio + unit: "%" + grain: monthly + time_column: start_date + table: campaigns + tables: + - campaigns + - orders + - web_leads + expression: "(SUM(attributed_revenue) - SUM(spend)) / NULLIF(SUM(spend), 0) * 100" + description: "Return on investment for marketing campaigns. Measures revenue generated relative to campaign spend. Negative ROI indicates underperforming campaigns that need optimization." + dimensions: + - campaign_type + - channel + - target_segment + notes: + - "Attribution uses last-touch model by default" + - "Joins campaigns to orders via utm_campaign tracking codes" + - "Web leads are attributed to campaigns via landing page tracking" + - "ROI above 300% is considered excellent for e-commerce" + synonyms: + - marketing_roi + - campaign_return + - roas + sql: | + SELECT + c.campaign_name, + c.campaign_type, + c.spend, + SUM(o.total_amount) AS attributed_revenue, + ROUND( + (SUM(o.total_amount) - c.spend) / NULLIF(c.spend, 0) * 100, 2 + ) AS roi_pct + FROM campaigns c + LEFT JOIN orders o ON o.utm_campaign = c.campaign_id + AND o.status = 'completed' + GROUP BY 1, 2, 3 + ORDER BY 5 DESC + sql_by_type: | + SELECT + c.campaign_type, + SUM(c.spend) AS total_spend, + SUM(o.total_amount) AS total_revenue, + ROUND( + (SUM(o.total_amount) - SUM(c.spend)) + / NULLIF(SUM(c.spend), 0) * 100, 2 + ) AS roi_pct + FROM campaigns c + LEFT JOIN orders o ON o.utm_campaign = c.campaign_id + AND o.status = 'completed' + GROUP BY 1 + ORDER BY 4 DESC diff --git a/docs/metrics/marketing/cost_per_acquisition.yml b/docs/metrics/marketing/cost_per_acquisition.yml new file mode 100644 index 0000000..b1ab94f --- /dev/null +++ b/docs/metrics/marketing/cost_per_acquisition.yml @@ -0,0 +1,53 @@ +- name: cost_per_acquisition + display_name: Cost per Acquisition + category: marketing + type: ratio + unit: USD + grain: monthly + time_column: start_date + table: campaigns + tables: + - campaigns + - customers + expression: "SUM(spend) / NULLIF(COUNT(DISTINCT new_customer_id), 0)" + description: "Average cost to acquire one new customer through marketing campaigns. Compares total campaign spend to the number of new customer registrations attributed to those campaigns." + dimensions: + - campaign_type + - channel + - region + notes: + - "Only counts first-time customers (no repeat purchasers)" + - "Joins campaigns to customers via attribution tracking" + - "CPA below customer lifetime value indicates sustainable growth" + synonyms: + - cpa + - customer_acquisition_cost + - cac + sql: | + SELECT + DATE_TRUNC('month', c.start_date) AS month, + SUM(c.spend) AS total_spend, + COUNT(DISTINCT cust.customer_id) AS new_customers, + ROUND( + SUM(c.spend) / NULLIF(COUNT(DISTINCT cust.customer_id), 0), 2 + ) AS cost_per_acquisition + FROM campaigns c + LEFT JOIN customers cust + ON cust.attribution_campaign = c.campaign_id + AND cust.is_first_purchase = true + GROUP BY 1 + ORDER BY 1 + sql_by_channel: | + SELECT + c.channel, + SUM(c.spend) AS total_spend, + COUNT(DISTINCT cust.customer_id) AS new_customers, + ROUND( + SUM(c.spend) / NULLIF(COUNT(DISTINCT cust.customer_id), 0), 2 + ) AS cpa + FROM campaigns c + LEFT JOIN customers cust + ON cust.attribution_campaign = c.campaign_id + AND cust.is_first_purchase = true + GROUP BY 1 + ORDER BY 4 diff --git a/docs/metrics/marketing/lead_conversion_rate.yml b/docs/metrics/marketing/lead_conversion_rate.yml new file mode 100644 index 0000000..f3469b5 --- /dev/null +++ b/docs/metrics/marketing/lead_conversion_rate.yml @@ -0,0 +1,46 @@ +- name: lead_conversion_rate + display_name: Lead Conversion Rate + category: marketing + type: ratio + unit: "%" + grain: monthly + time_column: created_at + table: web_leads + expression: "COUNT(CASE WHEN status = 'converted' THEN 1 END) / COUNT(*) * 100" + description: "Percentage of web leads that convert to paying customers. Measures the effectiveness of the sales funnel from initial lead capture through purchase completion." + dimensions: + - source + - landing_page + - lead_score_tier + notes: + - "A lead is 'converted' when they complete their first purchase" + - "Conversion window is 90 days from lead creation" + - "Duplicate leads (same email) are deduplicated by earliest creation" + synonyms: + - conversion_rate + - lead_to_customer_rate + - funnel_conversion + sql: | + SELECT + DATE_TRUNC('month', created_at) AS month, + COUNT(*) AS total_leads, + COUNT(CASE WHEN status = 'converted' THEN 1 END) AS converted, + ROUND( + COUNT(CASE WHEN status = 'converted' THEN 1 END) * 100.0 + / COUNT(*), 2 + ) AS conversion_rate_pct + FROM web_leads + GROUP BY 1 + ORDER BY 1 + sql_by_source: | + SELECT + source, + COUNT(*) AS total_leads, + COUNT(CASE WHEN status = 'converted' THEN 1 END) AS converted, + ROUND( + COUNT(CASE WHEN status = 'converted' THEN 1 END) * 100.0 + / COUNT(*), 2 + ) AS conversion_rate_pct + FROM web_leads + GROUP BY 1 + ORDER BY 4 DESC diff --git a/docs/metrics/revenue/average_order_value.yml b/docs/metrics/revenue/average_order_value.yml new file mode 100644 index 0000000..d761169 --- /dev/null +++ b/docs/metrics/revenue/average_order_value.yml @@ -0,0 +1,45 @@ +- name: average_order_value + display_name: Average Order Value + category: revenue + type: average + unit: USD + grain: monthly + time_column: order_date + table: orders + tables: + - orders + - customers + expression: "AVG(total_amount)" + description: "Average monetary value per order. Key indicator of customer purchasing behavior and pricing effectiveness. Joins to customers for segmentation." + dimensions: + - channel + - customer_segment + - product_category + - is_first_order + notes: + - "Calculated only on completed orders" + - "Joins to customers table via customer_id for segment analysis" + - "Useful to compare AOV by new vs returning customers" + synonyms: + - aov + - avg_basket_size + sql: | + SELECT + DATE_TRUNC('month', o.order_date) AS month, + AVG(o.total_amount) AS avg_order_value, + COUNT(*) AS order_count + FROM orders o + WHERE o.status = 'completed' + GROUP BY 1 + ORDER BY 1 + sql_by_segment: | + SELECT + DATE_TRUNC('month', o.order_date) AS month, + c.segment AS customer_segment, + AVG(o.total_amount) AS avg_order_value, + COUNT(*) AS order_count + FROM orders o + JOIN customers c ON o.customer_id = c.customer_id + WHERE o.status = 'completed' + GROUP BY 1, 2 + ORDER BY 1, 3 DESC diff --git a/docs/metrics/revenue/revenue_by_channel.yml b/docs/metrics/revenue/revenue_by_channel.yml new file mode 100644 index 0000000..db85aff --- /dev/null +++ b/docs/metrics/revenue/revenue_by_channel.yml @@ -0,0 +1,41 @@ +- name: revenue_by_channel + display_name: Revenue by Channel + category: revenue + type: sum + unit: USD + grain: monthly + time_column: order_date + table: orders + expression: "SUM(total_amount) GROUP BY channel" + description: "Revenue breakdown by sales channel (web, mobile, in-store, marketplace). Identifies highest-performing channels and guides marketing spend allocation." + dimensions: + - channel + - region + - product_category + notes: + - "Channel is assigned at order creation and does not change" + - "Marketplace channel includes all third-party platforms (Amazon, eBay, etc.)" + - "Cross-channel attribution is not applied; each order is counted once" + synonyms: + - channel_revenue + - sales_by_channel + sql: | + SELECT + DATE_TRUNC('month', order_date) AS month, + channel, + SUM(total_amount) AS revenue, + COUNT(*) AS order_count + FROM orders + WHERE status = 'completed' + GROUP BY 1, 2 + ORDER BY 1, 3 DESC + sql_by_region: | + SELECT + DATE_TRUNC('month', order_date) AS month, + channel, + region, + SUM(total_amount) AS revenue + FROM orders + WHERE status = 'completed' + GROUP BY 1, 2, 3 + ORDER BY 1, 4 DESC diff --git a/docs/metrics/revenue/total_revenue.yml b/docs/metrics/revenue/total_revenue.yml new file mode 100644 index 0000000..812a91b --- /dev/null +++ b/docs/metrics/revenue/total_revenue.yml @@ -0,0 +1,40 @@ +- name: total_revenue + display_name: Total Revenue + category: revenue + type: sum + unit: USD + grain: monthly + time_column: order_date + table: orders + expression: "SUM(total_amount)" + description: "Total revenue from all orders. Primary top-line metric tracking overall business performance across all channels and product categories." + dimensions: + - channel + - product_category + - region + - payment_method + notes: + - "Includes all completed orders, excludes cancelled and refunded" + - "Revenue is recognized at order completion date, not payment date" + - "Multi-currency orders are converted to USD at daily exchange rate" + synonyms: + - gross_revenue + - total_sales + - top_line_revenue + sql: | + SELECT + DATE_TRUNC('month', order_date) AS month, + SUM(total_amount) AS total_revenue + FROM orders + WHERE status = 'completed' + GROUP BY 1 + ORDER BY 1 + sql_by_channel: | + SELECT + DATE_TRUNC('month', order_date) AS month, + channel, + SUM(total_amount) AS revenue + FROM orders + WHERE status = 'completed' + GROUP BY 1, 2 + ORDER BY 1, 3 DESC diff --git a/docs/metrics/support/avg_resolution_hours.yml b/docs/metrics/support/avg_resolution_hours.yml new file mode 100644 index 0000000..bbe8da9 --- /dev/null +++ b/docs/metrics/support/avg_resolution_hours.yml @@ -0,0 +1,47 @@ +- name: avg_resolution_hours + display_name: Average Resolution Time + category: support + type: average + unit: hours + grain: monthly + time_column: created_at + table: support_tickets + expression: "AVG(EXTRACT(EPOCH FROM (resolved_at - created_at)) / 3600)" + description: "Average time in hours from ticket creation to resolution. Key support team performance metric. Lower values indicate more efficient support operations." + dimensions: + - priority + - category + - agent + - channel + notes: + - "Only includes resolved tickets (excludes open and escalated)" + - "Business hours calculation is not applied; uses wall-clock time" + - "Outliers above 720 hours (30 days) are excluded from average" + synonyms: + - resolution_time + - time_to_resolve + - ttr + sql: | + SELECT + DATE_TRUNC('month', created_at) AS month, + ROUND( + AVG(EXTRACT(EPOCH FROM (resolved_at - created_at)) / 3600), 1 + ) AS avg_resolution_hours, + COUNT(*) AS resolved_tickets + FROM support_tickets + WHERE resolved_at IS NOT NULL + AND EXTRACT(EPOCH FROM (resolved_at - created_at)) / 3600 <= 720 + GROUP BY 1 + ORDER BY 1 + sql_by_priority: | + SELECT + priority, + ROUND( + AVG(EXTRACT(EPOCH FROM (resolved_at - created_at)) / 3600), 1 + ) AS avg_resolution_hours, + COUNT(*) AS ticket_count + FROM support_tickets + WHERE resolved_at IS NOT NULL + AND EXTRACT(EPOCH FROM (resolved_at - created_at)) / 3600 <= 720 + GROUP BY 1 + ORDER BY 2 diff --git a/docs/metrics/support/satisfaction_score.yml b/docs/metrics/support/satisfaction_score.yml new file mode 100644 index 0000000..13ba267 --- /dev/null +++ b/docs/metrics/support/satisfaction_score.yml @@ -0,0 +1,46 @@ +- name: satisfaction_score + display_name: Customer Satisfaction Score + category: support + type: average + unit: score (1-5) + grain: monthly + time_column: created_at + table: support_tickets + expression: "AVG(satisfaction_score)" + description: "Average customer satisfaction rating on a 1-5 scale collected after ticket resolution. Measures customer perception of support quality and identifies areas for improvement." + dimensions: + - priority + - category + - agent + - resolution_type + notes: + - "Score is collected via post-resolution survey email" + - "Response rate is typically 25-35% of resolved tickets" + - "Score of 4+ is considered 'satisfied', below 3 is 'unsatisfied'" + - "Only tickets with a satisfaction response are included" + synonyms: + - csat + - customer_satisfaction + - satisfaction_rating + sql: | + SELECT + DATE_TRUNC('month', created_at) AS month, + ROUND(AVG(satisfaction_score), 2) AS avg_satisfaction, + COUNT(*) AS responses, + ROUND( + COUNT(CASE WHEN satisfaction_score >= 4 THEN 1 END) * 100.0 + / COUNT(*), 1 + ) AS pct_satisfied + FROM support_tickets + WHERE satisfaction_score IS NOT NULL + GROUP BY 1 + ORDER BY 1 + sql_by_category: | + SELECT + category, + ROUND(AVG(satisfaction_score), 2) AS avg_satisfaction, + COUNT(*) AS responses + FROM support_tickets + WHERE satisfaction_score IS NOT NULL + GROUP BY 1 + ORDER BY 2 DESC diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..f8f2e64 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,151 @@ +"""Tests for business metric YAML definitions and parser.""" + +import yaml +import pytest +from pathlib import Path + +from webapp.utils.metric_parser import MetricParser + + +METRICS_DIR = Path(__file__).parent.parent / "docs" / "metrics" + +REQUIRED_FIELDS = [ + "name", "display_name", "category", "type", "unit", + "grain", "time_column", "table", "description", "expression", +] + + +def _get_all_metric_files(): + """Return list of all metric YAML files.""" + return sorted(METRICS_DIR.glob("*/*.yml")) + + +class TestMetricYAMLValidity: + """Validate all metric YAML files have required fields.""" + + def test_metrics_directory_exists(self): + assert METRICS_DIR.exists(), f"Metrics directory not found: {METRICS_DIR}" + + def test_at_least_one_metric_exists(self): + files = _get_all_metric_files() + assert len(files) > 0, "No metric YAML files found" + + @pytest.mark.parametrize("metric_file", _get_all_metric_files(), ids=lambda f: f.relative_to(METRICS_DIR).as_posix()) + def test_all_metric_yamls_valid(self, metric_file): + """Every metric YAML must have all required fields.""" + with open(metric_file) as f: + raw = yaml.safe_load(f) + + assert isinstance(raw, list), f"{metric_file.name}: expected YAML list, got {type(raw).__name__}" + assert len(raw) >= 1, f"{metric_file.name}: YAML list is empty" + + metric = raw[0] + assert isinstance(metric, dict), f"{metric_file.name}: first item is not a dict" + + missing = [field for field in REQUIRED_FIELDS if field not in metric] + assert not missing, f"{metric_file.name}: missing required fields: {missing}" + + # Category must match parent directory name + expected_category = metric_file.parent.name + assert metric["category"] == expected_category, ( + f"{metric_file.name}: category '{metric['category']}' != directory '{expected_category}'" + ) + + +class TestMetricCategoriesInParser: + """Verify CATEGORY_COLORS has entries for all used categories.""" + + def test_all_categories_have_colors(self): + files = _get_all_metric_files() + categories_used = set() + for f in files: + with open(f) as fh: + raw = yaml.safe_load(fh) + if isinstance(raw, list) and raw: + categories_used.add(raw[0].get("category", "")) + + parser = MetricParser(METRICS_DIR) + missing = categories_used - set(parser.CATEGORY_COLORS.keys()) + assert not missing, f"CATEGORY_COLORS missing entries for: {missing}" + + +class TestMetricParserParsesSample: + """Parse one metric and verify structured output.""" + + def test_parse_total_revenue(self): + parser = MetricParser(METRICS_DIR) + data = parser.parse_metric("revenue/total_revenue.yml") + + assert data["name"] == "total_revenue" + assert data["display_name"] == "Total Revenue" + assert data["category"] == "revenue" + assert data["category_color"] == "#0073D1" + assert data["metadata"]["unit"] == "USD" + assert data["metadata"]["grain"] == "monthly" + assert len(data["dimensions"]) > 0 + assert "sql" in data["sql_examples"] + assert data["technical"]["table"] == "orders" + assert data["technical"]["expression"] == "SUM(total_amount)" + + def test_parse_metric_with_tables_field(self): + parser = MetricParser(METRICS_DIR) + data = parser.parse_metric("revenue/average_order_value.yml") + + assert data["name"] == "average_order_value" + assert "sql_by_segment" in data["sql_examples"] + + +class TestLoadMetricsData: + """Verify _load_metrics_data returns correct structure.""" + + def test_returns_four_categories(self): + from webapp.app import _load_metrics_data + result = _load_metrics_data() + assert isinstance(result, list) + assert len(result) == 4 + category_keys = [c["key"] for c in result] + assert "revenue" in category_keys + assert "customers" in category_keys + assert "marketing" in category_keys + assert "support" in category_keys + + def test_total_metrics_count(self): + from webapp.app import _load_metrics_data + result = _load_metrics_data() + total = sum(len(c["metrics"]) for c in result) + assert total == 10 + + def test_metric_has_required_fields(self): + from webapp.app import _load_metrics_data + result = _load_metrics_data() + for cat in result: + for m in cat["metrics"]: + assert "name" in m + assert "display_name" in m + assert "description" in m + assert "grain" in m + assert "path" in m + + +class TestDynamicSqlFields: + """Verify sql_by_* fields are auto-discovered by parser.""" + + def test_dynamic_sql_fields_discovered(self): + parser = MetricParser(METRICS_DIR) + data = parser.parse_metric("revenue/total_revenue.yml") + # sql_by_channel should be found via dynamic discovery + assert "sql_by_channel" in data["sql_examples"] + assert data["sql_examples"]["sql_by_channel"]["title"] == "By Channel" + + def test_dynamic_sql_title_generation(self): + parser = MetricParser(METRICS_DIR) + data = parser.parse_metric("customers/repeat_purchase_rate.yml") + # sql_by_channel should be found via dynamic discovery + assert "sql_by_channel" in data["sql_examples"] + assert data["sql_examples"]["sql_by_channel"]["title"] == "By Channel" + + def test_static_sql_still_works(self): + parser = MetricParser(METRICS_DIR) + data = parser.parse_metric("revenue/total_revenue.yml") + assert "sql" in data["sql_examples"] + assert data["sql_examples"]["sql"]["title"] == "Basic Query" diff --git a/webapp/app.py b/webapp/app.py index 5065d83..f7665ca 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -13,6 +13,8 @@ import os from datetime import datetime from pathlib import Path +import yaml + from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for from .auth import admin_required, auth_bp, login_required @@ -351,6 +353,80 @@ def _load_catalog_data() -> list: return catalog +# Category metadata for Business Metrics card +METRIC_CATEGORY_META = { + 'revenue': {'label': 'Revenue', 'css': 'sales', 'order': 1}, + 'customers': {'label': 'Customers', 'css': 'hr', 'order': 2}, + 'marketing': {'label': 'Marketing', 'css': 'telemetry', 'order': 3}, + 'support': {'label': 'Support', 'css': 'support', 'order': 4}, +} + + +def _load_metrics_data(): + """Load business metric definitions for catalog display. + + Returns list of category dicts ordered by METRIC_CATEGORY_META: + [{'key': 'revenue', 'label': 'Revenue', 'css': 'sales', 'metrics': [...]}, ...] + """ + # Try production path first, fall back to local dev path + metrics_dir = Path("/data/docs/metrics") + if not metrics_dir.exists(): + metrics_dir = Path(__file__).parent.parent / "docs" / "metrics" + + if not metrics_dir.exists(): + return [] + + categories = {} + for yml_file in sorted(metrics_dir.glob("*/*.yml")): + try: + with open(yml_file, 'r', encoding='utf-8') as f: + raw = yaml.safe_load(f) + + if isinstance(raw, list) and raw: + metric = raw[0] + elif isinstance(raw, dict): + metric = raw + else: + continue + + cat_key = yml_file.parent.name + if cat_key not in categories: + categories[cat_key] = [] + + categories[cat_key].append({ + 'name': metric.get('name', yml_file.stem), + 'display_name': metric.get('display_name', yml_file.stem), + 'description': metric.get('description', ''), + 'grain': metric.get('grain', ''), + 'path': f"{cat_key}/{yml_file.name}", + }) + except Exception as e: + logger.warning(f"Could not parse metric {yml_file}: {e}") + + # Build ordered result using METRIC_CATEGORY_META + result = [] + for cat_key, meta in sorted(METRIC_CATEGORY_META.items(), key=lambda x: x[1]['order']): + if cat_key in categories: + result.append({ + 'key': cat_key, + 'label': meta['label'], + 'css': meta['css'], + 'metrics': categories[cat_key], + }) + + # Add any unknown categories at the end + for cat_key, metrics in sorted(categories.items()): + if cat_key not in METRIC_CATEGORY_META: + result.append({ + 'key': cat_key, + 'label': cat_key.replace('_', ' ').title(), + 'css': cat_key, + 'metrics': metrics, + }) + + return result + + def _send_welcome_message(username: str) -> None: """Send a welcome message to the user via bot socket after linking.""" try: @@ -492,11 +568,14 @@ def register_routes(app: Flask) -> None: else: table["subscribed"] = table_subs.get(table["name"], False) + metrics_data = _load_metrics_data() + return render_template( "catalog.html", data_stats=data_stats, catalog_data=catalog_data, sync_settings=sync_settings, + metrics_data=metrics_data, ) @app.route("/api/catalog/profile/") diff --git a/webapp/static/css/metric_modal.css b/webapp/static/css/metric_modal.css index 17755b3..dad9e98 100644 --- a/webapp/static/css/metric_modal.css +++ b/webapp/static/css/metric_modal.css @@ -117,6 +117,26 @@ color: #78350F; } +.metric-chip.category.revenue { + background: #EFF6FF; + color: #1E40AF; +} + +.metric-chip.category.customers { + background: rgba(139, 92, 246, 0.1); + color: #7c3aed; +} + +.metric-chip.category.marketing { + background: #FEF3C7; + color: #78350F; +} + +.metric-chip.category.support { + background: rgba(234, 88, 12, 0.1); + color: #EA580C; +} + .metric-chip.grain, .metric-chip.unit { background: #F3F4F6; @@ -207,6 +227,26 @@ border-bottom-color: #78350F; } +.metric-tab.active.category-revenue { + color: #1E40AF; + border-bottom-color: #1E40AF; +} + +.metric-tab.active.category-customers { + color: #7c3aed; + border-bottom-color: #7c3aed; +} + +.metric-tab.active.category-marketing { + color: #78350F; + border-bottom-color: #78350F; +} + +.metric-tab.active.category-support { + color: #EA580C; + border-bottom-color: #EA580C; +} + /* Modal Body */ .metric-modal-body { flex: 1; diff --git a/webapp/static/js/metric_modal.js b/webapp/static/js/metric_modal.js index 75ab5c5..0572f37 100644 --- a/webapp/static/js/metric_modal.js +++ b/webapp/static/js/metric_modal.js @@ -112,7 +112,7 @@ function renderMetricModal(data) { // Apply category class to tabs document.querySelectorAll('.metric-tab').forEach(tab => { - tab.classList.remove('category-finance', 'category-telemetry', 'category-sales_revenue', 'category-weekly_leadership_kpis'); + tab.className = tab.className.replace(/\bcategory-\w+/g, ''); tab.classList.add(categoryClass); }); @@ -420,9 +420,13 @@ function formatCategory(category) { 'finance': 'Finance', 'product_usage': 'Product Usage', 'sales_revenue': 'Sales & Revenue', - 'weekly_leadership_kpis': 'Weekly Leadership KPIs' + 'weekly_leadership_kpis': 'Weekly Leadership KPIs', + 'revenue': 'Revenue', + 'customers': 'Customers', + 'marketing': 'Marketing', + 'support': 'Support' }; - return map[category] || category; + return map[category] || category.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); } /** diff --git a/webapp/static/style-custom.css b/webapp/static/style-custom.css index 3f9583a..4b99dd5 100644 --- a/webapp/static/style-custom.css +++ b/webapp/static/style-custom.css @@ -767,6 +767,9 @@ body { .category-tag.sales { background: rgba(0, 115, 209, 0.1); color: #0073D1; } .category-tag.telemetry { background: rgba(245, 159, 10, 0.1); color: #b45309; } .category-tag.support { background: rgba(234, 88, 12, 0.1); color: #EA580C; } +.category-tag.revenue { background: rgba(0, 115, 209, 0.1); color: #0073D1; } +.category-tag.customers { background: rgba(139, 92, 246, 0.1); color: #7c3aed; } +.category-tag.marketing { background: rgba(245, 159, 10, 0.1); color: #b45309; } .data-highlights { display: flex; diff --git a/webapp/templates/catalog.html b/webapp/templates/catalog.html index 55f0ae7..8412d2e 100644 --- a/webapp/templates/catalog.html +++ b/webapp/templates/catalog.html @@ -1374,6 +1374,9 @@ + {% if metrics_data %} + {% set metrics_total = namespace(n=0) %} + {% for c in metrics_data %}{% set metrics_total.n = metrics_total.n + c.metrics|length %}{% endfor %}
@@ -1385,8 +1388,8 @@
Business Metrics
-
Standardized metric definitions for revenue, usage, KPIs, and financial reporting
-
18 metrics · 4 categories
+
Standardized metric definitions with SQL examples and documentation
+
{{ metrics_total.n }} metrics · {{ metrics_data|length }} categories
@@ -1398,220 +1401,32 @@
- + {% for category in metrics_data %}
-
+ {% for metric in category.metrics %} +
-
infra_cost
-
Monthly infrastructure cost from cloud providers (GCP, AWS, Azure) and data warehouses (Snowflake, BigQuery)
+
{{ metric.name }}
+
{{ metric.description }}
- monthly -
-
-
-
- - -
- -
-
-
-
usage_value
-
Platform usage consumption aggregated per company and metric type with conditional aggregation
-
-
- monthly -
-
-
-
-
contract_limit_value
-
Monthly contracted limits for usage metrics per company
-
-
- monthly -
-
-
-
-
usage_vs_limit
-
Comparison of actual usage against contracted limits, identifies overage risk and unpaid usage
-
-
- monthly -
-
-
-
- - -
- -
-
-
-
product_revenue
-
Monthly recurring revenue broken down by individual contract lines (product level)
-
-
- monthly -
-
-
-
-
mrr
-
Total monthly recurring revenue aggregated at company level - PRIMARY MRR metric
-
-
- monthly -
-
-
-
-
new_arr
-
Annual Recurring Revenue from truly new customers (first month of revenue)
-
-
- monthly -
-
-
-
-
upsell_expansion
-
Revenue expansion from existing customers upgrading their contracts
-
-
- monthly -
-
-
-
- - -
- -
-
-
-
top_risk_churn_count
-
Weekly count of active customers flagged as high churn risk (CSM opinion = RED) • Customer Success
-
-
- weekly -
-
-
-
-
top_risk_churn_usd
-
Weekly ARR of active customers flagged as high churn risk • Customer Success
-
-
- weekly -
-
-
-
-
demo
-
Weekly count and ARR of New Business opportunities moved to Demo stage • Sales
-
-
- weekly -
-
-
-
-
negotiation
-
Weekly count and ARR of New Business opportunities moved to Negotiation stage • Sales
-
-
- weekly -
-
-
-
-
closed_won
-
Weekly count and ARR of New Business opportunities closed successfully • Sales
-
-
- weekly -
-
-
-
-
revenue_upsells_ytd
-
Year-to-Date cumulative revenue upsells based on contract signing dates • Sales
-
-
- weekly YTD -
-
-
-
-
revenue_downsells_ytd
-
Year-to-Date cumulative revenue downsells based on contract signing dates • Sales
-
-
- weekly YTD -
-
-
-
-
net_retention_rate
-
Net Dollar Retention Rate (LTM) - measures revenue retention and expansion from existing customers • Finance
-
-
- weekly -
-
-
-
-
credits_consumption_trend
-
Week-over-week percentage change in total credits consumption for customers aged 90+ days • Product
-
-
- weekly -
-
-
-
-
accounts_at_risk
-
Number of customer accounts showing concerning usage decline patterns (2 consecutive weeks of 25%+ job decline) • Product
-
-
- weekly + {{ metric.grain }}
+ {% endfor %}
+ {% endfor %}
+ {% endif %}
diff --git a/webapp/utils/metric_parser.py b/webapp/utils/metric_parser.py index f18305f..312db67 100644 --- a/webapp/utils/metric_parser.py +++ b/webapp/utils/metric_parser.py @@ -17,7 +17,11 @@ class MetricParser: 'finance': '#0d9668', 'product_usage': '#b45309', 'sales_revenue': '#0073D1', - 'weekly_leadership_kpis': '#0073D1' + 'weekly_leadership_kpis': '#0073D1', + 'revenue': '#0073D1', + 'customers': '#7c3aed', + 'marketing': '#b45309', + 'support': '#EA580C', } # Complexity keywords for SQL query classification @@ -181,6 +185,21 @@ class MetricParser: 'complexity': complexity } + # Dynamic discovery: auto-detect sql_* keys not in the static map + for key in metric: + if key.startswith('sql_') and key not in sql_fields and metric[key]: + # Generate title from key: "sql_by_channel" -> "By Channel" + title_parts = key.replace('sql_', '').replace('_', ' ').title() + # Clean up "By X" pattern + title = title_parts if title_parts.startswith('By') else title_parts + query = metric[key] + complexity = self._classify_sql_complexity(query) + sql_examples[key] = { + 'title': title, + 'query': query.strip(), + 'complexity': complexity + } + return sql_examples def _classify_sql_complexity(self, query: str) -> str: