Compare commits
18 Commits
e9d477aa5d
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e3ba71148 | |||
| 907acc4fec | |||
| ed0b3b7bd4 | |||
| c10d88c594 | |||
| b81031b542 | |||
| 2c84feeab0 | |||
| 4ac8800ef7 | |||
| 8879963cce | |||
| b91ea8be0a | |||
| 930d68df3e | |||
| f8d504b73f | |||
| 43e5c70197 | |||
| 3f23d70b28 | |||
| d97a2b97a6 | |||
| f386752536 | |||
| 5e08260b11 | |||
| 2c0fbf7e63 | |||
| dc442f7dc4 |
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
@@ -36,3 +36,7 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
.turbo/
|
.turbo/
|
||||||
|
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
test-results/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# syntax=docker.io/docker/dockerfile:1
|
# syntax=docker.io/docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
|
||||||
|
|
||||||
FROM node:24-alpine AS base
|
FROM node:24-alpine@sha256:7e0bd0460b26eb3854ea5b99b887a6a14d665d14cae694b78ae2936d14b2befb AS base
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|||||||
73
README.md
@@ -4,6 +4,79 @@ 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
|
||||||
|
|||||||
@@ -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 } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
async function getUserFromToken() {
|
async function getUserFromToken() {
|
||||||
const token = await getTokenCookie();
|
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 });
|
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
@@ -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
|
// 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
|
// Get all logs for this habit, ordered by date desc
|
||||||
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 =
|
const lastLoggedAt = logs.length > 0 ? logs[0].loggedAt : null;
|
||||||
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,
|
||||||
name: habit.name,
|
name: habit.name,
|
||||||
|
|||||||
BIN
app/apple-icon.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
128
app/dashboard/page.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,10 @@ 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';
|
||||||
@@ -73,9 +77,17 @@ 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());
|
||||||
|
|
||||||
@@ -92,11 +104,11 @@ export default function Dashboard() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update current time periodically to avoid impure Date.now() calls during render
|
// Update current time periodically
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setCurrentTime(Date.now());
|
setCurrentTime(Date.now());
|
||||||
}, 60000); // Update every minute
|
}, 60000);
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
@@ -129,6 +141,19 @@ 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> => {
|
||||||
@@ -148,6 +173,42 @@ 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({
|
||||||
@@ -157,6 +218,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 = () => {
|
const copyToken = () => {
|
||||||
if (authData?.token) {
|
if (authData?.token) {
|
||||||
void navigator.clipboard.writeText(authData.token);
|
void navigator.clipboard.writeText(authData.token);
|
||||||
@@ -355,7 +440,7 @@ export default function Dashboard() {
|
|||||||
{habits.map((habit: Habit) => (
|
{habits.map((habit: Habit) => (
|
||||||
<Card
|
<Card
|
||||||
key={habit.id}
|
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,
|
habit.type,
|
||||||
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
|
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -365,19 +450,56 @@ 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>
|
||||||
{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-${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 gap-2 text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<Clock className="h-4 w-4 text-zinc-500" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-zinc-400">
|
<Clock className="h-4 w-4 text-zinc-500" />
|
||||||
{habit.lastLoggedAt
|
<span className="text-zinc-400">
|
||||||
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
|
{habit.lastLoggedAt
|
||||||
: 'Never logged'}
|
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
|
||||||
</span>
|
: 'Never logged'}
|
||||||
|
</span>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<Separator className="bg-zinc-800" />
|
<Separator className="bg-zinc-800" />
|
||||||
@@ -439,6 +561,121 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
BIN
app/favicon.ico
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 15 KiB |
3
app/icon0.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="255.99998" height="255.99998"><svg width="255.99998" height="255.99998" viewBox="0 0 67.733328 67.733329" version="1.1" id="SvgjsSvg1501" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><defs id="SvgjsDefs1500"></defs><g id="SvgjsG1499"><rect style="font-variation-settings:'wght' 800;display:none;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:round" id="SvgjsRect1498" width="67.73333" height="67.73333" x="0" y="0"></rect><circle style="font-variation-settings:'wght' 800;display:inline;fill:#059669;fill-opacity:1;stroke:none;stroke-width:5.27484;stroke-linecap:round;stroke-linejoin:round" id="SvgjsCircle1497" cy="12.297192" cx="45.428497" r="8.4252577"></circle><circle style="font-variation-settings:'wght' 800;display:inline;fill:#d97706;fill-opacity:1;stroke:none;stroke-width:5.27484;stroke-linecap:round;stroke-linejoin:round" id="SvgjsCircle1496" cy="55.690498" cx="20.649494" r="8.4252577"></circle><path d="M 41.252138,22.687586 C 37.704621,21.261737 35.129699,18.124539 34.422921,14.367094 27.263765,12.036843 21.1959,16.3091 21.1959,16.3091 26.828105,11.378756 34.25937,11.488211 34.25937,11.488211 34.4658,8.6374649 35.754453,5.9735431 37.861353,4.0421364 27.616916,4.6289911 20.55393,9.007259 20.55393,9.007259 c 0,0 7.215201,-6.4715959 18.21468,-7.3018404 C 27.909388,0.39887564 15.094082,4.9774108 6.3514003,18.315496 c 4.0103957,-4.912052 8.0242977,-5.536907 8.0242977,-5.536907 0,0 -8.0242977,5.536907 -11.6352968,22.306673 C 6.693033,24.761888 14.134879,20.722399 14.134879,20.722399 c 0,0 -11.3944778,14.362863 -6.4196968,29.448183 -2.2340419,-20.604045 17.5727098,-29.52867 17.5727098,-29.52867 0,0 -9.999614,7.430395 -9.869074,14.604325 6.849011,-12.913208 16.759463,-12.648153 16.759463,-12.648153 0,0 -1.654004,0.160095 -2.797748,3.339924 4.372381,-3.267307 9.297788,-3.366744 11.871607,-3.250413 z" style="font-variation-settings:'wght' 800;display:inline;fill:#10b981;stroke-width:5.27484;stroke-linecap:round;stroke-linejoin:round" id="SvgjsPath1495"></path><path d="M 58.307152,17.632961 C 57.576231,39.826641 38.88902,48.927208 38.88902,48.927208 c 0,0 8.797937,-6.66472 11.674571,-16.28871 -9.182522,13.25164 -17.211482,12.958514 -17.211482,12.958514 0,0 1.23553,-0.13002 3.330258,-3.771331 0,0 -3.711354,3.633872 -11.73317,3.573653 3.398948,1.419962 5.877586,4.426569 6.623169,8.033957 6.622795,1.558117 13.334552,-1.898221 13.334552,-1.898221 0,0 -6.384514,4.403344 -13.142669,5.086176 -0.229172,2.737305 -1.460403,5.293772 -3.45775,7.179499 8.51737,-0.09688 17.40272,-5.405551 17.40272,-5.405551 0,0 -5.901793,5.983606 -18.21468,7.743557 7.503974,0.399663 25.113814,-1.775961 32.296802,-16.610078 -1.747838,3.616163 -8.224552,5.57683 -8.224552,5.57683 0,0 8.224552,-5.57683 11.474321,-22.22683 -2.178474,6.160256 -11.070591,14.364798 -11.070591,14.364798 0,0 11.070591,-14.364798 6.336629,-29.610449 z" style="font-variation-settings:'wght' 800;display:inline;fill:#f59e0b;fill-opacity:1;stroke-width:5.27484;stroke-linecap:round;stroke-linejoin:round" id="SvgjsPath1494"></path></g></svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
BIN
app/icon1.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
@@ -28,6 +28,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className="scroll-smooth">
|
<html lang="en" className="scroll-smooth">
|
||||||
<head>
|
<head>
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Track Every Day" />
|
||||||
<PlausibleProvider
|
<PlausibleProvider
|
||||||
domain="trackevery.day"
|
domain="trackevery.day"
|
||||||
customDomain="https://analytics.schulze.network"
|
customDomain="https://analytics.schulze.network"
|
||||||
|
|||||||
21
app/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Track Every Day",
|
||||||
|
"short_name": "Track",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
26
package.json
@@ -8,6 +8,9 @@
|
|||||||
"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",
|
||||||
@@ -28,10 +31,10 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cssnano": "^7.1.2",
|
"cssnano": "^7.1.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.45.0",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.561.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next": "16.0.7",
|
"next": "16.0.10",
|
||||||
"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",
|
||||||
@@ -43,24 +46,31 @@
|
|||||||
"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",
|
||||||
"@types/node": "24.10.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@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.7",
|
||||||
"@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.1",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.0.7",
|
"eslint-config-next": "16.0.10",
|
||||||
"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",
|
"turbo": "2.6.3",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.48.1"
|
"typescript-eslint": "8.49.0",
|
||||||
|
"vitest": "^4.0.15"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.24.0",
|
"packageManager": "pnpm@10.25.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.7",
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
1475
pnpm-lock.yaml
generated
BIN
public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||