Compare commits
9 Commits
query-vali
...
db-build
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce7bc2cde9 | ||
|
|
0ee426e1f3 | ||
|
|
90a19e4525 | ||
|
|
8b555bf729 | ||
|
|
c8ee194bc5 | ||
|
|
f77362e684 | ||
|
|
9ba13206c5 | ||
|
|
0c7baeb9d8 | ||
|
|
07b0309adb |
77
README.md
77
README.md
@@ -1,42 +1,75 @@
|
|||||||
# kongruity app
|
# kongruity
|
||||||
|
|
||||||
kongruity clusters large volumes of unstructured action items in development settings by their thematic or topical similarity.
|
kongruity pulls in the unstructured artifacts of the creative-engineering process -- to-dos, action items, agile tickets, Jira comment threads, Slack threads, retrospective notes -- and synthesizes them into semantically coherent, prioritized clusters ready for implementation planning.
|
||||||
|
|
||||||
In kongruity, "to dos", action items, agile tickets, Jira comments (appropriately tagged) ... all the myriad artifacts of the creative-engineering process, become thematic "sticky notes."
|
In kongruity world, these artifacts are "sticky notes." A board full of them looks chaotic. With a click, an LLM analyzes their semantic meaning and groups them into thematic clusters, each with a descriptive header.
|
||||||
|
|
||||||
kongruity's React/Vite UI displays a board of your team's seemingly chaotic "sticky notes".
|
An independent embedding-based evaluation scores clustering quality, so the output is data-backed. From there, teams can simply drag-and-rank clusters by implementation priority, turning noise into an actionable workflow.
|
||||||
|
|
||||||
They are transformed into manageable, actionable groups, with headers that explains each group's semantic relation, and project magament UI options: ranking temporal priority for future relase goals.
|
## How it works
|
||||||
|
|
||||||
The backend features an Express server/API that serves "sticky note" data and proxies semantic grouping requests to Large Language Models.
|
1. **Ingest** — Sticky notes are loaded and displayed on a board.
|
||||||
|
2. **Cluster** — An LLM reads every note and groups them by semantic similarity (not keywords).
|
||||||
|
3. **Evaluate** — In parallel, a separate embedding model (Voyage AI) generates vector representations of each note. A silhouette-based cohesion score measures how well-separated and internally consistent clusters are. The score is displayed alongside the results.
|
||||||
|
4. **Validate** — Structural checks confirm every note is assigned to exactly one cluster, no clusters are empty, and labels are present.
|
||||||
|
5. **Prioritize** — Clusters appear ranked and are drag-reorderable. Teams set implementation priority by dragging clusters into position.
|
||||||
|
|
||||||
Developers may freely swap in other LLM SDKs and/or APIs... and alter prompt syntax at backend/services/clustering.service.js to complement R&D with any LLM model/platform they prefer.
|
## Dev implementation note
|
||||||
|
|
||||||
|
Developers may swap in other LLM SDKs/APIs and alter prompt syntax in `backend/services/clustering.service.js` to experiment with any model or platform.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js (v18 or later recommended)
|
- Node.js (v18 or later recommended)
|
||||||
- An LLM Platform API key
|
- PostgreSQL (v14 or later recommended)
|
||||||
|
- An [Anthropic API key](https://console.anthropic.com/) (or other LLM platform, for clustering)
|
||||||
|
- A [Voyage AI API key](https://dash.voyageai.com/) (for embedding-based evaluation)
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### 1. Unzip the project
|
### 1. Clone the repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
unzip kongruity.zip
|
git clone https://github.com/kjannette/kongruity_
|
||||||
cd kongruity
|
cd kongruity
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Create an environment file
|
### 2. Create an environment file
|
||||||
|
|
||||||
The backend expects a `.env` file containing an Anthropic API key in the root `backend/` directory. This file is git-ignored and must be created manually:
|
The backend expects a `.env` file in the `backend/` directory. This file is git-ignored and must be created manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo 'LLM_API_KEY=<your LLM API key>' > backend/.env
|
cat > backend/.env << 'EOF'
|
||||||
|
ANTHROPIC_API_KEY=<your Anthropic API key> (or other LLM platform key)
|
||||||
|
VOYAGEAI_API_KEY=<your Voyage AI API key>
|
||||||
|
DATABASE_URL=postgresql://<user>:<password>@localhost:5432/kongruity
|
||||||
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `<your LLM API key>` with your actual key.
|
Replace placeholder values with your actual keys and database credentials.
|
||||||
|
|
||||||
### 3. Install dependencies
|
### 3. Set up the database
|
||||||
|
|
||||||
|
Create a PostgreSQL database for the project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
createdb kongruity
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the migration to create tables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Seed the database with the sample sticky notes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Install dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
@@ -50,7 +83,7 @@ npm install
|
|||||||
|
|
||||||
## Run the app
|
## Run the app
|
||||||
|
|
||||||
### Start the backend - Production Mode
|
### Start the backend — Production mode
|
||||||
|
|
||||||
From the `backend/` directory:
|
From the `backend/` directory:
|
||||||
|
|
||||||
@@ -60,15 +93,15 @@ npm run start
|
|||||||
|
|
||||||
The API server starts on **http://localhost:3001** (configurable via the `PORT` environment variable).
|
The API server starts on **http://localhost:3001** (configurable via the `PORT` environment variable).
|
||||||
|
|
||||||
### Start the backend - Development mode
|
### Start the backend — Development mode
|
||||||
|
|
||||||
To start using Nodemon for "hot reloads," if developing your own features:
|
To start using Nodemon for hot reloads while developing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build the frontend - Production mode
|
### Build the frontend — Production mode
|
||||||
|
|
||||||
From the `frontend/` directory:
|
From the `frontend/` directory:
|
||||||
|
|
||||||
@@ -76,13 +109,14 @@ From the `frontend/` directory:
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Start the frontend - Development mode
|
### Start the frontend — Development mode
|
||||||
|
|
||||||
From the `frontend/` directory:
|
From the `frontend/` directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The Vite dev server starts on **http://localhost:5173** by default. Open that URL in a browser.
|
The Vite dev server starts on **http://localhost:5173** by default. Open that URL in a browser.
|
||||||
|
|
||||||
## Running tests
|
## Running tests
|
||||||
@@ -94,7 +128,8 @@ From the `backend/` directory:
|
|||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
Backend tests use Supertest for HTTP assertions.
|
|
||||||
|
Backend tests use Vitest with Supertest for HTTP assertions.
|
||||||
|
|
||||||
### Frontend tests
|
### Frontend tests
|
||||||
|
|
||||||
@@ -104,7 +139,7 @@ From the `frontend/` directory:
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs `vitest run` with jsdom. For watch mode, during development:
|
This runs Vitest with jsdom. For watch mode during development:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:watch
|
npm run test:watch
|
||||||
|
|||||||
13
backend/db/index.js
Normal file
13
backend/db/index.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const pool = new pg.Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const query = (text, params) => pool.query(text, params);
|
||||||
|
|
||||||
|
export const getPool = () => pool;
|
||||||
|
|
||||||
|
export const close = () => pool.end();
|
||||||
|
|
||||||
|
export default pool;
|
||||||
30
backend/db/migrate.js
Normal file
30
backend/db/migrate.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { query, close } from './index.js';
|
||||||
|
|
||||||
|
const up = `
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
x INTEGER DEFAULT 0,
|
||||||
|
y INTEGER DEFAULT 0,
|
||||||
|
author VARCHAR(128),
|
||||||
|
color VARCHAR(32) DEFAULT 'yellow',
|
||||||
|
source_meta JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
await query(up);
|
||||||
|
console.log('Migration complete — notes table ready.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
54
backend/db/notes.dao.js
Normal file
54
backend/db/notes.dao.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { query } from './index.js';
|
||||||
|
|
||||||
|
export const getAllNotes = async () => {
|
||||||
|
const { rows } = await query(
|
||||||
|
'SELECT id, text, x, y, author, color FROM notes ORDER BY id'
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNoteById = async (id) => {
|
||||||
|
const { rows } = await query(
|
||||||
|
'SELECT id, text, x, y, author, color FROM notes WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createNote = async (note) => {
|
||||||
|
const { rows } = await query(
|
||||||
|
`INSERT INTO notes (id, text, x, y, author, color)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, text, x, y, author, color`,
|
||||||
|
[note.id, note.text, note.x ?? 0, note.y ?? 0, note.author, note.color ?? 'yellow']
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createNotes = async (notes) => {
|
||||||
|
const values = [];
|
||||||
|
const placeholders = [];
|
||||||
|
|
||||||
|
notes.forEach((note, i) => {
|
||||||
|
const offset = i * 6;
|
||||||
|
placeholders.push(
|
||||||
|
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6})`
|
||||||
|
);
|
||||||
|
values.push(
|
||||||
|
note.id,
|
||||||
|
note.text,
|
||||||
|
note.x ?? 0,
|
||||||
|
note.y ?? 0,
|
||||||
|
note.author,
|
||||||
|
note.color ?? 'yellow'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rows } = await query(
|
||||||
|
`INSERT INTO notes (id, text, x, y, author, color)
|
||||||
|
VALUES ${placeholders.join(', ')}
|
||||||
|
RETURNING id, text, x, y, author, color`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
40
backend/db/seed.js
Normal file
40
backend/db/seed.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { query, close } from './index.js';
|
||||||
|
|
||||||
|
const NOTES_PATH = new URL('../data/notes.json', import.meta.url);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(NOTES_PATH, 'utf-8');
|
||||||
|
const notes = JSON.parse(raw);
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO notes (id, text, x, y, author, color)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
for (const note of notes) {
|
||||||
|
const result = await query(insertQuery, [
|
||||||
|
note.id,
|
||||||
|
note.text,
|
||||||
|
note.x,
|
||||||
|
note.y,
|
||||||
|
note.author,
|
||||||
|
note.color,
|
||||||
|
]);
|
||||||
|
inserted += result.rowCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Seed complete — ${inserted} notes inserted (${notes.length - inserted} already existed).`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Seed failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
148
backend/package-lock.json
generated
148
backend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"voyageai": "^0.1.0"
|
"voyageai": "^0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2297,6 +2298,96 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
|
||||||
|
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.11.0",
|
||||||
|
"pg-pool": "^3.11.0",
|
||||||
|
"pg-protocol": "^1.11.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
|
||||||
|
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
|
||||||
|
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2346,6 +2437,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process": {
|
"node_modules/process": {
|
||||||
"version": "0.11.10",
|
"version": "0.11.10",
|
||||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
@@ -2680,6 +2810,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stackback": {
|
"node_modules/stackback": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
@@ -3250,6 +3389,15 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,16 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js",
|
"dev": "nodemon server.js",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"db:migrate": "node db/migrate.js",
|
||||||
|
"db:seed": "node db/seed.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.74.0",
|
"@anthropic-ai/sdk": "^0.74.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"voyageai": "^0.1.0"
|
"voyageai": "^0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { readFile } from 'fs/promises';
|
import { getAllNotes } from '../db/notes.dao.js';
|
||||||
import { clusterNotes } from '../services/clustering.service.js';
|
import { clusterNotes } from '../services/clustering.service.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const DATA_PATH = new URL('../data/notes.json', import.meta.url);
|
|
||||||
|
|
||||||
const loadNotes = async () => {
|
|
||||||
const raw = await readFile(DATA_PATH, 'utf-8');
|
|
||||||
return JSON.parse(raw);
|
|
||||||
};
|
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const notes = await loadNotes();
|
const notes = await getAllNotes();
|
||||||
res.json(notes);
|
res.json(notes);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Error loading notes: ${err}`);
|
console.error(`Error loading notes: ${err}`);
|
||||||
@@ -22,7 +16,7 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
router.post('/cluster', async (req, res) => {
|
router.post('/cluster', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const notes = await loadNotes();
|
const notes = await getAllNotes();
|
||||||
const result = await clusterNotes(notes);
|
const result = await clusterNotes(notes);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import app from '../app.js';
|
import app from '../app.js';
|
||||||
|
|
||||||
|
vi.mock('../db/notes.dao.js', () => ({
|
||||||
|
getAllNotes: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../services/clustering.service.js', () => ({
|
vi.mock('../services/clustering.service.js', () => ({
|
||||||
clusterNotes: vi.fn(),
|
clusterNotes: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('fs/promises', () => ({
|
import { getAllNotes } from '../db/notes.dao.js';
|
||||||
readFile: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { clusterNotes } from '../services/clustering.service.js';
|
import { clusterNotes } from '../services/clustering.service.js';
|
||||||
import { readFile } from 'fs/promises';
|
|
||||||
|
|
||||||
const MOCK_NOTES = [
|
const MOCK_NOTES = [
|
||||||
{ id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' },
|
{ id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' },
|
||||||
@@ -31,7 +31,7 @@ describe('GET /v1/notes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 200 and an array of notes', async () => {
|
it('should return 200 and an array of notes', async () => {
|
||||||
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES));
|
getAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||||
|
|
||||||
const res = await request(app).get('/v1/notes');
|
const res = await request(app).get('/v1/notes');
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ describe('GET /v1/notes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return notes with expected properties', async () => {
|
it('should return notes with expected properties', async () => {
|
||||||
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES));
|
getAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||||
|
|
||||||
const res = await request(app).get('/v1/notes');
|
const res = await request(app).get('/v1/notes');
|
||||||
const note = res.body[0];
|
const note = res.body[0];
|
||||||
@@ -54,8 +54,8 @@ describe('GET /v1/notes', () => {
|
|||||||
expect(note).toHaveProperty('color');
|
expect(note).toHaveProperty('color');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 when the data file cannot be read', async () => {
|
it('should return 500 when the database query fails', async () => {
|
||||||
readFile.mockRejectedValue(new Error('ENOENT: file not found'));
|
getAllNotes.mockRejectedValue(new Error('connection refused'));
|
||||||
|
|
||||||
const res = await request(app).get('/v1/notes');
|
const res = await request(app).get('/v1/notes');
|
||||||
|
|
||||||
@@ -63,15 +63,6 @@ describe('GET /v1/notes', () => {
|
|||||||
expect(res.body).toHaveProperty('error');
|
expect(res.body).toHaveProperty('error');
|
||||||
expect(res.body.error).toBe('Failed to load notes');
|
expect(res.body.error).toBe('Failed to load notes');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 when data file is invalid JSON', async () => {
|
|
||||||
readFile.mockResolvedValue('{ this is not valid json }');
|
|
||||||
|
|
||||||
const res = await request(app).get('/v1/notes');
|
|
||||||
|
|
||||||
expect(res.status).toBe(500);
|
|
||||||
expect(res.body).toHaveProperty('error');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /v1/notes/cluster', () => {
|
describe('POST /v1/notes/cluster', () => {
|
||||||
@@ -81,7 +72,7 @@ describe('POST /v1/notes/cluster', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 200 and clustered results', async () => {
|
it('should return 200 and clustered results', async () => {
|
||||||
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES));
|
getAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||||
clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
|
clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
|
||||||
|
|
||||||
const res = await request(app).post('/v1/notes/cluster');
|
const res = await request(app).post('/v1/notes/cluster');
|
||||||
@@ -91,7 +82,7 @@ describe('POST /v1/notes/cluster', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return clusters with the expected shape (label, noteIds)', async () => {
|
it('should return clusters with the expected shape (label, noteIds)', async () => {
|
||||||
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES));
|
getAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||||
clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
|
clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
|
||||||
|
|
||||||
const res = await request(app).post('/v1/notes/cluster');
|
const res = await request(app).post('/v1/notes/cluster');
|
||||||
@@ -104,7 +95,7 @@ describe('POST /v1/notes/cluster', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should pass the loaded notes to clusterNotes', async () => {
|
it('should pass the loaded notes to clusterNotes', async () => {
|
||||||
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES));
|
getAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||||
clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
|
clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
|
||||||
|
|
||||||
await request(app).post('/v1/notes/cluster');
|
await request(app).post('/v1/notes/cluster');
|
||||||
@@ -114,7 +105,7 @@ describe('POST /v1/notes/cluster', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 when clusterNotes (API call) fails', async () => {
|
it('should return 500 when clusterNotes (API call) fails', async () => {
|
||||||
readFile.mockResolvedValue(JSON.stringify(MOCK_NOTES));
|
getAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||||
clusterNotes.mockRejectedValue(new Error('LLM API error'));
|
clusterNotes.mockRejectedValue(new Error('LLM API error'));
|
||||||
|
|
||||||
const res = await request(app).post('/v1/notes/cluster');
|
const res = await request(app).post('/v1/notes/cluster');
|
||||||
@@ -124,12 +115,12 @@ describe('POST /v1/notes/cluster', () => {
|
|||||||
expect(res.body.error).toMatch(/^Clustering failed/);
|
expect(res.body.error).toMatch(/^Clustering failed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 when the data file cannot be read', async () => {
|
it('should return 500 when the database query fails', async () => {
|
||||||
readFile.mockRejectedValue(new Error('ENOENT: file not found'));
|
getAllNotes.mockRejectedValue(new Error('connection refused'));
|
||||||
|
|
||||||
const res = await request(app).post('/v1/notes/cluster');
|
const res = await request(app).post('/v1/notes/cluster');
|
||||||
|
|
||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
expect(res.body).toHaveProperty('error');
|
expect(res.body).toHaveProperty('error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
113
backend/tests/notes.dao.test.js
Normal file
113
backend/tests/notes.dao.test.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const { mockQuery } = vi.hoisted(() => ({
|
||||||
|
mockQuery: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
query: mockQuery,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getAllNotes, getNoteById, createNote, createNotes } from '../db/notes.dao.js';
|
||||||
|
|
||||||
|
const MOCK_ROWS = [
|
||||||
|
{ id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' },
|
||||||
|
{ id: 'note_002', text: 'Login flow is broken on mobile', x: 214, y: 281, author: 'user_9', color: 'yellow' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('notes.dao', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllNotes', () => {
|
||||||
|
it('should return all notes ordered by id', async () => {
|
||||||
|
mockQuery.mockResolvedValue({ rows: MOCK_ROWS });
|
||||||
|
|
||||||
|
const result = await getAllNotes();
|
||||||
|
|
||||||
|
expect(result).toEqual(MOCK_ROWS);
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
'SELECT id, text, x, y, author, color FROM notes ORDER BY id'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an empty array when no notes exist', async () => {
|
||||||
|
mockQuery.mockResolvedValue({ rows: [] });
|
||||||
|
|
||||||
|
const result = await getAllNotes();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should propagate database errors', async () => {
|
||||||
|
mockQuery.mockRejectedValue(new Error('connection refused'));
|
||||||
|
|
||||||
|
await expect(getAllNotes()).rejects.toThrow('connection refused');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNoteById', () => {
|
||||||
|
it('should return a single note when found', async () => {
|
||||||
|
mockQuery.mockResolvedValue({ rows: [MOCK_ROWS[0]] });
|
||||||
|
|
||||||
|
const result = await getNoteById('note_001');
|
||||||
|
|
||||||
|
expect(result).toEqual(MOCK_ROWS[0]);
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
'SELECT id, text, x, y, author, color FROM notes WHERE id = $1',
|
||||||
|
['note_001']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when note is not found', async () => {
|
||||||
|
mockQuery.mockResolvedValue({ rows: [] });
|
||||||
|
|
||||||
|
const result = await getNoteById('note_999');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createNote', () => {
|
||||||
|
it('should insert a note and return it', async () => {
|
||||||
|
const input = { id: 'note_003', text: 'Export fails', x: 100, y: 200, author: 'user_1', color: 'blue' };
|
||||||
|
mockQuery.mockResolvedValue({ rows: [input] });
|
||||||
|
|
||||||
|
const result = await createNote(input);
|
||||||
|
|
||||||
|
expect(result).toEqual(input);
|
||||||
|
expect(mockQuery).toHaveBeenCalledOnce();
|
||||||
|
const [sql, params] = mockQuery.mock.calls[0];
|
||||||
|
expect(sql).toContain('INSERT INTO notes');
|
||||||
|
expect(params).toEqual(['note_003', 'Export fails', 100, 200, 'user_1', 'blue']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use defaults for missing x, y, and color', async () => {
|
||||||
|
const input = { id: 'note_004', text: 'Needs fixing', author: 'user_2' };
|
||||||
|
mockQuery.mockResolvedValue({ rows: [{ ...input, x: 0, y: 0, color: 'yellow' }] });
|
||||||
|
|
||||||
|
await createNote(input);
|
||||||
|
|
||||||
|
const params = mockQuery.mock.calls[0][1];
|
||||||
|
expect(params[2]).toBe(0);
|
||||||
|
expect(params[3]).toBe(0);
|
||||||
|
expect(params[5]).toBe('yellow');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createNotes', () => {
|
||||||
|
it('should batch-insert multiple notes and return them', async () => {
|
||||||
|
mockQuery.mockResolvedValue({ rows: MOCK_ROWS });
|
||||||
|
|
||||||
|
const result = await createNotes(MOCK_ROWS);
|
||||||
|
|
||||||
|
expect(result).toEqual(MOCK_ROWS);
|
||||||
|
expect(mockQuery).toHaveBeenCalledOnce();
|
||||||
|
const [sql, params] = mockQuery.mock.calls[0];
|
||||||
|
expect(sql).toContain('INSERT INTO notes');
|
||||||
|
expect(params).toHaveLength(12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import type { DragEvent } from 'react';
|
||||||
import { useGetStickies, useClusterStickies } from '../api/api';
|
import { useGetStickies, useClusterStickies } from '../api/api';
|
||||||
import type { Sticky as StickyType } from '../types/types';
|
import type { Sticky as StickyType, RankedCluster } from '../types/types';
|
||||||
import Sticky from './sticky';
|
import Sticky from './sticky';
|
||||||
import Button from './button';
|
import Button from './button';
|
||||||
import '../styles/stickies.css';
|
import '../styles/stickies.css';
|
||||||
@@ -14,10 +16,22 @@ const scoreLabel = (score: number): string => {
|
|||||||
const Stickies = () => {
|
const Stickies = () => {
|
||||||
const { data: stickies, isLoading, error } = useGetStickies();
|
const { data: stickies, isLoading, error } = useGetStickies();
|
||||||
const { mutate: cluster, data: clusterResponse, isPending } = useClusterStickies();
|
const { mutate: cluster, data: clusterResponse, isPending } = useClusterStickies();
|
||||||
|
|
||||||
|
const [rankedClusters, setRankedClusters] = useState<RankedCluster[]>([]);
|
||||||
|
const dragIndex = useRef<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clusterResponse?.clusters) {
|
||||||
|
setRankedClusters(
|
||||||
|
clusterResponse.clusters.map((c, i) => ({ ...c, rank: i + 1 }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [clusterResponse]);
|
||||||
|
|
||||||
if (isLoading) return <div>Loading...</div>;
|
if (isLoading) return <div>Loading...</div>;
|
||||||
if (error) return <div>Error: {error.message}</div>;
|
if (error) return <div>Error: {error.message}</div>;
|
||||||
|
|
||||||
const clusters = clusterResponse?.clusters;
|
|
||||||
const score = clusterResponse?.score;
|
const score = clusterResponse?.score;
|
||||||
|
|
||||||
const handleCluster = () => {
|
const handleCluster = () => {
|
||||||
@@ -35,19 +49,69 @@ const Stickies = () => {
|
|||||||
const renderStickies = (items: StickyType[]) =>
|
const renderStickies = (items: StickyType[]) =>
|
||||||
items?.map((sticky) => <Sticky key={sticky.id} sticky={sticky} />);
|
items?.map((sticky) => <Sticky key={sticky.id} sticky={sticky} />);
|
||||||
|
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
dragIndex.current = index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (targetIndex: number) => {
|
||||||
|
const sourceIndex = dragIndex.current;
|
||||||
|
if (sourceIndex === null || sourceIndex === targetIndex) {
|
||||||
|
dragIndex.current = null;
|
||||||
|
setDragOverIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reordered = [...rankedClusters];
|
||||||
|
const [moved] = reordered.splice(sourceIndex, 1);
|
||||||
|
reordered.splice(targetIndex, 0, moved);
|
||||||
|
|
||||||
|
setRankedClusters(reordered.map((c, i) => ({ ...c, rank: i + 1 })));
|
||||||
|
dragIndex.current = null;
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
dragIndex.current = null;
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stickies-container">
|
<div className="stickies-container">
|
||||||
<Button onClick={handleCluster} isLoading={isPending} label="Group Stickies By Topic" />
|
<Button onClick={handleCluster} isLoading={isPending} label="Group Stickies By Topic" />
|
||||||
{clusters ? (
|
{rankedClusters.length > 0 ? (
|
||||||
<div className="clusters-container">
|
<div className="clusters-container">
|
||||||
{score != null && (
|
{score != null && (
|
||||||
<div className="cohesion-score">
|
<div className="cohesion-score">
|
||||||
Cluster cohesion: <strong>{score.toFixed(2)}</strong> — {scoreLabel(score)}
|
Cluster cohesion: <strong>{score.toFixed(2)}</strong> — {scoreLabel(score)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{clusters.map((group) => (
|
{rankedClusters.map((group, index) => (
|
||||||
<div key={group.label} className="cluster-group">
|
<div
|
||||||
<h3 className="cluster-label">{group.label}</h3>
|
key={group.label}
|
||||||
|
className={`cluster-group cluster-draggable${dragOverIndex === index ? ' cluster-drag-over' : ''}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={() => handleDrop(index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="cluster-header">
|
||||||
|
<span className="cluster-rank" aria-label={`Priority ${group.rank}`}>
|
||||||
|
{group.rank}
|
||||||
|
</span>
|
||||||
|
<h3 className="cluster-label">{group.label}</h3>
|
||||||
|
<span className="cluster-drag-handle" aria-hidden="true">⠿</span>
|
||||||
|
</div>
|
||||||
<div className="stickies-grid">
|
<div className="stickies-grid">
|
||||||
{renderStickies(
|
{renderStickies(
|
||||||
group?.noteIds
|
group?.noteIds
|
||||||
|
|||||||
@@ -24,10 +24,60 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cluster-draggable {
|
||||||
|
cursor: grab;
|
||||||
|
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-draggable:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-drag-over {
|
||||||
|
border-color: #ffb7ce;
|
||||||
|
box-shadow: 0 0 12px rgba(255, 183, 206, 0.4);
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-rank {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6dd6f4;
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.cluster-label {
|
.cluster-label {
|
||||||
margin: 0 0 16px 0;
|
margin: 0;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-drag-handle {
|
||||||
|
font-size: 1.4em;
|
||||||
|
color: #6dd6f4;
|
||||||
|
opacity: 0.4;
|
||||||
|
user-select: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-draggable:hover .cluster-drag-handle {
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cohesion-score {
|
.cohesion-score {
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ describe('Stickies', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show cluster labels after clustering succeeds', async () => {
|
it('should show cluster labels after clustering succeeds', async () => {
|
||||||
// First call = GET notes, second call = POST cluster
|
|
||||||
fetchMock
|
fetchMock
|
||||||
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
|
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
|
||||||
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
|
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
|
||||||
@@ -104,4 +103,41 @@ describe('Stickies', () => {
|
|||||||
expect(await screen.findByText('Login flow feels confusing')).toBeInTheDocument();
|
expect(await screen.findByText('Login flow feels confusing')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Export takes too long')).toBeInTheDocument();
|
expect(screen.getByText('Export takes too long')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should display rank badges on clustered groups', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
|
||||||
|
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
|
||||||
|
|
||||||
|
const { Wrapper } = createTestWrapper();
|
||||||
|
render(<Stickies />, { wrapper: Wrapper });
|
||||||
|
|
||||||
|
const btn = await screen.findByRole('button', { name: 'Group Stickies By Topic' });
|
||||||
|
await userEvent.click(btn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('Priority 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Priority 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make cluster groups draggable', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
|
||||||
|
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTER_RESPONSE));
|
||||||
|
|
||||||
|
const { Wrapper } = createTestWrapper();
|
||||||
|
render(<Stickies />, { wrapper: Wrapper });
|
||||||
|
|
||||||
|
const btn = await screen.findByRole('button', { name: 'Group Stickies By Topic' });
|
||||||
|
await userEvent.click(btn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const groups = document.querySelectorAll('.cluster-draggable');
|
||||||
|
groups.forEach((group) => {
|
||||||
|
expect(group).toHaveAttribute('draggable', 'true');
|
||||||
|
});
|
||||||
|
expect(groups.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
4
frontend/src/types/types.d.ts
vendored
4
frontend/src/types/types.d.ts
vendored
@@ -16,3 +16,7 @@ export type ClusterResponse = {
|
|||||||
clusters: Cluster[];
|
clusters: Cluster[];
|
||||||
score: number;
|
score: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RankedCluster = Cluster & {
|
||||||
|
rank: number;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user