# Trainer Dashboard Home API — الرئيسية

The trainer-facing landing screen: welcome banner + next session, the 8 stat tiles, urgent tasks, today's schedule, the completion donut, weekly training hours, student growth, test performance, content preview lists, and recent activity. Split across **two endpoints** on purpose — see [Why two endpoints](#why-two-endpoints) — so the page can render its hero/tiles immediately and load the heavier charts/lists in behind it.

- **Base path:** `{{base_url}}/trainer` (e.g. `http://localhost:8000/api/v1/trainer`)
- **Auth:** `Authorization: Bearer {{trainer_token}}` on every request
- **Middleware stack:** `api, set_locale, cors, auth:api, active, trainer.can:view_statistics`
- **Capability:** `view_statistics` ([TrainerCapability::ViewStatistics](../../app/Enums/TrainerCapability.php)) — admin-granted per trainer, same capability that already gates `GET /trainer/statistics`. No capability → `403` with `X-Missing-Permission: view_statistics`.
- **Source files:**
  - Controller: [TrainerStatisticsController.php](../../app/Http/Controllers/Api/V1/Trainer/TrainerStatisticsController.php)
  - Request: [TrainerDashboardRequest.php](../../app/Http/Requests/Trainer/TrainerDashboardRequest.php) (optional `preset`/`from`/`to`/`group_by`/`include_trend`)
  - Service: [StatisticsService.php](../../app/Services/Trainer/StatisticsService.php)
  - Routes: [routes/api/v1/trainer.php](../../routes/api/v1/trainer.php) (`dashboard/overview`, `dashboard/insights`)
  - Tests: [TrainerDashboardOverviewApiTest.php](../../tests/Feature/Education/TrainerDashboardOverviewApiTest.php), [TrainerDashboardInsightsApiTest.php](../../tests/Feature/Education/TrainerDashboardInsightsApiTest.php), [TrainerDashboardOverviewTrendTest.php](../../tests/Feature/Education/TrainerDashboardOverviewTrendTest.php), [TrainerDashboardInsightsTrendTest.php](../../tests/Feature/Education/TrainerDashboardInsightsTrendTest.php)

Sibling doc: [trainer.md](./trainer.md) covers every other trainer endpoint (courses, programs, students, consultations); this page is the deep-dive for the two dashboard endpoints it only summarizes.

---

## Why two endpoints

The Figma dashboard packs ~12 widgets onto one screen. Computing all of them in a single request means the whole page waits on the slowest query (quiz-attempt aggregation, six months of enrollment history, per-program attendance joins) before anything paints. Splitting by **"above the fold, cheap" vs "below the fold, heavier"** lets the frontend:

1. Call `overview` first (or in parallel) and paint the hero banner + 8 tiles + urgent tasks + today's schedule the moment it resolves.
2. Call `insights` right after (parallel request, not chained) and stream in the charts/lists once it resolves, with skeleton loaders in the meantime.

Both responses are independently cached per-trainer for 60 seconds ([`SmartCacheInterface`](../../app/Contracts/Cache/SmartCacheInterface.php)), and each cache entry is scoped to only the DB tables it actually reads — a save anywhere in the app that touches one of those tables invalidates just that entry, not the whole dashboard.

---

## Endpoints at a glance

| Section | Method | Endpoint | Cache key | Refresh cadence |
|---|---|---|---|---|
| Overview | `GET` | `{{base_url}}/trainer/dashboard/overview` | `trainer:{id}:dashboard:overview:{md5 of period params}` | Poll every 60s while the tab is open (matches TTL) if you want the "N minutes until next session" countdown and today's schedule to stay live |
| Insights | `GET` | `{{base_url}}/trainer/dashboard/insights` | `trainer:{id}:dashboard:insights:{md5 of period params}` | Fetch once per page load; re-fetch on tab focus is enough — nothing here needs second-by-second freshness |

Both endpoints now also accept the optional query parameters below — everything else is still scoped to the authenticated trainer server-side, with no other filter.

