Compare commits

...

18 Commits

Author SHA1 Message Date
2e3ba71148 AI linter fixes
All checks were successful
Lint / Lint and Check (push) Successful in 35s
2025-12-14 17:50:08 +01:00
907acc4fec add missing deps after rebase 2025-12-14 17:46:19 +01:00
ed0b3b7bd4 Adds Playwright E2E and Vitest test infrastructure
Integrates Playwright for end-to-end browser testing with automated web server setup, example smoke tests, and CI-compatible configuration. Introduces Vitest, Testing Library, and related utilities for fast component and unit testing.

Updates scripts, development dependencies, and lockfile to support both test suites. Establishes unified testing commands for local and CI workflows, laying groundwork for comprehensive automated UI and integration coverage.
2025-12-14 17:41:50 +01:00
c10d88c594 Adds habit edit, archive, and undo log features
Enables users to update or archive habits and to undo the latest habit log.
Adds PATCH/DELETE API endpoints for habit edit and soft deletion.
Introduces UI dialogs and controls for editing and archiving habits,
as well as for undoing the most recent log entry directly from the dashboard.
Improves log statistics handling by ordering and simplifies last log detection.
2025-12-14 17:40:31 +01:00
b81031b542 Plan 2025-12-14 17:40:31 +01:00
2c84feeab0 logo and favicon
All checks were successful
Lint / Lint and Check (push) Successful in 34s
2025-12-14 17:40:10 +01:00
4ac8800ef7 fix(deps): update nextjs monorepo to v16.0.10
Some checks are pending
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Check (pull_request) Successful in 29s
Lint / Lint and Check (push) Successful in 34s
2025-12-13 22:23:53 +00:00
8879963cce fix(deps): update dependency lucide-react to ^0.561.0
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-12-13 18:31:01 +01:00
b91ea8be0a chore(deps): update dependency eslint to v9.39.2
Some checks failed
Lint / Lint and Check (push) Has been cancelled
2025-12-13 18:29:05 +01:00
930d68df3e chore(deps): update dependency @types/node to v24.10.3
Some checks failed
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Check (pull_request) Successful in 28s
Lint / Lint and Check (push) Has been cancelled
2025-12-13 17:06:03 +00:00
f8d504b73f fix(deps): update dependency lucide-react to ^0.559.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 37s
2025-12-13 14:04:05 +00:00
43e5c70197 fix(deps): update dependency lucide-react to ^0.557.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (pull_request) Successful in 35s
Lint / Lint and Check (push) Successful in 30s
2025-12-13 13:04:00 +00:00
3f23d70b28 fix(deps): update dependency drizzle-orm to ^0.45.0
All checks were successful
Lint / Lint and Check (pull_request) Successful in 30s
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 43s
2025-12-13 11:28:52 +01:00
d97a2b97a6 chore(deps): pin dependencies
All checks were successful
Lint / Lint and Check (push) Successful in 34s
2025-12-13 11:27:36 +01:00
f386752536 chore(deps): update pnpm to v10.25.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2025-12-12 22:03:52 +00:00
5e08260b11 chore(deps): update dependency typescript-eslint to v8.49.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 29s
2025-12-12 21:04:13 +00:00
2c0fbf7e63 fix(deps): update nextjs monorepo to v16.0.8
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2025-12-12 20:05:34 +00:00
dc442f7dc4 chore(deps): update dependency @types/node to v24.10.2
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2025-12-12 19:31:35 +00:00
23 changed files with 2027 additions and 227 deletions

20
.cursorrules Normal file
View 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
View File

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

View File

@@ -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
FROM base AS deps

View File

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

View File

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

View 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 });
}
}

View File

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

BIN
app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

128
app/dashboard/page.test.tsx Normal file
View 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();
});
});
});

View File

@@ -16,6 +16,10 @@ import {
Check,
Trophy,
HeartCrack,
MoreVertical,
Trash2,
Save,
RotateCcw,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -73,9 +77,17 @@ interface HabitResponse {
export default function Dashboard() {
const router = useRouter();
const queryClient = useQueryClient();
// State
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
const [newHabitName, setNewHabitName] = useState('');
const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
const [editingHabit, setEditingHabit] = useState<Habit | null>(null);
const [editHabitName, setEditHabitName] = useState('');
const [editHabitType, setEditHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [copiedToken, setCopiedToken] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now());
@@ -92,11 +104,11 @@ export default function Dashboard() {
},
});
// Update current time periodically to avoid impure Date.now() calls during render
// Update current time periodically
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(Date.now());
}, 60000); // Update every minute
}, 60000);
return () => {
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
const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({
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 = () => {
if (newHabitName.trim()) {
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 = () => {
if (authData?.token) {
void navigator.clipboard.writeText(authData.token);
@@ -355,7 +440,7 @@ export default function Dashboard() {
{habits.map((habit: Habit) => (
<Card
key={habit.id}
className={`transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
className={`group relative transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
habit.type,
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
onClick={() => {
@@ -365,13 +450,27 @@ export default function Dashboard() {
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle className="text-lg">{habit.name}</CardTitle>
<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>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Last logged */}
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-zinc-500" />
<span className="text-zinc-400">
{habit.lastLoggedAt
@@ -379,6 +478,29 @@ export default function Dashboard() {
: '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>
<Separator className="bg-zinc-800" />
@@ -439,6 +561,121 @@ export default function Dashboard() {
</Card>
)}
</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>
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

3
app/icon0.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -28,6 +28,7 @@ export default function RootLayout({
return (
<html lang="en" className="scroll-smooth">
<head>
<meta name="apple-mobile-web-app-title" content="Track Every Day" />
<PlausibleProvider
domain="trackevery.day"
customDomain="https://analytics.schulze.network"

21
app/manifest.json Normal file
View 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"
}

View File

@@ -8,6 +8,9 @@
"build": "next build",
"start": "next start",
"lint": "next typegen && eslint . && npx tsc --noEmit",
"test": "vitest",
"test:e2e": "playwright test",
"test:coverage": "vitest run --coverage",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
@@ -28,10 +31,10 @@
"clsx": "^2.1.1",
"cssnano": "^7.1.2",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.7",
"lucide-react": "^0.556.0",
"drizzle-orm": "^0.45.0",
"lucide-react": "^0.561.0",
"nanoid": "^5.1.6",
"next": "16.0.7",
"next": "16.0.10",
"next-plausible": "^3.12.5",
"pg": "^8.16.3",
"pg-native": "^3.5.2",
@@ -43,24 +46,31 @@
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@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/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"drizzle-kit": "0.31.8",
"eslint": "9.39.1",
"eslint-config-next": "16.0.7",
"eslint": "9.39.2",
"eslint-config-next": "16.0.10",
"eslint-config-prettier": "10.1.8",
"jsdom": "^27.3.0",
"postcss": "8.5.6",
"prettier": "3.7.4",
"prettier-plugin-tailwindcss": "0.7.2",
"tailwindcss": "4.1.18",
"turbo": "2.6.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": {
"overrides": {
"@types/react": "19.2.7",

26
playwright.config.ts Normal file
View 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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

14
tests/e2e/smoke.spec.ts Normal file
View 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
View 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
View File

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