Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions packages/server/src/services/onboarding-template.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import { WELCOME_NODES, WELCOME_EDGES } from './onboarding.js';

/**
* The Welcome graph is the first thing every new user sees, so it must model
* the layout rules the app enforces everywhere else:
* - every node is "placed" (pinned) so the seed positions are authoritative
* and don't drift (a node at exactly (0,0) is treated as unplaced/unpinned);
* - simple template relationships are ORTHOGONAL — every edge is purely
* horizontal or vertical (the user's stated preference for simple graphs);
* - connected nodes sit far enough apart for the edge label to fit with only
* a small margin (the min-edge-length rule), not crammed together.
*/

// Card footprint (≈ getNodeDimensions) + the min-edge math used by the renderer.
const CARD_W = 170;
const CARD_H = 106;
const LABEL_W = 90; // a typical relationship label ("Depends On") width
const LABEL_MARGIN = 14;

// Per-axis half-extent the card covers along a horizontal / vertical edge.
const reachH = CARD_W / 2; // 85
const reachV = CARD_H / 2; // 53

describe('Welcome onboarding template', () => {
it('places every node (no node pinned at the unplaced origin)', () => {
for (const n of WELCOME_NODES) {
const atOrigin = n.positionX === 0 && n.positionY === 0;
expect(atOrigin, `"${n.title}" sits at (0,0) and would be unpinned/drift`).toBe(false);
}
});

it('draws every edge orthogonally (horizontal or vertical)', () => {
for (const e of WELCOME_EDGES) {
const s = WELCOME_NODES[e.sourceIndex];
const t = WELCOME_NODES[e.targetIndex];
const dx = Math.abs(s.positionX - t.positionX);
const dy = Math.abs(s.positionY - t.positionY);
const orthogonal = dx < 1 || dy < 1;
expect(
orthogonal,
`edge ${e.sourceIndex}->${e.targetIndex} ("${s.title}"->"${t.title}") is diagonal: dx=${dx} dy=${dy}`
).toBe(true);
}
});

it('spaces connected nodes so the edge label fits with a small margin', () => {
for (const e of WELCOME_EDGES) {
const s = WELCOME_NODES[e.sourceIndex];
const t = WELCOME_NODES[e.targetIndex];
const dx = Math.abs(s.positionX - t.positionX);
const dy = Math.abs(s.positionY - t.positionY);
const horizontal = dy < 1;
const centerDist = horizontal ? dx : dy;
const reach = horizontal ? reachH : reachV;
const gap = centerDist - 2 * reach;
expect(
gap,
`edge ${e.sourceIndex}->${e.targetIndex} gap ${gap.toFixed(0)}px < label ${LABEL_W}+${LABEL_MARGIN}`
).toBeGreaterThanOrEqual(LABEL_W + LABEL_MARGIN);
}
});

it('keeps the graph connected (every node touched by an edge)', () => {
const touched = new Set<number>();
for (const e of WELCOME_EDGES) {
touched.add(e.sourceIndex);
touched.add(e.targetIndex);
}
for (let i = 0; i < WELCOME_NODES.length; i++) {
expect(touched.has(i), `"${WELCOME_NODES[i].title}" has no edges`).toBe(true);
}
});

it('routes no edge straight through a non-endpoint node', () => {
for (const e of WELCOME_EDGES) {
const s = WELCOME_NODES[e.sourceIndex];
const t = WELCOME_NODES[e.targetIndex];
const horizontal = Math.abs(s.positionY - t.positionY) < 1;
for (let i = 0; i < WELCOME_NODES.length; i++) {
if (i === e.sourceIndex || i === e.targetIndex) continue;
const n = WELCOME_NODES[i];
const onLine = horizontal
? Math.abs(n.positionY - s.positionY) < 1 &&
n.positionX > Math.min(s.positionX, t.positionX) &&
n.positionX < Math.max(s.positionX, t.positionX)
: Math.abs(n.positionX - s.positionX) < 1 &&
n.positionY > Math.min(s.positionY, t.positionY) &&
n.positionY < Math.max(s.positionY, t.positionY);
expect(onLine, `edge ${e.sourceIndex}->${e.targetIndex} passes through "${n.title}"`).toBe(false);
}
}
});
});
46 changes: 22 additions & 24 deletions packages/server/src/services/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function sharedWelcomeGraphExists(driver: Driver): Promise<boolean>
}
}

