This commit is contained in:
Avery Felts 2026-02-24 02:03:27 -07:00
parent c31f8d8cfe
commit e5b3f649be
27 changed files with 1078 additions and 603 deletions

5
.gitignore vendored
View File

@ -34,6 +34,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
.dev.vars
.dev.vars.*
# vercel
.vercel
@ -50,3 +51,7 @@ next-env.d.ts
.open-next/
.open-next 2/
.wrangler/
# local editor / temp
.idea/
.vscode/

View File

@ -61,3 +61,56 @@ Expected URL:
- Keep file reads tightly scoped to explicit paths when requested.
- Avoid broad folder crawling unless asked.
## Latest Session Changes
### Product and UX
- Reworked mobile dashboard into dedicated swipe pages:
- Mood (+ Daily Inspiration)
- Quit Journey Plan
- Usage Stats
- Health Recovery
- Achievements
- Savings
- Usage Calendar
- Removed mobile side arrow buttons; swipe + dots are now primary navigation.
- Moved floating `Log Usage` button above the mobile swipe indicator and centered it on mobile.
- Updated profile menu to top-right and moved quick actions (notifications/install/theme) into menu.
- Expanded Quit Journey Plan sections (no dropdown collapse), showing both nicotine and weed plans.
- Replaced old theme token set with updated light/dark OKLCH theme variables in `src/app/globals.css`.
### Reliability and Auth
- Added explicit dashboard/substance data load error handling and retry surface.
- Fixed hourly reminders edge cases for overnight windows.
- Added reminder API input validation for time/frequency values.
- Implemented real password reset initiation via WorkOS endpoint and UI wiring in login/settings.
### Achievements
- Added inline unlock guidance text under each achievement tile.
- Added completed/locked status treatment directly in the tile.
- Added unlock safeguards to avoid duplicate global `first_day` unlock behavior:
- `first_day` now normalizes to `substance: both`
- dedupe normalization in achievements API GET
- API validation for badge/substance input
- `INSERT OR IGNORE` safety in D1 write path
## Validation Commands Used
```bash
bun run build
bun run build:worker
bun run dev:worker
```
## Quick Troubleshooting
- If UI changes do not appear in worker mode:
1. `bun run build:worker`
2. `bun run dev:worker`
3. hard refresh browser (`Cmd + Shift + R`)
- If mobile swipe feels off:
- swipe behavior is controlled in `src/app/globals.css` under `.swipe-container` and `.swipe-item`.

View File

@ -1,36 +1,77 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# QuitTraq (Stop Smoking Website v2)
## Getting Started
QuitTraq is a Next.js + Cloudflare Workers app for tracking nicotine/marijuana usage, recovery progress, achievements, reminders, and savings.
First, run the development server:
## Tech Stack
- Next.js 16 (App Router), React 19, TypeScript
- Cloudflare Workers via OpenNext
- Cloudflare D1 (SQLite)
- WorkOS AuthKit + WorkOS User Management
- Tailwind CSS v4
## Local Development
This app should be run in Worker mode for realistic auth + D1 behavior.
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
bun install
bun run d1:migrate
bun run build:worker
bun run dev:worker
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Local URL: `http://localhost:3000`
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Environment Variables
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
Create local env files:
## Learn More
- `.env.local` (Next.js runtime)
- `.dev.vars` (Wrangler runtime)
To learn more about Next.js, take a look at the following resources:
Required keys:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- `WORKOS_CLIENT_ID`
- `WORKOS_API_KEY`
- `WORKOS_REDIRECT_URI`
- `SESSION_SECRET`
- `DATABASE_URL`
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
Optional keys for push notifications and cron:
## Deploy on Vercel
- `NEXT_PUBLIC_VAPID_PUBLIC_KEY`
- `VAPID_PRIVATE_KEY`
- `VAPID_SUBJECT`
- `CRON_SECRET`
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## Deployment
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
```bash
bun run deploy:app
bun run deploy:cron
```
Or run both:
```bash
bun run deploy
```
## Database Migrations
```bash
# local D1
bun run d1:migrate
# remote D1
bun run d1:migrate:prod
```
## Recent Product/UX Updates
- Mobile dashboard uses swipe-first pages with improved snap behavior.
- Profile menu moved to top-right; quick actions are grouped in-menu.
- Daily Inspiration moved under Mood on mobile.
- Achievements cards now show unlock guidance inline.
- Achievements unlock flow hardened to avoid duplicate global first-step unlocks.

View File

