> ## Documentation Index
> Fetch the complete documentation index at: https://f4c7a9e2d8b1-docs.tenzo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Analytics Dashboard

> Architecture guide for the analytics dashboard and shared CTE system

<Warning>
  **Restricted Access**: This documentation is only accessible to @tenzo.ai and @salv.ai email addresses.
</Warning>

## Overview

The analytics page loads data from a single `GET /analytics/dashboard` endpoint that returns all Call-table-backed metrics (scalars + charts) in one request. A set of shared CTEs (Common Table Expressions) define base filtering once — all metric aggregations run against them.

8 additional endpoints serve data from non-Call-table sources (SMS, time saved, candidate satisfaction, campaigns launched, credits per candidate).

## Key Files

| File                                                    | Purpose                                       |
| ------------------------------------------------------- | --------------------------------------------- |
| `server/analytics/analytics_api.py`                     | Dashboard endpoint + Pydantic response models |
| `server/analytics/analytics_filters.py`                 | Shared filter parsing (`ResolvedFilters`)     |
| `server/dao/call_dao/call_dao_analytics_base.py`        | 3 shared CTE builders                         |
| `server/dao/call_dao/call_dao_analytics_metrics.py`     | Metric DAO methods (counts, rates, charts)    |
| `server/dao/call_dao/call_dao_analytics_passthrough.py` | Passthrough rate + thumbs up/down DAO methods |

## Shared CTEs

`CallDaoAnalyticsBase` provides 3 CTE builders that all analytics DAO methods inherit:

| CTE               | Base Tables                                           | Key Columns                                                                                                       | Used By                                                            |
| ----------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| **Call**          | `Call JOIN Campaign`                                  | `call_id`, `candidate_id`, `campaign_id`, `call_status`, `call_length_sec`, `question_completion_rate`, `created` | Total calls, answer rate, call completion, interviews, call length |
| **CandidateInfo** | `CandidateCampaignUserReview JOIN Call JOIN Campaign` | `candidate_id`, `campaign_id`, `feedback`, `max_score`                                                            | Passthrough rate, thumbs up/down, qualified candidates             |
| **Review**        | `CandidateCampaignUserReview JOIN Call JOIN Campaign` | `candidate_id`, `feedback`, `call_last_reviewed_at`                                                               | Thumbs up/down grouped by time                                     |

All 3 CTEs accept the same filter parameters: `org_id`, `date_range`, `campaign_ids`, `candidate_filter`. All queries use the **read-only replica** via `self.read_only_session()`.

## Dashboard Endpoint

`GET /analytics/dashboard` returns an `AnalyticsDashboardResponse` containing:

* **Scalar metrics**: answer rate, call completion rate, total calls, interviews, people contacted, call length, avg interview length, avg time to first interview, opt-outs, qualified candidates, passthrough rate, thumbs up/down
* **Chart data**: 6 typed time-series charts (calls, candidates, interviews, thumbs up, thumbs down, passthrough rate) — each chart has its own Pydantic model (e.g. `CallsChartResponse`, `PassthroughRateChartResponse`)
* **Withdrawal & accommodation**: withdrawal rate, withdrawal reasons breakdown, accommodation rate

All metrics are fetched in parallel via `asyncio.gather`, except `qualified_candidates` which runs sequentially (needs the passing score resolved first).

### Per-Campaign Passing Scores

When filtering to a single campaign, the dashboard looks up that campaign's custom passing score from Cosmos via `campaign_scripts_cosmos_dao.get_passing_scores()`. This matches the behavior of the passthrough rate DAO, which also uses per-campaign scores internally.

### Frontend

The frontend uses Orval-generated API client functions with Pydantic-backed TypeScript types. Each dashboard tile is fetched with an SWR hook keyed by `[tile, orgId + serialized filters]`. Changing filters produces a new cache key, so a stale in-flight response is automatically discarded by SWR rather than overwriting newer data, with no request-id guards needed.

## Custom AI Dashboard Sharing

Custom AI dashboards can be visible to everyone in the org, visible only to the owner, or shared with selected users in the same org. Selected users can view the dashboard and its AI charts, but they cannot rename it, delete it, change its visibility, add charts, or reorder/resize charts.

Sharing is only meaningful for private custom dashboards. Org-visible dashboards are already visible to everyone in the org, and the system Overview dashboard follows the org-wide dashboard permission rules instead of per-user sharing.

## Individual Endpoints

These 8 endpoints are separate from the dashboard because they use different data sources:

| Endpoint                                     | Data Source                                                      |
| -------------------------------------------- | ---------------------------------------------------------------- |
| `/analytics/sms_sent`                        | SMS helper (Cosmos + external)                                   |
| `/analytics/total_sms`                       | SMS helper                                                       |
| `/analytics/time_saved`                      | Calls + SMS + web calls + resume screening + notetaking          |
| `/analytics/total_time_saved`                | Calls + SMS + web calls + resume screening + notetaking (scalar) |
| `/analytics/candidate_satisfaction`          | Call reviews (1-5 rating)                                        |
| `/analytics/average_candidate_satisfaction`  | Same as above (scalar)                                           |
| `/analytics/campaigns_launched_per_week`     | Campaign table (no filters)                                      |
| `/analytics/credits_per_qualified_candidate` | Credits + qualified candidates                                   |

## Adding a New Filter

A new filter only needs 3 touch-points — no per-metric changes:

<Steps>
  <Step title="Parse in resolve_filters">
    Add the query param to `server/analytics/analytics_filters.py` and include it in `ResolvedFilters`.

    ```python theme={null}
    # analytics_filters.py
    @dataclass
    class ResolvedFilters:
        org_id: str
        date_range: DateRange | None
        campaign_ids: list[str]
        candidate_filter: CandidateFilter | None
        job_title: str | None  # new
    ```
  </Step>

  <Step title="Apply in one CTE builder">
    Add a `where` clause in the relevant CTE method in `server/dao/call_dao/call_dao_analytics_base.py`. All metrics using that CTE now respect it automatically.

    ```python theme={null}
    # call_dao_analytics_base.py
    if job_title:
        query = query.where(Campaign.job_title == job_title)
    ```
  </Step>

  <Step title="Pass through in dashboard endpoint">
    No per-metric logic needed — the dashboard passes `filters` to each DAO method, which passes them to the CTE builder. If the CTE uses it, all metrics respect it.
  </Step>
</Steps>
