14 Commits

Author SHA1 Message Date
KS Jannette
21453aab7d UI development 2026-02-24 21:02:43 -05:00
KS Jannette
691186e02c upgraded scoring algorithm model 2026-02-24 20:55:19 -05:00
S Jannette
fd48aaab4d Merge pull request #15 from kjannette/test-data-xfer
further DB infra buildout
2026-02-24 19:41:31 -05:00
KS Jannette
0b56d0d6c4 further DB infra buildout 2026-02-24 19:38:35 -05:00
S Jannette
0d22998c2e Merge pull request #14 from kjannette/db-build
db created, added tests -- tentative
2026-02-24 19:26:55 -05:00
KS Jannette
ce7bc2cde9 db created, added tests -- tentative 2026-02-24 19:25:42 -05:00
S Jannette
0ee426e1f3 Update README.md 2026-02-24 14:12:30 -05:00
KS Jannette
90a19e4525 edit 2026-02-24 13:51:19 -05:00
S Jannette
8b555bf729 Fix typo in README.md
Corrected a typo in the README regarding 'simply'.
2026-02-24 13:48:33 -05:00
KS Jannette
c8ee194bc5 hot edit readme 2026-02-24 13:47:24 -05:00
S Jannette
f77362e684 Merge pull request #13 from kjannette/workflow-UI
Add UI/logical temporal ordering feature
2026-02-24 13:32:29 -05:00
KS Jannette
9ba13206c5 Add UI/logical temporal ordering feature 2026-02-24 13:32:06 -05:00
KS Jannette
0c7baeb9d8 hotfix 2026-02-24 13:12:47 -05:00
S Jannette
07b0309adb Merge pull request #12 from kjannette/query-validation
Query validation
2026-02-24 13:07:39 -05:00
17 changed files with 829 additions and 467 deletions

View File

@@ -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

View File

