// NO PROD JSON API (bucket C) — mock until backend (see PROD_API_INVENTORY.md)
// Represents: GET /reports/dailyperformance/ · ReportsController — see docs/ISYSTEM_REFERENCE.md §Batch 2 "Daily performance"
/* ====================================================================
   DAILY PERFORMANCE — Report ▾ rebuild (Batch 2), Hrs* shell
   ====================================================================
   Sidebar entry `__('backend.dailyperformance')` — the key resolves in no
   committed lang file (live translations sit in gitignored storage/lang), so
   the on-screen title "Daily Performance" is inferred (label policy).

   Traceability:
   · Page   admin.reports.dailyperformance.index  GET /reports/dailyperformance/
            routes/admin.php:1528 · ReportsController::dailyperformance (104-120;
            view defaults period="mese", range=1, start=01/MM/YYYY, end=t/MM/YYYY)
   · Data   admin.reports.dailyperformance.data   GET /reports/dailyperformance/getDailyperformanceReport
            routes/admin.php:1529 · ReportsController::getDailyPerformanceReport (1657-1812)
   · Export admin.reports.dailyperformance.export GET /reports/dailyperformance/excel  (is_export=1)
            routes/admin.php:1530 · ReportsController::excelExportDailyPerformance (1814-1865)
   · Blade  admin/reports/dailyperformance.blade.php (+ AJAX partial
            dailyperformance/index.blade.php) · JS public/js/pages/reports/dailyperformance.js

   Gates (Customer Care only — every other role passes the !isCustomCare()
   short-circuit): `support_report` (Report ▾ dropdown) + `support_report_dailyperformance`
   (entry, sidebar.blade.php:360); both re-enforced server-side on the page AND
   the data endpoint via $this->authorize('asdasdas') → 403. The whole Report ▾
   block is additionally hidden below SHOP level (sidebar.blade.php:258).

   Faithful absences (nothing added — brief §3): no sorting (static HTML table,
   DataTables loaded but never initialized), no pagination (every day of the
   range renders), no totals/KPI row (neither HTML partial nor XLSX has one),
   no row/bulk actions. The backend also accepts period=periodo_anno (year,
   L1711-1714) but the real page has NO year input (JS reads a non-existent
   #periodo_anno) — so no Year filter is built here (no-invented-actions
   policy). Unknown period → 400 {'error':'Invalid period'} (unreachable from
   the real UI and from this one); empty custom dates → 400
   {'error':'Seleziona le date!'} (untranslated Italian — surfaced as a toast).

   Known-bug divergence (evident intent, per CLAUDE.md known-bug policy):
   the live getDailyPerformanceReport normalizes the dd/mm/yyyy datepicker text
   with date('Y-m-d', strtotime($v)) (ReportsController.php:1726-1727). PHP
   strtotime parses slash dates as US m/d/Y — day/month swap silently, and any
   day > 12 returns false → 1970-01-01. Net effect: custom ranges query the
   wrong days and the backend-only year period (…/31/12) is always empty. The
   pre-rewrite implementation (commented out at L1498-1656) converted via
   data_f() and was correct. This rebuild applies the dates the operator
   actually picked (native ISO date inputs). The default Month path is
   unaffected on the live platform too (its values are already Y-m-d|Y-m-d).
   // <!-- SUGGESTION: fix getDailyPerformanceReport L1726-1727 to normalize
   //      dd/mm/yyyy input via data_f() (as the commented-out L1498-1656
   //      version did) instead of strtotime(), which reads slash dates as US
   //      m/d/Y — custom ranges hit the wrong days and periodo_anno is
   //      permanently empty. -->

   Honest caveat (surfaced in the Explainer + column tips, NOT "fixed" in the
   mock): Positive/Negative players are row counts of players_report — one row
   per player + provider + currency per day — so a player active on several
   providers that day is counted once per row, not once per player.
   // <!-- SUGGESTION: count DISTINCT players_report.player_id per profit sign
   //      (e.g. COUNT(DISTINCT CASE WHEN profit > 0 THEN player_id END)) so
   //      the columns really count players, not player+provider+currency rows. -->
   // <!-- SUGGESTION: excelExportDailyPerformance has no permission check of
   //      its own and, when the inner getDailyPerformanceReport() returns a
   //      400/403 JsonResponse, foreach-iterates the response object instead
   //      of aborting — guard the export route like the data route. -->
   // <!-- SUGGESTION: clean the copy-paste artifacts: form id
   //      "business-report-form" vs JS targeting #dailyperformance-report-form
   //      (select2 user search never binds), the unclosed
   //      #reports-dailyperformance-ajax div, and the unused Highcharts /
   //      jsPDF / pdfmake / DataTables-buttons CDN bundles + four never-opened
   //      modals. -->

   Column-label keys that resolve nowhere in the repo (inferred labels, marked
   in tips): backend.total_bet_count, backend.total_grr (yes — "GRR", typo
   preserved in both the table partial and the XLSX header), backend.
   positive_players, backend.negative_players, backend.dailyperformance.
   Resolved keys: date/total_bet/total_win/user/period/month/custom_range/
   today…previous_month/search_button/export.

   Demo session = Super admin "admin" (level 0) — same persona as the other
   Host screens. Deterministic PRNG rows seeded per calendar day + user, so
   every visit shows identical figures; days after "today" zero-fill exactly
   like the real DatePeriod loop (L1778-1798). No auto-load: the real
   generaReportsdailyperformance() on-ready call is commented out
   (dailyperformance.js:60) — the table stays empty until Search.

   All new top-level names are hrdf/Hrdf/HRDF-prefixed except the required page
   component `DailyPerformance` (app.jsx case "report-dailyperf"); this file
   loads after HostReports.jsx so this definition wins over the legacy stub.
   No page CSS needed — kit classes + inline styles only.
   ==================================================================== */

