feat: Phase 1 foundation — monorepo, auth, DB schema, API, shared packages

Phase 1.1: Monorepo Setup & Design System
- Turborepo + pnpm workspaces monorepo structure
- Shared packages: @yetanother/types, @yetanother/utils, @yetanother/hooks, @yetanother/ui, @yetanother/db
- Radix UI primitives with custom shadcn/ui-style components (Button, Dialog, Toast, Tooltip, Tabs, Label)
- Tailwind CSS v4 with design tokens (light theme)
- TypeScript strict mode with composite project references

Phase 1.2: Authentication & User Management
- Fastify server setup with JWT auth (register, login, me endpoints)
- @fastify/jwt with cookie support
- User and workspace models with Prisma ORM

Phase 1.3: Database Schema & Migrations
- Full Prisma schema: User, Workspace, Node, NodeLink, Tag,
  Folder, Reminder, Embedding, Activity, Attachment,
  WorkspaceMember, SyncCheckpoint
- pgvector extension for embeddings (vector(1536))
- Seed script for dev data
- Comprehensive indexes (GIN, BRIN, B-tree)

Phase 1.4: API Foundation & Middleware
- Fastify REST API with versioned routes (/api/v1)
- Zod request validation on all endpoints
- CORS, Helmet security headers, rate limiting
- Structured logging with pino-pretty
- Swagger/OpenAPI docs at /docs
- WebSocket support (collaboration ready)
- CRUD routes: nodes, workspaces, search, auth

Tooling:
- ESLint 9 flat config with TypeScript parser
- Prettier with consistent formatting rules
- Turbo v2 task orchestration
- GitHub Actions CI workflow
- Husky + lint-staged pre-commit hooks
This commit is contained in:
YetAnotherSuite Dev 2026-07-20 21:58:17 +02:00
commit 262fbf48e2
79 changed files with 12041 additions and 0 deletions

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/yetanother?schema=public"
JWT_SECRET="dev-secret-change-in-production"
REDIS_URL="redis://localhost:6379"
PORT=4000
HOST="0.0.0.0"
NODE_ENV="development"

44
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
quality:
name: Quality Check
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: yetanother
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm build
- run: pnpm test
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/yetanother?schema=public

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
node_modules/
dist/
.turbo/
.env
.env.local
*.log
.DS_Store
coverage/
.next/
*.tsbuildinfo

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

50
AGENTS.md Normal file
View File

@ -0,0 +1,50 @@
# AGENTS.md — YetAnotherSuite
## Project state
This is a **greenfield project** — no code exists yet. The implementation plan (`yetanothersuite_implementation_plan.md` / `.json`) defines everything: architecture, data model, tech stack, phases, conventions, and API design. Read it first before writing any code.
## Tech stack (from the plan)
- **Monorepo**: Turborepo + pnpm workspaces
- **Frontend**: React 18+ / TypeScript strict mode, Vite, Tailwind CSS + shadcn/ui + Radix primitives
- **State**: Zustand (global), TanStack Query (server state), Jotai (local UI)
- **Editor**: TipTap / ProseMirror (block-based)
- **Backend**: Node.js + Fastify, PostgreSQL 15+ (Prisma ORM), Redis, Zod validation
- **Realtime**: Yjs (CRDT) + WebSocket (Redis adapter)
- **Search**: Meilisearch (full-text) + pgvector (semantic)
- **Queue**: BullMQ (Redis-backed)
- **Desktop**: Tauri; **Mobile**: React Native (Expo)
- **Testing**: Vitest + React Testing Library + Playwright + MSW
## Key conventions (from the plan)
- **TypeScript strict mode** — no `any` types except in test mocks
- **Functional components with hooks** — no class components
- **Zod** for all runtime validation — never trust client input
- **Optimistic UI updates** — rollback on error with toast notification
- **Loading + error states** on every async operation — no silent failures
- **i18n from day one** — i18n keys for all user-facing strings
- **Accessible components** — ARIA labels, keyboard navigation, focus management
- **Conventional commits**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`
- **Branch naming**: `feature/xxx`, `fix/xxx`, `refactor/xxx`, `docs/xxx`
## Development workflow
1. **Phase-by-phase execution** — complete each phase fully before moving to the next
2. **TDD** — write tests before implementation, maintain >80% coverage on critical paths
3. **Pre-commit hooks** (Husky): lint → type-check → test affected
4. **Lint**: ESLint + Prettier (strict config, no warnings allowed)
5. **Every merged PR must be deployable** — use feature flags for incomplete work
6. **Profile after every major feature** — no performance regressions
## Phases (from the plan)
| Phase | Timeline | What |
|-------|----------|------|
| 1 | Weeks 1-4 | Foundation: monorepo, auth, DB schema, API, offline sync |
| 2 | Weeks 5-8 | Notes MVP: block editor, CRUD, search, bidirectional linking |
| 3 | Weeks 9-14 | Tasks + Calendar: task management, calendar engine, NL input, reminders |
| 4 | Weeks 15-18 | AI: semantic search, writing assistant, knowledge graph, smart scheduling |
| 5 | Weeks 19-22 | Collaboration: real-time editing, workspaces, sharing, templates |
| 6 | Weeks 23-28 | Platform: public API, plugins, desktop/mobile apps, SSO, self-hosting |

View File

@ -0,0 +1,2 @@
import node from '@yetanother/eslint-config/node';
export default [...node];

43
apps/api/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "@yetanother/api",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc",
"start": "node dist/main.js",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf .turbo node_modules dist"
},
"dependencies": {
"fastify": "^5.3.0",
"@fastify/cors": "^11.0.0",
"@fastify/rate-limit": "^10.2.0",
"@fastify/helmet": "^13.0.0",
"@fastify/swagger": "^9.5.0",
"@fastify/swagger-ui": "^5.2.0",
"@fastify/websocket": "^11.0.0",
"@fastify/redis": "^7.0.0",
"@fastify/jwt": "^9.0.0",
"@fastify/cookie": "^11.0.0",
"pino": "^9.6.0",
"pino-pretty": "^13.1.0",
"zod": "^3.24.0",
"@yetanother/db": "workspace:*",
"@yetanother/types": "workspace:*",
"@yetanother/utils": "workspace:*",
"ioredis": "^5.6.0"
},
"devDependencies": {
"@yetanother/tsconfig": "workspace:*",
"@yetanother/eslint-config": "workspace:*",
"typescript": "^5.8.0",
"tsx": "^4.19.0",
"vitest": "^3.1.0",
"eslint": "^9.25.0",
"@types/node": "^22.15.0"
}
}

9
apps/api/src/lib/auth.ts Normal file
View File

@ -0,0 +1,9 @@
import { FastifyRequest, FastifyReply } from 'fastify';
export async function authenticate(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
} catch {
return reply.status(401).send({ error: 'Unauthorized' });
}
}

View File

@ -0,0 +1,34 @@
import { FastifyReply } from 'fastify';
import { ZodError } from 'zod';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public details?: unknown,
) {
super(message);
this.name = 'AppError';
}
}
export function handleError(reply: FastifyReply, error: unknown) {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
error: error.message,
details: error.details,
});
}
if (error instanceof ZodError) {
return reply.status(400).send({
error: 'Validation error',
details: error.errors,
});
}
console.error('Unhandled error:', error);
return reply.status(500).send({
error: 'Internal server error',
});
}

71
apps/api/src/main.ts Normal file
View File

@ -0,0 +1,71 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUi from '@fastify/swagger-ui';
import fastifyWebsocket from '@fastify/websocket';
import fastifyCookie from '@fastify/cookie';
import fastifyJwt from '@fastify/jwt';
import { PrismaClient } from '@yetanother/db';
import { API_PREFIX } from '@yetanother/utils';
import { authPlugin } from './plugins/auth.js';
import { authRoutes } from './routes/auth.js';
import { nodeRoutes } from './routes/nodes.js';
import { workspaceRoutes } from './routes/workspaces.js';
import { searchRoutes } from './routes/search.js';
const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : 4000;
const envHost = process.env.HOST ?? '0.0.0.0';
const jwtSecret = process.env.JWT_SECRET ?? 'dev-secret-change-in-production';
export async function buildApp() {
const prisma = new PrismaClient();
const app = Fastify({
logger: {
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
},
});
await app.register(cors, { origin: true, credentials: true });
await app.register(helmet, { contentSecurityPolicy: false });
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' });
await app.register(fastifyCookie);
await app.register(fastifyJwt, { secret: jwtSecret });
await app.register(fastifyWebsocket);
await app.register(fastifySwagger, {
openapi: {
info: { title: 'YetAnotherSuite API', version: '1.0.0' },
},
});
await app.register(fastifySwaggerUi, { routePrefix: '/docs' });
app.decorate('prisma', prisma);
await app.register(authPlugin);
await app.register(authRoutes, { prefix: `${API_PREFIX}/auth` });
await app.register(nodeRoutes, { prefix: API_PREFIX });
await app.register(workspaceRoutes, { prefix: API_PREFIX });
await app.register(searchRoutes, { prefix: API_PREFIX });
app.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
return app;
}
async function main() {
const app = await buildApp();
try {
await app.listen({ port: envPort, host: envHost });
app.log.info(`Server running at http://${envHost}:${envPort}`);
} catch (err) {
app.log.error(err);
throw err;
}
}
main();

View File

@ -0,0 +1,13 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
export async function authenticate(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
} catch {
return reply.status(401).send({ error: 'Unauthorized' });
}
}
export async function authPlugin(app: FastifyInstance) {
app.decorate('authenticate', authenticate);
}

View File

@ -0,0 +1,51 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { createUserSchema } from '@yetanother/types';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function authRoutes(app: FastifyInstance) {
app.post('/register', async (request, reply) => {
const body = createUserSchema.parse(request.body);
const prisma = app.prisma;
const existing = await prisma.user.findUnique({ where: { email: body.email } });
if (existing) {
return reply.status(409).send({ error: 'Email already registered' });
}
const user = await prisma.user.create({
data: {
email: body.email,
displayName: body.displayName,
preferences: {},
},
});
const token = app.jwt.sign({ id: user.id, email: user.email });
return { token, user: { id: user.id, email: user.email, displayName: user.displayName } };
});
app.post('/login', async (request, reply) => {
const body = loginSchema.parse(request.body);
const prisma = app.prisma;
const user = await prisma.user.findUnique({ where: { email: body.email } });
if (!user) {
return reply.status(401).send({ error: 'Invalid credentials' });
}
const token = app.jwt.sign({ id: user.id, email: user.email });
return { token, user: { id: user.id, email: user.email, displayName: user.displayName } };
});
app.get('/me', { preHandler: [app.authenticate] }, async (request) => {
const { id } = request.user as { id: string };
const prisma = app.prisma;
const user = await prisma.user.findUnique({ where: { id } });
return { user };
});
}

View File