@@ -1,402 +0,0 @@
[
{
"id": "note_038",
"text": "Live follow mode frequently breaks",
"x": 710,
"y": 579,
"author": "user_9",
"color": "yellow"
},
{
"id": "note_005",
"text": "SSO login loops back to the sign-in page",
"x": 184,
"y": 124,
"author": "user_7",
"color": "yellow"
},
{
"id": "note_023",
"text": "Undo/redo sometimes lags and applies late",
"x": 236,
"y": 687,
"author": "user_9",
"color": "purple"
},
{
"id": "note_017",
"text": "Exported file names are inconsistent and hard to track",
"x": 676,
"y": 201,
"author": "user_4",
"color": "blue"
},
{
"id": "note_049",
"text": "Need better controls for aligning and distributing notes",
"x": 462,
"y": 487,
"author": "user_7",
"color": "green"
},
{
"id": "note_010",
"text": "OAuth consent screen appears every time I sign in",
"x": 228,
"y": 124,
"author": "user_9",
"color": "yellow"
},
{
"id": "note_036",
"text": "Cursor presence is distracting and overlaps content",
"x": 788,
"y": 605,
"author": "user_5",
"color": "yellow"
},
{
"id": "note_011",
"text": "Need better export options (PDF quality is too low)",
"x": 748,
"y": 212,
"author": "user_9",
"color": "green"
},
{
"id": "note_012",
"text": "Exported PDF cuts off content near the edges",
"x": 733,
"y": 159,
"author": "user_6",
"color": "blue"
},
{
"id": "note_019",
"text": "Embedded exports dont update when the board changes",
"x": 662,
"y": 132,
"author": "user_1",
"color": "blue"
},
{
"id": "note_020",
"text": "Cant export comments and reactions with the content",
"x": 739,
"y": 139,
"author": "user_7",
"color": "green"
},
{
"id": "note_039",
"text": "Guest collaborators cant see updates without refreshing",
"x": 759,
"y": 711,
"author": "user_9",
"color": "yellow"
},
{
"id": "note_024",
"text": "App crashes when opening a large board",
"x": 239,
"y": 597,
"author": "user_10",
"color": "purple"
},
{
"id": "note_041",
"text": "Hard to find the right template quickly",
"x": 536,
"y": 394,
"author": "user_4",
"color": "orange"
},
{
"id": "note_040",
"text": "Activity feed lacks context about what changed",
"x": 710,
"y": 771,
"author": "user_7",
"color": "yellow"
},
{
"id": "note_014",
"text": "Export takes too long and sometimes never finishes",
"x": 798,
"y": 211,
"author": "user_2",
"color": "green"
},
{
"id": "note_031",
"text": "Hard to tell who is editing what in real time",
"x": 761,
"y": 704,
"author": "user_8",
"color": "yellow"
},
{
"id": "note_032",
"text": "Comments get lost — no clear thread view",
"x": 818,
"y": 641,
"author": "user_5",
"color": "yellow"
},
{
"id": "note_050",
"text": "Cant lock sections to prevent accidental edits during workshops",
"x": 521,
"y": 471,
"author": "user_6",
"color": "orange"
},
{
"id": "note_001",
"text": "Login flow feels confusing",
"x": 193,
"y": 191,
"author": "user_5",
"color": "yellow"
},
{
"id": "note_025",
"text": "Saving indicator spins but changes arent saved",
"x": 129,
"y": 781,
"author": "user_3",
"color": "purple"
},
{
"id": "note_021",
"text": "Board feels slow when there are many sticky notes",
"x": 341,
"y": 695,
"author": "user_10",
"color": "purple"
},
{
"id": "note_002",
"text": "Login flow is broken on mobile",
"x": 214,
"y": 281,
"author": "user_9",
"color": "yellow"
},
{
"id": "note_046",
"text": "Tags are missing — I need better categorization",
"x": 472,
"y": 435,
"author": "user_6",
"color": "green"
},
{
"id": "note_018",
"text": "No way to schedule recurring exports for stakeholders",
"x": 661,
"y": 229,
"author": "user_9",
"color": "blue"
},
{
"id": "note_034",
"text": "Too many notification emails for minor edits",
"x": 769,
"y": 739,
"author": "user_9",
"color": "yellow"
},
{
"id": "note_003",
"text": "When I enter my username and password I get an unknown error",
"x": 189,
"y": 193,
"author": "user_2",
"color": "yellow"
},
{
"id": "note_008",
"text": "Cannot change my password — save button does nothing",
"x": 244,
"y": 188,
"author": "user_2",
"color": "orange"
},
{
"id": "note_015",
"text": "Sharing link permissions are confusing",
"x": 688,
"y": 290,
"author": "user_6",
"color": "blue"
},
{
"id": "note_033",
"text": "Mentions (@) dont notify the right people",
"x": 696,
"y": 669,
"author": "user_5",
"color": "yellow"
},
{
"id": "note_006",
"text": "Two-factor code is rejected even when correct",
"x": 162,
"y": 213,
"author": "user_1",
"color": "yellow"
},
{
"id": "note_028",
"text": "High CPU usage even when idle on a board",
"x": 190,
"y": 695,
"author": "user_8",
"color": "purple"
},
{
"id": "note_009",
"text": "Account gets locked too quickly after one failed attempt",
"x": 280,
"y": 255,
"author": "user_10",
"color": "orange"
},
{
"id": "note_029",
"text": "Offline mode loses edits when reconnecting",
"x": 338,
"y": 632,
"author": "user_1",
"color": "pink"
},
{
"id": "note_035",
"text": "No notification when someone resolves my comment",
"x": 673,
"y": 636,
"author": "user_2",
"color": "blue"
},
{
"id": "note_037",
"text": "Cant easily hand off facilitation to another user",
"x": 816,
"y": 707,
"author": "user_2",
"color": "yellow"
},
{
"id": "note_013",
"text": "Cannot export selected area — only full board exports",
"x": 696,
"y": 205,
"author": "user_4",
"color": "green"
},
{
"id": "note_016",
"text": "Downloaded image is blurry compared to the canvas",
"x": 677,
"y": 245,
"author": "user_5",
"color": "blue"
},
{
"id": "note_043",
"text": "Cant organize boards into folders the way I need",
"x": 504,
"y": 398,
"author": "user_4",
"color": "green"
},
{
"id": "note_022",
"text": "Canvas freezes for a few seconds when zooming",
"x": 254,
"y": 707,
"author": "user_8",
"color": "pink"
},
{
"id": "note_026",
"text": "Search is slow on boards with lots of content",
"x": 253,
"y": 658,
"author": "user_2",
"color": "pink"
},
{
"id": "note_048",
"text": "Duplicating a board loses some formatting",
"x": 382,
"y": 410,
"author": "user_10",
"color": "orange"
},
{
"id": "note_007",
"text": "Im logged out unexpectedly after a few minutes",
"x": 185,
"y": 157,
"author": "user_3",
"color": "yellow"
},
{
"id": "note_045",
"text": "No bulk rename for multiple sticky notes",
"x": 455,
"y": 427,
"author": "user_1",
"color": "green"
},
{
"id": "note_042",
"text": "Template search results feel irrelevant",
"x": 400,
"y": 474,
"author": "user_6",
"color": "orange"
},
{
"id": "note_044",
"text": "Naming conventions arent enforced and things get messy",
"x": 469,
"y": 434,
"author": "user_9",
"color": "green"
},
{
"id": "note_004",
"text": "Password reset email never arrives",
"x": 207,
"y": 267,
"author": "user_9",
"color": "yellow"
},
{
"id": "note_047",
"text": "Hard to keep consistent styles across boards",
"x": 420,
"y": 426,
"author": "user_8",
"color": "green"
},
{
"id": "note_030",
"text": "I see random 'something went wrong' banners with no details",
"x": 214,
"y": 594,
"author": "user_5",
"color": "purple"
},
{
"id": "note_027",
"text": "Scrolling stutters on older laptops",
"x": 178,
"y": 586,
"author": "user_7",
"color": "pink"
}
]

