feat: implement Phase 1 Foundation for AutoScheduler GTD System

- Initialize npm workspaces monorepo (backend, frontend, shared-types)
- Scaffold NestJS backend with modules: Auth, Users, Tasks, Projects, Inbox, Health
- Create React frontend with Vite, TailwindCSS, Radix UI
- Implement TypeORM entities: User, InboxItem, Task, Project
- Add JWT authentication with Passport.js and bcrypt
- Build Inbox capture API (POST /inbox, GET /inbox, POST /inbox/:id/process)
- Create Inbox UI with quick-add form and GTD processing workflow modal
- Configure Docker Compose stack (postgres, redis, backend, frontend)
- Add health check endpoint with database/Redis status
- Write unit tests for auth and inbox services

Phase 1 features complete:
- GTD Inbox Capture: Manual tasks via web form quick-add
- GTD Processing Workflow: Interactive inbox processing interface

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-01-11 08:42:54 +00:00
parent ce0e5f1769
commit 64b8e0d80c
90 changed files with 21021 additions and 2 deletions

View File

@@ -0,0 +1,66 @@
---
active: true
iteration: 1
max_iterations: 30
completion_promise: "PHASE_1_COMPLETE"
started_at: "2026-01-11T08:23:00Z"
---
# Phase 1: Foundation
## Context
Read PROMPT.md for full project requirements and context.
This prompt focuses ONLY on Phase 1: Foundation.
## Phase Objective
Project setup, core infrastructure, and initial configuration
## Phase 1 Tasks
- [ ] GTD Inbox Capture: Multi-source task capture system that ingests tasks from manual web form, REST API, email (IMAP/Microsoft Graph), and ConnectWise Manage sync into an unprocessed inbox for later GTD clarification
- Acceptance: Manual tasks can be submitted via web form quick-add and appear in inbox
- [ ] GTD Processing Workflow: Interactive inbox processing interface that guides users through GTD clarification: converting raw inbox items into Next Actions with context tags, Projects, Waiting For items, Someday/Maybe, Reference Material, Tickler items, or Trash
- Acceptance: Inbox view displays unprocessed items with processing workflow controls
## Working Instructions
1. Read PROMPT.md to understand the full project context
2. Focus ONLY on the tasks listed above for this phase
3. For each task:
- Implement the feature
- Write tests
- Run: npm run build && npm run test && npm run lint
- Update prd.json to set passes: true for completed features
- Append progress to progress.txt
- Commit with conventional commit message
## Constraints
- Always run tests before committing
- Never commit failing code
- Do not implement features from other phases
- Make reasonable decisions - do not ask questions
- Update prd.json when features complete
## Verification
After completing all Phase 1 tasks:
```bash
npm run build && npm run test && npm run lint
```
All commands must pass with zero errors.
## Completion
When ALL Phase 1 tasks are complete and verified:
- All features for this phase pass their acceptance criteria
- prd.json shows passes: true for all Phase 1 features
- Build, test, and lint all pass
Output: <promise>PHASE_1_COMPLETE</promise>
If blocked and cannot proceed:
Output: <promise>ABORT_BLOCKED</promise>

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE=autoscheduler
# Redis
REDIS_URL=redis://localhost:6379
# JWT
JWT_SECRET=your-secret-key-change-in-production
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
JWT_EXPIRES_IN=1h
# Environment
NODE_ENV=development
# Frontend
FRONTEND_URL=http://localhost:5173
# Encryption (for credential storage)
ENCRYPTION_KEY=your-32-byte-encryption-key-here

78
docker-compose.yml Normal file
View File

@@ -0,0 +1,78 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: nick-tracker-postgres
environment:
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_DATABASE:-autoscheduler}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: nick-tracker-redis
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: .
dockerfile: packages/backend/Dockerfile
container_name: nick-tracker-backend
environment:
NODE_ENV: ${NODE_ENV:-development}
PORT: 3000
DB_HOST: postgres
DB_PORT: 5432
DB_USERNAME: ${DB_USERNAME:-postgres}
DB_PASSWORD: ${DB_PASSWORD:-postgres}
DB_DATABASE: ${DB_DATABASE:-autoscheduler}
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET:-development-secret-change-me}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-refresh-secret-change-me}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173}
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
build:
context: .
dockerfile: packages/frontend/Dockerfile
container_name: nick-tracker-frontend
ports:
- "80:80"
depends_on:
- backend
volumes:
postgres_data:
redis_data:

16730
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "nick-tracker",
"version": "1.0.0",
"description": "AutoScheduler GTD System - Self-hosted web application implementing GTD methodology with automatic calendar scheduling",
"private": true,
"workspaces": [
"packages/*"
],
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "npm run build -w @nick-tracker/shared-types && npm run dev --workspaces --if-present",
"build": "npm run build -w @nick-tracker/shared-types && npm run build -w @nick-tracker/backend && npm run build -w @nick-tracker/frontend",
"test": "npm run test --workspaces --if-present",
"lint": "npm run lint --workspaces --if-present",
"clean": "npm run clean --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"migration:generate": "npm run migration:generate -w @nick-tracker/backend",
"migration:run": "npm run migration:run -w @nick-tracker/backend",
"migration:revert": "npm run migration:revert -w @nick-tracker/backend"
},
"devDependencies": {
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
},
};

View File

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

View File

@@ -0,0 +1,32 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
FROM base AS builder
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY packages/shared-types/package.json ./packages/shared-types/
COPY packages/backend/package.json ./packages/backend/
RUN pnpm install --frozen-lockfile || pnpm install
COPY packages/shared-types ./packages/shared-types
COPY packages/backend ./packages/backend
RUN pnpm --filter @nick-tracker/shared-types build
RUN pnpm --filter @nick-tracker/backend build
FROM base AS runner
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages/shared-types/dist ./packages/shared-types/dist
COPY --from=builder /app/packages/shared-types/package.json ./packages/shared-types/
COPY --from=builder /app/packages/backend/dist ./packages/backend/dist
COPY --from=builder /app/packages/backend/package.json ./packages/backend/
COPY --from=builder /app/packages/backend/node_modules ./packages/backend/node_modules
WORKDIR /app/packages/backend
EXPOSE 3000
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,14 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^@nick-tracker/shared-types$': '<rootDir>/../../shared-types/src/index.ts',
},
};

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@@ -0,0 +1,69 @@
{
"name": "@nick-tracker/backend",
"version": "1.0.0",
"description": "NestJS backend for nick-tracker",
"main": "dist/main.js",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"start:prod": "node dist/main.js",
"lint": "eslint \"src/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"migration:generate": "typeorm migration:generate -d src/config/typeorm.config.ts",
"migration:run": "typeorm migration:run -d src/config/typeorm.config.ts",
"migration:revert": "typeorm migration:revert -d src/config/typeorm.config.ts"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/typeorm": "^10.0.1",
"@nestjs/websockets": "^10.3.0",
"@nick-tracker/shared-types": "*",
"bcrypt": "^5.1.1",
"bull": "^4.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"date-fns": "^3.2.0",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.3",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"typeorm": "^0.3.19"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"jest": "^29.7.0",
"prettier": "^3.1.1",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { InboxModule } from './modules/inbox/inbox.module';
import { TasksModule } from './modules/tasks/tasks.module';
import { ProjectsModule } from './modules/projects/projects.module';
import { HealthModule } from './modules/health/health.module';
import { User } from './modules/users/entities/user.entity';
import { InboxItem } from './modules/inbox/entities/inbox-item.entity';
import { Task } from './modules/tasks/entities/task.entity';
import { Project } from './modules/projects/entities/project.entity';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('DB_HOST', 'localhost'),
port: configService.get('DB_PORT', 5432),
username: configService.get('DB_USERNAME', 'postgres'),
password: configService.get('DB_PASSWORD', 'postgres'),
database: configService.get('DB_DATABASE', 'autoscheduler'),
entities: [User, InboxItem, Task, Project],
synchronize: configService.get('NODE_ENV') !== 'production',
logging: configService.get('NODE_ENV') !== 'production',
}),
inject: [ConfigService],
}),
ScheduleModule.forRoot(),
AuthModule,
UsersModule,
InboxModule,
TasksModule,
ProjectsModule,
HealthModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,38 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
const status =
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException ? exception.message : 'Internal server error';
this.logger.error(
`${request.method} ${request.url} ${status} - ${message}`,
exception instanceof Error ? exception.stack : undefined,
);
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@@ -0,0 +1 @@
export * from './http-exception.filter';

View File

@@ -0,0 +1,40 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/http-exception.filter';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, {
logger:
process.env.NODE_ENV === 'production'
? ['log', 'error', 'warn']
: ['log', 'error', 'warn', 'debug', 'verbose'],
});
app.use(helmet());
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
});
app.setGlobalPrefix('api/v1');
app.useGlobalFilters(new AllExceptionsFilter());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`Application is running on port ${port}`);
}
bootstrap();

View File

@@ -0,0 +1,36 @@
import { Controller, Post, Body, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body() refreshDto: RefreshTokenDto) {
return this.authService.refresh(refreshDto.refreshToken);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async logout(@Request() req: { user: { userId: string } }) {
await this.authService.logout(req.user.userId);
return { message: 'Logged out successfully' };
}
}