@ -1,6 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { getAchievementsD1, getAchievementD1, createAchievementD1 } from '@/lib/d1';
import { getAchievementsD1, getAchievementD1, getAchievementByBadgeD1, createAchievementD1 } from '@/lib/d1';
const VALID_BADGE_IDS = new Set([
'first_day',
'streak_3',
'streak_7',
'fighter',
'one_month',
'goal_crusher',
]);
const VALID_SUBSTANCES = new Set(['nicotine', 'weed', 'both']);
const GLOBAL_BADGE_IDS = new Set(['first_day']);
export async function GET() {
try {
@ -10,14 +22,22 @@ export async function GET() {
}
const achievements = await getAchievementsD1(session.user.id);
const seenGlobalBadges = new Set<string>();
return NextResponse.json(
achievements.map((a) => ({
const normalized = achievements
.filter((a) => {
if (!GLOBAL_BADGE_IDS.has(a.badgeId)) return true;
if (seenGlobalBadges.has(a.badgeId)) return false;
seenGlobalBadges.add(a.badgeId);
return true;
})
.map((a) => ({
badgeId: a.badgeId,
unlockedAt: a.unlockedAt,
substance: a.substance,
}))
);
substance: GLOBAL_BADGE_IDS.has(a.badgeId) ? 'both' : a.substance,
}));
return NextResponse.json(normalized);
} catch (error) {
console.error('Error fetching achievements:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@ -38,8 +58,22 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing badgeId or substance' }, { status: 400 });
}
if (!VALID_BADGE_IDS.has(badgeId)) {
return NextResponse.json({ error: 'Invalid badgeId' }, { status: 400 });
}
if (!VALID_SUBSTANCES.has(substance)) {
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
}
const isGlobalBadge = GLOBAL_BADGE_IDS.has(badgeId);
const targetSubstance = isGlobalBadge ? 'both' : substance;
// Check if already exists
const existing = await getAchievementD1(session.user.id, badgeId, substance);
const existing = isGlobalBadge
? await getAchievementByBadgeD1(session.user.id, badgeId)
: await getAchievementD1(session.user.id, badgeId, targetSubstance);
if (existing) {
return NextResponse.json({
badgeId: existing.badgeId,
@ -49,7 +83,7 @@ export async function POST(request: NextRequest) {
});
}
const achievement = await createAchievementD1(session.user.id, badgeId, substance);
const achievement = await createAchievementD1(session.user.id, badgeId, targetSubstance);
if (!achievement) {
return NextResponse.json({ error: 'Failed to unlock achievement' }, { status: 500 });

View File

@ -1,25 +1,28 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { workos } from '@/lib/workos';
// Send password reset email to current user
// NOTE: WorkOS AuthKit handles password reset through the hosted UI flow
// Users can reset their password by clicking "Forgot password" on the login page
export async function POST() {
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({})) as { email?: string };
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const email = (body.email || session?.user?.email || '').trim().toLowerCase();
if (!email) {
return NextResponse.json({ error: 'Email is required.' }, { status: 400 });
}
// For WorkOS AuthKit, password reset is handled through the hosted UI
// The user should be directed to re-authenticate via the login page
// where they can click "Forgot password"
try {
await workos.userManagement.createPasswordReset({ email });
} catch (error: any) {
// Prevent account enumeration: we intentionally return success either way.
console.warn('Password reset initiation warning:', error?.message || error);
}
// Return a message directing the user to the login page
return NextResponse.json({
success: true,
message: 'To reset your password, please log out and use the "Forgot Password" option on the login page.',
redirectTo: '/login'
message: 'If an account exists for this email, a password reset link has been sent.'
});
} catch (error) {
console.error('Password reset error:', error);

View File

@ -85,6 +85,20 @@ function getNotificationData(timeStr: string) {
return { title, message };
}
function toMinutes(timeStr: string) {
const [hours, minutes] = timeStr.split(':').map(Number);
return (hours * 60) + minutes;
}
function isWithinWindow(current: number, start: number, end: number) {
if (start <= end) {
return current >= start && current <= end;
}
// Overnight window (e.g. 22:00 -> 06:00)
return current >= start || current <= end;
}
export async function GET(request: NextRequest) {
try {
const isVapidReady = ensureVapidConfig();
@ -125,16 +139,20 @@ export async function GET(request: NextRequest) {
let tag = '';
if (user.frequency === 'hourly') {
const [currentH, currentM] = userTimeString.split(':');
const [startH, startM] = (user.hourlyStart || '09:00').split(':');
const [currentH, currentM] = userTimeString.split(':').map(Number);
const currentHourKey = `${userDateString}-${currentH}`; // YYYY-MM-DD-HH
// Check active hours
const startStr = user.hourlyStart || '09:00';
const endStr = user.hourlyEnd || '21:00';
const startMinutes = toMinutes(startStr);
const endMinutes = toMinutes(endStr);
const currentMinutes = toMinutes(userTimeString);
// Only send if we are in the time window AND the current minute matches the start minute
if (userTimeString >= startStr && userTimeString <= endStr && currentM === startM) {
const cadenceMinute = Number((user.reminderTime || startStr).split(':')[1] || '0');
// Send once per hour at configured cadence minute while within active window
if (isWithinWindow(currentMinutes, startMinutes, endMinutes) && currentM === cadenceMinute) {
if (user.lastNotifiedDate !== currentHourKey) {
shouldSend = true;
const { title: t, message } = getNotificationData(userTimeString);

View File

@ -50,6 +50,20 @@ export async function POST(request: NextRequest) {
};
const { enabled, reminderTime, frequency, hourlyStart, hourlyEnd, timezone } = body;
const timeRegex = /^\d{2}:\d{2}$/;
if (reminderTime && !timeRegex.test(reminderTime)) {
return NextResponse.json({ error: 'Invalid reminderTime format' }, { status: 400 });
}
if (hourlyStart && !timeRegex.test(hourlyStart)) {
return NextResponse.json({ error: 'Invalid hourlyStart format' }, { status: 400 });
}
if (hourlyEnd && !timeRegex.test(hourlyEnd)) {
return NextResponse.json({ error: 'Invalid hourlyEnd format' }, { status: 400 });
}
if (frequency && !['daily', 'hourly'].includes(frequency)) {
return NextResponse.json({ error: 'Invalid frequency' }, { status: 400 });
}
const settings = await upsertReminderSettingsD1(
session.user.id,
enabled ?? false,

View File

@ -4,105 +4,111 @@
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.9789 0.0082 121.6272);
--foreground: oklch(0 0 0);
--background: oklch(0.9838 0.0035 247.8583);
--foreground: oklch(0.1284 0.0267 261.5937);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0 0 0);
--card-foreground: oklch(0.1284 0.0267 261.5937);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.5106 0.2301 276.9656);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.7038 0.1230 182.5025);
--secondary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.7686 0.1647 70.0804);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0 0 0);
--input: oklch(0.5555 0 0);
--ring: oklch(0.7853 0.1041 274.7134);
--chart-1: oklch(0.5106 0.2301 276.9656);
--chart-2: oklch(0.7038 0.1230 182.5025);
--chart-3: oklch(0.7686 0.1647 70.0804);
--chart-4: oklch(0.6559 0.2118 354.3084);
--chart-5: oklch(0.7227 0.1920 149.5793);
--sidebar: oklch(0.9789 0.0082 121.6272);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.5106 0.2301 276.9656);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.7686 0.1647 70.0804);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.7853 0.1041 274.7134);
--popover-foreground: oklch(0.1284 0.0267 261.5937);
--primary: oklch(0.4865 0.2423 291.8661);
--primary-foreground: oklch(0.9838 0.0035 247.8583);
--secondary: oklch(0.9486 0.0085 303.5068);
--secondary-foreground: oklch(0.3410 0.1625 292.9477);
--muted: oklch(0.9679 0.0027 264.5424);
--muted-foreground: oklch(0.5503 0.0235 264.3620);
--accent: oklch(0.9546 0.0227 303.2883);
--accent-foreground: oklch(0.4865 0.2423 291.8661);
--destructive: oklch(0.6356 0.2082 25.3782);
--destructive-foreground: oklch(0.9838 0.0035 247.8583);
--border: oklch(0.9278 0.0058 264.5314);
--input: oklch(0.9278 0.0058 264.5314);
--ring: oklch(0.4865 0.2423 291.8661);
--chart-1: oklch(0.4865 0.2423 291.8661);
--chart-2: oklch(0.7216 0.1282 217.8676);
--chart-3: oklch(0.6356 0.1398 156.1492);
--chart-4: oklch(0.6192 0.2037 312.7283);
--chart-5: oklch(0.6532 0.2114 353.9392);
--sidebar: oklch(1.0000 0 0);
--sidebar-foreground: oklch(0.1284 0.0267 261.5937);
--sidebar-primary: oklch(0.4865 0.2423 291.8661);
--sidebar-primary-foreground: oklch(0.9838 0.0035 247.8583);
--sidebar-accent: oklch(0.9486 0.0085 303.5068);
--sidebar-accent-foreground: oklch(0.4865 0.2423 291.8661);
--sidebar-border: oklch(0.9278 0.0058 264.5314);
--sidebar-ring: oklch(0.4865 0.2423 291.8661);
--font-sans: Poppins, ui-sans-serif, sans-serif, system-ui;
--font-serif: Georgia, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 1rem;
--shadow-x: 0px;
--shadow-y: 0px;
--shadow-blur: 0px;
--shadow-y: 8px;
--shadow-blur: 30px;
--shadow-spread: 0px;
--shadow-opacity: 0.05;
--shadow-color: #1a1a1a;
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
--tracking-normal: normal;
--shadow-opacity: 0.08;
--shadow-color: hsl(263, 70%, 50%);
--shadow-2xs: 0px 8px 30px 0px hsl(263 70% 50% / 0.04);
--shadow-xs: 0px 8px 30px 0px hsl(263 70% 50% / 0.04);
--shadow-sm: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 1px 2px -1px hsl(263 70% 50% / 0.08);
--shadow: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 1px 2px -1px hsl(263 70% 50% / 0.08);
--shadow-md: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 2px 4px -1px hsl(263 70% 50% / 0.08);
--shadow-lg: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 4px 6px -1px hsl(263 70% 50% / 0.08);
--shadow-xl: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 8px 10px -1px hsl(263 70% 50% / 0.08);
--shadow-2xl: 0px 8px 30px 0px hsl(263 70% 50% / 0.20);
--tracking-normal: -0.02em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(1.0000 0 0);
--card: oklch(0.2455 0.0217 257.2823);
--card-foreground: oklch(1.0000 0 0);
--popover: oklch(0.2455 0.0217 257.2823);
--popover-foreground: oklch(1.0000 0 0);
--primary: oklch(0.6801 0.1583 276.9349);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.7845 0.1325 181.9120);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.3211 0 0);
--muted-foreground: oklch(0.8452 0 0);
--accent: oklch(0.8790 0.1534 91.6054);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.7106 0.1661 22.2162);
--destructive-foreground: oklch(0 0 0);
--border: oklch(0.4459 0 0);
--input: oklch(1.0000 0 0);
--ring: oklch(0.6801 0.1583 276.9349);
--chart-1: oklch(0.6801 0.1583 276.9349);
--chart-2: oklch(0.7845 0.1325 181.9120);
--chart-3: oklch(0.8790 0.1534 91.6054);
--chart-4: oklch(0.7253 0.1752 349.7607);
--chart-5: oklch(0.8003 0.1821 151.7110);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(1.0000 0 0);
--sidebar-primary: oklch(0.6801 0.1583 276.9349);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.8790 0.1534 91.6054);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(1.0000 0 0);
--sidebar-ring: oklch(0.6801 0.1583 276.9349);
--background: oklch(0.1091 0.0091 301.6956);
--foreground: oklch(0.9838 0.0035 247.8583);
--card: oklch(0.1376 0.0118 301.0607);
--card-foreground: oklch(0.9838 0.0035 247.8583);
--popover: oklch(0.1486 0.0140 299.9811);
--popover-foreground: oklch(0.9838 0.0035 247.8583);
--primary: oklch(0.6083 0.2172 297.1153);
--primary-foreground: oklch(0.1091 0.0091 301.6956);
--secondary: oklch(0.2363 0.0582 299.6364);
--secondary-foreground: oklch(0.8266 0.0933 301.9462);
--muted: oklch(0.2217 0.0242 299.7054);
--muted-foreground: oklch(0.7497 0.0224 301.0128);
--accent: oklch(0.2255 0.0836 296.7401);
--accent-foreground: oklch(0.6083 0.2172 297.1153);
--destructive: oklch(0.6356 0.2082 25.3782);
--destructive-foreground: oklch(0.9838 0.0035 247.8583);
--border: oklch(0.2505 0.0293 299.5707);
--input: oklch(0.2505 0.0293 299.5707);
--ring: oklch(0.6083 0.2172 297.1153);
--chart-1: oklch(0.6083 0.2172 297.1153);
--chart-2: oklch(0.7741 0.1272 215.0981);
--chart-3: oklch(0.7801 0.1859 154.5892);
--chart-4: oklch(0.7001 0.1882 313.2907);
--chart-5: oklch(0.6888 0.2092 353.1317);
--sidebar: oklch(0.1249 0.0104 301.6956);
--sidebar-foreground: oklch(0.9838 0.0035 247.8583);
--sidebar-primary: oklch(0.6083 0.2172 297.1153);
--sidebar-primary-foreground: oklch(0.1091 0.0091 301.6956);
--sidebar-accent: oklch(0.2096 0.0482 299.9505);
--sidebar-accent-foreground: oklch(0.6083 0.2172 297.1153);
--sidebar-border: oklch(0.2217 0.0242 299.7054);
--sidebar-ring: oklch(0.6083 0.2172 297.1153);
--font-sans: Poppins, ui-sans-serif, sans-serif, system-ui;
--font-serif: Georgia, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 1rem;
--shadow-x: 0px;
--shadow-y: 0px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0.05;
--shadow-color: #1a1a1a;
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
--shadow-y: 20px;
--shadow-blur: 40px;
--shadow-spread: -10px;
--shadow-opacity: 0.6;
--shadow-color: hsl(0, 0%, 0%);
--shadow-2xs: 0px 20px 40px -10px hsl(0 0% 0% / 0.30);
--shadow-xs: 0px 20px 40px -10px hsl(0 0% 0% / 0.30);
--shadow-sm: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 1px 2px -11px hsl(0 0% 0% / 0.60);
--shadow: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 1px 2px -11px hsl(0 0% 0% / 0.60);
--shadow-md: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 2px 4px -11px hsl(0 0% 0% / 0.60);
--shadow-lg: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 4px 6px -11px hsl(0 0% 0% / 0.60);
--shadow-xl: 0px 20px 40px -10px hsl(0 0% 0% / 0.60), 0px 8px 10px -11px hsl(0 0% 0% / 0.60);
--shadow-2xl: 0px 20px 40px -10px hsl(0 0% 0% / 1.50);
}
@theme inline {
@ -139,9 +145,9 @@
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: 'DM Sans', sans-serif;
--font-mono: 'Space Mono', monospace;
--font-serif: 'DM Sans', sans-serif;
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@ -164,57 +170,19 @@
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
/* Background gradients */
--bg-main: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 20%, #bbf7d0 40%, #dcfce7 60%, #f0fdf4 80%, #dcfce7 100%);
--bg-orbs:
radial-gradient(ellipse at 15% 10%, rgba(99, 102, 241, 0.1) 0%, transparent 40%),
radial-gradient(ellipse at 85% 20%, rgba(168, 85, 247, 0.08) 0%, transparent 35%),
radial-gradient(ellipse at 50% 50%, rgba(59, 130, 246, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at 20% 80%, rgba(34, 197, 94, 0.05) 0%, transparent 40%),
radial-gradient(ellipse at 80% 85%, rgba(239, 68, 68, 0.05) 0%, transparent 35%);
}
@layer base {
html {
scroll-behavior: smooth;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply text-foreground;
@apply bg-background text-foreground;
font-family: var(--font-sans);
letter-spacing: var(--tracking-normal);
background-color: transparent;
min-height: 100vh;
min-height: 100dvh;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-orbs), var(--bg-main);
background-size: cover;
pointer-events: none;
z-index: -50;
}
/* Dark mode overrides */
.dark {
--bg-main: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 20%, #16213e 40%, #1a1a2e 60%, #0f0f1a 80%, #1a1a2e 100%);
--bg-orbs:
radial-gradient(ellipse at 15% 10%, rgba(99, 102, 241, 0.15) 0%, transparent 40%),
radial-gradient(ellipse at 85% 20%, rgba(168, 85, 247, 0.12) 0%, transparent 35%),
radial-gradient(ellipse at 50% 50%, rgba(45, 55, 72, 0.1) 0%, transparent 50%),
radial-gradient(ellipse at 20% 80%, rgba(34, 197, 94, 0.08) 0%, transparent 40%),
radial-gradient(ellipse at 80% 85%, rgba(239, 68, 68, 0.08) 0%, transparent 35%);
overflow-x: clip;
}
/* Calendar styling - optimize cell size */
@ -645,18 +613,21 @@
@media (max-width: 640px) {
.swipe-container {
display: flex;
align-items: flex-start;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-snap-type: x proximity;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pinch-zoom;
scrollbar-width: none;
-ms-overflow-style: none;
gap: 1.25rem;
padding: 0.5rem 0 1.5rem;
gap: 1rem;
padding: 0.25rem 0 0.5rem;
margin: 0 -1rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
scroll-padding-inline: 1.5rem;
overscroll-behavior-x: contain;
will-change: transform, scroll-position;
}
@ -666,10 +637,14 @@
}
.swipe-item {
flex: 0 0 calc(100vw - 3rem);
width: calc(100vw - 3rem);
scroll-snap-align: center;
height: fit-content;
flex: 0 0 calc(100vw - 3.25rem);
width: calc(100vw - 3.25rem);
scroll-snap-align: start;
scroll-snap-stop: always;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow: visible;
}
}