131
backend/db/README.md Normal file
View File

@@ -0,0 +1,131 @@
# Database — kongruity backend
PostgreSQL database layer for the kongruity backend. All database modules live in this directory.
## Prerequisites
- PostgreSQL v14 or later
- The `DATABASE_URL` environment variable set in `backend/.env`
## Configuration
### Environment variable
The connection pool reads a single env var:
```
DATABASE_URL=postgresql://<user>:<password>@localhost:5432/kongruity
```
This is loaded via `dotenv` from `backend/.env` (git-ignored). The pool is created once in `index.js` and shared across the application.
### Connection pool (`index.js`)
| Export | Description |
|-------------|--------------------------------------------------|
| `query` | Execute a parameterized SQL statement |
| `getPool` | Return the underlying `pg.Pool` instance |
| `close` | Gracefully shut down the pool (`pool.end()`) |
| `default` | The pool itself (default export) |
## Schema
### `notes` table
Created by the migration in `migrate.js`. The DDL is idempotent (`CREATE TABLE IF NOT EXISTS`).
| Column | Type | Constraints / Defaults |
|---------------|----------------|------------------------------|
| `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()` |
## npm scripts
Run these from the `backend/` directory.
### Migrate
Creates the `notes` table (safe to re-run):
```bash
npm run db:migrate
```
### Seed
Inserts 50 sample sticky notes. Uses `ON CONFLICT (id) DO NOTHING`, so re-running is safe and will not duplicate data:
```bash
npm run db:seed
```
## Data access layer (`notes.dao.js`)
| Function | Description |
|---------------------------|--------------------------------------------------------------------|
| `getAllNotes()` | Returns all notes ordered by `id` |
| `getNoteById(id)` | Returns a single note by primary key, or `null` if not found |
| `createNote(note)` | Inserts one note and returns the created row |
| `createNotes(notes)` | Bulk-inserts an array of notes in a single query and returns rows |
All functions return plain objects with columns: `id`, `text`, `x`, `y`, `author`, `color`.
## File overview
```
db/
├── index.js # Connection pool and query helper
├── migrate.js # Table creation (run via npm run db:migrate)
├── notes.dao.js # Data access functions for the notes table
├── seed.js # Sample data seeder (run via npm run db:seed)
└── README.md # This file
```
## Useful psql commands
Connect to the database:
```bash
psql kongruity
```
Quick checks:
```sql
-- Row count
SELECT count(*) FROM notes;
-- Preview data
SELECT id, text, color FROM notes LIMIT 10;
-- Full schema info
\d notes
-- Drop and re-seed (destructive)
TRUNCATE notes;
```
Then re-seed:
```bash
npm run db:seed
```
## Resetting the database
To start completely fresh:
```bash
dropdb kongruity
createdb kongruity
cd backend
npm run db:migrate
npm run db:seed
```

