T1/Phase 1: scaffold Vite SPA + Hono API + Bun worker
- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs - apps/worker: Bun worker stub, DB connection, graceful SIGTERM - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference) - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy) - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api - docker-compose.yml: 4-service target (api, spa, db, worker) - packages/db/src/client.ts: shared Drizzle client for api + worker - db/client.ts: root-level alias for convenience Parent: t_e1cbd87d -> t_24c9c3fd (T0)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
'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) => {
|
||||
// Never override native behavior inside controls or modal UI.
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.isContentEditable ||
|
||||
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
|
||||
) {
|
||||
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 === 'g') { router.push('/graph'); e.preventDefault(); return; }
|
||||
if (key === 'r') { router.push('/reports'); e.preventDefault(); return; }
|
||||
if (key === 'c') { router.push('/calendar'); e.preventDefault(); return; }
|
||||
if (key === 's') { router.push('/search'); 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 'c': {
|
||||
// c t — new task (Cmd palette → "New task")
|
||||
// Check if we're on the tasks page
|
||||
if (window.location.pathname.startsWith('/tasks')) {
|
||||
document.dispatchEvent(new CustomEvent('open-create-task', { detail: { status: 'todo' } }));
|
||||
e.preventDefault();
|
||||
}
|
||||
// c h — new habit
|
||||
if (window.location.pathname.startsWith('/habits')) {
|
||||
document.dispatchEvent(new CustomEvent('open-create-habit'));
|
||||
e.preventDefault();
|
||||
}
|
||||
// c p — new project
|
||||
if (window.location.pathname.startsWith('/projects')) {
|
||||
document.dispatchEvent(new CustomEvent('open-create-project'));
|
||||
e.preventDefault();
|
||||
}
|
||||
// c n — new note
|
||||
if (window.location.pathname.startsWith('/notes')) {
|
||||
document.dispatchEvent(new CustomEvent('open-create-note'));
|
||||
e.preventDefault();
|
||||
}
|
||||
// c s — new section (on project detail page)
|
||||
if (window.location.pathname.match(/^\/projects\/[^/]+$/)) {
|
||||
document.dispatchEvent(new CustomEvent('open-create-section'));
|
||||
e.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'e': {
|
||||
// e — edit selected task (when task is focused)
|
||||
const focusedTask = document.querySelector('[data-task-id]:focus');
|
||||
if (focusedTask) {
|
||||
(focusedTask as HTMLElement).click();
|
||||
e.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'd': {
|
||||
// d — delete selected task
|
||||
const deleteBtn = document.querySelector('[data-delete-task]');
|
||||
if (deleteBtn) {
|
||||
(deleteBtn as HTMLElement).click();
|
||||
e.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ' ': {
|
||||
// Space — open task detail panel
|
||||
const firstTask = document.querySelector('[data-task-id]');
|
||||
if (firstTask && window.location.pathname.startsWith('/tasks')) {
|
||||
(firstTask as HTMLElement).click();
|
||||
e.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'escape': {
|
||||
// Esc — close detail panel
|
||||
const closeBtn = document.querySelector('[data-close-panel]');
|
||||
if (closeBtn) {
|
||||
(closeBtn as HTMLElement).click();
|
||||
e.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '1':
|
||||
case '2':
|
||||
case '3':
|
||||
case '4': {
|
||||
// 1/2/3/4 — filter kanban column
|
||||
if (window.location.pathname.startsWith('/tasks')) {
|
||||
const statuses: Record<string, string> = { '1': 'todo', '2': 'in_progress', '3': 'done', '4': 'cancelled' };
|
||||
document.dispatchEvent(new CustomEvent('filter-kanban', { detail: { status: statuses[key] } }));
|
||||
e.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [router, enabled, shortcuts]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user