- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Bulk import
- Playwright
- Prettier
- Block Rebound
- Transportation
Every school user (student, teacher, parent, staff) gets a GitHub-style profile: a left sidebar (avatar, name, real stat counts, earned badges, organizations) and right content (role tabs, a real contribution graph, pinned items, and an activity feed). One optional catch-all route handles both own-profile and view-by-id.
Everything on the page is real and tenant-scoped. The block was rebuilt (2026-06-15) to remove the previous prototype's fabricated data — hardcoded stats,
Math.random()activity, mock "Student of the Year" achievements, and "Chess Club" organizations. When a real source is missing, an honest empty state is shown.
Two layouts, one page
The page composes itself differently on a phone — it does not just narrow. Desktop keeps the profile column beside the content column, with a year rail of buttons next to the contribution graph. On a phone the order follows GitHub's own mobile profile: the tab rail comes first, then a 64px avatar beside the name, then the panel. The edit button goes full width, section headings step up a size, and the year rail becomes a dropdown on the graph heading. The feed shows six entries with the rest behind Show more activity — the server caps it at ten, so nothing is promised that is not already loaded. Editing keeps the one portrait and hangs the change-photo control off it, rather than drawing a second avatar inside the form.
The switch is useSidebar().isMobile (768px). Type-scale tweaks reset at md,
the same 768px, so the layout and the type never disagree. The reference
captures live in public/profile-github/.
What the contribution graph counts
The grid runs from January 1 to today for the year in progress — no one has contributions in the future, and empty months push the live weeks off a phone — and it opens scrolled to the most recent weeks, with the weekday column pinned.
Each role counts the domain rows that recorded its work:
| Role | Counted from |
|---|---|
| Student | attendance, assignment submissions, graded results, library loans |
| Teacher | attendance they marked, results they graded |
| Parent | messages they sent, plus their logged activity |
| Staff | expenses they approved, plus their logged activity |
Staff and parents count UserActivity; students and teachers deliberately do
not. A student's activity rows narrate the attendance and submissions already
counted above them, so counting both would count each day twice. Staff have no
domain table for most of what they do, which is why their graph and the activity
feed beneath it would otherwise disagree.
If a demo profile's graph looks empty, that is data, not code: run
pnpm db:seed:single profile-activity, which gives the demo accounts activity
across most school days of the year. The read is cached for five minutes, so
load twice before drawing a conclusion.
Routes
| Route | Page | Behaviour |
|---|---|---|
/profile | profile/[[...id]]/page.tsx | Own profile (isOwner=true) |
/profile/<id> | profile/[[...id]]/page.tsx | View by id (User, or wizard entity) |
Path: src/app/[lang]/s/[subdomain]/(school-dashboard)/profile/[[...id]]/. The
route has loading.tsx (layout-matched skeleton) and error.tsx (locale-aware
boundary). id may be a User id, or a wizard-created Student/Teacher id
that has no User row (handled by a fallback path).
Architecture
The page calls a single typed read and passes the result down to pure presenter components — no client component fetches or fabricates data.
page.tsx ──> getProfileView(targetId, lang) // queries.ts (typed, permission-masked)
│ └─ real stats · badges · organizations · pinned · activity · roleDetail
└─> ProfileDetailContent ──> ProfileContent (client.tsx)
├─ ProfileSidebar (avatar, stats, badges, organizations, edit entry)
├─ PinnedItems (real pins; owner reorder/remove, persisted)
├─ ContributionGraph(SWR → getContributionData; empty grid when none)
├─ ContributionActivity (real UserActivity grouped by month)
├─ AchievementsGrid (achievements tab: earned-badge grid)
└─ Student/Teacher/Parent/Staff dashboards (real subjects/sections/children/orgs)
queries.ts is intentionally not "use server" so callers can wrap it in
cache(). Client-callable mutations live in actions.ts ("use server").
Data model
| Model | Table | Purpose |
|---|---|---|
UserActivity | user_activities | Activity feed + contribution graph |
PinnedItem | pinned_items | Up to 6 pinned items per user |
ProfileBadge | profile_badges | Earned badges (awarded by the engine) |
Organization | organizations | School clubs / committees / teams |
OrganizationMembership | organization_memberships | User ↔ organization, with role |
Achievement (existing) | achievements | Student achievement records → badges |
GitHub-style fields (bio, website, timezone, pronouns, statusEmoji,
statusMessage, socialLinks) live on User.
Deploy note: the migration-of-record
prisma/migrations/20260615000000_add_profile_badges_organizations/was applied to prod on 2026-06-15. To populate the demo school's profile surfaces runpnpm db:seed:single profile-extrasthenpnpm db:seed:single profile-activity.
Demo data (profile-activity seed)
Every profile section renders from real rows, so the demo needs real rows. The
idempotent profile-activity seed (part of seedMain) creates, for the demo
school: current-year attendance for the demo student's section (marked by the
demo teacher — lights both graphs and earns the attendance badges), an
Arabic activity feed for demo accounts + roster/teacher/guardian users,
role-appropriate pinned items derived from real subjects/sections/children/
organizations, a parent↔teacher conversation whose parent-sent messages light
the parent graph, expense approvals spread across admin/staff/accountant (staff
graph), demo-student achievements in distinct categories, and the GitHub-style
User fields (status, website, social links, timezone). Badge artwork is served
from the CDN at hogwarts/<icon>.png (sources in /public/github, re-upload
with npx tsx prisma/scripts/upload-badge-art.ts).
Badge earning engine
badges.ts → recomputeProfileBadges(userId, schoolId, role) derives badges from
real signals and reconciles the user's auto-managed badge rows idempotently:
perfect_attendance— student with zero absences this year (or a teacher who marked attendance consistently)top_of_class/honor_roll— student merit rank 1 / top 3diligent_educator— teacher who graded/marked ≥ 20 items this yearactive_contributor— ≥ 20 logged activities this yearcommunity_member— belongs to ≥ 1 organization- a student's own
Achievementrecords → one badge each (icon by category, level by scope)
Manually-awarded badges (keys outside the catalog / achievement_ prefix) are
never touched. Trigger a refresh with the recomputeMyBadges server action.
Permissions (cross-tenant-safe)
getPermissionLevel (detail/permissions.ts) returns
OWNER | ADMIN | STAFF | RELATED | PUBLIC. Elevation rules:
- DEVELOPER is the only role with cross-school access.
- ADMIN / TEACHER / STAFF / ACCOUNTANT are elevated only when
viewerSchoolId === profileSchoolId— otherwisePUBLIC. - Same-school students/guardians get
RELATED.
getProfileView masks sensitive fields (email, ids) for non-privileged viewers
on top of the always-tenant-scoped DB query.
Server actions (actions.ts)
| Action | Purpose |
|---|---|
updateGitHubProfile(input) | Persist name/bio/website/timezone/pronouns/status/social |
uploadProfileAvatar(formData) | Avatar → User.image and role profilePhotoUrl |
getPinnedItems(userId?) | Read pins (public-only for other users) |
updatePinnedItems(items) | Replace own pins (max 6) in a transaction |
getContributionData(params) | Heatmap data (role lookup schoolId-scoped) |
getRecentActivity(userId?, limit) | Activity feed (viewer-permission gated) |
logUserActivity(input) | Append a UserActivity row |
recomputeMyBadges() | Re-run the earning engine for the caller |
Reads are in queries.ts (getProfileView); self-edit entity data in
edit-role-actions.ts (getOwnEntity).
Real data, no mock
Every surface is DB-backed:
- Stats — counts of the user's subjects / sections / students / children
- Activity — real
UserActivityrows (noMath.random()) - Badges — earned via the engine from real attendance/results/merit
- Organizations — real memberships (+ a teacher's primary department)
- Pinned items — real
PinnedItemrows; empty state when none
Known issues
- DB tables deploy-pending (see Deploy note).
- Adding a new pinned item from the UI is not yet wired (display + reorder + remove are).
profile-flows.spec.tspredates the rebuild — selectors + a cross-school isolation case are a follow-up.
See also
- Tests:
src/tests/school-dashboard/profile/ - Block records:
src/components/school-dashboard/profile/{README,ISSUE,CLAUDE}.md