diff --git a/input.txt.example b/input.txt.example index 7494b55..14f9f88 100644 --- a/input.txt.example +++ b/input.txt.example @@ -96,6 +96,8 @@ enable_duplicate_detection = true enable_language_detection = true analysis_fuzzy_threshold = 92 analysis_simhash_hamming = 0 +analysis_simhash_max_urls = 800 +analysis_fuzzy_max_urls = 600 analysis_dup_max_pages = 2000 # --- Audit steps --- diff --git a/pipeline-config.example.txt b/pipeline-config.example.txt index d1957b9..ab60161 100644 --- a/pipeline-config.example.txt +++ b/pipeline-config.example.txt @@ -97,6 +97,8 @@ enable_duplicate_detection = true enable_language_detection = true analysis_fuzzy_threshold = 92 analysis_simhash_hamming = 0 +analysis_simhash_max_urls = 800 +analysis_fuzzy_max_urls = 600 analysis_dup_max_pages = 2000 # --- Pipeline --- diff --git a/src/website_profiling/analysis/local.py b/src/website_profiling/analysis/local.py index c55adf1..807a9b6 100644 --- a/src/website_profiling/analysis/local.py +++ b/src/website_profiling/analysis/local.py @@ -97,9 +97,11 @@ def _import_langdetect(): def compute_duplicate_groups( df: pd.DataFrame, cfg: dict[str, str] | None, -) -> tuple[list[dict[str, Any]], dict[str, str]]: +) -> tuple[list[dict[str, Any]], dict[str, str], list[str]]: if df.empty or not _cfg_bool(cfg, "enable_duplicate_detection", False): - return [], {} + return [], {}, [] + + warnings: list[str] = [] success = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df if "content_type" in success.columns: @@ -126,6 +128,8 @@ def compute_duplicate_groups( fuzz = _import_rapidfuzz() fuzzy_threshold = _cfg_int(cfg, "analysis_fuzzy_threshold", 92) or 92 hamming_max = _cfg_int(cfg, "analysis_simhash_hamming", 0) or 0 + simhash_max_urls = _cfg_int(cfg, "analysis_simhash_max_urls", 800) or 800 + fuzzy_max_urls = _cfg_int(cfg, "analysis_fuzzy_max_urls", 600) or 600 parent: dict[str, str] = {} @@ -150,20 +154,30 @@ def union(a: str, b: str) -> None: for m in members[1:]: union(base, m) - if hamming_max > 0 and len(urls) <= 800: + if hamming_max > 0 and len(urls) <= simhash_max_urls: sh_list = [(u, url_to_sh[u]) for u in urls] for i, (u1, h1) in enumerate(sh_list): for u2, h2 in sh_list[i + 1 :]: if _hamming(h1, h2) <= hamming_max: union(u1, u2) + elif hamming_max > 0 and len(urls) > simhash_max_urls: + warnings.append( + f"Duplicate detection: SimHash similarity skipped for {len(urls)} URLs " + f"(cap {simhash_max_urls}); results may be incomplete." + ) - if len(urls) <= 600: + if len(urls) <= fuzzy_max_urls: for i, u1 in enumerate(urls): fp1 = url_to_fp.get(u1, "") for u2 in urls[i + 1 :]: fp2 = url_to_fp.get(u2, "") if fp1 and fp2 and fuzz.token_set_ratio(fp1, fp2) >= fuzzy_threshold: union(u1, u2) + elif len(urls) > fuzzy_max_urls: + warnings.append( + f"Duplicate detection: fuzzy title matching skipped for {len(urls)} URLs " + f"(cap {fuzzy_max_urls}); results may be incomplete." + ) clusters: dict[str, list[str]] = defaultdict(list) for u in urls: @@ -196,7 +210,7 @@ def union(a: str, b: str) -> None: if gid >= max_groups: break - return groups_out[:max_groups], url_to_gid + return groups_out[:max_groups], url_to_gid, warnings def compute_language_signals(df: pd.DataFrame, cfg: dict[str, str] | None) -> tuple[dict[str, str], dict[str, Any]]: @@ -243,9 +257,10 @@ def run_local_enrichment(df: pd.DataFrame, cfg: dict[str, str] | None) -> dict[s return bundle try: - dups, url_gid = compute_duplicate_groups(df, cfg) + dups, url_gid, dup_warnings = compute_duplicate_groups(df, cfg) bundle["content_duplicates"] = dups bundle["url_duplicate_group_id"] = url_gid + bundle["ml_errors"].extend(dup_warnings) except ImportError as e: bundle["ml_errors"].append(str(e)) diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py index a00b4c9..c3b9076 100644 --- a/src/website_profiling/crawl/crawler.py +++ b/src/website_profiling/crawl/crawler.py @@ -5,7 +5,7 @@ import json import time -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait from typing import Optional import pandas as pd @@ -449,6 +449,10 @@ def crawl( continue futures.append(ex.submit(self.worker, url)) + if futures and self.queue.empty(): + # Block until at least one future completes instead of busy-polling. + wait(futures, return_when=FIRST_COMPLETED) + remaining = [] for f in futures: if f.done(): @@ -471,7 +475,6 @@ def crawl( else: remaining.append(f) futures = remaining - time.sleep(0.01) if self.queue.empty() and not futures: break diff --git a/src/website_profiling/crawl/db_writer.py b/src/website_profiling/crawl/db_writer.py index 302ba7f..a5f108d 100644 --- a/src/website_profiling/crawl/db_writer.py +++ b/src/website_profiling/crawl/db_writer.py @@ -21,10 +21,12 @@ def __init__(self, crawl_run_id: int, batch_size: int = 500, *, store_page_html: self._error: Optional[BaseException] = None def enqueue(self, record: dict) -> None: + if self._error is not None: + return self._queue.put(("crawl", record)) def enqueue_html(self, record: dict) -> None: - if not self.store_page_html: + if not self.store_page_html or self._error is not None: return self._queue.put(("html", record)) diff --git a/src/website_profiling/crawl/fetchers/browser.py b/src/website_profiling/crawl/fetchers/browser.py index b3c3678..4d6a7d6 100644 --- a/src/website_profiling/crawl/fetchers/browser.py +++ b/src/website_profiling/crawl/fetchers/browser.py @@ -263,25 +263,26 @@ async def worker() -> None: workers = [asyncio.create_task(worker()) for _ in range(self.js_concurrency)] self._ready.set() - await asyncio.gather(*workers) - - for page in pages: + try: + await asyncio.gather(*workers) + finally: + for page in pages: + try: + await page.close() + except Exception: + pass try: - await page.close() + await context.close() + except Exception: + pass + try: + await browser.close() + except Exception: + pass + try: + await playwright.stop() except Exception: pass - try: - await context.close() - except Exception: - pass - try: - await browser.close() - except Exception: - pass - try: - await playwright.stop() - except Exception: - pass def _diagnostics_enabled(self) -> bool: return self.capture_console or self.capture_failed_requests diff --git a/src/website_profiling/crawl/fetchers/static.py b/src/website_profiling/crawl/fetchers/static.py index 8059e56..7477dcc 100644 --- a/src/website_profiling/crawl/fetchers/static.py +++ b/src/website_profiling/crawl/fetchers/static.py @@ -49,7 +49,7 @@ def fetch(self, url: str) -> FetchResult: redirect_chain_length=redirect_chain_length, fetch_method="static", ) - except Exception: + except requests.RequestException: return FetchResult( status=None, content_type=None, diff --git a/src/website_profiling/db/historical.py b/src/website_profiling/db/historical.py index b1e6279..cb71baf 100644 --- a/src/website_profiling/db/historical.py +++ b/src/website_profiling/db/historical.py @@ -4,14 +4,17 @@ import json import os import subprocess +import sys import time from pathlib import Path from typing import Any, Optional import pandas as pd from psycopg import Connection +from psycopg.sql import SQL, Identifier from urllib.parse import urlparse +from ..console_io import console_print from ._common import ( _executemany, _json_val, @@ -33,17 +36,26 @@ def backup_db_if_exists(skip_in_ci: bool = True) -> Optional[str]: suffix = time.strftime("%Y%m%d-%H%M%S") out_path = backup_dir / f"website_profiling-{suffix}.dump" try: + parsed = urlparse(get_database_url()) + pg_env = {**os.environ} + if parsed.hostname: + pg_env["PGHOST"] = parsed.hostname + if parsed.port: + pg_env["PGPORT"] = str(parsed.port) + if parsed.username: + pg_env["PGUSER"] = parsed.username + if parsed.password: + pg_env["PGPASSWORD"] = parsed.password + dbname = (parsed.path or "").lstrip("/") + cmd = ["pg_dump", "-Fc", "-f", str(out_path)] + if dbname: + cmd.append(dbname) subprocess.run( - [ - "pg_dump", - "-Fc", - "-f", - str(out_path), - get_database_url(), - ], + cmd, check=True, capture_output=True, timeout=300, + env=pg_env, ) return str(out_path) except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired): @@ -72,12 +84,18 @@ def read_historical_data() -> dict[str, list]: for table in tables: try: with conn.cursor() as cur: - cur.execute(f"SELECT * FROM {table}") + cur.execute(SQL("SELECT * FROM {}").format(Identifier(table))) result[table] = [dict(row) for row in cur.fetchall()] - except Exception: - pass - except Exception: - pass + except Exception as e: + console_print( + f" Warning: could not read historical table '{table}': {e}", + file=sys.stderr, + ) + except Exception as e: + console_print( + f" Warning: could not read historical data (a DB backup is still taken before any overwrite): {e}", + file=sys.stderr, + ) return result diff --git a/src/website_profiling/db/report_store.py b/src/website_profiling/db/report_store.py index ffd794c..431d9ae 100644 --- a/src/website_profiling/db/report_store.py +++ b/src/website_profiling/db/report_store.py @@ -6,6 +6,7 @@ from psycopg import Connection +from ..scoring import round_half_up from ._common import _json_val, _now_iso, _parse_row_json, _row_field from .crawl_store import get_crawl_run_info @@ -51,7 +52,7 @@ def _write_audit_health_snapshot( for c in categories if isinstance(c, dict) and isinstance(c.get("score"), (int, float)) ] - health_score = round(sum(scores) / len(scores)) if scores else None + health_score = round_half_up(sum(scores) / len(scores)) if scores else None category_scores: dict[str, float] = {} issue_counts = {"Critical": 0, "High": 0, "Medium": 0, "Low": 0} for cat in categories: diff --git a/src/website_profiling/integrations/crux/fetch.py b/src/website_profiling/integrations/crux/fetch.py index a34ef25..df2ea4c 100644 --- a/src/website_profiling/integrations/crux/fetch.py +++ b/src/website_profiling/integrations/crux/fetch.py @@ -49,9 +49,18 @@ def fetch_crux_origin_metrics(origin_or_url: str, api_key: str | None = None) -> lcp = parsed["metrics"].get("largest_contentful_paint", {}).get("p75") inp = parsed["metrics"].get("interaction_to_next_paint", {}).get("p75") cls = parsed["metrics"].get("cumulative_layout_shift", {}).get("p75") + + def _pass_threshold(value: Any, limit: float) -> bool: + if value is None: + return False + try: + return float(value) <= limit + except (TypeError, ValueError): + return False + parsed["pass"] = { - "lcp": lcp is not None and float(lcp) <= 2500, - "inp": inp is not None and float(inp) <= 200, - "cls": cls is not None and float(cls) <= 0.1, + "lcp": _pass_threshold(lcp, 2500), + "inp": _pass_threshold(inp, 200), + "cls": _pass_threshold(cls, 0.1), } return parsed diff --git a/src/website_profiling/integrations/google/keyword_enrich.py b/src/website_profiling/integrations/google/keyword_enrich.py index 10a1f77..f592051 100644 --- a/src/website_profiling/integrations/google/keyword_enrich.py +++ b/src/website_profiling/integrations/google/keyword_enrich.py @@ -21,6 +21,7 @@ from __future__ import annotations import json +import math import re from datetime import datetime, timezone from typing import Any @@ -34,14 +35,19 @@ def ctr_as_fraction(ctr: Any) -> float: - """GSC rows use CTR percent (2.8); normalize to fraction for comparisons.""" + """GSC rows use CTR as percent (e.g. 2.8 for 2.8%); normalize to fraction. + + Invariant: ingest always stores percent — see gsc._to_query_record / _to_page_record (* 100). + """ if ctr is None: return 0.0 try: v = float(ctr) except (TypeError, ValueError): return 0.0 - return v / 100.0 if v > 1 else v + if v > 100: + return 1.0 + return v / 100.0 QUESTION_STARTS = re.compile( r"^(how|what|why|when|where|who|can|does|is|are|should|will|do)\s", re.I @@ -143,13 +149,15 @@ def estimate_difficulty(kw: str, gsc_row: dict | None, branded: bool = False) -> # ── CTR curve ───────────────────────────────────────────────────────────────── def opportunity_clicks(impressions: int, current_pos: float, target_pos: int = 3) -> int: - cur_ctr = CTR_CURVE.get(round(current_pos), CTR_CURVE_DEFAULT) + pos_slot = max(1, math.ceil(current_pos)) if current_pos > 0 else 1 + cur_ctr = CTR_CURVE.get(pos_slot, CTR_CURVE_DEFAULT) tgt_ctr = CTR_CURVE.get(target_pos, CTR_CURVE.get(3, 0.103)) return max(0, int((impressions or 0) * (tgt_ctr - cur_ctr))) def industry_ctr(pos: float) -> float: - return CTR_CURVE.get(round(pos), CTR_CURVE_DEFAULT) + pos_slot = max(1, math.ceil(pos)) if pos > 0 else 1 + return CTR_CURVE.get(pos_slot, CTR_CURVE_DEFAULT) # ── Cannibalisation ─────────────────────────────────────────────────────────── diff --git a/src/website_profiling/integrations/serp/estimates.py b/src/website_profiling/integrations/serp/estimates.py index e344c09..ab81db0 100644 --- a/src/website_profiling/integrations/serp/estimates.py +++ b/src/website_profiling/integrations/serp/estimates.py @@ -6,6 +6,10 @@ import urllib.request from typing import Any +_MAX_ORGANIC = 10 +_MAX_FEATURES = 4 +_RAW_MAX = _MAX_ORGANIC * 8 + _MAX_FEATURES * 12 # 128 + def fetch_serp_features(keyword: str, api_key: str) -> dict[str, Any]: """Fetch SERP metadata from SerpAPI (Estimated competition proxy).""" @@ -38,13 +42,14 @@ def fetch_serp_features(keyword: str, api_key: str) -> dict[str, Any]: if data.get("top_stories"): features.append("top_stories") - competition = min(100, len(organic) * 8 + len(features) * 12) + raw_score = len(organic) * 8 + len(features) * 12 + competition = min(100, round(raw_score / _RAW_MAX * 100)) return { "ok": True, "organic_count": len(organic), "serp_features": features, "estimated_competition": competition, - "provenance": "Estimated", + "provenance": "Estimated (heuristic-v1)", } diff --git a/src/website_profiling/lighthouse/config.py b/src/website_profiling/lighthouse/config.py index 1a6c7b0..489bf7c 100644 --- a/src/website_profiling/lighthouse/config.py +++ b/src/website_profiling/lighthouse/config.py @@ -50,117 +50,4 @@ def _node_cmd() -> str: return node -def _build_report_html_content(summary: dict[str, Any]) -> str: - """Build report.html content (for DB or file). Returns HTML string.""" - import html as html_module - mm = summary.get("median_metrics") or {} - cs = summary.get("category_scores") or {} - failures = summary.get("top_failures") or [] - raw_reports = summary.get("raw_reports") or [] - url = html_module.escape(summary.get("url", "")) - path_summary = "summary.json" - path_human = "human_summary.txt" - path_diag = "diagnostics.json" - raw_dir = "raw_runs" - rows_fail = "".join( - f"{html_module.escape(str(f.get('id', '')))}{html_module.escape(str(f.get('impact', '')))}{html_module.escape(str(f.get('helpText', ''))[:80])}..." - for f in failures[:10] - ) or "None" - raw_links = "".join(f"{os.path.basename(p)} " for p in raw_reports[:5]) - return f""" - -Lighthouse Report - -

Lighthouse Report

-

URL: {url}

-

Median metrics

- - - - - - -
MetricValue
LCP (ms){mm.get('lcp_ms') or '—'}
CLS{mm.get('cls') or '—'}
TBT (ms){mm.get('tbt_ms') or '—'}
FCP (ms){mm.get('fcp_ms') or '—'}
-

Category scores (0–100)

- - - - - - - -
CategoryScore
performance{cs.get('performance') or '—'}
accessibility{cs.get('accessibility') or '—'}
best-practices{cs.get('best-practices') or '—'}
seo{cs.get('seo') or '—'}
pwa{cs.get('pwa') or '—'}
-

Top failures

-{rows_fail}
AuditImpactHelp
-

Artifacts

-

summary.json | human_summary.txt | diagnostics.json

-

Raw runs: {raw_links or '—'}

