Compare commits
17 Commits
f64fc274a7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d35f643825 | |||
| a6b5fa487d | |||
| 2d64efec92 | |||
| 6db60d7875 | |||
| d70b4bc90e | |||
| b8a86885de | |||
| a44fa3a50b | |||
| 7b7feea11f | |||
| 2f879b449e | |||
| f906749340 | |||
| 620765564c | |||
| cfc52b085e | |||
| 5aa8fce32f | |||
| bf2000c49a | |||
| cbff054301 | |||
| c5fbc57a78 | |||
| 3173397ad3 |
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 (`@/...`).
|
|
||||||
|
|
||||||
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/
|
|
||||||
|
|||||||
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,120 +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() {}
|
|
||||||
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,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,12 +92,14 @@ 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 () => clearInterval(interval);
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch habits
|
// Fetch habits
|
||||||
@@ -139,19 +129,6 @@ 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
|
// 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> => {
|
||||||
@@ -171,38 +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/${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 = () => {
|
const handleCreateHabit = () => {
|
||||||
if (newHabitName.trim()) {
|
if (newHabitName.trim()) {
|
||||||
createHabitMutation.mutate({
|
createHabitMutation.mutate({
|
||||||
@@ -212,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);
|
||||||
@@ -434,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={() => {
|
||||||
@@ -444,54 +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-${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: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>
|
</div>
|
||||||
|
|
||||||
<Separator className="bg-zinc-800" />
|
<Separator className="bg-zinc-800" />
|
||||||
@@ -553,105 +439,6 @@ export default function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
36
package.json
36
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,41 @@
|
|||||||
"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.44.7",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.555.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next": "16.0.3",
|
"next": "16.0.7",
|
||||||
"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": "^10.4.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.1",
|
||||||
"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.56.1",
|
|
||||||
"@tailwindcss/postcss": "4.1.17",
|
"@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/node": "24.10.1",
|
||||||
"@types/pg": "8.15.6",
|
"@types/pg": "8.15.6",
|
||||||
"@types/react": "19.2.6",
|
"@types/react": "19.2.7",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
|
||||||
"drizzle-kit": "0.31.7",
|
"drizzle-kit": "0.31.7",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.7",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"jsdom": "^27.2.0",
|
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.7.1",
|
||||||
"prettier-plugin-tailwindcss": "0.7.1",
|
"prettier-plugin-tailwindcss": "0.7.2",
|
||||||
"tailwindcss": "4.1.17",
|
"tailwindcss": "4.1.17",
|
||||||
"turbo": "2.6.1",
|
"turbo": "2.6.2",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.47.0",
|
"typescript-eslint": "8.48.1"
|
||||||
"vitest": "^4.0.13"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.23.0",
|
"packageManager": "pnpm@10.24.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "19.2.6",
|
"@types/react": "19.2.7",
|
||||||
"@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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
2053
pnpm-lock.yaml
generated
2053
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,27 +2,6 @@
|
|||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {},
|
'@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: {},
|
cssnano: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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