Compare commits
3 Commits
main
...
f64fc274a7
| Author | SHA1 | Date | |
|---|---|---|---|
| f64fc274a7 | |||
| 65f1fcb7bb | |||
| 55950e9473 |
20
.cursorrules
Normal file
20
.cursorrules
Normal file
@@ -0,0 +1,20 @@
|
||||
# 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 (`@/...`).
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -36,3 +36,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
.turbo/
|
||||
|
||||
playwright-report/
|
||||
|
||||
test-results/
|
||||
|
||||
73
README.md
73
README.md
@@ -4,6 +4,79 @@ https://trackevery.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
|
||||
|
||||
- **Token-based authentication** - No email or password required
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, habits, users, habitLogs } from '@/lib/db';
|
||||
import { getTokenCookie } from '@/lib/auth/cookies';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
|
||||
async function getUserFromToken() {
|
||||
const token = await getTokenCookie();
|
||||
@@ -58,3 +58,49 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
117
app/api/habits/[id]/route.ts
Normal file
117
app/api/habits/[id]/route.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,26 +33,22 @@ export async function GET() {
|
||||
// Then get aggregated log data for each habit
|
||||
const habitsWithStats = await Promise.all(
|
||||
userHabitsBase.map(async (habit) => {
|
||||
// Get all logs for this habit
|
||||
// Get all logs for this habit, ordered by date desc
|
||||
const logs = await db
|
||||
.select({
|
||||
id: habitLogs.id,
|
||||
loggedAt: habitLogs.loggedAt,
|
||||
})
|
||||
.from(habitLogs)
|
||||
.where(eq(habitLogs.habitId, habit.id));
|
||||
.where(eq(habitLogs.habitId, habit.id))
|
||||
.orderBy(desc(habitLogs.loggedAt));
|
||||
|
||||
// Calculate statistics
|
||||
const totalLogs = logs.length;
|
||||
const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length;
|
||||
const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length;
|
||||
const lastLoggedAt =
|
||||
logs.length > 0
|
||||
? logs.reduce(
|
||||
(latest, log) => (log.loggedAt > latest ? log.loggedAt : latest),
|
||||
logs[0].loggedAt,
|
||||
)
|
||||
: null;
|
||||
|
||||
const lastLoggedAt = logs.length > 0 ? logs[0].loggedAt : null;
|
||||
|
||||
return {
|
||||
id: habit.id,
|
||||
name: habit.name,
|
||||
|
||||
120
app/dashboard/page.test.tsx
Normal file
120
app/dashboard/page.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('Dashboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock Auth Response
|
||||
(global.fetch as any).mockImplementation((url: string) => {
|
||||
if (url === '/api/auth') {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({ authenticated: true, token: 'test-token' }),
|
||||
});
|
||||
}
|
||||
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(),
|
||||
}
|
||||
]
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
Check,
|
||||
Trophy,
|
||||
HeartCrack,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Save,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -73,9 +77,17 @@ interface HabitResponse {
|
||||
export default function Dashboard() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
|
||||
const [newHabitName, setNewHabitName] = useState('');
|
||||
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 [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
|
||||
@@ -92,14 +104,12 @@ export default function Dashboard() {
|
||||
},
|
||||
});
|
||||
|
||||
// Update current time periodically to avoid impure Date.now() calls during render
|
||||
// Update current time periodically
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(Date.now());
|
||||
}, 60000); // Update every minute
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Fetch habits
|
||||
@@ -129,6 +139,19 @@ export default function Dashboard() {
|
||||
},
|
||||
});
|
||||
|
||||
// Undo log mutation
|
||||
const undoLogMutation = useMutation<void, Error, number>({
|
||||
mutationFn: async (habitId: number): Promise<void> => {
|
||||
const res = await fetch(`/api/habits/${habitId}/log`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to undo log');
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['habits'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Create habit mutation
|
||||
const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({
|
||||
mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => {
|
||||
@@ -148,6 +171,38 @@ 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/${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<void, Error, number>({
|
||||
mutationFn: async (id: number): Promise<void> => {
|
||||
const res = await fetch(`/api/habits/${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 = () => {
|
||||
if (newHabitName.trim()) {
|
||||
createHabitMutation.mutate({
|
||||
@@ -157,6 +212,30 @@ 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 = () => {
|
||||
if (authData?.token) {
|
||||
void navigator.clipboard.writeText(authData.token);
|
||||
@@ -355,7 +434,7 @@ export default function Dashboard() {
|
||||
{habits.map((habit: Habit) => (
|
||||
<Card
|
||||
key={habit.id}
|
||||
className={`transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
|
||||
className={`group relative transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
|
||||
habit.type,
|
||||
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
|
||||
onClick={() => {
|
||||
@@ -365,19 +444,54 @@ export default function Dashboard() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg">{habit.name}</CardTitle>
|
||||
{getHabitIcon(habit.type)}
|
||||
<div className="flex items-center gap-2">
|
||||
{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-${habit.id}`}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Last logged */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-zinc-500" />
|
||||
<span className="text-zinc-400">
|
||||
{habit.lastLoggedAt
|
||||
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
|
||||
: 'Never logged'}
|
||||
</span>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-zinc-500" />
|
||||
<span className="text-zinc-400">
|
||||
{habit.lastLoggedAt
|
||||
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
|
||||
: 'Never logged'}
|
||||
</span>
|
||||
</div>
|
||||
{habit.lastLoggedAt && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-zinc-500 hover:text-red-400 hover:bg-red-950/30"
|
||||
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" />
|
||||
@@ -439,6 +553,105 @@ export default function Dashboard() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Habit Dialog */}
|
||||
<Dialog open={!!editingHabit} onOpenChange={(open) => !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 gap-2 justify-end">
|
||||
<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>
|
||||
);
|
||||
|
||||
36
package.json
36
package.json
@@ -8,6 +8,9 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next typegen && eslint . && npx tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
@@ -29,41 +32,48 @@
|
||||
"cssnano": "^7.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^0.554.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "16.0.7",
|
||||
"next": "16.0.3",
|
||||
"next-plausible": "^3.12.5",
|
||||
"pg": "^8.16.3",
|
||||
"pg-native": "^3.5.2",
|
||||
"postcss-flexbugs-fixes": "^5.0.2",
|
||||
"postcss-preset-env": "^10.4.0",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@tailwindcss/postcss": "4.1.17",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/pg": "8.15.6",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react": "19.2.6",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"drizzle-kit": "0.31.7",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-next": "16.0.7",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"jsdom": "^27.2.0",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.7.1",
|
||||
"prettier-plugin-tailwindcss": "0.7.2",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.7.1",
|
||||
"tailwindcss": "4.1.17",
|
||||
"turbo": "2.6.2",
|
||||
"turbo": "2.6.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.48.1"
|
||||
"typescript-eslint": "8.47.0",
|
||||
"vitest": "^4.0.13"
|
||||
},
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react": "19.2.6",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
26
playwright.config.ts
Normal file
26
playwright.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
2053
pnpm-lock.yaml
generated
2053
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,27 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
|
||||
"postcss-flexbugs-fixes": {
|
||||
"postcss-preset-env": {
|
||||
autoprefixer: {
|
||||
flexbox: "no-2009",
|
||||
},
|
||||
stage: 3,
|
||||
features: {
|
||||
"custom-properties": false,
|
||||
},
|
||||
},
|
||||
|
||||
"@fullhuman/postcss-purgecss": {
|
||||
content: [
|
||||
"./pages/**/*.{js,jsx,ts,tsx}",
|
||||
"./components/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
defaultExtractor: (content) => content.match(/[\w-/:]+(?<!:)/g) || [],
|
||||
safelist: ["html", "body"],
|
||||
},
|
||||
},
|
||||
cssnano: {},
|
||||
},
|
||||
};
|
||||
|
||||
14
tests/e2e/smoke.spec.ts
Normal file
14
tests/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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();
|
||||
});
|
||||
|
||||
17
vitest.config.ts
Normal file
17
vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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/**'],
|
||||
},
|
||||
});
|
||||
2
vitest.setup.ts
Normal file
2
vitest.setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
Reference in New Issue
Block a user