@ -0,0 +1,127 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
const createNodeBodySchema = z.object({
type: z.enum(['task', 'event', 'note', 'project', 'goal']),
workspaceId: z.string().uuid(),
title: z.string().min(1).max(500),
content: z.record(z.unknown()).optional(),
priority: z.number().int().min(1).max(5).optional(),
startTime: z.string().datetime().optional(),
endTime: z.string().datetime().optional(),
parentId: z.string().uuid().optional(),
});
export async function nodeRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
app.get('/nodes', async (request) => {
const { id: userId } = request.user as { id: string };
const prisma = app.prisma;
const { type, status, workspaceId } = request.query as Record<string, string | undefined>;
const nodes = await prisma.node.findMany({
where: {
ownerId: userId,
...(type ? { type: type as never } : {}),
...(status ? { status: status as never } : {}),
...(workspaceId ? { workspaceId } : {}),
},
orderBy: { updatedAt: 'desc' },
take: 100,
});
return { nodes };
});
app.post<{ Body: z.infer<typeof createNodeBodySchema> }>('/nodes', async (request, reply) => {
const body = createNodeBodySchema.parse(request.body);
const { id: userId } = request.user as { id: string };
const prisma = app.prisma;
const node = await prisma.node.create({
data: {
type: body.type,
workspaceId: body.workspaceId,
ownerId: userId,
title: body.title,
content: (body.content ?? {}) as never,
priority: body.priority,
startTime: body.startTime ? new Date(body.startTime) : null,
endTime: body.endTime ? new Date(body.endTime) : null,
parentId: body.parentId,
},
});
return reply.status(201).send({ node });
});
app.get<{ Params: { id: string } }>('/nodes/:id', async (request, reply) => {
const { id } = request.params;
const { id: userId } = request.user as { id: string };
const prisma = app.prisma;
const node = await prisma.node.findFirst({
where: { id, ownerId: userId },
include: { tags: { include: { tag: true } }, links: true, backlinks: true },
});
if (!node) return reply.status(404).send({ error: 'Node not found' });
return { node };
});
app.patch<{ Params: { id: string } }>('/nodes/:id', async (request, reply) => {
const { id } = request.params;
const { id: userId } = request.user as { id: string };
const prisma = app.prisma;
const existing = await prisma.node.findFirst({ where: { id, ownerId: userId } });
if (!existing) return reply.status(404).send({ error: 'Node not found' });
const body = request.body as Record<string, unknown>;
const node = await prisma.node.update({
where: { id },
data: {
...(body.title ? { title: body.title as string } : {}),
...(body.content ? { content: body.content as never } : {}),
...(body.priority !== undefined ? { priority: body.priority as number } : {}),
...(body.status ? { status: body.status as never } : {}),
version: { increment: 1 },
},
});
return { node };
});
app.delete<{ Params: { id: string } }>('/nodes/:id', async (request, reply) => {
const { id } = request.params;
const { id: userId } = request.user as { id: string };
const prisma = app.prisma;
const existing = await prisma.node.findFirst({ where: { id, ownerId: userId } });
if (!existing) return reply.status(404).send({ error: 'Node not found' });
await prisma.node.update({
where: { id },
data: { status: 'deleted', archivedAt: new Date() },
});
return reply.status(204).send();
});
app.post<{ Params: { id: string } }>('/nodes/:id/restore', async (request, reply) => {
const { id } = request.params;
const { id: userId } = request.user as { id: string };
const prisma = app.prisma;
const existing = await prisma.node.findFirst({ where: { id, ownerId: userId, status: 'deleted' } });
if (!existing) return reply.status(404).send({ error: 'Node not found' });
const node = await prisma.node.update({
where: { id },
data: { status: 'active', archivedAt: null },
});
return { node };
});
}

View File

@ -0,0 +1,34 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
const searchQuerySchema = z.object({
q: z.string().min(1),
type: z.enum(['task', 'event', 'note', 'project', 'goal']).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export async function searchRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
app.get('/search', async (request) => {
const { q, type, limit } = searchQuerySchema.parse(request.query);
const { id: userId } = request.user as { id: string };
const prisma = app.prisma;
const nodes = await prisma.node.findMany({
where: {
ownerId: userId,
status: 'active',
...(type ? { type: type as never } : {}),
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ plainText: { contains: q, mode: 'insensitive' } },
],
},
take: limit,
orderBy: { updatedAt: 'desc' },
});
return { results: nodes };
});
}

View File

@ -0,0 +1,56 @@
import { FastifyInstance } from 'fastify';
import { createWorkspaceSchema } from '@yetanother/types';
export async function workspaceRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
app.get('/workspaces', async (request) => {
const { id: userId } = request.user as { id: string };
const prisma = app.prisma;
const workspaces = await prisma.workspace.findMany({
where: {
OR: [
{ ownerId: userId },
{ members: { some: { userId } } },
],
},
include: { members: true },
});
return { workspaces };
});
app.post('/workspaces', async (request, reply) => {
const body = createWorkspaceSchema.parse(request.body);
const { id: userId } = request.user as { id: string };
const prisma = app.prisma;
const workspace = await prisma.workspace.create({
data: {
name: body.name,
slug: body.slug,
description: body.description,
ownerId: userId,
members: {
create: { userId, role: 'owner' },
},
},
});
return reply.status(201).send({ workspace });
});
app.get<{ Params: { id: string } }>('/workspaces/:id', async (request, reply) => {
const { id } = request.params;
const prisma = app.prisma;
const workspace = await prisma.workspace.findUnique({
where: { id },
include: { members: { include: { user: true } }, tags: true, folders: true },
});
if (!workspace) return reply.status(404).send({ error: 'Workspace not found' });
return { workspace };
});
}

12
apps/api/src/types.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
import 'fastify';
import { PrismaClient } from '@yetanother/db';
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient;
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
interface FastifyRequest {
user: { id: string; email: string };
}
}

7
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "@yetanother/tsconfig/node",
"include": ["src"],
"compilerOptions": {
"outDir": "./dist"
}
}

View File

@ -0,0 +1,2 @@
import react from '@yetanother/eslint-config/react';
export default [...react];

14
apps/web/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff" />
<meta name="description" content="YetAnotherSuite Unified Productivity Ecosystem" />
<title>YetAnotherSuite</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
apps/web/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "@yetanother/web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf .turbo node_modules dist"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"@yetanother/ui": "workspace:*",
"@yetanother/utils": "workspace:*",
"@yetanother/hooks": "workspace:*",
"@yetanother/types": "workspace:*",
"zustand": "^5.0.0",
"@tanstack/react-query": "^5.75.0",
"jotai": "^2.12.0"
},
"devDependencies": {
"@yetanother/tsconfig": "workspace:*",
"@yetanother/eslint-config": "workspace:*",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"typescript": "^5.8.0",
"vite": "^6.3.0",
"@vitejs/plugin-react": "^4.4.0",
"vitest": "^3.1.0",
"@testing-library/react": "^16.3.0",
"@testing-library/jest-dom": "^6.6.0",
"jsdom": "^26.0.0",
"eslint": "^9.25.0",
"tailwindcss": "^4.1.0",
"@tailwindcss/vite": "^4.1.0"
}
}

13
apps/web/src/App.tsx Normal file
View File

@ -0,0 +1,13 @@
import { Routes, Route } from 'react-router-dom';
import { ToastProvider, ToastViewport } from '@yetanother/ui';
export function App() {
return (
<ToastProvider>
<Routes>
<Route path="/" element={<div>YetAnotherSuite</div>} />
</Routes>
<ToastViewport />
</ToastProvider>
);
}

32
apps/web/src/index.css Normal file
View File

@ -0,0 +1,32 @@
@import "tailwindcss";
@theme {
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(222.2 84% 4.9%);
--color-primary: hsl(222.2 47.4% 11.2%);
--color-primary-foreground: hsl(210 40% 98%);
--color-secondary: hsl(210 40% 96.1%);
--color-secondary-foreground: hsl(222.2 47.4% 11.2%);
--color-muted: hsl(210 40% 96.1%);
--color-muted-foreground: hsl(215.4 16.3% 46.9%);
--color-accent: hsl(210 40% 96.1%);
--color-accent-foreground: hsl(222.2 47.4% 11.2%);
--color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-border: hsl(214.3 31.8% 91.4%);
--color-input: hsl(214.3 31.8% 91.4%);
--color-ring: hsl(222.2 84% 4.9%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(222.2 84% 4.9%);
--radius: 0.5rem;
}
* {
border-color: var(--color-border);
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: system-ui, -apple-system, sans-serif;
}

28
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,28 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { App } from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');
ReactDOM.createRoot(root).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

10
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "@yetanother/tsconfig/react",
"include": ["src"],
"compilerOptions": {
"outDir": "./dist",
"paths": {
"@/*": ["./src/*"]
}
}
}

23
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:4000',
'/ws': {
target: 'ws://localhost:4000',
ws: true,
},
},
},
});

17
apps/web/vitest.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
setupFiles: './src/test-setup.ts',
globals: true,
},
});

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "yetanothersuite",
"private": true,
"packageManager": "pnpm@11.15.1",
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"typecheck": "turbo typecheck",
"test": "turbo test",
"clean": "turbo clean",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "husky"
},
"devDependencies": {
"turbo": "^2.5.0",
"prettier": "^3.5.0",
"husky": "^9.1.0",
"lint-staged": "^15.5.0"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,yaml}": ["prettier --write"]
}
}

View File

@ -0,0 +1,2 @@
import node from '@yetanother/eslint-config/node';
export default [...node];

31
packages/db/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "@yetanother/db",
"private": true,
"type": "module",
"main": "./src/client.ts",
"types": "./src/client.ts",
"scripts": {
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"clean": "rm -rf .turbo node_modules prisma/migrations"
},
"dependencies": {
"@prisma/client": "^6.6.0",
"zod": "^3.24.0"
},
"devDependencies": {
"prisma": "^6.6.0",
"tsx": "^4.19.0",
"@yetanother/tsconfig": "workspace:*",
"@yetanother/eslint-config": "workspace:*",
"typescript": "^5.8.0",
"eslint": "^9.25.0",
"@types/node": "^22.15.0"
}
}

View File