const { useState: hrdfUseState, useMemo: hrdfUseMemo } = React;

/* ---------- tiny date/format helpers ---------- */
const hrdfPad = (n) => String(n).padStart(2, "0");
const hrdfIso = (d) => `${d.getFullYear()}-${hrdfPad(d.getMonth() + 1)}-${hrdfPad(d.getDate())}`;
const hrdfDmy = (d) => `${hrdfPad(d.getDate())}/${hrdfPad(d.getMonth() + 1)}/${d.getFullYear()}`; // real table renders dd/mm/YYYY
const hrdfParse = (s) => { const [y, m, d] = String(s || "").split("-").map(Number); return new Date(y, (m || 1) - 1, d || 1); };
const hrdfToday = () => { const t = new Date(); t.setHours(0, 0, 0, 0); return t; };

/* Deterministic PRNG (mulberry32-style, same family as the other report mocks) */
/* The deterministic PRNG lived here and is gone with the generator. */
/* ---------- User dropdown (UsersController::getUsers($base,1,0,0,1,SHOP_LEVEL)):
   self + every subtree user with level < SHOP(20); ADMINISTRATION / CUSTOMER
   CARE / AFFILIATE levels and shops/players never appear. Labels follow the
   real "username (Level name)" format. `scale` only shapes the mock volumes. */
/* WAS `HRDF_USERS`: six invented accounts carrying a `seed` and a `scale`
   that between them WERE this report — every figure was that scale multiplied
   by that seed's PRNG. Operators come from `networkUsers` now. */
const hrdfRoleName = (lvl) => ({
  0: "Super Admin", 1: "Affiliate", 2: "Skin", 4: "Customer care",
  6: "Administration", 8: "Master", 9: "Regulator", 10: "Promoter",
  15: "Shop", 20: "Cashier", 30: "Player",
}[Number(lvl)] || `Level ${lvl}`);

/* range_val enum (backend.php:309-314): 1 Today · 2 Yesterday · 3 This week
   (rangeWeek()) · 4 Previous week · 5 This month (rangeMonth()) · 6 Previous month */
const HRDF_RANGES = ["Today", "Yesterday", "This week", "Previous week", "This month", "Previous month"];

