30 Commits

Author SHA1 Message Date
67cb4a68be fix(deps): update dependency postcss-preset-env to v11
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (pull_request) Successful in 30s
Lint / Lint and Check (push) Successful in 30s
2026-01-17 08:03:19 +00:00
0a4535f3bc chore(deps): update dependency @tanstack/react-query to v5.90.17
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2026-01-17 07:03:05 +00:00
4134770b7f chore(deps): update dependency typescript-eslint to v8.53.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2026-01-17 04:04:19 +00:00
a4128436c1 chore(deps): update dependency postcss-preset-env to v10.6.1
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2026-01-17 03:04:30 +00:00
9508953a95 chore(deps): update dependency @types/node to v24.10.8
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2026-01-17 02:05:17 +00:00
c5537c5d9a chore(deps): update node.js to 931d7d5
All checks were successful
Lint / Lint and Check (push) Successful in 45s
2026-01-17 01:05:06 +00:00
9e32f86805 chore(deps): update actions/setup-node digest to 6044e13
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2026-01-17 00:04:07 +00:00
badc3a31e4 fix(deps): update nextjs monorepo to v16.1.2
Some checks are pending
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Check (pull_request) Successful in 34s
Lint / Lint and Check (push) Successful in 32s
2026-01-15 07:28:41 +00:00
94398e621b chore(deps): update pnpm to v10.28.0
All checks were successful
Lint / Lint and Check (push) Successful in 33s
2026-01-10 18:31:25 +01:00
869b2f3c46 chore(deps): update dependency @types/react to v19.2.8
Some checks failed
Lint / Lint and Check (push) Has been cancelled
2026-01-10 18:30:19 +01:00
d7edf19db4 chore(deps): update dependency @types/node to v24.10.6
Some checks failed
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Check (pull_request) Successful in 32s
Lint / Lint and Check (push) Has been cancelled
2026-01-10 17:03:55 +00:00
9c76ae2d73 chore(deps): update dependency typescript-eslint to v8.52.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2026-01-10 00:03:53 +00:00
ec40f342af remove turbo
All checks were successful
Lint / Lint and Check (push) Successful in 33s
2026-01-06 16:02:48 +01:00
13cafd6c64 chore(deps): update pnpm to v10.27.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 34s
2026-01-03 03:02:30 +00:00
4b3fd2b61d chore(deps): update dependency postcss-preset-env to v10.6.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 32s
2026-01-03 02:03:24 +00:00
bce33c9004 chore(deps): update dependency typescript-eslint to v8.51.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 31s
2026-01-03 01:04:13 +00:00
f14403a2f5 chore(deps): update dependency @tanstack/react-query to v5.90.16
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2026-01-03 00:03:27 +00:00
8ac8f70932 fix(deps): update nextjs monorepo to v16.1.1
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 35s
2025-12-27 03:03:53 +00:00
3099a1a4a0 chore(deps): update pnpm to v10.26.2
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 29s
2025-12-27 02:03:40 +00:00
e34a8f5140 chore(deps): update dependency typescript-eslint to v8.50.1
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 31s
2025-12-27 01:05:32 +00:00
7c1f0da953 chore(deps): update dependency turbo to v2.7.2
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 31s
2025-12-27 00:04:01 +00:00
c329ecdb31 chore(deps): update dependency turbo to v2.7.1
Some checks are pending
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Check (pull_request) Successful in 36s
Lint / Lint and Check (push) Successful in 30s
2025-12-21 08:47:25 +00:00
edf2ba0cb7 fix(deps): update nextjs monorepo to v16.1.0
All checks were successful
Lint / Lint and Check (push) Successful in 33s
2025-12-20 13:57:09 +01:00
2822690070 fix(deps): update dependency lucide-react to ^0.562.0
Some checks failed
Lint / Lint and Check (push) Has been cancelled
2025-12-20 13:55:49 +01:00
2eb30725b1 chore(deps): update dependency turbo to v2.7.0
Some checks failed
Lint / Lint and Check (push) Has been cancelled
2025-12-20 13:54:32 +01:00
6907520913 chore(deps): update pnpm to v10.26.1
Some checks failed
Lint / Lint and Check (pull_request) Successful in 37s
Lint / Lint and Check (push) Has been cancelled
renovate/stability-days Updates have not met minimum release age requirement
2025-12-20 12:16:13 +00:00
83d269b432 chore(deps): update pnpm to v10.26.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 31s
2025-12-20 03:04:37 +00:00
db12e07ab8 chore(deps): update dependency typescript-eslint to v8.50.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2025-12-20 02:05:00 +00:00
f66a70111d chore(deps): update dependency @types/node to v24.10.4
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 32s
2025-12-20 01:05:30 +00:00
9bc1d05bb1 chore(deps): update node.js to c921b97
All checks were successful
Lint / Lint and Check (push) Successful in 32s
2025-12-20 00:04:45 +00:00
16 changed files with 1018 additions and 2855 deletions

