Compare commits
25 Commits
056a7edd5c
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e3ba71148 | |||
| 907acc4fec | |||
| ed0b3b7bd4 | |||
| c10d88c594 | |||
| b81031b542 | |||
| 2c84feeab0 | |||
| 4ac8800ef7 | |||
| 8879963cce | |||
| b91ea8be0a | |||
| 930d68df3e | |||
| f8d504b73f | |||
| 43e5c70197 | |||
| 3f23d70b28 | |||
| d97a2b97a6 | |||
| f386752536 | |||
| 5e08260b11 | |||
| 2c0fbf7e63 | |||
| dc442f7dc4 | |||
| eacc7de9b0 | |||
| 2e88b710c3 | |||
| 1f626a64a2 | |||
| 05a0cb387d | |||
| 8286b9801f | |||
| ba3ef8bc2b | |||
| 5387e51f2d |
20
.cursorrules
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Project Rules
|
||||||
|
|
||||||
|
## Testing Policy
|
||||||
|
- **Mandatory Tests**: All new features and bug fixes must be accompanied by tests.
|
||||||
|
- **Unit/Integration Tests**: Use **Vitest** for testing utilities, hooks, and components.
|
||||||
|
- **E2E Tests**: Use **Playwright** for critical user flows (auth, core features).
|
||||||
|
- **Coverage**: Aim for high coverage on business logic and critical paths.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Framework**: Next.js 15 (App Router)
|
||||||
|
- **Language**: TypeScript
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **Database**: PostgreSQL with Drizzle ORM
|
||||||
|
- **State Management**: React Query
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
- **Functional Components**: Use arrow functions for components.
|
||||||
|
- **Types**: strict TypeScript usage (avoid `any`).
|
||||||
|
- **Imports**: Use absolute imports (`@/...`).
|
||||||
|
|
||||||
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
README.md
|
||||||
|
.next
|
||||||
|
.git
|
||||||
4
.gitignore
vendored
@@ -36,3 +36,7 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
.turbo/
|
.turbo/
|
||||||
|
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
test-results/
|
||||||
|
|||||||
69
Dockerfile
Normal 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"]
|
||||||
73
README.md
@@ -4,6 +4,79 @@ https://trackevery.day/
|
|||||||
|
|
||||||
A simple, privacy-focused habit tracking web app. Track anything, every day.
|
A simple, privacy-focused habit tracking web app. Track anything, every day.
|
||||||
|
|
||||||
|
## 🎯 Vision & Goal
|
||||||
|
|
||||||
|
**Goal**: To provide the most frictionless, privacy-respecting tool for users to build consistency in their lives without the barrier of complex sign-ups or data tracking concerns.
|
||||||
|
|
||||||
|
**Vision**: A world where self-improvement is accessible to everyone without trading their privacy for it. `trackevery-day` aims to become the standard for "unaccounted" personal tracking, eventually expanding into a broader minimalist "life logger" platform.
|
||||||
|
|
||||||
|
## 💼 Business Model
|
||||||
|
|
||||||
|
This project operates on a sustainable Open Source model:
|
||||||
|
|
||||||
|
1. **Core Product (Free & Open Source)**: The full application is available for free. Users can self-host or use the public instance.
|
||||||
|
2. **Supporter Tier (Future)**: Optional premium features for power users who want to support development:
|
||||||
|
- Advanced Data Analysis & Trends
|
||||||
|
- Encrypted Cloud Backups
|
||||||
|
- API Access for integrations
|
||||||
|
3. **Donations**: Community support via GitHub Sponsors / Ko-fi to cover hosting costs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Roadmap & Tasks
|
||||||
|
|
||||||
|
We are building this out in phases. Below is the breakdown of problems into small, actionable tasks.
|
||||||
|
|
||||||
|
### Phase 1: Core Refinement (Current Focus)
|
||||||
|
|
||||||
|
_Goal: Polish the existing functionality to be feature-complete._
|
||||||
|
|
||||||
|
- [ ] **Habit Management**
|
||||||
|
- [ ] Add "Edit Habit" functionality (rename, change type/color).
|
||||||
|
- [ ] Add "Delete/Archive Habit" functionality (UI implementation).
|
||||||
|
- [ ] Implement "Undo Log" (remove accidental logs).
|
||||||
|
- [ ] **Visualization**
|
||||||
|
- [ ] Add a "Contribution Graph" (GitHub style) heatmap for each habit.
|
||||||
|
- [ ] Add a simple line chart for "Frequency over Time".
|
||||||
|
- [ ] **UX Improvements**
|
||||||
|
- [ ] specific mobile-responsive tweaks for the dashboard grid.
|
||||||
|
- [ ] Add a "Settings" page to manage the token (regenerate, view).
|
||||||
|
|
||||||
|
### Phase 2: Data Sovereignty
|
||||||
|
|
||||||
|
_Goal: Ensure users truly own their data._
|
||||||
|
|
||||||
|
- [ ] **Export/Import**
|
||||||
|
- [ ] Create JSON export handler.
|
||||||
|
- [ ] Create CSV export handler (for spreadsheet analysis).
|
||||||
|
- [ ] Build a "Restore from Backup" feature (JSON import).
|
||||||
|
- [ ] **Local-First Enhancements**
|
||||||
|
- [ ] Cache habit data in `localStorage` for faster load times.
|
||||||
|
- [ ] Implement offline queuing for logs when network is unavailable.
|
||||||
|
|
||||||
|
### Phase 3: Engagement & Growth
|
||||||
|
|
||||||
|
_Goal: Help users stay consistent._
|
||||||
|
|
||||||
|
- [ ] **PWA Implementation**
|
||||||
|
- [ ] Add `manifest.json` and service workers.
|
||||||
|
- [ ] Enable "Add to Home Screen" prompt.
|
||||||
|
- [ ] **Gamification (Subtle)**
|
||||||
|
- [ ] Visual rewards for hitting streaks (confetti, badges).
|
||||||
|
- [ ] "Levels" based on total consistency score.
|
||||||
|
- [ ] **Notifications**
|
||||||
|
- [ ] Browser-based push notifications for reminders (optional).
|
||||||
|
|
||||||
|
### Phase 4: Advanced Features (Supporter Tier)
|
||||||
|
|
||||||
|
_Goal: Power features for data nerds._
|
||||||
|
|
||||||
|
- [ ] **Public Profile** (Optional public shareable link for specific habits).
|
||||||
|
- [ ] **API Access** (Generate API keys to log via curl/scripts).
|
||||||
|
- [ ] **Webhooks** (Trigger events when a habit is logged).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Token-based authentication** - No email or password required
|
- **Token-based authentication** - No email or password required
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, habits, users, habitLogs } from '@/lib/db';
|
import { db, habits, users, habitLogs } from '@/lib/db';
|
||||||
import { getTokenCookie } from '@/lib/auth/cookies';
|
import { getTokenCookie } from '@/lib/auth/cookies';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
async function getUserFromToken() {
|
async function getUserFromToken() {
|
||||||
const token = await getTokenCookie();
|
const token = await getTokenCookie();
|
||||||
@@ -58,3 +58,49 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const habitId = parseInt(id);
|
||||||
|
|
||||||
|
if (isNaN(habitId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify habit belongs to user
|
||||||
|
const habitRows = await db
|
||||||
|
.select()
|
||||||
|
.from(habits)
|
||||||
|
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
|
||||||
|
|
||||||
|
if (habitRows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find latest log
|
||||||
|
const latestLog = await db
|
||||||
|
.select()
|
||||||
|
.from(habitLogs)
|
||||||
|
.where(eq(habitLogs.habitId, habitId))
|
||||||
|
.orderBy(desc(habitLogs.loggedAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (latestLog.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No logs to undo' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete latest log
|
||||||
|
await db.delete(habitLogs).where(eq(habitLogs.id, latestLog[0].id));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Undo log error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
117
app/api/habits/[id]/route.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db, habits, users } from '@/lib/db';
|
||||||
|
import { getTokenCookie } from '@/lib/auth/cookies';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
async function getUserFromToken() {
|
||||||
|
const token = await getTokenCookie();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const userRows = await db.select().from(users).where(eq(users.token, token));
|
||||||
|
return userRows.length > 0 ? userRows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const habitId = parseInt(id);
|
||||||
|
|
||||||
|
if (isNaN(habitId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify habit belongs to user
|
||||||
|
const habitRows = await db
|
||||||
|
.select()
|
||||||
|
.from(habits)
|
||||||
|
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
|
||||||
|
|
||||||
|
if (habitRows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
targetFrequency?: { value: number; period: 'day' | 'week' | 'month' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { name, type, color, icon, targetFrequency } = body;
|
||||||
|
|
||||||
|
// Validate type if provided
|
||||||
|
if (type && !['positive', 'neutral', 'negative'].includes(type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Type must be one of: positive, neutral, negative' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedHabitRows = await db
|
||||||
|
.update(habits)
|
||||||
|
.set({
|
||||||
|
...(name && { name }),
|
||||||
|
...(type && { type: type as 'positive' | 'neutral' | 'negative' }),
|
||||||
|
...(color && { color }),
|
||||||
|
...(icon && { icon }),
|
||||||
|
...(targetFrequency && { targetFrequency }),
|
||||||
|
})
|
||||||
|
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({ habit: updatedHabitRows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update habit error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const habitId = parseInt(id);
|
||||||
|
|
||||||
|
if (isNaN(habitId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserFromToken();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify habit belongs to user
|
||||||
|
const habitRows = await db
|
||||||
|
.select()
|
||||||
|
.from(habits)
|
||||||
|
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
|
||||||
|
|
||||||
|
if (habitRows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete (archive)
|
||||||
|
await db
|
||||||
|
.update(habits)
|
||||||
|
.set({ isArchived: true, archivedAt: new Date() })
|
||||||
|
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete habit error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -33,26 +33,22 @@ export async function GET() {
|
|||||||
// Then get aggregated log data for each habit
|
// Then get aggregated log data for each habit
|
||||||
const habitsWithStats = await Promise.all(
|
const habitsWithStats = await Promise.all(
|
||||||
userHabitsBase.map(async (habit) => {
|
userHabitsBase.map(async (habit) => {
|
||||||
// Get all logs for this habit
|
// Get all logs for this habit, ordered by date desc
|
||||||
const logs = await db
|
const logs = await db
|
||||||
.select({
|
.select({
|
||||||
|
id: habitLogs.id,
|
||||||
loggedAt: habitLogs.loggedAt,
|
loggedAt: habitLogs.loggedAt,
|
||||||
})
|
})
|
||||||
.from(habitLogs)
|
.from(habitLogs)
|
||||||
.where(eq(habitLogs.habitId, habit.id));
|
.where(eq(habitLogs.habitId, habit.id))
|
||||||
|
.orderBy(desc(habitLogs.loggedAt));
|
||||||
|
|
||||||
// Calculate statistics
|
// Calculate statistics
|
||||||
const totalLogs = logs.length;
|
const totalLogs = logs.length;
|
||||||
const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length;
|
const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length;
|
||||||
const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length;
|
const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length;
|
||||||
const lastLoggedAt =
|
const lastLoggedAt = logs.length > 0 ? logs[0].loggedAt : null;
|
||||||
logs.length > 0
|
|
||||||
? logs.reduce(
|
|
||||||
(latest, log) => (log.loggedAt > latest ? log.loggedAt : latest),
|
|
||||||
logs[0].loggedAt,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: habit.id,
|
id: habit.id,
|
||||||
name: habit.name,
|
name: habit.name,
|
||||||
|
|||||||
BIN
app/apple-icon.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
128
app/dashboard/page.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import Dashboard from './page';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock ResizeObserver
|
||||||
|
global.ResizeObserver = class ResizeObserver {
|
||||||
|
observe(): void {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
unobserve(): void {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
disconnect(): void {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
const createTestQueryClient = () =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dashboard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock Auth Response
|
||||||
|
vi.mocked(global.fetch).mockImplementation((url: string | URL | Request) => {
|
||||||
|
if (url === '/api/auth') {
|
||||||
|
return Promise.resolve({
|
||||||
|
json: () => Promise.resolve({ authenticated: true, token: 'test-token' }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === '/api/habits') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
habits: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Habit',
|
||||||
|
type: 'neutral',
|
||||||
|
totalLogs: 5,
|
||||||
|
logsLastWeek: 2,
|
||||||
|
logsLastMonth: 5,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastLoggedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders habits correctly', async () => {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<Dashboard />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Habit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens edit dialog when edit button is clicked', async () => {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<Dashboard />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Habit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editBtn = screen.getByTestId('edit-habit-1');
|
||||||
|
fireEvent.click(editBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Edit Habit')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('Test Habit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens archive confirmation when archive button is clicked', async () => {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<Dashboard />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Habit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open Edit Dialog
|
||||||
|
fireEvent.click(screen.getByTestId('edit-habit-1'));
|
||||||
|
await waitFor(() => screen.getByRole('dialog'));
|
||||||
|
|
||||||
|
// Click Archive
|
||||||
|
fireEvent.click(screen.getByText('Archive Habit'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Confirm Delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Trophy,
|
Trophy,
|
||||||
HeartCrack,
|
HeartCrack,
|
||||||
|
MoreVertical,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -73,9 +77,17 @@ interface HabitResponse {
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State
|
||||||
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
|
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
|
||||||
const [newHabitName, setNewHabitName] = useState('');
|
const [newHabitName, setNewHabitName] = useState('');
|
||||||
const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
|
const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
|
||||||
|
|
||||||
|
const [editingHabit, setEditingHabit] = useState<Habit | null>(null);
|
||||||
|
const [editHabitName, setEditHabitName] = useState('');
|
||||||
|
const [editHabitType, setEditHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
const [copiedToken, setCopiedToken] = useState(false);
|
const [copiedToken, setCopiedToken] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||||
|
|
||||||
@@ -92,11 +104,11 @@ export default function Dashboard() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update current time periodically to avoid impure Date.now() calls during render
|
// Update current time periodically
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setCurrentTime(Date.now());
|
setCurrentTime(Date.now());
|
||||||
}, 60000); // Update every minute
|
}, 60000);
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
@@ -129,6 +141,19 @@ export default function Dashboard() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Undo log mutation
|
||||||
|
const undoLogMutation = useMutation<unknown, Error, number>({
|
||||||
|
mutationFn: async (habitId: number): Promise<void> => {
|
||||||
|
const res = await fetch(`/api/habits/${String(habitId)}/log`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to undo log');
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['habits'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Create habit mutation
|
// Create habit mutation
|
||||||
const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({
|
const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({
|
||||||
mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => {
|
mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => {
|
||||||
@@ -148,6 +173,42 @@ export default function Dashboard() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update habit mutation
|
||||||
|
const updateHabitMutation = useMutation<
|
||||||
|
HabitResponse,
|
||||||
|
Error,
|
||||||
|
{ id: number; name: string; type: string }
|
||||||
|
>({
|
||||||
|
mutationFn: async ({ id, name, type }): Promise<HabitResponse> => {
|
||||||
|
const res = await fetch(`/api/habits/${String(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, type }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update habit');
|
||||||
|
return res.json() as Promise<HabitResponse>;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['habits'] });
|
||||||
|
setEditingHabit(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete habit mutation
|
||||||
|
const deleteHabitMutation = useMutation<unknown, Error, number>({
|
||||||
|
mutationFn: async (id: number): Promise<void> => {
|
||||||
|
const res = await fetch(`/api/habits/${String(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete habit');
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['habits'] });
|
||||||
|
setEditingHabit(null);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleCreateHabit = () => {
|
const handleCreateHabit = () => {
|
||||||
if (newHabitName.trim()) {
|
if (newHabitName.trim()) {
|
||||||
createHabitMutation.mutate({
|
createHabitMutation.mutate({
|
||||||
@@ -157,6 +218,30 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateHabit = () => {
|
||||||
|
if (editingHabit && editHabitName.trim()) {
|
||||||
|
updateHabitMutation.mutate({
|
||||||
|
id: editingHabit.id,
|
||||||
|
name: editHabitName.trim(),
|
||||||
|
type: editHabitType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteHabit = () => {
|
||||||
|
if (editingHabit) {
|
||||||
|
deleteHabitMutation.mutate(editingHabit.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (e: React.MouseEvent, habit: Habit) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingHabit(habit);
|
||||||
|
setEditHabitName(habit.name);
|
||||||
|
setEditHabitType(habit.type);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
};
|
||||||
|
|
||||||
const copyToken = () => {
|
const copyToken = () => {
|
||||||
if (authData?.token) {
|
if (authData?.token) {
|
||||||
void navigator.clipboard.writeText(authData.token);
|
void navigator.clipboard.writeText(authData.token);
|
||||||
@@ -355,7 +440,7 @@ export default function Dashboard() {
|
|||||||
{habits.map((habit: Habit) => (
|
{habits.map((habit: Habit) => (
|
||||||
<Card
|
<Card
|
||||||
key={habit.id}
|
key={habit.id}
|
||||||
className={`transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
|
className={`group relative transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
|
||||||
habit.type,
|
habit.type,
|
||||||
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
|
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -365,19 +450,56 @@ export default function Dashboard() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<CardTitle className="text-lg">{habit.name}</CardTitle>
|
<CardTitle className="text-lg">{habit.name}</CardTitle>
|
||||||
{getHabitIcon(habit.type)}
|
<div className="flex items-center gap-2">
|
||||||
|
{getHabitIcon(habit.type)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/20"
|
||||||
|
onClick={(e) => {
|
||||||
|
openEditDialog(e, habit);
|
||||||
|
}}
|
||||||
|
data-testid={`edit-habit-${String(habit.id)}`}
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Last logged */}
|
{/* Last logged */}
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<Clock className="h-4 w-4 text-zinc-500" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-zinc-400">
|
<Clock className="h-4 w-4 text-zinc-500" />
|
||||||
{habit.lastLoggedAt
|
<span className="text-zinc-400">
|
||||||
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
|
{habit.lastLoggedAt
|
||||||
: 'Never logged'}
|
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
|
||||||
</span>
|
: 'Never logged'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{habit.lastLoggedAt && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-zinc-500 hover:bg-red-950/30 hover:text-red-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
undoLogMutation.mutate(habit.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Undo last log</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="bg-zinc-800" />
|
<Separator className="bg-zinc-800" />
|
||||||
@@ -439,6 +561,121 @@ export default function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Habit Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!editingHabit}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditingHabit(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="border-zinc-800 bg-zinc-950">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Habit</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Modify your habit details or archive it if you no longer want to track it.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="edit-name">Habit Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-name"
|
||||||
|
value={editHabitName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditHabitName(e.target.value);
|
||||||
|
}}
|
||||||
|
className="border-zinc-800 bg-zinc-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="edit-type">Habit Type</Label>
|
||||||
|
<Select
|
||||||
|
value={editHabitType}
|
||||||
|
onValueChange={(value: 'positive' | 'neutral' | 'negative') => {
|
||||||
|
setEditHabitType(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="border-zinc-800 bg-zinc-900">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="border-zinc-800 bg-zinc-900">
|
||||||
|
<SelectItem value="positive">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||||
|
<span>Positive - Something to do more</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="neutral">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4 text-zinc-500" />
|
||||||
|
<span>Neutral - Just tracking</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="negative">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||||
|
<span>Negative - Something to reduce</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex flex-1 justify-start">
|
||||||
|
{showDeleteConfirm ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteHabit}
|
||||||
|
disabled={deleteHabitMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteHabitMutation.isPending ? 'Deleting...' : 'Confirm Delete'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-900 text-red-500 hover:bg-red-950 hover:text-red-400"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Archive Habit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingHabit(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleUpdateHabit}
|
||||||
|
disabled={!editHabitName.trim() || updateHabitMutation.isPending}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{updateHabitMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
BIN
app/favicon.ico
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 15 KiB |
3
app/icon0.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="255.99998" height="255.99998"><svg width="255.99998" height="255.99998" viewBox="0 0 67.733328 67.733329" version="1.1" id="SvgjsSvg1501" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><defs id="SvgjsDefs1500"></defs><g id="SvgjsG1499"><rect style="font-variation-settings:'wght' 800;display:none;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:round" id="SvgjsRect1498" width="67.73333" height="67.73333" x="0" y="0"></rect><circle style="font-variation-settings:'wght' 800;display:inline;fill:#059669;fill-opacity:1;stroke:none;stroke-width:5.27484;stroke-linecap:round;stroke-linejoin:round" id="SvgjsCircle1497" cy="12.297192" cx="45.428497" r="8.4252577"></circle><circle style="font-variation-settings:'wght' 800;display:inline;fill:#d97706;fill-opacity:1;stroke:none;stroke-width:5.27484;stroke-linecap:round;stroke-linejoin:round" id="SvgjsCircle1496" cy="55.690498" cx="20.649494" r="8.4252577"></circle><path d="M 41.252138,22.687586 C 37.704621,21.261737 35.129699,18.124539 34.422921,14.367094 27.263765,12.036843 21.1959,16.3091 21.1959,16.3091 26.828105,11.378756 34.25937,11.488211 34.25937,11.488211 34.4658,8.6374649 35.754453,5.9735431 37.861353,4.0421364 27.616916,4.6289911 20.55393,9.007259 20.55393,9.007259 c 0,0 7.215201,-6.4715959 18.21468,-7.3018404 C 27.909388,0.39887564 15.094082,4.9774108 6.3514003,18.315496 c 4.0103957,-4.912052 8.0242977,-5.536907 8.0242977,-5.536907 0,0 -8.0242977,5.536907 -11.6352968,22.306673 C 6.693033,24.761888 14.134879,20.722399 14.134879,20.722399 c 0,0 -11.3944778,14.362863 -6.4196968,29.448183 -2.2340419,-20.604045 17.5727098,-29.52867 17.5727098,-29.52867 0,0 -9.999614,7.430395 -9.869074,14.604325 6.849011,-12.913208 16.759463,-12.648153 16.759463,-12.648153 0,0 -1.654004,0.160095 -2.797748,3.339924 4.372381,-3.267307 9.297788,-3.366744 11.871607,-3.250413 z" style="font-variation-settings:'wght' 800;display:inline;fill:#10b981;stroke-width:5.27484;stroke-linecap:round;stroke-linejoin:round" id="SvgjsPath1495"></path><path d="M 58.307152,17.632961 C 57.576231,39.826641 38.88902,48.927208 38.88902,48.927208 c 0,0 8.797937,-6.66472 11.674571,-16.28871 -9.182522,13.25164 -17.211482,12.958514 -17.211482,12.958514 0,0 1.23553,-0.13002 3.330258,-3.771331 0,0 -3.711354,3.633872 -11.73317,3.573653 3.398948,1.419962 5.877586,4.426569 6.623169,8.033957 6.622795,1.558117 13.334552,-1.898221 13.334552,-1.898221 0,0 -6.384514,4.403344 -13.142669,5.086176 -0.229172,2.737305 -1.460403,5.293772 -3.45775,7.179499 8.51737,-0.09688 17.40272,-5.405551 17.40272,-5.405551 0,0 -5.901793,5.983606 -18.21468,7.743557 7.503974,0.399663 25.113814,-1.775961 32.296802,-16.610078 -1.747838,3.616163 -8.224552,5.57683 -8.224552,5.57683 0,0 8.224552,-5.57683 11.474321,-22.22683 -2.178474,6.160256 -11.070591,14.364798 -11.070591,14.364798 0,0 11.070591,-14.364798 6.336629,-29.610449 z" style="font-variation-settings:'wght' 800;display:inline;fill:#f59e0b;fill-opacity:1;stroke-width:5.27484;stroke-linecap:round;stroke-linejoin:round" id="SvgjsPath1494"></path></g></svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
BIN
app/icon1.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
@@ -28,6 +28,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className="scroll-smooth">
|
<html lang="en" className="scroll-smooth">
|
||||||
<head>
|
<head>
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Track Every Day" />
|
||||||
<PlausibleProvider
|
<PlausibleProvider
|
||||||
domain="trackevery.day"
|
domain="trackevery.day"
|
||||||
customDomain="https://analytics.schulze.network"
|
customDomain="https://analytics.schulze.network"
|
||||||
|
|||||||
21
app/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Track Every Day",
|
||||||
|
"short_name": "Track",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {};
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
38
package.json
@@ -4,10 +4,13 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next typegen && eslint . && npx tsc --noEmit",
|
"lint": "next typegen && eslint . && npx tsc --noEmit",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
@@ -28,39 +31,46 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cssnano": "^7.1.2",
|
"cssnano": "^7.1.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.45.0",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.561.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next": "16.0.7",
|
"next": "16.0.10",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pg-native": "^3.5.2",
|
"pg-native": "^3.5.2",
|
||||||
"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.1",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "4.1.17",
|
"@playwright/test": "^1.57.0",
|
||||||
"@types/node": "24.10.1",
|
"@tailwindcss/postcss": "4.1.18",
|
||||||
"@types/pg": "8.15.6",
|
"@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": "19.2.7",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"drizzle-kit": "0.31.8",
|
"drizzle-kit": "0.31.8",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.0.7",
|
"eslint-config-next": "16.0.10",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
|
"jsdom": "^27.3.0",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.7.4",
|
"prettier": "3.7.4",
|
||||||
"prettier-plugin-tailwindcss": "0.7.2",
|
"prettier-plugin-tailwindcss": "0.7.2",
|
||||||
"tailwindcss": "4.1.17",
|
"tailwindcss": "4.1.18",
|
||||||
"turbo": "2.6.3",
|
"turbo": "2.6.3",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.48.1"
|
"typescript-eslint": "8.49.0",
|
||||||
|
"vitest": "^4.0.15"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.24.0",
|
"packageManager": "pnpm@10.25.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.7",
|
||||||
|
|||||||
26
playwright.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'pnpm dev',
|
||||||
|
url: 'http://localhost:3000',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
2030
pnpm-lock.yaml
generated
@@ -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 |
@@ -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 |
BIN
public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
12
turbo.json
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://turbo.build/schema.json",
|
|
||||||
"tasks": {
|
|
||||||
"build": {
|
|
||||||
"outputs": [
|
|
||||||
".next/**",
|
|
||||||
"!.next/cache/**"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"type-check": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './'),
|
||||||
|
},
|
||||||
|
// Exclude E2E tests from Vitest
|
||||||
|
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**', '**/tests/e2e/**'],
|
||||||
|
},
|
||||||
|
});
|
||||||
2
vitest.setup.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||