Round 17: QR Codes, Gamification Badges, Auto-Award, Badge Management

This commit is contained in:
Mustafa Cetin committed 2026-07-29 12:19:50 +00:00
1 parent 6174f127f9
commit cba8a8e724
23 files changed
+4028 -1

No files matched your search

+105
View File
@@ -2889,3 +2889,108 @@ export function fetchDistrictSchools(districtId: string): Promise<Array<{
export function assignSchoolToDistrict(districtId: string, schoolId: string): Promise<{ success: boolean }> {
return apiPost<{ success: boolean }>(`/api/districts/${districtId}/schools`, { schoolId });
}
/* ── Badges ──────────────────────────────────────────────────────── */
export interface BadgeData {
id: string;
schoolId: string;
name: string;
description: string | null;
icon: string;
color: string;
category: string;
requirementType: string;
requirementValue: number | null;
isAuto: boolean;
isDemo: boolean;
createdAt: string;
updatedAt: string;
_count?: { studentBadges: number };
}
export interface StudentBadgeData {
id: string;
schoolId: string;
studentId: string;
badgeId: string;
awardedAt: string;
awardedBy: string | null;
notes: string | null;
badge: BadgeData;
student: { id: string; firstName: string; lastName: string };
awardedByUser?: { id: string; firstName: string; lastName: string } | null;
}
export interface BadgeProgressData {
badgeId: string;
name: string;
icon: string;
color: string;
category: string;
earned: boolean;
progress: number;
current: number;
target: number;
}
export function fetchBadges(schoolId: string, category?: string): Promise<BadgeData[]> {
const params = new URLSearchParams({ schoolId });
if (category) params.set('category', category);
return apiGet<BadgeData[]>(`/api/badges?${params.toString()}`);
}
export function createBadge(data: {
schoolId: string;
name: string;
description?: string;
icon: string;
color?: string;
category: string;
requirementType: string;
requirementValue?: number;
isAuto?: boolean;
}): Promise<BadgeData> {
return apiPost<BadgeData>('/api/badges', data);
}
export function updateBadge(id: string, data: Partial<BadgeData>): Promise<BadgeData> {
return apiPut<BadgeData>(`/api/badges/${id}`, data);
}
export function deleteBadge(id: string): Promise<{ success: boolean }> {
return apiDelete<{ success: boolean }>(`/api/badges/${id}`);
}
export function fetchStudentBadges(schoolId: string, studentId?: string, badgeId?: string): Promise<StudentBadgeData[]> {
const params = new URLSearchParams({ schoolId });
if (studentId) params.set('studentId', studentId);
if (badgeId) params.set('badgeId', badgeId);
return apiGet<StudentBadgeData[]>(`/api/student-badges?${params.toString()}`);
}
export function awardBadgeToStudent(data: {
schoolId: string;
studentId: string;
badgeId: string;
notes?: string;
}): Promise<StudentBadgeData> {
return apiPost<StudentBadgeData>('/api/student-badges', data);
}
export function removeStudentBadge(id: string): Promise<{ success: boolean }> {
return apiDelete<{ success: boolean }>(`/api/student-badges/${id}`);
}
export function fetchBadgeProgress(schoolId: string, studentId: string): Promise<BadgeProgressData[]> {
const params = new URLSearchParams({ schoolId, studentId });
return apiGet<BadgeProgressData[]>(`/api/badge-progress?${params.toString()}`);
}
export function checkAndAwardBadges(schoolId: string, studentId: string): Promise<string[]> {
return apiPost<string[]>('/api/badge-check', { schoolId, studentId });
}
export function seedBadges(schoolId: string): Promise<{ success: boolean }> {
return apiPost<{ success: boolean }>('/api/badge-seed', { schoolId });
}
+432
View File
@@ -0,0 +1,432 @@
// CompetenceTrack — Badge Auto-Award Check Utility
// Checks if a student meets requirements for auto-awarded badges
import { db } from '@/lib/db';
// Default badge definitions that get seeded when a school is created
export const DEFAULT_BADGES = [
{
name: 'Perfect Attendance',
description: '100% attendance for 30 consecutive days',
icon: 'CalendarCheck',
color: '#10b981',
category: 'attendance',
requirementType: 'attendance_rate',
requirementValue: 30,
isAuto: true,
},
{
name: 'Competency Master',
description: 'Achieved mastery level 5+ in a competency',
icon: 'Award',
color: '#f59e0b',
category: 'competency',
requirementType: 'mastery_level',
requirementValue: 5,
isAuto: true,
},
{
name: 'Progress Pioneer',
description: '10+ progress entries in a month',
icon: 'TrendingUp',
color: '#3b82f6',
category: 'achievement',
requirementType: 'progress_entries',
requirementValue: 10,
isAuto: true,
},
{
name: 'Behavior Star',
description: '5+ positive behavior entries',
icon: 'Star',
color: '#eab308',
category: 'behavior',
requirementType: 'behavior_count',
requirementValue: 5,
isAuto: true,
},
{
name: 'Notebook Champion',
description: '10+ notebook pages created',
icon: 'BookOpen',
color: '#8b5cf6',
category: 'achievement',
requirementType: 'custom',
requirementValue: 10,
isAuto: true,
},
{
name: 'Drawing Artist',
description: '5+ drawings created',
icon: 'Pencil',
color: '#ec4899',
category: 'achievement',
requirementType: 'custom',
requirementValue: 5,
isAuto: true,
},
{
name: 'Homework Hero',
description: '10+ homework submissions on time',
icon: 'ClipboardCheck',
color: '#06b6d4',
category: 'achievement',
requirementType: 'custom',
requirementValue: 10,
isAuto: true,
},
{
name: 'Team Player',
description: '3+ peer assessments given',
icon: 'Users',
color: '#14b8a6',
category: 'competency',
requirementType: 'custom',
requirementValue: 3,
isAuto: true,
},
{
name: 'Goal Achiever',
description: '3+ learning goals completed',
icon: 'Target',
color: '#f97316',
category: 'milestone',
requirementType: 'custom',
requirementValue: 3,
isAuto: true,
},
{
name: 'Eco Warrior',
description: 'Environmental badge for using digital notebooks',
icon: 'Leaf',
color: '#22c55e',
category: 'milestone',
requirementType: 'custom',
requirementValue: 1,
isAuto: true,
},
] as const;
/**
* Seed default badges for a school if they don't exist yet
*/
export async function seedDefaultBadges(schoolId: string): Promise<void> {
const existing = await db.badge.findMany({ where: { schoolId, deletedAt: null } });
if (existing.length > 0) return; // Already seeded
for (const badge of DEFAULT_BADGES) {
await db.badge.create({
data: {
schoolId,
name: badge.name,
description: badge.description,
icon: badge.icon,
color: badge.color,
category: badge.category,
requirementType: badge.requirementType,
requirementValue: badge.requirementValue,
isAuto: badge.isAuto,
},
});
}
}
/**
* Check and auto-award badges for a student.
* Returns the list of newly awarded badge IDs.
*/
export async function checkAndAwardBadges(
schoolId: string,
studentId: string
): Promise<string[]> {
const awardedBadgeIds: string[] = [];
// Get all auto-award badges for the school
const autoBadges = await db.badge.findMany({
where: { schoolId, isAuto: true, deletedAt: null },
});
// Get already earned badges for this student
const earnedBadges = await db.studentBadge.findMany({
where: { studentId, schoolId },
select: { badgeId: true },
});
const earnedBadgeIdSet = new Set(earnedBadges.map(eb => eb.badgeId));
for (const badge of autoBadges) {
// Skip if already earned
if (earnedBadgeIdSet.has(badge.id)) continue;
const met = await checkBadgeRequirement(badge, schoolId, studentId);
if (met) {
await db.studentBadge.create({
data: {
schoolId,
studentId,
badgeId: badge.id,
awardedBy: null, // Auto-awarded
},
});
awardedBadgeIds.push(badge.id);
}
}
return awardedBadgeIds;
}
/**
* Check if a specific badge requirement is met for a student
*/
async function checkBadgeRequirement(
badge: {
id: string;
requirementType: string;
requirementValue: number | null;
},
schoolId: string,
studentId: string
): Promise<boolean> {
const threshold = badge.requirementValue ?? 0;
if (threshold === 0) return false;
switch (badge.requirementType) {
case 'attendance_rate': {
// Check if student has 30 consecutive days of attendance
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - threshold);
const attendanceRecords = await db.attendanceRecord.findMany({
where: {
studentId,
schoolId,
status: 'PRESENT',
session: { date: { gte: thirtyDaysAgo } },
},
include: { session: { select: { date: true } } },
});
return attendanceRecords.length >= threshold;
}
case 'mastery_level': {
// Check if student has any competency at mastery level >= threshold
const progressEntries = await db.learningProgressEntry.findMany({
where: {
studentId,
schoolId,
masteryLevelValue: { gte: threshold },
},
});
return progressEntries.length > 0;
}
case 'progress_entries': {
// Check if student has threshold+ progress entries in the last month
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
const count = await db.learningProgressEntry.count({
where: {
studentId,
schoolId,
date: { gte: oneMonthAgo },
},
});
return count >= threshold;
}
case 'behavior_count': {
// Check if student has threshold+ positive behavior entries
const count = await db.behaviorIncident.count({
where: {
studentId,
schoolId,
category: { valence: 'positive' },
},
});
return count >= threshold;
}
case 'custom': {
// For custom badges, check based on badge name
const badgeRecord = await db.badge.findUnique({ where: { id: badge.id } });
if (!badgeRecord) return false;
switch (badgeRecord.name) {
case 'Notebook Champion': {
const count = await db.notebookPage.count({
where: {
notebook: { schoolId },
notebook: { createdById: studentId },
},
});
return count >= threshold;
}
case 'Drawing Artist': {
const count = await db.drawing.count({
where: { schoolId, createdById: studentId },
});
return count >= threshold;
}
case 'Homework Hero': {
const count = await db.homeworkSubmission.count({
where: {
studentId,
homework: { schoolId },
status: 'SUBMITTED',
},
});
return count >= threshold;
}
case 'Team Player': {
const count = await db.peerAssessment.count({
where: {
assessorId: studentId,
schoolId,
},
});
return count >= threshold;
}
case 'Goal Achiever': {
const count = await db.learningGoal.count({
where: {
studentId,
schoolId,
status: 'COMPLETED',
},
});
return count >= threshold;
}
case 'Eco Warrior': {
// Awarded if student has at least 1 notebook
const count = await db.notebook.count({
where: { schoolId, createdById: studentId },
});
return count >= threshold;
}
default:
return false;
}
}
default:
return false;
}
}
/**
* Get badge progress for a student (how close they are to earning each badge)
*/
export async function getBadgeProgress(
schoolId: string,
studentId: string
): Promise<Array<{
badgeId: string;
name: string;
icon: string;
color: string;
category: string;
earned: boolean;
progress: number; // 0-100
current: number;
target: number;
}>> {
const badges = await db.badge.findMany({
where: { schoolId, deletedAt: null },
orderBy: [{ category: 'asc' }, { name: 'asc' }],
});
const earnedBadges = await db.studentBadge.findMany({
where: { studentId, schoolId },
select: { badgeId: true, awardedAt: true },
});
const earnedBadgeMap = new Map(earnedBadges.map(eb => [eb.badgeId, eb.awardedAt]));
const results = [];
for (const badge of badges) {
const earned = earnedBadgeMap.has(badge.id);
const target = badge.requirementValue ?? 1;
let current = 0;
if (!earned) {
current = await getCurrentProgress(badge, schoolId, studentId);
} else {
current = target;
}
results.push({
badgeId: badge.id,
name: badge.name,
icon: badge.icon,
color: badge.color,
category: badge.category,
earned,
progress: Math.min(100, Math.round((current / target) * 100)),
current,
target,
});
}
return results;
}
async function getCurrentProgress(
badge: { id: string; requirementType: string; requirementValue: number | null; name: string },
schoolId: string,
studentId: string
): Promise<number> {
const threshold = badge.requirementValue ?? 1;
switch (badge.requirementType) {
case 'attendance_rate': {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - threshold);
return db.attendanceRecord.count({
where: {
studentId,
schoolId,
status: 'PRESENT',
session: { date: { gte: thirtyDaysAgo } },
},
});
}
case 'mastery_level': {
const entries = await db.learningProgressEntry.findMany({
where: { studentId, schoolId },
orderBy: { masteryLevelValue: 'desc' },
take: 1,
});
return entries.length > 0 ? entries[0].masteryLevelValue : 0;
}
case 'progress_entries': {
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
return db.learningProgressEntry.count({
where: { studentId, schoolId, date: { gte: oneMonthAgo } },
});
}
case 'behavior_count': {
return db.behaviorIncident.count({
where: { studentId, schoolId, category: { valence: 'positive' } },
});
}
case 'custom': {
switch (badge.name) {
case 'Notebook Champion':
return db.notebookPage.count({ where: { notebook: { schoolId, createdById: studentId } } });
case 'Drawing Artist':
return db.drawing.count({ where: { schoolId, createdById: studentId } });
case 'Homework Hero':
return db.homeworkSubmission.count({ where: { studentId, homework: { schoolId }, status: 'SUBMITTED' } });
case 'Team Player':
return db.peerAssessment.count({ where: { assessorId: studentId, schoolId } });
case 'Goal Achiever':
return db.learningGoal.count({ where: { studentId, schoolId, status: 'COMPLETED' } });
case 'Eco Warrior':
return db.notebook.count({ where: { schoolId, createdById: studentId } });
default:
return 0;
}
}
default:
return 0;
}
}
+290
View File
@@ -3084,6 +3084,151 @@ const de: TranslationDict = {
'onboarding.previous': 'Zurück',
'onboarding.dont_show_again': 'Nicht mehr anzeigen',
'onboarding.restart_tour': 'Tour erneut starten',
// Student Transportation
'transport.title': 'Verkehrsmittel',
'transport.type': 'Verkehrsmitteltyp',
'transport.bus': 'Bus',
'transport.tram': 'Straßenbahn',
'transport.walk': 'Zu Fuß',
'transport.car': 'Auto',
'transport.bike': 'Fahrrad',
'transport.other': 'Sonstiges',
'transport.route': 'Linie',
'transport.stop': 'Haltestelle',
'transport.pickup': 'Abholung',
'transport.dropoff': 'Rückfahrt',
'transport.driver': 'Fahrer',
'transport.distance': 'Entfernung',
'transport.distance_km': '{count} km',
'transport.notes': 'Hinweise',
'transport.add': 'Verkehrsmittel hinzufügen',
'transport.edit': 'Verkehrsmittel bearbeiten',
'transport.no_transport': 'Keine Verkehrsmittel gespeichert',
'transport.route_number': 'Liniennummer',
'transport.stop_name': 'Haltestellenname',
'transport.pickup_time': 'Abholzeit',
'transport.dropoff_time': 'Rückfahrtzeit',
'transport.driver_name': 'Fahrername',
'transport.driver_phone': 'Fahrertelefon',
'transport.distance_km_field': 'Entfernung (km)',
'transport.class_overview': 'Klassenübersicht Verkehrsmittel',
'transport.quick_call': 'Anrufen',
// Health Records
'health.title': 'Gesundheitsdaten',
'health.blood_type': 'Blutgruppe',
'health.allergies': 'Allergien',
'health.medications': 'Medikamente',
'health.conditions': 'Erkrankungen',
'health.doctor': 'Arzt',
'health.insurance': 'Versicherung',
'health.confidential': 'Vertraulich',
'health.last_checkup': 'Letzte Untersuchung',
'health.warning': 'Gesundheitshinweis',
'health.add': 'Gesundheitsdaten hinzufügen',
'health.edit': 'Gesundheitsdaten bearbeiten',
'health.no_record': 'Keine Gesundheitsdaten vorhanden',
'health.doctor_name': 'Arztname',
'health.doctor_phone': 'Arzttelefon',
'health.insurance_number': 'Versicherungsnummer',
'health.insurance_provider': 'Versicherungsträger',
'health.last_checkup_date': 'Datum letzte Untersuchung',
'health.notes': 'Gesundheitshinweise',
'health.confidential_toggle': 'Vertraulichkeitsstatus',
'health.dosage': 'Dosierung',
'health.frequency': 'Häufigkeit',
'health.medication_name': 'Medikamentenname',
'health.add_allergy': 'Allergie hinzufügen',
'health.add_medication': 'Medikament hinzufügen',
'health.add_condition': 'Erkrankung hinzufügen',
// Interactive Seating Chart
'seating.title': 'Sitzplan',
'seating.chart': 'Sitzordnung',
'seating.row': 'Reihe',
'seating.column': 'Spalte',
'seating.save': 'Sitzplan speichern',
'seating.randomize': 'Mischen',
'seating.clear': 'Zurücksetzen',
'seating.print': 'Sitzplan drucken',
'seating.drag_to_rearrange': 'Ziehen zum Verschieben',
'seating.empty_seat': 'Freier Platz',
'seating.rows': 'Reihen',
'seating.columns': 'Spalten',
'seating.grid_size': 'Rastergröße',
'seating.student_initials': 'Initialen',
'seating.no_students': 'Keine Schüler für Sitzplan',
'seating.visual': 'Visueller Sitzplan',
'seating.list': 'Listenansicht',
// QR Code System
'qr.title': 'QR-Code',
'qr.student': 'Schüler-QR-Code',
'qr.class': 'Klassen-QR-Code',
'qr.attendance': 'Anwesenheits-QR-Code',
'qr.event': 'Veranstaltungs-QR-Code',
'qr.download': 'QR-Code herunterladen',
'qr.scan': 'QR-Code scannen',
'qr.refresh': 'QR-Code aktualisieren',
'qr.generate': 'QR-Code erzeugen',
'qr.scan_to_checkin': 'Scannen zum Einchecken',
'qr.session_active': 'QR-Sitzung aktiv',
'qr.refreshes_in': 'Aktualisierung in {seconds}s',
'qr.checked_in': 'Eingecheckt',
'qr.pending': 'Ausstehend',
'qr.absent': 'Abwesend',
'qr.show_qr': 'QR-Code anzeigen',
'qr.print_report': 'QR im Bericht',
'qr.student_card': 'Schülerausweis',
// Badge / Gamification System
'badges.title': 'Abzeichen',
'badges.earned': 'Verdient',
'badges.unearned': 'Noch nicht verdient',
'badges.progress': 'Fortschritt',
'badges.collection': 'Abzeichen-Sammlung',
'badges.award': 'Abzeichen verleihen',
'badges.auto': 'Automatisch vergeben',
'badges.manual': 'Manuell vergeben',
'badges.leaderboard': 'Abzeichen-Rangliste',
'badges.recent': 'Kürzlich verdient',
'badges.details': 'Abzeichen-Details',
'badges.earned_count': '{earned} von {total} Abzeichen verdient',
'badges.category': 'Kategorie',
'badges.competency': 'Kompetenz',
'badges.attendance': 'Anwesenheit',
'badges.behavior': 'Verhalten',
'badges.achievement': 'Leistung',
'badges.milestone': 'Meilenstein',
'badges.requirement': 'Voraussetzung',
'badges.create': 'Abzeichen erstellen',
'badges.edit': 'Abzeichen bearbeiten',
'badges.delete': 'Abzeichen löschen',
'badges.manage': 'Abzeichen verwalten',
'badges.statistics': 'Abzeichen-Statistik',
'badges.award_manually': 'Manuell verleihen',
'badges.awarded_by': 'Verliehen von',
'badges.awarded_at': 'Verliehen am',
'badges.no_badges': 'Keine Abzeichen vorhanden',
'badges.celebration': 'Neues Abzeichen verdient!',
'badges.icon': 'Icon',
'badges.color': 'Farbe',
'badges.threshold': 'Schwellenwert',
'badges.type': 'Typ',
'badges.is_auto': 'Automatisch vergeben',
// Badge Names
'badges.perfect_attendance': 'Perfekte Anwesenheit',
'badges.competency_master': 'Kompetenz-Meister',
'badges.progress_pioneer': 'Fortschritts-Pionier',
'badges.behavior_star': 'Verhaltensstern',
'badges.notebook_champion': 'Heft-Champion',
'badges.drawing_artist': 'Zeichen-Künstler',
'badges.homework_hero': 'Hausaufgaben-Held',
'badges.team_player': 'Teamplayer',
'badges.goal_achiever': 'Zielerreicher',
'badges.eco_warrior': 'Umweltkrieger',
};
const en: TranslationDict = {
@@ -6167,6 +6312,151 @@ const en: TranslationDict = {
'onboarding.previous': 'Previous',
'onboarding.dont_show_again': 'Don\'t show again',
'onboarding.restart_tour': 'Restart tour',
// Student Transportation
'transport.title': 'Transportation',
'transport.type': 'Transport Type',
'transport.bus': 'Bus',
'transport.tram': 'Tram',
'transport.walk': 'Walk',
'transport.car': 'Car',
'transport.bike': 'Bike',
'transport.other': 'Other',
'transport.route': 'Route',
'transport.stop': 'Stop',
'transport.pickup': 'Pickup',
'transport.dropoff': 'Drop-off',
'transport.driver': 'Driver',
'transport.distance': 'Distance',
'transport.distance_km': '{count} km',
'transport.notes': 'Notes',
'transport.add': 'Add Transportation',
'transport.edit': 'Edit Transportation',
'transport.no_transport': 'No transportation saved',
'transport.route_number': 'Route Number',
'transport.stop_name': 'Stop Name',
'transport.pickup_time': 'Pickup Time',
'transport.dropoff_time': 'Drop-off Time',
'transport.driver_name': 'Driver Name',
'transport.driver_phone': 'Driver Phone',
'transport.distance_km_field': 'Distance (km)',
'transport.class_overview': 'Class Transport Overview',
'transport.quick_call': 'Quick Call',
// Health Records
'health.title': 'Health Records',
'health.blood_type': 'Blood Type',
'health.allergies': 'Allergies',
'health.medications': 'Medications',
'health.conditions': 'Conditions',
'health.doctor': 'Doctor',
'health.insurance': 'Insurance',
'health.confidential': 'Confidential',
'health.last_checkup': 'Last Checkup',
'health.warning': 'Health Warning',
'health.add': 'Add Health Record',
'health.edit': 'Edit Health Record',
'health.no_record': 'No health records available',
'health.doctor_name': 'Doctor Name',
'health.doctor_phone': 'Doctor Phone',
'health.insurance_number': 'Insurance Number',
'health.insurance_provider': 'Insurance Provider',
'health.last_checkup_date': 'Last Checkup Date',
'health.notes': 'Health Notes',
'health.confidential_toggle': 'Confidentiality Status',
'health.dosage': 'Dosage',
'health.frequency': 'Frequency',
'health.medication_name': 'Medication Name',
'health.add_allergy': 'Add Allergy',
'health.add_medication': 'Add Medication',
'health.add_condition': 'Add Condition',
// Interactive Seating Chart
'seating.title': 'Seating Chart',
'seating.chart': 'Seating Order',
'seating.row': 'Row',
'seating.column': 'Column',
'seating.save': 'Save Seating',
'seating.randomize': 'Randomize',
'seating.clear': 'Clear',
'seating.print': 'Print Seating',
'seating.drag_to_rearrange': 'Drag to rearrange',
'seating.empty_seat': 'Empty Seat',
'seating.rows': 'Rows',
'seating.columns': 'Columns',
'seating.grid_size': 'Grid Size',
'seating.student_initials': 'Initials',
'seating.no_students': 'No students for seating chart',
'seating.visual': 'Visual Seating',
'seating.list': 'List View',
// QR Code System
'qr.title': 'QR Code',
'qr.student': 'Student QR Code',
'qr.class': 'Class QR Code',
'qr.attendance': 'Attendance QR Code',
'qr.event': 'Event QR Code',
'qr.download': 'Download QR Code',
'qr.scan': 'Scan QR Code',
'qr.refresh': 'Refresh QR Code',
'qr.generate': 'Generate QR Code',
'qr.scan_to_checkin': 'Scan to check in',
'qr.session_active': 'QR session active',
'qr.refreshes_in': 'Refreshes in {seconds}s',
'qr.checked_in': 'Checked in',
'qr.pending': 'Pending',
'qr.absent': 'Absent',
'qr.show_qr': 'Show QR Code',
'qr.print_report': 'QR on Report',
'qr.student_card': 'Student Card',
// Badge / Gamification System
'badges.title': 'Badges',
'badges.earned': 'Earned',
'badges.unearned': 'Not yet earned',
'badges.progress': 'Progress',
'badges.collection': 'Badge Collection',
'badges.award': 'Award Badge',
'badges.auto': 'Auto-awarded',
'badges.manual': 'Manually awarded',
'badges.leaderboard': 'Badge Leaderboard',
'badges.recent': 'Recently earned',
'badges.details': 'Badge Details',
'badges.earned_count': '{earned} of {total} badges earned',
'badges.category': 'Category',
'badges.competency': 'Competency',
'badges.attendance': 'Attendance',
'badges.behavior': 'Behavior',
'badges.achievement': 'Achievement',
'badges.milestone': 'Milestone',
'badges.requirement': 'Requirement',
'badges.create': 'Create Badge',
'badges.edit': 'Edit Badge',
'badges.delete': 'Delete Badge',
'badges.manage': 'Manage Badges',
'badges.statistics': 'Badge Statistics',
'badges.award_manually': 'Award Manually',
'badges.awarded_by': 'Awarded by',
'badges.awarded_at': 'Awarded on',
'badges.no_badges': 'No badges available',
'badges.celebration': 'New badge earned!',
'badges.icon': 'Icon',
'badges.color': 'Color',
'badges.threshold': 'Threshold',
'badges.type': 'Type',
'badges.is_auto': 'Auto-awarded',
// Badge Names
'badges.perfect_attendance': 'Perfect Attendance',
'badges.competency_master': 'Competency Master',
'badges.progress_pioneer': 'Progress Pioneer',
'badges.behavior_star': 'Behavior Star',
'badges.notebook_champion': 'Notebook Champion',
'badges.drawing_artist': 'Drawing Artist',
'badges.homework_hero': 'Homework Hero',
'badges.team_player': 'Team Player',
'badges.goal_achiever': 'Goal Achiever',
'badges.eco_warrior': 'Eco Warrior',
};
const translations: Record<string, TranslationDict> = { de, en };
+387
View File
@@ -0,0 +1,387 @@
// CompetenceTrack — QR Code Generation Utility
// Generates QR codes as base64 PNG images using canvas
export type QRCodeType = 'student' | 'class' | 'attendance' | 'event';
export interface QRCodeData {
type: QRCodeType;
id: string;
schoolId?: string;
label?: string;
// For attendance QR: session-specific data
sessionId?: string;
timestamp?: number;
}
/**
* Build a URL string from QR code data that can be encoded into a QR code
*/
export function buildQRCodeUrl(data: QRCodeData): string {
const base = typeof window !== 'undefined' ? window.location.origin : 'https://competencetrack.app';
switch (data.type) {
case 'student':
return `${base}/?student=${data.id}`;
case 'class':
return `${base}/?class=${data.id}`;
case 'attendance':
return `${base}/?attendance=${data.sessionId || data.id}&t=${data.timestamp || Date.now()}`;
case 'event':
return `${base}/?event=${data.id}`;
default:
return `${base}/?id=${data.id}`;
}
}
// Simple QR code encoder using Canvas API
// This implements a minimal QR code generator
// QR Code error correction levels
const ERROR_CORRECTION_LEVEL = {
L: 0, // 7% recovery
M: 1, // 15% recovery
Q: 2, // 25% recovery
H: 3, // 30% recovery
};
// QR Code version sizes (modules per side)
const VERSION_SIZES = [
0, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, // 1-10
61, 65, 69, 73, 77, 81, 85, 89, 93, 97, // 11-20
101, 105, 109, 113, 117, 121, 125, 129, 133, 137, // 21-30
141, 145, 149, 153, 157, 161, 165, 169, 173, 177, // 31-40
];
// Simplified QR code generation using canvas
// We use a well-known approach: encode data to a binary matrix, then render to canvas
function getAlphanumericCode(char: string): number {
const codes = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
const idx = codes.indexOf(char);
return idx >= 0 ? idx : -1;
}
function isAlphanumeric(text: string): boolean {
for (const c of text) {
if (getAlphanumericCode(c) < 0) return false;
}
return true;
}
interface QRMatrix {
size: number;
modules: boolean[][]; // true = dark
reserved: boolean[][];
}
function createMatrix(version: number): QRMatrix {
const size = VERSION_SIZES[version];
const modules: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false));
const reserved: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false));
return { size, modules, reserved };
}
function setFunctionPatterns(matrix: QRMatrix): void {
const { size, modules, reserved } = matrix;
// Finders: 7x7 at three corners
const finderPositions = [
[0, 0], [0, size - 7], [size - 7, 0],
];
for (const [row, col] of finderPositions) {
for (let r = -1; r <= 7; r++) {
for (let c = -1; c <= 7; c++) {
const rr = row + r;
const cc = col + c;
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
const isDark = (r >= 0 && r <= 6 && (c === 0 || c === 6)) ||
(c >= 0 && c <= 6 && (r === 0 || r === 6)) ||
(r >= 2 && r <= 4 && c >= 2 && c <= 4);
modules[rr][cc] = isDark;
reserved[rr][cc] = true;
}
}
}
// Timing patterns
for (let i = 8; i < size - 8; i++) {
if (!reserved[6][i]) {
modules[6][i] = i % 2 === 0;
reserved[6][i] = true;
}
if (!reserved[i][6]) {
modules[i][6] = i % 2 === 0;
reserved[i][6] = true;
}
}
// Alignment patterns (for version >= 2)
if (size >= 25) {
const alignPositions = getAlignmentPositions(size);
for (const row of alignPositions) {
for (const col of alignPositions) {
if (reserved[row]?.[col]) continue;
for (let r = -2; r <= 2; r++) {
for (let c = -2; c <= 2; c++) {
const rr = row + r;
const cc = col + c;
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
if (reserved[rr][cc]) continue;
const isDark = Math.abs(r) === 2 || Math.abs(c) === 2 || (r === 0 && c === 0);
modules[rr][cc] = isDark;
reserved[rr][cc] = true;
}
}
}
}
}
// Reserve format info areas
for (let i = 0; i < 8; i++) {
reserved[8][i] = true;
reserved[i][8] = true;
reserved[8][size - 1 - i] = true;
reserved[size - 1 - i][8] = true;
}
reserved[8][8] = true;
reserved[size - 8][8] = true;
reserved[8][size - 8] = true;
}
function getAlignmentPositions(size: number): number[] {
// Simplified: common alignment positions for various versions
const positions: number[] = [6];
if (size === 25) positions.push(18);
else if (size === 29) positions.push(22);
else if (size === 33) positions.push(26);
else if (size === 37) positions.push(30);
else if (size === 41) positions.push(34);
else if (size === 45) positions.push(38);
else if (size === 49) positions.push(42);
else if (size === 53) positions.push(46);
else positions.push(size - 7);
return positions;
}
function encodeData(text: string, version: number): number[] {
// Simple byte encoding
const data: number[] = [];
// Mode indicator: 0100 (byte mode)
data.push(0, 1, 0, 0);
// Character count (8 bits for byte mode, version 1-9)
const charCountBits = version <= 9 ? 8 : 16;
const countBin = text.length.toString(2).padStart(charCountBits, '0');
for (const bit of countBin) {
data.push(parseInt(bit));
}
// Data
for (const char of text) {
const byte = char.charCodeAt(0);
const byteBin = byte.toString(2).padStart(8, '0');
for (const bit of byteBin) {
data.push(parseInt(bit));
}
}
// Terminator
const maxDataBits = getDataCapacity(version);
const terminatorLen = Math.min(4, maxDataBits - data.length);
for (let i = 0; i < terminatorLen; i++) {
data.push(0);
}
// Pad to byte boundary
while (data.length % 8 !== 0) {
data.push(0);
}
// Pad bytes
const padBytes = [0xEC, 0x11];
let padIdx = 0;
while (data.length < maxDataBits) {
const byte = padBytes[padIdx % 2];
const byteBin = byte.toString(2).padStart(8, '0');
for (const bit of byteBin) {
data.push(parseInt(bit));
}
padIdx++;
}
return data;
}
function getDataCapacity(version: number): number {
// Simplified: total data bits for byte mode with L error correction
const capacities = [0, 152, 272, 400, 576, 752, 976, 1200, 1456, 1728, 2032];
return capacities[Math.min(version, 10)] || 2032;
}
function placeData(matrix: QRMatrix, data: number[]): void {
const { size, modules, reserved } = matrix;
let bitIdx = 0;
let upward = true;
for (let col = size - 1; col >= 0; col -= 2) {
if (col === 6) col = 5; // Skip timing column
for (let row = 0; row < size; row++) {
const actualRow = upward ? (size - 1 - row) : row;
for (let c = 0; c < 2; c++) {
const actualCol = col - c;
if (actualCol < 0 || actualCol >= size) continue;
if (reserved[actualRow][actualCol]) continue;
if (bitIdx < data.length) {
modules[actualRow][actualCol] = data[bitIdx] === 1;
bitIdx++;
}
// If no more data, leave as false (white)
}
}
upward = !upward;
}
}
function applyMask(matrix: QRMatrix): void {
const { size, modules, reserved } = matrix;
// Apply mask pattern 0: (row + col) % 2 === 0
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
if (reserved[row][col]) continue;
if ((row + col) % 2 === 0) {
modules[row][col] = !modules[row][col];
}
}
}
}
function addFormatInfo(matrix: QRMatrix): void {
const { size, modules, reserved } = matrix;
// Format info for L error correction + mask 0: 0x77C4
const formatBits = [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0];
// Horizontal format info
for (let i = 0; i < 8; i++) {
if (!reserved[8][i]) modules[8][i] = formatBits[i] === 1;
if (!reserved[8][size - 1 - i]) modules[8][size - 1 - i] = formatBits[7 + i] === 1;
}
// Vertical format info
for (let i = 0; i < 7; i++) {
if (!reserved[i][8]) modules[i][8] = formatBits[i] === 1;
if (!reserved[size - 7 + i][8]) modules[size - 7 + i][8] = formatBits[i] === 1;
}
}
function selectVersion(text: string): number {
const byteLen = new TextEncoder().encode(text).length;
// Capacities for byte mode, L error correction
const capacities = [0, 17, 32, 53, 78, 106, 134, 154, 192, 230, 271];
for (let v = 1; v <= 10; v++) {
if (byteLen <= capacities[v]) return v;
}
return 10; // Max version we support
}
/**
* Generate a QR code matrix from text
*/
function generateQRMatrix(text: string): QRMatrix {
const version = selectVersion(text);
const matrix = createMatrix(version);
setFunctionPatterns(matrix);
const data = encodeData(text, version);
placeData(matrix, data);
applyMask(matrix);
addFormatInfo(matrix);
return matrix;
}
/**
* Render QR matrix to canvas and return base64 PNG
*/
function renderToBase64(matrix: QRMatrix, size: number = 256, fgColor: string = '#000000', bgColor: string = '#ffffff'): string {
// Only works in browser
if (typeof document === 'undefined') return '';
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return '';
const quietZone = 4; // 4 module quiet zone
const totalModules = matrix.size + quietZone * 2;
const moduleSize = size / totalModules;
// Background
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, size, size);
// Modules
ctx.fillStyle = fgColor;
for (let row = 0; row < matrix.size; row++) {
for (let col = 0; col < matrix.size; col++) {
if (matrix.modules[row][col]) {
const x = (col + quietZone) * moduleSize;
const y = (row + quietZone) * moduleSize;
ctx.fillRect(x, y, moduleSize + 0.5, moduleSize + 0.5); // +0.5 to avoid gaps
}
}
}
return canvas.toDataURL('image/png');
}
/**
* Generate a QR code as a base64 PNG data URL
*/
export async function generateQRCode(
data: QRCodeData,
options?: { size?: number; fgColor?: string; bgColor?: string }
): Promise<string> {
const url = buildQRCodeUrl(data);
const matrix = generateQRMatrix(url);
const size = options?.size ?? 256;
const fgColor = options?.fgColor ?? '#000000';
const bgColor = options?.bgColor ?? '#ffffff';
return renderToBase64(matrix, size, fgColor, bgColor);
}
/**
* Generate a QR code as a base64 PNG data URL (sync version for browser)
*/
export function generateQRCodeSync(
data: QRCodeData,
options?: { size?: number; fgColor?: string; bgColor?: string }
): string {
const url = buildQRCodeUrl(data);
const matrix = generateQRMatrix(url);
const size = options?.size ?? 256;
const fgColor = options?.fgColor ?? '#000000';
const bgColor = options?.bgColor ?? '#ffffff';
return renderToBase64(matrix, size, fgColor, bgColor);
}
/**
* Download a QR code image
*/
export function downloadQRCode(dataUrl: string, filename: string = 'qrcode.png'): void {
const link = document.createElement('a');
link.download = filename;
link.href = dataUrl;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}