View File

@@ -1,20 +0,0 @@
# Project Rules
## Testing Policy
- **Mandatory Tests**: All new features and bug fixes must be accompanied by tests.
- **Unit/Integration Tests**: Use **Vitest** for testing utilities, hooks, and components.
- **E2E Tests**: Use **Playwright** for critical user flows (auth, core features).
- **Coverage**: Aim for high coverage on business logic and critical paths.
## Tech Stack
- **Framework**: Next.js 15 (App Router)
- **Language**: TypeScript
- **Styling**: Tailwind CSS
- **Database**: PostgreSQL with Drizzle ORM
- **State Management**: React Query
## Code Style
- **Functional Components**: Use arrow functions for components.
- **Types**: strict TypeScript usage (avoid `any`).
- **Imports**: Use absolute imports (`@/...`).

View File

@@ -19,7 +19,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with: with:
node-version: 24 node-version: 24
cache: 'pnpm' cache: 'pnpm'

4
.gitignore vendored
View File

@@ -36,7 +36,3 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
.turbo/ .turbo/
playwright-report/
test-results/

View File

@@ -1,6 +1,6 @@
# syntax=docker.io/docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6 # syntax=docker.io/docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
FROM node:24-alpine@sha256:7e0bd0460b26eb3854ea5b99b887a6a14d665d14cae694b78ae2936d14b2befb AS base FROM node:24-alpine@sha256:931d7d57f8c1fd0e2179dbff7cc7da4c9dd100998bc2b32afc85142d8efbc213 AS base
# Install dependencies only when needed # Install dependencies only when needed
FROM base AS deps FROM base AS deps

View File

