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
7 changes: 3 additions & 4 deletions client/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { Meteor } from 'meteor/meteor';
import { useTracker } from 'meteor/react-meteor-data';
import React, { useEffect, useState } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';

Check warning on line 7 in client/main.tsx

View workflow job for this annotation

GitHub Actions / Lint & Type Check (Node 22)

'hydrateRoot' is defined but never used

import { InboxPage } from '../imports/features/inbox/InboxPage';
import { AppLayout } from '../imports/ui/AppLayout';
Expand Down Expand Up @@ -60,10 +60,9 @@
// Dev inbox — no auth required, no SSR to hydrate.
createRoot(el).render(<InboxPage />);
} else if (window.location.pathname.startsWith('/app')) {
// Hydrate the server-rendered LoginForm.
// The SSR handler renders <LoginForm> for /app routes; after hydration
// React can swap to <AppLayout> once the user logs in.
hydrateRoot(el, <App />);
// SSR sends a loading placeholder (not a full React tree), so use
// createRoot — hydrateRoot would throw a hydration mismatch.
createRoot(el).render(<App />);
} else {
// Unknown routes — no SSR content to hydrate, mount fresh.
createRoot(el).render(<App />);
Expand Down
8 changes: 5 additions & 3 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ export default [
...ts.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'prettier/prettier': 'error',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'prettier/prettier': 'warn',
'simple-import-sort/imports': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/ban-ts-comment': 'warn',
},
},
];
2 changes: 1 addition & 1 deletion imports/features/clock/TimesheetPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export const TimesheetPage: React.FC = () => {

useEffect(() => {
fetchData();
}, [preset]); // eslint-disable-line react-hooks/exhaustive-deps
}, [preset]);

const presets: { key: Preset; label: string }[] = [
{ key: 'today', label: 'Today' },
Expand Down
2 changes: 1 addition & 1 deletion imports/features/messages/MessagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export const MessagesPage: React.FC = () => {
for (const u of users) names[u.id] = u.name;
setMemberNames(names);
}).catch(() => {});
}, [selectedTeam]); // eslint-disable-line react-hooks/exhaustive-deps
}, [selectedTeam]);

// Send message
const sendMessage = useMethod<
Expand Down
142 changes: 142 additions & 0 deletions imports/features/teams/TeamChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* TeamChart — Renders an interactive org chart using @mieweb/ychart (YChartEditor).
*/
import '@mieweb/ychart';
import React, { useEffect, useRef, useMemo } from 'react';

type ChartState = {
svgWidth: number;
svgHeight: number;
[key: string]: unknown;
};

type OrgChartInstance = {
render: () => OrgChartInstance;
clear?: () => void;
fit?: (params?: { animate?: boolean; scale?: boolean }) => OrgChartInstance;
getChartState?: () => ChartState;
};

type YChartInstance = {
initView(containerId: string, yaml: string): YChartInstance;
destroy?: () => void;
handleFit?: () => void;
orgChart?: OrgChartInstance;
};

declare global {
interface Window {
YChartEditor: new () => YChartInstance;
}
}

interface Member {
id: string;
name: string;
email?: string;
isAdmin?: boolean;
}

interface TeamChartProps {
teamName: string;
members: Member[];
}

function buildYaml(teamName: string, members: Member[]): string {
const lines: string[] = [];
lines.push(`- id: 0`);
lines.push(` name: ${JSON.stringify(teamName)}`);
lines.push(` title: Team`);
members.forEach((m, i) => {
lines.push(`- id: ${i + 1}`);
lines.push(` parentId: 0`);
lines.push(` name: ${JSON.stringify(m.name)}`);
if (m.isAdmin) lines.push(` title: Admin`);
if (m.email) lines.push(` email: ${JSON.stringify(m.email)}`);
});
return lines.join('\n');
}

