feat(deploy): configure cloudflare workers + D1 + feedback

- configure wrangler for CF account with D1 binding
- add feedback API route with rate limiting and github issue creation
- add feedback widget component
- add project detail page with status/schedule/info tabs
- add frappe-gantt type declarations
- fix type errors for production build
- add migration 0004 for feedback table
This commit is contained in:
Nicholai Vogel 2026-01-24 13:58:37 -07:00
parent d18c341352
commit 41fdfd9e4c
17 changed files with 1623 additions and 55 deletions

12
cloudflare-env.d.ts vendored
View File

@ -1,21 +1,15 @@
/* eslint-disable */ /* 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 // Runtime types generated with workerd@1.20260116.0 2025-12-01 global_fetch_strictly_public,nodejs_compat
declare namespace Cloudflare { declare namespace Cloudflare {
interface Env { interface Env {
NEXTJS_ENV: string; DB: D1Database;
WORKER_SELF_REFERENCE: Fetcher /* dashboard-app-template */; WORKER_SELF_REFERENCE: Fetcher /* compass */;
IMAGES: ImagesBinding; IMAGES: ImagesBinding;
ASSETS: Fetcher; ASSETS: Fetcher;
} }
} }
interface CloudflareEnv extends Cloudflare.Env {} interface CloudflareEnv extends Cloudflare.Env {}
type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "NEXTJS_ENV">> {}
}
// Begin runtime types // Begin runtime types
/*! ***************************************************************************** /*! *****************************************************************************

View File

@ -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
);

648
drizzle/meta/0004_snapshot.json Executable file
View File

@ -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": {}
}
}

View File

@ -29,6 +29,13 @@
"when": 1769286212480, "when": 1769286212480,
"tag": "0003_burly_kabuki", "tag": "0003_burly_kabuki",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1769287376759,
"tag": "0004_quick_firebrand",
"breakpoints": true
} }
] ]
} }

View File

@ -1,5 +1,5 @@
{ {
"name": "dashboard-app-template", "name": "compass",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {

23
src/app/actions/projects.ts Executable file
View File

@ -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 []
}
}

178
src/app/api/feedback/route.ts Executable file
View File

@ -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<string, unknown> | 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<number>`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<string> {
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<string, string> = {
bug: "bug",
feature: "enhancement",
question: "question",
general: "feedback",
}
async function createGithubIssue(
env: CloudflareEnv,
db: ReturnType<typeof drizzle>,
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<string, unknown>).GITHUB_TOKEN as string | undefined
?? process.env.GITHUB_TOKEN
const repo = (env as unknown as Record<string, unknown>).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
}
}

View File

@ -1,6 +1,5 @@
"use client" "use client"
import { Toaster } from "sonner"
import { FilesProvider } from "@/hooks/use-files" import { FilesProvider } from "@/hooks/use-files"
export default function FilesLayout({ export default function FilesLayout({
@ -11,7 +10,6 @@ export default function FilesLayout({
return ( return (
<FilesProvider> <FilesProvider>
{children} {children}
<Toaster position="bottom-right" />
</FilesProvider> </FilesProvider>
) )
} }

View File

@ -2,6 +2,8 @@ import { AppSidebar } from "@/components/app-sidebar"
import { SiteHeader } from "@/components/site-header" import { SiteHeader } from "@/components/site-header"
import { CommandMenuProvider } from "@/components/command-menu-provider" import { CommandMenuProvider } from "@/components/command-menu-provider"
import { SettingsProvider } from "@/components/settings-provider" import { SettingsProvider } from "@/components/settings-provider"
import { FeedbackWidget } from "@/components/feedback-widget"
import { Toaster } from "@/components/ui/sonner"
import { import {
SidebarInset, SidebarInset,
SidebarProvider, SidebarProvider,
@ -28,6 +30,7 @@ export default async function DashboardLayout({
} }
> >
<AppSidebar variant="inset" projects={projectList} /> <AppSidebar variant="inset" projects={projectList} />
<FeedbackWidget>
<SidebarInset className="overflow-hidden"> <SidebarInset className="overflow-hidden">
<SiteHeader /> <SiteHeader />
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
@ -36,9 +39,11 @@ export default async function DashboardLayout({
</div> </div>
</div> </div>
</SidebarInset> </SidebarInset>
</FeedbackWidget>
<p className="pointer-events-none fixed bottom-3 left-0 right-0 text-center text-xs text-muted-foreground/60"> <p className="pointer-events-none fixed bottom-3 left-0 right-0 text-center text-xs text-muted-foreground/60">
Pre-alpha build Pre-alpha build
</p> </p>
<Toaster position="bottom-right" />
</SidebarProvider> </SidebarProvider>
</CommandMenuProvider> </CommandMenuProvider>
</SettingsProvider> </SettingsProvider>

View File

@ -1,3 +1,4 @@
import { FeedbackCallout } from "@/components/feedback-widget"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
IconBrandGithub, IconBrandGithub,
@ -93,6 +94,9 @@ export default async function Page() {
Development preview features may be incomplete Development preview features may be incomplete
or change without notice. or change without notice.
</p> </p>
<div className="mt-4">
<FeedbackCallout />
</div>
</div> </div>
<div className="grid gap-10 lg:grid-cols-2"> <div className="grid gap-10 lg:grid-cols-2">

View File

@ -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<string, { total: number; completed: number }>()
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 (
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto p-6">
{/* header */}
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-semibold">{projectName}</h1>
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{projectStatus}
</span>
</div>
{project?.address && (
<p className="text-sm text-muted-foreground">
{project.address}
</p>
)}
<p className="text-sm text-muted-foreground mt-0.5">
{totalCount} tasks &middot; {completedPercent}% complete
</p>
</div>
<button className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground">
<IconDots className="size-5" />
</button>
</div>
{/* client / pm row */}
<div className="flex gap-8 mb-6">
<div>
<p className="text-xs font-medium uppercase text-muted-foreground mb-2">
Client
</p>
<div className="flex items-center gap-2">
{project?.clientName ? (
<>
<div className="size-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary">
{project.clientName.split(" ").map(w => w[0]).join("").slice(0, 2)}
</div>
<span className="text-sm">{project.clientName}</span>
</>
) : (
<button className="size-8 rounded-full border border-dashed flex items-center justify-center text-muted-foreground hover:border-foreground hover:text-foreground transition-colors">
<IconPlus className="size-4" />
</button>
)}
</div>
</div>
<div>
<p className="text-xs font-medium uppercase text-muted-foreground mb-2">
Project Manager
</p>
<div className="flex items-center gap-2">
{project?.projectManager ? (
<>
<div className="size-8 rounded-full bg-accent flex items-center justify-center">
<IconUser className="size-4 text-muted-foreground" />
</div>
<span className="text-sm">{project.projectManager}</span>
</>
) : (
<button className="size-8 rounded-full border border-dashed flex items-center justify-center text-muted-foreground hover:border-foreground hover:text-foreground transition-colors">
<IconPlus className="size-4" />
</button>
)}
</div>
</div>
<div className="ml-auto self-end">
<Link
href={`/dashboard/projects/${id}/schedule`}
className="text-sm text-primary hover:underline flex items-center gap-1.5"
>
<IconCalendarStats className="size-4" />
View schedule
</Link>
</div>
</div>
{/* progress bar */}
<div className="rounded-lg border p-4 mb-6">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium">Overall Progress</p>
<p className="text-sm font-semibold">{completedPercent}%</p>
</div>
<div className="w-full h-2 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${completedPercent}%` }}
/>
</div>
<div className="flex gap-4 mt-3 text-xs text-muted-foreground">
<span>{completedTasks.length} completed</span>
<span>{activeTasks.length} in progress</span>
{pastDue.length > 0 && (
<span className="text-destructive">{pastDue.length} overdue</span>
)}
</div>
</div>
{/* urgency columns */}
<div className="grid grid-cols-3 gap-px rounded-lg border overflow-hidden mb-6">
<div className="p-4 bg-background">
<p className="text-xs font-medium uppercase text-muted-foreground mb-3">
Past Due
</p>
{pastDue.length > 0 ? (
<div className="space-y-2">
{pastDue.slice(0, 4).map((t) => (
<div key={t.id} className="flex items-center justify-between">
<span className="text-sm truncate mr-2">{t.title}</span>
<span className="text-xs text-destructive font-medium shrink-0">
{new Date(t.endDateCalculated).toLocaleDateString(
"en-US", { month: "short", day: "numeric" }
)}
</span>
</div>
))}
{pastDue.length > 4 && (
<p className="text-xs text-muted-foreground">
+{pastDue.length - 4} more
</p>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<IconThumbUp className="size-4" />
Nothing past due
</div>
)}
</div>
<div className="p-4 bg-background border-x">
<p className="text-xs font-medium uppercase text-muted-foreground mb-3">
Due Today
</p>
{dueToday.length > 0 ? (
<div className="space-y-2">
{dueToday.map((t) => (
<div key={t.id} className="text-sm truncate">{t.title}</div>
))}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<IconThumbUp className="size-4" />
Nothing due today
</div>
)}
</div>
<div className="p-4 bg-background">
<p className="text-xs font-medium uppercase text-muted-foreground mb-3">
Upcoming Milestones
</p>
{upcomingMilestones.length > 0 ? (
<div className="space-y-2">
{upcomingMilestones.slice(0, 4).map((t) => (
<div key={t.id} className="flex items-center justify-between">
<span className="text-sm truncate mr-2">{t.title}</span>
<span className="text-xs text-muted-foreground shrink-0">
{new Date(t.startDate).toLocaleDateString(
"en-US", { month: "short", day: "numeric" }
)}
</span>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<IconFlag className="size-4" />
No upcoming milestones
</div>
)}
</div>
</div>
{/* two-column: phases + active tasks */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* phase breakdown */}
<div>
<h2 className="text-xs font-medium uppercase text-muted-foreground mb-3">
Phases
</h2>
{phases.size > 0 ? (
<div className="space-y-3">
{[...phases.entries()].map(([phase, data]) => {
const pct = Math.round((data.completed / data.total) * 100)
return (
<div key={phase}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm capitalize">{phase}</span>
<span className="text-xs text-muted-foreground">
{data.completed}/{data.total}
</span>
</div>
<div className="w-full h-1.5 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/70"
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No phases yet.</p>
)}
</div>
{/* active tasks */}
<div>
<h2 className="text-xs font-medium uppercase text-muted-foreground mb-3">
Active Tasks
</h2>
{activeTasks.length > 0 ? (
<div className="space-y-2.5">
{activeTasks
.sort((a, b) => a.startDate.localeCompare(b.startDate))
.slice(0, 8)
.map((t) => (
<div key={t.id} className="flex items-center gap-2 text-sm">
{t.endDateCalculated < todayStr ? (
<IconAlertTriangle className="size-3.5 text-destructive shrink-0" />
) : (
<IconClock className="size-3.5 text-muted-foreground shrink-0" />
)}
<span className="truncate flex-1">{t.title}</span>
<div className="w-12 h-1.5 rounded-full bg-muted shrink-0">
<div
className="h-full rounded-full bg-foreground/50"
style={{ width: `${t.percentComplete}%` }}
/>
</div>
</div>
))}
{activeTasks.length > 8 && (
<Link
href={`/dashboard/projects/${id}/schedule`}
className="text-xs text-primary hover:underline"
>
+{activeTasks.length - 8} more in schedule
</Link>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<IconCheck className="size-4" />
All tasks complete
</div>
)}
</div>
</div>
{/* recent updates */}
<div>
<h2 className="text-xs font-medium uppercase text-muted-foreground mb-3">
Recent Updates
</h2>
{recentUpdates.length > 0 ? (
<div className="space-y-3">
{recentUpdates.map((t) => (
<div key={t.id} className="flex items-start gap-3">
<div className="size-7 rounded-full bg-muted flex items-center justify-center mt-0.5 shrink-0">
{t.status === "COMPLETE" ? (
<IconCheck className="size-3.5 text-primary" />
) : (
<IconClock className="size-3.5 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm truncate">{t.title}</p>
<p className="text-xs text-muted-foreground">
{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" }
)}
</p>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No recent activity.</p>
)}
</div>
</div>
{/* right sidebar: week agenda */}
<div className="w-72 border-l overflow-y-auto p-4 shrink-0 hidden lg:block">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xs font-medium uppercase text-muted-foreground">
This Week
</h2>
<Link
href={`/dashboard/projects/${id}/schedule`}
className="text-xs text-primary hover:underline"
>
View schedule
</Link>
</div>
<div className="space-y-1">
{weekAgenda.map((day) => (
<div
key={day.dateStr}
className={`flex gap-3 rounded-md p-2 ${
day.isToday ? "bg-accent" : ""
}`}
>
<div className="text-center shrink-0 w-10">
<p className={`text-lg font-semibold leading-none ${
day.isToday ? "text-primary" : ""
}`}>
{day.date.getDate()}
</p>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{day.dayName}</p>
{day.isWeekend ? (
<p className="text-xs text-muted-foreground">Non-workday</p>
) : day.dayTasks.length > 0 ? (
<div className="space-y-0.5">
{day.dayTasks.slice(0, 3).map((t) => (
<p key={t.id} className="text-xs text-muted-foreground truncate">
{t.title}
</p>
))}
{day.dayTasks.length > 3 && (
<p className="text-xs text-muted-foreground">
+{day.dayTasks.length - 3} more
</p>
)}
</div>
) : (
<p className="text-xs text-muted-foreground">No tasks</p>
)}
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@ -43,8 +43,8 @@ export default async function SchedulePage({
getSchedule(id), getSchedule(id),
getBaselines(id), getBaselines(id),
]) ])
} catch (e: any) { } catch (e: unknown) {
if (e?.digest === "NEXT_NOT_FOUND") throw e 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") console.warn("D1 unavailable in dev mode, using empty data")
} }

View File

@ -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 (
<p className="text-primary font-semibold">
Have feedback?{" "}
<button onClick={open} className="underline underline-offset-2 hover:opacity-80">
Let us know what you think
</button>
{" "} we&apos;d love to hear from you.
</p>
)
}
export function FeedbackWidget({ children }: { children?: React.ReactNode }) {
const [dialogOpen, setDialogOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [type, setType] = useState<string>("")
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 (
<FeedbackContext.Provider value={{ open: () => setDialogOpen(true) }}>
{children}
<Button
onClick={() => setDialogOpen(true)}
size="icon-lg"
className="group fixed bottom-12 right-6 z-40 gap-0 rounded-full shadow-lg transition-all duration-200 hover:w-auto hover:gap-2 hover:px-4 overflow-hidden"
>
<MessageCircle className="size-5 shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-200 group-hover:max-w-40 group-hover:opacity-100">
Feedback
</span>
</Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Send Feedback</DialogTitle>
<DialogDescription>
Report a bug, request a feature, or ask a question.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="feedback-type">Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger id="feedback-type">
<SelectValue placeholder="Select type..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="bug">Bug Report</SelectItem>
<SelectItem value="feature">Feature Request</SelectItem>
<SelectItem value="question">Question</SelectItem>
<SelectItem value="general">General Feedback</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="feedback-message">Message</Label>
<Textarea
id="feedback-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Describe your feedback..."
maxLength={2000}
rows={4}
required
/>
<p className="text-xs text-muted-foreground text-right">
{message.length}/2000
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="feedback-name">Name (optional)</Label>
<Input
id="feedback-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="feedback-email">Email (optional)</Label>
<Input
id="feedback-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
</div>
</div>
<Button type="submit" disabled={submitting || !type || !message.trim()}>
{submitting ? "Submitting..." : "Submit Feedback"}
</Button>
</form>
</DialogContent>
</Dialog>
</FeedbackContext.Provider>
)
}

View File

@ -23,11 +23,13 @@ import {
import { SidebarTrigger } from "@/components/ui/sidebar" import { SidebarTrigger } from "@/components/ui/sidebar"
import { NotificationsPopover } from "@/components/notifications-popover" import { NotificationsPopover } from "@/components/notifications-popover"
import { useCommandMenu } from "@/components/command-menu-provider" import { useCommandMenu } from "@/components/command-menu-provider"
import { useFeedback } from "@/components/feedback-widget"
import { AccountModal } from "@/components/account-modal" import { AccountModal } from "@/components/account-modal"
export function SiteHeader() { export function SiteHeader() {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const { open: openCommand } = useCommandMenu() const { open: openCommand } = useCommandMenu()
const { open: openFeedback } = useFeedback()
const [accountOpen, setAccountOpen] = React.useState(false) const [accountOpen, setAccountOpen] = React.useState(false)
return ( return (
@ -55,6 +57,14 @@ export function SiteHeader() {
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground text-xs"
onClick={openFeedback}
>
Feedback
</Button>
<NotificationsPopover /> <NotificationsPopover />
<Button <Button
variant="ghost" variant="ghost"
@ -62,11 +72,8 @@ export function SiteHeader() {
className="size-8" className="size-8"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
> >
{theme === "dark" ? ( <IconSun className="size-4 hidden dark:block" />
<IconSun className="size-4" /> <IconMoon className="size-4 block dark:hidden" />
) : (
<IconMoon className="size-4" />
)}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View File

@ -105,5 +105,22 @@ export type ScheduleBaseline = typeof scheduleBaselines.$inferSelect
export type NewScheduleBaseline = typeof scheduleBaselines.$inferInsert export type NewScheduleBaseline = typeof scheduleBaselines.$inferInsert
export type Customer = typeof customers.$inferSelect export type Customer = typeof customers.$inferSelect
export type NewCustomer = typeof customers.$inferInsert export type NewCustomer = typeof customers.$inferInsert
export const feedback = sqliteTable("feedback", {
id: text("id").primaryKey(),
type: text("type").notNull(),
message: text("message").notNull(),
name: text("name"),
email: text("email"),
pageUrl: text("page_url"),
userAgent: text("user_agent"),
viewportWidth: integer("viewport_width"),
viewportHeight: integer("viewport_height"),
ipHash: text("ip_hash"),
githubIssueUrl: text("github_issue_url"),
createdAt: text("created_at").notNull(),
})
export type Vendor = typeof vendors.$inferSelect export type Vendor = typeof vendors.$inferSelect
export type NewVendor = typeof vendors.$inferInsert export type NewVendor = typeof vendors.$inferInsert
export type Feedback = typeof feedback.$inferSelect
export type NewFeedback = typeof feedback.$inferInsert

32
src/types/frappe-gantt.d.ts vendored Executable file
View File

@ -0,0 +1,32 @@
declare module "frappe-gantt" {
interface GanttTask {
id: string
name: string
start: string
end: string
progress: number
dependencies?: string
custom_class?: string
}
interface GanttOptions {
view_mode?: string
on_date_change?: (
task: { id: string },
start: Date,
end: Date
) => void
on_progress_change?: (
task: { id: string },
progress: number
) => void
}
export default class Gantt {
constructor(
element: HTMLElement,
tasks: GanttTask[],
options?: GanttOptions
)
}
}

View File

@ -1,35 +1,34 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
/** /**
* For more details on how to configure Wrangler, refer to: * For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/ * https://developers.cloudflare.com/workers/wrangler/configuration/
*/ */
{ {
"$schema": "node_modules/wrangler/config-schema.json", "$schema": "node_modules/wrangler/config-schema.json",
"name": "dashboard-app-template", "name": "compass",
"account_id": "8716137c706ea3d5c209b330084fa9e2",
"main": ".open-next/worker.js", "main": ".open-next/worker.js",
"compatibility_date": "2025-12-01", "compatibility_date": "2025-12-01",
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat", "nodejs_compat",
"global_fetch_strictly_public" "global_fetch_strictly_public"
], ],
"routes": [
{
"pattern": "compass.openrangeconstruction.ltd",
"custom_domain": true
}
],
"assets": { "assets": {
"binding": "ASSETS", "binding": "ASSETS",
"directory": ".open-next/assets" "directory": ".open-next/assets"
}, },
"images": { "images": {
// Enable image optimization
// see https://opennext.js.org/cloudflare/howtos/image
"binding": "IMAGES" "binding": "IMAGES"
}, },
"services": [ "services": [
{ {
// Self-reference service binding, the service name must match the worker name
// see https://opennext.js.org/cloudflare/caching
"binding": "WORKER_SELF_REFERENCE", "binding": "WORKER_SELF_REFERENCE",
"service": "dashboard-app-template" "service": "compass"
} }
], ],
"observability": { "observability": {
@ -39,26 +38,8 @@
{ {
"binding": "DB", "binding": "DB",
"database_name": "compass-db", "database_name": "compass-db",
"database_id": "placeholder-run-wrangler-d1-create", "database_id": "cd6983ff-d286-4042-a823-6b2433c9fba7",
"migrations_dir": "drizzle" "migrations_dir": "drizzle"
} }
] ]
/**
* Smart Placement
* https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" }
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
// "vars": { "MY_VARIABLE": "production_value" }
} }