## Query parameters

All optional; both endpoints share the same resolution rules as the admin dashboard (`RevenueReportService::range()`).

| Param | Type | Description |
|---|---|---|
| `preset` | `today` \| `this_month` \| `last_month` \| `this_year` | Selects the window for the added `period_metrics`/`trend` fields |
| `from`, `to` | `Y-m-d` | `to >= from`; same from/to defaulting as the admin dashboard |
| `group_by` | `day` \| `month` | Auto-chosen when omitted; `day` rejected for a resolved range over 366 days |
| `include_trend` | `0` \| `1` | Default `0`. `1` adds `overview.trend` / `insights.completion_trend`. `period_metrics` and `data.period` appear whenever any period param above is sent too, even with `include_trend=0` |

Calling both endpoints with the same parameters resolves to the same `data.period` — safe to request both in parallel for one selected range. The cache key folds these params in, so a chart request (`include_trend=1`) never returns a cached summary-only response or vice versa. See [admin-reports-duration-filter.md](admin-reports-duration-filter.md) for the full shared resolution/validation rules.

---

## `GET /trainer/dashboard/overview`

### Response shape

```json
{
  "status": "success",
  "message": "Dashboard overview retrieved successfully",
  "data": {
    "trainer": {
      "name": "Ahmed Al-Zahrani",
      "badges": ["Finance & Accounting", "Certified Professional Trainer"]
    },
    "next_session": {
      "id": "00000000-0000-0000-0000-0000000000a1",
      "title": "Excel for Accountants & Finance",
      "owner_type": "course",
      "mode": "online",
      "starts_at": "2026-09-22T11:00:00+03:00",
      "minutes_until": 45
    },
    "stats": {
      "total_students": 248,
      "consultations": 5,
      "training_programs": 3,
      "published_courses": 12,
      "certificates_issued": 124,
      "pending_review": 2,
      "unread_notifications": 3,
      "today_sessions": 4
    },
    "urgent_tasks": [
      { "type": "course", "id": "00000000-...-b1", "title": "Excel Course — Needs Editing", "status": "needs_revision", "urgent": true },
      { "type": "program", "id": "00000000-...-b2", "title": "Leadership & Management Skills", "status": "pending_review", "urgent": false }
    ],
    "today_schedule": [
      {
        "id": "00000000-...-c1",
        "title": "Excel for Accountants & Finance",
        "owner_type": "course",
        "mode": "online",
        "starts_at": "2026-09-22T10:00:00+03:00",
        "ends_at": "2026-09-22T11:00:00+03:00",
        "status": "running",
        "students_count": 18
      },
      {
        "id": "00000000-...-c2",
        "title": "Professional Project Management",
        "owner_type": "program",
        "mode": "online",
        "starts_at": "2026-09-22T13:00:00+03:00",
        "ends_at": "2026-09-22T14:30:00+03:00",
        "status": "upcoming",
        "students_count": 12
      }
    ]
  }
}
```

### Field reference

#### `trainer`

| Field | Type | Meaning |
|---|---|---|
| `name` | string \| null | The trainer's `full_name` |
| `badges` | `string[]` | Category names from the trainer's `TrainerCategory` specializations, in the request's locale (`Accept-Language`). Empty array if admin hasn't assigned any category |

#### `next_session`

The soonest session (own courses + programs) that hasn't finished yet — `UPCOMING` or `RUNNING` per [`SessionStatus`](../../app/Enums/SessionStatus.php), scanning up to 50 future rows. `null` when nothing is scheduled.

