diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..4bca16e --- /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 test && npm run build + - run: npm run test:e2e diff --git a/.gitignore b/.gitignore index 0b9dcdf..cb6d96b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ next.user-config.* node_modules .next/ .DS_Storetsconfig.tsbuildinfo + +/test-results/ +/playwright-report/ +.vercel +.env* diff --git a/README.md b/README.md index 9ea24ac..2c4bda5 100644 --- a/README.md +++ b/README.md @@ -225,3 +225,9 @@ 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. + +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/[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..f7f4882 --- /dev/null +++ b/app/api/shapes-metrics/route.ts @@ -0,0 +1,18 @@ +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?summary=history-v1', { + next: { revalidate: 300 }, signal: AbortSignal.timeout(8000), + }); + if (!response.ok) throw new Error('upstream_unavailable'); + const data = await response.json(); + if (!validShapesSummary(data)) throw new Error('invalid_summary'); + return Response.json({ version: 1, periodDays: 30, generatedAt: data.generatedAt, + ...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 new file mode 100644 index 0000000..80d9d4b --- /dev/null +++ b/components/shapes-metrics.tsx @@ -0,0 +1,74 @@ +'use client' +import { useEffect, useState } from 'react' +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 [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

+

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 => )} + {[...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 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) +})