diff --git a/DATABASE.md b/DATABASE.md index eb1231a..3cba45d 100644 --- a/DATABASE.md +++ b/DATABASE.md @@ -60,6 +60,7 @@ npm run db:migrate | `db:studio` | Open Prisma Studio GUI | | `db:reset` | Reset database (⚠️ destroys data) | | `db:generate` | Regenerate Prisma Client | +| `db:seed` | Seed demo user + competencies | ## Common Tasks @@ -88,6 +89,15 @@ npx prisma migrate status npm run db:reset ``` +**Seed demo content (user + 5 competencies):** + +```bash +npm run db:seed +``` + +Creates a demo user (`demo@memo.local`) and 5 sample competencies. Idempotent, safe to run multiple +times. + ## Schema See [prisma/schema.prisma](prisma/schema.prisma) for the complete schema. diff --git a/app/actions/competencies.ts b/app/actions/competencies.ts index 58ee9ba..9ce61a8 100644 --- a/app/actions/competencies.ts +++ b/app/actions/competencies.ts @@ -51,6 +51,21 @@ export async function getAllCompetenciesAction() { } } +export async function getRandomCompetenciesAction(count: number = 2) { + try { + const competencies = await competencyService.getRandomCompetencies(count); + return { success: true, competencies }; + } catch (error) { + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to fetch random competencies', + }; + } +} + export async function updateCompetencyAction( id: string, title?: string, diff --git a/app/actions/users.ts b/app/actions/users.ts index ad18685..e189757 100644 --- a/app/actions/users.ts +++ b/app/actions/users.ts @@ -65,3 +65,32 @@ export async function deleteUserAction(id: string) { }; } } + +/** + * Gets or creates the demo user for development/testing purposes. + * Returns the demo user with email 'demo@memo.local'. + */ +export async function getOrCreateDemoUserAction() { + try { + // Try to find existing demo user + let user = await userService.getUserByEmail('demo@memo.local'); + + // If not found, create it + if (!user) { + user = await userService.createUser({ + name: 'Demo User', + email: 'demo@memo.local', + }); + } + + return { success: true, user }; + } catch (error) { + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to get or create demo user', + }; + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx deleted file mode 100644 index c55d13e..0000000 --- a/app/dashboard/page.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import Link from 'next/link'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; - -export default function DashboardPage() { - return ( -
-
-
-
-
-
- -
-
- - Competency Benchmark Mapping Platform - -
-
- -
-
- -
-
- - Overview - -

- Contributor dashboard (placeholder) -

-

- Progress, streaks, and badges are mocked for now. Backend, auth, and - persistence are out of scope. -

-
- -
-
-

- Total mappings (example) -

-

42

-
-
-

- Streak -

-

3 days

-

- You mapped on 3 days in a row. -

-
-
-

- Badges -

-
    -
  • First 10 mappings
  • -
  • Consistency
  • -
  • Session finisher
  • -