@@ -4,79 +4,6 @@ https://trackevery.day/
A simple, privacy-focused habit tracking web app. Track anything, every day. A simple, privacy-focused habit tracking web app. Track anything, every day.
## 🎯 Vision & Goal
**Goal**: To provide the most frictionless, privacy-respecting tool for users to build consistency in their lives without the barrier of complex sign-ups or data tracking concerns.
**Vision**: A world where self-improvement is accessible to everyone without trading their privacy for it. `trackevery-day` aims to become the standard for "unaccounted" personal tracking, eventually expanding into a broader minimalist "life logger" platform.
## 💼 Business Model
This project operates on a sustainable Open Source model:
1. **Core Product (Free & Open Source)**: The full application is available for free. Users can self-host or use the public instance.
2. **Supporter Tier (Future)**: Optional premium features for power users who want to support development:
- Advanced Data Analysis & Trends
- Encrypted Cloud Backups
- API Access for integrations
3. **Donations**: Community support via GitHub Sponsors / Ko-fi to cover hosting costs.
---
## 🗺️ Roadmap & Tasks
We are building this out in phases. Below is the breakdown of problems into small, actionable tasks.
### Phase 1: Core Refinement (Current Focus)
_Goal: Polish the existing functionality to be feature-complete._
- [ ] **Habit Management**
- [ ] Add "Edit Habit" functionality (rename, change type/color).
- [ ] Add "Delete/Archive Habit" functionality (UI implementation).
- [ ] Implement "Undo Log" (remove accidental logs).
- [ ] **Visualization**
- [ ] Add a "Contribution Graph" (GitHub style) heatmap for each habit.
- [ ] Add a simple line chart for "Frequency over Time".
- [ ] **UX Improvements**
- [ ] specific mobile-responsive tweaks for the dashboard grid.
- [ ] Add a "Settings" page to manage the token (regenerate, view).
### Phase 2: Data Sovereignty
_Goal: Ensure users truly own their data._
- [ ] **Export/Import**
- [ ] Create JSON export handler.
- [ ] Create CSV export handler (for spreadsheet analysis).
- [ ] Build a "Restore from Backup" feature (JSON import).
- [ ] **Local-First Enhancements**
- [ ] Cache habit data in `localStorage` for faster load times.
- [ ] Implement offline queuing for logs when network is unavailable.
### Phase 3: Engagement & Growth
_Goal: Help users stay consistent._
- [ ] **PWA Implementation**
- [ ] Add `manifest.json` and service workers.
- [ ] Enable "Add to Home Screen" prompt.
- [ ] **Gamification (Subtle)**
- [ ] Visual rewards for hitting streaks (confetti, badges).
- [ ] "Levels" based on total consistency score.
- [ ] **Notifications**
- [ ] Browser-based push notifications for reminders (optional).
### Phase 4: Advanced Features (Supporter Tier)
_Goal: Power features for data nerds._
- [ ] **Public Profile** (Optional public shareable link for specific habits).
- [ ] **API Access** (Generate API keys to log via curl/scripts).
- [ ] **Webhooks** (Trigger events when a habit is logged).
---
## ✨ Features ## ✨ Features
- **Token-based authentication** - No email or password required - **Token-based authentication** - No email or password required

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, habits, users, habitLogs } from '@/lib/db'; import { db, habits, users, habitLogs } from '@/lib/db';
import { getTokenCookie } from '@/lib/auth/cookies'; import { getTokenCookie } from '@/lib/auth/cookies';
import { eq, and, desc } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
async function getUserFromToken() { async function getUserFromToken() {
const token = await getTokenCookie(); const token = await getTokenCookie();
@@ -58,49 +58,3 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
} }
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const habitId = parseInt(id);
if (isNaN(habitId)) {
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
}
const user = await getUserFromToken();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify habit belongs to user
const habitRows = await db
.select()
.from(habits)
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
if (habitRows.length === 0) {
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
}
// Find latest log
const latestLog = await db
.select()
.from(habitLogs)
.where(eq(habitLogs.habitId, habitId))
.orderBy(desc(habitLogs.loggedAt))
.limit(1);
if (latestLog.length === 0) {
return NextResponse.json({ error: 'No logs to undo' }, { status: 404 });
}
// Delete latest log
await db.delete(habitLogs).where(eq(habitLogs.id, latestLog[0].id));
return NextResponse.json({ success: true });
} catch (error) {
console.error('Undo log error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,117 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, habits, users } from '@/lib/db';
import { getTokenCookie } from '@/lib/auth/cookies';
import { eq, and } from 'drizzle-orm';
async function getUserFromToken() {
const token = await getTokenCookie();
if (!token) return null;
const userRows = await db.select().from(users).where(eq(users.token, token));
return userRows.length > 0 ? userRows[0] : null;
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const habitId = parseInt(id);
if (isNaN(habitId)) {
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
}
const user = await getUserFromToken();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify habit belongs to user
const habitRows = await db
.select()
.from(habits)
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
if (habitRows.length === 0) {
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
}
const body = (await request.json()) as {
name?: string;
type?: string;
color?: string;
icon?: string;
targetFrequency?: { value: number; period: 'day' | 'week' | 'month' };
};
const { name, type, color, icon, targetFrequency } = body;
// Validate type if provided
if (type && !['positive', 'neutral', 'negative'].includes(type)) {
return NextResponse.json(
{ error: 'Type must be one of: positive, neutral, negative' },
{ status: 400 }
);
}
const updatedHabitRows = await db
.update(habits)
.set({
...(name && { name }),
...(type && { type: type as 'positive' | 'neutral' | 'negative' }),
...(color && { color }),
...(icon && { icon }),
...(targetFrequency && { targetFrequency }),
})
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)))
.returning();
return NextResponse.json({ habit: updatedHabitRows[0] });
} catch (error) {
console.error('Update habit error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const habitId = parseInt(id);
if (isNaN(habitId)) {
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
}
const user = await getUserFromToken();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify habit belongs to user
const habitRows = await db
.select()
.from(habits)
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
if (habitRows.length === 0) {
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
}
// Soft delete (archive)
await db
.update(habits)
.set({ isArchived: true, archivedAt: new Date() })
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete habit error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -33,21 +33,25 @@ export async function GET() {
// Then get aggregated log data for each habit // Then get aggregated log data for each habit
const habitsWithStats = await Promise.all( const habitsWithStats = await Promise.all(
userHabitsBase.map(async (habit) => { userHabitsBase.map(async (habit) => {
// Get all logs for this habit, ordered by date desc // Get all logs for this habit
const logs = await db const logs = await db
.select({ .select({
id: habitLogs.id,
loggedAt: habitLogs.loggedAt, loggedAt: habitLogs.loggedAt,
}) })
.from(habitLogs) .from(habitLogs)
.where(eq(habitLogs.habitId, habit.id)) .where(eq(habitLogs.habitId, habit.id));
.orderBy(desc(habitLogs.loggedAt));
// Calculate statistics // Calculate statistics
const totalLogs = logs.length; const totalLogs = logs.length;
const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length; const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length;
const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length; const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length;
const lastLoggedAt = logs.length > 0 ? logs[0].loggedAt : null; const lastLoggedAt =
logs.length > 0
? logs.reduce(
(latest, log) => (log.loggedAt > latest ? log.loggedAt : latest),
logs[0].loggedAt,
)
: null;
return { return {
id: habit.id, id: habit.id,

View File

@@ -1,128 +0,0 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Dashboard from './page';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}));
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
observe(): void {
/* noop */
}
unobserve(): void {
/* noop */
}
disconnect(): void {
/* noop */
}
};
// Mock fetch
global.fetch = vi.fn();
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
describe('Dashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock Auth Response
vi.mocked(global.fetch).mockImplementation((url: string | URL | Request) => {
if (url === '/api/auth') {
return Promise.resolve({
json: () => Promise.resolve({ authenticated: true, token: 'test-token' }),
} as Response);
}
if (url === '/api/habits') {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
habits: [
{
id: 1,
name: 'Test Habit',
type: 'neutral',
totalLogs: 5,
logsLastWeek: 2,
logsLastMonth: 5,
createdAt: new Date().toISOString(),
lastLoggedAt: new Date().toISOString(),
},
],
}),
} as Response);
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
});
});
it('renders habits correctly', async () => {
render(
<QueryClientProvider client={createTestQueryClient()}>
<Dashboard />
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByText('Test Habit')).toBeInTheDocument();
});
});
it('opens edit dialog when edit button is clicked', async () => {
render(
<QueryClientProvider client={createTestQueryClient()}>
<Dashboard />
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByText('Test Habit')).toBeInTheDocument();
});
const editBtn = screen.getByTestId('edit-habit-1');
fireEvent.click(editBtn);
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Edit Habit')).toBeInTheDocument();
expect(screen.getByDisplayValue('Test Habit')).toBeInTheDocument();
});
});
it('opens archive confirmation when archive button is clicked', async () => {
render(
<QueryClientProvider client={createTestQueryClient()}>
<Dashboard />
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByText('Test Habit')).toBeInTheDocument();
});
// Open Edit Dialog
fireEvent.click(screen.getByTestId('edit-habit-1'));
await waitFor(() => screen.getByRole('dialog'));
// Click Archive
fireEvent.click(screen.getByText('Archive Habit'));
await waitFor(() => {
expect(screen.getByText('Confirm Delete')).toBeInTheDocument();
});
});
});