View File

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
UsersModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET', 'development-secret-change-me'),
signOptions: {
expiresIn: configService.get('JWT_EXPIRES_IN', '1h'),
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,128 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { User } from '../users/entities/user.entity';
describe('AuthService', () => {
let service: AuthService;
let usersService: Partial<UsersService>;
let jwtService: Partial<JwtService>;
const mockUser: Partial<User> = {
id: 'test-user-id',
email: 'test@example.com',
password: 'hashedPassword',
name: 'Test User',
timezone: 'UTC',
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
usersService = {
findByEmail: jest.fn(),
create: jest.fn(),
validatePassword: jest.fn(),
updateRefreshToken: jest.fn(),
toResponseDto: jest.fn().mockReturnValue({
id: mockUser.id,
email: mockUser.email,
name: mockUser.name,
timezone: mockUser.timezone,
createdAt: mockUser.createdAt?.toISOString(),
updatedAt: mockUser.updatedAt?.toISOString(),
}),
};
jwtService = {
sign: jest.fn().mockReturnValue('mock-token'),
verify: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: UsersService, useValue: usersService },
{ provide: JwtService, useValue: jwtService },
{ provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('secret') } },
],
}).compile();
service = module.get<AuthService>(AuthService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('register', () => {
it('should successfully register a new user', async () => {
(usersService.findByEmail as jest.Mock).mockResolvedValue(null);
(usersService.create as jest.Mock).mockResolvedValue(mockUser);
const result = await service.register({
email: 'test@example.com',
password: 'Password123!',
name: 'Test User',
timezone: 'UTC',
});
expect(result.token).toBe('mock-token');
expect(result.user.email).toBe('test@example.com');
});
it('should throw ConflictException if email exists', async () => {
(usersService.findByEmail as jest.Mock).mockResolvedValue(mockUser);
await expect(
service.register({
email: 'test@example.com',
password: 'Password123!',
name: 'Test User',
timezone: 'UTC',
}),
).rejects.toThrow(ConflictException);
});
});
describe('login', () => {
it('should successfully login a user', async () => {
(usersService.findByEmail as jest.Mock).mockResolvedValue(mockUser);
(usersService.validatePassword as jest.Mock).mockResolvedValue(true);
const result = await service.login({
email: 'test@example.com',
password: 'Password123!',
});
expect(result.token).toBe('mock-token');
expect(result.user.email).toBe('test@example.com');
});
it('should throw UnauthorizedException for invalid email', async () => {
(usersService.findByEmail as jest.Mock).mockResolvedValue(null);
await expect(
service.login({
email: 'nonexistent@example.com',
password: 'Password123!',
}),
).rejects.toThrow(UnauthorizedException);
});
it('should throw UnauthorizedException for invalid password', async () => {
(usersService.findByEmail as jest.Mock).mockResolvedValue(mockUser);
(usersService.validatePassword as jest.Mock).mockResolvedValue(false);
await expect(
service.login({
email: 'test@example.com',
password: 'WrongPassword',
}),
).rejects.toThrow(UnauthorizedException);
});
});
});

View File

@@ -0,0 +1,87 @@
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
import type { RegisterDto, LoginDto, AuthResponseDto } from '@nick-tracker/shared-types';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async register(registerDto: RegisterDto): Promise<AuthResponseDto> {
const existingUser = await this.usersService.findByEmail(registerDto.email);
if (existingUser) {
throw new ConflictException('Email already registered');
}
const user = await this.usersService.create(registerDto);
const tokens = await this.generateTokens(user.id, user.email);
await this.usersService.updateRefreshToken(user.id, tokens.refreshToken);
return {
token: tokens.accessToken,
user: this.usersService.toResponseDto(user),
};
}
async login(loginDto: LoginDto): Promise<AuthResponseDto> {
const user = await this.usersService.findByEmail(loginDto.email);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const isPasswordValid = await this.usersService.validatePassword(user, loginDto.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
const tokens = await this.generateTokens(user.id, user.email);
await this.usersService.updateRefreshToken(user.id, tokens.refreshToken);
return {
token: tokens.accessToken,
user: this.usersService.toResponseDto(user),
};
}
async refresh(refreshToken: string): Promise<{ token: string }> {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get('JWT_REFRESH_SECRET', 'refresh-secret-change-me'),
});
const user = await this.usersService.findById(payload.sub);
if (!user || user.refreshToken !== refreshToken) {
throw new UnauthorizedException('Invalid refresh token');
}
const tokens = await this.generateTokens(user.id, user.email);
await this.usersService.updateRefreshToken(user.id, tokens.refreshToken);
return { token: tokens.accessToken };
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}
async logout(userId: string): Promise<void> {
await this.usersService.updateRefreshToken(userId, null);
}
private async generateTokens(userId: string, email: string) {
const payload = { sub: userId, email };
const accessToken = this.jwtService.sign(payload);
const refreshToken = this.jwtService.sign(payload, {
secret: this.configService.get('JWT_REFRESH_SECRET', 'refresh-secret-change-me'),
expiresIn: '7d',
});
return { accessToken, refreshToken };
}
}

View File

@@ -0,0 +1,9 @@
import { IsEmail, IsString } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken: string;
}

View File

@@ -0,0 +1,23 @@
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
export class RegisterDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
@MaxLength(100)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message:
'Password must contain at least one uppercase letter, one lowercase letter, and one number',
})
password: string;
@IsString()
@MinLength(1)
@MaxLength(100)
name: string;
@IsString()
timezone: string;
}

View File

@@ -0,0 +1,16 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
handleRequest<TUser>(err: Error | null, user: TUser): TUser {
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../../users/users.service';
interface JwtPayload {
sub: string;
email: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly usersService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET', 'development-secret-change-me'),
});
}
async validate(payload: JwtPayload) {
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
return { userId: payload.sub, email: payload.email };
}
}

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { HealthService } from './health.service';
@Controller()
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get('health')
async check() {
return this.healthService.check();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}

View File

