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 <noreply@anthropic.com>
This commit is contained in:
parent
498b7b4dea
commit
750c5af465
4
.gitignore
vendored
4
.gitignore
vendored
@ -39,3 +39,7 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# database
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
21
bun.lock
21
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=="],
|
||||
|
||||
@ -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",
|
||||
|
||||
32
prisma/migrations/20260124050535_init/migration.sql
Normal file
32
prisma/migrations/20260124050535_init/migration.sql
Normal file
@ -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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -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"
|
||||
43
prisma/schema.prisma
Normal file
43
prisma/schema.prisma
Normal file
@ -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])
|
||||
}
|
||||
89
src/app/api/preferences/route.ts
Normal file
89
src/app/api/preferences/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
}
|
||||
149
src/app/api/usage/route.ts
Normal file
149
src/app/api/usage/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
<QuitPlanCard
|
||||
plan={preferences.quitPlan}
|
||||
onGeneratePlan={handleGeneratePlan}
|
||||
hasEnoughData={hasOneWeekOfData(preferences.substance, user.id)}
|
||||
hasEnoughData={hasOneWeekOfData(preferences.substance)}
|
||||
daysTracked={getDaysTracked()}
|
||||
currentAverage={calculateWeeklyAverage(preferences.substance, user.id)}
|
||||
currentAverage={calculateWeeklyAverage(preferences.substance)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<Date | undefined>(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);
|
||||
|
||||
@ -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<string | null>(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)
|
||||
|
||||
11
src/lib/prisma.ts
Normal file
11
src/lib/prisma.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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<UserPreferences> {
|
||||
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<void> {
|
||||
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<UsageEntry[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user