react-doctor-cli-dev 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/backend/.env +3 -0
  2. package/backend/dist/index.js +43 -0
  3. package/backend/dist/middleware/auth.js +16 -0
  4. package/backend/dist/routes/reports.js +93 -0
  5. package/backend/package-lock.json +2000 -0
  6. package/backend/package.json +30 -0
  7. package/backend/src/db.ts +24 -0
  8. package/backend/src/index.ts +49 -0
  9. package/backend/src/middleware/auth.ts +21 -0
  10. package/backend/src/routes/reports.ts +110 -0
  11. package/backend/tsconfig.json +12 -0
  12. package/cli/bin/react-doctor.js +29 -0
  13. package/cli/dist/commands/analyze.js +125 -0
  14. package/cli/dist/commands/full.js +366 -0
  15. package/cli/dist/commands/install.js +138 -0
  16. package/cli/dist/commands/profile.js +166 -0
  17. package/cli/dist/index.js +78 -0
  18. package/cli/dist/ui.js +113 -0
  19. package/cli/package-lock.json +936 -0
  20. package/cli/package.json +34 -0
  21. package/cli/src/commands/analyze.ts +162 -0
  22. package/cli/src/commands/full.ts +574 -0
  23. package/cli/src/commands/install.ts +163 -0
  24. package/cli/src/commands/profile.ts +246 -0
  25. package/cli/src/index.ts +84 -0
  26. package/cli/src/ui.ts +120 -0
  27. package/cli/tsconfig.json +16 -0
  28. package/core/report-compiler/index.ts +359 -0
  29. package/core/report-compiler/test-report-compiler.ts +126 -0
  30. package/core/rule-engine/context-builder.ts +146 -0
  31. package/core/rule-engine/evaluator.ts +131 -0
  32. package/core/rule-engine/index.ts +222 -0
  33. package/core/rule-engine/rules.json +304 -0
  34. package/core/rule-engine/suggestion-builder.ts +209 -0
  35. package/core/rule-engine/test-rule-engine.ts +144 -0
  36. package/core/rule-engine/types.ts +202 -0
  37. package/core/runtime/profiler/browser.ts +121 -0
  38. package/core/runtime/profiler/collectors.ts +216 -0
  39. package/core/runtime/profiler/index.ts +311 -0
  40. package/core/runtime/profiler/porfiler.ts +967 -0
  41. package/core/runtime/profiler/route-scanner.ts +76 -0
  42. package/core/runtime/profiler/score.ts +59 -0
  43. package/core/runtime/profiler/server.ts +115 -0
  44. package/core/runtime/profiler/types.ts +65 -0
  45. package/core/runtime/test-runtime-profiler.ts +226 -0
  46. package/core/static-ana/static/analyzer.ts +145 -0
  47. package/core/static-ana/static/ast-parser.ts +31 -0
  48. package/core/static-ana/static/detectors/console-log.ts +49 -0
  49. package/core/static-ana/static/detectors/dead-code.ts +51 -0
  50. package/core/static-ana/static/detectors/effect-loop.ts +45 -0
  51. package/core/static-ana/static/detectors/index.ts +16 -0
  52. package/core/static-ana/static/detectors/inline-function.ts +59 -0
  53. package/core/static-ana/static/detectors/inline-style.ts +52 -0
  54. package/core/static-ana/static/detectors/large-component.ts +79 -0
  55. package/core/static-ana/static/detectors/missing-key.ts +56 -0
  56. package/core/static-ana/static/detectors/missing-memo.ts +59 -0
  57. package/core/static-ana/static/detectors/prop-drilling.ts +66 -0
  58. package/core/static-ana/static/helpers.ts +81 -0
  59. package/core/static-ana/static/scanner.ts +93 -0
  60. package/core/static-ana/test-analyzer.ts +115 -0
  61. package/core/static-ana/types.ts +25 -0
  62. package/core/tests/mock-react-project/src/app.tsx +22 -0
  63. package/core/tests/mock-react-project/src/components/Button.tsx +9 -0
  64. package/core/tests/mock-react-project/src/components/Header.tsx +3 -0
  65. package/core/tests/mock-react-project/src/components/ListTesting.tsx +51 -0
  66. package/core/tests/mock-react-project/src/components/UserDashboard.tsx +66 -0
  67. package/core/tests/mock-react-project/src/utils.ts +4 -0
  68. package/package.json +55 -0
  69. package/react-doctor-cli-dev-1.0.0.tgz +0 -0
  70. package/shared/dist/index.d.ts +2 -0
  71. package/shared/dist/index.js +19 -0
  72. package/shared/dist/schemas.d.ts +91 -0
  73. package/shared/dist/schemas.js +82 -0
  74. package/shared/dist/types.d.ts +44 -0
  75. package/shared/dist/types.js +2 -0
  76. package/shared/package-lock.json +47 -0
  77. package/shared/package.json +21 -0
  78. package/shared/src/index.ts +4 -0
  79. package/shared/src/schemas.ts +136 -0
  80. package/shared/src/types.ts +137 -0
  81. package/shared/tsconfig.json +15 -0
  82. package/tsconfig.json +25 -0
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ // ─────────────────────────────────────────────────────────────
3
+ // cli/src/commands/profile.ts
4
+ //
5
+ // react-doctor profile <projectPath>
6
+ //
7
+ // Runs the Runtime Profiler only — needs Chrome.
8
+ // Boots the React dev server, opens headless Chrome,
9
+ // measures Web Vitals + React profiler data, saves
10
+ // runtimereport.json to .react-doctor/
11
+ //
12
+ // Use this when:
13
+ // - You already ran static analysis and want performance data
14
+ // - You want a quick runtime check without code analysis
15
+ // - You want to compare desktop vs mobile performance
16
+ //
17
+ // Options:
18
+ // --mobile also profile on mobile viewport
19
+ // --cpu 4 simulate 4x CPU slowdown (Lighthouse preset)
20
+ // --throttle slow4g simulate slow network (deployed URLs only)
21
+ //
22
+ // Use "react-doctor full" to run everything together.
23
+ // ─────────────────────────────────────────────────────────────
24
+ var __importDefault = (this && this.__importDefault) || function (mod) {
25
+ return (mod && mod.__esModule) ? mod : { "default": mod };
26
+ };
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.registerProfileCommand = registerProfileCommand;
29
+ const path_1 = __importDefault(require("path"));
30
+ const fs_1 = __importDefault(require("fs"));
31
+ const chalk_1 = __importDefault(require("chalk"));
32
+ const ui_1 = require("../ui");
33
+ function getCoreModule(relativePath) {
34
+ return require(path_1.default.resolve(__dirname, "..", "..", "..", "core", relativePath));
35
+ }
36
+ // ─────────────────────────────────────────────────────────────
37
+ // REGISTER COMMAND
38
+ // ─────────────────────────────────────────────────────────────
39
+ function registerProfileCommand(program) {
40
+ program
41
+ .command("profile")
42
+ .description("Run the runtime profiler only (requires Chrome)")
43
+ .argument("[projectPath]", "Path to the React project (defaults to current directory)", process.cwd())
44
+ .option("--desktop", "Profile on desktop viewport 1280x720 (default if neither flag is passed)", false)
45
+ .option("--mobile", "Profile on mobile viewport — iPhone 12 Pro 390x844", false)
46
+ .option("--cpu <rate>", "CPU throttle: 1 (real speed) | 4 (Lighthouse mobile) | 6 (low-end)", (v) => parseInt(v), 1)
47
+ .option("--throttle <preset>", "Network throttle: none | slow4g | 3g (only meaningful against deployed URLs)", "none")
48
+ .option("--no-banner", "Skip the banner")
49
+ .action(async (projectPath, options) => {
50
+ const resolvedPath = path_1.default.resolve(projectPath);
51
+ if (!options.noBanner)
52
+ (0, ui_1.printBanner)();
53
+ // ── Validate project ────────────────────────────────────
54
+ if (!fs_1.default.existsSync(path_1.default.join(resolvedPath, "package.json"))) {
55
+ (0, ui_1.printFail)(`No package.json found at: ${resolvedPath}\n\n` +
56
+ ` Pass the path to your React project:\n` +
57
+ ` react-doctor profile ./my-react-app`);
58
+ process.exit(1);
59
+ }
60
+ // ── Determine devices ────────────────────────────────────
61
+ // --desktop and --mobile are independent flags.
62
+ // If neither is passed, desktop is the default.
63
+ // If only --mobile is passed, only mobile runs.
64
+ // If both are passed, both run in one pass.
65
+ const wantDesktop = options.desktop || (!options.desktop && !options.mobile);
66
+ const wantMobile = options.mobile ?? false;
67
+ const devices = wantDesktop && wantMobile ? ["desktop", "mobile"] :
68
+ wantMobile ? "mobile" :
69
+ "desktop";
70
+ const deviceLabel = wantDesktop && wantMobile ? "desktop + mobile" :
71
+ wantMobile ? "mobile" :
72
+ "desktop";
73
+ (0, ui_1.printSection)("Runtime Profiler");
74
+ (0, ui_1.printInfo)("Project", resolvedPath);
75
+ (0, ui_1.printInfo)("Device", deviceLabel);
76
+ (0, ui_1.printInfo)("CPU", `${options.cpu}x`);
77
+ (0, ui_1.printInfo)("Network", options.throttle);
78
+ console.log();
79
+ // ── Run the profiler ────────────────────────────────────
80
+ const spin = (0, ui_1.spinner)("Starting dev server...");
81
+ try {
82
+ const { RuntimeProfiler } = getCoreModule("runtime/profiler/index");
83
+ const profiler = new RuntimeProfiler(resolvedPath);
84
+ spin.text = " Launching headless Chrome...";
85
+ const runtimeReports = await profiler.profile([], {
86
+ device: devices,
87
+ throttle: options.throttle,
88
+ cpuThrottle: options.cpu,
89
+ });
90
+ // Save to .react-doctor/
91
+ const outputDir = path_1.default.join(resolvedPath, ".react-doctor");
92
+ fs_1.default.mkdirSync(outputDir, { recursive: true });
93
+ fs_1.default.writeFileSync(path_1.default.join(outputDir, "runtimereport.json"), JSON.stringify(runtimeReports, null, 2));
94
+ const routeKeys = Object.keys(runtimeReports);
95
+ spin.succeed(chalk_1.default.green(`Profiling complete — ${routeKeys.length} route/device combination(s)`));
96
+ // ── Results ─────────────────────────────────────────────
97
+ (0, ui_1.printSection)("Results");
98
+ for (const [key, report] of Object.entries(runtimeReports)) {
99
+ const [route, device] = key.includes("::") ? key.split("::") : [key, "desktop"];
100
+ console.log();
101
+ console.log(` ${chalk_1.default.bold("Route:")} ${route} ` +
102
+ `${chalk_1.default.gray(`[${device}]`)} ` +
103
+ `Score: ${(0, ui_1.scoreBadge)(report.performanceScore)}`);
104
+ console.log();
105
+ // Web vitals
106
+ (0, ui_1.printResult)("LCP", `${report.metrics.lcp.toFixed(0)}ms`, (0, ui_1.vitalStatus)("lcp", report.metrics.lcp));
107
+ (0, ui_1.printResult)("FCP", `${report.metrics.fcp.toFixed(0)}ms`, (0, ui_1.vitalStatus)("fcp", report.metrics.fcp));
108
+ (0, ui_1.printResult)("TTFB", `${report.metrics.ttfb.toFixed(0)}ms`, (0, ui_1.vitalStatus)("ttfb", report.metrics.ttfb));
109
+ (0, ui_1.printResult)("CLS", report.metrics.cls.toFixed(3), (0, ui_1.vitalStatus)("cls", report.metrics.cls));
110
+ (0, ui_1.printResult)("INP", `${report.metrics.inp.toFixed(0)}ms`, (0, ui_1.vitalStatus)("inp", report.metrics.inp));
111
+ (0, ui_1.printResult)("Render time", `${report.renderTime}ms`, report.renderTime <= 2000 ? "good" : report.renderTime <= 4000 ? "warn" : "poor");
112
+ // React profiler
113
+ if (report.commitDurations?.length > 0) {
114
+ const avg = (report.commitDurations.reduce((a, b) => a + b, 0) /
115
+ report.commitDurations.length).toFixed(1);
116
+ const slow = report.commitDurations.filter((d) => d > 16).length;
117
+ (0, ui_1.printResult)("React commits", `${report.commitDurations.length} total, avg ${avg}ms`, slow > 0 ? "warn" : "good");
118
+ }
119
+ // Top re-render component
120
+ const rerenderEntries = Object.entries(report.rerenders ?? {})
121
+ .sort(([, a], [, b]) => b - a);
122
+ if (rerenderEntries.length > 0) {
123
+ const [topName, topCount] = rerenderEntries[0];
124
+ (0, ui_1.printResult)("Most re-renders", `${topName} (${topCount}x)`, topCount >= 10 ? "poor" : topCount >= 5 ? "warn" : "good");
125
+ }
126
+ // System stats
127
+ (0, ui_1.printResult)("Page weight", `${report.stats.payloadMB} MB`, "info");
128
+ (0, ui_1.printResult)("JS heap", `${report.stats.jsHeapMB} MB`, "info");
129
+ (0, ui_1.printResult)("DOM nodes", String(report.stats.domNodes), "info");
130
+ if (report.stats.topOffender) {
131
+ (0, ui_1.printResult)("Heaviest file", `${report.stats.topOffender.name} (${report.stats.topOffender.size.toFixed(2)} MB)`, "warn");
132
+ }
133
+ // Errors
134
+ const errorCount = (report.errors ?? []).filter((e) => e.type === "error").length;
135
+ const warningCount = (report.errors ?? []).filter((e) => e.type === "warning").length;
136
+ if (errorCount > 0 || warningCount > 0) {
137
+ (0, ui_1.printResult)("Issues", `${errorCount} error(s) ${warningCount} warning(s)`, errorCount > 0 ? "poor" : "warn");
138
+ (report.errors ?? []).slice(0, 3).forEach((e) => {
139
+ const icon = e.type === "error" ? chalk_1.default.red(" ✗") : chalk_1.default.yellow(" !");
140
+ console.log(`${icon} ${chalk_1.default.gray(e.message.slice(0, 90))}`);
141
+ });
142
+ }
143
+ else {
144
+ (0, ui_1.printResult)("Issues", "None detected", "good");
145
+ }
146
+ // Screenshots
147
+ if (report.screenshots?.length > 0) {
148
+ const labels = report.screenshots.map((s) => `${s.label}@${s.takenAt}ms`).join(" ");
149
+ (0, ui_1.printResult)("Screenshots", labels, "info");
150
+ }
151
+ console.log();
152
+ }
153
+ (0, ui_1.printResult)("Report saved", path_1.default.join(resolvedPath, ".react-doctor", "runtimereport.json"), "info");
154
+ console.log();
155
+ (0, ui_1.printDone)("Runtime profiling finished.");
156
+ console.log(chalk_1.default.gray(" Tip: run ") +
157
+ chalk_1.default.cyan("react-doctor full ./") +
158
+ chalk_1.default.gray(" to also get static analysis and improvement suggestions.\n"));
159
+ }
160
+ catch (err) {
161
+ spin.fail(chalk_1.default.red("Runtime profiling failed"));
162
+ console.log(chalk_1.default.red(`\n ${err.message}\n`));
163
+ process.exit(1);
164
+ }
165
+ });
166
+ }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // ─────────────────────────────────────────────────────────────
4
+ // cli/src/index.ts
5
+ //
6
+ // The CLI entry point. This is the file that runs when the
7
+ // user types "react-doctor" in their terminal.
8
+ //
9
+ // HOW IT WORKS:
10
+ // 1. Commander.js parses the command and flags from argv
11
+ // 2. The matching command handler is called
12
+ // 3. The handler imports core modules and runs the pipeline
13
+ //
14
+ // HOW THE BINARY REGISTRATION WORKS:
15
+ // package.json has a "bin" field:
16
+ // "bin": { "react-doctor": "./dist/index.js" }
17
+ //
18
+ // After "npm link" (dev) or "npm install" (production),
19
+ // npm creates a symlink from the system's bin directory
20
+ // to this file. That's what makes "react-doctor" a real
21
+ // terminal command available anywhere.
22
+ //
23
+ // THE SHEBANG (#!/usr/bin/env node) on line 1:
24
+ // This tells the OS to run this file with Node.js when
25
+ // called directly as a script. Without it, the OS doesn't
26
+ // know which interpreter to use.
27
+ // ─────────────────────────────────────────────────────────────
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ const commander_1 = require("commander");
30
+ const analyze_1 = require("./commands/analyze");
31
+ const profile_1 = require("./commands/profile");
32
+ const full_1 = require("./commands/full");
33
+ const install_1 = require("./commands/install");
34
+ const program = new commander_1.Command();
35
+ // ── Program metadata ──────────────────────────────────────────
36
+ program
37
+ .name("react-doctor")
38
+ .description("React performance analyzer — static analysis + runtime profiling + smart suggestions")
39
+ .version("1.0.0");
40
+ // ── Register all commands ─────────────────────────────────────
41
+ // Each function adds one command to the program.
42
+ // The order here is the order they appear in --help output.
43
+ (0, full_1.registerFullCommand)(program); // react-doctor full
44
+ (0, analyze_1.registerAnalyzeCommand)(program); // react-doctor analyze
45
+ (0, profile_1.registerProfileCommand)(program); // react-doctor profile
46
+ (0, install_1.registerInstallCommand)(program); // react-doctor install
47
+ // ── Usage examples shown at bottom of --help ─────────────────
48
+ program.addHelpText("after", `
49
+ Examples:
50
+ $ react-doctor full ./my-app Desktop only (default)
51
+ $ react-doctor full ./my-app --mobile Mobile only
52
+ $ react-doctor full ./my-app --desktop --mobile Both desktop and mobile
53
+ $ react-doctor full ./my-app --cpu 4 Simulate slow Android device
54
+ $ react-doctor full ./my-app --throttle slow4g Simulate slow 4G network
55
+ $ react-doctor full ./my-app --throttle 3g Simulate 3G network
56
+ $ react-doctor full ./my-app --cpu 4 --throttle 3g Slow device + slow network
57
+ $ react-doctor full ./my-app --upload Upload results to dashboard
58
+
59
+ $ react-doctor analyze ./my-app Static code analysis only
60
+ $ react-doctor analyze ./my-app --full Static + runtime + rules
61
+
62
+ $ react-doctor profile ./my-app Desktop only (default)
63
+ $ react-doctor profile ./my-app --mobile Mobile only
64
+ $ react-doctor profile ./my-app --desktop --mobile Both devices
65
+ $ react-doctor profile ./my-app --cpu 4 4x CPU slowdown simulation
66
+ $ react-doctor profile ./my-app --throttle slow4g Simulate slow 4G network
67
+ $ react-doctor profile ./my-app --throttle 3g Simulate 3G network
68
+
69
+ $ react-doctor install Install from GitHub into a project
70
+ $ react-doctor install --path ./my-app Install into a specific folder
71
+ `);
72
+ // ── Show help if called with no arguments ─────────────────────
73
+ // Without this, calling "react-doctor" with no command just
74
+ // exits silently, which is confusing. This prints help instead.
75
+ if (process.argv.length < 3) {
76
+ program.help();
77
+ }
78
+ program.parse(process.argv);
package/cli/dist/ui.js ADDED
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ // ─────────────────────────────────────────────────────────────
3
+ // cli/src/ui.ts
4
+ //
5
+ // Shared terminal output helpers used by every command.
6
+ // All chalk colors, ora spinners, and print functions live
7
+ // here so the visual style is consistent across the CLI.
8
+ //
9
+ // WHY ONE FILE FOR ALL UI:
10
+ // If each command file had its own chalk/ora setup, changing
11
+ // a color or spacing would require editing every file. This
12
+ // way you change it once here and it applies everywhere.
13
+ // ─────────────────────────────────────────────────────────────
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.printBanner = printBanner;
19
+ exports.printSection = printSection;
20
+ exports.spinner = spinner;
21
+ exports.printResult = printResult;
22
+ exports.scoreBadge = scoreBadge;
23
+ exports.severityIcon = severityIcon;
24
+ exports.printDone = printDone;
25
+ exports.printFail = printFail;
26
+ exports.printInfo = printInfo;
27
+ exports.vitalStatus = vitalStatus;
28
+ const chalk_1 = __importDefault(require("chalk"));
29
+ const ora_1 = __importDefault(require("ora"));
30
+ // ── Banner ────────────────────────────────────────────────────
31
+ // Printed once at the start of every command run.
32
+ function printBanner() {
33
+ console.log();
34
+ console.log(chalk_1.default.cyan.bold(" ┌─────────────────────────────────┐"));
35
+ console.log(chalk_1.default.cyan.bold(" │ 🩺 React Doctor │"));
36
+ console.log(chalk_1.default.cyan.bold(" │ React Performance Analyzer │"));
37
+ console.log(chalk_1.default.cyan.bold(" └─────────────────────────────────┘"));
38
+ console.log();
39
+ }
40
+ // ── Section headers ───────────────────────────────────────────
41
+ // Visually separates sections within a command's output.
42
+ function printSection(title) {
43
+ console.log();
44
+ console.log(chalk_1.default.cyan.bold(` ── ${title} `).padEnd(58, "─"));
45
+ console.log();
46
+ }
47
+ // ── Spinner ───────────────────────────────────────────────────
48
+ // Returns a running ora spinner.
49
+ // Caller calls .succeed() or .fail() when done.
50
+ function spinner(text) {
51
+ return (0, ora_1.default)({ text: ` ${text}`, color: "cyan", spinner: "dots" }).start();
52
+ }
53
+ // ── Single result line ────────────────────────────────────────
54
+ // Prints one labeled metric with a colored status dot.
55
+ function printResult(label, value, status = "none") {
56
+ const dot = status === "good" ? chalk_1.default.green("●") :
57
+ status === "warn" ? chalk_1.default.yellow("●") :
58
+ status === "poor" ? chalk_1.default.red("●") :
59
+ status === "info" ? chalk_1.default.blue("●") :
60
+ chalk_1.default.gray("·");
61
+ console.log(` ${dot} ${chalk_1.default.gray(label.padEnd(24))} ${value}`);
62
+ }
63
+ // ── Score badge ───────────────────────────────────────────────
64
+ // Formats a 0-100 score with a color band.
65
+ function scoreBadge(score) {
66
+ if (score >= 90)
67
+ return chalk_1.default.green.bold(`${score}/100`) + chalk_1.default.green(" Excellent");
68
+ if (score >= 70)
69
+ return chalk_1.default.yellow.bold(`${score}/100`) + chalk_1.default.yellow(" Good");
70
+ if (score >= 50)
71
+ return chalk_1.default.hex("#FFA500").bold(`${score}/100`) + chalk_1.default.hex("#FFA500")(" Needs Work");
72
+ return chalk_1.default.red.bold(`${score}/100`) + chalk_1.default.red(" Poor");
73
+ }
74
+ // ── Severity icon ─────────────────────────────────────────────
75
+ function severityIcon(s) {
76
+ if (s === "critical")
77
+ return chalk_1.default.red("❌");
78
+ if (s === "warning")
79
+ return chalk_1.default.yellow("⚠️ ");
80
+ return chalk_1.default.blue("ℹ️ ");
81
+ }
82
+ // ── Done / fail ───────────────────────────────────────────────
83
+ function printDone(message) {
84
+ console.log();
85
+ console.log(chalk_1.default.green.bold(` ✅ ${message}`));
86
+ console.log();
87
+ }
88
+ function printFail(message) {
89
+ console.log();
90
+ console.log(chalk_1.default.red.bold(` ❌ ${message}`));
91
+ console.log();
92
+ }
93
+ // ── Muted info line ───────────────────────────────────────────
94
+ function printInfo(label, value) {
95
+ console.log(` ${chalk_1.default.gray(label.padEnd(16))} ${chalk_1.default.white(value)}`);
96
+ }
97
+ // ── Web vital threshold helper ────────────────────────────────
98
+ // Maps a metric value to good/warn/poor for colored output.
99
+ function vitalStatus(metric, value) {
100
+ const thresholds = {
101
+ lcp: { good: 2500, poor: 4000 },
102
+ fcp: { good: 1800, poor: 3000 },
103
+ ttfb: { good: 800, poor: 1800 },
104
+ inp: { good: 200, poor: 500 },
105
+ cls: { good: 0.1, poor: 0.25 },
106
+ };
107
+ const t = thresholds[metric];
108
+ if (value <= t.good)
109
+ return "good";
110
+ if (value <= t.poor)
111
+ return "warn";
112
+ return "poor";
113
+ }