@@ -0,0 +1,67 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import type { HealthCheckResponseDto } from '@nick-tracker/shared-types';
@Injectable()
export class HealthService {
private readonly logger = new Logger(HealthService.name);
private redis: Redis | null = null;
constructor(
private readonly dataSource: DataSource,
private readonly configService: ConfigService,
) {
this.initRedis();
}
private initRedis() {
const redisUrl = this.configService.get<string>('REDIS_URL');
if (redisUrl) {
try {
this.redis = new Redis(redisUrl);
} catch (error) {
this.logger.warn('Failed to connect to Redis:', error);
}
}
}
async check(): Promise<HealthCheckResponseDto> {
const dbStatus = await this.checkDatabase();
const redisStatus = await this.checkRedis();
return {
status: dbStatus === 'connected' && redisStatus === 'connected' ? 'ok' : 'error',
database: dbStatus,
redis: redisStatus,
};
}
private async checkDatabase(): Promise<'connected' | 'disconnected'> {
try {
if (this.dataSource.isInitialized) {
await this.dataSource.query('SELECT 1');
return 'connected';
}
return 'disconnected';
} catch (error) {
this.logger.warn('Database health check failed:', error);
return 'disconnected';
}
}
private async checkRedis(): Promise<'connected' | 'disconnected'> {
if (!this.redis) {
return 'disconnected';
}
try {
const result = await this.redis.ping();
return result === 'PONG' ? 'connected' : 'disconnected';
} catch (error) {
this.logger.warn('Redis health check failed:', error);
return 'disconnected';
}
}
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsOptional, IsObject, MinLength } from 'class-validator';
export class CreateInboxItemDto {
@IsString()
@MinLength(1)
content: string;
@IsOptional()
@IsObject()
sourceMetadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,59 @@
import {
IsEnum,
IsOptional,
IsString,
IsNumber,
IsUUID,
IsDateString,
Min,
Max,
} from 'class-validator';
import { ProcessAction, TaskContext, TaskDomain } from '@nick-tracker/shared-types';
export class ProcessInboxItemDto {
@IsEnum(ProcessAction)
action: ProcessAction;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsEnum(TaskContext)
context?: TaskContext;
@IsOptional()
@IsEnum(TaskDomain)
domain?: TaskDomain;
@IsOptional()
@IsNumber()
@Min(1)
@Max(5)
priority?: number;
@IsOptional()
@IsNumber()
@Min(5)
estimatedDuration?: number;
@IsOptional()
@IsDateString()
dueDate?: string;
@IsOptional()
@IsUUID()
projectId?: string;
@IsOptional()
@IsDateString()
followUpDate?: string;
@IsOptional()
@IsDateString()
ticklerDate?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { InboxSource } from '@nick-tracker/shared-types';
@Entity('inbox_items')
export class InboxItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('text')
content: string;
@Column({
type: 'enum',
enum: InboxSource,
default: InboxSource.MANUAL,
})
source: InboxSource;
@Column({ type: 'jsonb', nullable: true })
sourceMetadata: Record<string, unknown> | null;
@Column({ default: false })
processed: boolean;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User, (user) => user.inboxItems, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,46 @@
import { Controller, Get, Post, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { InboxService } from './inbox.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CreateInboxItemDto } from './dto/create-inbox-item.dto';
import { ProcessInboxItemDto } from './dto/process-inbox-item.dto';
@Controller('inbox')
@UseGuards(JwtAuthGuard)
export class InboxController {
constructor(private readonly inboxService: InboxService) {}
@Get()
async findAll(@Request() req: { user: { userId: string } }) {
const items = await this.inboxService.findUnprocessed(req.user.userId);
return items.map((item) => this.inboxService.toResponseDto(item));
}
@Post()
async create(
@Request() req: { user: { userId: string } },
@Body() createDto: CreateInboxItemDto,
) {
const item = await this.inboxService.create(
req.user.userId,
createDto.content,
undefined,
createDto.sourceMetadata,
);
return this.inboxService.toResponseDto(item);
}
@Post(':id/process')
async process(
@Request() req: { user: { userId: string } },
@Param('id') id: string,
@Body() processDto: ProcessInboxItemDto,
) {
return this.inboxService.process(id, req.user.userId, processDto);
}
@Delete(':id')
async delete(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
await this.inboxService.delete(id, req.user.userId);
return { success: true };
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { InboxItem } from './entities/inbox-item.entity';
import { InboxService } from './inbox.service';
import { InboxController } from './inbox.controller';
import { TasksModule } from '../tasks/tasks.module';
import { ProjectsModule } from '../projects/projects.module';
@Module({
imports: [TypeOrmModule.forFeature([InboxItem]), TasksModule, ProjectsModule],
controllers: [InboxController],
providers: [InboxService],
exports: [InboxService],
})
export class InboxModule {}

View File

@@ -0,0 +1,147 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { InboxService } from './inbox.service';
import { InboxItem } from './entities/inbox-item.entity';
import { TasksService } from '../tasks/tasks.service';
import { ProjectsService } from '../projects/projects.service';
import { InboxSource, ProcessAction, TaskDomain, TaskContext } from '@nick-tracker/shared-types';
describe('InboxService', () => {
let service: InboxService;
let repository: Partial<Repository<InboxItem>>;
let tasksService: Partial<TasksService>;
let projectsService: Partial<ProjectsService>;
const mockInboxItem: Partial<InboxItem> = {
id: 'test-inbox-id',
content: 'Test inbox item',
source: InboxSource.MANUAL,
processed: false,
userId: 'test-user-id',
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
repository = {
create: jest.fn().mockReturnValue({ ...mockInboxItem }),
save: jest.fn().mockImplementation((item) => Promise.resolve({ ...item })),
find: jest.fn().mockResolvedValue([{ ...mockInboxItem }]),
findOne: jest.fn().mockResolvedValue({ ...mockInboxItem }),
remove: jest.fn().mockResolvedValue({ ...mockInboxItem }),
};
tasksService = {
create: jest.fn().mockResolvedValue({ id: 'task-id' }),
};
projectsService = {
create: jest.fn().mockResolvedValue({ id: 'project-id' }),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
InboxService,
{ provide: getRepositoryToken(InboxItem), useValue: repository },
{ provide: TasksService, useValue: tasksService },
{ provide: ProjectsService, useValue: projectsService },
],
}).compile();
service = module.get<InboxService>(InboxService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should create an inbox item', async () => {
const result = await service.create('test-user-id', 'Test content');
expect(repository.create).toHaveBeenCalledWith({
userId: 'test-user-id',
content: 'Test content',
source: InboxSource.MANUAL,
sourceMetadata: null,
processed: false,
});
expect(repository.save).toHaveBeenCalled();
expect(result).toBeDefined();
});
});
describe('findUnprocessed', () => {
it('should return unprocessed items', async () => {
const result = await service.findUnprocessed('test-user-id');
expect(repository.find).toHaveBeenCalledWith({
where: { userId: 'test-user-id', processed: false },
order: { createdAt: 'DESC' },
});
expect(result).toHaveLength(1);
});
});
describe('process', () => {
it('should process inbox item to task', async () => {
const result = await service.process('test-inbox-id', 'test-user-id', {
action: ProcessAction.TASK,
domain: TaskDomain.WORK,
context: TaskContext.DESK,
});
expect(tasksService.create).toHaveBeenCalled();
expect(repository.save).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.resultId).toBe('task-id');
});
it('should process inbox item to project', async () => {
const result = await service.process('test-inbox-id', 'test-user-id', {
action: ProcessAction.PROJECT,
domain: TaskDomain.WORK,
});
expect(projectsService.create).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.resultId).toBe('project-id');
});
it('should throw NotFoundException for non-existent item', async () => {
(repository.findOne as jest.Mock).mockResolvedValue(null);
await expect(
service.process('non-existent', 'test-user-id', {
action: ProcessAction.TRASH,
}),
).rejects.toThrow(NotFoundException);
});
it('should throw BadRequestException for already processed item', async () => {
(repository.findOne as jest.Mock).mockResolvedValue({
...mockInboxItem,
processed: true,
});
await expect(
service.process('test-inbox-id', 'test-user-id', {
action: ProcessAction.TRASH,
}),
).rejects.toThrow(BadRequestException);
});
});
describe('toResponseDto', () => {
it('should convert entity to response DTO', () => {
const result = service.toResponseDto(mockInboxItem as InboxItem);
expect(result.id).toBe('test-inbox-id');
expect(result.content).toBe('Test inbox item');
expect(result.source).toBe(InboxSource.MANUAL);
expect(result.processed).toBe(false);
});
});
});

View File

@@ -0,0 +1,151 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InboxItem } from './entities/inbox-item.entity';
import { TasksService } from '../tasks/tasks.service';
import { ProjectsService } from '../projects/projects.service';
import {
InboxSource,
ProcessAction,
TaskStatus,
type InboxItemResponseDto,
type ProcessInboxItemDto,
} from '@nick-tracker/shared-types';
@Injectable()
export class InboxService {
constructor(
@InjectRepository(InboxItem)
private readonly inboxRepository: Repository<InboxItem>,
private readonly tasksService: TasksService,
private readonly projectsService: ProjectsService,
) {}
async create(
userId: string,
content: string,
source: InboxSource = InboxSource.MANUAL,
sourceMetadata?: Record<string, unknown>,
): Promise<InboxItem> {
const item = this.inboxRepository.create({
userId,
content,
source,
sourceMetadata: sourceMetadata ?? null,
processed: false,
});
return this.inboxRepository.save(item);
}
async findUnprocessed(userId: string): Promise<InboxItem[]> {
return this.inboxRepository.find({
where: { userId, processed: false },
order: { createdAt: 'DESC' },
});
}
async findById(id: string, userId: string): Promise<InboxItem | null> {
return this.inboxRepository.findOne({
where: { id, userId },
});
}
async process(
id: string,
userId: string,
processDto: ProcessInboxItemDto,
): Promise<{ success: boolean; resultId?: string }> {
const item = await this.findById(id, userId);
if (!item) {
throw new NotFoundException('Inbox item not found');
}
if (item.processed) {
throw new BadRequestException('Inbox item already processed');
}
let resultId: string | undefined;
switch (processDto.action) {
case ProcessAction.TASK:
case ProcessAction.WAITING_FOR:
case ProcessAction.SOMEDAY_MAYBE:
case ProcessAction.TICKLER: {
const title = processDto.title || item.content;
const status = this.getTaskStatusFromAction(processDto.action);
const task = await this.tasksService.create(userId, {
title,
domain: processDto.domain!,
context: processDto.context,
priority: processDto.priority,
estimatedDuration: processDto.estimatedDuration,
dueDate: processDto.dueDate,
projectId: processDto.projectId,
notes: processDto.notes,
status,
followUpDate: processDto.followUpDate,
ticklerDate: processDto.ticklerDate,
});
resultId = task.id;
break;
}
case ProcessAction.PROJECT: {
const name = processDto.title || item.content;
const project = await this.projectsService.create(userId, {
name,
domain: processDto.domain!,
});
resultId = project.id;
break;
}
case ProcessAction.REFERENCE:
case ProcessAction.TRASH:
break;
default:
throw new BadRequestException('Invalid action');
}
item.processed = true;
await this.inboxRepository.save(item);
return { success: true, resultId };
}
async delete(id: string, userId: string): Promise<void> {
const item = await this.findById(id, userId);
if (!item) {
throw new NotFoundException('Inbox item not found');
}
await this.inboxRepository.remove(item);
}
private getTaskStatusFromAction(action: ProcessAction): TaskStatus {
switch (action) {
case ProcessAction.TASK:
return TaskStatus.NEXT_ACTION;
case ProcessAction.WAITING_FOR:
return TaskStatus.WAITING_FOR;
case ProcessAction.SOMEDAY_MAYBE:
return TaskStatus.SOMEDAY_MAYBE;
case ProcessAction.TICKLER:
return TaskStatus.TICKLER;
default:
return TaskStatus.NEXT_ACTION;
}
}
toResponseDto(item: InboxItem): InboxItemResponseDto {
return {
id: item.id,
content: item.content,
source: item.source,
sourceMetadata: item.sourceMetadata ?? undefined,
processed: item.processed,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,23 @@
import { IsString, IsEnum, IsOptional, MinLength } from 'class-validator';
import { TaskDomain } from '@nick-tracker/shared-types';
export class CreateProjectDto {
@IsString()
@MinLength(1)
name: string;
@IsEnum(TaskDomain)
domain: TaskDomain;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
desiredOutcome?: string;
@IsOptional()
@IsString()
connectwiseProjectId?: string;
}

View File

@@ -0,0 +1,12 @@
import { IsEnum, IsOptional } from 'class-validator';
import { TaskDomain, ProjectStatus } from '@nick-tracker/shared-types';
export class ProjectFilterDto {
@IsOptional()
@IsEnum(ProjectStatus)
status?: ProjectStatus;
@IsOptional()
@IsEnum(TaskDomain)
domain?: TaskDomain;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsEnum, IsOptional, MinLength } from 'class-validator';
import { TaskDomain, ProjectStatus } from '@nick-tracker/shared-types';
export class UpdateProjectDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsEnum(TaskDomain)
domain?: TaskDomain;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
desiredOutcome?: string;
@IsOptional()
@IsEnum(ProjectStatus)
status?: ProjectStatus;
}

View File

@@ -0,0 +1,60 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Task } from '../../tasks/entities/task.entity';
import { TaskDomain, ProjectStatus } from '@nick-tracker/shared-types';
@Entity('projects')
export class Project {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({
type: 'enum',
enum: TaskDomain,
})
domain: TaskDomain;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'text', nullable: true })
desiredOutcome: string | null;
@Column({
type: 'enum',
enum: ProjectStatus,
default: ProjectStatus.ACTIVE,
})
status: ProjectStatus;
@Column({ nullable: true })
connectwiseProjectId: string | null;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User, (user) => user.projects, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@OneToMany(() => Task, (task) => task.project)
tasks: Task[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,70 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ProjectsService } from './projects.service';
import { TasksService } from '../tasks/tasks.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { ProjectFilterDto } from './dto/project-filter.dto';
@Controller('projects')
@UseGuards(JwtAuthGuard)
export class ProjectsController {
constructor(
private readonly projectsService: ProjectsService,
private readonly tasksService: TasksService,
) {}
@Get()
async findAll(@Request() req: { user: { userId: string } }, @Query() filter: ProjectFilterDto) {
const projects = await this.projectsService.findAll(req.user.userId, filter);
return projects.map((project) => this.projectsService.toResponseDto(project));
}
@Get(':id')
async findOne(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
const project = await this.projectsService.findById(id, req.user.userId);
if (!project) {
throw new Error('Project not found');
}
return this.projectsService.toResponseDto(project);
}
@Get(':id/tasks')
async getTasks(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
const tasks = await this.projectsService.getProjectTasks(id, req.user.userId);
return tasks.map((task) => this.tasksService.toResponseDto(task));
}
@Post()
async create(@Request() req: { user: { userId: string } }, @Body() createDto: CreateProjectDto) {
const project = await this.projectsService.create(req.user.userId, createDto);
return this.projectsService.toResponseDto(project);
}
@Patch(':id')
async update(
@Request() req: { user: { userId: string } },
@Param('id') id: string,
@Body() updateDto: UpdateProjectDto,
) {
const project = await this.projectsService.update(id, req.user.userId, updateDto);
return this.projectsService.toResponseDto(project);
}
@Delete(':id')
async delete(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
await this.projectsService.delete(id, req.user.userId);
return { success: true };
}
}

