Compare commits

...

10 Commits

Author SHA1 Message Date
f38b0188df 🚨 fix: resolve all linter and typecheck warnings across codebase 2026-04-07 14:42:24 -04:00
54ca910609 🔧 chore: add biome config for Tailwind CSS and CSS module declarations 2026-04-07 14:42:17 -04:00
fab0d2ff47 refactor(components): derive ImagePicker variant types from buttonVariants
Replace manually duplicated variant/size type literals with
VariantProps<typeof buttonVariants> for type safety and
consistency with the Button component.
2026-04-07 13:11:42 -04:00
a7716d87df fix(components): add unoptimized image prop, overflow containment, and accessibility
Add unoptimized prop to image preview to support blob URLs, contain
overflow on preview container, replace div with semantic section
element in DragDropContainer with aria-label, and import shared
image constants.
2026-04-07 13:11:35 -04:00
096f548ec3 refactor(page): extract AI handler helpers and add client-side image validation
Break down the monolithic handleAiCreate into focused helpers
(sendAiRequest, persistAiEvents, populateEventForm), add client-side
image file validation before upload, and use toast.promise finally
callback for loading state cleanup.
2026-04-07 13:11:26 -04:00
79f98ebfd3 refactor(api): simplify AI event route with extracted utilities and env-based model
Replace inline JSON extraction with the shared json-utils module,
extract chat response content parsing into a dedicated helper, make
the AI model configurable via AI_MODEL env var, and improve error
messages for production safety.
2026-04-07 13:11:15 -04:00
7bb4f2be9d refactor(types): strengthen Zod schemas with regex validation and derive CalendarEvent
Add regex-based data URL validation for images, compute binary size
from base64 for accurate 10MB limit, enforce datetime strings with
offset for start/end fields, and derive CalendarEvent from the AI
response item type to eliminate field duplication.
2026-04-07 13:11:05 -04:00
dc4204a740 refactor(lib): extract shared image constants and JSON parsing utilities
Move image extensions, MIME types, and size limit into a dedicated
constants module. Extract JSON-from-text parsing into a pure utility
function for reuse across the codebase.
2026-04-07 13:10:59 -04:00
a0a7e021a8 style: standardize import ordering, type imports, and formatting in source files
Sort imports alphabetically, convert value imports to type-only where
appropriate, normalize indentation to tabs, and sort exports
alphabetically across UI components, pages, and lib modules.
2026-04-07 13:10:49 -04:00
cbae9fa1c9 style: standardize formatting in opencode tooling files
Reformat JSON configs and TypeScript scripts to use consistent
tab indentation, semicolons, and double quotes.
2026-04-07 13:10:35 -04:00
50 changed files with 1372 additions and 1260 deletions

View File

@@ -1,6 +1,6 @@
{ {
"source": "/tmp/skill-selector-curated-1953505229", "source": "/tmp/skill-selector-curated-1953505229",
"sourceType": "local", "sourceType": "local",
"localPath": "/tmp/skill-selector-curated-1953505229/zod-validation-expert", "localPath": "/tmp/skill-selector-curated-1953505229/zod-validation-expert",
"installedAt": "2026-04-07T15:11:20.921Z" "installedAt": "2026-04-07T15:11:20.921Z"
} }

View File

@@ -1,363 +1,341 @@
{ {
"$schema": "https://opencode.ai/schemas/agent-metadata.json", "$schema": "https://opencode.ai/schemas/agent-metadata.json",
"schema_version": "1.0.0", "schema_version": "1.0.0",
"description": "Centralized metadata for OpenAgents Control agents. This file stores metadata that is not part of the OpenCode agent schema but is needed for registry management, installation, and documentation.", "description": "Centralized metadata for OpenAgents Control agents. This file stores metadata that is not part of the OpenCode agent schema but is needed for registry management, installation, and documentation.",
"agents": { "agents": {
"openagent": { "openagent": {
"id": "openagent", "id": "openagent",
"name": "OpenAgent", "name": "OpenAgent",
"category": "core", "category": "core",
"type": "agent", "type": "agent",
"version": "1.0.0", "version": "1.0.0",
"author": "opencode", "author": "opencode",
"tags": ["universal", "coordination", "primary"], "tags": ["universal", "coordination", "primary"],
"dependencies": [ "dependencies": [
"subagent:task-manager", "subagent:task-manager",
"subagent:batch-executor", "subagent:batch-executor",
"subagent:documentation", "subagent:documentation",
"subagent:contextscout", "subagent:contextscout",
"subagent:externalscout", "subagent:externalscout",
"context:standards-code", "context:standards-code",
"context:standards-docs", "context:standards-docs",
"context:standards-tests", "context:standards-tests",
"context:review-ref", "context:review-ref",
"context:delegation-ref", "context:delegation-ref",
"context:external-libraries-workflow" "context:external-libraries-workflow"
] ]
}, },
"opencoder": { "opencoder": {
"id": "opencoder", "id": "opencoder",
"name": "OpenCoder", "name": "OpenCoder",
"category": "core", "category": "core",
"type": "agent", "type": "agent",
"version": "1.0.0", "version": "1.0.0",
"author": "opencode", "author": "opencode",
"tags": ["development", "coding", "implementation"], "tags": ["development", "coding", "implementation"],
"dependencies": [ "dependencies": [
"subagent:documentation", "subagent:documentation",
"subagent:task-manager", "subagent:task-manager",
"subagent:batch-executor", "subagent:batch-executor",
"subagent:coder-agent", "subagent:coder-agent",
"subagent:tester", "subagent:tester",
"subagent:reviewer", "subagent:reviewer",
"subagent:build-agent", "subagent:build-agent",
"subagent:contextscout", "subagent:contextscout",
"subagent:externalscout", "subagent:externalscout",
"context:standards-code", "context:standards-code",
"context:task-delegation-basics", "context:task-delegation-basics",
"context:component-planning", "context:component-planning",
"context:external-libraries-workflow" "context:external-libraries-workflow"
] ]
}, },
"repo-manager": { "repo-manager": {
"id": "repo-manager", "id": "repo-manager",
"name": "Repo Manager", "name": "Repo Manager",
"category": "meta", "category": "meta",
"type": "agent", "type": "agent",
"version": "1.0.0", "version": "1.0.0",
"author": "opencode", "author": "opencode",
"tags": ["repository", "management", "orchestration"], "tags": ["repository", "management", "orchestration"],
"dependencies": [ "dependencies": [
"subagent:task-manager", "subagent:task-manager",
"subagent:contextscout", "subagent:contextscout",
"subagent:documentation", "subagent:documentation",
"subagent:coder-agent", "subagent:coder-agent",
"subagent:tester", "subagent:tester",
"subagent:reviewer", "subagent:reviewer",
"subagent:build-agent" "subagent:build-agent"
] ]
}, },
"system-builder": { "system-builder": {
"id": "system-builder", "id": "system-builder",
"name": "System Builder", "name": "System Builder",
"category": "meta", "category": "meta",
"type": "agent", "type": "agent",
"version": "1.0.0", "version": "1.0.0",
"author": "opencode", "author": "opencode",
"tags": ["system-generation", "architecture", "scaffolding"], "tags": ["system-generation", "architecture", "scaffolding"],
"dependencies": [ "dependencies": [
"subagent:agent-generator", "subagent:agent-generator",
"subagent:command-creator", "subagent:command-creator",
"subagent:domain-analyzer", "subagent:domain-analyzer",
"subagent:context-organizer", "subagent:context-organizer",
"subagent:workflow-designer" "subagent:workflow-designer"
] ]
}, },
"copywriter": { "copywriter": {
"id": "copywriter", "id": "copywriter",
"name": "Copywriter", "name": "Copywriter",
"category": "content", "category": "content",
"type": "agent", "type": "agent",
"version": "1.0.0", "version": "1.0.0",
"author": "opencode", "author": "opencode",
"tags": ["content", "marketing", "writing"], "tags": ["content", "marketing", "writing"],
"dependencies": [ "dependencies": ["context:standards-docs"]
"context:standards-docs" },
] "technical-writer": {
}, "id": "technical-writer",
"technical-writer": { "name": "Technical Writer",
"id": "technical-writer", "category": "content",
"name": "Technical Writer", "type": "agent",
"category": "content", "version": "1.0.0",
"type": "agent", "author": "opencode",
"version": "1.0.0", "tags": ["documentation", "technical", "writing"],
"author": "opencode", "dependencies": ["context:standards-docs"]
"tags": ["documentation", "technical", "writing"], },
"dependencies": [ "data-analyst": {
"context:standards-docs" "id": "data-analyst",
] "name": "Data Analyst",
}, "category": "data",
"data-analyst": { "type": "agent",
"id": "data-analyst", "version": "1.0.0",
"name": "Data Analyst", "author": "opencode",
"category": "data", "tags": ["data", "analysis", "visualization"],
"type": "agent", "dependencies": []
"version": "1.0.0", },
"author": "opencode", "eval-runner": {
"tags": ["data", "analysis", "visualization"], "id": "eval-runner",
"dependencies": [] "name": "Eval Runner",
}, "category": "testing",
"eval-runner": { "type": "agent",
"id": "eval-runner", "version": "1.0.0",
"name": "Eval Runner", "author": "opencode",
"category": "testing", "tags": ["testing", "evaluation", "quality"],
"type": "agent", "dependencies": ["context:standards-tests"]
"version": "1.0.0", },
"author": "opencode", "task-manager": {
"tags": ["testing", "evaluation", "quality"], "id": "task-manager",
"dependencies": [ "name": "TaskManager",
"context:standards-tests" "category": "subagents/core",
] "type": "subagent",
}, "version": "2.0.0",
"task-manager": { "author": "opencode",
"id": "task-manager", "tags": ["task-breakdown", "planning", "coordination"],
"name": "TaskManager", "dependencies": ["context:task-delegation-basics"]
"category": "subagents/core", },
"type": "subagent", "batch-executor": {
"version": "2.0.0", "id": "batch-executor",
"author": "opencode", "name": "BatchExecutor",
"tags": ["task-breakdown", "planning", "coordination"], "category": "subagents/core",
"dependencies": [ "type": "subagent",
"context:task-delegation-basics" "version": "1.0.0",
] "author": "opencode",
}, "tags": ["parallel-execution", "batch-management", "coordination"],
"batch-executor": { "dependencies": ["subagent:coder-agent", "subagent:task-manager"]
"id": "batch-executor", },
"name": "BatchExecutor", "documentation": {
"category": "subagents/core", "id": "documentation",
"type": "subagent", "name": "DocWriter",
"version": "1.0.0", "category": "subagents/core",
"author": "opencode", "type": "subagent",
"tags": ["parallel-execution", "batch-management", "coordination"], "version": "1.0.0",
"dependencies": [ "author": "opencode",
"subagent:coder-agent", "tags": ["documentation", "writing"],
"subagent:task-manager" "dependencies": ["context:standards-docs"]
] },
}, "contextscout": {
"documentation": { "id": "contextscout",
"id": "documentation", "name": "ContextScout",
"name": "DocWriter", "category": "subagents/core",
"category": "subagents/core", "type": "subagent",
"type": "subagent", "version": "1.0.0",
"version": "1.0.0", "author": "opencode",
"author": "opencode", "tags": ["context", "discovery", "search"],
"tags": ["documentation", "writing"], "dependencies": []
"dependencies": [ },
"context:standards-docs" "externalscout": {
] "id": "externalscout",
}, "name": "ExternalScout",
"contextscout": { "category": "subagents/core",
"id": "contextscout", "type": "subagent",
"name": "ContextScout", "version": "1.0.0",
"category": "subagents/core", "author": "opencode",
"type": "subagent", "tags": ["external", "documentation", "search"],
"version": "1.0.0", "dependencies": []
"author": "opencode", },
"tags": ["context", "discovery", "search"], "context-manager": {
"dependencies": [] "id": "context-manager",
}, "name": "ContextManager",
"externalscout": { "category": "subagents/core",
"id": "externalscout", "type": "subagent",
"name": "ExternalScout", "version": "1.0.0",
"category": "subagents/core", "author": "opencode",
"type": "subagent", "tags": ["context", "management", "organization"],
"version": "1.0.0", "dependencies": []
"author": "opencode", },
"tags": ["external", "documentation", "search"], "context-retriever": {
"dependencies": [] "id": "context-retriever",
}, "name": "Context Retriever",
"context-manager": { "category": "subagents/core",
"id": "context-manager", "type": "subagent",
"name": "ContextManager", "version": "1.0.0",
"category": "subagents/core", "author": "opencode",
"type": "subagent", "tags": ["context", "retrieval", "search"],
"version": "1.0.0", "dependencies": []
"author": "opencode", },
"tags": ["context", "management", "organization"], "coder-agent": {
"dependencies": [] "id": "coder-agent",
}, "name": "CoderAgent",
"context-retriever": { "category": "subagents/code",
"id": "context-retriever", "type": "subagent",
"name": "Context Retriever", "version": "1.0.0",
"category": "subagents/core", "author": "opencode",
"type": "subagent", "tags": ["coding", "implementation"],
"version": "1.0.0", "dependencies": ["context:standards-code"]
"author": "opencode", },
"tags": ["context", "retrieval", "search"], "tester": {
"dependencies": [] "id": "tester",
}, "name": "TestEngineer",
"coder-agent": { "category": "subagents/code",
"id": "coder-agent", "type": "subagent",
"name": "CoderAgent", "version": "1.0.0",
"category": "subagents/code", "author": "opencode",
"type": "subagent", "tags": ["testing", "tdd", "quality"],
"version": "1.0.0", "dependencies": ["context:standards-tests"]
"author": "opencode", },
"tags": ["coding", "implementation"], "reviewer": {
"dependencies": [ "id": "reviewer",
"context:standards-code" "name": "CodeReviewer",
] "category": "subagents/code",
}, "type": "subagent",
"tester": { "version": "1.0.0",
"id": "tester", "author": "opencode",
"name": "TestEngineer", "tags": ["review", "security", "quality"],
"category": "subagents/code", "dependencies": ["context:standards-code", "context:review-ref"]
"type": "subagent", },
"version": "1.0.0", "build-agent": {
"author": "opencode", "id": "build-agent",
"tags": ["testing", "tdd", "quality"], "name": "BuildAgent",
"dependencies": [ "category": "subagents/code",
"context:standards-tests" "type": "subagent",
] "version": "1.0.0",
}, "author": "opencode",
"reviewer": { "tags": ["build", "validation", "type-checking"],
"id": "reviewer", "dependencies": []
"name": "CodeReviewer", },
"category": "subagents/code", "frontend-specialist": {
"type": "subagent", "id": "frontend-specialist",
"version": "1.0.0", "name": "OpenFrontendSpecialist",
"author": "opencode", "category": "subagents/development",
"tags": ["review", "security", "quality"], "type": "subagent",
"dependencies": [ "version": "1.0.0",
"context:standards-code", "author": "opencode",
"context:review-ref" "tags": ["frontend", "ui", "design"],
] "dependencies": ["context:standards-code"]
}, },
"build-agent": { "devops-specialist": {
"id": "build-agent", "id": "devops-specialist",
"name": "BuildAgent", "name": "OpenDevopsSpecialist",
"category": "subagents/code", "category": "subagents/development",
"type": "subagent", "type": "subagent",
"version": "1.0.0", "version": "1.0.0",
"author": "opencode", "author": "opencode",
"tags": ["build", "validation", "type-checking"], "tags": ["devops", "ci-cd", "infrastructure"],
"dependencies": [] "dependencies": []
}, },
"frontend-specialist": { "agent-generator": {
"id": "frontend-specialist", "id": "agent-generator",
"name": "OpenFrontendSpecialist", "name": "AgentGenerator",
"category": "subagents/development", "category": "subagents/system-builder",
"type": "subagent", "type": "subagent",
"version": "1.0.0", "version": "1.0.0",
"author": "opencode", "author": "opencode",
"tags": ["frontend", "ui", "design"], "tags": ["generation", "agents", "scaffolding"],
"dependencies": [ "dependencies": []
"context:standards-code" },
] "command-creator": {
}, "id": "command-creator",
"devops-specialist": { "name": "CommandCreator",
"id": "devops-specialist", "category": "subagents/system-builder",
"name": "OpenDevopsSpecialist", "type": "subagent",
"category": "subagents/development", "version": "1.0.0",
"type": "subagent", "author": "opencode",
"version": "1.0.0", "tags": ["commands", "generation", "scaffolding"],
"author": "opencode", "dependencies": []
"tags": ["devops", "ci-cd", "infrastructure"], },
"dependencies": [] "domain-analyzer": {
}, "id": "domain-analyzer",
"agent-generator": { "name": "DomainAnalyzer",
"id": "agent-generator", "category": "subagents/system-builder",
"name": "AgentGenerator", "type": "subagent",
"category": "subagents/system-builder", "version": "1.0.0",
"type": "subagent", "author": "opencode",
"version": "1.0.0", "tags": ["analysis", "domain", "architecture"],
"author": "opencode", "dependencies": []
"tags": ["generation", "agents", "scaffolding"], },
"dependencies": [] "context-organizer": {
}, "id": "context-organizer",
"command-creator": { "name": "ContextOrganizer",
"id": "command-creator", "category": "subagents/system-builder",
"name": "CommandCreator", "type": "subagent",
"category": "subagents/system-builder", "version": "1.0.0",
"type": "subagent", "author": "opencode",
"version": "1.0.0", "tags": ["context", "organization", "structure"],
"author": "opencode", "dependencies": []
"tags": ["commands", "generation", "scaffolding"], },
"dependencies": [] "workflow-designer": {
}, "id": "workflow-designer",
"domain-analyzer": { "name": "WorkflowDesigner",
"id": "domain-analyzer", "category": "subagents/system-builder",
"name": "DomainAnalyzer", "type": "subagent",
"category": "subagents/system-builder", "version": "1.0.0",
"type": "subagent", "author": "opencode",
"version": "1.0.0", "tags": ["workflow", "design", "architecture"],
"author": "opencode", "dependencies": []
"tags": ["analysis", "domain", "architecture"], },
"dependencies": [] "image-specialist": {
}, "id": "image-specialist",
"context-organizer": { "name": "Image Specialist",
"id": "context-organizer", "category": "subagents/utils",
"name": "ContextOrganizer", "type": "subagent",
"category": "subagents/system-builder", "version": "1.0.0",
"type": "subagent", "author": "opencode",
"version": "1.0.0", "tags": ["images", "editing", "generation"],
"author": "opencode", "dependencies": []
"tags": ["context", "organization", "structure"], },
"dependencies": [] "simple-responder": {
}, "id": "simple-responder",
"workflow-designer": { "name": "Simple Responder",
"id": "workflow-designer", "category": "subagents/test",
"name": "WorkflowDesigner", "type": "subagent",
"category": "subagents/system-builder", "version": "1.0.0",
"type": "subagent", "author": "opencode",
"version": "1.0.0", "tags": ["testing", "evaluation"],
"author": "opencode", "dependencies": []
"tags": ["workflow", "design", "architecture"], }
"dependencies": [] },
}, "defaults": {
"image-specialist": { "agent": {
"id": "image-specialist", "version": "1.0.0",
"name": "Image Specialist", "author": "opencode",
"category": "subagents/utils", "type": "agent",
"type": "subagent", "tags": []
"version": "1.0.0", },
"author": "opencode", "subagent": {
"tags": ["images", "editing", "generation"], "version": "1.0.0",
"dependencies": [] "author": "opencode",
}, "type": "subagent",
"simple-responder": { "tags": []
"id": "simple-responder", }
"name": "Simple Responder", }
"category": "subagents/test",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["testing", "evaluation"],
"dependencies": []
}
},
"defaults": {
"agent": {
"version": "1.0.0",
"author": "opencode",
"type": "agent",
"tags": []
},
"subagent": {
"version": "1.0.0",
"author": "opencode",
"type": "subagent",
"tags": []
}
}
} }

