From 750c5af46594aaf394e27e6db98c01121d4c1e78 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sat, 24 Jan 2026 01:08:10 -0700 Subject: [PATCH] Switch from localStorage to SQLite database for persistent storage - Add Prisma ORM with SQLite for server-side data persistence - Create UserPreferences and UsageEntry models - Add API routes for preferences and usage data CRUD operations - Update storage.ts to use fetch API calls instead of localStorage - Update components to use async data fetching - Data now persists across devices for each user account Co-Authored-By: Claude Opus 4.5 --- .gitignore | 4 + bun.lock | 21 ++ package.json | 8 +- .../20260124050535_init/migration.sql | 32 ++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 43 +++ src/app/api/preferences/route.ts | 89 ++++++ src/app/api/usage/route.ts | 149 ++++++++++ src/components/Dashboard.tsx | 63 ++-- src/components/UsageCalendar.tsx | 14 +- src/components/UserHeader.tsx | 11 +- src/lib/prisma.ts | 11 + src/lib/storage.ts | 280 +++++++++++------- 13 files changed, 569 insertions(+), 159 deletions(-) create mode 100644 prisma/migrations/20260124050535_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 src/app/api/preferences/route.ts create mode 100644 src/app/api/usage/route.ts create mode 100644 src/lib/prisma.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..0aa6b11 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# database +*.db +*.db-journal diff --git a/bun.lock b/bun.lock index 0f6a66a..68e9d93 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "quit_smoking_website", "dependencies": { + "@prisma/client": "5", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -15,8 +16,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "dotenv": "^17.2.3", "lucide-react": "^0.563.0", "next": "16.1.4", + "prisma": "5", "react": "19.2.3", "react-day-picker": "^9.13.0", "react-dom": "19.2.3", @@ -206,6 +209,18 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + "@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="], + + "@prisma/debug": ["@prisma/debug@5.22.0", "", {}, "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ=="], + + "@prisma/engines": ["@prisma/engines@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/fetch-engine": "5.22.0", "@prisma/get-platform": "5.22.0" } }, "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA=="], + + "@prisma/engines-version": ["@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "", {}, "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/get-platform": "5.22.0" } }, "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA=="], + + "@prisma/get-platform": ["@prisma/get-platform@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0" } }, "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -484,6 +499,8 @@ "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.278", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="], @@ -568,6 +585,8 @@ "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], @@ -812,6 +831,8 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prisma": ["prisma@5.22.0", "", { "dependencies": { "@prisma/engines": "5.22.0" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "prisma": "build/index.js" } }, "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], diff --git a/package.json b/package.json index ea9efd5..45b5112 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "prisma generate && next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "postinstall": "prisma generate" }, "dependencies": { + "@prisma/client": "5", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -19,8 +21,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "dotenv": "^17.2.3", "lucide-react": "^0.563.0", "next": "16.1.4", + "prisma": "5", "react": "19.2.3", "react-day-picker": "^9.13.0", "react-dom": "19.2.3", diff --git a/prisma/migrations/20260124050535_init/migration.sql b/prisma/migrations/20260124050535_init/migration.sql new file mode 100644 index 0000000..8d88e2d --- /dev/null +++ b/prisma/migrations/20260124050535_init/migration.sql @@ -0,0 +1,32 @@ +-- 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, + "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 +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UsageEntry_userId_date_substance_key" ON "UsageEntry"("userId", "date", "substance"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..fc3e593 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,43 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model UserPreferences { + id String @id @default(cuid()) + userId String @unique + substance String @default("nicotine") + trackingStartDate String? + hasCompletedSetup Boolean @default(false) + dailyGoal Int? + userName String? + userAge Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Quit plan fields stored as JSON string + quitPlanJson String? + + usageEntries UsageEntry[] +} + +model UsageEntry { + id String @id @default(cuid()) + userId String + date String + count Int + substance String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userPreferences UserPreferences? @relation(fields: [userId], references: [userId]) + + @@unique([userId, date, substance]) +} diff --git a/src/app/api/preferences/route.ts b/src/app/api/preferences/route.ts new file mode 100644 index 0000000..88bc976 --- /dev/null +++ b/src/app/api/preferences/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getSession } from '@/lib/session'; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const preferences = await prisma.userPreferences.findUnique({ + where: { userId: session.user.id }, + }); + + if (!preferences) { + return NextResponse.json({ + substance: 'nicotine', + trackingStartDate: null, + hasCompletedSetup: false, + dailyGoal: null, + quitPlan: null, + userName: null, + userAge: null, + }); + } + + return NextResponse.json({ + substance: preferences.substance, + trackingStartDate: preferences.trackingStartDate, + hasCompletedSetup: preferences.hasCompletedSetup, + dailyGoal: preferences.dailyGoal, + quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null, + userName: preferences.userName, + userAge: preferences.userAge, + }); + } catch (error) { + console.error('Error fetching preferences:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { substance, trackingStartDate, hasCompletedSetup, dailyGoal, quitPlan, userName, userAge } = body; + + const preferences = await prisma.userPreferences.upsert({ + where: { userId: session.user.id }, + update: { + substance, + trackingStartDate, + hasCompletedSetup, + dailyGoal, + quitPlanJson: quitPlan ? JSON.stringify(quitPlan) : null, + userName, + userAge, + }, + create: { + userId: session.user.id, + substance, + trackingStartDate, + hasCompletedSetup, + dailyGoal, + quitPlanJson: quitPlan ? JSON.stringify(quitPlan) : null, + userName, + userAge, + }, + }); + + return NextResponse.json({ + substance: preferences.substance, + trackingStartDate: preferences.trackingStartDate, + hasCompletedSetup: preferences.hasCompletedSetup, + dailyGoal: preferences.dailyGoal, + quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null, + userName: preferences.userName, + userAge: preferences.userAge, + }); + } catch (error) { + console.error('Error saving preferences:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/usage/route.ts b/src/app/api/usage/route.ts new file mode 100644 index 0000000..a966cf5 --- /dev/null +++ b/src/app/api/usage/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getSession } from '@/lib/session'; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const entries = await prisma.usageEntry.findMany({ + where: { userId: session.user.id }, + orderBy: { date: 'desc' }, + }); + + return NextResponse.json( + entries.map((e) => ({ + date: e.date, + count: e.count, + substance: e.substance, + })) + ); + } catch (error) { + console.error('Error fetching usage data:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { date, count, substance } = body; + + // 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, + }, + }, + }); + + if (existing) { + const updated = await prisma.usageEntry.update({ + where: { id: existing.id }, + data: { count: existing.count + count }, + }); + return NextResponse.json({ + date: updated.date, + count: updated.count, + substance: updated.substance, + }); + } else { + const created = await prisma.usageEntry.create({ + data: { + userId: session.user.id, + date, + count, + substance, + }, + }); + return NextResponse.json({ + date: created.date, + count: created.count, + substance: created.substance, + }); + } + } catch (error) { + console.error('Error saving usage entry:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { date, count, substance } = body; + + // Set the exact count (replace, not add) + const entry = await prisma.usageEntry.upsert({ + where: { + userId_date_substance: { + userId: session.user.id, + date, + substance, + }, + }, + update: { count }, + create: { + userId: session.user.id, + date, + count, + substance, + }, + }); + + return NextResponse.json({ + date: entry.date, + count: entry.count, + substance: entry.substance, + }); + } catch (error) { + console.error('Error updating usage entry:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const date = searchParams.get('date'); + const substance = searchParams.get('substance'); + + if (!date || !substance) { + return NextResponse.json({ error: 'Missing date or substance' }, { status: 400 }); + } + + await prisma.usageEntry.deleteMany({ + where: { + userId: session.user.id, + date, + substance, + }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting usage entry:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index ec80446..1e77bf9 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -3,15 +3,14 @@ import { useState, useEffect, useCallback } from 'react'; import { User } from '@/lib/session'; import { - getUsageData, - getPreferences, - savePreferences, - saveUsageEntry, + fetchPreferences, + fetchUsageData, + savePreferencesAsync, + saveUsageEntryAsync, shouldShowUsagePrompt, hasOneWeekOfData, calculateWeeklyAverage, generateQuitPlan, - setCurrentUserId, UserPreferences, UsageEntry, } from '@/lib/storage'; @@ -36,32 +35,34 @@ export function Dashboard({ user }: DashboardProps) { const [isLoading, setIsLoading] = useState(true); const [refreshKey, setRefreshKey] = useState(0); - const loadData = useCallback(() => { - // Always pass user.id explicitly to ensure correct data is loaded - const prefs = getPreferences(user.id); - const usage = getUsageData(user.id); + const loadData = useCallback(async () => { + const [prefs, usage] = await Promise.all([ + fetchPreferences(), + fetchUsageData(), + ]); setPreferences(prefs); setUsageData(usage); setRefreshKey(prev => prev + 1); return prefs; - }, [user.id]); + }, []); useEffect(() => { - // Set the current user ID for all storage operations - setCurrentUserId(user.id); + const init = async () => { + const prefs = await loadData(); - const prefs = loadData(); + if (!prefs.hasCompletedSetup) { + setShowSetup(true); + } else if (shouldShowUsagePrompt()) { + setShowUsagePrompt(true); + } - if (!prefs.hasCompletedSetup) { - setShowSetup(true); - } else if (shouldShowUsagePrompt()) { - setShowUsagePrompt(true); - } + setIsLoading(false); + }; - setIsLoading(false); - }, [user.id, loadData]); + init(); + }, [loadData]); - const handleSetupComplete = (data: { substance: 'nicotine' | 'weed'; name: string; age: number }) => { + const handleSetupComplete = async (data: { substance: 'nicotine' | 'weed'; name: string; age: number }) => { const today = new Date().toISOString().split('T')[0]; const newPrefs: UserPreferences = { substance: data.substance, @@ -72,14 +73,14 @@ export function Dashboard({ user }: DashboardProps) { userName: data.name, userAge: data.age, }; - savePreferences(newPrefs, user.id); + await savePreferencesAsync(newPrefs); setPreferences(newPrefs); setShowSetup(false); setShowUsagePrompt(true); setRefreshKey(prev => prev + 1); }; - const handleUsageSubmit = (count: number) => { + const handleUsageSubmit = async (count: number) => { if (!preferences) { setShowUsagePrompt(false); return; @@ -87,26 +88,26 @@ export function Dashboard({ user }: DashboardProps) { if (count > 0) { const today = new Date().toISOString().split('T')[0]; - saveUsageEntry({ + await saveUsageEntryAsync({ date: today, count, substance: preferences.substance, - }, user.id); + }); } setShowUsagePrompt(false); // Reload data and force calendar refresh - const usage = getUsageData(user.id); + const usage = await fetchUsageData(); setUsageData(usage); setRefreshKey(prev => prev + 1); }; - const handleGeneratePlan = () => { + const handleGeneratePlan = async () => { if (!preferences) return; - const plan = generateQuitPlan(preferences.substance, user.id); + const plan = generateQuitPlan(preferences.substance); const updatedPrefs = { ...preferences, quitPlan: plan }; - savePreferences(updatedPrefs, user.id); + await savePreferencesAsync(updatedPrefs); setPreferences(updatedPrefs); }; @@ -159,9 +160,9 @@ export function Dashboard({ user }: DashboardProps) { diff --git a/src/components/UsageCalendar.tsx b/src/components/UsageCalendar.tsx index dfc4427..ecefa9d 100644 --- a/src/components/UsageCalendar.tsx +++ b/src/components/UsageCalendar.tsx @@ -12,7 +12,7 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { UsageEntry, getUsageForDate, setUsageForDate, clearDayData } from '@/lib/storage'; +import { UsageEntry, getUsageForDate, setUsageForDateAsync, clearDayDataAsync } from '@/lib/storage'; import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; interface UsageCalendarProps { @@ -22,7 +22,7 @@ interface UsageCalendarProps { userId: string; } -export function UsageCalendar({ usageData, substance, onDataUpdate, userId }: UsageCalendarProps) { +export function UsageCalendar({ usageData, substance, onDataUpdate }: UsageCalendarProps) { const [selectedDate, setSelectedDate] = useState(undefined); const [editCount, setEditCount] = useState(''); const [isEditing, setIsEditing] = useState(false); @@ -37,16 +37,16 @@ export function UsageCalendar({ usageData, substance, onDataUpdate, userId }: Us setSelectedDate(date); const dateStr = date.toISOString().split('T')[0]; - const currentCount = getUsageForDate(dateStr, substance, userId); + const currentCount = getUsageForDate(dateStr, substance); setEditCount(currentCount.toString()); setIsEditing(true); }; - const handleSave = () => { + const handleSave = async () => { if (selectedDate) { const dateStr = selectedDate.toISOString().split('T')[0]; const newCount = parseInt(editCount, 10) || 0; - setUsageForDate(dateStr, newCount, substance, userId); + await setUsageForDateAsync(dateStr, newCount, substance); onDataUpdate(); } setIsEditing(false); @@ -60,10 +60,10 @@ export function UsageCalendar({ usageData, substance, onDataUpdate, userId }: Us setEditCount(''); }; - const handleClearDay = () => { + const handleClearDay = async () => { if (selectedDate) { const dateStr = selectedDate.toISOString().split('T')[0]; - clearDayData(dateStr, substance, userId); + await clearDayDataAsync(dateStr, substance); onDataUpdate(); } setIsEditing(false); diff --git a/src/components/UserHeader.tsx b/src/components/UserHeader.tsx index 326ffb3..617de1c 100644 --- a/src/components/UserHeader.tsx +++ b/src/components/UserHeader.tsx @@ -3,7 +3,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { User } from '@/lib/session'; -import { getPreferences } from '@/lib/storage'; +import { fetchPreferences } from '@/lib/storage'; import { useEffect, useState } from 'react'; interface UserHeaderProps { @@ -14,9 +14,12 @@ export function UserHeader({ user }: UserHeaderProps) { const [userName, setUserName] = useState(null); useEffect(() => { - const prefs = getPreferences(user.id); - setUserName(prefs.userName); - }, [user.id]); + const loadUserName = async () => { + const prefs = await fetchPreferences(); + setUserName(prefs.userName); + }; + loadUserName(); + }, []); const initials = [user.firstName?.[0], user.lastName?.[0]] .filter(Boolean) diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..a8dd9fa --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,11 @@ +import { PrismaClient } from '@prisma/client'; + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; + +export const prisma = globalForPrisma.prisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 8bccfb9..b250ded 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,5 +1,5 @@ // Client-side storage utilities for tracking data -// All data is stored per-user using their unique ID as a prefix +// Now uses API calls to persist data in SQLite database export interface UsageEntry { date: string; // ISO date string YYYY-MM-DD @@ -24,120 +24,179 @@ export interface QuitPlan { baselineAverage: number; } -const CURRENT_USER_KEY = 'quit_smoking_current_user'; +const defaultPreferences: UserPreferences = { + substance: 'nicotine', + trackingStartDate: null, + hasCompletedSetup: false, + dailyGoal: null, + quitPlan: null, + userName: null, + userAge: null, +}; -function getStorageKey(baseKey: string, userId?: string): string { - const uid = userId || getCurrentUserId(); - if (!uid) return baseKey; - return `${baseKey}_${uid}`; +// Cache for preferences and usage data to avoid excessive API calls +let preferencesCache: UserPreferences | null = null; +let usageDataCache: UsageEntry[] | null = null; + +export function clearCache(): void { + preferencesCache = null; + usageDataCache = null; } -export function setCurrentUserId(userId: string): void { - if (typeof window === 'undefined') return; - localStorage.setItem(CURRENT_USER_KEY, userId); +// These functions are kept for backwards compatibility but no longer used +export function setCurrentUserId(_userId: string): void { + // No-op - user ID is now managed by session } export function getCurrentUserId(): string | null { - if (typeof window === 'undefined') return null; - return localStorage.getItem(CURRENT_USER_KEY); + return null; } -export function getUsageData(userId?: string): UsageEntry[] { - if (typeof window === 'undefined') return []; - const key = getStorageKey('quit_smoking_usage', userId); - const data = localStorage.getItem(key); - return data ? JSON.parse(data) : []; -} - -export function saveUsageEntry(entry: UsageEntry, userId?: string): void { - if (typeof window === 'undefined') return; - const key = getStorageKey('quit_smoking_usage', userId); - const data = getUsageData(userId); - const existingIndex = data.findIndex( - (e) => e.date === entry.date && e.substance === entry.substance - ); - - if (existingIndex >= 0) { - data[existingIndex].count += entry.count; - } else { - data.push(entry); +// Async API functions +export async function fetchPreferences(): Promise { + try { + const response = await fetch('/api/preferences'); + if (!response.ok) { + console.error('Failed to fetch preferences'); + return defaultPreferences; + } + const data = await response.json(); + preferencesCache = data; + return data; + } catch (error) { + console.error('Error fetching preferences:', error); + return defaultPreferences; } - - localStorage.setItem(key, JSON.stringify(data)); } -export function setUsageForDate(date: string, count: number, substance: 'nicotine' | 'weed', userId?: string): void { - if (typeof window === 'undefined') return; - const key = getStorageKey('quit_smoking_usage', userId); - const data = getUsageData(userId); - const existingIndex = data.findIndex( - (e) => e.date === date && e.substance === substance - ); - - if (existingIndex >= 0) { - data[existingIndex].count = count; - } else { - data.push({ date, count, substance }); +export async function savePreferencesAsync(preferences: UserPreferences): Promise { + try { + const response = await fetch('/api/preferences', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(preferences), + }); + if (response.ok) { + preferencesCache = preferences; + } + } catch (error) { + console.error('Error saving preferences:', error); } - - localStorage.setItem(key, JSON.stringify(data)); } -export function getUsageForDate(date: string, substance: 'nicotine' | 'weed', userId?: string): number { - const data = getUsageData(userId); +export async function fetchUsageData(): Promise { + try { + const response = await fetch('/api/usage'); + if (!response.ok) { + console.error('Failed to fetch usage data'); + return []; + } + const data = await response.json(); + usageDataCache = data; + return data; + } catch (error) { + console.error('Error fetching usage data:', error); + return []; + } +} + +export async function saveUsageEntryAsync(entry: UsageEntry): Promise { + try { + await fetch('/api/usage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(entry), + }); + usageDataCache = null; // Invalidate cache + } catch (error) { + console.error('Error saving usage entry:', error); + } +} + +export async function setUsageForDateAsync( + date: string, + count: number, + substance: 'nicotine' | 'weed' +): Promise { + try { + await fetch('/api/usage', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ date, count, substance }), + }); + usageDataCache = null; // Invalidate cache + } catch (error) { + console.error('Error setting usage for date:', error); + } +} + +export async function clearDayDataAsync( + date: string, + substance: 'nicotine' | 'weed' +): Promise { + try { + await fetch(`/api/usage?date=${date}&substance=${substance}`, { + method: 'DELETE', + }); + usageDataCache = null; // Invalidate cache + } catch (error) { + console.error('Error clearing day data:', error); + } +} + +// Synchronous functions that use cache (for backwards compatibility) +// These should be replaced with async versions in components + +export function getPreferences(_userId?: string): UserPreferences { + return preferencesCache || defaultPreferences; +} + +export function getUsageData(_userId?: string): UsageEntry[] { + return usageDataCache || []; +} + +export function savePreferences(preferences: UserPreferences, _userId?: string): void { + preferencesCache = preferences; + savePreferencesAsync(preferences); +} + +export function saveUsageEntry(entry: UsageEntry, _userId?: string): void { + saveUsageEntryAsync(entry); +} + +export function setUsageForDate( + date: string, + count: number, + substance: 'nicotine' | 'weed', + _userId?: string +): void { + setUsageForDateAsync(date, count, substance); +} + +export function getUsageForDate( + date: string, + substance: 'nicotine' | 'weed', + _userId?: string +): number { + const data = usageDataCache || []; const entry = data.find((e) => e.date === date && e.substance === substance); return entry?.count ?? 0; } -export function getPreferences(userId?: string): UserPreferences { - const defaultPrefs: UserPreferences = { - substance: 'nicotine', - trackingStartDate: null, - hasCompletedSetup: false, - dailyGoal: null, - quitPlan: null, - userName: null, - userAge: null, - }; - - if (typeof window === 'undefined') { - return defaultPrefs; - } - - const key = getStorageKey('quit_smoking_preferences', userId); - const data = localStorage.getItem(key); - if (!data) { - return defaultPrefs; - } - - return { ...defaultPrefs, ...JSON.parse(data) }; -} - -export function savePreferences(preferences: UserPreferences, userId?: string): void { - if (typeof window === 'undefined') return; - const key = getStorageKey('quit_smoking_preferences', userId); - localStorage.setItem(key, JSON.stringify(preferences)); -} - -export function getLastPromptDate(userId?: string): string | null { - if (typeof window === 'undefined') return null; - const key = getStorageKey('quit_smoking_last_prompt', userId); - return localStorage.getItem(key); -} - -export function setLastPromptDate(date: string, userId?: string): void { - if (typeof window === 'undefined') return; - const key = getStorageKey('quit_smoking_last_prompt', userId); - localStorage.setItem(key, date); +export function clearDayData( + date: string, + substance: 'nicotine' | 'weed', + _userId?: string +): void { + clearDayDataAsync(date, substance); } export function shouldShowUsagePrompt(): boolean { - // Always show the prompt - users can log multiple times throughout the day return true; } -export function getWeeklyData(substance: 'nicotine' | 'weed', userId?: string): UsageEntry[] { - const data = getUsageData(userId); +export function getWeeklyData(substance: 'nicotine' | 'weed', _userId?: string): UsageEntry[] { + const data = usageDataCache || []; const today = new Date(); const weekAgo = new Date(today); weekAgo.setDate(weekAgo.getDate() - 7); @@ -148,17 +207,17 @@ export function getWeeklyData(substance: 'nicotine' | 'weed', userId?: string): }); } -export function calculateWeeklyAverage(substance: 'nicotine' | 'weed', userId?: string): number { - const weeklyData = getWeeklyData(substance, userId); +export function calculateWeeklyAverage(substance: 'nicotine' | 'weed', _userId?: string): number { + const weeklyData = getWeeklyData(substance); if (weeklyData.length === 0) return 0; const total = weeklyData.reduce((sum, entry) => sum + entry.count, 0); return Math.round(total / weeklyData.length); } -export function hasOneWeekOfData(substance: 'nicotine' | 'weed', userId?: string): boolean { - const prefs = getPreferences(userId); - if (!prefs.trackingStartDate) return false; +export function hasOneWeekOfData(substance: 'nicotine' | 'weed', _userId?: string): boolean { + const prefs = preferencesCache; + if (!prefs?.trackingStartDate) return false; const startDate = new Date(prefs.trackingStartDate); const today = new Date(); @@ -167,8 +226,8 @@ export function hasOneWeekOfData(substance: 'nicotine' | 'weed', userId?: string return daysDiff >= 7; } -export function generateQuitPlan(substance: 'nicotine' | 'weed', userId?: string): QuitPlan { - const baseline = calculateWeeklyAverage(substance, userId); +export function generateQuitPlan(substance: 'nicotine' | 'weed', _userId?: string): QuitPlan { + const baseline = calculateWeeklyAverage(substance); const today = new Date(); const startDate = today.toISOString().split('T')[0]; @@ -192,9 +251,9 @@ export function generateQuitPlan(substance: 'nicotine' | 'weed', userId?: string }; } -export function getCurrentWeekTarget(userId?: string): number | null { - const prefs = getPreferences(userId); - if (!prefs.quitPlan) return null; +export function getCurrentWeekTarget(_userId?: string): number | null { + const prefs = preferencesCache; + if (!prefs?.quitPlan) return null; const startDate = new Date(prefs.quitPlan.startDate); const today = new Date(); @@ -207,20 +266,11 @@ export function getCurrentWeekTarget(userId?: string): number | null { return prefs.quitPlan.weeklyTargets[weekNumber]; } -export function clearDayData(date: string, substance: 'nicotine' | 'weed', userId?: string): void { - if (typeof window === 'undefined') return; - const key = getStorageKey('quit_smoking_usage', userId); - const data = getUsageData(userId); - const filtered = data.filter((e) => !(e.date === date && e.substance === substance)); - localStorage.setItem(key, JSON.stringify(filtered)); +export async function clearAllDataAsync(): Promise { + // This would need a dedicated API endpoint + console.warn('clearAllData not implemented for API-based storage'); } -export function clearAllData(userId?: string): void { - if (typeof window === 'undefined') return; - const usageKey = getStorageKey('quit_smoking_usage', userId); - const prefsKey = getStorageKey('quit_smoking_preferences', userId); - const promptKey = getStorageKey('quit_smoking_last_prompt', userId); - localStorage.removeItem(usageKey); - localStorage.removeItem(prefsKey); - localStorage.removeItem(promptKey); +export function clearAllData(_userId?: string): void { + clearAllDataAsync(); }