Skip to content

Commit 5e46c48

Browse files
committed
Merge feature/wow-dashboard: hero + insights dashboard redesign
2 parents 9c7be2a + 10ab25a commit 5e46c48

19 files changed

Lines changed: 1026 additions & 110 deletions
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# "Wow" dashboard redesign — Design
2+
3+
Date: 2026-05-25
4+
Status: Approved for implementation
5+
6+
Redesign the logged-in dashboard (`/dashboard`) around three pillars: a hero with your profile link + live preview, beautiful insights, and premium polish. Two tracks (Go backend, web frontend), disjoint files → parallelizable.
7+
8+
## Contract (normative)
9+
New authed endpoint: `GET /api/v1/insights``{ "daily": DailyStat[] }`
10+
- `DailyStat = { "date": "YYYY-MM-DD", "views": number, "clicks": number }`
11+
- Exactly 14 entries, ascending by date, ending today (`today-13 … today`), **zero-filled** for days with no activity. Scope: all carts owned by the viewer.
12+
13+
Everything else the dashboard needs (per-cart `viewsLast7d/clicksLast7d/reachLast7d`, totals, top cart) is already in `listMyCarts()` — computed on the frontend. The daily series is the only new data.
14+
15+
---
16+
17+
## Workstream A — Backend (Go)
18+
19+
### sqlc queries (append to `internal/db/queries.sql`, regen with `"$(go env GOPATH)/bin/sqlc" generate`)
20+
```sql
21+
-- name: AccountDailyViews :many
22+
SELECT cv.day::date AS day, COALESCE(SUM(cv.views), 0)::bigint AS views
23+
FROM cart_views_daily cv
24+
JOIN carts c ON c.id = cv.cart_id
25+
WHERE c.user_id = $1 AND cv.day >= current_date - 13
26+
GROUP BY cv.day
27+
ORDER BY cv.day;
28+
29+
-- name: AccountDailyClicks :many
30+
SELECT cd.day::date AS day, COALESCE(SUM(cd.clicks), 0)::bigint AS clicks
31+
FROM click_daily cd
32+
JOIN links l ON l.id = cd.link_id
33+
JOIN carts c ON c.id = l.cart_id
34+
WHERE c.user_id = $1 AND cd.day >= current_date - 13
35+
GROUP BY cd.day
36+
ORDER BY cd.day;
37+
```
38+
(`links.cart_id` is nullable; the JOIN naturally drops non-cart links.)
39+
40+
### Service (`internal/carts/service.go`)
41+
- `AccountDailyStats(ctx, userID int64) ([]DailyStat, error)`: run both queries, merge by date into a **14-day, ascending, zero-filled** slice covering `today-13 … today` (build the date skeleton in Go using `time.Now().UTC()`, then fill from the query rows keyed by date). `DailyStat` is a small struct `{ Date time.Time / string, Views int64, Clicks int64 }`.
42+
43+
### Marshal (`internal/carts/marshal.go`)
44+
- `DailyStatJSON { Date string `json:"date"`; Views int `json:"views"`; Clicks int `json:"clicks"` }` with `Date` formatted `"2006-01-02"`. A `MarshalDailyStats([]DailyStat) []DailyStatJSON` helper.
45+
46+
### Handler (`internal/carts/handlers.go`)
47+
- Add `r.Get("/insights", getInsights(svc))` inside `RegisterRoutes` (already under `/api/v1` + `RequireUser`).
48+
- `getInsights`: read `uid` from context, call `AccountDailyStats`, write `{"daily": MarshalDailyStats(...)}` (200). On error → 500.
49+
50+
### Tests
51+
- `internal/carts/service_test.go` or `handlers_test.go`: seed `cart_views_daily` + `click_daily` across several days (incl. a gap day) for the user's cart(s); assert the series is length 14, ascending, zero-filled on the gap, correct sums, and excludes another user's carts. Handler test: `GET /api/v1/insights` returns a 14-element `daily` array.
52+
53+
### Files (A) — no web/
54+
`internal/db/queries.sql`, `internal/db/sqlc/*` (regen), `internal/carts/service.go`, `internal/carts/marshal.go`, `internal/carts/handlers.go`, `internal/carts/{service,handlers}_test.go`.
55+
56+
---
57+
58+
## Workstream B — Frontend (web)
59+
60+
### Types + client
61+
- `web/lib/types.ts`: `export interface DailyStat { date: string; views: number; clicks: number }`.
62+
- `web/lib/api-client.ts`: `getInsights(opts?: AuthOpts): Promise<DailyStat[]>``GET /api/v1/insights`, returns the `daily` array (`[]` on absence).
63+
- `web/lib/insights.ts` (pure, tested): `summarizeDaily(daily: DailyStat[])``{ viewsThisWeek, viewsPrevWeek, viewsDeltaPct, clicksThisWeek, clicksPrevWeek, clicksDeltaPct }` (last 7 vs the prior 7; delta `null`/0-safe when prev week is 0). Unit-tested (node-env vitest).
64+
65+
### Components
66+
- `web/components/sparkline.tsx` — pure inline **SVG** sparkline from `values: number[]` (props: values, width, height, className for stroke). No chart library. Renders nothing/flat baseline for all-zero input.
67+
- `web/components/dashboard-hero.tsx` (`"use client"`) — props `{ user }`. Left: greeting (avatar + "Hi {firstName}" + warm line) and the **profile link** ``{origin}/u/{handle}`` with **Copy** (reuse `lib/clipboard.ts`) + **Share** (reuse `share-sheet.tsx` or `navigator.share`). Right (lg+): a **live preview** — reuse `web/components/phone-frame.tsx` wrapping an `<iframe src={`/u/${handle}`} loading="lazy">` scaled to fit. Below `lg`: hide the phone, show a "View my profile →" link to `/u/{handle}`. Warm accent gradient background. Guard: if `handle` is empty, show the greeting without the link/preview.
68+
- `web/components/insight-summary.tsx` — headline **views this week** via `AnimatedNumber`, a ▲/▼ **delta** chip vs last week (green up / muted down, from `summarizeDaily`), the `Sparkline` (14-day views), and a compact chip row: clicks (7d), reach (7d, summed from carts), carts count, products count. Server or client component (no interactivity needed beyond AnimatedNumber).
69+
- `web/components/top-cart-card.tsx` — featured "⭐ Top cart" = the cart with the highest `viewsLast7d` (passed in). Larger cover, title, its views/clicks. Links to `/dashboard/carts/{id}`. Hidden if no carts have any views.
70+
71+
### Page (`web/app/dashboard/page.tsx`)
72+
- Fetch `user`, `carts`, and `getInsights()` in parallel (forward cookie; insights failure → `[]`, never break the page).
73+
- Layout (has carts): `<DashboardHero user={user} />``<InsightSummary daily={...} carts={...} />` + `<TopCartCard .../>` → header row ("Your carts" + Add/New cart) → cart grid (reuse `CartCard`, wrap in `RevealOnScroll` for entrance). Keep `InstallNudge`.
74+
- Empty state: keep `FirstTimeOnboarding` (a slimmed hero greeting is fine; skip insights/top-cart when there are no carts).
75+
- Premium polish: warm gradient in hero, `RevealOnScroll` entrance on grid, existing hover-lift on cards, refined serif hierarchy. Keep within the existing theme tokens (ink/cream/paper/accent/muted/rule).
76+
77+
### Mobile
78+
Hero stacks (phone preview hidden → "View my profile" link); insight chips wrap; sparkline is responsive width; grid stays `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`; ≥44px tap targets; keep `pb-24 sm:pb-10`.
79+
80+
### Tests
81+
- `web/lib/insights.test.ts` (summarizeDaily: week split, delta %, zero-prev-week safety, short arrays).
82+
- `web/lib/api-client.test.ts`: `getInsights` builds `/api/v1/insights` (GET).
83+
(Sparkline/hero are visual; not unit-tested — keep logic in the tested pure helper.)
84+
85+
### Files (B) — no Go
86+
Create: `web/lib/insights.ts`, `web/lib/insights.test.ts`, `web/components/sparkline.tsx`, `web/components/dashboard-hero.tsx`, `web/components/insight-summary.tsx`, `web/components/top-cart-card.tsx`.
87+
Modify: `web/lib/types.ts`, `web/lib/api-client.ts`, `web/app/dashboard/page.tsx`.
88+
Reuse: `phone-frame.tsx`, `lib/clipboard.ts`, `share-sheet.tsx`, `animated-number.tsx`, `cart-card.tsx`, `reveal-on-scroll.tsx`, `install-nudge.tsx`.
89+
90+
---
91+
92+
## Verification
93+
- Backend: `go build ./...`, `go vet ./...`, `go test ./internal/carts/... -race -count=1`; golangci-lint if present.
94+
- Frontend: `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm test`. (Integrator runs the authoritative `pnpm build` + a logged-in visual check.)
95+
96+
## Out of scope
97+
Per-cart trend charts; configurable date ranges (fixed 14-day); earnings/revenue; real-time updates.

