diff --git a/app/globals.css b/app/globals.css
index 01d8300..a057498 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -81,3 +81,26 @@
@apply bg-background text-foreground;
}
}
+
+@layer utilities {
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar {
+ width: 8px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: rgb(203 213 225);
+ border-radius: 4px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background-color: rgb(148 163 184);
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 013d117..7de6703 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -37,10 +37,10 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
-
+
- {children}
+
{children}
diff --git a/app/page.tsx b/app/page.tsx
index 11fa4a2..796013b 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -46,7 +46,7 @@ export default function Page() {
transparent competency benchmark
- . Let’s build it together.
+ . Let's build it together.
Memo turns collaborative competency mapping into an open
@@ -92,15 +92,7 @@ export default function Page() {
Competency-Based Recommenders
-
+
Competency-based recommenders use{' '}
competency networks
diff --git a/app/session/page.tsx b/app/session/page.tsx
index 2d417a0..2f819a0 100644
--- a/app/session/page.tsx
+++ b/app/session/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useState, useCallback, Suspense } from 'react';
+import { useEffect, useState, useCallback, useRef, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { getRandomCompetenciesAction } from '@/app/actions/competencies';
import {
@@ -18,15 +18,22 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Kbd } from '@/components/ui/kbd';
-import { ArrowRight } from 'lucide-react';
+import {
+ ArrowRight,
+ Info,
+ ArrowLeftRight,
+ Check,
+ Layers,
+ TrendingUp,
+ Equal,
+ Unlink,
+} from 'lucide-react';
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
-import { Label } from '@/components/ui/label';
-import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
type SessionStats = {
completed: number;
@@ -46,6 +53,74 @@ const RELATIONSHIP_TYPES: RelationshipTypeOption[] = (
label: type.charAt(0) + type.slice(1).toLowerCase(),
}));
+// Blue-Purple theme colors (hardcoded after user selection)
+const THEME_COLORS = {
+ source: { primary: '#0a4da2', secondary: '#4263eb' },
+ destination: { primary: '#7c3aed', secondary: '#9775fa' },
+};
+
+// Helper to get dynamic description for relationship types
+// Used in the live preview banner
+const getRelationshipDescription = (
+ type: RelationshipType,
+ titleA: string,
+ titleB: string
+) => {
+ switch (type) {
+ case 'ASSUMES':
+ return (
+ <>
+ {titleA} requires{' '}
+ {titleB} as
+ prerequisite
+ >
+ );
+ case 'EXTENDS':
+ return (
+ <>
+ {titleA} builds on
+ / is a subset or advanced form of{' '}
+ {titleB}
+ >
+ );
+ case 'MATCHES':
+ return (
+ <>
+ {titleA} is
+ equivalent / strongly overlaps with{' '}
+ {titleB}
+ >
+ );
+ case 'UNRELATED':
+ return (
+ <>
+ {titleA} and{' '}
+ {titleB} have no
+ meaningful relationship
+ >
+ );
+ default:
+ return '';
+ }
+};
+
+// Text colors for relationship types (used in preview sentence)
+const RELATIONSHIP_TYPE_TEXT_COLORS: Record = {
+ ASSUMES: 'text-blue-600',
+ EXTENDS: 'text-purple-600',
+ MATCHES: 'text-emerald-600',
+ UNRELATED: 'text-slate-600',
+};
+
+// Shared scrollbar styles for competency description containers
+const SCROLLBAR_STYLES: {
+ scrollbarWidth: 'thin';
+ scrollbarColor: string;
+} = {
+ scrollbarWidth: 'thin',
+ scrollbarColor: 'rgb(203 213 225) transparent',
+};
+
function SessionPageContent() {
const searchParams = useSearchParams();
const countParam = searchParams.get('count');
@@ -53,9 +128,7 @@ function SessionPageContent() {
const count =
Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 2;
- const [relation, setRelation] = useState(
- RELATIONSHIP_TYPES[0]!.value
- );
+ const [relation, setRelation] = useState(null);
const [stats, setStats] = useState({
completed: 0,
skipped: 0,
@@ -71,6 +144,31 @@ function SessionPageContent() {
const [error, setError] = useState(null);
const [userId, setUserId] = useState(null);
const [isCreating, setIsCreating] = useState(false);
+ const [isTransitioning, setIsTransitioning] = useState(false);
+ const [swapRotation, setSwapRotation] = useState(0);
+ const [hoveredRelation, setHoveredRelation] =
+ useState(null);
+ const hoverTimeoutRef = useRef(null);
+
+ const handleMouseEnter = useCallback((val: RelationshipType) => {
+ if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
+ hoverTimeoutRef.current = setTimeout(() => setHoveredRelation(val), 120);
+ }, []);
+
+ const handleMouseLeave = useCallback(() => {
+ if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
+ hoverTimeoutRef.current = setTimeout(() => setHoveredRelation(null), 70);
+ }, []);
+
+ // Cleanup hover timeout on unmount to prevent memory leaks
+ useEffect(() => {
+ return () => {
+ if (hoverTimeoutRef.current) {
+ clearTimeout(hoverTimeoutRef.current);
+ hoverTimeoutRef.current = null;
+ }
+ };
+ }, []);
const [relationshipToDelete, setRelationshipToDelete] = useState<
string | null
>(null);
@@ -99,30 +197,43 @@ function SessionPageContent() {
void loadDemoUser();
}, []);
- const loadCompetencies = useCallback(async () => {
- setError(null);
- setCompetencies(null);
+ const loadCompetencies = useCallback(
+ async (isInitialLoad = false) => {
+ setError(null);
- const result = await getRandomCompetenciesAction(count);
- if (!result.success) {
- setError(
- result.error ??
- 'An unexpected error occurred while fetching competencies.'
- );
- setCompetencies([]);
- return;
- }
+ // Only set competencies to null on initial load, not during transitions
+ if (isInitialLoad) {
+ setCompetencies(null);
+ } else {
+ setIsTransitioning(true);
+ }
- if (!result.competencies || result.competencies.length === 0) {
- setCompetencies([]);
- return;
- }
+ const result = await getRandomCompetenciesAction(count);
- setCompetencies(result.competencies);
- }, [count]);
+ if (!result.success) {
+ setError(
+ result.error ??
+ 'An unexpected error occurred while fetching competencies.'
+ );
+ setCompetencies([]);
+ setIsTransitioning(false);
+ return;
+ }
+
+ if (!result.competencies || result.competencies.length === 0) {
+ setCompetencies([]);
+ setIsTransitioning(false);
+ return;
+ }
+
+ setCompetencies(result.competencies);
+ setIsTransitioning(false);
+ },
+ [count]
+ );
useEffect(() => {
- void loadCompetencies();
+ loadCompetencies(true);
}, [loadCompetencies]);
const handleAction = useCallback(
@@ -162,6 +273,7 @@ function SessionPageContent() {
competencies: competencies ? [...competencies] : undefined,
},
]);
+ setRelation(null); // Reset selection after adding relation
await loadCompetencies();
} catch (err) {
setError(
@@ -180,6 +292,7 @@ function SessionPageContent() {
competencies: competencies ? [...competencies] : undefined,
},
]);
+ setRelation(null); // Reset relationship type selection
try {
await loadCompetencies();
} catch (err) {
@@ -256,12 +369,14 @@ function SessionPageContent() {
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
+ // Shift+Z for Undo (only if there's history to undo, and NOT Cmd/Ctrl+Shift+Z)
+ // Check this early to prevent conflicts with other handlers
if (
- (event.metaKey || event.ctrlKey) &&
- event.key === 'z' &&
+ event.key.toLowerCase() === 'z' &&
event.shiftKey &&
+ !event.metaKey &&
+ !event.ctrlKey &&
+ !event.altKey &&
history.length > 0
) {
event.preventDefault();
@@ -270,6 +385,18 @@ function SessionPageContent() {
return;
}
+ // If Enter is pressed on a relationship button, prevent default button behavior
+ // and let the global handler manage it
+ if (
+ event.key === 'Enter' &&
+ target.tagName === 'BUTTON' &&
+ target.closest('[data-relationship-button]')
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ // Continue to global Enter handler below
+ }
+
// Space for Skip (only if not loading and has competencies)
if (
event.key === ' ' &&
@@ -283,6 +410,24 @@ function SessionPageContent() {
return;
}
+ // 1, 2, 3, 4 for relationship types (only if relationship types are loaded)
+ // Check this BEFORE Enter to prevent conflicts
+ if (
+ ['1', '2', '3', '4'].includes(event.key) &&
+ RELATIONSHIP_TYPES.length > 0 &&
+ !isLoading &&
+ !isCreating
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ const index = parseInt(event.key) - 1;
+ if (RELATIONSHIP_TYPES[index]) {
+ const selectedValue = RELATIONSHIP_TYPES[index]!.value;
+ setRelation(prev => (prev === selectedValue ? null : selectedValue));
+ }
+ return;
+ }
+
// Enter for Add Relation (only if not loading, not creating, has userId, relation, and competencies)
if (
event.key === 'Enter' &&
@@ -294,6 +439,7 @@ function SessionPageContent() {
competencies.length >= 2
) {
event.preventDefault();
+ event.stopPropagation();
handleAction('completed');
return;
}
@@ -315,22 +461,33 @@ function SessionPageContent() {
]);
return (
-
+
-
-
-
-
Mapping Session
-
•
-
Completed: {stats.completed}
+
+
+ {/* Header Section */}
+
+
+
+ Mapping Session
+
+
+
+
+
+ Completed:{' '}
+
+ {stats.completed}
+
+
+
+
+
{error && (
@@ -378,36 +535,103 @@ function SessionPageContent() {
{!error && !noCompetencies && !notEnough && (
<>
-
- {isLoading || !competencies || competencies.length < 2
- ? 'Loading competencies for this mapping session...'
- : `How does "${competencies[0]!.title}" relate to "${
- competencies[1]!.title
- }"?`}
-
-
-
-
+ {/* Main Question */}
+
+ {isLoading || !competencies || competencies.length < 2 ? (
+
+
+ How does
+
+
+
+ relate to
+
+
+
+ ?
+
+
+ ) : (
+
+ How does{' '}
+
+ {competencies[0]!.title}
+ {' '}
+ relate to{' '}
+
+ {competencies[1]!.title}
+
+ ?
+
+ )}
+
+
+ {/* Competencies Side by Side for Comparison */}
+
+
+ {/* Origin Competency */}
{isLoading || !competencies || !competencies[0] ? (
-
-
-
- Loading first competency...
-
+
+
+
+
+
+
+
) : (
-
-
-
+
+
+
+
+
+ Competency
+
+
+
{/* Placeholder badges; replace with competency metadata once available */}
-
+
Apply
+
@@ -425,80 +649,134 @@ function SessionPageContent() {
-
+
Control Flow
-
-
+
+
{competencies[0]!.title}
-
- {competencies[0]!.description}
-
+
+
+ {competencies[0]!.description}
+
+
)}
-
-
-
-
- Select Relation Type
-
-
-
- setRelation(value as RelationshipType)
- }
- className="gap-4 items-start"
- >
- {RELATIONSHIP_TYPES.map(({ value, label }) => (
-
-
-
- {label}
-
-
- ))}
-
+ {/* Arrow Connection with Swap Button */}
+
+
+ {competencies && competencies.length >= 2 && (
+
{
+ setSwapRotation(prev => prev + 180);
+ setIsTransitioning(true);
+
+ // Wait for fade out before swapping
+ setTimeout(() => {
+ if (competencies && competencies.length >= 2) {
+ setCompetencies([
+ competencies[1]!,
+ competencies[0]!,
+ ]);
+ }
+ setIsTransitioning(false);
+ }, 300);
+ }}
+ className="text-slate-700 border border-slate-300 hover:text-slate-900 hover:bg-slate-100 hover:border-slate-400 focus:outline-none focus:ring-0 focus-visible:ring-0"
+ >
+
+ Swap Direction
+
+ )}
-
-
+ {/* Destination Competency */}
{isLoading || !competencies || !competencies[1] ? (
-
-
-
- Loading second competency...
-
+
+
+
+
+
+
+
) : (
-
-
-
+
+
+
+
+
+ Competency
+
+
+
{/* Placeholder badges; replace with competency metadata once available */}
-
+
Apply
+
@@ -516,17 +794,22 @@ function SessionPageContent() {
-
+
Programming Fundamentals
-
-
+
+
{competencies[1]!.title}
-
- {competencies[1]!.description}
-
+
+
+ {competencies[1]!.description}
+
+
@@ -534,50 +817,230 @@ function SessionPageContent() {
-
-
- Undo{' '}
-
- ⌘
-
-
- ⇧
-
-
- Z
-
-
-
-
handleAction('skipped')}
- disabled={isLoading}
- >
- Skip{' '}
-
- Space
-
-
-
handleAction('completed')}
- disabled={isLoading || isCreating || !userId || !relation}
- >
- {isCreating ? 'Creating...' : 'Add Relation'}{' '}
-
- ⏎
-
-
+ {/* Combined Relationship Selection Section */}
+
+ {/* Live Preview Sentence */}
+ {competencies && competencies.length >= 2 && (
+
+ {hoveredRelation ? (
+
+
+ {
+ RELATIONSHIP_TYPES.find(
+ rt => rt.value === hoveredRelation
+ )?.label
+ }
+ :
+ {' '}
+ {getRelationshipDescription(
+ hoveredRelation,
+ competencies[0]!.title,
+ competencies[1]!.title
+ )}
+
+ ) : relation ? (
+
+
+ {competencies[0]!.title}
+ {' '}
+
+ {relation === 'UNRELATED'
+ ? 'is unrelated to'
+ : RELATIONSHIP_TYPES.find(
+ rt => rt.value === relation
+ )?.label.toLowerCase() || ''}
+ {' '}
+
+ {competencies[1]!.title}
+
+
+ ) : (
+
+ Select a relationship type below or press{' '}
+
+ 1
+ {' '}
+
+ 2
+ {' '}
+
+ 3
+ {' '}
+
+ 4
+
+
+ )}
+
+ )}
+
+ {/* Relationship Type Buttons */}
+ {RELATIONSHIP_TYPES.length > 0 ? (
+
+ {RELATIONSHIP_TYPES.map(({ value, label }) => {
+ const isSelected = relation === value;
+ const shortcutIndex = RELATIONSHIP_TYPES.findIndex(
+ rt => rt.value === value
+ );
+ const shortcutKey =
+ shortcutIndex >= 0
+ ? (shortcutIndex + 1).toString()
+ : null;
+
+ // Color mapping for selected state
+ const selectedColors: Record = {
+ ASSUMES:
+ 'bg-blue-600 text-white shadow-lg shadow-blue-500/25 border-blue-600',
+ EXTENDS:
+ 'bg-purple-600 text-white shadow-lg shadow-purple-500/25 border-purple-600',
+ MATCHES:
+ 'bg-emerald-600 text-white shadow-lg shadow-emerald-500/25 border-emerald-600',
+ UNRELATED:
+ 'bg-slate-600 text-white shadow-lg shadow-slate-500/25 border-slate-600',
+ };
+
+ // Color mapping for unselected state - subtle color hints
+ const unselectedColors: Record =
+ {
+ ASSUMES:
+ 'bg-white text-slate-800 border-blue-300 hover:border-blue-500 hover:bg-blue-50',
+ EXTENDS:
+ 'bg-white text-slate-800 border-purple-300 hover:border-purple-500 hover:bg-purple-50',
+ MATCHES:
+ 'bg-white text-slate-800 border-emerald-300 hover:border-emerald-500 hover:bg-emerald-50',
+ UNRELATED:
+ 'bg-white text-slate-800 border-slate-300 hover:border-slate-500 hover:bg-slate-50',
+ };
+
+ // Icon mapping for relationship types
+ const iconMap: Record =
+ {
+ ASSUMES: ,
+ EXTENDS: ,
+ MATCHES: ,
+ UNRELATED: ,
+ };
+
+ return (
+ setRelation(isSelected ? null : value)}
+ onMouseEnter={() => handleMouseEnter(value)}
+ onMouseLeave={handleMouseLeave}
+ className={`
+ relative flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 w-full
+ font-medium text-sm transition-all duration-200 ease-out
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
+ ${
+ isSelected
+ ? `${selectedColors[value]} scale-[1.02]`
+ : unselectedColors[value]
+ }
+ `}
+ >
+ {iconMap[value]}
+ {label}
+ {shortcutKey && (
+
+ {shortcutKey}
+
+ )}
+
+ );
+ })}
+
+ ) : (
+
+
+ Loading relationship types...
+
+
+ )}
+
+
+ {/* Action Bar */}
+
+
+ {/* Left: Undo */}
+
+
+ Undo
+
+ ⇧
+
+ +
+
+ Z
+
+
+
+
+ {/* Right: Primary Actions */}
+
+ handleAction('skipped')}
+ disabled={isLoading || isCreating}
+ className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-slate-700 border border-slate-300 rounded-lg transition-all duration-200 hover:text-slate-900 hover:bg-slate-100 hover:border-slate-400 disabled:opacity-40 disabled:cursor-not-allowed"
+ >
+ Skip
+
+ Space
+
+
+
+ handleAction('completed')}
+ disabled={isLoading || isCreating || !userId || !relation}
+ className={`
+ relative flex items-center justify-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold text-white
+ min-w-[180px]
+ bg-gradient-to-r from-[#0a4da2] to-[#5538d1]
+ shadow-lg shadow-blue-500/20
+ transition-all duration-200 ease-out
+ hover:shadow-xl hover:shadow-blue-500/30 hover:scale-[1.02]
+ active:scale-[0.98]
+ disabled:opacity-50 disabled:cursor-not-allowed
+ disabled:hover:shadow-lg disabled:hover:scale-100
+ `}
+ >
+ {isCreating ? (
+
+
+ Loading...
+
+ ) : (
+ <>
+ Add Relation
+
+ ⏎
+
+ >
+ )}
+
+
+
>
)}
diff --git a/components/footer.tsx b/components/footer.tsx
index 3cd1ba9..de9f319 100644
--- a/components/footer.tsx
+++ b/components/footer.tsx
@@ -1,5 +1,7 @@
'use client';
+import Link from 'next/link';
+
export function Footer() {
return (
@@ -8,12 +10,12 @@ export function Footer() {
Memo Benchmark Platform
© {new Date().getFullYear()} Memo
diff --git a/components/navbar.tsx b/components/navbar.tsx
index 622716e..e98e4a4 100644
--- a/components/navbar.tsx
+++ b/components/navbar.tsx
@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
+
import { usePathname } from 'next/navigation';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
@@ -18,10 +19,7 @@ export function Navbar() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [scrolled, setScrolled] = useState(false);
- const activeIndex =
- navItems.findIndex(item => item.href === pathname) === -1
- ? 0
- : navItems.findIndex(item => item.href === pathname);
+ const activeIndex = navItems.findIndex(item => item.href === pathname);
useEffect(() => {
setMounted(true);
@@ -73,14 +71,16 @@ export function Navbar() {
{/* Center - Sliding nav */}
-
+ {activeIndex >= 0 && (
+
+ )}
{navItems.map(item => (
{/* Login button - last element per guidelines */}
-
-
- Start Contributing
-
-
+ Start Contributing
+
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
index f072945..cf2ef04 100644
--- a/components/ui/badge.tsx
+++ b/components/ui/badge.tsx
@@ -1,3 +1,4 @@
+import type React from 'react';
import { cn } from '../../domain_core/infrastructure/utils';
export interface BadgeProps extends React.HTMLAttributes
{
diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
index a7c9a03..0b334dd 100644
--- a/components/ui/tooltip.tsx
+++ b/components/ui/tooltip.tsx
@@ -1,21 +1,31 @@
-import * as React from 'react';
+import {
+ createContext,
+ MutableRefObject,
+ ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import { createPortal } from 'react-dom';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/domain_core/infrastructure/utils';
type TooltipProps = {
- children: React.ReactNode;
+ children: ReactNode;
className?: string;
delayDuration?: number;
side?: 'top' | 'bottom';
};
type TooltipContentProps = {
- children: React.ReactNode;
+ children: ReactNode;
className?: string;
};
type TooltipTriggerProps = {
- children: React.ReactNode;
+ children: ReactNode;
asChild?: boolean;
className?: string;
};
@@ -24,11 +34,13 @@ type TooltipContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
delayDuration: number;
- timerRef: React.MutableRefObject | null>;
+ timerRef: MutableRefObject | null>;
side: 'top' | 'bottom';
+ triggerElement: HTMLElement | null;
+ setTriggerElement: (element: HTMLElement | null) => void;
};
-const TooltipContext = React.createContext(null);
+const TooltipContext = createContext(null);
export function Tooltip({
children,
@@ -36,12 +48,23 @@ export function Tooltip({
delayDuration = 300,
side = 'top',
}: TooltipProps) {
- const [open, setOpen] = React.useState(false);
- const timerRef = React.useRef | null>(null);
+ const [open, setOpen] = useState(false);
+ const timerRef = useRef | null>(null);
+ const [triggerElement, setTriggerElement] = useState(
+ null
+ );
return (
{children}
@@ -53,13 +76,30 @@ export function TooltipTrigger({
asChild,
className,
}: TooltipTriggerProps) {
- const context = React.useContext(TooltipContext);
+ const context = useContext(TooltipContext);
+ const triggerRef = useRef(null);
if (!context) {
throw new Error('TooltipTrigger must be used within a Tooltip');
}
- const { setOpen, delayDuration, timerRef } = context;
+ const { setOpen, delayDuration, timerRef, setTriggerElement } = context;
+
+ // Unified ref callback that handles both triggerRef and setTriggerElement
+ const handleRef = useCallback(
+ (node: HTMLElement | null) => {
+ triggerRef.current = node;
+ setTriggerElement(node);
+ },
+ [setTriggerElement]
+ );
+
+ useEffect(() => {
+ return () => {
+ setTriggerElement(null);
+ };
+ }, []);
+
const clearTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
@@ -90,6 +130,7 @@ export function TooltipTrigger({
return (
{children}
@@ -98,30 +139,75 @@ export function TooltipTrigger({
}
export function TooltipContent({ children, className }: TooltipContentProps) {
- const context = React.useContext(TooltipContext);
+ const context = useContext(TooltipContext);
+ const [position, setPosition] = useState<{
+ top: number;
+ left: number;
+ } | null>(null);
+ const [isVisible, setIsVisible] = useState(false);
if (!context) {
throw new Error('TooltipContent must be used within a Tooltip');
}
- const { open, side } = context;
+ const { open, side, triggerElement } = context;
const isTop = side === 'top';
- return (
+ useEffect(() => {
+ if (!open || !triggerElement) {
+ setIsVisible(false);
+ setPosition(null);
+ return;
+ }
+
+ const updatePosition = () => {
+ if (!triggerElement) return;
+
+ const rect = triggerElement.getBoundingClientRect();
+ const TOOLTIP_SPACING = 8;
+
+ setPosition({
+ top: isTop ? rect.top - TOOLTIP_SPACING : rect.bottom + TOOLTIP_SPACING,
+ left: rect.left + rect.width / 2,
+ });
+ setIsVisible(true);
+ };
+
+ // Use requestAnimationFrame to ensure DOM is ready
+ const rafId = requestAnimationFrame(updatePosition);
+
+ const handleResize = () => updatePosition();
+ const handleScroll = () => updatePosition();
+
+ window.addEventListener('resize', handleResize);
+ window.addEventListener('scroll', handleScroll, { passive: true });
+
+ return () => {
+ cancelAnimationFrame(rafId);
+ window.removeEventListener('resize', handleResize);
+ window.removeEventListener('scroll', handleScroll);
+ };
+ }, [open, triggerElement, isTop]);
+
+ if (!open || !position) return null;
+
+ const content = (
{children}
);
+
+ return createPortal(content, document.body);
}
diff --git a/docker/development/docker-compose.yml b/docker/development/docker-compose.yml
index fe6251a..bb518d5 100644
--- a/docker/development/docker-compose.yml
+++ b/docker/development/docker-compose.yml
@@ -1,5 +1,3 @@
-version: '3.8'
-
name: memo-dev
services:
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 91d12c1..6eb18a5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -42,7 +42,9 @@ model CompetencyRelationship {
destination Competency @relation("DestinationCompetency", fields: [destinationId], references: [id], onDelete: Cascade)
user User @relation("RelationshipCreator", fields: [userId], references: [id])
- @@unique([originId, destinationId, relationshipType])
+ @@index([originId])
+ @@index([destinationId])
+ @@index([userId])
@@map("competency_relationships")
}
@@ -81,5 +83,8 @@ enum RelationshipType {
ASSUMES
EXTENDS
MATCHES
+ // Explicitly records that two competencies have been reviewed and found not to be related,
+ // as opposed to simply having no CompetencyRelationship row between them.
+ UNRELATED
}