View File

@ -3,7 +3,7 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Mail, Lock, LogOut, ExternalLink } from 'lucide-react';
import { ArrowLeft, Mail, Lock } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useTheme } from '@/lib/theme-context';
import { cn } from '@/lib/utils';
@ -11,10 +11,33 @@ import { cn } from '@/lib/utils';
export default function SettingsPage() {
const router = useRouter();
const { theme } = useTheme();
const [isSendingReset, setIsSendingReset] = useState(false);
const [resetMessage, setResetMessage] = useState<string | null>(null);
const [resetError, setResetError] = useState<string | null>(null);
const handlePasswordReset = () => {
// Log out and redirect to login page where they can use "Forgot Password"
window.location.href = '/api/auth/logout?redirect=/login';
const handlePasswordReset = async () => {
setIsSendingReset(true);
setResetMessage(null);
setResetError(null);
try {
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json() as { message?: string; error?: string };
if (!response.ok) {
throw new Error(data.error || 'Failed to start password reset.');
}
setResetMessage(data.message || 'Reset instructions sent to your email.');
} catch (error: any) {
setResetError(error.message || 'Failed to start password reset.');
} finally {
setIsSendingReset(false);
}
};
return (
@ -64,18 +87,30 @@ export default function SettingsPage() {
: "bg-white/5 border-white/10"
)}>
<p className="text-sm text-muted-foreground">
To change your password, you'll be logged out and redirected to the login page.
Use the <strong>"Forgot Password"</strong> option to receive a reset link via email.
Send a secure password reset link to your account email.
</p>
</div>
{resetMessage && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
{resetMessage}
</div>
)}
{resetError && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
{resetError}
</div>
)}
<Button
onClick={handlePasswordReset}
variant="outline"
className="w-full h-12 text-base font-semibold rounded-xl"
disabled={isSendingReset}
>
<LogOut className="mr-2 h-4 w-4" />
Log Out to Reset Password
<Lock className="mr-2 h-4 w-4" />
{isSendingReset ? 'Sending Reset Link...' : 'Send Password Reset Link'}
</Button>
</CardContent>
</Card>

View File

