diff --git a/.agent/rules/agents.md b/.agent/rules/agents.md deleted file mode 100644 index a8a3a5f1..00000000 --- a/.agent/rules/agents.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -trigger: always_on ---- - -# Project Context - -## Product Vision - -Full details at [AGENTS.md](./AGENTS.md). Please read it before proceeding with any tasks. - -We're simplifying the calendar events and tasks creation process. - -### Tech Stack - -- **Framework:** Next.js 15 (App Router) -- **Database:** PostgreSQL + Prisma ORM -- **Authentication:** Auth.js with Prisma Adapter -- **UI:** React + Tailwind CSS v4 + shadcn/ui -- **AI:** Vercel AI SDK (with OpenAI, Cerebras, OpenRouter) -- **Date/Time:** date-fns + date-fns-tz (timezone-aware operations) -- **Email:** React Email + Resend -- **Package Manager:** pnpm -- **Error Tracking:** Sentry -- **Validation:** Zod v4 -- **Rate Limiting:** Upstash Redis - ---- - -## Development Environment - -### Initial Setup - -```sh -# Install dependencies -pnpm install - -# Setup environment -cp .env.example .env.local -# Edit .env.local with your keys - -# Setup database -pnpm db:push - -# Start dev server -pnpm dev -``` - -### Essential Commands (Quick Reference) - -```bash -pnpm install # Install dependencies -pnpm dev # Start dev server (localhost:3000) -pnpm build # Type-check + build (no separate type-check) -pnpm db:push # Push schema changes (dev only - never on prod) -pnpm db:studio # Open Prisma Studio (localhost:5555) -pnpm email # Email preview (localhost:3333) -pnpm lint # Run ESLint -ncu -i --format group # Update dependencies interactively -``` - -**Critical Warnings:** - -- **Never run migrations without explicit approval** - Use `pnpm db:push` for development only -- `pnpm build` includes type-checking (no separate `pnpm type-check` needed) - -### File Structure - -```md -app/ # Next.js App Router pages -├── (auth)/ # Authentication pages -├── (marketing)/ # Public marketing pages -├── (protected)/ # Authenticated pages -└── api/ # API routes -actions/ # Server Actions -components/ # React components -├── ui/ # shadcn/ui base components -├── dashboard/ # Dashboard-specific components -└── forms/ # Form components -lib/ # Business logic & utilities -├── validations/ # Zod schemas -└── ai/ # AI/LLM utilities -prisma/ # Database schema & migrations -docs/agents/ # Agent documentation -``` - -### Key File Locations (Quick Reference) - -- **Authentication:** `auth.ts`, `auth.config.ts`, `middleware.ts` -- **Database:** `lib/db.ts` (single Prisma instance) -- **Environment:** `env.mjs` (type-safe config with `@t3-oss/env-nextjs`) -- **AI Routing:** `lib/ai/factory.ts` (auto-routes text→Cerebras, attachments→OpenAI) -- **Parsing Logic:** `lib/parsing.ts`, `lib/parsing-prompts.ts` -- **Timezone Utils:** `lib/timezone-utils.ts` (date-fns-tz patterns, validation) -- **Rate Limiting:** `lib/rate-limiter.ts` (Upstash Redis - optional) -- **HTTP Helpers:** `lib/http.ts` (`apiSuccess`/`apiError` response helpers) -- **Validations:** `lib/validations/*.ts` (all Zod schemas) -- **Schema:** `prisma/schema.prisma` (database models) - ---- - -## Architecture Patterns - -### 5-Layer Architecture - -**MUST** read [docs/agents/architecture-patterns.md](./docs/agents/architecture-patterns.md) before implementing features. - -**Implementation Order:** - -1. **Schema Layer** (`prisma/schema.prisma`) - Define data models -2. **Business Logic Layer** (`lib/`) - Implement data access -3. **Validation Layer** (`lib/validations/`) - Define Zod schemas -4. **Server Actions Layer** (`actions/`) - Create mutations -5. **Presentation Layer** (`app/`, `components/`) - Build UI - ---- - -## Quick Reference - -**Start working:** - -```sh -pnpm dev -``` - -**Check types:** - -```sh -pnpm build # includes type-check -``` - -**Database changes:** - -```sh -pnpm db:push # Development -pnpm db:studio # View data -``` - -### Emergency Debugging - -**Type errors:** - -```bash -# Regenerate Prisma types -pnpm prisma generate - -# Then restart TypeScript server in VS Code -# Command Palette → TypeScript: Restart TS Server -``` - -**Build fails:** - -```bash -# Clear Next.js cache -rm -rf .next - -# Full reinstall -rm -rf node_modules && pnpm install -``` - -**Database issues:** - -```bash -# Reset database (DEV ONLY - DESTRUCTIVE) -pnpm db:push --force-reset - -# Check migration status -pnpm prisma migrate status - -# View data -pnpm db:studio -``` \ No newline at end of file diff --git a/.agent/rules/agents.md b/.agent/rules/agents.md new file mode 120000 index 00000000..6100270f --- /dev/null +++ b/.agent/rules/agents.md @@ -0,0 +1 @@ +../../CLAUDE.md \ No newline at end of file diff --git a/.agent/workflows/check-layer-violations.md b/.agent/workflows/check-layer-violations.md index 11d2951f..b1fd1105 100644 --- a/.agent/workflows/check-layer-violations.md +++ b/.agent/workflows/check-layer-violations.md @@ -45,7 +45,7 @@ Repeat until all changed files have been reviewed. | Layer | Location | Allowed | NOT Allowed | |-------|----------|---------|-------------| | **Presentation** | `app/`, `components/` | UI rendering, user interactions, `page.tsx` (Server Comp), calling server actions/API | Direct Prisma calls, business logic, `page.tsx` as Client Comp (avoid if possible) | -| **API** | `app/api/`, `actions/` | Auth checks, input validation, calls to `lib/`, **Simple** Prisma operations (single table) | Transactions, aggregations, raw SQL, complex conditionally branching logic | +| **API** | `app/api/`, `actions/` | Auth checks, input validation, calls to `lib/`, **Simple** Prisma operations (single table); new routes prefer package services | Transactions, aggregations, raw SQL, complex conditionally branching logic | | **Business Logic** | `lib/` (except `lib/hooks/`) | Data access, business rules, Prisma operations, universal utils (formatting) | JSX, React Components, React Hooks (`useState`, `useEffect`) | | **Client Hooks** | `lib/hooks/` | React hooks for client-side state | Direct Prisma calls, server-only code | | **Validation** | `lib/validations/` | Zod schemas, type exports | Database calls, async refinements with side effects | @@ -53,6 +53,29 @@ Repeat until all changed files have been reviewed. --- +## Additive Monorepo Note (New Features in packages/) + +This repo is transitioning to a monorepo without moving the legacy root app. + +- Legacy code remains in `app/`, `components/`, `actions/`, `lib/`, `prisma/`. +- New features should prefer `packages//`. + +### Package boundary rules (enforced) + +- Code in `packages/**` must not import from legacy root folders (including the `@/` alias). +- Feature packages must provide explicit entrypoints: + - `@simplifying/` (safe default) + - `@simplifying//client` (React components/hooks) + - `@simplifying//server` (repositories + server-only services) + +### Quick checks for package changes + +- Files under `packages/**` must not import `@/` (the legacy root alias). +- Avoid imports of `@simplifying/*/server` from `packages/*/{components,hooks}/**` — client code cannot depend on server entrypoints. +- If a file under `packages/*/{components,hooks}/**` imports `repositories/**` or `types.server.ts`, it's a violation. + +--- + ## Violation Patterns by Layer ### ❌ Presentation Layer Violations (`app/**/*.tsx`, `components/**/*.tsx`) diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..3a5b4c1a --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,376 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +language: "en-US" +early_access: false +enable_free_tier: true + +reviews: + profile: "chill" + request_changes_workflow: true + high_level_summary: true + high_level_summary_placeholder: "@coderabbitai summary" + poem: false + review_status: true + commit_status: true + fail_commit_status: false + collapse_walkthrough: false + changed_files_summary: true + sequence_diagrams: true + estimate_code_review_effort: true + assess_linked_issues: true + related_issues: true + related_prs: true + suggested_labels: true + auto_apply_labels: false + suggested_reviewers: true + auto_assign_reviewers: false + abort_on_close: true + + auto_review: + enabled: true + auto_incremental_review: true + drafts: false + ignore_title_keywords: + - "WIP" + - "DO NOT MERGE" + - "[skip ci]" + - "[no review]" + base_branches: + - main + - staging + + path_filters: + - "!**/*.min.js" + - "!**/pnpm-lock.yaml" + - "!**/.next/**" + - "!**/node_modules/**" + - "!**/coverage/**" + - "!**/*.tsbuildinfo" + - "!**/*.test.ts" + - "!**/*.test.tsx" + - "!**/__tests__/**" + + path_instructions: + - path: "packages/*/package.json" + instructions: | + Package manifest for monorepo package. Verify: + - peerDependencies versions align with root package.json + - peerDependencies are mirrored in devDependencies (enables local development) + - Version ranges match sibling packages for shared dependencies + - exports field maps entrypoints: ".", "./client", "./server" + - main points to dist/index.js, types to dist/index.d.ts + - React ecosystem packages (react, @tanstack/react-query) are peers, not dependencies + + - path: "prisma/schema.prisma" + instructions: | + Review for proper indexing, relationship definitions, cascade rules, and naming conventions. + Ensure all tables have @@map with snake_case names. + Check that new fields have appropriate default values. + + - path: "lib/**/*.ts" + instructions: | + This is the Business Logic Layer. Review for: + - Proper separation of concerns + - Type safety with explicit return types + - Centralized error handling + - No direct Prisma imports in client-safe files + - Validation using Zod schemas from lib/validations/ + + - path: "lib/validations/**/*.ts" + instructions: | + These are Zod validation schemas. Ensure: + - Schemas are properly typed and exported + - Validation rules are consistent with Prisma schema + - Proper use of z.infer for type exports + + - path: "actions/**/*.ts" + instructions: | + These are Server Actions. Verify: + - "use server" directive at the top + - Authentication check using auth() before any mutation + - Zod validation for all inputs + - Proper revalidatePath calls after mutations + - Error handling with try/catch + + - path: "app/api/**/route.ts" + instructions: | + These are API Route handlers and MUST follow the API Contract Layer conventions. + + Response envelope: + - Success responses use apiSuccess({ data, meta? }, { status?, request }) + - Import apiSuccess/apiError from @simplifying/api-server/server + - status defaults to 200 and may be omitted; when provided, put before request + - Import prisma from @simplifying/prisma/server (not @/lib/db) + - request parameter is passed so the response includes X-Request-Id + - Top-level success body keys are limited to data and optional meta only + - DTOs returned in data are JSON-compatible (no Date, BigInt, Map, class instances) + + Meta: + - meta uses registry-backed types from @simplifying/errors with a satisfies annotation + - No ad-hoc meta keys without an exported type definition in @simplifying/errors + - LimitMeta shape is { limits: { maxAllowed } } + - WarningsMeta shape is { warnings: [{ code, message }] } + - OffsetPaginationMeta shape is { pagination: { total, skip, take, hasMore } } + - CursorPaginationMeta shape is { pagination: { nextCursor, hasMore } } + + Serialization: + - Route converts Prisma entities via serializer functions (no raw Prisma objects in responses) + - Serializer uses explicit field mapping (no object spread from Prisma) + - Timestamp fields serialize to ISO 8601 UTC instants via toISOString() from @simplifying/serialization + - No local toISOString/toDate helpers defined in serializers + - serialize* functions are exported ONLY from a package's server.ts entrypoint + - deserialize* functions are exported ONLY from a package's index.ts entrypoint + - Deserializers return *Model types (not @prisma/client types) + + Error handling (RFC 9457): + - Route uses the withApiHandler HOF wrapper + - Expected errors throw AppError(ErrorCodes.*, message | init) + - No direct RFC 9457 response construction in routes (withApiHandler handles) + - Validation errors use the flat Zod tree: data: z.treeifyError(parsed.error) + (do not wrap as data: { errors: z.treeifyError(...) } to avoid nested errors.errors) + - Error codes come from ErrorCodes (no string literals) + + Package boundaries (when routes call into packages/*): + - server.ts has import "server-only" as the FIRST import + - types.ts is client-safe (no @prisma/client runtime imports) + - types.server.ts exists with Db* Prisma type aliases + - Enums are local string unions (not Prisma re-exports) + - Payload types derived from Zod via z.infer + - No @/ alias inside packages + - No imports from legacy root (app/, lib/, components/, actions/) within packages/* + + Date/Time: + - Persisted instants are UTC; DTO timestamps are ISO 8601 UTC instants + - No new Date() in serializers; use toDate() helper + - Wall-time parsing is explicit about timeZone (IANA TZ) + + Pagination query params: + - Offset pagination uses skip (>=0) and take (1..MAX_TAKE) + - Cursor pagination uses cursor (opaque token) and take (1..MAX_TAKE) + - Filter params are namespaced under filter. + - Sorting uses sort=field:asc|desc + + - path: "components/**/*.tsx" + instructions: | + Review React components for: + - Proper use of "use client" directive when needed + - No business logic (should be in lib/) + - Accessibility (aria labels, semantic HTML) + - Tailwind CSS class ordering + + - path: "app/(protected)/**/*.tsx" + instructions: | + Protected routes require authentication. Ensure: + - Session checks are present + - Proper handling of unauthenticated states + - No sensitive data leaks in error messages + + - path: "middleware.ts" + instructions: | + Critical security file. Review for: + - Proper route protection patterns + - Correct auth redirects + - Rate limiting considerations + + # ─── Package Architecture Instructions ─────────────────────────────────────── + + - path: "packages/*/src/index.ts" + instructions: | + Package SSR-safe entrypoint. Verify: + - NO "use client" directive (must be SSR-safe) + - Exports: lib utilities, types, API functions, query keys, queryOptions factories, deserializers + - MUST NOT export: TanStack hooks, serializers, repositories, services + - MUST NOT import from client.ts or server.ts + + - path: "packages/*/src/client.ts" + instructions: | + Package client-only entrypoint. Verify: + - MUST have "use client" directive at top + - Exports: TanStack hooks, React components, local React hooks + - MUST NOT re-export from index.ts (keeps boundaries clear) + - MUST NOT import from server.ts or repositories/ + + - path: "packages/*/src/server.ts" + instructions: | + Package server-only entrypoint. Verify: + - MUST have `import "server-only"` as FIRST import + - Exports: repositories, server services, serializers (Prisma → DTO), types.server.ts types + - MUST NOT be imported in client.ts or components/ + + - path: "packages/*/src/types.ts" + instructions: | + Client-safe types for the package. Verify: + - NO @prisma/client runtime imports (type-only allowed) + - DTOs use ISO 8601 strings for dates (not Date objects) + - Enums are local string unions (NOT Prisma re-exports) + - Export *DTO types (wire format) and *Model types (deserialized) + - Exported from index.ts entrypoint + + - path: "packages/*/src/types.server.ts" + instructions: | + Server-only types for the package. Verify: + - Defines Db* type aliases from Prisma models + - Can reference @prisma/client types + - Exported from server.ts entrypoint ONLY + - MUST NOT be imported in client code or types.ts + + - path: "packages/*/src/lib/*-serializer.ts" + instructions: | + Entity serializers (Prisma ↔ DTO ↔ Model). Verify: + - Use explicit field mapping (NO Prisma object spread) + - Import toISOString/toDate from @simplifying/serialization (no local helpers) + - serialize*() functions exported from server.ts ONLY + - deserialize*() functions exported from index.ts (client-safe) + - Deserializers return *Model types (not @prisma/client types) + - `import type` from @prisma/client allowed (no runtime imports) + + - path: "packages/*/src/repositories/*.ts" + instructions: | + Data access layer (Prisma queries). Verify: + - Contains ONLY Prisma queries (no business logic) + - Uses factory function pattern: createXRepository(prisma: PrismaClientForDI) + - Returns object with methods, type inferred via ReturnType<> + - Throws domain-specific errors (AppError with ErrorCodes) + - Exported from server.ts entrypoint ONLY + + - path: "packages/*/src/services/server-*.ts" + instructions: | + Server-side use-case orchestration. Verify: + - Uses factory function pattern: createXService(prisma) + - Creates repositories internally (DI) + - Contains business logic and validation + - NO direct Prisma imports (use repositories) + - Exported from server.ts entrypoint ONLY + + - path: "packages/*/src/query/options.ts" + instructions: | + SSR-safe queryOptions factories. CRITICAL: + - MUST NOT have "use client" directive (enables SSR prefetch) + - MUST NOT import from query/hooks.ts or any "use client" module + - Export queryOptions() factories for SSR prefetch + - Export mutation function references (not wrapped hooks) + - Import only from api/ and keys.ts + - Exported from index.ts (SSR-safe) + + - path: "packages/*/src/query/hooks.ts" + instructions: | + Client-only TanStack Query hooks. Verify: + - MUST have "use client" directive + - Wraps queryOptions from options.ts in useQuery() + - Implements useMutation() with cache invalidation + - Uses query keys from keys.ts for invalidation + - Exported from client.ts entrypoint ONLY + + - path: "packages/*/src/query/keys.ts" + instructions: | + Query key factory. Verify: + - Type-safe query key factory pattern + - Hierarchical structure (all → lists → list, all → details → detail) + - Export const object with `as const` assertion + - Exported from index.ts (SSR-safe) + + - path: "packages/*/src/api/*.api.ts" + instructions: | + HTTP transport layer (fetch wrappers). Verify: + - Pure fetch wrappers (no React, no TanStack imports) + - Use createFetchClient from @simplifying/api-client + - For paginated endpoints: use getEnvelope() + - Import pagination meta types from @simplifying/errors + - Exported from index.ts (SSR-safe) + + - path: "packages/*/src/components/*.tsx" + instructions: | + Package React components. Verify: + - Add "use client" when required (forms, event handlers, hooks) + - No business logic (should be in lib/ or services/) + - Import types from @simplifying/ (not @prisma/client) + - Accessibility (aria labels, semantic HTML) + - Exported from client.ts entrypoint + + - path: "packages/**/*.ts" + instructions: | + Monorepo package isolation rules. Verify: + - NO @/ alias imports (use relative or @simplifying/*) + - NO imports from legacy root (app/, lib/, components/, actions/, providers/) + - Cross-package imports use @simplifying/ format + - Date operations use date-fns with explicit timezone + + finishing_touches: + docstrings: + enabled: true + unit_tests: + enabled: true + + pre_merge_checks: + custom_checks: + - mode: warning + name: "Peer dependency consistency" + instructions: | + Pass/fail criteria for any changed package.json in packages/*: + + PASS if ALL are true: + 1. Each peerDependency version range matches root package.json + 2. Sibling packages declaring the same peer use identical ranges + 3. Each peerDependency is mirrored in devDependencies + 4. react, react-dom, @tanstack/react-query are peers (not dependencies) + + FAIL if ANY are true: + - peerDependency version differs from root package.json + - Two packages declare same peer with different ranges + - peerDependency missing from devDependencies + - React ecosystem package in dependencies instead of peerDependencies + + - mode: error + name: "API contract layer conventions" + instructions: | + This check applies to any new or modified Next.js API route handlers (app/api/**/route.ts) and any code they touch. + + Deterministic pass/fail criteria for changed route handlers: + - Success: apiSuccess({ data, meta? }, { status?, request }) is used and imported from @simplifying/api-server/server. + - status defaults to 200 and may be omitted; when provided, put before request. request is required (X-Request-Id must be present). + - Import prisma from @simplifying/prisma/server (not @/lib/db). + - Success envelope contains ONLY top-level keys data and optional meta. + - Returned DTOs are JSON-compatible; Prisma entities are serialized via explicit-field-mapping serializers. + - Timestamp serialization uses toISOString() from @simplifying/serialization (no inline or local helpers). + - Errors: routes are wrapped with withApiHandler and expected failures throw AppError(ErrorCodes.*). + - Validation errors use data: z.treeifyError(parsed.error) without extra wrapping. + - Pagination/query conventions: skip/take (offset) or cursor/take (cursor), filter.* params, sort=field:asc|desc. + + tools: + biome: + enabled: true + markdownlint: + enabled: true + github-checks: + enabled: true + timeout_ms: 90000 + actionlint: + enabled: true + gitleaks: + enabled: true + yamllint: + enabled: true + shellcheck: + enabled: true + hadolint: + enabled: false + ruff: + enabled: false + phpstan: + enabled: false + golangci-lint: + enabled: false + +chat: + auto_reply: true + +knowledge_base: + opt_out: false + web_search: + enabled: true + learnings: + scope: "auto" + issues: + scope: "auto" + pull_requests: + scope: "auto" diff --git a/.env.example b/.env.example deleted file mode 100644 index 0caa2f3d..00000000 --- a/.env.example +++ /dev/null @@ -1,127 +0,0 @@ -# ----------------------------------------------------------------------------- -# App - Don't add "/" in the end of the url (same in production) -# ----------------------------------------------------------------------------- -NEXT_PUBLIC_APP_URL=http://localhost:3000 - -# ----------------------------------------------------------------------------- -# Authentication (NextAuth.js) -# ----------------------------------------------------------------------------- -AUTH_SECRET= - -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= - -# ----------------------------------------------------------------------------- -# Database (MySQL - Neon DB) -# ----------------------------------------------------------------------------- -DATABASE_URL='postgres://[user]:[password]@[neon_hostname]/[dbname]?sslmode=require' - -# ----------------------------------------------------------------------------- -# Email (Resend) -# ----------------------------------------------------------------------------- -RESEND_API_KEY= -EMAIL_FROM="Simplifying App " - -# ----------------------------------------------------------------------------- -# Subscriptions (Stripe) -# ----------------------------------------------------------------------------- -STRIPE_API_KEY= -STRIPE_WEBHOOK_SECRET= - -NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PLAN_ID= -NEXT_PUBLIC_STRIPE_PRO_YEARLY_PLAN_ID= - -NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PLAN_ID= -NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PLAN_ID= - -# ----------------------------------------------------------------------------- -# Cloudflare R2 (for file uploads) -# ----------------------------------------------------------------------------- -R2_ACCOUNT_ID= -R2_ACCESS_KEY_ID= -R2_SECRET_ACCESS_KEY= -R2_BUCKET_NAME= -R2_ENDPOINT_URL= - -# ----------------------------------------------------------------------------- -# Sentry (error monitoring) -# ----------------------------------------------------------------------------- -# Values for source map uploads -SENTRY_ORG= -SENTRY_PROJECT= -# Server/Edge DSN - used for server-side and edge runtime error tracking -SENTRY_DSN= -# Client DSN - used for client-side error tracking (can be same as SENTRY_DSN) -NEXT_PUBLIC_SENTRY_DSN_CLIENT= -# Error sampling rate (0.0 to 1.0, default: 1.0 = 100% of errors) -SENTRY_SAMPLE_RATE= -# Performance trace sampling rate (0.0 to 1.0, default: 0.0 = no traces) -SENTRY_TRACES_SAMPLE_RATE=1.0 -# Enable Sentry debug logging ("true" or "1" to enable) -SENTRY_DEBUG="1" -# Enable Sentry client-side logging ("true" or "1" to enable) -SENTRY_ENABLE_LOGS="false" - -# ----------------------------------------------------------------------------- -# Parser Provider Configuration -# ----------------------------------------------------------------------------- -APP_PARSER_PROVIDER="openai" - -# ----------------------------------------------------------------------------- -# OpenAI (default provider) -# ----------------------------------------------------------------------------- -OPENAI_API_KEY= -OPENAI_API_BASE_URL="https://api.openai.com/v1" -OPENAI_PARSER_MODEL="gpt-4o-mini" - -# ----------------------------------------------------------------------------- -# Cerebras (optional) -# ----------------------------------------------------------------------------- -CEREBRAS_API_KEY= -CEREBRAS_API_BASE_URL="https://api.cerebras.ai/v1" -CEREBRAS_PARSER_MODEL="llama3.1-8b" - -# ----------------------------------------------------------------------------- -# OpenRouter (optional) -# ----------------------------------------------------------------------------- -OPENROUTER_API_KEY= -OPENROUTER_API_BASE_URL= -OPENROUTER_PARSER_MODEL="openai/gpt-4o" - -# ----------------------------------------------------------------------------- -# Model configuration -# ----------------------------------------------------------------------------- -APP_TRANSCRIPTION_MODEL="gpt-4o-mini-transcribe" -APP_TRANSCRIPTION_PROVIDER="openai" - -# ----------------------------------------------------------------------------- -# Groq (transcription provider - optional override) -# ----------------------------------------------------------------------------- -# Required only when APP_TRANSCRIPTION_PROVIDER="groq" -GROQ_API_KEY= -GROQ_API_BASE_URL="https://api.groq.com/openai/v1" - -# ----------------------------------------------------------------------------- -# Rate Limiting (Upstash Redis - optional) -# ----------------------------------------------------------------------------- -UPSTASH_REDIS_REST_URL= -UPSTASH_REDIS_REST_TOKEN= - -# ----------------------------------------------------------------------------- -# Microsoft Azure AD (for Outlook Calendar + Microsoft To Do) -# ----------------------------------------------------------------------------- -# Required for Microsoft provider integration -# Get these from Azure Portal > App registrations > Your app -AZURE_AD_CLIENT_ID= -AZURE_AD_CLIENT_SECRET= -# Use "common" for multi-tenant + personal accounts -AZURE_AD_TENANT_ID=common - -# ----------------------------------------------------------------------------- -# Calendar Sync Encryption -# ----------------------------------------------------------------------------- -# Preferred: base64-encoded 32-byte key (generate: openssl rand -base64 32) -ENCRYPTION_KEY= -# Legacy hex fallback (64 hex chars) -# Generate with: openssl rand -hex 32 -CALENDAR_CREDENTIAL_ENCRYPTION_KEY= diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index d03914fa..00000000 --- a/.eslintignore +++ /dev/null @@ -1,34 +0,0 @@ -# dependencies -node_modules/ - -# build outputs -.next/ -dist/ -build/ -out/ - -# generated files -*.tsbuildinfo -next-env.d.ts - -# coverage -coverage/ - -# logs -*.log - -# environment files -.env* - -# contentlayer -.contentlayer/ - -# IDE -.vscode/ - -# tools -.cursor/ -.claude/ -.codex/ -.specify/ -*.min.js diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index ffd6c446..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/eslintrc", - "root": true, - "extends": [ - "next/core-web-vitals", - "prettier", - "plugin:tailwindcss/recommended" - ], - "plugins": ["tailwindcss"], - "rules": { - "@next/next/no-html-link-for-pages": "off", - "react/jsx-key": "off", - "tailwindcss/no-custom-classname": "off", - "tailwindcss/classnames-order": "error" - }, - "settings": { - "tailwindcss": { - "callees": ["cn"], - "config": "tailwind.config.ts" - }, - "next": { - "rootDir": true - } - }, - "overrides": [ - { - "files": ["*.ts", "*.tsx"], - "parser": "@typescript-eslint/parser" - } - ] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1206784e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,147 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + biome: + name: Biome (lint + format) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Biome + uses: biomejs/setup-biome@v2 + with: + version: 2.3.11 + + - name: Run Biome + run: biome ci . + + lint-next: + name: Next.js Rules + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.18.3 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run Next.js ESLint + run: pnpm lint:next + + boundary-checks: + name: Architecture Boundaries + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.18.3 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check workspace packages SSOT + run: pnpm lint:workspace-packages + + - name: Check package boundary imports + run: pnpm lint:package-boundaries + + - name: Check route Prisma imports + run: pnpm lint:route-prisma + + typecheck-test: + name: Type Check & Tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: simplifying_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/simplifying_test + NODE_OPTIONS: "--max-old-space-size=4096" + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.18.3 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Cache Turbo + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ github.sha }} + restore-keys: | + turbo-${{ runner.os }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Push database schema + run: pnpm exec prisma db push --skip-generate + + - name: Fetch base branch for PR comparison + if: github.event_name == 'pull_request' + run: git fetch --no-tags origin ${{ github.base_ref }} --depth=1 + + - name: Type check + tests (affected-only on PRs) + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + pnpm exec turbo run type-check test:run --affected + else + pnpm exec turbo run type-check test:run + fi diff --git a/.github/workflows/prisma-check.yml b/.github/workflows/prisma-check.yml new file mode 100644 index 00000000..3d225cfe --- /dev/null +++ b/.github/workflows/prisma-check.yml @@ -0,0 +1,64 @@ +name: Prisma Migration Check + +on: + pull_request: + paths: + - "prisma/schema.prisma" + - "prisma/migrations/**" + push: + branches: [main] + paths: + - "prisma/schema.prisma" + - "prisma/migrations/**" + +permissions: + contents: read + +concurrency: + group: prisma-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/simplifying_test + +jobs: + check-migrations: + name: Validate Schema-Migration Consistency + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: simplifying_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check migrations match schema + run: | + pnpm exec prisma migrate diff --exit-code \ + --from-migrations ./prisma/migrations \ + --to-schema-datamodel ./prisma/schema.prisma \ + --shadow-database-url "$DATABASE_URL" diff --git a/.gitignore b/.gitignore index 93e87a0b..bba85ef9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,20 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules +node_modules /.pnp .pnp.js # testing -/coverage +coverage # next.js -/.next/ +.next/ /out/ +# turborepo +.turbo/ + # production /build dist/ @@ -38,7 +41,7 @@ yarn-error.log* next-env.d.ts # email -/.react-email/ +.react-email/ .vscode .contentlayer @@ -67,3 +70,5 @@ next-env.d.ts # windsurf .windsurf/ + +docs/plans \ No newline at end of file diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100755 index 00000000..4813f7c9 --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,46 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +# Copy .env to new worktrees automatically. +# post-checkout receives: +# branch-flag=1 means branch checkout (including worktree add). + +BRANCH_FLAG="${3:-0}" +if [ "$BRANCH_FLAG" != "1" ]; then + exit 0 +fi + +CURRENT_WORKTREE="$(git rev-parse --show-toplevel 2>/dev/null)" +if [ -z "$CURRENT_WORKTREE" ]; then + echo "Failed to resolve CURRENT_WORKTREE with git rev-parse --show-toplevel." >&2 + exit 1 +fi + +GIT_COMMON_DIR="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" +if [ -z "$GIT_COMMON_DIR" ]; then + GIT_COMMON_DIR="$(git rev-parse --git-common-dir 2>/dev/null)" + if [ -z "$GIT_COMMON_DIR" ]; then + echo "Failed to resolve MAIN_WORKTREE using git rev-parse --path-format=absolute --git-common-dir." >&2 + echo "Retry with git rev-parse --git-common-dir also failed. Upgrade Git to >= 2.31.0." >&2 + exit 1 + fi + case "$GIT_COMMON_DIR" in + /*) ;; + *) + GIT_COMMON_DIR="$(cd "$CURRENT_WORKTREE/$GIT_COMMON_DIR" 2>/dev/null && pwd)" + ;; + esac +fi + +if [ -z "$GIT_COMMON_DIR" ]; then + echo "Failed to normalize git rev-parse --git-common-dir output for MAIN_WORKTREE." >&2 + exit 1 +fi + +MAIN_WORKTREE="${GIT_COMMON_DIR%/.git}" + +# Only act when inside a worktree (not the main working tree) +if [ "$MAIN_WORKTREE" != "$CURRENT_WORKTREE" ] && [ -f "$MAIN_WORKTREE/.env" ] && [ ! -f "$CURRENT_WORKTREE/.env" ]; then + cp "$MAIN_WORKTREE/.env" "$CURRENT_WORKTREE/.env" + echo "Copied .env from main worktree to $CURRENT_WORKTREE" +fi diff --git a/.husky/pre-commit b/.husky/pre-commit index 0da96d6b..6d93b282 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,5 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -npx pretty-quick --staged +npx biome check --staged --write --no-errors-on-unmatched +pnpm --filter @simplifying/web lint diff --git a/.nvmrc b/.nvmrc index 50e4b92a..51105aad 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.18.0 +24.0.0 diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 320d9e35..00000000 --- a/.prettierignore +++ /dev/null @@ -1,9 +0,0 @@ -dist -node_modules -.next -build -.contentlayer -coverage -package-lock.json -yarn.lock -pnpm-lock.yaml \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index d57cf746..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,581 +0,0 @@ -# Project Context - -## Product Vision - -We're simplifying the calendar events and tasks creation process. - -### Tech Stack - -- **Framework:** Next.js 15 (App Router) -- **Database:** PostgreSQL + Prisma ORM -- **Authentication:** Auth.js with Prisma Adapter -- **UI:** React + Tailwind CSS v4 + shadcn/ui -- **AI:** Vercel AI SDK (with OpenAI, Cerebras, OpenRouter) -- **Date/Time:** date-fns + date-fns-tz (timezone-aware operations) -- **Email:** React Email + Resend -- **Package Manager:** pnpm -- **Error Tracking:** Sentry -- **Validation:** Zod v4 -- **Rate Limiting:** Upstash Redis - ---- - -## Core Principles - -### Knowledge & Context - -- **Your training data is outdated** - ALWAYS fetch latest documentation -- **Answer in English** regardless of query language -- **Read project docs** before suggesting solutions - -### Workflow Requirements - -- **MUST** break down complex problems into smaller ones and conquer them one-by-one. -- **MUST** use `Context7` tools for latest documentation -- **MUST NOT** run migrations without **EXPLICIT** approval -- **API-First Policy for New Features:** All new features must expose a RESTful API endpoint under `app/api/**/route.ts`, following the response contract defined in `docs/agents/response-contract.md`. -- **MUST** instrument API routes and server actions with Sentry (`attachUserContext`, `Sentry.withServerActionInstrumentation`, and spans via `Sentry.startSpan`). -- **MUST NOT** use inline comments to communicate with users. **SHOULD ONLY** leave comments necessary for code documentation. - ---- - -## Development Environment - -### Initial Setup - -```sh -# Install dependencies -pnpm install - -# Setup environment -cp .env.example .env.local -# Edit .env.local with your keys - -# Setup database -pnpm db:push - -# Start dev server -pnpm dev -``` - -### Essential Commands (Quick Reference) - -```bash -pnpm install # Install dependencies -pnpm dev # Start dev server (localhost:3000) -pnpm build # Type-check + build (no separate type-check) -pnpm db:push # Push schema changes (dev only - never on prod) -pnpm db:studio # Open Prisma Studio (localhost:5555) -pnpm email # Email preview (localhost:3333) -pnpm lint # Run ESLint -ncu -i --format group # Update dependencies interactively -``` - -**⚠️ Critical Warnings:** - -- **Never run migrations without explicit approval** - Use `pnpm db:push` for development only -- `pnpm build` includes type-checking (no separate `pnpm type-check` needed) - -### File Structure - -```md -app/ # Next.js App Router pages -├── (auth)/ # Authentication pages -├── (marketing)/ # Public marketing pages -├── (protected)/ # Authenticated pages -└── api/ # API routes -actions/ # Server Actions -components/ # React components -├── ui/ # shadcn/ui base components -├── dashboard/ # Dashboard-specific components -└── forms/ # Form components -lib/ # Business logic & utilities -├── validations/ # Zod schemas -└── ai/ # AI/LLM utilities -prisma/ # Database schema & migrations -docs/agents/ # Agent documentation -``` - -### Key File Locations (Quick Reference) - -- **Authentication:** `auth.ts`, `auth.config.ts`, `middleware.ts` -- **Database:** `lib/db.ts` (single Prisma instance) -- **Environment:** `env.mjs` (type-safe config with `@t3-oss/env-nextjs`) -- **AI Routing:** `lib/ai/factory.ts` (auto-routes text→Cerebras, attachments→OpenAI) -- **Parsing Logic:** `lib/parsing.ts`, `lib/parsing-prompts.ts` -- **Timezone Utils:** `lib/timezone-utils.ts` (date-fns-tz patterns, validation) -- **Rate Limiting:** `lib/rate-limiter.ts` (Upstash Redis - optional) -- **HTTP Helpers:** `lib/http.ts` (`apiSuccess`/`apiError` response helpers) -- **Validations:** `lib/validations/*.ts` (all Zod schemas) -- **Schema:** `prisma/schema.prisma` (database models) - ---- - -## Architecture Patterns - -### 5-Layer Architecture - -**MUST** read [docs/agents/architecture-patterns.md](./docs/agents/architecture-patterns.md) before implementing features. - -**Implementation Order:** - -1. **Schema Layer** (`prisma/schema.prisma`) - Define data models -2. **Business Logic Layer** (`lib/`) - Implement data access -3. **Validation Layer** (`lib/validations/`) - Define Zod schemas -4. **Server Actions Layer** (`actions/`) - Create mutations -5. **Presentation Layer** (`app/`, `components/`) - Build UI - -### Quick Patterns Reference - -**Authentication Pattern (Every API Route/Action):** - -```typescript -// In API routes -const session = await auth(); -if (!session?.user?.id) { - return apiError(ErrorCodes.UNAUTHORIZED, "Authentication required", null, 401); -} - -// In Server Actions -const session = await auth(); -if (!session?.user) { - throw new Error("Unauthorized"); -} -``` - -**API Response Pattern:** - -```typescript -// Success -import { apiSuccess } from "@/lib/http"; -return apiSuccess({ id: "123" }, 201); - -// Error -import { apiError, ErrorCodes } from "@/lib/http"; -return apiError(ErrorCodes.VALIDATION_FAILED, "Invalid input", details, 422); -``` - -**Validation Pattern:** - -```typescript -// With error handling for API routes -const parsed = mySchema.safeParse(data); -if (!parsed.success) { - return apiError( - ErrorCodes.VALIDATION_FAILED, - "Invalid input", - z.treeifyError(parsed.error), - 422 - ); -} - -// Direct parsing for Server Actions -const validatedData = schemaName.parse(data); -``` - -**Revalidation Pattern:** - -```typescript -revalidatePath("/dashboard/feature-name"); -``` - -### Data Flow - -```md -User Input → Component → API Route → Zod Validation → Business Logic → Prisma → DB - ↓ ↓ ↓ ↓ - Client State ← apiSuccess ← Service ← Type-safe Result -``` - ---- - -## Code Standards - -### TypeScript - -- **Strict mode enabled** - no implicit any -- **MUST NOT** use `any` type in the project -- Use explicit type annotations for function parameters -- Prefer interfaces for object shapes -- Use type inference where obvious - -### Date & Time Handling - -**Standard Libraries:** - -- **MUST** use `date-fns` for date manipulation -- **MUST** use `date-fns-tz` for timezone-aware operations -- **MUST** reuse patterns from `lib/timezone-utils.ts` - -**Core Principles:** - -- **MUST** make all datetime operations timezone-aware -- **MUST NOT** use server timezone for user-facing dates -- **MUST** store UTC timestamps in database -- **MUST** use user's timezone for display and parsing -- **MUST** validate timezones using `validateTimezone()` from `lib/timezone-utils.ts` - -**Common Patterns:** - -```typescript -// Import from standard utilities -import { formatInTimeZone, fromZonedTime } from "date-fns-tz"; -import { validateTimezone } from "@/lib/timezone-utils"; - -// Format UTC date in user's timezone -const displayDate = formatInTimeZone(utcDate, userTimeZone, "yyyy-MM-dd HH:mm"); - -// Convert user's local time to UTC for storage -const utcDate = fromZonedTime(localDateString, userTimeZone); - -// Validate timezone before use -if (!validateTimezone(userTimeZone)) { - // Fallback to UTC - userTimeZone = "UTC"; -} -``` - -**Anti-Patterns:** - -- **MUST NOT** use `new Date()` without timezone context -- **MUST NOT** use `.toLocaleString()` without explicit timezone -- **MUST NOT** use `Intl.DateTimeFormat` without specifying timezone -- **MUST NOT** perform date arithmetic without timezone awareness - -### Naming Conventions - -- **Files:** kebab-case (`user-settings.ts`) -- **Components:** PascalCase (`UserSettings.tsx`) -- **Functions:** camelCase (`getUserSettings()`) -- **Constants:** UPPER_SNAKE_CASE (`MAX_RETRIES`) -- **Types/Interfaces:** PascalCase (`UserSettings`) - -### Import Style - -- Use ES6 `import` and `export` -- Group imports: external → internal → relative -- Use absolute imports via `@/` path alias -- **Don't use dynamic import except avoiding circular dependencies** - -### React Components - -- **Server Components by default** (Next.js 14) -- Mark with `"use client"` only when needed -- **Follow [docs/agents/rules/useEffect-rules.md](./docs/agents/rules/useEffect-rules.md) strictly** - avoid unnecessary Effects -- Prefer composition over prop drilling -- Extract reusable logic into custom hooks - -### Styling - -- Use Tailwind CSS utility classes -- Follow shadcn/ui patterns for consistency -- Use `cn()` utility for conditional classes -- Responsive-first approach (mobile → desktop) - -Visual Aesthetic: Modern Glassmorphism** - -Apply premium glassmorphism styling for modals, cards, and interactive elements: - -- **Backdrop blur:** Use `backdrop-blur-sm` for glass-like transparency -- **Soft shadows:** Layer shadows like `shadow-lg shadow-primary/5` -- **Border opacity:** Use `border-border/50` for subtle borders -- **Glow effects:** Blurred circles with `bg-primary/10 blur-2xl` behind focal elements - -**Animation Standards:** - -- **Entrance:** `animate-in fade-in-0 zoom-in-95 duration-300` -- **Hover states:** `transition-all duration-300` with scale, shadow, or color changes -- **Sliding effects:** `-translate-x-full group-hover:translate-x-full transition-transform duration-700` - -**Reference implementation:** `components/modals/sign-in-modal.tsx` - ---- - -## Testing & Quality - -### Testing Strategy (MVP Phase) - -- **Manual testing** for critical user flows -- **Type safety** as primary quality gate -- Add **automated tests** after user validation -- Focus testing on business logic (`lib/`) first - -### Critical Test Areas - -1. Authentication flows -2. Parsing input → output transformation -3. Calendar event creation -4. User settings persistence - -### Performance Guidelines - -- Use React Server Components for data fetching -- Implement proper database indexes -- Avoid unnecessary client-side JavaScript -- Use Next.js Image component for images -- Profile before optimizing - -### Security Checklist - -- **MUST** Validate authentication in all server actions -- **MUST** Sanitize user inputs -- **MUST** Use Zod schemas for validation -- **MUST NOT** Expose sensitive data in API responses -- **MUST** Implement proper error handling (no stack traces to client) - ---- - -## Documentation & Resources - -### Required Reading - -1. **[README.md](./README.md)** - Project vision and tech stack -2. **[stop-doing-list.md](./docs/agents/stop-doing-list.md)** - Features to AVOID (MVP focus) -3. **[architecture-patterns.md](./docs/agents/architecture-patterns.md)** - 5-layer architecture details -4. **[useEffect-rules.md](./docs/agents/rules/useEffect-rules.md)** - React Effects best practices - -### Fetching Latest Documentation - -- **MUST** use Context7 MCP for library docs -- **SHOULD** use `curl` or WebFetch for API documentation -- Check official docs for framework/library updates -- Verify breaking changes before suggesting updates - -### When to Use Which Tool - -- **manage_todo_list:** Track implementation steps (3+ steps) -- **Context7:** Fetch latest library documentation -- **Read:** Understand existing code patterns -- **Grep:** Search for implementation examples - ---- - -## Pull Request & Commit Conventions - -### Commit Message Format - -```md -(): - -[optional body] -``` - -**Types:** feat, fix, refactor, docs, test, chore, perf - -**Examples:** - -- `feat(parsing): add support for time ranges in voice input` -- `fix(calendar): handle timezone conversion edge cases` -- `refactor(auth): extract session validation to utility` - -### PR Description Template - -```markdown -## Summary - -Brief description of changes - -## Changes - -- List specific changes -- Bullet point format - -## Testing - -How to test these changes - -## Checklist - -- [ ] Follows 5-layer architecture -- [ ] Type-safe (no `any` types) -- [ ] Authentication checks in place -- [ ] Zod validation for inputs -- [ ] useEffect rules followed -- [ ] No stop-doing-list violations -``` - -### Branch Naming - -- `feat/description` - New features -- `fix/description` - Bug fixes -- `refactor/description` - Code improvements - ---- - -## Decision Guides - -### Where to Put New Code? - -**New Database Model:** - -1. Add to `prisma/schema.prisma` -2. Create service in `lib/[model-name].ts` -3. Create validation in `lib/validations/[model-name].ts` -4. Create actions in `actions/[model-name].ts` -5. Create UI in `components/[model-name]/` - -**New API Endpoint:** - -- External API: `app/api/[endpoint]/route.ts` -- Internal mutation: Use Server Actions in `actions/` - -**New Component:** - -- Base UI: `components/ui/` -- Feature-specific: `components/[feature]/` -- Form: `components/forms/` -- Layout: `components/layout/` - -### Should I Use an Effect? - -1. **Synchronizing with external system?** → Use Effect -2. **Transforming data for rendering?** → Calculate during render -3. **Handling user event?** → Use event handler -4. **Caching expensive calculation?** → Use useMemo -5. **Resetting state on prop change?** → Use key prop - -See [useEffect-rules.md](./docs/agents/rules/useEffect-rules.md) for detailed flowchart. - -### Should I Add This Feature? - -Check [stop-doing-list.md](./docs/agents/stop-doing-list.md) first. Ask: - -1. Does it solve the core parsing → calendar problem? -2. Is it needed before product-market fit? -3. Does it add complexity without clear user value? -4. Can it wait until after MVP validation? - -**If any answer is "no" → Don't build it yet.** - ---- - -## Common Pitfalls - -### Architecture Violations - -- **MUST NOT** Skip validation layer - MUST use Zod schemas -- **MUST NOT** Put database queries in components - Use Server Actions -- **MUST NOT** Put business logic in Server Actions - Extract to `lib/` -- **MUST NOT** Update schema without migrations - Follow Prisma workflow - -### React Anti-Patterns - -- **MUST NOT** Use unnecessary useEffect - See [useEffect-rules.md](./docs/agents/rules/useEffect-rules.md) -- **SHOULD NOT** Prop drill > 2 levels - Extract context or composition -- **MUST NOT** Use Client Components by default - Use Server Components first -- **SHOULD NOT** Put state in URL params - Use Next.js searchParams - -### Security Issues - -- **MUST NOT** Miss authentication checks in actions -- **MUST NOT** Trust client-side validation only -- **MUST NOT** Expose user data without ownership checks -- **MUST NOT** Use raw SQL queries - Use Prisma - -### Performance Mistakes - -- **MUST NOT** Fetch in loops - Batch queries -- **SHOULD NOT** Miss database indexes - Add for frequent queries -- **SHOULD NOT** Create large client bundles - Lazy load heavy components -- **MUST NOT** Use unoptimized images - Use next/image - -### Date & Time Mistakes - -- **MUST NOT** Use server timezone for user-facing dates -- **MUST NOT** Perform date operations without timezone context -- **MUST NOT** Use `new Date()` for user input parsing -- **MUST NOT** Skip timezone validation before operations -- **MUST** Always store UTC in database, convert on display - -### MVP Scope Creep - -- **MUST NOT** Build admin features - Use direct DB access -- **MUST NOT** Add multiple OAuth providers - Google only for now -- **MUST NOT** Build advanced analytics - Focus on core parsing -- **MUST NOT** Build custom notifications - Use email only - ---- - -## Quick Reference - -### Most Common Tasks - -**Start working:** - -```sh -pnpm dev -``` - -**Check types:** - -```sh -pnpm build # includes type-check -``` - -**Database changes:** - -```sh -pnpm db:push # Development -pnpm db:studio # View data -``` - -**Add new feature (follow this order):** - -1. Update `prisma/schema.prisma` -2. Create `lib/[feature].ts` -3. Create `lib/validations/[feature].ts` -4. Create `actions/[feature].ts` -5. Create `components/[feature]/` - -### Emergency Debugging - -**Type errors:** - -```bash -# Regenerate Prisma types -pnpm prisma generate - -# Then restart TypeScript server in VS Code -# Command Palette → TypeScript: Restart TS Server -``` - -**Build fails:** - -```bash -# Clear Next.js cache -rm -rf .next - -# Full reinstall -rm -rf node_modules && pnpm install -``` - -**Database issues:** - -```bash -# Reset database (DEV ONLY - DESTRUCTIVE) -pnpm db:push --force-reset - -# Check migration status -pnpm prisma migrate status - -# View data -pnpm db:studio -``` - -**Runtime errors:** - -```bash -# Check environment variables -# Ensure .env.local has all required keys from .env.example - -# Verify AI provider credentials -# OPENAI_API_KEY, CEREBRAS_API_KEY (optional), etc. -``` - -### Key File Shortcuts - -- Architecture: [docs/agents/architecture-patterns.md](./docs/agents/architecture-patterns.md) -- Stop list: [docs/agents/stop-doing-list.md](./docs/agents/stop-doing-list.md) -- React rules: [docs/agents/rules/useEffect-rules.md](./docs/agents/rules/useEffect-rules.md) -- Schema: [prisma/schema.prisma](./prisma/schema.prisma) -- Config: [auth.ts](./auth.ts), [env.mjs](./env.mjs) diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index f7f118cf..643036e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,18 +1,241 @@ -# CLAUDE.md +# General Guide -MUST Read [AGENTS.md](./AGENTS.md) for full project context and instructions. +Tech lead guidance for all agents working in this repository. -Before proceeding with ANY task, you MUST: +## Tech Stack -1. Read AGENTS.md in its entirety -2. Confirm you understand the 5-layer architecture -3. Verify the task doesn't violate [stop-doing-list.md](./docs/agents/stop-doing-list.md) +Next.js 15 (App Router)+PostgreSQL+Prisma+Auth.js+React+Tailwind v4+shadcn/ui+Vercel AI SDK+date-fns+date-fns-tz+Zod v4+pnpm+Zustand+TanStack Query -IMPORTANT: If you proceed without reading AGENTS.md, you will likely violate architectural patterns and waste development time. +## Commands -## Active Technologies -- TypeScript 5.x (Next.js 15) + Next.js 15 (App Router), React, Prisma ORM, Zod v4, date-fns + date-fns-tz, googleapis, @microsoft/microsoft-graph-clien (001-conflict-detection) -- PostgreSQL (via Prisma), Redis (Upstash) for caching (001-conflict-detection) +```bash +pnpm dev # Dev server (localhost:3000) +pnpm build # Build all +pnpm db:push # Push schema (dev only) +pnpm lint:fix # Biome lint + format with auto-fix +pnpm type-check:all # Type-check workspace +pnpm test # Vitest watch mode +pnpm test:run # Tests once (CI) +``` -## Recent Changes -- 001-conflict-detection: Added TypeScript 5.x (Next.js 15) + Next.js 15 (App Router), React, Prisma ORM, Zod v4, date-fns + date-fns-tz, googleapis, @microsoft/microsoft-graph-clien +**Critical:** Never run `pnpm db:migrate:deploy` without explicit approval. + +**Pre-commit:** Run CI checks locally before committing: `pnpm lint:fix && pnpm lint:next && pnpm type-check:all && pnpm test:run` + +**Git:** Commits and PRs MUST NOT include Claude attribution or Co-Authored-By lines. + +**Git Branching:** Always use `git checkout -b origin/` to create feature branches — this auto-configures upstream tracking. + +## Architecture + +### File Structure + +``` +apps/web/ # Next.js web application +apps/chrome-extension/ # Chrome extension +packages// # ALL new features go here +``` + +### Package Structure + +``` +packages//src/ + index.ts # Safe exports (lib + types + deserializers) + client.ts # React components/hooks ("use client") + server.ts # Repositories + services + serializers (import "server-only" FIRST) + types.ts # Client-safe types (*DTO, *Model) — no @prisma/client runtime + types.server.ts # Server types (Db* aliases from Prisma) + lib/ # Business logic (no React, no Prisma queries) + services/ # createXService(prisma) factory pattern + repositories/ # createXRepository(prisma) factory pattern + components/ # React components + hooks/ # React hooks +``` + +**Feature packages** (full-stack): Three entrypoints required +- `.` → types, deserializers, lib (isomorphic) +- `./client` → React hooks/components (`"use client"`) +- `./server` → repositories, services, serializers (`import "server-only"`) + +**Infrastructure packages**: Entrypoints match purpose +- Server-only (e.g., `api-server`): `.` + `./server` +- Client-only (e.g., `ui`): `.` + `./client` +- Isomorphic utilities (e.g., `errors`, `serialization`): `.` only +- Config packages (e.g., `tsconfig`): JSON exports only + +### Package Manifest (`packages/*/package.json`) + +- `exports`: `.` → `src/index.ts`, `./client` → `src/client.ts`, `./server` → `src/server.ts` +- `main` and `types` both point to `./src/index.ts` (no dist build) +- `peerDependencies` versions match root `package.json`, mirrored in `devDependencies` +- React ecosystem (`react`, `@tanstack/react-query`) are peers, not dependencies + +### Isolation Rules + +- Packages MUST NOT import from the web app (`apps/web/`) +- NEVER use `@/` alias inside `packages/*` +- Client code MUST NOT import `/server` entrypoints +- Prisma queries MUST be in `repositories/` only +- `lib/` may use `import type` from `@prisma/client` for serializers (no runtime imports) + +### TanStack Query Pattern + +For packages with HTTP APIs, structure files within `packages//src/`: + +``` +api/ + .api.ts # API client functions (fetch wrappers) +query/ + keys.ts # Query key factories + options.ts # queryOptions factories (SSR-safe, no "use client") + hooks.ts # TanStack hooks ("use client" required) +hooks/ + use-*.ts # Custom hooks consuming query hooks +``` + +Flow: `api/.api.ts` → `query/{keys,options,hooks}.ts` → `hooks/use-*.ts` + +- `index.ts` — SSR-safe: API functions, query keys, `queryOptions` factories, deserializers +- `client.ts` — Client-only: TanStack hooks, components (MUST have "use client") +- `server.ts` — Server-only: repositories, services, serializers +- `options.ts` — MUST NOT have "use client" (enables SSR prefetch) +- `hooks.ts` — MUST have "use client" + +### API Route Pattern + +Use `withApiHandler` HOF (from `@simplifying/api-server/server`): + +- Throw `AppError(ErrorCodes.*)` for domain errors → auto-converted to RFC 9457 +- Validation: `z.treeifyError(parsed.error)` passed flat to `data` (not wrapped) +- Success: `apiSuccess({ data, meta? }, { status?, request })` — `status` defaults to 200; when provided, put before `request` +- DTOs must be JSON-compatible (no Date, BigInt, Map, class instances) +- Pagination params: `skip`/`take` (offset) or `cursor`/`take` · `filter.*` namespace · `sort=field:asc|desc` + +**Response meta patterns:** + +| Pattern | Meta shape | +|---------|-----------| +| Offset pagination | `{ pagination: { total, skip, take, hasMore } }` | +| Cursor pagination | `{ pagination: { nextCursor, hasMore } }` | +| Dual cursor | `{ pagination: { nextCursors: {...}, hasMore } }` | +| Limits | `{ limits: { maxAllowed } }` | +| Warnings | `{ warnings: [{ code, message }] }` | + +**Client meta types:** `OffsetPaginationMeta`, `CursorPaginationMeta`, `LimitMeta` from `@simplifying/errors` + +### Server Actions (Legacy) + +`"use server"` directive · `auth()` before mutations · Zod validation · `revalidatePath` after · try/catch + +### Prisma Schema + +`@@map` with snake_case table names · New fields need defaults · Proper indexing and cascade rules + +### Serializers + +Explicit field mapping (no spread) · `serialize*()` from server.ts · `deserialize*()` from index.ts returns `*Model` types + +### Key Files + +| Purpose | Location | +|---------|----------| +| Auth | `apps/web/auth.ts`, `apps/web/auth.config.ts`, `apps/web/middleware.ts` | +| Database | `packages/prisma/` (`@simplifying/prisma/server`), `prisma/schema.prisma` | +| Environment | `apps/web/env.mjs` | +| AI routing | `apps/web/lib/ai/factory.ts` | +| API helpers | `packages/api-server/`, `packages/api-client/` | +| Errors | `packages/errors/` | +| Serialization | `packages/serialization/` | + +## Code Standards + +**TypeScript:** Strict mode, no `any`, no inline `as unknown as X` casts (centralize in typed helpers when unavoidable), explicit parameter types, interfaces for objects, JSDoc on exports + +**Date/Time:** date-fns + date-fns-tz only · Store UTC · Display in user timezone · Never `new Date()` without timezone · Never `.toLocaleString()` without timezone + +**React:** Server Components default · `"use client"` only when required · No unnecessary `useEffect` · No prop drilling >2 levels · No `console.log` + +**Naming:** Files kebab-case · Components PascalCase · Functions camelCase · Constants UPPER_SNAKE_CASE + +**Styling:** Tailwind + shadcn/ui · `cn()` for conditionals · Glassmorphism: `backdrop-blur-sm`, `shadow-lg shadow-primary/5` + +**Bash:** No inline comments in commands · Commands should be clean and self-explanatory + +## Testing + +Co-located: `src/**/__tests__/*.test.ts` or `src/**/*.test.ts` + +Focus: Auth flows · Parsing transformations · Calendar events · User settings + +## Where to Put New Code + +| Type | Location | +|------|----------| +| New feature | `packages//` with 3 entrypoints + models in `prisma/schema.prisma` | +| New API endpoint | Package: `services/server-*.ts` · Web app: `apps/web/app/api/[endpoint]/route.ts` | +| New component | Package: `components/` · Web app: `apps/web/components/[feature]/` · UI primitives: import from `@simplifying/ui/client` | + +## Common Pitfalls + +| Category | Avoid | +|----------|-------| +| Architecture | Importing from `apps/web/` in packages · `@/` alias in packages · Server imports in client · Direct Prisma queries outside `repositories/` are not allowed. Route handlers may import `@simplifying/prisma/server` solely to inject Prisma into services/repositories; do not use it for inline queries. | +| Imports | `.js` extensions in imports (use `from "./lib/errors"` not `from "./lib/errors.js"`) — packages are consumed as TS source via `transpilePackages`, webpack can't resolve `.js` to `.ts` | +| Serialization | Raw Prisma objects in API responses · Serializers in `index.ts` (use `server.ts`) · Prisma enums in `types.ts` (use string unions) | +| React | Client Components by default · Unnecessary `useEffect` | +| Date/Time | Server timezone for user dates · `new Date()` without timezone | + +## Development Workflow + +### Git Worktrees + +Use worktrees for parallel Claude sessions: + +```bash +git worktree add .claude/worktrees/ origin/main +cd .claude/worktrees/ && claude +``` + +### Planning Requirements + +| Change Type | Requirement | +|-------------|-------------| +| Multi-file changes | Plan mode required | +| Architecture decisions | Plan mode required | +| Complex features | Plan → Review (second session) → Implement | + +### Data & Analytics + +Use CLI tools for one-off queries: +- `psql` for database queries +- Prefer direct CLI over building scripts for ad-hoc analysis + +## Critical Thinking + +- Fix root cause (not band-aid) +- Unsure: read more code; if still stuck, ask with short options +- Conflicts: call out; pick safer path +- Unrecognized changes: assume other agent; keep going; focus your changes. If it causes issues, stop + ask user +- Leave breadcrumb notes in thread + +## Code Review Workflow + +When verifying code review findings, always read the relevant files BEFORE attempting any edits or analysis. Never spend an entire session just reading without producing results. + +## Refactoring Rules + +After deleting or renaming files, always search the entire codebase for imports/references to those files and update them before running verification. Use grep/ripgrep to find all references. + +## Git & PR Conventions + +When creating PRs, always target the `staging` branch unless explicitly told otherwise. Never attempt to merge directly into a branch that may be checked out in another worktree — create a PR instead. + +When a PR or branch has no diff from the target branch, identify this immediately by running `git diff` before attempting PR creation. Do not spend multiple rounds discovering this. + +## Verification & CI + +After making changes, always run the full verification chain: type-check, lint, and tests. Fix ALL errors in a single pass rather than fixing one at a time and re-running. Check for cascading dependency issues (e.g., hoisted @types packages) proactively. + +## Communication Style + +When explaining fixes to the user, provide a brief plain-English summary of what was wrong and what changed BEFORE diving into technical details. Assume the user may not be deeply familiar with every framework concept (e.g., Next.js Server/Client Component boundaries). diff --git a/README.md b/README.md index a7ec48a9..275e15ed 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,327 @@ -## Introduction +# Simplifying -### Steps +Simplifying transforms unstructured inputs into trustworthy events and actionable tasks—fast, clear, and synced to the tools you already use. -1. Install dependencies using pnpm: +## Quick Start ```sh +# 1. Install dependencies pnpm install + +# 2. Set up environment +cp .env.example .env.local + +# 3. Start development server +pnpm dev +``` + +> **Tip**: Use `npx ncu -i --format group` with [npm-check-updates](https://www.npmjs.com/package/npm-check-updates) to update dependencies. + +--- + +## Architecture Overview + +This is a **pnpm monorepo** with a Next.js 15 app and modular feature packages: + +```text +simplifying/ +├── apps/ +│ ├── web/ # Next.js web application +│ └── chrome-extension/ # Chrome extension +├── packages/ # ✨ Modular feature packages +│ ├── tsconfig/ # Shared TypeScript configs +│ ├── ui/ # Shared UI components +│ ├── prisma/ # Database client & encryption +│ ├── errors/ # Error classes + response types +│ ├── serialization/ # ISO date helpers (toISOString, toDate) +│ ├── api-server/ # API response builders +│ ├── api-client/ # Fetch-based HTTP client +│ ├── task/ # Task management (full-stack) +│ └── task-group/ # Task grouping (full-stack) +├── prisma/ # Database schema +└── turbo.json # Turborepo config ``` -2. Copy `.env.example` to `.env.local` and update the variables. +**All new features go in `packages/`**. The web app (`apps/web/`) consumes packages via `@simplifying/*` imports. + +--- + +## Monorepo Packages + +### Package Overview + +| Package | Purpose | Entrypoints | +|---------|---------|-------------| +| `@simplifying/tsconfig` | Shared TypeScript configs | `./base`, `./react-library`, `./node-library` | +| `@simplifying/ui` | UI components (shadcn/ui) | `.`, `./client` | +| `@simplifying/prisma` | Encrypted Prisma client | `.`, `./client`, `./server` | +| `@simplifying/errors` | Error classes, response envelope types | `.` | +| `@simplifying/serialization` | ISO date helpers (`toISOString`, `toDate`) | `.`, `./client`, `./server` | +| `@simplifying/api-server` | API response builders (`apiSuccess`, `apiError`) | `.`, `./server` | +| `@simplifying/api-client` | Fetch-based HTTP client | `.` | +| `@simplifying/task` | Task CRUD + TanStack Query | `.`, `./client`, `./server` | +| `@simplifying/task-group` | Task grouping (full-stack) | `.`, `./client`, `./server` | + +### Three-Entrypoint Pattern + +Every feature package exposes three entrypoints: + +```typescript +// Default: Types, schemas, constants (safe for all contexts) +import { MAX_GROUPS, type TaskGroupDTO } from "@simplifying/task-group"; + +// Client: React components, TanStack hooks +import { GroupSelector, useTaskGroups } from "@simplifying/task-group/client"; + +// Server: Repositories, services (Prisma-dependent) +import { createTaskGroupService } from "@simplifying/task-group/server"; +``` + +### Standard Package Structure + +```text +packages// +├── package.json # Exports: ., ./client, ./server +├── tsconfig.json # Extends @simplifying/tsconfig +└── src/ + ├── index.ts # Default exports (lib + types) + ├── client.ts # React exports ("use client") + ├── server.ts # Server exports (repositories) + ├── types.ts # Client-safe DTOs + ├── types.server.ts # Server-only types (Prisma aliases) + ├── lib/ # Pure business logic + serializers + │ └── -serializer.ts # Prisma ↔ DTO conversion + ├── api/ # HTTP transport (fetch) + ├── query/ # TanStack Query (keys, options, hooks) + ├── components/ # React components + ├── hooks/ # Local React hooks + ├── services/ # Use-case flows + └── repositories/ # Prisma data access +``` + +### Import Rules (Enforced by Biome) + +| From | Can Import | Cannot Import | +|------|-----------|---------------| +| `packages/**` | `@simplifying/*`, relative | `@/`, `apps/` | +| Client code | `./client`, default | `./server`, `repositories/` | +| Server code | All entrypoints | — | +| Serializers (`serialize*`) | Export from `./server` only | Export from default/client | +| Deserializers (`deserialize*`) | Export from default (`.`) | — | + +--- + +## API Client Pattern (TanStack Query) + +Packages with HTTP APIs use `@simplifying/api-client` + TanStack Query: + +```typescript +// Layer 1: API functions (api/.api.ts) +import { createFetchClient } from "@simplifying/api-client"; +const client = createFetchClient({ baseURL: "/api/tasks" }); + +export async function createTask(data: TaskCreatePayload): Promise { + return client.post("/", data); // Returns T directly +} + +// Layer 2: Query keys (query/keys.ts) +export const taskKeys = { + all: ["tasks"] as const, + lists: () => [...taskKeys.all, "list"] as const, + detail: (id: string) => [...taskKeys.all, "detail", id] as const, +} as const; + +// Layer 3: TanStack hooks (query/hooks.ts) +export function useCreateTask() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createTask, + onSuccess: () => queryClient.invalidateQueries({ queryKey: taskKeys.all }), + }); +} +``` + +**Usage**: + +```typescript +import { useCreateTask } from "@simplifying/task/client"; + +const createTask = useCreateTask(); +createTask.mutate({ title: "New Task" }); +``` + +--- + +## Commands + +### Development ```sh -cp .env.example .env.local +pnpm dev # Start Next.js dev server +pnpm dev:all # Start all packages in dev mode (Turbo) +pnpm build # Build packages + Next.js app ``` -3. Start the development server: +### Database ```sh -pnpm run dev +pnpm db:push # Push schema changes (dev only) +pnpm exec prisma studio # Database GUI +pnpm exec prisma generate # Regenerate Prisma types ``` -> [!NOTE] -> Use [npm-check-updates](https://www.npmjs.com/package/npm-check-updates) package for update this project. -> Use this command for update the project: `ncu -i --format group` +### Code Quality -## Tech Stack + Features +```sh +pnpm lint # Lint across workspace (Turbo) +pnpm lint:fix # Biome with auto-fix +pnpm type-check # Type-check root app +pnpm type-check:all # Type-check across workspace (Turbo) +``` -### Frameworks +### Testing -- [Next.js](https://nextjs.org/) – React framework for building performant apps with the best developer experience -- [Auth.js](https://authjs.dev/) – Handle user authentication with ease with providers like Google, Twitter, GitHub, etc. -- [Prisma](https://www.prisma.io/) – Typescript-first ORM for Node.js -- [React Email](https://react.email/) – Versatile email framework for efficient and flexible email development -- [AI-SDK](https://sdk.vercel.ai/) – The AI Toolkit for TypeScript +```sh +pnpm test # Run Vitest (watch mode) +pnpm test:run # Run tests once +pnpm test:coverage # Run with coverage +pnpm test:all # Tests across workspace (Turbo) +``` + +--- -### Platforms +## Creating a New Package -- [Vercel](https://vercel.com/) – Easily preview & deploy changes with git -- [Resend](https://resend.com/) – A powerful email framework for streamlined email development -- [Neon](https://neon.tech/) – Serverless Postgres with autoscaling, branching, bottomless storage and generous free tier. +### 1. Create the folder structure + +```sh +mkdir -p packages//src/{lib,api,query,components,hooks,services,repositories} +``` -### UI +### 2. Add package.json -- [Tailwind CSS](https://tailwindcss.com/) – Utility-first CSS framework for rapid UI development -- [Shadcn/ui](https://ui.shadcn.com/) – Re-usable components built using Radix UI and Tailwind CSS -- [Framer Motion](https://framer.com/motion) – Motion library for React to animate components with ease -- [Lucide](https://lucide.dev/) – Beautifully simple, pixel-perfect icons -- [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) – Optimize custom fonts and remove external network requests for improved performance -- [`ImageResponse`](https://nextjs.org/docs/app/api-reference/functions/image-response) – Generate dynamic Open Graph images at the edge +```json +{ + "name": "@simplifying/", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts", + "./server": "./src/server.ts" + }, + "scripts": { + "type-check": "tsc -p tsconfig.json --noEmit", + "lint": "biome check ." + }, + "devDependencies": { + "@simplifying/tsconfig": "workspace:*", + "typescript": "5.5.3" + } +} +``` -### Hooks and Utilities +### 3. Add tsconfig.json -- `useIntersectionObserver` – React hook to observe when an element enters or leaves the viewport -- `useLocalStorage` – Persist data in the browser's local storage -- `useScroll` – React hook to observe scroll position ([example](https://github.com/mickasmt/precedent/blob/main/components/layout/navbar.tsx#L12)) -- `nFormatter` – Format numbers with suffixes like `1.2k` or `1.2M` -- `capitalize` – Capitalize the first letter of a string -- `truncate` – Truncate a string to a specified length -- [`use-debounce`](https://www.npmjs.com/package/use-debounce) – Debounce a function call / state update +```json +{ + "extends": "@simplifying/tsconfig/react-library", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} +``` -### Code Quality +### 4. Create entrypoints -- [TypeScript](https://www.typescriptlang.org/) – Static type checker for end-to-end typesafety -- [Prettier](https://prettier.io/) – Opinionated code formatter for consistent code style -- [ESLint](https://eslint.org/) – Pluggable linter for Next.js and TypeScript +```typescript +// src/index.ts - Safe defaults (types, constants, schemas) +export type { MyFeatureDTO } from "./types"; +export { mySchema } from "./lib/schemas"; -### Miscellaneous +// src/client.ts - React layer +"use client"; +export { MyComponent } from "./components/my-component"; +export { useMyHook } from "./hooks/use-my-hook"; -- [Vercel Analytics](https://vercel.com/analytics) – Track unique visitors, pageviews, and more in a privacy-friendly way +// src/server.ts - Server layer +export { createMyRepository } from "./repositories/my-repository"; +export { createMyService } from "./services/server-my-service"; +``` + +### 5. Add to apps/web/package.json + +```json +{ + "dependencies": { + "@simplifying/": "workspace:*" + } +} +``` + +### 6. Add to apps/web/next.config.mjs (if has React components) + +```javascript +transpilePackages: [ + "@simplifying/", +], +``` + +--- + +## Tech Stack -## Product Vision: Simplifying — From Noise to Commitments +| Category | Technology | +|----------|------------| +| Framework | Next.js 15 (App Router) | +| Database | PostgreSQL + Prisma ORM | +| Auth | Auth.js + Prisma Adapter | +| UI | React + Tailwind CSS v3.4.6 + shadcn/ui | +| State | TanStack Query (server) + Zustand (client) | +| AI | Vercel AI SDK (OpenAI, Cerebras) | +| Date/Time | date-fns + date-fns-tz | +| Validation | Zod v4.1.12 | +| Build | Turborepo + pnpm workspaces | +| Linting | Biome | +| Testing | Vitest | + +--- + +## Configuration Files + +| File | Purpose | +|------|---------| +| `pnpm-workspace.yaml` | Workspace packages definition | +| `turbo.json` | Turborepo task pipeline | +| `biome.json` | Linting and formatting rules | +| `tsconfig.json` | Root TypeScript config | +| `apps/web/next.config.mjs` | Next.js configuration | +| `apps/web/env.mjs` | Type-safe environment variables | + +--- + +## Setup Notes + +### Google OAuth for Calendar + +```env +GOOGLE_CLIENT_ID=your_client_id +GOOGLE_CLIENT_SECRET=your_client_secret +``` -Simplifying exists to turn the messiness of modern work into momentum. In a world where intent is scattered across chat threads, emails, notes, and voice memos, people don't need another tool to manage work—they need an intelligent partner that understands intent, clarifies ambiguity, and commits the right things to their time and task systems. Our vision is to make every user feel in control of their commitments by transforming unstructured inputs into trustworthy events and actionable tasks—fast, clear, and synced to the tools they already use. +Note: Some guides use `GOOGLE_OAUTH_CLIENT_ID`/`GOOGLE_OAUTH_CLIENT_SECRET`—this project uses the non-`OAUTH` names above. -## Setup notes +--- -- Google OAuth for Calendar uses the following environment variables in this project: - - `GOOGLE_CLIENT_ID` - - `GOOGLE_CLIENT_SECRET` +## Additional Documentation - Some external guides refer to `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET`; those are not used here. Make sure to use the non-`OAUTH` names above and add them to `.env.local`. +- **Package Development**: See [packages/README.md](packages/README.md) for detailed package conventions +- **API Contract**: See [api-contract-layer-spec/](api-contract-layer-spec/) for response envelope, serialization, and RFC 9457 error conventions +- **Database**: See [prisma/schema.prisma](prisma/schema.prisma) for data models +- **AI Instructions**: See [.claude/CLAUDE.md](.claude/CLAUDE.md) for coding standards diff --git a/actions/calendar.ts b/actions/calendar.ts deleted file mode 100644 index ca52dc09..00000000 --- a/actions/calendar.ts +++ /dev/null @@ -1,137 +0,0 @@ -"use server"; - -import { auth } from "@/auth"; -import { revalidatePath } from "next/cache"; -import { - backfillEventsToDestination, - disconnectCalendarForUser, - getCredential, - setDestinationCalendar, -} from "@/lib/calendar"; -import { type CalendarProviderType } from "@/lib/providers/factory"; -import { PATHS } from "@/lib/constants/paths"; -import { withServerActionTracing } from "@/lib/sentry"; -import * as Sentry from "@sentry/nextjs"; -import { headers } from "next/headers"; -import { CredentialMismatchError } from "@/lib/errors/credential"; -import { - selectCalendarBodySchema, - disconnectCalendarSchema, - calendarProviderSchema, -} from "@/lib/validations/calendar"; - - -export async function selectCalendarAction( - provider: CalendarProviderType, - calendarId: string, - calendarName: string, -) { - return withServerActionTracing( - "selectCalendarAction", - { - headers: await headers(), - recordResponse: true, - }, - async () => { - const session = await auth(); - if (!session?.user?.id) { - throw new Error("Unauthorized"); - } - - const userId = session.user.id; - - const validated = selectCalendarBodySchema.parse({ calendarId, calendarName }); - const providerValue = calendarProviderSchema.parse(provider); - - await Sentry.startSpan( - { name: "SA: Select Calendar", op: "sa.calendar.select" }, - async () => { - const credential = await getCredential(userId, providerValue); - if (!credential) { - throw new Error("No calendar credential found"); - } - - if (credential.provider !== providerValue) { - throw new CredentialMismatchError({ - expectedProvider: providerValue, - actualProvider: credential.provider, - resource: "calendar", - }); - } - - const destination = await setDestinationCalendar({ - userId, - credentialId: credential.id, - provider: providerValue, - calendarId: validated.calendarId, - calendarName: validated.calendarName, - isPrimary: false, - }); - - // Fire-and-forget backfill: queue existing events that were created before - // a destination was selected so they can be synced to the new provider. - // - // LIMITATION: In serverless environments (e.g., Vercel), this async work may be - // terminated before completion when the response is sent. Errors are captured - // via Sentry.captureException, but successful completion is not guaranteed. - // - // TODO: Migrate to a background job system (Inngest, QStash, or Next.js - // unstable_after) to ensure reliable completion of backfill operations. - void Sentry.startSpan( - { name: "SA: Calendar Backfill After Select", op: "sa.calendar.backfill" }, - async () => { - try { - await backfillEventsToDestination(userId); - } catch (error) { - Sentry.captureException(error, { - tags: { feature: "calendar-backfill" }, - extra: { - userId, - provider: providerValue, - destinationId: destination.id, - }, - }); - } - }, - ); - }, - ); - - revalidatePath(PATHS.SETTINGS.INTEGRATIONS); - return { success: true }; - }, - ); -} - -export async function disconnectCalendarAction(provider: "GOOGLE_CALENDAR" | "MICROSOFT_CALENDAR") { - return withServerActionTracing( - "disconnectCalendarAction", - { - headers: await headers(), - recordResponse: true, - }, - async () => { - const session = await auth(); - if (!session?.user?.id) { - throw new Error("Unauthorized"); - } - - const userId = session.user.id; - - const validated = disconnectCalendarSchema.parse({ provider }); - - await Sentry.startSpan( - { name: "SA: Calendar Disconnect", op: "sa.calendar.disconnect" }, - async () => { - await disconnectCalendarForUser(userId, validated.provider); - }, - ); - - revalidatePath(PATHS.SETTINGS.INTEGRATIONS); - revalidatePath(PATHS.SETTINGS.CONFLICTS); - return { success: true }; - }, - ); -} - - diff --git a/actions/generate-user-stripe.ts b/actions/generate-user-stripe.ts deleted file mode 100644 index ed8de3d9..00000000 --- a/actions/generate-user-stripe.ts +++ /dev/null @@ -1,65 +0,0 @@ -"use server"; - -import { auth } from "@/auth"; -import { stripe } from "@/lib/stripe"; -import { getUserSubscriptionPlan } from "@/lib/subscription"; -import { absoluteUrl } from "@/lib/utils"; -import { redirect } from "next/navigation"; - -export type responseAction = { - status: "success" | "error"; - stripeUrl?: string; -} - -const billingUrl = absoluteUrl("/pricing"); - -export async function generateUserStripe(priceId: string): Promise { - let redirectUrl: string = ""; - - try { - const session = await auth() - const user = session?.user; - - if (!user || !user.email || !user.id) { - throw new Error("Unauthorized"); - } - - const subscriptionPlan = await getUserSubscriptionPlan(user.id) - - if (subscriptionPlan.isPaid && subscriptionPlan.stripeCustomerId) { - // User on Paid Plan - Create a portal session to manage subscription. - const stripeSession = await stripe.billingPortal.sessions.create({ - customer: subscriptionPlan.stripeCustomerId, - return_url: billingUrl, - }) - - redirectUrl = stripeSession.url as string - } else { - // User on Free Plan - Create a checkout session to upgrade. - const stripeSession = await stripe.checkout.sessions.create({ - success_url: billingUrl, - cancel_url: billingUrl, - payment_method_types: ["card"], - mode: "subscription", - billing_address_collection: "auto", - customer_email: user.email, - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - metadata: { - userId: user.id, - }, - }) - - redirectUrl = stripeSession.url as string - } - } catch (error) { - throw new Error("Failed to generate user stripe session"); - } - - // no revalidatePath because redirect - redirect(redirectUrl) -} \ No newline at end of file diff --git a/actions/inbox.ts b/actions/inbox.ts deleted file mode 100644 index 71c78d03..00000000 --- a/actions/inbox.ts +++ /dev/null @@ -1,88 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; - -import { auth } from "@/auth"; -import { getUserById } from "@/lib/user"; -import { inboxDecisionSchema, type InboxDecisionInput } from "@/lib/validations/inbox"; -import { processInboxItemsService } from "@/lib/inbox/service"; -import { PATHS } from "@/lib/constants/paths"; -import { z } from "zod"; - -type InboxActionResult = - | { - success: true; - data: { - acceptedEvents: number; - acceptedTasks: number; - declinedEvents: number; - declinedTasks: number; - }; - } - | { - success: false; - error: { - code: "UNAUTHORIZED" | "VALIDATION_ERROR" | "DATABASE_ERROR"; - message: string; - issues?: unknown; - }; - }; - -/** - * Accept or decline inbox items (events and tasks) - * Unlike parsing actions, this doesn't require items to belong to the same ParsedResult - */ -export async function processInboxItems(input: InboxDecisionInput): Promise { - const session = await auth(); - - if (!session?.user?.id) { - return { - success: false, - error: { - code: "UNAUTHORIZED", - message: "You must be signed in to process inbox items.", - }, - }; - } - - const parsedInput = inboxDecisionSchema.safeParse(input); - - if (!parsedInput.success) { - return { - success: false, - error: { - code: "VALIDATION_ERROR", - message: "Your selection is invalid.", - issues: z.treeifyError(parsedInput.error), - }, - }; - } - - const user = await getUserById(session.user.id); - - if (!user) { - return { - success: false, - error: { - code: "UNAUTHORIZED", - message: "Unable to load user profile.", - }, - }; - } - - // Delegate to business logic layer - const serviceResult = await processInboxItemsService({ - userId: user.id, - payload: parsedInput.data, - }); - - if (!serviceResult.success) { - return serviceResult; - } - - revalidatePath(PATHS.INBOX); - revalidatePath(PATHS.HOME); - revalidatePath(PATHS.DASHBOARD); - - return serviceResult; -} diff --git a/actions/open-customer-portal.ts b/actions/open-customer-portal.ts deleted file mode 100644 index e5013981..00000000 --- a/actions/open-customer-portal.ts +++ /dev/null @@ -1,41 +0,0 @@ -"use server"; - -import { redirect } from "next/navigation"; -import { auth } from "@/auth"; - -import { stripe } from "@/lib/stripe"; -import { absoluteUrl } from "@/lib/utils"; - -export type responseAction = { - status: "success" | "error"; - stripeUrl?: string; -}; - -const billingUrl = absoluteUrl("/settings/billing"); - -export async function openCustomerPortal( - userStripeId: string, -): Promise { - let redirectUrl: string = ""; - - try { - const session = await auth(); - - if (!session?.user || !session?.user.email) { - throw new Error("Unauthorized"); - } - - if (userStripeId) { - const stripeSession = await stripe.billingPortal.sessions.create({ - customer: userStripeId, - return_url: billingUrl, - }); - - redirectUrl = stripeSession.url as string; - } - } catch (error) { - throw new Error("Failed to generate user stripe session"); - } - - redirect(redirectUrl); -} diff --git a/actions/parsing.ts b/actions/parsing.ts deleted file mode 100644 index 61dd5e17..00000000 --- a/actions/parsing.ts +++ /dev/null @@ -1,392 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; -import { headers } from "next/headers"; -import { InputSource } from "@prisma/client"; -import type { RawInput, RawInputAttachment } from "@prisma/client"; - -import { auth } from "@/auth"; -import { prisma } from "@/lib/db"; -import * as Sentry from "@sentry/nextjs"; -import { attachUserContext } from "@/lib/sentry-utils-server"; -import { acceptParsedItems, parseUserInput } from "@/lib/parsing"; -import type { ParsingAttachmentInput } from "@/lib/types/parsing"; -import { - AcceptRequestInput, - acceptRequestSchema, - parseRequestSchema, - ParseRequestInput, - ParseFileDescriptor, -} from "@/lib/validations/parsing"; -import { getUserTimezoneServer } from "@/lib/timezone"; -import { getWeekStartDay } from "@/lib/user"; -import { PATHS } from "@/lib/constants/paths"; - -export async function createRawInput(input?: { text?: string; requestId?: string }) { - return await Sentry.withServerActionInstrumentation( - "createRawInput", - { - headers: await headers(), - recordResponse: true, - }, - async () => { - const session = await auth(); - - const userId = session?.user?.id; - - if (!userId) { - return { - success: false as const, - error: { - code: "UNAUTHORIZED" as const, - message: "You must be signed in to create an input.", - }, - }; - } - - try { - await attachUserContext(session); - const rawInput = await Sentry.startSpan( - { name: "Create Raw Input", op: "db.write", attributes: { - "input.source": "CHAT", - "input.has_request_id": Boolean(input?.requestId), - "input.text_length": (input?.text?.trim() ?? "").length, - } }, - async () => { - return prisma.rawInput.create({ - data: { - userId, - text: input?.text?.trim() ?? "", - source: InputSource.CHAT, - requestId: input?.requestId ?? null, - }, - select: { id: true }, - }); - }, - ); - - return { - success: true as const, - data: { - rawInputId: rawInput.id, - }, - }; - } catch (error) { - console.error("[createRawInput] Failed to create raw input", error); - - return { - success: false as const, - error: { - code: "DATABASE_ERROR" as const, - message: "Unable to create raw input at this time.", - }, - }; - } - }, - ); -} - -type OrderedAttachment = ParsingAttachmentInput; - -function validateAttachments( - descriptors: ParseFileDescriptor[], - records: RawInputAttachment[], -): { success: true; attachments: OrderedAttachment[] } | { - success: false; - mismatches: Array<{ key: string; field: string; expected: unknown; actual: unknown }>; -} { - const attachmentMap = new Map(); - for (const record of records) { - attachmentMap.set(record.objectKey, record); - } - - const mismatches: Array<{ key: string; field: string; expected: unknown; actual: unknown }> = []; - const ordered: OrderedAttachment[] = []; - - for (const descriptor of descriptors) { - const record = attachmentMap.get(descriptor.key); - - if (!record) { - mismatches.push({ - key: descriptor.key, - field: "objectKey", - expected: descriptor.key, - actual: null, - }); - continue; - } - - attachmentMap.delete(descriptor.key); - - if (record.mimeType !== descriptor.contentType) { - mismatches.push({ - key: descriptor.key, - field: "contentType", - expected: descriptor.contentType, - actual: record.mimeType, - }); - } - - if (record.size !== descriptor.sizeBytes) { - mismatches.push({ - key: descriptor.key, - field: "sizeBytes", - expected: descriptor.sizeBytes, - actual: record.size, - }); - } - - if (record.checksum?.toLowerCase() !== descriptor.sha256.toLowerCase()) { - mismatches.push({ - key: descriptor.key, - field: "sha256", - expected: descriptor.sha256.toLowerCase(), - actual: record.checksum, - }); - } - - const recordWithMetadata = record as RawInputAttachment & { - etag?: string | null; - }; - - if (recordWithMetadata.etag && recordWithMetadata.etag !== descriptor.etag) { - mismatches.push({ - key: descriptor.key, - field: "etag", - expected: descriptor.etag, - actual: recordWithMetadata.etag, - }); - } - - - - const uploadedIso = record.uploadedAt.toISOString(); - if (uploadedIso !== descriptor.createdAt) { - mismatches.push({ - key: descriptor.key, - field: "createdAt", - expected: descriptor.createdAt, - actual: uploadedIso, - }); - } - - ordered.push({ descriptor, record }); - } - - if (mismatches.length > 0) { - return { success: false as const, mismatches }; - } - - return { success: true as const, attachments: ordered }; -} - -export async function requestParsing(input: ParseRequestInput) { - return await Sentry.withServerActionInstrumentation( - "requestParsing", - { - headers: await headers(), - recordResponse: true, - }, - async () => { - const session = await auth(); - - const userId = session?.user?.id; - - if (!userId) { - return { - success: false as const, - error: { - code: "UNAUTHORIZED" as const, - message: "You must be signed in to submit parsing requests.", - }, - }; - } - - const parsedInput = parseRequestSchema.safeParse(input); - - if (!parsedInput.success) { - return { - success: false as const, - error: { - code: "VALIDATION_ERROR" as const, - message: "Parsing request is invalid.", - issues: parsedInput.error.flatten(), - }, - }; - } - - const { rawInputId, requestId, text, files } = parsedInput.data; - await attachUserContext(session); - const trimmedText = text.trim(); - - const existingRawInput = await prisma.rawInput.findUnique({ - where: { id: rawInputId }, - include: { - attachments: { - orderBy: { uploadedAt: "asc" }, - }, - }, - }); - - if (!existingRawInput) { - return { - success: false as const, - error: { - code: "NOT_FOUND" as const, - message: "Raw input could not be found.", - }, - }; - } - - if (existingRawInput.userId !== userId) { - return { - success: false as const, - error: { - code: "FORBIDDEN" as const, - message: "You do not have permission to modify this input.", - }, - }; - } - - if (existingRawInput.requestId && existingRawInput.requestId !== requestId) { - return { - success: false as const, - error: { - code: "CONFLICT" as const, - message: "This input has already been processed with a different request identifier.", - }, - }; - } - - const updatedRawInput = await prisma.rawInput.update({ - where: { id: rawInputId }, - data: { - text: trimmedText, - requestId: existingRawInput.requestId ?? requestId, - }, - include: { - attachments: { - orderBy: { uploadedAt: "asc" }, - }, - }, - }); - - const attachmentCheck = validateAttachments(files, updatedRawInput.attachments); - - if (!attachmentCheck.success) { - return { - success: false as const, - error: { - code: "VALIDATION_ERROR" as const, - message: "Attachment metadata does not match confirmed uploads.", - details: attachmentCheck.mismatches, - }, - }; - } - - const userTimeZone = await getUserTimezoneServer(); - const weekStartDay = await getWeekStartDay(userId); - - const serviceResult = await Sentry.startSpan( - { - name: "Parsing Service: parse", - op: "app.parsing.parse", - attributes: { - "raw_input.id": rawInputId, - "request.id": requestId, - "attachments.count": attachmentCheck.attachments.length, - "text.length": trimmedText.length, - }, - }, - async () => - parseUserInput({ - userId, - rawInput: updatedRawInput as RawInput & { attachments: RawInputAttachment[] }, - userTimeZone, - requestId, - attachments: attachmentCheck.attachments, - weekStartDay, - }), - ); - - if (!serviceResult.success) { - return serviceResult; - } - - revalidatePath(PATHS.HOME); - revalidatePath(PATHS.DASHBOARD); - - return serviceResult; - }, - ); -} - -export async function acceptParsingDecision(input: AcceptRequestInput) { - return await Sentry.withServerActionInstrumentation( - "acceptParsingDecision", - { - headers: await headers(), - recordResponse: true, - }, - async () => { - const session = await auth(); - - const userId = session?.user?.id; - - if (!userId) { - return { - success: false, - error: { - code: "UNAUTHORIZED" as const, - message: "You must be signed in to accept parsed items.", - }, - }; - } - - const parsedInput = acceptRequestSchema.safeParse(input); - - if (!parsedInput.success) { - return { - success: false, - error: { - code: "VALIDATION_ERROR" as const, - message: "Your selection is invalid.", - issues: parsedInput.error.flatten(), - }, - }; - } - - const userTimeZone = await getUserTimezoneServer(); - await attachUserContext(session); - - const serviceResult = await Sentry.startSpan( - { - name: "Parsing Service: accept", - op: "app.parsing.accept", - attributes: { - "parsed_result.id": parsedInput.data.parsedResultId, - "accepted.events": parsedInput.data.acceptedEventIds.length, - "accepted.tasks": parsedInput.data.acceptedTaskIds.length, - "declined.events": parsedInput.data.declinedEventIds.length, - "declined.tasks": parsedInput.data.declinedTaskIds.length, - }, - }, - async () => - acceptParsedItems({ - userId, - userTimeZone, - payload: parsedInput.data, - }), - ); - - if (!serviceResult.success) { - return serviceResult; - } - - revalidatePath(PATHS.HOME); - revalidatePath(PATHS.DASHBOARD); - - return serviceResult; - }, - ); -} diff --git a/actions/tasks.ts b/actions/tasks.ts deleted file mode 100644 index 93320a24..00000000 --- a/actions/tasks.ts +++ /dev/null @@ -1,158 +0,0 @@ -"use server"; - -import { auth } from "@/auth"; -import { revalidatePath } from "next/cache"; -import { headers } from "next/headers"; -import { - backfillTasksToDestination, - createDestinationTaskList, - disconnectTaskProvider, - ensureDefaultDestinationTaskListForUser, - getCredential, -} from "@/lib/tasks"; -import type { TaskProviderType } from "@/lib/providers/factory"; -import { TaskProviderFactory } from "@/lib/providers/factory"; -import { prisma } from "@/lib/db"; -import { withServerActionTracing } from "@/lib/sentry"; -import { - selectTaskListSchema, - disconnectTaskListSchema, - taskProviderSchema, -} from "@/lib/validations/tasks"; -import { PATHS } from "@/lib/constants/paths"; -import * as Sentry from "@sentry/nextjs"; -import { CredentialMismatchError } from "@/lib/errors/credential"; - -export async function selectTaskListAction( - provider: TaskProviderType, - taskListId: string, - taskListName: string, -) { - return await withServerActionTracing( - "selectTaskListAction", - { - headers: await headers(), - recordResponse: true, - }, - async () => { - const session = await auth(); - if (!session?.user?.id) { - throw new Error("Unauthorized"); - } - - const userId = session.user.id; - - // Validate input parameters - const validated = selectTaskListSchema.parse({ taskListId, taskListName }); - const providerValue = taskProviderSchema.parse(provider); - - await Sentry.startSpan( - { name: "SA: Select Task List", op: "sa.tasks.select" }, - async () => { - const credential = await getCredential(userId, providerValue); - if (!credential) { - throw new Error("No tasks credential found"); - } - - if (credential.provider !== providerValue) { - throw new CredentialMismatchError({ - expectedProvider: providerValue, - actualProvider: credential.provider, - resource: "tasks", - }); - } - - const destination = await createDestinationTaskList({ - userId, - credentialId: credential.id, - provider: providerValue, - taskListId: validated.taskListId, - taskListName: validated.taskListName, - isPrimary: true, - }); - - // Fire-and-forget backfill: queue existing tasks that were created before - // a destination was selected so they can be synced to the new provider. - // - // LIMITATION: In serverless environments (e.g., Vercel), this async work may be - // terminated before completion when the response is sent. Errors are captured - // via Sentry.captureException, but successful completion is not guaranteed. - // - // TODO: Migrate to a background job system (Inngest, QStash, or Next.js - // unstable_after) to ensure reliable completion of backfill operations. - void Sentry.startSpan( - { name: "SA: Tasks Backfill After Select", op: "sa.tasks.backfill" }, - async () => { - try { - await backfillTasksToDestination(userId); - } catch (error) { - Sentry.captureException(error, { - tags: { feature: "tasks-backfill" }, - extra: { - userId, - provider: providerValue, - destinationId: destination.id, - }, - }); - } - }, - ); - }, - ); - - revalidatePath(PATHS.SETTINGS.INTEGRATIONS); - return { success: true }; - }, - ); -} - -export async function disconnectTaskListAction(provider: "GOOGLE_TASKS" | "MICROSOFT_TODO") { - return await withServerActionTracing( - "disconnectTaskListAction", - { - headers: await headers(), - recordResponse: true, - }, - async () => { - const session = await auth(); - if (!session?.user?.id) { - throw new Error("Unauthorized"); - } - - const userId = session.user.id; - - const validated = disconnectTaskListSchema.parse({ provider }); - - await Sentry.startSpan( - { name: "SA: Tasks Disconnect", op: "sa.tasks.disconnect" }, - async () => { - // Best-effort remote revocation before invalidating locally - const credentialRecords = await prisma.taskCredential.findMany({ - where: { userId, provider: validated.provider, invalid: false }, - }); - if (credentialRecords.length > 0) { - const providerInstance = TaskProviderFactory.create(validated.provider); - if (providerInstance.revokeCredential) { - for (const cred of credentialRecords) { - try { - await providerInstance.revokeCredential(cred); - } catch (error) { - Sentry.captureException(error, { - tags: { feature: "oauth-revoke", provider: validated.provider, resource: "tasks" }, - extra: { credentialId: cred.id, userId }, - }); - } - } - } - } - - await disconnectTaskProvider(userId, validated.provider); - await ensureDefaultDestinationTaskListForUser(userId); - }, - ); - - revalidatePath(PATHS.SETTINGS.INTEGRATIONS); - return { success: true }; - }, - ); -} diff --git a/actions/update-user-name.ts b/actions/update-user-name.ts deleted file mode 100644 index 3f9090c7..00000000 --- a/actions/update-user-name.ts +++ /dev/null @@ -1,41 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; - -import { auth } from "@/auth"; -import { prisma } from "@/lib/db"; -import { userNameSchema } from "@/lib/validations/user"; -import { PATHS } from "@/lib/constants/paths"; - -export type FormData = { - name: string; -}; - -export async function updateUserName(data: FormData) { - try { - const session = await auth(); - - if (!session?.user?.id) { - throw new Error("Unauthorized"); - } - - const { name } = userNameSchema.parse(data); - const userId = session.user.id; - - // Update the user name. - await prisma.user.update({ - where: { - id: userId, - }, - data: { - name: name, - }, - }); - - revalidatePath(PATHS.SETTINGS.ROOT); - return { status: "success" }; - } catch (error) { - // console.log(error) - return { status: "error" }; - } -} \ No newline at end of file diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx deleted file mode 100644 index cab02c88..00000000 --- a/app/(auth)/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { redirect } from "next/navigation"; - -import { getCurrentUser } from "@/lib/session"; - -interface AuthLayoutProps { - children: React.ReactNode; -} - -export default async function AuthLayout({ children }: AuthLayoutProps) { - const user = await getCurrentUser(); - - if (user) { - if (user.role === "ADMIN") redirect("/admin"); - redirect("/home"); - } - - return
{children}
; -} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx deleted file mode 100644 index 2f1eeb31..00000000 --- a/app/(auth)/login/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Suspense } from "react"; -import { Metadata } from "next"; -import Link from "next/link"; - -import { cn } from "@/lib/utils"; -import { buttonVariants } from "@/components/ui/button"; -import { UserAuthForm } from "@/components/forms/user-auth-form"; -import { Icons } from "@/components/shared/icons"; - -export const metadata: Metadata = { - title: "Login", - description: "Login to your account", -}; - -export default function LoginPage() { - return ( -
- - <> - - Back - - -
-
- -

- Welcome back -

-

- Enter your email to sign in to your account -

-
- - - -

- - Don't have an account? Sign Up - -

-
-
- ); -} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx deleted file mode 100644 index a05ff823..00000000 --- a/app/(auth)/register/page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import Link from "next/link" - -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" -import { Icons } from "@/components/shared/icons" -import { UserAuthForm } from "@/components/forms/user-auth-form" -import { Suspense } from "react" - -export const metadata = { - title: "Create an account", - description: "Create an account to get started.", -} - -export default function RegisterPage() { - return ( -
- - Login - -
-
-
-
- -

