Compare commits

...

25 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
eacc7de9b0 chore(deps): update dependency @types/pg to v8.16.0
All checks were successful
Lint / Lint and Check (push) Successful in 31s
2025-12-12 20:23:25 +01:00
2e88b710c3 fix(deps): update react monorepo to v19.2.3
Some checks failed
Lint / Lint and Check (push) Has been cancelled
2025-12-12 20:22:18 +01:00
1f626a64a2 chore(deps): update tailwindcss monorepo to v4.1.18
Some checks failed
Lint / Lint and Check (pull_request) Successful in 28s
Lint / Lint and Check (push) Has been cancelled
renovate/stability-days Updates have not met minimum release age requirement
2025-12-12 18:28:35 +00:00
05a0cb387d add wget to runner image for healthcheck
All checks were successful
Lint / Lint and Check (push) Successful in 35s
2025-12-09 13:28:08 +01:00
8286b9801f add curl for healthcheck
All checks were successful
Lint / Lint and Check (push) Successful in 38s
2025-12-09 13:24:43 +01:00
ba3ef8bc2b Add Docker support for optimized Next.js deployment
All checks were successful
Lint / Lint and Check (push) Successful in 36s
Introduces Docker configuration files to enable containerized builds
and streamlined deployment using a multi-stage strategy with
standalone Next.js output. Updates configuration to optimize image
size and leverage production best practices.
2025-12-09 13:18:19 +01:00
5387e51f2d purge old crap 2025-12-09 13:06:34 +01:00
29 changed files with 2388 additions and 528 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 (`@/...`).

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git

4
.gitignore vendored
View File

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

69
Dockerfile Normal file
View File

@@ -0,0 +1,69 @@
# syntax=docker.io/docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
FROM node:24-alpine@sha256:7e0bd0460b26eb3854ea5b99b887a6a14d665d14cae694b78ae2936d14b2befb AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
# wget needed for healthcheck
RUN apk add --no-cache wget
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy public files. "[c]" as workaround for conditional matching since the folder might not exist.
COPY --from=builder /app/publi[c] ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

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,19 +450,56 @@ export default function Dashboard() {
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle className="text-lg">{habit.name}</CardTitle>
{getHabitIcon(habit.type)}
<div className="flex items-center gap-2">
{getHabitIcon(habit.type)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/20"
onClick={(e) => {
openEditDialog(e, habit);
}}
data-testid={`edit-habit-${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">
<Clock className="h-4 w-4 text-zinc-500" />
<span className="text-zinc-400">
{habit.lastLoggedAt
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
: 'Never logged'}
</span>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-zinc-500" />
<span className="text-zinc-400">
{habit.lastLoggedAt
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
: 'Never logged'}
</span>
</div>
{habit.lastLoggedAt && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-zinc-500 hover: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

@@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
output: 'standalone',
};
export default nextConfig;

View File

@@ -4,10 +4,13 @@
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev",
"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,39 +31,46 @@
"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",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-preset-env": "^10.4.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.17",
"@types/node": "24.10.1",
"@types/pg": "8.15.6",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "4.1.18",
"@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.17",
"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,
},
});

2030
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Before

Width:  |  Height:  |  Size: 629 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,5 +0,0 @@
sonar.projectKey=trackevery.day
# relative paths to source directories. More details and properties are described
# at https://docs.sonarqube.org/latest/project-administration/narrowing-the-focus/
sonar.sources=.

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

View File

@@ -1,12 +0,0 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"outputs": [
".next/**",
"!.next/cache/**"
]
},
"type-check": {}
}
}

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';