feat: Add D1 database integration with proper Cloudflare Workers support
- Update prisma.ts to use getPrismaWithD1() with getCloudflareContext() - Update all API routes to use async D1 connection - Add open-next.config.ts for Cloudflare Workers deployment - Add wrangler.jsonc with D1 binding and custom domain routes - Fix TypeScript type errors in API routes and storage - Add @workos-inc/authkit-nextjs dependency - Remove incompatible prisma.config.ts
This commit is contained in:
parent
92c710287b
commit
14c45eeb24
4
.gitignore
vendored
4
.gitignore
vendored
@ -44,3 +44,7 @@ next-env.d.ts
|
||||
# database
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# cloudflare
|
||||
.open-next/
|
||||
.wrangler/
|
||||
|
||||
10
env.d.ts
vendored
Normal file
10
env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
declare global {
|
||||
interface CloudflareEnv {
|
||||
DB: D1Database;
|
||||
ASSETS: Fetcher;
|
||||
}
|
||||
}
|
||||
|
||||
export { };
|
||||
@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
// Required for OpenNext/Cloudflare Workers
|
||||
serverExternalPackages: ["@prisma/client"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
28
open-next.config.ts
Normal file
28
open-next.config.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { OpenNextConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
const config: OpenNextConfig = {
|
||||
default: {
|
||||
override: {
|
||||
wrapper: "cloudflare-node",
|
||||
converter: "edge",
|
||||
proxyExternalRequest: "fetch",
|
||||
incrementalCache: "dummy",
|
||||
tagCache: "dummy",
|
||||
queue: "dummy",
|
||||
},
|
||||
},
|
||||
edgeExternals: ["node:crypto"],
|
||||
middleware: {
|
||||
external: true,
|
||||
override: {
|
||||
wrapper: "cloudflare-edge",
|
||||
converter: "edge",
|
||||
proxyExternalRequest: "fetch",
|
||||
incrementalCache: "dummy",
|
||||
tagCache: "dummy",
|
||||
queue: "dummy",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
package.json
14
package.json
@ -7,10 +7,18 @@
|
||||
"build": "prisma generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"postinstall": "prisma generate"
|
||||
"postinstall": "prisma generate",
|
||||
"build:worker": "opennextjs-cloudflare build",
|
||||
"dev:worker": "wrangler dev",
|
||||
"deploy": "bun run build:worker && wrangler deploy",
|
||||
"d1:migrate": "wrangler d1 migrations apply quit-smoking-db --local",
|
||||
"d1:migrate:prod": "wrangler d1 migrations apply quit-smoking-db --remote"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opennextjs/cloudflare": "^1.1.1",
|
||||
"@prisma/adapter-d1": "^5.22.0",
|
||||
"@prisma/client": "5",
|
||||
"@workos-inc/authkit-nextjs": "^0.16.0",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@ -33,6 +41,7 @@
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250121.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
@ -41,7 +50,8 @@
|
||||
"eslint-config-next": "16.1.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"wrangler": "^4"
|
||||
},
|
||||
"ignoreScripts": [
|
||||
"sharp",
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
82
prisma/d1_schema.sql
Normal file
82
prisma/d1_schema.sql
Normal file
@ -0,0 +1,82 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserPreferences" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"substance" TEXT NOT NULL DEFAULT 'nicotine',
|
||||
"trackingStartDate" TEXT,
|
||||
"hasCompletedSetup" BOOLEAN NOT NULL DEFAULT false,
|
||||
"dailyGoal" INTEGER,
|
||||
"userName" TEXT,
|
||||
"userAge" INTEGER,
|
||||
"religion" TEXT,
|
||||
"lastNicotineUsageTime" TEXT,
|
||||
"lastWeedUsageTime" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"quitPlanJson" TEXT
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UsageEntry" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"date" TEXT NOT NULL,
|
||||
"count" INTEGER NOT NULL,
|
||||
"substance" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "UsageEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Achievement" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"badgeId" TEXT NOT NULL,
|
||||
"unlockedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"substance" TEXT NOT NULL,
|
||||
CONSTRAINT "Achievement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ReminderSettings" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"reminderTime" TEXT NOT NULL DEFAULT '09:00',
|
||||
"lastNotifiedDate" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "ReminderSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SavingsConfig" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"costPerUnit" REAL NOT NULL,
|
||||
"unitsPerDay" REAL NOT NULL,
|
||||
"savingsGoal" REAL,
|
||||
"goalName" TEXT,
|
||||
"currency" TEXT NOT NULL DEFAULT 'USD',
|
||||
"substance" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "SavingsConfig_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UsageEntry_userId_date_substance_key" ON "UsageEntry"("userId", "date", "substance");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Achievement_userId_badgeId_substance_key" ON "Achievement"("userId", "badgeId", "substance");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ReminderSettings_userId_key" ON "ReminderSettings"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SavingsConfig_userId_key" ON "SavingsConfig"("userId");
|
||||
|
||||
@ -2,14 +2,16 @@
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["driverAdapters"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:./dev.db"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
|
||||
model UserPreferences {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getPrismaWithD1 } from '@/lib/prisma';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export async function GET() {
|
||||
@ -9,6 +9,7 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
const achievements = await prisma.achievement.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { unlockedAt: 'desc' },
|
||||
@ -34,13 +35,15 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await request.json() as { badgeId?: string; substance?: string };
|
||||
const { badgeId, substance } = body;
|
||||
|
||||
if (!badgeId || !substance) {
|
||||
return NextResponse.json({ error: 'Missing badgeId or substance' }, { status: 400 });
|
||||
}
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
|
||||
// Check if achievement already exists
|
||||
const existing = await prisma.achievement.findUnique({
|
||||
where: {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getPrismaWithD1 } from '@/lib/prisma';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export async function GET() {
|
||||
@ -9,6 +9,7 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
const preferences = await prisma.userPreferences.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
@ -50,7 +51,18 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await request.json() as {
|
||||
substance?: string;
|
||||
trackingStartDate?: string;
|
||||
hasCompletedSetup?: boolean;
|
||||
dailyGoal?: number;
|
||||
quitPlan?: unknown;
|
||||
userName?: string;
|
||||
userAge?: number;
|
||||
religion?: string;
|
||||
lastNicotineUsageTime?: string;
|
||||
lastWeedUsageTime?: string;
|
||||
};
|
||||
const {
|
||||
substance,
|
||||
trackingStartDate,
|
||||
@ -64,6 +76,7 @@ export async function POST(request: NextRequest) {
|
||||
lastWeedUsageTime
|
||||
} = body;
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
const preferences = await prisma.userPreferences.upsert({
|
||||
where: { userId: session.user.id },
|
||||
update: {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getPrismaWithD1 } from '@/lib/prisma';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export async function GET() {
|
||||
@ -9,6 +9,7 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
const settings = await prisma.reminderSettings.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
@ -37,9 +38,10 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await request.json() as { enabled?: boolean; reminderTime?: string };
|
||||
const { enabled, reminderTime } = body;
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
const settings = await prisma.reminderSettings.upsert({
|
||||
where: { userId: session.user.id },
|
||||
update: {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getPrismaWithD1 } from '@/lib/prisma';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export async function GET() {
|
||||
@ -9,6 +9,7 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
const config = await prisma.savingsConfig.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
@ -38,13 +39,21 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await request.json() as {
|
||||
costPerUnit?: number;
|
||||
unitsPerDay?: number;
|
||||
savingsGoal?: number;
|
||||
goalName?: string;
|
||||
currency?: string;
|
||||
substance?: string;
|
||||
};
|
||||
const { costPerUnit, unitsPerDay, savingsGoal, goalName, currency, substance } = body;
|
||||
|
||||
if (costPerUnit === undefined || unitsPerDay === undefined || !substance) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
const config = await prisma.savingsConfig.upsert({
|
||||
where: { userId: session.user.id },
|
||||
update: {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getPrismaWithD1 } from '@/lib/prisma';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export async function GET() {
|
||||
@ -9,6 +9,7 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
const entries = await prisma.usageEntry.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { date: 'desc' },
|
||||
@ -34,16 +35,22 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await request.json() as { date?: string; count?: number; substance?: string };
|
||||
const { date, count, substance } = body;
|
||||
|
||||
if (!date || count === undefined || !substance) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
|
||||
// Upsert: add to existing count or create new entry
|
||||
const existing = await prisma.usageEntry.findUnique({
|
||||
where: {
|
||||
userId_date_substance: {
|
||||
userId: session.user.id,
|
||||
date,
|
||||
substance,
|
||||
date: date,
|
||||
substance: substance,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -62,9 +69,9 @@ export async function POST(request: NextRequest) {
|
||||
const created = await prisma.usageEntry.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
date,
|
||||
count,
|
||||
substance,
|
||||
date: date,
|
||||
count: count,
|
||||
substance: substance,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({
|
||||
@ -86,24 +93,30 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await request.json() as { date?: string; count?: number; substance?: string };
|
||||
const { date, count, substance } = body;
|
||||
|
||||
if (!date || count === undefined || !substance) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
|
||||
// Set the exact count (replace, not add)
|
||||
const entry = await prisma.usageEntry.upsert({
|
||||
where: {
|
||||
userId_date_substance: {
|
||||
userId: session.user.id,
|
||||
date,
|
||||
substance,
|
||||
date: date,
|
||||
substance: substance,
|
||||
},
|
||||
},
|
||||
update: { count },
|
||||
update: { count: count },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
date,
|
||||
count,
|
||||
substance,
|
||||
date: date,
|
||||
count: count,
|
||||
substance: substance,
|
||||
},
|
||||
});
|
||||
|
||||
@ -133,6 +146,8 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing date or substance' }, { status: 400 });
|
||||
}
|
||||
|
||||
const prisma = await getPrismaWithD1();
|
||||
|
||||
await prisma.usageEntry.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
|
||||
@ -2,40 +2,63 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getUserData,
|
||||
saveUserData,
|
||||
getUsageLogs,
|
||||
addUsageLog,
|
||||
getRecentLogs,
|
||||
calculateStats,
|
||||
getQuitPlan,
|
||||
generateQuitPlan,
|
||||
getCurrentWeek,
|
||||
canGeneratePlan,
|
||||
needsCheckIn as checkNeedsCheckIn,
|
||||
getPreferences,
|
||||
savePreferences,
|
||||
getUsageData,
|
||||
saveUsageEntry,
|
||||
getWeeklyData,
|
||||
calculateWeeklyAverage,
|
||||
generateQuitPlan as generateQuitPlanFn,
|
||||
hasOneWeekOfData,
|
||||
fetchPreferences,
|
||||
fetchUsageData,
|
||||
UserPreferences,
|
||||
UsageEntry,
|
||||
QuitPlan,
|
||||
} from "@/lib/storage";
|
||||
import { UserData, UsageLog, QuitPlan, SubstanceType } from "@/types";
|
||||
|
||||
type SubstanceType = 'nicotine' | 'weed';
|
||||
|
||||
interface UserData {
|
||||
substanceType: SubstanceType;
|
||||
stayLoggedIn: boolean;
|
||||
onboardingComplete: boolean;
|
||||
}
|
||||
|
||||
export function useUserData() {
|
||||
const [userData, setUserData] = useState<UserData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setUserData(getUserData());
|
||||
setIsLoading(false);
|
||||
const loadData = async () => {
|
||||
const prefs = await fetchPreferences();
|
||||
setUserData({
|
||||
substanceType: prefs.substance,
|
||||
stayLoggedIn: true,
|
||||
onboardingComplete: prefs.hasCompletedSetup,
|
||||
});
|
||||
setIsLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const updateUserData = useCallback((data: Partial<UserData>) => {
|
||||
const updated = saveUserData(data);
|
||||
setUserData(updated);
|
||||
const prefs = getPreferences();
|
||||
const updated: UserPreferences = {
|
||||
...prefs,
|
||||
substance: data.substanceType || prefs.substance,
|
||||
hasCompletedSetup: data.onboardingComplete ?? prefs.hasCompletedSetup,
|
||||
};
|
||||
savePreferences(updated);
|
||||
setUserData((prev) => (prev ? { ...prev, ...data } : null));
|
||||
return updated;
|
||||
}, []);
|
||||
|
||||
const completeOnboarding = useCallback(
|
||||
(substanceType: SubstanceType, stayLoggedIn: boolean) => {
|
||||
(substanceType: SubstanceType, _stayLoggedIn: boolean) => {
|
||||
return updateUserData({
|
||||
substanceType,
|
||||
stayLoggedIn,
|
||||
stayLoggedIn: true,
|
||||
onboardingComplete: true,
|
||||
});
|
||||
},
|
||||
@ -52,32 +75,52 @@ export function useUserData() {
|
||||
}
|
||||
|
||||
export function useUsageLogs() {
|
||||
const [logs, setLogs] = useState<UsageLog[]>([]);
|
||||
const [logs, setLogs] = useState<UsageEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refreshLogs = useCallback(() => {
|
||||
setLogs(getUsageLogs());
|
||||
const refreshLogs = useCallback(async () => {
|
||||
const data = await fetchUsageData();
|
||||
setLogs(data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshLogs();
|
||||
setIsLoading(false);
|
||||
refreshLogs().then(() => setIsLoading(false));
|
||||
}, [refreshLogs]);
|
||||
|
||||
const logUsage = useCallback(
|
||||
(puffs: number, date?: string) => {
|
||||
const newLog = addUsageLog(puffs, date);
|
||||
const prefs = getPreferences();
|
||||
const logDate = date || new Date().toISOString().split("T")[0];
|
||||
const newEntry: UsageEntry = {
|
||||
date: logDate,
|
||||
count: puffs,
|
||||
substance: prefs.substance,
|
||||
};
|
||||
saveUsageEntry(newEntry);
|
||||
refreshLogs();
|
||||
return newLog;
|
||||
return newEntry;
|
||||
},
|
||||
[refreshLogs]
|
||||
);
|
||||
|
||||
const getRecent = useCallback((days: number = 7) => {
|
||||
return getRecentLogs(days);
|
||||
const data = getUsageData();
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
return data.filter((e) => new Date(e.date) >= cutoff);
|
||||
}, []);
|
||||
|
||||
const stats = calculateStats(getRecentLogs(7));
|
||||
const calculateStats = (entries: UsageEntry[]) => {
|
||||
if (entries.length === 0) {
|
||||
return { totalPuffs: 0, averagePuffs: 0, daysTracked: 0 };
|
||||
}
|
||||
const totalPuffs = entries.reduce((sum, e) => sum + e.count, 0);
|
||||
const daysTracked = new Set(entries.map((e) => e.date)).size;
|
||||
const averagePuffs = Math.round(totalPuffs / daysTracked);
|
||||
return { totalPuffs, averagePuffs, daysTracked };
|
||||
};
|
||||
|
||||
const stats = calculateStats(getRecent(7));
|
||||
|
||||
return {
|
||||
logs,
|
||||
@ -93,30 +136,44 @@ export function useQuitPlan() {
|
||||
const [plan, setPlan] = useState<QuitPlan | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refreshPlan = useCallback(() => {
|
||||
setPlan(getQuitPlan());
|
||||
const refreshPlan = useCallback(async () => {
|
||||
const prefs = await fetchPreferences();
|
||||
setPlan(prefs.quitPlan);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshPlan();
|
||||
setIsLoading(false);
|
||||
refreshPlan().then(() => setIsLoading(false));
|
||||
}, [refreshPlan]);
|
||||
|
||||
const createPlan = useCallback(() => {
|
||||
const recentLogs = getRecentLogs(7);
|
||||
const { averagePuffs } = calculateStats(recentLogs);
|
||||
const prefs = getPreferences();
|
||||
const avgPuffs = calculateWeeklyAverage(prefs.substance);
|
||||
|
||||
if (averagePuffs === 0) {
|
||||
if (avgPuffs === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newPlan = generateQuitPlan(averagePuffs);
|
||||
const newPlan = generateQuitPlanFn(prefs.substance);
|
||||
const updatedPrefs = { ...prefs, quitPlan: newPlan };
|
||||
savePreferences(updatedPrefs);
|
||||
setPlan(newPlan);
|
||||
return newPlan;
|
||||
}, []);
|
||||
|
||||
const getCurrentWeek = (quitPlan: QuitPlan): number => {
|
||||
const startDate = new Date(quitPlan.startDate);
|
||||
const today = new Date();
|
||||
const weekNumber = Math.floor(
|
||||
(today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 7)
|
||||
);
|
||||
return Math.min(weekNumber, quitPlan.weeklyTargets.length - 1);
|
||||
};
|
||||
|
||||
const currentWeek = plan ? getCurrentWeek(plan) : 0;
|
||||
const { canGenerate, daysTracked } = canGeneratePlan();
|
||||
const prefs = getPreferences();
|
||||
const canGenerate = hasOneWeekOfData(prefs.substance);
|
||||
const weeklyData = getWeeklyData(prefs.substance);
|
||||
const daysTracked = new Set(weeklyData.map((e) => e.date)).size;
|
||||
|
||||
return {
|
||||
plan,
|
||||
@ -133,7 +190,11 @@ export function useCheckIn() {
|
||||
const [needsCheckIn, setNeedsCheckIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setNeedsCheckIn(checkNeedsCheckIn());
|
||||
// Check if user needs to check in today
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const data = getUsageData();
|
||||
const todayEntry = data.find((e) => e.date === today);
|
||||
setNeedsCheckIn(!todayEntry);
|
||||
}, []);
|
||||
|
||||
const markCheckedIn = useCallback(() => {
|
||||
|
||||
@ -1,9 +1,39 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaD1 } from '@prisma/adapter-d1';
|
||||
|
||||
// For local development fallback
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get Prisma client for Cloudflare Workers with D1.
|
||||
* This should be called in each request handler to get a fresh client with D1 binding.
|
||||
*/
|
||||
export async function getPrismaWithD1(): Promise<PrismaClient> {
|
||||
// Try to get Cloudflare context (only works in Workers environment)
|
||||
try {
|
||||
const { getCloudflareContext } = await import('@opennextjs/cloudflare');
|
||||
const { env } = await getCloudflareContext();
|
||||
|
||||
if (env?.DB) {
|
||||
const adapter = new PrismaD1(env.DB);
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
} catch {
|
||||
// Not in Cloudflare Workers environment, fall back to local client
|
||||
}
|
||||
|
||||
// Fallback for local development
|
||||
if (!globalForPrisma.prisma) {
|
||||
globalForPrisma.prisma = new PrismaClient();
|
||||
}
|
||||
|
||||
return globalForPrisma.prisma;
|
||||
}
|
||||
|
||||
// Legacy synchronous export for backward compatibility in local dev
|
||||
// NOTE: This does NOT work with D1 in production - use getPrismaWithD1() instead
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
|
||||
@ -134,7 +134,7 @@ export async function fetchPreferences(): Promise<UserPreferences> {
|
||||
console.error('Failed to fetch preferences');
|
||||
return defaultPreferences;
|
||||
}
|
||||
const data = await response.json();
|
||||
const data = await response.json() as UserPreferences;
|
||||
preferencesCache = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
@ -166,7 +166,7 @@ export async function fetchUsageData(): Promise<UsageEntry[]> {
|
||||
console.error('Failed to fetch usage data');
|
||||
return [];
|
||||
}
|
||||
const data = await response.json();
|
||||
const data = await response.json() as UsageEntry[];
|
||||
usageDataCache = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
@ -226,7 +226,7 @@ export async function fetchAchievements(): Promise<Achievement[]> {
|
||||
try {
|
||||
const response = await fetch('/api/achievements');
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
const data = await response.json() as Achievement[];
|
||||
achievementsCache = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
@ -246,7 +246,7 @@ export async function unlockAchievement(
|
||||
body: JSON.stringify({ badgeId, substance }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const data = await response.json() as { badgeId: string; unlockedAt: string; substance: 'nicotine' | 'weed' | 'both'; alreadyUnlocked?: boolean };
|
||||
achievementsCache = null; // Invalidate cache
|
||||
return {
|
||||
achievement: {
|
||||
@ -275,7 +275,7 @@ export async function fetchReminderSettings(): Promise<ReminderSettings> {
|
||||
try {
|
||||
const response = await fetch('/api/reminders');
|
||||
if (!response.ok) return { enabled: false, reminderTime: '09:00' };
|
||||
const data = await response.json();
|
||||
const data = await response.json() as ReminderSettings;
|
||||
reminderSettingsCache = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
@ -310,7 +310,7 @@ export async function fetchSavingsConfig(): Promise<SavingsConfig | null> {
|
||||
try {
|
||||
const response = await fetch('/api/savings');
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
const data = await response.json() as SavingsConfig | null;
|
||||
savingsConfigCache = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
|
||||
39
wrangler.jsonc
Normal file
39
wrangler.jsonc
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
// Worker name - change this to your preferred name
|
||||
"name": "quit-smoking-app",
|
||||
// Use the OpenNext preset for Next.js on Cloudflare Workers
|
||||
"main": ".open-next/worker.js",
|
||||
// Compatibility settings for Node.js APIs
|
||||
"compatibility_date": "2024-09-23",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
// Set up route matching for static assets
|
||||
"assets": {
|
||||
"directory": ".open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
},
|
||||
// D1 Database binding
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "quit-smoking-db",
|
||||
"database_id": "1fca511a-cf91-4fde-854b-561ed92abfa8"
|
||||
}
|
||||
],
|
||||
// Environment variables
|
||||
"vars": {
|
||||
"NODE_ENV": "production"
|
||||
},
|
||||
// Custom domain
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "quittraq.com/*",
|
||||
"zone_name": "quittraq.com"
|
||||
},
|
||||
{
|
||||
"pattern": "www.quittraq.com/*",
|
||||
"zone_name": "quittraq.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user