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:
commit
262fbf48e2
6
.env.example
Normal file
6
.env.example
Normal 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
44
.github/workflows/ci.yml
vendored
Normal 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
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
.next/
|
||||
*.tsbuildinfo
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
50
AGENTS.md
Normal file
50
AGENTS.md
Normal 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 |
|
||||
2
apps/api/eslint.config.js
Normal file
2
apps/api/eslint.config.js
Normal file
@ -0,0 +1,2 @@
|
||||
import node from '@yetanother/eslint-config/node';
|
||||
export default [...node];
|
||||
43
apps/api/package.json
Normal file
43
apps/api/package.json
Normal 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
9
apps/api/src/lib/auth.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
34
apps/api/src/lib/errors.ts
Normal file
34
apps/api/src/lib/errors.ts
Normal 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
71
apps/api/src/main.ts
Normal 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();
|
||||
13
apps/api/src/plugins/auth.ts
Normal file
13
apps/api/src/plugins/auth.ts
Normal 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);
|
||||
}
|
||||
51
apps/api/src/routes/auth.ts
Normal file
51
apps/api/src/routes/auth.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
127
apps/api/src/routes/nodes.ts
Normal file
127
apps/api/src/routes/nodes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
34
apps/api/src/routes/search.ts
Normal file
34
apps/api/src/routes/search.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
56
apps/api/src/routes/workspaces.ts
Normal file
56
apps/api/src/routes/workspaces.ts
Normal 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
12
apps/api/src/types.d.ts
vendored
Normal 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
7
apps/api/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@yetanother/tsconfig/node",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
||||
2
apps/web/eslint.config.js
Normal file
2
apps/web/eslint.config.js
Normal file
@ -0,0 +1,2 @@
|
||||
import react from '@yetanother/eslint-config/react';
|
||||
export default [...react];
|
||||
14
apps/web/index.html
Normal file
14
apps/web/index.html
Normal 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
43
apps/web/package.json
Normal 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
13
apps/web/src/App.tsx
Normal 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
32
apps/web/src/index.css
Normal 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
28
apps/web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
1
apps/web/src/test-setup.ts
Normal file
1
apps/web/src/test-setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
1
apps/web/src/vite-env.d.ts
vendored
Normal file
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
10
apps/web/tsconfig.json
Normal file
10
apps/web/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@yetanother/tsconfig/react",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
23
apps/web/vite.config.ts
Normal file
23
apps/web/vite.config.ts
Normal 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
17
apps/web/vitest.config.ts
Normal 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
26
package.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
2
packages/db/eslint.config.js
Normal file
2
packages/db/eslint.config.js
Normal file
@ -0,0 +1,2 @@
|
||||
import node from '@yetanother/eslint-config/node';
|
||||
export default [...node];
|
||||
31
packages/db/package.json
Normal file
31
packages/db/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
304
packages/db/prisma/schema.prisma
Normal file
304
packages/db/prisma/schema.prisma
Normal 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")
|
||||
}
|
||||
47
packages/db/prisma/seed.ts
Normal file
47
packages/db/prisma/seed.ts
Normal 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
15
packages/db/src/client.ts
Normal 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';
|
||||
7
packages/db/tsconfig.json
Normal file
7
packages/db/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@yetanother/tsconfig/node",
|
||||
"include": ["src", "prisma"],
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
||||
2
packages/hooks/eslint.config.js
Normal file
2
packages/hooks/eslint.config.js
Normal file
@ -0,0 +1,2 @@
|
||||
import react from '@yetanother/eslint-config/react';
|
||||
export default [...react];
|
||||
22
packages/hooks/package.json
Normal file
22
packages/hooks/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
packages/hooks/src/index.ts
Normal file
3
packages/hooks/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { useDebounce } from './useDebounce';
|
||||
export { useLocalStorage } from './useLocalStorage';
|
||||
export { useMediaQuery } from './useMediaQuery';
|
||||
12
packages/hooks/src/useDebounce.ts
Normal file
12
packages/hooks/src/useDebounce.ts
Normal 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;
|
||||
}
|
||||
23
packages/hooks/src/useLocalStorage.ts
Normal file
23
packages/hooks/src/useLocalStorage.ts
Normal 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;
|
||||
}
|
||||
19
packages/hooks/src/useMediaQuery.ts
Normal file
19
packages/hooks/src/useMediaQuery.ts
Normal 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;
|
||||
}
|
||||
7
packages/hooks/tsconfig.json
Normal file
7
packages/hooks/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@yetanother/tsconfig/react",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
||||
2
packages/types/eslint.config.js
Normal file
2
packages/types/eslint.config.js
Normal file
@ -0,0 +1,2 @@
|
||||
import base from '@yetanother/eslint-config/base';
|
||||
export default [...base];
|
||||
21
packages/types/package.json
Normal file
21
packages/types/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
12
packages/types/src/common.ts
Normal file
12
packages/types/src/common.ts
Normal 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>;
|
||||
4
packages/types/src/index.ts
Normal file
4
packages/types/src/index.ts
Normal 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
113
packages/types/src/node.ts
Normal 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>;
|
||||
33
packages/types/src/user.ts
Normal file
33
packages/types/src/user.ts
Normal 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>;
|
||||
40
packages/types/src/workspace.ts
Normal file
40
packages/types/src/workspace.ts
Normal 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>;
|
||||
7
packages/types/tsconfig.json
Normal file
7
packages/types/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@yetanother/tsconfig/base",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
||||
2
packages/ui/eslint.config.js
Normal file
2
packages/ui/eslint.config.js
Normal file
@ -0,0 +1,2 @@
|
||||
import react from '@yetanother/eslint-config/react';
|
||||
export default [...react];
|
||||
36
packages/ui/package.json
Normal file
36
packages/ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
packages/ui/src/button.tsx
Normal file
49
packages/ui/src/button.tsx
Normal 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 };
|
||||
67
packages/ui/src/dialog.tsx
Normal file
67
packages/ui/src/dialog.tsx
Normal 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
6
packages/ui/src/index.ts
Normal 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
20
packages/ui/src/label.tsx
Normal 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
52
packages/ui/src/tabs.tsx
Normal 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
44
packages/ui/src/toast.tsx
Normal 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 };
|
||||
27
packages/ui/src/tooltip.tsx
Normal file
27
packages/ui/src/tooltip.tsx
Normal 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 };
|
||||
7
packages/ui/tsconfig.json
Normal file
7
packages/ui/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@yetanother/tsconfig/react",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
||||
2
packages/utils/eslint.config.js
Normal file
2
packages/utils/eslint.config.js
Normal file
@ -0,0 +1,2 @@
|
||||
import base from '@yetanother/eslint-config/base';
|
||||
export default [...base];
|
||||
22
packages/utils/package.json
Normal file
22
packages/utils/package.json
Normal 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
6
packages/utils/src/cn.ts
Normal 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));
|
||||
}
|
||||
14
packages/utils/src/constants.ts
Normal file
14
packages/utils/src/constants.ts
Normal 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',
|
||||
};
|
||||
2
packages/utils/src/index.ts
Normal file
2
packages/utils/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './cn';
|
||||
export * from './constants';
|
||||
7
packages/utils/tsconfig.json
Normal file
7
packages/utils/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@yetanother/tsconfig/base",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
||||
7470
pnpm-lock.yaml
Normal file
7470
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
9
pnpm-workspace.yaml
Normal file
9
pnpm-workspace.yaml
Normal 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
33
tooling/eslint/base.js
Normal 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
11
tooling/eslint/node.js
Normal file
@ -0,0 +1,11 @@
|
||||
import base from './base.js';
|
||||
|
||||
export default [
|
||||
...base,
|
||||
{
|
||||
files: ['**/*.ts', '**/*.js'],
|
||||
rules: {
|
||||
'no-process-exit': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
20
tooling/eslint/package.json
Normal file
20
tooling/eslint/package.json
Normal 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
22
tooling/eslint/react.js
vendored
Normal 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' },
|
||||
},
|
||||
},
|
||||
];
|
||||
23
tooling/typescript/base.json
Normal file
23
tooling/typescript/base.json
Normal 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
|
||||
}
|
||||
}
|
||||
7
tooling/typescript/node.json
Normal file
7
tooling/typescript/node.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
9
tooling/typescript/package.json
Normal file
9
tooling/typescript/package.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@yetanother/tsconfig",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./base": "./base.json",
|
||||
"./react": "./react.json",
|
||||
"./node": "./node.json"
|
||||
}
|
||||
}
|
||||
7
tooling/typescript/react.json
Normal file
7
tooling/typescript/react.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
27
turbo.json
Normal file
27
turbo.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
1575
yetanothersuite_implementation_plan.json
Normal file
1575
yetanothersuite_implementation_plan.json
Normal file
File diff suppressed because it is too large
Load Diff
953
yetanothersuite_implementation_plan.md
Normal file
953
yetanothersuite_implementation_plan.md
Normal 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*
|
||||
Loading…
Reference in New Issue
Block a user