diff --git a/cloudflare-env.d.ts b/cloudflare-env.d.ts index bc33b85..1209fec 100755 --- a/cloudflare-env.d.ts +++ b/cloudflare-env.d.ts @@ -1,21 +1,15 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts` (hash: 6247394513f9e5a236d8a8ed9914d756) +// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts` (hash: 17faa1ab93062fdc4d6b4055bea03b00) // Runtime types generated with workerd@1.20260116.0 2025-12-01 global_fetch_strictly_public,nodejs_compat declare namespace Cloudflare { interface Env { - NEXTJS_ENV: string; - WORKER_SELF_REFERENCE: Fetcher /* dashboard-app-template */; + DB: D1Database; + WORKER_SELF_REFERENCE: Fetcher /* compass */; IMAGES: ImagesBinding; ASSETS: Fetcher; } } interface CloudflareEnv extends Cloudflare.Env {} -type StringifyValues> = { - [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; -}; -declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} -} // Begin runtime types /*! ***************************************************************************** diff --git a/drizzle/0004_quick_firebrand.sql b/drizzle/0004_quick_firebrand.sql new file mode 100755 index 0000000..3d13c0d --- /dev/null +++ b/drizzle/0004_quick_firebrand.sql @@ -0,0 +1,14 @@ +CREATE TABLE `feedback` ( + `id` text PRIMARY KEY NOT NULL, + `type` text NOT NULL, + `message` text NOT NULL, + `name` text, + `email` text, + `page_url` text, + `user_agent` text, + `viewport_width` integer, + `viewport_height` integer, + `ip_hash` text, + `github_issue_url` text, + `created_at` text NOT NULL +); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100755 index 0000000..7c941ba --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,648 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b6fe056d-1639-42cd-9360-80d10f7ebb3a", + "prevId": "20ead1c6-feea-44d0-8b20-6f4a5c6989ab", + "tables": { + "customers": { + "name": "customers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_url": { + "name": "page_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewport_width": { + "name": "viewport_width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewport_height": { + "name": "viewport_height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_issue_url": { + "name": "github_issue_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'OPEN'" + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_manager": { + "name": "project_manager", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_baselines": { + "name": "schedule_baselines", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_data": { + "name": "snapshot_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "schedule_baselines_project_id_projects_id_fk": { + "name": "schedule_baselines_project_id_projects_id_fk", + "tableFrom": "schedule_baselines", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_tasks": { + "name": "schedule_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workdays": { + "name": "workdays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date_calculated": { + "name": "end_date_calculated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'PENDING'" + }, + "is_critical_path": { + "name": "is_critical_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_milestone": { + "name": "is_milestone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "percent_complete": { + "name": "percent_complete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "assigned_to": { + "name": "assigned_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "schedule_tasks_project_id_projects_id_fk": { + "name": "schedule_tasks_project_id_projects_id_fk", + "tableFrom": "schedule_tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_dependencies": { + "name": "task_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "predecessor_id": { + "name": "predecessor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "successor_id": { + "name": "successor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'FS'" + }, + "lag_days": { + "name": "lag_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "task_dependencies_predecessor_id_schedule_tasks_id_fk": { + "name": "task_dependencies_predecessor_id_schedule_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "schedule_tasks", + "columnsFrom": [ + "predecessor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_dependencies_successor_id_schedule_tasks_id_fk": { + "name": "task_dependencies_successor_id_schedule_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "schedule_tasks", + "columnsFrom": [ + "successor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vendors": { + "name": "vendors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Subcontractor'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workday_exceptions": { + "name": "workday_exceptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'non_working'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'company_holiday'" + }, + "recurrence": { + "name": "recurrence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'one_time'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "workday_exceptions_project_id_projects_id_fk": { + "name": "workday_exceptions_project_id_projects_id_fk", + "tableFrom": "workday_exceptions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c20e782..b74648f 100755 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1769286212480, "tag": "0003_burly_kabuki", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1769287376759, + "tag": "0004_quick_firebrand", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index be39f49..6b26c10 100755 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "dashboard-app-template", + "name": "compass", "version": "0.1.0", "private": true, "scripts": { diff --git a/src/app/actions/projects.ts b/src/app/actions/projects.ts new file mode 100755 index 0000000..b3cbb27 --- /dev/null +++ b/src/app/actions/projects.ts @@ -0,0 +1,23 @@ +"use server" + +import { getCloudflareContext } from "@opennextjs/cloudflare" +import { getDb } from "@/db" +import { projects } from "@/db/schema" +import { asc } from "drizzle-orm" + +export async function getProjects(): Promise<{ id: string; name: string }[]> { + try { + const { env } = await getCloudflareContext() + if (!env?.DB) return [] + + const db = getDb(env.DB) + const allProjects = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .orderBy(asc(projects.name)) + + return allProjects + } catch { + return [] + } +} diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts new file mode 100755 index 0000000..e100a5b --- /dev/null +++ b/src/app/api/feedback/route.ts @@ -0,0 +1,178 @@ +import { getCloudflareContext } from "@opennextjs/cloudflare" +import { drizzle } from "drizzle-orm/d1" +import { feedback } from "@/db/schema" +import { sql } from "drizzle-orm" + +const FEEDBACK_TYPES = ["bug", "feature", "question", "general"] as const + +export async function POST(request: Request) { + const body = await request.json().catch(() => null) as Record | null + if (!body) { + return Response.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const { type, message, name, email, pageUrl, userAgent, viewportWidth, viewportHeight } = body as { + type: string + message: string + name?: string + email?: string + pageUrl?: string + userAgent?: string + viewportWidth?: number + viewportHeight?: number + } + + if (!(FEEDBACK_TYPES as readonly string[]).includes(type)) { + return Response.json( + { error: "Invalid type. Must be: bug, feature, question, or general" }, + { status: 400 }, + ) + } + if (!message || typeof message !== "string" || message.trim().length === 0) { + return Response.json({ error: "Message is required" }, { status: 400 }) + } + if (message.length > 2000) { + return Response.json( + { error: "Message must be 2000 characters or less" }, + { status: 400 }, + ) + } + + const { env, cf } = await getCloudflareContext() + const db = drizzle(env.DB) + + const ip = (cf as { request?: Request })?.request?.headers?.get("cf-connecting-ip") + ?? request.headers.get("cf-connecting-ip") + ?? "unknown" + const ipHash = await hashIp(ip) + + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString() + const recentSubmissions = await db + .select({ count: sql`count(*)` }) + .from(feedback) + .where( + sql`${feedback.ipHash} = ${ipHash} AND ${feedback.createdAt} > ${oneHourAgo}`, + ) + + if (recentSubmissions[0].count >= 5) { + return Response.json( + { error: "Too many submissions. Please try again later." }, + { status: 429 }, + ) + } + + const id = crypto.randomUUID() + const createdAt = new Date().toISOString() + + await db.insert(feedback).values({ + id, + type, + message: message.trim(), + name: name?.trim() || null, + email: email?.trim() || null, + pageUrl: pageUrl || null, + userAgent: userAgent || null, + viewportWidth: viewportWidth || null, + viewportHeight: viewportHeight || null, + ipHash, + createdAt, + }) + + createGithubIssue(env, db, id, { + type, + message: message.trim(), + name: name?.trim(), + email: email?.trim(), + pageUrl, + userAgent, + viewportWidth, + viewportHeight, + createdAt, + }) + + return Response.json({ success: true }) +} + +async function hashIp(ip: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(ip) + const hashBuffer = await crypto.subtle.digest("SHA-256", data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map(b => b.toString(16).padStart(2, "0")).join("") +} + +const LABEL_MAP: Record = { + bug: "bug", + feature: "enhancement", + question: "question", + general: "feedback", +} + +async function createGithubIssue( + env: CloudflareEnv, + db: ReturnType, + feedbackId: string, + data: { + type: string + message: string + name?: string + email?: string + pageUrl?: string + userAgent?: string + viewportWidth?: number + viewportHeight?: number + createdAt: string + }, +) { + const token = (env as unknown as Record).GITHUB_TOKEN as string | undefined + ?? process.env.GITHUB_TOKEN + const repo = (env as unknown as Record).GITHUB_REPO as string | undefined + ?? process.env.GITHUB_REPO + if (!token || !repo) return + + const titlePrefix = `[${data.type}]` + const titleMessage = data.message.slice(0, 60) + (data.message.length > 60 ? "..." : "") + const title = `${titlePrefix} ${titleMessage}` + + const fromLine = data.name + ? `${data.name}${data.email ? ` (${data.email})` : ""}` + : `Anonymous${data.email ? ` (${data.email})` : ""}` + + const body = `## Feedback: ${data.type} + +${data.message} + +--- + +**From:** ${fromLine} +**Page:** ${data.pageUrl || "Unknown"} +**Viewport:** ${data.viewportWidth || "?"}x${data.viewportHeight || "?"} +**User Agent:** ${data.userAgent || "Unknown"} +**Timestamp:** ${data.createdAt}` + + try { + const res = await fetch(`https://api.github.com/repos/${repo}/issues`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "compass-feedback-widget", + }, + body: JSON.stringify({ + title, + body, + labels: [LABEL_MAP[data.type] || "feedback"], + }), + }) + + if (res.ok) { + const issue = await res.json() as { html_url: string } + await db + .update(feedback) + .set({ githubIssueUrl: issue.html_url }) + .where(sql`${feedback.id} = ${feedbackId}`) + } + } catch { + // non-blocking: don't fail the feedback submission + } +} diff --git a/src/app/dashboard/files/layout.tsx b/src/app/dashboard/files/layout.tsx index b618961..8caed49 100755 --- a/src/app/dashboard/files/layout.tsx +++ b/src/app/dashboard/files/layout.tsx @@ -1,6 +1,5 @@ "use client" -import { Toaster } from "sonner" import { FilesProvider } from "@/hooks/use-files" export default function FilesLayout({ @@ -11,7 +10,6 @@ export default function FilesLayout({ return ( {children} - ) } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 429a0d2..8395bca 100755 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -2,6 +2,8 @@ import { AppSidebar } from "@/components/app-sidebar" import { SiteHeader } from "@/components/site-header" import { CommandMenuProvider } from "@/components/command-menu-provider" import { SettingsProvider } from "@/components/settings-provider" +import { FeedbackWidget } from "@/components/feedback-widget" +import { Toaster } from "@/components/ui/sonner" import { SidebarInset, SidebarProvider, @@ -28,17 +30,20 @@ export default async function DashboardLayout({ } > - - -
-
- {children} + + + +
+
+ {children} +
-
- + +