View File

@@ -16,10 +16,6 @@ import {
Check, Check,
Trophy, Trophy,
HeartCrack, HeartCrack,
MoreVertical,
Trash2,
Save,
RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -77,17 +73,9 @@ interface HabitResponse {
export default function Dashboard() { export default function Dashboard() {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// State
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false); const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
const [newHabitName, setNewHabitName] = useState(''); const [newHabitName, setNewHabitName] = useState('');
const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral'); const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
const [editingHabit, setEditingHabit] = useState<Habit | null>(null);
const [editHabitName, setEditHabitName] = useState('');
const [editHabitType, setEditHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [copiedToken, setCopiedToken] = useState(false); const [copiedToken, setCopiedToken] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now()); const [currentTime, setCurrentTime] = useState(() => Date.now());
@@ -104,11 +92,11 @@ export default function Dashboard() {
}, },
}); });
// Update current time periodically // Update current time periodically to avoid impure Date.now() calls during render
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setCurrentTime(Date.now()); setCurrentTime(Date.now());
}, 60000); }, 60000); // Update every minute
return () => { return () => {
clearInterval(interval); clearInterval(interval);
}; };
@@ -141,19 +129,6 @@ export default function Dashboard() {
}, },
}); });
// Undo log mutation
const undoLogMutation = useMutation<unknown, Error, number>({
mutationFn: async (habitId: number): Promise<void> => {
const res = await fetch(`/api/habits/${String(habitId)}/log`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to undo log');
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['habits'] });
},
});
// Create habit mutation // Create habit mutation
const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({ const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({
mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => { mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => {
@@ -173,42 +148,6 @@ export default function Dashboard() {
}, },
}); });
// Update habit mutation
const updateHabitMutation = useMutation<
HabitResponse,
Error,
{ id: number; name: string; type: string }
>({
mutationFn: async ({ id, name, type }): Promise<HabitResponse> => {
const res = await fetch(`/api/habits/${String(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type }),
});
if (!res.ok) throw new Error('Failed to update habit');
return res.json() as Promise<HabitResponse>;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['habits'] });
setEditingHabit(null);
},
});
// Delete habit mutation
const deleteHabitMutation = useMutation<unknown, Error, number>({
mutationFn: async (id: number): Promise<void> => {
const res = await fetch(`/api/habits/${String(id)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete habit');
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['habits'] });
setEditingHabit(null);
setShowDeleteConfirm(false);
},
});
const handleCreateHabit = () => { const handleCreateHabit = () => {
if (newHabitName.trim()) { if (newHabitName.trim()) {
createHabitMutation.mutate({ createHabitMutation.mutate({
@@ -218,30 +157,6 @@ export default function Dashboard() {
} }
}; };
const handleUpdateHabit = () => {
if (editingHabit && editHabitName.trim()) {
updateHabitMutation.mutate({
id: editingHabit.id,
name: editHabitName.trim(),
type: editHabitType,
});
}
};
const handleDeleteHabit = () => {
if (editingHabit) {
deleteHabitMutation.mutate(editingHabit.id);
}
};
const openEditDialog = (e: React.MouseEvent, habit: Habit) => {
e.stopPropagation();
setEditingHabit(habit);
setEditHabitName(habit.name);
setEditHabitType(habit.type);
setShowDeleteConfirm(false);
};
const copyToken = () => { const copyToken = () => {
if (authData?.token) { if (authData?.token) {
void navigator.clipboard.writeText(authData.token); void navigator.clipboard.writeText(authData.token);
@@ -440,7 +355,7 @@ export default function Dashboard() {
{habits.map((habit: Habit) => ( {habits.map((habit: Habit) => (
<Card <Card
key={habit.id} key={habit.id}
className={`group relative transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass( className={`transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
habit.type, habit.type,
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`} )} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
onClick={() => { onClick={() => {
@@ -450,27 +365,13 @@ export default function Dashboard() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<CardTitle className="text-lg">{habit.name}</CardTitle> <CardTitle className="text-lg">{habit.name}</CardTitle>
<div className="flex items-center gap-2">
{getHabitIcon(habit.type)} {getHabitIcon(habit.type)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/20"
onClick={(e) => {
openEditDialog(e, habit);
}}
data-testid={`edit-habit-${String(habit.id)}`}
>
<MoreVertical className="h-4 w-4" />
</Button>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{/* Last logged */} {/* Last logged */}
<div className="flex items-center justify-between text-sm"> <div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-zinc-500" /> <Clock className="h-4 w-4 text-zinc-500" />
<span className="text-zinc-400"> <span className="text-zinc-400">
{habit.lastLoggedAt {habit.lastLoggedAt
@@ -478,29 +379,6 @@ export default function Dashboard() {
: 'Never logged'} : 'Never logged'}
</span> </span>
</div> </div>
{habit.lastLoggedAt && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-zinc-500 hover:bg-red-950/30 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
undoLogMutation.mutate(habit.id);
}}
>
<RotateCcw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Undo last log</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<Separator className="bg-zinc-800" /> <Separator className="bg-zinc-800" />
@@ -561,121 +439,6 @@ export default function Dashboard() {
</Card> </Card>
)} )}
</div> </div>
{/* Edit Habit Dialog */}
<Dialog
open={!!editingHabit}
onOpenChange={(open) => {
if (!open) setEditingHabit(null);
}}
>
<DialogContent className="border-zinc-800 bg-zinc-950">
<DialogHeader>
<DialogTitle>Edit Habit</DialogTitle>
<DialogDescription>
Modify your habit details or archive it if you no longer want to track it.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">Habit Name</Label>
<Input
id="edit-name"
value={editHabitName}
onChange={(e) => {
setEditHabitName(e.target.value);
}}
className="border-zinc-800 bg-zinc-900"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-type">Habit Type</Label>
<Select
value={editHabitType}
onValueChange={(value: 'positive' | 'neutral' | 'negative') => {
setEditHabitType(value);
}}
>
<SelectTrigger className="border-zinc-800 bg-zinc-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-zinc-800 bg-zinc-900">
<SelectItem value="positive">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-emerald-500" />
<span>Positive - Something to do more</span>
</div>
</SelectItem>
<SelectItem value="neutral">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-zinc-500" />
<span>Neutral - Just tracking</span>
</div>
</SelectItem>
<SelectItem value="negative">
<div className="flex items-center gap-2">
<TrendingDown className="h-4 w-4 text-red-500" />
<span>Negative - Something to reduce</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:justify-between">
<div className="flex flex-1 justify-start">
{showDeleteConfirm ? (
<div className="flex items-center gap-2">
<Button
variant="destructive"
onClick={handleDeleteHabit}
disabled={deleteHabitMutation.isPending}
>
{deleteHabitMutation.isPending ? 'Deleting...' : 'Confirm Delete'}
</Button>
<Button
variant="ghost"
onClick={() => {
setShowDeleteConfirm(false);
}}
>
Cancel
</Button>
</div>
) : (
<Button
variant="outline"
className="border-red-900 text-red-500 hover:bg-red-950 hover:text-red-400"
onClick={() => {
setShowDeleteConfirm(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Archive Habit
</Button>
)}
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setEditingHabit(null);
}}
>
Cancel
</Button>
<Button
onClick={handleUpdateHabit}
disabled={!editHabitName.trim() || updateHabitMutation.isPending}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Save className="mr-2 h-4 w-4" />
{updateHabitMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
); );

View File

@@ -8,9 +8,6 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next typegen && eslint . && npx tsc --noEmit", "lint": "next typegen && eslint . && npx tsc --noEmit",
"test": "vitest",
"test:e2e": "playwright test",
"test:coverage": "vitest run --coverage",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
@@ -32,48 +29,40 @@
"cssnano": "^7.1.2", "cssnano": "^7.1.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.0",
"lucide-react": "^0.561.0", "lucide-react": "^0.562.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next": "16.0.10", "next": "16.1.2",
"next-plausible": "^3.12.5", "next-plausible": "^3.12.5",
"pg": "^8.16.3", "pg": "^8.16.3",
"pg-native": "^3.5.2", "pg-native": "^3.5.2",
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-preset-env": "^10.4.0", "postcss-preset-env": "^11.0.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "4.1.18", "@tailwindcss/postcss": "4.1.18",
"@testing-library/dom": "^10.4.1", "@types/node": "24.10.8",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/node": "24.10.3",
"@types/pg": "8.16.0", "@types/pg": "8.16.0",
"@types/react": "19.2.7", "@types/react": "19.2.8",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"drizzle-kit": "0.31.8", "drizzle-kit": "0.31.8",
"eslint": "9.39.2", "eslint": "9.39.2",
"eslint-config-next": "16.0.10", "eslint-config-next": "16.1.2",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"jsdom": "^27.3.0",
"postcss": "8.5.6", "postcss": "8.5.6",
"prettier": "3.7.4", "prettier": "3.7.4",
"prettier-plugin-tailwindcss": "0.7.2", "prettier-plugin-tailwindcss": "0.7.2",
"tailwindcss": "4.1.18", "tailwindcss": "4.1.18",
"turbo": "2.6.3",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.49.0", "typescript-eslint": "8.53.0"
"vitest": "^4.0.15"
}, },
"packageManager": "pnpm@10.25.0", "packageManager": "pnpm@10.28.0",
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"@types/react": "19.2.7", "@types/react": "19.2.8",
"@types/react-dom": "19.2.3" "@types/react-dom": "19.2.3"
} }
} }

View File

@@ -1,26 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

3116
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +0,0 @@
import { test, expect } from '@playwright/test';
test('landing page loads and has create account button', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Track Every Day' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Start Tracking Now' })).toBeVisible();
});
test('can navigate to login input', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'I Have a Token' }).click();
await expect(page.getByLabel('Access Token')).toBeVisible();
});

View File

@@ -1,17 +0,0 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
alias: {
'@': path.resolve(__dirname, './'),
},
// Exclude E2E tests from Vitest
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**', '**/tests/e2e/**'],
},
});

View File

@@ -1,2 +0,0 @@
import '@testing-library/jest-dom';