View File

@@ -0,0 +1,14 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Project } from './entities/project.entity';
import { ProjectsService } from './projects.service';
import { ProjectsController } from './projects.controller';
import { TasksModule } from '../tasks/tasks.module';
@Module({
imports: [TypeOrmModule.forFeature([Project]), forwardRef(() => TasksModule)],
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
})
export class ProjectsModule {}

View File

@@ -0,0 +1,96 @@
import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
import { Project } from './entities/project.entity';
import { TasksService } from '../tasks/tasks.service';
import {
ProjectStatus,
TaskDomain,
type CreateProjectDto,
type UpdateProjectDto,
type ProjectResponseDto,
} from '@nick-tracker/shared-types';
@Injectable()
export class ProjectsService {
constructor(
@InjectRepository(Project)
private readonly projectRepository: Repository<Project>,
@Inject(forwardRef(() => TasksService))
private readonly tasksService: TasksService,
) {}
async create(userId: string, createDto: CreateProjectDto): Promise<Project> {
const project = this.projectRepository.create({
...createDto,
userId,
status: ProjectStatus.ACTIVE,
});
return this.projectRepository.save(project);
}
async findAll(
userId: string,
filter?: { status?: ProjectStatus; domain?: TaskDomain },
): Promise<Project[]> {
const where: FindOptionsWhere<Project> = { userId };
if (filter?.status) {
where.status = filter.status;
}
if (filter?.domain) {
where.domain = filter.domain;
}
return this.projectRepository.find({
where,
order: { createdAt: 'DESC' },
});
}
async findById(id: string, userId: string): Promise<Project | null> {
return this.projectRepository.findOne({
where: { id, userId },
});
}
async update(id: string, userId: string, updateDto: UpdateProjectDto): Promise<Project> {
const project = await this.findById(id, userId);
if (!project) {
throw new NotFoundException('Project not found');
}
Object.assign(project, updateDto);
return this.projectRepository.save(project);
}
async delete(id: string, userId: string): Promise<void> {
const project = await this.findById(id, userId);
if (!project) {
throw new NotFoundException('Project not found');
}
await this.projectRepository.remove(project);
}
async getProjectTasks(id: string, userId: string) {
const project = await this.findById(id, userId);
if (!project) {
throw new NotFoundException('Project not found');
}
return this.tasksService.findByProject(id, userId);
}
toResponseDto(project: Project): ProjectResponseDto {
return {
id: project.id,
name: project.name,
domain: project.domain,
description: project.description ?? undefined,
desiredOutcome: project.desiredOutcome ?? undefined,
status: project.status,
connectwiseProjectId: project.connectwiseProjectId ?? undefined,
createdAt: project.createdAt.toISOString(),
updatedAt: project.updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,60 @@
import {
IsString,
IsEnum,
IsOptional,
IsNumber,
IsUUID,
IsDateString,
Min,
Max,
MinLength,
} from 'class-validator';
import { TaskStatus, TaskContext, TaskDomain } from '@nick-tracker/shared-types';
export class CreateTaskDto {
@IsString()
@MinLength(1)
title: string;
@IsEnum(TaskDomain)
domain: TaskDomain;
@IsOptional()
@IsEnum(TaskContext)
context?: TaskContext;
@IsOptional()
@IsNumber()
@Min(1)
@Max(5)
priority?: number;
@IsOptional()
@IsNumber()
@Min(5)
estimatedDuration?: number;
@IsOptional()
@IsDateString()
dueDate?: string;
@IsOptional()
@IsUUID()
projectId?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@IsOptional()
@IsDateString()
followUpDate?: string;
@IsOptional()
@IsDateString()
ticklerDate?: string;
}

View File

@@ -0,0 +1,20 @@
import { IsEnum, IsOptional, IsUUID } from 'class-validator';
import { TaskStatus, TaskContext, TaskDomain } from '@nick-tracker/shared-types';
export class TaskFilterDto {
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@IsOptional()
@IsEnum(TaskContext)
context?: TaskContext;
@IsOptional()
@IsEnum(TaskDomain)
domain?: TaskDomain;
@IsOptional()
@IsUUID()
projectId?: string;
}

View File

@@ -0,0 +1,75 @@
import {
IsString,
IsEnum,
IsOptional,
IsNumber,
IsUUID,
IsDateString,
IsBoolean,
Min,
Max,
MinLength,
} from 'class-validator';
import { TaskStatus, TaskContext, TaskDomain } from '@nick-tracker/shared-types';
export class UpdateTaskDto {
@IsOptional()
@IsString()
@MinLength(1)
title?: string;
@IsOptional()
@IsEnum(TaskDomain)
domain?: TaskDomain;
@IsOptional()
@IsEnum(TaskContext)
context?: TaskContext;
@IsOptional()
@IsNumber()
@Min(1)
@Max(5)
priority?: number;
@IsOptional()
@IsNumber()
@Min(5)
estimatedDuration?: number;
@IsOptional()
@IsDateString()
dueDate?: string;
@IsOptional()
@IsUUID()
projectId?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@IsOptional()
@IsDateString()
followUpDate?: string;
@IsOptional()
@IsDateString()
ticklerDate?: string;
@IsOptional()
@IsDateString()
scheduledStart?: string;
@IsOptional()
@IsDateString()
scheduledEnd?: string;
@IsOptional()
@IsBoolean()
isLocked?: boolean;
}

View File

@@ -0,0 +1,94 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Project } from '../../projects/entities/project.entity';
import { TaskStatus, TaskContext, TaskDomain } from '@nick-tracker/shared-types';
@Entity('tasks')
export class Task {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column({
type: 'enum',
enum: TaskDomain,
})
domain: TaskDomain;
@Column({
type: 'enum',
enum: TaskContext,
nullable: true,
})
context: TaskContext | null;
@Column({
type: 'enum',
enum: TaskStatus,
default: TaskStatus.NEXT_ACTION,
})
status: TaskStatus;
@Column({ type: 'int', nullable: true })
priority: number | null;
@Column({ type: 'int', nullable: true })
estimatedDuration: number | null;
@Column({ type: 'timestamp', nullable: true })
dueDate: Date | null;
@Column({ type: 'timestamp', nullable: true })
scheduledStart: Date | null;
@Column({ type: 'timestamp', nullable: true })
scheduledEnd: Date | null;
@Column({ default: false })
isLocked: boolean;
@Column({ type: 'text', nullable: true })
notes: string | null;
@Column({ type: 'timestamp', nullable: true })
followUpDate: Date | null;
@Column({ type: 'timestamp', nullable: true })
ticklerDate: Date | null;
@Column({ nullable: true })
connectwisePriority: string | null;
@Column({ nullable: true })
connectwiseSLA: string | null;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User, (user) => user.tasks, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'uuid', nullable: true })
projectId: string | null;
@ManyToOne(() => Project, (project) => project.tasks, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'projectId' })
project: Project | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,72 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { TasksService } from './tasks.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { TaskFilterDto } from './dto/task-filter.dto';
@Controller('tasks')
@UseGuards(JwtAuthGuard)
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
async findAll(@Request() req: { user: { userId: string } }, @Query() filter: TaskFilterDto) {
const tasks = await this.tasksService.findAll(req.user.userId, filter);
return tasks.map((task) => this.tasksService.toResponseDto(task));
}
@Get(':id')
async findOne(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
const task = await this.tasksService.findById(id, req.user.userId);
if (!task) {
throw new Error('Task not found');
}
return this.tasksService.toResponseDto(task);
}
@Post()
async create(@Request() req: { user: { userId: string } }, @Body() createDto: CreateTaskDto) {
const task = await this.tasksService.create(req.user.userId, createDto);
return this.tasksService.toResponseDto(task);
}
@Patch(':id')
async update(
@Request() req: { user: { userId: string } },
@Param('id') id: string,
@Body() updateDto: UpdateTaskDto,
) {
const task = await this.tasksService.update(id, req.user.userId, updateDto);
return this.tasksService.toResponseDto(task);
}
@Delete(':id')
async delete(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
await this.tasksService.delete(id, req.user.userId);
return { success: true };
}
@Post(':id/lock')
async lock(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
const task = await this.tasksService.lock(id, req.user.userId);
return this.tasksService.toResponseDto(task);
}
@Post(':id/unlock')
async unlock(@Request() req: { user: { userId: string } }, @Param('id') id: string) {
const task = await this.tasksService.unlock(id, req.user.userId);
return this.tasksService.toResponseDto(task);
}
}

