Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ next.user-config.*
node_modules
.next/
.DS_Storetsconfig.tsbuildinfo

/test-results/
/playwright-report/
.vercel
.env*
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion app/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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" }] : []),
]
Expand Down Expand Up @@ -120,6 +121,8 @@ export default async function ReportDetailPage({ params }: Props) {
</div>
</section>

{project.slug === "primary-2-shapes-adventure" && <ShapesMetrics />}

{/* Metrics */}
{project.metrics.length > 0 && (
<section id="metrics" className="mb-12">
Expand Down
18 changes: 18 additions & 0 deletions app/api/shapes-metrics/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
74 changes: 74 additions & 0 deletions components/shapes-metrics.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="grid sm:grid-cols-3 gap-4">
{metrics.map(([label, key]) => <div key={key} className="rounded-xl bg-card border border-border p-5">
<p className="text-xs font-semibold uppercase tracking-widest text-primary mb-3">{label}</p>
<p className="text-3xl font-bold text-foreground">{counts[key].toLocaleString()}</p>
</div>)}
</div>
}
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<ShapesSummary | null>(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 <section id="metrics" className="mb-12" aria-label="Shapes usage metrics">
<h2 className="text-lg font-semibold text-foreground mb-5">Usage</h2>
<p className="text-sm text-muted-foreground mb-4">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.</p>
{summary ? <>
<div aria-label="All-time totals" className="mb-8">
<h3 className="font-semibold text-foreground mb-2">All-time totals</h3>
<p className="text-xs text-muted-foreground mb-4">{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.'}</p>
<CountCards counts={summary.allTime} />
</div>
<div aria-label="Last 30 days" className="mb-8">
<h3 className="font-semibold text-foreground mb-4">Last 30 days</h3>
<CountCards counts={summary} />
</div>
<div aria-label="Monthly trends" className="mb-4">
<h3 className="font-semibold text-foreground mb-2">Monthly trends</h3>
<p className="text-xs text-muted-foreground mb-4">Calendar months in Singapore time. The latest month is partial. Zero-activity months are included from the first recorded event.</p>
{summary.monthly.length ? <>
<div className="h-72 min-w-0 mb-5" aria-label="Monthly usage chart, latest 12 months">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={summary.monthly.slice(-12)} margin={{ top: 12, right: 16, bottom: 8, left: -20 }} accessibilityLayer>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="month" tickFormatter={monthLabel} stroke="var(--muted-foreground)" fontSize={11} />
<YAxis allowDecimals={false} stroke="var(--muted-foreground)" fontSize={11} />
<Tooltip labelFormatter={label => monthLabel(String(label))} contentStyle={{ background: 'var(--card)', borderColor: 'var(--border)', color: 'var(--foreground)' }} />
<Legend />
<Line type="linear" dataKey="visits" name="Visits" stroke="var(--primary)" strokeWidth={2} isAnimationActive={false} />
<Line type="linear" dataKey="gamesStarted" name="Games started" stroke="var(--accent)" strokeWidth={2} strokeDasharray="6 3" isAnimationActive={false} />
<Line type="linear" dataKey="gamesCompleted" name="Games completed" stroke="#FFB86B" strokeWidth={2} strokeDasharray="2 3" isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
<p className="text-xs text-muted-foreground mb-3">Chart: latest 12 months. Table: all recorded months.</p>
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full text-sm text-left" aria-label="Monthly activity counts">
<thead className="bg-card"><tr>{['Month', 'Visits', 'Started', 'Completed'].map(label => <th key={label} scope="col" className="p-3 font-semibold text-primary">{label}</th>)}</tr></thead>
<tbody>{[...summary.monthly].reverse().map((row, index) => <tr key={row.month} className="border-t border-border">
<th scope="row" className="p-3 font-medium whitespace-nowrap">{monthLabel(row.month)}{index === 0 ? ' (partial)' : ''}</th>
{metrics.map(([, key]) => <td key={key} className="p-3 tabular-nums">{row[key].toLocaleString()}</td>)}
</tr>)}</tbody>
</table>
</div>
</> : <p className="text-sm text-muted-foreground">Monthly trends will appear after the first recorded event.</p>}
</div>
<p className="text-xs text-muted-foreground mt-3">Updated {new Date(summary.generatedAt).toLocaleString('en-SG', { timeZone: 'Asia/Singapore' })} SGT. Refreshed about every five minutes.</p>
</> : <p role="status" className="text-sm text-muted-foreground">{failed ? 'Usage metrics are temporarily unavailable. Please try again later.' : 'Loading usage metrics…'}</p>}
</section>
}
6 changes: 6 additions & 0 deletions dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 12 additions & 1 deletion lib/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
27 changes: 27 additions & 0 deletions lib/shapes-summary.ts
Original file line number Diff line number Diff line change
@@ -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)
}
46 changes: 46 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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 } });
Loading
Loading