Compare commits
30 Commits
dev
...
renovate/p
| Author | SHA1 | Date | |
|---|---|---|---|
| 67cb4a68be | |||
| 0a4535f3bc | |||
| 4134770b7f | |||
| a4128436c1 | |||
| 9508953a95 | |||
| c5537c5d9a | |||
| 9e32f86805 | |||
| badc3a31e4 | |||
| 94398e621b | |||
| 869b2f3c46 | |||
| d7edf19db4 | |||
| 9c76ae2d73 | |||
| ec40f342af | |||
| 13cafd6c64 | |||
| 4b3fd2b61d | |||
| bce33c9004 | |||
| f14403a2f5 | |||
| 8ac8f70932 | |||
| 3099a1a4a0 | |||
| e34a8f5140 | |||
| 7c1f0da953 | |||
| c329ecdb31 | |||
| edf2ba0cb7 | |||
| 2822690070 | |||
| 2eb30725b1 | |||
| 6907520913 | |||
| 83d269b432 | |||
| db12e07ab8 | |||
| f66a70111d | |||
| 9bc1d05bb1 |
20
.cursorrules
20
.cursorrules
@@ -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 (`@/...`).
|
|
||||||
|
|
||||||
@@ -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
4
.gitignore
vendored
@@ -36,7 +36,3 @@ 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@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
|
||||||
|
|||||||
73
README.md
73
README.md
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -33,22 +33,26 @@ 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,
|
||||||
name: habit.name,
|
name: habit.name,
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,56 +365,19 @@ 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
|
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
|
||||||
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
|
: 'Never logged'}
|
||||||
: 'Never logged'}
|
</span>
|
||||||
</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" />
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
29
package.json
29
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
3116
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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();
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -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/**'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import '@testing-library/jest-dom';
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user