internal/carts/handlers.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
// RegisterRoutes mounts /carts/*, /me, /og-fetch under the parent router.
1616
func RegisterRoutes(r chi.Router, svc *Service, fetcher *ogfetch.Fetcher) {
1717
r.Get("/me", getMe(svc))
18+
r.Get("/insights", getInsights(svc))
1819
r.Get("/cover-images", listCoverImages(svc))
1920
r.Get("/carts", listCarts(svc))
2021
r.Post("/carts", createCart(svc))
@@ -40,6 +41,18 @@ func getMe(svc *Service) http.HandlerFunc {
4041
}
4142
}
4243

44+
func getInsights(svc *Service) http.HandlerFunc {
45+
return func(w http.ResponseWriter, r *http.Request) {
46+
uid, _ := auth.UserIDFromContext(r.Context())
47+
stats, err := svc.AccountDailyStats(r.Context(), uid)
48+
if err != nil {
49+
http.Error(w, err.Error(), http.StatusInternalServerError)
50+
return
51+
}
52+
writeJSON(w, http.StatusOK, map[string][]DailyStatJSON{"daily": MarshalDailyStats(stats)})
53+
}
54+
}
55+
4356
func listCoverImages(svc *Service) http.HandlerFunc {
4457
return func(w http.ResponseWriter, r *http.Request) {
4558
uid, _ := auth.UserIDFromContext(r.Context())

internal/carts/handlers_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,30 @@ func TestGETMe_ReturnsUser(t *testing.T) {
102102
assert.Equal(t, "Test User", u["displayName"])
103103
}
104104

105+
func TestGETInsights_Returns14DayDaily(t *testing.T) {
106+
r, _ := setupHandlers(t)
107+
req := httptest.NewRequest(http.MethodGet, "/api/v1/insights", nil)
108+
rr := httptest.NewRecorder()
109+
r.ServeHTTP(rr, req)
110+
assert.Equal(t, http.StatusOK, rr.Code)
111+
112+
var out struct {
113+
Daily []struct {
114+
Date string `json:"date"`
115+
Views int `json:"views"`
116+
Clicks int `json:"clicks"`
117+
} `json:"daily"`
118+
}
119+
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &out))
120+
require.Len(t, out.Daily, 14)
121+
// Ascending by date, zero-filled (no activity seeded).
122+
for i := 1; i < len(out.Daily); i++ {
123+
assert.Less(t, out.Daily[i-1].Date, out.Daily[i].Date)
124+
}
125+
assert.Equal(t, 0, out.Daily[0].Views)
126+
assert.Equal(t, 0, out.Daily[0].Clicks)
127+
}
128+
105129
func TestPOSTCartItems_AddsExplicitProduct(t *testing.T) {
106130
r, _ := setupHandlers(t)
107131
// create cart

internal/carts/marshal.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,27 @@ func MarshalCart(c sqlcgen.Cart, owner sqlcgen.User, items []sqlcgen.ListCartIte
9797
return out
9898
}
9999

100+
// DailyStatJSON is the frontend-compatible JSON shape for a single day's stats.
101+
type DailyStatJSON struct {
102+
Date string `json:"date"`
103+
Views int `json:"views"`
104+
Clicks int `json:"clicks"`
105+
}
106+
107+
// MarshalDailyStats converts a DailyStat series into JSON shapes, formatting the
108+
// date as "YYYY-MM-DD". Always returns a non-nil slice so it serializes as [].
109+
func MarshalDailyStats(stats []DailyStat) []DailyStatJSON {
110+
out := make([]DailyStatJSON, 0, len(stats))
111+
for _, s := range stats {
112+
out = append(out, DailyStatJSON{
113+
Date: s.Date.Format("2006-01-02"),
114+
Views: int(s.Views),
115+
Clicks: int(s.Clicks),
116+
})
117+
}
118+
return out
119+
}
120+
100121
func intStr(i int64) string {
101122
return strconv.FormatInt(i, 10)
102123
}

internal/carts/service.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package carts
33
import (
44
"context"
55
"errors"
6+
"time"
67

78
"github.com/jackc/pgx/v5"
89
"github.com/jackc/pgx/v5/pgtype"
@@ -70,6 +71,66 @@ func (s *Service) CartStats7d(ctx context.Context, cartID int64) (views, clicks,
7071
return views, clicks, reach
7172
}
7273

74+
// DailyStat is a single day's view and click totals across all of a user's carts.
75+
type DailyStat struct {
76+
Date time.Time
77+
Views int64
78+
Clicks int64
79+
}
80+
81+
// dailySeriesDays is the fixed window length of the insights series.
82+
const dailySeriesDays = 14
83+
84+
// dateKey is the canonical day key used to merge query rows onto the skeleton.
85+
const dateKey = "2006-01-02"
86+
87+
// AccountDailyStats returns a 14-day, ascending, zero-filled series of view and
88+
// click totals across all carts owned by userID, covering today-13 … today.
89+
//
90+
// The date skeleton is built in Go from time.Now().UTC() so every day is present
91+
// even when the underlying tables have no rows for it; the SQL queries only
92+
// return days with activity and we merge those onto the skeleton by date.
93+
func (s *Service) AccountDailyStats(ctx context.Context, userID int64) ([]DailyStat, error) {
94+
viewRows, err := s.q.AccountDailyViews(ctx, userID)
95+
if err != nil {
96+
return nil, err
97+
}
98+
clickRows, err := s.q.AccountDailyClicks(ctx, userID)
99+
if err != nil {
100+
return nil, err
101+
}
102+
103+
views := make(map[string]int64, len(viewRows))
104+
for _, r := range viewRows {
105+
if r.Day.Valid {
106+
views[r.Day.Time.UTC().Format(dateKey)] = r.Views
107+
}
108+
}
109+
clicks := make(map[string]int64, len(clickRows))
110+
for _, r := range clickRows {
111+
if r.Day.Valid {
112+
clicks[r.Day.Time.UTC().Format(dateKey)] = r.Clicks
113+
}
114+
}
115+
116+
// Anchor "today" in UTC to match how DATE columns are stored/compared
117+
// (current_date in the queries). Truncate to the day boundary.
118+
now := time.Now().UTC()
119+
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
120+
121+
out := make([]DailyStat, 0, dailySeriesDays)
122+
for i := dailySeriesDays - 1; i >= 0; i-- {
123+
day := today.AddDate(0, 0, -i)
124+
key := day.Format(dateKey)
125+
out = append(out, DailyStat{
126+
Date: day,
127+
Views: views[key],
128+
Clicks: clicks[key],
129+
})
130+
}
131+
return out, nil
132+
}
133+
73134
// ListMyCoverImages returns the distinct cover image URLs the user has used
74135
// across their carts, most-recent first — their "personal" cover library.
75136
func (s *Service) ListMyCoverImages(ctx context.Context, userID int64) ([]string, error) {

internal/carts/service_test.go

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package carts_test
33
import (
44
"context"
55
"testing"
6+
"time"
67

8+
"github.com/jackc/pgx/v5/pgxpool"
79
"github.com/mayur-tolexo/shoplit/internal/auth"
810
"github.com/mayur-tolexo/shoplit/internal/carts"
911
"github.com/mayur-tolexo/shoplit/internal/db"
@@ -14,6 +16,14 @@ import (
1416
)
1517

1618
func setupSvc(t *testing.T) (*carts.Service, int64) {
19+
svc, uid, _, _ := setupSvcWithPool(t)
20+
return svc, uid
21+
}
22+
23+
// setupSvcWithPool is like setupSvc but also exposes the raw pool and Queries so
24+
// tests can seed analytics tables (cart_views_daily / click_daily) on arbitrary
25+
// days — the Bump* queries only ever write current_date.
26+
func setupSvcWithPool(t *testing.T) (*carts.Service, int64, *pgxpool.Pool, *sqlcgen.Queries) {
1727
pool, dsn := testutil.NewPostgres(t)
1828
require.NoError(t, db.MigrateUp(dsn, "../db/migrations"))
1929
q := sqlcgen.New(pool)
@@ -23,7 +33,7 @@ func setupSvc(t *testing.T) (*carts.Service, int64) {
2333
Sub: "g-1", Email: "test@example.com", Name: "Test User", Picture: "https://example.com/a.jpg",
2434
})
2535
require.NoError(t, err)
26-
return carts.NewService(q), uid
36+
return carts.NewService(q), uid, pool, q
2737
}
2838

2939
func TestService_CreateAndListCart(t *testing.T) {
@@ -155,3 +165,96 @@ func TestService_GetPublicCart_PublicVisibleToAnon(t *testing.T) {
155165
require.NoError(t, err)
156166
assert.Equal(t, cart.ID, got.ID)
157167
}
168+
169+
func TestService_AccountDailyStats(t *testing.T) {
170+
svc, uid, pool, _ := setupSvcWithPool(t)
171+
ctx := context.Background()
172+
173+
// The viewer's cart, with a link to attribute clicks to.
174+
cart, err := svc.CreateCart(ctx, uid, "Mine")
175+
require.NoError(t, err)
176+
item, err := svc.AddProduct(ctx, uid, cart.ID, carts.AddProductInput{
177+
OriginalURL: "https://example.com/x", Retailer: "other", Title: "P1",
178+
})
179+
require.NoError(t, err)
180+
// The link backing the item is what click_daily references.
181+
var linkID int64
182+
require.NoError(t, pool.QueryRow(ctx,
183+
`SELECT link_id FROM cart_items WHERE id = $1`, item.ID).Scan(&linkID))
184+
185+
// Another user with their own cart + analytics — must NOT leak into uid's series.
186+
q := sqlcgen.New(pool)
187+
other, err := auth.NewUserUpsertFn(q)(auth.GoogleUserInfo{
188+
Sub: "g-other", Email: "other@example.com", Name: "Other", Picture: "",
189+
})
190+
require.NoError(t, err)
191+
otherSvc := carts.NewService(q)
192+
otherCart, err := otherSvc.CreateCart(ctx, other, "Theirs")
193+
require.NoError(t, err)
194+
otherItem, err := otherSvc.AddProduct(ctx, other, otherCart.ID, carts.AddProductInput{
195+
OriginalURL: "https://example.com/y", Retailer: "other", Title: "P2",
196+
})
197+
require.NoError(t, err)
198+
var otherLinkID int64
199+
require.NoError(t, pool.QueryRow(ctx,
200+
`SELECT link_id FROM cart_items WHERE id = $1`, otherItem.ID).Scan(&otherLinkID))
201+
202+
// Seed views on day offsets 0, 3, 13 (gap on day 1, 2, etc.) for the viewer.
203+
// Offsets are relative to current_date (UTC date in Postgres).
204+
seedViews := func(cartID int64, offset, n int32) {
205+
_, e := pool.Exec(ctx,
206+
`INSERT INTO cart_views_daily (cart_id, day, views) VALUES ($1, current_date - ($2)::int, $3)`,
207+
cartID, offset, n)
208+
require.NoError(t, e)
209+
}
210+
seedClicks := func(linkID int64, offset, n int32) {
211+
_, e := pool.Exec(ctx,
212+
`INSERT INTO click_daily (link_id, day, clicks) VALUES ($1, current_date - ($2)::int, $3)`,
213+
linkID, offset, n)
214+
require.NoError(t, e)
215+
}
216+
217+
seedViews(cart.ID, 13, 5) // oldest day in window
218+
seedViews(cart.ID, 3, 7)
219+
seedViews(cart.ID, 0, 11) // today
220+
seedClicks(linkID, 3, 2)
221+
seedClicks(linkID, 0, 4)
222+
223+
// Out-of-window view (14 days ago) must be excluded.
224+
seedViews(cart.ID, 14, 99)
225+
// Another user's activity on day 0 must be excluded.
226+
seedViews(otherCart.ID, 0, 1000)
227+
seedClicks(otherLinkID, 0, 1000)
228+
229+
stats, err := svc.AccountDailyStats(ctx, uid)
230+
require.NoError(t, err)
231+
232+
// Exactly 14 entries, ascending by date, ending today.
233+
require.Len(t, stats, 14)
234+
today := time.Now().UTC().Truncate(24 * time.Hour)
235+
for i, s := range stats {
236+
want := today.AddDate(0, 0, -(13 - i))
237+
assert.Equal(t, want.Format("2006-01-02"), s.Date.Format("2006-01-02"),
238+
"entry %d should be ascending", i)
239+
}
240+
241+
// Zero-filled on the gap days; correct sums on the seeded days.
242+
// index 0 == today-13, index 10 == today-3, index 13 == today.
243+
assert.EqualValues(t, 5, stats[0].Views)
244+
assert.EqualValues(t, 0, stats[0].Clicks)
245+
assert.EqualValues(t, 0, stats[1].Views, "gap day must be zero-filled")
246+
assert.EqualValues(t, 0, stats[1].Clicks)
247+
assert.EqualValues(t, 7, stats[10].Views)
248+
assert.EqualValues(t, 2, stats[10].Clicks)
249+
assert.EqualValues(t, 11, stats[13].Views)
250+
assert.EqualValues(t, 4, stats[13].Clicks)
251+
252+
// Totals reflect only the viewer's carts (other user's 1000s excluded).
253+
var totalViews, totalClicks int64
254+
for _, s := range stats {
255+
totalViews += s.Views
256+
totalClicks += s.Clicks
257+
}
258+
assert.EqualValues(t, 5+7+11, totalViews)
259+
assert.EqualValues(t, 2+4, totalClicks)
260+
}

0 commit comments

Comments
 (0)