@ -1,12 +1,13 @@
'use client';
import React, { useMemo, useState } from 'react';
import React, { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Achievement, BADGE_DEFINITIONS, BadgeDefinition } from '@/lib/storage';
import { Achievement, BADGE_DEFINITIONS } from '@/lib/storage';
import { useTheme } from '@/lib/theme-context';
import {
Trophy,
Lock,
CheckCircle2,
Footprints,
Flame,
Shield,
@ -30,7 +31,6 @@ const iconMap: Record<string, React.ElementType> = {
function AchievementsCardComponent({ achievements, substance }: AchievementsCardProps) {
const { theme } = useTheme();
const [hoveredBadge, setHoveredBadge] = useState<string | null>(null);
const unlockedBadgeIds = useMemo(() => {
return new Set(
@ -49,11 +49,9 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
return (
<Card
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative`}
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-full min-h-[62dvh] sm:min-h-0 flex flex-col`}
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-yellow-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-2">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<Trophy className="h-5 w-5 text-yellow-400" />
@ -61,46 +59,20 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
</CardTitle>
</CardHeader>
<CardContent className="relative z-10">
<div className="grid grid-cols-3 gap-3">
<CardContent className="relative z-10 flex flex-1 flex-col pb-6">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 auto-rows-fr">
{BADGE_DEFINITIONS.map((badge) => {
const isUnlocked = unlockedBadgeIds.has(badge.id);
const Icon = iconMap[badge.icon] || Trophy;
const unlockedAchievement = achievements.find(
(a) =>
a.badgeId === badge.id &&
(a.substance === substance || a.substance === 'both')
);
const isHovered = hoveredBadge === badge.id;
return (
<div
key={badge.id}
className={`relative p-3 rounded-xl text-center transition-all duration-300 cursor-pointer ${isUnlocked
? 'bg-gradient-to-br from-yellow-500/30 to-amber-600/20 border border-yellow-500/50 hover:scale-105'
className={`relative p-3 rounded-xl text-center transition-all duration-300 h-full flex flex-col justify-between ${isUnlocked
? 'bg-gradient-to-br from-yellow-500/30 to-amber-600/20 border border-yellow-500/50'
: 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20'
}`}
onMouseEnter={() => setHoveredBadge(badge.id)}
onMouseLeave={() => setHoveredBadge(null)}
>
{/* Hover tooltip */}
{isHovered && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 w-48 p-2 bg-gray-900/95 border border-white/20 rounded-lg shadow-xl backdrop-blur-sm">
<p className="text-xs text-white font-medium mb-1">{badge.name}</p>
<p className="text-[10px] text-white/70">
{isUnlocked
? `Unlocked: ${new Date(unlockedAchievement!.unlockedAt).toLocaleDateString()}`
: badge.howToUnlock}
</p>
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900/95" />
</div>
)}
{!isUnlocked && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded-xl pointer-events-none">
<Lock className="h-4 w-4 text-white/40" />
</div>
)}
<div
className={`mx-auto mb-1 p-2 rounded-full w-fit ${isUnlocked
? 'bg-yellow-500/30 text-yellow-300'
@ -109,18 +81,37 @@ function AchievementsCardComponent({ achievements, substance }: AchievementsCard
>
<Icon className="h-5 w-5" />
</div>
<p
className={`text-xs font-medium ${isUnlocked ? 'text-white' : 'text-white/40'
}`}
>
{badge.name}
</p>
<p className={`mt-1.5 text-[10px] leading-tight ${isUnlocked ? 'text-white/35 line-through' : 'text-white/75'}`}>
{badge.howToUnlock}
</p>
<div className="mt-2 flex items-center justify-center gap-1.5">
{isUnlocked ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-yellow-300/90" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-white/55">Completed</span>
</>
) : (
<>
<Lock className="h-3.5 w-3.5 text-white/45" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-white/55">Locked</span>
</>
)}
</div>
</div>
);
})}
</div>
<div className="mt-4 text-center">
<div className="mt-auto pt-4 text-center">
<p className="text-sm text-white/70">
{unlockedBadgeIds.size} of {BADGE_DEFINITIONS.length} badges unlocked
</p>

View File

@ -138,8 +138,6 @@ export function DailyInspirationCard({ initialReligion, onReligionChange }: Dail
boxShadow: `inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 4px 20px ${getShadowColor()}`
}}
>
<div className="absolute top-0 right-0 w-24 h-24 sm:w-32 sm:h-32 bg-gradient-to-br from-white/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="relative z-10">
<div className="flex items-center justify-between mb-2 sm:mb-3">
<div className="flex items-center gap-2">

View File

@ -22,6 +22,7 @@ import {
SavingsConfig,
BADGE_DEFINITIONS,
BadgeDefinition,
StorageRequestError,
} from '@/lib/storage';
import { UserHeader } from './UserHeader';
import { SetupWizard } from './SetupWizard';
@ -33,11 +34,12 @@ import { CelebrationAnimation } from './CelebrationAnimation';
import { HealthTimelineCard } from './HealthTimelineCard';
import { SavingsTrackerCard } from './SavingsTrackerCard';
import { MoodTracker } from './MoodTracker';
import { DailyInspirationCard } from './DailyInspirationCard';
import { ScrollWheelLogger } from './ScrollWheelLogger';
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
import { VersionUpdateModal } from './VersionUpdateModal';
import { Button } from '@/components/ui/button';
import { PlusCircle, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { PlusCircle, X } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import { getTodayString } from '@/lib/date-utils';
@ -46,6 +48,18 @@ interface DashboardProps {
user: User;
}
const MOBILE_SLIDES = [
{ id: 'mood', label: 'How Are You Feeling' },
{ id: 'plan', label: 'Quit Journey Plan' },
{ id: 'stats', label: 'Usage Stats' },
{ id: 'recovery', label: 'Health Recovery' },
{ id: 'achievements', label: 'Achievements' },
{ id: 'savings', label: 'Savings' },
{ id: 'calendar', label: 'Usage Calendar' },
];
const GLOBAL_BADGE_IDS = new Set(['first_day']);
export function Dashboard({ user }: DashboardProps) {
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
@ -57,6 +71,7 @@ export function Dashboard({ user }: DashboardProps) {
const [activeLoggingSubstance, setActiveLoggingSubstance] = useState<'nicotine' | 'weed' | null>(null);
const [newBadge, setNewBadge] = useState<BadgeDefinition | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [currentPage, setCurrentPage] = useState(0);
const [modalOpenCount, setModalOpenCount] = useState(0);
@ -64,32 +79,71 @@ export function Dashboard({ user }: DashboardProps) {
const { theme } = useTheme();
const isModalOpen = modalOpenCount > 0 || showSetup || showCelebration;
const isNavHidden = isModalOpen || isSubstancePickerOpen || !!activeLoggingSubstance;
const totalPages = MOBILE_SLIDES.length;
const handleModalStateChange = useCallback((isOpen: boolean) => {
setModalOpenCount(prev => isOpen ? prev + 1 : Math.max(0, prev - 1));
}, []);
const handleScroll = useCallback(() => {
if (!swipeContainerRef.current) return;
const scrollLeft = swipeContainerRef.current.scrollLeft;
const width = swipeContainerRef.current.offsetWidth;
const page = Math.round(scrollLeft / width);
if (page !== currentPage) {
setCurrentPage(page);
const container = swipeContainerRef.current;
if (!container) return;
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
if (slides.length === 0) return;
let nearestIndex = 0;
let nearestDistance = Number.POSITIVE_INFINITY;
slides.forEach((slide, index) => {
const distance = Math.abs(container.scrollLeft - slide.offsetLeft);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
if (nearestIndex !== currentPage) {
setCurrentPage(nearestIndex);
}
}, [currentPage]);
const scrollToPage = (pageIndex: number) => {
if (!swipeContainerRef.current) return;
const width = swipeContainerRef.current.offsetWidth;
swipeContainerRef.current.scrollTo({
left: pageIndex * width,
const container = swipeContainerRef.current;
if (!container) return;
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
if (slides.length === 0) return;
const boundedPage = Math.min(Math.max(pageIndex, 0), totalPages - 1);
const targetSlide = slides[boundedPage];
container.scrollTo({
left: targetSlide?.offsetLeft ?? 0,
behavior: 'smooth'
});
};
useEffect(() => {
if (typeof window === 'undefined') return;
const savedPage = Number(localStorage.getItem('quittraq_mobile_dashboard_page'));
if (Number.isNaN(savedPage) || savedPage < 0 || savedPage >= totalPages) return;
setCurrentPage(savedPage);
const timeout = window.setTimeout(() => {
scrollToPage(savedPage);
}, 0);
return () => window.clearTimeout(timeout);
}, [totalPages]);
useEffect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem('quittraq_mobile_dashboard_page', String(currentPage));
}, [currentPage]);
const loadData = useCallback(async () => {
try {
const [prefs, usage, achvs, savings] = await Promise.all([
fetchPreferences(),
fetchUsageData(),
@ -100,9 +154,17 @@ export function Dashboard({ user }: DashboardProps) {
setUsageData(usage);
setAchievements(achvs);
setSavingsConfig(savings);
setLoadError(null);
console.log('[Dashboard] Loaded prefs:', prefs);
setRefreshKey(prev => prev + 1);
return { prefs, usage, achvs };
} catch (error) {
const message = error instanceof StorageRequestError
? error.message
: 'Unable to sync your dashboard right now. Please try again.';
setLoadError(message);
throw error;
}
}, []);
const checkAndUnlockAchievements = useCallback(async (
@ -112,11 +174,47 @@ export function Dashboard({ user }: DashboardProps) {
) => {
// Current unlocked set (local + server)
const unlockedIds = new Set(currentAchievements.map(a => `${a.badgeId}-${a.substance}`));
const unlockedGlobalBadges = new Set(
currentAchievements
.filter((a) => GLOBAL_BADGE_IDS.has(a.badgeId))
.map((a) => a.badgeId)
);
const newUnlocked: Achievement[] = [];
let badgeToCelebrate: BadgeDefinition | null = null;
const hasUsageBySubstance = {
nicotine: usage.some((entry) => entry.substance === 'nicotine' && entry.count > 0),
weed: usage.some((entry) => entry.substance === 'weed' && entry.count > 0),
};
for (const badge of BADGE_DEFINITIONS) {
if (GLOBAL_BADGE_IDS.has(badge.id)) {
if (unlockedGlobalBadges.has(badge.id)) continue;
const isEligible = checkBadgeEligibility(badge.id, usage, prefs, 'nicotine')
|| checkBadgeEligibility(badge.id, usage, prefs, 'weed');
if (!isEligible) continue;
try {
const result = await unlockAchievement(badge.id, 'both');
if (result.isNew && result.achievement) {
newUnlocked.push(result.achievement);
unlockedGlobalBadges.add(badge.id);
if (!badgeToCelebrate) {
badgeToCelebrate = badge;
}
}
} catch (e) {
console.error('Error unlocking global achievement:', e);
}
continue;
}
for (const substance of ['nicotine', 'weed'] as const) {
if (!hasUsageBySubstance[substance]) continue;
const key = `${badge.id}-${substance}`;
if (unlockedIds.has(key)) continue;
@ -154,6 +252,7 @@ export function Dashboard({ user }: DashboardProps) {
useEffect(() => {
const init = async () => {
try {
const { prefs, usage, achvs } = await loadData();
if (!prefs.hasCompletedSetup) {
@ -168,8 +267,11 @@ export function Dashboard({ user }: DashboardProps) {
markPromptShown();
}
}
} catch (error) {
console.error('Dashboard init error:', error);
} finally {
setIsLoading(false);
}
};
init();
@ -307,10 +409,39 @@ export function Dashboard({ user }: DashboardProps) {
/>
<main className="container mx-auto px-4 py-4 sm:py-8 pb-4 sm:pb-8 max-w-full">
{loadError && (
<div className={`mb-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm ${theme === 'light' ? 'text-amber-800' : 'text-amber-100'}`}>
<div className="flex items-center justify-between gap-3">
<span>{loadError}</span>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={async () => {
setIsLoading(true);
try {
await loadData();
} finally {
setIsLoading(false);
}
}}
>
Retry
</Button>
</div>
</div>
)}
{!preferences && !isLoading && (
<div className="rounded-2xl border border-border bg-card/70 p-6 text-center">
<p className="text-sm text-muted-foreground">Your dashboard data is unavailable right now.</p>
</div>
)}
{preferences && (
<>
{/* Floating Log Button - Simplified to toggle Picker */}
<div className={`fixed bottom-6 right-6 z-40 transition-all duration-300 ${isModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
<div className={`fixed bottom-[6.5rem] sm:bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-6 z-40 transition-all duration-300 ${isModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
<Button
size="lg"
onClick={() => setIsSubstancePickerOpen(!isSubstancePickerOpen)}
@ -335,36 +466,6 @@ export function Dashboard({ user }: DashboardProps) {
{/* Dashboard Sections */}
<div className="space-y-6 sm:space-y-12 relative overflow-hidden">
{/* Mobile Navigation Buttons - LARGE */}
<div className="sm:hidden">
{currentPage > 0 && !isNavHidden && (
<button
onClick={() => scrollToPage(currentPage - 1)}
className="fixed left-3 top-[55%] -translate-y-1/2 z-40 p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
style={{
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(16px)',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
}}
>
<ChevronLeft className="h-8 w-8 text-primary group-hover:scale-110" />
</button>
)}
{currentPage < 3 && !isNavHidden && (
<button
onClick={() => scrollToPage(currentPage + 1)}
className="fixed right-3 top-[55%] -translate-y-1/2 z-40 p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
style={{
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(16px)',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
}}
>
<ChevronRight className="h-8 w-8 text-primary group-hover:scale-110" />
</button>
)}
</div>
{/* DESKTOP LAYOUT - Hidden on mobile */}
<div className="hidden sm:block space-y-8">
{/* Row 1: Mood + Quit Plan */}
@ -385,12 +486,6 @@ export function Dashboard({ user }: DashboardProps) {
usageData={usageData}
onDataUpdate={loadData}
userId={user.id}
religion={preferences.religion}
onReligionUpdate={async (religion: 'christian' | 'secular') => {
const updatedPrefs = { ...preferences, religion };
setPreferences(updatedPrefs);
await savePreferencesAsync(updatedPrefs);
}}
preferences={preferences}
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
await savePreferencesAsync(updatedPrefs);
@ -433,17 +528,48 @@ export function Dashboard({ user }: DashboardProps) {
{/* MOBILE SWIPE LAYOUT - Hidden on desktop */}
<div
ref={swipeContainerRef}
id="mobile-dashboard-slides"
onScroll={handleScroll}
onKeyDown={(event) => {
if (event.key === 'ArrowRight') {
event.preventDefault();
scrollToPage(currentPage + 1);
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollToPage(currentPage - 1);
}
}}
className="swipe-container sm:hidden"
tabIndex={0}
role="region"
aria-label="Mobile dashboard sections"
>
{/* SLIDE 1: Mindset (Mood & Personalized Plan) */}
<div className="swipe-item space-y-4">
{/* SLIDE 1: Mood */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Daily Mindset</h2>
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">How Are You Feeling</h2>
</div>
<div className="space-y-4">
<div className="w-full max-w-[30rem] mx-auto space-y-3">
<MoodTracker />
<DailyInspirationCard
initialReligion={preferences.religion}
onReligionChange={async (religion) => {
const updatedPrefs = { ...preferences, religion };
setPreferences(updatedPrefs);
await savePreferencesAsync(updatedPrefs);
}}
/>
</div>
</div>
{/* SLIDE 2: Quit Plan */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Quit Journey Plan</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<UnifiedQuitPlanCard
preferences={preferences}
usageData={usageData}
@ -453,16 +579,31 @@ export function Dashboard({ user }: DashboardProps) {
</div>
</div>
{/* SLIDE 2: Stats & Recovery (Side-by-side Stats + Health) */}
<div className="swipe-item space-y-4">
{/* SLIDE 3: Stats */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage & Recovery</h2>
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Stats</h2>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
<div className="grid grid-cols-1 gap-3 w-full max-w-[30rem] mx-auto">
<StatsCard
key={`stats-nicotine-${refreshKey}`}
usageData={usageData}
substance="nicotine"
/>
<StatsCard
key={`stats-weed-${refreshKey}`}
usageData={usageData}
substance="weed"
/>
</div>
</div>
{/* SLIDE 4: Recovery */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Health Recovery</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<HealthTimelineCard
key={`health-${refreshKey}`}
usageData={usageData}
@ -471,17 +612,26 @@ export function Dashboard({ user }: DashboardProps) {
</div>
</div>
{/* SLIDE 3: Achievements & Money (Insights) */}
<div className="swipe-item space-y-4">
{/* SLIDE 5: Achievements */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements & Savings</h2>
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements</h2>
</div>
<div className="space-y-4">
<div className="w-full max-w-[30rem] mx-auto">
<AchievementsCard
key={`achievements-${refreshKey}`}
achievements={achievements}
substance={preferences.substance}
/>
</div>
</div>
{/* SLIDE 6: Savings */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Savings</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<SavingsTrackerCard
key={`savings-${refreshKey}`}
savingsConfig={savingsConfig}
@ -493,22 +643,17 @@ export function Dashboard({ user }: DashboardProps) {
</div>
</div>
{/* SLIDE 4: Calendar */}
{/* SLIDE 7: Calendar */}
<div id="calendar-section-mobile" className="swipe-item">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Calendar</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<UsageCalendar
key={refreshKey}
usageData={usageData}
onDataUpdate={loadData}
userId={user.id}
religion={preferences.religion}
onReligionUpdate={async (religion: 'christian' | 'secular') => {
const updatedPrefs = { ...preferences, religion };
setPreferences(updatedPrefs);
await savePreferencesAsync(updatedPrefs);
}}
preferences={preferences}
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
await savePreferencesAsync(updatedPrefs);
@ -516,8 +661,29 @@ export function Dashboard({ user }: DashboardProps) {
}}
/>
</div>
</div>
</div>
<div className="sm:hidden mt-1 mb-1 px-2">
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.2em] opacity-60 text-center mb-2">
{MOBILE_SLIDES[currentPage]?.label}
</div>
<div className="flex items-center justify-center gap-2">
{MOBILE_SLIDES.map((slide, index) => (
<button
key={slide.id}
type="button"
onClick={() => scrollToPage(index)}
aria-label={`Go to ${slide.label}`}
aria-current={currentPage === index ? 'page' : undefined}
className={`h-2 rounded-full transition-all ${currentPage === index ? 'w-8 bg-primary' : 'w-2 bg-white/30'}`}
/>
))}
</div>
</div>
</div>
</div>
</>
)}

View File

@ -311,11 +311,9 @@ function HealthTimelineCardComponent({
return (
<Card
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-[500px] flex flex-col"
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-[min(calc(58dvh+13rem),43rem)] sm:h-[500px] flex flex-col"
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-teal-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-4 shrink-0">
<CardTitle className={`flex items-center gap-2 ${theme === 'light' ? 'text-teal-900' : 'text-white'} text-shadow-sm`}>
<Heart className="h-5 w-5 text-teal-500" />

View File

@ -10,13 +10,20 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Download, Share, Plus, MoreVertical, Smartphone } from 'lucide-react';
import { cn } from '@/lib/utils';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function InstallAppButton() {
interface InstallAppButtonProps {
compact?: boolean;
className?: string;
onBeforeOpen?: () => void;
}
export function InstallAppButton({ compact = false, className, onBeforeOpen }: InstallAppButtonProps) {
const [showInstructions, setShowInstructions] = useState(false);
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isIOS, setIsIOS] = useState(false);
@ -50,6 +57,7 @@ export function InstallAppButton() {
}, []);
const handleInstallClick = async () => {
onBeforeOpen?.();
if (deferredPrompt) {
// Use the native install prompt (Android/Chrome)
await deferredPrompt.prompt();
@ -74,11 +82,21 @@ export function InstallAppButton() {
variant="outline"
size="sm"
onClick={handleInstallClick}
className="gap-2 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50"
className={cn(
compact
? 'h-12 w-12 p-0 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50 flex items-center justify-center'
: 'gap-2 border-purple-500/30 hover:bg-purple-500/10 hover:border-purple-500/50',
className
)}
aria-label="Install app"
>
<Smartphone className="h-4 w-4 text-purple-400" />
<Smartphone className={cn('text-purple-400', compact ? 'h-5 w-5' : 'h-4 w-4')} />
{!compact && (
<>
<span className="hidden sm:inline">Add to Home Screen</span>
<span className="sm:hidden">Install</span>
</>
)}
</Button>
<Dialog open={showInstructions} onOpenChange={setShowInstructions}>

View File

@ -84,8 +84,6 @@ export function ReminderSettingsCard({
className="backdrop-blur-xl border border-indigo-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-indigo-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-2">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<Bell className="h-5 w-5 text-indigo-400" />

View File

@ -81,8 +81,6 @@ function SavingsTrackerCardComponent({
className="backdrop-blur-xl border border-emerald-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<DollarSign className="h-5 w-5 text-emerald-400" />
@ -127,8 +125,6 @@ function SavingsTrackerCardComponent({
className="backdrop-blur-xl border border-emerald-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">

View File

@ -10,7 +10,10 @@ import {
Settings,
Shield,
Heart,
Calendar
Calendar,
Bell,
Moon,
Sun
} from 'lucide-react';
import React from 'react';
import { useRouter } from 'next/navigation';
@ -18,6 +21,7 @@ import { cn } from '@/lib/utils';
import { useTheme } from '@/lib/theme-context';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { createPortal } from 'react-dom';
import { InstallAppButton } from './InstallAppButton';
interface SideMenuProps {
isOpen: boolean;
@ -30,11 +34,12 @@ interface SideMenuProps {
profilePictureUrl?: string | null;
};
userName: string | null;
onOpenNotifications: () => void;
}
export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
export function SideMenu({ isOpen, onClose, user, userName, onOpenNotifications }: SideMenuProps) {
const router = useRouter();
const { theme } = useTheme();
const { theme, toggleTheme } = useTheme();
if (!isOpen) return null;
@ -72,10 +77,10 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
{/* Menu Content */}
<div
className={cn(
"relative w-72 h-full flex flex-col shadow-2xl transition-all animate-in slide-in-from-left duration-300",
"relative ml-auto w-72 h-full flex flex-col shadow-2xl transition-all animate-in slide-in-from-right duration-300",
theme === 'light'
? "bg-white text-slate-900"
: "bg-slate-900 text-white border-r border-white/5"
? "bg-white text-slate-900 border-l border-slate-100"
: "bg-slate-900 text-white border-l border-white/5"
)}
>
{/* Header/Profile Info */}
@ -103,6 +108,59 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
{/* Navigation Links */}
<div className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<div className="px-3 pb-2">
<span className="text-[10px] font-bold uppercase tracking-widest opacity-30">Quick Actions</span>
</div>
<div className="px-3 pb-3">
<div className="flex items-center justify-center gap-3">
<button
onClick={() => {
onClose();
onOpenNotifications();
}}
className={cn(
"h-12 w-12 rounded-xl border transition-all active:scale-95 flex items-center justify-center",
theme === 'light'
? "bg-slate-50 border-slate-200 hover:bg-slate-100"
: "bg-white/5 border-white/10 hover:bg-white/10"
)}
aria-label="Open notification settings"
>
<Bell className="h-5 w-5" />
</button>
<InstallAppButton
compact
onBeforeOpen={onClose}
className={cn(
theme === 'light'
? 'bg-slate-50 border-slate-200 hover:bg-slate-100'
: 'bg-white/5 border-white/10 hover:bg-white/10'
)}
/>
<button
onClick={toggleTheme}
className={cn(
"h-12 w-12 rounded-xl border transition-all active:scale-95 flex items-center justify-center",
theme === 'light'
? "bg-slate-50 border-slate-200 hover:bg-slate-100"
: "bg-white/5 border-white/10 hover:bg-white/10"
)}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? (
<Moon className="h-5 w-5 text-blue-300" />
) : (
<Sun className="h-5 w-5 text-amber-500" />
)}
</button>
</div>
</div>
<div className="my-2 px-3">
<span className="text-[10px] font-bold uppercase tracking-widest opacity-30">Main</span>
</div>
<MenuLink
icon={Home}
label="Dashboard"
@ -148,10 +206,10 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) {
/>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-3 rounded-xl text-red-500 hover:bg-red-500/10 transition-colors font-medium mt-1"
className="relative w-full flex items-center justify-center px-3 py-3 rounded-xl text-red-500 hover:bg-red-500/10 transition-colors font-medium mt-1"
>
<LogOut className="h-5 w-5" />
<span>Sign out</span>
<LogOut className="absolute left-3 h-5 w-5" />
<span className="text-center leading-none">Sign out</span>
</button>
</div>
</div>
@ -182,20 +240,20 @@ function MenuLink({ icon: Icon, label, onClick, color }: MenuLinkProps) {
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-3 py-3 rounded-xl transition-all font-medium group",
"relative w-full flex items-center justify-center px-3 py-3 rounded-xl transition-all font-medium group",
theme === 'light'
? "hover:bg-slate-100"
: "hover:bg-white/5"
)}
>
<div className={cn(
"p-2 rounded-lg transition-colors",
"absolute left-3 p-2 rounded-lg transition-colors",
color ? colors[color] : (theme === 'light' ? "bg-slate-100" : "bg-white/5")
)}>
<Icon className="h-4 w-4" />
</div>
<span className="flex-1 text-left text-sm">{label}</span>
<div className="w-1.5 h-1.5 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
<span className="text-sm text-center leading-none">{label}</span>
<div className="absolute right-3 w-1.5 h-1.5 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
);
}

View File

@ -92,7 +92,6 @@ function StatsCardComponent({ usageData, substance }: StatsCardProps) {
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative`}
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-white/5 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="pb-2 relative z-10">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<SubstanceIcon className={`h-5 w-5 ${iconColor}`} />

View File

@ -17,12 +17,20 @@ interface SubstanceTrackingPageProps {
export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPageProps) {
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const { theme } = useTheme();
const loadData = useCallback(async () => {
try {
const usage = await fetchUsageData();
setUsageData(usage);
setLoadError(null);
} catch (error) {
console.error('Failed to load substance usage data:', error);
setLoadError('Unable to load tracking data right now.');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
@ -88,6 +96,12 @@ export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPage
</div>
{/* Stats and Graph */}
{loadError && (
<div className="mb-6 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
{loadError}
</div>
)}
<div className="grid gap-6 md:grid-cols-2">
<div className="opacity-0 animate-fade-in-up delay-200">
<StatsCard usageData={usageData} substance={substance} />

View File

@ -23,6 +23,8 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
const [pendingVerification, setPendingVerification] = useState(false);
const [verificationCode, setVerificationCode] = useState('');
const [userId, setUserId] = useState<string | null>(null);
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [resetStatus, setResetStatus] = useState<string | null>(null);
const [formData, setFormData] = useState({
email: '',
@ -107,6 +109,39 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
const toggleMode = () => {
setIsSignup(!isSignup);
setError(null);
setResetStatus(null);
};
const handleForgotPassword = async () => {
const email = formData.email.trim();
if (!email) {
setError('Enter your email first, then request password reset.');
return;
}
setError(null);
setResetStatus(null);
setIsResettingPassword(true);
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json() as { message?: string; error?: string };
if (!res.ok) {
throw new Error(data.error || 'Could not start password reset.');
}
setResetStatus(data.message || 'Password reset instructions sent if the account exists.');
} catch (err: any) {
setError(err.message || 'Could not start password reset.');
} finally {
setIsResettingPassword(false);
}
};
// Checkbox Component to reuse
@ -276,16 +311,14 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
{!isSignup && !pendingVerification && (
<a
href="#"
onClick={(e) => {
e.preventDefault();
alert('Please use the Contact Support option or wait for upcoming full password reset features.');
}}
<button
type="button"
onClick={handleForgotPassword}
className="text-xs text-primary hover:underline"
disabled={isResettingPassword}
>
Forgot password?
</a>
{isResettingPassword ? 'Sending reset link...' : 'Forgot password?'}
</button>
)}
</div>
<div className="relative">
@ -324,6 +357,12 @@ export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
</div>
)}
{resetStatus && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-300">
{resetStatus}
</div>
)}
<StayLoggedInCheckbox />
<Button

View File

@ -1,10 +1,10 @@
'use client';
import React, { useState, useMemo } from 'react';
import React, { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { QuitPlan, UsageEntry, UserPreferences } from '@/lib/storage';
import { Target, TrendingDown, ChevronDown, ChevronUp, Cigarette, Leaf, AlertTriangle, XCircle } from 'lucide-react';
import { TrendingDown, Cigarette, Leaf, AlertTriangle, XCircle } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import { getTodayString } from '@/lib/date-utils';
import { cn } from '@/lib/utils';
@ -15,8 +15,6 @@ interface SubstancePlanSectionProps {
usageData: UsageEntry[];
trackingStartDate: string | null;
onGeneratePlan: () => void;
isExpanded: boolean;
onToggle: () => void;
}
function SubstancePlanSection({
@ -24,9 +22,7 @@ function SubstancePlanSection({
plan,
usageData,
trackingStartDate,
onGeneratePlan,
isExpanded,
onToggle
onGeneratePlan
}: SubstancePlanSectionProps) {
const { theme } = useTheme();
@ -76,6 +72,7 @@ function SubstancePlanSection({
const isNicotine = substance === 'nicotine';
const Icon = isNicotine ? Cigarette : Leaf;
const label = isNicotine ? 'Nicotine' : 'Weed';
const unitLabel = isNicotine ? 'puffs' : 'hits';
// Base Colors
const bgColor = isNicotine
@ -100,8 +97,7 @@ function SubstancePlanSection({
<div className={cn("rounded-xl border transition-all duration-300 overflow-hidden mb-3", bgColor, borderColor)}>
{/* HEADER / SUMMARY ROW */}
<div
onClick={onToggle}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-black/5 active:bg-black/10 transition-colors"
className="flex items-center justify-between p-4"
>
<div className="flex items-center gap-3">
<div className={cn("p-2 rounded-lg", isNicotine ? "bg-yellow-500/20" : "bg-emerald-500/20")}>
@ -122,13 +118,11 @@ function SubstancePlanSection({
{todayUsage}{activePlan ? ` / ${currentTarget}` : ''}
</span>
</div>
{isExpanded ? <ChevronUp className="h-5 w-5 opacity-30" /> : <ChevronDown className="h-5 w-5 opacity-30" />}
</div>
</div>
{/* EXPANDED CONTENT */}
{isExpanded && (
<div className="px-4 pb-4 animate-in slide-in-from-top-2 duration-200">
<div className="px-4 pb-4">
<div className="h-px w-full bg-border mb-4 opacity-30" />
{!activePlan ? (
@ -151,9 +145,9 @@ function SubstancePlanSection({
{isUnlocked ? (
<div className="text-center space-y-3">
<p className="text-sm">
Baseline established: <strong className={accentColor}>{currentAverage} puffs/day</strong>
Baseline established: <strong className={accentColor}>{currentAverage} {unitLabel}/day</strong>
</p>
<Button onClick={(e) => { e.stopPropagation(); onGeneratePlan(); }} size="sm" className={cn("w-full h-10 font-bold", progressFill, "text-white hover:opacity-90")}>
<Button onClick={onGeneratePlan} size="sm" className={cn("w-full h-10 font-bold", progressFill, "text-white hover:opacity-90")}>
Generate Plan
</Button>
</div>
@ -169,7 +163,7 @@ function SubstancePlanSection({
<div className="text-center">
<p className="text-[10px] font-bold uppercase opacity-50 mb-1">Current Daily Limit</p>
<p className={cn("text-4xl font-black", accentColor)}>{currentTarget}</p>
<p className="text-xs opacity-50 mt-1">puffs allowed today</p>
<p className="text-xs opacity-50 mt-1">{unitLabel} allowed today</p>
</div>
{/* Progress Bar Detail */}
@ -252,13 +246,12 @@ function SubstancePlanSection({
</div>
<div className="bg-black/5 p-3 rounded-lg flex justify-between items-center text-[10px] uppercase font-bold opacity-50">
<span>Start: {activePlan.baselineAverage}/day</span>
<span>Start: {activePlan.baselineAverage} {unitLabel}/day</span>
<span>End: {new Date(activePlan.endDate).toLocaleDateString()}</span>
</div>
</div>
)}
</div>
)}
</div>
);
}
@ -276,16 +269,8 @@ export function UnifiedQuitPlanCard({
onGeneratePlan,
refreshKey
}: UnifiedQuitPlanCardProps) {
const [expandedSubstance, setExpandedSubstance] = useState<'nicotine' | 'weed' | 'none'>('nicotine');
if (!preferences) return null;
// Determine which substances to show
const showNicotine = preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine');
const showWeed = preferences.substance === 'weed' || usageData.some(e => e.substance === 'weed');
if (!showNicotine && !showWeed) return null;
return (
<Card className="backdrop-blur-2xl shadow-2xl border-white/10 overflow-hidden bg-white/5">
<CardHeader className="pb-1 pt-6 px-4 sm:px-6">
@ -295,11 +280,8 @@ export function UnifiedQuitPlanCard({
</CardTitle>
</CardHeader>
<CardContent className="pt-2 p-2 sm:p-4">
{showNicotine && (
<SubstancePlanSection
substance="nicotine"
isExpanded={expandedSubstance === 'nicotine'}
onToggle={() => setExpandedSubstance(expandedSubstance === 'nicotine' ? 'none' : 'nicotine')}
plan={preferences.quitState?.nicotine?.plan || (preferences.substance === 'nicotine' ? preferences.quitPlan : null)}
usageData={usageData}
trackingStartDate={
@ -310,13 +292,9 @@ export function UnifiedQuitPlanCard({
}
onGeneratePlan={() => onGeneratePlan('nicotine')}
/>
)}
{showWeed && (
<SubstancePlanSection
substance="weed"
isExpanded={expandedSubstance === 'weed'}
onToggle={() => setExpandedSubstance(expandedSubstance === 'weed' ? 'none' : 'weed')}
plan={preferences.quitState?.weed?.plan || (preferences.substance === 'weed' ? preferences.quitPlan : null)}
usageData={usageData}
trackingStartDate={
@ -327,7 +305,6 @@ export function UnifiedQuitPlanCard({
}
onGeneratePlan={() => onGeneratePlan('weed')}
/>
)}
</CardContent>
</Card>
);

View File

@ -16,7 +16,6 @@ import { UsageEntry, UserPreferences, setUsageForDateAsync, clearDayDataAsync }
import { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf, Sparkles } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import { getLocalDateString, getTodayString } from '@/lib/date-utils';
import { DailyInspirationCard } from './DailyInspirationCard';
import { cn } from '@/lib/utils';
import React from 'react';
@ -25,13 +24,11 @@ interface UsageCalendarProps {
usageData: UsageEntry[];
onDataUpdate: () => void;
userId: string;
religion?: 'christian' | 'secular' | null;
onReligionUpdate?: (religion: 'christian' | 'secular') => void;
preferences?: UserPreferences | null;
onPreferencesUpdate?: (prefs: UserPreferences) => Promise<void>;
}
function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) {
function UsageCalendarComponent({ usageData, onDataUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [editNicotineCount, setEditNicotineCount] = useState('');
const [editWeedCount, setEditWeedCount] = useState('');
@ -258,9 +255,9 @@ function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionU
</div>
</div>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-8 items-center lg:items-stretch justify-center max-w-6xl mx-auto">
{/* Calendar - Give it proper width on desktop */}
<div className="w-full lg:w-1/2 flex flex-col items-center">
<div className="max-w-4xl mx-auto">
{/* Calendar */}
<div className="w-full flex flex-col items-center">
<div className={cn(
"rounded-2xl p-2 sm:p-4 border shadow-inner transition-all duration-500 w-full",
theme === 'light' ? "bg-slate-50/50 border-slate-200/60" : "bg-black/20 border-white/5"
@ -302,17 +299,6 @@ function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionU
/>
</div>
</div>
{/* Desktop Vertical Divider */}
<div className="hidden lg:block w-px self-stretch bg-gradient-to-b from-transparent via-white/10 to-transparent" />
{/* Daily Inspiration - Matching width on desktop */}
<div className="w-full lg:w-1/2 flex flex-col justify-center">
<DailyInspirationCard
initialReligion={religion}
onReligionChange={onReligionUpdate}
/>
</div>
</div>
</CardContent>

View File

@ -25,13 +25,19 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { User } from '@/lib/session';
import { fetchPreferences, fetchReminderSettings, saveReminderSettings, ReminderSettings, UserPreferences } from '@/lib/storage';
import {
fetchPreferences,
fetchReminderSettings,
saveReminderSettings,
ReminderSettings,
UserPreferences,
defaultReminderSettings,
} from '@/lib/storage';
import { useNotifications } from '@/hooks/useNotifications';
import { useRef, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon, Bell, BellOff, BellRing, Menu, Sparkles, Link as LinkIcon } from 'lucide-react';
import { Bell, BellOff, BellRing, Menu, Sparkles } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import { InstallAppButton } from './InstallAppButton';
import { cn } from '@/lib/utils';
import { SideMenu } from './SideMenu';
@ -131,7 +137,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
const [localTime, setLocalTime] = useState('09:00');
const [localFrequency, setLocalFrequency] = useState<'daily' | 'hourly'>('daily');
const router = useRouter();
const { theme, toggleTheme } = useTheme();
const { theme } = useTheme();
const { isSupported, permission, requestPermission } = useNotifications(reminderSettings);
const videoRef = useRef<HTMLVideoElement>(null);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
@ -222,6 +228,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
useEffect(() => {
const loadData = async () => {
try {
const [prefs, reminders] = await Promise.all([
preferences ? Promise.resolve(preferences) : fetchPreferences(),
fetchReminderSettings(),
@ -242,6 +249,12 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
setReminderSettings(settingsToUse);
setLocalTime(settingsToUse.reminderTime);
setLocalFrequency(settingsToUse.frequency || 'daily');
} catch (error) {
console.error('Failed to load header data:', error);
setReminderSettings(defaultReminderSettings);
setLocalTime(defaultReminderSettings.reminderTime);
setLocalFrequency(defaultReminderSettings.frequency);
}
};
loadData();
}, [preferences]);
@ -379,27 +392,9 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
</div>
<div className="container mx-auto px-4 h-16 sm:h-20 flex items-center justify-between relative z-50">
{/* LEFT: User Profile / Side Menu Trigger */}
<div className="flex-1 flex justify-start">
<button
onClick={() => setIsSideMenuOpen(true)}
className="group relative flex items-center gap-2 p-1.5 pr-3 rounded-full transition-all hover:bg-white/10 active:scale-95 border border-transparent hover:border-white/10"
>
<div className="relative">
<Avatar className="h-9 w-9 sm:h-10 sm:w-10 ring-2 ring-white/20 transition-all group-hover:ring-white/50 shadow-sm">
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
<AvatarFallback className="bg-gradient-to-br from-indigo-500 to-purple-500 text-white font-bold">{initials}</AvatarFallback>
</Avatar>
<div className="absolute -bottom-1 -right-1 bg-slate-900 rounded-full p-0.5 shadow-sm border border-white/10">
<Menu className="h-3 w-3 text-white" />
</div>
</div>
<div className="hidden sm:block text-left">
<div className="text-[10px] font-bold uppercase tracking-widest opacity-50 text-white leading-none">Menu</div>
</div>
</button>
</div>
<div className="container mx-auto px-4 h-16 sm:h-20 relative z-50 grid grid-cols-[1fr_auto_1fr] items-center">
{/* LEFT: Spacer for balanced centered title */}
<div className="flex-1" />
{/* CENTER: Title and Welcome Message */}
<div className="flex-[2] flex flex-col items-center justify-center text-center">
@ -422,33 +417,20 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
)}
</div>
{/* RIGHT: Action Buttons */}
<div className="flex-1 flex items-center justify-end gap-1.5 sm:gap-3">
{/* RIGHT: User Profile / Side Menu Trigger */}
<div className="flex items-center justify-end justify-self-end">
<button
onClick={() => setShowReminderDialog(true)}
className={cn(
"p-2 sm:p-2.5 rounded-full transition-all duration-300 active:scale-90 shadow-sm",
reminderSettings.enabled
? 'bg-indigo-500/15 text-indigo-400 border border-indigo-500/20'
: 'bg-white/5 border border-transparent text-white/50 hover:bg-white/10'
)}
onClick={() => setIsSideMenuOpen(true)}
className="group relative flex h-10 w-10 sm:h-11 sm:w-11 items-center justify-center rounded-full transition-all hover:bg-white/10 active:scale-95 border border-white/15 hover:border-white/30"
aria-label="Open profile menu"
>
{reminderSettings.enabled ? (
<BellRing className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
) : (
<Bell className="h-4.5 w-4.5 sm:h-5 sm:w-5" />
)}
</button>
<InstallAppButton />
<button
onClick={toggleTheme}
className="p-2 sm:p-2.5 rounded-full bg-white/5 border border-transparent hover:bg-white/10 transition-all active:scale-90"
>
{theme === 'dark' ? (
<Moon className="h-4.5 w-4.5 sm:h-5 sm:w-5 text-blue-300" />
) : (
<Sun className="h-4.5 w-4.5 sm:h-5 sm:w-5 text-yellow-500" />
)}
<Avatar className="h-8 w-8 sm:h-9 sm:w-9 ring-2 ring-white/20 transition-all group-hover:ring-white/50 shadow-sm">
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
<AvatarFallback className="bg-gradient-to-br from-indigo-500 to-purple-500 text-white font-bold text-xs sm:text-sm">{initials}</AvatarFallback>
</Avatar>
<div className="absolute -bottom-1 -right-1 rounded-full p-0.5 shadow-sm border border-white/10 bg-slate-900/90">
<Menu className="h-3 w-3 text-white" />
</div>
</button>
</div>
</div>
@ -460,6 +442,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
onClose={() => setIsSideMenuOpen(false)}
user={user}
userName={userName}
onOpenNotifications={() => setShowReminderDialog(true)}
/>
{/* Reminder Settings Dialog */}
@ -606,9 +589,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
<HourlyTimePicker
value={reminderSettings.hourlyStart || '09:00'}
onChange={async (newTime) => {
const [h, m] = newTime.split(':');
const end = (reminderSettings.hourlyEnd || '21:00').split(':');
const newSettings = { ...reminderSettings, hourlyStart: newTime, hourlyEnd: `${end[0]}:${m}` };
const newSettings = { ...reminderSettings, hourlyStart: newTime };
setReminderSettings(newSettings);
await saveReminderSettings(newSettings);
}}
@ -620,9 +601,7 @@ export function UserHeader({ user, preferences, onModalStateChange }: UserHeader
<HourlyTimePicker
value={reminderSettings.hourlyEnd || '21:00'}
onChange={async (newTime) => {
const [h, m] = newTime.split(':');
const start = (reminderSettings.hourlyStart || '09:00').split(':');
const newSettings = { ...reminderSettings, hourlyEnd: newTime, hourlyStart: `${start[0]}:${m}` };
const newSettings = { ...reminderSettings, hourlyEnd: newTime };
setReminderSettings(newSettings);
await saveReminderSettings(newSettings);
}}

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { useState, useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react';
import { Cigarette, Leaf, Heart, Trophy, DollarSign, TrendingDown, CheckCircle, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
@ -169,6 +169,8 @@ const DEMO_SCREENS: DemoScreen[] = [
// Phone mockup component
function PhoneMockup({ activeScreen }: { activeScreen: number }) {
const active = DEMO_SCREENS[activeScreen];
return (
<div className="relative">
{/* Phone frame glow */}
@ -183,25 +185,19 @@ function PhoneMockup({ activeScreen }: { activeScreen: number }) {
<div className="absolute inset-0 p-4 pt-10">
{/* Screen header */}
<div className="text-center mb-4">
<h3 className="text-lg font-bold">{DEMO_SCREENS[activeScreen].title}</h3>
<h3 className="text-lg font-bold">{active.title}</h3>
<p className="text-xs text-muted-foreground">
{DEMO_SCREENS[activeScreen].subtitle}
{active.subtitle}
</p>
</div>
{/* Crossfade content */}
<div className="relative h-[calc(100%-5rem)]">
{DEMO_SCREENS.map((screen, index) => (
<div
key={screen.id}
className={cn(
"absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none",
activeScreen === index ? "opacity-100 z-10" : "opacity-0 z-0"
)}
id="demo-active-panel"
role="tabpanel"
aria-labelledby={`demo-tab-${active.id}`}
className="h-[calc(100%-5rem)] transition-opacity duration-300 motion-reduce:transition-none"
>
{screen.content}
</div>
))}
{active.content}
</div>
</div>
@ -222,7 +218,7 @@ function FeatureCard({ screen, isActive }: { screen: DemoScreen; isActive: boole
"p-6 sm:p-8 rounded-2xl transition-all duration-500 motion-reduce:transition-none",
isActive
? "bg-primary/10 border border-primary/30 scale-100 opacity-100"
: "bg-white/5 border border-white/10 scale-[0.98] opacity-60"
: "bg-card/60 border border-border scale-[0.98] opacity-70"
)}
>
<div className="flex items-start gap-4">
@ -247,19 +243,48 @@ export function DemoSection() {
const [activeScreen, setActiveScreen] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const sectionRefs = useRef<(HTMLDivElement | null)[]>([]);
const resumeTimerRef = useRef<number | null>(null);
// Timer-based rotation for mobile
const handleManualSelect = (index: number) => {
setActiveScreen(index);
setIsPaused(true);
if (resumeTimerRef.current) {
window.clearTimeout(resumeTimerRef.current);
}
resumeTimerRef.current = window.setTimeout(() => {
setIsPaused(false);
}, 8000);
};
// Timer-based rotation for mobile only
useEffect(() => {
if (isPaused) return;
// Only run timer on mobile (will be hidden on lg+)
const mobileMedia = window.matchMedia('(max-width: 1023px)');
const reducedMotionMedia = window.matchMedia('(prefers-reduced-motion: reduce)');
if (!mobileMedia.matches || reducedMotionMedia.matches) {
return;
}
const interval = setInterval(() => {
if (document.hidden) return;
setActiveScreen((prev) => (prev + 1) % DEMO_SCREENS.length);
}, 4000);
return () => clearInterval(interval);
}, [isPaused]);
useEffect(() => {
return () => {
if (resumeTimerRef.current) {
window.clearTimeout(resumeTimerRef.current);
}
};
}, []);
// Intersection Observer for desktop scroll spy
useEffect(() => {
const observers: IntersectionObserver[] = [];
@ -271,6 +296,7 @@ export function DemoSection() {
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
if (!window.matchMedia('(min-width: 1024px)').matches) return;
setActiveScreen(index);
}
});
@ -317,6 +343,7 @@ export function DemoSection() {
<p className="text-muted-foreground max-w-2xl mx-auto">
A quick look at how QuitTraq helps you track progress and stay motivated.
</p>
<p className="text-xs text-muted-foreground/80 mt-3">Preview uses sample data for demonstration.</p>
</div>
{/* Desktop: Split Sticky Scroll Layout */}
@ -333,9 +360,9 @@ export function DemoSection() {
key={screen.id}
ref={(el) => { sectionRefs.current[index] = el; }}
className={cn(
"min-h-[70vh] flex items-center py-8",
"min-h-[52vh] flex items-center py-6",
index === 0 && "pt-0",
index === DEMO_SCREENS.length - 1 && "min-h-[50vh]"
index === DEMO_SCREENS.length - 1 && "min-h-[40vh]"
)}
>
<FeatureCard screen={screen} isActive={activeScreen === index} />
@ -362,15 +389,29 @@ export function DemoSection() {
<div className="w-full max-w-md text-center">
<div className="space-y-4">
{/* Screen descriptions as buttons */}
<div role="tablist" aria-label="QuitTraq demo screens" className="space-y-4">
{DEMO_SCREENS.map((screen, index) => (
<button
key={screen.id}
onClick={() => setActiveScreen(index)}
id={`demo-tab-${screen.id}`}
role="tab"
type="button"
aria-selected={activeScreen === index}
aria-controls="demo-active-panel"
tabIndex={activeScreen === index ? 0 : -1}
onClick={() => handleManualSelect(index)}
onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
const nextIndex = (index + direction + DEMO_SCREENS.length) % DEMO_SCREENS.length;
handleManualSelect(nextIndex);
}}
className={cn(
"w-full text-left p-4 rounded-xl transition-all duration-300",
activeScreen === index
? "bg-primary/10 border border-primary/30"
: "hover:bg-white/5 border border-transparent"
: "hover:bg-card/60 border border-transparent"
)}
>
<h4
@ -385,18 +426,20 @@ export function DemoSection() {
</button>
))}
</div>
</div>
{/* Dot indicators */}
<div className="flex justify-center gap-2 mt-6">
{DEMO_SCREENS.map((_, index) => (
<button
key={index}
onClick={() => setActiveScreen(index)}
type="button"
onClick={() => handleManualSelect(index)}
className={cn(
"w-2 h-2 rounded-full transition-all",
activeScreen === index
? "w-6 bg-primary"
: "bg-white/20 hover:bg-white/40"
: "bg-muted-foreground/30 hover:bg-muted-foreground/60"
)}
aria-label={`Go to screen ${index + 1}`}
/>

View File

@ -52,9 +52,6 @@ export function FeatureCard({ title, description, icons, gradient, delay = 0 }:
background: `linear-gradient(135deg, ${gradient})`,
}}
>
{/* Decorative gradient orb */}
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-white/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none group-hover:scale-110 transition-transform duration-500" />
<CardHeader className="relative z-10">
<div className="flex items-center gap-2 mb-3">
{icons.map((icon, index) => (

View File

@ -221,6 +221,17 @@ export async function getAchievementD1(userId: string, badgeId: string, substanc
return result;
}
export async function getAchievementByBadgeD1(userId: string, badgeId: string): Promise<AchievementRow | null> {
const db = getD1();
if (!db) return null;
const result = await db.prepare(
'SELECT * FROM Achievement WHERE userId = ? AND badgeId = ? ORDER BY CASE WHEN substance = ? THEN 0 ELSE 1 END, unlockedAt DESC LIMIT 1'
).bind(userId, badgeId, 'both').first<AchievementRow>();
return result;
}
export async function createAchievementD1(userId: string, badgeId: string, substance: string): Promise<AchievementRow | null> {
const db = getD1();
if (!db) return null;
@ -232,7 +243,7 @@ export async function createAchievementD1(userId: string, badgeId: string, subst
const now = new Date().toISOString();
await db.prepare(
`INSERT INTO Achievement (id, userId, badgeId, unlockedAt, substance)
`INSERT OR IGNORE INTO Achievement (id, userId, badgeId, unlockedAt, substance)
VALUES (?, ?, ?, ?, ?)`
).bind(id, userId, badgeId, now, substance).run();

View File

@ -128,6 +128,22 @@ const defaultPreferences: UserPreferences = {
religion: null,
};
export const defaultReminderSettings: ReminderSettings = {
enabled: false,
reminderTime: '09:00',
frequency: 'daily',
};
export class StorageRequestError extends Error {
status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'StorageRequestError';
this.status = status;
}
}
// Cache for preferences and usage data to avoid excessive API calls
let preferencesCache: UserPreferences | null = null;
let usageDataCache: UsageEntry[] | null = null;
@ -160,15 +176,17 @@ export async function fetchPreferences(): Promise<UserPreferences> {
try {
const response = await fetch('/api/preferences', { cache: 'no-store' });
if (!response.ok) {
console.error('Failed to fetch preferences');
return defaultPreferences;
throw new StorageRequestError('Unable to load profile preferences.', response.status);
}
const data = await response.json() as UserPreferences;
preferencesCache = data;
return data;
} catch (error) {
console.error('Error fetching preferences:', error);
return defaultPreferences;
if (error instanceof StorageRequestError) {
throw error;
}
throw new StorageRequestError('Unable to load profile preferences.');
}
}
@ -195,15 +213,17 @@ export async function fetchUsageData(): Promise<UsageEntry[]> {
try {
const response = await fetch('/api/usage', { cache: 'no-store' });
if (!response.ok) {
console.error('Failed to fetch usage data');
return [];
throw new StorageRequestError('Unable to load usage history.', response.status);
}
const data = await response.json() as UsageEntry[];
usageDataCache = data;
return data;
} catch (error) {
console.error('Error fetching usage data:', error);
return [];
if (error instanceof StorageRequestError) {
throw error;
}
throw new StorageRequestError('Unable to load usage history.');
}
}
@ -306,13 +326,18 @@ export async function fetchReminderSettings(): Promise<ReminderSettings> {
if (reminderSettingsCache) return reminderSettingsCache;
try {
const response = await fetch('/api/reminders');
if (!response.ok) return { enabled: false, reminderTime: '09:00', frequency: 'daily' };
if (!response.ok) {
throw new StorageRequestError('Unable to load reminder settings.', response.status);
}
const data = await response.json() as ReminderSettings;
reminderSettingsCache = data;
return data;
} catch (error) {
console.error('Error fetching reminder settings:', error);
return { enabled: false, reminderTime: '09:00', frequency: 'daily' };
if (error instanceof StorageRequestError) {
throw error;
}
throw new StorageRequestError('Unable to load reminder settings.');
}
}
@ -332,7 +357,7 @@ export async function saveReminderSettings(settings: ReminderSettings): Promise<
}
export function getReminderSettings(): ReminderSettings {
return reminderSettingsCache || { enabled: false, reminderTime: '09:00', frequency: 'daily' };
return reminderSettingsCache || defaultReminderSettings;
}
// ============ SAVINGS FUNCTIONS ============
@ -489,6 +514,10 @@ export function checkBadgeEligibility(
preferences: UserPreferences,
substance: 'nicotine' | 'weed'
): boolean {
const hasLoggedUsageForSubstance = usageData.some(
(entry) => entry.substance === substance && entry.count > 0
);
// Pre-calculate common stats once O(n)
const stats = (() => {
const nicotineMap = new Map<string, number>();
@ -576,7 +605,7 @@ export function checkBadgeEligibility(
};
switch (badgeId) {
case 'first_day': return stats.totalDays >= 1;
case 'first_day': return hasLoggedUsageForSubstance;
case 'streak_3': return streak >= 3;
case 'streak_7': return stats.totalDays >= 7;
case 'fighter':