);
}
+
+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 (
-
+
-
-
-
+
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 }) => (
-
-
-
- {label}
-
-
- ))}
-
-
-
-
-
-
-
-
-
+ {error && (
+
+
+
+ Failed to load competencies
+
+
+ {error}
+
+
+
+ )}
-
-
- Undo ⌘+Z
-
-
-
handleAction('skipped')}
- >
- Skip
-
-
handleAction('completed')}
- >
- Add Relation
-
-
+ {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}
+
+
+
+
+ )}
+
+
+
+
+
+ Select Relation Type
+
+
+
+ setRelation(value as RelationshipType)
+ }
+ className="gap-4 items-start"
+ >
+ {RELATIONSHIP_TYPES.map(({ value, label }) => (
+
+
+
+ {label}
+
+
+ ))}
+
+
+
+
+
+ {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}
+
+
+
+
+ )}
+
+
+
+
+
+ Undo{' '}
+
+ ⌘
+
+
+ ⇧
+
+
+ Z
+
+
+
+
handleAction('skipped')}
+ disabled={isLoading}
+ >
+ Skip{' '}
+
+ Space
+
+
+
handleAction('completed')}
+ disabled={isLoading || isCreating || !userId || !relation}
+ >
+ {isCreating ? 'Creating...' : 'Add Relation'}{' '}
+
+ ⏎
+
+
+
+ >
+ )}
);
}
+
+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();
+ });