# Story 2.4: JavaScript Utilities & API Error Helper

Status: review

## Story

As a developer,
I want shared JavaScript utilities and a Python API error helper in place,
so that all fetch interactions, modals, loading states, and API error responses follow a single consistent pattern.

## Acceptance Criteria

1. `app/static/js/chart-defaults.js` sets `Chart.defaults` once at page load: consistent palette, tooltip styling, `responsive: true`. Guards against `Chart` being undefined on pages that don't use charts.
2. `app/static/js/modal.js` handles the confirm modal component (Story 2.2): triggered by `data-confirm-target`, cancel by `data-close-modal`, confirm submits to `data-confirm-url` via `data-confirm-method`. Focus trap inside modal, Esc closes, focus returns to trigger on close.
3. `app/static/js/loading.js` exports `setLoading(element, bool)` toggling `element.dataset.loading` between `'true'` and `'false'`.
4. `app/utils/errors.py` defines `api_error(message: str, code: int) -> Response` returning `jsonify({"error": message, "code": code})` with the given HTTP status code.
5. Contract test for `api_error`: `Content-Type` is `application/json`, body has both `error` and `code` keys, HTTP status matches the `code` argument.
6. All 3 JS files are already referenced in `base.html` (Story 2.1) — do not modify `base.html`.
7. `pytest tests/` passes with all prior 78 tests still green (no regressions).

## Tasks / Subtasks

- [ ] **Task 1: Write `tests/test_utils/test_errors.py`** (TDD RED)
  - [ ] Test `api_error` returns `application/json` Content-Type
  - [ ] Test body has `error` key matching `message` argument
  - [ ] Test body has `code` key matching `code` argument
  - [ ] Test HTTP status matches `code` argument (test with 400, 404, 500)
  - [ ] Confirm tests fail RED before implementation

- [ ] **Task 2: Implement `app/utils/errors.py`** (AC: 4)
  - [ ] `api_error(message: str, code: int) -> Response`
  - [ ] Uses `jsonify({"error": message, "code": code})` with status code

- [ ] **Task 3: Implement `app/static/js/loading.js`** (AC: 3)
  - [ ] `setLoading(element, bool)` sets `element.dataset.loading = 'true'` or `'false'`

- [ ] **Task 4: Implement `app/static/js/chart-defaults.js`** (AC: 1)
  - [ ] Guard: `if (typeof Chart === 'undefined') return;`
  - [ ] `Chart.defaults.responsive = true`
  - [ ] Consistent color palette array
  - [ ] Tooltip styling

- [ ] **Task 5: Implement `app/static/js/modal.js`** (AC: 2)
  - [ ] Click `[data-confirm-target]` → show modal, store trigger ref
  - [ ] Click `[data-close-modal]` → hide modal, return focus to trigger
  - [ ] Click `[data-confirm-url]` → POST/form-submit to URL, show loading
  - [ ] Esc keydown → hide modal, return focus to trigger
  - [ ] Focus trap: Tab/Shift+Tab cycle within modal

- [ ] **Task 6: Run full test suite GREEN** (AC: 7)
  - [ ] `pytest tests/ -v` — 78+ passed, 1 skipped

## Dev Notes

### `app/utils/errors.py`

```python
from flask import jsonify

def api_error(message: str, code: int):
    response = jsonify({"error": message, "code": code})
    response.status_code = code
    return response
```

### `app/static/js/loading.js`

```js
function setLoading(element, isLoading) {
  element.dataset.loading = isLoading ? 'true' : 'false';
}
```

### `app/static/js/chart-defaults.js`

```js
(function () {
  if (typeof Chart === 'undefined') { return; }

  Chart.defaults.responsive = true;
  Chart.defaults.maintainAspectRatio = false;

  // Consistent fin- palette: safe / accent / warning / danger / muted + extras
  Chart.defaults.color = '#636c76';  // axis labels — var(--color-muted)

  const FIN_PALETTE = [
    '#0969da',  // accent  (blue)
    '#1a7f37',  // safe    (green)
    '#9a6700',  // warning (amber)
    '#cf222e',  // danger  (red)
    '#6639ba',  // purple
    '#0a3069',  // deep blue
    '#953800',  // deep amber
  ];

  Chart.defaults.plugins.tooltip.backgroundColor = '#1f2328';
  Chart.defaults.plugins.tooltip.titleColor      = '#fff';
  Chart.defaults.plugins.tooltip.bodyColor       = '#d0d7de';
  Chart.defaults.plugins.tooltip.padding         = 10;
  Chart.defaults.plugins.tooltip.cornerRadius    = 6;

  // Expose palette so individual chart scripts can reference it
  window.FIN_PALETTE = FIN_PALETTE;
})();
```

### `app/static/js/modal.js`