| Field | Type | Meaning |
|---|---|---|
| `id` | uuid | The session's uuid |
| `title` | string \| null | Owning course/program name |
| `owner_type` | `course` \| `program` | What kind of content this session belongs to |
| `mode` | `online` \| `onsite` | |
| `starts_at` | ISO 8601 \| null | Null only for a Phase-A placeholder session with no calendar slot yet (shouldn't reach `next_session` in practice, since those are filtered out) |
| `minutes_until` | integer | Minutes from now until `starts_at`. **`0` while the session is already running**, not negative — don't render "-12 min," render a "live now" state instead (see [`today_schedule`](#today_schedule) `status` for that) |

#### `stats`

The 8 dashboard tiles. All integers, all scoped to the trainer's own content.

| Field | Meaning |
|---|---|
| `total_students` | Distinct users enrolled across the trainer's courses **and** programs (a student enrolled in two of the trainer's courses counts once) |
| `consultations` | All-time count of consultation bookings against the trainer's offerings, any status |
| `training_programs` | Count of the trainer's training programs, any `content_status` |
| `published_courses` | Count of the trainer's courses with `content_status = approved` |
| `certificates_issued` | Certificates issued for the trainer's courses/programs, all time |
| `pending_review` | Courses **+** programs currently in `pending_review` or `needs_revision` — the same number backing [`urgent_tasks`](#urgent_tasks) below |
| `unread_notifications` | `unreadNotifications()->count()` on the trainer's user — same source as `GET /shared/notifications` (see [Notifications](#notifications-not-duplicated-here)) |
| `today_sessions` | Sessions (courses + programs) scheduled today, any status |

All of these are lifetime/current totals, unaffected by any period param. With `include_trend=1` (or any of `preset`/`from`/`to`/`group_by`), the response additionally carries `period` and `period_metrics`, and (with `include_trend=1`) `trend`:

```json
{
  "data": {
    "period": { "from": "2026-09-01", "to": "2026-09-22", "group_by": "day", "timezone": "UTC" },
    "period_metrics": { "new_students": 12, "new_consultations": 3, "certificates_issued": 5 },
    "trend": [
      { "period": "2026-09-01", "new_students": 2, "new_consultations": 1, "certificates_issued": 0 },
      { "period": "2026-09-02", "new_students": 1, "new_consultations": 0, "certificates_issued": 1 }
    ]
  }
}
```

| Field | Meaning |
|---|---|
| `period_metrics.new_students` | Distinct students whose **first-ever** eligible enrollment in any of the trainer's courses/programs falls in the range — a student enrolled in two of the trainer's offerings in the period still counts once |
| `period_metrics.new_consultations` | Consultation bookings for this trainer created in the range, any status |
| `period_metrics.certificates_issued` | Certificates issued for the trainer's courses/programs in the range |
| `trend[]` | Same three fields, per bucket, zero-filled, oldest first; each is the **count in that bucket** (sums to the matching `period_metrics` value), not a running total |

These do not replace `stats.total_students`/`consultations`/`certificates_issued` above, which remain lifetime totals — render them as a separate "this period" annotation next to the lifetime tile.

#### `urgent_tasks`

Up to **6** items: the trainer's own courses/programs sitting in `needs_revision` or `pending_review`, `needs_revision` first (`urgent: true`), then `pending_review` (`urgent: false`). This is the dashboard's compact preview — the full lists live at `GET /trainer/courses` / `GET /trainer/programs` filtered by status.

| Field | Type | Meaning |
|---|---|---|
| `type` | `course` \| `program` | |
| `id` | uuid | Deep-link target |
| `title` | string | |
| `status` | `needs_revision` \| `pending_review` | |
| `urgent` | boolean | `true` only for `needs_revision` — drives the red "عاجل" chip vs a neutral "مسودة/قيد المراجعة" chip |

> ⚠️ Only these two `content_status` values ever appear here. There is no generic "task" table in the schema — reminders like "prepare next lecture" or "review weekly attendance" from the Figma mockup have no backing data and are intentionally **not** in this response. If product wants those, they need a real source (see [Not included](#not-included-yet)).

#### `today_schedule`

Every session (courses + programs) whose `session_date` is today, ordered by `start_time`. Can be empty (`[]`) on a day with nothing scheduled.

| Field | Type | Meaning |
|---|---|---|
| `id` | uuid | |
| `title` | string \| null | Owning course/program name |
| `owner_type` | `course` \| `program` | |
| `mode` | `online` \| `onsite` | |
| `starts_at` / `ends_at` | ISO 8601 | |
| `status` | `upcoming` \| `running` \| `completed` | Derived live from `starts_at`/`ends_at`, not stored — a `running` row is the one to show a green "انضم الآن / Join now" button on |
| `students_count` | integer | Total enrollments on the owning course/program (not per-session attendance — attendance is only known *after* the session runs) |

---

## `GET /trainer/dashboard/insights`

### Response shape

```json
{
  "status": "success",
  "message": "Dashboard insights retrieved successfully",
  "data": {
    "completion": {
      "total_enrollments": 320,
      "completed_percent": 62,
      "in_progress_percent": 28,
      "not_started_percent": 10
    },
    "weekly_training_hours": {
      "total_hours": 22.5,
      "daily": [
        { "day": "sun", "hours": 2.0 },
        { "day": "mon", "hours": 4.5 },
        { "day": "tue", "hours": 1.0 },
        { "day": "wed", "hours": 6.0 },
        { "day": "thu", "hours": 3.0 },
        { "day": "fri", "hours": 0.0 },
        { "day": "sat", "hours": 6.0 }
      ]
    },
    "student_growth": {
      "growth_percent": 12.2,
      "monthly": [
        { "month": "2026-04", "students": 130 },
        { "month": "2026-05", "students": 148 },
        { "month": "2026-06", "students": 160 },
        { "month": "2026-07", "students": 175 },
        { "month": "2026-08", "students": 190 },
        { "month": "2026-09", "students": 248 }
      ]
    },
    "test_performance": [
      { "course_id": "00000000-...-d1", "course_title": "Excel for Accountants & Finance", "average_score": 82.5, "pass_rate": 91 },
      { "course_id": "00000000-...-d2", "course_title": "Power BI & Data Analysis", "average_score": 74.0, "pass_rate": 78 }
    ],
    "training_programs": [
      {
        "id": "00000000-...-e1",
        "title": "Professional Project Management",
        "status": "approved",
        "students_count": 124,
        "attendance_percent": 91,
        "completion_percent": 68,
        "next_session_at": "2026-09-22T18:00:00+03:00"
      }
    ],
    "published_courses": [
      {
        "id": "00000000-...-f1",
        "title": "Excel for Accountants & Finance",
        "status": "approved",
        "students_count": 89,
        "lessons_count": 24,
        "completion_percent": 75
      }
    ],
    "recent_activity": [
      { "type": "enrollment", "message": "Mona Farouk enrolled in Excel for Accountants & Finance", "occurred_at": "2026-09-22T07:40:00+03:00" },
      { "type": "certificate", "message": "Certificate issued to Omar Khaled for Power BI & Data Analysis", "occurred_at": "2026-09-21T16:05:00+03:00" },
      { "type": "quiz_completed", "message": "Sara Al-Otaibi completed a quiz in Excel for Accountants & Finance", "occurred_at": "2026-09-21T09:12:00+03:00" }
    ]
  }
}
```

### Field reference

#### `completion`

Enrollment progress across the trainer's courses **and** programs combined, for the donut chart.

| Field | Type | Meaning |
|---|---|---|
| `total_enrollments` | integer | Denominator for all three percentages |
| `completed_percent` | integer | `status = completed` |
| `in_progress_percent` | integer | `status = active` **and** `progress_percent > 0` |
| `not_started_percent` | integer | `status = active` **and** `progress_percent = 0` |

This donut is always the lifetime/current snapshot, unaffected by any period param. With `include_trend=1` (or any of `preset`/`from`/`to`/`group_by`), the response additionally carries `period` and `period_metrics`, and (with `include_trend=1`) `completion_trend` — a **selected-cohort** view, scoped to enrollments in the trainer's own courses/programs during the range:

```json
{
  "data": {
    "period": { "from": "2026-09-01", "to": "2026-09-22", "group_by": "day", "timezone": "UTC" },
    "period_metrics": { "new_enrollments": 20, "completions_in_period": 7, "cohort_completed": 4, "cohort_completion_rate": 20 },
    "completion_trend": [
      { "period": "2026-09-01", "new_enrollments": 2, "completions_in_bucket": 0, "cohort_enrolled_to_date": 2, "cohort_completed_to_date": 0, "cohort_completion_rate": 0 }
    ]
  }
}
```

Same field definitions as the course/program report's `period_metrics`/`trend` — see [reports-admin.md → Dated period_metrics/trend](./reports-admin.md#dated-period_metricstrend). The last `completion_trend` point's `cohort_completion_rate` equals `period_metrics.cohort_completion_rate`; bucket counts sum to `period_metrics.new_enrollments`/`completions_in_period`. Plot `completion_trend` as a dated **rate** sparkline next to the (unrelated) lifetime donut above — don't merge the two into one number.

⚠️ Each percentage is rounded independently (`round(count / total × 100)`), so the three **can sum to 99 or 101**, not always exactly 100 — this is expected rounding drift, not a bug. Don't force them to sum to 100 client-side (e.g. by computing the third as `100 - a - b`); render each value as given, or the donut's largest slice will silently absorb a rounding error that isn't really there.

#### `weekly_training_hours`

Sum of `class_sessions.duration_minutes` for the **current calendar week** (Sunday → Saturday, not a rolling 7 days), bucketed per day for the bar chart.

| Field | Type | Meaning |
|---|---|---|
| `total_hours` | number | Whole week's total, rounded to 1 decimal |
| `daily[].day` | `sun`\|`mon`\|`tue`\|`wed`\|`thu`\|`fri`\|`sat` | Always all 7, always in this order, even for days with `hours: 0` — no client-side gap-filling needed |
| `daily[].hours` | number | Rounded to 1 decimal |

#### `student_growth`

New **distinct** students per calendar month, this month and the 5 before it (6 points, oldest first).

| Field | Type | Meaning |
|---|---|---|
| `growth_percent` | number | `(last_month − first_month) / first_month × 100`, 1 decimal. `100.0` if the first month had zero students but the last month has some (can't divide by zero, but growth clearly happened); `0.0` if both are zero |
| `monthly[].month` | `YYYY-MM` | |
| `monthly[].students` | integer | Distinct `user_id`s enrolled that month — a student who re-enrolls in a different course the same month is **not** double-counted, but the same student enrolling in different months counts once per month they show up in |

#### `test_performance`

One row per course that has at least one **completed** quiz attempt (`completed_at IS NOT NULL`). Courses with a quiz nobody has attempted yet, or with only in-progress attempts, don't appear — render an empty state ("No quiz data yet"), not a zero row.

| Field | Type | Meaning |
|---|---|---|
| `course_id` | uuid | |
| `course_title` | string | |
| `average_score` | number | Mean of `quiz_attempts.score` across all completed attempts on that course's lessons, 1 decimal |
| `pass_rate` | integer | `% of completed attempts with passed = true`, rounded |

#### `training_programs` / `published_courses`

Preview lists — the **5 most recently created** programs/courses only (programs: any status; courses: `content_status = approved` only). This is *not* a paginated index; link the "See all" button to `GET /trainer/programs` / `GET /trainer/courses`.

| Field | In | Meaning |
|---|---|---|
| `id`, `title`, `status` | both | `status` is `content_status` — programs can show any value, courses are always `approved` here |
| `students_count` | both | Total enrollments |
| `attendance_percent` | programs only | `attended / total` across all `class_session_attendance` rows for the program's sessions, all time — not just today |
| `lessons_count` | courses only | Count of `course_lessons` rows (lessons + quizzes combined) |
| `completion_percent` | both | Average `enrollments.progress_percent` across the item's students, rounded — **this is student progress, not "how much content the trainer has uploaded."** The Figma mockup's "% اكتمال المحتوى" label reads as content-authoring completeness; there's no such metric in the schema, so this field answers a related-but-different question. Label it "Average student progress" in the UI, or ask product before reusing the mockup's exact wording |
| `next_session_at` | programs only | ISO 8601 \| null — soonest not-yet-finished session for that program |

#### `recent_activity`

Up to **10** items, newest first, merged from three sources: new enrollments, certificate issuances, and completed quiz attempts across the trainer's own content. `message` is pre-formatted and locale-aware (translated via `Accept-Language`) — render it as-is rather than reconstructing it from `type`.

| Field | Type | Meaning |
|---|---|---|
| `type` | `enrollment` \| `certificate` \| `quiz_completed` | Use to pick an icon, not to re-derive the message text |
| `message` | string | Ready-to-render sentence |
| `occurred_at` | ISO 8601 \| null | Sort key already applied server-side; re-sort client-side only if you merge in other local data |

> There is no dedicated activity-feed table backing this — it's assembled from three separate queries and merged in PHP. If you need more activity types later (content published, session rescheduled, etc.), that's new work on the backend, not a frontend-only change.

---

## Frontend integration & design guide

Mapping the two endpoints onto the mockup screens, in the order they appear top-to-bottom.

### 1. Hero banner (`overview`)

- Trainer name + `badges` render as the name line and the pill chips next to it ("مدرب محترف معتمد", "مالي ومحاسبي" in the mockup — real data comes from the trainer's assigned categories, so badge *wording* will differ trainer to trainer, unlike the mockup's fixed labels).
- "الجلسة التالية خلال 45 دقيقة" countdown = `next_session.minutes_until` + `next_session.title`. If `next_session` is `null`, hide the countdown pill entirely rather than showing "0 minutes" or a stale placeholder.
- "لديك 4 جلسات مجدولة اليوم • 2 مهام معلقة" = `stats.today_sessions` + `urgent_tasks.length` (not `stats.pending_review` — they're the same number today, but `urgent_tasks` is the one capped at 6 and is what the "عرض الكل" link under it actually opens).
- The 4 small stats under the banner in the mockup (شهادات أصدرت / متوسط التقييم / معدل إتمام / أيام نشاط متواصل) only have **3** real equivalents here: `stats.certificates_issued`, and — from the *existing* `GET /trainer/statistics` endpoint, not this one — `average_rating` and `completion_rate`. The 4th ("أيام نشاط متواصل" / active-day streak) was deliberately dropped; there's no activity-log table to compute it truthfully. If you need a 4th tile, pick a real one (e.g. `stats.total_students`) rather than fabricating a streak count.

### 2. The 8 stat tiles (`overview.stats`)

Direct 1:1 mapping — `total_students`, `consultations`, `training_programs`, `published_courses`, `certificates_issued`, `pending_review`, `unread_notifications`, `today_sessions` — these are always current/lifetime totals, never selected-period ones.

Three of the eight now have real period-over-period backing when the request includes `include_trend=1` (plus a `preset`/`from`/`to`): request `overview?include_trend=1&preset=this_month`, and render the `+12 هذا الشهر`-style delta from `data.period_metrics.new_students`/`new_consultations`/`certificates_issued`, with the matching sparkline from `data.trend[].new_students`/`new_consultations`/`certificates_issued` (one point per bucket, tooltip = `trend[].period` + the value). Keep the tile's own number as the lifetime `stats.*` value — the sparkline/delta is a separate, clearly-labeled "this period" annotation next to it, not a replacement.

The other five tiles (`training_programs`, `published_courses`, `pending_review`, `unread_notifications`, `today_sessions`) still have no period-over-period backing — there's no reliable status-history table to compute one truthfully, so don't fabricate a trend line for those.

### 3. Urgent tasks card (`overview.urgent_tasks`)

Render `urgent: true` rows with the red "عاجل" badge, `urgent: false` rows with a neutral badge showing their `status` (`pending_review` → "قيد المراجعة"). Empty array → show the card's empty state, not a zero-height gap. Remember only two task *kinds* exist (course/program needing revision or review) — don't build UI affordances (checkboxes, "mark done") that imply a general task system; these clear themselves automatically when the trainer edits the content and it leaves `needs_revision`/`pending_review`.

### 4. Today's schedule (`overview.today_schedule`)

One row per item, `status` drives the CTA:

| `status` | UI |
|---|---|
| `running` | Green "انضم الآن" button, live pulse indicator |
| `upcoming` | Neutral "مقبلة" badge, no join button yet |
| `completed` | Greyed out, no CTA (today's earlier sessions) |

`mode` (`online`/`onsite`) picks the icon (camera vs. pin). Join actions themselves are **not** part of this endpoint — use the existing session-join endpoints (`POST .../sessions/{session}/join`) with `today_schedule[].id`.

### 5. Completion donut (`insights.completion`)

Three-segment donut: `completed_percent` (green), `in_progress_percent` (blue), `not_started_percent` (grey) — same color convention as the mockup. Render `total_enrollments` as the center label instead of a percentage, since the three percentages may not sum to exactly 100 (see the rounding note above) and a center "100%" would look wrong next to slices that don't quite add up.

With `include_trend=1`, `insights` also returns `data.completion_trend` — a dated **rate** series (`cohort_completion_rate` per bucket, plus `new_enrollments`/`completions_in_bucket`/`cohort_enrolled_to_date`/`cohort_completed_to_date`), scoped to enrollments in the selected period. Plot it as a separate sparkline labeled "selected-period completion rate", with `data.period_metrics.cohort_completion_rate` as its headline value — the donut above stays the lifetime/current snapshot; don't conflate the two numbers in one widget.

### 6. Weekly training hours (`insights.weekly_training_hours`)

Bar chart, `daily[]` in order (already Sun→Sat, no reordering needed). Title the chart "This week" (calendar week), not "Last 7 days" — that's an important distinction from what the mockup's label implies, since the data resets every Sunday rather than rolling.

### 7. Student growth (`insights.student_growth`)

Line/area chart over `monthly[]` (6 points). `growth_percent` is the badge in the chart header ("+12.2%"). A negative value is valid (shrinking enrollment) — style the badge red in that case rather than assuming growth is always positive.

### 8. Test performance (`insights.test_performance`)

Grouped bar chart (avg score vs pass rate) per course, matching the mockup's Excel/Power BI/... layout — except real course titles instead of fixed subject names, and **the list only contains courses that actually have quiz data**. A trainer with no quizzes yet sees an empty chart section — hide it entirely rather than rendering an empty axis.

### 9. Training programs / Published courses lists (`insights.training_programs` / `insights.published_courses`)

These are **previews (5 items each)**, matching the mockup's card grid, not the full catalog. Wire each card's "إدارة المحتوى" button to the program/course detail screen; wire the section's "عرض الكل" link to the full `GET /trainer/programs` / `GET /trainer/courses` list endpoints (which support real pagination/filtering) — don't try to paginate through the dashboard endpoint itself.

### 10. Quick access shortcuts

Pure frontend navigation (Notifications / Materials / Consultations / Exams / Attendance / Students / Programs / Courses icons in the mockup) — **no backend endpoint**. Wire these directly to existing routes/screens; nothing here needs new data.

### 11. Notifications (not duplicated here)

The mockup's notifications panel is **not** reproduced by these two endpoints beyond the `stats.unread_notifications` count. For the actual list (and mark-as-read), use the existing cross-role endpoints:

```
GET   {{base_url}}/shared/notifications              # paginated list + unread_count
GET   {{base_url}}/shared/notifications?read_status=unread
POST  {{base_url}}/shared/notifications/mark-all-read
```

These already work for trainers (see [trainer.md](./trainer.md) — trainers hit the shared notification controller like every other role). Don't build a second notifications endpoint under `/trainer` — `stats.unread_notifications` on the overview response reads the exact same `unreadNotifications()` count, so the two stay in sync automatically.

### 12. Recent activity (`insights.recent_activity`)

Simple timeline, icon by `type`, text from `message` as-is. If the trainer has done nothing yet (new account), this is `[]` — show an empty state, not a loading spinner stuck forever.

### Loading strategy

1. Fire `overview` and `insights` requests **in parallel** on page mount — don't chain them, they're independent.
2. Paint the hero + 8 tiles + urgent tasks + today's schedule as soon as `overview` resolves, with skeletons for everything from the donut down.
3. Fill in the rest as soon as `insights` resolves. If `insights` is slower, that's expected and fine — it's the whole point of the split.
4. Re-poll `overview` every ~60s if the page stays open (for the live countdown / running-session state); leave `insights` as a one-shot fetch, refreshed on tab focus or manual refresh only.

### Permission handling

If the trainer lacks `view_statistics`, both endpoints 403. Check the capability from the trainer's permissions payload (`GET /trainer/profile` or wherever the app already loads granted capabilities) **before** routing to this page at all — hide "الرئيسية" from the sidebar nav and redirect to the first screen the trainer *does* have access to, rather than showing a broken/empty dashboard behind a 403.

### Locale / RTL

All `title`/`name`/`message` string fields respect the request's `Accept-Language` header (`ar` if omitted — this app's default, not `en`). Send the user's active locale on every request; don't assume English unless you explicitly set the header.

---

## Error codes

| HTTP Status | Trigger |
|---|---|
| `401 Unauthorized` | Missing or expired trainer bearer token |
| `403 Forbidden` | Authenticated user has no trainer profile, or their trainer profile lacks the `view_statistics` capability |
| `422 Unprocessable Entity` | `to` before `from`; `from`/`to` not `Y-m-d`; `preset`/`group_by` outside their allowed values; explicit `group_by=day` with a resolved range over 366 days |

Both endpoints are read-only with no request body, but do accept the optional query parameters above.

---

## Example requests

```bash
# Overview — hero banner, tiles, urgent tasks, today's schedule
curl -H "Authorization: Bearer {{trainer_token}}" \
     -H "Accept-Language: ar" \
     "{{base_url}}/trainer/dashboard/overview"

# Insights — charts, content preview lists, recent activity
curl -H "Authorization: Bearer {{trainer_token}}" \
     -H "Accept-Language: ar" \
     "{{base_url}}/trainer/dashboard/insights"

# Both endpoints with the same range and dated trends
curl -H "Authorization: Bearer {{trainer_token}}" \
     "{{base_url}}/trainer/dashboard/overview?include_trend=1&preset=this_month"
curl -H "Authorization: Bearer {{trainer_token}}" \
     "{{base_url}}/trainer/dashboard/insights?include_trend=1&preset=this_month"
```

Postman: see "Dashboard" in [trainer.md](./trainer.md#dashboard) for the raw request examples, or the "Overview Dashboard"/"Insights Dashboard" items in the Postman collection.

---

## Not included (yet)

- **"Active streak" days** — no activity-log table to compute it from; dropped rather than fabricated (see [hero banner](#1-hero-banner-overview) above).
- **Generic tasks/reminders** — only `needs_revision`/`pending_review` content counts as an "urgent task"; there's no table for ad hoc reminders like "prepare next lecture."
- **Tile trend deltas** (e.g. "+12 this month") on the 8 stat tiles — these report current totals only, no period-over-period comparison.
- **Content-authoring completeness** — `completion_percent` on the courses/programs preview lists is student progress, not "% of planned content uploaded" (see the field reference note above).
- **A dedicated trainer notifications endpoint** — intentionally reuses `GET /shared/notifications` instead of duplicating it.