13
backend/db/index.js Normal file
View 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
View 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
View 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;
};

87
backend/db/seed.js Normal file
View File

@@ -0,0 +1,87 @@
import 'dotenv/config';
import { query, close } from './index.js';
const notes = [
{ 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' },
{ id: 'note_003', text: 'When I enter my username and password I get an unknown error', x: 189, y: 193, author: 'user_2', color: 'yellow' },
{ id: 'note_004', text: 'Password reset email never arrives', x: 207, y: 267, author: 'user_9', color: 'yellow' },
{ id: 'note_005', text: 'SSO login loops back to the sign-in page', x: 184, y: 124, author: 'user_7', color: 'yellow' },
{ id: 'note_006', text: 'Two-factor code is rejected even when correct', x: 162, y: 213, author: 'user_1', color: 'yellow' },
{ id: 'note_007', text: "I'm logged out unexpectedly after a few minutes", x: 185, y: 157, author: 'user_3', color: 'yellow' },
{ id: 'note_008', text: 'Cannot change my password — save button does nothing', x: 244, y: 188, author: 'user_2', color: 'orange' },
{ id: 'note_009', text: 'Account gets locked too quickly after one failed attempt', x: 280, y: 255, author: 'user_10', color: 'orange' },
{ id: 'note_010', text: 'OAuth consent screen appears every time I sign in', x: 228, y: 124, author: 'user_9', color: 'yellow' },
{ id: 'note_011', text: 'Need better export options (PDF quality is too low)', x: 748, y: 212, author: 'user_9', color: 'green' },
{ id: 'note_012', text: 'Exported PDF cuts off content near the edges', x: 733, y: 159, author: 'user_6', color: 'blue' },
{ id: 'note_013', text: 'Cannot export selected area — only full board exports', x: 696, y: 205, author: 'user_4', color: 'green' },
{ id: 'note_014', text: 'Export takes too long and sometimes never finishes', x: 798, y: 211, author: 'user_2', color: 'green' },
{ id: 'note_015', text: 'Sharing link permissions are confusing', x: 688, y: 290, author: 'user_6', color: 'blue' },
{ id: 'note_016', text: 'Downloaded image is blurry compared to the canvas', x: 677, y: 245, author: 'user_5', color: 'blue' },
{ id: 'note_017', text: 'Exported file names are inconsistent and hard to track', x: 676, y: 201, author: 'user_4', color: 'blue' },
{ id: 'note_018', text: 'No way to schedule recurring exports for stakeholders', x: 661, y: 229, author: 'user_9', color: 'blue' },
{ id: 'note_019', text: "Embedded exports don't update when the board changes", x: 662, y: 132, author: 'user_1', color: 'blue' },
{ id: 'note_020', text: "Can't export comments and reactions with the content", x: 739, y: 139, author: 'user_7', color: 'green' },
{ id: 'note_021', text: 'Board feels slow when there are many sticky notes', x: 341, y: 695, author: 'user_10', color: 'purple' },
{ id: 'note_022', text: 'Canvas freezes for a few seconds when zooming', x: 254, y: 707, author: 'user_8', color: 'pink' },
{ id: 'note_023', text: 'Undo/redo sometimes lags and applies late', x: 236, y: 687, author: 'user_9', color: 'purple' },
{ id: 'note_024', text: 'App crashes when opening a large board', x: 239, y: 597, author: 'user_10', color: 'purple' },
{ id: 'note_025', text: "Saving indicator spins but changes aren't saved", x: 129, y: 781, author: 'user_3', color: 'purple' },
{ id: 'note_026', text: 'Search is slow on boards with lots of content', x: 253, y: 658, author: 'user_2', color: 'pink' },
{ id: 'note_027', text: 'Scrolling stutters on older laptops', x: 178, y: 586, author: 'user_7', color: 'pink' },
{ id: 'note_028', text: 'High CPU usage even when idle on a board', x: 190, y: 695, author: 'user_8', color: 'purple' },
{ id: 'note_029', text: 'Offline mode loses edits when reconnecting', x: 338, y: 632, author: 'user_1', color: 'pink' },
{ id: 'note_030', text: "I see random 'something went wrong' banners with no details", x: 214, y: 594, author: 'user_5', color: 'purple' },
{ id: 'note_031', text: 'Hard to tell who is editing what in real time', x: 761, y: 704, author: 'user_8', color: 'yellow' },
{ id: 'note_032', text: 'Comments get lost — no clear thread view', x: 818, y: 641, author: 'user_5', color: 'yellow' },
{ id: 'note_033', text: "Mentions (@) don't notify the right people", x: 696, y: 669, author: 'user_5', color: 'yellow' },
{ id: 'note_034', text: 'Too many notification emails for minor edits', x: 769, y: 739, author: 'user_9', color: 'yellow' },
{ id: 'note_035', text: 'No notification when someone resolves my comment', x: 673, y: 636, author: 'user_2', color: 'blue' },
{ id: 'note_036', text: 'Cursor presence is distracting and overlaps content', x: 788, y: 605, author: 'user_5', color: 'yellow' },
{ id: 'note_037', text: "Can't easily hand off facilitation to another user", x: 816, y: 707, author: 'user_2', color: 'yellow' },
{ id: 'note_038', text: 'Live follow mode frequently breaks', x: 710, y: 579, author: 'user_9', color: 'yellow' },
{ id: 'note_039', text: "Guest collaborators can't see updates without refreshing", x: 759, y: 711, author: 'user_9', color: 'yellow' },
{ id: 'note_040', text: 'Activity feed lacks context about what changed', x: 710, y: 771, author: 'user_7', color: 'yellow' },
{ id: 'note_041', text: 'Hard to find the right template quickly', x: 536, y: 394, author: 'user_4', color: 'orange' },
{ id: 'note_042', text: 'Template search results feel irrelevant', x: 400, y: 474, author: 'user_6', color: 'orange' },
{ id: 'note_043', text: "Can't organize boards into folders the way I need", x: 504, y: 398, author: 'user_4', color: 'green' },
{ id: 'note_044', text: "Naming conventions aren't enforced and things get messy", x: 469, y: 434, author: 'user_9', color: 'green' },
{ id: 'note_045', text: 'No bulk rename for multiple sticky notes', x: 455, y: 427, author: 'user_1', color: 'green' },
{ id: 'note_046', text: 'Tags are missing — I need better categorization', x: 472, y: 435, author: 'user_6', color: 'green' },
{ id: 'note_047', text: 'Hard to keep consistent styles across boards', x: 420, y: 426, author: 'user_8', color: 'green' },
{ id: 'note_048', text: 'Duplicating a board loses some formatting', x: 382, y: 410, author: 'user_10', color: 'orange' },
{ id: 'note_049', text: 'Need better controls for aligning and distributing notes', x: 462, y: 487, author: 'user_7', color: 'green' },
{ id: 'note_050', text: "Can't lock sections to prevent accidental edits during workshops", x: 521, y: 471, author: 'user_6', color: 'orange' },
];
const run = async () => {
try {
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();

View File

@@ -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"
}
} }
} }
} }

View File

@@ -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": {

View File

@@ -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) {

View File

@@ -13,7 +13,7 @@ export const embedNotes = async (notes) => {
const response = await client.embed({ const response = await client.embed({
input: texts, input: texts,
model: "voyage-3-lite", model: "voyage-3",
}); });
const embeddingMap = new Map(); const embeddingMap = new Map();

View File

@@ -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');
}); });
}); });

View 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);
});
});
});

View File

@@ -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,72 @@ 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>
{group.rank === 1 && (
<span className="cluster-reorder-hint">Drag and drop to reorganize cluster priority</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

View File

@@ -24,10 +24,68 @@
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-reorder-hint {
font-size: 0.8em;
color: #9ca3af;
font-style: italic;
white-space: nowrap;
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 {

View File

@@ -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);
});
});
}); });

View File

@@ -16,3 +16,7 @@ export type ClusterResponse = {
clusters: Cluster[]; clusters: Cluster[];
score: number; score: number;
}; };
export type RankedCluster = Cluster & {
rank: number;
};