- Create an account -

-

- Enter your email below to create your account -

-
- - - -

- By clicking continue, you agree to our{" "} - - Terms of Service - {" "} - and{" "} - - Privacy Policy - - . -

-
-
-
- ) -} diff --git a/app/(marketing)/error.tsx b/app/(marketing)/error.tsx deleted file mode 100644 index b7735b27..00000000 --- a/app/(marketing)/error.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -import { Button } from '@/components/ui/button'; - -export default function Error({ - reset, -}: { - reset: () => void; -}) { - - return ( -
-

Something went wrong!

- -
- ); -} \ No newline at end of file diff --git a/app/(marketing)/pricing/loading.tsx b/app/(marketing)/pricing/loading.tsx deleted file mode 100644 index e90f2311..00000000 --- a/app/(marketing)/pricing/loading.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; -import { HeaderSection } from "@/components/shared/header-section"; -import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; - -export default function Loading() { - return ( -
- -
-
- - -
- -
- - - -
- -
- - -
-
-
- -
-
- ); -} diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx deleted file mode 100644 index 124535a9..00000000 --- a/app/(marketing)/pricing/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import Image from "next/image"; -import Link from "next/link"; - -import { getCurrentUser } from "@/lib/session"; -import { getUserSubscriptionPlan } from "@/lib/subscription"; -import { constructMetadata } from "@/lib/utils"; -import { ComparePlans } from "@/components/pricing/compare-plans"; -import { PricingCards } from "@/components/pricing/pricing-cards"; -import { PricingFaq } from "@/components/pricing/pricing-faq"; - -export const metadata = constructMetadata({ - title: "Pricing", - description: "Explore our subscription plans.", -}); - -export default async function PricingPage() { - const user = await getCurrentUser(); - - if (user?.role === "ADMIN") { - return ( -
-

Seriously?

- 403 -

- You are an {user.role}. Back to{" "} - - Dashboard - - . -

-
- ); - } - - let subscriptionPlan; - if (user && user.id) { - subscriptionPlan = await getUserSubscriptionPlan(user.id); - } - - return ( -
- -
- - -
- ); -} diff --git a/app/(protected)/admin/layout.tsx b/app/(protected)/admin/layout.tsx deleted file mode 100644 index 12d5588f..00000000 --- a/app/(protected)/admin/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { redirect } from "next/navigation"; - -import { getCurrentUser } from "@/lib/session"; - -interface ProtectedLayoutProps { - children: React.ReactNode; -} - -export default async function Dashboard({ children }: ProtectedLayoutProps) { - const user = await getCurrentUser(); - if (!user || user.role !== "ADMIN") redirect("/login"); - - return <>{children}; -} diff --git a/app/(protected)/admin/loading.tsx b/app/(protected)/admin/loading.tsx deleted file mode 100644 index 261bf557..00000000 --- a/app/(protected)/admin/loading.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; -import { DashboardHeader } from "@/components/dashboard/header"; - -export default function AdminPanelLoading() { - return ( - <> - -
-
- - - - -
- - -
- - ); -} diff --git a/app/(protected)/admin/orders/loading.tsx b/app/(protected)/admin/orders/loading.tsx deleted file mode 100644 index b144afcd..00000000 --- a/app/(protected)/admin/orders/loading.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; -import { DashboardHeader } from "@/components/dashboard/header"; - -export default function OrdersLoading() { - return ( - <> - - - - ); -} diff --git a/app/(protected)/admin/orders/page.tsx b/app/(protected)/admin/orders/page.tsx deleted file mode 100644 index d0f6dce0..00000000 --- a/app/(protected)/admin/orders/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { redirect } from "next/navigation"; - -import { getCurrentUser } from "@/lib/session"; -import { constructMetadata } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { DashboardHeader } from "@/components/dashboard/header"; -import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; - -export const metadata = constructMetadata({ - title: "Orders – SaaS Starter", - description: "Check and manage your latest orders.", -}); - -export default async function OrdersPage() { - // const user = await getCurrentUser(); - // if (!user || user.role !== "ADMIN") redirect("/login"); - - return ( - <> - - - - No orders listed - - You don't have any orders yet. Start ordering a product. - - - - - ); -} diff --git a/app/(protected)/admin/page.tsx b/app/(protected)/admin/page.tsx deleted file mode 100644 index 76b2d1ea..00000000 --- a/app/(protected)/admin/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { redirect } from "next/navigation"; - -import { getCurrentUser } from "@/lib/session"; -import { constructMetadata } from "@/lib/utils"; -import { DashboardHeader } from "@/components/dashboard/header"; -import InfoCard from "@/components/dashboard/info-card"; -import TransactionsList from "@/components/dashboard/transactions-list"; - -export const metadata = constructMetadata({ - title: "Admin – SaaS Starter", - description: "Admin page for only admin management.", -}); - -export default async function AdminPage() { - const user = await getCurrentUser(); - if (!user || user.role !== "ADMIN") redirect("/login"); - - return ( - <> - -
-
- - - - -
- - -
- - ); -} diff --git a/app/(protected)/home/page.tsx b/app/(protected)/home/page.tsx deleted file mode 100644 index 9dedc486..00000000 --- a/app/(protected)/home/page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { getCurrentUser } from "@/lib/session"; -import { getPendingParsedResults } from "@/lib/parsing"; -import { getUserTimezoneServer } from "@/lib/timezone"; -import { constructMetadata } from "@/lib/utils"; -import { HomePageClient } from "@/components/parsing/home-page-client"; -import { getUserTierLimits } from "@/lib/quota"; - -export const metadata = constructMetadata({ - title: "Home", - description: "Convert raw notes into structured events and tasks.", -}); - -export default async function HomePage() { - const sessionUser = await getCurrentUser(); - const userId = sessionUser!.id!; - - const [userTimeZone, pendingResults, tierLimits] = await Promise.all([ - getUserTimezoneServer(), - getPendingParsedResults({ userId, limit: 1 }), - getUserTierLimits(userId), - ]); - - return ( - 0} - /> - ); -} - diff --git a/app/(protected)/inbox/loading.tsx b/app/(protected)/inbox/loading.tsx deleted file mode 100644 index 9aa1fec3..00000000 --- a/app/(protected)/inbox/loading.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function InboxLoading() { - return ( -
- {/* Header skeleton */} -
- - -
- - {/* Tabs skeleton */} - - - {/* List items skeleton */} -
- {[...Array(5)].map((_, i) => ( - - ))} -
-
- ); -} diff --git a/app/(protected)/inbox/page.tsx b/app/(protected)/inbox/page.tsx deleted file mode 100644 index 643290c0..00000000 --- a/app/(protected)/inbox/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { getCurrentUser } from "@/lib/session"; -import { getUserTimezoneServer } from "@/lib/timezone"; -import { listPendingAll, listPendingEvents, listPendingTasks } from "@/lib/inbox/queries"; -import { encodeCursor } from "@/lib/inbox/cursor-utils"; -import { constructMetadata } from "@/lib/utils"; -import { InboxPageClient } from "@/components/inbox/inbox-page-client"; -import { DashboardHeader } from "@/components/dashboard/header"; - -export const metadata = constructMetadata({ - title: "Inbox", - description: "Review and manage pending events and tasks.", -}); - -export default async function InboxPage() { - const sessionUser = await getCurrentUser(); - const userId = sessionUser!.id!; - - // T014, T029, T030: Fetch initial data for all tabs in parallel - const [userTimeZone, allData, eventsData, tasksData] = await Promise.all([ - getUserTimezoneServer(), - listPendingAll(userId, 20), - listPendingEvents(userId, 20), - listPendingTasks(userId, 20), - ]); - - const encodedAllCursors = { - eventsCursor: encodeCursor(allData.nextCursors.eventsCursor), - tasksCursor: encodeCursor(allData.nextCursors.tasksCursor), - }; - - const encodedEventsCursor = encodeCursor(eventsData.nextCursor); - const encodedTasksCursor = encodeCursor(tasksData.nextCursor); - - return ( -
- - -
- ); -} diff --git a/app/(protected)/layout.tsx b/app/(protected)/layout.tsx deleted file mode 100644 index 74cc8045..00000000 --- a/app/(protected)/layout.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { redirect } from "next/navigation"; - -import { sidebarLinks } from "@/config/dashboard"; -import { getCurrentUser } from "@/lib/session"; -import { hasActiveSubscription } from "@/lib/subscription"; -import { - DashboardSidebar, - MobileSheetSidebar, -} from "@/components/layout/dashboard-sidebar"; -import { ModeToggle } from "@/components/layout/mode-toggle"; -import { UserAccountNav } from "@/components/layout/user-account-nav"; -import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; - -interface ProtectedLayoutProps { - children: React.ReactNode; -} - -export default async function Dashboard({ children }: ProtectedLayoutProps) { - const user = await getCurrentUser(); - - if (!user?.id) redirect("/login"); - - // Check if user has an active subscription - const hasSubscription = await hasActiveSubscription(user.id); - - const filteredLinks = sidebarLinks.map((section) => ({ - ...section, - items: section.items.filter( - ({ authorizeOnly }) => !authorizeOnly || authorizeOnly === user.role, - ), - })); - - return ( -
- - -
-
- - - -
- - - - -
- -
- - {children} - -
-
-
- ); -} diff --git a/app/(protected)/schedule/components/event-detail-pane.tsx b/app/(protected)/schedule/components/event-detail-pane.tsx deleted file mode 100644 index 12b8aace..00000000 --- a/app/(protected)/schedule/components/event-detail-pane.tsx +++ /dev/null @@ -1,403 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { motion } from "framer-motion"; -import { toast } from "sonner"; -import { datetimeLocalToIso, isoToDatetimeLocal, resolveTimezone, getTimezones } from "@/lib/timezone-utils"; -import type { UpdateEventBody } from "@/lib/validations/calendar"; -import type { EditableEvent } from "./event-edit-form"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { cn } from "@/lib/utils"; -import { buildChipText } from "@/lib/inbox/utils/recurrence"; -import { prepareEventUpdatePayload } from "@/lib/events"; -import { DetailHeader } from "./event-detail/detail-header"; -import { TitleSection } from "./event-detail/title-section"; -import { TimeSection } from "./event-detail/time-section"; -import { AttendeesSection } from "./event-detail/attendees-section"; -import { DescriptionSection } from "./event-detail/description-section"; -import { useConflictCheck } from "@/lib/hooks/use-conflict-check"; - -const SAVE_DEBOUNCE_MS = 250; - -const panelVariants = { - hidden: { - opacity: 0, - x: 32, - scale: 0.96, - }, - visible: { - opacity: 1, - x: 0, - scale: 1, - transition: { - duration: 0.35, - ease: [0.16, 1, 0.3, 1] as const, - staggerChildren: 0.08, - delayChildren: 0.1, - }, - }, - exit: { - opacity: 0, - x: 32, - scale: 0.96, - transition: { - duration: 0.25, - ease: [0.4, 0, 1, 1] as const, - }, - }, -}; - -const sectionVariants = { - hidden: { opacity: 0, y: 8 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.3, - ease: [0.16, 1, 0.3, 1] as const, - }, - }, -}; - -interface EventDetailPaneProps { - event: EditableEvent | null; - onClose: () => void; - onUpdate: (eventId: string, data: UpdateEventBody) => Promise; - onDelete: (eventId: string) => Promise; - className?: string; -} - -export function EventDetailPane({ event, onClose, onUpdate, onDelete, className }: EventDetailPaneProps) { - const [title, setTitle] = useState(event?.title ?? ""); - const [description, setDescription] = useState(event?.description ?? ""); - const [startLocal, setStartLocal] = useState(""); - const [endLocal, setEndLocal] = useState(""); - const [timeZone, setTimeZone] = useState(event?.timeZone ?? ""); - const [allDay, setAllDay] = useState(event?.allDay ?? false); - const [attendees, setAttendees] = useState((event?.attendees ?? []).join(", ")); - const [recurrence, setRecurrence] = useState(event?.recurrence ?? ""); - const [isSaving, setIsSaving] = useState(false); - const [saveError, setSaveError] = useState(null); - const [isEditing, setIsEditing] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const timezones = useMemo(() => getTimezones(), []); - - const saveTimeoutRef = useRef(); - const pendingUpdatesRef = useRef({}); - const onUpdateRef = useRef(onUpdate); - const eventIdRef = useRef(event?.id); - - // Keep refs up to date for cleanup - useEffect(() => { - onUpdateRef.current = onUpdate; - eventIdRef.current = event?.id; - }, [onUpdate, event?.id]); - - useEffect(() => { - if (!event || isEditing) return; - const initialTimeZone = resolveTimezone(event.timeZone); - - setTitle(event.title); - setDescription(event.description ?? ""); - setTimeZone(initialTimeZone); - setAllDay(event.allDay); - setAttendees((event.attendees ?? []).join(", ")); - setRecurrence(event.recurrence ?? ""); - setStartLocal(isoToDatetimeLocal(event.start, initialTimeZone)); - setEndLocal(isoToDatetimeLocal(event.end, initialTimeZone)); - pendingUpdatesRef.current = {}; - setSaveError(null); - }, [event, isEditing]); - - const resolvedTimeZone = useMemo( - () => resolveTimezone(timeZone), - [timeZone], - ); - - // Compute ISO start/end for real-time conflict checking - const conflictCheckParams = useMemo(() => { - if (allDay || !startLocal || !endLocal) { - return { start: null, end: null }; - } - try { - const startIso = datetimeLocalToIso(startLocal, resolvedTimeZone); - const endIso = datetimeLocalToIso(endLocal, resolvedTimeZone); - return { start: startIso, end: endIso }; - } catch { - return { start: null, end: null }; - } - }, [startLocal, endLocal, resolvedTimeZone, allDay]); - - // Real-time conflict checking (matches EventEditForm behavior) - const conflictCheck = useConflictCheck({ - start: conflictCheckParams.start, - end: conflictCheckParams.end, - timeZone: resolvedTimeZone, - excludeEventId: event?.id, - originalStart: event?.start, - originalEnd: event?.end, - enabled: !allDay && Boolean(conflictCheckParams.start && conflictCheckParams.end), - includeExternal: true, - }); - - // Save pending updates on unmount - useEffect(() => { - return () => { - if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); - - const currentEventId = eventIdRef.current; - const pending = pendingUpdatesRef.current; - - if (currentEventId && Object.keys(pending).length > 0) { - onUpdateRef.current(currentEventId, pending).catch(() => {}); - } - }; - }, []); - - const debouncedSave = useCallback( - (updates: UpdateEventBody) => { - if (!event) return; - if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); - - pendingUpdatesRef.current = { - ...pendingUpdatesRef.current, - ...updates, - }; - - saveTimeoutRef.current = setTimeout(async () => { - setIsSaving(true); - const payload = { ...pendingUpdatesRef.current }; - pendingUpdatesRef.current = {}; - - try { - setSaveError(null); - await onUpdate(event.id, payload); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Failed to save changes"; - setSaveError(message); - - // Restore payload to pending so retry works - pendingUpdatesRef.current = { ...payload, ...pendingUpdatesRef.current }; - } finally { - setIsSaving(false); - } - }, SAVE_DEBOUNCE_MS); - }, - [event, onUpdate], - ); - - const handleRetry = async () => { - if (!event) return; - if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); - - setIsSaving(true); - const payload = { ...pendingUpdatesRef.current }; - pendingUpdatesRef.current = {}; - - try { - setSaveError(null); - await onUpdate(event.id, payload); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Retry failed"; - setSaveError(message); - pendingUpdatesRef.current = { ...payload, ...pendingUpdatesRef.current }; - } finally { - setIsSaving(false); - } - }; - - const handleDateChange = (field: "start" | "end", value: string) => { - if (!event) return; - if (field === "start") setStartLocal(value); - if (field === "end") setEndLocal(value); - - const payload = prepareEventUpdatePayload({ - startLocal: field === "start" ? value : startLocal, - endLocal: field === "end" ? value : endLocal, - timeZone: resolvedTimeZone, - }); - - if (!payload.start || !payload.end) return; - - debouncedSave({ start: payload.start, end: payload.end, timeZone: resolvedTimeZone }); - }; - - const handleAttendeesChange = (value: string) => { - setAttendees(value); - }; - - const handleAttendeesBlur = () => { - const payload = prepareEventUpdatePayload({ - attendees, - timeZone: resolvedTimeZone, - }); - debouncedSave({ attendees: payload.attendees }); - }; - - const handleDeleteClick = () => { - setShowDeleteDialog(true); - }; - - const handleDeleteConfirm = async () => { - if (!event) return; - try { - await onDelete(event.id); - setShowDeleteDialog(false); - toast.success("Event deleted"); - onClose(); - } catch (error) { - setShowDeleteDialog(false); - toast.error("Failed to delete event"); - } - }; - - const recurrenceLabel = useMemo(() => { - if (!recurrence || !event?.start) return "Never"; - return buildChipText(recurrence, event.start, resolvedTimeZone); - }, [recurrence, event?.start, resolvedTimeZone]); - - if (!event) return null; - - return ( - - ); -} diff --git a/app/(protected)/schedule/components/event-detail/attendees-section.tsx b/app/(protected)/schedule/components/event-detail/attendees-section.tsx deleted file mode 100644 index f68515d9..00000000 --- a/app/(protected)/schedule/components/event-detail/attendees-section.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { motion } from "framer-motion"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; - -interface AttendeesSectionProps { - attendees: string; - onChange: (value: string) => void; - onBlur: () => void; -} - -export function AttendeesSection({ attendees, onChange, onBlur }: AttendeesSectionProps) { - return ( - -
- - onChange(e.target.value)} - onBlur={onBlur} - placeholder="Add attendees..." - className="border-none bg-transparent px-0 text-base shadow-none focus-visible:ring-0" - /> -
-
- ); -} diff --git a/app/(protected)/schedule/page.tsx b/app/(protected)/schedule/page.tsx deleted file mode 100644 index ffc81f17..00000000 --- a/app/(protected)/schedule/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { getCurrentUser } from "@/lib/session"; -import { getUserTimezoneServer } from "@/lib/timezone"; -import { constructMetadata } from "@/lib/utils"; -import { EVENTS_PAGE_SIZE } from "@/lib/constants/events"; -import { getEventsWithSyncStatus, getEventCount } from "@/lib/events"; -import { DashboardHeader } from "@/components/dashboard/header"; -import { AcceptedEventsList } from "@/components/events/events-list"; -import { CalendarSyncStatus } from "@/components/calendar-sync-status"; -import { getUserTierLimits } from "@/lib/quota"; - -export const metadata = constructMetadata({ - title: "Schedule", - description: "View and manage your calendar events.", -}); - -type RawSearchParams = Record; - -function getStringParam(params: RawSearchParams, key: string): string | undefined { - const value = params[key]; - if (Array.isArray(value)) return value[0]; - return value ?? undefined; -} - -function parsePage(value: string | undefined): number { - const parsed = value ? Number.parseInt(value, 10) : 1; - if (!Number.isFinite(parsed) || parsed < 1) return 1; - return parsed; -} - -export default async function EventsPage({ - searchParams, -}: { - searchParams: Promise; -}) { - const sessionUser = await getCurrentUser(); - const userId = sessionUser!.id!; - - const resolvedSearchParams = await searchParams; - const searchQuery = getStringParam(resolvedSearchParams, "q") ?? ""; - const page = parsePage(getStringParam(resolvedSearchParams, "page")); - - // Fetch user's timezone (cached per request) - const userTimeZone = await getUserTimezoneServer(); - - // Fetch events with sync status and total count - const [eventsWithSyncStatus, totalCount, tierLimits] = await Promise.all([ - getEventsWithSyncStatus(userId, { - searchQuery: searchQuery || undefined, - take: EVENTS_PAGE_SIZE * page, - }), - getEventCount(userId, searchQuery || undefined), - getUserTierLimits(userId), - ]); - - return ( -
- - - -
- ); -} diff --git a/app/(protected)/settings/billing/loading.tsx b/app/(protected)/settings/billing/loading.tsx deleted file mode 100644 index 8e58ee5e..00000000 --- a/app/(protected)/settings/billing/loading.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { DashboardHeader } from "@/components/dashboard/header"; -import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; - -export default function BillingLoading() { - return ( - <> - -
- - -
- - -
-
-
-
-
- - -
- -
- -
- - - - -
-
-
- -
-
- - -
- - - - -
-
-
-
- - ) -} diff --git a/app/(protected)/settings/billing/page.tsx b/app/(protected)/settings/billing/page.tsx deleted file mode 100644 index 58b9f297..00000000 --- a/app/(protected)/settings/billing/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { redirect } from "next/navigation"; - -import { getCurrentUser } from "@/lib/session"; -import { getUserSubscriptionPlan } from "@/lib/subscription"; -import { constructMetadata } from "@/lib/utils"; -import { DashboardHeader } from "@/components/dashboard/header"; -import { BillingInfo } from "@/components/pricing/billing-info"; - -export const metadata = constructMetadata({ - title: "Billing – Simplifying", - description: "Manage billing and your subscription plan.", -}); - -export default async function SettingsBillingPage() { - const user = await getCurrentUser(); - - let userSubscriptionPlan; - if (user && user.id && user.role === "USER") { - userSubscriptionPlan = await getUserSubscriptionPlan(user.id); - } else { - redirect("/login"); - } - - return ( - <> - - - - ); -} diff --git a/app/(protected)/settings/conflicts/page.tsx b/app/(protected)/settings/conflicts/page.tsx deleted file mode 100644 index e641416c..00000000 --- a/app/(protected)/settings/conflicts/page.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { redirect } from "next/navigation"; - -import { auth } from "@/auth"; -import { - getSelectedCalendars, - hasConnectedCalendarCredentials, -} from "@/lib/conflict-detection/calendars"; -import { getUserTierLimits } from "@/lib/quota"; -import { getConflictPreferences } from "@/lib/conflict-detection"; -import { constructMetadata } from "@/lib/utils"; -import { DashboardHeader } from "@/components/dashboard/header"; -import { ConflictSettings } from "@/components/settings/conflict-settings"; - -export const metadata = constructMetadata({ - title: "Conflict Detection – Simplifying", - description: - "Configure conflict detection settings for your calendar events.", -}); - -export default async function ConflictSettingsPage() { - const session = await auth(); - - if (!session?.user?.id) { - redirect("/login"); - } - - const userId = session.user.id; - - // Fetch user preferences, selected calendars, credential status, and tier limits in parallel - const [prefs, calendars, hasConnectedProviders, tierLimits] = await Promise.all([ - getConflictPreferences(userId), - getSelectedCalendars(userId), - hasConnectedCalendarCredentials(userId), - getUserTierLimits(userId), - ]); - - const preferences = { - // Force disabled if user tier doesn't allow it - conflictDetectionEnabled: tierLimits.conflictDetectionEnabled - ? prefs.conflictDetectionEnabled - : false, - defaultBufferBefore: prefs.defaultBufferBefore, - defaultBufferAfter: prefs.defaultBufferAfter, - workingHoursWarningEnabled: tierLimits.conflictDetectionEnabled - ? prefs.workingHoursWarningEnabled - : false, - }; - - return ( - <> - - - - ); -} - diff --git a/app/(protected)/settings/general/loading.tsx b/app/(protected)/settings/general/loading.tsx deleted file mode 100644 index d80bfbf2..00000000 --- a/app/(protected)/settings/general/loading.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { DashboardHeader } from "@/components/dashboard/header"; -import { SkeletonSection } from "@/components/shared/section-skeleton"; -import { Skeleton } from "@/components/ui/skeleton"; - -export default function ProfileLoading() { - return ( - <> - -
- - - {/* Timezone */} -
-
- - -
-
- - -
-
- - {/* Week Start Day */} -
-
- - -
-
-
- - -
-
- - -
-
-
- - {/* Working Hours */} -
-
- - -
-
-
- - -
- -
-
- - {/* Working Days */} -
-
- - -
-
-
- {Array.from({ length: 7 }).map((_, i) => ( - - ))} -
-
- - - -
-
-
- - -
- - ); -} diff --git a/app/(protected)/settings/general/page.tsx b/app/(protected)/settings/general/page.tsx deleted file mode 100644 index 5d017362..00000000 --- a/app/(protected)/settings/general/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { useSession } from "next-auth/react"; -import { toast } from "sonner"; - -import { usePreferencesStore } from "@/lib/store/preferences-store"; -import { DeleteAccountSection } from "@/components/dashboard/delete-account"; -import { DashboardHeader } from "@/components/dashboard/header"; -import { Icons } from "@/components/shared/icons"; -import { UserNameForm } from "@/components/forms/user-name-form"; -import { UserPreferencesForm } from "@/components/forms/user-preferences-form"; - -export default function GeneralSettingsPage() { - const router = useRouter(); - const { data: session, status } = useSession(); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - // Redirect if not authenticated - if (status === "unauthenticated") { - router.push("/login"); - return; - } - - // Wait for session to load - if (status === "loading") { - return; - } - - // Load preferences from server via API - const loadPreferences = async () => { - try { - await usePreferencesStore.getState().loadFromServer(); - } catch (error) { - toast.error("Failed to load preferences"); - console.error("Preferences load error:", error); - } finally { - setIsLoading(false); - } - }; - - if (session?.user) { - loadPreferences(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session?.user?.id, status]); - - // Show loading state while session or preferences are loading - if (status === "loading" || isLoading) { - return ( -
- -
- ); - } - - // Session is loaded but no user (shouldn't happen due to middleware, but safety check) - if (!session?.user) { - return null; - } - - const user = session.user; - - return ( - <> - -
- - - -
- - ); -} diff --git a/app/(protected)/settings/integrations/loading.tsx b/app/(protected)/settings/integrations/loading.tsx deleted file mode 100644 index 25b10772..00000000 --- a/app/(protected)/settings/integrations/loading.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { DashboardHeader } from "@/components/dashboard/header"; -import { Skeleton } from "@/components/ui/skeleton"; - -function PrimarySelectorRowSkeleton() { - return ( -
-
- -
- - -
-
-
- - -
-
- ); -} - -function IntegrationRowSkeleton() { - return ( -
-
-
- -
- - -
-
-
- - -
-
-
- ); -} - -export default function IntegrationsLoading() { - return ( - <> - - - {/* Primary selectors card */} -
-
- -
-
- -
-
- - {/* Integrations list card */} -
-
- -
-
- -
-
- -
-
- -
-
- - ); -} diff --git a/app/(protected)/settings/integrations/page.tsx b/app/(protected)/settings/integrations/page.tsx deleted file mode 100644 index ec727a28..00000000 --- a/app/(protected)/settings/integrations/page.tsx +++ /dev/null @@ -1,296 +0,0 @@ -import { getCurrentUser } from "@/lib/session"; -import { constructMetadata } from "@/lib/utils"; -import * as Sentry from "@sentry/nextjs"; -import { - getAllCalendarCredentials, - getDestinationCalendar, - invalidateCalendarCredentialById, -} from "@/lib/calendar"; -import { - getAllTaskCredentials, - getDestinationTaskList, - invalidateTaskCredentialById, -} from "@/lib/tasks"; -import { CalendarProviderFactory, TaskProviderFactory } from "@/lib/providers/factory"; -import { ProviderError } from "@/lib/providers/types"; -import { ErrorType } from "@/lib/errors/types"; -import { DashboardHeader } from "@/components/dashboard/header"; -import { GoogleCalendarIntegration } from "@/components/integrations/google-calendar-integration"; -import { GoogleTasksIntegration } from "@/components/integrations/google-tasks-integration"; -import { OutlookCalendarIntegration } from "@/components/integrations/outlook-calendar-integration"; -import { MicrosoftTodoIntegration } from "@/components/integrations/microsoft-todo-integration"; -import { IntegrationToasts } from "@/components/integrations/integration-toasts"; -import { PrimaryCalendarSelector, CalendarOption } from "@/components/integrations/primary-calendar-selector"; -import { PrimaryTaskListSelector, TaskListOption } from "@/components/integrations/primary-task-list-selector"; -import { IntegrationErrorBoundary } from "@/components/integrations/integration-error-boundary"; - -function isInsufficientScopesError(error: unknown): boolean { - if (error instanceof Error) { - const message = error.message.toLowerCase(); - return ( - message.includes("insufficient") || - message.includes("scope") || - message.includes("insufficientauthenticationscopes") || - message.includes("403") || - message.includes("forbidden") || - message.includes("access denied") - ); - } - return false; -} - -async function handleProviderFetchError( - error: unknown, - credentialId: string, - provider: "google" | "microsoft", - resource: "calendar" | "tasks", - invalidateCredential: (id: string, reason: string) => Promise, -): Promise { - Sentry.captureException(error, { - tags: { provider, resource }, - extra: { credentialId }, - }); - - if (error instanceof ProviderError) { - const { type, message } = error.normalized; - if (type === ErrorType.Authentication || type === ErrorType.Authorization) { - await invalidateCredential(credentialId, message); - return true; - } - } - if (isInsufficientScopesError(error)) { - await invalidateCredential(credentialId, "INSUFFICIENT_SCOPES"); - return true; - } - return false; -} - -export const metadata = constructMetadata({ - title: "Integrations – Simplifying", - description: "Connect your calendars and task managers. Supports Google Calendar, Outlook Calendar, Google Tasks, and Microsoft To Do.", -}); - -export default async function SettingsIntegrationsPage() { - const sessionUser = await getCurrentUser(); - const userId = sessionUser!.id!; - - // Fetch all credentials and destination settings - const [allCalendarCredentials, destinationCalendar, allTaskCredentials, destinationTaskList] = await Promise.all([ - getAllCalendarCredentials(userId), - getDestinationCalendar(userId), - getAllTaskCredentials(userId), - getDestinationTaskList(userId), - ]); - - // Group credentials by provider - const googleCalendarCredential = allCalendarCredentials.find(c => c.provider === "GOOGLE_CALENDAR"); - const microsoftCalendarCredential = allCalendarCredentials.find(c => c.provider === "MICROSOFT_CALENDAR"); - const googleTasksCredential = allTaskCredentials.find(c => c.provider === "GOOGLE_TASKS"); - const microsoftTasksCredential = allTaskCredentials.find(c => c.provider === "MICROSOFT_TODO"); - - // Fetch calendars and task lists for each provider - let googleCalendars: Array<{ id: string; name: string; isPrimary: boolean; backgroundColor?: string | null }> = []; - let microsoftCalendars: Array<{ id: string; name: string; isPrimary: boolean; backgroundColor?: string | null }> = []; - let googleTaskLists: Array<{ id: string; name: string; isPrimary: boolean }> = []; - let microsoftTaskLists: Array<{ id: string; name: string; isPrimary: boolean }> = []; - - let googleCalendarNeedsReconnect = false; - let microsoftCalendarNeedsReconnect = false; - let googleTasksNeedsReconnect = false; - let microsoftTasksNeedsReconnect = false; - - // Fetch Google Calendar calendars - if (googleCalendarCredential && !googleCalendarCredential.invalid) { - try { - const provider = CalendarProviderFactory.create("GOOGLE_CALENDAR"); - const allCalendars = await provider.listCalendars(googleCalendarCredential); - googleCalendars = allCalendars.filter((cal) => cal.canEdit); - } catch (error) { - googleCalendarNeedsReconnect = await handleProviderFetchError( - error, - googleCalendarCredential.id, - "google", - "calendar", - invalidateCalendarCredentialById, - ); - } - } - - // Fetch Microsoft Calendar calendars - if (microsoftCalendarCredential && !microsoftCalendarCredential.invalid) { - try { - const provider = CalendarProviderFactory.create("MICROSOFT_CALENDAR"); - const allCalendars = await provider.listCalendars(microsoftCalendarCredential); - microsoftCalendars = allCalendars.filter((cal) => cal.canEdit); - } catch (error) { - microsoftCalendarNeedsReconnect = await handleProviderFetchError( - error, - microsoftCalendarCredential.id, - "microsoft", - "calendar", - invalidateCalendarCredentialById, - ); - } - } - - // Fetch Google Tasks task lists - if (googleTasksCredential && !googleTasksCredential.invalid) { - try { - const provider = TaskProviderFactory.create("GOOGLE_TASKS"); - googleTaskLists = await provider.listTaskLists(googleTasksCredential); - } catch (error) { - googleTasksNeedsReconnect = await handleProviderFetchError( - error, - googleTasksCredential.id, - "google", - "tasks", - invalidateTaskCredentialById, - ); - } - } - - // Fetch Microsoft To Do task lists - if (microsoftTasksCredential && !microsoftTasksCredential.invalid) { - try { - const provider = TaskProviderFactory.create("MICROSOFT_TODO"); - microsoftTaskLists = await provider.listTaskLists(microsoftTasksCredential); - } catch (error) { - microsoftTasksNeedsReconnect = await handleProviderFetchError( - error, - microsoftTasksCredential.id, - "microsoft", - "tasks", - invalidateTaskCredentialById, - ); - } - } - - // Determine connection states - const googleCalendarConnected = Boolean(googleCalendarCredential && !googleCalendarCredential.invalid && !googleCalendarNeedsReconnect); - const microsoftCalendarConnected = Boolean(microsoftCalendarCredential && !microsoftCalendarCredential.invalid && !microsoftCalendarNeedsReconnect); - const googleTasksConnected = Boolean(googleTasksCredential && !googleTasksCredential.invalid && !googleTasksNeedsReconnect); - const microsoftTasksConnected = Boolean(microsoftTasksCredential && !microsoftTasksCredential.invalid && !microsoftTasksNeedsReconnect); - - // Determine invalid states (exclude user-initiated disconnects) - const googleCalendarIsInvalid = googleCalendarNeedsReconnect || ( - googleCalendarCredential?.invalid === true && - googleCalendarCredential?.invalidReason !== "USER_DISCONNECTED" && - googleCalendarCredential?.invalidReason !== "REPLACED" - ); - const microsoftCalendarIsInvalid = microsoftCalendarNeedsReconnect || ( - microsoftCalendarCredential?.invalid === true && - microsoftCalendarCredential?.invalidReason !== "USER_DISCONNECTED" && - microsoftCalendarCredential?.invalidReason !== "REPLACED" - ); - const googleTasksIsInvalid = googleTasksNeedsReconnect || ( - googleTasksCredential?.invalid === true && - googleTasksCredential?.invalidReason !== "USER_DISCONNECTED" && - googleTasksCredential?.invalidReason !== "REPLACED" - ); - const microsoftTasksIsInvalid = microsoftTasksNeedsReconnect || ( - microsoftTasksCredential?.invalid === true && - microsoftTasksCredential?.invalidReason !== "USER_DISCONNECTED" && - microsoftTasksCredential?.invalidReason !== "REPLACED" - ); - - // Derive primary provider state from destinations - const primaryCalendarProvider = destinationCalendar?.provider; - const primaryTasksProvider = destinationTaskList?.provider; - - // Compute primary status for each integration - const isGoogleCalendarPrimary = primaryCalendarProvider === "GOOGLE_CALENDAR"; - const isMicrosoftCalendarPrimary = primaryCalendarProvider === "MICROSOFT_CALENDAR"; - const isGoogleTasksPrimary = primaryTasksProvider === "GOOGLE_TASKS"; - const isMicrosoftTasksPrimary = primaryTasksProvider === "MICROSOFT_TODO"; - - // Build unified lists for selectors - const allUnifiedCalendars: CalendarOption[] = [ - ...googleCalendars.map((c) => ({ - ...c, - provider: "GOOGLE_CALENDAR" as const, - credentialId: googleCalendarCredential?.id!, - })), - ...microsoftCalendars.map((c) => ({ - ...c, - provider: "MICROSOFT_CALENDAR" as const, - credentialId: microsoftCalendarCredential?.id!, - })), - ]; - - const allUnifiedTaskLists: TaskListOption[] = [ - ...googleTaskLists.map((t) => ({ - ...t, - provider: "GOOGLE_TASKS" as const, - credentialId: googleTasksCredential?.id!, - })), - ...microsoftTaskLists.map((t) => ({ - ...t, - provider: "MICROSOFT_TODO" as const, - credentialId: microsoftTasksCredential?.id!, - })), - ]; - - return ( - - - - -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- ); -} diff --git a/app/(protected)/settings/page.tsx b/app/(protected)/settings/page.tsx deleted file mode 100644 index 8f24bf88..00000000 --- a/app/(protected)/settings/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function SettingsIndexPage() { - redirect("/settings/general"); -} diff --git a/app/(protected)/task/components/task-detail-pane.tsx b/app/(protected)/task/components/task-detail-pane.tsx deleted file mode 100644 index 160193eb..00000000 --- a/app/(protected)/task/components/task-detail-pane.tsx +++ /dev/null @@ -1,274 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { type Task, Priority } from "@prisma/client"; -import type { UpdateTaskPayload } from "@/lib/validations/tasks"; -import { motion } from "framer-motion"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { cn } from "@/lib/utils"; -import { DueDatePickerPopover } from "./due-date-picker-popover"; -import { PriorityPickerPopover } from "./priority-picker-popover"; -import { formatInTimeZone, fromZonedTime } from "date-fns-tz"; -import { isoToDatetimeLocal, datetimeLocalToIso } from "@/lib/timezone-utils"; -import { TaskSidePanelShell } from "@/components/task/task-side-panel-shell"; - -interface TaskDetailPaneProps { - task: Task | null; - onClose: () => void; - onUpdate: (taskId: string, data: UpdateTaskPayload) => Promise; - onDelete: (taskId: string) => Promise; - className?: string; -} - -const PRIORITY_LABELS = { - LOW: "Low", - MEDIUM: "Medium", - HIGH: "High", -}; - -const SAVE_DEBOUNCE_MS = 200; - -const sectionVariants = { - hidden: { opacity: 0, y: 8 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.3, - ease: [0.16, 1, 0.3, 1] as const, - }, - }, -}; - -export function TaskDetailPane({ task, onClose, onUpdate, onDelete, className }: TaskDetailPaneProps) { - const [title, setTitle] = useState(task?.title || ""); - const [description, setDescription] = useState(task?.description || ""); - const [priority, setPriority] = useState(task?.priority ?? null); - const [dueDateLocal, setDueDateLocal] = useState(""); - const [isSaving, setIsSaving] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [isEditing, setIsEditing] = useState(false); - - const saveTimeoutRef = useRef(); - - const timeZone = useMemo( - () => task?.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", - [task?.timeZone], - ); - - useEffect(() => { - if (task?.dueDate) { - const formatted = isoToDatetimeLocal( - task.dueDate instanceof Date ? task.dueDate.toISOString() : task.dueDate, - timeZone - ); - setDueDateLocal(formatted); - } else { - setDueDateLocal(""); - } - }, [task?.dueDate, timeZone]); - - useEffect(() => { - if (task && !isEditing) { - setTitle(task.title); - setDescription(task.description || ""); - setPriority(task.priority ?? null); - } - }, [task, isEditing]); - - const debouncedSave = useCallback( - async (updates: UpdateTaskPayload) => { - if (!task) return; - - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } - - saveTimeoutRef.current = setTimeout(async () => { - setIsSaving(true); - try { - await onUpdate(task.id, updates); - } catch (error) { - console.error("Failed to save:", error); - } finally { - setIsSaving(false); - } - }, SAVE_DEBOUNCE_MS); - }, - [task, onUpdate] - ); - - const handleTitleChange = (e: React.ChangeEvent) => { - const newTitle = e.target.value; - setTitle(newTitle); - - if (newTitle && newTitle !== task?.title) { - debouncedSave({ title: newTitle }); - } - }; - - const handleDescriptionChange = (e: React.ChangeEvent) => { - const newDescription = e.target.value; - setDescription(newDescription); - - if (newDescription !== (task?.description || "")) { - debouncedSave({ description: newDescription || null }); - } - }; - - const handlePriorityChange = (newPriority: Priority | null) => { - setPriority(newPriority); - if (newPriority !== (task?.priority ?? null)) { - debouncedSave({ priority: newPriority }); - } - }; - - const handleDueDateChange = (newDate: string) => { - setDueDateLocal(newDate); - debouncedSave({ - dueDate: datetimeLocalToIso(newDate, timeZone), - timeZone: timeZone, - }); - }; - - const handleDeleteClick = () => { - setShowDeleteDialog(true); - }; - - const handleDeleteConfirm = async () => { - if (!task) return; - try { - await onDelete(task.id); - setShowDeleteDialog(false); - onClose(); - } catch (error) { - console.error("Failed to delete task:", error); - setShowDeleteDialog(false); - } - }; - - useEffect(() => { - return () => { - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } - }; - }, []); - - if (!task) { - return null; - } - - const formattedDueDate = dueDateLocal - ? formatInTimeZone(fromZonedTime(dueDateLocal, timeZone), timeZone, "MMM d, h:mm a") - : null; - - const priorityLabel = priority ? PRIORITY_LABELS[priority] : "None"; - const dueDateLabel = formattedDueDate ?? "None"; - - return ( - <> - -
- {/* Title Section */} - - setIsEditing(true)} - onBlur={() => setIsEditing(false)} - className="border-none bg-transparent px-0 text-3xl font-bold tracking-tight shadow-none outline-none placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" - placeholder="Task title" - aria-label="Task title" - /> - - - {/* Details Group */} - - {/* Priority */} -
- -
- -
-
- - {/* Due Date */} -
- -
- -
-
-
- - {/* Description Group */} - -