-
-
- -
- - -
-
-
- ); -} diff --git a/app/page.tsx b/app/page.tsx index 2c6a6ee..11fa4a2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -35,17 +35,10 @@ const [leadProblem, ...otherProblems] = problemStatements; export default function Page() { return (
-
-
-
-
-
+
-
+

@@ -66,7 +59,7 @@ export default function Page() { className="h-12 rounded-full bg-[#0a4da2] px-7 text-base font-semibold text-white shadow-[0_18px_45px_-26px_rgba(7,30,84,0.75)] transition hover:bg-[#0d56b5]" asChild > - Start Contributing + Start Contributing

@@ -125,7 +118,7 @@ export default function Page() {
); } + +function LiquidGlassGradient() { + return ( +
+
+
+
+
+
+
+
+ ); +} diff --git a/app/session/page.tsx b/app/session/page.tsx index ce5fcc2..2d417a0 100644 --- a/app/session/page.tsx +++ b/app/session/page.tsx @@ -1,12 +1,26 @@ 'use client'; -import Link from 'next/link'; -import { useState } from 'react'; -import { Badge } from '@/components/ui/badge'; +import { useEffect, useState, useCallback, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { getRandomCompetenciesAction } from '@/app/actions/competencies'; +import { + createCompetencyRelationshipAction, + deleteCompetencyRelationshipAction, +} from '@/app/actions/competency_relationships'; +import { getOrCreateDemoUserAction } from '@/app/actions/users'; +import type { Competency } from '@/domain_core/model/domain_model'; +import { RelationshipType } from '@/domain_core/model/domain_model'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Kbd } from '@/components/ui/kbd'; +import { ArrowRight } from 'lucide-react'; import { Card, - CardContent, CardDescription, CardHeader, CardTitle, @@ -19,137 +33,299 @@ type SessionStats = { skipped: number; }; -type Competency = { - title: string; - description: string; - tags: string[]; - outcomes: string[]; +type RelationshipTypeOption = { + value: RelationshipType; + label: string; }; -const competencies: Competency[] = [ - { - title: 'Competency A', - description: - 'Short description about competency A. What students should be able to do.', - tags: ['Taxonomy', 'Knowledge Area'], - outcomes: [ - 'Key learning outcome 1', - 'Key learning outcome 2', - 'Key learning outcome 3', - ], - }, - { - title: 'Competency B', - description: "Short description about competency B and why it's relevant.", - tags: ['Taxonomy', 'Knowledge Area'], - outcomes: [ - 'Learning outcome A', - 'Learning outcome B', - 'Learning outcome C', - ], - }, -]; - -const relationTypes = [ - { value: 'isPrerequisiteOf', label: 'isPrerequisiteOf' }, - { value: 'isSimilarTo', label: 'isSimilarTo' }, - { value: 'isEquivalentTo', label: 'isEquivalentTo' }, - { value: 'isPartOf', label: 'isPartOf' }, -] as const; - -const defaultRelationType = relationTypes[0]!.value; - -function CompetencyCard({ title, description, tags, outcomes }: Competency) { - return ( - - -
- {tags.map(tag => ( - - {tag} - - ))} -
-
- {title} - - {description} - -
-
- -
    - {outcomes.map(outcome => ( -
  • {outcome}
  • - ))} -
-
-
- ); -} +// Define relationship types statically from the enum - no server call needed +const RELATIONSHIP_TYPES: RelationshipTypeOption[] = ( + Object.values(RelationshipType) as RelationshipType[] +).map(type => ({ + value: type, + label: type.charAt(0) + type.slice(1).toLowerCase(), +})); -export default function SessionPage() { - const [relation, setRelation] = useState(defaultRelationType); +function SessionPageContent() { + const searchParams = useSearchParams(); + const countParam = searchParams.get('count'); + const parsedCount = countParam ? Number(countParam) : NaN; + const count = + Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 2; + + const [relation, setRelation] = useState( + RELATIONSHIP_TYPES[0]!.value + ); const [stats, setStats] = useState({ completed: 0, skipped: 0, }); - const [, setHistory] = useState>([]); + const [history, setHistory] = useState< + Array<{ + type: 'completed' | 'skipped'; + relationshipId?: string; + competencies?: Competency[]; + }> + >([]); + const [competencies, setCompetencies] = useState(null); + const [error, setError] = useState(null); + const [userId, setUserId] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [relationshipToDelete, setRelationshipToDelete] = useState< + string | null + >(null); + + useEffect(() => { + async function loadDemoUser() { + try { + const result = await getOrCreateDemoUserAction(); + if (result.success && result.user) { + setUserId(result.user.id); + } else { + setError( + result.error ?? + 'Failed to load user information. Please try again later.' + ); + } + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'An unexpected error occurred while loading user information.' + ); + } + } + + void loadDemoUser(); + }, []); - function handleAction(type: 'completed' | 'skipped') { - setStats(prev => ({ ...prev, [type]: prev[type] + 1 })); - setHistory(prev => [...prev, type]); - } + const loadCompetencies = useCallback(async () => { + setError(null); + setCompetencies(null); - function handleUndo() { + const result = await getRandomCompetenciesAction(count); + if (!result.success) { + setError( + result.error ?? + 'An unexpected error occurred while fetching competencies.' + ); + setCompetencies([]); + return; + } + + if (!result.competencies || result.competencies.length === 0) { + setCompetencies([]); + return; + } + + setCompetencies(result.competencies); + }, [count]); + + useEffect(() => { + void loadCompetencies(); + }, [loadCompetencies]); + + const handleAction = useCallback( + async (type: 'completed' | 'skipped') => { + if (type === 'completed') { + // Create relationship in database using FormData + if (!competencies || competencies.length < 2 || !relation || !userId) { + setError('Missing required data to create relationship'); + return; + } + + setIsCreating(true); + setError(null); + + try { + const formData = new FormData(); + formData.set('relationshipType', relation); + formData.set('originId', competencies[0]!.id); + formData.set('destinationId', competencies[1]!.id); + formData.set('userId', userId); + + const result = await createCompetencyRelationshipAction(formData); + + if (!result.success) { + setError(result.error ?? 'Failed to create relationship'); + setIsCreating(false); + return; + } + + // Success - update stats and reload new competencies + setStats(prev => ({ ...prev, completed: prev.completed + 1 })); + setHistory(prev => [ + ...prev, + { + type: 'completed', + relationshipId: result.relationship?.id, + competencies: competencies ? [...competencies] : undefined, + }, + ]); + await loadCompetencies(); + } catch (err) { + setError( + err instanceof Error ? err.message : 'An unexpected error occurred' + ); + } finally { + setIsCreating(false); + } + } else if (type === 'skipped') { + // When skipping, just update stats and reload + setStats(prev => ({ ...prev, skipped: prev.skipped + 1 })); + setHistory(prev => [ + ...prev, + { + type: 'skipped', + competencies: competencies ? [...competencies] : undefined, + }, + ]); + try { + await loadCompetencies(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'An unexpected error occurred while fetching competencies.' + ); + } + } + }, + [competencies, relation, userId, loadCompetencies] + ); + + const handleUndo = useCallback(() => { setHistory(prev => { const updated = [...prev]; const last = updated.pop(); if (last) { setStats(prevStats => ({ ...prevStats, - [last]: Math.max(0, prevStats[last] - 1), + [last.type]: Math.max(0, prevStats[last.type] - 1), })); + + // If it was a completed action with a relationship, mark it for deletion + if (last.type === 'completed' && last.relationshipId) { + setRelationshipToDelete(last.relationshipId); + } + + // Restore the competencies that were shown before this action + if (last.competencies && last.competencies.length >= 2) { + setCompetencies(last.competencies); + } } return updated; }); - } + }, []); + + // Delete relationship from DB when relationshipToDelete is set + useEffect(() => { + if (relationshipToDelete) { + deleteCompetencyRelationshipAction(relationshipToDelete) + .then(result => { + if (!result.success) { + setError(result.error ?? 'Failed to delete relationship'); + } + }) + .catch(err => { + setError( + err instanceof Error ? err.message : 'Failed to delete relationship' + ); + }) + .finally(() => { + setRelationshipToDelete(null); + }); + } + }, [relationshipToDelete]); + + const isLoading = competencies === null && !error; + const noCompetencies = competencies !== null && competencies.length === 0; + const notEnough = + !!competencies && competencies.length > 0 && competencies.length < 2; + + // Keyboard shortcuts + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + // Don't trigger shortcuts if user is typing in an input/textarea + const target = event.target as HTMLElement; + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ) { + return; + } + + // ⌘+Shift+Z or Ctrl+Shift+Z for Undo (only if there's history to undo) + // Using Shift+Z to avoid browser's default undo behavior + if ( + (event.metaKey || event.ctrlKey) && + event.key === 'z' && + event.shiftKey && + history.length > 0 + ) { + event.preventDefault(); + event.stopPropagation(); + handleUndo(); + return; + } + + // Space for Skip (only if not loading and has competencies) + if ( + event.key === ' ' && + !isLoading && + !isCreating && + competencies && + competencies.length >= 2 + ) { + event.preventDefault(); + handleAction('skipped'); + return; + } + + // Enter for Add Relation (only if not loading, not creating, has userId, relation, and competencies) + if ( + event.key === 'Enter' && + !isLoading && + !isCreating && + userId && + relation && + competencies && + competencies.length >= 2 + ) { + event.preventDefault(); + handleAction('completed'); + return; + } + } + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [ + isLoading, + isCreating, + userId, + relation, + competencies, + history, + handleAction, + handleUndo, + ]); return ( -
+
-
-
- - Competency Benchmark Mapping Platform - -
-
- - -
-
- -
+
Mapping Session @@ -157,78 +333,270 @@ export default function SessionPage() { Completed: {stats.completed}
-

- How does Competency A relate to Competency B? -

- -
-
- -
- -
-
Select Relation Type
- - - - {relationTypes.map(({ value, label }) => ( -
- - -
- ))} -
-
-
-
- -
- -
-
+ {error && ( + + + + Failed to load competencies + + + {error} + + + + )} -
- -
- - -
+ {noCompetencies && !error && ( + + + + No competencies available + + + There are currently no competencies in the database. Please + run the seed script (e.g. npm run db:seed) or + create competencies manually before starting a mapping + session. + + + + )} + + {notEnough && !error && ( + + + + Not enough competencies + + + At least two competencies are required to start a mapping + session. Please add more competencies first. + + + + )} + + {!error && !noCompetencies && !notEnough && ( + <> +

+ {isLoading || !competencies || competencies.length < 2 + ? 'Loading competencies for this mapping session...' + : `How does "${competencies[0]!.title}" relate to "${ + competencies[1]!.title + }"?`} +

+ +
+
+ {isLoading || !competencies || !competencies[0] ? ( + + + + Loading first competency... + + + + ) : ( + + +
+ {/* Placeholder badges; replace with competency metadata once available */} + + + + Apply + + + +

+ Bloom's Taxonomy categorizes learning + objectives by cognitive level. +

+

+ → Apply: use knowledge in practice (implement, + execute, solve). +

+

+ Levels: Remember • Understand • Apply • Analyze + • Evaluate • Create +

+
+
+ + Control Flow + +
+
+ + {competencies[0]!.title} + + + {competencies[0]!.description} + +
+
+
+ )} +
+ +
+
+
+
+ +
+ {isLoading || !competencies || !competencies[1] ? ( + + + + Loading second competency... + + + + ) : ( + + +
+ {/* Placeholder badges; replace with competency metadata once available */} + + + + Apply + + + +

+ Bloom's Taxonomy categorizes learning + objectives by cognitive level. +

+

+ → Apply: use knowledge in practice (implement, + execute, solve). +

+

+ Levels: Remember • Understand • Apply • Analyze + • Evaluate • Create +

+
+
+ + Programming Fundamentals + +
+
+ + {competencies[1]!.title} + + + {competencies[1]!.description} + +
+
+
+ )} +
+
+ +
+ +
+ + +
+ + )}
); } + +export default function SessionPage() { + return ( + +
Loading session...
+
+ } + > + + + ); +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index 3f4ccd9..f072945 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -1,8 +1,6 @@ import { cn } from '../../domain_core/infrastructure/utils'; -export interface BadgeProps { - children?: React.ReactNode; - className?: string; +export interface BadgeProps extends React.HTMLAttributes { variant?: 'default' | 'secondary' | 'outline' | 'destructive'; } @@ -10,6 +8,7 @@ export const Badge = ({ children, className, variant = 'default', + ...props }: BadgeProps) => { const variants: Record = { default: 'bg-primary text-primary-foreground', @@ -25,6 +24,7 @@ export const Badge = ({ variants[variant], className )} + {...props} > {children} diff --git a/components/ui/card.tsx b/components/ui/card.tsx index c3460aa..18b691c 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -10,7 +10,7 @@ export const Card = ({ return (
@@ -36,7 +36,7 @@ export const CardTitle = ({ }) => (

diff --git a/components/ui/kbd.tsx b/components/ui/kbd.tsx new file mode 100644 index 0000000..dc0a617 --- /dev/null +++ b/components/ui/kbd.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/domain_core/infrastructure/utils'; + +function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { + return ( + + ); +} + +function KbdGroup({ className, ...props }: React.ComponentProps<'kbd'>) { + return ( + + ); +} + +export { Kbd, KbdGroup }; diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 0000000..a7c9a03 --- /dev/null +++ b/components/ui/tooltip.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cn } from '@/domain_core/infrastructure/utils'; + +type TooltipProps = { + children: React.ReactNode; + className?: string; + delayDuration?: number; + side?: 'top' | 'bottom'; +}; + +type TooltipContentProps = { + children: React.ReactNode; + className?: string; +}; + +type TooltipTriggerProps = { + children: React.ReactNode; + asChild?: boolean; + className?: string; +}; + +type TooltipContextValue = { + open: boolean; + setOpen: (open: boolean) => void; + delayDuration: number; + timerRef: React.MutableRefObject | null>; + side: 'top' | 'bottom'; +}; + +const TooltipContext = React.createContext(null); + +export function Tooltip({ + children, + className, + delayDuration = 300, + side = 'top', +}: TooltipProps) { + const [open, setOpen] = React.useState(false); + const timerRef = React.useRef | null>(null); + + return ( + +
{children}
+
+ ); +} + +export function TooltipTrigger({ + children, + asChild, + className, +}: TooltipTriggerProps) { + const context = React.useContext(TooltipContext); + + if (!context) { + throw new Error('TooltipTrigger must be used within a Tooltip'); + } + + const { setOpen, delayDuration, timerRef } = context; + const clearTimer = () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + + const handleOpen = () => { + clearTimer(); + timerRef.current = setTimeout(() => { + setOpen(true); + }, delayDuration); + }; + + const handleClose = () => { + clearTimer(); + setOpen(false); + }; + const triggerProps = { + onMouseEnter: handleOpen, + onMouseLeave: handleClose, + onFocus: handleOpen, + onBlur: handleClose, + }; + + const TriggerComponent = asChild ? Slot : 'span'; + + return ( + + {children} + + ); +} + +export function TooltipContent({ children, className }: TooltipContentProps) { + const context = React.useContext(TooltipContext); + + if (!context) { + throw new Error('TooltipContent must be used within a Tooltip'); + } + + const { open, side } = context; + const isTop = side === 'top'; + + return ( +
+ {children} +
+ ); +} diff --git a/domain_core/services/competency.ts b/domain_core/services/competency.ts index 1db27c7..549aa4e 100644 --- a/domain_core/services/competency.ts +++ b/domain_core/services/competency.ts @@ -24,6 +24,20 @@ export class CompetencyService { return this.repository.findAll(); } + /** + * Returns a random subset of competencies, used for mapping sessions. + * Business logic lives in the service layer (see DOMAIN_CORE.md). + */ + async getRandomCompetencies(count: number) { + const all = await this.repository.findAll(); + if (all.length === 0 || count <= 0) { + return []; + } + + const shuffled = [...all].sort(() => Math.random() - 0.5); + return shuffled.slice(0, Math.min(count, shuffled.length)); + } + async updateCompetency(id: string, data: UpdateCompetencyInput) { await this.getCompetencyById(id); return this.repository.update(id, data); diff --git a/package.json b/package.json index 98d2484..be333af 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,11 @@ "db:migrate:prod": "prisma migrate deploy", "db:push": "prisma db push", "db:studio": "prisma studio", - "db:reset": "prisma migrate reset" + "db:reset": "prisma migrate reset", + "db:seed": "prisma db seed" + }, + "prisma": { + "seed": "node prisma/seed.js" }, "dependencies": { "@prisma/client": "^6.19.0", diff --git a/prisma/seed.js b/prisma/seed.js new file mode 100644 index 0000000..7e249fa --- /dev/null +++ b/prisma/seed.js @@ -0,0 +1,77 @@ +// Load environment variables from .env file +require('dotenv').config(); +const { PrismaClient, UserRole } = require('@prisma/client'); + +// Initialize Prisma Client for database operations +const prisma = new PrismaClient(); + +// Define competencies to seed into the database +const competencies = [ + { + title: 'Data Structures and Pattern Matching', + description: + 'Utilize built-in data types, implement pattern matching to deconstruct complex types, and create user-defined data structures.', + }, + { + title: 'Functional Programming Paradigms', + description: + 'Implement solutions using pure functions without side effects, write recursive functions, and optimize through tail recursion.', + }, + { + title: 'Higher-Order Functions', + description: + 'Differentiate between named and anonymous functions, create higher-order functions, and apply currying and partial application techniques.', + }, + { + title: 'Polymorphism', + description: + 'Understand polymorphism and instantiation, implement polymorphic functions and data types for general-purpose reusable code.', + }, + { + title: 'Module System and Abstraction', + description: + 'Design modules with clear interfaces, implement information hiding through abstract types, create functors, and structure programs using modular components.', + }, +]; + +async function main() { + if (!process.env.DATABASE_URL) { + throw new Error( + 'DATABASE_URL is not set. Provide it in environment variables or a .env file.' + ); + } + + console.log('[Seed] Starting database seed'); + + // Create or update demo user (upsert = update if exists, create if not) + const user = await prisma.user.upsert({ + where: { email: 'demo@memo.local' }, + update: {}, + create: { + name: 'Demo User', + email: 'demo@memo.local', + role: UserRole.USER, + }, + }); + console.log(`[Seed] Demo user ready: ${user.email}`); + + // Seed competencies in bulk, skipping duplicates by title + const result = await prisma.competency.createMany({ + data: competencies, + skipDuplicates: true, + }); + + console.log( + `[Seed] Seeding complete. Created ${result.count} new competencies (duplicates skipped).` + ); +} + +// Execute main function and handle errors +main() + .catch(error => { + console.error('[Seed] Error during seeding:', error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + });