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