/* Month radio options: one per calendar month per year 2021→current
   (getDateCalendarioMonth(), utils.php:2115) — the kit's "calendar" mode. */
const HRDF_MONTH_OPTS = hrsPeriodOptions("calendar", 2021);

const HRDF_CUSTOM_DEFAULT = (() => {
  const t = hrdfToday();
  return { from: hrdfIso(new Date(t.getFullYear(), t.getMonth(), 1)), to: hrdfIso(new Date(t.getFullYear(), t.getMonth() + 1, 0)) };
})(); // real datepickers prefill 01/MM/YYYY – lastday/MM/YYYY

/* Controller default is period="mese" with the current month preselected; the
   range select still shows Today (its radio just isn't the checked one). */
const hrdfInitialDraft = () => ({
  user: "admin",
  mode: "mese",                        // "range" | "mese" | "custom_range"
  period: "Today",
  month: HRDF_MONTH_OPTS[0].label,
  custom: { ...HRDF_CUSTOM_DEFAULT },
});

/* ---------- period resolution (mirrors getDailyPerformanceReport L1690-1727,
   minus the strtotime bug — see header) ---------- */
const hrdfResolve = (v) => {
  const t = hrdfToday();
  if (v.mode === "mese") {
    const opt = HRDF_MONTH_OPTS.find(o => o.label === v.month) || HRDF_MONTH_OPTS[0];
    const [s, e] = opt.value.split("|");
    return { start: hrdfParse(s), end: hrdfParse(e) };
  }
  if (v.mode === "custom_range") {
    if (!v.custom || !v.custom.from || !v.custom.to) return null; // real: 400 'Seleziona le date!'
    return { start: hrdfParse(v.custom.from), end: hrdfParse(v.custom.to) };
  }
  switch (v.period) { // period=range → range_val 1..6
    case "Today": return { start: t, end: t };
    case "Yesterday": { const y = new Date(t); y.setDate(y.getDate() - 1); return { start: y, end: y }; }
    case "This week": { const mon = new Date(t); mon.setDate(mon.getDate() - ((mon.getDay() + 6) % 7)); const sun = new Date(mon); sun.setDate(sun.getDate() + 6); return { start: mon, end: sun }; }
    case "Previous week": { const mon = new Date(t); mon.setDate(mon.getDate() - ((mon.getDay() + 6) % 7) - 7); const sun = new Date(mon); sun.setDate(sun.getDate() + 6); return { start: mon, end: sun }; }
    case "This month": return { start: new Date(t.getFullYear(), t.getMonth(), 1), end: new Date(t.getFullYear(), t.getMonth() + 1, 0) };
    case "Previous month": return { start: new Date(t.getFullYear(), t.getMonth() - 1, 1), end: new Date(t.getFullYear(), t.getMonth(), 0) };
    default: return null; // real: 400 'Invalid period' — unreachable from the UI
  }
};

/* One row per calendar day, gaps zero-filled (DatePeriod loop, L1778-1798).
   Days after today zero-fill exactly like days with no data. The 3660-iteration
   cap is only an infinite-loop guard for absurd custom ranges — the real page
   also renders every single day.

   WAS a generator: bets, bet, win, positive and negative players were all
   `rng()` scaled by a per-user `scale` factor, seeded on (day, user) so the
   same range always showed the same numbers. That stability is what made it
   read as a report rather than as filler — an operator could search twice and
   get the same GGR.

   TWO SOURCES, because no single view carries all seven columns:

     `report_daily_performance`  bet_count, real_stake, real_payout, ggr —
                                 already aggregated per (skin, day, currency).
     `report_player_daily`       one row per player per day, which is the only
                                 way to answer "how many players were up".

   Positive / negative players are counted from the PLAYER rows, not derived
   from the daily totals, because a day's net GGR says nothing about how it
   split across players. Profit is the HOUSE's: real_stake - real_payout, so a
   "positive player" is one the house won from, matching the green/red colouring
   upstream.

   <!-- SUGGESTION: upstream counts one row per player + provider + currency, so
        a player active on three providers counts three times. `report_player_daily`
        groups by (user, day, currency, vertical) — no provider dimension — so a
        multi-provider player counts once per vertical here. Divergence recorded
        rather than faked; add provider_id to that view if the upstream count is
        the one that matters. --> */
