From b7a7b350307f72f8216fd4dfc039cbd165b21c35 Mon Sep 17 00:00:00 2001 From: Kahhow Lee Date: Mon, 26 Oct 2026 23:00:59 +0800 Subject: [PATCH 1/3] Connect Shapes Adventure aggregate usage to String Reports --- .github/workflows/checks.yml | 17 ++++++++++++ .gitignore | 3 +++ README.md | 4 +++ app/[slug]/page.tsx | 5 +++- app/api/shapes-metrics/route.ts | 16 ++++++++++++ components/shapes-metrics.tsx | 28 ++++++++++++++++++++ dev.md | 3 +++ lib/data.ts | 13 +++++++++- package-lock.json | 46 +++++++++++++++++++++++++++++++++ package.json | 6 +++-- playwright.config.ts | 4 +++ tests/shapes-report.spec.ts | 29 +++++++++++++++++++++ 12 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/checks.yml create mode 100644 app/api/shapes-metrics/route.ts create mode 100644 components/shapes-metrics.tsx create mode 100644 playwright.config.ts create mode 100644 tests/shapes-report.spec.ts diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..be46b69 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,17 @@ +name: Checks +on: [push, pull_request] +jobs: + checks: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npx tsc --noEmit && npm run build + - run: npm run test:e2e diff --git a/.gitignore b/.gitignore index 0b9dcdf..64552ad 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ next.user-config.* node_modules .next/ .DS_Storetsconfig.tsbuildinfo + +/test-results/ +/playwright-report/ diff --git a/README.md b/README.md index 9ea24ac..6d5460b 100644 --- a/README.md +++ b/README.md @@ -225,3 +225,7 @@ String is volunteer-run and always welcoming help. Here are the most impactful o String runs entirely on volunteer effort. To join the team, visit [join.string.sg](https://join.string.sg). For technical questions or to discuss a contribution, open an issue or pull request in this repository. + +### Shapes Adventure live usage + +The `primary-2-shapes-adventure` product report reads opt-in visits, game starts and completions over a rolling 30 days. `/api/shapes-metrics` validates and proxies the game's public aggregate endpoint, caching upstream data for five minutes. No database credentials or pupil records are shared with Reports. Unavailable data is labelled unavailable, not zero. Run `npm run test:e2e` for desktop/mobile report rendering and failure-state checks. diff --git a/app/[slug]/page.tsx b/app/[slug]/page.tsx index c740265..7ffd841 100644 --- a/app/[slug]/page.tsx +++ b/app/[slug]/page.tsx @@ -1,3 +1,4 @@ +import { ShapesMetrics } from "@/components/shapes-metrics" import { notFound } from "next/navigation" import Link from "next/link" import { ExternalLink } from "lucide-react" @@ -30,7 +31,7 @@ export default async function ReportDetailPage({ params }: Props) { const sections = [ { id: "overview", label: "Overview" }, - ...(project.metrics.length > 0 ? [{ id: "metrics", label: "Metrics" }] : []), + ...((project.metrics.length > 0 || project.slug === "primary-2-shapes-adventure") ? [{ id: "metrics", label: "Metrics" }] : []), ...(project.contributors.length > 0 ? [{ id: "contributors", label: "Contributors" }] : []), ...(project.updates.length > 0 ? [{ id: "updates", label: "Updates" }] : []), ] @@ -120,6 +121,8 @@ export default async function ReportDetailPage({ params }: Props) { + {project.slug === "primary-2-shapes-adventure" && } + {/* Metrics */} {project.metrics.length > 0 && (
diff --git a/app/api/shapes-metrics/route.ts b/app/api/shapes-metrics/route.ts new file mode 100644 index 0000000..6ad5cae --- /dev/null +++ b/app/api/shapes-metrics/route.ts @@ -0,0 +1,16 @@ +export async function GET() { + try { + const response = await fetch('https://primary-2-shapes-adventure.vercel.app/api/metrics', { + next: { revalidate: 300 }, signal: AbortSignal.timeout(8000), + }); + if (!response.ok) throw new Error('upstream_unavailable'); + const data = await response.json(); + if (data.version !== 1 || data.periodDays !== 30 || + !['visits', 'gamesStarted', 'gamesCompleted'].every(key => Number.isSafeInteger(data[key]) && data[key] >= 0) || + typeof data.generatedAt !== 'string' || !Number.isFinite(Date.parse(data.generatedAt))) throw new Error('invalid_summary'); + return Response.json({ version: 1, periodDays: 30, generatedAt: data.generatedAt, + visits: data.visits, gamesStarted: data.gamesStarted, gamesCompleted: data.gamesCompleted }); + } catch { + return Response.json({ error: 'metrics_unavailable' }, { status: 503 }); + } +} diff --git a/components/shapes-metrics.tsx b/components/shapes-metrics.tsx new file mode 100644 index 0000000..add84a6 --- /dev/null +++ b/components/shapes-metrics.tsx @@ -0,0 +1,28 @@ +'use client' +import { useEffect, useState } from 'react' +type Summary = { visits: number; gamesStarted: number; gamesCompleted: number; generatedAt: string } +export function ShapesMetrics() { + const [summary, setSummary] = useState(null) + const [failed, setFailed] = useState(false) + useEffect(() => { + const controller = new AbortController() + fetch('/api/shapes-metrics', { signal: controller.signal }) + .then(async response => { if (!response.ok) throw new Error('unavailable'); return response.json() }) + .then(setSummary).catch(() => { if (!controller.signal.aborted) setFailed(true) }) + return () => controller.abort() + }, []) + return
+

Usage in the last 30 days

+

Opt-in activity counts, not unique pupils or learning outcomes. Names, classes, answers and scores are excluded. Declined analytics and blocked requests are not counted.

+ {summary ? <> +
+ {([['Visits', summary.visits], ['Games started', summary.gamesStarted], ['Games completed', summary.gamesCompleted]] as const).map(([label, value]) => +
+

{label}

+

{value.toLocaleString()}

+
)} +
+

Updated {new Date(summary.generatedAt).toLocaleString('en-SG', { timeZone: 'Asia/Singapore' })} SGT. Refreshed about every five minutes.

+ :

{failed ? 'Usage metrics are temporarily unavailable. Please try again later.' : 'Loading usage metrics…'}

} +
+} diff --git a/dev.md b/dev.md index 0f4fde7..0e8bf39 100644 --- a/dev.md +++ b/dev.md @@ -83,3 +83,6 @@ - [ ] Update aggregate `Educators reached` once String's registered user count is available - [ ] Update aggregate `Volunteer contributors` count - [ ] Add String URL once rebuilt product is live + +## 26 October 2026 — Shapes Adventure usage +Added the Prototype product report and a same-origin summary proxy. The game API supplies opt-in visits, starts and completions over a rolling 30 days. No pupil records, unique-learner claims or teacher-impact counts. Unavailable metrics remain unavailable instead of showing fake zeroes. Product count derives from non-deprecated projects. diff --git a/lib/data.ts b/lib/data.ts index 961a6c6..25f748d 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -49,6 +49,17 @@ export interface Project { } export const PROJECTS: Project[] = [ + { + slug: "primary-2-shapes-adventure", + name: "Primary 2 Shapes Adventure", + tagline: "Patterns and shapes practice for Primary 2 learners.", + description: "A browser game with 2D patterns, 3D shape recognition and everyday object sorting. Completed practice records stay on the device. Optional usage analytics count visits, game starts and completions without pupil details or scores.", + url: "https://primary-2-shapes-adventure.vercel.app", + status: "Prototype", since: "2026", audience: ["Students", "Teachers"], + problemSpace: ["Learning"], contributors: [], + highlightStat: { value: "Live", label: "opt-in usage report" }, metrics: [], + updates: [{ date: "26 October 2026", body: "Added opt-in aggregate usage reporting and automated desktop/mobile game checks. Counts describe activity, not unique learners or assessment impact." }], + }, { slug: "remarkscopilot", volunteers: 10, @@ -337,7 +348,7 @@ export const PROJECTS: Project[] = [ export const AGGREGATE = { // Active: String (Building), Events (Building), Diagrams (Building), Bingo (Maintenance), MatCHER (Building). Remarks Co-Pilot, Whine, and CherGPT are deprecated. - totalProjects: 5, + totalProjects: PROJECTS.filter(project => project.status !== "Deprecated").length, // Auto-computed: sum of teacherImpact across all projects. // To update, set teacherImpact on the relevant project above. totalUsers: PROJECTS.reduce((sum, p) => sum + (p.teacherImpact ?? 0), 0).toLocaleString(), diff --git a/package-lock.json b/package-lock.json index fc5933b..626bc85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@playwright/test": "^1.64.1", "@tailwindcss/postcss": "^4.2.0", "@types/node": "^22", "@types/react": "19.2.14", @@ -804,6 +805,22 @@ "node": ">= 10" } }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3753,6 +3770,35 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/package.json b/package.json index 7e43b1b..2bd0651 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "test:e2e": "playwright test" }, "dependencies": { "@hookform/resolvers": "^3.9.1", - "@vercel/analytics": "1.6.1", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-aspect-ratio": "1.1.8", @@ -38,6 +38,7 @@ "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", + "@vercel/analytics": "1.6.1", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -60,6 +61,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@playwright/test": "^1.64.1", "@tailwindcss/postcss": "^4.2.0", "@types/node": "^22", "@types/react": "19.2.14", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..496881b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ testDir: './tests', use: { baseURL: 'http://127.0.0.1:4320' }, + projects: [{ name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: 'mobile', use: { ...devices['iPhone 13'], defaultBrowserType: 'chromium' } }], + webServer: { command: 'npm run dev -- --hostname 127.0.0.1 --port 4320', url: 'http://127.0.0.1:4320', reuseExistingServer: false } }); diff --git a/tests/shapes-report.spec.ts b/tests/shapes-report.spec.ts new file mode 100644 index 0000000..b499ecb --- /dev/null +++ b/tests/shapes-report.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '@playwright/test'; +test('product card opens its report and renders aggregate usage', async ({ page }) => { + await page.route('**/api/shapes-metrics', route => route.fulfill({ json: { + version: 1, periodDays: 30, generatedAt: '2026-10-26T12:00:00.000Z', visits: 42, gamesStarted: 30, gamesCompleted: 20, + } })); + await page.goto('/'); + await page.getByRole('link', { name: 'Primary 2 Shapes Adventure', exact: true }).click(); + await expect(page).toHaveURL(/primary-2-shapes-adventure$/); + await expect(page.getByRole('heading', { name: 'Primary 2 Shapes Adventure', exact: true })).toBeVisible(); + const metrics = page.getByRole('region', { name: 'Shapes usage metrics' }); + await expect(metrics).toContainText('42'); + await expect(metrics).toContainText('30'); + await expect(metrics).toContainText('20'); + await expect(metrics).toContainText('not unique pupils'); + await expect(metrics).toContainText('SGT'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +}); +test('zero activity is shown as zero; a failed feed is shown as unavailable', async ({ page }) => { + await page.route('**/api/shapes-metrics', route => route.fulfill({ json: { + version: 1, periodDays: 30, generatedAt: '2026-10-26T12:00:00.000Z', visits: 0, gamesStarted: 0, gamesCompleted: 0, + } })); + await page.goto('/primary-2-shapes-adventure'); + await expect(page.locator('#metrics').getByText('0', { exact: true })).toHaveCount(3); + await page.unroute('**/api/shapes-metrics'); + await page.route('**/api/shapes-metrics', route => route.fulfill({ status: 503, json: { error: 'metrics_unavailable' } })); + await page.reload(); + await expect(page.getByRole('status')).toContainText('temporarily unavailable'); + await expect(page.locator('#metrics').getByText('0', { exact: true })).toHaveCount(0); +}); From 3248508839775319e25a2c87a713a20f5e882440 Mon Sep 17 00:00:00 2001 From: Kahhow Lee Date: Mon, 26 Oct 2026 23:04:40 +0800 Subject: [PATCH 2/3] Ignore local Vercel configuration and environment files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 64552ad..cb6d96b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ node_modules /test-results/ /playwright-report/ +.vercel +.env* From efad19a9f46354bb93b44ff390e82c38381a3a53 Mon Sep 17 00:00:00 2001 From: Kahhow Lee Date: Tue, 27 Oct 2026 20:44:52 +0800 Subject: [PATCH 3/3] Show all-time usage and monthly activity trends --- .github/workflows/checks.yml | 2 +- README.md | 2 ++ app/api/shapes-metrics/route.ts | 12 ++++--- components/shapes-metrics.tsx | 64 ++++++++++++++++++++++++++++----- dev.md | 3 ++ lib/shapes-summary.ts | 27 ++++++++++++++ package.json | 3 +- playwright.config.ts | 2 +- tests/shapes-report.spec.ts | 14 +++++++- tests/summary.test.mjs | 21 +++++++++++ 10 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 lib/shapes-summary.ts create mode 100644 tests/summary.test.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index be46b69..4bca16e 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -13,5 +13,5 @@ jobs: cache: npm - run: npm ci - run: npx playwright install --with-deps chromium - - run: npx tsc --noEmit && npm run build + - run: npx tsc --noEmit && npm test && npm run build - run: npm run test:e2e diff --git a/README.md b/README.md index 6d5460b..2c4bda5 100644 --- a/README.md +++ b/README.md @@ -229,3 +229,5 @@ For technical questions or to discuss a contribution, open an issue or pull requ ### Shapes Adventure live usage The `primary-2-shapes-adventure` product report reads opt-in visits, game starts and completions over a rolling 30 days. `/api/shapes-metrics` validates and proxies the game's public aggregate endpoint, caching upstream data for five minutes. No database credentials or pupil records are shared with Reports. Unavailable data is labelled unavailable, not zero. Run `npm run test:e2e` for desktop/mobile report rendering and failure-state checks. + +The Shapes report also shows all-time totals since the first recorded event and monthly trends in Singapore time. The chart shows the latest 12 months; the table keeps every recorded month. Zero-activity gaps are included and the current month is labelled partial. The existing 30-day cards remain available. Events stay in the game's Neon database; Reports caches aggregate responses only. diff --git a/app/api/shapes-metrics/route.ts b/app/api/shapes-metrics/route.ts index 6ad5cae..f7f4882 100644 --- a/app/api/shapes-metrics/route.ts +++ b/app/api/shapes-metrics/route.ts @@ -1,15 +1,17 @@ +import { validShapesSummary, type UsageCounts } from '@/lib/shapes-summary' +const publicCounts = (counts: UsageCounts) => ({ visits: counts.visits, gamesStarted: counts.gamesStarted, gamesCompleted: counts.gamesCompleted }) export async function GET() { try { - const response = await fetch('https://primary-2-shapes-adventure.vercel.app/api/metrics', { + const response = await fetch('https://primary-2-shapes-adventure.vercel.app/api/metrics?summary=history-v1', { next: { revalidate: 300 }, signal: AbortSignal.timeout(8000), }); if (!response.ok) throw new Error('upstream_unavailable'); const data = await response.json(); - if (data.version !== 1 || data.periodDays !== 30 || - !['visits', 'gamesStarted', 'gamesCompleted'].every(key => Number.isSafeInteger(data[key]) && data[key] >= 0) || - typeof data.generatedAt !== 'string' || !Number.isFinite(Date.parse(data.generatedAt))) throw new Error('invalid_summary'); + if (!validShapesSummary(data)) throw new Error('invalid_summary'); return Response.json({ version: 1, periodDays: 30, generatedAt: data.generatedAt, - visits: data.visits, gamesStarted: data.gamesStarted, gamesCompleted: data.gamesCompleted }); + ...publicCounts(data), allTime: publicCounts(data.allTime), firstEventAt: data.firstEventAt, + reportingTimezone: data.reportingTimezone, + monthly: data.monthly.map(row => ({ month: row.month, ...publicCounts(row) })) }); } catch { return Response.json({ error: 'metrics_unavailable' }, { status: 503 }); } diff --git a/components/shapes-metrics.tsx b/components/shapes-metrics.tsx index add84a6..80d9d4b 100644 --- a/components/shapes-metrics.tsx +++ b/components/shapes-metrics.tsx @@ -1,8 +1,22 @@ 'use client' import { useEffect, useState } from 'react' -type Summary = { visits: number; gamesStarted: number; gamesCompleted: number; generatedAt: string } +import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' +import type { ShapesSummary, UsageCounts } from '@/lib/shapes-summary' + +const metrics = [['Visits', 'visits'], ['Games started', 'gamesStarted'], ['Games completed', 'gamesCompleted']] as const +function CountCards({ counts }: { counts: UsageCounts }) { + return
+ {metrics.map(([label, key]) =>
+

{label}

+

{counts[key].toLocaleString()}

+
)} +
+} +function monthLabel(month: string) { + return new Date(`${month}-01T00:00:00Z`).toLocaleDateString('en-SG', { month: 'short', year: 'numeric', timeZone: 'Asia/Singapore' }) +} export function ShapesMetrics() { - const [summary, setSummary] = useState(null) + const [summary, setSummary] = useState(null) const [failed, setFailed] = useState(false) useEffect(() => { const controller = new AbortController() @@ -12,15 +26,47 @@ export function ShapesMetrics() { return () => controller.abort() }, []) return
-

Usage in the last 30 days

+

Usage

Opt-in activity counts, not unique pupils or learning outcomes. Names, classes, answers and scores are excluded. Declined analytics and blocked requests are not counted.

{summary ? <> -
- {([['Visits', summary.visits], ['Games started', summary.gamesStarted], ['Games completed', summary.gamesCompleted]] as const).map(([label, value]) => -
-

{label}

-

{value.toLocaleString()}

-
)} +
+

All-time totals

+

{summary.firstEventAt ? `Since the first recorded event on ${new Date(summary.firstEventAt).toLocaleDateString('en-SG', { timeZone: 'Asia/Singapore', day: 'numeric', month: 'short', year: 'numeric' })}. Earlier usage was not tracked.` : 'No usage recorded yet.'}

+ +
+
+

Last 30 days

+ +
+
+

Monthly trends

+

Calendar months in Singapore time. The latest month is partial. Zero-activity months are included from the first recorded event.

+ {summary.monthly.length ? <> +
+ + + + + + monthLabel(String(label))} contentStyle={{ background: 'var(--card)', borderColor: 'var(--border)', color: 'var(--foreground)' }} /> + + + + + + +
+

Chart: latest 12 months. Table: all recorded months.

+
+ + {['Month', 'Visits', 'Started', 'Completed'].map(label => )} + {[...summary.monthly].reverse().map((row, index) => + + {metrics.map(([, key]) => )} + )} +
{label}
{monthLabel(row.month)}{index === 0 ? ' (partial)' : ''}{row[key].toLocaleString()}
+
+ :

Monthly trends will appear after the first recorded event.

}

Updated {new Date(summary.generatedAt).toLocaleString('en-SG', { timeZone: 'Asia/Singapore' })} SGT. Refreshed about every five minutes.

:

{failed ? 'Usage metrics are temporarily unavailable. Please try again later.' : 'Loading usage metrics…'}

} diff --git a/dev.md b/dev.md index 0e8bf39..c0e39e1 100644 --- a/dev.md +++ b/dev.md @@ -86,3 +86,6 @@ ## 26 October 2026 — Shapes Adventure usage Added the Prototype product report and a same-origin summary proxy. The game API supplies opt-in visits, starts and completions over a rolling 30 days. No pupil records, unique-learner claims or teacher-impact counts. Unavailable metrics remain unavailable instead of showing fake zeroes. Product count derives from non-deprecated projects. + +## 27 October 2026 — All-time and monthly Shapes usage +Expanded the aggregate contract with all-time counts, first-event timestamp and calendar-month buckets (`Asia/Singapore`). Kept 30-day comparisons. A 12-month chart and complete monthly table distinguish recent activity from lifetime totals; the current month is partial. The proxy rejects inconsistent or malformed series. No additional event collection, retention change or duplicated database was introduced. diff --git a/lib/shapes-summary.ts b/lib/shapes-summary.ts new file mode 100644 index 0000000..1a0b65a --- /dev/null +++ b/lib/shapes-summary.ts @@ -0,0 +1,27 @@ +export type UsageCounts = { visits: number; gamesStarted: number; gamesCompleted: number } +export type ShapesSummary = UsageCounts & { + version: 1; periodDays: 30; generatedAt: string; firstEventAt: string | null; + reportingTimezone: 'Asia/Singapore'; allTime: UsageCounts; + monthly: (UsageCounts & { month: string })[]; +} +const keys = ['visits', 'gamesStarted', 'gamesCompleted'] as const +function counts(value: unknown): value is UsageCounts { + return !!value && typeof value === 'object' && keys.every(key => Number.isSafeInteger((value as UsageCounts)[key]) && (value as UsageCounts)[key] >= 0) +} +export function validShapesSummary(value: unknown): value is ShapesSummary { + if (!value || typeof value !== 'object') return false + const data = value as ShapesSummary + if (data.version !== 1 || data.periodDays !== 30 || data.reportingTimezone !== 'Asia/Singapore' || + typeof data.generatedAt !== 'string' || !Number.isFinite(Date.parse(data.generatedAt)) || + !(data.firstEventAt === null || (typeof data.firstEventAt === 'string' && Number.isFinite(Date.parse(data.firstEventAt)))) || + !counts(data) || !counts(data.allTime) || !Array.isArray(data.monthly)) return false + const totals = { visits: 0, gamesStarted: 0, gamesCompleted: 0 } + let previous = '' + for (const row of data.monthly) { + if (!counts(row) || typeof row.month !== 'string' || !/^\d{4}-(0[1-9]|1[0-2])$/.test(row.month) || row.month <= previous) return false + previous = row.month + for (const key of keys) totals[key] += row[key] + } + return keys.every(key => totals[key] === data.allTime[key] && data[key] <= data.allTime[key]) && + (data.firstEventAt === null ? data.monthly.length === 0 : data.monthly.length > 0) +} diff --git a/package.json b/package.json index 2bd0651..deb2a38 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "eslint .", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test": "node --experimental-strip-types --test tests/summary.test.mjs" }, "dependencies": { "@hookform/resolvers": "^3.9.1", diff --git a/playwright.config.ts b/playwright.config.ts index 496881b..dac98a9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,4 @@ import { defineConfig, devices } from '@playwright/test'; -export default defineConfig({ testDir: './tests', use: { baseURL: 'http://127.0.0.1:4320' }, +export default defineConfig({ testDir: './tests', testMatch: '**/*.spec.ts', use: { baseURL: 'http://127.0.0.1:4320' }, projects: [{ name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: 'mobile', use: { ...devices['iPhone 13'], defaultBrowserType: 'chromium' } }], webServer: { command: 'npm run dev -- --hostname 127.0.0.1 --port 4320', url: 'http://127.0.0.1:4320', reuseExistingServer: false } }); diff --git a/tests/shapes-report.spec.ts b/tests/shapes-report.spec.ts index b499ecb..336ca30 100644 --- a/tests/shapes-report.spec.ts +++ b/tests/shapes-report.spec.ts @@ -2,6 +2,11 @@ import { test, expect } from '@playwright/test'; test('product card opens its report and renders aggregate usage', async ({ page }) => { await page.route('**/api/shapes-metrics', route => route.fulfill({ json: { version: 1, periodDays: 30, generatedAt: '2026-10-26T12:00:00.000Z', visits: 42, gamesStarted: 30, gamesCompleted: 20, + reportingTimezone: 'Asia/Singapore', firstEventAt: '2026-08-01T00:00:00.000Z', + allTime: { visits: 100, gamesStarted: 60, gamesCompleted: 40 }, + monthly: [{ month: '2026-08', visits: 58, gamesStarted: 30, gamesCompleted: 20 }, + { month: '2026-09', visits: 0, gamesStarted: 0, gamesCompleted: 0 }, + { month: '2026-10', visits: 42, gamesStarted: 30, gamesCompleted: 20 }], } })); await page.goto('/'); await page.getByRole('link', { name: 'Primary 2 Shapes Adventure', exact: true }).click(); @@ -13,14 +18,21 @@ test('product card opens its report and renders aggregate usage', async ({ page await expect(metrics).toContainText('20'); await expect(metrics).toContainText('not unique pupils'); await expect(metrics).toContainText('SGT'); + await expect(page.getByLabel('All-time totals')).toContainText('100'); + await expect(page.getByLabel('All-time totals')).toContainText('1 Aug 2026'); + await expect(page.getByRole('table', { name: 'Monthly activity counts' }).locator('tbody tr')).toHaveCount(3); + await expect(page.getByRole('table').locator('tbody tr').first()).toContainText('Oct 2026 (partial)'); + await expect(page.getByRole('table').locator('tbody tr').nth(1)).toContainText('Sept 2026'); + await expect(page.getByLabel('Monthly usage chart, latest 12 months').locator('svg').first()).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); }); test('zero activity is shown as zero; a failed feed is shown as unavailable', async ({ page }) => { await page.route('**/api/shapes-metrics', route => route.fulfill({ json: { version: 1, periodDays: 30, generatedAt: '2026-10-26T12:00:00.000Z', visits: 0, gamesStarted: 0, gamesCompleted: 0, + reportingTimezone: 'Asia/Singapore', firstEventAt: null, allTime: { visits: 0, gamesStarted: 0, gamesCompleted: 0 }, monthly: [], } })); await page.goto('/primary-2-shapes-adventure'); - await expect(page.locator('#metrics').getByText('0', { exact: true })).toHaveCount(3); + await expect(page.getByLabel('All-time totals').getByText('0', { exact: true })).toHaveCount(3); await page.unroute('**/api/shapes-metrics'); await page.route('**/api/shapes-metrics', route => route.fulfill({ status: 503, json: { error: 'metrics_unavailable' } })); await page.reload(); diff --git a/tests/summary.test.mjs b/tests/summary.test.mjs new file mode 100644 index 0000000..5860d35 --- /dev/null +++ b/tests/summary.test.mjs @@ -0,0 +1,21 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { validShapesSummary } from '../lib/shapes-summary.ts' +const valid = { + version: 1, periodDays: 30, generatedAt: '2026-10-27T00:00:00.000Z', + visits: 1, gamesStarted: 0, gamesCompleted: 0, + reportingTimezone: 'Asia/Singapore', firstEventAt: '2026-09-01T00:00:00.000Z', + allTime: { visits: 3, gamesStarted: 0, gamesCompleted: 0 }, + monthly: [{ month: '2026-09', visits: 2, gamesStarted: 0, gamesCompleted: 0 }, { month: '2026-10', visits: 1, gamesStarted: 0, gamesCompleted: 0 }], +} +test('accepts expanded summary and an empty history', () => { + assert.equal(validShapesSummary(valid), true) + assert.equal(validShapesSummary({ ...valid, visits: 0, allTime: { visits: 0, gamesStarted: 0, gamesCompleted: 0 }, firstEventAt: null, monthly: [] }), true) +}) +test('rejects inconsistent totals, invalid months, missing series and unordered data', () => { + for (const value of [null, { ...valid, allTime: { ...valid.allTime, visits: 99 } }, + { ...valid, monthly: [{ ...valid.monthly[0], month: '2026-13' }] }, + { ...valid, monthly: [...valid.monthly].reverse() }, + { ...valid, visits: 4 }, { ...valid, monthly: undefined }, + { ...valid, reportingTimezone: 'UTC' }]) assert.equal(validShapesSummary(value), false) +})