Pre-alpha build

+ diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 29c8bc4..60b11d7 100755 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,3 +1,4 @@ +import { FeedbackCallout } from "@/components/feedback-widget" import { Button } from "@/components/ui/button" import { IconBrandGithub, @@ -93,6 +94,9 @@ export default async function Page() { Development preview — features may be incomplete or change without notice.

+
+ +
diff --git a/src/app/dashboard/projects/[id]/page.tsx b/src/app/dashboard/projects/[id]/page.tsx new file mode 100755 index 0000000..8c8e788 --- /dev/null +++ b/src/app/dashboard/projects/[id]/page.tsx @@ -0,0 +1,478 @@ +import { getCloudflareContext } from "@opennextjs/cloudflare" +import { getDb } from "@/db" +import { projects, scheduleTasks } from "@/db/schema" +import { eq } from "drizzle-orm" +import { notFound } from "next/navigation" +import Link from "next/link" +import { + IconAlertTriangle, + IconCalendarStats, + IconCheck, + IconClock, + IconDots, + IconFlag, + IconPlus, + IconThumbUp, + IconUser, +} from "@tabler/icons-react" +import type { ScheduleTask } from "@/db/schema" + +function getWeekDays(): { date: Date; dayName: string }[] { + const today = new Date() + const day = today.getDay() + const monday = new Date(today) + monday.setDate(today.getDate() - (day === 0 ? 6 : day - 1)) + + const days = [] + for (let i = 0; i < 7; i++) { + const d = new Date(monday) + d.setDate(monday.getDate() + i) + days.push({ + date: d, + dayName: d.toLocaleDateString("en-US", { weekday: "long" }), + }) + } + return days +} + +function formatDateStr(d: Date): string { + return d.toISOString().split("T")[0] +} + +function isTaskOnDate(task: ScheduleTask, dateStr: string): boolean { + return task.startDate <= dateStr && task.endDateCalculated >= dateStr +} + +export default async function ProjectSummaryPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + + let project: { + id: string + name: string + status: string + address: string | null + clientName: string | null + projectManager: string | null + createdAt: string + } | null = null + let tasks: ScheduleTask[] = [] + + try { + const { env } = await getCloudflareContext() + if (!env?.DB) throw new Error("D1 not available") + + const db = getDb(env.DB) + + const [found] = await db + .select() + .from(projects) + .where(eq(projects.id, id)) + .limit(1) + + if (!found) notFound() + project = found + + tasks = await db + .select() + .from(scheduleTasks) + .where(eq(scheduleTasks.projectId, id)) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + if (e?.digest === "NEXT_NOT_FOUND") throw e + console.warn("D1 unavailable in dev mode, using empty data") + } + + const projectName = project?.name ?? "Project" + const projectStatus = project?.status ?? "OPEN" + const todayStr = formatDateStr(new Date()) + + const completedTasks = tasks.filter((t) => t.status === "COMPLETE") + const activeTasks = tasks.filter((t) => t.status !== "COMPLETE") + const totalCount = tasks.length + const completedPercent = totalCount > 0 + ? Math.round((completedTasks.length / totalCount) * 100) + : 0 + + const pastDue = activeTasks.filter( + (t) => t.endDateCalculated < todayStr + ) + const dueToday = activeTasks.filter( + (t) => t.endDateCalculated === todayStr + ) + const upcomingMilestones = tasks.filter( + (t) => t.isMilestone && t.startDate >= todayStr && t.status !== "COMPLETE" + ) + + // phase breakdown + const phases = new Map() + for (const t of tasks) { + const entry = phases.get(t.phase) ?? { total: 0, completed: 0 } + entry.total++ + if (t.status === "COMPLETE") entry.completed++ + phases.set(t.phase, entry) + } + + // recent updates (tasks sorted by updatedAt desc) + const recentUpdates = [...tasks] + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .slice(0, 5) + + // week agenda + const weekDays = getWeekDays() + const weekAgenda = weekDays.map((day) => { + const dateStr = formatDateStr(day.date) + const dayTasks = tasks.filter((t) => isTaskOnDate(t, dateStr)) + const isToday = dateStr === todayStr + const isWeekend = day.date.getDay() === 0 || day.date.getDay() === 6 + return { ...day, dateStr, dayTasks, isToday, isWeekend } + }) + + return ( +
+
+ {/* header */} +
+
+
+

{projectName}

+ + {projectStatus} + +
+ {project?.address && ( +

+ {project.address} +

+ )} +

+ {totalCount} tasks · {completedPercent}% complete +

+
+ +
+ + {/* client / pm row */} +
+
+

+ Client +

+
+ {project?.clientName ? ( + <> +
+ {project.clientName.split(" ").map(w => w[0]).join("").slice(0, 2)} +
+ {project.clientName} + + ) : ( + + )} +
+
+
+

+ Project Manager +

+
+ {project?.projectManager ? ( + <> +
+ +
+ {project.projectManager} + + ) : ( + + )} +
+
+
+ + + View schedule + +
+
+ + {/* progress bar */} +
+
+

Overall Progress

+

{completedPercent}%

+
+
+
+
+
+ {completedTasks.length} completed + {activeTasks.length} in progress + {pastDue.length > 0 && ( + {pastDue.length} overdue + )} +
+
+ + {/* urgency columns */} +
+
+

+ Past Due +

+ {pastDue.length > 0 ? ( +
+ {pastDue.slice(0, 4).map((t) => ( +
+ {t.title} + + {new Date(t.endDateCalculated).toLocaleDateString( + "en-US", { month: "short", day: "numeric" } + )} + +
+ ))} + {pastDue.length > 4 && ( +

+ +{pastDue.length - 4} more +

+ )} +
+ ) : ( +
+ + Nothing past due +
+ )} +
+ +
+

+ Due Today +

+ {dueToday.length > 0 ? ( +
+ {dueToday.map((t) => ( +
{t.title}
+ ))} +
+ ) : ( +
+ + Nothing due today +
+ )} +
+ +
+

+ Upcoming Milestones +

+ {upcomingMilestones.length > 0 ? ( +
+ {upcomingMilestones.slice(0, 4).map((t) => ( +
+ {t.title} + + {new Date(t.startDate).toLocaleDateString( + "en-US", { month: "short", day: "numeric" } + )} + +
+ ))} +
+ ) : ( +
+ + No upcoming milestones +
+ )} +
+
+ + {/* two-column: phases + active tasks */} +
+ {/* phase breakdown */} +
+

+ Phases +

+ {phases.size > 0 ? ( +
+ {[...phases.entries()].map(([phase, data]) => { + const pct = Math.round((data.completed / data.total) * 100) + return ( +
+
+ {phase} + + {data.completed}/{data.total} + +
+
+
+
+
+ ) + })} +
+ ) : ( +

No phases yet.

+ )} +
+ + {/* active tasks */} +
+

+ Active Tasks +

+ {activeTasks.length > 0 ? ( +
+ {activeTasks + .sort((a, b) => a.startDate.localeCompare(b.startDate)) + .slice(0, 8) + .map((t) => ( +
+ {t.endDateCalculated < todayStr ? ( + + ) : ( + + )} + {t.title} +
+
+
+
+ ))} + {activeTasks.length > 8 && ( + + +{activeTasks.length - 8} more in schedule + + )} +
+ ) : ( +
+ + All tasks complete +
+ )} +
+
+ + {/* recent updates */} +
+

+ Recent Updates +

+ {recentUpdates.length > 0 ? ( +
+ {recentUpdates.map((t) => ( +
+
+ {t.status === "COMPLETE" ? ( + + ) : ( + + )} +
+
+

{t.title}

+

+ {t.status === "COMPLETE" ? "Completed" : `${t.percentComplete}% complete`} + {" \u00b7 "} + {t.phase} + {" \u00b7 "} + {new Date(t.updatedAt).toLocaleDateString( + "en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" } + )} +

+
+
+ ))} +
+ ) : ( +

No recent activity.

+ )} +
+
+ + {/* right sidebar: week agenda */} +
+
+

+ This Week +

+ + View schedule + +
+
+ {weekAgenda.map((day) => ( +
+
+

+ {day.date.getDate()} +

+
+
+

{day.dayName}

+ {day.isWeekend ? ( +

Non-workday

+ ) : day.dayTasks.length > 0 ? ( +
+ {day.dayTasks.slice(0, 3).map((t) => ( +

+ {t.title} +

+ ))} + {day.dayTasks.length > 3 && ( +

+ +{day.dayTasks.length - 3} more +

+ )} +
+ ) : ( +

No tasks

+ )} +
+
+ ))} +
+
+
+ ) +} diff --git a/src/app/dashboard/projects/[id]/schedule/page.tsx b/src/app/dashboard/projects/[id]/schedule/page.tsx index 2c09744..12ff285 100755 --- a/src/app/dashboard/projects/[id]/schedule/page.tsx +++ b/src/app/dashboard/projects/[id]/schedule/page.tsx @@ -43,8 +43,8 @@ export default async function SchedulePage({ getSchedule(id), getBaselines(id), ]) - } catch (e: any) { - if (e?.digest === "NEXT_NOT_FOUND") throw e + } catch (e: unknown) { + if (e && typeof e === "object" && "digest" in e && e.digest === "NEXT_NOT_FOUND") throw e console.warn("D1 unavailable in dev mode, using empty data") } diff --git a/src/components/feedback-widget.tsx b/src/components/feedback-widget.tsx new file mode 100755 index 0000000..f68b659 --- /dev/null +++ b/src/components/feedback-widget.tsx @@ -0,0 +1,182 @@ +"use client" + +import { createContext, useContext, useState } from "react" +import { usePathname } from "next/navigation" +import { MessageCircle } from "lucide-react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +const FeedbackContext = createContext<{ open: () => void }>({ open: () => {} }) + +export function useFeedback() { + return useContext(FeedbackContext) +} + +export function FeedbackCallout() { + const { open } = useFeedback() + return ( +

+ Have feedback?{" "} + + {" "}— we'd love to hear from you. +

+ ) +} + +export function FeedbackWidget({ children }: { children?: React.ReactNode }) { + const [dialogOpen, setDialogOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [type, setType] = useState("") + const [message, setMessage] = useState("") + const [name, setName] = useState("") + const [email, setEmail] = useState("") + const pathname = usePathname() + + function resetForm() { + setType("") + setMessage("") + setName("") + setEmail("") + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!type || !message.trim()) return + + setSubmitting(true) + try { + const res = await fetch("/api/feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type, + message, + name: name || undefined, + email: email || undefined, + pageUrl: pathname, + userAgent: navigator.userAgent, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }), + }) + + if (res.ok) { + toast.success("Feedback submitted, thank you!") + resetForm() + setDialogOpen(false) + } else { + const data = await res.json() as { error?: string } + toast.error(data.error || "Something went wrong") + } + } catch { + toast.error("Failed to submit feedback") + } finally { + setSubmitting(false) + } + } + + return ( + setDialogOpen(true) }}> + {children} + + + + + + + Send Feedback + + Report a bug, request a feature, or ask a question. + + +
+
+ + +
+ +
+ +