View File

@@ -0,0 +1,14 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Task } from './entities/task.entity';
import { TasksService } from './tasks.service';
import { TasksController } from './tasks.controller';
import { ProjectsModule } from '../projects/projects.module';
@Module({
imports: [TypeOrmModule.forFeature([Task]), forwardRef(() => ProjectsModule)],
controllers: [TasksController],
providers: [TasksService],
exports: [TasksService],
})
export class TasksModule {}

View File

@@ -0,0 +1,149 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
import { Task } from './entities/task.entity';
import {
TaskStatus,
type CreateTaskDto,
type UpdateTaskDto,
type TaskResponseDto,
type TaskFilterDto,
} from '@nick-tracker/shared-types';
@Injectable()
export class TasksService {
constructor(
@InjectRepository(Task)
private readonly taskRepository: Repository<Task>,
) {}
async create(userId: string, createDto: CreateTaskDto): Promise<Task> {
const task = this.taskRepository.create({
...createDto,
userId,
dueDate: createDto.dueDate ? new Date(createDto.dueDate) : null,
followUpDate: createDto.followUpDate ? new Date(createDto.followUpDate) : null,
ticklerDate: createDto.ticklerDate ? new Date(createDto.ticklerDate) : null,
status: createDto.status || TaskStatus.NEXT_ACTION,
});
return this.taskRepository.save(task);
}
async findAll(userId: string, filter?: TaskFilterDto): Promise<Task[]> {
const where: FindOptionsWhere<Task> = { userId };
if (filter?.status) {
where.status = filter.status;
}
if (filter?.context) {
where.context = filter.context;
}
if (filter?.domain) {
where.domain = filter.domain;
}
if (filter?.projectId) {
where.projectId = filter.projectId;
}
return this.taskRepository.find({
where,
order: { priority: 'ASC', createdAt: 'DESC' },
});
}
async findById(id: string, userId: string): Promise<Task | null> {
return this.taskRepository.findOne({
where: { id, userId },
});
}
async findByProject(projectId: string, userId: string): Promise<Task[]> {
return this.taskRepository.find({
where: { projectId, userId },
order: { priority: 'ASC', createdAt: 'DESC' },
});
}
async update(id: string, userId: string, updateDto: UpdateTaskDto): Promise<Task> {
const task = await this.findById(id, userId);
if (!task) {
throw new NotFoundException('Task not found');
}
Object.assign(task, {
...updateDto,
dueDate:
updateDto.dueDate !== undefined
? updateDto.dueDate
? new Date(updateDto.dueDate)
: null
: task.dueDate,
followUpDate:
updateDto.followUpDate !== undefined
? updateDto.followUpDate
? new Date(updateDto.followUpDate)
: null
: task.followUpDate,
ticklerDate:
updateDto.ticklerDate !== undefined
? updateDto.ticklerDate
? new Date(updateDto.ticklerDate)
: null
: task.ticklerDate,
scheduledStart:
updateDto.scheduledStart !== undefined
? updateDto.scheduledStart
? new Date(updateDto.scheduledStart)
: null
: task.scheduledStart,
scheduledEnd:
updateDto.scheduledEnd !== undefined
? updateDto.scheduledEnd
? new Date(updateDto.scheduledEnd)
: null
: task.scheduledEnd,
});
return this.taskRepository.save(task);
}
async delete(id: string, userId: string): Promise<void> {
const task = await this.findById(id, userId);
if (!task) {
throw new NotFoundException('Task not found');
}
await this.taskRepository.remove(task);
}
async lock(id: string, userId: string): Promise<Task> {
return this.update(id, userId, { isLocked: true });
}
async unlock(id: string, userId: string): Promise<Task> {
return this.update(id, userId, { isLocked: false });
}
toResponseDto(task: Task): TaskResponseDto {
return {
id: task.id,
title: task.title,
domain: task.domain,
context: task.context ?? undefined,
status: task.status,
priority: task.priority ?? undefined,
estimatedDuration: task.estimatedDuration ?? undefined,
dueDate: task.dueDate?.toISOString(),
scheduledStart: task.scheduledStart?.toISOString(),
scheduledEnd: task.scheduledEnd?.toISOString(),
isLocked: task.isLocked,
projectId: task.projectId ?? undefined,
notes: task.notes ?? undefined,
followUpDate: task.followUpDate?.toISOString(),
ticklerDate: task.ticklerDate?.toISOString(),
connectwisePriority: task.connectwisePriority ?? undefined,
connectwiseSLA: task.connectwiseSLA ?? undefined,
createdAt: task.createdAt.toISOString(),
updatedAt: task.updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,93 @@
import {
IsOptional,
IsString,
IsNumber,
IsBoolean,
ValidateNested,
Min,
Max,
} from 'class-validator';
import { Type } from 'class-transformer';
class DayHoursDto {
@IsString()
start: string;
@IsString()
end: string;
}
class WorkingHoursDto {
@IsOptional()
@ValidateNested()
@Type(() => DayHoursDto)
monday?: DayHoursDto;
@IsOptional()
@ValidateNested()
@Type(() => DayHoursDto)
tuesday?: DayHoursDto;
@IsOptional()
@ValidateNested()
@Type(() => DayHoursDto)
wednesday?: DayHoursDto;
@IsOptional()
@ValidateNested()
@Type(() => DayHoursDto)
thursday?: DayHoursDto;
@IsOptional()
@ValidateNested()
@Type(() => DayHoursDto)
friday?: DayHoursDto;
@IsOptional()
@ValidateNested()
@Type(() => DayHoursDto)
saturday?: DayHoursDto;
@IsOptional()
@ValidateNested()
@Type(() => DayHoursDto)
sunday?: DayHoursDto;
}
class NotificationPreferencesDto {
@IsBoolean()
email: boolean;
@IsBoolean()
webhook: boolean;
@IsOptional()
@IsString()
webhookUrl?: string;
}
export class UpdateUserPreferencesDto {
@IsOptional()
@ValidateNested()
@Type(() => WorkingHoursDto)
workingHours?: WorkingHoursDto;
@IsOptional()
@IsString()
timezone?: string;
@IsOptional()
@ValidateNested()
@Type(() => NotificationPreferencesDto)
notificationPreferences?: NotificationPreferencesDto;
@IsOptional()
@IsNumber()
@Min(0)
@Max(6)
weeklyReviewDay?: number;
@IsOptional()
@IsString()
weeklyReviewTime?: string;
}

View File

@@ -0,0 +1,63 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { InboxItem } from '../../inbox/entities/inbox-item.entity';
import { Task } from '../../tasks/entities/task.entity';
import { Project } from '../../projects/entities/project.entity';
import type { WorkingHours, NotificationPreferences } from '@nick-tracker/shared-types';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
password: string;
@Column()
name: string;
@Column({ default: 'UTC' })
timezone: string;
@Column({ type: 'jsonb', nullable: true })
workingHours: WorkingHours | null;
@Column({ type: 'jsonb', nullable: true })
notificationPreferences: NotificationPreferences | null;
@Column({ type: 'int', default: 5 })
weeklyReviewDay: number;
@Column({ default: '16:00' })
weeklyReviewTime: string;
@Column({ type: 'timestamp', nullable: true })
lastWeeklyReview: Date | null;
@Column({ nullable: true })
refreshToken: string | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => InboxItem, (inbox) => inbox.user)
inboxItems: InboxItem[];
@OneToMany(() => Task, (task) => task.user)
tasks: Task[];
@OneToMany(() => Project, (project) => project.user)
projects: Project[];
}

View File

@@ -0,0 +1,37 @@
import { Controller, Get, Patch, Body, UseGuards, Request } from '@nestjs/common';
import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UpdateUserPreferencesDto } from './dto/update-preferences.dto';
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('me')
async getMe(@Request() req: { user: { userId: string } }) {
const user = await this.usersService.findById(req.user.userId);
if (!user) {
throw new Error('User not found');
}
return this.usersService.toResponseDto(user);
}
@Get('preferences')
async getPreferences(@Request() req: { user: { userId: string } }) {
const user = await this.usersService.findById(req.user.userId);
if (!user) {
throw new Error('User not found');
}
return this.usersService.toPreferencesDto(user);
}
@Patch('preferences')
async updatePreferences(
@Request() req: { user: { userId: string } },
@Body() updateDto: UpdateUserPreferencesDto,
) {
const user = await this.usersService.updatePreferences(req.user.userId, updateDto);
return this.usersService.toPreferencesDto(user);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,97 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import type { RegisterDto, UpdateUserPreferencesDto } from '@nick-tracker/shared-types';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async create(registerDto: RegisterDto): Promise<User> {
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
const user = this.userRepository.create({
...registerDto,
password: hashedPassword,
workingHours: {
monday: { start: '09:00', end: '17:00' },
tuesday: { start: '09:00', end: '17:00' },
wednesday: { start: '09:00', end: '17:00' },
thursday: { start: '09:00', end: '17:00' },
friday: { start: '09:00', end: '17:00' },
},
notificationPreferences: {
email: false,
webhook: false,
},
});
return this.userRepository.save(user);
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({ where: { email } });
}
async findById(id: string): Promise<User | null> {
return this.userRepository.findOne({ where: { id } });
}
async updateRefreshToken(userId: string, refreshToken: string | null): Promise<void> {
await this.userRepository.update(userId, { refreshToken });
}
async updatePreferences(userId: string, preferences: UpdateUserPreferencesDto): Promise<User> {
const user = await this.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
if (preferences.workingHours !== undefined) {
user.workingHours = preferences.workingHours;
}
if (preferences.timezone !== undefined) {
user.timezone = preferences.timezone;
}
if (preferences.notificationPreferences !== undefined) {
user.notificationPreferences = preferences.notificationPreferences;
}
if (preferences.weeklyReviewDay !== undefined) {
user.weeklyReviewDay = preferences.weeklyReviewDay;
}
if (preferences.weeklyReviewTime !== undefined) {
user.weeklyReviewTime = preferences.weeklyReviewTime;
}
return this.userRepository.save(user);
}
async validatePassword(user: User, password: string): Promise<boolean> {
return bcrypt.compare(password, user.password);
}
toResponseDto(user: User) {
return {
id: user.id,
email: user.email,
name: user.name,
timezone: user.timezone,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
};
}
toPreferencesDto(user: User) {
return {
workingHours: user.workingHours,
timezone: user.timezone,
notificationPreferences: user.notificationPreferences,
weeklyReviewDay: user.weeklyReviewDay,
weeklyReviewTime: user.weeklyReviewTime,
lastWeeklyReview: user.lastWeeklyReview?.toISOString() ?? null,
};
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View File

@@ -0,0 +1,19 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
};

View File

@@ -0,0 +1,24 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
FROM base AS builder
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY packages/shared-types/package.json ./packages/shared-types/
COPY packages/frontend/package.json ./packages/frontend/
RUN pnpm install --frozen-lockfile || pnpm install
COPY packages/shared-types ./packages/shared-types
COPY packages/frontend ./packages/frontend
RUN pnpm --filter @nick-tracker/shared-types build
RUN pnpm --filter @nick-tracker/frontend build
FROM nginx:alpine AS runner
COPY --from=builder /app/packages/frontend/dist /usr/share/nginx/html
COPY packages/frontend/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoScheduler GTD</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}

