diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fc7ea71 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Build & Publish Unsigned macOS Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build-macos: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install frontend deps + run: | + cd frontend + npm ci + + - name: Install backend deps + run: | + cd backend + npm ci + + - name: Build & package Electron app + run: | + cd frontend + npm run electron:build + + - name: Create GitHub Release + id: create_release + uses: actions/create-release@v1 + with: + tag_name: ${{ github.ref_name || github.sha }} + release_name: Release ${{ github.ref_name || github.sha }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload packaged artifacts to release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + UPLOAD_URL: ${{ steps.create_release.outputs.upload_url }} + run: | + set -euo pipefail + # Upload any dmg/zip files produced by electron-builder + for f in frontend/dist/*.{dmg,zip}; do + [ -f "$f" ] || continue + name=$(basename "$f") + echo "Uploading $f as $name" + curl -sSL -X POST -H "Authorization: token ${GITHUB_TOKEN}" -H "Content-Type: application/octet-stream" --data-binary @"$f" "${UPLOAD_URL}?name=${name}" + done diff --git a/.gitignore b/.gitignore index 9246ea6..d603ba4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,11 @@ node_modules/ # Database files (live data lives in Docker volume) backend/db.json backend/data/ + +# TLS certificates — never commit private keys +nginx/certs/*.pem + +# Environment secrets +.env +.env.local +.env.production.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a7903e --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +Project Hub — Desktop app + backend + +This repository contains a React+Vite frontend packaged as an Electron desktop app, and a small Node/Express backend using lowdb for JSON persistence. Development builds and packaged macOS installer artifacts are stored in `releases/`. + +Quick links + +- Releases: `releases/` (DMG, ZIP, packaged .app) +- Frontend: `frontend/` +- Backend: `backend/` + +Development + +1) Backend (fast, using Docker): + +```bash +# from project root +cd backend +# install deps (if needed) +npm install +# build image and run (example) +docker build -t project-manager-backend:latest . +docker run -d --name project-manager-api -p 4000:4000 \ + -v project_manager_data:/app/data \ + -e NODE_ENV=production -e PORT=4000 -e HOST=0.0.0.0 \ + -e CORS_ORIGIN="http://localhost:5173" -e WRITE_API_KEY="dev-key" \ + --restart unless-stopped project-manager-backend:latest +``` + +Or use `docker compose up -d` if you prefer Compose. + +Endpoints + +- Health: `GET /health` +- API root: `GET /api/projects`, `GET /api/members`, etc. + +Config / env vars + +- `PORT` (default 4000) +- `HOST` (default 0.0.0.0) +- `DB_FILE` (optional path to db file) +- `CORS_ORIGIN` (comma-separated allowed origins) +- `WRITE_API_KEY` (if set, write operations require `x-api-key` header) + +2) Frontend (dev) + +```bash +cd frontend +npm install +npm run electron:dev # starts Vite and launches Electron +``` + +Runtime API URL + +The app now supports runtime API endpoint configuration: + +- In the desktop UI, click **Server** in the top-right and set the API base URL (example: `http://localhost:4000/api`). +- The value is persisted in Electron app storage and used on next requests without rebuilding. +- If no runtime value is set, the app falls back to `VITE_API_URL`, then `http://localhost:4000/api`. + +Write authentication (`WRITE_API_KEY`) + +- If backend sets `WRITE_API_KEY`, all POST/PUT/DELETE endpoints require `x-api-key`. +- Frontend and Electron now propagate this key automatically when provided via: + - `WRITE_API_KEY` (preferred for Electron runtime) + - `VITE_WRITE_API_KEY` (frontend build-time fallback) +- GET endpoints remain readable without API key unless you add additional auth middleware. + +Build & releases + +- Build renderer and package Electron (mac example): + +```bash +cd frontend +npm run electron:build +``` + +- Built artifacts are placed into `frontend/dist/` and this project’s `releases/` directory after packaging. + +- Verified release build (runs persistence check before packaging): + +```bash +npm run build:verified +``` + +- Verified release build with backend restart check: + +```bash +npm run build:verified:restart +``` + +- One-command verified release pipeline (persistence test + frontend build + dist sync + Electron packaging): + +```bash +npm run release:verified +``` + +- Same as above, but also verifies persistence across backend restart: + +```bash +npm run release:verified:restart +``` + +Unsigned macOS Gatekeeper note + +These builds are currently unsigned. macOS Gatekeeper may prevent opening the app. Users can bypass with one of the following (instruct users to accept the risk): + +```bash +# Open by right-clicking the app and choosing "Open", or run: +xattr -r -d com.apple.quarantine "/Applications/Project Hub.app" +# or for a packaged app in releases: +xattr -r -d com.apple.quarantine "releases/Project Hub.app" +``` + +CI / Distribution suggestions + +- Add a GitHub Actions workflow to build and upload unsigned artifacts to GitHub Releases (I can add this). +- To avoid Gatekeeper prompts for wide distribution, sign and notarize the app (requires Apple Developer account and credentials). + +Security & production notes + +- `lowdb` (JSON file) is fine short-term for small teams. For production/many users, migrate to a proper DB (Postgres, managed DB). +- Use `WRITE_API_KEY` or user accounts to protect write operations; always run behind TLS (reverse proxy like nginx/Traefik) for public hosting. + +Need help? + +I can add a GitHub Actions workflow to build/upload unsigned releases, or add signing/notarization steps if you acquire an Apple Developer account. Tell me which and I’ll scaffold it. + +Persistence self-test + +Run this before release to verify that created records are persisted and can be read back: + +```bash +npm run test:persistence +``` + +If your backend is running under Docker Compose and you also want to verify persistence across a backend restart: + +```bash +npm run test:persistence:restart +``` + +Optional env vars: + +- `API_BASE` (default `http://localhost:4000/api`) +- `WRITE_API_KEY` (needed if backend write endpoints are protected) + +Wiring smoke test + +Run a focused integration check for task/member/invite route wiring: + +```bash +npm run test:wiring +``` + +Optional env vars for this test: + +- `API_BASE` (default `http://localhost:4000/api`) +- `WRITE_API_KEY` (required if write endpoints are protected) \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index b26e6e1..71a3c56 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,12 +1,20 @@ FROM node:20-alpine +ENV NODE_ENV=production + WORKDIR /app COPY package*.json ./ -RUN npm install --omit=dev +RUN apk add --no-cache curl && npm install --omit=dev COPY . . +RUN chown -R node:node /app + +USER node EXPOSE 4000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:4000/health || exit 1 + CMD ["node", "index.js"] diff --git a/backend/index.js b/backend/index.js index a8e95fb..4b31f14 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,45 +1,254 @@ import express from 'express'; import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import rateLimit from 'express-rate-limit'; import { Low } from 'lowdb'; import { JSONFile } from 'lowdb/node'; -import { join, dirname } from 'path'; +import { join, dirname, isAbsolute, extname } from 'path'; import { fileURLToPath } from 'url'; +import { mkdirSync, unlinkSync, existsSync, readFileSync } from 'fs'; +import { randomBytes, randomUUID } from 'crypto'; +import multer from 'multer'; +import nodemailer from 'nodemailer'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dataDir = join(__dirname, 'data'); -import { mkdirSync } from 'fs'; -mkdirSync(dataDir, { recursive: true }); -const dbFile = join(dataDir, 'db.json'); + +const dbFileEnv = process.env.DB_FILE; +const dbFile = dbFileEnv ? (isAbsolute(dbFileEnv) ? dbFileEnv : join(dataDir, dbFileEnv)) : join(dataDir, 'db.json'); +const legacyDbFile = join(__dirname, 'db.json'); +mkdirSync(dirname(dbFile), { recursive: true }); const adapter = new JSONFile(dbFile); -const defaultData = { members: [], projects: [], tasks: [] }; +const defaultData = { members: [], projects: [], tasks: [], invites: [] }; const db = new Low(adapter, defaultData); -await db.read(); +const safeReadJson = (filePath) => { + try { + if (!existsSync(filePath)) return null; + const raw = readFileSync(filePath, 'utf8').trim(); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +}; + +const toInitials = (name = '') => + String(name) + .split(' ') + .filter(Boolean) + .map(w => w[0]) + .join('') + .toUpperCase() + .slice(0, 2); + +const ensureProjectShape = (project) => { + if (!Array.isArray(project.members)) project.members = []; + if (!Array.isArray(project.tasks)) project.tasks = []; + if (!Array.isArray(project.milestones)) project.milestones = []; +}; + +try { + await db.read(); +} catch (err) { + console.warn('Primary db file was unreadable, reinitializing with defaults.', err?.message || err); + db.data = { ...defaultData }; + await db.write(); +} +if (!db.data) db.data = defaultData; +if (!db.data.invites) db.data.invites = []; +if (!db.data.members) db.data.members = []; +if (!db.data.projects) db.data.projects = []; +if (!db.data.tasks) db.data.tasks = []; + +// One-time bootstrap from legacy backend/db.json when the runtime db is empty. +const runtimeHasData = (db.data.members.length + db.data.projects.length + db.data.invites.length) > 0; +if (!runtimeHasData && dbFile !== legacyDbFile) { + const legacy = safeReadJson(legacyDbFile); + if (legacy && typeof legacy === 'object') { + db.data.members = Array.isArray(legacy.members) ? legacy.members : []; + db.data.projects = Array.isArray(legacy.projects) ? legacy.projects : []; + db.data.invites = Array.isArray(legacy.invites) ? legacy.invites : []; + db.data.tasks = Array.isArray(legacy.tasks) ? legacy.tasks : []; + } +} + +// Normalize projects and migrate any legacy flat tasks into nested project.tasks. +for (const project of db.data.projects) ensureProjectShape(project); +for (const task of db.data.tasks) { + const project = db.data.projects.find(p => p.id === task.projectId); + if (!project) continue; + ensureProjectShape(project); + const existing = project.tasks.find(t => t.id === task.id); + if (existing) continue; + project.tasks.push({ + id: task.id || randomUUID(), + title: task.title || 'Untitled Task', + description: task.description || '', + status: task.status || 'todo', + priority: task.priority || 'medium', + dueDate: task.dueDate || '', + startDate: task.startDate || '', + assignedTo: task.assignedTo || task.memberId || '', + estimatedHours: task.estimatedHours, + actualHours: task.actualHours, + recurrence: task.recurrence || 'none', + subtasks: Array.isArray(task.subtasks) ? task.subtasks : [], + attachments: Array.isArray(task.attachments) ? task.attachments : [], + }); +} +db.data.tasks = []; + +for (const project of db.data.projects) { + ensureProjectShape(project); + project.members = project.members + .map(member => { + if (member && typeof member === 'object') { + return { + ...member, + initials: member.initials || toInitials(member.name), + }; + } + const roster = db.data.members.find(m => m.id === member); + if (!roster) return null; + return { ...roster, initials: roster.initials || toInitials(roster.name) }; + }) + .filter(Boolean); +} await db.write(); +// ── File uploads (multer) ─────────────────────────────────────────────────── +const UPLOAD_DIR = join(__dirname, 'uploads'); +mkdirSync(UPLOAD_DIR, { recursive: true }); + +const ALLOWED_MIME = new Set([ + 'image/jpeg','image/png','image/gif','image/webp','image/svg+xml', + 'application/pdf','text/plain','text/csv','text/markdown', + 'application/json', + 'application/zip','application/x-zip-compressed', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx +]); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, UPLOAD_DIR), + filename: (req, file, cb) => cb(null, randomUUID() + extname(file.originalname).toLowerCase()), +}); +const upload = multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB + fileFilter: (req, file, cb) => { + if (ALLOWED_MIME.has(file.mimetype)) cb(null, true); + else cb(new Error('File type not allowed')); + }, +}); + +// ── Notification helper ──────────────────────────────────────────────────── +async function notify({ webhookUrl, notifyEmail, subject, text, payload }) { + if (webhookUrl) { + try { + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload || { subject, text }), + signal: AbortSignal.timeout(5000), + }); + } catch (err) { + console.warn('Webhook notification failed', err?.message || err); + } + } + if (notifyEmail && process.env.SMTP_HOST) { + try { + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT) || 587, + secure: process.env.SMTP_SECURE === 'true', + auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined, + }); + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@projecthub.local', + to: notifyEmail, + subject, + text, + }); + } catch (err) { + console.warn('Email notification failed', err?.message || err); + } + } +} + const app = express(); -app.use(cors()); +app.use(helmet()); +app.use(morgan(process.env.MORGAN_FORMAT || 'combined')); + +const configuredOrigins = (process.env.CORS_ORIGIN || '') + .split(',') + .map(o => o.trim()) + .filter(Boolean); +const allowedOrigins = configuredOrigins.length + ? configuredOrigins + : ['http://localhost:5173', 'http://localhost:4000', 'null']; + +const corsOptions = { + credentials: true, + origin: (origin, callback) => { + if (!origin) return callback(null, true); + if (origin === 'null' || origin.startsWith('file://')) return callback(null, true); + if (allowedOrigins.includes(origin)) return callback(null, true); + return callback(new Error('Not allowed by CORS')); + }, +}; +app.use(cors(corsOptions)); app.use(express.json()); +const limiter = rateLimit({ + windowMs: Number(process.env.RATE_WINDOW_MS) || 15 * 60 * 1000, + max: Number(process.env.RATE_MAX) || 200, +}); + +const requireApiKey = (req, res, next) => { + const key = process.env.WRITE_API_KEY; + if (!key) return next(); + const provided = req.get('x-api-key') || req.query.api_key; + if (provided === key) return next(); + return res.status(401).json({ error: 'Unauthorized' }); +}; + +// Health +app.get('/health', async (req, res) => { + try { + await db.read(); + return res.json({ status: 'ok', timestamp: Date.now() }); + } catch (err) { + return res.status(500).json({ status: 'error' }); + } +}); + // Member CRUD app.get('/api/members', (req, res) => { res.json(db.data.members); }); -app.post('/api/members', (req, res) => { +app.post('/api/members', limiter, requireApiKey, async (req, res) => { const member = { id: Date.now().toString(), ...req.body }; + member.initials = member.initials || toInitials(member.name); db.data.members.push(member); - db.write(); + await db.write(); res.status(201).json(member); }); -app.delete('/api/members/:id', (req, res) => { +app.delete('/api/members/:id', limiter, requireApiKey, async (req, res) => { db.data.members = db.data.members.filter(m => m.id !== req.params.id); - db.write(); + for (const project of db.data.projects) { + ensureProjectShape(project); + project.members = project.members.filter(m => m.id !== req.params.id); + } + await db.write(); res.status(204).end(); }); -app.put('/api/members/:id', async (req, res) => { +app.put('/api/members/:id', limiter, requireApiKey, async (req, res) => { const idx = db.data.members.findIndex(m => m.id === req.params.id); if (idx === -1) return res.status(404).json({ error: 'Member not found' }); db.data.members[idx] = { ...db.data.members[idx], ...req.body, id: req.params.id }; @@ -52,55 +261,263 @@ app.get('/api/projects', (req, res) => { res.json(db.data.projects); }); -app.post('/api/projects', (req, res) => { - const project = { id: Date.now().toString(), members: [], ...req.body }; +app.post('/api/projects', limiter, requireApiKey, async (req, res) => { + const project = { id: Date.now().toString(), members: [], tasks: [], milestones: [], ...req.body }; + ensureProjectShape(project); db.data.projects.push(project); - db.write(); + await db.write(); res.status(201).json(project); }); -app.put('/api/projects/:id', async (req, res) => { +app.put('/api/projects/:id', limiter, requireApiKey, async (req, res) => { const idx = db.data.projects.findIndex(p => p.id === req.params.id); const updated = { ...req.body, id: req.params.id }; + ensureProjectShape(updated); if (idx === -1) db.data.projects.push(updated); else db.data.projects[idx] = updated; await db.write(); res.json(updated); }); -app.delete('/api/projects/:id', (req, res) => { +app.delete('/api/projects/:id', limiter, requireApiKey, async (req, res) => { db.data.projects = db.data.projects.filter(p => p.id !== req.params.id); - db.write(); + await db.write(); res.status(204).end(); }); // Assign member to project -app.post('/api/projects/:projectId/members/:memberId', (req, res) => { +app.post('/api/projects/:projectId/members/:memberId', limiter, requireApiKey, async (req, res) => { const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); - if (!project.members.includes(req.params.memberId)) { - project.members.push(req.params.memberId); - db.write(); + ensureProjectShape(project); + const member = db.data.members.find(m => m.id === req.params.memberId); + if (!member) return res.status(404).json({ error: 'Member not found' }); + + const alreadyAssigned = project.members.some(m => m.id === member.id); + if (!alreadyAssigned) { + project.members.push({ ...member, initials: member.initials || toInitials(member.name) }); + await db.write(); } res.json(project); }); // Tasks (assign to member in project) -app.post('/api/projects/:projectId/tasks', (req, res) => { - const { title, description, memberId } = req.body; +app.post('/api/projects/:projectId/tasks', limiter, requireApiKey, async (req, res) => { + const { title, description, memberId, assignedTo } = req.body; + const assigneeId = assignedTo || memberId || ''; const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); - if (!project.members.includes(memberId)) return res.status(400).json({ error: 'Member not assigned to project' }); - const task = { id: Date.now().toString(), projectId: project.id, memberId, title, description, status: 'todo' }; - db.data.tasks.push(task); - db.write(); + ensureProjectShape(project); + if (assigneeId && !project.members.some(m => m.id === assigneeId)) { + return res.status(400).json({ error: 'Member not assigned to project' }); + } + + const task = { + id: randomUUID(), + title: String(title || '').trim() || 'Untitled Task', + description: description || '', + status: req.body.status || 'todo', + priority: req.body.priority || 'medium', + dueDate: req.body.dueDate || '', + startDate: req.body.startDate || '', + assignedTo: assigneeId, + estimatedHours: req.body.estimatedHours, + actualHours: req.body.actualHours, + recurrence: req.body.recurrence || 'none', + subtasks: Array.isArray(req.body.subtasks) ? req.body.subtasks : [], + attachments: Array.isArray(req.body.attachments) ? req.body.attachments : [], + }; + + project.tasks.push(task); + await db.write(); res.status(201).json(task); }); app.get('/api/projects/:projectId/tasks', (req, res) => { - const tasks = db.data.tasks.filter(t => t.projectId === req.params.projectId); - res.json(tasks); + const project = db.data.projects.find(p => p.id === req.params.projectId); + if (!project) return res.status(404).json({ error: 'Project not found' }); + ensureProjectShape(project); + res.json(project.tasks); +}); + +app.put('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => { + const project = db.data.projects.find(p => p.id === req.params.projectId); + if (!project) return res.status(404).json({ error: 'Project not found' }); + ensureProjectShape(project); + const idx = project.tasks.findIndex(t => t.id === req.params.taskId); + if (idx === -1) return res.status(404).json({ error: 'Task not found' }); + + const assigneeId = req.body.assignedTo || req.body.memberId || project.tasks[idx].assignedTo || ''; + if (assigneeId && !project.members.some(m => m.id === assigneeId)) { + return res.status(400).json({ error: 'Member not assigned to project' }); + } + + project.tasks[idx] = { + ...project.tasks[idx], + ...req.body, + id: req.params.taskId, + assignedTo: assigneeId, + }; + await db.write(); + res.json(project.tasks[idx]); +}); + +app.delete('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => { + const project = db.data.projects.find(p => p.id === req.params.projectId); + if (!project) return res.status(404).json({ error: 'Project not found' }); + ensureProjectShape(project); + const idx = project.tasks.findIndex(t => t.id === req.params.taskId); + if (idx === -1) return res.status(404).json({ error: 'Task not found' }); + project.tasks.splice(idx, 1); + await db.write(); + res.status(204).end(); +}); + +// ── Static uploads ────────────────────────────────────────────────────────── +app.use('/uploads', express.static(UPLOAD_DIR)); + +// ── File attachments ──────────────────────────────────────────────────────── +// Attachments are stored inline in project.tasks[].attachments[] + +app.post('/api/projects/:projectId/tasks/:taskId/attachments', limiter, requireApiKey, (req, res, next) => { + upload.single('file')(req, res, async err => { + if (err) return res.status(400).json({ error: err.message }); + if (!req.file) return res.status(400).json({ error: 'No file provided' }); + + const project = db.data.projects.find(p => p.id === req.params.projectId); + if (!project) return res.status(404).json({ error: 'Project not found' }); + ensureProjectShape(project); + const task = project.tasks.find(t => t.id === req.params.taskId); + if (!task) return res.status(404).json({ error: 'Task not found' }); + + if (!task.attachments) task.attachments = []; + const attachment = { + id: randomUUID(), + filename: req.file.filename, + originalName: req.file.originalname, + mimetype: req.file.mimetype, + size: req.file.size, + url: `/uploads/${req.file.filename}`, + uploadedAt: new Date().toISOString(), + }; + task.attachments.push(attachment); + await db.write(); + res.status(201).json(attachment); + }); +}); + +app.delete('/api/projects/:projectId/tasks/:taskId/attachments/:attachmentId', limiter, requireApiKey, async (req, res) => { + const project = db.data.projects.find(p => p.id === req.params.projectId); + if (!project) return res.status(404).json({ error: 'Project not found' }); + ensureProjectShape(project); + const task = project.tasks.find(t => t.id === req.params.taskId); + if (!task) return res.status(404).json({ error: 'Task not found' }); + + const idx = task.attachments?.findIndex(a => a.id === req.params.attachmentId); + if (idx === undefined || idx === -1) return res.status(404).json({ error: 'Attachment not found' }); + + const [removed] = task.attachments.splice(idx, 1); + // Delete file from disk + const filePath = join(UPLOAD_DIR, removed.filename); + if (existsSync(filePath)) { try { unlinkSync(filePath); } catch {} } + + await db.write(); + res.status(204).end(); +}); + +// ── Webhook/notification settings on project ──────────────────────────────── +// Projects can have optional webhookUrl and notifyEmail; these are just +// stored as part of the project doc. The POST /api/projects/:id/notify endpoint +// fires a test notification. +app.post('/api/projects/:id/notify', limiter, requireApiKey, async (req, res) => { + const project = db.data.projects.find(p => p.id === req.params.id); + if (!project) return res.status(404).json({ error: 'Project not found' }); + const { subject = 'Project Hub notification', text = `Update for project: ${project.name}` } = req.body; + notify({ webhookUrl: project.webhookUrl, notifyEmail: project.notifyEmail, subject, text, payload: { projectId: project.id, projectName: project.name, subject, text } }); + res.json({ sent: true }); +}); + +// ── Invite system ──────────────────────────────────────────────────────────── +// Generate an invite token for a project +app.post('/api/projects/:id/invites', limiter, requireApiKey, async (req, res) => { + const project = db.data.projects.find(p => p.id === req.params.id); + if (!project) return res.status(404).json({ error: 'Project not found' }); + + const token = randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + const invite = { + id: randomUUID(), + token, + projectId: project.id, + projectName: project.name, + email: req.body.email || null, + createdAt: new Date().toISOString(), + expiresAt: expiresAt.toISOString(), + acceptedAt: null, + }; + db.data.invites.push(invite); + await db.write(); + + // Fire email if SMTP configured and email provided + if (req.body.email) { + const appUrl = process.env.APP_URL || 'http://localhost:5173'; + notify({ + notifyEmail: req.body.email, + subject: `You've been invited to "${project.name}"`, + text: `You've been invited to join the project "${project.name}".\n\nAccept your invitation:\n${appUrl}/invite/${token}\n\nThis link expires in 7 days.`, + }); + } + + res.status(201).json({ token, expiresAt: invite.expiresAt, inviteId: invite.id }); +}); + +// Validate an invite token (public) +app.get('/api/invites/:token', (req, res) => { + const invite = db.data.invites.find(i => i.token === req.params.token); + if (!invite) return res.status(404).json({ error: 'Invite not found or expired' }); + if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' }); + if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' }); + res.json({ projectId: invite.projectId, projectName: invite.projectName, email: invite.email, expiresAt: invite.expiresAt }); +}); + +// Accept an invite (attaches provided member name to project if given) +app.post('/api/invites/:token/accept', limiter, async (req, res) => { + const invite = db.data.invites.find(i => i.token === req.params.token); + if (!invite) return res.status(404).json({ error: 'Invite not found' }); + if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' }); + if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' }); + + invite.acceptedAt = new Date().toISOString(); + + // Optionally add the member to the project + if (req.body.name) { + const project = db.data.projects.find(p => p.id === invite.projectId); + if (project) { + ensureProjectShape(project); + const initials = req.body.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2); + const email = (req.body.email || invite.email || '').trim(); + let rosterMember = db.data.members.find(m => email && m.email === email); + if (!rosterMember) { + rosterMember = { + id: randomUUID(), + name: req.body.name, + role: req.body.role || 'Team Member', + initials, + email, + }; + db.data.members.push(rosterMember); + } + + if (!project.members.some(m => m.id === rosterMember.id)) { + project.members.push({ ...rosterMember, initials: rosterMember.initials || toInitials(rosterMember.name) }); + } + } + } + + await db.write(); + res.json({ projectId: invite.projectId, projectName: invite.projectName }); }); const PORT = process.env.PORT || 4000; -app.listen(PORT, () => console.log(`Backend running on port ${PORT}`)); +const HOST = process.env.HOST || '0.0.0.0'; +app.listen(PORT, HOST, () => console.log(`Backend running on ${HOST}:${PORT}`)); diff --git a/backend/package-lock.json b/backend/package-lock.json index b53c0bc..3ebe13f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,7 +10,12 @@ "dependencies": { "cors": "^2.8.5", "express": "^4.18.2", - "lowdb": "^6.0.1" + "express-rate-limit": "^6.8.0", + "helmet": "^6.0.1", + "lowdb": "^6.0.1", + "morgan": "^1.10.0", + "multer": "^2.1.1", + "nodemailer": "^8.0.2" }, "devDependencies": { "nodemon": "^3.0.1" @@ -43,6 +48,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -59,6 +70,24 @@ "node": "18 || 20 || >=22" } }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -122,6 +151,23 @@ "node": ">=8" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -185,6 +231,21 @@ "fsevents": "~2.3.2" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -386,6 +447,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz", + "integrity": "sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==", + "license": "MIT", + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "express": "^4 || ^5" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -555,6 +628,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-6.2.0.tgz", + "integrity": "sha512-DWlwuXLLqbrIOltR6tFQXShj/+7Cyp0gLi6uAb8qMdFh/YBBFbKSgQ6nbXmScYd8emMctuthmgIa7tUfo9Rtyg==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -755,12 +837,59 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -770,6 +899,15 @@ "node": ">= 0.6" } }, + "node_modules/nodemailer": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz", + "integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -867,6 +1005,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -954,6 +1101,20 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1163,6 +1324,23 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -1221,6 +1399,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1237,6 +1421,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index ffb5418..8bc79e0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,12 +5,18 @@ "type": "module", "scripts": { "start": "node index.js", - "dev": "nodemon index.js" + "dev": "nodemon index.js", + "build": "node --check index.js" }, "dependencies": { + "cors": "^2.8.5", "express": "^4.18.2", + "express-rate-limit": "^6.8.0", + "helmet": "^6.0.1", "lowdb": "^6.0.1", - "cors": "^2.8.5" + "morgan": "^1.10.0", + "multer": "^2.1.1", + "nodemailer": "^8.0.2" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/docker-compose.yml b/docker-compose.yml index 2f518c1..95d6aac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: - "${PORT_BACKEND:-4000}:${PORT_BACKEND:-4000}" environment: - PORT=${PORT_BACKEND:-4000} + - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5173,http://localhost:4000,null} + - WRITE_API_KEY=${WRITE_API_KEY:-} volumes: - db_data:/app/data restart: unless-stopped diff --git a/frontend/electron/main.cjs b/frontend/electron/main.cjs index 2ea1648..b02ac1f 100644 --- a/frontend/electron/main.cjs +++ b/frontend/electron/main.cjs @@ -4,8 +4,18 @@ const fs = require('fs') const isDev = process.env.NODE_ENV !== 'production' const devUrl = 'http://localhost:5173' +const API_URL_KEY = 'runtime_api_url' +const DEFAULT_API_URL = process.env.VITE_API_URL || 'http://localhost:4000/api' let mainWindow +function normalizeApiUrl(value) { + if (!value || typeof value !== 'string') return null + const trimmed = value.trim().replace(/\/+$/, '') + if (!trimmed) return null + if (!/^https?:\/\//i.test(trimmed)) return null + return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api` +} + function getStoragePath() { return path.join(app.getPath('userData'), 'storage.json') } @@ -31,9 +41,11 @@ function writeStorage(obj) { } } -ipcMain.handle('storage-get', () => { +ipcMain.handle('storage-get', (event, key) => { const s = readStorage() - return s + if (!key) return s + if (!(key in s)) return null + return { value: s[key] } }) ipcMain.handle('storage-set', (event, key, value) => { @@ -50,7 +62,20 @@ ipcMain.handle('storage-remove', (event, key) => { return true }) -ipcMain.handle('get-api-url', () => process.env.VITE_API_URL || 'http://localhost:4000/api') +ipcMain.handle('get-api-url', () => { + const s = readStorage() + return normalizeApiUrl(s[API_URL_KEY]) || normalizeApiUrl(DEFAULT_API_URL) || 'http://localhost:4000/api' +}) + +ipcMain.handle('set-api-url', (event, value) => { + const next = normalizeApiUrl(value) + if (!next) throw new Error('API URL must be a valid http(s) URL') + + const s = readStorage() + s[API_URL_KEY] = next + writeStorage(s) + return next +}) function createWindow() { mainWindow = new BrowserWindow({ diff --git a/frontend/electron/preload.cjs b/frontend/electron/preload.cjs index 457a5a4..1b130bf 100644 --- a/frontend/electron/preload.cjs +++ b/frontend/electron/preload.cjs @@ -1,7 +1,8 @@ const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('ENV', { - API_URL: process.env.VITE_API_URL || 'http://localhost:4000/api' + API_URL: process.env.VITE_API_URL || 'http://localhost:4000/api', + API_KEY: process.env.WRITE_API_KEY || process.env.VITE_WRITE_API_KEY || '' }) contextBridge.exposeInMainWorld('app', { @@ -10,5 +11,13 @@ contextBridge.exposeInMainWorld('app', { set: (key, value) => ipcRenderer.invoke('storage-set', key, value), remove: (key) => ipcRenderer.invoke('storage-remove', key) }, - getAPIUrl: () => ipcRenderer.invoke('get-api-url') + getAPIUrl: () => ipcRenderer.invoke('get-api-url'), + setAPIUrl: (url) => ipcRenderer.invoke('set-api-url', url) +}) + +// Backward-compatible alias used by the React app persistence wrapper. +contextBridge.exposeInMainWorld('storage', { + get: (key) => ipcRenderer.invoke('storage-get', key), + set: (key, value) => ipcRenderer.invoke('storage-set', key, value), + remove: (key) => ipcRenderer.invoke('storage-remove', key) }) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 01190db..ad8e6f4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,8 @@ import React, { useState, useEffect } from 'react' import { api } from './api.js' +import GanttView from './components/GanttView.jsx' +import CalendarView from './components/CalendarView.jsx' +import BurndownChart from './components/BurndownChart.jsx' // The full App adapted from your original inline HTML (keeps UI and behavior). @@ -116,9 +119,17 @@ function App(){ const [delId, setDelId] = useState(null) const [view, setView] = useState('projects') const [allMembers, setAllMembers] = useState([]) + const [serverOpen, setServerOpen] = useState(false) + const [apiUrlInput, setApiUrlInput] = useState('') + const [apiUrlMsg, setApiUrlMsg] = useState('') useEffect(()=>{ (async()=>{ + try { + const currentApiUrl = await api.getApiUrl() + setApiUrlInput(currentApiUrl) + } catch {} + try { const m = await api.getMembers(); setAllMembers(m) } catch {} try { const bp = await api.getProjects() @@ -158,6 +169,42 @@ function App(){ setDelId(null) api.deleteProject(id).catch(() => {}) } + + const createTaskForProject = async (projectId, taskDraft) => { + try { + const created = await api.createTask(projectId, taskDraft) + setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: [...p.tasks, created] } : p)) + if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, created] } : s) + } catch { + const local = { ...taskDraft, id: uid(), subtasks: taskDraft.subtasks || [], attachments: taskDraft.attachments || [] } + setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: [...p.tasks, local] } : p)) + if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, local] } : s) + } + } + + const updateTaskForProject = async (projectId, taskId, patch) => { + setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) } : p)) + if (sel?.id === projectId) { + setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) } : s) + } + + try { + const updated = await api.updateTask(projectId, taskId, patch) + setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.map(t => t.id === taskId ? updated : t) } : p)) + if (sel?.id === projectId) { + setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? updated : t) } : s) + } + } catch {} + } + + const deleteTaskForProject = async (projectId, taskId) => { + setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.filter(t => t.id !== taskId) } : p)) + if (sel?.id === projectId) { + setSel(s => s ? { ...s, tasks: s.tasks.filter(t => t.id !== taskId) } : s) + } + try { await api.deleteTask(projectId, taskId) } catch {} + } + const change = u => { setProjects(ps => ps.map(p => p.id === u.id ? u : p)) setSel(u) @@ -181,15 +228,22 @@ function App(){
+
- {view === 'projects' && } +
+ + {view === 'projects' && } +
+ {view === 'mytasks' && { const p = projects.find(x => x.id === id); if(p) { setSel(p); setTab('tasks'); setView('projects') }}} />} {view === 'members' && { const temp = { ...m, id: uid() } @@ -246,7 +300,7 @@ function App(){ }
- {sel && setSel(null)} onEdit={() => setEditing(sel)} onChange={change} allMembers={allMembers} />} + {sel && setSel(null)} onEdit={() => setEditing(sel)} onChange={change} onTaskCreate={createTaskForProject} onTaskUpdate={updateTaskForProject} onTaskDelete={deleteTaskForProject} allMembers={allMembers} />} {editing !== null && setEditing(null)} />} {delId && ( @@ -260,6 +314,38 @@ function App(){ )} + + {serverOpen && ( + +
+
API Server
+
Set the backend base URL used by this app. Example: http://localhost:4000/api
+ setApiUrlInput(e.target.value)} + placeholder="http://localhost:4000/api" + style={{ ...inp(), marginBottom: 10 }} + /> + {apiUrlMsg &&
{apiUrlMsg}
} +
+ + +
+
+
+ )} ) } @@ -308,11 +394,33 @@ function Card({ project: p, onOpen, onEdit, onDel }){ } // ── Detail Panel ── -function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers = [] }){ +function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreate, onTaskUpdate, onTaskDelete, allMembers = [] }){ const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning - const upTask = (id, u) => onChange({ ...p, tasks: p.tasks.map(t => t.id === id ? { ...t, ...u } : t) }) - const addTask = t => onChange({ ...p, tasks: [...p.tasks, { ...t, id: uid(), subtasks: [] }] }) - const delTask = id => onChange({ ...p, tasks: p.tasks.filter(t => t.id !== id) }) + const upTask = (id, u) => { + const task = p.tasks.find(t => t.id === id) + if (!task) return + + const merged = { ...task, ...u } + onTaskUpdate?.(p.id, id, u) + + // Recurring: when marked done, auto-clone with next due date + if (u.status === 'done' && merged.recurrence && merged.recurrence !== 'none' && merged.dueDate) { + const base = new Date(merged.dueDate) + if (merged.recurrence === 'daily') base.setDate(base.getDate() + 1) + if (merged.recurrence === 'weekly') base.setDate(base.getDate() + 7) + if (merged.recurrence === 'monthly') base.setMonth(base.getMonth() + 1) + const next = { + ...merged, + status: 'todo', + dueDate: base.toISOString().slice(0,10), + subtasks: (merged.subtasks || []).map(s => ({ ...s, done: false })), + } + delete next.id + onTaskCreate?.(p.id, next) + } + } + const addTask = t => onTaskCreate?.(p.id, { ...t, subtasks: t.subtasks || [] }) + const delTask = id => onTaskDelete?.(p.id, id) const togMs = id => onChange({ ...p, milestones: p.milestones.map(m => m.id === id ? { ...m, completed: !m.completed } : m) }) const addMs = m => onChange({ ...p, milestones: [...p.milestones, { ...m, id: uid(), completed: false }] }) const delMs = id => onChange({ ...p, milestones: p.milestones.filter(m => m.id !== id) }) @@ -337,16 +445,22 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers
Progress{pr}%
-
- {['tasks','milestones','team'].map(t => ( - +
+ {['tasks','milestones','team','gantt','calendar','burndown'].map(t => ( + ))}
{tab === 'tasks' && } {tab === 'milestones' && } - {tab === 'team' && } + {tab === 'team' && } + {tab === 'gantt' && } + {tab === 'calendar' && upTask(t.id, {})} />} + {tab === 'burndown' && }
@@ -357,10 +471,11 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers // For brevity these components are preserved from the original inline app; include full implementations below. function TasksTab({ project: p, onUpdate, onAdd, onDel }){ + const EMPTY = { title: '', status: 'todo', priority: 'medium', dueDate: '', startDate: '', assignedTo: '', estimatedHours: '', recurrence: 'none' } const [adding, setAdding] = useState(false) - const [nt, setNt] = useState({ title: '', status: 'todo', priority: 'medium', dueDate: '', assignedTo: '' }) + const [nt, setNt] = useState(EMPTY) const [exp, setExp] = useState(null) - const submit = () => { if(!nt.title.trim()) return; onAdd(nt); setNt({ title:'', status:'todo', priority:'medium', dueDate:'', assignedTo:'' }); setAdding(false) } + const submit = () => { if(!nt.title.trim()) return; onAdd({ ...nt, estimatedHours: nt.estimatedHours ? Number(nt.estimatedHours) : undefined }); setNt(EMPTY); setAdding(false) } const groups = { todo: p.tasks.filter(t => t.status === 'todo'), 'in-progress': p.tasks.filter(t => t.status === 'in-progress'), done: p.tasks.filter(t => t.status === 'done') } return (
@@ -375,7 +490,14 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){ - setNt({ ...nt, dueDate: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }} /> +
+
+
Start Date
setNt({ ...nt, startDate: e.target.value })} style={{ ...inp(), padding: '6px 8px' }} />
+
Due Date
setNt({ ...nt, dueDate: e.target.value })} style={{ ...inp(), padding: '6px 8px' }} />
+
+
+
Est. Hours
setNt({ ...nt, estimatedHours: e.target.value })} placeholder="0" style={{ ...inp(), padding: '6px 8px' }} />
+
Recurrence
{p.members.length > 0 && ( )} -
0 ? 0 : 9 }}> +
@@ -393,7 +515,7 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){ groups[st].length > 0 && (
{st === 'in-progress' ? 'In Progress' : st === 'todo' ? 'To Do' : 'Done'} · {groups[st].length}
- {groups[st].map(t => setExp(exp === t.id ? null : t.id)} />)} + {groups[st].map(t => setExp(exp === t.id ? null : t.id)} />)}
) ))} @@ -402,12 +524,27 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){ ) } -function TaskRow({ task: t, color, members = [], onUpdate, onDel, expanded, onToggle }){ +function TaskRow({ task: t, color, members = [], projectId, onUpdate, onDel, expanded, onToggle }){ const pr = PRI[t.priority] || PRI.medium, ov = overdue(t.dueDate) const cycle = e => { e.stopPropagation(); const c = { todo: 'in-progress', 'in-progress': 'done', done: 'todo' }; onUpdate(t.id, { status: c[t.status] }) } - const togSub = sid => onUpdate(t.id, { subtasks: t.subtasks.map(s => s.id === sid ? { ...s, done: !s.done } : s) }) - const addSub = title => onUpdate(t.id, { subtasks: [...t.subtasks, { id: uid(), title, done: false }] }) - const delSub = sid => onUpdate(t.id, { subtasks: t.subtasks.filter(s => s.id !== sid) }) + const togSub = sid => onUpdate(t.id, { subtasks: (t.subtasks||[]).map(s => s.id === sid ? { ...s, done: !s.done } : s) }) + const addSub = title => onUpdate(t.id, { subtasks: [...(t.subtasks||[]), { id: uid(), title, done: false }] }) + const delSub = sid => onUpdate(t.id, { subtasks: (t.subtasks||[]).filter(s => s.id !== sid) }) + + const handleUpload = async e => { + const file = e.target.files?.[0]; if (!file || !projectId) return + try { + const att = await api.uploadAttachment(projectId, t.id, file) + onUpdate(t.id, { attachments: [...(t.attachments||[]), att] }) + } catch { /* show nothing on failure */ } + e.target.value = '' + } + const delAttachment = async id => { + if (!projectId) return + await api.deleteAttachment(projectId, t.id, id).catch(()=>{}) + onUpdate(t.id, { attachments: (t.attachments||[]).filter(a => a.id !== id) }) + } + return (
@@ -419,12 +556,35 @@ function TaskRow({ task: t, color, members = [], onUpdate, onDel, expanded, onTo
{pr.label} {t.dueDate && {fmt(t.dueDate)}} - {t.subtasks.length > 0 && {t.subtasks.filter(s => s.done).length}/{t.subtasks.length}} + {t.estimatedHours > 0 && ⏱{t.estimatedHours}h} + {t.recurrence && t.recurrence !== 'none' && 🔁} + {(t.attachments?.length > 0) && 📎{t.attachments.length}} + {t.subtasks?.length > 0 && {t.subtasks.filter(s => s.done).length}/{t.subtasks.length}} {t.assignedTo && (() => { const m = members.find(x => x.id === t.assignedTo); return m ?
{m.initials}
: null })()}
- {expanded && } + {expanded && ( + <> + + {/* Attachments section */} +
+
Attachments
+ {(t.attachments||[]).map(a => ( +
+ 📎 + {a.originalName} + {(a.size/1024).toFixed(0)}KB + +
+ ))} + +
+ + )}
) } @@ -433,7 +593,7 @@ function SubList({ task, onToggle, onAdd, onDel }){ const [val, setVal] = useState('') return (
- {task.subtasks.map(s => ( + {(task.subtasks||[]).map(s => (
{s.title} @@ -492,6 +652,19 @@ function TeamTab({ project: p, onChange, allMembers = [] }){ const [mode, setMode] = useState('roster') const [nm, setNm] = useState({ name: '', role: '' }) const [rSearch, setRSearch] = useState('') + const [inviting, setInviting] = useState(false) + const [inviteEmail, setInviteEmail] = useState('') + const [inviteResult, setInviteResult] = useState(null) + const [inviteErr, setInviteErr] = useState('') + + const doInvite = async () => { + setInviteErr(''); setInviteResult(null) + try { + const res = await api.createInvite(p.id, inviteEmail) + setInviteResult(res) + setInviteEmail('') + } catch (e) { setInviteErr('Failed to create invite. Is the backend running?') } + } const assigned = new Set(p.members.map(m => m.id)) const available = allMembers.filter(m => !assigned.has(m.id) && @@ -508,7 +681,28 @@ function TeamTab({ project: p, onChange, allMembers = [] }){ return (
- +
+ + +
+ + {/* Invite panel */} + {inviting && ( +
+
Send invite link
+
+ setInviteEmail(e.target.value)} placeholder="colleague@example.com" style={{ ...inp(), flex: 1, padding: '6px 8px' }} onKeyDown={e => e.key === 'Enter' && doInvite()} /> + +
+ {inviteErr &&
{inviteErr}
} + {inviteResult && ( +
+ ✓ Invite created!{inviteResult.token && Token: {inviteResult.token.slice(0,16)}…} + Expires: {new Date(inviteResult.expiresAt).toLocaleDateString()} +
+ )} +
+ )} {adding && (
@@ -658,4 +852,102 @@ function MembersPage({ members, onAdd, onUpdate, onDelete }){ ) } +// ── My Tasks (cross-project view) ── +const PRI_COLOR_MT = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' } + +function MyTasksView({ projects, allMembers, onSelectProject }) { + const [memberId, setMemberId] = useState(allMembers[0]?.id || '') + const [filter, setFilter] = useState('all') + + const member = allMembers.find(m => m.id === memberId) + + // Collect tasks across all projects for this member + const taskItems = [] + projects.forEach(proj => { + proj.tasks.forEach(t => { + if (memberId && t.assignedTo !== memberId) return + taskItems.push({ task: t, project: proj }) + }) + }) + + const filtered = filter === 'all' ? taskItems : taskItems.filter(i => i.task.status === filter) + const todayNow = new Date() + + const groups = { + overdue: filtered.filter(i => i.task.dueDate && new Date(i.task.dueDate) < todayNow && i.task.status !== 'done'), + inProgress: filtered.filter(i => i.task.status === 'in-progress'), + todo: filtered.filter(i => i.task.status === 'todo' && !(i.task.dueDate && new Date(i.task.dueDate) < todayNow)), + done: filtered.filter(i => i.task.status === 'done'), + } + + const renderItem = ({ task: t, project: proj }) => ( +
onSelectProject(proj.id)} + style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 13px', background: '#0d0d1a', border: '1px solid #1e2030', borderRadius: 8, marginBottom: 5, cursor: 'pointer' }} + onMouseEnter={e => e.currentTarget.style.borderColor = '#2d3148'} + onMouseLeave={e => e.currentTarget.style.borderColor = '#1e2030'} + > +
+
+
{t.title}
+
+ {proj.name} + {t.priority} + {t.dueDate && {fmt(t.dueDate)}} + {t.recurrence && t.recurrence !== 'none' && 🔁 {t.recurrence}} +
+
+ {t.estimatedHours > 0 && {t.estimatedHours}h} +
+ ) + + const Section = ({ label, color, items }) => items.length === 0 ? null : ( +
+
{label} · {items.length}
+ {items.map(renderItem)} +
+ ) + + return ( +
+
+ ✅ My Tasks + {taskItems.length} total across {projects.length} project{projects.length !== 1 ? 's' : ''} +
+ + {/* Member picker */} + {allMembers.length > 0 && ( +
+ Showing tasks for: + +
+ )} + + {/* Status filter */} +
+ {['all','todo','in-progress','done'].map(s => ( + + ))} +
+ + {filtered.length === 0 && ( +
+ {memberId ? 'No tasks assigned to this member.' : 'No tasks found.'} +
+ )} + +
+
+
+ {(filter === 'all' || filter === 'done') &&
} +
+ ) +} + export default App diff --git a/frontend/src/api.js b/frontend/src/api.js index 7fb17e5..89f6fbc 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,17 +1,156 @@ -const BASE = (typeof window !== 'undefined' && window.ENV && window.ENV.API_URL) || import.meta.env.VITE_API_URL || 'http://localhost:4000/api' -const h = { 'Content-Type': 'application/json' } -const json = r => { if (!r.ok) throw new Error(r.status); return r.json() } +const API_URL_STORAGE_KEY = 'project_hub_api_url' +const DEFAULT_API_URL = 'http://localhost:4000/api' + +let cachedBaseUrl = null + +const normalizeApiUrl = (value) => { + if (!value || typeof value !== 'string') return null + const trimmed = value.trim().replace(/\/+$/, '') + if (!trimmed) return null + if (!/^https?:\/\//i.test(trimmed)) return null + return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api` +} + +const getEnvApiUrl = () => { + if (typeof window !== 'undefined' && window.ENV?.API_URL) return window.ENV.API_URL + return import.meta.env.VITE_API_URL || DEFAULT_API_URL +} + +const getStoredApiUrl = () => { + if (typeof window === 'undefined') return null + try { + return localStorage.getItem(API_URL_STORAGE_KEY) + } catch { + return null + } +} + +const getWriteApiKey = () => { + if (typeof window !== 'undefined' && window.ENV?.API_KEY) return window.ENV.API_KEY + return import.meta.env.VITE_WRITE_API_KEY || '' +} + +const parseResponse = async (response) => { + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new Error(body || String(response.status)) + } + + if (response.status === 204) return null + const contentType = response.headers.get('content-type') || '' + if (!contentType.includes('application/json')) return null + return response.json() +} + +const getSyncBaseUrl = () => { + if (cachedBaseUrl) return cachedBaseUrl + const candidate = normalizeApiUrl(getStoredApiUrl()) || normalizeApiUrl(getEnvApiUrl()) || DEFAULT_API_URL + cachedBaseUrl = candidate + return cachedBaseUrl +} + +const getBaseUrl = async () => { + if (cachedBaseUrl) return cachedBaseUrl + + let runtimeUrl = null + if (typeof window !== 'undefined' && window.app?.getAPIUrl) { + runtimeUrl = await window.app.getAPIUrl().catch(() => null) + } + + cachedBaseUrl = + normalizeApiUrl(runtimeUrl) || + normalizeApiUrl(getStoredApiUrl()) || + normalizeApiUrl(getEnvApiUrl()) || + DEFAULT_API_URL + + return cachedBaseUrl +} + +const request = async (path, { method = 'GET', body, write = false, formData = false } = {}) => { + const base = await getBaseUrl() + const headers = {} + + if (!formData) headers['Content-Type'] = 'application/json' + if (write) { + const key = getWriteApiKey() + if (key) headers['x-api-key'] = key + } + + const options = { method, headers } + if (body !== undefined) options.body = formData ? body : JSON.stringify(body) + + const response = await fetch(`${base}${path}`, options) + return parseResponse(response) +} + +const toAbsoluteUrl = (maybeRelativeUrl) => { + if (!maybeRelativeUrl) return '' + if (/^https?:\/\//i.test(maybeRelativeUrl)) return maybeRelativeUrl + const root = getSyncBaseUrl().replace(/\/api$/i, '') + const suffix = maybeRelativeUrl.startsWith('/') ? maybeRelativeUrl : `/${maybeRelativeUrl}` + return `${root}${suffix}` +} export const api = { + getApiUrl: () => getBaseUrl(), + setApiUrl: async (url) => { + const normalized = normalizeApiUrl(url) + if (!normalized) throw new Error('API URL must be http(s) and non-empty') + + if (typeof window !== 'undefined') { + try { localStorage.setItem(API_URL_STORAGE_KEY, normalized) } catch {} + if (window.app?.setAPIUrl) await window.app.setAPIUrl(normalized) + } + + cachedBaseUrl = normalized + return normalized + }, + toAbsoluteUrl, + // Members - getMembers: () => fetch(`${BASE}/members`).then(json), - addMember: d => fetch(`${BASE}/members`, { method: 'POST', headers: h, body: JSON.stringify(d) }).then(json), - updateMember: (id, d) => fetch(`${BASE}/members/${id}`, { method: 'PUT', headers: h, body: JSON.stringify(d) }).then(json), - deleteMember: id => fetch(`${BASE}/members/${id}`, { method: 'DELETE' }), + getMembers: () => request('/members'), + addMember: d => request('/members', { method: 'POST', body: d, write: true }), + updateMember: (id, d) => request(`/members/${id}`, { method: 'PUT', body: d, write: true }), + deleteMember: id => request(`/members/${id}`, { method: 'DELETE', write: true }), // Projects - getProjects: () => fetch(`${BASE}/projects`).then(json), - createProject: d => fetch(`${BASE}/projects`, { method: 'POST', headers: h, body: JSON.stringify(d) }).then(json), - updateProject: (id,d) => fetch(`${BASE}/projects/${id}`, { method: 'PUT', headers: h, body: JSON.stringify(d) }), - deleteProject: id => fetch(`${BASE}/projects/${id}`, { method: 'DELETE' }), + getProjects: () => request('/projects'), + createProject: d => request('/projects', { method: 'POST', body: d, write: true }), + updateProject: (id, d) => request(`/projects/${id}`, { method: 'PUT', body: d, write: true }), + deleteProject: id => request(`/projects/${id}`, { method: 'DELETE', write: true }), + + // Project members + assignMemberToProject: (projectId, memberId) => + request(`/projects/${projectId}/members/${memberId}`, { method: 'POST', write: true }), + + // Tasks + getTasks: projectId => request(`/projects/${projectId}/tasks`), + createTask: (projectId, data) => request(`/projects/${projectId}/tasks`, { method: 'POST', body: data, write: true }), + updateTask: (projectId, taskId, data) => + request(`/projects/${projectId}/tasks/${taskId}`, { method: 'PUT', body: data, write: true }), + deleteTask: (projectId, taskId) => request(`/projects/${projectId}/tasks/${taskId}`, { method: 'DELETE', write: true }), + + // Attachments + uploadAttachment: (projectId, taskId, file) => { + const fd = new FormData() + fd.append('file', file) + return request(`/projects/${projectId}/tasks/${taskId}/attachments`, { + method: 'POST', + body: fd, + formData: true, + write: true, + }) + }, + deleteAttachment: (projectId, taskId, attachmentId) => + request(`/projects/${projectId}/tasks/${taskId}/attachments/${attachmentId}`, { method: 'DELETE', write: true }), + + // Invites + createInvite: (projectId, email) => + request(`/projects/${projectId}/invites`, { method: 'POST', body: { email }, write: true }), + getInvite: token => request(`/invites/${token}`), + acceptInvite: (token, data) => request(`/invites/${token}/accept`, { method: 'POST', body: data }), + + // Notify (test webhook/email) + notify: (projectId, subject, text) => + request(`/projects/${projectId}/notify`, { method: 'POST', body: { subject, text }, write: true }), } diff --git a/frontend/src/components/BurndownChart.jsx b/frontend/src/components/BurndownChart.jsx new file mode 100644 index 0000000..0e96778 --- /dev/null +++ b/frontend/src/components/BurndownChart.jsx @@ -0,0 +1,173 @@ +import React, { useMemo } from 'react' + +const SVG_W = 480 +const SVG_H = 220 +const PAD = { top: 16, right: 20, bottom: 36, left: 40 } +const CW = SVG_W - PAD.left - PAD.right +const CH = SVG_H - PAD.top - PAD.bottom + +const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r } +const dayStr = d => d.toISOString().slice(0, 10) + +export default function BurndownChart({ project }) { + const { tasks, startDate: pStart, dueDate: pEnd } = project + const today = new Date(); today.setHours(0, 0, 0, 0) + + const start = pStart ? new Date(pStart) : (() => { + const dates = tasks.filter(t => t.dueDate).map(t => new Date(t.dueDate)) + return dates.length ? new Date(Math.min(...dates)) : today + })() + start.setHours(0, 0, 0, 0) + + const end = pEnd ? new Date(pEnd) : (() => { + const dates = tasks.filter(t => t.dueDate).map(t => new Date(t.dueDate)) + return dates.length ? addDays(new Date(Math.max(...dates)), 1) : addDays(today, 14) + })() + end.setHours(0, 0, 0, 0) + + const total = tasks.length + const done = tasks.filter(t => t.status === 'done').length + const open = total - done + + const spanDays = Math.max(Math.round((end - start) / 86400000), 1) + const daysLeft = Math.max(Math.round((end - today) / 86400000), 0) + const elapsed = Math.round((today - start) / 86400000) + + // Ideal burndown: linear from (0, total) to (spanDays, 0) + const idealLine = total > 0 + ? [[0, total], [spanDays, 0]].map(([d, v]) => [ + PAD.left + (d / spanDays) * CW, + PAD.top + (1 - v / total) * CH, + ]) + : [] + + // Actual: we only know current state, so we draw from (0, total) → (elapsed, open) + // This gives a useful snapshot even without historical data + const actualPoints = total > 0 ? [ + [PAD.left, PAD.top], + [PAD.left + Math.min(elapsed, spanDays) / spanDays * CW, PAD.top + (1 - open / total) * CH], + ] : [] + + const todayX = PAD.left + Math.min(elapsed, spanDays) / spanDays * CW + + // Y axis ticks (0%, 25%, 50%, 75%, 100%) + const yTicks = [0, 0.25, 0.5, 0.75, 1].map(frac => ({ + y: PAD.top + (1 - frac) * CH, + label: Math.round(frac * total), + })) + + // X axis ticks — start / mid / end + const xTicks = [0, 0.5, 1].map(frac => { + const d = addDays(start, Math.round(frac * spanDays)) + return { x: PAD.left + frac * CW, label: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } + }) + + const toPoints = pts => pts.map(([x, y]) => `${x},${y}`).join(' ') + + const behind = total > 0 && elapsed > 0 && open > (total * (1 - elapsed / spanDays)) + + const stats = [ + { label: 'Total', value: total, color: '#64748b' }, + { label: 'Done', value: done, color: '#10b981' }, + { label: 'Open', value: open, color: '#f59e0b' }, + { label: 'Days left',value: daysLeft, color: daysLeft <= 3 ? '#ef4444' : '#818cf8' }, + ] + + if (total === 0) { + return ( +
+ Add tasks to generate the burndown chart. +
+ ) + } + + return ( +
+ {/* Stats row */} +
+ {stats.map(s => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ + {behind && ( +
+ ⚠ Behind schedule — ideal remaining at this point: {Math.round(total * (1 - elapsed / spanDays))} tasks +
+ )} + + {/* SVG chart */} + + {/* Background */} + + + {/* Y grid lines */} + {yTicks.map((t, i) => ( + + + {t.label} + + ))} + + {/* X axis ticks */} + {xTicks.map((t, i) => ( + {t.label} + ))} + + {/* Ideal line (dashed) */} + {idealLine.length === 2 && ( + + )} + + {/* Area under actual line */} + {actualPoints.length === 2 && ( + + )} + + {/* Actual line */} + {actualPoints.length === 2 && ( + + )} + + {/* Today vertical line */} + {elapsed >= 0 && elapsed <= spanDays && ( + + )} + + {/* Start / end dots */} + {actualPoints.length > 0 && } + {actualPoints.length > 1 && } + + {/* Axes */} + + + + {/* Y axis label */} + Tasks remaining + + + {/* Legend */} +
+ + + Ideal + + + + Actual + + + Snapshot as of today — historical data recorded over time + +
+
+ ) +} diff --git a/frontend/src/components/CalendarView.jsx b/frontend/src/components/CalendarView.jsx new file mode 100644 index 0000000..c42e33a --- /dev/null +++ b/frontend/src/components/CalendarView.jsx @@ -0,0 +1,147 @@ +import React, { useState } from 'react' + +const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] +const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'] + +const PRI_COLOR = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' } +const STATUS_DOT = { todo: '#3d4166', 'in-progress': '#f59e0b', done: '#10b981' } + +const isoDate = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` + +export default function CalendarView({ project, onEditTask }) { + const today = new Date() + const [year, setYear] = useState(today.getFullYear()) + const [month, setMonth] = useState(today.getMonth()) + + const prevMonth = () => { if (month === 0) { setYear(y => y-1); setMonth(11) } else setMonth(m => m-1) } + const nextMonth = () => { if (month === 11) { setYear(y => y+1); setMonth(0) } else setMonth(m => m+1) } + + // Build a map of dateString → items + const itemMap = {} + const push = (dateStr, item) => { if (!itemMap[dateStr]) itemMap[dateStr] = []; itemMap[dateStr].push(item) } + + project.tasks.forEach(t => { + if (!t.dueDate) return + const d = new Date(t.dueDate); d.setHours(0,0,0,0) + push(isoDate(d), { type: 'task', data: t }) + }) + project.milestones.forEach(m => { + if (!m.date) return + const d = new Date(m.date); d.setHours(0,0,0,0) + push(isoDate(d), { type: 'milestone', data: m }) + }) + + // Build calendar grid + const firstDay = new Date(year, month, 1).getDay() + const daysInMonth = new Date(year, month + 1, 0).getDate() + const cells = [] + for (let i = 0; i < firstDay; i++) cells.push(null) + for (let d = 1; d <= daysInMonth; d++) cells.push(d) + // Pad to full weeks + while (cells.length % 7 !== 0) cells.push(null) + + const todayStr = isoDate(today) + + const MAX_VISIBLE = 3 + + return ( +
+ {/* Header */} +
+ +
{MONTHS[month]} {year}
+ +
+ + {/* Day headers */} +
+ {DAYS.map(d => ( +
{d}
+ ))} +
+ + {/* Grid */} +
+ {cells.map((day, idx) => { + if (!day) return
+ + const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(day).padStart(2,'0')}` + const items = itemMap[dateStr] || [] + const isToday = dateStr === todayStr + const visible = items.slice(0, MAX_VISIBLE) + const overflow = items.length - MAX_VISIBLE + + return ( +
+ {/* Day number */} +
+ {day} +
+ + {/* Items */} + {visible.map((item, i) => ( + item.type === 'task' ? ( +
onEditTask && onEditTask(item.data)} + style={{ + fontSize: 10, lineHeight: '14px', + background: '#0a0a18', + borderLeft: `2px solid ${PRI_COLOR[item.data.priority] || '#475569'}`, + borderRadius: '0 3px 3px 0', + padding: '2px 4px', + color: item.data.status === 'done' ? '#475569' : '#94a3b8', + overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + cursor: 'pointer', + textDecoration: item.data.status === 'done' ? 'line-through' : 'none', + }} + title={item.data.title} + > + + {item.data.title} +
+ ) : ( +
+ ◆ {item.data.title} +
+ ) + ))} + + {overflow > 0 && ( +
+{overflow} more
+ )} +
+ ) + })} +
+ + {/* Legend */} +
+ {[['High priority','#ef4444'],['Medium','#f59e0b'],['Low','#10b981']].map(([l, c]) => ( + + {l} + + ))} + + Milestone + +
+
+ ) +} diff --git a/frontend/src/components/GanttView.jsx b/frontend/src/components/GanttView.jsx new file mode 100644 index 0000000..456308b --- /dev/null +++ b/frontend/src/components/GanttView.jsx @@ -0,0 +1,231 @@ +import React, { useMemo, useRef, useState } from 'react' + +const ROW_H = 34 +const HEAD_H = 52 +const LEFT_W = 200 +const DAY_W = 28 +const PAD_DAYS = 3 + +const STATUS_COLOR = { + todo: '#3d4166', + 'in-progress': '#f59e0b', + done: '#10b981', +} +const PRI_COLOR = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' } + +const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r } +const diffDays = (a, b) => Math.round((b - a) / 86400000) +const fmt = d => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + +export default function GanttView({ project }) { + const svgRef = useRef(null) + const [tooltip, setTooltip] = useState(null) + + const { tasks, milestones } = project + + // Compute timeline bounds ----------------------------------------- + const dates = [] + const today = new Date(); today.setHours(0, 0, 0, 0) + + if (project.startDate) dates.push(new Date(project.startDate)) + if (project.dueDate) dates.push(new Date(project.dueDate)) + tasks.forEach(t => { + if (t.startDate) dates.push(new Date(t.startDate)) + if (t.dueDate) dates.push(new Date(t.dueDate)) + }) + milestones.forEach(m => { if (m.date) dates.push(new Date(m.date)) }) + dates.push(today) + + if (dates.length === 0) { + return
Add tasks with due dates to see the Gantt chart.
+ } + + const rawStart = new Date(Math.min(...dates)) + const rawEnd = new Date(Math.max(...dates)) + rawStart.setHours(0, 0, 0, 0) + rawEnd.setHours(0, 0, 0, 0) + + const start = addDays(rawStart, -PAD_DAYS) + const end = addDays(rawEnd, PAD_DAYS + 1) + const totalDays = diffDays(start, end) + + const SVG_W = LEFT_W + totalDays * DAY_W + const taskRows = tasks.filter(t => t.dueDate) + const msRows = milestones.filter(m => m.date) + const SVG_H = HEAD_H + (taskRows.length + msRows.length) * ROW_H + 10 + + const dayX = date => LEFT_W + diffDays(start, date) * DAY_W + const todayX = dayX(today) + + // Build month tick marks + const months = [] + const cur = new Date(start) + cur.setDate(1) + while (cur <= end) { + months.push({ label: cur.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }), x: dayX(cur) }) + cur.setMonth(cur.getMonth() + 1) + } + + // Week ticks (only if span ≤ 120 days) + const weekTicks = [] + if (totalDays <= 120) { + const w = new Date(start) + while (w <= end) { + weekTicks.push(dayX(w)) + w.setDate(w.getDate() + 7) + } + } + + return ( +
+ + + + + + {/* Background */} + + + {/* Row backgrounds */} + {[...taskRows, ...msRows].map((_, i) => ( + + ))} + + {/* Left column background */} + + + + {/* Week grid lines */} + {weekTicks.map((x, i) => ( + + ))} + + {/* Month header */} + + + {months.map((m, i) => ( + + + {m.label} + + ))} + + {/* Today line */} + + Today + + {/* Project span bar at header bottom */} + {project.startDate && project.dueDate && (() => { + const ps = dayX(new Date(project.startDate)) + const pe = dayX(new Date(project.dueDate)) + return + })()} + + {/* Task rows */} + {taskRows.map((t, i) => { + const y = HEAD_H + i * ROW_H + const due = new Date(t.dueDate) + const taskStart = t.startDate ? new Date(t.startDate) + : t.estimatedHours ? addDays(due, -Math.ceil(t.estimatedHours / 8)) + : addDays(due, -1) + const x1 = Math.max(dayX(taskStart), LEFT_W + 2) + const x2 = Math.max(dayX(due), x1 + 6) + const barW = x2 - x1 + const barColor = STATUS_COLOR[t.status] || '#3d4166' + const priColor = PRI_COLOR[t.priority] || '#475569' + + return ( + setTooltip({ t, x: e.clientX, y: e.clientY })} onMouseLeave={() => setTooltip(null)}> + {/* Left group: priority stripe + name */} + + + {/* status dot */} + + {t.status === 'done' && } + {t.status === 'in-progress' && } + + {t.title.length > 20 ? t.title.slice(0, 19) + '…' : t.title} + + + {/* Bar */} + + + {/* Progress overlay */} + {t.estimatedHours > 0 && t.actualHours > 0 && ( + + )} + + {/* Due date dot */} + + + ) + })} + + {/* Milestone rows */} + {msRows.map((ms, i) => { + const y = HEAD_H + (taskRows.length + i) * ROW_H + const mx = dayX(new Date(ms.date)) + const D = 7 + + return ( + + + + + {ms.title.length > 20 ? ms.title.slice(0, 19) + '…' : ms.title} + + {/* Diamond */} + + + ) + })} + + {/* Row dividers */} + {[...taskRows, ...msRows].map((_, i) => ( + + ))} + + {/* Section labels */} + {taskRows.length > 0 && ( + TASKS + )} + {msRows.length > 0 && ( + MILESTONES + )} + + + {/* Tooltip */} + {tooltip && ( +
+
{tooltip.t.title}
+
Status: {tooltip.t.status}
+ {tooltip.t.dueDate &&
Due: {new Date(tooltip.t.dueDate).toLocaleDateString()}
} + {tooltip.t.estimatedHours &&
Est: {tooltip.t.estimatedHours}h
} +
+ )} + + {taskRows.length === 0 && msRows.length === 0 && ( +
+ Add tasks or milestones with dates to see the Gantt chart. +
+ )} +
+ ) +} diff --git a/package.json b/package.json index 041301f..799152c 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,36 @@ "main": "main.js", "scripts": { "start": "electron .", - "build": "electron-builder" + "build": "electron-builder", + "build:desktop": "npm run build:renderer && npm run sync:dist && npm run build", + "build:renderer": "npm run build --prefix frontend", + "sync:dist": "rm -rf dist && mkdir -p dist && cp -R frontend/dist/* dist/", + "test:wiring": "node scripts/wiring-smoke-test.cjs", + "test:persistence": "node scripts/persistence-self-test.cjs", + "test:persistence:restart": "node scripts/persistence-self-test.cjs --restart-backend", + "build:verified": "npm run test:persistence && npm run build", + "build:verified:restart": "npm run test:persistence:restart && npm run build", + "release:verified": "npm run test:persistence && npm run build:renderer && npm run sync:dist && npm run build", + "release:verified:restart": "npm run test:persistence:restart && npm run build:renderer && npm run sync:dist && npm run build" + }, + "build": { + "appId": "com.projecthub.app", + "productName": "Project Hub", + "directories": { + "output": "releases" + }, + "mac": { + "identity": null, + "hardenedRuntime": false, + "target": [ + { "target": "dmg", "arch": ["arm64"] } + ] + }, + "afterSign": "scripts/adhoc-sign.js" }, "dependencies": { "electron": "^30.0.0", "electron-builder": "^24.0.0" } } + diff --git a/scripts/adhoc-sign.js b/scripts/adhoc-sign.js new file mode 100644 index 0000000..f8bca86 --- /dev/null +++ b/scripts/adhoc-sign.js @@ -0,0 +1,15 @@ +// Post-build hook: ad-hoc sign the .app bundle so macOS 14+ will open it +// without requiring an Apple Developer certificate. +const { execSync } = require('child_process'); +const path = require('path'); + +exports.default = async function (context) { + const appPath = path.join( + context.appOutDir, + `${context.packager.appInfo.productFilename}.app`, + ); + console.log(`Ad-hoc signing: ${appPath}`); + execSync(`xattr -cr "${appPath}"`); + execSync(`codesign --force --deep --sign - "${appPath}"`); + console.log('Ad-hoc signing complete.'); +}; diff --git a/scripts/persistence-self-test.cjs b/scripts/persistence-self-test.cjs new file mode 100644 index 0000000..d3b9856 --- /dev/null +++ b/scripts/persistence-self-test.cjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +const { execSync } = require('child_process'); + +const args = new Set(process.argv.slice(2)); +if (args.has('--help') || args.has('-h')) { + console.log('Project Hub persistence self-test'); + console.log(''); + console.log('Usage:'); + console.log(' node scripts/persistence-self-test.cjs [--restart-backend]'); + console.log(''); + console.log('Environment:'); + console.log(' API_BASE default: http://localhost:4000/api'); + console.log(' WRITE_API_KEY optional x-api-key for write endpoints'); + process.exit(0); +} + +const API_BASE = process.env.API_BASE || 'http://localhost:4000/api'; +const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health'; +const WRITE_API_KEY = process.env.WRITE_API_KEY || ''; +const SHOULD_RESTART = args.has('--restart-backend'); + +const marker = `selftest-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +const state = { + memberId: null, + projectId: null, + taskId: null, +}; + +function log(msg) { + console.log(`[persistence-test] ${msg}`); +} + +async function sleep(ms) { + await new Promise(resolve => setTimeout(resolve, ms)); +} + +async function request(path, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 10000); + + const headers = { + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(WRITE_API_KEY ? { 'x-api-key': WRITE_API_KEY } : {}), + ...(options.headers || {}), + }; + + const response = await fetch(`${API_BASE}${path}`, { + method: options.method || 'GET', + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); + + const text = await response.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = null; + } + + if (!response.ok) { + const details = parsed ? JSON.stringify(parsed) : text; + throw new Error(`${response.status} ${response.statusText}${details ? `: ${details}` : ''}`); + } + + return parsed; +} + +async function waitForHealth(maxSeconds = 30) { + const start = Date.now(); + while ((Date.now() - start) / 1000 < maxSeconds) { + try { + const response = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(3000) }); + if (response.ok) return; + } catch { + // Keep polling. + } + await sleep(1000); + } + throw new Error(`Health check failed after ${maxSeconds}s at ${HEALTH_URL}`); +} + +function restartBackendContainer() { + log('Restarting backend via docker compose...'); + execSync('docker compose restart backend', { stdio: 'inherit' }); +} + +async function cleanup() { + if (state.projectId) { + try { + await request(`/projects/${state.projectId}`, { method: 'DELETE' }); + log(`Cleanup: deleted project ${state.projectId}`); + } catch (e) { + log(`Cleanup warning: failed to delete project ${state.projectId}: ${e.message}`); + } + } + + if (state.memberId) { + try { + await request(`/members/${state.memberId}`, { method: 'DELETE' }); + log(`Cleanup: deleted member ${state.memberId}`); + } catch (e) { + log(`Cleanup warning: failed to delete member ${state.memberId}: ${e.message}`); + } + } +} + +async function run() { + log(`Using API base ${API_BASE}`); + await waitForHealth(); + log('Health check passed.'); + + const member = await request('/members', { + method: 'POST', + body: { + name: `Self Test ${marker}`, + role: 'QA Bot', + initials: 'ST', + email: `${marker}@local.test`, + }, + }); + state.memberId = member.id; + log(`Created member ${state.memberId}`); + + const project = await request('/projects', { + method: 'POST', + body: { + name: `Persistence ${marker}`, + description: 'Automated persistence verification', + status: 'active', + color: '#10b981', + startDate: new Date().toISOString().slice(0, 10), + dueDate: new Date(Date.now() + 86400000).toISOString().slice(0, 10), + members: [], + tasks: [], + milestones: [], + }, + }); + state.projectId = project.id; + log(`Created project ${state.projectId}`); + + await request(`/projects/${state.projectId}/members/${state.memberId}`, { method: 'POST' }); + log(`Assigned member ${state.memberId} to project ${state.projectId}`); + + const task = await request(`/projects/${state.projectId}/tasks`, { + method: 'POST', + body: { + title: `Task ${marker}`, + description: 'Persistence check task', + memberId: state.memberId, + }, + }); + state.taskId = task.id; + log(`Created task ${state.taskId}`); + + if (SHOULD_RESTART) { + restartBackendContainer(); + await waitForHealth(45); + log('Backend is healthy after restart.'); + } + + const projects = await request('/projects'); + const persistedProject = projects.find(p => p.id === state.projectId); + if (!persistedProject) { + throw new Error(`Persisted project not found after verification step: ${state.projectId}`); + } + + const tasks = await request(`/projects/${state.projectId}/tasks`); + const persistedTask = tasks.find(t => t.id === state.taskId); + if (!persistedTask) { + throw new Error(`Persisted task not found after verification step: ${state.taskId}`); + } + + log('Persistence verified: project and task were read back successfully.'); + await cleanup(); + log('PASS'); +} + +run().catch(async err => { + console.error(`[persistence-test] FAIL: ${err.message}`); + await cleanup(); + process.exit(1); +}); diff --git a/scripts/wiring-smoke-test.cjs b/scripts/wiring-smoke-test.cjs new file mode 100644 index 0000000..ab36da1 --- /dev/null +++ b/scripts/wiring-smoke-test.cjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node + +const API_BASE = process.env.API_BASE || 'http://localhost:4000/api'; +const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health'; +const WRITE_API_KEY = process.env.WRITE_API_KEY || ''; + +const marker = `wiring-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; +const state = { + memberId: null, + projectId: null, + taskId: null, + inviteToken: null, +}; + +function log(message) { + console.log(`[wiring-test] ${message}`); +} + +async function sleep(ms) { + await new Promise(resolve => setTimeout(resolve, ms)); +} + +async function waitForHealth(maxSeconds = 30) { + const started = Date.now(); + while ((Date.now() - started) / 1000 < maxSeconds) { + try { + const response = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(3000) }); + if (response.ok) return; + } catch { + // keep polling + } + await sleep(1000); + } + throw new Error(`Backend not healthy at ${HEALTH_URL} after ${maxSeconds}s`); +} + +async function request(path, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 10000); + const headers = { + ...(options.body || options.formData ? {} : {}), + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(options.write && WRITE_API_KEY ? { 'x-api-key': WRITE_API_KEY } : {}), + ...(options.headers || {}), + }; + + const response = await fetch(`${API_BASE}${path}`, { + method: options.method || 'GET', + headers, + body: options.formData || (options.body ? JSON.stringify(options.body) : undefined), + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); + + const text = await response.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = null; + } + + if (!response.ok) { + const details = parsed ? JSON.stringify(parsed) : text; + throw new Error(`${response.status} ${response.statusText}${details ? `: ${details}` : ''}`); + } + + return parsed; +} + +async function cleanup() { + if (state.projectId) { + try { + await request(`/projects/${state.projectId}`, { method: 'DELETE', write: true }); + log(`Cleanup: deleted project ${state.projectId}`); + } catch (err) { + log(`Cleanup warning: project delete failed: ${err.message}`); + } + } + + if (state.memberId) { + try { + await request(`/members/${state.memberId}`, { method: 'DELETE', write: true }); + log(`Cleanup: deleted member ${state.memberId}`); + } catch (err) { + log(`Cleanup warning: member delete failed: ${err.message}`); + } + } +} + +async function run() { + log(`Using API base ${API_BASE}`); + await waitForHealth(); + log('Health check passed'); + + const member = await request('/members', { + method: 'POST', + write: true, + body: { + name: `Wiring Member ${marker}`, + role: 'QA Bot', + email: `${marker}@local.test`, + }, + }); + state.memberId = member.id; + log(`Created member ${state.memberId}`); + + const project = await request('/projects', { + method: 'POST', + write: true, + body: { + name: `Wiring Project ${marker}`, + description: 'Wiring smoke test', + status: 'active', + color: '#6366f1', + members: [], + tasks: [], + milestones: [], + }, + }); + state.projectId = project.id; + log(`Created project ${state.projectId}`); + + const assigned = await request(`/projects/${state.projectId}/members/${state.memberId}`, { + method: 'POST', + write: true, + }); + if (!Array.isArray(assigned.members) || !assigned.members.some(m => m.id === state.memberId)) { + throw new Error('Assigned member missing from project.members'); + } + log('Assigned member to project'); + + const task = await request(`/projects/${state.projectId}/tasks`, { + method: 'POST', + write: true, + body: { + title: `Wiring Task ${marker}`, + description: 'Task route check', + assignedTo: state.memberId, + status: 'todo', + priority: 'medium', + }, + }); + state.taskId = task.id; + log(`Created task ${state.taskId}`); + + const updatedTask = await request(`/projects/${state.projectId}/tasks/${state.taskId}`, { + method: 'PUT', + write: true, + body: { status: 'done' }, + }); + if (updatedTask.status !== 'done') { + throw new Error('Task update did not persist expected status'); + } + log('Updated task status to done'); + + const invite = await request(`/projects/${state.projectId}/invites`, { + method: 'POST', + write: true, + body: { email: `${marker}.invite@local.test` }, + }); + state.inviteToken = invite.token; + log('Created invite token'); + + await request(`/invites/${state.inviteToken}`); + await request(`/invites/${state.inviteToken}/accept`, { + method: 'POST', + body: { name: `Invitee ${marker}`, role: 'Collaborator', email: `${marker}.invite@local.test` }, + }); + log('Invite get/accept flow passed'); + + await request(`/projects/${state.projectId}/tasks/${state.taskId}`, { + method: 'DELETE', + write: true, + }); + log('Deleted task'); + + const tasksAfterDelete = await request(`/projects/${state.projectId}/tasks`); + if (tasksAfterDelete.some(t => t.id === state.taskId)) { + throw new Error('Task still present after delete'); + } + + log('PASS'); + await cleanup(); +} + +run().catch(async err => { + console.error(`[wiring-test] FAIL: ${err.message}`); + await cleanup(); + process.exit(1); +});