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:
Avery Felts 2026-01-24 01:08:10 -07:00
parent 498b7b4dea
commit 750c5af465
13 changed files with 569 additions and 159 deletions

4
.gitignore vendored
View File

@ -39,3 +39,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# database
*.db
*.db-journal

View File

@ -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=="],

View File

@ -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",

View 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");

View 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
View 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])
}

View 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
View 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 });
}
}

View File

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

View File

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

View File

@ -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
View 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;
}

View File

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