+ 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 ? <>
+
+
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 => | {label} | )}
+ {[...summary.monthly].reverse().map((row, index) =>
+ | {monthLabel(row.month)}{index === 0 ? ' (partial)' : ''} |
+ {metrics.map(([, key]) => {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 0f4fde7..c0e39e1 100644
--- a/dev.md
+++ b/dev.md
@@ -83,3 +83,9 @@
- [ ] 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.
+
+## 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/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/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-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..deb2a38 100644
--- a/package.json
+++ b/package.json
@@ -6,11 +6,12 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint ."
+ "lint": "eslint .",
+ "test:e2e": "playwright test",
+ "test": "node --experimental-strip-types --test tests/summary.test.mjs"
},
"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 +39,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 +62,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..dac98a9
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,4 @@
+import { defineConfig, devices } from '@playwright/test';
+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
new file mode 100644
index 0000000..336ca30
--- /dev/null
+++ b/tests/shapes-report.spec.ts
@@ -0,0 +1,41 @@
+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();
+ 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');
+ 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.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();
+ await expect(page.getByRole('status')).toContainText('temporarily unavailable');
+ await expect(page.locator('#metrics').getByText('0', { exact: true })).toHaveCount(0);
+});
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)
+})