refactor: migrate to monorepo structure with Docker, PocketBase, and e2e tests

- Reorganize into apps/, packages/, docs/, e2e/, pocketbase/ directories
- Add Dockerfiles for web, worker, and PocketBase services
- Add docker-compose.yml for local orchestration
- Add turbo.json for monorepo task management
- Add Playwright e2e test infrastructure
- Add PocketBase backend with migrations
- Remove Vite/Next.js/ESLint/PostCSS config files
- Update package.json with workspace dependencies
- Add .env.example and .dockerignore
This commit is contained in:
2026-07-16 06:19:58 -04:00
parent ec14645a4b
commit 8f55626e03
286 changed files with 31992 additions and 9245 deletions
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { Flame, CheckCircle2, Circle } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
interface HabitCardProps {
habit: {
id: string;
name: string;
description?: string;
frequency: 'daily' | 'weekly' | 'custom';
current_streak: number;
best_streak: number;
score: number;
completion_mode: 'quick' | 'detailed';
domain: string;
logged_today: boolean;
};
onComplete: () => void;
}
export function HabitCard({ habit, onComplete }: HabitCardProps) {
return (
<Card className="relative overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-base">{habit.name}</CardTitle>
{habit.description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{habit.description}
</p>
)}
</div>
<Badge variant="outline" className="ml-2 shrink-0">
{habit.domain}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Streak info */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
<span className="font-semibold">{habit.current_streak}</span>
<span className="text-muted-foreground">day streak</span>
</div>
<span className="text-xs text-muted-foreground">
Best: {habit.best_streak}
</span>
</div>
{/* Score */}
<div>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-muted-foreground">Score</span>
<span className="font-semibold">{habit.score}/100</span>
</div>
<Progress value={habit.score} className="h-2" />
</div>
{/* Frequency badge */}
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{habit.frequency}
</Badge>
<Badge variant="secondary" className="text-xs">
{habit.completion_mode}
</Badge>
</div>
{/* Complete button */}
<Button
onClick={onComplete}
variant={habit.logged_today ? 'outline' : 'default'}
className="w-full"
disabled={habit.logged_today}
>
{habit.logged_today ? (
<>
<CheckCircle2 className="mr-2 h-4 w-4 text-green-600" />
Completed today
</>
) : (
<>
<Circle className="mr-2 h-4 w-4" />
Mark complete
</>
)}
</Button>
</CardContent>
</Card>
);
}
@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
interface Habit {
id: string;
name: string;
}
interface HabitCompletionDialogProps {
habit: Habit;
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: { mood?: number; value?: number; notes?: string }) => void;
}
const moods = [
{ value: 5, label: 'Great' },
{ value: 4, label: 'Good' },
{ value: 3, label: 'Okay' },
{ value: 2, label: 'Meh' },
{ value: 1, label: 'Bad' },
];
export function HabitCompletionDialog({
habit,
open,
onOpenChange,
onSubmit,
}: HabitCompletionDialogProps) {
const [mood, setMood] = useState<number | undefined>();
const [quantity, setQuantity] = useState<number | undefined>();
const [notes, setNotes] = useState('');
function handleSubmit() {
onSubmit({
mood,
value: quantity,
notes: notes || undefined,
});
setMood(undefined);
setQuantity(undefined);
setNotes('');
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Log {habit.name}</DialogTitle>
<DialogDescription>
How did it go? (optional you can skip and just log completion)
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Mood picker */}
<div className="space-y-2">
<Label>Mood</Label>
<div className="flex gap-2">
{moods.map((m) => (
<Button
key={m.value}
variant={mood === m.value ? 'default' : 'outline'}
size="sm"
onClick={() => setMood(m.value)}
className="flex-1"
>
{m.label}
</Button>
))}
</div>
</div>
{/* Quantity */}
<div className="space-y-2">
<Label htmlFor="quantity">Quantity (optional)</Label>
<Input
id="quantity"
type="number"
placeholder="e.g., 30 minutes, 10 pages"
value={quantity ?? ''}
onChange={(e) =>
setQuantity(e.target.value ? Number(e.target.value) : undefined)
}
/>
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Notes (optional)</Label>
<Textarea
id="notes"
placeholder="Any thoughts or reflections..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleSubmit} className="flex-1">
Log completion
</Button>
<Button
variant="outline"
onClick={() => onSubmit({})}
className="flex-1"
>
Skip
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,92 @@
'use client';
import { useEffect, useState } from 'react';
import CalendarHeatmap from 'react-calendar-heatmap';
import type { Habit } from '@project-e/shared';
interface HeatmapValue {
date: Date | string;
count: number;
}
interface HabitHeatmapProps {
habits: Habit[];
}
export function HabitHeatmap({ habits }: HabitHeatmapProps) {
const [values, setValues] = useState<HeatmapValue[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchHeatmapData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [habits.length]);
async function fetchHeatmapData() {
try {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const response = await fetch(
`/api/habit-logs?start=${oneYearAgo.toISOString()}`
);
if (response.ok) {
const data = await response.json();
const logs: Array<{ logged_at: string }> = data.items || [];
// Group by date
const byDate: Record<string, number> = {};
logs.forEach((log) => {
const date = new Date(log.logged_at).toISOString().split('T')[0];
byDate[date] = (byDate[date] || 0) + 1;
});
const heatmapValues: HeatmapValue[] = Object.entries(byDate).map(
([date, count]) => ({
date,
count,
})
);
setValues(heatmapValues);
}
} catch (error) {
console.error('Failed to fetch heatmap data:', error);
} finally {
setLoading(false);
}
}
if (loading) {
return <p className="text-sm text-muted-foreground">Loading...</p>;
}
const today = new Date();
const oneYearAgo = new Date();
oneYearAgo.setFullYear(today.getFullYear() - 1);
return (
<div className="overflow-x-auto">
<CalendarHeatmap
startDate={oneYearAgo}
endDate={today}
values={values}
classForValue={(value) => {
if (!value || value.count === 0) return 'heatmap-empty';
if (value.count <= 1) return 'heatmap-scale-1';
if (value.count <= 2) return 'heatmap-scale-2';
if (value.count <= 3) return 'heatmap-scale-3';
return 'heatmap-scale-4';
}}
tooltipDataAttrs={(value) => {
if (!value || value.count === 0) return null;
const date = new Date(value.date).toLocaleDateString();
return {
'data-tip': `${date}: ${value.count} habit${value.count === 1 ? '' : 's'}`,
};
}}
showWeekdayLabels
/>
</div>
);
}