diff --git a/apps/manacore/apps/landing/src/data/ecosystem-health.json b/apps/manacore/apps/landing/src/data/ecosystem-health.json
index d9561c6cd..24e029ede 100644
--- a/apps/manacore/apps/landing/src/data/ecosystem-health.json
+++ b/apps/manacore/apps/landing/src/data/ecosystem-health.json
@@ -1,28 +1,38 @@
{
- "generatedAt": "2026-03-31T11:53:48.040Z",
- "overallScore": 76,
+ "generatedAt": "2026-03-31T12:04:45.568Z",
+ "overallScore": 70,
"scores": {
"sharedPackages": 90,
"iconConsistency": 89,
- "modalConsistency": 24,
+ "modalConsistency": 19,
"errorHandling": 20,
"i18nCoverage": 86,
"localFirst": 93,
- "styleConsistency": 87
+ "styleConsistency": 87,
+ "errorBoundaries": 54,
+ "typescriptStrict": 100,
+ "testCoverage": 72,
+ "pwaSupport": 2,
+ "maintainability": 0
},
"weights": {
- "sharedPackages": 25,
- "iconConsistency": 15,
- "modalConsistency": 10,
- "errorHandling": 10,
- "i18nCoverage": 15,
- "localFirst": 10,
- "styleConsistency": 15
+ "sharedPackages": 20,
+ "iconConsistency": 10,
+ "modalConsistency": 5,
+ "errorHandling": 5,
+ "i18nCoverage": 10,
+ "localFirst": 8,
+ "styleConsistency": 10,
+ "errorBoundaries": 8,
+ "typescriptStrict": 7,
+ "testCoverage": 7,
+ "pwaSupport": 5,
+ "maintainability": 5
},
"details": {
"icons": {
"adoption": 89,
- "phosphorFiles": 353,
+ "phosphorFiles": 354,
"inlineSvgFiles": 45,
"perApp": {
"calc": {
@@ -30,7 +40,7 @@
"inlineSvg": 0
},
"calendar": {
- "phosphor": 34,
+ "phosphor": 35,
"inlineSvg": 0
},
"chat": {
@@ -144,8 +154,8 @@
}
},
"modals": {
- "adoption": 24,
- "total": 51,
+ "adoption": 19,
+ "total": 64,
"sharedUsage": 12,
"focusTrapUsage": 7
},
@@ -212,6 +222,158 @@
"styles": {
"themeAdoption": 83,
"tailwindAdoption": 90
+ },
+ "errorBoundaries": {
+ "adoption": 54,
+ "errorAdoption": 28,
+ "offlineAdoption": 79,
+ "appsWithErrorPage": 8,
+ "appsWithOfflinePage": 23,
+ "missing": {
+ "error": [
+ "calc",
+ "chat",
+ "citycorners",
+ "clock",
+ "context",
+ "manadeck",
+ "manavoxel",
+ "moodlit",
+ "mukke",
+ "news",
+ "nutriphi",
+ "photos",
+ "picture",
+ "planta",
+ "playground",
+ "questions",
+ "skilltree",
+ "storage",
+ "uload",
+ "wisekeep",
+ "zitare"
+ ],
+ "offline": ["manavoxel", "moodlit", "news", "playground", "uload", "wisekeep"]
+ }
+ },
+ "typescript": {
+ "adoption": 100,
+ "strictApps": 29,
+ "nonStrict": []
+ },
+ "tests": {
+ "adoption": 72,
+ "e2eAdoption": 14,
+ "appsWithTests": 21,
+ "appsWithE2e": 4,
+ "totalTestFiles": 111,
+ "noTests": [
+ "calc",
+ "inventar",
+ "manavoxel",
+ "moodlit",
+ "news",
+ "playground",
+ "uload",
+ "wisekeep"
+ ]
+ },
+ "pwa": {
+ "adoption": 2,
+ "manifestAdoption": 3,
+ "swAdoption": 0,
+ "appsWithManifest": 1,
+ "appsWithServiceWorker": 0,
+ "noPwa": [
+ "calc",
+ "calendar",
+ "chat",
+ "citycorners",
+ "clock",
+ "contacts",
+ "context",
+ "inventar",
+ "manacore",
+ "manadeck",
+ "manavoxel",
+ "matrix",
+ "moodlit",
+ "mukke",
+ "news",
+ "nutriphi",
+ "photos",
+ "picture",
+ "planta",
+ "playground",
+ "presi",
+ "questions",
+ "skilltree",
+ "storage",
+ "times",
+ "todo",
+ "wisekeep",
+ "zitare"
+ ]
+ },
+ "fileSizes": {
+ "adoption": 0,
+ "totalLargeFiles": 38,
+ "largestFile": {
+ "path": "matrix: lib/matrix/store.svelte.ts",
+ "lines": 2019
+ },
+ "topOffenders": [
+ {
+ "app": "matrix",
+ "file": "lib/matrix/store.svelte.ts",
+ "lines": 2019
+ },
+ {
+ "app": "calendar",
+ "file": "lib/components/event/QuickEventOverlay.svelte",
+ "lines": 1816
+ },
+ {
+ "app": "contacts",
+ "file": "lib/components/ContactDetailModal.svelte",
+ "lines": 1511
+ },
+ {
+ "app": "todo",
+ "file": "lib/components/TaskItem.svelte",
+ "lines": 1217
+ },
+ {
+ "app": "todo",
+ "file": "lib/components/board-views/ViewEditorModal.svelte",
+ "lines": 1190
+ },
+ {
+ "app": "contacts",
+ "file": "lib/components/NewContactModal.svelte",
+ "lines": 1130
+ },
+ {
+ "app": "calendar",
+ "file": "lib/components/calendar/WeekView.svelte",
+ "lines": 1043
+ },
+ {
+ "app": "zitare",
+ "file": "routes/(app)/lists/[id]/+page.svelte",
+ "lines": 958
+ },
+ {
+ "app": "calendar",
+ "file": "routes/(app)/settings/sync/+page.svelte",
+ "lines": 898
+ },
+ {
+ "app": "matrix",
+ "file": "routes/(auth)/login/+page.svelte",
+ "lines": 849
+ }
+ ]
}
},
"apps": [
diff --git a/apps/manacore/apps/landing/src/pages/manascore/ecosystem.astro b/apps/manacore/apps/landing/src/pages/manascore/ecosystem.astro
index 346d0dea2..d83ca35f0 100644
--- a/apps/manacore/apps/landing/src/pages/manascore/ecosystem.astro
+++ b/apps/manacore/apps/landing/src/pages/manascore/ecosystem.astro
@@ -76,6 +76,36 @@ const categories = [
icon: '๐ญ',
description: 'Theme-Variablen + Tailwind CSS Nutzung',
},
+ {
+ key: 'errorBoundaries',
+ label: 'Error Boundaries',
+ icon: '๐ก๏ธ',
+ description: '+error.svelte + Offline Page pro App',
+ },
+ {
+ key: 'typescriptStrict',
+ label: 'TypeScript Strict',
+ icon: '๐',
+ description: 'Strict Mode in allen Apps',
+ },
+ {
+ key: 'testCoverage',
+ label: 'Test Coverage',
+ icon: '๐งช',
+ description: 'Apps mit mindestens einem Test',
+ },
+ {
+ key: 'pwaSupport',
+ label: 'PWA Support',
+ icon: '๐ฑ',
+ description: 'Manifest + Service Worker',
+ },
+ {
+ key: 'maintainability',
+ label: 'Maintainability',
+ icon: '๐',
+ description: 'Dateien unter 500 Zeilen',
+ },
];
const packageDetails = details.packages.perPackage;
@@ -238,6 +268,62 @@ const generatedDate = new Date(ecosystemData.generatedAt).toLocaleDateString('de
+
+ {
+ details.fileSizes?.topOffenders?.length > 0 && (
+
+
+ Largest Files (Refactoring-Kandidaten)
+
+
+
+
+
+ | Datei |
+ Zeilen |
+
+
+
+ {details.fileSizes.topOffenders.slice(0, 10).map((f: any) => (
+
+ |
+ {f.app}/
+ {f.file}
+ |
+ 1000 ? 'text-red-500' : 'text-orange-500'}`}
+ >
+ {f.lines}
+ |
+
+ ))}
+
+
+
+
+ )
+ }
+
+
+ {
+ details.tests?.noTests?.length > 0 && (
+
+
Apps ohne Tests
+
+ {details.tests.noTests.map((app: string) => (
+
+ {app}
+
+ ))}
+
+
+ {details.tests.totalTestFiles} Test-Dateien gesamt ·{' '}
+ {details.tests.appsWithE2e} Apps mit E2E-Tests
+
+
+ )
+ }
+
diff --git a/scripts/ecosystem-audit.mjs b/scripts/ecosystem-audit.mjs
index 8128678f5..b13f595c3 100644
--- a/scripts/ecosystem-audit.mjs
+++ b/scripts/ecosystem-audit.mjs
@@ -296,6 +296,223 @@ function measureStyleConsistency() {
return { themeAdoption, tailwindAdoption };
}
+function measureErrorBoundaries() {
+ console.log('๐ Measuring Error Boundaries...');
+ let appsWithErrorPage = 0;
+ let appsWithOfflinePage = 0;
+ const missing = { error: [], offline: [] };
+
+ for (const app of WEB_APPS) {
+ const webSrc = join(APPS_DIR, app, 'apps/web/src');
+ if (!existsSync(webSrc)) continue;
+
+ const hasErrorPage = fileCount('+error.svelte', webSrc) > 0;
+ const hasOfflinePage =
+ fileCount('*offline*', webSrc) > 0 ||
+ fileCount('*Offline*', webSrc) > 0 ||
+ grepCount('OfflinePage', webSrc, '*.svelte') > 0;
+
+ if (hasErrorPage) appsWithErrorPage++;
+ else missing.error.push(app);
+ if (hasOfflinePage) appsWithOfflinePage++;
+ else missing.offline.push(app);
+ }
+
+ const errorAdoption = Math.round((appsWithErrorPage / WEB_APPS.length) * 100);
+ const offlineAdoption = Math.round((appsWithOfflinePage / WEB_APPS.length) * 100);
+ const adoption = Math.round((errorAdoption + offlineAdoption) / 2);
+
+ console.log(` +error.svelte: ${appsWithErrorPage}/${WEB_APPS.length} (${errorAdoption}%)`);
+ console.log(` Offline page: ${appsWithOfflinePage}/${WEB_APPS.length} (${offlineAdoption}%)`);
+ if (missing.error.length > 0) console.log(` Missing error page: ${missing.error.join(', ')}`);
+ console.log(` Combined: ${adoption}%\n`);
+
+ return {
+ adoption,
+ errorAdoption,
+ offlineAdoption,
+ appsWithErrorPage,
+ appsWithOfflinePage,
+ missing,
+ };
+}
+
+function measureTypeScriptStrictness() {
+ console.log('๐ Measuring TypeScript Strictness...');
+ let strictApps = 0;
+ const nonStrict = [];
+
+ for (const app of WEB_APPS) {
+ const tsconfigPaths = [
+ join(APPS_DIR, app, 'apps/web/tsconfig.json'),
+ join(APPS_DIR, app, 'apps/web/.svelte-kit/tsconfig.json'),
+ ];
+
+ let isStrict = false;
+ for (const p of tsconfigPaths) {
+ try {
+ const content = readFileSync(p, 'utf-8');
+ if (content.includes('"strict": true') || content.includes('"strict":true')) {
+ isStrict = true;
+ break;
+ }
+ } catch {}
+ }
+
+ // SvelteKit apps inherit strict from .svelte-kit/tsconfig.json which extends the user config
+ // Check if svelte.config.js exists (indicates SvelteKit = inherits strict)
+ if (!isStrict && existsSync(join(APPS_DIR, app, 'apps/web/svelte.config.js'))) {
+ isStrict = true; // SvelteKit default is strict
+ }
+
+ if (isStrict) strictApps++;
+ else nonStrict.push(app);
+ }
+
+ const adoption = Math.round((strictApps / WEB_APPS.length) * 100);
+ console.log(` Strict apps: ${strictApps}/${WEB_APPS.length} (${adoption}%)`);
+ if (nonStrict.length > 0) console.log(` Non-strict: ${nonStrict.join(', ')}`);
+ console.log('');
+
+ return { adoption, strictApps, nonStrict };
+}
+
+function measureTestCoverage() {
+ console.log('๐ Measuring Test Coverage...');
+ let appsWithTests = 0;
+ let appsWithE2e = 0;
+ let totalTestFiles = 0;
+ const noTests = [];
+
+ for (const app of WEB_APPS) {
+ const appBase = join(APPS_DIR, app);
+
+ const unitTests =
+ fileCount('*.test.ts', appBase) +
+ fileCount('*.spec.ts', appBase) +
+ fileCount('*.test.js', appBase);
+ const e2eTests =
+ fileCount('*.spec.ts', join(appBase, 'apps/web/e2e')) +
+ fileCount('*.spec.ts', join(appBase, 'apps/web/tests'));
+
+ totalTestFiles += unitTests + e2eTests;
+
+ if (unitTests > 0 || e2eTests > 0) appsWithTests++;
+ else noTests.push(app);
+ if (e2eTests > 0) appsWithE2e++;
+ }
+
+ const adoption = Math.round((appsWithTests / WEB_APPS.length) * 100);
+ const e2eAdoption = Math.round((appsWithE2e / WEB_APPS.length) * 100);
+
+ console.log(` Apps with tests: ${appsWithTests}/${WEB_APPS.length} (${adoption}%)`);
+ console.log(` Apps with E2E: ${appsWithE2e}/${WEB_APPS.length} (${e2eAdoption}%)`);
+ console.log(` Total test files: ${totalTestFiles}`);
+ if (noTests.length > 0 && noTests.length <= 10) console.log(` No tests: ${noTests.join(', ')}`);
+ console.log('');
+
+ return { adoption, e2eAdoption, appsWithTests, appsWithE2e, totalTestFiles, noTests };
+}
+
+function measurePwaSupport() {
+ console.log('๐ Measuring PWA Support...');
+ let appsWithManifest = 0;
+ let appsWithServiceWorker = 0;
+ const noPwa = [];
+
+ for (const app of WEB_APPS) {
+ const webDir = join(APPS_DIR, app, 'apps/web');
+ const webSrc = join(webDir, 'src');
+ const staticDir = join(webDir, 'static');
+
+ const hasManifest =
+ existsSync(join(staticDir, 'manifest.json')) ||
+ existsSync(join(staticDir, 'manifest.webmanifest')) ||
+ grepCount('manifest', webSrc, '*.html') > 0 ||
+ grepCount('manifest', join(webSrc, 'app.html'), '*') > 0;
+
+ const hasSw =
+ existsSync(join(staticDir, 'sw.js')) ||
+ existsSync(join(staticDir, 'service-worker.js')) ||
+ existsSync(join(webSrc, 'service-worker.ts')) ||
+ existsSync(join(webSrc, 'service-worker.js')) ||
+ grepCount('serviceWorker', webSrc, '*.ts') > 0;
+
+ if (hasManifest) appsWithManifest++;
+ if (hasSw) appsWithServiceWorker++;
+ if (!hasManifest && !hasSw) noPwa.push(app);
+ }
+
+ const manifestAdoption = Math.round((appsWithManifest / WEB_APPS.length) * 100);
+ const swAdoption = Math.round((appsWithServiceWorker / WEB_APPS.length) * 100);
+ const adoption = Math.round((manifestAdoption + swAdoption) / 2);
+
+ console.log(` Manifest: ${appsWithManifest}/${WEB_APPS.length} (${manifestAdoption}%)`);
+ console.log(` Service Worker: ${appsWithServiceWorker}/${WEB_APPS.length} (${swAdoption}%)`);
+ if (noPwa.length > 0 && noPwa.length <= 10) console.log(` No PWA: ${noPwa.join(', ')}`);
+ console.log('');
+
+ return { adoption, manifestAdoption, swAdoption, appsWithManifest, appsWithServiceWorker, noPwa };
+}
+
+function measureFileSizes() {
+ console.log('๐ Measuring File Sizes (Maintainability)...');
+ let totalLargeFiles = 0;
+ let largestFile = { path: '', lines: 0 };
+ const largeFiles = [];
+
+ for (const app of WEB_APPS) {
+ const webSrc = join(APPS_DIR, app, 'apps/web/src');
+ if (!existsSync(webSrc)) continue;
+
+ try {
+ const result = execSync(
+ `find ${webSrc} -name "*.svelte" -o -name "*.ts" | xargs wc -l 2>/dev/null | sort -rn | head -5`,
+ { encoding: 'utf-8' }
+ );
+ const lines = result.trim().split('\n');
+ for (const line of lines) {
+ const match = line.trim().match(/^(\d+)\s+(.+)$/);
+ if (match && !match[2].includes('total')) {
+ const lineCount = parseInt(match[1]);
+ const filePath = match[2];
+ if (lineCount > 500) {
+ totalLargeFiles++;
+ largeFiles.push({ app, file: filePath.replace(`${webSrc}/`, ''), lines: lineCount });
+ }
+ if (lineCount > largestFile.lines) {
+ largestFile = {
+ path: `${app}: ${filePath.replace(`${webSrc}/`, '')}`,
+ lines: lineCount,
+ };
+ }
+ }
+ }
+ } catch {}
+ }
+
+ // Score: fewer large files = better (100% = no files >500 lines, deduct per large file)
+ const adoption = Math.max(0, 100 - totalLargeFiles * 3);
+
+ console.log(` Files > 500 lines: ${totalLargeFiles}`);
+ console.log(` Largest: ${largestFile.path} (${largestFile.lines} lines)`);
+ if (largeFiles.length > 0) {
+ console.log(' Top offenders:');
+ largeFiles
+ .sort((a, b) => b.lines - a.lines)
+ .slice(0, 5)
+ .forEach((f) => console.log(` ${f.lines} lines โ ${f.app}/${f.file}`));
+ }
+ console.log('');
+
+ return {
+ adoption,
+ totalLargeFiles,
+ largestFile,
+ topOffenders: largeFiles.sort((a, b) => b.lines - a.lines).slice(0, 10),
+ };
+}
+
// ============================================================
// Main
// ============================================================
@@ -310,6 +527,11 @@ const errors = measureErrorHandling();
const i18n = measureI18nConsistency();
const localFirst = measureLocalFirstAdoption();
const styles = measureStyleConsistency();
+const errorBoundaries = measureErrorBoundaries();
+const typescript = measureTypeScriptStrictness();
+const tests = measureTestCoverage();
+const pwa = measurePwaSupport();
+const fileSizes = measureFileSizes();
// Calculate overall scores
const scores = {
@@ -320,17 +542,27 @@ const scores = {
i18nCoverage: i18n.adoption,
localFirst: localFirst.adoption,
styleConsistency: Math.round((styles.themeAdoption + styles.tailwindAdoption) / 2),
+ errorBoundaries: errorBoundaries.adoption,
+ typescriptStrict: typescript.adoption,
+ testCoverage: tests.adoption,
+ pwaSupport: pwa.adoption,
+ maintainability: fileSizes.adoption,
};
// Weighted overall score
const weights = {
- sharedPackages: 25,
- iconConsistency: 15,
- modalConsistency: 10,
- errorHandling: 10,
- i18nCoverage: 15,
- localFirst: 10,
- styleConsistency: 15,
+ sharedPackages: 20,
+ iconConsistency: 10,
+ modalConsistency: 5,
+ errorHandling: 5,
+ i18nCoverage: 10,
+ localFirst: 8,
+ styleConsistency: 10,
+ errorBoundaries: 8,
+ typescriptStrict: 7,
+ testCoverage: 7,
+ pwaSupport: 5,
+ maintainability: 5,
};
let totalWeight = 0;
@@ -344,25 +576,13 @@ const overallScore = Math.round(weightedSum / totalWeight);
console.log('='.repeat(50));
console.log('\n๐ ECOSYSTEM HEALTH SCORE\n');
console.log(` Overall: ${overallScore}/100\n`);
-console.log(
- ` Shared Package Adoption: ${scores.sharedPackages}% (weight: ${weights.sharedPackages}%)`
-);
-console.log(
- ` Icon Consistency: ${scores.iconConsistency}% (weight: ${weights.iconConsistency}%)`
-);
-console.log(
- ` Modal Consistency: ${scores.modalConsistency}% (weight: ${weights.modalConsistency}%)`
-);
-console.log(
- ` Error Handling: ${scores.errorHandling}% (weight: ${weights.errorHandling}%)`
-);
-console.log(
- ` i18n Coverage: ${scores.i18nCoverage}% (weight: ${weights.i18nCoverage}%)`
-);
-console.log(` Local-First: ${scores.localFirst}% (weight: ${weights.localFirst}%)`);
-console.log(
- ` Style Consistency: ${scores.styleConsistency}% (weight: ${weights.styleConsistency}%)`
-);
+const pad = (s, n) => s.padEnd(n);
+for (const [key, weight] of Object.entries(weights)) {
+ const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
+ console.log(
+ ` ${pad(label + ':', 28)} ${String(scores[key]).padStart(3)}% (weight: ${weight}%)`
+ );
+}
// Generate output JSON
const output = {
@@ -391,6 +611,11 @@ const output = {
i18n,
localFirst,
styles,
+ errorBoundaries,
+ typescript,
+ tests,
+ pwa,
+ fileSizes,
},
apps: WEB_APPS,
};