const hrdfRowsFor = (perfRows, playerRows, start, end) => {
  const today = hrdfToday();

  /* Fold both feeds down to one bucket per ISO day before walking the calendar,
     so a missing day is a zero row rather than a gap in the table. */
  const perf = {};
  (perfRows || []).forEach(r => {
    const b = perf[r.day] || (perf[r.day] = { bets: 0, bet: 0, win: 0 });
    b.bets += Number(r.bet_count)   || 0;
    b.bet  += Number(r.real_stake)  || 0;
    b.win  += Number(r.real_payout) || 0;
  });

  const pl = {};
  (playerRows || []).forEach(r => {
    const b = pl[r.day] || (pl[r.day] = { pos: 0, neg: 0 });
    const profit = (Number(r.real_stake) || 0) - (Number(r.real_payout) || 0);
    /* Upstream's SQL CASE tests profit <= 0 but an outer `profit != 0` filter
       makes it effectively < 0, so a player who broke exactly even counts as
       neither. Copied, including the gap. */
    if (profit > 0) b.pos++; else if (profit < 0) b.neg++;
  });

  const out = [];
  for (let d = new Date(start), i = 0; d <= end && i < 3660; d.setDate(d.getDate() + 1), i++) {
    const day = new Date(d);
    const iso = hrdfIso(day);
    const p = (day > today ? null : perf[iso]) || { bets: 0, bet: 0, win: 0 };
    const q = (day > today ? null : pl[iso])   || { pos: 0, neg: 0 };
    out.push({ key: iso, date: hrdfDmy(day), bets: p.bets, bet: p.bet, win: p.win,
               ggr: p.bet - p.win, pos: q.pos, neg: q.neg });
  }
  return out;
};

/* ---------- 7 real columns, display order per dailyperformance/index.blade.php:5-11.
   No `sortable` anywhere — the real table never sorts. Money cells are plain
   number_format(x,2) with no currency suffix, mirrored via hrsMoney(n). ---------- */
const HRDF_COLS = [
  { key: "date", label: "Date", width: 110 },
  {
    key: "bets", align: "right", render: r => hrsInt(r.bets),
    label: <>Total bet count<Tip size={12}>SUM(players_report.bet_count) for the day across the selected subtree. Label inferred — <code>backend.total_bet_count</code> resolves in no committed lang file.</Tip></>,
  },
  { key: "bet", label: "Total bet", align: "right", render: r => hrsMoney(r.bet) },
  { key: "win", label: "Total win", align: "right", render: r => hrsMoney(r.win) },
  {
    key: "ggr", align: "right", render: r => hrsMoney(r.ggr), cellClass: r => (r.ggr < 0 ? "hrs-neg" : "hrs-pos"),
    label: <>Total GGR<Tip size={12}>Total bet − Total win; red when negative, green otherwise (real cell classes text-danger / text-success). Label inferred — the real key is <code>backend.total_grr</code> ("GRR", typo preserved in both the table and the XLSX header).</Tip></>,
  },
  {
    key: "pos", align: "right", render: r => hrsInt(r.pos), cellClass: () => "hrs-pos",
    label: <>Positive players<Tip size={12}>players_report rows with profit &gt; 0 that day. One row per player + provider + currency — a player on several providers counts once per row, not once per player. Label inferred (<code>backend.positive_players</code>).</Tip></>,
  },
  {
    key: "neg", align: "right", render: r => hrsInt(r.neg), cellClass: () => "hrs-neg",
    label: <>Negative players<Tip size={12}>players_report rows with profit &lt; 0 (the SQL CASE tests profit &lt;= 0, but an outer profit != 0 filter makes it effectively &lt; 0). Same row-count caveat as Positive players. Label inferred (<code>backend.negative_players</code>).</Tip></>,
  },
];