```js
(function () {
  'use strict';

  let _triggerEl = null;  // element that opened the currently-visible modal

  function getFocusable(modal) {
    return Array.from(
      modal.querySelectorAll(
        'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
      )
    );
  }

  function openModal(modal, trigger) {
    _triggerEl = trigger;
    modal.style.display = 'flex';
    const focusable = getFocusable(modal);
    if (focusable.length) { focusable[0].focus(); }
  }

  function closeModal(modal) {
    modal.style.display = 'none';
    if (_triggerEl) { _triggerEl.focus(); _triggerEl = null; }
  }

  document.addEventListener('click', function (e) {
    // Open modal
    const trigger = e.target.closest('[data-confirm-target]');
    if (trigger) {
      const modal = document.getElementById(trigger.dataset.confirmTarget);
      if (modal) { openModal(modal, trigger); }
      return;
    }

    // Close modal (cancel button)
    const closeBtn = e.target.closest('[data-close-modal]');
    if (closeBtn) {
      const modal = document.getElementById(closeBtn.dataset.closeModal);
      if (modal) { closeModal(modal); }
      return;
    }

    // Confirm button — submit via form POST
    const confirmBtn = e.target.closest('[data-confirm-url]');
    if (confirmBtn) {
      const url    = confirmBtn.dataset.confirmUrl;
      const method = (confirmBtn.dataset.confirmMethod || 'POST').toUpperCase();
      const form = document.createElement('form');
      form.method = method === 'GET' ? 'GET' : 'POST';
      form.action = url;
      // Include CSRF token if present on page
      const csrfInput = document.querySelector('input[name="csrf_token"]');
      if (csrfInput) {
        const hidden = document.createElement('input');
        hidden.type  = 'hidden';
        hidden.name  = 'csrf_token';
        hidden.value = csrfInput.value;
        form.appendChild(hidden);
      }
      document.body.appendChild(form);
      form.submit();
      return;
    }

    // Click outside modal content — close if backdrop clicked
    if (e.target.classList.contains('fin-modal')) {
      closeModal(e.target);
    }
  });

  // Esc key closes modal
  document.addEventListener('keydown', function (e) {
    if (e.key !== 'Escape') { return; }
    const open = document.querySelector('.fin-modal[style*="flex"]');
    if (open) { closeModal(open); }
  });

  // Focus trap — Tab / Shift+Tab cycles within modal
  document.addEventListener('keydown', function (e) {
    if (e.key !== 'Tab') { return; }
    const open = document.querySelector('.fin-modal[style*="flex"]');
    if (!open) { return; }
    const focusable = getFocusable(open);
    if (!focusable.length) { e.preventDefault(); return; }
    const first = focusable[0];
    const last  = focusable[focusable.length - 1];
    if (e.shiftKey) {
      if (document.activeElement === first) {
        e.preventDefault(); last.focus();
      }
    } else {
      if (document.activeElement === last) {
        e.preventDefault(); first.focus();
      }
    }
  });
})();
```

### Test File: `tests/test_utils/test_errors.py`

```python
"""Contract tests for api_error helper — Story 2.4 (AC: 5)."""
import json
import pytest
from app.utils.errors import api_error


class TestApiError:
    def test_content_type_is_json(self, app):
        with app.app_context():
            response = api_error("something went wrong", 400)
            assert response.content_type == 'application/json'

    def test_body_has_error_key(self, app):
        with app.app_context():
            response = api_error("not found", 404)
            data = json.loads(response.data)
            assert 'error' in data
            assert data['error'] == 'not found'

    def test_body_has_code_key(self, app):
        with app.app_context():
            response = api_error("not found", 404)
            data = json.loads(response.data)
            assert 'code' in data
            assert data['code'] == 404

    @pytest.mark.parametrize("code", [400, 404, 500])
    def test_http_status_matches_code_argument(self, app, code):
        with app.app_context():
            response = api_error("error", code)
            assert response.status_code == code
```

### Architecture Compliance

- **AR-17**: `api_error` satisfies the contract: `{"error": "...", "code": HTTP_STATUS}` with matching HTTP status.
- **Base.html**: Already references all 3 JS files (Story 2.1) — do not modify.
- JS files are plain vanilla JS (no framework, no imports) per NFR-5.

### File List (Expected Changes)

| File | Status |
|------|--------|
| `tests/test_utils/test_errors.py` | NEW |
| `app/utils/errors.py` | MODIFY (replace stub) |
| `app/static/js/loading.js` | MODIFY (replace stub) |
| `app/static/js/chart-defaults.js` | MODIFY (replace stub) |
| `app/static/js/modal.js` | MODIFY (replace stub) |

---

## Dev Agent Record

### Agent Model Used
claude-sonnet-4-6

### Debug Log References
None

### Completion Notes List
- 84 passed, 1 skipped — zero regressions against prior 78 tests
- modal.js uses event delegation on document (single listener) rather than per-element binding, keeping it clean for dynamically-added modals
- chart-defaults.js guards `typeof Chart === 'undefined'` so it's safe on pages without Chart.js

### File List
- `tests/test_utils/test_errors.py` — NEW
- `app/utils/errors.py` — REPLACED stub with full implementation
- `app/static/js/loading.js` — REPLACED stub
- `app/static/js/chart-defaults.js` — REPLACED stub
- `app/static/js/modal.js` — REPLACED stub

### Change Log
- 2026-05-28: Story implemented and moved to review status
