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
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useKeyboardShortcutsStore } from '@/lib/stores/use-keyboard-shortcuts-store';
export function useKeyboardShortcuts() {
const router = useRouter();
const { shortcuts, enabled } = useKeyboardShortcutsStore();
useEffect(() => {
if (!enabled) return;
let pendingKey = '';
let pendingTimeout: ReturnType<typeof setTimeout>;
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in an input/textarea
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
// Ignore if modifier keys are pressed (except for our shortcuts)
if (e.metaKey || e.ctrlKey || e.altKey) return;
const key = e.key.toLowerCase();
// Handle two-key combos (G then letter)
if (pendingKey) {
clearTimeout(pendingTimeout);
pendingKey = '';
if (key === 'd') { router.push('/dashboard'); e.preventDefault(); return; }
if (key === 't') { router.push('/tasks'); e.preventDefault(); return; }
if (key === 'h') { router.push('/habits'); e.preventDefault(); return; }
if (key === 'p') { router.push('/projects'); e.preventDefault(); return; }
if (key === 'n') { router.push('/notes'); e.preventDefault(); return; }
if (key === 'r') { router.push('/reports'); e.preventDefault(); return; }
if (key === 'c') { router.push('/calendar'); e.preventDefault(); return; }
if (key === 'a') { router.push('/analytics'); e.preventDefault(); return; }
return;
}
// Single key shortcuts
switch (key) {
case 'g':
pendingKey = 'g';
pendingTimeout = setTimeout(() => { pendingKey = ''; }, 1000);
e.preventDefault();
break;
case '/':
// Focus search
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
e.preventDefault();
break;
case 'n':
// New (context-dependent)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
e.preventDefault();
break;
case '?':
// Show shortcuts help
console.log('Show shortcuts help');
e.preventDefault();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [router, enabled, shortcuts]);
}
+127
View File
@@ -0,0 +1,127 @@
'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
export interface RealtimeEvent {
type: string;
collection?: string;
record?: Record<string, unknown>;
}
interface UseRealtimeOptions {
collections?: string[];
onEvent?: (event: RealtimeEvent) => void;
enabled?: boolean;
}
const MAX_RETRY_DELAY = 30000; // 30 seconds max
const INITIAL_RETRY_DELAY = 1000; // 1 second initial
const MAX_RETRIES = 10;
export function useRealtime({
collections,
onEvent,
enabled = true,
}: UseRealtimeOptions = {}) {
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const onEventRef = useRef(onEvent);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
const mountedRef = useRef(true);
onEventRef.current = onEvent;
const cleanup = useCallback(() => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}, []);
const connect = useCallback(() => {
if (!enabled || !mountedRef.current) return;
// Close existing connection
cleanup();
const params = new URLSearchParams();
if (collections && collections.length > 0) {
params.set('collections', collections.join(','));
}
const url = `/api/realtime?${params.toString()}`;
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as RealtimeEvent;
if (data.type === 'connected') {
setConnected(true);
setError(null);
retryCountRef.current = 0; // Reset on successful connection
}
onEventRef.current?.(data);
} catch {
// Ignore parse errors (e.g., ping comments)
}
};
eventSource.onerror = () => {
setConnected(false);
setError('Connection lost');
if (!mountedRef.current) return;
// Exponential backoff reconnection
const retryCount = retryCountRef.current;
if (retryCount < MAX_RETRIES) {
const delay = Math.min(
INITIAL_RETRY_DELAY * Math.pow(2, retryCount),
MAX_RETRY_DELAY
);
retryCountRef.current = retryCount + 1;
retryTimerRef.current = setTimeout(() => {
if (mountedRef.current) {
connect();
}
}, delay);
} else {
setError('Connection failed — max retries reached');
}
};
eventSource.onopen = () => {
setConnected(true);
setError(null);
retryCountRef.current = 0;
};
}, [collections, enabled, cleanup]);
const disconnect = useCallback(() => {
cleanup();
setConnected(false);
retryCountRef.current = MAX_RETRIES; // Prevent auto-reconnect
}, [cleanup]);
useEffect(() => {
mountedRef.current = true;
connect();
return () => {
mountedRef.current = false;
cleanup();
};
}, [connect, cleanup]);
return { connected, error, reconnect: connect, disconnect };
}
+104
View File
@@ -0,0 +1,104 @@
'use client';
import { useEffect } from 'react';
/**
* Reports Core Web Vitals (LCP, CLS, INP) via PerformanceObserver.
* Only runs in production to avoid noise in development.
*/
function reportMetric(name: string, value: number, rating: 'good' | 'needs-improvement' | 'poor') {
if (process.env.NODE_ENV !== 'production') return;
const body = JSON.stringify({
name,
value,
rating,
page: window.location.pathname,
userAgent: navigator.userAgent,
timestamp: Date.now(),
});
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics/vitals', body);
}
}
function getLCPRating(value: number): 'good' | 'needs-improvement' | 'poor' {
if (value <= 2500) return 'good';
if (value <= 4000) return 'needs-improvement';
return 'poor';
}
function getCLSRating(value: number): 'good' | 'needs-improvement' | 'poor' {
if (value <= 0.1) return 'good';
if (value <= 0.25) return 'needs-improvement';
return 'poor';
}
function getINPRating(value: number): 'good' | 'needs-improvement' | 'poor' {
if (value <= 200) return 'good';
if (value <= 500) return 'needs-improvement';
return 'poor';
}
export function useWebVitals() {
useEffect(() => {
if (typeof PerformanceObserver === 'undefined') return;
// LCP — Largest Contentful Paint
const lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1] as unknown as PerformanceEntry & { startTime: number };
if (lastEntry) {
reportMetric('LCP', lastEntry.startTime, getLCPRating(lastEntry.startTime));
}
});
try {
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
} catch {
// Not supported
}
// CLS — Cumulative Layout Shift
let clsValue = 0;
const clsObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!(entry as unknown as { hadRecentInput?: boolean }).hadRecentInput) {
clsValue += (entry as unknown as { value: number }).value;
reportMetric('CLS', clsValue, getCLSRating(clsValue));
}
}
});
try {
clsObserver.observe({ type: 'layout-shift', buffered: true });
} catch {
// Not supported
}
// INP — Interaction to Next Paint
let maxINP = 0;
const inpObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const eventEntry = entry as unknown as { duration: number };
if (eventEntry.duration > maxINP) {
maxINP = eventEntry.duration;
reportMetric('INP', maxINP, getINPRating(maxINP));
}
}
});
try {
inpObserver.observe({ type: 'event', buffered: true });
} catch {
// Not supported
}
return () => {
lcpObserver.disconnect();
clsObserver.disconnect();
inpObserver.disconnect();
};
}, []);
}