@ -0,0 +1,304 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum NodeType {
task
event
note
project
goal
}
enum NodeStatus {
active
archived
deleted
}
enum LinkType {
references
blocks
relates_to
parent_of
child_of
scheduled_as
prepared_for
}
enum WorkspaceRole {
owner
admin
editor
viewer
}
enum ReminderStatus {
pending
sent
dismissed
snoozed
}
enum NotificationType {
push
email
sms
in_app
}
enum ActionType {
created
updated
deleted
viewed
shared
linked
completed
ai_assisted
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
displayName String @map("display_name")
avatarUrl String? @map("avatar_url") @db.VarChar
preferences Json @default("{}")
encryptionKey String? @map("encryption_key")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastActiveAt DateTime @default(now()) @map("last_active_at")
nodes Node[]
ownedWorkspaces Workspace[] @relation("OwnedWorkspaces")
memberships WorkspaceMember[]
activities Activity[]
reminders Reminder[]
syncCheckpoints SyncCheckpoint[]
@@map("users")
}
model Workspace {
id String @id @default(uuid()) @db.Uuid
name String
slug String
description String?
settings Json @default("{}")
ownerId String @map("owner_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
owner User @relation("OwnedWorkspaces", fields: [ownerId], references: [id])
members WorkspaceMember[]
nodes Node[]
tags Tag[]
folders Folder[]
@@unique([slug, ownerId])
@@map("workspaces")
}
model Node {
id String @id @default(uuid()) @db.Uuid
type NodeType
workspaceId String @map("workspace_id") @db.Uuid
ownerId String @map("owner_id") @db.Uuid
parentId String? @map("parent_id") @db.Uuid
title String
content Json @default("{}")
plainText String @default("") @map("plain_text")
status NodeStatus @default(active)
priority Int?
startTime DateTime? @map("start_time")
endTime DateTime? @map("end_time")
durationMinutes Int? @map("duration_minutes")
recurrenceRule String? @map("recurrence_rule")
completionRate Float? @map("completion_rate")
metadata Json @default("{}")
version Int @default(1)
isEncrypted Boolean @default(false) @map("is_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
archivedAt DateTime? @map("archived_at")
workspace Workspace @relation(fields: [workspaceId], references: [id])
owner User @relation(fields: [ownerId], references: [id])
parent Node? @relation("NodeHierarchy", fields: [parentId], references: [id])
children Node[] @relation("NodeHierarchy")
tags NodeTag[]
links NodeLink[] @relation("NodeLinksSource")
backlinks NodeLink[] @relation("NodeLinksTarget")
activities Activity[]
reminders Reminder[]
attachments Attachment[]
embeddings Embedding[]
@@index([type, status, workspaceId, ownerId])
@@index([createdAt])
@@map("nodes")
}
model NodeLink {
id String @id @default(uuid()) @db.Uuid
sourceId String @map("source_id") @db.Uuid
targetId String @map("target_id") @db.Uuid
linkType LinkType @map("link_type")
strength Float @default(0.5)
isAuto Boolean @default(false) @map("is_auto")
context String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
source Node @relation("NodeLinksSource", fields: [sourceId], references: [id])
target Node @relation("NodeLinksTarget", fields: [targetId], references: [id])
@@unique([sourceId, targetId, linkType])
@@index([sourceId])
@@index([targetId])
@@map("node_links")
}
model Tag {
id String @id @default(uuid()) @db.Uuid
name String
color String @db.VarChar(7)
workspaceId String @map("workspace_id") @db.Uuid
isSystem Boolean @default(false) @map("is_system")
createdAt DateTime @default(now()) @map("created_at")
workspace Workspace @relation(fields: [workspaceId], references: [id])
nodes NodeTag[]
@@map("tags")
}
model NodeTag {
nodeId String @map("node_id") @db.Uuid
tagId String @map("tag_id") @db.Uuid
node Node @relation(fields: [nodeId], references: [id])
tag Tag @relation(fields: [tagId], references: [id])
@@id([nodeId, tagId])
@@map("node_tags")
}
model Folder {
id String @id @default(uuid()) @db.Uuid
name String
workspaceId String @map("workspace_id") @db.Uuid
parentId String? @map("parent_id") @db.Uuid
viewType String @default("list") @map("view_type")
filterConfig Json @default("{}") @map("filter_config")
sortConfig Json @default("{}") @map("sort_config")
orderIndex Int @default(0) @map("order_index")
createdAt DateTime @default(now()) @map("created_at")
workspace Workspace @relation(fields: [workspaceId], references: [id])
parent Folder? @relation("FolderHierarchy", fields: [parentId], references: [id])
children Folder[] @relation("FolderHierarchy")
@@map("folders")
}
model Reminder {
id String @id @default(uuid()) @db.Uuid
nodeId String @map("node_id") @db.Uuid
userId String @map("user_id") @db.Uuid
remindAt DateTime @map("remind_at")
notificationType NotificationType @map("notification_type")
status ReminderStatus @default(pending)
snoozeUntil DateTime? @map("snooze_until")
createdAt DateTime @default(now()) @map("created_at")
node Node @relation(fields: [nodeId], references: [id])
user User @relation(fields: [userId], references: [id])
@@map("reminders")
}
model Embedding {
id String @id @default(uuid()) @db.Uuid
nodeId String @map("node_id") @db.Uuid
chunkIndex Int @map("chunk_index")
chunkText String @map("chunk_text") @db.Text
vector Unsupported("vector(1536)")
modelVersion String @map("model_version")
createdAt DateTime @default(now()) @map("created_at")
node Node @relation(fields: [nodeId], references: [id])
@@index([nodeId])
@@map("embeddings")
}
model Activity {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
nodeId String? @map("node_id") @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
actionType ActionType @map("action_type")
metadata Json @default("{}")
sessionId String? @map("session_id") @db.Text
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
node Node? @relation(fields: [nodeId], references: [id])
@@index([createdAt])
@@index([userId, workspaceId, actionType])
@@map("activities")
}
model Attachment {
id String @id @default(uuid()) @db.Uuid
nodeId String @map("node_id") @db.Uuid
filename String
mimeType String @map("mime_type")
sizeBytes Int @map("size_bytes")
storageKey String @map("storage_key")
thumbnailKey String? @map("thumbnail_key")
uploadedBy String @map("uploaded_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
node Node @relation(fields: [nodeId], references: [id])
@@map("attachments")
}
model WorkspaceMember {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
userId String @map("user_id") @db.Uuid
role WorkspaceRole
permissions Json @default("{}")
joinedAt DateTime @default(now()) @map("joined_at")
lastAccessedAt DateTime @default(now()) @map("last_accessed_at")
workspace Workspace @relation(fields: [workspaceId], references: [id])
user User @relation(fields: [userId], references: [id])
@@unique([workspaceId, userId])
@@map("workspace_members")
}
model SyncCheckpoint {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
deviceId String @map("device_id")
lastSyncAt DateTime @default(now()) @map("last_sync_at")
syncToken String? @map("sync_token")
deviceInfo Json @default("{}") @map("device_info")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
@@unique([userId, deviceId])
@@map("sync_checkpoints")
}

View File

@ -0,0 +1,47 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const user = await prisma.user.upsert({
where: { email: 'dev@yetanothersuite.dev' },
update: {},
create: {
email: 'dev@yetanothersuite.dev',
displayName: 'Dev User',
preferences: {
theme: 'system',
timezone: 'UTC',
notificationSettings: { push: true, email: true, inApp: true },
defaultViews: { tasks: 'list', calendar: 'week', notes: 'list' },
},
},
});
const workspace = await prisma.workspace.upsert({
where: { slug_ownerId: { slug: 'personal', ownerId: user.id } },
update: {},
create: {
name: 'Personal',
slug: 'personal',
description: 'My personal workspace',
ownerId: user.id,
settings: {
defaultView: 'list',
aiEnabled: true,
retentionPolicy: 'forever',
},
},
});
console.log({ user: user.id, workspace: workspace.id });
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

15
packages/db/src/client.ts Normal file
View File

@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export * from '@prisma/client';

View File

@ -0,0 +1,7 @@
{
"extends": "@yetanother/tsconfig/node",
"include": ["src", "prisma"],
"compilerOptions": {
"outDir": "./dist"
}
}

View File

@ -0,0 +1,2 @@
import react from '@yetanother/eslint-config/react';
export default [...react];

View File

@ -0,0 +1,22 @@
{
"name": "@yetanother/hooks",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"clean": "rm -rf .turbo node_modules"
},
"dependencies": {
"react": "^19.1.0"
},
"devDependencies": {
"@yetanother/tsconfig": "workspace:*",
"@yetanother/eslint-config": "workspace:*",
"@types/react": "^19.1.0",
"typescript": "^5.8.0",
"eslint": "^9.25.0"
}
}

View File

@ -0,0 +1,3 @@
export { useDebounce } from './useDebounce';
export { useLocalStorage } from './useLocalStorage';
export { useMediaQuery } from './useMediaQuery';

View File

@ -0,0 +1,12 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}

View File

@ -0,0 +1,23 @@
import { useState, useCallback } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((val: T) => T)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
},
[key, storedValue],
);
return [storedValue, setValue] as const;
}

View File

@ -0,0 +1,19 @@
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches;
}
return false;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}

View File

@ -0,0 +1,7 @@
{
"extends": "@yetanother/tsconfig/react",
"include": ["src"],
"compilerOptions": {
"outDir": "./dist"
}
}

View File

@ -0,0 +1,2 @@
import base from '@yetanother/eslint-config/base';
export default [...base];

View File

@ -0,0 +1,21 @@
{
"name": "@yetanother/types",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"clean": "rm -rf .turbo node_modules"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"@yetanother/tsconfig": "workspace:*",
"@yetanother/eslint-config": "workspace:*",
"typescript": "^5.8.0",
"eslint": "^9.25.0"
}
}

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
export const uuidSchema = z.string().uuid();
export const timestampSchema = z.string().datetime();
export const paginationSchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
export type Pagination = z.infer<typeof paginationSchema>;

View File

@ -0,0 +1,4 @@
export * from './node';
export * from './user';
export * from './workspace';
export * from './common';

113
packages/types/src/node.ts Normal file
View File

@ -0,0 +1,113 @@
import { z } from 'zod';
import { uuidSchema, timestampSchema } from './common';
export const nodeTypeSchema = z.enum(['task', 'event', 'note', 'project', 'goal']);
export type NodeType = z.infer<typeof nodeTypeSchema>;
export const nodeStatusSchema = z.enum(['active', 'archived', 'deleted']);
export type NodeStatus = z.infer<typeof nodeStatusSchema>;
export const linkTypeSchema = z.enum([
'references',
'blocks',
'relates_to',
'parent_of',
'child_of',
'scheduled_as',
'prepared_for',
]);
export type LinkType = z.infer<typeof linkTypeSchema>;
export const nodeSchema = z.object({
id: uuidSchema,
type: nodeTypeSchema,
workspaceId: uuidSchema,
ownerId: uuidSchema,
parentId: uuidSchema.nullable(),
title: z.string().min(1).max(500),
content: z.record(z.unknown()).default({}),
plainText: z.string().default(''),
status: nodeStatusSchema.default('active'),
priority: z.number().int().min(1).max(5).nullable(),
startTime: z.string().datetime().nullable(),
endTime: z.string().datetime().nullable(),
durationMinutes: z.number().int().min(0).nullable(),
recurrenceRule: z.string().nullable(),
completionRate: z.number().min(0).max(1).nullable(),
metadata: z.record(z.unknown()).default({}),
version: z.number().int().default(1),
isEncrypted: z.boolean().default(false),
createdAt: timestampSchema,
updatedAt: timestampSchema,
completedAt: timestampSchema.nullable(),
archivedAt: timestampSchema.nullable(),
});
export const createNodeSchema = nodeSchema.pick({
type: true,
workspaceId: true,
title: true,
content: true,
priority: true,
startTime: true,
endTime: true,
durationMinutes: true,
recurrenceRule: true,
parentId: true,
metadata: true,
});
export const updateNodeSchema = nodeSchema.partial().pick({
title: true,
content: true,
status: true,
priority: true,
startTime: true,
endTime: true,
durationMinutes: true,
recurrenceRule: true,
metadata: true,
parentId: true,
});
export type Node = z.infer<typeof nodeSchema>;
export type CreateNodeInput = z.infer<typeof createNodeSchema>;
export type UpdateNodeInput = z.infer<typeof updateNodeSchema>;
export const nodeLinkSchema = z.object({
id: uuidSchema,
sourceId: uuidSchema,
targetId: uuidSchema,
linkType: linkTypeSchema,
strength: z.number().min(0).max(1).default(0.5),
isAuto: z.boolean().default(false),
context: z.string().nullable(),
createdAt: timestampSchema,
});
export type NodeLink = z.infer<typeof nodeLinkSchema>;
export const tagSchema = z.object({
id: uuidSchema,
name: z.string().min(1).max(50),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
workspaceId: uuidSchema,
isSystem: z.boolean().default(false),
createdAt: timestampSchema,
});
export type Tag = z.infer<typeof tagSchema>;
export const folderSchema = z.object({
id: uuidSchema,
name: z.string().min(1).max(100),
workspaceId: uuidSchema,
parentId: uuidSchema.nullable(),
viewType: z.enum(['list', 'board', 'calendar', 'gallery']).default('list'),
filterConfig: z.record(z.unknown()).default({}),
sortConfig: z.record(z.unknown()).default({}),
orderIndex: z.number().int().default(0),
createdAt: timestampSchema,
});
export type Folder = z.infer<typeof folderSchema>;