View File

@@ -0,0 +1,58 @@
{
"name": "@nick-tracker/frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint \"src/**/*.{ts,tsx}\" --fix",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@nick-tracker/shared-types": "*",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"date-fns": "^3.2.0",
"lucide-react": "^0.309.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.3",
"react-router-dom": "^6.21.2",
"socket.io-client": "^4.6.1",
"tailwind-merge": "^2.2.0",
"zod": "^3.22.4",
"zustand": "^4.4.7"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/react": "^14.1.2",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"jsdom": "^23.2.0",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.11",
"vitest": "^1.2.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,46 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/auth';
import { Layout } from './components/layout/Layout';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { InboxPage } from './pages/InboxPage';
import { CalendarPage } from './pages/CalendarPage';
import { ProjectsPage } from './pages/ProjectsPage';
import { SettingsPage } from './pages/SettingsPage';
import { Toaster } from './components/ui/Toaster';
function PrivateRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((state) => state.token);
if (!token) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function App() {
return (
<>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/"
element={
<PrivateRoute>
<Layout />
</PrivateRoute>
}
>
<Route index element={<Navigate to="/inbox" replace />} />
<Route path="inbox" element={<InboxPage />} />
<Route path="calendar" element={<CalendarPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
<Toaster />
</>
);
}
export default App;

View File

@@ -0,0 +1,211 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import * as Dialog from '@radix-ui/react-dialog';
import * as Select from '@radix-ui/react-select';
import { X, ChevronDown, Check } from 'lucide-react';
import { inboxApi } from '../../lib/api';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { cn } from '../../lib/utils';
import {
ProcessAction,
TaskContext,
TaskDomain,
type InboxItemResponseDto,
} from '@nick-tracker/shared-types';
interface ProcessModalProps {
item: InboxItemResponseDto;
onClose: () => void;
}
export function ProcessModal({ item, onClose }: ProcessModalProps) {
const [action, setAction] = useState<ProcessAction | ''>('');
const [title, setTitle] = useState(item.content);
const [context, setContext] = useState<TaskContext | ''>('');
const [domain, setDomain] = useState<TaskDomain | ''>('');
const [priority, setPriority] = useState('');
const processMutation = useMutation({
mutationFn: () => {
if (!action) throw new Error('Please select an action');
return inboxApi.process(item.id, {
action,
title: title !== item.content ? title : undefined,
context: context || undefined,
domain: domain || undefined,
priority: priority ? parseInt(priority) : undefined,
});
},
onSuccess: () => {
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
processMutation.mutate();
};
const needsDetails = action === ProcessAction.TASK ||
action === ProcessAction.WAITING_FOR ||
action === ProcessAction.SOMEDAY_MAYBE ||
action === ProcessAction.TICKLER ||
action === ProcessAction.PROJECT;
return (
<Dialog.Root open onOpenChange={() => onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-background rounded-lg shadow-lg w-full max-w-lg p-6">
<div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-semibold">
Process Inbox Item
</Dialog.Title>
<Dialog.Close asChild>
<button className="text-muted-foreground hover:text-foreground">
<X className="h-5 w-5" />
</button>
</Dialog.Close>
</div>
<div className="mb-4 p-3 bg-muted rounded-md">
<p className="text-sm">{item.content}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">What is this?</label>
<Select.Root value={action} onValueChange={(v) => setAction(v as ProcessAction)}>
<Select.Trigger className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
<Select.Value placeholder="Select an action..." />
<Select.Icon>
<ChevronDown className="h-4 w-4 opacity-50" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content className="overflow-hidden bg-background rounded-md border shadow-md">
<Select.Viewport className="p-1">
<SelectItem value={ProcessAction.TASK}>Next Action</SelectItem>
<SelectItem value={ProcessAction.PROJECT}>Project (multi-step)</SelectItem>
<SelectItem value={ProcessAction.WAITING_FOR}>Waiting For</SelectItem>
<SelectItem value={ProcessAction.SOMEDAY_MAYBE}>Someday/Maybe</SelectItem>
<SelectItem value={ProcessAction.TICKLER}>Tickler (future date)</SelectItem>
<SelectItem value={ProcessAction.REFERENCE}>Reference Material</SelectItem>
<SelectItem value={ProcessAction.TRASH}>Trash</SelectItem>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
{needsDetails && (
<>
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter a title"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Domain</label>
<Select.Root value={domain} onValueChange={(v) => setDomain(v as TaskDomain)}>
<Select.Trigger className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
<Select.Value placeholder="Select domain..." />
<Select.Icon>
<ChevronDown className="h-4 w-4 opacity-50" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content className="overflow-hidden bg-background rounded-md border shadow-md">
<Select.Viewport className="p-1">
<SelectItem value={TaskDomain.WORK}>Work</SelectItem>
<SelectItem value={TaskDomain.HOMELAB}>Homelab</SelectItem>
<SelectItem value={TaskDomain.DAILY_ROUTINES}>Daily Routines</SelectItem>
<SelectItem value={TaskDomain.HOUSE}>House</SelectItem>
<SelectItem value={TaskDomain.PROFESSIONAL_DEVELOPMENT}>Professional Development</SelectItem>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
{action !== ProcessAction.PROJECT && (
<div className="space-y-2">
<label className="text-sm font-medium">Context</label>
<Select.Root value={context} onValueChange={(v) => setContext(v as TaskContext)}>
<Select.Trigger className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
<Select.Value placeholder="Select context..." />
<Select.Icon>
<ChevronDown className="h-4 w-4 opacity-50" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content className="overflow-hidden bg-background rounded-md border shadow-md">
<Select.Viewport className="p-1">
<SelectItem value={TaskContext.DESK}>@desk</SelectItem>
<SelectItem value={TaskContext.PHONE}>@phone</SelectItem>
<SelectItem value={TaskContext.ERRAND}>@errand</SelectItem>
<SelectItem value={TaskContext.HOMELAB}>@homelab</SelectItem>
<SelectItem value={TaskContext.ANYWHERE}>@anywhere</SelectItem>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium">Priority (1-5)</label>
<Input
type="number"
min="1"
max="5"
value={priority}
onChange={(e) => setPriority(e.target.value)}
placeholder="Optional"
/>
</div>
</>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
disabled={!action || (needsDetails && !domain) || processMutation.isPending}
>
{processMutation.isPending ? 'Processing...' : 'Process'}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function SelectItem({ children, value, className }: { children: React.ReactNode; value: string; className?: string }) {
return (
<Select.Item
value={value}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Select.ItemIndicator>
<Check className="h-4 w-4" />
</Select.ItemIndicator>
</span>
<Select.ItemText>{children}</Select.ItemText>
</Select.Item>
);
}

View File

@@ -0,0 +1,67 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { Inbox, Calendar, FolderKanban, Settings, LogOut } from 'lucide-react';
import { useAuthStore } from '../../store/auth';
import { Button } from '../ui/Button';
import { cn } from '../../lib/utils';
const navItems = [
{ to: '/inbox', icon: Inbox, label: 'Inbox' },
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
{ to: '/settings', icon: Settings, label: 'Settings' },
];
export function Layout() {
const navigate = useNavigate();
const logout = useAuthStore((state) => state.logout);
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div className="min-h-screen flex">
<aside className="w-64 bg-muted border-r flex flex-col">
<div className="p-6">
<h1 className="text-xl font-bold">AutoScheduler</h1>
<p className="text-sm text-muted-foreground">GTD System</p>
</div>
<nav className="flex-1 px-4">
<ul className="space-y-2">
{navItems.map(({ to, icon: Icon, label }) => (
<li key={to}>
<NavLink
to={to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="p-4 border-t">
<Button variant="ghost" className="w-full justify-start" onClick={handleLogout}>
<LogOut className="h-5 w-5 mr-3" />
Logout
</Button>
</div>
</aside>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const buttonVariants = cva(
'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',
{
variants: {
variant: {
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',
},
size: {
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',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,90 @@
import * as React from 'react';
import * as ToastPrimitive from '@radix-ui/react-toast';
import { X } from 'lucide-react';
import { cn } from '../../lib/utils';
const ToastProvider = ToastPrimitive.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitive.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 = ToastPrimitive.Viewport.displayName;
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Root> & {
variant?: 'default' | 'destructive';
}
>(({ className, variant = 'default', ...props }, ref) => {
return (
<ToastPrimitive.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',
variant === 'default' && 'border bg-background text-foreground',
variant === 'destructive' &&
'destructive group border-destructive bg-destructive text-destructive-foreground',
className,
)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitive.Root.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitive.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100',
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitive.Close>
));
ToastClose.displayName = ToastPrimitive.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitive.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
));
ToastTitle.displayName = ToastPrimitive.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitive.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitive.Description.displayName;
export function Toaster() {
return (
<ToastProvider>
<ToastViewport />
</ToastProvider>
);
}
export { Toast, ToastClose, ToastTitle, ToastDescription };

View File

@@ -0,0 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,99 @@
import axios from 'axios';
import { useAuthStore } from '../store/auth';
const api = axios.create({
baseURL: '/api/v1',
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout();
window.location.href = '/login';
}
return Promise.reject(error);
},
);
export { api };
// Auth API
export const authApi = {
register: (data: { email: string; password: string; name: string; timezone: string }) =>
api.post('/auth/register', data),
login: (data: { email: string; password: string }) => api.post('/auth/login', data),
logout: () => api.post('/auth/logout'),
};
// Inbox API
export const inboxApi = {
getAll: () => api.get('/inbox'),
create: (content: string) => api.post('/inbox', { content }),
process: (
id: string,
data: {
action: string;
title?: string;
context?: string;
domain?: string;
priority?: number;
},
) => api.post(`/inbox/${id}/process`, data),
delete: (id: string) => api.delete(`/inbox/${id}`),
};
// Tasks API
export const tasksApi = {
getAll: (params?: { status?: string; context?: string; domain?: string }) =>
api.get('/tasks', { params }),
getOne: (id: string) => api.get(`/tasks/${id}`),
create: (data: {
title: string;
domain: string;
context?: string;
priority?: number;
estimatedDuration?: number;
dueDate?: string;
projectId?: string;
notes?: string;
status?: string;
}) => api.post('/tasks', data),
update: (id: string, data: Record<string, unknown>) => api.patch(`/tasks/${id}`, data),
delete: (id: string) => api.delete(`/tasks/${id}`),
lock: (id: string) => api.post(`/tasks/${id}/lock`),
unlock: (id: string) => api.post(`/tasks/${id}/unlock`),
};
// Projects API
export const projectsApi = {
getAll: (params?: { status?: string; domain?: string }) => api.get('/projects', { params }),
getOne: (id: string) => api.get(`/projects/${id}`),
getTasks: (id: string) => api.get(`/projects/${id}/tasks`),
create: (data: {
name: string;
domain: string;
description?: string;
desiredOutcome?: string;
}) => api.post('/projects', data),
update: (id: string, data: Record<string, unknown>) => api.patch(`/projects/${id}`, data),
delete: (id: string) => api.delete(`/projects/${id}`),
};
// User API
export const userApi = {
getMe: () => api.get('/users/me'),
getPreferences: () => api.get('/users/preferences'),
updatePreferences: (data: Record<string, unknown>) => api.patch('/users/preferences', data),
};

View File

@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { cn } from './utils';
describe('cn utility', () => {
it('should merge class names', () => {
expect(cn('foo', 'bar')).toBe('foo bar');
});
it('should handle conditional classes', () => {
expect(cn('foo', false && 'bar', 'baz')).toBe('foo baz');
});
it('should merge tailwind classes correctly', () => {
expect(cn('px-2 py-1', 'px-4')).toBe('py-1 px-4');
});
});

View File

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

View File

@@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,18 @@
export function CalendarPage() {
return (
<div className="p-8">
<div className="max-w-5xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Calendar</h1>
<p className="text-muted-foreground">
View and manage your scheduled tasks.
</p>
</div>
<div className="bg-muted rounded-lg p-12 text-center text-muted-foreground">
Calendar view will be implemented in Phase 2 with FullCalendar integration.
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, ArrowRight, Trash2 } from 'lucide-react';
import { inboxApi } from '../lib/api';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { Card, CardContent } from '../components/ui/Card';
import { ProcessModal } from '../components/inbox/ProcessModal';
import type { InboxItemResponseDto } from '@nick-tracker/shared-types';
export function InboxPage() {
const queryClient = useQueryClient();
const [newItem, setNewItem] = useState('');
const [selectedItem, setSelectedItem] = useState<InboxItemResponseDto | null>(null);
const { data: items = [], isLoading } = useQuery({
queryKey: ['inbox'],
queryFn: async () => {
const response = await inboxApi.getAll();
return response.data as InboxItemResponseDto[];
},
});
const createMutation = useMutation({
mutationFn: (content: string) => inboxApi.create(content),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inbox'] });
setNewItem('');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => inboxApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inbox'] });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (newItem.trim()) {
createMutation.mutate(newItem.trim());
}
};
const handleProcess = (item: InboxItemResponseDto) => {
setSelectedItem(item);
};
const handleCloseModal = () => {
setSelectedItem(null);
queryClient.invalidateQueries({ queryKey: ['inbox'] });
};
return (
<div className="p-8">
<div className="max-w-3xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Inbox</h1>
<p className="text-muted-foreground">
Capture everything on your mind. Process items to clarify what they mean.
</p>
</div>
<form onSubmit={handleSubmit} className="mb-8 flex gap-2">
<Input
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
placeholder="What's on your mind?"
className="flex-1"
/>
<Button type="submit" disabled={createMutation.isPending || !newItem.trim()}>
<Plus className="h-4 w-4 mr-2" />
Add
</Button>
</form>
{isLoading ? (
<div className="text-center text-muted-foreground py-8">Loading...</div>
) : items.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
Your inbox is empty. Add items above to capture thoughts and tasks.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{items.map((item) => (
<Card key={item.id}>
<CardContent className="py-4 flex items-center justify-between">
<div className="flex-1">
<p className="font-medium">{item.content}</p>
<p className="text-sm text-muted-foreground">
{new Date(item.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleProcess(item)}
>
<ArrowRight className="h-4 w-4 mr-2" />
Process
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate(item.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
{selectedItem && (
<ProcessModal item={selectedItem} onClose={handleCloseModal} />
)}
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import { authApi } from '../lib/api';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/Card';
export function LoginPage() {
const navigate = useNavigate();
const setAuth = useAuthStore((state) => state.setAuth);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await authApi.login({ email, password });
setAuth(response.data.token, response.data.user);
navigate('/inbox');
} catch (err) {
setError('Invalid email or password');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-muted">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Sign In</CardTitle>
<CardDescription>Enter your credentials to access your account</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
<p className="text-sm text-muted-foreground">
Don't have an account?{' '}
<Link to="/register" className="text-primary hover:underline">
Register
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { useQuery } from '@tanstack/react-query';
import { FolderKanban } from 'lucide-react';
import { projectsApi } from '../lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card';
import type { ProjectResponseDto } from '@nick-tracker/shared-types';
export function ProjectsPage() {
const { data: projects = [], isLoading } = useQuery({
queryKey: ['projects'],
queryFn: async () => {
const response = await projectsApi.getAll();
return response.data as ProjectResponseDto[];
},
});
return (
<div className="p-8">
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Projects</h1>
<p className="text-muted-foreground">
Multi-step outcomes requiring more than one action.
</p>
</div>
{isLoading ? (
<div className="text-center text-muted-foreground py-8">Loading...</div>
) : projects.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<FolderKanban className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
No projects yet. Process inbox items to create projects.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{projects.map((project) => (
<Card key={project.id}>
<CardHeader>
<CardTitle className="text-lg">{project.name}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="px-2 py-0.5 bg-muted rounded">{project.domain}</span>
<span className="px-2 py-0.5 bg-muted rounded">{project.status}</span>
</div>
{project.description && (
<p className="mt-2 text-sm">{project.description}</p>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import { authApi } from '../lib/api';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/Card';
export function RegisterPage() {
const navigate = useNavigate();
const setAuth = useAuthStore((state) => state.setAuth);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const response = await authApi.register({ name, email, password, timezone });
setAuth(response.data.token, response.data.user);
navigate('/inbox');
} catch (err) {
setError('Registration failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-muted">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Create Account</CardTitle>
<CardDescription>Start organizing your tasks with GTD</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Name
</label>
<Input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
required
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Create a password"
required
minLength={8}
/>
<p className="text-xs text-muted-foreground">
Password must be at least 8 characters with uppercase, lowercase, and number.
</p>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Creating account...' : 'Create Account'}
</Button>
<p className="text-sm text-muted-foreground">
Already have an account?{' '}
<Link to="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { useAuthStore } from '../store/auth';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../components/ui/Card';
export function SettingsPage() {
const user = useAuthStore((state) => state.user);
return (
<div className="p-8">
<div className="max-w-2xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Settings</h1>
<p className="text-muted-foreground">
Manage your account and preferences.
</p>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Your account information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Name</label>
<p className="mt-1">{user?.name}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Email</label>
<p className="mt-1">{user?.email}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Timezone</label>
<p className="mt-1">{user?.timezone}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Integrations</CardTitle>
<CardDescription>Connect your calendars and email</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Calendar and email integrations will be available in Phase 3.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { UserResponseDto } from '@nick-tracker/shared-types';
interface AuthState {
token: string | null;
user: UserResponseDto | null;
setAuth: (token: string, user: UserResponseDto) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
setAuth: (token, user) => set({ token, user }),
logout: () => set({ token: null, user: null }),
}),
{
name: 'auth-storage',
},
),
);

View File

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

View File

@@ -0,0 +1,41 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
{
"name": "@nick-tracker/shared-types",
"version": "1.0.0",
"description": "Shared TypeScript types for nick-tracker",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup src/index.ts --dts --format cjs,esm",
"dev": "tsup src/index.ts --dts --format cjs,esm --watch",
"clean": "rm -rf dist",
"lint": "echo 'No lint configured'",
"test": "echo 'No tests'",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"tsup": "^8.0.1",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,243 @@
// Enums
export enum TaskStatus {
NEXT_ACTION = 'NEXT_ACTION',
WAITING_FOR = 'WAITING_FOR',
SOMEDAY_MAYBE = 'SOMEDAY_MAYBE',
TICKLER = 'TICKLER',
COMPLETED = 'COMPLETED',
}
export enum TaskContext {
DESK = 'DESK',
PHONE = 'PHONE',
ERRAND = 'ERRAND',
HOMELAB = 'HOMELAB',
ANYWHERE = 'ANYWHERE',
}
export enum TaskDomain {
WORK = 'WORK',
HOMELAB = 'HOMELAB',
DAILY_ROUTINES = 'DAILY_ROUTINES',
HOUSE = 'HOUSE',
PROFESSIONAL_DEVELOPMENT = 'PROFESSIONAL_DEVELOPMENT',
}
export enum InboxSource {
MANUAL = 'MANUAL',
EMAIL = 'EMAIL',
CONNECTWISE = 'CONNECTWISE',
TICKLER = 'TICKLER',
}
export enum ProcessAction {
TASK = 'TASK',
PROJECT = 'PROJECT',
WAITING_FOR = 'WAITING_FOR',
SOMEDAY_MAYBE = 'SOMEDAY_MAYBE',
TICKLER = 'TICKLER',
REFERENCE = 'REFERENCE',
TRASH = 'TRASH',
}
// DTOs - Auth
export interface RegisterDto {
email: string;
password: string;
name: string;
timezone: string;
}
export interface LoginDto {
email: string;
password: string;
}
export interface AuthResponseDto {
token: string;
user: UserResponseDto;
}
export interface RefreshTokenDto {
refreshToken: string;
}
// DTOs - User
export interface UserResponseDto {
id: string;
email: string;
name: string;
timezone: string;
createdAt: string;
updatedAt: string;
}
export interface UpdateUserPreferencesDto {
workingHours?: WorkingHours;
timezone?: string;
notificationPreferences?: NotificationPreferences;
weeklyReviewDay?: number;
weeklyReviewTime?: string;
}
export interface WorkingHours {
monday?: DayHours;
tuesday?: DayHours;
wednesday?: DayHours;
thursday?: DayHours;
friday?: DayHours;
saturday?: DayHours;
sunday?: DayHours;
}
export interface DayHours {
start: string;
end: string;
}
export interface NotificationPreferences {
email: boolean;
webhook: boolean;
webhookUrl?: string;
}
// DTOs - Inbox
export interface CreateInboxItemDto {
content: string;
sourceMetadata?: Record<string, unknown>;
}
export interface InboxItemResponseDto {
id: string;
content: string;
source: InboxSource;
sourceMetadata?: Record<string, unknown>;
processed: boolean;
createdAt: string;
updatedAt: string;
}
export interface ProcessInboxItemDto {
action: ProcessAction;
title?: string;
context?: TaskContext;
domain?: TaskDomain;
priority?: number;
estimatedDuration?: number;
dueDate?: string;
projectId?: string;
followUpDate?: string;
ticklerDate?: string;
notes?: string;
}
// DTOs - Task
export interface CreateTaskDto {
title: string;
domain: TaskDomain;
context?: TaskContext;
priority?: number;
estimatedDuration?: number;
dueDate?: string;
projectId?: string;
notes?: string;
status?: TaskStatus;
followUpDate?: string;
ticklerDate?: string;
}
export interface UpdateTaskDto {
title?: string;
domain?: TaskDomain;
context?: TaskContext;
priority?: number;
estimatedDuration?: number;
dueDate?: string;
projectId?: string;
notes?: string;
status?: TaskStatus;
followUpDate?: string;
ticklerDate?: string;
scheduledStart?: string;
scheduledEnd?: string;
isLocked?: boolean;
}
export interface TaskResponseDto {
id: string;
title: string;
domain: TaskDomain;
context?: TaskContext;
status: TaskStatus;
priority?: number;
estimatedDuration?: number;
dueDate?: string;
scheduledStart?: string;
scheduledEnd?: string;
isLocked: boolean;
projectId?: string;
notes?: string;
followUpDate?: string;
ticklerDate?: string;
connectwisePriority?: string;
connectwiseSLA?: string;
createdAt: string;
updatedAt: string;
}
export interface TaskFilterDto {
status?: TaskStatus;
context?: TaskContext;
domain?: TaskDomain;
projectId?: string;
}
// DTOs - Project
export interface CreateProjectDto {
name: string;
domain: TaskDomain;
description?: string;
desiredOutcome?: string;
connectwiseProjectId?: string;
}
export interface UpdateProjectDto {
name?: string;
domain?: TaskDomain;
description?: string;
desiredOutcome?: string;
status?: ProjectStatus;
}
export enum ProjectStatus {
ACTIVE = 'ACTIVE',
ON_HOLD = 'ON_HOLD',
COMPLETED = 'COMPLETED',
}
export interface ProjectResponseDto {
id: string;
name: string;
domain: TaskDomain;
description?: string;
desiredOutcome?: string;
status: ProjectStatus;
connectwiseProjectId?: string;
createdAt: string;
updatedAt: string;
}
// Health check
export interface HealthCheckResponseDto {
status: 'ok' | 'error';
database: 'connected' | 'disconnected';
redis: 'connected' | 'disconnected';
}
// Error response
export interface ErrorResponseDto {
statusCode: number;
message: string;
timestamp: string;
path?: string;
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -8,7 +8,7 @@
"name": "GTD Inbox Capture", "name": "GTD Inbox Capture",
"description": "Multi-source task capture system that ingests tasks from manual web form, REST API, email (IMAP/Microsoft Graph), and ConnectWise Manage sync into an unprocessed inbox for later GTD clarification", "description": "Multi-source task capture system that ingests tasks from manual web form, REST API, email (IMAP/Microsoft Graph), and ConnectWise Manage sync into an unprocessed inbox for later GTD clarification",
"priority": 1, "priority": 1,
"passes": false, "passes": true,
"acceptance": "Manual tasks can be submitted via web form quick-add and appear in inbox" "acceptance": "Manual tasks can be submitted via web form quick-add and appear in inbox"
}, },
{ {
@@ -17,7 +17,7 @@
"name": "GTD Processing Workflow", "name": "GTD Processing Workflow",
"description": "Interactive inbox processing interface that guides users through GTD clarification: converting raw inbox items into Next Actions with context tags, Projects, Waiting For items, Someday/Maybe, Reference Material, Tickler items, or Trash", "description": "Interactive inbox processing interface that guides users through GTD clarification: converting raw inbox items into Next Actions with context tags, Projects, Waiting For items, Someday/Maybe, Reference Material, Tickler items, or Trash",
"priority": 2, "priority": 2,
"passes": false, "passes": true,
"acceptance": "Inbox view displays unprocessed items with processing workflow controls" "acceptance": "Inbox view displays unprocessed items with processing workflow controls"
}, },
{ {

View File

@@ -5,3 +5,17 @@
--- ---
[2026-01-11T04:59:40.694Z] [0] [INIT] - Project scaffold created [2026-01-11T04:59:40.694Z] [0] [INIT] - Project scaffold created
[2026-01-11T08:42:00.000Z] [1] [COMPLETE] - Phase 1 Foundation implemented
- Monorepo initialized with npm workspaces (packages/backend, packages/frontend, packages/shared-types)
- Backend NestJS app scaffolded with modules: AuthModule, UsersModule, TasksModule, ProjectsModule, InboxModule, HealthModule
- Frontend React app created with Vite, TailwindCSS, Radix UI configured
- PostgreSQL database schema created via TypeORM entities: User, InboxItem, Task, Project
- Docker Compose stack configured: backend, postgres, redis, frontend services
- JWT-based authentication implemented with Passport.js, bcrypt password hashing
- Frontend authentication flow: login/register forms, protected routes, Zustand auth store
- Global exception filter for standardized error responses
- Health endpoint with database and Redis connection status
- Inbox capture endpoints: POST /api/v1/inbox, GET /api/v1/inbox, POST /api/v1/inbox/:id/process
- Inbox UI with quick-add form and GTD processing workflow modal
- All tests passing, build succeeds, lint passes (one warning)