// Inner component: one mount = one initView, one unmount = one clean teardown.
// Remounted via key= in the outer component when data changes.
const TeamChartMount: React.FC<{ yaml: string }> = ({ yaml }) => {
const containerRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<YChartInstance | null>(null);
const chartId = useRef(
`tc-${Date.now()}-${Math.random().toString(36).slice(2)}`
).current;

useEffect(() => {
const el = containerRef.current!;
el.id = chartId;
let fitTimerId = 0;

// One RAF so the container has real layout dimensions before initView
const rafId = requestAnimationFrame(() => {
if (!el.isConnected) return;
try {
instanceRef.current = new window.YChartEditor().initView(chartId, yaml);
fitTimerId = window.setTimeout(() => {
const oc = instanceRef.current?.orgChart;
if (!oc || !el.isConnected) return;
const rect = el.getBoundingClientRect();
const state = oc.getChartState?.();
if (state && rect.width > 0 && rect.height > 0) {
state.svgWidth = rect.width;
state.svgHeight = rect.height;
}
oc.fit?.({ animate: false });
}, 450);
} catch (err) {
// Unmounted before RAF fired is expected; anything else is a real error
if (el.isConnected) console.error('[TeamChart] initView failed:', err);
}
});

return () => {
cancelAnimationFrame(rafId);
window.clearTimeout(fitTimerId);

const inst = instanceRef.current;
if (inst) {
// Step 1: Remove d3-org-chart's window resize + keydown listeners.
// Without this, those listeners fire after the DOM is cleared and crash
// on null.getBoundingClientRect().
inst.orgChart?.clear?.();

// Step 2: Patch render to a no-op as a safety net for any already-queued
// RAF callbacks that slip through before clear() takes effect.
if (inst.orgChart) {
const oc = inst.orgChart;
oc.render = () => oc;
}

// Step 3: Now safe — destroy clears innerHTML and frees React roots
inst.destroy?.();
instanceRef.current = null;
}
};
}, []);

return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
};

export const TeamChart: React.FC<TeamChartProps> = ({ teamName, members }) => {
const memberKey = members.map((m) => `${m.id}:${m.name}:${m.email ?? ''}:${m.isAdmin ? '1' : '0'}`).join(',');
const yaml = useMemo(() => buildYaml(teamName, members), [teamName, memberKey]);

if (members.length === 0) {
return (
<p className="text-center text-sm text-neutral-500 py-8">No members to display.</p>
);
}

return (
<div
style={{ width: '100%', height: '500px' }}
aria-label={`Org chart for ${teamName}`}
>
<TeamChartMount key={yaml} yaml={yaml} />
</div>
);
};
35 changes: 34 additions & 1 deletion imports/features/teams/TeamsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ import React, { useCallback, useMemo, useState } from 'react';
import { useTeam } from '../../lib/TeamContext';
import { useMethod } from '../../lib/useMethod';
import { Teams } from './api';
const TeamChart = React.lazy(() =>
import('./TeamChart').then((m) => ({ default: m.TeamChart }))
);

// ─── TeamsPage ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -115,6 +118,11 @@ export const TeamsPage: React.FC = () => {
[teams],
);

const usersById = useMemo(
() => new Map(members.map((u) => [u._id!, u])),
[members],
);

// ── Handlers ──

const handleCreate = useCallback(async () => {
Expand Down Expand Up @@ -308,7 +316,7 @@ export const TeamsPage: React.FC = () => {
</div>
<ul className="divide-y divide-neutral-100 dark:divide-neutral-800">
{selectedTeam.members.map((memberId) => {
const user = members.find((u) => u._id === memberId);
const user = usersById.get(memberId);
const name = user ? getName(user) : memberId;
const email = user?.emails?.[0]?.address ?? '';
const isMemberAdmin = selectedTeam.admins.includes(memberId);
Expand Down Expand Up @@ -376,6 +384,31 @@ export const TeamsPage: React.FC = () => {
</Card>
)}

{/* Chart */}
{selectedTeam && !selectedTeam.isPersonal && (
<Card padding="none">
<CardHeader className="px-5 py-4">
<CardTitle>Chart</CardTitle>
</CardHeader>
<CardContent className="p-0">
<React.Suspense fallback={<div className="flex items-center justify-center p-8"><Spinner size="lg" label="Loading chart…" /></div>}>
<TeamChart
teamName={selectedTeam.name}
members={selectedTeam.members.map((memberId) => {
const user = usersById.get(memberId);
return {
id: memberId,
name: user ? getName(user) : memberId,
email: user?.emails?.[0]?.address,
isAdmin: selectedTeam.admins.includes(memberId),
};
})}
/>
</React.Suspense>
</CardContent>
</Card>
)}

{/* ── Modals ── */}

<Modal open={modal === 'create'} onOpenChange={(open) => !open && closeModal()} size="md">
Expand Down
2 changes: 1 addition & 1 deletion imports/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ export const MESSAGES_PENDING_THREAD_KEY = 'app:messagesPendingThread' as const;
// ─── Misc ─────────────────────────────────────────────────────────────────────

export const REPO_URL =
'https://github.com/wreiske/meteor-react-tailwind-prettier-starter' as const;
'https://github.com/mieweb/timehuddle' as const;
Loading
Loading