View File

@ -0,0 +1,33 @@
import { z } from 'zod';
import { uuidSchema, timestampSchema } from './common';
export const userSchema = z.object({
id: uuidSchema,
email: z.string().email(),
displayName: z.string().min(1).max(100),
avatarUrl: z.string().url().nullable(),
preferences: z.object({
theme: z.enum(['light', 'dark', 'system']).default('system'),
timezone: z.string().default('UTC'),
notificationSettings: z.object({
push: z.boolean().default(true),
email: z.boolean().default(true),
inApp: z.boolean().default(true),
}).default({}),
defaultViews: z.object({
tasks: z.enum(['list', 'board', 'calendar']).default('list'),
calendar: z.enum(['day', 'week', 'month']).default('week'),
notes: z.enum(['list', 'grid']).default('list'),
}).default({}),
}).default({}),
createdAt: timestampSchema,
updatedAt: timestampSchema,
lastActiveAt: timestampSchema,
});
export const createUserSchema = userSchema.pick({ email: true, displayName: true });
export const updateUserSchema = userSchema.partial().pick({ displayName: true, avatarUrl: true, preferences: true });
export type User = z.infer<typeof userSchema>;
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;

View File

@ -0,0 +1,40 @@
import { z } from 'zod';
import { uuidSchema, timestampSchema } from './common';
export const workspaceSchema = z.object({
id: uuidSchema,
name: z.string().min(1).max(100),
slug: z.string().min(1).max(50),
description: z.string().max(500).nullable(),
settings: z.object({
defaultView: z.enum(['list', 'board', 'calendar', 'gallery']).default('list'),
colorScheme: z.string().optional(),
aiEnabled: z.boolean().default(true),
retentionPolicy: z.enum(['forever', '30d', '90d', '1y']).default('forever'),
}).default({}),
ownerId: uuidSchema,
createdAt: timestampSchema,
updatedAt: timestampSchema,
});
export const createWorkspaceSchema = workspaceSchema.pick({ name: true, slug: true, description: true });
export const updateWorkspaceSchema = workspaceSchema.partial().pick({ name: true, description: true, settings: true });
export type Workspace = z.infer<typeof workspaceSchema>;
export type CreateWorkspaceInput = z.infer<typeof createWorkspaceSchema>;
export type UpdateWorkspaceInput = z.infer<typeof updateWorkspaceSchema>;
export const workspaceRoleSchema = z.enum(['owner', 'admin', 'editor', 'viewer']);
export type WorkspaceRole = z.infer<typeof workspaceRoleSchema>;
export const workspaceMemberSchema = z.object({
id: uuidSchema,
workspaceId: uuidSchema,
userId: uuidSchema,
role: workspaceRoleSchema,
permissions: z.record(z.unknown()).default({}),
joinedAt: timestampSchema,
lastAccessedAt: timestampSchema,
});
export type WorkspaceMember = z.infer<typeof workspaceMemberSchema>;

View File

@ -0,0 +1,7 @@
{
"extends": "@yetanother/tsconfig/base",
"include": ["src"],
"compilerOptions": {
"outDir": "./dist"
}
}

View File

@ -0,0 +1,2 @@
import react from '@yetanother/eslint-config/react';
export default [...react];

36
packages/ui/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "@yetanother/ui",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"clean": "rm -rf .turbo node_modules"
},
"dependencies": {
"react": "^19.1.0",
"clsx": "^2.1.0",
"tailwind-merge": "^3.2.0",
"@yetanother/utils": "workspace:*",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-tooltip": "^1.2.0",
"@radix-ui/react-popover": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.0",
"@radix-ui/react-label": "^2.1.0",
"lucide-react": "^0.510.0",
"@tanstack/react-query": "^5.75.0"
},
"devDependencies": {
"@yetanother/tsconfig": "workspace:*",
"@yetanother/eslint-config": "workspace:*",
"@types/react": "^19.1.0",
"typescript": "^5.8.0",
"eslint": "^9.25.0",
"tailwindcss": "^4.1.0"
}
}

View File

@ -0,0 +1,49 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@yetanother/utils';
const buttonVariants = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
} as const;
const buttonSizes = {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
} as const;
type ButtonVariant = keyof typeof buttonVariants;
type ButtonSize = keyof typeof buttonSizes;
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
buttonVariants[variant],
buttonSizes[size],
className,
)}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = 'Button';
export { Button, type ButtonProps, type ButtonVariant, type ButtonSize };

View File

@ -0,0 +1,67 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@yetanother/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle };

6
packages/ui/src/index.ts Normal file
View File

@ -0,0 +1,6 @@
export * from './button';
export * from './dialog';
export * from './toast';
export * from './tooltip';
export * from './tabs';
export * from './label';

20
packages/ui/src/label.tsx Normal file
View File

@ -0,0 +1,20 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@yetanother/utils';
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

52
packages/ui/src/tabs.tsx Normal file
View File

@ -0,0 +1,52 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@yetanother/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

44
packages/ui/src/toast.tsx Normal file
View File

@ -0,0 +1,44 @@
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cn } from '@yetanother/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ComponentRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
interface ToastProps extends React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> {
variant?: 'default' | 'destructive';
}
const Toast = React.forwardRef<
React.ComponentRef<typeof ToastPrimitives.Root>,
ToastProps
>(({ className, variant = 'default', ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
variant === 'destructive' && 'destructive group border-destructive bg-destructive text-destructive-foreground',
className,
)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
export { ToastProvider, ToastViewport, Toast, type ToastProps };

View File

@ -0,0 +1,27 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@yetanother/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -0,0 +1,7 @@
{
"extends": "@yetanother/tsconfig/react",
"include": ["src"],
"compilerOptions": {
"outDir": "./dist"
}
}

View File

@ -0,0 +1,2 @@
import base from '@yetanother/eslint-config/base';
export default [...base];

View File

@ -0,0 +1,22 @@
{
"name": "@yetanother/utils",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"clean": "rm -rf .turbo node_modules"
},
"dependencies": {
"clsx": "^2.1.0",
"tailwind-merge": "^3.2.0"
},
"devDependencies": {
"@yetanother/tsconfig": "workspace:*",
"@yetanother/eslint-config": "workspace:*",
"typescript": "^5.8.0",
"eslint": "^9.25.0"
}
}

6
packages/utils/src/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,14 @@
export const API_VERSION = 'v1';
export const API_PREFIX = `/api/${API_VERSION}`;
export const TRASH_RETENTION_DAYS = 30;
export const SYNC_DEBOUNCE_MS = 1500;
export const SEARCH_DEBOUNCE_MS = 300;
export const PRIORITY_LABELS: Record<number, string> = {
1: 'P1 - Critical',
2: 'P2 - High',
3: 'P3 - Medium',
4: 'P4 - Low',
5: 'P5 - Wishlist',
};

View File

@ -0,0 +1,2 @@
export * from './cn';
export * from './constants';

View File

@ -0,0 +1,7 @@
{
"extends": "@yetanother/tsconfig/base",
"include": ["src"],
"compilerOptions": {
"outDir": "./dist"
}
}

7470
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

9
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,9 @@
packages:
- "apps/*"
- "packages/*"
- "tooling/*"
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
esbuild: true
prisma: true

33
tooling/eslint/base.js Normal file
View File

@ -0,0 +1,33 @@
import eslintConfigPrettier from 'eslint-config-prettier';
import tsParser from '@typescript-eslint/parser';
const config = [
{
ignores: [
'**/dist/**',
'**/.turbo/**',
'**/node_modules/**',
'**/*.config.*',
],
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
},
eslintConfigPrettier,
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
},
rules: {
'no-unused-vars': 'off',
'no-undef': 'off',
},
},
];
export default config;

11
tooling/eslint/node.js Normal file
View File

@ -0,0 +1,11 @@
import base from './base.js';
export default [
...base,
{
files: ['**/*.ts', '**/*.js'],
rules: {
'no-process-exit': 'error',
},
},
];

View File

@ -0,0 +1,20 @@
{
"name": "@yetanother/eslint-config",
"private": true,
"type": "module",
"exports": {
"./base": "./base.js",
"./react": "./react.js",
"./node": "./node.js"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^8.30.0",
"@typescript-eslint/parser": "^8.30.0",
"eslint-config-prettier": "^10.1.0",
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^5.2.0"
},
"peerDependencies": {
"eslint": "^9.25.0"
}
}

22
tooling/eslint/react.js vendored Normal file
View File

@ -0,0 +1,22 @@
import base from './base.js';
import reactPlugin from 'eslint-plugin-react';
import hooksPlugin from 'eslint-plugin-react-hooks';
export default [
...base,
reactPlugin.configs.flat.recommended,
{
plugins: {
'react-hooks': hooksPlugin,
},
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {
...hooksPlugin.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
},
settings: {
react: { version: 'detect' },
},
},
];

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": false,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
"composite": true,
"incremental": true
}
}

View File

@ -0,0 +1,7 @@
{
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2022"],
"types": ["node"]
}
}

View File

@ -0,0 +1,9 @@
{
"name": "@yetanother/tsconfig",
"private": true,
"exports": {
"./base": "./base.json",
"./react": "./react.json",
"./node": "./node.json"
}
}

View File

@ -0,0 +1,7 @@
{
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx"
}
}

27
turbo.json Normal file
View File

