diff --git a/app/admin/(panel)/page.tsx b/app/admin/(panel)/page.tsx index db39f94..e01edcc 100644 --- a/app/admin/(panel)/page.tsx +++ b/app/admin/(panel)/page.tsx @@ -1,17 +1,33 @@ import type { Metadata } from "next"; +import { connection } from "next/server"; -import { Welcome } from "@/features/admin-auth/components/welcome"; import { adminMetadata, requireAdmin } from "@/features/admin-auth/require-admin"; +import { DashboardPage } from "@/features/dashboard/components/dashboard-page"; +import { dayText, parseSalesWindow } from "@/features/dashboard/period"; +import { storeToday } from "@/lib/dates"; +import { env } from "@/lib/env"; // Reads the session or search params at the top level; allowed to block until converted -// to Suspense (spec 0005, Caching model). +// to Suspense (spec 0005, Caching model). The figures stream in behind their own Suspense. export const instant = false; export function generateMetadata(): Promise { - return adminMetadata({ title: "Welcome" }); + return adminMetadata({ title: "Dashboard" }); } -export default async function AdminHomePage() { +// spec 0015: the sales dashboard is the panel's landing page. +export default async function AdminDashboardPage({ searchParams }: PageProps<"/admin">) { const admin = await requireAdmin(); - return ; + // "Today" is read per request, never baked into a prerender. + await connection(); + const today = storeToday(new Date(), env.STORE_TIMEZONE); + const window = parseSalesWindow(await searchParams, today, env.STORE_TIMEZONE); + return ( + + ); } diff --git a/docs/scope/scope.md b/docs/scope/scope.md index b644db6..bfac72e 100644 --- a/docs/scope/scope.md +++ b/docs/scope/scope.md @@ -28,7 +28,8 @@ _These are recommendations to keep your build orderly, not requirements. Skip an | 12 | Customer accounts | Slice 6 | done | | 13 | Categories, search & filters | Slice 7 | done | | 14 | Discounts & promo codes | Slice 8 | done | -| 15 | Admin sales dashboard | Slice 9 | planned | +| 15 | Admin sales dashboard | Slice 9 | done | +| 23 | Native controls in GET forms | Slice 9 follow up | done | | 16 | Legal pages & cookie consent | Launch | planned | | 17 | SEO for storefront pages | Launch | planned | | 18 | Analytics & error tracking | Launch | planned | @@ -281,10 +282,32 @@ Spec: [0014](../specs/0014-discounts-promo-codes/index.md) · code in [src/featu ## Slice 9: Insights -### 15. Admin sales dashboard +### 15. Admin sales dashboard · done The admin landing page: revenue, order count, average order value, and best sellers over a chosen period. **Done when:** numbers match the orders in the database for the chosen period, refunds are subtracted, and the page loads fast with a year of orders. -- [ ] Build it: `/develop admin sales dashboard` +Spec: [0015](../specs/0015-admin-sales-dashboard/index.md) · code in [src/features/dashboard/](../../src/features/dashboard/) +- [x] Design it (spec): `/architect admin sales dashboard` +- [x] Build it: `/develop admin sales dashboard` + - [x] Thin thread: store day helper, default period, live totals, the four cards on `/admin`, the Dashboard nav item, skeletons and failure logging (AC-1, AC-2, AC-5 to AC-8, AC-14) + - [x] Period picker and comparison: presets, custom range with clamp and fallback, previous window, change text, currency notice (AC-2 to AC-4, AC-7, AC-10) + - [x] Best sellers and chart: the top 10 table, shadcn Chart with day, week and month buckets, the table toggle (AC-8, AC-9, AC-11, AC-12) + - [x] Orders link and safety net: `dates=paid` on the orders list, the seeded year speed and exactness test, e2e with axe and keyboard (AC-13, AC-15, AC-16) +- [x] Verify it: `/check verify admin sales dashboard` +- [x] Test it: `/test admin sales dashboard` + +## Slice 9 follow ups (from spec 0016) + +### 23. Native controls in GET forms · done +Every filter, period picker and search box uses native controls (`NativeSelect`, native checkbox and radio) and a submit button, so it works before the app's JavaScript loads. The admin orders, discounts and products filters move off the Base UI Select and Checkbox, and the hand rolled selects move to `NativeSelect`. from spec 0016 +**Done when:** no GET form uses a Base UI control, ESLint bans `@/components/ui/select` outside the style guide, and every GET form has a passing e2e test with the app's scripts blocked. +spec [0016](../specs/0016-native-controls-get-forms.md) · code in [src/components/ui/native-select.tsx](../../src/components/ui/native-select.tsx) +- [x] Design it (spec): `/architect native controls in GET forms` +- [x] Build it: `/develop native controls in GET forms` + - [x] `NativeSelect` from the shadcn registry, with its states on `/style-guide` + - [x] Move the orders, discounts, products, dashboard, sort and checkout country controls to native ones, with the e2e steps that open them updated + - [x] `blockAppScripts` helper and one no JavaScript e2e test per GET form, then the ESLint ban +- [x] Verify it: `/check verify native controls in GET forms` +- [x] Test it: `/test native controls in GET forms` ## Launch readiness diff --git a/docs/specs/0015-admin-sales-dashboard/index.md b/docs/specs/0015-admin-sales-dashboard/index.md new file mode 100644 index 0000000..2de08c9 --- /dev/null +++ b/docs/specs/0015-admin-sales-dashboard/index.md @@ -0,0 +1,182 @@ +# 0015. Admin sales dashboard + +**Date**: 2026-10-18 +**Status**: Accepted + +## Summary + +The admin landing page (`/admin`) becomes a sales dashboard. It shows net revenue (what customers paid minus refunds), order count, average order value, net units sold, a net revenue chart and the top 10 products, for a period the admin picks (presets or a custom range, in store days). A refund comes off the period it succeeded in, not the period of its order, so past periods never change. Every number is a live SQL sum over columns and indexes that already exist, with no cache and no new table, and a database test proves the sums are exact and fast with a year of orders. + +## Requirements + +**User stories**: +- As an admin, I want to see what the store earned in a period, after refunds, so that I know how the business is doing without opening Stripe. +- As an admin, I want to compare a period with the one before it, so that I can tell whether sales are going up or down. +- As an admin, I want to see which products sell best, so that I know what to restock and promote. +- As an admin, I want to jump from the order count to the matching orders, so that I can check the numbers myself. + +**Acceptance criteria**: + +*The page and the period* +- **AC-1**: `/admin` (the panel landing page) shows the dashboard: an `h1` "Dashboard", the line "Signed in as ", the period picker with the line "Showing , compared with ." under it (so the previous window of AC-4 is visible, not only implied), four stat cards (Net revenue, Orders, Average order value, Net units sold), the net revenue chart and the best sellers table, in that order. The `Welcome` component is deleted. "Dashboard" is the first sidebar item (`href: "/admin"`), marked `aria-current="page"` only on `/admin` itself, not on its sub pages. The page calls `requireAdmin()` itself; anyone not signed in as an admin gets the existing admin gate answer (spec 0004). +- **AC-2**: The presets are Today, Last 7 days, Last 30 days, This month and Last 12 months, in the URL as `?period=today|7d|30d|month|12m`. With no valid period in the URL, Last 30 days is used. Days are calendar days in `STORE_TIMEZONE`; a period runs from the start of its first day to the start of the day after its last day. Today is today. Last 7 days is today and the 6 days before. Last 30 days is today and the 29 days before. This month runs from the 1st of the current month to today. Last 12 months runs from the 1st of the month 11 months before the current one to today (the current month is month to date). +- **AC-3**: A custom range is `?from=YYYY-MM-DD&to=YYYY-MM-DD`, both store days, inclusive. When both are valid they win over `period`, except when the request also carries `shown=custom`: the picker adds that hidden field on a page showing a custom range, because without JavaScript its date inputs stay filled when a preset is picked, and then a valid `period` wins. A `to` after today is clamped to today. A missing side, a day that does not exist (`parseCalendarDay`), `from` after `to`, `from` after today, or a range longer than 366 days falls back to Last 30 days and shows the notice "That date range can't be used. Showing the last 30 days." The picker is a plain GET form (a preset select, two labelled date inputs and an Apply button) that works without JavaScript. +- **AC-4**: The previous window has the same number of store days as the chosen one and ends on the day before the chosen one starts. This holds for every preset: on Oct 18, This month (18 days) compares with Sep 13 to Sep 30, and Last 12 months compares with the same number of days just before its start, never with "last month to date". Each card shows its change against the previous window's value of the same figure: when the previous value is above 0 and the current figure exists, "Up N%", "Down N%" or "No change", followed by "vs previous days" (N rounded to a whole percent, half away from zero; "No change" when N rounds to 0); otherwise "No earlier figure to compare" with no suffix. The meaning is carried by the words, never by colour alone. + +*The numbers* +- **AC-5**: A counted order is any order whose `paid_at` is inside the window and whose `currency` equals `STORE_CURRENCY`, whatever its status now (`paid`, `shipped`, `delivered`, or `cancelled` after payment). Orders is the count of counted orders. Gross is the sum of their `total_cents` (after discounts, delivery included). +- **AC-6**: A counted refund is a refund with `status = succeeded` whose `succeeded_at` is inside the window and whose order has a `paid_at` (in any period) and `currency = STORE_CURRENCY`. Refunds of orders that were never marked paid (late payments, spec 0010) never count; `pending` and `failed` refunds never count. Refunds is the sum of `amount_cents` of counted refunds. Net revenue is Gross minus Refunds; it can be negative and is then shown with a minus sign. The Net revenue card shows the net as its main figure and the lines "Gross " and "Refunds " under it. +- **AC-7**: Average order value is Gross divided by Orders, rounded to a whole cent (half up). With 0 orders the card shows "No orders" instead of an amount, and its change line is fixed by this rule: "No orders in this period" when the previous window had orders, otherwise "No earlier figure to compare". Neither text gets the "vs previous days" suffix, which only follows a percent (AC-4). +- **AC-8**: Net units sold is the sum of `order_lines.quantity` on counted orders minus the sum of `refund_lines.quantity` on counted refunds. +- **AC-9**: Best sellers group lines by `product_id`, or by the line's `product_name` snapshot when `product_id` is null (product deleted). Per group: units sold (lines of counted orders), units refunded (refund lines of counted refunds), net units (the difference) and sales (the sum of `line_total_cents` of counted orders' lines). A group exists when it has sold lines or refunded lines in the window (or both), so a product only refunded in the window is a group with negative net units. Only groups with net units above 0 are listed: the top 10, sorted by net units descending, then sales descending, then name ascending. Columns: Product (the current `products.name`, linking to `/admin/products/`, when the product still exists, otherwise the `product_name` of its most recent line, with no link), Net units, Sales. Whenever at least one group has net units of 0 or less (sold and fully refunded, or only refunded), the line "Units refunded for products not listed here also come off Net units sold." shows under the table. With no listed group: "No products sold in this period." +- **AC-10**: When orders with `paid_at` inside the window, or succeeded refunds with `succeeded_at` inside the window, belong to orders in a currency other than `STORE_CURRENCY`, the page shows "N orders in another currency are left out of these figures." (singular: "1 order in another currency is left out of these figures."), where N counts distinct such orders from both. Their refunds are left out of every figure too. All money is formatted with `formatMoney` in `STORE_CURRENCY` and `STORE_LOCALE`. + +*The chart* +- **AC-11**: The chart shows net revenue per bucket. A window of up to 31 days has a bucket per day, up to 182 days a bucket per week (weeks start on Monday), and longer a bucket per calendar month. The first and last week or month are clipped to the window. Buckets are in `STORE_TIMEZONE`. A bucket is keyed by its real first day (the Monday, or the 1st of the month, even when that is before the window start) but labelled from its first day inside the window: "Oct 18" for a day, "Week of Oct 15" for a week clipped to start on Oct 15, "Oct 2026" for a month (a clipped month keeps the month label). A bucket's net is the gross of counted orders paid in it minus counted refunds succeeded in it, using the same half open bounds as the window, so the buckets add up to the window's figures exactly. Every bucket of the window is shown, an empty one as 0, and a negative bucket goes below the zero line. The chart has a fixed height (so it renders without a measured parent). Bars use the `--chart-*` tokens from `design.md`, and axis tick text uses a token pair that passes AA contrast; a tooltip shows the bucket label and its net. +- **AC-12**: The chart has the visible heading "Net revenue per day" (or week or month) and the SVG is hidden from screen readers. A "Show as table" button (`aria-expanded`, keyboard reachable, visible focus) shows the same buckets in a `Table` with a caption and the columns Period, Gross, Refunds and Net; pressing it again ("Hide table") hides it. + +*Links into orders* +- **AC-13**: The Orders card links "View these orders" to `/admin/orders?status=all&dates=paid&from=&to=` for the chosen window. `/admin/orders` accepts `dates=created|paid` (default `created`; any other value is ignored like the list's other bad params). With `paid`, `from` and `to` filter `paid_at` by store days instead of `created_at`, and orders without a `paid_at` are left out; `dates=paid` with neither `from` nor `to` only leaves out unpaid orders. `dates` is carried by the list's href builder (`adminOrdersHref`, so paging keeps it) and counted by its "filters are set" check. The orders filter form gains a "Date" select with "Order placed" and "Paid" so the state is visible and can be changed; its date inputs are relabelled from "Placed from" and "Placed to" to "From" and "To", since the select now names which date they filter. When every order in the window is in `STORE_CURRENCY`, the rows of the list, counted across all its pages, equal the dashboard's order count. + +*States, speed and access* +- **AC-14**: While the figures load, the cards, chart and table show skeletons inside `Suspense`. A period with no orders and no refunds shows 0 figures, "No orders", a flat chart and the empty best sellers text. A failed read logs `dashboard.query_failed` with the SQLSTATE (`pgErrorCode`, never the Postgres message) and rethrows, so the existing `app/error.tsx` boundary shows the error page (no new error file). +- **AC-15**: Every figure comes from live queries on each request: no `'use cache'`, no stored totals, no new table or column. The reads run in one `REPEATABLE READ` read only transaction, so every figure on the page comes from the same snapshot. In `pnpm test:db`, with a seeded year of about 20,000 counted orders (with lines) and about 2,000 succeeded refunds (some with lines), every figure for a Last 12 months window (current and previous windows, cards, chart and best sellers) equals the sum the test computes on its own from the seeded rows (strict). After one warm up run, the median of 3 runs of all dashboard reads is within 1,500 ms (the CI budget); 500 ms is the local target, logged by the test but not asserted. +- **AC-16**: The page meets WCAG AA: the picker's controls have labels, everything works by keyboard with visible focus, axe finds no violations with data and with an empty period (chart tick text included, with no axe rule disabled), and on a phone width the cards stack with no horizontal page scroll. + +## Decision + +**Chosen option**: Option 1: Live SQL aggregates per request on existing indexes + +The dashboard reads its figures with a few parameterised raw SQL aggregate queries (Prisma `$queryRaw`) on every request, bucketed in `STORE_TIMEZONE` inside Postgres, with no cache layer and no schema change; the chart uses the shadcn Chart component (Recharts). + +**Implementation skills**: `shadcn` (`shadcn-ui/ui`, `.agents/skills/shadcn/`) · `prisma-client-api` (`prisma/skills`, `.agents/skills/prisma-client-api/`) · `supabase-postgres-best-practices` (`supabase/agent-skills`, `.agents/skills/supabase-postgres-best-practices/`) + +## Rationale + +Reasoning and options: see [rationale.md](rationale.md). + +## Feature design + +**Data model sketch**: + +No new table, column or index. The dashboard reads: + +| Table | Columns read | Index used | +|---|---|---| +| `orders` | `id`, `paid_at`, `currency`, `total_cents` | `orders_paid_at_idx` (`@@index([paidAt])`) | +| `refunds` | `order_id`, `status`, `succeeded_at`, `amount_cents` | `@@index([succeededAt])` | +| `order_lines` | `order_id`, `product_id`, `product_name`, `quantity`, `line_total_cents`, `created_at` | `@@index([orderId])` | +| `refund_lines` | `refund_id`, `order_line_id`, `quantity` | `@@unique([refundId, orderLineId])`, `@@index([orderLineId])` | +| `products` | `id`, `name` | primary key | + +Relationships used: `refunds.order_id → orders.id` (N:1), `order_lines.order_id → orders.id` (N:1), `refund_lines.refund_id → refunds.id` and `refund_lines.order_line_id → order_lines.id` (N:1 each), `order_lines.product_id → products.id` (N:1, nullable). If the AC-15 timing test misses its budget, an index is added then in its own migration (Follow-up), not now. + +**State transitions**: none. The page is read only and changes no state. + +**API surface**: + +| Endpoint | Method | Key inputs | Key outputs | Auth | Key errors | +|---|---|---|---|---|---| +| `/admin` (page, `app/admin/(panel)/page.tsx`) | GET | `period`: `today\|7d\|30d\|month\|12m` (opt), `from`: `YYYY-MM-DD` (opt), `to`: `YYYY-MM-DD` (opt), `shown`: `custom` (opt, AC-3) | the dashboard HTML | `requireAdmin()` (admin with TOTP, spec 0004) | bad params: default window plus notice (AC-3), never an error; a DB failure: the `app/error.tsx` page (AC-14) | +| `getSalesDashboard(window)` (`src/features/dashboard/queries.ts`, server only) | function | `SalesWindow` (current and previous `[start, end)` UTC instants, bucket unit, store day labels) | `SalesDashboard` (both windows' totals, buckets, best sellers, foreign currency count) | caller already passed `requireAdmin()`; calls nothing itself | throws on a DB error after logging (AC-14) | +| `/admin/orders` (existing page) | GET | adds `dates`: `created\|paid` (opt) | the order list filtered by `paid_at` when `paid` | `requireAdmin()` (unchanged) | an invalid `dates` is ignored (AC-13) | + +No server action, route handler, cron or env variable is added. + +**Value sourcing**: + +| Action | Value produced / displayed | Source | +|---|---|---| +| period parse | today (a store day) | new pure `storeToday(now, timeZone)` in `src/lib/dates.ts` (Intl `formatToParts`), fed `new Date()` after `connection()` and `STORE_TIMEZONE` (env); day arithmetic with `Date.UTC` overflow, as `admin-queries.ts` already does | +| period parse | window start and end instants | the store days from `period` or `from`/`to` (URL), turned into UTC with `zonedDayStart` (`src/lib/dates.ts`) | +| period parse | previous window | derived: same day count, ending the day before the start (AC-4) | +| period parse | bucket unit | derived from the window's day count (AC-11); `12m` always month | +| page | admin name | `requireAdmin()` result | +| Orders card | order count | `count(*)` of `orders` with `paid_at` in window, `currency = STORE_CURRENCY` | +| Net revenue card | gross | `sum(orders.total_cents)` of the same rows | +| Net revenue card | refunds | `sum(refunds.amount_cents)`, `status = 'succeeded'`, `succeeded_at` in window, joined order `paid_at IS NOT NULL` and `currency = STORE_CURRENCY` | +| Net revenue card | net | derived: gross minus refunds | +| AOV card | average order value | derived: `Math.round(gross / orders)` in TypeScript, from the two sums | +| Net units card | net units | `sum(order_lines.quantity)` of counted orders minus `sum(refund_lines.quantity)` of counted refunds | +| every card | change text | derived from this window's and the previous window's figure (AC-4), pure function | +| chart | bucket key | `to_char(date_trunc(, ts AT TIME ZONE ${tz}::text), 'YYYY-MM-DD')` in SQL on `paid_at` and `succeeded_at` (text, never a `timestamp` Prisma would read as UTC); every bucket listed by a pure TypeScript generator from the window with the same keys, empty ones filled with 0 | +| chart | bucket label | the bucket's first store day inside the window, formatted with `STORE_LOCALE` (AC-11) | +| every SQL sum or count | number | `bigint` from Postgres, through one `toSafeNumber` helper in `queries.ts` that throws when not a safe integer | +| best sellers | group key and name | `order_lines.product_id`, else `order_lines.product_name`; shown name from `products.name` when the product row exists, else the `product_name` of the line with the latest `created_at` in the group | +| best sellers | net units, sales | sums from AC-9 over `order_lines` and `refund_lines` | +| currency notice | foreign order count | `count(DISTINCT orders.id)` over orders with `paid_at` in window, or with a succeeded refund whose `succeeded_at` is in window, and `currency <> STORE_CURRENCY` | +| money text | currency and locale | `STORE_CURRENCY`, `STORE_LOCALE` (env), through `formatMoney` | +| orders link | `from`, `to` | the window's first and last store day (YYYY-MM-DD) | + +**Key invariants**: +- An order counts toward revenue only through `paid_at`; a refund counts only through `succeeded_at` and only if its order has a `paid_at`. So over all time, net revenue equals the money Stripe kept for orders the store recorded as paid. +- A past window's figures never change unless a refund row's `succeeded_at` lands in it (it is set once, when the refund succeeds). +- Net revenue = gross minus refunds, in each window and in each bucket; the buckets of a window add up to the window's figures exactly. +- Money is summed as integers. Postgres `sum` of `int` returns `bigint`, read as `bigint` and turned to `number` only after checking it is a safe integer (a broken invariant throws). +- Every SQL value is a bound parameter (`$queryRaw` tagged template): instants as `${d}::timestamptz`, the time zone as `${tz}::text`; nothing is concatenated into SQL. The bucket unit (`day`, `week`, `month`) is chosen from a fixed TypeScript union and bound as text too. +- Current and previous windows are read in the same queries (one scan over `[previous start, current end)` with `FILTER` clauses), and all queries run in one `REPEATABLE READ` read only transaction, so every figure and every change comes from one snapshot. + +**Security model**: +- Every signed in admin (spec 0004, TOTP required) may see the dashboard; there are no roles below admin. The page calls `requireAdmin()` itself; the proxy stays only the first gate. +- The page shows totals and product names only. It reads no email, name, address or phone, so the PII purge (spec 0008) and the customer data rules are untouched. +- Logs carry the period, the window's day count and the SQLSTATE, never row data. +- The tables keep RLS on with no policies; Prisma reads them server only, as everywhere else. + +**Configuration required**: none. `STORE_TIMEZONE`, `STORE_CURRENCY` and `STORE_LOCALE` already exist in `src/lib/env.ts`. One new dependency, `recharts`, comes in through `pnpm dlx shadcn@latest add chart`. + +**Recommended implementation choices** (settled here so the build does not invent them): +- **Feature folder**: `src/features/dashboard/` with `period.ts` (pure: params to `SalesWindow`, presets, clamp, previous window, buckets and labels), `change.ts` (pure: the AC-4 text), `queries.ts` (server only SQL), `components/` (`DashboardPage`, `PeriodPicker`, `StatCard`, `RevenueChart` as the only client component, `BestSellersTable`), `log.ts`. The route file stays thin. Runner up: putting it under `orders`, rejected because it also reads catalog and refund data and the orders feature is already large. +- **Queries**: four raw SQL queries inside one `db.$transaction(..., { isolationLevel: "RepeatableRead" })`, run one after another (a transaction holds one connection): totals plus buckets for orders, totals plus buckets for refunds, best sellers (sold groups and refunded groups as two CTEs joined with `FULL OUTER JOIN` on the group key, then `LIMIT 10` after `WHERE net > 0`, plus a count of groups with net 0 or less and the overall unit sums), and the count of orders in another currency (AC-10). Runner up: Prisma `groupBy`, rejected because it cannot bucket by a time zone or join refund lines to products. +- **Timing guard**: a `dashboard.slow` warning log when the reads take over 1,000 ms in total, with the period and duration, so a slow page shows up in logs before an admin complains. Runner up: no timing log. +- **Rendering**: the page reads `searchParams` and the session, so the dashboard body sits in `Suspense` with skeletons (AC-14). `RevenueChart` gets plain serialisable bucket data as props. +- **Change rounding**: `Math.sign(x) * Math.round(Math.abs(x))` on the percent, so 0.5% reads "Up 1%" and minus 0.5% reads "Down 1%". + +**Critical test scenarios**: +- Happy path: seed paid orders and refunds across two months, open `/admin?period=month`, the four cards, chart buckets and top 10 equal the sums the test computes, verifies **AC-1**, **AC-5**, **AC-6**, **AC-8**, **AC-9**, **AC-11** +- Refund timing: an order paid last month refunded this month lowers this month's net and leaves last month's unchanged; a refund of a late payment (no `paid_at`) and `pending` or `failed` refunds count nowhere, verifies **AC-6** +- Day edges: an order paid at 23:59 and one at 00:00 store time land on different days, including across a daylight saving change in `Europe/Warsaw`, verifies **AC-2**, **AC-11** +- Bad params: `from` after `to`, a 400 day range, `2026-02-30`, a future `from` each show the notice and the 30 day window; a future `to` is clamped, verifies **AC-3** +- Change text: previous 0, previous above 0 with up, down and rounding to 0, net negative previous, verifies **AC-4**, **AC-7** +- Currency: an order in another currency is left out of every figure and named in the notice, verifies **AC-10** +- Orders link: the link's list count equals the card count, `dates=paid` drops orders without `paid_at`, verifies **AC-13** +- Speed: the seeded year gives exact sums, and the median of 3 warm runs is within 1,500 ms, verifies **AC-15** +- Buckets add up: for day, week (clipped) and month (clipped) windows, the buckets' gross, refunds and net sum to the window's figures, verifies **AC-11** +- Empty and failure: an empty period shows zeros and empty texts; a failing query logs `dashboard.query_failed` and reaches the error page, verifies **AC-14** +- Auth/permission: signed out, or signed in without TOTP, `/admin` gets the admin gate answer, not the figures, verifies **AC-1** +- Access: axe clean with data and empty, the table toggle works by keyboard, no horizontal scroll at phone width, verifies **AC-12**, **AC-16** + +## Build plan + +Tracer Bullet: a thin thread through page, period parsing, SQL and the cards first, then each part thickened end to end. + +1. Thin thread: `storeToday` in `src/lib/dates.ts`, `src/features/dashboard/period.ts` with the presets and the default window (pure, unit tested), `queries.ts` with the orders and refunds totals for the current window, `DashboardPage` with the four cards (net with gross and refunds, orders, AOV, and net units from the totals), `requireAdmin()` on `/admin`, the "Dashboard" sidebar item, the `Welcome` component deleted, skeletons in `Suspense`, `dashboard.query_failed` logging, a db test of the exact sums, an e2e smoke test, satisfies **AC-1**, **AC-2**, **AC-5**, **AC-6**, **AC-7**, **AC-8**, **AC-14** +2. Period picker and comparison: the GET form, custom `from`/`to` with clamp, fallback and notice, the previous window read in the same queries with `FILTER`, `change.ts`, the change line on each card, the foreign currency count and notice, unit and db tests (day edges and daylight saving included), satisfies **AC-2**, **AC-3**, **AC-4**, **AC-7**, **AC-10** +3. Best sellers: the grouped query with the product name rule, the top 10 table, the hidden rows note and the empty text, db tests (deleted product, refunded more than sold), satisfies **AC-8**, **AC-9** +4. Chart: `pnpm dlx shadcn@latest add chart` (adds `recharts`), bucket SQL, the pure bucket generator and labels, `RevenueChart` with the `--chart-*` tokens, the "Show as table" toggle and its table, unit and db tests for buckets, satisfies **AC-11**, **AC-12** +5. Orders link: `dates=created|paid` in `src/features/orders/list-params.ts` and `admin-queries.ts`, the "Date" select in the orders filter form, the "View these orders" link, tests in the orders db and unit suites, satisfies **AC-13** +6. Safety net: the seeded year speed test with exact sums in `tests/db/`, the `dashboard.slow` log, e2e with axe (data and empty), keyboard on the picker and toggle, phone width, satisfies **AC-14**, **AC-15**, **AC-16** + +## Consequences + +**Positive**: +- Every figure is exact and fresh on every load, with nothing to expire when an order is paid or refunded, so no money path has to remember the dashboard. +- No schema change and no new job, so nothing new to migrate, run or watch. +- Past periods are stable (refunds on their own date), which matches Stripe payouts and makes month to month comparison honest. + +**Negative / tradeoffs**: +- Net revenue for a short period can go negative after a large refund of an older order; the admin has to read it as cash flow, not as the value of that period's orders. +- Average order value uses gross, so it does not match net revenue divided by orders; the card label must stay "Average order value" with no "net". +- Best sellers cannot rank by net money, because refunds are free amounts not split per line; the table ranks by net units and shows gross line sales. +- Each load runs a few aggregate scans; if the store grows far beyond a year of tens of thousands of orders, the queries may need an index or a rollup table (the AC-15 test is the alarm). +- A new client dependency (`recharts`) on the admin bundle only. +- A cancelled paid order keeps counting as revenue until its cancel refund succeeds (the cancel action reserves a refund for everything left, spec 0010); while that refund is pending, net is higher than the money the store will keep. + +**Neutral**: +- `/admin/orders` gains a `dates` param and a "Date" select, a small change in the orders feature. +- The `--chart-1` to `--chart-5` tokens reserved in `design.md` for this feature come into use. + +## Follow-up + +- [ ] If the AC-15 timing test misses its budget, add an index (for example `orders (paid_at) INCLUDE (total_cents, currency)`) in its own migration, measured before and after. +- [ ] Record `recharts` (through the shadcn chart component) in root `AGENTS.md` `## Stack` key dependencies and the dashboard feature in the context files list (`/sync`). +- [ ] Agent Skills for Recharts: none installed. The official `recharts/recharts` skills target Recharts contributors (`guide` writes documentation articles, so it was removed after a trial install), and no Recharts MCP server is worth adding. The build uses the `shadcn` skill plus the Recharts docs through the connected Context7 server. `/sync` may record the decline in `AGENTS.md` so it is not offered again. diff --git a/docs/specs/0015-admin-sales-dashboard/rationale.md b/docs/specs/0015-admin-sales-dashboard/rationale.md new file mode 100644 index 0000000..6c77a3b --- /dev/null +++ b/docs/specs/0015-admin-sales-dashboard/rationale.md @@ -0,0 +1,64 @@ +# 0015. Admin sales dashboard: rationale + +## Context + +The admin panel lands on a placeholder `Welcome` page. Admins have no way to see how the store is doing without opening the Stripe dashboard, and Stripe cannot tell them which products sell or how a period compares with the one before. Scope feature 15 asks for revenue, order count, average order value and best sellers over a chosen period, with three hard conditions: the numbers match the orders in the database, refunds are subtracted, and the page loads fast with a year of orders. + +The money already lives in the database in a shape that can be summed exactly: orders carry integer `total_cents`, `paid_at` and `currency`; refunds carry `amount_cents`, `status` and `succeeded_at`; order lines and refund lines carry quantities. `orders.refunded_cents` is a running total, not dated, so it cannot say when money went back. Refunds are free amounts an admin types (spec 0010), with refund lines recording only units, so money cannot be split back onto products exactly. A late payment on an expired or cancelled order is money in with no `paid_at` (spec 0010, `flagLatePayment`), and its refund is money out. + +Day boundaries matter. The store shows dates in `STORE_TIMEZONE` and stores UTC; an order at 23:30 store time must land on that store day, also across daylight saving changes. The orders list already turns store days into UTC instants with `zonedDayStart`, but filters by `created_at`, while a sale happens at `paid_at`. + +The store is a single brand shop with a few admins. A busy year is thousands to low tens of thousands of orders. Cache Components is on, and every cached read in this app must be expired by whatever changes it; the money paths (webhook, reconcile cron, refund action, refund sync, system refunds) are already numerous and carefully guarded. + +## Options considered + +### Option 1: Live SQL aggregates per request on existing indexes + +Each page load runs a few parameterised aggregate queries (`$queryRaw`) over `[previous start, current end)`, using `FILTER` clauses for the two windows and `date_trunc` in the store's time zone for chart buckets, on the existing `orders.paid_at` and `refunds.succeeded_at` indexes. Nothing is cached or stored. + +**Pros**: +- Always exact and fresh; "numbers match the database" holds by construction. +- No new table, migration, cron or cache tag; no money path has to change. +- Time zone bucketing happens in one place (Postgres), with bound parameters. + +**Cons**: +- Every load scans the window's rows; cost grows with order volume. +- Raw SQL is less type safe than Prisma's query builder and needs its own result parsing (bigint sums). + +### Option 2: `'use cache'` reads with a sales tag + +The same queries wrapped in cached functions, tagged with a new sales tag that every paid order and every refund state change expires. + +**Pros**: +- Repeat loads are near instant. +- Fits the app's existing Cache Components pattern for catalog reads. + +**Cons**: +- Five separate money paths must remember to expire the tag; a miss shows stale money, the one thing this page must never do. +- "Today" and rolling windows depend on the current time, so cache keys need the store day, adding subtle staleness at midnight. +- Solves a speed problem that has not been measured. + +### Option 3: Daily rollup table + +A table of per day totals (and per day per product units), kept by a trigger or by the reconcile cron, read by the dashboard. + +**Pros**: +- Reads stay fast at any scale. +- Chart buckets become simple sums over at most 366 rows. + +**Cons**: +- A new table, migration, RLS setup and a second source of truth that must be kept exactly in step with orders and refunds, or the numbers stop matching. +- Today's figures need either a live query anyway or a trigger on every money write. +- Far more machinery than a store of this size needs. + +### Chart: shadcn Chart (Recharts) against hand built SVG + +The shadcn Chart component wraps Recharts and themes it from the design tokens, with tooltip support; it adds one dependency to the admin bundle. Hand built SVG bars add no dependency and render on the server, but mean writing axes, labels, tooltips, negative values and dark mode by hand. The engineer chose shadcn Chart; accessibility is kept in our hands with a hidden SVG plus a "Show as table" toggle rather than relying on Recharts' own accessibility layer. + +## Rationale + +Option 1 wins because the scope's first condition is exactness, and only a live read is exact by construction. Options 2 and 3 both create a second copy of money figures that every payment and refund path has to keep in step; this app already has five such paths with careful locking, and adding a cache expiry or a rollup write to each of them is exactly the kind of quiet coupling that produces a wrong dashboard months later. The third condition, speed with a year of orders, is real but not yet a problem: Postgres sums tens of thousands of indexed integer rows in milliseconds. So instead of guessing, the spec makes speed a test (AC-15, exact sums on a seeded year within a CI time budget) and leaves an index as the measured next step if it fails. Option 2 is the runner up, and the right move only if that test or production logs (`dashboard.slow`) show a problem. + +The money definitions follow from what the data can say honestly. Refunds come off the day they succeeded (cash basis) because a refund row has its own date while `refunded_cents` does not, and because a past period that keeps changing breaks comparison. Orders count by `paid_at` whatever their status now, so a cancelled paid order contributes its payment and its refund like any other, and money stays true over all time. Refunds of orders with no `paid_at` are left out so a late payment and its refund cancel off the books instead of pushing net below the real money. Average order value uses gross, not net, so a refund of an old order cannot move this period's AOV. Best sellers rank by net units, the one per product figure refunds can be subtracted from exactly, since refund money is not split per line. + +The orders list gains a paid date filter rather than the dashboard linking with created dates, because the scope promises the numbers match the orders, and the link is how an admin checks that. Filtering to `STORE_CURRENCY` with a visible notice, instead of summing every row, means a future currency change cannot silently add unlike money. diff --git a/docs/specs/0015-admin-sales-dashboard/verify.md b/docs/specs/0015-admin-sales-dashboard/verify.md new file mode 100644 index 0000000..83a684f --- /dev/null +++ b/docs/specs/0015-admin-sales-dashboard/verify.md @@ -0,0 +1,44 @@ +# Verify: Admin sales dashboard · spec 0015 · updated 2026-10-19 +_Steps derived from spec 0015 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._ + +## UI / manual +- [x] Sign in as an admin with TOTP and open `/admin` → you see the `h1` "Dashboard", "Signed in as ", the picker, four cards (Net revenue, Orders, Average order value, Net units sold), the chart and Best sellers, in that order → AC-1 +- [x] Look at the sidebar on `/admin`, then on `/admin/orders` → "Dashboard" is the first item; it has `aria-current="page"` only on `/admin` → AC-1 +- [x] Open `/admin` signed out, and signed in as a non admin → the admin gate answers (sign in redirect, or the store 404), never the figures → AC-1 +- [x] Open `/admin` with no params, then `?period=bogus` → Last 30 days (today and the 29 days before, in `STORE_TIMEZONE`) → AC-2 +- [x] Try each preset (`today`, `7d`, `30d`, `month`, `12m`) → the "Showing … compared with …" line gives the right store days; `12m` starts on the 1st of the month 11 months back → AC-2 +- [x] Open `?from=2026-08-01&to=2026-08-31&period=7d` → the custom range wins, and the select shows "Custom range" → AC-3 +- [x] Open `?from=&to=` → the end is clamped to today, with no notice → AC-3 +- [x] Open `?from=2026-02-30&to=2026-03-05`, `?from=2026-10-10&to=2026-10-01`, `?from=&to=`, `?from=2024-01-01&to=2026-01-01`, `?from=2026-10-01` → each shows "That date range can't be used. Showing the last 30 days." and the 30 day window → AC-3 +- [x] Turn JavaScript off and submit the picker → it still works as a GET form → AC-3 +- [x] Pick "This month" → the comparison line names the same number of days ending the day before the 1st (on Oct 18: Sep 13 to Sep 30) → AC-4 +- [x] Compare a period whose previous window had sales → "Up N%", "Down N%" or "No change" plus "vs previous days"; one with no earlier sales → "No earlier figure to compare" with no suffix → AC-4 +- [x] Check an order paid in the window that is now `cancelled` → it still counts in Orders and Gross → AC-5 +- [x] Refund (succeeded) an order paid last month → this month's Net and Refunds drop; last month's figures don't change → AC-6 +- [x] Choose a period with no orders → Average order value shows "No orders", and its line says "No orders in this period" when the previous window had orders → AC-7 +- [x] Refund one unit with a line → Net units sold drops by 1 → AC-8 +- [x] Best sellers: a live product links to `/admin/products/` with its current name; a deleted product shows its line name with no link; a product only refunded in the window is left out, and the "Units refunded for products not listed here…" line shows → AC-9 +- [x] Pay an order in another currency in the window → "1 order in another currency is left out of these figures." and no figure moves → AC-10 +- [x] Check the chart for 7 days, about 90 days and 12 months → bars per day, per week ("Week of …", clipped to the window) and per month ("Oct 2026"); a day with refunds over sales goes below zero → AC-11 +- [x] Press "Show as table" by keyboard → `aria-expanded` flips, the table (Period, Gross, Refunds, Net, with a caption) shows the same buckets, and "Hide table" hides it; the chart SVG is hidden from screen readers → AC-12 +- [x] Click "View these orders" → `/admin/orders?status=all&dates=paid&from=…&to=…`, the Date select shows "Paid", and the list's rows (across pages) equal the Orders card → AC-13 +- [x] On `/admin/orders`, set Date to "Paid" with no From or To → only unpaid orders drop out → AC-13 +- [x] Throttle the network → skeletons show for the cards, chart and table while the figures load → AC-14 +- [x] At 375px width → the cards stack and the page has no horizontal scroll → AC-16 + +## Value sourcing +- [x] Today: open `/admin?period=today` just after midnight in `STORE_TIMEZONE` (or with the server in another zone) → the window is the store's day, not UTC's → AC-2 +- [x] Day edges: an order paid at 23:59:59 and one at 00:00 store time, including on a daylight saving change day in `Europe/Warsaw` → they land on different chart days → AC-2, AC-11 +- [x] Currency: change `STORE_CURRENCY` locally → orders in the old currency move into the notice and out of every figure → AC-10 +- [x] Locale: set `STORE_LOCALE=pl-PL` → money, counts and chart labels use Polish formats → AC-10, AC-11 +- [x] Orders link `from`/`to`: they equal the window's first and last store day → AC-13 +- [x] Average order value: gross 1001 cents over 2 orders shows 5.01 (half up) → AC-7 + +## Commands +- [x] `pnpm test` → `period.test.ts`, `change.test.ts`, `dates.test.ts`, `list-params.test.ts` pass → AC-2 to AC-4, AC-7, AC-11, AC-13 +- [x] `pnpm test:db` → `dashboard.db.test.ts` (exact sums, refund timing, currency, buckets add up, best sellers, orders link, failure logging) and `dashboard-year.db.test.ts` (a seeded year: exact figures, median of 3 warm runs within 1,500 ms, 144 ms measured locally) pass → AC-5 to AC-11, AC-13 to AC-15 +- [x] `pnpm test:e2e tests/e2e/admin/dashboard.spec.ts` → figures, table toggle, orders link, empty period, bad range notice, keyboard, nav and phone width pass with axe clean on both projects → AC-1, AC-3, AC-12, AC-13, AC-16 +- [x] Read the log of a forced DB failure → `dashboard.query_failed` with `pgCode` (the SQLSTATE) and no Postgres message; the `app/error.tsx` page shows → AC-14 + +## Acceptance-criteria coverage +- AC-1 … nav and page steps, e2e · AC-2 … presets, today, day edges · AC-3 … custom range and notice steps · AC-4 … comparison steps · AC-5 … cancelled order step · AC-6 … refund timing step · AC-7 … no orders, half up · AC-8 … refunded unit step · AC-9 … best sellers step · AC-10 … currency steps · AC-11 … chart and day edge steps · AC-12 … table toggle step · AC-13 … orders link steps · AC-14 … skeleton and failure log steps · AC-15 … seeded year command · AC-16 … phone width step, e2e axe diff --git a/docs/specs/0016-native-controls-get-forms.md b/docs/specs/0016-native-controls-get-forms.md new file mode 100644 index 0000000..1d3cf2c --- /dev/null +++ b/docs/specs/0016-native-controls-get-forms.md @@ -0,0 +1,166 @@ +# 0016. Native controls in GET forms + +**Date**: 2026-10-19 +**Status**: Accepted + +## Summary + +Every GET form (a form that only changes the page URL, such as a filter, a period picker or a search box) uses native browser controls: `NativeSelect` for a select, `` and `` for the rest, and a real submit button. That way the form works before the app's JavaScript loads, and still works if it fails to load. The Base UI Select is banned by a lint rule, because it is a button that only opens with JavaScript. The three admin filters and the hand rolled native selects move over in one change, and each GET form gets one e2e test with the app's scripts blocked. + +## Context + +Spec 0003 made the Base UI flavour of shadcn the component set, with `Select`, `Checkbox` and `RadioGroup` among the core components. These are buttons and spans with ARIA roles. The real form value lives in a hidden input that only their JavaScript can change. In a POST form built with React Hook Form that is fine, because the form needs JavaScript anyway. In a GET form it is not. Until the bundles load (a slow phone network, a cold start) or if they fail, the control is dead. The form still submits, but always with the value the page was rendered with. + +This already caused a shipped bug. The dashboard's period picker (spec 0015, AC-3, "works without JavaScript") used the Base UI Select. Without JavaScript the preset could not be changed, and Apply sent the same period again. It was fixed on 2026-10-19 with a native select. The storefront had met the same problem earlier and solved it locally: spec 0013's sort is a native select, with a comment saying the Base UI Select needs JavaScript. Nothing written down carried that lesson to the next form. + +Today the codebase has two competing patterns in GET forms: +- **Base UI controls**: the orders list filters (`orders-filters.tsx`: Status, Refund and Date selects, plus the "Needs attention only" Checkbox), the discount codes Status filter (`status-filter.tsx`) and the products Category filter (`category-filter.tsx`). All four are dead before hydration (the moment React takes over the server HTML). +- **Hand rolled native controls**: the storefront sort (`sort-select.tsx`), the dashboard period picker (`period-picker.tsx`), the storefront filter panel checkboxes (`filter-panel.tsx`), and the checkout saved addresses select (`checkout-form.tsx`, a POST form; the country is shown as text, not a select). Each one styles its own `` with the classes the storefront filter panel already uses is simpler than a wrapper. + +## Standard definition + +**What counts as a GET form**: a `
` with no `method`, or `method="get"`, whose submit only changes the URL query (filters, period pickers, search boxes). POST forms (server actions, React Hook Form editors, sign in) are outside this standard. + +**Canonical pattern**: +```tsx +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; + +// spec 0016: a GET form works before the app's JavaScript loads, so its controls are native. + +
+ + + {statusItems.map((item) => ( + + {item.label} + + ))} + +
+
+ + +
+ +
+``` + +Rules the pattern carries: +- **Select**: `NativeSelect` with `NativeSelectOption`, always with a `name` and a `