Files
learning-platform/docs/frontend-spec.md
RaymondVerhoef 472685f0d7 Add specifications for gamification, generation, and R42 chat services
- Introduced gamification service spec detailing responsibilities, API surface, XP calculation, levels, streaks, badges, milestone cards, and heatmap data.
- Added generation service spec outlining the process for generating micro learning content, including API endpoints, AI call configuration, prompt strategies, and error handling.
- Created R42 chat service spec covering chatbot interactions, retrieval pipeline, prompt construction, response generation, and stateless design principles.
2026-05-23 18:13:08 +02:00

21 KiB
Raw Blame History

Frontend spec

Responsibility

Single Next.js 14 codebase serving two distinct role-based experiences:

  • /admin/* — content administration (document upload, KB review, curriculum)
  • /app/* — employee learning experience (sessions, library, R42, gamification)

Mobile-first. Designed for 375px width, scales up. Installable as a PWA.


Location

app/frontend/
  ├── src/
  │     ├── app/                        Next.js app router
  │     │     ├── layout.tsx            root layout — global stylesheet import
  │     │     ├── page.tsx              redirect → role-based landing
  │     │     ├── admin/
  │     │     │     ├── layout.tsx      admin shell (sidebar nav)
  │     │     │     ├── page.tsx        admin dashboard
  │     │     │     ├── documents/
  │     │     │     │     └── page.tsx  document upload + ingestion status
  │     │     │     ├── knowledge/
  │     │     │     │     ├── page.tsx  theme batch review list
  │     │     │     │     └── [themeId]/page.tsx  theme detail + topic edit
  │     │     │     └── curriculum/
  │     │     │           └── page.tsx  curriculum editor + regeneration
  │     │     ├── app/
  │     │     │     ├── layout.tsx      employee shell (bottom nav + R42)
  │     │     │     ├── page.tsx        redirect → /app/session
  │     │     │     ├── session/
  │     │     │     │     └── page.tsx  current week session
  │     │     │     ├── library/
  │     │     │     │     ├── page.tsx  knowledge library browse
  │     │     │     │     └── [topicId]/page.tsx  topic detail
  │     │     │     └── profile/
  │     │     │           └── page.tsx  gamification profile + heatmap + badges
  │     │     ├── auth/
  │     │     │     └── page.tsx        login (PocketBase auth)
  │     │     └── api/                  Next.js API routes (thin proxies only)
  │     ├── components/
  │     │     ├── admin/                admin-specific components
  │     │     ├── employee/             employee-specific components
  │     │     ├── micro-learnings/      one component per micro learning type
  │     │     ├── r42/                  R42 chatbot components
  │     │     ├── gamification/         heatmap, badges, leaderboard
  │     │     └── ui/                   shared primitives
  │     ├── lib/
  │     │     ├── pocketbase.ts         PocketBase client (browser)
  │     │     ├── services.ts           typed API calls to backend services
  │     │     ├── auth.ts               auth helpers + role guards
  │     │     └── hooks/                custom React hooks
  │     └── types/
  │           └── index.ts              shared TypeScript types
  ├── public/
  │     ├── manifest.json               PWA manifest
  │     ├── sw.js                       service worker (generated)
  │     └── icons/                      PWA icons (192, 512)
  ├── next.config.js
  ├── tailwind.config.ts
  ├── tsconfig.json
  └── .env.example

Stylesheet integration

/stylesheet.css lives at the repo root — not inside app/frontend/.

Import it as the first global stylesheet in src/app/layout.tsx:

import '../../../stylesheet.css'     // path from app/frontend/src/app/
import './globals.css'               // Tailwind directives second

Rules:

  • stylesheet.css is the authoritative visual style — never override it
  • Where Tailwind utility classes conflict with stylesheet.css rules, stylesheet.css wins
  • Tailwind is used for layout, spacing, and elements not covered by the stylesheet — match the visual language (spacing scale, colour, type) of the existing stylesheet when doing so
  • Inspect stylesheet.css before implementing any component — use its CSS custom properties (if any) rather than hardcoding values

PWA configuration

next.config.js

Use next-pwa package to generate service worker and manifest wiring:

const withPWA = require('next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
  disable: process.env.NODE_ENV === 'development'
})

module.exports = withPWA({
  reactStrictMode: true,
})

public/manifest.json

{
  "name": "Learning Platform",
  "short_name": "Learn",
  "description": "Employee knowledge and learning",
  "start_url": "/app",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#ffffff",
  "orientation": "portrait",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

Note: set theme_color and background_color to match stylesheet.css primary background after inspecting the file.

Service worker caching strategy

  • Static assets: cache-first
  • PocketBase API calls: network-first, fall back to cache
  • Backend service calls: network-only (no caching for dynamic content)

Auth

PocketBase handles auth. Two roles: admin and employee.

Login flow

/auth page → email + password form
  ↓
PocketBase authWithPassword()
  ↓
Store token in PocketBase SDK (persists in localStorage)
  ↓
Read user.role from auth record
  ↓
role === 'admin'    → redirect to /admin
role === 'employee' → redirect to /app

Route guards

Implement as Next.js middleware (middleware.ts at app root):

// Admin routes: require role === 'admin'
// Employee routes: require role === 'employee'
// Unauthenticated: redirect to /auth
// Wrong role: redirect to correct landing

PocketBase client (browser)

// lib/pocketbase.ts
import PocketBase from 'pocketbase'
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL)

Use pb.authStore for auth state. Use pb.collection().getFullList() etc. for direct PocketBase reads. The frontend reads KB content (topics, micro learnings) directly from PocketBase — it does not proxy through backend services.

Service calls

Backend services (ingestion, generation, curriculum, chat, progress) are called via typed fetch wrappers in lib/services.ts:

// Example
export async function postComplete(payload: CompletePayload) {
  const res = await fetch(`${PROGRESS_URL}/complete`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  })
  if (!res.ok) throw new Error(`Complete failed: ${res.status}`)
  return res.json() as Promise<CompleteResponse>
}

All service response types imported from types/index.ts.


Admin app

Shell layout (admin/layout.tsx)

Sidebar navigation on desktop, top navigation on mobile.

Nav items:

  • Documents
  • Knowledge base
  • Curriculum
  • (link back to employee app)

Documents page (admin/documents/page.tsx)

Upload section

  • Drag-and-drop file input: accepts .pdf, .md, .txt
  • On upload: POST file to PocketBase storage → then POST to ingestion service /ingest with document metadata
  • Show upload confirmation with filename

Job status list

  • Poll GET /status/:jobId every 3 seconds while status is not done/failed
  • Show per-job progress:
    • Status badge: queued / extracting / chunking / structuring / embedding / done / failed
    • Progress bar derived from chunksEmbedded / chunksTotal
    • On done: "N themes, N topics ready for review" → link to knowledge base
    • On failed: error reason in red, no retry (admin re-uploads)
  • Stop polling when status === 'done' or 'failed'

Document history

  • List of all source_documents from PocketBase
  • Columns: filename, format, status, ingested_at, chunk_count

Knowledge base page (admin/knowledge/page.tsx)

Lists all Themes with status indicator.

Theme card

[Theme title]                    [status badge: draft / published]
N topics · from: filename.pdf
[Approve batch]  [Edit]  [Reject]

Approve batch:

  • Calls PocketBase to set theme.status → 'published', all child topics → 'published'
  • Triggers generation service: POST /generate-all with themeId
  • Shows toast: "Generation queued for N topics"

Reject:

  • Sets theme.status → 'rejected'
  • Removes from list

Edit → navigates to /admin/knowledge/[themeId]

Theme detail page (admin/knowledge/[themeId]/page.tsx)

Displays all Topics in the Theme as editable cards.

Topic card fields (all editable inline):

  • title (text input)
  • body (textarea — rich enough for paragraphs, no full rich text editor needed)
  • difficulty (select: introductory / intermediate / advanced)
  • key_terms (tag input — comma-separated)
  • related_topics (multi-select from published topics)
  • prerequisite_topics (multi-select)

Save button per card — calls PocketBase PATCH on the topic record.

Below topic list: [Approve batch] button — approves all topics in the theme.

Micro learning generation status After batch approval, show generation status per topic:

Concept explainer    ✓ published
Scenario quiz        ⏳ generating
Comparison card      ✓ published
...

Poll micro_learnings collection filtered by topic until all 10 are published.


Curriculum page (admin/curriculum/page.tsx)

Current curriculum view 26 weeks displayed as a list. Each week shows:

Week 7
[Theme: Holacratic roles]
Topics: Role definitions · Circle structure · Lead link responsibilities
Estimated: 25 min
[Edit week]   [Admin notes]

Regeneration banner When a pending regeneration is queued:

⚠  8 new topics added. A new curriculum version is ready to preview.
[Preview changes]  [Confirm regeneration]  [Dismiss]

Preview: shows proposed schedule with diff highlighting — weeks that changed are highlighted, weeks that stay the same are dimmed.

Confirm: calls POST /generate confirm on curriculum service → applies new version to all active employees.

Drag-to-reorder Each week row is draggable. Reordering calls PATCH /weeks/:weekId on the curriculum service to swap theme assignments.

Admin notes Inline text input per week — saved to curriculum_weeks.admin_notes.


Employee app

Shell layout (app/layout.tsx)

Bottom navigation bar (mobile-first):

[Session]  [Library]  [Profile]

R42 floating button: fixed position, bottom-right, above the nav bar. Z-index above all content.

Session page (app/session/page.tsx)

Week header

Week 7 of 26 · Cycle 1
[Theme title: Holacratic roles]
[Progress bar: N of 26 weeks complete]

Topic list Each topic in the week's theme rendered as a card:

[Topic title]
[difficulty badge]  [estimated: 10 min]

Choose how to learn this topic:
[Concept explainer]  [Scenario quiz]  [How-to]  ...
(only published types shown as buttons)

[Completed types: ✓ Concept explainer]

Selecting a type opens the micro learning inline (no navigation — expands in place on mobile). Employee reads/completes it, then taps [Mark complete].

On mark complete:

  • POST to progress service /complete
  • Response displays: commits earned + any new badges as a toast notification
  • Topic card updates to show type as completed (✓)
  • All types in topic completable in one session

Week complete state When all topics in the week have at least one completed type:

🚀 Week 7 complete
You earned N commits
[Continue to Week 8]

Continue button calls POST /advance/:userId on curriculum service.


Micro learning components

One component per type in components/micro-learnings/. Each receives the content JSON field from the micro_learnings record.

Component Key interactions
ConceptExplainer Render paragraphs + example — read only
ScenarioQuiz Select option → reveal explanation — stateful
Misconceptions Accordion: tap misconception to reveal correction
HowTo Numbered steps — tap step to check it off
ComparisonCard Two-column table — swipeable on mobile
ReflectionPrompt Open text area → reveal model answer on submit
FlashcardSet Flip card interaction — swipe through deck
CaseStudy Scenario text + open questions — read only
GlossaryAnchor Term card with definition + examples
MythVsEvidence Myth card → tap to reveal evidence

All components are self-contained. They receive content JSON and emit an onComplete callback. They do not call any services directly.

type MicroLearningProps = {
  content: unknown          // typed per component
  onComplete: () => void
}

Knowledge library (app/library/page.tsx)

Browse view All published topics, grouped by Theme. Search input: filters by title and key_terms in real time (client-side). Filter chips: by difficulty (introductory / intermediate / advanced).

Each topic shown as a card:

[Topic title]
[Theme] · [difficulty badge]
[key terms as chips]

Tap → navigate to topic detail.

Topic detail (app/library/[topicId]/page.tsx)

[Topic title]
[Theme] · [difficulty]

[Topic body — rendered as paragraphs]

Key terms: [chip] [chip] [chip]

Related topics: [card] [card]
Prerequisite for: [card] [card]

How to learn this topic:
[micro learning type buttons — same as session view]

Completing a micro learning from the library records the completion via progress service. Week_number is set to the employee's current week.


Profile page (app/profile/page.tsx)

Header

[Display name]
[Level badge: Junior]  [N commits]
[Current streak: 5 weeks]  [Longest: 8 weeks]

Heatmap GitHub-style contribution graph. 26 columns (weeks) × rows implied by completions per week. Cell colour: 0 completions = lightest, 5+ completions = darkest. Tap a cell → tooltip: "Week N · N completions". Scrollable horizontally on mobile if needed.

Implementation: render as SVG or CSS grid — no charting library required.

// Data from GET /profile/:userId → heatmap[]
// Colour scale: 4 levels based on completions count
// 0: var(--heatmap-0)
// 1: var(--heatmap-1)
// 2-3: var(--heatmap-2)
// 4+: var(--heatmap-3)
// Use CSS custom properties — values derived from stylesheet.css palette

Badges Grid of earned badges. Unearned badges shown as locked (greyed out). Tap badge → tooltip with award condition.

🥉 First commit     ✓
🥈 Five sessions    ✓
🥇 On a streak      🔒 (13 week streak needed)
⭐ Shipped          🔒
---
🏷 Governance nerd  ✓
🏷 Deep reader      🔒 (3/5 case studies)

Leaderboard tab Toggle between "My profile" and "Leaderboard".

Leaderboard: table of all employees from GET /leaderboard. Columns: Name · Commits · Streak · Types used · Badges · Level. Not ranked 1N. No sorting by the user — display order is commits descending. Current employee row is highlighted.

Activity feed tab Third tab: "Feed". Milestone cards from GET /feed. Most recent first.

🚀  Alex shipped the full curriculum
    26 weeks · 847 commits · 3 badges
    Longest streak: 18 weeks
    [timestamp]

R42 chatbot components (components/r42/)

R42Button

Fixed position, bottom-right, above bottom nav bar. Circle button with R42 label or icon. Tap → opens R42Drawer.

// Position: fixed, bottom: calc(nav-height + 16px), right: 16px
// Z-index: above all content, below modals

R42Drawer

Slides up from bottom on mobile (sheet pattern). On desktop: expands to a side panel.

[R42 header bar]                    [close ×]
─────────────────────────────────────────────
[Response area — scrollable]

Based on: [Holacratic roles ×] [Circle structure ×]
─────────────────────────────────────────────
[Type a question...]           [Send →]

State machine:

idle → loading (query sent) → streaming → done
                                        ↘ out_of_scope

Streaming implementation:

// POST /chat with fetch, read SSE stream
const response = await fetch(`${CHAT_URL}/chat`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query, userId })
})

const reader = response.body!.getReader()
const decoder = new TextDecoder()

while (true) {
  const { done, value } = await reader.read()
  if (done) break
  const lines = decoder.decode(value).split('\n')
  for (const line of lines) {
    if (!line.startsWith('data: ')) continue
    const event = JSON.parse(line.slice(6))
    if (event.type === 'chunk') appendText(event.text)
    if (event.type === 'citations') setCitations(event.topics)
    if (event.type === 'out_of_scope') setOutOfScope(event.text)
    if (event.type === 'done') setDone()
  }
}

Citations Rendered as tappable pills below the response. Tap → closes R42Drawer, navigates to /app/library/[topicId].

Out of scope response Render as a muted message (not an error state): "This doesn't appear to be covered in the knowledge base. You can browse the full library in the Knowledge section."

Stateless by design Conversation cleared on drawer close. No history persisted. Input cleared on send.


Mobile-first layout rules

All layout decisions start at 375px and scale up.

  • Bottom navigation: fixed, height 56px, icons + labels
  • R42 button: 48px circle, positioned above nav bar
  • Session topic cards: full width, stack vertically
  • Micro learning components: full width, no horizontal scroll except ComparisonCard (swipeable)
  • Heatmap: horizontal scroll container on narrow screens
  • Leaderboard table: horizontally scrollable on mobile, sticky name column
  • Drawer/sheet pattern for R42 on mobile, side panel on desktop (breakpoint: 768px)
  • Tap targets: minimum 44×44px on all interactive elements
  • No hover-only interactions — all hover states have tap equivalents

Environment variables

NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
NEXT_PUBLIC_INGESTION_URL=http://localhost:3001
NEXT_PUBLIC_GENERATION_URL=http://localhost:3002
NEXT_PUBLIC_CURRICULUM_URL=http://localhost:3003
NEXT_PUBLIC_CHAT_URL=http://localhost:3004
NEXT_PUBLIC_PROGRESS_URL=http://localhost:3005

Dependencies

{
  "dependencies": {
    "next": "14",
    "react": "^18",
    "react-dom": "^18",
    "pocketbase": "^0.21",
    "next-pwa": "^5",
    "zod": "^3"
  },
  "devDependencies": {
    "typescript": "^5",
    "tailwindcss": "^3",
    "autoprefixer": "^10",
    "postcss": "^8",
    "@types/react": "^18",
    "@types/node": "^20"
  }
}

No component library. No charting library. No drag-and-drop library — implement curriculum drag-to-reorder with native HTML5 drag API. The heatmap is SVG or CSS grid — no D3.


TypeScript strict mode requirements

  • No any types
  • All PocketBase collection responses typed against data-model.md schemas
  • All service API responses typed against response types from each service spec
  • Micro learning content JSON typed per type using discriminated union:
type MicroLearningContent =
  | { type: 'concept_explainer'; paragraphs: string[]; example: string }
  | { type: 'scenario_quiz'; scenario: string; options: QuizOption[] }
  | { type: 'misconceptions'; items: MisconceptionItem[] }
  // ... all 10 types
  • SSE event types as discriminated union
  • No implicit any on event handlers

What the frontend does NOT do

  • Does not run AI calls directly — all AI goes through backend services
  • Does not write to Qdrant — embedding is the ingestion service's responsibility
  • Does not implement auth logic — delegates entirely to PocketBase SDK
  • Does not implement curriculum generation — calls curriculum service

Testing checkpoints

Admin app

  1. Upload a PDF → ingestion job created → status polls and updates → done state shows link
  2. Theme batch appears after ingestion → approve → generation queued
  3. Edit a topic title and body → save → changes persisted in PocketBase
  4. Curriculum renders 26 weeks → drag week 3 and week 5 → order persists
  5. Regeneration banner appears → preview shows → confirm applies new version

Employee app

  1. Login as employee → redirected to /app/session → correct week shown
  2. Select micro learning type → content renders → mark complete → commits toast shown
  3. Complete all topics in week → week complete state shown → advance to next week
  4. Library browse → search filters results → topic detail renders body + related topics
  5. Profile page → heatmap renders for current cycle → badges show locked/unlocked state
  6. Leaderboard tab → all employees shown → current employee row highlighted
  7. R42 button visible on every screen → opens drawer → question answered with citations
  8. R42 citation tap → navigates to correct topic in library
  9. Out-of-scope question → muted message shown, no citations
  10. All screens render correctly at 375px width — no horizontal overflow except intentional scroll containers