From b81e99e006a2810b99eff1ad6aec4772e45e7a80 Mon Sep 17 00:00:00 2001 From: Christian-Bermejo Date: Sun, 14 Jun 2026 07:35:23 +0800 Subject: [PATCH] Modularize Studio App.jsx into lib/components and add a test suite App.jsx was a single 997-line file mixing data, analysis logic, CSV parsing, UI primitives, and five feature components. This splits it into focused modules and adds the Studio's first automated tests. Extraction (behavior-preserving, verbatim moves): - src/lib/lexicons.js word/emoji sets, TONE_DEFS/COLOR/PATTERNS, CAPTION_TONES - src/lib/analysis.js tokenize, countEmoji, analyzeSentiment, analyzeTone, enrich - src/lib/csv.js FIELD_MAP, pick, toNum, normalize, parseCSV - src/components/ primitives + UploadCard/Overview/Brands/Tweets/CaptionStudio - src/App.jsx thin shell (997 -> 174 lines) wiring tabs + selection state Tests (Vitest, jsdom-free Node env): - 40 tests across analysis/csv/lexicons; 100% line + 95.5% branch coverage of lib/ - npm test / test:watch / test:coverage scripts; standalone vitest.config.js so the build config is untouched Behavior is unchanged: production build is byte-stable (identical CSS hash), and a normalized source diff vs the original shows only formatter cosmetics (semicolons, trailing commas, arrow-param parens) plus the deliberate enrich/CAPTION_TONES moves. The unused lucide `Filter` import was dropped. --- studio/package-lock.json | 535 +++++++++++- studio/package.json | 9 +- studio/src/App.jsx | 1055 +++-------------------- studio/src/components/BrandsTab.jsx | 111 +++ studio/src/components/CaptionStudio.jsx | 387 +++++++++ studio/src/components/OverviewTab.jsx | 169 ++++ studio/src/components/TweetsTab.jsx | 218 +++++ studio/src/components/UploadCard.jsx | 96 +++ studio/src/components/primitives.jsx | 68 ++ studio/src/lib/analysis.js | 96 +++ studio/src/lib/analysis.test.js | 147 ++++ studio/src/lib/csv.js | 66 ++ studio/src/lib/csv.test.js | 87 ++ studio/src/lib/lexicons.js | 355 ++++++++ studio/src/lib/lexicons.test.js | 44 + studio/vitest.config.js | 15 + 16 files changed, 2516 insertions(+), 942 deletions(-) create mode 100644 studio/src/components/BrandsTab.jsx create mode 100644 studio/src/components/CaptionStudio.jsx create mode 100644 studio/src/components/OverviewTab.jsx create mode 100644 studio/src/components/TweetsTab.jsx create mode 100644 studio/src/components/UploadCard.jsx create mode 100644 studio/src/components/primitives.jsx create mode 100644 studio/src/lib/analysis.js create mode 100644 studio/src/lib/analysis.test.js create mode 100644 studio/src/lib/csv.js create mode 100644 studio/src/lib/csv.test.js create mode 100644 studio/src/lib/lexicons.js create mode 100644 studio/src/lib/lexicons.test.js create mode 100644 studio/vitest.config.js diff --git a/studio/package-lock.json b/studio/package-lock.json index f7e3373..090257b 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -16,8 +16,10 @@ "devDependencies": { "@tailwindcss/vite": "^4.0.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.1.8", "tailwindcss": "^4.0.0", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vitest": "^4.1.8" } }, "node_modules/@babel/code-frame": { @@ -302,6 +304,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1190,6 +1202,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", @@ -1519,6 +1538,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1547,6 +1584,179 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.8", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.32", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", @@ -1615,6 +1825,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1671,6 +1891,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1723,6 +1950,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1773,6 +2020,62 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2129,6 +2432,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2165,12 +2509,33 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/papaparse": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2319,6 +2684,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2329,6 +2701,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -2350,6 +2749,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2367,6 +2783,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2473,6 +2899,113 @@ } } }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/studio/package.json b/studio/package.json index b94dcd6..0a447c0 100644 --- a/studio/package.json +++ b/studio/package.json @@ -6,7 +6,10 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "lucide-react": "^0.468.0", @@ -17,7 +20,9 @@ "devDependencies": { "@tailwindcss/vite": "^4.0.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.1.8", "tailwindcss": "^4.0.0", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vitest": "^4.1.8" } } diff --git a/studio/src/App.jsx b/studio/src/App.jsx index e5fe0ef..ed827e5 100644 --- a/studio/src/App.jsx +++ b/studio/src/App.jsx @@ -1,938 +1,78 @@ -import { useState, useMemo, useRef } from 'react' -import Papa from 'papaparse' +import { useState, useMemo } from "react"; import { - Upload, FileText, Sparkles, Copy, Check, AlertCircle, BarChart3, - Hash, MessageSquare, TrendingUp, Heart, Repeat2, Eye, Wand2, Filter, X, -} from 'lucide-react' - -/* ============================================================ - * LEXICONS & TONE PATTERNS - * ============================================================ */ - -const POSITIVE_WORDS = new Set([ - 'love','loved','loving','amazing','awesome','great','fantastic','wonderful','excellent', - 'beautiful','perfect','happy','excited','thrilled','delighted','enjoy','enjoyed', - 'best','better','good','nice','cool','wow','yay','win','wins','winning','success', - 'proud','grateful','thankful','thanks','thank','blessed','inspire','inspired', - 'inspiring','stunning','gorgeous','brilliant','genius','masterpiece','legendary', - 'iconic','favorite','fave','incredible','magical','vibes','slay','slayed','ate', - 'bomb','fire','lit','dope','goat','queen','king','support','supporting','cutie', - 'cute','adorable','pretty','sweet','bloom','blooming','obsessed','impressed', - 'recommend','worth','quality','smooth','soft','fresh','charming','elegant', -]) - -const NEGATIVE_WORDS = new Set([ - 'hate','terrible','awful','horrible','bad','worst','worse','sad','angry','mad', - 'upset','disappoint','disappointed','disappointing','fail','failed','failure', - 'broken','ugly','stupid','dumb','trash','garbage','lame','boring','cringe','flop', - 'flopped','cancel','cancelled','scam','fake','mid','cheap','gross','annoying', - 'annoyed','frustrated','frustrating','ugh','yikes','reject','hurt','hurting', - 'tired','sucks','suck','sucked','overpriced','rip','ripoff','regret','regretted', - 'delayed','delay','missing','lost','stolen','rude', -]) - -const INTENSIFIERS = new Set(['very','really','so','super','extremely','absolutely','totally','completely']) -const NEGATIONS = new Set(['not','no','never','dont',"don't","doesn't","didn't","isn't","wasn't","won't"]) - -// Note: 😭 and πŸ₯Ί are intentionally treated as POSITIVE in social-media context -// ("so good I'm crying" / affectionate). Move them to NEGATIVE_EMOJI if your -// dataset is mostly customer-support / complaints rather than fan reactions. -const POSITIVE_EMOJI = ['❀️','🧑','πŸ’›','πŸ’š','πŸ’™','πŸ’œ','πŸ–€','🀍','πŸ’–','πŸ’•','πŸ’“','πŸ’ž','πŸ’—','πŸ’˜','😍','πŸ₯°','😘','😊','πŸ™‚','πŸ˜„','😁','πŸ˜ƒ','πŸ˜†','πŸ₯³','πŸŽ‰','✨','⭐','🌟','πŸ’«','πŸ”₯','πŸ’―','πŸ‘','πŸ‘','πŸ™Œ','🀩','😎','πŸ’ͺ','πŸ†','πŸ₯‡','🌸','🌺','🌼','🌷','🌹','🌻','🌈','😭','πŸ₯Ί'] -const NEGATIVE_EMOJI = ['😒','😞','πŸ˜”','😟','πŸ˜•','☹️','πŸ™','😣','πŸ˜–','😫','😩','😀','😠','😑','🀬','πŸ‘Ž','πŸ’”','😱','😰','😨','πŸ˜₯'] -const NEGATIVE_EMOTICONS = /(:\(|:-\(|D:|:\/)/g - -const TONE_DEFS = [ - { tag: 'Excited', color: 'bg-orange-100 text-orange-800' }, - { tag: 'Promotional', color: 'bg-purple-100 text-purple-800' }, - { tag: 'Informational', color: 'bg-slate-100 text-slate-800' }, - { tag: 'Playful', color: 'bg-pink-100 text-pink-800' }, - { tag: 'Inspirational', color: 'bg-amber-100 text-amber-800' }, - { tag: 'Urgent', color: 'bg-red-100 text-red-800' }, - { tag: 'Question', color: 'bg-sky-100 text-sky-800' }, - { tag: 'CTA', color: 'bg-indigo-100 text-indigo-800' }, - { tag: 'Storytelling', color: 'bg-emerald-100 text-emerald-800' }, - { tag: 'Community', color: 'bg-teal-100 text-teal-800' }, - { tag: 'Gratitude', color: 'bg-yellow-100 text-yellow-800' }, -] -const TONE_COLOR = Object.fromEntries(TONE_DEFS.map(t => [t.tag, t.color])) - -const TONE_PATTERNS = [ - { tag: 'Excited', match: (t, m) => m.exclaim >= 2 || m.capsRatio > 0.3 || /\b(wow|omg|yay|yesss+|yess+|let\'?s go|insane|unreal)\b/i.test(t) }, - { tag: 'Promotional', match: (t) => /\b(available|check out|shop now|launch|launching|announcing|new collection|drop|dropping|sale|off|discount|pre-?order|now available|just dropped|new!)\b/i.test(t) }, - { tag: 'Informational', match: (t) => /\b(update|announcement|fyi|reminder|guide|explained|learn|here\'?s how|breakdown)\b/i.test(t) }, - { tag: 'Playful', match: (t, m) => m.emoji >= 3 || /\b(lol|haha|hehe|teehee|aww+|bestie|besties|lmao)\b/i.test(t) }, - { tag: 'Inspirational', match: (t) => /\b(dream|believe|journey|grow|growth|strength|courage|shine|bloom|rise|possible|never give up|keep going|you can|we can)\b/i.test(t) }, - { tag: 'Urgent', match: (t) => /\b(now|today|tonight|hurry|last chance|limited|ends soon|don\'?t miss|only \d+ (left|hours|days)|this week|tomorrow)\b/i.test(t) }, - { tag: 'Question', match: (t) => /\?/.test(t) && /\b(what|how|why|who|when|which|where|should|would|could|do you|have you|are you)\b/i.test(t) }, - { tag: 'CTA', match: (t) => /\b(follow|share|retweet|rt|like|tag|comment|reply|dm|drop a|click|swipe|link in bio|subscribe|join)\b/i.test(t) }, - { tag: 'Storytelling', match: (t) => t.length > 160 && /\b(when|remember|once|today i|so i|then|after|before)\b/i.test(t) }, - { tag: 'Community', match: (t) => /\b(we|us|our|together|fam|family|crew|team|community|everyone|y\'?all)\b/i.test(t) }, - { tag: 'Gratitude', match: (t) => /\b(thank|thanks|grateful|appreciate|appreciated|blessed|bless)\b/i.test(t) }, -] - -/* ============================================================ - * ANALYSIS - * ============================================================ */ - -function tokenize(text) { - return text.toLowerCase().replace(/[^a-z0-9\s']/g, ' ').split(/\s+/).filter(Boolean) -} - -function countEmoji(text, list) { - let count = 0 - for (const e of list) { - const re = new RegExp(e.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g') - const m = text.match(re) - if (m) count += m.length - } - return count -} - -function analyzeSentiment(text) { - if (!text) return { score: 0, label: 'neutral', magnitude: 0 } - const tokens = tokenize(text) - let pos = 0, neg = 0 - for (let i = 0; i < tokens.length; i++) { - const w = tokens[i] - const prev = tokens[i - 1] || '' - const prev2 = tokens[i - 2] || '' - const boost = INTENSIFIERS.has(prev) ? 1.5 : 1 - const negated = NEGATIONS.has(prev) || NEGATIONS.has(prev2) - if (POSITIVE_WORDS.has(w)) { if (negated) neg += boost; else pos += boost } - else if (NEGATIVE_WORDS.has(w)) { if (negated) pos += boost; else neg += boost } - } - pos += countEmoji(text, POSITIVE_EMOJI) * 0.8 - neg += countEmoji(text, NEGATIVE_EMOJI) * 0.8 - neg += (text.match(NEGATIVE_EMOTICONS) || []).length * 0.8 - const raw = pos - neg - const norm = raw / Math.max(1, Math.sqrt(pos + neg + 1)) - const score = Math.max(-1, Math.min(1, norm / 3)) - const magnitude = pos + neg - let label = 'neutral' - if (score > 0.12) label = 'positive' - else if (score < -0.12) label = 'negative' - return { score, label, magnitude } -} - -function analyzeTone(text) { - if (!text) return [] - const exclaim = (text.match(/!/g) || []).length - const letters = text.replace(/[^a-zA-Z]/g, '') - const caps = text.replace(/[^A-Z]/g, '').length - const capsRatio = letters.length ? caps / letters.length : 0 - const emoji = countEmoji(text, POSITIVE_EMOJI) + countEmoji(text, NEGATIVE_EMOJI) - const meta = { exclaim, capsRatio, emoji } - return TONE_PATTERNS.filter(p => p.match(text, meta)).map(p => p.tag) -} - -/* ============================================================ - * CSV PARSING - * ============================================================ */ - -const FIELD_MAP = { - text: ['text', 'content', 'tweet', 'body', 'message', 'full_text'], - date: ['createdAt', 'created_at', 'date', 'timestamp', 'time'], - likes: ['likeCount', 'likes', 'favorite_count', 'favoriteCount', 'like'], - rts: ['retweetCount', 'retweets', 'retweet_count'], - replies: ['replyCount', 'replies', 'reply_count'], - views: ['viewCount', 'views', 'view_count', 'impressions'], - author: ['authorUsername', 'author', 'user', 'username', 'screen_name'], - url: ['url', 'tweet_url', 'link'], - id: ['id', 'id_str', 'tweet_id'], - brand: ['sourceTerm', 'brand', 'query', 'searchTerm', 'topic'], -} - -function pick(row, candidates) { - const keys = Object.keys(row) - for (const c of candidates) { - const k = keys.find(k => k.toLowerCase() === c.toLowerCase()) - if (k !== undefined) return row[k] - } - return '' -} - -function toNum(v) { - const n = typeof v === 'number' ? v : parseFloat(String(v).replace(/,/g, '')) - return isNaN(n) ? 0 : n -} - -function normalize(row) { - const text = String(pick(row, FIELD_MAP.text) || '') - return { - id: String(pick(row, FIELD_MAP.id) || ''), - url: String(pick(row, FIELD_MAP.url) || ''), - date: String(pick(row, FIELD_MAP.date) || ''), - author: String(pick(row, FIELD_MAP.author) || ''), - brand: String(pick(row, FIELD_MAP.brand) || ''), - text, - likes: toNum(pick(row, FIELD_MAP.likes)), - retweets: toNum(pick(row, FIELD_MAP.rts)), - replies: toNum(pick(row, FIELD_MAP.replies)), - views: toNum(pick(row, FIELD_MAP.views)), - } -} - -function enrich(r) { - const sentiment = analyzeSentiment(r.text) - const tones = analyzeTone(r.text) - const engagement = r.likes + r.retweets * 2 + r.replies - const emoji = countEmoji(r.text, POSITIVE_EMOJI) + countEmoji(r.text, NEGATIVE_EMOJI) - const hashtags = (r.text.match(/#\w+/g) || []).length - const mentions = (r.text.match(/@\w+/g) || []).length - return { ...r, sentiment, tones, engagement, emoji, hashtags, mentions, length: r.text.length } -} - -function parseCSV(text) { - return new Promise((resolve, reject) => { - Papa.parse(text, { - header: true, - skipEmptyLines: true, - complete: (res) => { - const rows = (res.data || []) - .map(normalize) - .filter(r => r.text) - .map((r, i) => enrich({ ...r, uid: r.id || `tw_${i}` })) - resolve(rows) - }, - error: reject, - }) - }) -} - -/* ============================================================ - * UI PRIMITIVES - * ============================================================ */ - -function Stat({ icon: Icon, label, value, sub, tone = 'slate' }) { - const tones = { - slate: 'bg-slate-50 text-slate-700', - green: 'bg-green-50 text-green-700', - rose: 'bg-rose-50 text-rose-700', - sky: 'bg-sky-50 text-sky-700', - amber: 'bg-amber-50 text-amber-700', - } - return ( -
-
- {Icon &&
} -
{label}
-
-
{value}
- {sub &&
{sub}
} -
- ) -} - -function Bar({ label, value, total, color = 'bg-indigo-500' }) { - const pct = total ? Math.round((value / total) * 100) : 0 - return ( -
-
- {label} - {value} ({pct}%) -
-
-
-
-
- ) -} - -function Chip({ children, color = 'bg-slate-100 text-slate-700' }) { - return {children} -} - -function SentimentChip({ label }) { - const map = { - positive: 'bg-green-100 text-green-800', - neutral: 'bg-slate-100 text-slate-700', - negative: 'bg-rose-100 text-rose-800', - } - return {label} -} - -/* ============================================================ - * UPLOAD - * ============================================================ */ - -function UploadCard({ onLoad, onError }) { - const ref = useRef(null) - const [drag, setDrag] = useState(false) - const [paste, setPaste] = useState('') - - const handleFile = async (file) => { - try { - const text = await file.text() - const rows = await parseCSV(text) - if (!rows.length) throw new Error('No valid rows found. CSV needs at least a "text" column.') - onLoad(rows, file.name) - } catch (e) { onError(e.message) } - } - - const handlePaste = async () => { - try { - const rows = await parseCSV(paste) - if (!rows.length) throw new Error('No valid rows found in pasted text.') - onLoad(rows, 'pasted.csv') - } catch (e) { onError(e.message) } - } - - return ( -
-
{ e.preventDefault(); setDrag(true) }} - onDragLeave={() => setDrag(false)} - onDrop={(e) => { e.preventDefault(); setDrag(false); const f = e.dataTransfer.files[0]; if (f) handleFile(f) }} - onClick={() => ref.current?.click()} - className={`rounded-2xl border-2 border-dashed p-12 text-center cursor-pointer transition ${drag ? 'border-indigo-500 bg-indigo-50' : 'border-slate-300 bg-white hover:bg-slate-50'}`} - > - -
Drop a tweets CSV here
-
or click to browse
- e.target.files[0] && handleFile(e.target.files[0])} /> -
- -
- Or paste CSV text -