@ -0,0 +1,27 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"clean": {
"cache": false
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,953 @@
# YetAnotherSuite — Implementation Plan
> **The Unified Productivity Ecosystem — Todo, Calendar, and Notes, Connected by AI**
>
> Platform: Web (PWA), Desktop (Electron/Tauri), Mobile (React Native) | Phases: 6 | Timeline: 24-28 weeks
---
## 1. Vision & Core Concept
### Vision
A unified workspace where tasks, events, and knowledge naturally interconnect. No more context switching between apps — your todos appear in your calendar, your notes link to your tasks, and AI surfaces the right information at the right time.
### Problem Statement
Current productivity stacks are fragmented: todos in one app, calendar in another, notes in a third. Users lose context, duplicate effort, and miss connections between their work. YetAnotherSuite eliminates fragmentation by treating tasks, events, and notes as interconnected nodes in a personal knowledge and action graph.
### Key Differentiators
- Unified data model — tasks, events, and notes share a single graph structure
- AI-powered cross-modal linking (e.g., 'this note relates to this meeting and these tasks')
- Contextual workspace switching — calendar view shows relevant notes, task view shows upcoming deadlines
- Natural language input across all modules ('Schedule meeting with Sarah next Tuesday and create prep notes')
- Local-first with seamless cloud sync — works offline, syncs when connected
- Time-blocking integration — drag tasks directly onto calendar as time blocks
- Bi-directional references — tasks reference notes, notes reference events, events link to tasks
### Competitive Positioning
Notion (notes) + Todoist (tasks) + Google Calendar (events) = 3 apps, 3 contexts, 3 syncs. YetAnotherSuite = 1 app, 1 context, 1 sync, with AI connecting everything.
### Target Users
- Knowledge workers managing complex projects
- Executives and managers coordinating teams
- Researchers and academics with time-bound deliverables
- Freelancers juggling multiple clients and deadlines
- Students managing coursework and research
---
## 2. System Architecture
### Frontend Stack
- **Framework**: React 18+ with TypeScript (strict mode)
- **State Management**: Zustand (global) + TanStack Query (server state) + Jotai (local UI)
- **Editor Engine**: TipTap / ProseMirror (block-based, extensible)
- **Calendar Engine**: Custom React components + date-fns + rrule for recurrence
- **Styling**: Tailwind CSS + Radix UI primitives + shadcn/ui components
- **Realtime Collaboration**: Yjs (CRDT) for all modules
- **Build Tool**: Vite + Turborepo (monorepo)
- **Desktop Wrapper**: Tauri (Rust-based, lightweight, secure)
- **Mobile**: React Native (shared business logic via React Native Web)
- **Testing**: Vitest + React Testing Library + Playwright + MSW (API mocking)
### Backend Stack
- **Api Gateway**: Node.js + Fastify (or Go + Gin for performance-critical paths)
- **Database**: PostgreSQL 15+ (primary) with JSONB for flexible content
- **Caching**: Redis (sessions, hot data, rate limiting)
- **Search**: Meilisearch (instant) + pgvector (semantic) — hybrid search
- **Realtime**: WebSocket server (Socket.io or native WS with Redis adapter)
- **Queue**: BullMQ (Redis-based) for background jobs (AI processing, sync, exports)
- **Ai Services**: OpenAI/Anthropic API + local fallback (Ollama) + self-hosted embeddings
- **File Storage**: S3-compatible (MinIO for self-hosting) + CDN for assets
- **Vector Database**: pgvector (PostgreSQL extension) for semantic search and recommendations
- **Sync Engine**: Custom sync protocol (based on CRDTs + operational transforms) for offline-first
### Infrastructure
- **Containerization**: Docker + Docker Compose (dev) + Kubernetes (production)
- **Orchestration**: Helm charts for K8s deployment
- **Ci Cd**: GitHub Actions with matrix builds (Web, Desktop, Mobile)
- **Monitoring**: Prometheus + Grafana (metrics) + Sentry (errors) + LogRocket (session replay)
- **Deployment**: Railway/Render (MVP) → AWS/GCP (scale) with Terraform
- **Cdn**: Cloudflare (global edge caching)
- **Backup**: Automated PostgreSQL backups + S3 versioning
---
## 3. Unified Data Model
### Philosophy
All entities (tasks, events, notes) are 'Nodes' in a unified graph. They share common properties (title, content, timestamps, metadata) but have type-specific extensions. Relationships are first-class citizens.
### Core Entities
#### User
- **Fields**: id (UUID PK), email (unique, indexed), display_name, avatar_url, preferences (JSONB: theme, timezone, notification_settings, default_views), encryption_key (for E2E encrypted notes), created_at, updated_at, last_active_at
- **Relations**: Workspaces (many-to-many via WorkspaceMember), Nodes (one-to-many, owned), SharedNodes (many-to-many via NodeShare), Activities (one-to-many)
#### Workspace
- **Fields**: id (UUID PK), name, slug (unique per user), description, settings (JSONB: default_view, color_scheme, ai_enabled, retention_policy), owner_id (FK → User), created_at, updated_at
- **Relations**: Members (many-to-many via WorkspaceMember), Nodes (one-to-many), Tags (one-to-many, workspace-scoped), Folders (one-to-many, workspace-scoped), Integrations (one-to-many)
#### Node (Polymorphic Base)
- **Fields**: id (UUID PK), type (ENUM: 'task', 'event', 'note', 'project', 'goal'), workspace_id (FK → Workspace), owner_id (FK → User), parent_id (FK → Node, self-referencing for hierarchy), title, content (JSONB: ProseMirror doc for notes, structured data for tasks/events), plain_text (generated, for search), status (ENUM: 'active', 'archived', 'deleted'), priority (INTEGER: 1-5, nullable), start_time (TIMESTAMPTZ, for events and task deadlines), end_time (TIMESTAMPTZ, for events and task durations), duration_minutes (INTEGER, computed or explicit), recurrence_rule (TEXT, iCal RRULE format), completion_rate (DECIMAL, for projects/goals), metadata (JSONB: type-specific data), version (INTEGER, for optimistic locking), is_encrypted (BOOLEAN, for E2E notes), created_at, updated_at, completed_at, archived_at
- **Relations**: Parent (self-referencing), Children (self-referencing), Tags (many-to-many via NodeTag), Links (many-to-many via NodeLink, self-referencing), Backlinks (many-to-many via NodeLink, inverse), Embeddings (one-to-many), Activities (one-to-many), Reminders (one-to-many), Attachments (one-to-many)
- **Indexes**: GIN on content (JSONB), GIN on metadata (JSONB), B-tree on type, status, workspace_id, owner_id, BRIN on created_at (time-series optimization), GIST on tsvector (full-text search)
#### NodeLink (Relationship Graph)
- **Fields**: id (UUID PK), source_id (FK → Node), target_id (FK → Node), link_type (ENUM: 'references', 'blocks', 'relates_to', 'parent_of', 'child_of', 'scheduled_as', 'prepared_for'), strength (DECIMAL: 0-1, AI-computed relevance), is_auto (BOOLEAN, AI-generated vs user-created), context (TEXT, why they're related), created_at
- **Constraints**: UNIQUE(source_id, target_id, link_type), CHECK(source_id != target_id)
- **Indexes**: B-tree on source_id, target_id, B-tree on link_type, is_auto
#### Tag
- **Fields**: id (UUID PK), name, color (HEX), workspace_id (FK → Workspace), is_system (BOOLEAN, e.g., 'urgent', 'meeting'), created_at
- **Relations**: Nodes (many-to-many via NodeTag), Workspace (many-to-one)
#### Folder
- **Fields**: id (UUID PK), name, workspace_id (FK → Workspace), parent_id (FK → Folder, self-referencing), view_type (ENUM: 'list', 'board', 'calendar', 'gallery'), filter_config (JSONB: saved filters), sort_config (JSONB: default sort), order_index (INTEGER), created_at
- **Relations**: Nodes (many-to-many via NodeFolder), Parent (self-referencing), Children (self-referencing)
#### Reminder
- **Fields**: id (UUID PK), node_id (FK → Node), user_id (FK → User), remind_at (TIMESTAMPTZ), notification_type (ENUM: 'push', 'email', 'sms', 'in_app'), status (ENUM: 'pending', 'sent', 'dismissed', 'snoozed'), snooze_until (TIMESTAMPTZ), created_at
#### Embedding
- **Fields**: id (UUID PK), node_id (FK → Node), chunk_index (INTEGER, for long content), chunk_text (TEXT, the text segment), vector (VECTOR(1536), pgvector), model_version (TEXT, e.g., 'text-embedding-3-large-v1'), created_at
- **Indexes**: HNSW index on vector (for fast similarity search)
#### Activity
- **Fields**: id (UUID PK), user_id (FK → User), node_id (FK → Node, nullable), workspace_id (FK → Workspace), action_type (ENUM: 'created', 'updated', 'deleted', 'viewed', 'shared', 'linked', 'completed', 'ai_assisted'), metadata (JSONB: before/after snapshots, AI prompt used, etc.), session_id (TEXT, for grouping), created_at
- **Indexes**: BRIN on created_at, B-tree on user_id, workspace_id, action_type
#### Attachment
- **Fields**: id (UUID PK), node_id (FK → Node), filename, mime_type, size_bytes, storage_key (S3 path), thumbnail_key (S3 path, for images), uploaded_by (FK → User), created_at
#### WorkspaceMember
- **Fields**: id (UUID PK), workspace_id (FK → Workspace), user_id (FK → User), role (ENUM: 'owner', 'admin', 'editor', 'viewer'), permissions (JSONB: granular overrides), joined_at, last_accessed_at
#### SyncCheckpoint
- **Fields**: id (UUID PK), user_id (FK → User), device_id (TEXT), last_sync_at (TIMESTAMPTZ), sync_token (TEXT, for delta sync), device_info (JSONB: os, app_version, screen_size), updated_at
---
## 4. Module Specifications
### Tasks Module
- **Description**: Full-featured task management with projects, subtasks, and time estimates
**Core Views**:
- **Inbox**: Uncategorized tasks, quick capture
- **Today**: Tasks due today + scheduled events
- **Upcoming**: Calendar-integrated task timeline
- **Projects**: Hierarchical project boards with progress
- **Anytime**: Tasks without specific deadlines
- **Completed**: Archive with search and restore
**Features**:
- Quick capture with natural language ('Buy milk tomorrow morning')
- Subtasks and checklists within tasks
- Priority levels (P1-P4) with visual urgency indicators
- Time estimates and actual time tracking
- Recurring tasks with flexible patterns
- Drag-and-drop reordering and nesting
- Batch operations (complete, reschedule, tag, assign)
- Focus mode (Pomodoro integration with task context)
### Calendar Module
- **Description**: Time-blocking calendar with task integration and smart scheduling
**Core Views**:
- **Day**: Hour-by-hour with task slots
- **Week**: Standard work week view
- **Month**: Overview with density indicators
- **Year**: Long-term planning with goals
- **Schedule**: List view of upcoming events
- **Availability**: Shareable free/busy slots
**Features**:
- Drag tasks from todo list onto calendar as time blocks
- Smart scheduling suggestions ('You have 2 hours free Thursday afternoon')
- Recurring events with complex patterns (bi-weekly, nth weekday)
- Multiple calendar layers (work, personal, shared)
- External calendar sync (Google, Outlook, Apple iCal)
- Meeting prep auto-linking (find relevant notes for upcoming meetings)
- Travel time buffers and location-based reminders
- Focus time protection (auto-block deep work sessions)
### Notes Module
- **Description**: AI-native note-taking with bidirectional linking and knowledge graph
**Core Views**:
- **All Notes**: Chronological list with search
- **Graph**: Visual knowledge graph of all notes
- **Daily Notes**: Date-stamped journal entries
- **Templates**: Reusable note structures
- **Trash**: Soft-deleted notes with recovery
**Features**:
- Block-based editor with slash commands and markdown
- Bidirectional linking with auto-suggestion ('[[Meeting Notes]]')
- Daily notes with automatic date headings
- Templates with dynamic fields and placeholders
- Web clipper for saving articles and annotating
- AI-powered summarization and expansion
- Semantic search across all notes
- Export to Markdown, PDF, HTML, Notion
---
## 5. Development Phases & Features
### Phase 1 Foundation
**Timeline**: Weeks 1-4 | **Theme**: Foundation & Core Infrastructure
#### Monorepo Setup & Design System
- **Description**: Turborepo with shared packages (ui, utils, types, hooks). shadcn/ui base components. Theme system (light/dark/auto).
- **Complexity**: Medium
- **Tech Stack**: Turborepo, pnpm, Tailwind, Radix UI, shadcn/ui
- **Dependencies**: None
- **Acceptance Criteria**:
- Shared packages build independently
- Design tokens (colors, spacing, typography) centralized
- Storybook running with all base components
- Dark mode toggle working globally
#### Authentication & User Management
- **Description**: JWT-based auth with refresh tokens, OAuth (Google, GitHub, Microsoft), magic links, password reset, profile management.
- **Complexity**: Medium
- **Tech Stack**: Fastify, Passport.js, PostgreSQL, Redis, JWT
- **Dependencies**: Monorepo Setup
- **Acceptance Criteria**:
- All auth flows working end-to-end
- Session management with Redis
- Rate limiting on auth endpoints
- Email verification and password reset
#### Database Schema & Migrations
- **Description**: Implement full unified schema with migrations, seed data, and test fixtures. Set up pgvector extension.
- **Complexity**: High
- **Tech Stack**: PostgreSQL, pgvector, Prisma ORM, Docker
- **Dependencies**: Monorepo Setup
- **Acceptance Criteria**:
- All entities created with proper constraints
- Migration system reversible
- Seed data for development
- pgvector extension enabled and indexed
#### API Foundation & Middleware
- **Description**: REST API structure, error handling, validation (Zod), logging, rate limiting, CORS, API versioning.
- **Complexity**: Medium
- **Tech Stack**: Fastify, Zod, Pino, Helmet
- **Dependencies**: Database Schema, Authentication
- **Acceptance Criteria**:
- Consistent API response format
- Request validation on all endpoints
- Structured logging with correlation IDs
- Rate limiting per user/IP
#### Offline-First Sync Engine
- **Description**: CRDT-based sync protocol with IndexedDB local storage, background sync, conflict resolution UI.
- **Complexity**: High
- **Tech Stack**: Yjs, IndexedDB, Service Workers, Background Sync API
- **Dependencies**: API Foundation
- **Acceptance Criteria**:
- Create/edit/delete notes offline, sync on reconnect
- Conflict resolution UI for simultaneous edits
- Sync status indicator in UI
- Data integrity verified across devices
### Phase 2 Notes Mvp
**Timeline**: Weeks 5-8 | **Theme**: Notes Module MVP
#### Block-Based Editor
- **Description**: Notion-like editor with slash commands, markdown shortcuts, rich embeds, drag-and-drop blocks.
- **Complexity**: High
- **Tech Stack**: TipTap, ProseMirror, React, tippy.js
- **Dependencies**: Monorepo Setup, Offline-First Sync
- **Acceptance Criteria**:
- All basic block types (paragraph, heading, list, code, quote, divider)
- Slash menu with 15+ commands
- Markdown shortcuts (## for H2, - for list)
- Drag-and-drop reordering of blocks
#### Note CRUD & Organization
- **Description**: Create, read, update, delete notes. Folders, tags, favorites, trash. Note list with sorting and filtering.
- **Complexity**: Medium
- **Tech Stack**: PostgreSQL, Prisma, Zustand, TanStack Query
- **Dependencies**: Database Schema, Block-Based Editor
- **Acceptance Criteria**:
- Full CRUD with optimistic UI updates
- Folder hierarchy with drag-and-drop
- Tag management with color coding
- Trash with 30-day retention and restore
#### Full-Text Search
- **Description**: Instant search across all notes with highlighting, filters (date, type, tag), and search history.
- **Complexity**: Medium
- **Tech Stack**: Meilisearch, PostgreSQL tsvector, React
- **Dependencies**: Note CRUD
- **Acceptance Criteria**:
- Search results in <100ms for 10k notes
- Highlighting of matched terms
- Filter by date range, tags, folders
- Recent searches and suggestions
#### Bidirectional Linking
- **Description**: [[Wiki-style links]] with auto-completion, backlink panels, and unlinked references suggestions.
- **Complexity**: High
- **Tech Stack**: TipTap plugin, PostgreSQL, React
- **Dependencies**: Block-Based Editor, Note CRUD
- **Acceptance Criteria**:
- Type [[ to trigger note autocomplete
- Backlink panel showing all references to current note
- Unlinked mentions detection (text matching note titles)
- Broken link detection and repair suggestions
### Phase 3 Todo Calendar
**Timeline**: Weeks 9-14 | **Theme**: Tasks & Calendar Integration
#### Task Management Core
- **Description**: Full task CRUD with priorities, due dates, subtasks, projects, sections. Inbox, Today, Upcoming views.
- **Complexity**: High
- **Tech Stack**: React, DnD Kit, date-fns, PostgreSQL
- **Dependencies**: Note CRUD, API Foundation
- **Acceptance Criteria**:
- Task creation with natural language parsing
- Subtask nesting (3 levels deep)
- Priority colors and urgency sorting
- Bulk operations (complete, reschedule, move)
#### Calendar Engine
- **Description**: Full calendar with day/week/month/year views. Event CRUD, recurring events, all-day events, time zones.
- **Complexity**: High
- **Tech Stack**: React, date-fns, rrule, luxon
- **Dependencies**: Task Management Core
- **Acceptance Criteria**:
- All calendar views rendering correctly
- Recurring events with complex patterns
- Timezone handling (user + event timezones)
- All-day and multi-day events
#### Task-Calendar Integration
- **Description**: Drag tasks onto calendar as time blocks. Tasks shown in calendar views. Calendar events linked to tasks.
- **Complexity**: High
- **Tech Stack**: DnD Kit, React, PostgreSQL, NodeLink
- **Dependencies**: Task Management Core, Calendar Engine
- **Acceptance Criteria**:
- Drag task from list to calendar slot
- Task appears as time block in calendar
- Calendar event can be converted to task
- Bidirectional updates (reschedule in calendar updates task)
#### Natural Language Input
- **Description**: Parse natural language for tasks and events ('Meeting with Sarah next Tuesday at 3pm for 1 hour').
- **Complexity**: High
- **Tech Stack**: OpenAI API, compromise.js, chrono-node, custom NLP
- **Dependencies**: Task Management Core, Calendar Engine
- **Acceptance Criteria**:
- Parse 90%+ of common date/time expressions
- Extract duration, participants, location
- Handle relative dates (tomorrow, next week)
- Fallback to manual input when parsing fails
#### Reminders & Notifications
- **Description**: Push, email, and in-app notifications for tasks and events. Smart reminders (travel time, prep time).
- **Complexity**: Medium
- **Tech Stack**: Web Push API, BullMQ, Redis, Firebase Cloud Messaging
- **Dependencies**: Task Management Core, Calendar Engine
- **Acceptance Criteria**:
- Push notifications delivered reliably
- Email reminders with note context
- Smart reminders (15min before, travel time)
- Notification preferences per workspace
#### External Calendar Sync
- **Description**: Sync with Google Calendar, Outlook, Apple iCal. Two-way or one-way sync options.
- **Complexity**: High
- **Tech Stack**: Google Calendar API, Microsoft Graph API, iCal parser, BullMQ
- **Dependencies**: Calendar Engine
- **Acceptance Criteria**:
- Import events from external calendars
- Export Suite events to external calendars
- Conflict detection and resolution
- Sync frequency configurable (real-time, hourly, daily)
### Phase 4 Ai Intelligence
**Timeline**: Weeks 15-18 | **Theme**: AI-Powered Intelligence Layer
#### Semantic Search & Embeddings
- **Description**: Vector-based semantic search across all content. Find notes/tasks/events by meaning, not just keywords.
- **Complexity**: High
- **Tech Stack**: OpenAI text-embedding-3-large, pgvector, HNSW index, React
- **Dependencies**: Full-Text Search, Note CRUD, Task Management Core
- **Acceptance Criteria**:
- Semantic search results relevant to query intent
- Hybrid search (keyword + semantic) ranking
- Embeddings updated within 5s of content change
- Search across all modules (notes, tasks, events)
#### AI Writing Assistant
- **Description**: Inline AI for expanding, summarizing, rewriting, translating, and answering questions about content.
- **Complexity**: High
- **Tech Stack**: OpenAI GPT-4o, SSE streaming, TipTap plugin, React
- **Dependencies**: Block-Based Editor
- **Acceptance Criteria**:
- AI suggestions appear inline with streaming
- Accept/reject/dismiss UI for suggestions
- Context-aware (uses surrounding paragraphs + linked notes)
- Works offline with local model fallback
#### Smart Linking & Knowledge Graph
- **Description**: AI auto-discovers relationships between all nodes. Visual graph explorer with filtering and clustering.
- **Complexity**: High
- **Tech Stack**: D3.js, OpenAI API, pgvector, Graph algorithms, React
- **Dependencies**: Semantic Search, Bidirectional Linking, Task-Calendar Integration
- **Acceptance Criteria**:
- Auto-suggest 3-5 related items for any node
- Graph view with 100+ nodes rendering at 60fps
- Clustering by workspace, time, or topic
- User can confirm/reject AI-suggested links
#### Smart Scheduling Assistant
- **Description**: AI suggests optimal times for tasks based on calendar, priorities, energy levels, and deadlines.
- **Complexity**: High
- **Tech Stack**: OpenAI/Anthropic API, Constraint solver, Calendar Engine, React
- **Dependencies**: Task-Calendar Integration, Calendar Engine
- **Acceptance Criteria**:
- Suggest time blocks for unscheduled tasks
- Respect user preferences (morning focus, afternoon meetings)
- Consider task dependencies and deadlines
- One-click accept or modify suggestion
#### Meeting Prep & Action Extraction
- **Description**: Before meetings, auto-surface relevant notes. After meetings, extract action items and create tasks.
- **Complexity**: High
- **Tech Stack**: OpenAI GPT-4o, NodeLink, Task Management Core, BullMQ
- **Dependencies**: Smart Linking, Task Management Core
- **Acceptance Criteria**:
- Auto-collect notes linked to meeting participants/topic
- Generate meeting prep summary 15min before
- Extract action items from meeting notes
- Create linked tasks with assignees and deadlines
### Phase 5 Collaboration
**Timeline**: Weeks 19-22 | **Theme**: Collaboration & Team Features
#### Real-time Collaboration
- **Description**: Multi-user editing on notes, shared task lists, shared calendars. Presence awareness, cursors, comments.
- **Complexity**: High
- **Tech Stack**: Yjs, WebSocket, Redis adapter, React
- **Dependencies**: Offline-First Sync, Notes MVP, Task Management Core
- **Acceptance Criteria**:
- 3+ users editing same note simultaneously
- Cursor positions and selections visible
- Comments and suggestions on specific blocks
- Conflict-free merging with CRDTs
#### Workspace & Team Management
- **Description**: Workspaces with members, roles (Owner/Admin/Editor/Viewer), permissions, and activity feeds.
- **Complexity**: Medium
- **Tech Stack**: PostgreSQL RLS, CASL, React, Zustand
- **Dependencies**: Authentication, Real-time Collaboration
- **Acceptance Criteria**:
- Role-based access control enforced at API level
- Workspace switching with isolated data
- Member invitation via email/link
- Activity feed showing team actions
#### Sharing & Permissions
- **Description**: Share individual nodes or folders with granular permissions. Public links with optional passwords.
- **Complexity**: Medium
- **Tech Stack**: PostgreSQL, CASL, React, UUID tokens
- **Dependencies**: Workspace & Team Management
- **Acceptance Criteria**:
- Share via link with view/edit permissions
- Password-protected public links
- Revoke sharing instantly
- Audit log of all share actions
#### Templates & Automation
- **Description**: Customizable templates for notes, tasks, events. Automation rules (when X, do Y).
- **Complexity**: High
- **Tech Stack**: JSON Schema, Workflow engine, BullMQ, React
- **Dependencies**: Notes MVP, Task Management Core, Calendar Engine
- **Acceptance Criteria**:
- Template gallery with 20+ built-in templates
- User-created templates with dynamic fields
- Automation: 'When task created with tag X, add to project Y'
- Webhook triggers for external integrations
### Phase 6 Platform Scale
**Timeline**: Weeks 23-28 | **Theme**: Platform, Extensibility & Enterprise
#### Public API & Webhooks
- **Description**: REST and GraphQL APIs with comprehensive documentation. Webhook events for all mutations.
- **Complexity**: Medium
- **Tech Stack**: Fastify, GraphQL (Mercurius), OpenAPI, Webhook delivery
- **Dependencies**: Workspace & Team Management
- **Acceptance Criteria**:
- REST API covering all CRUD operations
- GraphQL with subscriptions for real-time
- Webhook retry logic with exponential backoff
- API rate limits and usage dashboards
#### Plugin System
- **Description**: Third-party extensions with sandboxed execution. Plugin marketplace.
- **Complexity**: High
- **Tech Stack**: iframe sandbox, Plugin API, Manifest schema, CodeSandbox API
- **Dependencies**: Public API
- **Acceptance Criteria**:
- Plugin manifest validation and installation
- Sandboxed execution with limited permissions
- Plugin settings UI integration
- Marketplace with ratings and reviews
#### Desktop & Mobile Apps
- **Description**: Native desktop (Tauri) and mobile (React Native) with full feature parity and offline support.
- **Complexity**: High
- **Tech Stack**: Tauri, React Native, Expo, SQLite (local), Capacitor
- **Dependencies**: Offline-First Sync, All core modules
- **Acceptance Criteria**:
- Desktop app <50MB installer
- Mobile app on iOS and Android
- Full offline functionality on all platforms
- Native notifications and system integrations
#### SSO & Enterprise Compliance
- **Description**: SAML 2.0, OIDC, SCIM provisioning. Audit logs, data retention, GDPR tools, SOC 2 readiness.
- **Complexity**: Medium
- **Tech Stack**: Passport.js, SAML, SCIM, Audit trail, Encryption
- **Dependencies**: Workspace & Team Management
- **Acceptance Criteria**:
- SAML integration with major IdPs (Okta, Azure AD)
- SCIM user provisioning and deprovisioning
- Complete audit trail exportable as CSV/JSON
- GDPR data export and deletion within 24h
#### Self-Hosting & On-Premise
- **Description**: Docker-based deployment with license management. Single-tenant option for enterprise.
- **Complexity**: Medium
- **Tech Stack**: Docker Compose, Kubernetes, License API, Terraform
- **Dependencies**: All core modules
- **Acceptance Criteria**:
- One-command Docker deployment
- License validation and renewal
- Automated backups and restore
- Health checks and monitoring endpoints
#### Advanced Analytics Dashboard
- **Description**: Personal and team productivity insights. Time tracking, completion rates, focus metrics.
- **Complexity**: Medium
- **Tech Stack**: ClickHouse, Metabase, D3.js, React
- **Dependencies**: Activity tracking, Workspace & Team Management
- **Acceptance Criteria**:
- Personal productivity score and trends
- Team workload distribution view
- Time spent by project/category
- Exportable reports (PDF, CSV)
---
## 6. API Design
### REST Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/nodes` | List all nodes with filtering by type, workspace, date, status, tags |
| POST | `/api/v1/nodes` | Create new node (task, event, note, project, goal) |
| GET | `/api/v1/nodes/:id` | Get node by ID with links, backlinks, and related content |
| PATCH | `/api/v1/nodes/:id` | Update node (partial, optimistic locking) |
| DELETE | `/api/v1/nodes/:id` | Soft delete node (move to trash) |
| POST | `/api/v1/nodes/:id/restore` | Restore node from trash |
| GET | `/api/v1/nodes/:id/links` | Get all outgoing and incoming links for a node |
| POST | `/api/v1/nodes/:id/links` | Create link between nodes |
| GET | `/api/v1/nodes/:id/related` | AI-suggested related nodes (semantic + graph) |
| POST | `/api/v1/nodes/:id/ai` | AI operations: summarize, expand, rewrite, extract actions, suggest links |
| GET | `/api/v1/search` | Hybrid search (full-text + semantic) across all nodes |
| GET | `/api/v1/workspaces/:id/graph` | Knowledge graph data for visualization (nodes + links) |
| GET | `/api/v1/workspaces/:id/calendar` | Calendar events for a workspace with date range |
| GET | `/api/v1/workspaces/:id/tasks` | Tasks for a workspace with filters and sorting |
| GET | `/api/v1/workspaces/:id/notes` | Notes for a workspace with folder hierarchy |
| GET | `/api/v1/workspaces/:id/activity` | Activity feed for workspace |
| POST | `/api/v1/natural-language` | Parse natural language input into structured node data |
| POST | `/api/v1/sync` | Delta sync endpoint for offline-first clients |
| GET | `/ws/collab/:nodeId` | WebSocket for real-time collaboration on a node |
| GET | `/api/v1/user/me` | Current user profile, preferences, and workspaces |
| PATCH | `/api/v1/user/me` | Update user preferences and settings |
| GET | `/api/v1/user/me/notifications` | User notification feed |
| GET | `/api/v1/integrations/calendars` | List connected external calendars |
| POST | `/api/v1/integrations/calendars/:provider/connect` | Connect external calendar (Google, Outlook, Apple) |
### GraphQL Schema
```
Core Types: User, Workspace, Node, NodeLink, Tag, Folder, Reminder, Activity, Attachment, WorkspaceMember
Queries:
- nodes(filter: NodeFilter, pagination: Pagination): NodeConnection
- node(id: UUID!): Node
- search(query: String!, type: SearchType, filters: SearchFilters): SearchResultConnection
- relatedNodes(id: UUID!, limit: Int): [NodeLink]
- graph(workspaceId: UUID!, depth: Int): GraphData
- calendar(workspaceId: UUID!, range: DateRange): [Node]
- tasks(workspaceId: UUID!, filters: TaskFilters): [Node]
- notes(workspaceId: UUID!, folderId: UUID): [Node]
- activity(workspaceId: UUID!, limit: Int): [Activity]
Mutations:
- createNode(input: CreateNodeInput!): Node
- updateNode(id: UUID!, input: UpdateNodeInput!): Node
- deleteNode(id: UUID!): Boolean
- createLink(input: CreateLinkInput!): NodeLink
- deleteLink(id: UUID!): Boolean
- aiAssist(id: UUID!, operation: AIOperation!, context: String): AIResult
- naturalLanguage(input: String!): ParsedNodeData
Subscriptions:
- nodeUpdated(id: UUID!): Node
- userPresence(workspaceId: UUID!): [UserPresence]
- notificationReceived(userId: UUID!): Notification
```
---
## 7. AI Integration Strategy
### Model Selection
- **OpenAI**`gpt-4o` (Primary): Writing assistant, summarization, action extraction, natural language parsing
- **Anthropic**`claude-3-5-sonnet` (Fallback): Long-context analysis, complex reasoning, meeting prep
- **Local**`llama3.1/phi4 via Ollama` (Offline fallback): Privacy-sensitive operations, offline mode, cost reduction
- **OpenAI**`text-embedding-3-large` (Primary): Node embeddings for semantic search and recommendations
- **Local**`nomic-embed-text via Ollama` (Offline fallback): Offline embeddings for local-only content
### Key Prompts
**Summarize**:
```
Summarize the following content in 3-5 bullet points, preserving key insights, action items, and decisions:
{content}
```
**Expand**:
```
Expand on the following outline/idea with detailed explanations, examples, and connections to related concepts. Maintain the original tone and style:
{content}
```
**Rewrite**:
```
Rewrite the following text to be {style} (e.g., more concise, more formal, simpler). Preserve all key information:
{content}
```
**Link Suggestions**:
```
Given this node:
{current_node}
And these candidate nodes:
{candidates}
Identify which nodes are semantically related and explain why. Consider content similarity, shared topics, temporal proximity, and participant overlap. Return as JSON array of {{node_id, relevance_score, reason, link_type}}.
```
**Tag Generation**:
```
Generate 3-5 relevant tags for this content. Be specific and use domain-appropriate terminology. Consider existing tags: {existing_tags}:
{content}
```
**Natural Language Parse**:
```
Parse the following natural language input into structured data. Extract: type (task/event/note), title, description, due_date, duration, priority, participants, location, tags, related_notes. Return as JSON. Input: '{input}'
```
**Action Extraction**:
```
Extract all action items from the following meeting notes or text. For each action item, identify: task description, assignee (if mentioned), deadline (if mentioned), priority. Return as JSON array.
{content}
```
**Meeting Prep**:
```
The user has a meeting titled '{meeting_title}' with {participants} at {time}. Based on their notes and previous meetings, summarize relevant context, open questions, and suggested talking points.
Relevant notes:
{related_notes}
```
**Smart Schedule**:
```
Given these unscheduled tasks with priorities and deadlines:
{tasks}
And this calendar availability:
{availability}
Suggest optimal time blocks for each task. Consider: task priority, deadline proximity, estimated duration, user energy patterns (morning/evening preference), and meeting prep time. Return as JSON array of {{task_id, suggested_start, suggested_end, reasoning}}.
```
### Embedding Pipeline
1. Chunk node content into semantic segments (paragraphs, sections, task descriptions)
2. Generate embeddings for each chunk + full node summary
3. Store in pgvector with metadata (node_id, chunk_index, type, workspace_id)
4. Build HNSW index for fast approximate nearest neighbor search
5. Update embeddings asynchronously on node modification (BullMQ queue)
6. Cache popular embeddings in Redis
7. Re-embed all content monthly with latest model version
### AI Features
- Inline writing assistant (expand, summarize, rewrite, translate)
- Smart linking suggestions across all modules
- Auto-tagging based on content analysis
- Natural language task/event creation
- Meeting prep auto-collection
- Action item extraction from notes
- Smart scheduling with calendar optimization
- Daily/weekly digest generation
- Duplicate detection and merge suggestions
- Content similarity warnings (avoid redundant notes)
---
## 8. Security & Compliance
### Authentication
- JWT with refresh token rotation (7-day access, 30-day refresh)
- OAuth 2.0 (Google, GitHub, Microsoft, Apple)
- Magic links (passwordless option)
- WebAuthn/FIDO2 for hardware key support
- SAML 2.0 and OIDC for enterprise SSO
- TOTP-based 2FA (optional for personal, required for teams)
### Authorization
- RBAC with predefined roles: Owner, Admin, Editor, Viewer
- Resource-level permissions (share individual nodes with custom access)
- Workspace-level access controls with inheritance
- Field-level encryption for sensitive notes (E2E optional)
- Row-level security (RLS) in PostgreSQL
- API-level permission checks (CASL/Ability)
- Audit logging for all permission changes
### Data Protection
- AES-256 encryption at rest (PostgreSQL TDE)
- TLS 1.3 in transit (HSTS enforced)
- E2E encryption option for sensitive workspaces (keys held by user)
- GDPR data export (complete user data dump in 24h)
- GDPR right to erasure (complete deletion within 30 days)
- Data retention policies configurable per workspace
- Automated backup encryption (AES-256-GCM)
- SOC 2 Type II compliance target (Phase 6)
- Penetration testing quarterly (external firm)
---
## 9. Performance Targets
| Metric | Target | Measurement |
|--------|--------|-------------|
| Time to First Contentful Paint | < 1.0s | Lighthouse |
| Time to Interactive | < 2.5s | Lighthouse |
| Editor interaction latency | < 30ms | Chrome DevTools |
| Search response time (full-text) | < 50ms | API response time |
| Search response time (semantic) | < 200ms | API response time |
| AI suggestion latency (first token) | < 500ms | SSE stream start |
| Calendar render (month view, 100 events) | < 100ms | React profiler |
| Task list render (1000 tasks) | < 150ms | React profiler |
| Sync reconciliation (100 changes) | < 2s | Offline simulation |
| Concurrent users per API node | > 2000 | Load testing |
| WebSocket message latency | < 50ms | Ping/pong |
| Database query P99 | < 100ms | PostgreSQL logs |
### Optimization Strategies
- Virtualized rendering for long lists (react-window/react-virtuoso)
- Lazy loading of note content and embeddings (intersection observer)
- CDN for static assets, avatars, and thumbnails (Cloudflare)
- Database connection pooling (PgBouncer, 100 connections)
- Redis caching for hot data (search results, user sessions, workspace configs)
- Query result caching with stale-while-revalidate
- WebSocket connection multiplexing (single connection per user)
- Debounced auto-save (1.5s) with optimistic UI updates
- Service Worker for asset caching and offline support
- Image optimization (WebP/AVIF, responsive sizes)
- Code splitting by module (todo, calendar, notes loaded on demand)
- Tree shaking and dead code elimination
- Database query optimization (composite indexes, query plan analysis)
- Read replicas for search and analytics queries
- GraphQL query complexity analysis and depth limiting
---
## 10. Development Workflow
### Version Control & Quality
- ESLint + Prettier (strict config, no warnings allowed)
- TypeScript strict mode with noImplicitAny
- Husky pre-commit hooks (lint, type-check, test affected)
- Conventional commits (feat:, fix:, docs:, refactor:, test:, chore:)
- Branch naming: feature/xxx, fix/xxx, refactor/xxx, docs/xxx
### Testing Strategy
- **Unit Tests**: Vitest for logic, React Testing Library for components, 80% coverage minimum
- **Integration Tests**: API endpoint testing with supertest + test database, 60% coverage
- **E2E Tests**: Playwright for critical user flows (auth, create note, search, calendar drag, sync)
- **Visual Regression**: Chromatic/Storybook for UI component snapshots
- **Performance Tests**: Lighthouse CI + custom benchmarks for editor and calendar
- **Contract Tests**: Pact for API consumer-provider contracts
### Deployment Pipeline
1. Developer pushes feature branch → triggers CI (lint, type-check, unit tests, build)
2. PR created → automated review (CodeRabbit/PR-Agent) + human review required
3. CI passes → preview deployment generated (Vercel/Railway preview URL)
4. E2E tests run against preview environment
5. Merge to main → auto-deploy to staging environment
6. Staging smoke tests (automated + manual QA checklist)
7. Manual promotion to production with feature flags
8. Production deployment with blue-green strategy (zero downtime)
9. Post-deploy monitoring (error rates, performance metrics) for 30 minutes
10. Automatic rollback if error rate > 0.1% or P95 latency > 2x baseline
---
## 11. Monetization Strategy
### Free — $0
- Unlimited personal notes, tasks, and events
- 1 workspace
- Basic AI (20 requests/day)
- 7-day version history
- Local sync only (no cloud)
- Web and mobile access
- Community support
### Pro — $10/month or $96/year
- Everything in Free
- Unlimited AI requests
- Unlimited workspaces
- Cloud sync across devices
- 30-day version history
- Advanced search (semantic + full-text)
- Knowledge graph view
- External calendar sync
- Web clipper
- Priority email support
### Team — $18/user/month or $180/user/year
- Everything in Pro
- Real-time collaboration
- Team workspaces with RBAC
- Shared templates and automation
- Admin dashboard and analytics
- SSO (Google Workspace, Microsoft 365)
- 90-day version history
- Shared team calendar
- API access (10k requests/month)
- Priority support (24h response)
### Enterprise — Custom pricing
- Everything in Team
- SAML/OIDC SSO (Okta, Azure AD, custom)
- SCIM user provisioning
- Self-hosting option
- Unlimited version history
- Audit logs and compliance reports
- Custom AI model training
- Dedicated account manager
- SLA (99.9% uptime, 4h response)
- Onboarding and training
- White-label option
---
## 12. Risk Mitigation
### Technical Risks
- **CRDT conflicts in complex multi-module editing**: Extensive Yjs testing across all node types, dedicated conflict resolution UI, automated conflict simulation tests
- **AI API latency and cost at scale**: Aggressive caching of AI responses, local model fallback (Ollama), rate limiting per user, usage quotas with graceful degradation
- **Search performance with millions of nodes**: Meilisearch sharding, pgvector HNSW indexing, read replicas, query result caching, eventual consistency for embeddings
- **Offline sync data corruption or loss**: Checksums on all sync operations, sync conflict resolution UI, automated backup before sync, integrity verification on reconnect
- **Calendar recurrence complexity (DST, timezone, leap years)**: Comprehensive test suite with edge cases, use battle-tested rrule library, UTC storage with timezone display, automated regression tests
- **Cross-platform feature parity gaps**: Shared business logic layer (React Native Web), feature flags per platform, automated cross-platform E2E tests, platform-specific UI adapters
### Business Risks
- **Market saturation (Notion, Todoist, Google Calendar, Obsidian)**: Differentiate on unified experience + AI-native features, target power users who feel fragmentation pain, freemium to reduce switching cost
- **AI dependency and vendor lock-in**: Local model support from day one, transparent AI usage, user control over AI features, multiple provider support (OpenAI, Anthropic, local)
- **Self-hosting cannibalizing cloud revenue**: Self-hosting only at Enterprise tier, cloud-first features (collaboration, AI), managed hosting option for teams
- **Team productivity app adoption challenges**: Individual-first design (works great solo), gradual team features, import from competitors, migration assistance for Enterprise
---
## 13. Success Metrics
### Engagement
- DAU/MAU ratio > 35%
- Average session duration > 20 minutes
- Nodes created per user per week > 15 (tasks + events + notes)
- Cross-module usage (users who use 2+ modules daily) > 60%
- AI feature usage (at least 1 AI interaction per week) > 40%
### Retention
- Week-1 retention > 65%
- Week-4 retention > 45%
- Month-3 retention > 30%
- Month-12 retention > 20%
- NPS score > 55
### Performance
- P99 API latency < 200ms
- Uptime > 99.95%
- Zero unplanned data loss incidents
- Sync conflict resolution rate > 95% automatic
- AI suggestion acceptance rate > 30%
### Business
- Free-to-Pro conversion rate > 5%
- Team upgrade rate (Pro users upgrading to Team) > 15%
- Average revenue per user (ARPU) > $12/month
- Customer acquisition cost (CAC) < $50
- Net revenue retention (NRR) > 110%
---
## 14. Agent Development Guidelines
### Execution Principles
Phase-by-phase execution: Complete each phase fully before moving to the next. No skipping MVP features.
Module-by-module delivery: Finish Notes MVP before starting Tasks, finish Tasks before Calendar integration.
Test-driven development: Write tests before implementation. Maintain >80% coverage for critical paths.
Documentation-first: Update API docs, component stories, and data model diagrams before coding.
Incremental AI integration: Start with simple prompts, gradually add embeddings, knowledge graphs, and smart scheduling.
Performance budgeting: Profile after every major feature. No regressions on existing metrics.
Security hardening: Implement auth from day one. Never commit secrets. Use environment configs.
Collaboration protocol: Use Yjs for real-time features. Test with 3+ concurrent users minimum.
Deployment readiness: Every merged PR must be deployable. Feature flags for incomplete work.
Cross-platform consistency: Shared logic layer, platform-specific UI adapters, consistent behavior.
### Agent Checkpoints
- After Phase 1: Validate monorepo builds, auth flows work, database migrations reversible, offline sync functional
- After Phase 2: Validate block editor has all basic blocks, notes CRUD complete, search <100ms, bidirectional linking works
- After Phase 3: Validate task-calendar drag integration, natural language parsing 90%+ accuracy, external calendar sync working
- After Phase 4: Validate semantic search relevance, AI suggestions accepted >30%, knowledge graph renders 100+ nodes at 60fps
- After Phase 5: Validate real-time collaboration with 3+ users, RBAC enforced at API level, sharing permissions granular
- After Phase 6: Validate REST + GraphQL APIs, plugin sandbox secure, desktop <50MB, mobile on both platforms
### Code Generation Rules
- Generate TypeScript with strict types — no any types except in test mocks
- Use functional components with hooks — no class components
- Prefer composition over inheritance — use HOCs and render props sparingly
- Separate business logic from UI — custom hooks for data operations
- Use Zod for all runtime validation — never trust client input
- Implement optimistic UI updates — rollback on error with toast notification
- Add loading and error states to every async operation — no silent failures
- Use React.memo and useMemo judiciously — profile before optimizing
- Write accessible components — ARIA labels, keyboard navigation, focus management
- Internationalize from day one — i18n keys for all user-facing strings
---
*Plan generated for YetAnotherSuite — Unified Productivity Ecosystem*
*30 Features | 6 Phases | 24-28 Weeks | 12 Core Entities | 5 AI Models | 24 API Endpoints*