View File

@@ -1,7 +1,7 @@
{ {
"description": "Additional context file paths - agents load this via @ reference for dynamic pathing", "description": "Additional context file paths - agents load this via @ reference for dynamic pathing",
"paths": { "paths": {
"local": ".opencode/context", "local": ".opencode/context",
"global": "~/.config/opencode/context" "global": "~/.config/opencode/context"
} }
} }

View File

@@ -19,517 +19,576 @@
* .tmp/tasks/completed/{feature-slug}/ * .tmp/tasks/completed/{feature-slug}/
*/ */
const fs = require('fs'); const fs = require("fs");
const path = require('path'); const path = require("path");
// Find project root (look for .git or package.json) // Find project root (look for .git or package.json)
function findProjectRoot(): string { function findProjectRoot(): string {
let dir = process.cwd(); let dir = process.cwd();
while (dir !== path.dirname(dir)) { while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'package.json'))) { if (
return dir; fs.existsSync(path.join(dir, ".git")) ||
} fs.existsSync(path.join(dir, "package.json"))
dir = path.dirname(dir); ) {
} return dir;
return process.cwd(); }
dir = path.dirname(dir);
}
return process.cwd();
} }
const PROJECT_ROOT = findProjectRoot(); const PROJECT_ROOT = findProjectRoot();
const TASKS_DIR = path.join(PROJECT_ROOT, '.tmp', 'tasks'); const TASKS_DIR = path.join(PROJECT_ROOT, ".tmp", "tasks");
const COMPLETED_DIR = path.join(TASKS_DIR, 'completed'); const COMPLETED_DIR = path.join(TASKS_DIR, "completed");
interface Task { interface Task {
id: string; id: string;
name: string; name: string;
status: 'active' | 'completed' | 'blocked' | 'archived'; status: "active" | "completed" | "blocked" | "archived";
objective: string; objective: string;
context_files: string[]; context_files: string[];
reference_files?: string[]; reference_files?: string[];
exit_criteria: string[]; exit_criteria: string[];
subtask_count: number; subtask_count: number;
completed_count: number; completed_count: number;
created_at: string; created_at: string;
completed_at: string | null; completed_at: string | null;
} }
interface Subtask { interface Subtask {
id: string; id: string;
seq: string; seq: string;
title: string; title: string;
status: 'pending' | 'in_progress' | 'completed' | 'blocked'; status: "pending" | "in_progress" | "completed" | "blocked";
depends_on: string[]; depends_on: string[];
parallel: boolean; parallel: boolean;
context_files: string[]; context_files: string[];
reference_files?: string[]; reference_files?: string[];
acceptance_criteria: string[]; acceptance_criteria: string[];
deliverables: string[]; deliverables: string[];
agent_id: string | null; agent_id: string | null;
suggested_agent?: string; suggested_agent?: string;
started_at: string | null; started_at: string | null;
completed_at: string | null; completed_at: string | null;
completion_summary: string | null; completion_summary: string | null;
} }
// Helpers // Helpers
function getFeatureDirs(): string[] { function getFeatureDirs(): string[] {
if (!fs.existsSync(TASKS_DIR)) return []; if (!fs.existsSync(TASKS_DIR)) return [];
return fs.readdirSync(TASKS_DIR).filter((f: string) => { return fs.readdirSync(TASKS_DIR).filter((f: string) => {
const fullPath = path.join(TASKS_DIR, f); const fullPath = path.join(TASKS_DIR, f);
return fs.statSync(fullPath).isDirectory() && f !== 'completed'; return fs.statSync(fullPath).isDirectory() && f !== "completed";
}); });
} }
function loadTask(feature: string): Task | null { function loadTask(feature: string): Task | null {
const taskPath = path.join(TASKS_DIR, feature, 'task.json'); const taskPath = path.join(TASKS_DIR, feature, "task.json");
if (!fs.existsSync(taskPath)) return null; if (!fs.existsSync(taskPath)) return null;
return JSON.parse(fs.readFileSync(taskPath, 'utf-8')); return JSON.parse(fs.readFileSync(taskPath, "utf-8"));
} }
function loadSubtasks(feature: string): Subtask[] { function loadSubtasks(feature: string): Subtask[] {
const featureDir = path.join(TASKS_DIR, feature); const featureDir = path.join(TASKS_DIR, feature);
if (!fs.existsSync(featureDir)) return []; if (!fs.existsSync(featureDir)) return [];
const files = fs.readdirSync(featureDir) const files = fs
.filter((f: string) => f.match(/^subtask_\d{2}\.json$/)) .readdirSync(featureDir)
.sort(); .filter((f: string) => f.match(/^subtask_\d{2}\.json$/))
.sort();
return files.map((f: string) => JSON.parse(fs.readFileSync(path.join(featureDir, f), 'utf-8'))); return files.map((f: string) =>
JSON.parse(fs.readFileSync(path.join(featureDir, f), "utf-8")),
);
} }
function saveSubtask(feature: string, subtask: Subtask): void { function saveSubtask(feature: string, subtask: Subtask): void {
const subtaskPath = path.join(TASKS_DIR, feature, `subtask_${subtask.seq}.json`); const subtaskPath = path.join(
fs.writeFileSync(subtaskPath, JSON.stringify(subtask, null, 2)); TASKS_DIR,
feature,
`subtask_${subtask.seq}.json`,
);
fs.writeFileSync(subtaskPath, JSON.stringify(subtask, null, 2));
} }
function saveTask(feature: string, task: Task): void { function saveTask(feature: string, task: Task): void {
const taskPath = path.join(TASKS_DIR, feature, 'task.json'); const taskPath = path.join(TASKS_DIR, feature, "task.json");
fs.writeFileSync(taskPath, JSON.stringify(task, null, 2)); fs.writeFileSync(taskPath, JSON.stringify(task, null, 2));
} }
// Commands // Commands
function cmdStatus(feature?: string): void { function cmdStatus(feature?: string): void {
const features = feature ? [feature] : getFeatureDirs(); const features = feature ? [feature] : getFeatureDirs();
if (features.length === 0) { if (features.length === 0) {
console.log('No active features found.'); console.log("No active features found.");
return; return;
} }
for (const f of features) { for (const f of features) {
const task = loadTask(f); const task = loadTask(f);
const subtasks = loadSubtasks(f); const subtasks = loadSubtasks(f);
if (!task) { if (!task) {
console.log(`\n[${f}] - No task.json found`); console.log(`\n[${f}] - No task.json found`);
continue; continue;
} }
const counts = { const counts = {
pending: subtasks.filter(s => s.status === 'pending').length, pending: subtasks.filter((s) => s.status === "pending").length,
in_progress: subtasks.filter(s => s.status === 'in_progress').length, in_progress: subtasks.filter((s) => s.status === "in_progress").length,
completed: subtasks.filter(s => s.status === 'completed').length, completed: subtasks.filter((s) => s.status === "completed").length,
blocked: subtasks.filter(s => s.status === 'blocked').length, blocked: subtasks.filter((s) => s.status === "blocked").length,
}; };
const progress = subtasks.length > 0 const progress =
? Math.round((counts.completed / subtasks.length) * 100) subtasks.length > 0
: 0; ? Math.round((counts.completed / subtasks.length) * 100)
: 0;
console.log(`\n[${f}] ${task.name}`); console.log(`\n[${f}] ${task.name}`);
console.log(` Status: ${task.status} | Progress: ${progress}% (${counts.completed}/${subtasks.length})`); console.log(
console.log(` Pending: ${counts.pending} | In Progress: ${counts.in_progress} | Completed: ${counts.completed} | Blocked: ${counts.blocked}`); ` Status: ${task.status} | Progress: ${progress}% (${counts.completed}/${subtasks.length})`,
} );
console.log(
` Pending: ${counts.pending} | In Progress: ${counts.in_progress} | Completed: ${counts.completed} | Blocked: ${counts.blocked}`,
);
}
} }
function cmdNext(feature?: string): void { function cmdNext(feature?: string): void {
const features = feature ? [feature] : getFeatureDirs(); const features = feature ? [feature] : getFeatureDirs();
console.log('\n=== Ready Tasks (deps satisfied) ===\n'); console.log("\n=== Ready Tasks (deps satisfied) ===\n");
for (const f of features) { for (const f of features) {
const subtasks = loadSubtasks(f); const subtasks = loadSubtasks(f);
const completedSeqs = new Set(subtasks.filter(s => s.status === 'completed').map(s => s.seq)); const completedSeqs = new Set(
subtasks.filter((s) => s.status === "completed").map((s) => s.seq),
);
const ready = subtasks.filter(s => { const ready = subtasks.filter((s) => {
if (s.status !== 'pending') return false; if (s.status !== "pending") return false;
return s.depends_on.every(dep => completedSeqs.has(dep)); return s.depends_on.every((dep) => completedSeqs.has(dep));
}); });
if (ready.length > 0) { if (ready.length > 0) {
console.log(`[${f}]`); console.log(`[${f}]`);
for (const s of ready) { for (const s of ready) {
const parallel = s.parallel ? '[parallel]' : '[sequential]'; const parallel = s.parallel ? "[parallel]" : "[sequential]";
console.log(` ${s.seq} - ${s.title} ${parallel}`); console.log(` ${s.seq} - ${s.title} ${parallel}`);
} }
console.log(); console.log();
} }
} }
} }
function cmdParallel(feature?: string): void { function cmdParallel(feature?: string): void {
const features = feature ? [feature] : getFeatureDirs(); const features = feature ? [feature] : getFeatureDirs();
console.log('\n=== Parallelizable Tasks Ready Now ===\n'); console.log("\n=== Parallelizable Tasks Ready Now ===\n");
for (const f of features) { for (const f of features) {
const subtasks = loadSubtasks(f); const subtasks = loadSubtasks(f);
const completedSeqs = new Set(subtasks.filter(s => s.status === 'completed').map(s => s.seq)); const completedSeqs = new Set(
subtasks.filter((s) => s.status === "completed").map((s) => s.seq),
);
const parallel = subtasks.filter(s => { const parallel = subtasks.filter((s) => {
if (s.status !== 'pending') return false; if (s.status !== "pending") return false;
if (!s.parallel) return false; if (!s.parallel) return false;
return s.depends_on.every(dep => completedSeqs.has(dep)); return s.depends_on.every((dep) => completedSeqs.has(dep));
}); });
if (parallel.length > 0) { if (parallel.length > 0) {
console.log(`[${f}] - ${parallel.length} parallel tasks:`); console.log(`[${f}] - ${parallel.length} parallel tasks:`);
for (const s of parallel) { for (const s of parallel) {
console.log(` ${s.seq} - ${s.title}`); console.log(` ${s.seq} - ${s.title}`);
} }
console.log(); console.log();
} }
} }
} }
function cmdDeps(feature: string, seq: string): void { function cmdDeps(feature: string, seq: string): void {
const subtasks = loadSubtasks(feature); const subtasks = loadSubtasks(feature);
const target = subtasks.find(s => s.seq === seq); const target = subtasks.find((s) => s.seq === seq);
if (!target) { if (!target) {
console.log(`Task ${seq} not found in ${feature}`); console.log(`Task ${seq} not found in ${feature}`);
return; return;
} }
console.log(`\n=== Dependency Tree: ${feature}/${seq} ===\n`); console.log(`\n=== Dependency Tree: ${feature}/${seq} ===\n`);
console.log(`${seq} - ${target.title} [${target.status}]`); console.log(`${seq} - ${target.title} [${target.status}]`);
if (target.depends_on.length === 0) { if (target.depends_on.length === 0) {
console.log(' └── (no dependencies)'); console.log(" └── (no dependencies)");
return; return;
} }
const printDeps = (seqs: string[], indent: string = ' '): void => { const printDeps = (seqs: string[], indent: string = " "): void => {
for (let i = 0; i < seqs.length; i++) { for (let i = 0; i < seqs.length; i++) {
const depSeq = seqs[i]; const depSeq = seqs[i];
const dep = subtasks.find(s => s.seq === depSeq); const dep = subtasks.find((s) => s.seq === depSeq);
const isLast = i === seqs.length - 1; const isLast = i === seqs.length - 1;
const branch = isLast ? '└──' : '├──'; const branch = isLast ? "└──" : "├──";
if (dep) { if (dep) {
const statusIcon = dep.status === 'completed' ? '✓' : dep.status === 'in_progress' ? '~' : '○'; const statusIcon =
console.log(`${indent}${branch} ${statusIcon} ${depSeq} - ${dep.title} [${dep.status}]`); dep.status === "completed"
if (dep.depends_on.length > 0) { ? "✓"
const newIndent = indent + (isLast ? ' ' : '│ '); : dep.status === "in_progress"
printDeps(dep.depends_on, newIndent); ? "~"
} : "○";
} else { console.log(
console.log(`${indent}${branch} ? ${depSeq} - NOT FOUND`); `${indent}${branch} ${statusIcon} ${depSeq} - ${dep.title} [${dep.status}]`,
} );
} if (dep.depends_on.length > 0) {
}; const newIndent = indent + (isLast ? " " : "│ ");
printDeps(dep.depends_on, newIndent);
}
} else {
console.log(`${indent}${branch} ? ${depSeq} - NOT FOUND`);
}
}
};
printDeps(target.depends_on); printDeps(target.depends_on);
} }
function cmdBlocked(feature?: string): void { function cmdBlocked(feature?: string): void {
const features = feature ? [feature] : getFeatureDirs(); const features = feature ? [feature] : getFeatureDirs();
console.log('\n=== Blocked Tasks ===\n'); console.log("\n=== Blocked Tasks ===\n");
for (const f of features) { for (const f of features) {
const subtasks = loadSubtasks(f); const subtasks = loadSubtasks(f);
const completedSeqs = new Set(subtasks.filter(s => s.status === 'completed').map(s => s.seq)); const completedSeqs = new Set(
subtasks.filter((s) => s.status === "completed").map((s) => s.seq),
);
const blocked = subtasks.filter(s => { const blocked = subtasks.filter((s) => {
if (s.status === 'blocked') return true; if (s.status === "blocked") return true;
if (s.status !== 'pending') return false; if (s.status !== "pending") return false;
return !s.depends_on.every(dep => completedSeqs.has(dep)); return !s.depends_on.every((dep) => completedSeqs.has(dep));
}); });
if (blocked.length > 0) { if (blocked.length > 0) {
console.log(`[${f}]`); console.log(`[${f}]`);
for (const s of blocked) { for (const s of blocked) {
const waitingFor = s.depends_on.filter(dep => !completedSeqs.has(dep)); const waitingFor = s.depends_on.filter(
const reason = s.status === 'blocked' (dep) => !completedSeqs.has(dep),
? 'explicitly blocked' );
: `waiting: ${waitingFor.join(', ')}`; const reason =
console.log(` ${s.seq} - ${s.title} (${reason})`); s.status === "blocked"
} ? "explicitly blocked"
console.log(); : `waiting: ${waitingFor.join(", ")}`;
} console.log(` ${s.seq} - ${s.title} (${reason})`);
} }
console.log();
}
}
} }
function cmdComplete(feature: string, seq: string, summary: string): void { function cmdComplete(feature: string, seq: string, summary: string): void {
if (summary.length > 200) { if (summary.length > 200) {
console.log('Error: Summary must be max 200 characters'); console.log("Error: Summary must be max 200 characters");
process.exit(1); process.exit(1);
} }
const subtasks = loadSubtasks(feature); const subtasks = loadSubtasks(feature);
const subtask = subtasks.find(s => s.seq === seq); const subtask = subtasks.find((s) => s.seq === seq);
if (!subtask) { if (!subtask) {
console.log(`Task ${seq} not found in ${feature}`); console.log(`Task ${seq} not found in ${feature}`);
process.exit(1); process.exit(1);
} }
subtask.status = 'completed'; subtask.status = "completed";
subtask.completed_at = new Date().toISOString(); subtask.completed_at = new Date().toISOString();
subtask.completion_summary = summary; subtask.completion_summary = summary;
saveSubtask(feature, subtask); saveSubtask(feature, subtask);
// Update task.json counts // Update task.json counts
const task = loadTask(feature); const task = loadTask(feature);
if (task) { if (task) {
const newSubtasks = loadSubtasks(feature); const newSubtasks = loadSubtasks(feature);
task.completed_count = newSubtasks.filter(s => s.status === 'completed').length; task.completed_count = newSubtasks.filter(
saveTask(feature, task); (s) => s.status === "completed",
} ).length;
saveTask(feature, task);
}
console.log(`\n✓ Marked ${feature}/${seq} as completed`); console.log(`\n✓ Marked ${feature}/${seq} as completed`);
console.log(` Summary: ${summary}`); console.log(` Summary: ${summary}`);
if (task) { if (task) {
console.log(` Progress: ${task.completed_count}/${task.subtask_count}`); console.log(` Progress: ${task.completed_count}/${task.subtask_count}`);
} }
} }
function cmdValidate(feature?: string): void { function cmdValidate(feature?: string): void {
const features = feature ? [feature] : getFeatureDirs(); const features = feature ? [feature] : getFeatureDirs();
let hasErrors = false; let hasErrors = false;
const validTaskStatuses = new Set(['active', 'completed', 'blocked', 'archived']); const validTaskStatuses = new Set([
const validSubtaskStatuses = new Set(['pending', 'in_progress', 'completed', 'blocked']); "active",
"completed",
"blocked",
"archived",
]);
const validSubtaskStatuses = new Set([
"pending",
"in_progress",
"completed",
"blocked",
]);
const requiredTaskFields = [ const requiredTaskFields = [
'id', "id",
'name', "name",
'status', "status",
'objective', "objective",
'context_files', "context_files",
'exit_criteria', "exit_criteria",
'subtask_count', "subtask_count",
'completed_count', "completed_count",
'created_at', "created_at",
'completed_at', "completed_at",
]; ];
const requiredSubtaskFields = [ const requiredSubtaskFields = [
'id', "id",
'seq', "seq",
'title', "title",
'status', "status",
'depends_on', "depends_on",
'parallel', "parallel",
'context_files', "context_files",
'acceptance_criteria', "acceptance_criteria",
'deliverables', "deliverables",
'agent_id', "agent_id",
'started_at', "started_at",
'completed_at', "completed_at",
'completion_summary', "completion_summary",
]; ];
const hasField = (obj: any, field: string): boolean => Object.prototype.hasOwnProperty.call(obj, field); const hasField = (obj: any, field: string): boolean =>
const isStringArray = (value: any): boolean => Array.isArray(value) && value.every(v => typeof v === 'string'); Object.prototype.hasOwnProperty.call(obj, field);
const isStringArray = (value: any): boolean =>
Array.isArray(value) && value.every((v) => typeof v === "string");
console.log('\n=== Validation Results ===\n'); console.log("\n=== Validation Results ===\n");
for (const f of features) { for (const f of features) {
const errors: string[] = []; const errors: string[] = [];
// Check task.json exists // Check task.json exists
const task = loadTask(f); const task = loadTask(f);
if (!task) { if (!task) {
errors.push('Missing task.json'); errors.push("Missing task.json");
} }
// Load and validate subtasks // Load and validate subtasks
const subtasks = loadSubtasks(f); const subtasks = loadSubtasks(f);
const seqCounts = new Map<string, number>(); const seqCounts = new Map<string, number>();
for (const s of subtasks) { for (const s of subtasks) {
const seq = typeof s.seq === 'string' ? s.seq : ''; const seq = typeof s.seq === "string" ? s.seq : "";
seqCounts.set(seq, (seqCounts.get(seq) || 0) + 1); seqCounts.set(seq, (seqCounts.get(seq) || 0) + 1);
} }
const seqs = new Set(subtasks.map(s => s.seq)); const seqs = new Set(subtasks.map((s) => s.seq));
if (task) { if (task) {
// Required fields in task.json // Required fields in task.json
for (const field of requiredTaskFields) { for (const field of requiredTaskFields) {
if (!hasField(task, field)) { if (!hasField(task, field)) {
errors.push(`task.json: missing required field '${field}'`); errors.push(`task.json: missing required field '${field}'`);
} }
} }
// Task ID should match feature slug // Task ID should match feature slug
if (task.id !== f) { if (task.id !== f) {
errors.push(`task.json id ('${task.id}') should match feature slug ('${f}')`); errors.push(
} `task.json id ('${task.id}') should match feature slug ('${f}')`,
);
}
// Task status should be valid // Task status should be valid
if (!validTaskStatuses.has(task.status)) { if (!validTaskStatuses.has(task.status)) {
errors.push(`task.json: invalid status '${task.status}'`); errors.push(`task.json: invalid status '${task.status}'`);
} }
// Basic type checks for key task fields // Basic type checks for key task fields
if (!isStringArray(task.context_files)) { if (!isStringArray(task.context_files)) {
errors.push('task.json: context_files must be string[]'); errors.push("task.json: context_files must be string[]");
} }
if (hasField(task, 'reference_files') && task.reference_files !== undefined && !isStringArray(task.reference_files)) { if (
errors.push('task.json: reference_files must be string[] when present'); hasField(task, "reference_files") &&
} task.reference_files !== undefined &&
if (!isStringArray(task.exit_criteria)) { !isStringArray(task.reference_files)
errors.push('task.json: exit_criteria must be string[]'); ) {
} errors.push("task.json: reference_files must be string[] when present");
if (typeof task.subtask_count !== 'number') { }
errors.push('task.json: subtask_count must be number'); if (!isStringArray(task.exit_criteria)) {
} errors.push("task.json: exit_criteria must be string[]");
if (typeof task.completed_count !== 'number') { }
errors.push('task.json: completed_count must be number'); if (typeof task.subtask_count !== "number") {
} errors.push("task.json: subtask_count must be number");
} }
if (typeof task.completed_count !== "number") {
errors.push("task.json: completed_count must be number");
}
}
for (const s of subtasks) { for (const s of subtasks) {
// Required fields in subtask files // Required fields in subtask files
for (const field of requiredSubtaskFields) { for (const field of requiredSubtaskFields) {
if (!hasField(s, field)) { if (!hasField(s, field)) {
errors.push(`${s.seq || '??'}: missing required field '${field}'`); errors.push(`${s.seq || "??"}: missing required field '${field}'`);
} }
} }
// Sequence format and uniqueness // Sequence format and uniqueness
if (!/^\d{2}$/.test(s.seq)) { if (!/^\d{2}$/.test(s.seq)) {
errors.push(`${s.seq}: sequence must be 2 digits (e.g., 01, 02)`); errors.push(`${s.seq}: sequence must be 2 digits (e.g., 01, 02)`);
} }
if ((seqCounts.get(s.seq) || 0) > 1) { if ((seqCounts.get(s.seq) || 0) > 1) {
errors.push(`${s.seq}: duplicate sequence number`); errors.push(`${s.seq}: duplicate sequence number`);
} }
// Check ID format // Check ID format
if (!s.id.startsWith(f)) { if (!s.id.startsWith(f)) {
errors.push(`${s.seq}: ID should start with feature name`); errors.push(`${s.seq}: ID should start with feature name`);
} }
// Status should be valid // Status should be valid
if (!validSubtaskStatuses.has(s.status)) { if (!validSubtaskStatuses.has(s.status)) {
errors.push(`${s.seq}: invalid status '${s.status}'`); errors.push(`${s.seq}: invalid status '${s.status}'`);
} }
// Type checks // Type checks
if (!isStringArray(s.depends_on)) { if (!isStringArray(s.depends_on)) {
errors.push(`${s.seq}: depends_on must be string[]`); errors.push(`${s.seq}: depends_on must be string[]`);
} }
if (typeof s.parallel !== 'boolean') { if (typeof s.parallel !== "boolean") {
errors.push(`${s.seq}: parallel must be boolean`); errors.push(`${s.seq}: parallel must be boolean`);
} }
if (!isStringArray(s.context_files)) { if (!isStringArray(s.context_files)) {
errors.push(`${s.seq}: context_files must be string[]`); errors.push(`${s.seq}: context_files must be string[]`);
} }
if (hasField(s, 'reference_files') && s.reference_files !== undefined && !isStringArray(s.reference_files)) { if (
errors.push(`${s.seq}: reference_files must be string[] when present`); hasField(s, "reference_files") &&
} s.reference_files !== undefined &&
if (!isStringArray(s.acceptance_criteria)) { !isStringArray(s.reference_files)
errors.push(`${s.seq}: acceptance_criteria must be string[]`); ) {
} else if (s.acceptance_criteria.length === 0) { errors.push(`${s.seq}: reference_files must be string[] when present`);
errors.push(`${s.seq}: No acceptance criteria defined`); }
} if (!isStringArray(s.acceptance_criteria)) {
if (!isStringArray(s.deliverables)) { errors.push(`${s.seq}: acceptance_criteria must be string[]`);
errors.push(`${s.seq}: deliverables must be string[]`); } else if (s.acceptance_criteria.length === 0) {
} else if (s.deliverables.length === 0) { errors.push(`${s.seq}: No acceptance criteria defined`);
errors.push(`${s.seq}: No deliverables defined`); }
} if (!isStringArray(s.deliverables)) {
errors.push(`${s.seq}: deliverables must be string[]`);
} else if (s.deliverables.length === 0) {
errors.push(`${s.seq}: No deliverables defined`);
}
// Self dependency is invalid // Self dependency is invalid
if (Array.isArray(s.depends_on) && s.depends_on.includes(s.seq)) { if (Array.isArray(s.depends_on) && s.depends_on.includes(s.seq)) {
errors.push(`${s.seq}: task cannot depend on itself`); errors.push(`${s.seq}: task cannot depend on itself`);
} }
// Check for missing dependencies // Check for missing dependencies
for (const dep of (Array.isArray(s.depends_on) ? s.depends_on : [])) { for (const dep of Array.isArray(s.depends_on) ? s.depends_on : []) {
if (!seqs.has(dep)) { if (!seqs.has(dep)) {
errors.push(`${s.seq}: depends on non-existent task ${dep}`); errors.push(`${s.seq}: depends on non-existent task ${dep}`);
} }
} }
// Check for circular dependencies // Check for circular dependencies
const visited = new Set<string>(); const visited = new Set<string>();
const checkCircular = (seq: string, path: string[]): boolean => { const checkCircular = (seq: string, path: string[]): boolean => {
if (path.includes(seq)) { if (path.includes(seq)) {
errors.push(`${s.seq}: circular dependency detected: ${[...path, seq].join(' -> ')}`); errors.push(
return true; `${s.seq}: circular dependency detected: ${[...path, seq].join(" -> ")}`,
} );
if (visited.has(seq)) return false; return true;
visited.add(seq); }
if (visited.has(seq)) return false;
visited.add(seq);
const task = subtasks.find(t => t.seq === seq); const task = subtasks.find((t) => t.seq === seq);
if (task) { if (task) {
for (const dep of task.depends_on) { for (const dep of task.depends_on) {
if (checkCircular(dep, [...path, seq])) return true; if (checkCircular(dep, [...path, seq])) return true;
} }
} }
return false; return false;
}; };
checkCircular(s.seq, []); checkCircular(s.seq, []);
} }
// Check counts match // Check counts match
if (task && task.subtask_count !== subtasks.length) { if (task && task.subtask_count !== subtasks.length) {
errors.push(`task.json subtask_count (${task.subtask_count}) doesn't match actual count (${subtasks.length})`); errors.push(
} `task.json subtask_count (${task.subtask_count}) doesn't match actual count (${subtasks.length})`,
);
}
// Print results // Print results
console.log(`[${f}]`); console.log(`[${f}]`);
if (errors.length === 0) { if (errors.length === 0) {
console.log(' ✓ All checks passed'); console.log(" ✓ All checks passed");
} else { } else {
for (const e of errors) { for (const e of errors) {
console.log(` ✗ ERROR: ${e}`); console.log(` ✗ ERROR: ${e}`);
hasErrors = true; hasErrors = true;
} }
} }
console.log(); console.log();
} }
process.exit(hasErrors ? 1 : 0); process.exit(hasErrors ? 1 : 0);
} }
// Main // Main
const [,, command, ...args] = process.argv; const [, , command, ...args] = process.argv;
switch (command) { switch (command) {
case 'status': case "status":
cmdStatus(args[0]); cmdStatus(args[0]);
break; break;
case 'next': case "next":
cmdNext(args[0]); cmdNext(args[0]);
break; break;
case 'parallel': case "parallel":
cmdParallel(args[0]); cmdParallel(args[0]);
break; break;
case 'deps': case "deps":
if (args.length < 2) { if (args.length < 2) {
console.log('Usage: deps <feature> <seq>'); console.log("Usage: deps <feature> <seq>");
process.exit(1); process.exit(1);
} }
cmdDeps(args[0], args[1]); cmdDeps(args[0], args[1]);
break; break;
case 'blocked': case "blocked":
cmdBlocked(args[0]); cmdBlocked(args[0]);
break; break;
case 'complete': case "complete":
if (args.length < 3) { if (args.length < 3) {
console.log('Usage: complete <feature> <seq> "summary"'); console.log('Usage: complete <feature> <seq> "summary"');
process.exit(1); process.exit(1);
} }
cmdComplete(args[0], args[1], args.slice(2).join(' ')); cmdComplete(args[0], args[1], args.slice(2).join(" "));
break; break;
case 'validate': case "validate":
cmdValidate(args[0]); cmdValidate(args[0]);
break; break;
default: default:
console.log(` console.log(`
Task Management CLI Task Management CLI
Usage: bunx --bun ts-node task-cli.ts <command> [feature] [args...] Usage: bunx --bun ts-node task-cli.ts <command> [feature] [args...]

View File

@@ -1,168 +1,188 @@
import { readFile } from "fs/promises" import { readFile } from "fs/promises";
import { resolve } from "path" import { resolve } from "path";
/** /**
* Configuration for environment variable loading * Configuration for environment variable loading
*/ */
export interface EnvLoaderConfig { export interface EnvLoaderConfig {
/** Custom paths to search for .env files (relative to current working directory) */ /** Custom paths to search for .env files (relative to current working directory) */
searchPaths?: string[] searchPaths?: string[];
/** Whether to log when environment variables are loaded */ /** Whether to log when environment variables are loaded */
verbose?: boolean verbose?: boolean;
/** Whether to override existing environment variables */ /** Whether to override existing environment variables */
override?: boolean override?: boolean;
} }
/** /**
* Default search paths for .env files * Default search paths for .env files
*/ */
const DEFAULT_ENV_PATHS = [ const DEFAULT_ENV_PATHS = [
'./.env', "./.env",
'../.env', "../.env",
'../../.env', "../../.env",
'../plugin/.env', "../plugin/.env",
'../../../.env' "../../../.env",
] ];
/** /**
* Load environment variables from .env files * Load environment variables from .env files
* Searches multiple common locations for .env files and loads them into process.env * Searches multiple common locations for .env files and loads them into process.env
* *
* @param config Configuration options * @param config Configuration options
* @returns Object containing loaded environment variables * @returns Object containing loaded environment variables
*/ */
export async function loadEnvVariables(config: EnvLoaderConfig = {}): Promise<Record<string, string>> { export async function loadEnvVariables(
const { config: EnvLoaderConfig = {},
searchPaths = DEFAULT_ENV_PATHS, ): Promise<Record<string, string>> {
verbose = false, const {
override = false searchPaths = DEFAULT_ENV_PATHS,
} = config verbose = false,
override = false,
const loadedVars: Record<string, string> = {} } = config;
for (const envPath of searchPaths) { const loadedVars: Record<string, string> = {};
try {
const fullPath = resolve(envPath) for (const envPath of searchPaths) {
const content = await readFile(fullPath, 'utf8') try {
const fullPath = resolve(envPath);
if (verbose) { const content = await readFile(fullPath, "utf8");
console.log(`Checking .env file: ${envPath}`)
} if (verbose) {
console.log(`Checking .env file: ${envPath}`);
// Parse .env file content }
const lines = content.split('\n')
for (const line of lines) { // Parse .env file content
const trimmed = line.trim() const lines = content.split("\n");
if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) { for (const line of lines) {
const [key, ...valueParts] = trimmed.split('=') const trimmed = line.trim();
const value = valueParts.join('=').trim() if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
const [key, ...valueParts] = trimmed.split("=");
// Remove quotes if present const value = valueParts.join("=").trim();
const cleanValue = value.replace(/^["']|["']$/g, '')
// Remove quotes if present
if (key && cleanValue && (override || !process.env[key])) { const cleanValue = value.replace(/^["']|["']$/g, "");
process.env[key] = cleanValue
loadedVars[key] = cleanValue if (key && cleanValue && (override || !process.env[key])) {
process.env[key] = cleanValue;
if (verbose) { loadedVars[key] = cleanValue;
console.log(`Loaded ${key} from ${envPath}`)
} if (verbose) {
} console.log(`Loaded ${key} from ${envPath}`);
} }
} }
} catch (error) { }
// File doesn't exist or can't be read, continue to next }
if (verbose) { } catch (error) {
console.log(`Could not read ${envPath}: ${error.message}`) // File doesn't exist or can't be read, continue to next
} if (verbose) {
} console.log(`Could not read ${envPath}: ${error.message}`);
} }
}
return loadedVars }
return loadedVars;
} }
/** /**
* Get a specific environment variable with automatic .env file loading * Get a specific environment variable with automatic .env file loading
* *
* @param varName Name of the environment variable * @param varName Name of the environment variable
* @param config Configuration options * @param config Configuration options
* @returns The environment variable value or null if not found * @returns The environment variable value or null if not found
*/ */
export async function getEnvVariable(varName: string, config: EnvLoaderConfig = {}): Promise<string | null> { export async function getEnvVariable(
// First check if it's already in the environment varName: string,
let value = process.env[varName] config: EnvLoaderConfig = {},
): Promise<string | null> {
if (!value) { // First check if it's already in the environment
// Try to load from .env files let value = process.env[varName];
const loadedVars = await loadEnvVariables(config)
value = loadedVars[varName] || process.env[varName] if (!value) {
} // Try to load from .env files
const loadedVars = await loadEnvVariables(config);
return value || null value = loadedVars[varName] || process.env[varName];
}
return value || null;
} }
/** /**
* Get a required environment variable with automatic .env file loading * Get a required environment variable with automatic .env file loading
* Throws an error if the variable is not found * Throws an error if the variable is not found
* *
* @param varName Name of the environment variable * @param varName Name of the environment variable
* @param config Configuration options * @param config Configuration options
* @returns The environment variable value * @returns The environment variable value
* @throws Error if the variable is not found * @throws Error if the variable is not found
*/ */
export async function getRequiredEnvVariable(varName: string, config: EnvLoaderConfig = {}): Promise<string> { export async function getRequiredEnvVariable(
const value = await getEnvVariable(varName, config) varName: string,
config: EnvLoaderConfig = {},
if (!value) { ): Promise<string> {
const searchPaths = config.searchPaths || DEFAULT_ENV_PATHS const value = await getEnvVariable(varName, config);
throw new Error(`${varName} not found. Please set it in your environment or .env file.
if (!value) {
const searchPaths = config.searchPaths || DEFAULT_ENV_PATHS;
throw new Error(`${varName} not found. Please set it in your environment or .env file.
To fix this: To fix this:
1. Add to .env file: ${varName}=your_value_here 1. Add to .env file: ${varName}=your_value_here
2. Or export it: export ${varName}=your_value_here 2. Or export it: export ${varName}=your_value_here
Current working directory: ${process.cwd()} Current working directory: ${process.cwd()}
Searched paths: ${searchPaths.join(', ')} Searched paths: ${searchPaths.join(", ")}
Environment variables available: ${Object.keys(process.env).filter(k => k.includes(varName.split('_')[0])).join(', ') || 'none matching'}`) Environment variables available: ${
} Object.keys(process.env)
.filter((k) => k.includes(varName.split("_")[0]))
return value .join(", ") || "none matching"
}`);
}
return value;
} }
/** /**
* Load multiple required environment variables at once * Load multiple required environment variables at once
* *
* @param varNames Array of environment variable names * @param varNames Array of environment variable names
* @param config Configuration options * @param config Configuration options
* @returns Object with variable names as keys and values as values * @returns Object with variable names as keys and values as values
* @throws Error if any variable is not found * @throws Error if any variable is not found
*/ */
export async function getRequiredEnvVariables(varNames: string[], config: EnvLoaderConfig = {}): Promise<Record<string, string>> { export async function getRequiredEnvVariables(
const result: Record<string, string> = {} varNames: string[],
config: EnvLoaderConfig = {},
// Load all .env files first ): Promise<Record<string, string>> {
await loadEnvVariables(config) const result: Record<string, string> = {};
// Check each required variable // Load all .env files first
for (const varName of varNames) { await loadEnvVariables(config);
const value = process.env[varName]
if (!value) { // Check each required variable
throw new Error(`Required environment variable ${varName} not found. Please set it in your environment or .env file.`) for (const varName of varNames) {
} const value = process.env[varName];
result[varName] = value if (!value) {
} throw new Error(
`Required environment variable ${varName} not found. Please set it in your environment or .env file.`,
return result );
}
result[varName] = value;
}
return result;
} }
/** /**
* Utility function specifically for API keys * Utility function specifically for API keys
* *
* @param apiKeyName Name of the API key environment variable * @param apiKeyName Name of the API key environment variable
* @param config Configuration options * @param config Configuration options
* @returns The API key value * @returns The API key value
* @throws Error if the API key is not found * @throws Error if the API key is not found
*/ */
export async function getApiKey(apiKeyName: string, config: EnvLoaderConfig = {}): Promise<string> { export async function getApiKey(
return getRequiredEnvVariable(apiKeyName, config) apiKeyName: string,
} config: EnvLoaderConfig = {},
): Promise<string> {
return getRequiredEnvVariable(apiKeyName, config);
}

14
biome.json Normal file
View File

@@ -0,0 +1,14 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
"css": {
"linter": {
"enabled": true
},
"parser": {
"tailwindDirectives": true
},
"formatter": {
"enabled": true
}
}
}

View File

@@ -1,10 +1,11 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { extractJsonFromText } from "@/lib/json-utils";
import { openRouterClient } from "@/lib/openrouter-client"; import { openRouterClient } from "@/lib/openrouter-client";
import { AiEventRequestSchema, AiEventResponseSchema } from "@/lib/types"; import { AiEventRequestSchema, AiEventResponseSchema } from "@/lib/types";
const MODEL = "openai/gpt-5.4-mini"; const MODEL = process.env.AI_MODEL ?? "openai/gpt-5.4-mini";
const buildSystemPrompt = () => ` const buildSystemPrompt = () => `
You are an assistant that converts natural language and images into an ARRAY of calendar events. You are an assistant that converts natural language and images into an ARRAY of calendar events.
@@ -42,7 +43,26 @@ const callTextOnly = async (systemPrompt: string, prompt: string) => {
}); });
const rawResponse = await result.getText(); const rawResponse = await result.getText();
return { rawResponse, startTime: performance.now() }; return { rawResponse };
};
/** Extract the text content from an OpenRouter chat.send response. */
const extractContentFromChatResponse = (response: unknown): string => {
if (
typeof response === "object" &&
response !== null &&
"choices" in response
) {
const choices = (
response as {
choices: Array<{ message: { content: string | unknown } }>;
}
).choices;
const content = choices?.[0]?.message?.content;
if (typeof content === "string") return content;
if (content) return JSON.stringify(content);
}
throw new Error("Unexpected response format from AI chat API");
}; };
const callMultimodal = async ( const callMultimodal = async (
@@ -70,8 +90,6 @@ const callMultimodal = async (
}, },
]; ];
const startTime = performance.now();
const response = await openRouterClient.chat.send({ const response = await openRouterClient.chat.send({
chatRequest: { chatRequest: {
model: MODEL, model: MODEL,
@@ -79,32 +97,8 @@ const callMultimodal = async (
}, },
}); });
const rawResponse = const rawResponse = extractContentFromChatResponse(response);
typeof response === "object" && return { rawResponse };
"choices" in response &&
response.choices?.[0]?.message
? typeof response.choices[0].message.content === "string"
? response.choices[0].message.content
: JSON.stringify(response.choices[0].message.content)
: JSON.stringify(response);
return { rawResponse, startTime };
};
const extractJsonFromText = (text: string): unknown => {
try {
return JSON.parse(text);
} catch {
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (codeBlockMatch) {
return JSON.parse(codeBlockMatch[1].trim());
}
const arrayMatch = text.match(/\[[\s\S]*\]/);
if (arrayMatch) {
return JSON.parse(arrayMatch[0]);
}
throw new Error(`No JSON found in response: ${text.slice(0, 200)}`);
}
}; };
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -133,25 +127,19 @@ export async function POST(request: Request) {
} }
const { prompt, imageBase64 } = parsedInput.data; const { prompt, imageBase64 } = parsedInput.data;
const inputMode = imageBase64 ? "multimodal" : "text";
const systemPrompt = buildSystemPrompt(); const systemPrompt = buildSystemPrompt();
let rawResponse: string | undefined;
try { try {
const result = const result = imageBase64
inputMode === "multimodal" ? await callMultimodal(systemPrompt, prompt, imageBase64)
? await callMultimodal(systemPrompt, prompt, imageBase64!) : await callTextOnly(systemPrompt, prompt ?? "");
: await callTextOnly(systemPrompt, prompt!);
rawResponse = result.rawResponse; const rawJson = extractJsonFromText(result.rawResponse);
const rawJson = extractJsonFromText(rawResponse);
const validated = AiEventResponseSchema.safeParse(rawJson); const validated = AiEventResponseSchema.safeParse(rawJson);
if (!validated.success) { if (!validated.success) {
console.error("AI response validation failed:", { console.error("AI response validation failed:", {
issues: validated.error.flatten().fieldErrors, issues: validated.error.flatten().fieldErrors,
rawResponse,
}); });
return NextResponse.json( return NextResponse.json(
@@ -167,10 +155,7 @@ export async function POST(request: Request) {
} catch (error) { } catch (error) {
console.error("AI Event Creation Error:", error); console.error("AI Event Creation Error:", error);
return NextResponse.json( return NextResponse.json(
{ { error: "Failed to process AI response. Please try again." },
error: "Failed to parse AI output",
raw: error instanceof Error ? error.message : String(error),
},
{ status: 500 }, { status: 500 },
); );
} }

View File

@@ -1,6 +1,6 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers";
import { openRouterClient } from "@/lib/openrouter-client"; import { openRouterClient } from "@/lib/openrouter-client";
export async function POST(request: Request) { export async function POST(request: Request) {

View File

@@ -1,4 +1,4 @@
import { auth } from "@/auth";
import { toNextJsHandler } from "better-auth/next-js"; import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/auth";
export const { GET, POST } = toNextJsHandler(auth); export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function Search() { function Search() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();

View File

@@ -1,6 +1,9 @@
"use client"; "use client";
import { signIn, useSession } from "@/lib/auth-client"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -9,10 +12,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import Link from "next/link"; import { signIn, useSession } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
export default function SignInPage() { export default function SignInPage() {
const { data: session, isPending } = useSession(); const { data: session, isPending } = useSession();
@@ -32,7 +32,7 @@ export default function SignInPage() {
providerId: "authentik", providerId: "authentik",
callbackURL: "/", callbackURL: "/",
}); });
} catch (_error) { } catch {
toast.error("Failed to sign in. Please try again."); toast.error("Failed to sign in. Please try again.");
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { signOut, useSession } from "@/lib/auth-client"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -9,9 +11,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import Link from "next/link"; import { signOut, useSession } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function SignOutPage() { export default function SignOutPage() {
const { data: session, isPending } = useSession(); const { data: session, isPending } = useSession();

View File

@@ -4,159 +4,174 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(0.9232 0.0026 48.7171); --background: oklch(0.9232 0.0026 48.7171);
--foreground: oklch(0.2795 0.0368 260.0310); --foreground: oklch(0.2795 0.0368 260.031);
--card: oklch(0.9699 0.0013 106.4238); --card: oklch(0.9699 0.0013 106.4238);
--card-foreground: oklch(0.2795 0.0368 260.0310); --card-foreground: oklch(0.2795 0.0368 260.031);
--popover: oklch(0.9699 0.0013 106.4238); --popover: oklch(0.9699 0.0013 106.4238);
--popover-foreground: oklch(0.2795 0.0368 260.0310); --popover-foreground: oklch(0.2795 0.0368 260.031);
--primary: oklch(0.5854 0.2041 277.1173); --primary: oklch(0.5854 0.2041 277.1173);
--primary-foreground: oklch(1.0000 0 0); --primary-foreground: oklch(1 0 0);
--secondary: oklch(0.8687 0.0043 56.3660); --secondary: oklch(0.8687 0.0043 56.366);
--secondary-foreground: oklch(0.4461 0.0263 256.8018); --secondary-foreground: oklch(0.4461 0.0263 256.8018);
--muted: oklch(0.9232 0.0026 48.7171); --muted: oklch(0.9232 0.0026 48.7171);
--muted-foreground: oklch(0.5510 0.0234 264.3637); --muted-foreground: oklch(0.551 0.0234 264.3637);
--accent: oklch(0.9376 0.0260 321.9388); --accent: oklch(0.9376 0.026 321.9388);
--accent-foreground: oklch(0.3729 0.0306 259.7328); --accent-foreground: oklch(0.3729 0.0306 259.7328);
--destructive: oklch(0.6368 0.2078 25.3313); --destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0); --destructive-foreground: oklch(1 0 0);
--border: oklch(0.8687 0.0043 56.3660); --border: oklch(0.8687 0.0043 56.366);
--input: oklch(0.8687 0.0043 56.3660); --input: oklch(0.8687 0.0043 56.366);
--ring: oklch(0.5854 0.2041 277.1173); --ring: oklch(0.5854 0.2041 277.1173);
--chart-1: oklch(0.5854 0.2041 277.1173); --chart-1: oklch(0.5854 0.2041 277.1173);
--chart-2: oklch(0.5106 0.2301 276.9656); --chart-2: oklch(0.5106 0.2301 276.9656);
--chart-3: oklch(0.4568 0.2146 277.0229); --chart-3: oklch(0.4568 0.2146 277.0229);
--chart-4: oklch(0.3984 0.1773 277.3662); --chart-4: oklch(0.3984 0.1773 277.3662);
--chart-5: oklch(0.3588 0.1354 278.6973); --chart-5: oklch(0.3588 0.1354 278.6973);
--sidebar: oklch(0.8687 0.0043 56.3660); --sidebar: oklch(0.8687 0.0043 56.366);
--sidebar-foreground: oklch(0.2795 0.0368 260.0310); --sidebar-foreground: oklch(0.2795 0.0368 260.031);
--sidebar-primary: oklch(0.5854 0.2041 277.1173); --sidebar-primary: oklch(0.5854 0.2041 277.1173);
--sidebar-primary-foreground: oklch(1.0000 0 0); --sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.9376 0.0260 321.9388); --sidebar-accent: oklch(0.9376 0.026 321.9388);
--sidebar-accent-foreground: oklch(0.3729 0.0306 259.7328); --sidebar-accent-foreground: oklch(0.3729 0.0306 259.7328);
--sidebar-border: oklch(0.8687 0.0043 56.3660); --sidebar-border: oklch(0.8687 0.0043 56.366);
--sidebar-ring: oklch(0.5854 0.2041 277.1173); --sidebar-ring: oklch(0.5854 0.2041 277.1173);
--font-sans: Plus Jakarta Sans, sans-serif; --font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif; --font-serif: Lora, serif;
--font-mono: Roboto Mono, monospace; --font-mono: Roboto Mono, monospace;
--radius: 1.25rem; --radius: 1.25rem;
--shadow-2xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09); --shadow-2xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
--shadow-xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09); --shadow-xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
--shadow-sm: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18); --shadow-sm:
--shadow: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18); 2px 2px 10px 4px hsl(240 4% 60% / 0.18),
--shadow-md: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 2px 4px 3px hsl(240 4% 60% / 0.18); 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
--shadow-lg: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 4px 6px 3px hsl(240 4% 60% / 0.18); --shadow:
--shadow-xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 8px 10px 3px hsl(240 4% 60% / 0.18); 2px 2px 10px 4px hsl(240 4% 60% / 0.18),
--shadow-2xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.45); 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
--tracking-normal: 0em; --shadow-md:
--spacing: 0.25rem; 2px 2px 10px 4px hsl(240 4% 60% / 0.18),
2px 2px 4px 3px hsl(240 4% 60% / 0.18);
--shadow-lg:
2px 2px 10px 4px hsl(240 4% 60% / 0.18),
2px 4px 6px 3px hsl(240 4% 60% / 0.18);
--shadow-xl:
2px 2px 10px 4px hsl(240 4% 60% / 0.18),
2px 8px 10px 3px hsl(240 4% 60% / 0.18);
--shadow-2xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.45);
--tracking-normal: 0em;
--spacing: 0.25rem;
} }
.dark { .dark {
--background: oklch(0.2244 0.0074 67.4370); --background: oklch(0.2244 0.0074 67.437);
--foreground: oklch(0.9288 0.0126 255.5078); --foreground: oklch(0.9288 0.0126 255.5078);
--card: oklch(0.2801 0.0080 59.3379); --card: oklch(0.2801 0.008 59.3379);
--card-foreground: oklch(0.9288 0.0126 255.5078); --card-foreground: oklch(0.9288 0.0126 255.5078);
--popover: oklch(0.2801 0.0080 59.3379); --popover: oklch(0.2801 0.008 59.3379);
--popover-foreground: oklch(0.9288 0.0126 255.5078); --popover-foreground: oklch(0.9288 0.0126 255.5078);
--primary: oklch(0.5994 0.1568 47.5224); --primary: oklch(0.5994 0.1568 47.5224);
--primary-foreground: oklch(0.2244 0.0074 67.4370); --primary-foreground: oklch(0.2244 0.0074 67.437);
--secondary: oklch(0.3359 0.0077 59.4197); --secondary: oklch(0.3359 0.0077 59.4197);
--secondary-foreground: oklch(0.8717 0.0093 258.3382); --secondary-foreground: oklch(0.8717 0.0093 258.3382);
--muted: oklch(0.2801 0.0080 59.3379); --muted: oklch(0.2801 0.008 59.3379);
--muted-foreground: oklch(0.7137 0.0192 261.3246); --muted-foreground: oklch(0.7137 0.0192 261.3246);
--accent: oklch(0.3896 0.0074 59.4734); --accent: oklch(0.3896 0.0074 59.4734);
--accent-foreground: oklch(0.8717 0.0093 258.3382); --accent-foreground: oklch(0.8717 0.0093 258.3382);
--destructive: oklch(0.6368 0.2078 25.3313); --destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(0.2244 0.0074 67.4370); --destructive-foreground: oklch(0.2244 0.0074 67.437);
--border: oklch(0.3359 0.0077 59.4197); --border: oklch(0.3359 0.0077 59.4197);
--input: oklch(0.3359 0.0077 59.4197); --input: oklch(0.3359 0.0077 59.4197);
--ring: oklch(0.6801 0.1583 276.9349); --ring: oklch(0.6801 0.1583 276.9349);
--chart-1: oklch(0.6801 0.1583 276.9349); --chart-1: oklch(0.6801 0.1583 276.9349);
--chart-2: oklch(0.5854 0.2041 277.1173); --chart-2: oklch(0.5854 0.2041 277.1173);
--chart-3: oklch(0.5106 0.2301 276.9656); --chart-3: oklch(0.5106 0.2301 276.9656);
--chart-4: oklch(0.4568 0.2146 277.0229); --chart-4: oklch(0.4568 0.2146 277.0229);
--chart-5: oklch(0.3984 0.1773 277.3662); --chart-5: oklch(0.3984 0.1773 277.3662);
--sidebar: oklch(0.3359 0.0077 59.4197); --sidebar: oklch(0.3359 0.0077 59.4197);
--sidebar-foreground: oklch(0.9288 0.0126 255.5078); --sidebar-foreground: oklch(0.9288 0.0126 255.5078);
--sidebar-primary: oklch(0.6801 0.1583 276.9349); --sidebar-primary: oklch(0.6801 0.1583 276.9349);
--sidebar-primary-foreground: oklch(0.2244 0.0074 67.4370); --sidebar-primary-foreground: oklch(0.2244 0.0074 67.437);
--sidebar-accent: oklch(0.3896 0.0074 59.4734); --sidebar-accent: oklch(0.3896 0.0074 59.4734);
--sidebar-accent-foreground: oklch(0.8717 0.0093 258.3382); --sidebar-accent-foreground: oklch(0.8717 0.0093 258.3382);
--sidebar-border: oklch(0.3359 0.0077 59.4197); --sidebar-border: oklch(0.3359 0.0077 59.4197);
--sidebar-ring: oklch(0.6801 0.1583 276.9349); --sidebar-ring: oklch(0.6801 0.1583 276.9349);
--font-sans: Plus Jakarta Sans, sans-serif; --font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif; --font-serif: Lora, serif;
--font-mono: Roboto Mono, monospace; --font-mono: Roboto Mono, monospace;
--radius: 1.25rem; --radius: 1.25rem;
--shadow-2xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09); --shadow-2xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
--shadow-xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09); --shadow-xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
--shadow-sm: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18); --shadow-sm:
--shadow: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18); 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
--shadow-md: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 2px 4px 3px hsl(0 0% 0% / 0.18); --shadow:
--shadow-lg: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 4px 6px 3px hsl(0 0% 0% / 0.18); 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
--shadow-xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 8px 10px 3px hsl(0 0% 0% / 0.18); --shadow-md:
--shadow-2xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.45); 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 2px 4px 3px hsl(0 0% 0% / 0.18);
--shadow-lg:
2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 4px 6px 3px hsl(0 0% 0% / 0.18);
--shadow-xl:
2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 8px 10px 3px hsl(0 0% 0% / 0.18);
--shadow-2xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.45);
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-mono); --font-mono: var(--font-mono);
--font-serif: var(--font-serif); --font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs); --shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs); --shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm); --shadow-sm: var(--shadow-sm);
--shadow: var(--shadow); --shadow: var(--shadow);
--shadow-md: var(--shadow-md); --shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg); --shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl); --shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl); --shadow-2xl: var(--shadow-2xl);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }

View File

@@ -1,11 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Magra } from "next/font/google"; import { Geist, Magra } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Link from "next/link";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { ModeToggle } from "@/components/mode-toggle"; import { ModeToggle } from "@/components/mode-toggle";
import SignIn from "@/components/sign-in"; import SignIn from "@/components/sign-in";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import Link from "next/link";
const geist = Geist({ const geist = Geist({
subsets: ["latin", "cyrillic"], subsets: ["latin", "cyrillic"],

View File

@@ -1,26 +1,25 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { useSession } from "@/lib/auth-client"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AIToolbar } from "@/components/ai-toolbar";
import { DragDropContainer } from "@/components/drag-drop-container";
import { EventActionsToolbar } from "@/components/event-actions-toolbar";
import { EventDialog } from "@/components/event-dialog";
import { EventsList } from "@/components/events-list";
import { useSession } from "@/lib/auth-client";
import { IMAGE_MIME_TYPES, MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
import { import {
saveEvent as addEvent, saveEvent as addEvent,
clearEvents,
deleteEvent, deleteEvent,
getEvents as getAllEvents, getEvents as getAllEvents,
clearEvents,
updateEvent, updateEvent,
} from "@/lib/events-db"; } from "@/lib/events-db";
import { parseICS, generateICS } from "@/lib/ical"; import { generateICS, parseICS } from "@/lib/ical";
import type { CalendarEvent } from "@/lib/types"; import type { CalendarEvent } from "@/lib/types";
import { AIToolbar } from "@/components/ai-toolbar";
import { EventActionsToolbar } from "@/components/event-actions-toolbar";
import { EventsList } from "@/components/events-list";
import { EventDialog } from "@/components/event-dialog";
import { DragDropContainer } from "@/components/drag-drop-container";
const fileToBase64 = (file: File): Promise<string> => const fileToBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -29,6 +28,16 @@ const fileToBase64 = (file: File): Promise<string> =>
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
const validateImageFile = (file: File): string | null => {
if (!IMAGE_MIME_TYPES.includes(file.type)) {
return "Only PNG, JPEG, and WebP images are supported.";
}
if (file.size > MAX_IMAGE_SIZE_BYTES) {
return "Image must be less than 10MB.";
}
return null;
};
export default function HomePage() { export default function HomePage() {
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -79,6 +88,11 @@ export default function HomePage() {
}; };
const handleImageSelect = async (file: File) => { const handleImageSelect = async (file: File) => {
const error = validateImageFile(file);
if (error) {
toast.error(error);
return;
}
const base64 = await fileToBase64(file); const base64 = await fileToBase64(file);
setImageBase64(base64); setImageBase64(base64);
setImagePreview(URL.createObjectURL(file)); setImagePreview(URL.createObjectURL(file));
@@ -154,95 +168,84 @@ export default function HomePage() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
// AI Create Event const populateEventForm = (ev: CalendarEvent) => {
setTitle(ev.title || "");
setDescription(ev.description || "");
setLocation(ev.location || "");
setUrl(ev.url || "");
setStart(ev.start || "");
setEnd(ev.end || "");
setAllDay(ev.allDay || false);
setEditingId(null);
setRecurrenceRule(ev.recurrenceRule || undefined);
};
const persistAiEvents = async (data: CalendarEvent[]) => {
for (const ev of data) {
const newEvent: CalendarEvent = {
...ev,
id: nanoid(),
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
};
await addEvent(newEvent);
}
const stored = await getAllEvents();
setEvents(stored);
};
const sendAiRequest = async (): Promise<CalendarEvent[]> => {
const res = await fetch("/api/ai-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: aiPrompt,
imageBase64: imageBase64 || undefined,
}),
});
if (res.status === 401) {
throw new Error("Please sign in to use AI features.");
}
const data = await res.json();
if (!Array.isArray(data) || data.length === 0) {
throw new Error("AI did not return event data.");
}
return data;
};
const handleAiCreate = async () => { const handleAiCreate = async () => {
if (!aiPrompt.trim() && !imageBase64) return; if (!aiPrompt.trim() && !imageBase64) return;
setAiLoading(true); setAiLoading(true);
const promise = (): Promise<{ message: string }> => const promise = async (): Promise<{ message: string }> => {
new Promise(async (resolve, reject) => { const data = await sendAiRequest();
try {
const res = await fetch("/api/ai-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: aiPrompt,
imageBase64: imageBase64 || undefined,
}),
});
if (res.status === 401) { if (data.length === 1) {
setAiLoading(false); populateEventForm(data[0]);
reject({ setAiPrompt("");
message: "Please sign in to use AI features.", setDialogOpen(true);
}); handleImageClear();
return; return { message: "Event has been created!" };
} }
const data = await res.json(); await persistAiEvents(data);
setAiPrompt("");
if (Array.isArray(data) && data.length > 0) { setSummary(`Added ${data.length} AI-generated events.`);
if (data.length === 1) { setSummaryUpdated(new Date().toLocaleString());
const ev = data[0]; handleImageClear();
setTitle(ev.title || ""); return { message: "Events have been created!" };
setDescription(ev.description || ""); };
setLocation(ev.location || "");
setUrl(ev.url || "");
setStart(ev.start || "");
setEnd(ev.end || "");
setAllDay(ev.allDay || false);
setEditingId(null);
setAiPrompt("");
setDialogOpen(true);
setRecurrenceRule(ev.recurrenceRule || undefined);
handleImageClear();
resolve({
message: "Event has been created!",
});
} else {
for (const ev of data) {
const newEvent = {
id: nanoid(),
...ev,
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
};
await addEvent(newEvent);
}
const stored = await getAllEvents();
setEvents(stored);
setAiPrompt("");
setSummary(`Added ${data.length} AI-generated events.`);
setSummaryUpdated(new Date().toLocaleString());
handleImageClear();
resolve({
message: "Events have been created!",
});
}
} else {
reject({
message: "AI did not return event data.",
});
}
} catch (err) {
console.error(err);
reject({
message: "Error from AI service.",
});
}
});
toast.promise(promise, { toast.promise(promise, {
loading: "Generating event...", loading: "Generating event...",
success: ({ message }) => { success: ({ message }) => message,
return message; error: ({ message }) => message,
}, finally: () => setAiLoading(false),
error: ({ message }) => {
return message;
},
}); });
setAiLoading(false);
}; };
// AI Summarize Events // AI Summarize Events