interface OnboardingNode {
export interface OnboardingNode {
title: string;
description: string;
type: string;
Expand All @@ -35,13 +35,13 @@ interface OnboardingNode {
positionZ: number;
}

interface OnboardingEdge {
export interface OnboardingEdge {
sourceIndex: number;
targetIndex: number;
type: string;
}

const WELCOME_NODES: OnboardingNode[] = [
export const WELCOME_NODES: OnboardingNode[] = [
{
title: 'Welcome to GraphDone!',
description: `# Welcome to GraphDone! 🎉
Expand All @@ -62,8 +62,8 @@ GraphDone is a graph-native project management system that reimagines how work f
This is your workspace - feel free to edit, delete, or reorganize these tutorial nodes as you learn!`,
type: 'DOCUMENTATION',
status: 'COMPLETED',
positionX: 0,
positionY: 0,
positionX: -180,
positionY: -240,
positionZ: 0
},
{
Expand All @@ -80,8 +80,8 @@ Each work item has:
Try creating a work item right now!`,
type: 'TASK',
status: 'NOT_STARTED',
positionX: -200,
positionY: 150,
positionX: -180,
positionY: 0,
positionZ: 0
},
{
Expand All @@ -102,8 +102,8 @@ To create a dependency:
Dependencies determine priority - the more dependents a node has, the higher its priority becomes.`,
type: 'TASK',
status: 'NOT_STARTED',
positionX: 200,
positionY: 150,
positionX: 180,
positionY: 0,
positionZ: 0
},
{
Expand All @@ -125,8 +125,8 @@ Dependencies determine priority - the more dependents a node has, the higher its
The graph is alive - it reorganizes as you add dependencies and complete work!`,
type: 'DOCUMENTATION',
status: 'COMPLETED',
positionX: 0,
positionY: 300,
positionX: 180,
positionY: 240,
positionZ: 0
},
{
Expand All @@ -144,8 +144,8 @@ Switch between views using the mode buttons at the top center of the screen.
Each view presents the same underlying graph data in different ways - choose what works best for your current task!`,
type: 'DOCUMENTATION',
status: 'COMPLETED',
positionX: -200,
positionY: -150,
positionX: -180,
positionY: 240,
positionZ: 0
},
{
Expand All @@ -170,21 +170,19 @@ Remember: In GraphDone, work flows through natural dependencies, not artificial
You can always come back to this Welcome graph for reference, or delete it when you're ready.`,
type: 'MILESTONE',
status: 'NOT_STARTED',
positionX: 200,
positionY: -150,
positionX: 180,
positionY: 480,
positionZ: 0
}
];

const WELCOME_EDGES: OnboardingEdge[] = [
{ sourceIndex: 1, targetIndex: 0, type: 'DEPENDS_ON' },
{ sourceIndex: 2, targetIndex: 0, type: 'DEPENDS_ON' },
{ sourceIndex: 3, targetIndex: 0, type: 'RELATES_TO' },
{ sourceIndex: 4, targetIndex: 0, type: 'RELATES_TO' },
{ sourceIndex: 5, targetIndex: 1, type: 'DEPENDS_ON' },
{ sourceIndex: 5, targetIndex: 2, type: 'DEPENDS_ON' },
{ sourceIndex: 5, targetIndex: 3, type: 'DEPENDS_ON' },
{ sourceIndex: 5, targetIndex: 4, type: 'DEPENDS_ON' }
export const WELCOME_EDGES: OnboardingEdge[] = [
{ sourceIndex: 1, targetIndex: 0, type: 'RELATES_TO' },
{ sourceIndex: 2, targetIndex: 1, type: 'DEPENDS_ON' },
{ sourceIndex: 3, targetIndex: 2, type: 'DEPENDS_ON' },
{ sourceIndex: 4, targetIndex: 1, type: 'RELATES_TO' },
{ sourceIndex: 4, targetIndex: 3, type: 'RELATES_TO' },
{ sourceIndex: 5, targetIndex: 3, type: 'DEPENDS_ON' }
];

export async function createSharedWelcomeGraph(driver: Driver): Promise<string> {
Expand Down
33 changes: 29 additions & 4 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3924,6 +3924,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap

// Track previous node count to detect transition from empty to non-empty
const prevNodeCountRef = useRef<number>(0);
// Track the previous edge signature (id + type + direction) so a relationship
// TYPE change or a direction FLIP — which keep the edge COUNT the same — still
// forces a rebuild. Without this the edge label/arrow keep the stale value.
const prevEdgeSigRef = useRef<string>('');

// Comprehensive reinitialization effect - ONLY when actually needed
useEffect(() => {
Expand All @@ -3940,15 +3944,29 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
const isNowPopulated = nodes.length > 0;
const transitioningFromEmpty = wasEmpty && isNowPopulated;

// Detect a relationship TYPE change or direction FLIP. Both keep the edge
// count the same, so length-based checks miss them; compare an id+type+
// direction signature against the last render and force a rebuild on change.
const edgeSig = (validatedEdges as any[])
.map((e) => {
const sId = typeof e.source === 'object' ? e.source?.id : e.source;
const tId = typeof e.target === 'object' ? e.target?.id : e.target;
return `${e.id}:${e.type}:${sId}>${tId}`;
})
.sort()
.join(',');
const edgesChanged = prevEdgeSigRef.current !== '' && prevEdgeSigRef.current !== edgeSig;

// Only reinitialize if this is truly necessary
const shouldReinit =
!svgRef.current ||
!containerRef.current ||
nodes.length === 0 ||
!d3.select(svgRef.current).select('.main-graph-group').node() ||
reinitTrigger > 0 ||
transitioningFromEmpty; // Force reinit when adding first node to empty graph

transitioningFromEmpty || // Force reinit when adding first node to empty graph
edgesChanged; // relationship type changed or direction flipped

if (shouldReinit) {
console.log('[Graph Debug] Full reinitialization required');
initializeVisualization();
Expand All @@ -3962,8 +3980,9 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
updateVisualizationData();
}

// Update previous node count for next comparison
// Update previous node count + edge signature for next comparison
prevNodeCountRef.current = nodes.length;
prevEdgeSigRef.current = edgeSig;

const handleResize = () => {
if (!containerRef.current || !svgRef.current || !simulationRef.current) return;
Expand Down Expand Up @@ -3995,7 +4014,13 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
loading, // Re-init when loading completes
edgesLoading, // Re-init when edges loading completes
// Track node property changes for selective updates (only titles, descriptions, types)
nodes.map(n => `${n.id}:${n.title}:${n.description}:${n.type}:${n.status}`).join(',')
nodes.map(n => `${n.id}:${n.title}:${n.description}:${n.type}:${n.status}`).join(','),
// Track edge type/direction changes so a relationship edit or flip rebuilds
validatedEdges.map((e: any) => {
const sId = typeof e.source === 'object' ? e.source?.id : e.source;
const tId = typeof e.target === 'object' ? e.target?.id : e.target;
return `${e.id}:${e.type}:${sId}>${tId}`;
}).join(',')
]);

// Manual reinitialization function (expose globally for debugging)
Expand Down
Loading
Loading