- - -""" - - -def _write_report_html(output_dir: str, summary: dict[str, Any]) -> None: - """Write report.html to output_dir (used when not using DB).""" - content = summary.get("report_html") or _build_report_html_content(summary) - report_path = os.path.join(output_dir, "report.html") - with open(report_path, "w", encoding="utf-8") as f: - f.write(content) - - -def _url_safe(s: str) -> str: - """Return a filesystem-safe slug from URL for filenames.""" - return re.sub(r"[^\w\-.]", "_", s.strip().rstrip("/"))[:80] - - -def _lighthouse_cmd() -> list[str]: - """Return argv prefix: [resolved lighthouse] or [resolved npx, -y, lighthouse]. Paths from shutil.which (portable).""" - explicit = (os.environ.get("LIGHTHOUSE_PATH") or os.environ.get("LIGHTHOUSE_BIN") or "").strip() - if explicit and os.path.isfile(explicit) and os.access(explicit, os.X_OK): - return [explicit] - lh = shutil.which("lighthouse") - if lh is not None: - return [lh] - npx = shutil.which("npx") - if npx is not None: - return [npx, "-y", "lighthouse"] - raise RuntimeError(_LIGHTHOUSE_INSTALL_MSG) - - -def _uses_npx(cmd: list[str]) -> bool: - base = os.path.basename(cmd[0]).lower() - return base in ("npx", "npx.cmd") - - -def is_lighthouse_available() -> bool: - """Return True if lighthouse or npx is on PATH (so we can run Lighthouse).""" - try: - _lighthouse_cmd() - return True - except RuntimeError: - return False - - -def _preset_for_strategy(strategy: str) -> str: - """Map user strategy 'mobile'|'desktop' to Lighthouse CLI preset. Newer Lighthouse only accepts perf, experimental, desktop.""" - s = (strategy or "mobile").lower() - if s == "desktop": - return "desktop" - return "perf" # mobile -> perf (mobile-like throttling in current Lighthouse) - - -# Valid Lighthouse category IDs for --only-categories -LIGHTHOUSE_CATEGORY_IDS = {"performance", "accessibility", "best-practices", "seo", "pwa"} - - -def _parse_categories(categories: str | list[str] | None) -> list[str] | None: - """Return list of valid category IDs, or None to run all categories.""" - if categories is None: - return None - if isinstance(categories, str): - categories = [c.strip().lower() for c in categories.split(",") if c.strip()] - if not categories: - return None - out = [c for c in categories if c in LIGHTHOUSE_CATEGORY_IDS] - return out if out else None diff --git a/src/website_profiling/lighthouse/runner.py b/src/website_profiling/lighthouse/runner.py index 3b0d0ca..39eaf52 100644 --- a/src/website_profiling/lighthouse/runner.py +++ b/src/website_profiling/lighthouse/runner.py @@ -21,16 +21,10 @@ _LIGHTHOUSE_INSTALL_MSG, _LIGHTHOUSE_FLOW_MODES, _NPX_LIGHTHOUSE_LOCK, - _lighthouse_cmd, _lighthouse_flow_script, _node_cmd, _normalize_lighthouse_mode, - _parse_categories, - _preset_for_strategy, _repo_root, - _url_safe, - _uses_npx, - is_lighthouse_available, ) from .result_parser import _evidence_from_audit, extract_from_lighthouse_json, median_or_none @@ -175,10 +169,8 @@ def run_lighthouse_flow_once( flow_args.append("--categories=" + ",".join(categories)) if npx is not None: cmd = [npx, "-y", "-p", "lighthouse", "-p", "puppeteer", "node", script, *flow_args] - use_lock = True else: cmd = [node, script, *flow_args] - use_lock = False try: run_kwargs = { "capture_output": True, @@ -187,10 +179,8 @@ def run_lighthouse_flow_once( "timeout": 300, "cwd": _repo_root(), } - if use_lock: - with _NPX_LIGHTHOUSE_LOCK: - return subprocess.run(cmd, **run_kwargs) - return subprocess.run(cmd, **run_kwargs) + with _NPX_LIGHTHOUSE_LOCK: + return subprocess.run(cmd, **run_kwargs) except FileNotFoundError as e: raise RuntimeError(_LIGHTHOUSE_INSTALL_MSG) from e @@ -205,6 +195,10 @@ def run_lighthouse_once( wait_ms: int = 1500, ) -> subprocess.CompletedProcess: """Run lighthouse once; navigation uses CLI, snapshot/timespan use User Flow API.""" + from urllib.parse import urlparse as _urlparse + parsed_scheme = _urlparse(url).scheme.lower() + if parsed_scheme not in ("http", "https"): + raise ValueError(f"Lighthouse URL must use http or https, got: {url!r}") lh_mode = _normalize_lighthouse_mode(mode) if lh_mode in _LIGHTHOUSE_FLOW_MODES: return run_lighthouse_flow_once( @@ -235,10 +229,8 @@ def run_lighthouse_once( "errors": "replace", "timeout": 300, } - if _uses_npx(base): - with _NPX_LIGHTHOUSE_LOCK: - return subprocess.run(cmd, **run_kwargs) - return subprocess.run(cmd, **run_kwargs) + with _NPX_LIGHTHOUSE_LOCK: + return subprocess.run(cmd, **run_kwargs) except FileNotFoundError as e: raise RuntimeError(_LIGHTHOUSE_INSTALL_MSG) from e diff --git a/src/website_profiling/llm/audit_summary.py b/src/website_profiling/llm/audit_summary.py index 0943f0d..bdb786b 100644 --- a/src/website_profiling/llm/audit_summary.py +++ b/src/website_profiling/llm/audit_summary.py @@ -3,6 +3,8 @@ from typing import Any +from ..scoring import round_half_up + def rank_issues_by_traffic( categories: list[dict[str, Any]], @@ -51,17 +53,10 @@ def generate_audit_executive_summary( gsc_pages = gsc.get("pages") if isinstance(gsc, dict) else [] top_issues = rank_issues_by_traffic(categories, gsc_pages)[:5] - lines = [] scores = [c.get("score") for c in categories if isinstance(c.get("score"), (int, float))] - if scores: - avg = round(sum(scores) / len(scores)) - lines.append(f"Overall audit health score: {avg}/100.") - if top_issues: - lines.append("Top traffic-impacting issues:") - for i, iss in enumerate(top_issues[:3], 1): - lines.append(f"{i}. [{iss.get('priority')}] {iss.get('message')} ({iss.get('url') or 'site-wide'})") + avg = round_half_up(sum(scores) / len(scores)) if scores else None - fallback = "\n".join(lines) if lines else "No major issues detected in this audit run." + fallback = _deterministic_summary_text(avg, top_issues) source = "deterministic" priorities: list[str] = [] @@ -72,11 +67,13 @@ def generate_audit_executive_summary( fallback = str(llm_result["summary"]) priorities = llm_result.get("priorities") or [] else: - lines.append("(LLM summary unavailable — using deterministic summary.)") - fallback = "\n".join(lines) + fallback = _deterministic_summary_text(avg, top_issues, llm_unavailable=True) elif llm_is_enabled(cfg or {}): - lines.append("(Enable audit executive summary in AI task settings for LLM narrative.)") - fallback = "\n".join(lines) + fallback = _deterministic_summary_text( + avg, + top_issues, + hint_enable_llm=True, + ) return { "ok": True, @@ -87,6 +84,30 @@ def generate_audit_executive_summary( } +def _deterministic_summary_text( + avg: int | None, + top_issues: list[dict[str, Any]], + *, + llm_unavailable: bool = False, + hint_enable_llm: bool = False, +) -> str: + """Short narrative for UI; structured score/issues render separately in the app.""" + if top_issues: + msg = "Prioritize fixes below by severity and Search Console traffic impact." + elif avg is not None and avg >= 80: + msg = "Site health looks strong. Keep monitoring crawl and Search Console trends." + elif avg is not None: + msg = "Review category scores and address high-priority issues to improve overall health." + else: + msg = "No major issues detected in this audit run." + + if llm_unavailable: + msg = f"{msg} (AI summary unavailable — showing structured overview only.)" + elif hint_enable_llm: + msg = f"{msg} Enable audit executive summary in AI settings for an AI narrative." + return msg + + def _audit_summary_llm_enabled(cfg: dict[str, str]) -> bool: v = str(cfg.get("llm_enable_audit_summary", "true")).lower() return v in ("true", "1", "yes") @@ -104,7 +125,7 @@ def _generate_llm_executive_summary( categories = report_payload.get("categories") or [] scores = [c.get("score") for c in categories if isinstance(c.get("score"), (int, float))] - avg = round(sum(scores) / len(scores)) if scores else None + avg = round_half_up(sum(scores) / len(scores)) if scores else None payload = { "health_score": avg, "category_scores": [ diff --git a/src/website_profiling/reporting/builder.py b/src/website_profiling/reporting/builder.py index 24660ee..f2c14ec 100644 --- a/src/website_profiling/reporting/builder.py +++ b/src/website_profiling/reporting/builder.py @@ -18,6 +18,7 @@ from ..llm.enrich import cluster_keywords_llm, run_llm_enrichment from ..llm_config import load_llm_config_from_db, llm_is_enabled from ..security_scanner import run_security_scan +from ..scoring import round_half_up from .categories import build_categories from .content_analytics import ( _build_content_analytics, @@ -1079,7 +1080,7 @@ def _bool_col(col): scores.append(int(float(c.get("score")))) except (TypeError, ValueError): continue - prop_health = round(sum(scores) / len(scores)) if scores else None + prop_health = round_half_up(sum(scores) / len(scores)) if scores else None prop_count = int(portfolio.get("count") or 0) median = portfolio.get("median_health_score") bench: dict[str, Any] = { diff --git a/src/website_profiling/reporting/compare_payload.py b/src/website_profiling/reporting/compare_payload.py index fc187e6..87112df 100644 --- a/src/website_profiling/reporting/compare_payload.py +++ b/src/website_profiling/reporting/compare_payload.py @@ -4,6 +4,8 @@ from typing import Any from urllib.parse import urlparse +from ..scoring import round_half_up + _PRIORITY_ORDER = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3} _LH_DELTA_THRESHOLD = 5 _ISSUE_DELTA_CAP = 100 @@ -50,7 +52,7 @@ def _score_from_categories(categories: list[Any]) -> int | None: for c in categories if isinstance(c, dict) and isinstance(c.get("score"), (int, float)) ] - return round(sum(scores) / len(scores)) if scores else None + return round_half_up(sum(scores) / len(scores)) if scores else None def _issue_key(url: str, category: str, message: str) -> str: diff --git a/src/website_profiling/reporting/crawl_segments.py b/src/website_profiling/reporting/crawl_segments.py index ee0c25d..4dfa960 100644 --- a/src/website_profiling/reporting/crawl_segments.py +++ b/src/website_profiling/reporting/crawl_segments.py @@ -4,6 +4,8 @@ from typing import Any from urllib.parse import urlparse +from ..scoring import round_half_up + def build_crawl_segments( df, @@ -18,7 +20,7 @@ def build_crawl_segments( for c in categories if isinstance(c, dict) and isinstance(c.get("score"), (int, float)) ] - overall = round(sum(overall_scores) / len(overall_scores)) if overall_scores else None + overall = round_half_up(sum(overall_scores) / len(overall_scores)) if overall_scores else None segments: list[dict[str, Any]] = [] for prefix in path_prefixes: diff --git a/src/website_profiling/reporting/indexation.py b/src/website_profiling/reporting/indexation.py index 12f0306..8f8394e 100644 --- a/src/website_profiling/reporting/indexation.py +++ b/src/website_profiling/reporting/indexation.py @@ -31,7 +31,8 @@ def _gsc_page_urls(google_data: dict[str, Any] | None) -> list[str]: if not google_data: return [] gsc = google_data.get("gsc") if isinstance(google_data.get("gsc"), dict) else {} - pages = gsc.get("pages") if isinstance(gsc.get("pages"), list) else [] + raw = gsc.get("top_pages") or gsc.get("pages") + pages = raw if isinstance(raw, list) else [] out: list[str] = [] for row in pages: if isinstance(row, dict): @@ -45,7 +46,8 @@ def _gsc_by_page(google_data: dict[str, Any] | None) -> dict[str, dict]: if not google_data: return {} gsc = google_data.get("gsc") if isinstance(google_data.get("gsc"), dict) else {} - pages = gsc.get("pages") if isinstance(gsc.get("pages"), list) else [] + raw = gsc.get("top_pages") or gsc.get("pages") + pages = raw if isinstance(raw, list) else [] out: dict[str, dict] = {} for row in pages: if isinstance(row, dict): diff --git a/src/website_profiling/reporting/optional_audits.py b/src/website_profiling/reporting/optional_audits.py index 32c79f1..f95c79f 100644 --- a/src/website_profiling/reporting/optional_audits.py +++ b/src/website_profiling/reporting/optional_audits.py @@ -4,6 +4,7 @@ import json import re import sys +import threading from typing import Any, Optional from urllib.parse import urlparse @@ -15,6 +16,7 @@ from .categories import _issue, _sort_issues _WAYBACK_CACHE: dict[str, bool] = {} +_WAYBACK_LOCK: threading.Lock = threading.Lock() def _parse_page_analysis(raw: object) -> dict[str, Any]: @@ -221,8 +223,10 @@ def wayback_issues(df: pd.DataFrame, *, max_lookups: int = 15) -> list[dict]: if not url: continue cache_key = url.rstrip("/") - if cache_key in _WAYBACK_CACHE: - if _WAYBACK_CACHE[cache_key]: + with _WAYBACK_LOCK: + cached = _WAYBACK_CACHE.get(cache_key, None) + if cached is not None: + if cached: issues.append(_issue( "404 URL has Wayback snapshot (Estimated).", url=url, @@ -240,7 +244,8 @@ def wayback_issues(df: pd.DataFrame, *, max_lookups: int = 15) -> list[dict]: data = resp.json() snap = (data.get("archived_snapshots") or {}).get("closest") or {} available = bool(snap.get("available")) - _WAYBACK_CACHE[cache_key] = available + with _WAYBACK_LOCK: + _WAYBACK_CACHE[cache_key] = available if available: ts = snap.get("timestamp") or "unknown" issues.append(_issue( @@ -251,7 +256,8 @@ def wayback_issues(df: pd.DataFrame, *, max_lookups: int = 15) -> list[dict]: )) looked += 1 except Exception: - _WAYBACK_CACHE[cache_key] = False + with _WAYBACK_LOCK: + _WAYBACK_CACHE[cache_key] = False continue return issues diff --git a/src/website_profiling/scoring.py b/src/website_profiling/scoring.py new file mode 100644 index 0000000..dbcb276 --- /dev/null +++ b/src/website_profiling/scoring.py @@ -0,0 +1,9 @@ +"""Shared score rounding helpers.""" +from __future__ import annotations + +import math + + +def round_half_up(value: float) -> int: + """Round to nearest integer, halves away from zero (not banker's rounding).""" + return math.floor(value + 0.5) diff --git a/src/website_profiling/security_scanner.py b/src/website_profiling/security_scanner.py index f545299..4affb2b 100644 --- a/src/website_profiling/security_scanner.py +++ b/src/website_profiling/security_scanner.py @@ -256,7 +256,9 @@ def _passive_html_checks( evidence=pname, )) break - except Exception: + except Exception as exc: + import sys + print(f" security_scanner: skipping {url}: {type(exc).__name__}: {exc}", file=sys.stderr) continue return findings diff --git a/src/website_profiling/tools/audit_tools/geo_list_tools.py b/src/website_profiling/tools/audit_tools/geo_list_tools.py index 4aaaa24..32d8d7a 100644 --- a/src/website_profiling/tools/audit_tools/geo_list_tools.py +++ b/src/website_profiling/tools/audit_tools/geo_list_tools.py @@ -74,7 +74,7 @@ def _aeo_score(rec: dict[str, Any]) -> dict[str, Any]: def _parse_robots_txt(domain: str) -> str: if not domain: return "" - base = f"https://{domain.lstrip('https://').lstrip('http://').split('/')[0]}" + base = f"https://{re.sub(r'^https?://', '', domain).split('/')[0]}" url = urljoin(base + "/", "robots.txt") try: resp = requests.get(url, timeout=8, headers={"User-Agent": "SiteAudit/1.0"}) diff --git a/src/website_profiling/tools/audit_tools/geo_tools.py b/src/website_profiling/tools/audit_tools/geo_tools.py index 2c4a729..a61a8d7 100644 --- a/src/website_profiling/tools/audit_tools/geo_tools.py +++ b/src/website_profiling/tools/audit_tools/geo_tools.py @@ -20,7 +20,7 @@ def _fetch_llms_txt(domain: str) -> dict[str, Any]: if not domain: return {"found": False, "error": "domain unknown"} - base = f"https://{domain.lstrip('https://').lstrip('http://').split('/')[0]}" + base = f"https://{re.sub(r'^https?://', '', domain).split('/')[0]}" paths = ("/llms.txt", "/.well-known/llms.txt") for path in paths: url = urljoin(base + "/", path.lstrip("/")) diff --git a/src/website_profiling/tools/audit_tools/lighthouse.py b/src/website_profiling/tools/audit_tools/lighthouse.py index 70944b9..0d8a546 100644 --- a/src/website_profiling/tools/audit_tools/lighthouse.py +++ b/src/website_profiling/tools/audit_tools/lighthouse.py @@ -234,7 +234,7 @@ def list_lighthouse_poor_best_practices_pages(conn: Connection, ctx: AuditToolCo def list_lighthouse_cwv_failures(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: - from ...lighthouse.runner import CLS_GOOD, LCP_GOOD_MS + from ...lighthouse.runner import CLS_GOOD, LCP_GOOD_MS, TBT_GOOD_MS scoped = ctx.with_args(args) payload = scoped.load_payload(conn) @@ -263,7 +263,7 @@ def list_lighthouse_cwv_failures(conn: Connection, ctx: AuditToolContext, args: except (TypeError, ValueError): pass try: - if tbt is not None and float(tbt) > 200: + if tbt is not None and float(tbt) > TBT_GOOD_MS: failed.append("tbt") except (TypeError, ValueError): pass diff --git a/src/website_profiling/tools/audit_tools/report.py b/src/website_profiling/tools/audit_tools/report.py index d5c25b6..cf38b17 100644 --- a/src/website_profiling/tools/audit_tools/report.py +++ b/src/website_profiling/tools/audit_tools/report.py @@ -50,13 +50,16 @@ def _iter_category_issues(payload: dict[str, Any]) -> list[dict[str, Any]]: return rows +from ...scoring import round_half_up + + def _health_score(payload: dict[str, Any]) -> int | None: scores = [ float(c.get("score")) for c in (payload.get("categories") or []) if isinstance(c, dict) and isinstance(c.get("score"), (int, float)) ] - return round(sum(scores) / len(scores)) if scores else None + return round_half_up(sum(scores) / len(scores)) if scores else None def _issue_counts(issues: list[dict[str, Any]]) -> dict[str, int]: diff --git a/src/website_profiling/tools/export_audit_data.py b/src/website_profiling/tools/export_audit_data.py index 56974a4..e6d28a0 100644 --- a/src/website_profiling/tools/export_audit_data.py +++ b/src/website_profiling/tools/export_audit_data.py @@ -6,6 +6,7 @@ from typing import Any, Optional from ..reporting.terminology import category_display_name +from ..scoring import round_half_up _GLOSSARY_ROWS: list[tuple[str, str]] = [ ("Crawl", "URLs fetched by the site spider (status codes, titles, inlinks)."), @@ -191,7 +192,7 @@ def _overall_score(payload: dict[str, Any]) -> Optional[int]: continue if not scores: return None - return int(round(sum(scores) / len(scores))) + return round_half_up(sum(scores) / len(scores)) def _score_band(score: Optional[float]) -> tuple[str, str]: diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 6227ecf..31d5511 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -3,7 +3,7 @@ import pandas as pd -from website_profiling.analysis.local import compute_duplicate_groups, simhash_64 +from website_profiling.analysis.local import compute_duplicate_groups, run_local_enrichment, simhash_64 def test_simhash_identical_text_same_hash(): @@ -39,6 +39,40 @@ def test_duplicate_groups_fuzzy_merge(): "analysis_fuzzy_threshold": "90", "analysis_dup_max_pages": "100", } - groups, url_gid = compute_duplicate_groups(df, cfg) + groups, url_gid, warnings = compute_duplicate_groups(df, cfg) assert len(groups) >= 1 assert url_gid.get("https://example.com/a") == url_gid.get("https://example.com/b") + assert warnings == [] + + +def test_duplicate_groups_emit_warnings_when_url_caps_exceeded(monkeypatch) -> None: + monkeypatch.setattr( + "website_profiling.analysis.local._import_rapidfuzz", + lambda: type("F", (), {"token_set_ratio": staticmethod(lambda _a, _b: 0)})(), + ) + rows = [] + for i in range(3): + rows.append( + { + "url": f"https://example.com/p{i}", + "status": "200", + "content_type": "text/html", + "title": f"Unique page title number {i}", + "meta_description": "desc", + "h1": "h1", + "content_excerpt": " ".join(["content"] * 50), + } + ) + df = pd.DataFrame(rows) + cfg = { + "enable_duplicate_detection": "true", + "analysis_simhash_hamming": "3", + "analysis_simhash_max_urls": "1", + "analysis_fuzzy_max_urls": "1", + } + _groups, _url_gid, warnings = compute_duplicate_groups(df, cfg) + assert any("SimHash" in w for w in warnings) + assert any("fuzzy" in w for w in warnings) + + bundle = run_local_enrichment(df, cfg) + assert any("SimHash" in w for w in bundle.get("ml_errors") or []) diff --git a/tests/test_analysis_crawl_stores_edge_unit.py b/tests/test_analysis_crawl_stores_edge_unit.py index 3bfc891..584d508 100644 --- a/tests/test_analysis_crawl_stores_edge_unit.py +++ b/tests/test_analysis_crawl_stores_edge_unit.py @@ -85,7 +85,7 @@ def test_analysis_local_duplicate_and_language_paths(monkeypatch) -> None: {"url": "https://a.com/3", "status": "404", "content_type": "text/html", "title": long_text}, ] ) - groups, mapping = local.compute_duplicate_groups( + groups, mapping, _warnings = local.compute_duplicate_groups( df, { "enable_duplicate_detection": "true", @@ -529,7 +529,7 @@ def find_all(self, name=None, **kwargs): {"url": "https://a.com/3", "status": "200", "content_type": "text/html", "title": "word " * 20}, ] ) - groups, _ = local.compute_duplicate_groups( + groups, _mapping, _warnings = local.compute_duplicate_groups( dup_df, {"enable_duplicate_detection": "true", "analysis_simhash_hamming": "64"} ) assert groups @@ -543,7 +543,7 @@ def find_all(self, name=None, **kwargs): title = f"unique duplicate group title number {i} " * 4 many_rows.append({"url": f"https://a.com/{i}a", "status": "200", "content_type": "text/html", "title": title}) many_rows.append({"url": f"https://a.com/{i}b", "status": "200", "content_type": "text/html", "title": title}) - many_groups, _ = local.compute_duplicate_groups( + many_groups, _mapping, _warnings = local.compute_duplicate_groups( pd.DataFrame(many_rows), {"enable_duplicate_detection": "true", "analysis_fuzzy_threshold": "90"}, ) diff --git a/tests/test_commands_config_stores_edge_unit.py b/tests/test_commands_config_stores_edge_unit.py index 0057d6d..ceab5a6 100644 --- a/tests/test_commands_config_stores_edge_unit.py +++ b/tests/test_commands_config_stores_edge_unit.py @@ -68,7 +68,7 @@ def test_compute_duplicate_groups_full_paths(monkeypatch) -> None: }, ] ) - groups, mapping = local.compute_duplicate_groups( + groups, mapping, _warnings = local.compute_duplicate_groups( df, {"enable_duplicate_detection": "true", "analysis_simhash_hamming": "3", "analysis_fuzzy_threshold": "90"} ) assert len(groups) >= 1 @@ -76,7 +76,7 @@ def test_compute_duplicate_groups_full_paths(monkeypatch) -> None: # short fingerprint skipped df_short = pd.DataFrame([{"url": "https://a.com/x", "status": "200", "content_type": "text/html", "title": "hi"}]) - g2, m2 = local.compute_duplicate_groups(df_short, {"enable_duplicate_detection": "true"}) + g2, m2, _w2 = local.compute_duplicate_groups(df_short, {"enable_duplicate_detection": "true"}) assert g2 == [] diff --git a/tests/test_common_analysis_commands_db_unit.py b/tests/test_common_analysis_commands_db_unit.py index a61d180..d244466 100644 --- a/tests/test_common_analysis_commands_db_unit.py +++ b/tests/test_common_analysis_commands_db_unit.py @@ -361,7 +361,7 @@ def test_compute_duplicate_groups_hamming_and_fuzzy(monkeypatch) -> None: "analysis_fuzzy_threshold": "90", "analysis_dup_max_pages": "10", } - groups, mapping = local.compute_duplicate_groups(df, cfg) + groups, mapping, _warnings = local.compute_duplicate_groups(df, cfg) assert len(groups) >= 1 assert any(k.startswith("dup_") for k in mapping.values()) @@ -384,7 +384,7 @@ def test_compute_language_signals_enabled(monkeypatch) -> None: def test_run_local_enrichment_success(monkeypatch) -> None: from website_profiling.analysis import local - monkeypatch.setattr(local, "compute_duplicate_groups", lambda *_a, **_k: ([{"id": "dup_0"}], {"https://a.com": "dup_0"})) + monkeypatch.setattr(local, "compute_duplicate_groups", lambda *_a, **_k: ([{"id": "dup_0"}], {"https://a.com": "dup_0"}, [])) monkeypatch.setattr( local, "compute_language_signals", @@ -911,7 +911,7 @@ def test_historical_backup_success_and_restore_fallback(monkeypatch, tmp_path) - from website_profiling.db import historical as h monkeypatch.setattr(h, "get_data_dir", lambda: str(tmp_path)) - monkeypatch.setattr(h, "get_database_url", lambda: "postgres://u:p@h/db") + monkeypatch.setattr(h, "get_database_url", lambda: "postgres://u:p@h:5432/db") dump_path = tmp_path / "backups" / "out.dump" diff --git a/tests/test_config_schema_keys.py b/tests/test_config_schema_keys.py index 9395716..bfcd483 100644 --- a/tests/test_config_schema_keys.py +++ b/tests/test_config_schema_keys.py @@ -89,6 +89,8 @@ "enable_language_detection", "analysis_fuzzy_threshold", "analysis_simhash_hamming", + "analysis_simhash_max_urls", + "analysis_fuzzy_max_urls", "analysis_dup_max_pages", "run_crawl", "run_report", diff --git a/tests/test_crawl_db_writer_imports.py b/tests/test_crawl_db_writer_imports.py index 17c3459..164bb38 100644 --- a/tests/test_crawl_db_writer_imports.py +++ b/tests/test_crawl_db_writer_imports.py @@ -101,6 +101,31 @@ def __exit__(self, _t, _v, _tb): writer.raise_if_failed() +def test_crawl_db_writer_enqueue_short_circuits_after_error(monkeypatch: pytest.MonkeyPatch) -> None: + """enqueue/enqueue_html should be no-ops once _error is set (avoids unbounded queue growth).""" + from website_profiling.crawl.crawler import _CrawlDbWriter + + class _BrokenCtx: + def __enter__(self): + raise RuntimeError("db down") + + def __exit__(self, _t, _v, _tb): + return False + + monkeypatch.setattr("website_profiling.db.db_session", lambda: _BrokenCtx()) + + writer = _CrawlDbWriter(crawl_run_id=1, batch_size=50, store_page_html=True) + writer.enqueue({"url": "https://a.com"}) + writer.finish() + writer.run() # sets _error + + # Queue should be empty now; further enqueues must be dropped + assert not writer._queue.qsize() + writer.enqueue({"url": "https://b.com"}) + writer.enqueue_html({"url": "https://b.com", "html": ""}) + assert not writer._queue.qsize() + + def test_crawl_db_writer_run_does_not_import_error() -> None: """ Regression: during the db/ split, _CrawlDbWriter.run() imported helpers from diff --git a/tests/test_crux_fetch.py b/tests/test_crux_fetch.py new file mode 100644 index 0000000..ae470e7 --- /dev/null +++ b/tests/test_crux_fetch.py @@ -0,0 +1,30 @@ +"""Tests for CrUX fetch helpers.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from website_profiling.integrations.crux.fetch import fetch_crux_origin_metrics + + +def test_fetch_crux_non_numeric_p75_does_not_raise() -> None: + payload = { + "record": { + "metrics": { + "largest_contentful_paint": {"percentiles": {"p75": "n/a"}, "histogram": []}, + "interaction_to_next_paint": {"percentiles": {"p75": None}, "histogram": []}, + "cumulative_layout_shift": {"percentiles": {"p75": "bad"}, "histogram": []}, + } + } + } + body = json.dumps(payload).encode("utf-8") + mock_resp = MagicMock() + mock_resp.read.return_value = body + mock_resp.__enter__.return_value = mock_resp + mock_resp.__exit__.return_value = False + + with patch("website_profiling.integrations.crux.fetch.urlopen", return_value=mock_resp): + out = fetch_crux_origin_metrics("https://example.com", api_key="test-key") + + assert out["ok"] is True + assert out["pass"] == {"lcp": False, "inp": False, "cls": False} diff --git a/tests/test_fetchers_sitemap_config_unit.py b/tests/test_fetchers_sitemap_config_unit.py index d224b1d..d53511f 100644 --- a/tests/test_fetchers_sitemap_config_unit.py +++ b/tests/test_fetchers_sitemap_config_unit.py @@ -39,11 +39,13 @@ def test_fetch_result_as_tuple(): def test_static_fetcher_network_error_returns_empty_result(): + import requests as req_module + class BoomSession: headers = {} def get(self, *_a, **_k): - raise ConnectionError("offline") + raise req_module.exceptions.ConnectionError("offline") def close(self): pass diff --git a/tests/test_indexation_coverage.py b/tests/test_indexation_coverage.py index 1472d6b..a7bc498 100644 --- a/tests/test_indexation_coverage.py +++ b/tests/test_indexation_coverage.py @@ -22,22 +22,44 @@ def test_success_urls_filters_non_200() -> None: assert urls == ["https://example.com/a"] -def test_gsc_page_urls_extracts_pages() -> None: - google = {"gsc": {"pages": [{"page": "https://example.com/x"}, {"url": "https://example.com/y"}]}} +def test_gsc_page_urls_extracts_top_pages() -> None: + google = { + "gsc": { + "top_pages": [ + {"page": "https://example.com/x"}, + {"url": "https://example.com/y"}, + ] + } + } assert len(_gsc_page_urls(google)) == 2 +def test_gsc_page_urls_legacy_pages_fallback() -> None: + google = {"gsc": {"pages": [{"page": "https://example.com/x"}]}} + assert _gsc_page_urls(google) == ["https://example.com/x"] + + @patch("website_profiling.reporting.indexation.discover_sitemap_urls") def test_build_indexation_coverage_lists(mock_sitemap) -> None: mock_sitemap.return_value = ["https://example.com/", "https://example.com/sitemap-only"] df = pd.DataFrame([{"url": "https://example.com/", "status": "200"}]) - google = {"gsc": {"pages": [{"page": "https://example.com/gsc-only"}]}} + google = {"gsc": {"top_pages": [{"page": "https://example.com/gsc-only"}]}} out = build_indexation_coverage(df, "https://example.com/", google) assert out["counts"]["crawled"] == 1 assert out["counts"]["sitemap_only"] >= 1 assert "sitemap_only" in out["lists"] +@patch("website_profiling.reporting.indexation.discover_sitemap_urls") +def test_build_indexation_coverage_gsc_not_crawled(mock_sitemap) -> None: + mock_sitemap.return_value = [] + df = pd.DataFrame([{"url": "https://example.com/", "status": "200"}]) + google = {"gsc": {"top_pages": [{"page": "https://example.com/gsc-only"}]}} + out = build_indexation_coverage(df, "https://example.com/", google) + assert out["counts"]["gsc_pages"] == 1 + assert "https://example.com/gsc-only" in out["lists"]["gsc_not_crawled"] + + def test_success_urls_empty_dataframe() -> None: assert _success_urls(pd.DataFrame()) == [] diff --git a/tests/test_keyword_enrich.py b/tests/test_keyword_enrich.py new file mode 100644 index 0000000..f5bd465 --- /dev/null +++ b/tests/test_keyword_enrich.py @@ -0,0 +1,35 @@ +"""Tests for keyword enrichment math helpers.""" +from __future__ import annotations + +import pytest +from website_profiling.integrations.google.keyword_enrich import ( + CTR_CURVE, + ctr_as_fraction, + industry_ctr, + opportunity_clicks, +) + + +def test_ctr_as_fraction_percent_values() -> None: + assert ctr_as_fraction(2.8) == pytest.approx(0.028) + assert ctr_as_fraction(100) == 1.0 + assert ctr_as_fraction(0.5) == 0.005 + + +def test_ctr_as_fraction_clamps_above_100_percent() -> None: + assert ctr_as_fraction(150) == 1.0 + + +def test_opportunity_clicks_uses_ceil_for_position_slot() -> None: + # Position 4.1 -> slot 5 (conservative), not slot 4 from round(). + clicks = opportunity_clicks(1000, 4.1, target_pos=3) + assert clicks == int(1000 * (CTR_CURVE[3] - CTR_CURVE[5])) + + +def test_opportunity_clicks_boundary_position_three() -> None: + assert opportunity_clicks(1000, 3.0, target_pos=3) == 0 + + +def test_industry_ctr_uses_ceil() -> None: + assert industry_ctr(2.1) == CTR_CURVE[3] + assert industry_ctr(3.0) == CTR_CURVE[3] diff --git a/tests/test_missing_points_batch.py b/tests/test_missing_points_batch.py index d45ca0b..a223763 100644 --- a/tests/test_missing_points_batch.py +++ b/tests/test_missing_points_batch.py @@ -18,7 +18,7 @@ def test_compute_duplicate_groups_disabled_returns_empty() -> None: from website_profiling.analysis.local import compute_duplicate_groups df = pd.DataFrame([{"url": "https://a.com", "status": "200", "content_type": "text/html"}]) - groups, mapping = compute_duplicate_groups(df, {"enable_duplicate_detection": "false"}) + groups, mapping, _warnings = compute_duplicate_groups(df, {"enable_duplicate_detection": "false"}) assert groups == [] assert mapping == {} @@ -35,7 +35,7 @@ def test_compute_duplicate_groups_basic_cluster(monkeypatch) -> None: {"url": "https://a.com/2", "status": "200", "content_type": "text/html"}, ] ) - groups, mapping = local.compute_duplicate_groups(df, {"enable_duplicate_detection": "true"}) + groups, mapping, _warnings = local.compute_duplicate_groups(df, {"enable_duplicate_detection": "true"}) assert len(groups) == 1 assert mapping["https://a.com/1"].startswith("dup_") diff --git a/tests/test_roadmap_extras.py b/tests/test_roadmap_extras.py index 3c0ccc8..c2e4889 100644 --- a/tests/test_roadmap_extras.py +++ b/tests/test_roadmap_extras.py @@ -34,8 +34,9 @@ def test_executive_summary_deterministic() -> None: result = generate_audit_executive_summary(payload, {}) assert result["ok"] is True assert result["source"] == "deterministic" - assert "80" in result["summary"] assert len(result["top_issues"]) >= 1 + assert isinstance(result["summary"], str) + assert "Prioritize fixes below" in result["summary"] def test_executive_summary_empty_payload() -> None: diff --git a/tests/test_scoring.py b/tests/test_scoring.py new file mode 100644 index 0000000..ef259da --- /dev/null +++ b/tests/test_scoring.py @@ -0,0 +1,9 @@ +"""Tests for scoring helpers.""" +from __future__ import annotations + +from website_profiling.scoring import round_half_up + + +def test_round_half_up_away_from_bankers_rounding() -> None: + assert round_half_up(49.5) == 50 + assert round_half_up(50.5) == 51 diff --git a/tests/test_serp_estimates.py b/tests/test_serp_estimates.py new file mode 100644 index 0000000..7a08c8f --- /dev/null +++ b/tests/test_serp_estimates.py @@ -0,0 +1,26 @@ +"""Tests for SERP competition estimates.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from website_profiling.integrations.serp.estimates import fetch_serp_features + + +def test_standard_serp_competition_normalized_to_72() -> None: + payload = { + "organic_results": [{}] * 10, + "answer_box": {"snippet": "x"}, + } + body = json.dumps(payload).encode("utf-8") + mock_resp = MagicMock() + mock_resp.read.return_value = body + mock_resp.__enter__.return_value = mock_resp + mock_resp.__exit__.return_value = False + + with patch("website_profiling.integrations.serp.estimates.urllib.request.urlopen", return_value=mock_resp): + out = fetch_serp_features("seo tools", "key") + + assert out["ok"] is True + assert out["estimated_competition"] == 72 + assert out["provenance"] == "Estimated (heuristic-v1)" diff --git a/web/app/api/ai/fix-suggestion/route.ts b/web/app/api/ai/fix-suggestion/route.ts index 054df7a..78ed2a8 100644 --- a/web/app/api/ai/fix-suggestion/route.ts +++ b/web/app/api/ai/fix-suggestion/route.ts @@ -63,13 +63,22 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { stdout += c.toString(); }); proc.stdin?.write(JSON.stringify(payload)); proc.stdin?.end(); + proc.on('error', () => { + clearTimeout(timer); + resolve(NextResponse.json({ error: 'Fix suggestion failed: could not start Python process' }, { status: 500 })); + }); proc.on('close', (code) => { + clearTimeout(timer); const parsed = parsePythonJsonStdout(stdout); if (code === 0 && parsed) { resolve(NextResponse.json(parsed)); return; } - resolve(NextResponse.json({ error: stdout.trim() || 'Fix suggestion failed' }, { status: 500 })); + resolve(NextResponse.json({ error: 'Fix suggestion failed' }, { status: 500 })); }); + const timer = setTimeout(() => { + try { proc.kill(); } catch { /* ignore */ } + resolve(NextResponse.json({ error: 'Fix suggestion timed out after 90s' }, { status: 504 })); + }, 90_000); }); }; diff --git a/web/app/api/alerts/check/route.ts b/web/app/api/alerts/check/route.ts index 0ef27fa..0d3d86e 100644 --- a/web/app/api/alerts/check/route.ts +++ b/web/app/api/alerts/check/route.ts @@ -2,7 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { spawn } from 'child_process'; import path from 'path'; -import { resolvePythonExecutable } from '@/server/resolvePython'; +import { resolvePythonExecutable, formatPythonSpawnError } from '@/server/resolvePython'; import { getRepoRoot } from '@/server/pipelineSpawnEnv'; import type { ApiRouteHandler } from '@/types/api'; @@ -50,6 +50,9 @@ print(json.dumps({"alerts": alerts, "webhook_sent": webhook_sent})) }); let stdout = ''; proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); + proc.on('error', (err: Error) => { + resolve(NextResponse.json({ error: formatPythonSpawnError(err, pythonExe, repoRoot) }, { status: 500 })); + }); proc.on('close', (code) => { try { const parsed = JSON.parse(stdout.trim() || '{}'); diff --git a/web/app/api/backlinks/competitor-import/route.ts b/web/app/api/backlinks/competitor-import/route.ts index 404b7dc..151289a 100644 --- a/web/app/api/backlinks/competitor-import/route.ts +++ b/web/app/api/backlinks/competitor-import/route.ts @@ -58,13 +58,16 @@ print(json.dumps(build_competitor_domain_gap(our, payload.get("competitor") or " }), ); proc.stdin?.end(); + proc.on('error', () => { + resolve(NextResponse.json({ error: 'Import failed: could not start Python process' }, { status: 500 })); + }); proc.on('close', (code) => { const parsed = parsePythonJsonStdout(stdout); if (code === 0 && parsed) { resolve(NextResponse.json({ gap: parsed })); return; } - resolve(NextResponse.json({ error: stdout.trim() || 'Import failed' }, { status: 500 })); + resolve(NextResponse.json({ error: 'Competitor backlink import failed' }, { status: 500 })); }); }); }; diff --git a/web/app/api/backlinks/third-party-import/route.ts b/web/app/api/backlinks/third-party-import/route.ts index 363fcd1..fe1ef91 100644 --- a/web/app/api/backlinks/third-party-import/route.ts +++ b/web/app/api/backlinks/third-party-import/route.ts @@ -77,18 +77,16 @@ print(json.dumps(result)) }), ); proc.stdin?.end(); + proc.on('error', () => { + resolve(NextResponse.json({ error: 'Import failed: could not start Python process' }, { status: 500 })); + }); proc.on('close', (code) => { const parsed = parsePythonJsonStdout(stdout); if (code === 0 && parsed) { resolve(NextResponse.json(parsed)); return; } - resolve( - NextResponse.json( - { error: (stderr || stdout).trim() || 'Import failed' }, - { status: 500 }, - ), - ); + resolve(NextResponse.json({ error: 'Third-party backlink import failed' }, { status: 500 })); }); }); }; diff --git a/web/app/api/chat/artifacts/[id]/route.ts b/web/app/api/chat/artifacts/[id]/route.ts index 942ac29..7b60731 100644 --- a/web/app/api/chat/artifacts/[id]/route.ts +++ b/web/app/api/chat/artifacts/[id]/route.ts @@ -3,7 +3,7 @@ import { spawn } from 'child_process'; import path from 'path'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { requireApiAuthForChat } from '@/server/auth'; -import { resolvePythonExecutable } from '@/server/resolvePython'; +import { resolvePythonExecutable, formatPythonSpawnError } from '@/server/resolvePython'; import type { ApiRouteHandlerWithParams } from '@/types/api'; export const runtime = 'nodejs'; @@ -62,6 +62,9 @@ export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( proc.stderr.on('data', (c) => { err += c.toString(); }); + proc.on('error', (spawnErr: Error) => { + resolve(NextResponse.json({ error: formatPythonSpawnError(spawnErr, python, REPO_ROOT) }, { status: 500 })); + }); proc.on('close', (code) => { if (code !== 0) { resolve(NextResponse.json({ error: err.trim() || 'Artifact read failed' }, { status: 500 })); diff --git a/web/app/api/integrations/bing/sync/route.ts b/web/app/api/integrations/bing/sync/route.ts index bee7e96..cf04374 100644 --- a/web/app/api/integrations/bing/sync/route.ts +++ b/web/app/api/integrations/bing/sync/route.ts @@ -1,7 +1,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { spawn } from 'child_process'; import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import { resolvePythonExecutable, parsePythonJsonStdout, formatPythonSpawnError } from '@/server/resolvePython'; import { loadPipelineConfig } from '@/server/pipelineConfig'; import type { ApiRouteHandler } from '@/types/api'; @@ -46,6 +46,9 @@ print(json.dumps(fetch_bing_backlinks_summary(api_key, site_url))) }); let stdout = ''; proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); + proc.on('error', (err: Error) => { + resolve(NextResponse.json({ error: formatPythonSpawnError(err, pythonExe, repoRoot) }, { status: 500 })); + }); proc.on('close', (code) => { const parsed = parsePythonJsonStdout(stdout); if (code === 0 && parsed) { diff --git a/web/app/api/integrations/google/page-compare/route.ts b/web/app/api/integrations/google/page-compare/route.ts index 9f97134..1efc447 100644 --- a/web/app/api/integrations/google/page-compare/route.ts +++ b/web/app/api/integrations/google/page-compare/route.ts @@ -147,6 +147,9 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise { + clearTimeout(timer); resolve( NextResponse.json( { ok: false, error: formatPythonSpawnError(err, pythonExe, repoRoot), log }, @@ -73,6 +74,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { + clearTimeout(timer); try { const lines = stdout.trim().split('\n').filter(Boolean); const last = lines[lines.length - 1] || '{}'; @@ -103,7 +105,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { + const timer = setTimeout(() => { try { proc.kill(); } catch { diff --git a/web/app/api/integrations/google/test/route.ts b/web/app/api/integrations/google/test/route.ts index e5bd3a2..f66492e 100644 --- a/web/app/api/integrations/google/test/route.ts +++ b/web/app/api/integrations/google/test/route.ts @@ -34,6 +34,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { + clearTimeout(timer); const message = formatPythonSpawnError(err, pythonExe, repoRoot); resolve( NextResponse.json({ ok: false, log, error: message }, { status: 500 }), @@ -41,11 +42,12 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { + clearTimeout(timer); resolve(NextResponse.json({ ok: code === 0, log, exitCode: code })); }); // Safety timeout: 30s - setTimeout(() => { + const timer = setTimeout(() => { try { proc.kill(); } catch { /* ignore */ } resolve( NextResponse.json({ ok: false, log, error: 'Test timed out after 30s' }, { status: 504 }), diff --git a/web/app/api/issues/fix-suggestion/route.ts b/web/app/api/issues/fix-suggestion/route.ts index 95a1965..b3b27b6 100644 --- a/web/app/api/issues/fix-suggestion/route.ts +++ b/web/app/api/issues/fix-suggestion/route.ts @@ -60,13 +60,22 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { stdout += c.toString(); }); proc.stdin?.write(JSON.stringify(payload)); proc.stdin?.end(); + proc.on('error', () => { + clearTimeout(timer); + resolve(NextResponse.json({ error: 'Fix suggestion failed: could not start Python process' }, { status: 500 })); + }); proc.on('close', (code) => { + clearTimeout(timer); const parsed = parsePythonJsonStdout(stdout); if (code === 0 && parsed) { resolve(NextResponse.json(parsed)); return; } - resolve(NextResponse.json({ error: stdout.trim() || 'Fix suggestion failed' }, { status: 500 })); + resolve(NextResponse.json({ error: 'Fix suggestion failed' }, { status: 500 })); }); + const timer = setTimeout(() => { + try { proc.kill(); } catch { /* ignore */ } + resolve(NextResponse.json({ error: 'Fix suggestion timed out after 90s' }, { status: 504 })); + }, 90_000); }); }; diff --git a/web/app/api/keywords/competitor-import/route.ts b/web/app/api/keywords/competitor-import/route.ts index 2936942..b797687 100644 --- a/web/app/api/keywords/competitor-import/route.ts +++ b/web/app/api/keywords/competitor-import/route.ts @@ -60,6 +60,9 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { stderr += c.toString(); }); proc.stdin?.write(JSON.stringify({ propertyId, competitor, csvText })); proc.stdin?.end(); + proc.on('error', () => { + resolve(NextResponse.json({ error: 'Import failed: could not start Python process' }, { status: 500 })); + }); proc.on('close', (code) => { const parsed = parsePythonJsonStdout(stdout); if (code === 0 && parsed) { @@ -73,12 +76,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { stdout += c.toString(); }); proc.stdin?.write(JSON.stringify({ keyword, rows: body.rows || [], gaps: body.gaps || [] })); proc.stdin?.end(); + proc.on('error', () => { + clearTimeout(timer); + resolve(NextResponse.json({ error: 'Content brief failed: could not start Python process' }, { status: 500 })); + }); proc.on('close', (code) => { + clearTimeout(timer); const parsed = parsePythonJsonStdout(stdout); if (code === 0 && parsed) { resolve(NextResponse.json({ brief: parsed })); return; } - resolve(NextResponse.json({ error: stdout.trim() || 'Brief failed' }, { status: 500 })); + resolve(NextResponse.json({ error: 'Content brief generation failed' }, { status: 500 })); }); + const timer = setTimeout(() => { + try { proc.kill(); } catch { /* ignore */ } + resolve(NextResponse.json({ error: 'Content brief timed out after 90s' }, { status: 504 })); + }, 90_000); }); }; diff --git a/web/app/api/links/page-coach/route.ts b/web/app/api/links/page-coach/route.ts index 5a1e774..5dc9669 100644 --- a/web/app/api/links/page-coach/route.ts +++ b/web/app/api/links/page-coach/route.ts @@ -42,9 +42,6 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise((resolve) => { let log = ''; @@ -73,6 +70,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { + clearTimeout(timer); resolve( NextResponse.json( { ok: false, error: formatPythonSpawnError(err, pythonExe, repoRoot), log }, @@ -82,6 +80,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { + clearTimeout(timer); try { const lines = stdout.trim().split('\n').filter(Boolean); const last = lines[lines.length - 1] || '{}'; @@ -108,7 +107,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { + const timer = setTimeout(() => { try { proc.kill(); } catch { diff --git a/web/app/api/logs/upload/route.ts b/web/app/api/logs/upload/route.ts index 14ce9eb..8023632 100644 --- a/web/app/api/logs/upload/route.ts +++ b/web/app/api/logs/upload/route.ts @@ -49,13 +49,21 @@ print(json.dumps(analysis)) const meta = JSON.stringify({ start_url: startUrl, crawl_urls: crawlUrls }); const proc = spawn('python3', ['-c', script, meta], { cwd: repoRoot, shell: false }); let out = ''; + let errOut = ''; proc.stdout?.on('data', (c: Buffer) => { out += c.toString(); }); - proc.stderr?.on('data', (c: Buffer) => { out += c.toString(); }); + proc.stderr?.on('data', (c: Buffer) => { errOut += c.toString(); }); proc.stdin?.write(text); proc.stdin?.end(); + proc.on('error', (e) => reject(e)); proc.on('close', (code) => { - if (code !== 0) reject(new Error(out || 'parse failed')); - else resolve(JSON.parse(out.trim() || '{}') as Record); + if (code !== 0) reject(new Error(errOut || out || 'parse failed')); + else { + try { + resolve(JSON.parse(out.trim() || '{}') as Record); + } catch { + reject(new Error('Invalid JSON response from log parser')); + } + } }); }); await withDb(async (client) => { diff --git a/web/app/api/report/export-sitemap/route.ts b/web/app/api/report/export-sitemap/route.ts index 2b0b130..38a7c51 100644 --- a/web/app/api/report/export-sitemap/route.ts +++ b/web/app/api/report/export-sitemap/route.ts @@ -41,9 +41,12 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise { out += c.toString(); }); proc.stderr.on('data', (c) => { err += c.toString(); }); + proc.on('error', () => { + resolve(NextResponse.json({ error: 'Sitemap export failed: could not start Python process' }, { status: 500 })); + }); proc.on('close', (code) => { if (code !== 0) { - resolve(NextResponse.json({ error: err.trim() || 'Sitemap export failed' }, { status: 500 })); + resolve(NextResponse.json({ error: 'Sitemap export failed' }, { status: 500 })); return; } resolve( diff --git a/web/app/api/report/export-workbook/route.ts b/web/app/api/report/export-workbook/route.ts index fc74070..8c99ada 100644 --- a/web/app/api/report/export-workbook/route.ts +++ b/web/app/api/report/export-workbook/route.ts @@ -49,9 +49,12 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise { err += c.toString(); }); + proc.on('error', () => { + resolve(NextResponse.json({ error: 'Workbook export failed: could not start Python process' }, { status: 500 })); + }); proc.on('close', (code) => { if (code !== 0) { - resolve(NextResponse.json({ error: err.trim() || 'Workbook export failed' }, { status: 500 })); + resolve(NextResponse.json({ error: 'Workbook export failed' }, { status: 500 })); return; } const body = Buffer.concat(chunks); diff --git a/web/app/api/report/export/route.ts b/web/app/api/report/export/route.ts index 321f6d8..f8155ab 100644 --- a/web/app/api/report/export/route.ts +++ b/web/app/api/report/export/route.ts @@ -75,9 +75,12 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise { err += c.toString(); }); + proc.on('error', () => { + resolve(NextResponse.json({ error: 'Export failed: could not start Python process' }, { status: 500 })); + }); proc.on('close', (code) => { if (code !== 0) { - resolve(NextResponse.json({ error: err.trim() || 'Export failed' }, { status: 500 })); + resolve(NextResponse.json({ error: 'Export failed' }, { status: 500 })); return; } const body = Buffer.concat(chunks); diff --git a/web/app/api/schedule/check/route.ts b/web/app/api/schedule/check/route.ts index a83ae89..58be5f9 100644 --- a/web/app/api/schedule/check/route.ts +++ b/web/app/api/schedule/check/route.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { spawn } from 'child_process'; import path from 'path'; +import { resolvePythonExecutable, formatPythonSpawnError } from '@/server/resolvePython'; import type { ApiRouteHandler } from '@/types/api'; export const runtime = 'nodejs'; @@ -14,17 +15,21 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { - const proc = spawn('python3', ['-m', 'src.website_profiling.tools.schedule_runner'], { + const proc = spawn(pythonExe, ['-m', 'src.website_profiling.tools.schedule_runner'], { cwd: repoRoot, shell: false, }); let out = ''; proc.stdout?.on('data', (c) => { out += c.toString(); }); proc.stderr?.on('data', (c) => { out += c.toString(); }); + proc.on('error', (err: Error) => { + resolve(NextResponse.json({ error: formatPythonSpawnError(err, pythonExe, repoRoot) }, { status: 500 })); + }); proc.on('close', (code) => { const staleProc = spawn( - 'python3', + pythonExe, [ '-c', 'from website_profiling.tools.schedule_runner import run_gsc_links_staleness_alerts; import json; print(json.dumps(run_gsc_links_staleness_alerts()))', @@ -33,6 +38,16 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { staleOut += c.toString(); }); + staleProc.on('error', () => { + // Secondary staleness enrichment failed to spawn — degrade gracefully + // rather than hang, returning the primary result with an empty list. + resolve( + NextResponse.json( + { ok: code === 0, output: out.trim(), gscLinksStale: [] }, + { status: code === 0 ? 200 : 500 }, + ), + ); + }); staleProc.on('close', () => { let stale: unknown[] = []; try { diff --git a/web/app/globals.css b/web/app/globals.css index 7cebdcd..fa11f18 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -49,6 +49,24 @@ --chat-surface-hover: var(--app-bg-muted); --chat-glow: rgba(66, 97, 255, 0.08); --chat-glow-secondary: rgba(138, 79, 255, 0.04); + + /* Warm secondary accent (welcome moments / highlights) — complements the cool blue */ + --accent-warm: #f97316; + --accent-warm-soft: #fb923c; + --accent-2: #8b5cf6; + --surface-warm: rgba(249, 115, 22, 0.06); + + /* Elevation scale (depth) */ + --elevation-1: 0 1px 2px rgba(15, 23, 42, 0.06); + --elevation-2: 0 6px 16px -4px rgba(15, 23, 42, 0.1); + --elevation-3: 0 16px 36px -8px rgba(15, 23, 42, 0.18); + + /* Motion tokens (theme-independent) */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --dur-fast: 120ms; + --dur-base: 220ms; + --dur-slow: 420ms; } html.dark { @@ -91,6 +109,17 @@ html.dark { --chat-surface-hover: #28292a; --chat-glow: rgba(66, 97, 255, 0.12); --chat-glow-secondary: rgba(138, 79, 255, 0.06); + + /* Warm secondary accent — brighter on dark surfaces */ + --accent-warm: #fb923c; + --accent-warm-soft: #fdba74; + --accent-2: #a78bfa; + --surface-warm: rgba(251, 146, 60, 0.08); + + /* Elevation scale — deeper shadows on dark */ + --elevation-1: 0 1px 2px rgba(0, 0, 0, 0.4); + --elevation-2: 0 6px 16px -4px rgba(0, 0, 0, 0.45); + --elevation-3: 0 16px 36px -8px rgba(0, 0, 0, 0.55); } @theme { @@ -113,7 +142,14 @@ html.dark { --color-link: var(--app-link); --color-link-soft: var(--app-link-soft); - --radius-card: 0.75rem; + --color-accent-warm: var(--accent-warm); + --color-accent-warm-soft: var(--accent-warm-soft); + --color-accent-2: var(--accent-2); + + --radius-sm: 0.5rem; + --radius-card: 1rem; + --radius-lg: 1.25rem; + --radius-xl: 1.5rem; --spacing-page-x: 1.5rem; --spacing-page-y: 1.5rem; @@ -177,7 +213,7 @@ body { } .fade-in { - animation: fadeIn 0.3s ease-in-out; + animation: fadeIn var(--dur-base, 0.3s) var(--ease-out, ease-in-out); } @keyframes fadeIn { from { @@ -190,6 +226,12 @@ body { } } +@media (prefers-reduced-motion: reduce) { + .fade-in { + animation: none; + } +} + .tab-active { background-color: rgba(37, 99, 235, 0.12); border-color: rgba(37, 99, 235, 0.35); @@ -527,7 +569,7 @@ select:focus-visible { } .landing-gradient-text { - background: linear-gradient(135deg, var(--app-link) 0%, #8b5cf6 55%, var(--app-link-soft) 100%); + background: linear-gradient(135deg, var(--app-link) 0%, var(--accent-2) 52%, var(--accent-warm-soft) 100%); -webkit-background-clip: text; background-clip: text; color: transparent; @@ -575,3 +617,131 @@ select:focus-visible { .landing-section-alt { background: color-mix(in srgb, var(--app-bg-muted) 35%, transparent); } + +/* ───────────────────────────────────────────────────────────── + Reusable redesign utilities — depth, motion, warmth + ───────────────────────────────────────────────────────────── */ + +/* Ambient "aurora" backdrop — generalizes the hand-rolled blob layers + previously inlined on Landing/Home. Drop
+ into any `relative overflow-hidden` container. */ +.aurora-bg { + position: absolute; + inset: 0; + z-index: -10; + overflow: hidden; + pointer-events: none; +} +.aurora-bg::before { + content: ""; + position: absolute; + inset: -12%; + background: + radial-gradient(38% 38% at 18% 20%, color-mix(in srgb, var(--app-link) 22%, transparent), transparent 70%), + radial-gradient(42% 42% at 84% 10%, color-mix(in srgb, var(--accent-2) 20%, transparent), transparent 70%), + radial-gradient(40% 40% at 62% 96%, color-mix(in srgb, var(--accent-warm) 16%, transparent), transparent 72%); + filter: blur(64px); + opacity: 0.85; +} + +/* Reusable gradient hairline border (generalizes .landing-mock-glow). + Apply to an element with a border-radius; ::before paints a 1px ring. */ +.gradient-border { + position: relative; +} +.gradient-border::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--app-link) 45%, transparent), + color-mix(in srgb, var(--accent-2) 35%, transparent), + color-mix(in srgb, var(--accent-warm) 22%, transparent) + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + /* Enter animation — single element */ + .animate-in { + animation: fadeRise var(--dur-slow) var(--ease-out) both; + } + + /* Scroll-reveal (driven by + useInView). Guarded by reduced-motion, + so motion-averse users always see content regardless of scroll state. */ + [data-reveal='hidden'] { + opacity: 0; + transform: translateY(12px); + } + [data-reveal='shown'] { + animation: fadeRise var(--dur-slow) var(--ease-out) both; + } + + /* Staggered children — set style={{ '--i': index }} on each child */ + .stagger > * { + animation: fadeRise var(--dur-slow) var(--ease-out) both; + animation-delay: calc(var(--i, 0) * 60ms); + } + + @keyframes fadeRise { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: none; + } + } + + /* Hover elevation */ + .hover-lift { + transition: + transform var(--dur-base) var(--ease-out), + box-shadow var(--dur-base) var(--ease-out), + border-color var(--dur-base) var(--ease-out); + } + .hover-lift:hover { + transform: translateY(-3px); + box-shadow: var(--elevation-3); + } + + /* Tactile press feedback */ + .press { + transition: transform var(--dur-fast) var(--ease-out); + } + .press:active { + transform: scale(0.97); + } + + /* Skeleton shimmer sweep (replaces animate-pulse) */ + .shimmer { + position: relative; + overflow: hidden; + } + .shimmer::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--app-text-subtle) 16%, transparent), + transparent + ); + animation: shimmerSweep 1.6s ease-in-out infinite; + } + @keyframes shimmerSweep { + 100% { + transform: translateX(100%); + } + } +} diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index deede26..bbf8a6a 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -4,6 +4,8 @@ import { useEffect, useState, type ReactNode } from 'react'; import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { + ChevronLeft, + ChevronRight, ExternalLink, Menu, Search, @@ -41,6 +43,20 @@ interface ReportCategoryWithIssues { issues?: unknown[]; } +const SIDEBAR_COLLAPSED_KEY = 'app-sidebar-collapsed'; + +function navItemBadgeCount( + itemId: NavItemId, + issueCount: number, + securityCount: number, + jsErrorPageCount: number, +): number { + if (itemId === 'issues') return issueCount; + if (itemId === 'security') return securityCount; + if (itemId === 'javascript-errors') return jsErrorPageCount; + return 0; +} + export interface AppShellProps { children: ReactNode; showSidebar?: boolean; @@ -62,6 +78,7 @@ export default function AppShell({ const pathname = usePathname(); const searchParams = useSearchParams(); const [sidebarOpen, setSidebarOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [integrationsOpen, setIntegrationsOpen] = useState(false); const [integrationsToast, setIntegrationsToast] = useState(null); const { data, startUrlByRunId } = useReport(); @@ -70,6 +87,28 @@ export default function AppShell({ const trailing = searchParams.toString() ? `?${searchParams.toString()}` : ''; const closeSidebar = () => setSidebarOpen(false); + const toggleSidebarCollapsed = () => { + setSidebarCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0'); + } catch { + /* ignore storage errors */ + } + return next; + }); + }; + + useEffect(() => { + try { + if (localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1') { + setSidebarCollapsed(true); + } + } catch { + /* ignore storage errors */ + } + }, []); + useEffect(() => { const intParam = searchParams.get('integrations'); const authParam = searchParams.get('auth'); @@ -144,14 +183,25 @@ export default function AppShell({ {showSidebar ? ( @@ -242,15 +347,25 @@ export default function AppShell({
) : null} {showSidebar ? ( -
- +
+
+ + +
{showSearch && onSearchChange ? (
diff --git a/web/src/components/Badge.tsx b/web/src/components/Badge.tsx index 4051f52..beaa8c4 100644 --- a/web/src/components/Badge.tsx +++ b/web/src/components/Badge.tsx @@ -19,11 +19,13 @@ export default function Badge({ value, label, className = '', + live = false, }: { variant?: string; value?: string | number | null; label?: string; className?: string; + live?: boolean; }) { const v = variant || getBadgeVariant(value); const display = label != null ? label : (value != null && value !== '' ? String(value) : '—'); @@ -31,6 +33,7 @@ export default function Badge({ return ( {display} diff --git a/web/src/components/Button.tsx b/web/src/components/Button.tsx index aa4dae9..af95335 100644 --- a/web/src/components/Button.tsx +++ b/web/src/components/Button.tsx @@ -1,4 +1,5 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { Loader2 } from 'lucide-react'; type ButtonVariant = 'primary' | 'secondary' | 'ghost'; @@ -6,30 +7,43 @@ type ButtonProps = { children?: ReactNode; variant?: ButtonVariant; className?: string; + /** Shows a spinner and disables interaction. */ + loading?: boolean; } & ButtonHTMLAttributes; /** * Shared button: primary (Export style), secondary (border), ghost. * Same size: px-4 py-2 rounded-lg text-sm font-medium/bold for primary. + * Includes tactile press feedback (.press) and, for primary, hover elevation. */ export default function Button({ children, variant = 'primary', type = 'button', className = '', + loading = false, onClick, disabled, ...rest }: ButtonProps) { - const base = 'inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none'; + const base = + 'press inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:opacity-50 disabled:pointer-events-none'; const variants: Record = { - primary: 'bg-blue-600 hover:bg-blue-500 text-white font-bold', + primary: 'bg-blue-600 hover:bg-blue-500 text-white font-bold hover:shadow-[var(--elevation-2)]', secondary: 'border border-default text-foreground hover:bg-brand-700/80', ghost: 'text-muted-foreground hover:text-foreground hover:bg-brand-800/80', }; const combined = `${base} ${variants[variant] || variants.primary} ${className}`.trim(); return ( - ); diff --git a/web/src/components/Card.tsx b/web/src/components/Card.tsx index e73af0c..b8ecdf1 100644 --- a/web/src/components/Card.tsx +++ b/web/src/components/Card.tsx @@ -6,12 +6,15 @@ type CardProps = { padding?: 'default' | 'tight' | 'none'; shadow?: boolean; overflowHidden?: boolean; + /** Adds hover elevation + pointer affordance (for clickable cards). */ + interactive?: boolean; onClick?: MouseEventHandler; }; /** * Standard card container: bg-brand-800, border, rounded-xl, padding. - * Use shadow for stat cards, overflowHidden for table wrappers. + * Use shadow for stat cards, overflowHidden for table wrappers, + * interactive for clickable cards (hover lift). */ export default function Card({ children, @@ -19,15 +22,17 @@ export default function Card({ padding = 'default', shadow = false, overflowHidden = false, + interactive = false, onClick, }: CardProps) { const paddingClass = padding === 'none' ? '' : padding === 'tight' ? 'p-4' : 'p-5'; const shadowClass = shadow ? 'shadow-sm' : ''; const overflowClass = overflowHidden ? 'overflow-hidden' : ''; + const interactiveClass = interactive ? 'hover-lift cursor-pointer' : ''; return (
{children}
diff --git a/web/src/components/EmptyState.tsx b/web/src/components/EmptyState.tsx new file mode 100644 index 0000000..771dff3 --- /dev/null +++ b/web/src/components/EmptyState.tsx @@ -0,0 +1,105 @@ +import type { ReactNode } from 'react'; +import Link from 'next/link'; +import type { LucideIcon } from 'lucide-react'; +import Button from './Button'; + +interface EmptyStateAction { + label: string; + onClick?: () => void; + href?: string; + loading?: boolean; +} + +interface EmptyStateHighlight { + icon: LucideIcon; + label: string; +} + +export interface EmptyStateProps { + icon?: LucideIcon; + title: string; + description?: ReactNode; + primaryAction?: EmptyStateAction; + secondaryAction?: EmptyStateAction; + /** Optional "what you'll get" row beneath the actions. */ + highlights?: EmptyStateHighlight[]; + /** Adds the ambient aurora backdrop behind the content. */ + aurora?: boolean; + className?: string; +} + +function ActionButton({ + action, + variant, +}: { + action: EmptyStateAction; + variant: 'primary' | 'secondary'; +}) { + const btn = ( + + ); + return action.href ? {btn} : btn; +} + +/** + * Shared, welcoming empty/first-run state: icon, title, description, optional + * primary/secondary actions and a "what you'll get" highlights row. + * Consolidates the previously bespoke per-view empty states. + */ +export default function EmptyState({ + icon: Icon, + title, + description, + primaryAction, + secondaryAction, + highlights, + aurora = false, + className = '', +}: EmptyStateProps) { + return ( +
+ {aurora ?
: null} +
+ {Icon ? ( + + + + ) : null} +

{title}

+ {description ? ( +

{description}

+ ) : null} + {primaryAction || secondaryAction ? ( +
+ {primaryAction ? : null} + {secondaryAction ? : null} +
+ ) : null} + {highlights && highlights.length ? ( +
    + {highlights.map(({ icon: Hi, label }) => ( +
  • + + + + {label} +
  • + ))} +
+ ) : null} +
+
+ ); +} diff --git a/web/src/components/HelpHint.tsx b/web/src/components/HelpHint.tsx index bb20c2a..61c01a5 100644 --- a/web/src/components/HelpHint.tsx +++ b/web/src/components/HelpHint.tsx @@ -45,7 +45,7 @@ export default function HelpHint({ const [mounted, setMounted] = useState(false); const id = useId(); const rootRef = useRef(null); - const buttonRef = useRef(null); + const buttonRef = useRef(null); const tooltipRef = useRef(null); useEffect(() => { @@ -151,16 +151,31 @@ export default function HelpHint({
) : null; + const toggleOpen = useCallback(() => { + setOpen((v) => !v); + }, []); + return ( - + {mounted && tooltip ? createPortal(tooltip, document.body) : null} ); diff --git a/web/src/components/Reveal.tsx b/web/src/components/Reveal.tsx new file mode 100644 index 0000000..baed719 --- /dev/null +++ b/web/src/components/Reveal.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { createElement, type ElementType, type ReactNode } from 'react'; +import { useInView } from '@/lib/useInView'; + +interface RevealProps { + children: ReactNode; + /** Element to render (default div). Use 'section' to reveal a landing section in place. */ + as?: ElementType; + className?: string; + /** Extra delay before the reveal animation, in ms. */ + delayMs?: number; + /** Any extra props (e.g. id) are forwarded to the rendered element. */ + [key: string]: unknown; +} + +/** + * Wraps content and fades/rises it in once it scrolls into view. + * Reduced-motion users always see content (the data-reveal CSS lives inside a + * prefers-reduced-motion: no-preference block). + */ +export default function Reveal({ + children, + as = 'div', + className = '', + delayMs, + ...rest +}: RevealProps) { + const { ref, inView } = useInView(); + return createElement( + as, + { + ref, + 'data-reveal': inView ? 'shown' : 'hidden', + className, + style: delayMs ? { animationDelay: `${delayMs}ms` } : undefined, + ...rest, + }, + children, + ); +} diff --git a/web/src/components/Skeleton.tsx b/web/src/components/Skeleton.tsx index 60d9523..ab4b80e 100644 --- a/web/src/components/Skeleton.tsx +++ b/web/src/components/Skeleton.tsx @@ -4,7 +4,7 @@ export function Skeleton({ className = '' }: { className?: string }) { return (
); diff --git a/web/src/components/StatCard.tsx b/web/src/components/StatCard.tsx index dfade8f..38ec37b 100644 --- a/web/src/components/StatCard.tsx +++ b/web/src/components/StatCard.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import Link from 'next/link'; import HelpHint, { normalizeHintContent, type HelpHintContent } from './HelpHint'; import Card from './Card'; @@ -6,39 +7,92 @@ export interface StatCardProps { label: ReactNode; value: ReactNode; sub?: ReactNode; + band?: ReactNode; + bandClassName?: string; icon?: ReactNode; hint?: HelpHintContent; size?: 'md' | 'lg'; className?: string; shadow?: boolean; + href?: string; + fillHeight?: boolean; + valueClassName?: string; } export default function StatCard({ label, value, sub, + band, + bandClassName = 'text-muted-foreground', icon, hint, size = 'md', className = '', shadow = false, + href, + fillHeight = false, + valueClassName = 'text-bright', }: StatCardProps) { - const valueClass = size === 'lg' ? 'text-3xl font-bold text-bright' : 'text-2xl font-bold text-bright tabular-nums'; + const valueClass = + size === 'lg' ? `text-3xl font-bold tabular-nums ${valueClassName}` : `text-2xl font-bold tabular-nums ${valueClassName}`; const hintContent = normalizeHintContent(hint); + const heightClass = fillHeight ? 'flex h-full flex-col' : ''; + const linkHeightClass = fillHeight ? 'h-full' : ''; - return ( - -

+ const hintNode = hintContent ? ( + + {hintContent.body} + + ) : fillHeight ? ( + + ) : null; + + const footer = fillHeight ? ( +

+

+ {band ?? '—'} +

+

+ {sub ?? '—'} +

+
+ ) : ( + <> + {band ?

{band}

: null} + {sub ?

{sub}

: null} + + ); + + const card = ( + +

{icon} - {label} - {hintContent ? ( - - {hintContent.body} - - ) : null} + {label} + {hintNode}

{value ?? '—'}

- {sub ?

{sub}

: null} + {footer}
); + + if (href) { + return ( + + {card} + + ); + } + + return fillHeight ?
{card}
: card; } diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx index 46fa205..8f5c511 100644 --- a/web/src/components/ThemeToggle.tsx +++ b/web/src/components/ThemeToggle.tsx @@ -30,13 +30,16 @@ export default function ThemeToggle() { title={label()} aria-label={label()} aria-pressed={active} - className={`p-2 rounded-md transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 ${ + className={`press p-2 rounded-md transition-all focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 ${ active ? 'bg-brand-700 text-bright shadow-sm' : 'text-muted-foreground hover:text-foreground' }`} > - + ); })} diff --git a/web/src/components/ViewTabs.tsx b/web/src/components/ViewTabs.tsx index 527cd01..786012e 100644 --- a/web/src/components/ViewTabs.tsx +++ b/web/src/components/ViewTabs.tsx @@ -47,16 +47,16 @@ export default function ViewTabs({ aria-selected={isActive} aria-controls={panelId} onClick={() => onChange(tab.id)} - className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-1.5 whitespace-nowrap shrink-0 ${ + className={`press px-3 py-1.5 rounded-lg text-sm font-medium transition-all flex items-center gap-1.5 whitespace-nowrap shrink-0 ${ isActive - ? 'bg-brand-700 text-foreground' + ? 'bg-brand-700 text-foreground shadow-[var(--elevation-1)]' : 'text-muted-foreground hover:text-foreground hover:bg-brand-800' }`} > {tab.icon} {tab.label} {badge != null && badge > 0 ? ( - + {badge} ) : null} diff --git a/web/src/components/charts/CategoryScoreGauge.tsx b/web/src/components/charts/CategoryScoreGauge.tsx index 8dcb67d..648a225 100644 --- a/web/src/components/charts/CategoryScoreGauge.tsx +++ b/web/src/components/charts/CategoryScoreGauge.tsx @@ -9,7 +9,7 @@ const sj = strings.common; export interface CategoryScoreGaugeProps { name: string; score?: number | null; - size?: 'sm' | 'md'; + size?: 'sm' | 'md' | 'lg'; onClick?: () => void; } @@ -27,8 +27,9 @@ export function CategoryScoreGauge({ name, score, size = 'md', onClick }: Catego : 'text-red-600 dark:text-red-500'; const color = scoreBandColor(score); const isCritical = score != null && score < 50; - const dim = size === 'sm' ? 'w-16 h-16' : 'w-20 h-20'; - const textSize = size === 'sm' ? 'text-lg' : 'text-xl'; + const dim = + size === 'lg' ? 'w-28 h-28' : size === 'sm' ? 'w-16 h-16' : 'w-20 h-20'; + const textSize = size === 'lg' ? 'text-3xl' : size === 'sm' ? 'text-lg' : 'text-xl'; const inner = ( <> @@ -71,7 +72,7 @@ export function CategoryScoreGauge({ name, score, size = 'md', onClick }: Catego
-

{name}

+

{name}

{label}

diff --git a/web/src/components/charts/ScoreDelta.test.ts b/web/src/components/charts/ScoreDelta.test.ts new file mode 100644 index 0000000..9ff64b6 --- /dev/null +++ b/web/src/components/charts/ScoreDelta.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { isImprovedScoreDelta, isNeutralScoreDelta } from './ScoreDelta'; + +describe('ScoreDelta helpers', () => { + it('treats null, zero, and non-finite deltas as neutral', () => { + expect(isNeutralScoreDelta(null)).toBe(true); + expect(isNeutralScoreDelta(undefined)).toBe(true); + expect(isNeutralScoreDelta(0)).toBe(true); + expect(isNeutralScoreDelta(NaN)).toBe(true); + expect(isNeutralScoreDelta(Infinity)).toBe(true); + expect(isNeutralScoreDelta(-Infinity)).toBe(true); + expect(isNeutralScoreDelta(5)).toBe(false); + }); + + it('scores improvement when higher is better', () => { + expect(isImprovedScoreDelta(3, true)).toBe(true); + expect(isImprovedScoreDelta(-2, true)).toBe(false); + }); + + it('inverts improvement when lower is better', () => { + expect(isImprovedScoreDelta(3, false)).toBe(false); + expect(isImprovedScoreDelta(-2, false)).toBe(true); + }); +}); diff --git a/web/src/components/charts/ScoreDelta.tsx b/web/src/components/charts/ScoreDelta.tsx index 7d97781..daec442 100644 --- a/web/src/components/charts/ScoreDelta.tsx +++ b/web/src/components/charts/ScoreDelta.tsx @@ -4,24 +4,43 @@ import { Minus, TrendingDown, TrendingUp } from 'lucide-react'; export interface ScoreDeltaProps { delta: number | null | undefined; + /** When false, a negative delta is good (e.g. LCP, response time). Default true. */ + higherIsBetter?: boolean; } -export function ScoreDelta({ delta }: ScoreDeltaProps) { - if (delta == null || delta === 0) { +export function isNeutralScoreDelta(delta: number | null | undefined): boolean { + return delta == null || delta === 0 || !Number.isFinite(delta); +} + +export function isDisplayableScoreDelta( + delta: number | null | undefined, +): delta is number { + return delta != null && delta !== 0 && Number.isFinite(delta); +} + +export function isImprovedScoreDelta(delta: number, higherIsBetter = true): boolean { + return higherIsBetter ? delta > 0 : delta < 0; +} + +export function ScoreDelta({ delta, higherIsBetter = true }: ScoreDeltaProps) { + if (!isDisplayableScoreDelta(delta)) { return ( 0 ); } - const up = delta > 0; - const Icon = up ? TrendingUp : TrendingDown; - const color = up ? 'text-emerald-700 dark:text-emerald-400' : 'text-rose-700 dark:text-rose-400'; + const value = delta; + const improved = isImprovedScoreDelta(value, higherIsBetter); + const Icon = value > 0 ? TrendingUp : TrendingDown; + const color = improved + ? 'text-emerald-700 dark:text-emerald-400' + : 'text-rose-700 dark:text-rose-400'; return ( - {up ? '+' : ''} - {delta} + {value > 0 ? '+' : ''} + {value} ); } diff --git a/web/src/components/compare/CompareTabPanels.tsx b/web/src/components/compare/CompareTabPanels.tsx index 1a1eff1..623f1af 100644 --- a/web/src/components/compare/CompareTabPanels.tsx +++ b/web/src/components/compare/CompareTabPanels.tsx @@ -467,7 +467,7 @@ export function CompareLinksPanel({ compare, searchQuery, vc, emptyLabel }: Pane {row.baseline} {row.current} - + ))} diff --git a/web/src/components/google/SortablePaginatedTable.tsx b/web/src/components/google/SortablePaginatedTable.tsx index 5d26942..29b951c 100644 --- a/web/src/components/google/SortablePaginatedTable.tsx +++ b/web/src/components/google/SortablePaginatedTable.tsx @@ -85,6 +85,7 @@ export default function SortablePaginatedTable({ toggle(col.key)} + aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'} className="px-3 py-2 text-left text-xs font-bold text-muted-foreground uppercase tracking-wider cursor-pointer select-none hover:text-foreground whitespace-nowrap" > @@ -102,8 +103,15 @@ export default function SortablePaginatedTable({ ) : null} {sortKey === col.key && ( - {sortDir === 'asc' ? '↑' : '↓'} + + {sortDir === 'asc' ? '↑' : '↓'} + )} + {sortKey === col.key ? ( + + {sortDir === 'asc' ? 'sorted ascending' : 'sorted descending'} + + ) : null} ); })} diff --git a/web/src/components/index.ts b/web/src/components/index.ts index ce1db9f..3e137dc 100644 --- a/web/src/components/index.ts +++ b/web/src/components/index.ts @@ -4,6 +4,8 @@ export { default as Card } from './Card'; export { default as Button } from './Button'; export { default as Badge } from './Badge'; export { default as AlertBanner } from './AlertBanner'; +export { default as EmptyState } from './EmptyState'; +export type { EmptyStateProps } from './EmptyState'; export { default as StatCard } from './StatCard'; export { default as HelpHint, normalizeHintContent, LabelWithHint, ChartTitleWithHint } from './HelpHint'; export type { HelpHintProps, HelpHintContent } from './HelpHint'; diff --git a/web/src/components/links/tabs/TechnicalTab.tsx b/web/src/components/links/tabs/TechnicalTab.tsx index d390c40..55d4fdf 100644 --- a/web/src/components/links/tabs/TechnicalTab.tsx +++ b/web/src/components/links/tabs/TechnicalTab.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { Shield, Zap, Image } from 'lucide-react'; +import { Shield, Zap, Image as ImageIcon } from 'lucide-react'; import type { LinkDetail } from '@/types/report'; import { strings, format } from '../../../lib/strings'; import SecHeaderRow from '../SecHeaderRow'; @@ -99,7 +99,7 @@ export default function TechnicalTab({ link }: TechnicalTabProps) {

- {lt.imagesA11y} + {lt.imagesA11y}

; +const CHART_HEIGHT = 'h-64'; + +function ChartSection({ + title, + hint, + icon, + children, +}: { + title: string; + hint: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ {icon} +
+

{title}

+

{hint}

+
+
+
{children}
+
+ ); +} + +function ChartInsightCard({ + title, + helpKey, + hint, + takeaway, + viewHref, + viewLabel, + className = '', + children, +}: { + title: string; + helpKey?: string; + hint?: string; + takeaway?: string; + viewHref?: string; + viewLabel?: string; + className?: string; + children: ReactNode; +}) { + return ( + + + {takeaway ? ( +

{takeaway}

+ ) : null} +
{children}
+ {viewHref && viewLabel ? ( + + {viewLabel} + + + ) : null} +
+ ); +} + function OverviewBarChart({ chart, yTitle, + heightClass = CHART_HEIGHT, }: { chart: OverviewChartBlock; yTitle: string; + heightClass?: string; }) { const labels = chart.data.labels?.map(String) ?? []; const opts = chart.horizontal ? barOptionsHorizontal(undefined, labels) : barOptsVertical(yTitle, chart.aria); return ( - +
@@ -36,15 +119,41 @@ function OverviewBarChart({ ); } +function SocialCoverageRings({ + og, + twitter, + ogImage, + aria, +}: { + og: number | null; + twitter: number | null; + ogImage: number | null; + aria: string; +}) { + return ( +
+ {og != null ? ( + + ) : null} + {twitter != null ? ( + + ) : null} + {ogImage != null ? ( + + ) : null} +
+ ); +} + export interface OverviewChartsTabProps { charts: OverviewCharts; depth: NonNullable; + data: ReportPayload; + querySuffix: string; } -export function OverviewChartsTab({ charts, depth }: OverviewChartsTabProps) { - const vo = strings.views.overview; - const sj = strings.common; - const pct = strings.common.percentOfPages; +export function OverviewChartsTab({ charts, depth, data, querySuffix }: OverviewChartsTabProps) { + const concerns = useMemo(() => selectChartConcerns({ data, querySuffix }), [data, querySuffix]); const { statusDistribution, wordCountChart, @@ -59,131 +168,244 @@ export function OverviewChartsTab({ charts, depth }: OverviewChartsTabProps) { lighthouseScores, hasInsightCharts, } = charts; - const lhLabels = strings.lighthouse.categoryLabels as Record; + + const hasCrawlSection = statusDistribution || responseTimeChart || depthChart; + const hasContentSection = wordCountChart || titleMetaChart || readingLevelChart; + const hasDiscoverySection = mimeChart || outlinksChart || domainsChart; return ( - + {hasInsightCharts ? ( -
-

- - {vo.insightsGlance} -

-

{vo.insightsHint}

-
- {statusDistribution && ( - - - - - )} - {wordCountChart && ( - - - - - )} - {responseTimeChart && ( - - - - - )} - {depthChart && ( - - -
- {format(vo.depthSummaryLine, { - maxDepth: depth.max_depth ?? sj.emDash, - avgDepth: depth.avg_depth ?? sj.emDash, - })} -
- -
- -
-
-
- )} - {titleMetaChart && ( - - - -
- -
-
-
- )} - {socialStats && ( - - -
- {socialStats.og != null && ( - - )} - {socialStats.twitter != null && ( - - )} - {socialStats.ogImage != null && ( - - )} + <> +
+
+
+

+ + {vo.insightsGlance} +

+

{vo.chartsSubtitle}

+
+
+ + {concerns.length > 0 ? ( +
+

+ {vo.chartsTopConcerns} +

+
+ {concerns.map((concern) => ( + + + {concern.label} + + + ))}
- - )} - {readingLevelChart && ( - - - - - )} - {mimeChart && ( - - - - - )} - {outlinksChart && ( - - - - - )} - {domainsChart && ( - - - - - )} - {lighthouseScores && ( - - +
+ ) : null} +
+ + {lighthouseScores ? ( + } + > + - - )} -
-
+ + + ) : null} + + {hasCrawlSection ? ( + } + > + {statusDistribution ? ( + + + + ) : null} + {responseTimeChart ? ( + + + + ) : null} + {depthChart ? ( + +
+ {format(vo.depthSummaryLine, { + maxDepth: depth.max_depth ?? sj.emDash, + avgDepth: depth.avg_depth ?? sj.emDash, + })} +
+ +
+ +
+
+
+ ) : null} +
+ ) : null} + + {hasContentSection ? ( + } + > + {wordCountChart ? ( + + + + ) : null} + {titleMetaChart ? ( + + +
+ +
+
+
+ ) : null} + {readingLevelChart ? ( + + + + ) : null} +
+ ) : null} + + {hasDiscoverySection ? ( + } + > + {mimeChart ? ( + + + + ) : null} + {outlinksChart ? ( + + + + ) : null} + {domainsChart ? ( + + + + ) : null} + + ) : null} + + {socialStats ? ( + } + > + + + + + ) : null} + ) : ( - {vo.chartsEmpty} + {vo.chartsEmpty} )} ); diff --git a/web/src/components/overview/OverviewContentQuality.tsx b/web/src/components/overview/OverviewContentQuality.tsx new file mode 100644 index 0000000..ab8b7cc --- /dev/null +++ b/web/src/components/overview/OverviewContentQuality.tsx @@ -0,0 +1,378 @@ +'use client'; + +import { useMemo, type ReactNode } from 'react'; +import Link from 'next/link'; +import { + AlertTriangle, + ChevronRight, + Copy, + Globe2, + Languages, + Sparkles, + Tag, +} from 'lucide-react'; +import type { ReportPayload } from '@/types'; +import { strings, format } from '@/lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; +import HelpHint from '@/components/HelpHint'; +import { Card, StatCard } from '@/components'; +import { buildKeywordsTabHref } from './overviewKeywordOpportunities'; +import { + buildViewHref, + duplicateGroupsBand, + duplicateMemberCount, + languageCount, + languageShares, + selectContentConcerns, + selectTopDuplicateClusters, + shouldShowContentQuality, + stripUrlForDisplay, + totalDuplicateMemberPages, +} from './contentQualityMetrics'; +import { bandClassName, metricBandLabel } from './crawlSnapshotMetrics'; + +const vo = strings.views.overview; + +export interface OverviewContentQualityProps { + data: ReportPayload; + querySuffix: string; + keywordsHref: string; +} + +function LanguageMixBars({ counts }: { counts: Record }) { + const shares = languageShares(counts, 5); + if (!shares.length) return null; + + return ( +
+ {shares.map((row) => ( +
+
+ {row.lang} + + {format(vo.contentQualityLanguageShare, { + count: row.count.toLocaleString(), + pct: row.pct, + })} + +
+
+
+
+
+ ))} +
+ ); +} + +function ContentQualityColumn({ + title, + viewAllHref, + viewAllLabel, + statCard, + children, +}: { + title: string; + viewAllHref: string; + viewAllLabel: string; + statCard: ReactNode; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ + {viewAllLabel} + +
+
{statCard}
+
{children}
+
+ ); +} + +export function OverviewContentQuality({ data, querySuffix, keywordsHref }: OverviewContentQualityProps) { + const { + duplicateGroupCount, + duplicatePages, + topDuplicates, + languageCounts, + languagesDetected, + mixedLanguage, + duplicateBand, + languageShareRows, + dominantLanguage, + kpiParts, + concerns, + showAdvancedInsights, + semanticTopics, + hasNer, + entityTotal, + pagesWithNer, + contentOverviewHref, + textAnalysisHref, + contentAnalyticsHref, + topicsHref, + } = useMemo(() => { + const dupeGroups = data.content_duplicates || []; + const dupeCount = dupeGroups.length; + const dupePages = totalDuplicateMemberPages(dupeGroups); + const langCounts = data.language_summary?.counts || {}; + const langDetected = languageCount(langCounts); + const mixed = Boolean(data.language_summary?.mixed_site); + const semanticTopics = data.semantic_keyword_clusters?.length ?? 0; + const entityTotal = data.ner_site_summary?.total_entities ?? 0; + const pagesWithNer = data.ner_site_summary?.pages_with_ner ?? 0; + const contentHref = buildViewHref('content', querySuffix, { tab: 'overview' }); + const textHref = buildViewHref('text-content-analysis', querySuffix); + const analyticsHref = buildViewHref('content-analytics', querySuffix); + const shareRows = languageShares(langCounts, 1); + const parts: string[] = []; + if (dupeCount > 0) parts.push(format(vo.contentQualityKpiDuplicates, { groups: dupeCount.toLocaleString() })); + if (langDetected > 0) parts.push(format(vo.contentQualityKpiLanguages, { count: langDetected.toLocaleString() })); + if (mixed) parts.push(vo.contentQualityKpiMixedLanguage); + return { + duplicateGroupCount: dupeCount, + duplicatePages: dupePages, + topDuplicates: selectTopDuplicateClusters(dupeGroups, 2), + languageCounts: langCounts, + languagesDetected: langDetected, + mixedLanguage: mixed, + duplicateBand: duplicateGroupsBand(dupeCount), + languageShareRows: shareRows, + dominantLanguage: shareRows[0], + kpiParts: parts, + concerns: selectContentConcerns({ + duplicateGroups: dupeCount, + duplicatePages: dupePages, + mixedLanguage: mixed, + languageCount: langDetected, + contentHref, + textAnalysisHref: textHref, + formatDuplicateGroups: (groups, pages) => format(vo.contentConcernDuplicates, { groups, pages }), + formatMixedLanguage: (languages) => format(vo.contentConcernMixedLanguage, { languages }), + }), + showAdvancedInsights: semanticTopics > 0 || entityTotal > 0 || pagesWithNer > 0, + semanticTopics, + hasNer: entityTotal > 0 || pagesWithNer > 0, + entityTotal, + pagesWithNer, + contentOverviewHref: contentHref, + textAnalysisHref: textHref, + contentAnalyticsHref: analyticsHref, + topicsHref: buildKeywordsTabHref(keywordsHref, 'topics'), + }; + }, [data, querySuffix, keywordsHref]); + + if (!shouldShowContentQuality(data)) return null; + + return ( + +
+
+
+
+ +

{vo.contentIntelligence}

+ + {vo.contentQualityHelpBody} + +
+

{vo.contentQualitySubtitle}

+ {kpiParts.length > 0 ? ( +

{kpiParts.join(' · ')}

+ ) : null} +
+
+ {duplicateGroupCount > 0 ? ( + + + {vo.contentQualityReviewDuplicates} + + ) : null} + + + {vo.contentQualityOpenTextAnalysis} + +
+
+ + {concerns.length > 0 ? ( +
+

+ {vo.contentQualityTopConcerns} +

+
+ {concerns.map((concern) => ( + + + {concern.label} + + + ))} +
+
+ ) : null} +
+ +
+ {duplicateGroupCount > 0 ? ( + } + label={vo.contentQualityGroupsCount} + value={duplicateGroupCount.toLocaleString()} + sub={format(vo.contentQualityDuplicatePages, { pages: duplicatePages.toLocaleString() })} + band={metricBandLabel(duplicateBand, vo)} + bandClassName={bandClassName(duplicateBand)} + valueClassName={bandClassName(duplicateBand)} + className={ + duplicateBand === 'critical' + ? 'border-amber-500/25 ring-1 ring-inset ring-amber-500/15' + : 'border-default' + } + hint={metricHelpHint('views.content.duplicateCluster')} + /> + } + > +
+

+ {vo.contentQualityLargestClusters} +

+
    + {topDuplicates.map((cluster) => { + const members = duplicateMemberCount(cluster); + const label = stripUrlForDisplay(cluster.representative_url || cluster.id); + return ( +
  • + +
    +

    + {label} +

    +

    + {format(vo.contentQualityClusterMembers, { count: members.toLocaleString() })} +

    +
    + + +
  • + ); + })} +
+
+
+ ) : null} + + {languagesDetected > 0 ? ( + } + label={vo.contentQualityLocaleCount} + value={languagesDetected.toLocaleString()} + sub={ + dominantLanguage + ? format(vo.contentQualityDominantLanguage, { + lang: dominantLanguage.lang, + pct: dominantLanguage.pct, + }) + : undefined + } + band={mixedLanguage ? vo.mixedLanguage : vo.metricBandGood} + bandClassName={mixedLanguage ? bandClassName('fair') : bandClassName('good')} + className={ + mixedLanguage ? 'border-amber-500/20 ring-1 ring-inset ring-amber-500/10' : 'border-cyan-500/15' + } + hint={metricHelpHint('views.overview.contentQualityLocales')} + /> + } + > +
+
+

+ {vo.contentQualityLanguageMix} +

+ + {vo.contentQualityOpenContentAnalytics} + +
+ {mixedLanguage ? ( +

+ {vo.contentQualityMixedLanguageHint} +

+ ) : null} + +
+
+ ) : null} +
+ + {showAdvancedInsights ? ( +
+

+ {vo.contentQualityAdvancedInsights} +

+
+ {semanticTopics > 0 ? ( + } + label={vo.parentTopics} + value={semanticTopics.toLocaleString()} + sub={vo.semanticGroups} + /> + ) : null} + {hasNer ? ( + } + label={vo.namedEntities} + value={entityTotal.toLocaleString()} + sub={ + pagesWithNer > 0 + ? format(vo.pagesSampled, { n: pagesWithNer }) + : vo.entitiesSitewide + } + /> + ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/web/src/components/overview/OverviewCrawlMetrics.tsx b/web/src/components/overview/OverviewCrawlMetrics.tsx new file mode 100644 index 0000000..e6c4122 --- /dev/null +++ b/web/src/components/overview/OverviewCrawlMetrics.tsx @@ -0,0 +1,286 @@ +'use client'; + +import type { ReactNode } from 'react'; +import Link from 'next/link'; +import { + AlertTriangle, + BarChart3, + BookOpen, + CheckCircle, + ChevronRight, + Cpu, + FileCode, + Globe, + Share, + Timer, +} from 'lucide-react'; +import type { ReportPayload } from '@/types'; +import { strings, format } from '@/lib/strings'; +import { metricHelpHint } from '@/lib/metricHelp'; +import { crawledUrlCount } from '@/lib/crawlCounts'; +import { Card, StatCard } from '@/components'; +import { + bandClassName, + brokenSubline, + buildViewHref, + medianWordsBand, + metricBandLabel, + ogCoverageBand, + pctOfCrawl, + responseTimeBand, + selectCrawlConcerns, + successRateBand, +} from './crawlSnapshotMetrics'; + +const vo = strings.views.overview; +const sj = strings.common; + +export interface OverviewCrawlMetricsProps { + data: ReportPayload; + querySuffix: string; +} + +function MetricSection({ + title, + hint, + viewAllHref, + viewAllLabel, + children, +}: { + title: string; + hint: string; + viewAllHref?: string; + viewAllLabel?: string; + children: ReactNode; +}) { + return ( +
+
+
+

{title}

+

{hint}

+
+ {viewAllHref && viewAllLabel ? ( + + {viewAllLabel} + + + ) : null} +
+
{children}
+
+ ); +} + +export function OverviewCrawlMetrics({ data, querySuffix }: OverviewCrawlMetricsProps) { + const s = data.summary || {}; + const crawledCount = crawledUrlCount(data); + const brokenCount = (s.count_4xx || 0) + (s.count_5xx || 0); + const h1Zero = data.seo_health?.h1_zero ?? 0; + const successRate = s.success_rate ?? null; + const medianWords = + data.content_analytics?.word_count_stats?.median != null + ? Math.round(data.content_analytics.word_count_stats.median) + : null; + const ogPct = data.social_coverage?.og_coverage_pct ?? null; + const techCount = data.tech_stack_summary?.technologies?.length ?? null; + const p50 = data.response_time_stats?.p50 ?? null; + const p95 = data.response_time_stats?.p95 ?? null; + + const linksHref = buildViewHref('links', querySuffix); + const contentHref = buildViewHref('content', querySuffix); + const contentAnalyticsHref = buildViewHref('content-analytics', querySuffix); + const techHref = buildViewHref('tech-stack', querySuffix); + const networkHref = buildViewHref('network', querySuffix); + const chartsHref = buildViewHref('overview', querySuffix, { tab: 'charts' }); + + const successBand = successRateBand(successRate); + const wordsBand = medianWordsBand(medianWords); + const responseBand = responseTimeBand(p50); + const ogBand = ogCoverageBand(ogPct); + const h1Pct = pctOfCrawl(h1Zero, crawledCount); + + const concerns = selectCrawlConcerns({ + brokenCount, + h1Zero, + crawledCount, + successRate, + medianWords, + responseP50: p50, + linksHref, + contentHref, + contentAnalyticsHref, + chartsHref, + formatBroken: (count, pct) => format(vo.crawlConcernBroken, { count, pct }), + formatMissingH1: (count, pct) => format(vo.crawlConcernMissingH1, { count, pct }), + formatSuccess: (rate) => format(vo.crawlConcernSuccess, { rate }), + formatThinContent: (median) => format(vo.crawlConcernThinContent, { median }), + formatSlowResponse: (ms) => format(vo.crawlConcernSlowResponse, { ms }), + }); + + return ( + +
+
+
+

{vo.crawlSnapshotTitle}

+

{vo.crawlSnapshotSubtitle}

+
+ + + {vo.crawlSnapshotViewCharts} + +
+ + {concerns.length > 0 ? ( +
+

+ {vo.crawlTopConcerns} +

+
+ {concerns.map((concern) => ( + + + {concern.label} + + + ))} +
+
+ ) : null} +
+ +
+ + } + label={vo.totalUrls} + value={crawledCount.toLocaleString()} + sub={vo.crawlPagesDiscovered} + hint={metricHelpHint('views.overview.totalUrls')} + fillHeight + /> + } + label={vo.successRate} + value={successRate != null ? `${successRate}%` : '—'} + band={successRate != null ? metricBandLabel(successBand, vo) : undefined} + bandClassName={successRate != null ? bandClassName(successBand) : undefined} + valueClassName={successRate != null ? bandClassName(successBand) : 'text-muted-foreground'} + hint={metricHelpHint('shared.successRate')} + fillHeight + /> + } + label={vo.broken} + value={brokenCount.toLocaleString()} + sub={brokenSubline( + s.count_4xx ?? 0, + s.count_5xx ?? 0, + crawledCount, + (count, pct) => format(vo.crawlMetricCountPct, { count, pct }), + (count4xx, count5xx) => format(vo.count4xx5xx, { count4xx, count5xx }), + )} + band={brokenCount > 0 ? vo.metricBandCritical : vo.metricBandGood} + bandClassName={brokenCount > 0 ? bandClassName('critical') : bandClassName('good')} + valueClassName={brokenCount > 0 ? bandClassName('critical') : bandClassName('good')} + className={brokenCount > 0 ? 'border-red-900/30 ring-1 ring-inset ring-red-500/20' : ''} + hint={metricHelpHint('views.overview.brokenLinks')} + fillHeight + /> + } + label={vo.missingH1s} + value={h1Zero.toLocaleString()} + sub={ + h1Pct != null + ? format(vo.crawlMetricCountPct, { count: h1Zero.toLocaleString(), pct: `${h1Pct}%` }) + : undefined + } + band={h1Zero > 0 ? vo.metricBandNeedsAttention : vo.metricBandGood} + bandClassName={h1Zero > 0 ? bandClassName('fair') : bandClassName('good')} + valueClassName={h1Zero > 0 ? bandClassName('fair') : bandClassName('good')} + hint={metricHelpHint('views.overview.missingH1')} + fillHeight + /> + + + + } + label={vo.medianWordCount} + value={medianWords != null ? medianWords.toLocaleString() : sj.emDash} + sub={vo.perPage2xx} + band={medianWords != null ? metricBandLabel(wordsBand, vo) : undefined} + bandClassName={medianWords != null ? bandClassName(wordsBand) : undefined} + valueClassName={medianWords != null ? bandClassName(wordsBand) : 'text-bright'} + hint={metricHelpHint('shared.medianWords')} + fillHeight + /> + } + label={vo.ogCoverage} + value={ogPct != null ? `${ogPct}%` : sj.emDash} + sub={vo.ogPagesWith} + band={ogPct != null ? metricBandLabel(ogBand, vo) : undefined} + bandClassName={ogPct != null ? bandClassName(ogBand) : undefined} + valueClassName={ogPct != null ? bandClassName(ogBand) : 'text-bright'} + hint={metricHelpHint('views.overview.ogCoverage')} + fillHeight + /> + } + label={vo.technologies} + value={techCount ?? sj.emDash} + sub={vo.techDetectedAcross} + hint={metricHelpHint('views.overview.technologies')} + fillHeight + /> + } + label={vo.responseP50} + value={p50 != null ? `${Math.round(p50)}ms` : sj.emDash} + sub={ + p95 != null + ? `${vo.p95Label} ${Math.round(p95)}ms` + : undefined + } + band={p50 != null ? metricBandLabel(responseBand, vo) : undefined} + bandClassName={p50 != null ? bandClassName(responseBand) : undefined} + valueClassName={p50 != null ? bandClassName(responseBand) : 'text-bright'} + hint={metricHelpHint('views.overview.responseP50')} + fillHeight + /> + +
+
+ ); +} diff --git a/web/src/components/overview/OverviewExecutiveSummary.tsx b/web/src/components/overview/OverviewExecutiveSummary.tsx new file mode 100644 index 0000000..a00f1ab --- /dev/null +++ b/web/src/components/overview/OverviewExecutiveSummary.tsx @@ -0,0 +1,310 @@ +'use client'; + +import { useEffect, useId, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { + AlertOctagon, + ArrowLeftRight, + ChevronRight, + Sparkles, + TrendingDown, + TrendingUp, +} from 'lucide-react'; +import type { ReportPayload } from '@/types'; +import { strings, format } from '@/lib/strings'; +import { Card, Badge } from '@/components'; +import { CategoryScoreGauge } from '@/components/charts/CategoryScoreGauge'; + +const vo = strings.views.overview; + +export interface TopExecutiveIssue { + message?: string; + priority?: string; + gsc_clicks?: number; + url?: string; + category?: string; +} + +export interface OverviewExecutiveSummaryProps { + data: ReportPayload; + currentHealth: number | null; + topIssues: TopExecutiveIssue[]; + compareHref: string; + reportCount: number; + querySuffix: string; +} + +function countIssuesByPriority(categories: ReportPayload['categories']) { + const counts = { critical: 0, high: 0, medium: 0, low: 0, total: 0 }; + for (const cat of categories || []) { + for (const iss of cat?.issues || []) { + counts.total += 1; + const p = String(iss?.priority || ''); + if (p === 'Critical') counts.critical += 1; + else if (p === 'High') counts.high += 1; + else if (p === 'Medium') counts.medium += 1; + else counts.low += 1; + } + } + return counts; +} + +function priorityBadgeVariant(priority?: string): string { + if (priority === 'Critical') return 'critical'; + if (priority === 'High') return 'high'; + if (priority === 'Medium') return 'medium'; + return 'low'; +} + +function HealthTrendSparkline({ scores }: { scores: number[] }) { + const fillId = useId(); + if (scores.length < 2) return null; + + const ordered = [...scores].reverse(); + const max = Math.max(...ordered); + const min = Math.min(...ordered); + const range = max - min || 1; + const coords = ordered + .map((score, i) => { + const x = (i / (ordered.length - 1)) * 100; + const y = 100 - ((score - min) / range) * 100; + return `${x},${y}`; + }) + .join(' '); + + return ( + + + + + + + + + + + ); +} + +function ExecutiveIssueRow({ + issue, + issuesHref, +}: { + issue: TopExecutiveIssue; + issuesHref: string; +}) { + const clicks = Number(issue.gsc_clicks || 0); + const scopeLabel = issue.url + ? issue.url.replace(/^https?:\/\//, '').slice(0, 72) + : vo.issueSitewideScope; + + return ( + + +
+

{issue.message || vo.issueUntitled}

+

+ {scopeLabel} + {clicks > 0 ? ( + <> + {' · '} + {format(vo.issueClicksImpact, { clicks: clicks.toLocaleString() })} + + ) : null} +

+
+ + + ); +} + +export function OverviewExecutiveSummary({ + data, + currentHealth, + topIssues, + compareHref, + reportCount, + querySuffix, +}: OverviewExecutiveSummaryProps) { + const [healthDelta, setHealthDelta] = useState(null); + const [healthTrend, setHealthTrend] = useState([]); + const [historyError, setHistoryError] = useState(null); + + const execSummary = data.executive_summary?.summary; + const execPriorities = (data.executive_summary?.priorities || []).filter(Boolean); + const execSource = data.executive_summary?.source; + const isAiSummary = execSource === 'ai_insights' && Boolean(execSummary); + + const issueCounts = useMemo(() => countIssuesByPriority(data.categories), [data.categories]); + const issuesHref = `/issues${querySuffix}`; + const showHero = currentHealth != null || topIssues.length > 0 || Boolean(execSummary); + + useEffect(() => { + const domain = data.site_name || ''; + setHealthDelta(null); + setHealthTrend([]); + setHistoryError(null); + if (!domain) return; + void fetch(`/api/report/history?domain=${encodeURIComponent(domain)}&limit=8`) + .then(async (r) => { + if (!r.ok) { + setHistoryError(vo.historyTrendUnavailable); + return; + } + const payload = (await r.json()) as { history?: Array<{ healthScore?: number | null }> }; + const hist = payload.history || []; + const scores = hist + .map((row) => row.healthScore) + .filter((score): score is number => score != null && Number.isFinite(score)); + setHealthTrend(scores); + if (hist.length >= 2 && currentHealth != null && hist[1]?.healthScore != null) { + setHealthDelta(currentHealth - Number(hist[1].healthScore)); + } + }) + .catch(() => setHistoryError(vo.historyTrendUnavailable)); + }, [data.site_name, currentHealth]); + + if (!showHero) return null; + + return ( +
+ {(currentHealth != null || topIssues.length > 0) && ( + + {currentHealth != null ? ( +
+ + +
+
+
+ {healthDelta != null ? ( +
0 + ? 'text-emerald-600 dark:text-emerald-400' + : healthDelta < 0 + ? 'text-rose-600 dark:text-rose-400' + : 'text-muted-foreground' + }`} + > + {healthDelta > 0 ? ( + + ) : healthDelta < 0 ? ( + + ) : null} + {healthDelta === 0 + ? vo.healthDeltaFlat + : format(vo.healthDeltaVsPrior, { + delta: `${healthDelta > 0 ? '+' : ''}${healthDelta}`, + })} +
+ ) : null} + {historyError ? ( +

{historyError}

+ ) : null} + {issueCounts.total > 0 ? ( +

+ {format(vo.executiveIssueCounts, { + total: issueCounts.total.toLocaleString(), + critical: issueCounts.critical.toLocaleString(), + high: issueCounts.high.toLocaleString(), + })} +

+ ) : ( +

{vo.executiveNoIssues}

+ )} +
+ {healthTrend.length >= 2 ? ( +
+

+ {vo.healthTrendLabel} +

+ +
+ ) : null} +
+ +
+ + + {vo.viewAllIssues} + + {reportCount >= 2 ? ( + + + {strings.views.compare.title} + + ) : null} +
+
+
+ ) : null} + + {topIssues.length > 0 ? ( +
+

+ {vo.needsAttention} +

+
+ {topIssues.slice(0, 5).map((issue, i) => ( + + ))} +
+
+ ) : null} +
+ )} + + {isAiSummary || execPriorities.length > 0 ? ( + +
+
+ +

{vo.executiveAiLabel}

+
+ {isAiSummary ? ( +

{execSummary}

+ ) : null} + {execPriorities.length > 0 ? ( +
+ {execPriorities.map((line, i) => ( + + {line} + + ))} +
+ ) : null} +
+
+ ) : !isAiSummary && execSummary && topIssues.length === 0 ? ( + +

{execSummary}

+
+ ) : null} +
+ ); +} diff --git a/web/src/components/overview/OverviewHealthTab.tsx b/web/src/components/overview/OverviewHealthTab.tsx index 368259f..a519ad7 100644 --- a/web/src/components/overview/OverviewHealthTab.tsx +++ b/web/src/components/overview/OverviewHealthTab.tsx @@ -26,9 +26,17 @@ export interface OverviewHealthTabProps { data: ReportPayload; categoriesFiltered: ReportCategory[]; recommendationsFiltered: string[]; + compareHref?: string; + reportCount?: number; } -export function OverviewHealthTab({ data, categoriesFiltered, recommendationsFiltered }: OverviewHealthTabProps) { +export function OverviewHealthTab({ + data, + categoriesFiltered, + recommendationsFiltered, + compareHref, + reportCount = 0, +}: OverviewHealthTabProps) { const vo = strings.views.overview; const sj = strings.common; const searchParams = useSearchParams(); @@ -45,8 +53,12 @@ export function OverviewHealthTab({ data, categoriesFiltered, recommendationsFil return ( - -
+ +

{vo.healthByCategory}

{data.categories && data.categories.length > 0 ? ( categoriesFiltered.length > 0 ? ( diff --git a/web/src/components/overview/OverviewKeywordOpportunitiesCard.tsx b/web/src/components/overview/OverviewKeywordOpportunitiesCard.tsx index 5d14230..7994ad2 100644 --- a/web/src/components/overview/OverviewKeywordOpportunitiesCard.tsx +++ b/web/src/components/overview/OverviewKeywordOpportunitiesCard.tsx @@ -1,14 +1,19 @@ 'use client'; +import type { ReactNode } from 'react'; import Link from 'next/link'; -import { Lightbulb, Zap, ChevronRight, Tag } from 'lucide-react'; +import { CheckCircle2, ChevronRight, Lightbulb, Settings2, Tag, TrendingUp, Zap } from 'lucide-react'; import type { KeywordRow } from '@/types/components'; import type { ContentAnalyticsData, KeywordOpportunities, KeywordReportData } from '@/types/report'; import { strings, format } from '@/lib/strings'; import { viewIdToPathSlug } from '@/routes'; +import { dispatchOpenIntegrations } from '@/lib/pipelineJobEvents'; import { Card } from '@/components'; +import HelpHint from '@/components/HelpHint'; import { isJunkSemanticTerm } from '@/lib/semanticTextHygiene'; import { + KEYWORD_PREVIEW_LIMIT, + buildKeywordsTabHref, formatCrawlActionLabel, formatCrawlPagesSuffix, formatGscOpportunitySuffix, @@ -19,6 +24,7 @@ import { selectGscQuickWins, selectSiteTopKeywords, selectTopTopicClusters, + sumGscQuickWinClicks, } from './overviewKeywordOpportunities'; interface OverviewKeywordOpportunitiesCardProps { @@ -29,21 +35,66 @@ interface OverviewKeywordOpportunitiesCardProps { hasGoogleConnected: boolean; } -function KeywordListRow({ keyword, suffix }: { keyword: string; suffix: string }) { +function KeywordPreviewRow({ + href, + keyword, + suffix, + metricClassName = 'text-muted-foreground', +}: { + href: string; + keyword: string; + suffix: string; + metricClassName?: string; +}) { return ( -
  • - - {keyword} - - {suffix ? ( - - {suffix} +
  • + + + {keyword} - ) : null} + {suffix ? ( + {suffix} + ) : null} + +
  • ); } +function PreviewColumn({ + title, + icon: Icon, + iconClassName, + viewAllHref, + viewAllLabel, + children, +}: { + title: string; + icon: typeof Zap; + iconClassName: string; + viewAllHref: string; + viewAllLabel: string; + children: ReactNode; +}) { + return ( + +
    +

    + + {title} +

    + + {viewAllLabel} + +
    + {children} +
    + ); +} + export function OverviewKeywordOpportunitiesCard({ keywords, keywordOpportunities, @@ -59,136 +110,201 @@ export function OverviewKeywordOpportunitiesCard({ const gscKeywordCount = keywords?.gsc_keyword_count ?? 0; const hasGscEnrichment = gscKeywordCount > 0; - const gscQuickWins = selectGscQuickWins(kwRows); - const gscOpportunities = selectGscOpportunities(kwRows); - const crawlQuickWins = selectCrawlQuickWins(keywordOpportunities?.quick_wins); - const crawlHighValue = selectCrawlHighEmphasis(keywordOpportunities?.high_value); - const topicClusters = selectTopTopicClusters(keywordOpportunities?.token_topic_clusters); - const siteTopTerms = selectSiteTopKeywords(contentAnalytics?.top_keywords_site); + const gscQuickWinsAll = selectGscQuickWins(kwRows, 200); + const gscOpportunitiesAll = selectGscOpportunities(kwRows, 200); + const gscQuickWins = gscQuickWinsAll.slice(0, KEYWORD_PREVIEW_LIMIT); + const gscOpportunities = gscOpportunitiesAll.slice(0, KEYWORD_PREVIEW_LIMIT); + const crawlQuickWinsAll = selectCrawlQuickWins(keywordOpportunities?.quick_wins, 200); + const crawlHighValueAll = selectCrawlHighEmphasis(keywordOpportunities?.high_value, 200); + const crawlQuickWins = crawlQuickWinsAll.slice(0, KEYWORD_PREVIEW_LIMIT); + const crawlHighValue = crawlHighValueAll.slice(0, KEYWORD_PREVIEW_LIMIT); + const topicClusters = selectTopTopicClusters(keywordOpportunities?.token_topic_clusters, 6); + const siteTopTermsAll = selectSiteTopKeywords(contentAnalytics?.top_keywords_site, 200); + const siteTopTerms = siteTopTermsAll.slice(0, KEYWORD_PREVIEW_LIMIT); - const useGscMode = hasGscEnrichment && (gscQuickWins.length > 0 || gscOpportunities.length > 0); - const showCrawlColumns = !useGscMode && (crawlQuickWins.length > 0 || crawlHighValue.length > 0); - const showSiteTerms = !useGscMode && !showCrawlColumns && siteTopTerms.length > 0; + const useGscMode = hasGscEnrichment && (gscQuickWinsAll.length > 0 || gscOpportunitiesAll.length > 0); + const showCrawlColumns = !useGscMode && (crawlQuickWinsAll.length > 0 || crawlHighValueAll.length > 0); + const showSiteTerms = !useGscMode && !showCrawlColumns && siteTopTermsAll.length > 0; const showCard = useGscMode || showCrawlColumns || showSiteTerms || topicClusters.length > 0; if (!showCard) return null; - const quickWinsHref = `${keywordsHref}${keywordsHref.includes('?') ? '&' : '?'}tab=quickwins`; - const opportunitiesHref = `${keywordsHref}${keywordsHref.includes('?') ? '&' : '?'}tab=opportunities`; + const quickWinsHref = buildKeywordsTabHref(keywordsHref, 'quickwins'); + const opportunitiesHref = buildKeywordsTabHref(keywordsHref, 'opportunities'); + const topicsHref = buildKeywordsTabHref(keywordsHref, 'topics'); + const keywordRowHref = (tab: string) => buildKeywordsTabHref(keywordsHref, tab); + + const kpiLine = useGscMode + ? format(vo.keywordOpportunitiesKpiGsc, { + quickWins: gscQuickWinsAll.length.toLocaleString(), + clicks: sumGscQuickWinClicks(kwRows).toLocaleString(), + expansion: gscOpportunitiesAll.length.toLocaleString(), + }) + : showCrawlColumns + ? format(vo.keywordOpportunitiesKpiCrawl, { + actions: crawlQuickWinsAll.length.toLocaleString(), + emphasis: crawlHighValueAll.length.toLocaleString(), + }) + : showSiteTerms + ? format(vo.keywordOpportunitiesKpiSite, { + terms: siteTopTermsAll.length.toLocaleString(), + }) + : null; + + const showGscUpsell = !hasGscEnrichment && !hasGoogleConnected; return ( - -
    -
    - -

    {vo.keywordOpportunities}

    -
    - {kwRows.length > 0 ? ( + +
    +
    +
    +
    + +

    {vo.keywordOpportunities}

    + + {useGscMode ? vo.keywordOpportunitiesGscHint : vo.keywordOpportunitiesHint} + +
    +

    {vo.keywordOpportunitiesSubtitle}

    + {kpiLine ?

    {kpiLine}

    : null} + {hasGscEnrichment ? ( +

    + + {vo.keywordOpportunitiesGscConnected} +

    + ) : null} +
    {vo.viewKeywords} - + +
    + + {showGscUpsell ? ( +
    +
    +

    + + {vo.keywordOpportunitiesConnectGsc} +

    +

    {vo.keywordOpportunitiesConnectGscDetail}

    +
    + +
    ) : null}
    -

    - {useGscMode ? vo.keywordOpportunitiesGscHint : vo.keywordOpportunitiesHint} -

    - - {!hasGscEnrichment && !hasGoogleConnected ? ( -

    - {ke.dataStatus.noGscDetail} -

    - ) : null} - {(useGscMode || showCrawlColumns || showSiteTerms) && ( -
    +
    {useGscMode ? ( <> {gscQuickWins.length > 0 ? ( -
    -
    -

    - - {ke.overview.topQuickWins} -

    - - {ke.overview.viewAll} - -
    +
      {gscQuickWins.map((row) => ( - ))}
    -
    + ) : null} {gscOpportunities.length > 0 ? ( -
    -
    -

    - - {ke.overview.topOpportunities} -

    - - {ke.overview.viewAll} - -
    +
      {gscOpportunities.map((row) => ( - ))}
    -
    + ) : null} ) : showSiteTerms ? ( -
    -

    {vo.siteTopTerms}

    -
      + +
        {siteTopTerms.map((term) => ( - ))}
      -
    + ) : ( <> {crawlQuickWins.length > 0 ? ( -
    -

    {vo.quickWinsEase}

    +
      {crawlQuickWins.map((k, idx) => ( - ))}
    -
    + ) : null} {crawlHighValue.length > 0 ? ( -
    -

    {vo.highEmphasis}

    +
      {crawlHighValue.map((k, idx) => ( - format(vo.onPagesCount, { n })) || sj.emDash @@ -196,7 +312,7 @@ export function OverviewKeywordOpportunitiesCard({ /> ))}
    -
    + ) : null} )} @@ -204,34 +320,40 @@ export function OverviewKeywordOpportunitiesCard({ )} {topicClusters.length > 0 ? ( -
    -

    - - {vo.topThemes} -

    -
      +
      +
      +

      + + {vo.topThemes} +

      + + {vo.topThemesViewAll} + +
      +
      {topicClusters.map((cl, idx) => { const label = String(cl.top_keyword ?? cl.representative ?? ''); - const related = Array.isArray(cl.keywords) - ? cl.keywords.filter((kw) => !isJunkSemanticTerm(String(kw))).slice(0, 4).join(', ') - : ''; + if (!label || isJunkSemanticTerm(label)) return null; + const termCount = Array.isArray(cl.keywords) + ? cl.keywords.filter((kw) => !isJunkSemanticTerm(String(kw))).length + : 0; return ( -
    • -
      - {label} -
      - {related ? ( -
      - {related} -
      + {label} + {termCount > 0 ? ( + + {format(vo.topThemeTermCount, { n: termCount })} + ) : null} -
    • + ); })} -
    +
    ) : null} diff --git a/web/src/components/overview/OverviewSummaryTab.tsx b/web/src/components/overview/OverviewSummaryTab.tsx index 96d7809..df74f1c 100644 --- a/web/src/components/overview/OverviewSummaryTab.tsx +++ b/web/src/components/overview/OverviewSummaryTab.tsx @@ -1,20 +1,11 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { - Globe, - CheckCircle, - AlertTriangle, - FileCode, - BookOpen, - Share, - Cpu, - Timer, TrendingUp, ChevronRight, - Sparkles, ArrowLeftRight, FileDown, } from 'lucide-react'; @@ -22,12 +13,14 @@ import type { ReportPayload } from '@/types'; import type { DataSourceId } from '@/lib/dataProvenance'; import { strings, format } from '@/lib/strings'; import { metricHelpHint } from '@/lib/metricHelp'; -import { crawledUrlCount } from '@/lib/crawlCounts'; import { googleSnapshotStatus } from '@/lib/googleSnapshot'; -import { Card, AlertBanner, StatCard, LabelWithHint } from '@/components'; +import { Card, AlertBanner, StatCard } from '@/components'; import { DataSourceBadgeRow } from '@/components/DataSourceBadge'; import LlmDisclosure from '@/components/LlmDisclosure'; import { OverviewTabPanel } from './OverviewTabPanel'; +import { OverviewExecutiveSummary } from './OverviewExecutiveSummary'; +import { OverviewCrawlMetrics } from './OverviewCrawlMetrics'; +import { OverviewContentQuality } from './OverviewContentQuality'; import { OverviewKeywordOpportunitiesCard, buildKeywordsHref, @@ -42,149 +35,68 @@ export interface OverviewSummaryTabProps { export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount }: OverviewSummaryTabProps) { const vo = strings.views.overview; - const sj = strings.common; const searchParams = useSearchParams(); - const [healthDelta, setHealthDelta] = useState(null); - const [historyError, setHistoryError] = useState(null); + const querySuffix = searchParams.toString() ? `?${searchParams.toString()}` : ''; const keywordsHref = useMemo( () => buildKeywordsHref(searchParams.toString()), [searchParams], ); - const s = data.summary || {}; - const crawledCount = crawledUrlCount(data); - const healthScore = (data.categories || []) - .map((c) => Number(c?.score)) - .filter((n) => Number.isFinite(n)); - const currentHealth = - healthScore.length > 0 - ? Math.round(healthScore.reduce((a, b) => a + b, 0) / healthScore.length) - : null; + const { currentHealth, topIssues } = useMemo(() => { + const scores = (data.categories || []) + .map((c) => Number(c?.score)) + .filter((n) => Number.isFinite(n)); + const health = + scores.length > 0 + ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) + : null; + const exec = (data.executive_summary?.top_issues || []).slice(0, 5); + const fallback = (data.categories || []) + .flatMap((cat) => + (cat.issues || []).map((iss) => ({ + ...iss, + category: cat.name || cat.id, + })), + ) + .filter((iss) => iss.priority === 'Critical' || iss.priority === 'High') + .slice(0, 3); + return { currentHealth: health, topIssues: exec.length > 0 ? exec : fallback }; + }, [data.categories, data.executive_summary]); - useEffect(() => { - const domain = data.site_name || ''; - if (!domain) return; - setHistoryError(null); - void fetch(`/api/report/history?domain=${encodeURIComponent(domain)}&limit=2`) - .then(async (r) => { - if (!r.ok) { - setHistoryError(vo.historyTrendUnavailable ?? 'Could not load health trend.'); - return; - } - const payload = (await r.json()) as { history?: Array<{ healthScore?: number | null }> }; - const hist = payload.history || []; - if (hist.length >= 2 && currentHealth != null && hist[1]?.healthScore != null) { - setHealthDelta(currentHealth - Number(hist[1].healthScore)); - } - }) - .catch(() => setHistoryError(vo.historyTrendUnavailable ?? 'Could not load health trend.')); - }, [data.site_name, currentHealth, vo.historyTrendUnavailable]); - - const execTopIssues = (data.executive_summary?.top_issues || []).slice(0, 5); - const execPriorities = (data.executive_summary?.priorities || []).filter(Boolean); - const execSource = data.executive_summary?.source; - const fallbackTopIssues = (data.categories || []) - .flatMap((cat) => - (cat.issues || []).map((iss) => ({ - ...iss, - category: cat.name || cat.id, - })), - ) - .filter((iss) => iss.priority === 'Critical' || iss.priority === 'High') - .slice(0, 3); - const topIssues = execTopIssues.length > 0 ? execTopIssues : fallbackTopIssues; - const h1Zero = (data.seo_health && data.seo_health.h1_zero) || 0; - const brokenCount = (s.count_4xx || 0) + (s.count_5xx || 0); const googleData = data.google; const googleSnap = googleSnapshotStatus(googleData); - const metaSources = (data.report_meta?.data_sources || []) as string[]; - const provenanceSources: DataSourceId[] = metaSources - .map((src) => { - if (src === 'search_console') return 'search_console'; - if (src === 'analytics') return 'analytics'; - if (src === 'lighthouse') return 'lighthouse'; - if (src === 'estimated') return 'estimated'; - if (src === 'ai') return 'ai'; - return 'crawl'; - }) - .filter((v, i, a) => a.indexOf(v) === i) as DataSourceId[]; - - const showContentIntelligence = - (data.content_duplicates?.length ?? 0) > 0 || - (data.language_summary?.counts && Object.keys(data.language_summary.counts).length > 0) || - (data.semantic_keyword_clusters?.length ?? 0) > 0 || - (data.ner_site_summary?.label_counts && Object.keys(data.ner_site_summary.label_counts).length > 0); - - const execSummary = data.executive_summary?.summary; - const gscClicks = data.google?.gsc?.summary?.clicks; + const provenanceSources: DataSourceId[] = useMemo(() => { + const metaSources = (data.report_meta?.data_sources || []) as string[]; + return metaSources + .map((src) => { + if (src === 'search_console') return 'search_console'; + if (src === 'analytics') return 'analytics'; + if (src === 'lighthouse') return 'lighthouse'; + if (src === 'estimated') return 'estimated'; + if (src === 'ai') return 'ai'; + return 'crawl'; + }) + .filter((v, i, a) => a.indexOf(v) === i) as DataSourceId[]; + }, [data.report_meta]); return (
    - {(execSummary || currentHealth != null) && ( - - {currentHealth != null && ( -

    - Audit health: {currentHealth}/100 - {healthDelta != null && healthDelta !== 0 && ( - 0 ? ' text-emerald-600' : ' text-rose-600'}> - {' '} - ({healthDelta > 0 ? '+' : ''} - {healthDelta} vs prior run) - - )} - {historyError ? ( - {historyError} - ) : null} -

    - )} - {execSummary ? ( - <> - {execSource === 'ai_insights' ? ( -

    - {vo.executiveAiLabel} -

    - ) : null} -

    {execSummary}

    - - ) : null} - {execPriorities.length > 0 ? ( -
      - {execPriorities.map((line, i) => ( -
    • {line}
    • - ))} -
    - ) : null} - {gscClicks != null && ( -

    - Search Console clicks ({data.report_meta?.google_date_range_days ?? 28}d): {Number(gscClicks).toLocaleString()} -

    - )} - {topIssues.length > 0 && ( -
    -

    - {vo.topTrafficIssues} -

    -
      - {topIssues.map((iss, i) => { - const row = iss as { message?: string; priority?: string; gsc_clicks?: number }; - const clicks = Number(row.gsc_clicks || 0); - return ( -
    • - [{row.priority}] {row.message} - {clicks > 0 ? ( - - ({clicks.toLocaleString()} {vo.clicksLabel}) - - ) : null} -
    • - ); - })} -
    -
    - )} -
    - )} + + {provenanceSources.length > 0 ? (
    {vo.dataSourcesLabel}: @@ -266,80 +178,9 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount ) : null}
    -
    - -
    - -
    -
    {crawledCount.toLocaleString()}
    -
    {s.avg_outlinks ?? 0} {vo.avgOutlinks}
    -
    - -
    - -
    -
    {s.success_rate ?? 0}%
    -
    - -
    - -
    -
    {brokenCount}
    -
    - {format(vo.count4xx5xx, { count4xx: s.count_4xx ?? 0, count5xx: s.count_5xx ?? 0 })} -
    -
    - -
    - -
    -
    {h1Zero}
    -
    -
    + -
    - -
    - -
    -
    - {data.content_analytics?.word_count_stats?.median != null - ? Math.round(data.content_analytics.word_count_stats.median).toLocaleString() - : sj.emDash} -
    -
    {vo.perPage2xx}
    -
    - -
    - -
    -
    - {data.social_coverage?.og_coverage_pct != null ? `${data.social_coverage.og_coverage_pct}%` : sj.emDash} -
    -
    {vo.ogPagesWith}
    -
    - -
    - {vo.technologies} -
    -
    - {data.tech_stack_summary?.technologies?.length ?? sj.emDash} -
    -
    {vo.techDetectedAcross}
    -
    - -
    - -
    -
    - {data.response_time_stats?.p50 != null ? `${Math.round(data.response_time_stats.p50)}ms` : sj.emDash} -
    -
    - {vo.p95Label}{' '} - {data.response_time_stats?.p95 != null ? `${Math.round(data.response_time_stats.p95)}ms` : sj.emDash} -
    -
    -
    + {reportCount >= 2 ? ( @@ -361,81 +202,6 @@ export function OverviewSummaryTab({ data, exportHref, compareHref, reportCount
    ) : null} - - - - {showContentIntelligence ? ( -
    -

    - - {vo.contentIntelligence} -

    -
    - -
    - {vo.duplicateGroups} -
    -
    - {data.content_duplicates?.length ?? 0} -
    -
    {vo.nearDuplicateGroups}
    -
    - -
    - {vo.parentTopics} -
    -
    - {data.semantic_keyword_clusters?.length ?? 0} -
    -
    {vo.semanticGroups}
    -
    - -
    - {vo.namedEntities} -
    -
    - {data.ner_site_summary?.total_entities != null - ? data.ner_site_summary.total_entities.toLocaleString() - : sj.emDash} -
    -
    - {data.ner_site_summary?.pages_with_ner != null - ? format(vo.pagesSampled, { n: data.ner_site_summary.pages_with_ner }) - : vo.entitiesSitewide} -
    -
    - -
    - {vo.languagesSampled} -
    -
    - {Object.entries(data.language_summary?.counts || {}) - .slice(0, 8) - .map(([lang, n]) => ( - - {lang}: {String(n)} - - ))} - {(!data.language_summary?.counts || Object.keys(data.language_summary.counts).length === 0) && ( - {sj.emDash} - )} -
    - {data.language_summary?.mixed_site && ( -

    {vo.mixedLanguage}

    - )} -
    -
    -
    - ) : null}
    ); } diff --git a/web/src/components/overview/PortfolioBenchmarkCard.tsx b/web/src/components/overview/PortfolioBenchmarkCard.tsx index c598a78..c92017d 100644 --- a/web/src/components/overview/PortfolioBenchmarkCard.tsx +++ b/web/src/components/overview/PortfolioBenchmarkCard.tsx @@ -1,81 +1,232 @@ 'use client'; -import { AlertCircle, TrendingUp } from 'lucide-react'; -import { Card } from '@/components'; -import { strings } from '@/lib/strings'; -import type { PortfolioBenchmark } from '@/types/report'; +import Link from 'next/link'; +import { + AlertCircle, + ArrowLeftRight, + ChevronRight, + LayoutGrid, + TrendingDown, + TrendingUp, +} from 'lucide-react'; +import { Card, HelpHint } from '@/components'; +import { CategoryScoreGauge } from '@/components/charts/CategoryScoreGauge'; +import { strings, format } from '@/lib/strings'; +import type { PortfolioBenchmark, PortfolioBenchmarkStatus } from '@/types/report'; +import { + portfolioDeltaClassName, + portfolioDeltaNarrative, + portfolioMedianClassName, +} from './portfolioBenchmarkUtils'; -interface PortfolioBenchmarkCardProps { +const vo = strings.views.overview; + +export interface PortfolioBenchmarkCardProps { benchmark?: PortfolioBenchmark | null; + compareHref?: string; + reportCount?: number; + portfolioHref?: string; + categoriesAnchorId?: string; +} + +function PortfolioComparisonBar({ + property, + median, +}: { + property: number; + median: number; +}) { + const propertyPct = Math.min(100, Math.max(0, property)); + const medianPct = Math.min(100, Math.max(0, median)); + + return ( +
    +
    +
    +
    +
    +
    +
    + 0 + + {format(vo.portfolioMedianMarker, { score: median })} + + 100 +
    +
    + ); +} + +function StatusBanner({ + status, + message, + portfolioHref, +}: { + status: PortfolioBenchmarkStatus; + message?: string; + portfolioHref: string; +}) { + if (!message) return null; + + const isError = status === 'error'; + const ctaLabel = + status === 'single_property' ? vo.portfolioSinglePropertyCta : vo.portfolioViewPortfolio; + + return ( +
    +
    + +

    + {message} +

    +
    + + + {ctaLabel} + + +
    + ); } -export function PortfolioBenchmarkCard({ benchmark }: PortfolioBenchmarkCardProps) { - const vo = strings.views.overview; +export function PortfolioBenchmarkCard({ + benchmark, + compareHref, + reportCount = 0, + portfolioHref = '/', + categoriesAnchorId = 'overview-health-categories', +}: PortfolioBenchmarkCardProps) { if (!benchmark) return null; const status = benchmark.status; const property = benchmark.property_health_score; const median = benchmark.median_health_score; - const showScores = status === 'ok' || status == null; - const showBanner = status && status !== 'ok'; - - if (!showScores && !showBanner && property == null && median == null) return null; - + const propertyCount = benchmark.property_count; + const isComparable = status === 'ok' || status == null; const delta = property != null && median != null ? property - median : null; + const deltaNarrative = portfolioDeltaNarrative(delta); + + if (!isComparable && property == null && !benchmark.message) return null; return ( - -
    - -

    {vo.portfolioBenchmarkTitle}

    + +
    +
    +
    +
    + +

    {vo.portfolioBenchmarkTitle}

    + + {vo.portfolioBenchmarkHelpBody} + +
    +

    {vo.portfolioBenchmarkSubtitle}

    + {isComparable && propertyCount != null && propertyCount > 1 ? ( +

    + {format(vo.portfolioPropertyCount, { count: propertyCount.toLocaleString() })} +

    + ) : null} +
    +
    + + + {vo.portfolioViewPortfolio} + + {compareHref && reportCount > 1 ? ( + + + {vo.portfolioCompareRuns} + + ) : null} +
    +
    -

    {vo.portfolioBenchmarkHint}

    - {showBanner && benchmark.message ? ( -
    - - {benchmark.message} -
    - ) : null} +
    + {!isComparable && benchmark.message ? ( + + ) : null} - {showScores ? ( -
    -
    -
    {vo.portfolioPropertyScore}
    -
    {property ?? '—'}
    -
    -
    -
    {vo.portfolioMedianScore}
    -
    {median ?? '—'}
    + {isComparable && property != null && median != null ? ( +
    + +
    + +
    + {deltaNarrative ? ( +

    + {delta != null && delta < 0 ? ( + + ) : delta != null && delta > 0 ? ( + + ) : null} + {deltaNarrative} +

    + ) : null} +
    +
    +
    +

    + {vo.portfolioMedianScore} +

    +

    + {median} +

    +
    +
    +

    + {vo.portfolioDelta} +

    +

    + {delta == null ? '—' : `${delta >= 0 ? '+' : ''}${delta}`} +

    +
    +
    +
    -
    -
    {vo.portfolioDelta}
    -
    = 0 - ? 'text-green-700 dark:text-green-400' - : 'text-amber-700 dark:text-amber-400' - }`} + ) : property != null ? ( +
    + + - {delta == null ? '—' : `${delta >= 0 ? '+' : ''}${delta}`} -
    + {vo.portfolioScrollCategories} + +
    -
    - ) : property != null ? ( -
    -
    {vo.portfolioPropertyScore}
    -
    {property}
    -
    - ) : null} + ) : null} +
    ); } diff --git a/web/src/components/overview/contentQualityMetrics.test.ts b/web/src/components/overview/contentQualityMetrics.test.ts new file mode 100644 index 0000000..8adb2d0 --- /dev/null +++ b/web/src/components/overview/contentQualityMetrics.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + duplicateGroupsBand, + languageShares, + selectContentConcerns, + selectTopDuplicateClusters, + shouldShowContentQuality, + totalDuplicateMemberPages, +} from './contentQualityMetrics'; + +describe('contentQualityMetrics', () => { + it('shows section when duplicates or languages exist', () => { + expect(shouldShowContentQuality({ content_duplicates: [{ id: 'a', representative_url: 'https://x.com' }] })).toBe(true); + expect(shouldShowContentQuality({ language_summary: { counts: { en: 10 } } })).toBe(true); + expect(shouldShowContentQuality({})).toBe(false); + }); + + it('sorts duplicate clusters by member count', () => { + const clusters = selectTopDuplicateClusters( + [ + { id: 'a', representative_url: 'https://a.com', member_count: 2 }, + { id: 'b', representative_url: 'https://b.com', member_count: 8 }, + ], + 1, + ); + expect(clusters[0]?.id).toBe('b'); + }); + + it('computes duplicate member totals and bands', () => { + expect( + totalDuplicateMemberPages([ + { id: 'a', representative_url: 'https://a.com', member_count: 3 }, + { id: 'b', representative_url: 'https://b.com', member_count: 7 }, + ]), + ).toBe(10); + expect(duplicateGroupsBand(10)).toBe('critical'); + expect(duplicateGroupsBand(2)).toBe('fair'); + }); + + it('builds language share percentages', () => { + const shares = languageShares({ en: 90, fr: 10 }, 2); + expect(shares).toHaveLength(2); + expect(shares[0]?.lang).toBe('en'); + expect(shares[0]?.pct).toBe(90); + }); + + it('selects content concerns', () => { + const concerns = selectContentConcerns({ + duplicateGroups: 10, + duplicatePages: 40, + mixedLanguage: true, + languageCount: 6, + contentHref: '/content', + textAnalysisHref: '/text-content-analysis', + formatDuplicateGroups: (groups, pages) => `${groups}/${pages}`, + formatMixedLanguage: (languages) => `mixed ${languages}`, + }); + expect(concerns[0]?.id).toBe('duplicates'); + expect(concerns.some((c) => c.id === 'mixed-language')).toBe(true); + }); +}); diff --git a/web/src/components/overview/contentQualityMetrics.ts b/web/src/components/overview/contentQualityMetrics.ts new file mode 100644 index 0000000..79d5fab --- /dev/null +++ b/web/src/components/overview/contentQualityMetrics.ts @@ -0,0 +1,113 @@ +import type { ContentDuplicateCluster } from '@/types/report'; +import { buildViewHref } from './crawlSnapshotMetrics'; + +export interface LanguageShare { + lang: string; + count: number; + pct: number; +} + +export interface ContentConcern { + id: string; + label: string; + href: string; + severity: number; +} + +export function shouldShowContentQuality(data: { + content_duplicates?: ContentDuplicateCluster[]; + language_summary?: { counts?: Record }; + semantic_keyword_clusters?: unknown[]; + ner_site_summary?: { label_counts?: Record; total_entities?: number; pages_with_ner?: number }; +}): boolean { + return ( + (data.content_duplicates?.length ?? 0) > 0 || + Object.keys(data.language_summary?.counts || {}).length > 0 || + (data.semantic_keyword_clusters?.length ?? 0) > 0 || + Object.keys(data.ner_site_summary?.label_counts || {}).length > 0 || + (data.ner_site_summary?.total_entities ?? 0) > 0 + ); +} + +export function duplicateMemberCount(cluster: ContentDuplicateCluster): number { + return cluster.member_count ?? cluster.member_urls?.length ?? 0; +} + +export function totalDuplicateMemberPages(clusters: ContentDuplicateCluster[] | undefined): number { + return (clusters || []).reduce((sum, cluster) => sum + duplicateMemberCount(cluster), 0); +} + +export function selectTopDuplicateClusters( + clusters: ContentDuplicateCluster[] | undefined, + limit = 2, +): ContentDuplicateCluster[] { + return [...(clusters || [])] + .sort((a, b) => duplicateMemberCount(b) - duplicateMemberCount(a)) + .slice(0, limit); +} + +export function languageShares(counts: Record | undefined, limit = 5): LanguageShare[] { + const entries = Object.entries(counts || {}) + .map(([lang, count]) => ({ lang, count: Number(count) || 0 })) + .filter((row) => row.lang && row.count > 0) + .sort((a, b) => b.count - a.count); + const total = entries.reduce((sum, row) => sum + row.count, 0) || 1; + return entries.slice(0, limit).map((row) => ({ + ...row, + pct: Math.round((row.count / total) * 1000) / 10, + })); +} + +export function languageCount(counts: Record | undefined): number { + return Object.keys(counts || {}).filter((lang) => Number(counts?.[lang] ?? 0) > 0).length; +} + +export function duplicateGroupsBand(groupCount: number): 'good' | 'fair' | 'critical' { + if (groupCount <= 0) return 'good'; + if (groupCount < 5) return 'fair'; + return 'critical'; +} + +export function stripUrlForDisplay(url: string, maxLen = 72): string { + return url.replace(/^https?:\/\//, '').slice(0, maxLen); +} + +export interface ContentConcernInput { + duplicateGroups: number; + duplicatePages: number; + mixedLanguage: boolean; + languageCount: number; + contentHref: string; + textAnalysisHref: string; + formatDuplicateGroups: (groups: string, pages: string) => string; + formatMixedLanguage: (languages: string) => string; +} + +export function selectContentConcerns(input: ContentConcernInput, limit = 3): ContentConcern[] { + const concerns: ContentConcern[] = []; + + if (input.duplicateGroups > 0) { + concerns.push({ + id: 'duplicates', + label: input.formatDuplicateGroups( + input.duplicateGroups.toLocaleString(), + input.duplicatePages.toLocaleString(), + ), + href: input.contentHref, + severity: 200 + input.duplicateGroups, + }); + } + + if (input.mixedLanguage) { + concerns.push({ + id: 'mixed-language', + label: input.formatMixedLanguage(input.languageCount.toLocaleString()), + href: input.textAnalysisHref, + severity: 150 + input.languageCount, + }); + } + + return concerns.sort((a, b) => b.severity - a.severity).slice(0, limit); +} + +export { buildViewHref }; diff --git a/web/src/components/overview/crawlSnapshotMetrics.test.ts b/web/src/components/overview/crawlSnapshotMetrics.test.ts new file mode 100644 index 0000000..584e380 --- /dev/null +++ b/web/src/components/overview/crawlSnapshotMetrics.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { + buildViewHref, + medianWordsBand, + pctOfCrawl, + responseTimeBand, + selectCrawlConcerns, + successRateBand, +} from './crawlSnapshotMetrics'; + +describe('crawlSnapshotMetrics', () => { + it('computes crawl percentage', () => { + expect(pctOfCrawl(255, 1500)).toBe(17); + expect(pctOfCrawl(0, 1500)).toBeNull(); + }); + + it('clamps pctOfCrawl to 100 when sources disagree', () => { + expect(pctOfCrawl(2000, 1500)).toBe(100); + }); + + it('bands success rate', () => { + expect(successRateBand(95)).toBe('good'); + expect(successRateBand(85)).toBe('fair'); + expect(successRateBand(79)).toBe('critical'); + }); + + it('bands median words and response time', () => { + expect(medianWordsBand(64)).toBe('critical'); + expect(medianWordsBand(200)).toBe('fair'); + expect(responseTimeBand(2235)).toBe('critical'); + }); + + it('builds view href with tab param', () => { + expect(buildViewHref('overview', '?domain=example.com', { tab: 'charts' })).toBe( + '/dashboard?domain=example.com&tab=charts', + ); + }); + + it('selects top crawl concerns by severity', () => { + const concerns = selectCrawlConcerns({ + brokenCount: 255, + h1Zero: 405, + crawledCount: 1500, + successRate: 79, + medianWords: 64, + responseP50: 2235, + linksHref: '/links', + contentHref: '/content', + contentAnalyticsHref: '/content-analytics', + chartsHref: '/dashboard?tab=charts', + formatBroken: (count, pct) => `${count} broken (${pct})`, + formatMissingH1: (count, pct) => `${count} h1 (${pct})`, + formatSuccess: (rate) => `success ${rate}`, + formatThinContent: (median) => `thin ${median}`, + formatSlowResponse: (ms) => `slow ${ms}`, + }); + expect(concerns[0]?.id).toBe('broken'); + expect(concerns.length).toBeLessThanOrEqual(3); + }); +}); diff --git a/web/src/components/overview/crawlSnapshotMetrics.ts b/web/src/components/overview/crawlSnapshotMetrics.ts new file mode 100644 index 0000000..3e9c5fa --- /dev/null +++ b/web/src/components/overview/crawlSnapshotMetrics.ts @@ -0,0 +1,168 @@ +import type { ViewId } from '@/routes'; +import { viewIdToPathSlug } from '@/routes'; + +export type MetricBand = 'good' | 'fair' | 'critical'; + +export interface CrawlConcern { + id: string; + label: string; + href: string; + severity: number; +} + +export function pctOfCrawl(count: number, total: number): number | null { + if (total <= 0 || count <= 0) return null; + return Math.min(100, Math.round((count / total) * 1000) / 10); +} + +export function successRateBand(rate: number | null | undefined): MetricBand { + if (rate == null || !Number.isFinite(rate)) return 'fair'; + if (rate >= 90) return 'good'; + if (rate >= 80) return 'fair'; + return 'critical'; +} + +export function medianWordsBand(median: number | null | undefined): MetricBand { + if (median == null || !Number.isFinite(median)) return 'fair'; + if (median >= 300) return 'good'; + if (median >= 150) return 'fair'; + return 'critical'; +} + +export function responseTimeBand(p50Ms: number | null | undefined): MetricBand { + if (p50Ms == null || !Number.isFinite(p50Ms)) return 'fair'; + if (p50Ms <= 800) return 'good'; + if (p50Ms <= 1500) return 'fair'; + return 'critical'; +} + +export function ogCoverageBand(pct: number | null | undefined): MetricBand { + if (pct == null || !Number.isFinite(pct)) return 'fair'; + if (pct >= 90) return 'good'; + if (pct >= 70) return 'fair'; + return 'critical'; +} + +export function bandClassName(band: MetricBand): string { + if (band === 'good') return 'text-green-700 dark:text-green-400'; + if (band === 'fair') return 'text-yellow-700 dark:text-yellow-400'; + return 'text-red-600 dark:text-red-400'; +} + +export function metricBandLabel( + band: MetricBand, + labels: { metricBandGood: string; metricBandFair: string; metricBandCritical: string }, +): string { + if (band === 'good') return labels.metricBandGood; + if (band === 'fair') return labels.metricBandFair; + return labels.metricBandCritical; +} + + +export function buildViewHref( + viewId: ViewId, + querySuffix: string, + extraParams?: Record, +): string { + const base = `/${viewIdToPathSlug(viewId)}`; + const params = new URLSearchParams(querySuffix.startsWith('?') ? querySuffix.slice(1) : querySuffix); + if (extraParams) { + for (const [key, value] of Object.entries(extraParams)) { + params.set(key, value); + } + } + const q = params.toString(); + return q ? `${base}?${q}` : base; +} + +export interface CrawlConcernInput { + brokenCount: number; + h1Zero: number; + crawledCount: number; + successRate: number | null | undefined; + medianWords: number | null | undefined; + responseP50: number | null | undefined; + linksHref: string; + contentHref: string; + contentAnalyticsHref: string; + chartsHref: string; + formatBroken: (count: string, pct: string) => string; + formatMissingH1: (count: string, pct: string) => string; + formatSuccess: (rate: string) => string; + formatThinContent: (median: string) => string; + formatSlowResponse: (ms: string) => string; +} + +export function selectCrawlConcerns(input: CrawlConcernInput, limit = 3): CrawlConcern[] { + const concerns: CrawlConcern[] = []; + const brokenPct = pctOfCrawl(input.brokenCount, input.crawledCount); + const h1Pct = pctOfCrawl(input.h1Zero, input.crawledCount); + + if (input.brokenCount > 0) { + concerns.push({ + id: 'broken', + label: input.formatBroken( + input.brokenCount.toLocaleString(), + brokenPct != null ? `${brokenPct}%` : '—', + ), + href: input.linksHref, + severity: 300 + input.brokenCount, + }); + } + + if (input.h1Zero > 0) { + concerns.push({ + id: 'h1', + label: input.formatMissingH1(input.h1Zero.toLocaleString(), h1Pct != null ? `${h1Pct}%` : '—'), + href: input.contentHref, + severity: 80 + input.h1Zero, + }); + } + + const successBand = successRateBand(input.successRate); + if (successBand === 'critical' && input.successRate != null) { + concerns.push({ + id: 'success', + label: input.formatSuccess(String(input.successRate)), + href: input.linksHref, + severity: 70, + }); + } + + const wordsBand = medianWordsBand(input.medianWords); + if (wordsBand === 'critical' && input.medianWords != null) { + concerns.push({ + id: 'thin', + label: input.formatThinContent(String(Math.round(input.medianWords))), + href: input.contentAnalyticsHref, + severity: 60, + }); + } + + const responseBand = responseTimeBand(input.responseP50); + if (responseBand === 'critical' && input.responseP50 != null) { + concerns.push({ + id: 'slow', + label: input.formatSlowResponse(String(Math.round(input.responseP50))), + href: input.chartsHref, + severity: 50 + Math.round(input.responseP50 / 100), + }); + } + + return concerns.sort((a, b) => b.severity - a.severity).slice(0, limit); +} + +export function brokenSubline( + count4xx: number, + count5xx: number, + crawledCount: number, + formatCountPct: (count: string, pct: string) => string, + formatSplit: (count4xx: number, count5xx: number) => string, +): string { + const total = count4xx + count5xx; + const pct = pctOfCrawl(total, crawledCount); + if (count4xx > 0 && count5xx > 0) { + return `${formatCountPct(total.toLocaleString(), pct != null ? `${pct}%` : '—')} · ${formatSplit(count4xx, count5xx)}`; + } + return formatCountPct(total.toLocaleString(), pct != null ? `${pct}%` : '—'); +} diff --git a/web/src/components/overview/overviewChartInsights.test.ts b/web/src/components/overview/overviewChartInsights.test.ts new file mode 100644 index 0000000..ceb07d5 --- /dev/null +++ b/web/src/components/overview/overviewChartInsights.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + dominantBucketLabel, + thinWordCountPages, + titleMetaProblemPages, + wordCountBucketColors, + responseTimeBucketColors, + statusDistributionTakeaway, +} from './overviewChartInsights'; +import { SEMANTIC } from '@/utils/chartPalette'; + +describe('overviewChartInsights', () => { + it('colors thin word-count buckets as poor', () => { + const colors = wordCountBucketColors(['0-100', '101-300', '601-1000']); + expect(colors[0]).toBe(SEMANTIC.poor); + expect(colors[1]).toBe(SEMANTIC.poor); + expect(colors[2]).toBe(SEMANTIC.good); + }); + + it('colors slow response buckets as poor', () => { + const colors = responseTimeBucketColors(['<200ms', '1-2s', '>2s']); + expect(colors[0]).toBe(SEMANTIC.good); + expect(colors[1]).toBe(SEMANTIC.poor); + expect(colors[2]).toBe(SEMANTIC.poor); + }); + + it('finds dominant bucket', () => { + const dominant = dominantBucketLabel(['a', 'b', 'c'], [10, 50, 5]); + expect(dominant?.label).toBe('b'); + expect(dominant?.count).toBe(50); + expect(dominant?.pct).toBeGreaterThan(75); + }); + + it('sums thin word count pages', () => { + expect(thinWordCountPages({ '0-100': 5, '101-300': 3, '601-1000': 2 })).toBe(8); + }); + + it('sums title meta problems', () => { + expect( + titleMetaProblemPages({ + missing_title: 2, + title_ok: 10, + missing_meta_desc: 3, + meta_desc_ok: 8, + }), + ).toBe(5); + }); + + it('builds status takeaway for errors', () => { + const takeaway = statusDistributionTakeaway({ '404': 10, '500': 2 }, 100); + expect(takeaway).toContain('12'); + }); +}); diff --git a/web/src/components/overview/overviewChartInsights.ts b/web/src/components/overview/overviewChartInsights.ts new file mode 100644 index 0000000..6def25f --- /dev/null +++ b/web/src/components/overview/overviewChartInsights.ts @@ -0,0 +1,291 @@ +import type { ReportPayload } from '@/types'; +import { strings, format } from '@/lib/strings'; +import { crawledUrlCount } from '@/lib/crawlCounts'; +import { SEMANTIC } from '@/utils/chartPalette'; +import { + buildViewHref, + pctOfCrawl, + selectCrawlConcerns, + successRateBand, + medianWordsBand, + responseTimeBand, + ogCoverageBand, + type CrawlConcern, +} from './crawlSnapshotMetrics'; + +const vo = strings.views.overview; + +export function wordCountBucketColors(labels: string[]): string[] { + const thin = new Set(['0-100', '101-300']); + const fair = new Set(['301-600']); + return labels.map((label) => { + if (thin.has(label)) return SEMANTIC.poor; + if (fair.has(label)) return SEMANTIC.warn; + return SEMANTIC.good; + }); +} + +export function responseTimeBucketColors(labels: string[]): string[] { + const slow = new Set(['1-2s', '>2s']); + const fair = new Set(['500ms-1s']); + return labels.map((label) => { + if (slow.has(label)) return SEMANTIC.poor; + if (fair.has(label)) return SEMANTIC.warn; + return SEMANTIC.good; + }); +} + +export function titleMetaBucketColors(): string[] { + return [SEMANTIC.poor, SEMANTIC.warn, SEMANTIC.warn, SEMANTIC.good]; +} + +export function dominantBucketLabel( + labels: string[], + values: number[], +): { label: string; count: number; pct: number } | null { + if (!labels.length || !values.length) return null; + let maxIdx = 0; + let maxVal = 0; + const total = values.reduce((a, b) => a + b, 0); + if (total <= 0) return null; + values.forEach((v, i) => { + if (v > maxVal) { + maxVal = v; + maxIdx = i; + } + }); + return { + label: labels[maxIdx], + count: maxVal, + pct: Math.round((maxVal / total) * 1000) / 10, + }; +} + +export function thinWordCountPages(dist: Record | undefined): number { + if (!dist) return 0; + return Number(dist['0-100'] || 0) + Number(dist['101-300'] || 0); +} + +export function slowResponseUrls(dist: Record | undefined): number { + if (!dist) return 0; + return Number(dist['1-2s'] || 0) + Number(dist['>2s'] || 0); +} + +export function titleMetaProblemPages(seo: ReportPayload['seo_health']): number { + if (!seo) return 0; + return ( + Number(seo.missing_title || 0) + + Number(seo.title_short || 0) + + Number(seo.title_long || 0) + + Number(seo.missing_meta_desc || 0) + + Number(seo.meta_desc_short || 0) + + Number(seo.meta_desc_long || 0) + ); +} + +export interface ChartInsightContext { + querySuffix: string; + data: ReportPayload; +} + +export function buildChartViewHrefs(querySuffix: string) { + return { + links: buildViewHref('links', querySuffix), + content: buildViewHref('content', querySuffix), + contentAnalytics: buildViewHref('content-analytics', querySuffix), + network: buildViewHref('network', querySuffix), + techStack: buildViewHref('tech-stack', querySuffix), + lighthouse: buildViewHref('lighthouse', querySuffix), + }; +} + +export function selectChartConcerns(ctx: ChartInsightContext, limit = 5): CrawlConcern[] { + const s = ctx.data.summary || {}; + const crawledCount = crawledUrlCount(ctx.data); + const brokenCount = (s.count_4xx || 0) + (s.count_5xx || 0); + const h1Zero = ctx.data.seo_health?.h1_zero ?? 0; + const medianWords = + ctx.data.content_analytics?.word_count_stats?.median != null + ? Math.round(ctx.data.content_analytics.word_count_stats.median) + : null; + const p50 = ctx.data.response_time_stats?.p50 ?? null; + const hrefs = buildChartViewHrefs(ctx.querySuffix); + + const concerns = selectCrawlConcerns({ + brokenCount, + h1Zero, + crawledCount, + successRate: s.success_rate, + medianWords, + responseP50: p50, + linksHref: hrefs.links, + contentHref: hrefs.content, + contentAnalyticsHref: hrefs.contentAnalytics, + chartsHref: buildViewHref('overview', ctx.querySuffix, { tab: 'charts' }), + formatBroken: (count, pct) => format(vo.crawlConcernBroken, { count, pct }), + formatMissingH1: (count, pct) => format(vo.crawlConcernMissingH1, { count, pct }), + formatSuccess: (rate) => format(vo.crawlConcernSuccess, { rate }), + formatThinContent: (median) => format(vo.crawlConcernThinContent, { median }), + formatSlowResponse: (ms) => format(vo.crawlConcernSlowResponse, { ms }), + }, limit); + + const titleMetaProblems = titleMetaProblemPages(ctx.data.seo_health); + if (titleMetaProblems > 0) { + const pct = pctOfCrawl(titleMetaProblems, crawledCount); + concerns.push({ + id: 'title-meta', + label: format(vo.chartsConcernTitleMeta, { + count: titleMetaProblems.toLocaleString(), + pct: pct != null ? `${pct}%` : '—', + }), + href: hrefs.content, + severity: 75 + titleMetaProblems, + }); + } + + const mixed = Boolean(ctx.data.language_summary?.mixed_site); + if (mixed) { + concerns.push({ + id: 'mixed-lang', + label: vo.contentQualityKpiMixedLanguage, + href: buildViewHref('text-content-analysis', ctx.querySuffix), + severity: 40, + }); + } + + return concerns.sort((a, b) => b.severity - a.severity).slice(0, limit); +} + +export function statusDistributionTakeaway( + statusCounts: Record | undefined, + crawledCount: number, +): string | undefined { + if (!statusCounts || crawledCount <= 0) return undefined; + let broken = 0; + Object.entries(statusCounts).forEach(([code, raw]) => { + const n = parseInt(String(code).trim(), 10); + if (!Number.isNaN(n) && n >= 400 && n < 600) broken += Number(raw) || 0; + }); + if (broken <= 0) return vo.chartsTakeawayStatusGood; + const pct = pctOfCrawl(broken, crawledCount); + return format(vo.chartsTakeawayStatusBroken, { + count: broken.toLocaleString(), + pct: pct != null ? `${pct}%` : '—', + }); +} + +export function wordCountTakeaway(dist: Record | undefined, crawledCount: number): string | undefined { + if (!dist) return undefined; + const knownKeys = vo.wcBuckets.filter((k) => k in dist); + const knownValues = knownKeys.map((k) => Number(dist[k] || 0)); + const total = knownValues.reduce((a, v) => a + v, 0); + if (total <= 0) return undefined; + const thin = thinWordCountPages(dist); + const dominant = dominantBucketLabel(knownKeys, knownValues); + if (thin > 0 && thin / total >= 0.25) { + const pct = Math.round((thin / total) * 1000) / 10; + return format(vo.chartsTakeawayThinContent, { count: thin.toLocaleString(), pct }); + } + if (dominant) { + return format(vo.chartsTakeawayWordCountDominant, { + bucket: dominant.label, + count: dominant.count.toLocaleString(), + pct: dominant.pct, + }); + } + return undefined; +} + +export function responseTimeTakeaway(dist: Record | undefined): string | undefined { + if (!dist) return undefined; + const slow = slowResponseUrls(dist); + const total = Object.values(dist).reduce((a, v) => a + Number(v || 0), 0); + if (total <= 0) return undefined; + if (slow > 0) { + const pct = Math.round((slow / total) * 1000) / 10; + return format(vo.chartsTakeawaySlowResponse, { count: slow.toLocaleString(), pct }); + } + const dominant = dominantBucketLabel( + vo.rtBuckets.filter((k) => k in dist), + vo.rtBuckets.filter((k) => k in dist).map((k) => Number(dist[k] || 0)), + ); + if (dominant) { + return format(vo.chartsTakeawayResponseDominant, { + bucket: dominant.label, + count: dominant.count.toLocaleString(), + pct: dominant.pct, + }); + } + return vo.chartsTakeawayResponseGood; +} + +export function depthTakeaway( + byDepth: Record | undefined, + maxDepth: number | null | undefined, + avgDepth: number | null | undefined, +): string | undefined { + if (!byDepth) return undefined; + const entries = Object.entries(byDepth).map(([k, v]) => [Number(k), Number(v)] as const).filter(([k]) => !Number.isNaN(k)); + if (!entries.length) return undefined; + const depth1 = entries.find(([d]) => d === 1)?.[1] ?? 0; + const total = entries.reduce((a, [, v]) => a + v, 0); + if (total <= 0) return undefined; + const depth1Pct = Math.round((depth1 / total) * 1000) / 10; + return format(vo.chartsTakeawayDepth, { + maxDepth: maxDepth ?? '—', + avgDepth: avgDepth ?? '—', + depth1Pct, + }); +} + +export function titleMetaTakeaway(seo: ReportPayload['seo_health'], crawledCount: number): string | undefined { + if (!seo) return undefined; + const problems = titleMetaProblemPages(seo); + if (problems <= 0) return vo.chartsTakeawayTitleMetaGood; + const pct = pctOfCrawl(problems, crawledCount); + const missingTitle = Number(seo.missing_title || 0); + const missingMeta = Number(seo.missing_meta_desc || 0); + return format(vo.chartsTakeawayTitleMetaProblems, { + count: problems.toLocaleString(), + pct: pct != null ? `${pct}%` : '—', + missingTitle: missingTitle.toLocaleString(), + missingMeta: missingMeta.toLocaleString(), + }); +} + +export function socialTakeaway(social: ReportPayload['social_coverage']): string | undefined { + if (!social) return undefined; + const og = social.og_coverage_pct; + const img = social.og_image_coverage_pct; + if (og != null && og < 70) { + return format(vo.chartsTakeawaySocialOgLow, { pct: Number(og).toFixed(1) }); + } + if (img != null && img < 70) { + return format(vo.chartsTakeawaySocialImageLow, { pct: Number(img).toFixed(1) }); + } + if (og != null) { + return format(vo.chartsTakeawaySocialGood, { og: Number(og).toFixed(1) }); + } + return undefined; +} + +export function lighthouseTakeaway(scores: Record): string | undefined { + const perf = scores.performance; + const seo = scores.seo; + if (perf != null && perf < 50) { + return format(vo.chartsTakeawayLhPerfLow, { score: perf }); + } + if (seo != null && seo < 80) { + return format(vo.chartsTakeawayLhSeoLow, { score: seo }); + } + const avg = ['performance', 'accessibility', 'best-practices', 'seo'] + .map((k) => scores[k]) + .filter((v): v is number => v != null && Number.isFinite(v)); + if (!avg.length) return undefined; + const mean = Math.round(avg.reduce((a, b) => a + b, 0) / avg.length); + return format(vo.chartsTakeawayLhGood, { score: mean }); +} + +export function socialCoverageBand(pct: number | null | undefined): 'good' | 'fair' | 'critical' { + return ogCoverageBand(pct); +} diff --git a/web/src/components/overview/overviewKeywordOpportunities.test.ts b/web/src/components/overview/overviewKeywordOpportunities.test.ts index 1e04cea..00fcef1 100644 --- a/web/src/components/overview/overviewKeywordOpportunities.test.ts +++ b/web/src/components/overview/overviewKeywordOpportunities.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it } from 'vitest'; import { + buildKeywordsTabHref, formatCrawlPagesSuffix, formatGscQuickWinSuffix, - isJunkCrawlKeyword, selectCrawlHighEmphasis, selectCrawlQuickWins, selectGscOpportunities, selectGscQuickWins, selectSiteTopKeywords, + sumGscQuickWinClicks, } from './overviewKeywordOpportunities'; +import { isJunkSemanticTerm as isJunkCrawlKeyword } from '@/lib/semanticTextHygiene'; describe('overviewKeywordOpportunities', () => { it('selects GSC quick wins by position and opportunity clicks', () => { @@ -30,6 +32,27 @@ describe('overviewKeywordOpportunities', () => { expect(selectGscOpportunities(rows).map((r) => r.keyword)).toEqual(['new', 'other']); }); + it('selectGscQuickWins excludes rows with missing gsc_position', () => { + const rows = [ + { keyword: 'no-pos', opportunity_clicks: 200 }, + { keyword: 'has-pos', gsc_position: 10, opportunity_clicks: 20 }, + ]; + expect(selectGscQuickWins(rows).map((r) => r.keyword)).toEqual(['has-pos']); + }); + + it('selectGscOpportunities does not flag position-0 rows as opportunities', () => { + const rows = [ + { keyword: 'ranked-zero', gsc_position: 0, sources: ['gsc'] }, + { keyword: 'no-pos', sources: ['suggest'] }, + ]; + expect(selectGscOpportunities(rows).map((r) => r.keyword)).toEqual(['no-pos']); + }); + + it('formatGscQuickWinSuffix omits click count when zero', () => { + expect(formatGscQuickWinSuffix({ keyword: 'x', gsc_position: 9, opportunity_clicks: 0 })).toBe('pos 9.0'); + expect(formatGscQuickWinSuffix({ keyword: 'x', opportunity_clicks: 0 })).toBe(''); + }); + it('sorts crawl quick wins by sources_count', () => { const items = [ { keyword: 'low', sources_count: 2, relevance: 0.9 }, @@ -79,4 +102,17 @@ describe('overviewKeywordOpportunities', () => { ]; expect(selectSiteTopKeywords(items).map((k) => k.keyword)).toEqual(['games', 'reviews']); }); + + it('sums quick win opportunity clicks', () => { + const rows = [ + { keyword: 'a', gsc_position: 8, opportunity_clicks: 20 }, + { keyword: 'b', gsc_position: 12, opportunity_clicks: 40 }, + ]; + expect(sumGscQuickWinClicks(rows)).toBe(60); + }); + + it('builds keywords tab href', () => { + expect(buildKeywordsTabHref('/keywords?domain=x', 'quickwins')).toBe('/keywords?domain=x&tab=quickwins'); + expect(buildKeywordsTabHref('/keywords', 'opportunities')).toBe('/keywords?tab=opportunities'); + }); }); diff --git a/web/src/components/overview/overviewKeywordOpportunities.ts b/web/src/components/overview/overviewKeywordOpportunities.ts index cf8f59f..2d4bd86 100644 --- a/web/src/components/overview/overviewKeywordOpportunities.ts +++ b/web/src/components/overview/overviewKeywordOpportunities.ts @@ -2,8 +2,9 @@ import type { KeywordRow } from '@/types/components'; import type { KeywordOpportunityItem, TopicCluster } from '@/types/report'; import { isJunkSemanticTerm } from '@/lib/semanticTextHygiene'; -/** @deprecated Use isJunkSemanticTerm */ -export const isJunkCrawlKeyword = isJunkSemanticTerm; +/** Max keyword rows shown on Overview summary preview. */ +export const KEYWORD_PREVIEW_LIMIT = 5; + function filterCrawlItems(items: KeywordOpportunityItem[] | undefined): KeywordOpportunityItem[] { return (items ?? []).filter((item) => !isJunkSemanticTerm(item.keyword)); @@ -38,7 +39,8 @@ export function selectSiteTopKeywords( export function selectGscQuickWins(rows: KeywordRow[], limit = 8): KeywordRow[] { return [...rows] .filter((r) => { - const pos = parseFloat(String(r.gsc_position ?? 0)); + if (r.gsc_position == null) return false; + const pos = parseFloat(String(r.gsc_position)); return pos >= 4 && pos <= 20 && (r.opportunity_clicks || 0) > 5; }) .sort((a, b) => (b.opportunity_clicks || 0) - (a.opportunity_clicks || 0)) @@ -47,7 +49,7 @@ export function selectGscQuickWins(rows: KeywordRow[], limit = 8): KeywordRow[] export function selectGscOpportunities(rows: KeywordRow[], limit = 8): KeywordRow[] { return [...rows] - .filter((r) => !r.gsc_position && (r.sources || []).length > 0) + .filter((r) => r.gsc_position == null && (r.sources || []).length > 0) .sort((a, b) => (b.traffic_potential || 0) - (a.traffic_potential || 0)) .slice(0, limit); } @@ -79,8 +81,9 @@ export function selectTopTopicClusters(clusters: TopicCluster[] | undefined, lim export function formatGscQuickWinSuffix(row: KeywordRow): string { const clicks = row.opportunity_clicks || 0; const pos = row.gsc_position != null ? Number(row.gsc_position).toFixed(1) : null; - if (pos != null) return `+${clicks.toLocaleString()} est. clicks · pos ${pos}`; - return `+${clicks.toLocaleString()} est. clicks`; + const clicksSuffix = clicks > 0 ? `+${clicks.toLocaleString()} est. clicks` : ''; + if (pos != null) return clicksSuffix ? `${clicksSuffix} · pos ${pos}` : `pos ${pos}`; + return clicksSuffix; } export function formatGscOpportunitySuffix(row: KeywordRow): string { @@ -105,3 +108,12 @@ export function formatCrawlPagesSuffix(item: KeywordOpportunityItem, onPagesLabe if (item.volume != null && item.volume > 0) return `${Math.round(item.volume * 100)}% site freq.`; return ''; } + +export function sumGscQuickWinClicks(rows: KeywordRow[]): number { + return selectGscQuickWins(rows, 200).reduce((total, row) => total + (row.opportunity_clicks || 0), 0); +} + +export function buildKeywordsTabHref(keywordsHref: string, tab: string): string { + const joiner = keywordsHref.includes('?') ? '&' : '?'; + return `${keywordsHref}${joiner}tab=${encodeURIComponent(tab)}`; +} diff --git a/web/src/components/overview/portfolioBenchmarkUtils.test.ts b/web/src/components/overview/portfolioBenchmarkUtils.test.ts new file mode 100644 index 0000000..b8816dc --- /dev/null +++ b/web/src/components/overview/portfolioBenchmarkUtils.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { + portfolioDeltaClassName, + portfolioDeltaNarrative, + portfolioMedianClassName, +} from './portfolioBenchmarkUtils'; + +describe('portfolioBenchmarkUtils', () => { + it('narrates ahead, behind, and even deltas', () => { + expect(portfolioDeltaNarrative(5)).toContain('5'); + expect(portfolioDeltaNarrative(-8)).toContain('8'); + expect(portfolioDeltaNarrative(0)).toBeTruthy(); + }); + + it('colors deltas semantically', () => { + expect(portfolioDeltaClassName(3)).toContain('green'); + expect(portfolioDeltaClassName(-12)).toContain('red'); + expect(portfolioDeltaClassName(-3)).toContain('amber'); + }); + + it('colors median scores by band', () => { + expect(portfolioMedianClassName(85)).toContain('green'); + expect(portfolioMedianClassName(55)).toContain('yellow'); + expect(portfolioMedianClassName(40)).toContain('red'); + }); +}); diff --git a/web/src/components/overview/portfolioBenchmarkUtils.ts b/web/src/components/overview/portfolioBenchmarkUtils.ts new file mode 100644 index 0000000..8dbefbc --- /dev/null +++ b/web/src/components/overview/portfolioBenchmarkUtils.ts @@ -0,0 +1,28 @@ +import { strings, format } from '@/lib/strings'; + +const vo = strings.views.overview; + +export function portfolioDeltaNarrative(delta: number | null): string | undefined { + if (delta == null) return undefined; + if (delta === 0) return vo.portfolioEvenMedian; + const abs = Math.abs(delta); + if (delta > 0) { + return format(vo.portfolioAheadOfMedian, { delta: abs }); + } + return format(vo.portfolioBehindMedian, { delta: abs }); +} + +export function portfolioDeltaClassName(delta: number | null): string { + if (delta == null) return 'text-muted-foreground'; + if (delta > 0) return 'text-green-700 dark:text-green-400'; + if (delta <= -10) return 'text-red-600 dark:text-red-400'; + if (delta < 0) return 'text-amber-700 dark:text-amber-400'; + return 'text-muted-foreground'; +} + +export function portfolioMedianClassName(median: number | null | undefined): string { + if (median == null) return 'text-muted-foreground'; + if (median >= 80) return 'text-green-700 dark:text-green-400'; + if (median >= 50) return 'text-yellow-700 dark:text-yellow-400'; + return 'text-red-600 dark:text-red-400'; +} diff --git a/web/src/components/overview/types.ts b/web/src/components/overview/types.ts index a96a205..f3d96eb 100644 --- a/web/src/components/overview/types.ts +++ b/web/src/components/overview/types.ts @@ -4,27 +4,37 @@ import type { StatusDistribution } from '@/lib/statusDistribution'; export const OVERVIEW_TABS = ['summary', 'charts', 'health', 'pages'] as const; export type OverviewTabId = (typeof OVERVIEW_TABS)[number]; -export interface OverviewChartBlock { +export interface OverviewChartInsightMeta { + takeaway?: string; + viewHref?: string; + viewLabel?: string; +} + +export interface OverviewChartBlock extends OverviewChartInsightMeta { data: ChartData<'bar'>; aria: string; /** Render with horizontal bars (indexAxis y). */ horizontal?: boolean; } -export interface OverviewSocialStats { +export interface OverviewStatusChart extends OverviewChartInsightMeta { + distribution: StatusDistribution; +} + +export interface OverviewSocialStats extends OverviewChartInsightMeta { og: number | null; twitter: number | null; ogImage: number | null; aria: string; } -export interface OverviewLighthouseScores { +export interface OverviewLighthouseScores extends OverviewChartInsightMeta { scores: Record; aria: string; } export interface OverviewCharts { - statusDistribution: StatusDistribution | null; + statusDistribution: OverviewStatusChart | null; wordCountChart: OverviewChartBlock | null; responseTimeChart: OverviewChartBlock | null; depthChart: OverviewChartBlock | null; @@ -36,5 +46,6 @@ export interface OverviewCharts { domainsChart: OverviewChartBlock | null; lighthouseScores: OverviewLighthouseScores | null; chartCount: number; + concernCount: number; hasInsightCharts: boolean; } diff --git a/web/src/components/overview/useOverviewCharts.ts b/web/src/components/overview/useOverviewCharts.ts index 1b97855..2960b98 100644 --- a/web/src/components/overview/useOverviewCharts.ts +++ b/web/src/components/overview/useOverviewCharts.ts @@ -4,11 +4,26 @@ import { filterLighthouseByHost, lighthouseSummaryMatchesHost, } from '@/lib/domainSlug'; +import { crawledUrlCount } from '@/lib/crawlCounts'; import { palette, sortByValue, PALETTE_CATEGORICAL } from '@/utils/chartPalette'; import type { ReportPayload } from '@/types'; import type { OverviewCharts } from './types'; import { statusDistributionFromCounts } from '@/lib/statusDistribution'; - +import { + buildChartViewHrefs, + depthTakeaway, + dominantBucketLabel, + lighthouseTakeaway, + responseTimeBucketColors, + responseTimeTakeaway, + selectChartConcerns, + socialTakeaway, + statusDistributionTakeaway, + titleMetaBucketColors, + titleMetaTakeaway, + wordCountBucketColors, + wordCountTakeaway, +} from './overviewChartInsights'; import { sumObject } from './chartUtils'; const LH_CAT_ORDER = ['performance', 'accessibility', 'best-practices', 'seo'] as const; @@ -16,12 +31,27 @@ const LH_CAT_ORDER = ['performance', 'accessibility', 'best-practices', 'seo'] a export function useOverviewCharts( data: ReportPayload | null | undefined, expectedHost: string, + querySuffix = '', ): OverviewCharts { const vo = strings.views.overview; + const hrefs = buildChartViewHrefs(querySuffix); + const crawledCount = crawledUrlCount(data); + + const concernCount = useMemo(() => { + if (!data) return 0; + return selectChartConcerns({ data, querySuffix }).length; + }, [data, querySuffix]); const statusDistribution = useMemo(() => { - return statusDistributionFromCounts(data?.status_counts); - }, [data?.status_counts]); + const distribution = statusDistributionFromCounts(data?.status_counts); + if (!distribution) return null; + return { + distribution, + takeaway: statusDistributionTakeaway(data?.status_counts, crawledCount), + viewHref: hrefs.links, + viewLabel: vo.chartsViewLinks, + }; + }, [data?.status_counts, crawledCount, hrefs.links, vo.chartsViewLinks]); const wordCountChart = useMemo(() => { const dist = data?.content_analytics?.word_count_distribution; @@ -37,14 +67,17 @@ export function useOverviewCharts( { label: vo.chartPages, data: values, - backgroundColor: PALETTE_CATEGORICAL[0], + backgroundColor: wordCountBucketColors(labels), borderRadius: 4, }, ], }, aria, + takeaway: wordCountTakeaway(dist, crawledCount), + viewHref: hrefs.contentAnalytics, + viewLabel: vo.chartsViewContentAnalytics, }; - }, [data?.content_analytics?.word_count_distribution, vo]); + }, [data?.content_analytics?.word_count_distribution, crawledCount, hrefs.contentAnalytics, vo]); const responseTimeChart = useMemo(() => { const dist = data?.response_time_stats?.distribution; @@ -60,14 +93,17 @@ export function useOverviewCharts( { label: vo.chartUrls, data: values, - backgroundColor: PALETTE_CATEGORICAL[5], + backgroundColor: responseTimeBucketColors(labels), borderRadius: 4, }, ], }, aria, + takeaway: responseTimeTakeaway(dist), + viewHref: hrefs.network, + viewLabel: vo.chartsViewNetwork, }; - }, [data?.response_time_stats?.distribution, vo]); + }, [data?.response_time_stats?.distribution, hrefs.network, vo]); const depthChart = useMemo(() => { const by = data?.depth_distribution?.by_depth; @@ -93,8 +129,11 @@ export function useOverviewCharts( ], }, aria, + takeaway: depthTakeaway(by, data?.depth_distribution?.max_depth, data?.depth_distribution?.avg_depth), + viewHref: hrefs.links, + viewLabel: vo.chartsViewLinks, }; - }, [data?.depth_distribution?.by_depth, vo]); + }, [data?.depth_distribution, hrefs.links, vo]); const titleMetaChart = useMemo(() => { const seo = data?.seo_health || {}; @@ -110,6 +149,7 @@ export function useOverviewCharts( seo.meta_desc_ok != null; if (!hasTitle && !hasMeta) return null; const labels = [...vo.titleMetaLabels]; + const metaColors = titleMetaBucketColors(); const titleData = hasTitle ? [ Number(seo.missing_title || 0), @@ -131,7 +171,7 @@ export function useOverviewCharts( datasets.push({ label: vo.chartTitleTags, data: titleData, - backgroundColor: PALETTE_CATEGORICAL[0], + backgroundColor: metaColors, borderRadius: 4, }); } @@ -139,7 +179,7 @@ export function useOverviewCharts( datasets.push({ label: vo.chartMetaDesc, data: metaData, - backgroundColor: PALETTE_CATEGORICAL[1], + backgroundColor: metaColors, borderRadius: 4, }); } @@ -148,8 +188,11 @@ export function useOverviewCharts( return { data: { labels, datasets }, aria: vo.groupedTitleMetaAria, + takeaway: titleMetaTakeaway(seo, crawledCount), + viewHref: hrefs.content, + viewLabel: vo.chartsViewContent, }; - }, [data?.seo_health, vo]); + }, [data?.seo_health, crawledCount, hrefs.content, vo]); const socialStats = useMemo(() => { const social = data?.social_coverage || {}; @@ -167,8 +210,11 @@ export function useOverviewCharts( twitter: tw != null ? Number(tw) : null, ogImage: img != null ? Number(img) : null, aria: `${vo.ariaSocialIntro} ${parts.join(', ')}.`, + takeaway: socialTakeaway(social), + viewHref: hrefs.content, + viewLabel: vo.chartsViewContent, }; - }, [data?.social_coverage, vo]); + }, [data?.social_coverage, hrefs.content, vo]); const readingLevelChart = useMemo(() => { const dist = data?.content_analytics?.reading_level_distribution; @@ -176,6 +222,7 @@ export function useOverviewCharts( const labels = vo.rlBuckets.filter((k) => k in dist); const values = labels.map((k) => Number(dist[k] || 0)); if (!labels.length || sumObject(dist) === 0) return null; + const dominant = dominantBucketLabel(labels, values); return { data: { labels, @@ -183,14 +230,23 @@ export function useOverviewCharts( { label: vo.chartPages, data: values, - backgroundColor: PALETTE_CATEGORICAL[6], + backgroundColor: palette(labels.length), borderRadius: 4, }, ], }, aria: `${vo.ariaReadingIntro} ${labels.map((l, i) => `${values[i]} ${l}`).join(', ')}.`, + takeaway: dominant + ? format(vo.chartsTakeawayReadingDominant, { + bucket: dominant.label, + count: dominant.count.toLocaleString(), + pct: dominant.pct, + }) + : undefined, + viewHref: hrefs.contentAnalytics, + viewLabel: vo.chartsViewContentAnalytics, }; - }, [data?.content_analytics?.reading_level_distribution, vo]); + }, [data?.content_analytics?.reading_level_distribution, hrefs.contentAnalytics, vo]); const mimeChart = useMemo(() => { let labels = data?.mime_labels || []; @@ -213,13 +269,20 @@ export function useOverviewCharts( ], }, aria: `${vo.ariaMimeIntro} ${labels.map((l, i) => `${values[i]} ${l}`).join(', ')}.`, + takeaway: format(vo.chartsTakeawayMimeTop, { + mime: labels[0], + count: values[0].toLocaleString(), + }), + viewHref: hrefs.links, + viewLabel: vo.chartsViewLinks, }; - }, [data?.mime_labels, data?.mime_values, vo]); + }, [data?.mime_labels, data?.mime_values, hrefs.links, vo]); const outlinksChart = useMemo(() => { const labels = data?.outlink_labels || []; const values = (data?.outlink_counts || []).map(Number); if (!labels.length || !values.some((v) => v > 0)) return null; + const dominant = dominantBucketLabel(labels, values); return { data: { labels, @@ -233,8 +296,17 @@ export function useOverviewCharts( ], }, aria: vo.ariaOutlinks, + takeaway: dominant + ? format(vo.chartsTakeawayOutlinksDominant, { + bucket: dominant.label, + count: dominant.count.toLocaleString(), + pct: dominant.pct, + }) + : undefined, + viewHref: hrefs.links, + viewLabel: vo.chartsViewLinks, }; - }, [data?.outlink_labels, data?.outlink_counts, vo]); + }, [data?.outlink_labels, data?.outlink_counts, hrefs.links, vo]); const domainsChart = useMemo(() => { let labels = data?.domain_labels || []; @@ -258,8 +330,14 @@ export function useOverviewCharts( }, aria: `${vo.ariaDomainsPrefix} ${labels[0]}: ${values[0]}.`, horizontal: true, + takeaway: format(vo.chartsTakeawayDomainsTop, { + domain: labels[0], + count: values[0].toLocaleString(), + }), + viewHref: hrefs.links, + viewLabel: vo.chartsViewLinks, }; - }, [data?.domain_labels, data?.domain_values, vo]); + }, [data?.domain_labels, data?.domain_values, hrefs.links, vo]); const lighthouseScores = useMemo(() => { let cs = null; @@ -289,8 +367,11 @@ export function useOverviewCharts( return { scores, aria: `${vo.ariaLighthouseIntro} ${ariaParts.join(', ')}.`, + takeaway: lighthouseTakeaway(scores), + viewHref: hrefs.lighthouse, + viewLabel: vo.chartsViewLighthouse, }; - }, [data?.lighthouse_summary, data?.lighthouse_by_url, expectedHost, vo]); + }, [data?.lighthouse_summary, data?.lighthouse_by_url, expectedHost, hrefs.lighthouse, vo]); const chartCount = useMemo(() => { let n = 0; @@ -347,6 +428,7 @@ export function useOverviewCharts( domainsChart, lighthouseScores, chartCount, + concernCount, hasInsightCharts, }; } diff --git a/web/src/lib/issuePriority.test.ts b/web/src/lib/issuePriority.test.ts new file mode 100644 index 0000000..a84b1d6 --- /dev/null +++ b/web/src/lib/issuePriority.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { normalizePriority } from './issuePriority'; + +describe('normalizePriority', () => { + it('normalizes mixed-case strings to canonical PriorityKey', () => { + expect(normalizePriority('high')).toBe('High'); + expect(normalizePriority('HIGH')).toBe('High'); + expect(normalizePriority('critical')).toBe('Critical'); + expect(normalizePriority('MEDIUM')).toBe('Medium'); + expect(normalizePriority('low')).toBe('Low'); + }); + + it('defaults unknown or empty values to Medium', () => { + expect(normalizePriority(undefined)).toBe('Medium'); + expect(normalizePriority(null)).toBe('Medium'); + expect(normalizePriority('')).toBe('Medium'); + expect(normalizePriority('urgent')).toBe('Medium'); + }); +}); diff --git a/web/src/lib/pipelineConfigSchema.ts b/web/src/lib/pipelineConfigSchema.ts index 1613012..92f1f30 100644 --- a/web/src/lib/pipelineConfigSchema.ts +++ b/web/src/lib/pipelineConfigSchema.ts @@ -484,6 +484,8 @@ export const PIPELINE_CONFIG_SECTIONS: PipelineConfigSection[] = [ { key: 'enable_language_detection', label: 'Language detection', type: 'bool', defaultValue: true }, { key: 'analysis_fuzzy_threshold', label: 'Near-duplicate similarity (%)', type: 'number', defaultValue: '92' }, { key: 'analysis_simhash_hamming', label: 'Near-duplicate hash distance', type: 'number', defaultValue: '0' }, + { key: 'analysis_simhash_max_urls', label: 'Max URLs for SimHash duplicate pass', type: 'number', defaultValue: '800' }, + { key: 'analysis_fuzzy_max_urls', label: 'Max URLs for fuzzy duplicate pass', type: 'number', defaultValue: '600' }, { key: 'analysis_dup_max_pages', label: 'Max pages for duplicate scan', type: 'number', defaultValue: '2000' }, ], }, diff --git a/web/src/lib/reportCompareExtras.ts b/web/src/lib/reportCompareExtras.ts index 0516856..be63e11 100644 --- a/web/src/lib/reportCompareExtras.ts +++ b/web/src/lib/reportCompareExtras.ts @@ -41,6 +41,7 @@ export interface LinkMetricRow { current: number; baseline: number; delta: number; + higherIsBetter: boolean; } export interface RedirectDeltaRow { @@ -216,11 +217,17 @@ export function buildLinkMetricDeltas( baseline: ReportPayload, labels: { inlinks: string; outlinks: string; wordCount: string; responseMs: string }, ): LinkMetricRow[] { - const specs: { key: keyof ReportLink; metric: string; label: string; minDelta: number }[] = [ - { key: 'inlinks', metric: 'inlinks', label: labels.inlinks, minDelta: 1 }, - { key: 'outlinks', metric: 'outlinks', label: labels.outlinks, minDelta: 1 }, - { key: 'word_count', metric: 'word_count', label: labels.wordCount, minDelta: 25 }, - { key: 'response_time_ms', metric: 'response_ms', label: labels.responseMs, minDelta: 150 }, + const specs: { + key: keyof ReportLink; + metric: string; + label: string; + minDelta: number; + higherIsBetter: boolean; + }[] = [ + { key: 'inlinks', metric: 'inlinks', label: labels.inlinks, minDelta: 1, higherIsBetter: true }, + { key: 'outlinks', metric: 'outlinks', label: labels.outlinks, minDelta: 1, higherIsBetter: true }, + { key: 'word_count', metric: 'word_count', label: labels.wordCount, minDelta: 25, higherIsBetter: true }, + { key: 'response_time_ms', metric: 'response_ms', label: labels.responseMs, minDelta: 150, higherIsBetter: false }, ]; const curMap = new Map(); for (const l of current.links ?? []) { @@ -246,6 +253,7 @@ export function buildLinkMetricDeltas( current: c, baseline: b, delta, + higherIsBetter: spec.higherIsBetter, }); } } diff --git a/web/src/lib/useInView.ts b/web/src/lib/useInView.ts new file mode 100644 index 0000000..d83f0ae --- /dev/null +++ b/web/src/lib/useInView.ts @@ -0,0 +1,52 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +export interface UseInViewOptions { + /** Fraction of the element visible before it counts as in-view. */ + threshold?: number; + /** Margin around the root (e.g. "0px 0px -10% 0px" to trigger slightly early). */ + rootMargin?: string; + /** Stop observing after the first time it becomes visible. */ + once?: boolean; +} + +/** + * Lightweight IntersectionObserver hook for scroll-reveal animations. + * SSR-safe: nothing runs until the effect fires on the client. When + * IntersectionObserver is unavailable, elements default to visible so + * content is never hidden. + */ +export function useInView( + options: UseInViewOptions = {}, +): { ref: React.RefObject; inView: boolean } { + const { threshold = 0.12, rootMargin = '0px 0px -8% 0px', once = true } = options; + const ref = useRef(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const node = ref.current; + if (!node) return; + if (typeof IntersectionObserver === 'undefined') { + setInView(true); + return; + } + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + setInView(true); + if (once) observer.disconnect(); + } else if (!once) { + setInView(false); + } + } + }, + { threshold, rootMargin }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [threshold, rootMargin, once]); + + return { ref, inView }; +} diff --git a/web/src/server/alertsCheckRoute.test.ts b/web/src/server/alertsCheckRoute.test.ts index 63e6742..b5d7385 100644 --- a/web/src/server/alertsCheckRoute.test.ts +++ b/web/src/server/alertsCheckRoute.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { localRequest, remoteRequest, makeSpawnChild } from '@/server/testHelpers/routeTestUtils'; +import { localRequest, remoteRequest, makeSpawnChild, makeSpawnError } from '@/server/testHelpers/routeTestUtils'; const spawnMock = vi.fn(); vi.mock('child_process', () => ({ @@ -34,4 +34,13 @@ describe('alerts/check route', () => { const body = await res.json(); expect(body.alerts).toHaveLength(1); }); + + it('returns 500 (not a hang) when the Python process fails to spawn', async () => { + spawnMock.mockImplementation(() => makeSpawnError('spawn python3 ENOENT')); + const { POST } = await import('../../app/api/alerts/check/route'); + const res = await POST(localRequest('/api/alerts/check?propertyId=3', { method: 'POST' })); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/ENOENT/); + }); }); diff --git a/web/src/server/bingSyncRoute.test.ts b/web/src/server/bingSyncRoute.test.ts index 68725a9..56a1a85 100644 --- a/web/src/server/bingSyncRoute.test.ts +++ b/web/src/server/bingSyncRoute.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { localRequest, makeSpawnChild } from '@/server/testHelpers/routeTestUtils'; +import { localRequest, makeSpawnChild, makeSpawnError } from '@/server/testHelpers/routeTestUtils'; const spawnMock = vi.fn(); const loadPipelineConfigMock = vi.fn(); @@ -41,4 +41,16 @@ describe('integrations/bing/sync route', () => { const body = await res.json(); expect(body.linked_page_count).toBe(3); }); + + it('returns 500 (not a hang) when the Python process fails to spawn', async () => { + loadPipelineConfigMock.mockResolvedValue({ + state: { bing_webmaster_api_key: 'key', start_url: 'https://example.com' }, + }); + spawnMock.mockImplementation(() => makeSpawnError('spawn python3 ENOENT')); + const { POST } = await import('../../app/api/integrations/bing/sync/route'); + const res = await POST(localRequest('/api/integrations/bing/sync', { method: 'POST' })); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/ENOENT/); + }); }); diff --git a/web/src/server/db.ts b/web/src/server/db.ts index 26e60a8..bc92b11 100644 --- a/web/src/server/db.ts +++ b/web/src/server/db.ts @@ -23,6 +23,9 @@ export function getPool(): Pool { connectionString: getDatabaseUrl(), max: Number.isFinite(max) && max > 0 ? max : 20, }); + pool.on('error', (err) => { + console.error('pg pool idle client error:', err); + }); } return pool; } diff --git a/web/src/server/exportSitemapRoute.test.ts b/web/src/server/exportSitemapRoute.test.ts index a32e54c..97662fa 100644 --- a/web/src/server/exportSitemapRoute.test.ts +++ b/web/src/server/exportSitemapRoute.test.ts @@ -16,7 +16,7 @@ describe('report/export-sitemap route', () => { spawnMock.mockImplementation(() => ({ stdout: { on: (_: string, cb: (c: Buffer) => void) => cb(Buffer.from('')) }, stderr: { on: () => undefined }, - on: (_: string, cb: (code: number) => void) => cb(0), + on: (event: string, cb: (code: number) => void) => { if (event === 'close') cb(0); }, })); const { GET } = await import('../../app/api/report/export-sitemap/route'); const res = await GET(localRequest('/api/report/export-sitemap?reportId=12')); @@ -30,7 +30,7 @@ describe('report/export-sitemap route', () => { spawnMock.mockImplementation(() => ({ stdout: { on: () => undefined }, stderr: { on: (_: string, cb: (c: Buffer) => void) => cb(Buffer.from('boom')) }, - on: (_: string, cb: (code: number) => void) => cb(1), + on: (event: string, cb: (code: number) => void) => { if (event === 'close') cb(1); }, })); const { GET } = await import('../../app/api/report/export-sitemap/route'); const res = await GET(localRequest('/api/report/export-sitemap?reportId=12')); diff --git a/web/src/server/keywordsCompetitorImportRoute.test.ts b/web/src/server/keywordsCompetitorImportRoute.test.ts index 5297240..d9a4fbc 100644 --- a/web/src/server/keywordsCompetitorImportRoute.test.ts +++ b/web/src/server/keywordsCompetitorImportRoute.test.ts @@ -41,7 +41,7 @@ describe('keywords/competitor-import route', () => { }, stderr: { on: () => undefined }, stdin: { write: () => undefined, end: () => undefined }, - on: (_: string, cb: (code: number) => void) => cb(0), + on: (event: string, cb: (code: number) => void) => { if (event === 'close') cb(0); }, })); const { POST } = await import('../../app/api/keywords/competitor-import/route'); const res = await POST( @@ -68,7 +68,7 @@ describe('keywords/competitor-import route', () => { stdout: { on: () => undefined }, stderr: { on: (_: string, cb: (c: Buffer) => void) => cb(Buffer.from('db error')) }, stdin: { write: () => undefined, end: () => undefined }, - on: (_: string, cb: (code: number) => void) => cb(1), + on: (event: string, cb: (code: number) => void) => { if (event === 'close') cb(1); }, })); const { POST } = await import('../../app/api/keywords/competitor-import/route'); const res = await POST( @@ -83,6 +83,6 @@ describe('keywords/competitor-import route', () => { ); expect(res.status).toBe(500); const body = await res.json(); - expect(body.error).toContain('db error'); + expect(body.error).toContain('failed'); }); }); diff --git a/web/src/server/testHelpers/routeTestUtils.ts b/web/src/server/testHelpers/routeTestUtils.ts index a4e98a2..941a437 100644 --- a/web/src/server/testHelpers/routeTestUtils.ts +++ b/web/src/server/testHelpers/routeTestUtils.ts @@ -33,6 +33,24 @@ export function makeSpawnChild(stdout: string, exitCode: number) { return proc; } +/** A spawned child that fails to launch — emits 'error' (e.g. ENOENT) instead of 'close'. */ +export function makeSpawnError(message = 'spawn python3 ENOENT') { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + stdin: { write: ReturnType; end: ReturnType }; + kill: ReturnType; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { write: vi.fn(), end: vi.fn() }; + proc.kill = vi.fn(); + setTimeout(() => { + proc.emit('error', new Error(message)); + }, 10); + return proc; +} + export function withAuthSecret(secret = 'test-secret-for-vitest') { const prev = process.env.AUTH_SECRET; process.env.AUTH_SECRET = secret; diff --git a/web/src/strings.json b/web/src/strings.json index a37613b..dcf301c 100644 --- a/web/src/strings.json +++ b/web/src/strings.json @@ -167,6 +167,15 @@ "responseP50": { "body": "Median (50th percentile) server response time across crawled URLs." }, + "portfolioBenchmark": { + "body": "Average of all audit category scores for this property, compared to the median across properties on this instance (not industry benchmarks)." + }, + "technologies": { + "body": "Distinct technologies detected across crawled pages (CMS, analytics, frameworks, etc.)." + }, + "contentQualityLocales": { + "body": "Distinct content languages detected from sampled pages in this crawl." + }, "urlJoinMatched": { "body": "URLs present in both this crawl and Google Search Console data for the selected property." }, @@ -668,6 +677,8 @@ "productSubtitle": "SEO & technical analysis", "ariaCloseMenu": "Close menu", "ariaOpenMenu": "Open menu", + "sidebarCollapse": "Collapse sidebar", + "sidebarExpand": "Expand sidebar", "viewSiteLabel": "View site", "searchPlaceholder": "Search URLs, issues...", "themeLight": "Light theme", @@ -1698,7 +1709,21 @@ "deleteConfirm": "Remove", "deleteCancel": "Cancel", "deleteFailed": "Could not remove property. Try again.", - "deleting": "Removing…" + "deleting": "Removing…", + "greetingMorning": "Good morning", + "greetingAfternoon": "Good afternoon", + "greetingEvening": "Good evening", + "greetingTagline": "Here's your site portfolio at a glance.", + "quickActionRunLabel": "Run new audit", + "quickActionChatLabel": "Ask AI", + "resumeHeading": "Jump back in", + "resumeReopen": "Reopen", + "emptyTitle": "Welcome to Site Audit", + "emptyBody": "Run your first crawl to surface technical SEO issues, Lighthouse scores, content gaps, and more — all self-hosted, no signup required.", + "emptyCta": "Run your first audit", + "emptyHighlightCrawl": "Crawl & technical SEO", + "emptyHighlightLighthouse": "Lighthouse performance", + "emptyHighlightAi": "AI-assisted insights" }, "issues": { "title": "Issues", @@ -2902,6 +2927,18 @@ "googleConnectBody": "Add real query, click, and traffic data to this audit.", "googleConnectSubtitle": "Sync clicks, impressions, average position, and Analytics sessions. Open Integrations (gear icon, top right).", "executiveAiLabel": "AI executive summary", + "auditHealth": "Audit health", + "healthDeltaVsPrior": "{delta} vs prior run", + "healthDeltaFlat": "No change vs prior run", + "healthTrendLabel": "Health trend", + "executiveIssueCounts": "{total} issues · {critical} critical · {high} high", + "executiveNoIssues": "No open issues detected in this audit.", + "viewAllIssues": "View all issues", + "needsAttention": "Needs attention", + "issueSitewideScope": "Site-wide", + "issueUnknownPriority": "Issue", + "issueUntitled": "Untitled issue", + "issueClicksImpact": "{clicks} Search Console clicks", "topTrafficIssues": "Top traffic-impacting issues", "clicksLabel": "clicks", "gscClicksCard": "Search Console clicks", @@ -2921,6 +2958,27 @@ "crawlDoneSeconds": "Crawl completed in {seconds}s.", "totalUrls": "Total URLs", "avgOutlinks": "Avg Outlinks / Page", + "crawlSnapshotTitle": "Crawl snapshot", + "crawlSnapshotSubtitle": "Key metrics from this audit crawl — click any tile to investigate.", + "crawlSnapshotViewCharts": "View charts", + "crawlTopConcerns": "Top crawl concerns", + "crawlHealthSection": "Crawl health", + "crawlHealthSectionHint": "URL volume, HTTP success, and on-page SEO basics.", + "crawlContentSection": "Content & performance", + "crawlContentSectionHint": "Copy depth, share tags, stack, and response times.", + "crawlOpenLinks": "Open all URLs", + "crawlOpenContentAnalytics": "Open content analytics", + "crawlPagesDiscovered": "Pages discovered in this crawl", + "crawlMetricCountPct": "{count} ({pct} of crawl)", + "metricBandGood": "Good", + "metricBandFair": "Needs improvement", + "metricBandCritical": "Critical", + "metricBandNeedsAttention": "Needs attention", + "crawlConcernBroken": "{count} broken URLs ({pct} of crawl)", + "crawlConcernMissingH1": "{count} pages missing H1 ({pct} of crawl)", + "crawlConcernSuccess": "Low success rate ({rate}%)", + "crawlConcernThinContent": "Thin content (median {median} words)", + "crawlConcernSlowResponse": "Slow responses ({ms}ms p50)", "successRate": "Success Rate (2xx)", "broken": "Broken (4xx/5xx)", "count4xx5xx": "{count4xx} 4xx, {count5xx} 5xx", @@ -2944,11 +3002,22 @@ "sampleRemoved": "Sample removed", "sampleContentChanged": "Sample content changed", "keywordOpportunities": "Keyword opportunities", + "keywordOpportunitiesSubtitle": "Ranking upside from Search Console when connected; otherwise estimated from on-page copy.", + "keywordOpportunitiesHelpTitle": "About keyword opportunity data", "keywordOpportunitiesHint": "Keywords found in crawl copy (titles, headings, meta, URLs). Frequency is estimated from this site — not Google search volume or Keyword Planner data.", "keywordOpportunitiesGscHint": "Top opportunities from Search Console rankings and expansion sources. +est. clicks models upside if a query moves into the top 3.", - "viewKeywords": "View all keywords", + "keywordOpportunitiesKpiGsc": "{quickWins} quick wins · +{clicks} est. clicks · {expansion} expansion terms", + "keywordOpportunitiesKpiCrawl": "{actions} suggested actions · {emphasis} high-emphasis terms", + "keywordOpportunitiesKpiSite": "{terms} top terms from on-page copy", + "keywordOpportunitiesGscConnected": "Search Console rankings connected", + "keywordOpportunitiesConnectGsc": "Connect Search Console for real ranking opportunities", + "keywordOpportunitiesConnectGscDetail": "Unlock quick wins, lost clicks, and traffic-weighted keyword lists from your verified property.", + "keywordOpportunitiesConnectCta": "Open integrations", + "viewKeywords": "Open Keywords Explorer", "onPagesCount": "on {n} pages", "topThemes": "Top themes (from crawl)", + "topThemesViewAll": "View topic map", + "topThemeTermCount": "({n} terms)", "siteTopTerms": "Top terms on site (from page copy)", "siteTermMentions": "{n} mentions", "crawlActionLabels": { @@ -2970,12 +3039,72 @@ "entitiesSitewide": "entities sitewide", "languagesSampled": "Languages (sampled)", "mixedLanguage": "Mixed-language site detected", + "contentQualitySubtitle": "Duplication, language mix, and optional semantic insights from this crawl.", + "contentQualityHelpTitle": "About content quality metrics", + "contentQualityHelpBody": "Duplicate groups are near-duplicate page clusters. Language counts are sampled from crawled HTML. Semantic topics and named entities appear when those audit features are enabled.", + "contentQualityKpiDuplicates": "{groups} duplicate groups", + "contentQualityKpiLanguages": "{count} languages detected", + "contentQualityKpiMixedLanguage": "Mixed-language site", + "contentQualityReviewDuplicates": "Review duplicates", + "contentQualityOpenTextAnalysis": "Text content analysis", + "contentQualityTopConcerns": "Top content concerns", + "contentConcernDuplicates": "{groups} near-duplicate groups ({pages} URLs)", + "contentConcernMixedLanguage": "Mixed-language site ({languages} locales)", + "contentQualityDuplicatePages": "{pages} URLs in duplicate groups", + "contentQualityLargestClusters": "Largest duplicate clusters", + "contentQualityClusterMembers": "{count} similar URLs", + "contentQualityLanguageMix": "Language mix (sampled pages)", + "contentQualityLanguageShare": "{count} pages ({pct}%)", + "contentQualityLocaleCount": "Locales detected", + "contentQualityGroupsCount": "Near-duplicate groups", + "contentQualityDominantLanguage": "{lang} {pct}% of sampled pages", + "contentQualityMixedLanguageHint": "Multiple content languages detected — check hreflang and locale targeting.", + "contentQualitySingleLanguageSite": "Primary language dominates sampled pages", + "contentQualityOpenContentAnalytics": "Content analytics", + "contentQualityAdvancedInsights": "Semantic & entity insights", + "contentQualityInsightsUnavailable": "Enable semantic keyword clusters or NER in audit settings for topic and entity insights.", "anomalyUrls": "Anomaly URLs", "thUrl": "URL", "thScore": "Score", "thReasons": "Reasons", "insightsGlance": "Insights at a glance", "insightsHint": "Distributions and coverage derived from this crawl: where content sits by length, how fast URLs responded, how deep the crawler reached, on-page metadata health, share-preview tags, and—when a Lighthouse run is attached—category scores.", + "chartsSubtitle": "Distributions from this crawl — grouped by theme with key takeaways and links to dig deeper.", + "chartsTopConcerns": "Chart highlights", + "chartsSectionCrawl": "Crawl & HTTP", + "chartsSectionCrawlHint": "Response codes, fetch latency, and crawl depth from the start URL.", + "chartsSectionContent": "Content & on-page SEO", + "chartsSectionContentHint": "Word-count depth, title and meta checks, and reading-level estimates.", + "chartsSectionDiscovery": "Discovery & links", + "chartsSectionDiscoveryHint": "MIME mix, external outlinks, and top linked domains.", + "chartsSectionSocialPerf": "Social & performance", + "chartsSectionSocialPerfHint": "Share-preview tag coverage and Lighthouse category scores when available.", + "chartsViewLinks": "Open all URLs", + "chartsViewContent": "Open content", + "chartsViewContentAnalytics": "Open content analytics", + "chartsViewNetwork": "Open network", + "chartsViewLighthouse": "Open Lighthouse", + "chartsConcernTitleMeta": "{count} title/meta issues ({pct} of crawl)", + "chartsTakeawayStatusGood": "No 4xx/5xx errors in this crawl.", + "chartsTakeawayStatusBroken": "{count} error URLs ({pct} of crawl) — review broken links.", + "chartsTakeawayThinContent": "{count} thin pages ({pct}) in the 0–300 word buckets.", + "chartsTakeawayWordCountDominant": "Most pages sit in the {bucket} words bucket ({count} pages, {pct}%).", + "chartsTakeawaySlowResponse": "{count} slow URLs ({pct}) took over 1 second to respond.", + "chartsTakeawayResponseDominant": "Most URLs responded in {bucket} ({count} URLs, {pct}%).", + "chartsTakeawayResponseGood": "Most URLs responded quickly (<500ms).", + "chartsTakeawayDepth": "Max depth {maxDepth}, avg {avgDepth} — {depth1Pct}% of URLs at depth 1.", + "chartsTakeawayTitleMetaGood": "Title and meta description lengths look healthy across the crawl.", + "chartsTakeawayTitleMetaProblems": "{count} title/meta issues ({pct}) — {missingTitle} missing titles, {missingMeta} missing descriptions.", + "chartsTakeawaySocialOgLow": "Only {pct}% of pages have Open Graph tags.", + "chartsTakeawaySocialImageLow": "Only {pct}% of pages have og:image for link previews.", + "chartsTakeawaySocialGood": "{og}% Open Graph coverage on sampled pages.", + "chartsTakeawayLhPerfLow": "Performance score {score} — prioritize speed fixes.", + "chartsTakeawayLhSeoLow": "Lighthouse SEO score {score} — review on-page issues.", + "chartsTakeawayLhGood": "Lighthouse categories average {score}/100.", + "chartsTakeawayReadingDominant": "Most pages read at {bucket} level ({count} pages, {pct}%).", + "chartsTakeawayMimeTop": "Top MIME: {mime} ({count} URLs).", + "chartsTakeawayOutlinksDominant": "Most pages have {bucket} outlinks ({count} pages, {pct}%).", + "chartsTakeawayDomainsTop": "Most-linked external domain: {domain} ({count} URLs).", "contentDepth": "Content depth (word count)", "contentDepthHint": "2xx HTML pages by word-count bucket. Heavy left skew often means thin or template-heavy indexable URLs.", "serverLatency": "Server / fetch latency", @@ -3006,9 +3135,23 @@ "healthByCategory": "Health by Category", "portfolioBenchmarkTitle": "Portfolio benchmark", "portfolioBenchmarkHint": "Compare this property’s average category score to the median across all properties on this instance.", + "portfolioBenchmarkSubtitle": "See whether this site’s average audit health is ahead or behind your portfolio median.", + "portfolioBenchmarkHelpTitle": "About portfolio benchmark", + "portfolioBenchmarkHelpBody": "We average category scores (Technical SEO, Performance, etc.) for this property and compare to the median across all properties on this instance.", "portfolioPropertyScore": "This property", "portfolioMedianScore": "Portfolio median", "portfolioDelta": "Vs median", + "portfolioPropertyCount": "Compared across {count} properties on this instance", + "portfolioMedianMarker": "Portfolio median · {score}", + "portfolioAheadOfMedian": "{delta} points ahead of portfolio median", + "portfolioBehindMedian": "{delta} points behind portfolio median", + "portfolioEvenMedian": "Matches portfolio median", + "portfolioViewPortfolio": "View portfolio", + "portfolioCompareRuns": "Compare audit runs", + "portfolioGaugeLabel": "Average category score", + "portfolioNoBenchmarkLabel": "Your site health (no benchmark yet)", + "portfolioSinglePropertyCta": "Add another property", + "portfolioScrollCategories": "Review categories below", "noCategorySearch": "No categories match your search.", "noCategoryData": "No category data.", "scoreGood": "Good", diff --git a/web/src/views/Backlinks.tsx b/web/src/views/Backlinks.tsx index eca96bc..a286746 100644 --- a/web/src/views/Backlinks.tsx +++ b/web/src/views/Backlinks.tsx @@ -9,7 +9,7 @@ import { useReport } from '../context/useReport'; import { useOptionalPipeline } from '../context/PipelineContext'; import { apiUrl } from '../lib/publicBase'; import { strings, format } from '../lib/strings'; -import { PageLayout, PageHeader, ViewTabs } from '../components'; +import { PageLayout, PageHeader, ViewTabs, EmptyState } from '../components'; import SortablePaginatedTable from '../components/google/SortablePaginatedTable'; import GoogleTableToolbar from '../components/google/GoogleTableToolbar'; import GscLinksSummaryCards from '../components/backlinks/GscLinksSummaryCards'; @@ -244,15 +244,19 @@ export default function Backlinks(_props: ViewProps) { if (!gscLinks?.export_types?.length) { return ( -
    - -

    {vb.emptyTitle}

    -

    {vb.emptyBody}

    -

    - - {vb.emptyIntegrationsHint} -

    -
    + + {vb.emptyBody} + + + {vb.emptyIntegrationsHint} + + + } + />
    ); } diff --git a/web/src/views/CompareReports.tsx b/web/src/views/CompareReports.tsx index dd0c25b..e67eda0 100644 --- a/web/src/views/CompareReports.tsx +++ b/web/src/views/CompareReports.tsx @@ -7,9 +7,6 @@ import { useUrlTab } from '@/hooks/useUrlTab'; import { ArrowLeftRight, FolderTree, - TrendingDown, - TrendingUp, - Minus, } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings } from '../lib/strings'; @@ -39,6 +36,7 @@ import { CompareGooglePanel, } from '../components/compare/CompareTabPanels'; import CompareUrlMetadataTable from '../components/compare/CompareUrlMetadataTable'; +import { ScoreDelta } from '@/components/charts/ScoreDelta'; import dynamic from 'next/dynamic'; const CompareOverviewCharts = dynamic( @@ -71,26 +69,6 @@ function filterUrls(urls: string[], query: string): string[] { return urls.filter((u) => u.toLowerCase().includes(q)); } -function ScoreDelta({ delta }: { delta: number | null }) { - if (delta == null || delta === 0) { - return ( - - 0 - - ); - } - const up = delta > 0; - const Icon = up ? TrendingUp : TrendingDown; - const color = up ? 'text-emerald-700 dark:text-emerald-400' : 'text-rose-700 dark:text-rose-400'; - return ( - - - {up ? '+' : ''} - {delta} - - ); -} - function UrlDiffTable({ urls, emptyLabel, @@ -612,16 +590,7 @@ export default function CompareReports({ searchQuery = '' }: ViewProps) { {row.current} {row.baseline} - 0 : row.delta < 0) - ? 'text-emerald-700 dark:text-emerald-400 text-xs font-semibold' - : 'text-rose-700 dark:text-rose-400 text-xs font-semibold' - } - > - {row.delta > 0 ? '+' : ''} - {row.delta} - + ))} diff --git a/web/src/views/Home.tsx b/web/src/views/Home.tsx index 966d3ce..cb6eee0 100644 --- a/web/src/views/Home.tsx +++ b/web/src/views/Home.tsx @@ -1,7 +1,17 @@ -import { Building2, ChevronDown, Search } from 'lucide-react'; +import { + Building2, + ChevronDown, + Cpu, + Gauge, + MessageSquare, + Plus, + Search, + Sparkles, +} from 'lucide-react'; +import Link from 'next/link'; import { useMemo, useState, useEffect, useCallback } from 'react'; -import AppLogo from '@/components/AppLogo'; -import { PageLayout, Card, LabelWithHint } from '../components'; +import type { CSSProperties } from 'react'; +import { PageLayout, Button, StatCard, EmptyState, LabelWithHint } from '../components'; import PortfolioPropertyCard from '@/components/portfolio/PortfolioPropertyCard'; import { healthScoreClass, portfolioCardKey } from '@/components/portfolio/portfolioCardUtils'; import { Skeleton, SkeletonDomainCard } from '../components/Skeleton'; @@ -29,6 +39,7 @@ export default function Home({ onNavigate }: ViewProps) { const vh = strings.views.home; const sj = strings.common; const [filterQuery, setFilterQuery] = useState(''); + const [greeting, setGreeting] = useState(vh.greetingMorning); const [domainGroups, setDomainGroups] = useState([]); const [portfolioLoading, setPortfolioLoading] = useState(false); const [openingCrawlId, setOpeningCrawlId] = useState(null); @@ -43,6 +54,12 @@ export default function Home({ onNavigate }: ViewProps) { >({}); const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()); + // Time-aware greeting computed after mount to avoid SSR/timezone hydration mismatch. + useEffect(() => { + const h = new Date().getHours(); + setGreeting(h < 12 ? vh.greetingMorning : h < 18 ? vh.greetingAfternoon : vh.greetingEvening); + }, [vh.greetingMorning, vh.greetingAfternoon, vh.greetingEvening]); + const toggleGroupCollapsed = useCallback((rootDomain: string) => { setCollapsedGroups((prev) => { const next = new Set(prev); @@ -205,81 +222,145 @@ export default function Home({ onNavigate }: ViewProps) { .toSorted((a, b) => (b.items[0]?.generatedAtMs ?? 0) - (a.items[0]?.generatedAtMs ?? 0)); }, [filteredGroups]); - const emptyMessage = filterQuery ? vh.noSearchResults : vh.empty; + // "Jump back in" — the most recently generated audits, derived from existing data. + const recentAudits = useMemo( + () => domainGroups.toSorted((a, b) => b.generatedAtMs - a.generatedAtMs).slice(0, 4), + [domainGroups], + ); + const showResume = !filterQuery && !portfolioLoading && recentAudits.length > 1; + + // Inline (span) shimmer for StatCard values — a block here would nest + // a
    inside StatCard's value

    , which is invalid DOM. + const statSkeleton = ( + + ); return ( -

    -
    -
    -
    -
    -
    +
    -
    -
    -
    - -
    -

    {vh.title}

    -

    {vh.subtitle}

    + {/* Welcome header + quick actions */} +
    +
    +

    + {greeting} +

    +

    + {vh.title} +

    +

    {vh.greetingTagline}

    +
    +
    + + + + + + +
    +
    -
    - - setFilterQuery(e.target.value)} - placeholder={vh.searchPlaceholder} - className="w-full rounded-full border border-default bg-brand-900/30 px-9 py-2 text-xs sm:text-sm text-foreground outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" - /> -
    + {/* Search */} +
    + + setFilterQuery(e.target.value)} + placeholder={vh.searchPlaceholder} + className="w-full rounded-full border border-default bg-brand-900/40 px-10 py-2.5 text-sm text-foreground outline-none transition-all focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" + /> +
    -
    -
    -

    - -

    - {portfolioLoading ? ( - - ) : ( -

    {portfolioTotals.totalBrands.toLocaleString()}

    - )} -
    -
    -

    - -

    - {portfolioLoading ? ( - - ) : ( -

    {portfolioTotals.totalUrls.toLocaleString()}

    - )} -
    -
    -

    - -

    - {portfolioLoading ? ( - - ) : ( -

    - {portfolioTotals.avgHealth ?? sj.emDash} -

    - )} -
    -
    -
    + {/* Portfolio stat row */} +
    + } + value={portfolioLoading ? statSkeleton : portfolioTotals.totalBrands.toLocaleString()} + icon={} + size="lg" + shadow + /> + } + value={portfolioLoading ? statSkeleton : portfolioTotals.totalUrls.toLocaleString()} + icon={} + size="lg" + shadow + /> + } + value={ + portfolioLoading ? statSkeleton : (portfolioTotals.avgHealth ?? sj.emDash) + } + valueClassName={ + portfolioTotals.avgHealth != null ? healthScoreClass(portfolioTotals.avgHealth) : 'text-bright' + } + icon={} + size="lg" + shadow + />
    {deleteError ? ( -

    +

    {deleteError}

    ) : null} + {/* Jump back in */} + {showResume ? ( +
    +

    {vh.resumeHeading}

    +
    + {recentAudits.map((group, i) => { + const opening = openingCrawlId != null && openingCrawlId === group.crawlRunId; + return ( + + ); + })} +
    +
    + ) : null} + + {/* Portfolio groups */} {portfolioLoading ? ( -
    +
    {strings.app.loading}
    @@ -291,11 +372,11 @@ export default function Home({ onNavigate }: ViewProps) {
    ) : filteredGroups.length > 0 ? ( -
    +
    {groupedPortfolio.map(({ rootDomain, items }) => { const collapsed = collapsedGroups.has(rootDomain); return ( -
    +
    + ) : filterQuery ? ( +
    + +
    ) : ( - -

    {emptyMessage}

    -
    +
    + +
    )} ); diff --git a/web/src/views/Issues.tsx b/web/src/views/Issues.tsx index a5b459b..017ecf0 100644 --- a/web/src/views/Issues.tsx +++ b/web/src/views/Issues.tsx @@ -190,7 +190,7 @@ export default function Issues({ searchQuery = '' }: ViewProps) { let filtered = list; if (priorityFilter !== sj.all) { - filtered = filtered.filter((item) => (item.issue.priority || 'Medium') === priorityFilter); + filtered = filtered.filter((item) => normalizePriority(item.issue.priority) === priorityFilter); } filtered.sort((a, b) => { diff --git a/web/src/views/Landing.tsx b/web/src/views/Landing.tsx index b3af71a..8002d3d 100644 --- a/web/src/views/Landing.tsx +++ b/web/src/views/Landing.tsx @@ -13,6 +13,7 @@ import { } from 'lucide-react'; import AppLogo from '@/components/AppLogo'; import Button from '@/components/Button'; +import Reveal from '@/components/Reveal'; import LandingCodeBlock from '@/components/landing/LandingCodeBlock'; import LandingFeatureSpotlight from '@/components/landing/LandingFeatureSpotlight'; import LandingFinalCta from '@/components/landing/LandingFinalCta'; @@ -45,12 +46,7 @@ export default function LandingPage() { }>
    -
    -
    -
    -
    -
    -
    +
    @@ -105,13 +101,14 @@ export default function LandingPage() {
    - - + + + + + + -
    +
    -
    + - + + + -
    @@ -160,11 +160,14 @@ export default function LandingPage() {

    {vl.quickStartDocsHint}

    - + - + + + -
    @@ -178,10 +181,10 @@ export default function LandingPage() { {FEATURES.map(({ icon: Icon, title, description }) => (
    - - + +

    {title}

    {description}

    @@ -189,10 +192,14 @@ export default function LandingPage() { ))}
    - + - - + + + + + + ); } diff --git a/web/src/views/Overview.tsx b/web/src/views/Overview.tsx index 7fb4842..78c5b3f 100644 --- a/web/src/views/Overview.tsx +++ b/web/src/views/Overview.tsx @@ -67,18 +67,20 @@ export default function Overview({ searchQuery = '' }: ViewProps) { }); }, [data?.top_pages, q]); - const charts = useOverviewCharts(data, expectedHost); + const charts = useOverviewCharts(data, expectedHost, searchParams.toString()); const overviewTabItems = useMemo((): ViewTabItem[] => { const catCount = data?.categories?.length ?? 0; const pageCount = data?.top_pages?.length ?? 0; + const chartsBadge = + charts.concernCount > 0 ? charts.concernCount : charts.chartCount > 0 ? charts.chartCount : null; return [ { id: 'summary', label: vo.tabs.summary, icon: }, { id: 'charts', label: vo.tabs.charts, icon: , - badge: charts.chartCount > 0 ? charts.chartCount : null, + badge: chartsBadge, }, { id: 'health', @@ -93,7 +95,7 @@ export default function Overview({ searchQuery = '' }: ViewProps) { badge: pageCount > 0 ? pageCount : null, }, ]; - }, [vo.tabs, charts.chartCount, data?.categories?.length, data?.top_pages?.length]); + }, [vo.tabs, charts.concernCount, charts.chartCount, data?.categories?.length, data?.top_pages?.length]); if (!data) return null; @@ -134,13 +136,22 @@ export default function Overview({ searchQuery = '' }: ViewProps) { /> )} - {activeTab === 'charts' && } + {activeTab === 'charts' && ( + + )} {activeTab === 'health' && ( )} diff --git a/web/src/views/SearchPerformance.tsx b/web/src/views/SearchPerformance.tsx index dbf2b10..063b140 100644 --- a/web/src/views/SearchPerformance.tsx +++ b/web/src/views/SearchPerformance.tsx @@ -133,6 +133,7 @@ export default function SearchPerformance() { href={String(v ?? '')} target="_blank" rel="noreferrer" + title={String(v ?? '')} className="text-link hover:underline font-mono text-xs truncate block min-w-0 max-w-none" > {String(v ?? '')} diff --git a/web/src/views/Traffic.tsx b/web/src/views/Traffic.tsx index 8d63c79..636b6d9 100644 --- a/web/src/views/Traffic.tsx +++ b/web/src/views/Traffic.tsx @@ -8,7 +8,7 @@ import { Users, AlertCircle, Settings2, Download } from 'lucide-react'; import { useReport } from '../context/useReport'; import { strings, format } from '../lib/strings'; import { metricHelpHint } from '@/lib/metricHelp'; -import { PageLayout, PageHeader, Card, AlertBanner, StatCard, ViewTabs } from '../components'; +import { PageLayout, PageHeader, Card, AlertBanner, StatCard, ViewTabs, EmptyState } from '../components'; import SortablePaginatedTable from '../components/google/SortablePaginatedTable'; import GoogleTableToolbar from '../components/google/GoogleTableToolbar'; import { filterBySearch, exportCsv } from '../components/google/tableUtils'; @@ -185,15 +185,19 @@ export default function Traffic() { if (!google) { return ( -
    - -

    {tf.emptyTitle}

    -

    {tf.emptyBody}

    -

    - - {tf.emptyIntegrationsHint} -

    -
    + + {tf.emptyBody} + + + {tf.emptyIntegrationsHint} + + + } + />
    ); } diff --git a/web/tailwind.config.js b/web/tailwind.config.js index b6eeb53..a8e7213 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -17,7 +17,7 @@ export default { }, }, borderRadius: { - card: '0.75rem', + card: '1rem', }, spacing: { 'page-x': '1.5rem',