View File

@@ -1,9 +1,9 @@
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card";
import { ImagePicker } from "@/components/image-picker";
import { X } from "lucide-react"; import { X } from "lucide-react";
import Image from "next/image";
import { ImagePicker } from "@/components/image-picker";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
interface AIToolbarProps { interface AIToolbarProps {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -53,13 +53,14 @@ export const AIToolbar = ({
onChange={(e) => setAiPrompt(e.target.value)} onChange={(e) => setAiPrompt(e.target.value)}
/> />
{imagePreview && ( {imagePreview && (
<div className="relative mt-2 inline-block"> <div className="relative mt-2 inline-block max-w-full overflow-hidden">
<Image <Image
src={imagePreview} src={imagePreview}
alt="Attached event flyer" alt="Attached event flyer"
className="h-20 rounded-md object-cover border" className="h-20 rounded-md object-cover border"
width={80} width={80}
height={80} height={80}
unoptimized
/> />
<Button <Button
variant="destructive" variant="destructive"

View File

@@ -1,7 +1,6 @@
import { ReactNode } from "react"; import type { ReactNode } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { IMAGE_EXTENSIONS } from "@/lib/constants";
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp"];
const getFileType = (file: File): "ics" | "image" | null => { const getFileType = (file: File): "ics" | "image" | null => {
const name = file.name.toLowerCase(); const name = file.name.toLowerCase();
@@ -53,7 +52,8 @@ export const DragDropContainer = ({
}; };
return ( return (
<div <section
aria-label="Drag and drop file import area"
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -67,6 +67,6 @@ export const DragDropContainer = ({
Drag & Drop *.ics or an event screenshot here Drag & Drop *.ics or an event screenshot here
</div> </div>
</div> </div>
</div> </section>
); );
}; };

View File

@@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button";
import { IcsFilePicker } from "@/components/ics-file-picker"; import { IcsFilePicker } from "@/components/ics-file-picker";
import { Button } from "@/components/ui/button";
import type { CalendarEvent } from "@/lib/types"; import type { CalendarEvent } from "@/lib/types";
interface EventActionsToolbarProps { interface EventActionsToolbarProps {

View File

@@ -1,13 +1,13 @@
import { Clock, LucideMapPin, MoreHorizontal } from "lucide-react";
import { RRuleDisplay } from "@/components/rrule-display";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardContent } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { LucideMapPin, Clock, MoreHorizontal } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { RRuleDisplay } from "@/components/rrule-display";
import type { CalendarEvent } from "@/lib/types"; import type { CalendarEvent } from "@/lib/types";
interface EventCardProps { interface EventCardProps {

View File

@@ -1,3 +1,4 @@
import { RecurrencePicker } from "@/components/recurrence-picker";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -7,7 +8,6 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { RecurrencePicker } from "@/components/recurrence-picker";
interface EventDialogProps { interface EventDialogProps {
open: boolean; open: boolean;

View File

@@ -1,6 +1,6 @@
import { Calendar1Icon } from "lucide-react"; import { Calendar1Icon } from "lucide-react";
import { EventCard } from "./event-card";
import type { CalendarEvent } from "@/lib/types"; import type { CalendarEvent } from "@/lib/types";
import { EventCard } from "./event-card";
interface EventsListProps { interface EventsListProps {
events: CalendarEvent[]; events: CalendarEvent[];

View File

@@ -1,10 +1,9 @@
"use client"; "use client";
import { Calendar } from "lucide-react";
import type React from "react"; import type React from "react";
import { useRef } from "react"; import { useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "lucide-react";
interface IcsFilePickerProps { interface IcsFilePickerProps {
onFileSelect?: (file: File) => void; onFileSelect?: (file: File) => void;
@@ -48,7 +47,6 @@ export function IcsFilePicker({
accept=".ics" accept=".ics"
onChange={handleFileChange} onChange={handleFileChange}
className="hidden" className="hidden"
aria-hidden="true"
/> />
<Button <Button
onClick={handleButtonClick} onClick={handleButtonClick}

View File

@@ -1,22 +1,15 @@
"use client"; "use client";
import type { VariantProps } from "class-variance-authority";
import { ImageIcon } from "lucide-react";
import type React from "react"; import type React from "react";
import { useRef } from "react"; import { useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button, type buttonVariants } from "@/components/ui/button";
import { ImageIcon } from "lucide-react";
interface ImagePickerProps { interface ImagePickerProps extends VariantProps<typeof buttonVariants> {
onFileSelect?: (file: File) => void; onFileSelect?: (file: File) => void;
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size?: "default" | "sm" | "lg" | "icon";
disabled?: boolean; disabled?: boolean;
} }

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import * as React from "react"; import { Monitor, Moon, Sun } from "lucide-react";
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import * as React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@@ -10,7 +11,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
type Recurrence = { type Recurrence = {
freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY"; freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY";

View File

@@ -41,8 +41,8 @@ export function RRuleDisplayDetailed({
{showBadges && details.length > 0 && ( {showBadges && details.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{details.map((detail, index) => ( {details.map((detail) => (
<Badge key={index} variant="outline" className="text-xs"> <Badge key={detail} variant="outline" className="text-xs">
{detail} {detail}
</Badge> </Badge>
))} ))}
@@ -159,7 +159,7 @@ function formatRRuleToHuman(rule: RecurrenceRule): string {
const [, num, dayCode] = match; const [, num, dayCode] = match;
const dayName = dayNames[dayCode as keyof typeof dayNames]; const dayName = dayNames[dayCode as keyof typeof dayNames];
if (num) { if (num) {
const ordinal = getOrdinal(parseInt(num)); const ordinal = getOrdinal(parseInt(num, 10));
return `${ordinal} ${dayName}`; return `${ordinal} ${dayName}`;
} }
return dayName; return dayName;

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { signOut, useSession } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { signOut, useSession } from "@/lib/auth-client";
export default function SignIn() { export default function SignIn() {
const { data: session, isPending } = useSession(); const { data: session, isPending } = useSession();
@@ -13,7 +13,7 @@ export default function SignIn() {
try { try {
await signOut(); await signOut();
router.push("/"); router.push("/");
} catch (_error) { } catch {
toast.error("Failed to sign out. Please try again."); toast.error("Failed to sign out. Please try again.");
} }
}; };

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes"; import { ThemeProvider as NextThemesProvider } from "next-themes";
import type * as React from "react";
export function ThemeProvider({ export function ThemeProvider({
children, children,

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@@ -1,15 +1,18 @@
"use client"; "use client";
import * as React from "react";
import { import {
ChevronDownIcon, ChevronDownIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
} from "lucide-react"; } from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"; import * as React from "react";
import {
import { cn } from "@/lib/utils"; type DayButton,
DayPicker,
getDefaultClassNames,
} from "react-day-picker";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Calendar({ function Calendar({
className, className,

View File

@@ -1,4 +1,4 @@
import * as React from "react"; import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -83,10 +83,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
export { export {
Card, Card,
CardHeader,
CardFooter,
CardTitle,
CardAction, CardAction,
CardDescription,
CardContent, CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
}; };

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -240,18 +240,18 @@ function DropdownMenuSubContent({
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuPortal, DropdownMenuCheckboxItem,
DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}; };

View File

@@ -1,4 +1,4 @@
import * as React from "react"; import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label"; import * as LabelPrimitive from "@radix-ui/react-label";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner"; import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme(); const { theme = "system" } = useTheme();

View File

@@ -1,4 +1,4 @@
import * as React from "react"; import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

4
src/css.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "*.css" {
const content: Record<string, string>;
export default content;
}

View File

@@ -2,7 +2,11 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import * as schema from "./schema"; import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!; const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL environment variable is required");
}
const client = postgres(connectionString, { const client = postgres(connectionString, {
prepare: false, prepare: false,

View File

@@ -1,4 +1,4 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core"; import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const user = pgTable("user", { export const user = pgTable("user", {
id: text("id").primaryKey(), id: text("id").primaryKey(),

View File

@@ -1,5 +1,5 @@
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins"; import { genericOAuthClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
plugins: [genericOAuthClient()], plugins: [genericOAuthClient()],

9
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,9 @@
/** Shared constants for image handling across the app. */
export const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp"];
export const IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"];
export const IMAGE_ACCEPT = IMAGE_MIME_TYPES.join(",");
export const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB

View File

@@ -1,4 +1,4 @@
import { openDB, type IDBPDatabase } from "idb"; import { type IDBPDatabase, openDB } from "idb";
import type { CalendarEvent } from "@/lib/types"; import type { CalendarEvent } from "@/lib/types";
const DB_NAME = "LocalCalEvents"; const DB_NAME = "LocalCalEvents";

View File

@@ -1,12 +1,12 @@
import ICAL from "ical.js"; import ICAL from "ical.js";
import type { CalendarEvent } from "@/lib/types"; import type { CalendarEvent } from "@/lib/types";
import { import {
isRecur,
isTime,
isUtcOffset,
isBinary, isBinary,
isDuration, isDuration,
isPeriod, isPeriod,
isRecur,
isTime,
isUtcOffset,
} from "./ical-helpers"; } from "./ical-helpers";
function safeValueToString( function safeValueToString(

19
src/lib/json-utils.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Extract JSON from text that may contain prose, markdown code blocks, or raw JSON.
* Pure function — same input = same output, no side effects.
*/
export const extractJsonFromText = (text: string): unknown => {
try {
return JSON.parse(text);
} catch {
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (codeBlockMatch) {
return JSON.parse(codeBlockMatch[1].trim());
}
const arrayMatch = text.match(/\[[\s\S]*\]/);
if (arrayMatch) {
return JSON.parse(arrayMatch[0]);
}
throw new Error("No JSON found in response");
}
};

View File

@@ -1,12 +1,26 @@
import { z } from "zod"; import { z } from "zod";
import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
/** Validates that a base64 data URL string decodes to binary under the max size. */
const isValidImageSize = (val: string | undefined): boolean => {
if (!val) return true;
const base64Part = val.split(",")[1] ?? "";
const binarySize = Math.ceil(base64Part.length * 0.75);
return binarySize <= MAX_IMAGE_SIZE_BYTES;
};
export const AiEventRequestSchema = z export const AiEventRequestSchema = z
.object({ .object({
prompt: z.string().trim().max(2000).optional(), prompt: z.string().trim().max(2000).optional(),
imageBase64: z imageBase64: z
.string() .string()
.startsWith("data:", "Must be a valid data URL") .regex(
.max(10 * 1024 * 1024 * 1.37, "Image must be less than 10MB") /^data:image\/(png|jpeg|webp);base64,/,
"Must be a valid image data URL (PNG, JPEG, or WebP)",
)
.refine(isValidImageSize, {
message: "Image must be less than 10MB",
})
.optional(), .optional(),
}) })
.refine((data) => data.prompt || data.imageBase64, { .refine((data) => data.prompt || data.imageBase64, {
@@ -21,25 +35,18 @@ export const AiEventResponseItemSchema = z.object({
description: z.string().optional(), description: z.string().optional(),
location: z.string().optional(), location: z.string().optional(),
url: z.string().optional(), url: z.string().optional(),
start: z.string(), start: z.string().datetime({ offset: true }),
end: z.string().optional(), end: z.string().datetime({ offset: true }).optional(),
allDay: z.boolean().optional(), allDay: z.boolean().optional(),
recurrenceRule: z.string().optional(), recurrenceRule: z.string().optional(),
}); });
export const AiEventResponseSchema = z.array(AiEventResponseItemSchema); export const AiEventResponseSchema = z.array(AiEventResponseItemSchema);
export type CalendarEvent = { export type AiEventResponseItem = z.infer<typeof AiEventResponseItemSchema>;
export type CalendarEvent = AiEventResponseItem & {
id: string; id: string;
title: string;
description?: string;
location?: string;
url?: string;
start: string;
end?: string;
allDay?: boolean;
createdAt?: string; createdAt?: string;
lastModified?: string; lastModified?: string;
recurrenceRule?: string;
}; };

View File

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