/* Export columns — same 7 labels as the real XLSX headers A1:G1 */
const HRDF_EXPORT_COLS = [
  { key: "date", label: "Date" },
  { key: "bets", label: "Total bet count" },
  { key: "bet", label: "Total bet", get: r => r.bet.toFixed(2) },
  { key: "win", label: "Total win", get: r => r.win.toFixed(2) },
  { key: "ggr", label: "Total GGR", get: r => r.ggr.toFixed(2) },
  { key: "pos", label: "Positive players" },
  { key: "neg", label: "Negative players" },
];

const HRDF_ROW_STYLE = { display: "flex", alignItems: "center", gap: 8 };
const HRDF_RADIO_STYLE = { accentColor: "var(--p-600)", width: 15, height: 15, margin: 0, flex: "0 0 auto", cursor: "pointer" };

const DailyPerformance = () => {
  window.useLocale && window.useLocale();
  const [draft, setDraft] = hrdfUseState(hrdfInitialDraft);
  const [applied, setApplied] = hrdfUseState(null); // null = not searched yet (no auto-load, dailyperformance.js:60)

  /* The operator picker. WAS `HRDF_USERS`: six invented accounts, each with a
     `seed` and a `scale` that WERE the report — every figure was that scale
     multiplied by that seed's PRNG.

     DECLARED AT THE TOP, and that is not style. In-browser Babel compiles these
     `const`s loosely enough that a reference before the declaration reads as
     `undefined` rather than raising the temporal-dead-zone error it would in a
     real module — so the filter list below crashed with "cannot read properties
     of undefined" and pointed at the render, not at the ordering. */
  const opts = useHrsFetch(() => window.sb.list("networkUsers", { limit: 200 }), []);
  const userOpts = ((opts.data || [])).map(u => ({
    value: String(u.id), label: `${u.username} (${hrdfRoleName(u.user_level)})`, path: String(u.path || ""),
  }));

  const pickMode = (mode) => setDraft(d => ({ ...d, mode }));

  /* The real form is four radio-picked blocks; interacting with an input also
     selects its radio here (evident intent — on the live page you can edit an
     input whose radio isn't checked and silently search something else). */
  const FIELDS = [
    {
      key: "user", label: "User", type: "select", icon: "user", width: 230,
      options: userOpts.map(({ value, label }) => ({ value, label })),
      defaultValue: "admin",
      tip: <>Self + every subtree user above Shop level — Administration, Customer Care, Affiliate accounts and shops/players never appear. Affiliates see this control disabled. The real select2 remote search never initializes (JS targets a form id that doesn't exist), so it behaves as a plain preloaded select — mirrored here.</>,
    },
    {
      key: "period", label: "Period", type: "custom", width: 250, defaultValue: "Today",
      tip: <>Radio-picked quick ranges (range_val 1–6). Only the block whose radio is selected is applied on Search — Period, Month and Custom range are mutually exclusive, exactly like the real form.</>,
      render: (v, set) => (
        <div style={HRDF_ROW_STYLE}>
          <input type="radio" name="hrdf-mode" style={HRDF_RADIO_STYLE} checked={draft.mode === "range"} onChange={() => pickMode("range")} title="Use the quick range" />
          <select className="hrs-fctl" style={{ flex: 1, minWidth: 0 }} value={v == null ? "Today" : v}
            onChange={e => { set(e.target.value); pickMode("range"); }}>
            {HRDF_RANGES.map(r => <option key={r} value={r}>{r}</option>)}
          </select>
        </div>
      ),
    },
    {
      key: "month", label: "Month", type: "custom", width: 250, defaultValue: HRDF_MONTH_OPTS[0].label,
      tip: <>Calendar months, one option per month per year from 2021 (getDateCalendarioMonth()). This is the default-checked block — the controller opens the page on the current month.</>,
      render: (v, set) => (
        <div style={HRDF_ROW_STYLE}>
          <input type="radio" name="hrdf-mode" style={HRDF_RADIO_STYLE} checked={draft.mode === "mese"} onChange={() => pickMode("mese")} title="Use a calendar month" />
          <select className="hrs-fctl" style={{ flex: 1, minWidth: 0 }} value={v == null ? HRDF_MONTH_OPTS[0].label : v}
            onChange={e => { set(e.target.value); pickMode("mese"); }}>
            {HRDF_MONTH_OPTS.map(o => <option key={o.value} value={o.label}>{o.label}</option>)}
          </select>
        </div>
      ),
    },
    {
      key: "custom", label: "Custom range", type: "custom", grow: true, defaultValue: HRDF_CUSTOM_DEFAULT,
      tip: <>Free date pair (prefilled with the current month). The live endpoint mangles these dd/mm/yyyy values through strtotime() — US month/day swap, days &gt; 12 collapse to 1970-01-01; this rebuild applies the dates you actually pick (evident intent — see the file header). Leaving either date empty answers 400 "Seleziona le date!".</>,
      render: (v, set) => {
        const val = v || { from: "", to: "" };
        const pick = (patch) => { set({ ...val, ...patch }); pickMode("custom_range"); };
        return (
          <div style={HRDF_ROW_STYLE}>
            <input type="radio" name="hrdf-mode" style={HRDF_RADIO_STYLE} checked={draft.mode === "custom_range"} onChange={() => pickMode("custom_range")} title="Use a custom range" />
            <input type="date" className="hrs-fctl hrs-fctl--date" value={val.from} onChange={e => pick({ from: e.target.value })} />
            <span className="hrs-rangesep">→</span>
            <input type="date" className="hrs-fctl hrs-fctl--date" value={val.to} onChange={e => pick({ to: e.target.value })} />
          </div>
        );
      },
    },
  ];

  const apply = (v) => {
    const res = hrdfResolve(v);
    if (!res) {
      hrsToast("Seleziona le date!", "400 — the real endpoint answers with this untranslated Italian message when the custom range is missing a date. Pick both dates and search again.");
      return;
    }
    setApplied({ ...v, start: res.start, end: res.end });
  };

  /* The report. Both feeds are scoped to the SAME date range so the two halves
     of a row always describe the same day; the subtree comes from the selected
     operator's path, and with none selected RLS is already the scope. */
  const feed = useHrsFetch(() => {
    if (!applied) return Promise.resolve({ ok: true, data: { perf: [], players: [] }, meta: {}, source: "live" });
    const from = hrdfIso(applied.start), to = hrdfIso(applied.end);
    const sel = userOpts.find(u => u.value === String(applied.user));
    const pf = { from, to };
    const qf = { from, to };
    if (sel && sel.path) qf.subtree = sel.path;
    return Promise.all([
      window.sb.list("reportDailyPerformance", { limit: 5000, filters: pf }),
      window.sb.list("reportPlayers", { limit: 5000, filters: qf }),
    ]).then(([perf, players]) => {
      const bad = [perf, players].find(r => !r.ok);
      if (bad) return bad;
      return { ok: true, meta: {}, source: "live",
               data: { perf: perf.data, players: players.data } };
    });
  }, [applied, opts.data]);

  const rows = hrdfUseMemo(
    () => (applied && feed.data
      ? hrdfRowsFor(feed.data.perf, feed.data.players, applied.start, applied.end)
      : []),
    [applied, feed.data]);

  /* fnExcelReport() serializes the CURRENT form into /excel — the export
     follows the filters as drafted now, not the last searched table. */
  /* fnExcelReport() serializes the CURRENT form into /excel, so upstream's
     export can differ from the table on screen. It cannot here without a second
     round trip for a range nobody has searched — so the export follows the
     searched table, and says so rather than silently exporting something else. */
  const exportRows = rows;

  return (
    <HrsShell
      title="Daily Performance"
      subtitle="One row per calendar day of the searched period — bet volumes, GGR and positive / negative player counts across the selected subtree."
      gate={["support_report", "support_report_dailyperformance"]}
      gateNote={<> Gates bind Customer Care accounts only — every other role passes the <code>!isCustomCare()</code> short-circuit. The whole Report ▾ menu is additionally hidden from Shop/Cashier and Player levels, and both the page and its data endpoint repeat the check server-side (403).</>}
      explainer={{
        bullets: [
          <>One row per calendar day of the searched period; days without activity (including future days of an open month or week) are <b>zero-filled</b>. The table only loads after Search — the real page never auto-loads.</>,
          <>Money columns aggregate <code>business_report</code> bet/win across the chosen user's subtree, each figure multiplied by the owning skin's <code>reports_multiplier</code>; <b>Total GGR = Total bet − Total win</b> (red when negative). No totals row, sorting or pagination exist on the real screen, so none are added.</>,
          <>Positive / Negative players are <b>row counts</b> from <code>players_report</code> — one row per player + provider + currency per day — so a player active on several providers that day is counted once <i>per row</i>, not once per player.</>,
          <>Labels for <code>backend.total_bet_count</code>, <code>backend.total_grr</code>, <code>backend.positive_players</code>, <code>backend.negative_players</code> and the page title itself resolve to raw keys in the repo — the labels shown are inferred.</>,
        ],
      }}
    >
      <HrsFilters
        fields={FIELDS}
        values={draft}
        onChange={(k, v) => setDraft(d => ({ ...d, [k]: v }))}
        onSearch={apply}
        onReset={() => { setDraft(hrdfInitialDraft()); setApplied(null); }}
        resultLabel={applied ? <>{hrsInt(rows.length)} day rows · {hrdfDmy(applied.start)} → {hrdfDmy(applied.end)}</> : "—"}
      />

      {/* This report zero-fills every day in the range, so a failed read renders
          a full table of zeros — the most confident possible way to show
          nothing. Surfaced above it, and the rows withheld until it succeeds. */}
      {opts.error && <HrsError error={opts.error} onRetry={opts.retry} />}
      {feed.error && <HrsError error={feed.error} onRetry={feed.retry} />}
      {applied && feed.loading && <HrsSkeleton rows={8} cols={7} />}

      <HrsTable
        columns={HRDF_COLS}
        rows={feed.loading || feed.error ? [] : rows}
        rowKey="key"
        maxHeight="calc(100vh - 330px)"
        empty={<>Pick a user and a period, then press <b>Search</b> — the real page renders nothing until then (its auto-load call is commented out in dailyperformance.js:60).</>}
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{r.date}</b>
              <span className={r.ggr < 0 ? "hrs-neg" : "hrs-pos"} style={{ padding: "1px 8px", borderRadius: 4 }}>{hrsMoney(r.ggr)}</span>
            </div>
            <div className="hrs-card__grid">
              <span>Total bet count</span><b>{hrsInt(r.bets)}</b>
              <span>Total bet</span><b>{hrsMoney(r.bet)}</b>
              <span>Total win</span><b>{hrsMoney(r.win)}</b>
              <span>Positive players</span><b style={{ color: "var(--ok-700)" }}>{hrsInt(r.pos)}</b>
              <span>Negative players</span><b style={{ color: "var(--err-700)" }}>{hrsInt(r.neg)}</b>
            </div>
          </>
        )}
      />

      {/* Real export: XLSX daily_performance_report.xlsx (headers A1:G1, black
          fill/white bold, autosized, money number_format 2dp) opened in a new
          window — single-phase, and NOT gated by support_export (the /excel
          route carries no permission check of its own; see header SUGGESTION).
          The prototype downloads the same 7 columns as CSV. */}
      <HrsExport
        count={exportRows.length}
        filename="daily_performance_report.csv"
        onCsv={() => hrsCsv(exportRows, HRDF_EXPORT_COLS, "daily_performance_report.csv")}
        note={<>Real platform emits <code>daily_performance_report.xlsx</code> and — like fnExcelReport() — serializes the <b>current form</b>, not the last searched table: this button follows the filters as drafted now.</>}
      />
    </HrsShell>
  );
};

window.DailyPerformance = DailyPerformance;
