Implement wiring hardening, runtime API config, smoke tests, and build scripts

This commit is contained in:
Ryan Lancaster
2026-03-17 12:58:43 -04:00
parent a3949c32ee
commit ca46302aff
19 changed files with 2358 additions and 77 deletions

60
.github/workflows/release.yml vendored Normal file
View File

@@ -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

8
.gitignore vendored
View File

@@ -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

158
README.md Normal file
View File

@@ -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 projects `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 Ill 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)

View File

@@ -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"]

View File

@@ -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);
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}`));

View File

@@ -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",

View File

@@ -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"

View File

@@ -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

View File

@@ -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({

View File

@@ -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)
})

View File

@@ -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(){
</div>
<div style={{ display: 'flex', gap: 3 }}>
<button onClick={()=>setView('projects')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='projects' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='projects' ? '#818cf8' : '#64748b' }}>Projects</button>
<button onClick={()=>setView('mytasks')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='mytasks' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='mytasks' ? '#818cf8' : '#64748b' }}>My Tasks</button>
<button onClick={()=>setView('members')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='members' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='members' ? '#818cf8' : '#64748b' }}>Members <span style={{ background: '#181828', padding: '1px 6px', borderRadius: 999, fontSize: 10, marginLeft: 3 }}>{allMembers.length}</span></button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button onClick={() => { setApiUrlMsg(''); setServerOpen(true) }} style={{ ...btn(), background: 'transparent', color: '#94a3b8', border: '1px solid #252540', padding: '7px 12px', fontSize: 12, fontWeight: 500 }}>
Server
</button>
{view === 'projects' && <button onClick={()=>setEditing({name:'',description:'',status:'planning',color:COLORS[0],startDate:'',dueDate:'',members:[],tasks:[],milestones:[]})} style={{ ...btn(), background: '#6366f1', color: '#fff', padding: '8px 15px', display: 'flex', alignItems: 'center', gap: 6 }}>
<Plus size={14}/> New Project
</button>}
</div>
</div>
<div style={{ padding: '22px 24px', maxWidth: 1400, margin: '0 auto' }}>
{view === 'mytasks' && <MyTasksView projects={projects} allMembers={allMembers} onSelectProject={id => { const p = projects.find(x => x.id === id); if(p) { setSel(p); setTab('tasks'); setView('projects') }}} />}
{view === 'members' && <MembersPage members={allMembers}
onAdd={async m => {
const temp = { ...m, id: uid() }
@@ -246,7 +300,7 @@ function App(){
</>}
</div>
{sel && <Panel project={sel} tab={tab} setTab={setTab} onClose={() => setSel(null)} onEdit={() => setEditing(sel)} onChange={change} allMembers={allMembers} />}
{sel && <Panel project={sel} tab={tab} setTab={setTab} onClose={() => setSel(null)} onEdit={() => setEditing(sel)} onChange={change} onTaskCreate={createTaskForProject} onTaskUpdate={updateTaskForProject} onTaskDelete={deleteTaskForProject} allMembers={allMembers} />}
{editing !== null && <FormModal project={editing} onSave={save} onClose={() => setEditing(null)} />}
{delId && (
<Overlay>
@@ -260,6 +314,38 @@ function App(){
</div>
</Overlay>
)}
{serverOpen && (
<Overlay>
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 14, padding: 24, width: '100%', maxWidth: 520 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#f1f5f9', marginBottom: 8 }}>API Server</div>
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 12 }}>Set the backend base URL used by this app. Example: http://localhost:4000/api</div>
<input
value={apiUrlInput}
onChange={e => setApiUrlInput(e.target.value)}
placeholder="http://localhost:4000/api"
style={{ ...inp(), marginBottom: 10 }}
/>
{apiUrlMsg && <div style={{ fontSize: 11, color: apiUrlMsg.startsWith('Saved') ? '#86efac' : '#fca5a5', marginBottom: 10 }}>{apiUrlMsg}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => setServerOpen(false)} style={{ ...btn(), padding: '8px 14px', background: 'transparent', border: '1px solid #181828', color: '#94a3b8', fontWeight: 500 }}>Close</button>
<button
onClick={async () => {
try {
await api.setApiUrl(apiUrlInput)
setApiUrlMsg('Saved. Reload the app if you changed environments.')
} catch {
setApiUrlMsg('Invalid API URL. Use a full http(s) URL.')
}
}}
style={{ ...btn(), padding: '8px 14px', background: '#6366f1', color: '#fff' }}
>
Save
</button>
</div>
</div>
</Overlay>
)}
</div>
)
}
@@ -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,9 +445,12 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#475569', marginBottom: 5 }}><span>Progress</span><span style={{ color: p.color, fontWeight: 700 }}>{pr}%</span></div>
<div style={{ height: 5, background: '#181828', borderRadius: 999 }}><div style={{ height: '100%', width: `${pr}%`, background: p.color, borderRadius: 999 }} /></div>
</div>
<div style={{ display: 'flex', gap: 2, marginTop: 14 }}>
{['tasks','milestones','team'].map(t => (
<button key={t} onClick={() => setTab(t)} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: tab === t ? 'rgba(99,102,241,0.12)' : 'transparent', color: tab === t ? '#818cf8' : '#64748b' }}>{t.charAt(0).toUpperCase() + t.slice(1)} <span style={{ marginLeft: 3, background: '#181828', padding: '1px 6px', borderRadius: 999, fontSize: 10 }}>{t === 'tasks' ? p.tasks.length : t === 'milestones' ? p.milestones.length : p.members.length}</span></button>
<div style={{ display: 'flex', gap: 2, marginTop: 14, flexWrap: 'wrap' }}>
{['tasks','milestones','team','gantt','calendar','burndown'].map(t => (
<button key={t} onClick={() => setTab(t)} style={{ ...btn(), padding: '5px 12px', fontSize: 11, fontWeight: 500, background: tab === t ? 'rgba(99,102,241,0.12)' : 'transparent', color: tab === t ? '#818cf8' : '#64748b' }}>
{t === 'tasks' ? '📋 Tasks' : t === 'milestones' ? '🏁 Milestones' : t === 'team' ? '👥 Team' : t === 'gantt' ? '📊 Gantt' : t === 'calendar' ? '📅 Calendar' : '📉 Burndown'}
{(t === 'tasks' || t === 'milestones' || t === 'team') && <span style={{ marginLeft: 3, background: '#181828', padding: '1px 5px', borderRadius: 999, fontSize: 9 }}>{t === 'tasks' ? p.tasks.length : t === 'milestones' ? p.milestones.length : p.members.length}</span>}
</button>
))}
</div>
</div>
@@ -347,6 +458,9 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers
{tab === 'tasks' && <TasksTab project={p} onUpdate={upTask} onAdd={addTask} onDel={delTask} />}
{tab === 'milestones' && <MsTab project={p} onToggle={togMs} onAdd={addMs} onDel={delMs} />}
{tab === 'team' && <TeamTab project={p} onChange={onChange} allMembers={allMembers} />}
{tab === 'gantt' && <GanttView project={p} />}
{tab === 'calendar' && <CalendarView project={p} onEditTask={t => upTask(t.id, {})} />}
{tab === 'burndown' && <BurndownChart project={p} />}
</div>
</div>
</div>
@@ -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 (
<div>
@@ -375,7 +490,14 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
<select value={nt.priority} onChange={e => setNt({ ...nt, priority: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }}>
<option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option>
</select>
<input type="date" value={nt.dueDate} onChange={e => setNt({ ...nt, dueDate: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }} />
</div>
<div style={{ display: 'flex', gap: 7, marginBottom: 8 }}>
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Start Date</div><input type="date" value={nt.startDate} onChange={e => setNt({ ...nt, startDate: e.target.value })} style={{ ...inp(), padding: '6px 8px' }} /></div>
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Due Date</div><input type="date" value={nt.dueDate} onChange={e => setNt({ ...nt, dueDate: e.target.value })} style={{ ...inp(), padding: '6px 8px' }} /></div>
</div>
<div style={{ display: 'flex', gap: 7, marginBottom: 8 }}>
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Est. Hours</div><input type="number" min="0" step="0.5" value={nt.estimatedHours} onChange={e => setNt({ ...nt, estimatedHours: e.target.value })} placeholder="0" style={{ ...inp(), padding: '6px 8px' }} /></div>
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Recurrence</div><select value={nt.recurrence} onChange={e => setNt({ ...nt, recurrence: e.target.value })} style={{ ...inp(), padding: '6px 8px' }}><option value="none">None</option><option value="daily">Daily</option><option value="weekly">Weekly</option><option value="monthly">Monthly</option></select></div>
</div>
{p.members.length > 0 && (
<select value={nt.assignedTo} onChange={e => setNt({ ...nt, assignedTo: e.target.value })} style={{ ...inp(), marginBottom: 9, padding: '6px 8px' }}>
@@ -383,7 +505,7 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
{p.members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
)}
<div style={{ display: 'flex', gap: 7, marginTop: p.members.length > 0 ? 0 : 9 }}>
<div style={{ display: 'flex', gap: 7, marginTop: 2 }}>
<button onClick={submit} style={{ ...btn(), flex:1, padding: '7px', background: '#6366f1', color: '#fff' }}>Add Task</button>
<button onClick={() => setAdding(false)} style={{ ...btn(), flex:1, padding: '7px', background: 'transparent', border: '1px solid #181828', color: '#64748b', fontWeight: 500 }}>Cancel</button>
</div>
@@ -393,7 +515,7 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
groups[st].length > 0 && (
<div key={st} style={{ marginBottom: 18 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 7 }}>{st === 'in-progress' ? 'In Progress' : st === 'todo' ? 'To Do' : 'Done'} · {groups[st].length}</div>
{groups[st].map(t => <TaskRow key={t.id} task={t} color={p.color} members={p.members} onUpdate={onUpdate} onDel={onDel} expanded={exp === t.id} onToggle={() => setExp(exp === t.id ? null : t.id)} />)}
{groups[st].map(t => <TaskRow key={t.id} task={t} color={p.color} members={p.members} projectId={p.id} onUpdate={onUpdate} onDel={onDel} expanded={exp === t.id} onToggle={() => setExp(exp === t.id ? null : t.id)} />)}
</div>
)
))}
@@ -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 (
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 8, marginBottom: 5, overflow: 'hidden' }}>
<div style={{ padding: '9px 11px', display: 'flex', alignItems: 'center', gap: 9, cursor: 'pointer' }} onClick={onToggle}>
@@ -419,12 +556,35 @@ function TaskRow({ task: t, color, members = [], onUpdate, onDel, expanded, onTo
<div style={{ display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0 }}>
<span style={{ fontSize: 10, fontWeight: 700, color: pr.color }}>{pr.label}</span>
{t.dueDate && <span style={{ fontSize: 10, color: ov ? '#ef4444' : '#475569' }}>{fmt(t.dueDate)}</span>}
{t.subtasks.length > 0 && <span style={{ fontSize: 10, color: '#475569' }}>{t.subtasks.filter(s => s.done).length}/{t.subtasks.length}</span>}
{t.estimatedHours > 0 && <span style={{ fontSize: 9, color: '#3d4166' }} title="Estimated hours">{t.estimatedHours}h</span>}
{t.recurrence && t.recurrence !== 'none' && <span style={{ fontSize: 9, color: '#6366f1' }} title={`Repeats ${t.recurrence}`}>🔁</span>}
{(t.attachments?.length > 0) && <span style={{ fontSize: 9, color: '#64748b' }} title="Attachments">📎{t.attachments.length}</span>}
{t.subtasks?.length > 0 && <span style={{ fontSize: 10, color: '#475569' }}>{t.subtasks.filter(s => s.done).length}/{t.subtasks.length}</span>}
{t.assignedTo && (() => { const m = members.find(x => x.id === t.assignedTo); return m ? <div title={m.name} style={{ width: 16, height: 16, borderRadius: '50%', background: color, color: '#fff', fontSize: 8, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{m.initials}</div> : null })()}
<button onClick={e => { e.stopPropagation(); onDel(t.id) }} style={{ background: 'none', border: 'none', color: '#475569', cursor: 'pointer', padding: 0 }}><X size={11} /></button>
</div>
</div>
{expanded && <SubList task={t} onToggle={togSub} onAdd={addSub} onDel={delSub} />}
{expanded && (
<>
<SubList task={t} onToggle={togSub} onAdd={addSub} onDel={delSub} />
{/* Attachments section */}
<div style={{ borderTop: '1px solid #181828', padding: '8px 11px 10px', marginLeft: 27 }}>
<div style={{ fontSize: 9, fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 5 }}>Attachments</div>
{(t.attachments||[]).map(a => (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 3 }}>
<span style={{ fontSize: 10 }}>📎</span>
<a href={api.toAbsoluteUrl(a.url)} target="_blank" rel="noreferrer" style={{ fontSize: 11, color: '#818cf8', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.originalName}</a>
<span style={{ fontSize: 9, color: '#3d4166' }}>{(a.size/1024).toFixed(0)}KB</span>
<button onClick={() => delAttachment(a.id)} style={{ background: 'none', border: 'none', color: '#475569', cursor: 'pointer', padding: 0 }}><X size={9} /></button>
</div>
))}
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 10, color: '#6366f1', cursor: 'pointer', marginTop: 3 }}>
<Plus size={10} />Upload file
<input type="file" style={{ display: 'none' }} onChange={handleUpload} />
</label>
</div>
</>
)}
</div>
)
}
@@ -433,7 +593,7 @@ function SubList({ task, onToggle, onAdd, onDel }){
const [val, setVal] = useState('')
return (
<div style={{ borderTop: '1px solid #181828', padding: '8px 11px 10px 38px' }}>
{task.subtasks.map(s => (
{(task.subtasks||[]).map(s => (
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '3px 0' }}>
<button onClick={() => onToggle(s.id)} style={{ width: 13, height: 13, borderRadius: 3, border: '1px solid #252540', background: s.done ? '#6366f1' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0, flexShrink: 0 }}>{s.done && <Check size={8} stroke="#fff" />}</button>
<span style={{ flex: 1, fontSize: 11, color: s.done ? '#475569' : '#94a3b8', textDecoration: s.done ? 'line-through' : 'none' }}>{s.title}</span>
@@ -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 (
<div>
<button onClick={() => { setAdding(true); setMode('roster') }} style={{ width: '100%', padding: '8px', border: '1px dashed #1e2030', borderRadius: 8, background: 'transparent', color: '#475569', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginBottom: 18 }}><Plus size={12}/> Assign Member</button>
<div style={{ display: 'flex', gap: 7, marginBottom: 18 }}>
<button onClick={() => { setAdding(true); setMode('roster') }} style={{ flex: 1, padding: '8px', border: '1px dashed #1e2030', borderRadius: 8, background: 'transparent', color: '#475569', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}><Plus size={12}/> Assign Member</button>
<button onClick={() => setInviting(!inviting)} style={{ flex: 1, padding: '8px', border: '1px dashed #3d2060', borderRadius: 8, background: inviting ? 'rgba(99,102,241,0.08)' : 'transparent', color: '#818cf8', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}> Invite by email</button>
</div>
{/* Invite panel */}
{inviting && (
<div style={{ background: '#0d0d1a', border: '1px solid #252545', borderRadius: 10, padding: 13, marginBottom: 14 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#818cf8', marginBottom: 8 }}>Send invite link</div>
<div style={{ display: 'flex', gap: 7, marginBottom: 8 }}>
<input type="email" value={inviteEmail} onChange={e => setInviteEmail(e.target.value)} placeholder="colleague@example.com" style={{ ...inp(), flex: 1, padding: '6px 8px' }} onKeyDown={e => e.key === 'Enter' && doInvite()} />
<button onClick={doInvite} style={{ ...btn(), padding: '6px 14px', background: '#6366f1', color: '#fff', fontSize: 12 }}>Send</button>
</div>
{inviteErr && <div style={{ fontSize: 11, color: '#fca5a5', marginBottom: 6 }}>{inviteErr}</div>}
{inviteResult && (
<div style={{ fontSize: 11, color: '#86efac', background: '#0a1f0a', borderRadius: 6, padding: '8px 10px' }}>
Invite created!{inviteResult.token && <span style={{ display: 'block', marginTop: 4, color: '#64748b', wordBreak: 'break-all' }}>Token: {inviteResult.token.slice(0,16)}<button onClick={() => navigator.clipboard?.writeText(inviteResult.token).catch(()=>{})} style={{ ...btn(), marginLeft: 6, fontSize: 10, color: '#818cf8', padding: '1px 5px', border: '1px solid #252545' }}>copy</button></span>}
<span style={{ color: '#475569', display: 'block', marginTop: 3 }}>Expires: {new Date(inviteResult.expiresAt).toLocaleDateString()}</span>
</div>
)}
</div>
)}
{adding && (
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 10, padding: 13, marginBottom: 14 }}>
<div style={{ display: 'flex', gap: 5, marginBottom: 10 }}>
@@ -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 }) => (
<div key={t.id} onClick={() => 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'}
>
<div style={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0, background: t.status === 'done' ? '#10b981' : t.status === 'in-progress' ? '#f59e0b' : '#3d4166' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, color: t.status === 'done' ? '#475569' : '#e2e8f0', textDecoration: t.status === 'done' ? 'line-through' : 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.title}</div>
<div style={{ display: 'flex', gap: 8, marginTop: 2, alignItems: 'center' }}>
<span style={{ fontSize: 10, color: '#64748b', borderLeft: `2px solid ${proj.color || '#6366f1'}`, paddingLeft: 5 }}>{proj.name}</span>
<span style={{ fontSize: 10, color: PRI_COLOR_MT[t.priority] }}>{t.priority}</span>
{t.dueDate && <span style={{ fontSize: 10, color: new Date(t.dueDate) < todayNow && t.status !== 'done' ? '#ef4444' : '#475569' }}>{fmt(t.dueDate)}</span>}
{t.recurrence && t.recurrence !== 'none' && <span style={{ fontSize: 9, color: '#6366f1' }}>🔁 {t.recurrence}</span>}
</div>
</div>
{t.estimatedHours > 0 && <span style={{ fontSize: 10, color: '#3d4166', flexShrink: 0 }}>{t.estimatedHours}h</span>}
</div>
)
const Section = ({ label, color, items }) => items.length === 0 ? null : (
<div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 10, fontWeight: 700, color, textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 7 }}>{label} · {items.length}</div>
{items.map(renderItem)}
</div>
)
return (
<div style={{ maxWidth: 640 }}>
<div style={{ fontSize: 17, fontWeight: 700, color: '#f1f5f9', marginBottom: 18 }}>
My Tasks
<span style={{ fontSize: 12, color: '#475569', fontWeight: 400, marginLeft: 8 }}>{taskItems.length} total across {projects.length} project{projects.length !== 1 ? 's' : ''}</span>
</div>
{/* Member picker */}
{allMembers.length > 0 && (
<div style={{ display: 'flex', gap: 10, marginBottom: 16, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#64748b' }}>Showing tasks for:</span>
<select value={memberId} onChange={e => setMemberId(e.target.value)}
style={{ ...inp(), width: 'auto', padding: '5px 10px', fontSize: 12 }}>
<option value="">All members</option>
{allMembers.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
)}
{/* Status filter */}
<div style={{ display: 'flex', gap: 6, marginBottom: 18 }}>
{['all','todo','in-progress','done'].map(s => (
<button key={s} onClick={() => setFilter(s)}
style={{ ...btn(), background: filter===s ? '#6366f1' : '#1e2030', color: filter===s ? '#fff' : '#94a3b8', padding: '5px 12px', fontSize: 11, border: 'none' }}>
{s === 'all' ? 'All' : s === 'in-progress' ? 'In Progress' : s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
</div>
{filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>
{memberId ? 'No tasks assigned to this member.' : 'No tasks found.'}
</div>
)}
<Section label="⚠ Overdue" color="#ef4444" items={groups.overdue} />
<Section label="In Progress" color="#f59e0b" items={groups.inProgress} />
<Section label="To Do" color="#64748b" items={groups.todo} />
{(filter === 'all' || filter === 'done') && <Section label="Done" color="#10b981" items={groups.done} />}
</div>
)
}
export default App

View File

@@ -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 }),
}

View File

@@ -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 (
<div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>
Add tasks to generate the burndown chart.
</div>
)
}
return (
<div>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 18 }}>
{stats.map(s => (
<div key={s.label} style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 8, padding: '10px 12px', textAlign: 'center' }}>
<div style={{ fontSize: 22, fontWeight: 800, color: s.color, lineHeight: 1 }}>{s.value}</div>
<div style={{ fontSize: 10, color: '#475569', marginTop: 3 }}>{s.label}</div>
</div>
))}
</div>
{behind && (
<div style={{ fontSize: 11, color: '#fca5a5', background: '#2d1515', border: '1px solid #3d1515', borderRadius: 6, padding: '6px 12px', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 6 }}>
Behind schedule ideal remaining at this point: {Math.round(total * (1 - elapsed / spanDays))} tasks
</div>
)}
{/* SVG chart */}
<svg viewBox={`0 0 ${SVG_W} ${SVG_H}`} width="100%" style={{ display: 'block', overflow: 'visible' }}>
{/* Background */}
<rect x={PAD.left} y={PAD.top} width={CW} height={CH} fill="#0a0a18" rx={4} />
{/* Y grid lines */}
{yTicks.map((t, i) => (
<g key={i}>
<line x1={PAD.left} y1={t.y} x2={PAD.left + CW} y2={t.y} stroke="#181828" strokeWidth={0.5} />
<text x={PAD.left - 4} y={t.y + 3.5} textAnchor="end" fill="#475569" fontSize={9}>{t.label}</text>
</g>
))}
{/* X axis ticks */}
{xTicks.map((t, i) => (
<text key={i} x={t.x} y={PAD.top + CH + 14} textAnchor="middle" fill="#475569" fontSize={9}>{t.label}</text>
))}
{/* Ideal line (dashed) */}
{idealLine.length === 2 && (
<line x1={idealLine[0][0]} y1={idealLine[0][1]} x2={idealLine[1][0]} y2={idealLine[1][1]}
stroke="#475569" strokeWidth={1.5} strokeDasharray="5,3" />
)}
{/* Area under actual line */}
{actualPoints.length === 2 && (
<polygon
points={`${actualPoints[0][0]},${actualPoints[0][1]} ${actualPoints[1][0]},${actualPoints[1][1]} ${actualPoints[1][0]},${PAD.top + CH} ${actualPoints[0][0]},${PAD.top + CH}`}
fill="#6366f1" opacity={0.08} />
)}
{/* Actual line */}
{actualPoints.length === 2 && (
<polyline points={toPoints(actualPoints)} fill="none" stroke="#6366f1" strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" />
)}
{/* Today vertical line */}
{elapsed >= 0 && elapsed <= spanDays && (
<line x1={todayX} y1={PAD.top} x2={todayX} y2={PAD.top + CH}
stroke="#6366f1" strokeWidth={1} strokeDasharray="3,2" opacity={0.6} />
)}
{/* Start / end dots */}
{actualPoints.length > 0 && <circle cx={actualPoints[0][0]} cy={actualPoints[0][1]} r={4} fill="#6366f1" />}
{actualPoints.length > 1 && <circle cx={actualPoints[1][0]} cy={actualPoints[1][1]} r={4} fill="#6366f1" />}
{/* Axes */}
<line x1={PAD.left} y1={PAD.top} x2={PAD.left} y2={PAD.top + CH} stroke="#252540" />
<line x1={PAD.left} y1={PAD.top + CH} x2={PAD.left + CW} y2={PAD.top + CH} stroke="#252540" />
{/* Y axis label */}
<text x={10} y={PAD.top + CH / 2} fill="#475569" fontSize={9} textAnchor="middle"
transform={`rotate(-90, 10, ${PAD.top + CH / 2})`}>Tasks remaining</text>
</svg>
{/* Legend */}
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: '#475569' }}>
<svg width={24} height={8}><line x1={0} y1={4} x2={24} y2={4} stroke="#475569" strokeWidth={1.5} strokeDasharray="4,2" /></svg>
Ideal
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: '#475569' }}>
<svg width={24} height={8}><line x1={0} y1={4} x2={24} y2={4} stroke="#6366f1" strokeWidth={2.5} /></svg>
Actual
</span>
<span style={{ fontSize: 10, color: '#475569' }}>
Snapshot as of today historical data recorded over time
</span>
</div>
</div>
)
}

View File

@@ -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 (
<div>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<button onClick={prevMonth} style={{ background: 'none', border: '1px solid #1e2030', color: '#94a3b8', cursor: 'pointer', borderRadius: 6, padding: '4px 10px', fontSize: 16 }}></button>
<div style={{ fontSize: 15, fontWeight: 700, color: '#e2e8f0' }}>{MONTHS[month]} {year}</div>
<button onClick={nextMonth} style={{ background: 'none', border: '1px solid #1e2030', color: '#94a3b8', cursor: 'pointer', borderRadius: 6, padding: '4px 10px', fontSize: 16 }}></button>
</div>
{/* Day headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 2 }}>
{DAYS.map(d => (
<div key={d} style={{ textAlign: 'center', fontSize: 10, fontWeight: 700, color: '#475569', padding: '4px 0', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{d}</div>
))}
</div>
{/* Grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
{cells.map((day, idx) => {
if (!day) return <div key={idx} style={{ minHeight: 80, background: '#0a0a18', borderRadius: 4 }} />
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 (
<div key={idx} style={{
minHeight: 80, background: '#0d0d1a',
border: `1px solid ${isToday ? '#6366f1' : '#181828'}`,
borderRadius: 6, padding: '5px 4px', display: 'flex', flexDirection: 'column', gap: 2,
}}>
{/* Day number */}
<div style={{
fontSize: 11, fontWeight: isToday ? 800 : 500,
color: isToday ? '#818cf8' : '#64748b',
marginBottom: 2, textAlign: 'right', paddingRight: 2,
}}>
{day}
</div>
{/* Items */}
{visible.map((item, i) => (
item.type === 'task' ? (
<div key={i}
onClick={() => 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}
>
<span style={{ display: 'inline-block', width: 5, height: 5, borderRadius: '50%', background: STATUS_DOT[item.data.status], marginRight: 3, verticalAlign: 'middle' }} />
{item.data.title}
</div>
) : (
<div key={i} style={{
fontSize: 10, lineHeight: '14px',
background: 'rgba(139,92,246,0.15)',
borderLeft: '2px solid #8b5cf6',
borderRadius: '0 3px 3px 0',
padding: '2px 4px',
color: item.data.completed ? '#475569' : '#c4b5fd',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
textDecoration: item.data.completed ? 'line-through' : 'none',
}} title={item.data.title}>
{item.data.title}
</div>
)
))}
{overflow > 0 && (
<div style={{ fontSize: 9, color: '#475569', paddingLeft: 4 }}>+{overflow} more</div>
)}
</div>
)
})}
</div>
{/* Legend */}
<div style={{ display: 'flex', gap: 14, marginTop: 14, flexWrap: 'wrap' }}>
{[['High priority','#ef4444'],['Medium','#f59e0b'],['Low','#10b981']].map(([l, c]) => (
<span key={l} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10, color: '#475569' }}>
<span style={{ display: 'inline-block', width: 8, height: 8, borderLeft: `3px solid ${c}`, borderRadius: '0 2px 2px 0', background: '#0a0a18' }} />{l}
</span>
))}
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10, color: '#475569' }}>
<span style={{ color: '#8b5cf6' }}></span> Milestone
</span>
</div>
</div>
)
}

View File

@@ -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 <div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>Add tasks with due dates to see the Gantt chart.</div>
}
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 (
<div style={{ overflowX: 'auto', overflowY: 'auto', position: 'relative', maxHeight: '70vh' }}>
<svg ref={svgRef} width={SVG_W} height={SVG_H} style={{ display: 'block', fontSize: 11, fontFamily: 'inherit' }}>
<defs>
<clipPath id="clip-chart"><rect x={LEFT_W} y={0} width={SVG_W - LEFT_W} height={SVG_H} /></clipPath>
</defs>
{/* Background */}
<rect width={SVG_W} height={SVG_H} fill="#090910" />
{/* Row backgrounds */}
{[...taskRows, ...msRows].map((_, i) => (
<rect key={i} x={0} y={HEAD_H + i * ROW_H} width={SVG_W} height={ROW_H}
fill={i % 2 === 0 ? '#0a0a18' : '#0d0d1a'} />
))}
{/* Left column background */}
<rect x={0} y={0} width={LEFT_W} height={SVG_H} fill="#0d0d1a" />
<line x1={LEFT_W} y1={0} x2={LEFT_W} y2={SVG_H} stroke="#181828" strokeWidth={1} />
{/* Week grid lines */}
{weekTicks.map((x, i) => (
<line key={i} x1={x} y1={HEAD_H} x2={x} y2={SVG_H} stroke="#181828" strokeWidth={1} strokeDasharray="2,4" clipPath="url(#clip-chart)" />
))}
{/* Month header */}
<rect x={0} y={0} width={SVG_W} height={HEAD_H} fill="#0d0d1a" />
<line x1={0} y1={HEAD_H} x2={SVG_W} y2={HEAD_H} stroke="#181828" />
{months.map((m, i) => (
<g key={i}>
<line x1={m.x} y1={0} x2={m.x} y2={HEAD_H} stroke="#252540" />
<text x={m.x + 6} y={22} fill="#64748b" fontSize={10} fontWeight={600}>{m.label}</text>
</g>
))}
{/* Today line */}
<line x1={todayX} y1={HEAD_H} x2={todayX} y2={SVG_H} stroke="#6366f1" strokeWidth={1.5} strokeDasharray="4,3" clipPath="url(#clip-chart)" />
<text x={todayX + 3} y={HEAD_H - 6} fill="#6366f1" fontSize={9}>Today</text>
{/* 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 <rect x={ps} y={HEAD_H - 8} width={Math.max(pe - ps, 4)} height={4} rx={2} fill={project.color || '#6366f1'} opacity={0.7} clipPath="url(#clip-chart)" />
})()}
{/* 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 (
<g key={t.id} onMouseEnter={(e) => setTooltip({ t, x: e.clientX, y: e.clientY })} onMouseLeave={() => setTooltip(null)}>
{/* Left group: priority stripe + name */}
<rect x={0} y={y} width={LEFT_W} height={ROW_H} fill="transparent" />
<rect x={0} y={y} width={3} height={ROW_H} fill={priColor} opacity={0.7} />
{/* status dot */}
<circle cx={12} cy={y + ROW_H / 2} r={4}
fill={t.status === 'done' ? barColor : 'none'}
stroke={barColor} strokeWidth={1.5} />
{t.status === 'done' && <polyline points={`${10},${y + ROW_H / 2} ${12},${y + ROW_H / 2 + 2} ${15},${y + ROW_H / 2 - 2}`} fill="none" stroke="#fff" strokeWidth={1} />}
{t.status === 'in-progress' && <circle cx={12} cy={y + ROW_H / 2} r={2} fill={barColor} />}
<text x={22} y={y + ROW_H / 2 + 4} fill={t.status === 'done' ? '#475569' : '#cbd5e1'} fontSize={11}
textDecoration={t.status === 'done' ? 'line-through' : 'none'}>
{t.title.length > 20 ? t.title.slice(0, 19) + '…' : t.title}
</text>
{/* Bar */}
<rect x={x1} y={y + 9} width={barW} height={ROW_H - 18} rx={3} fill={barColor} opacity={0.85}
clipPath="url(#clip-chart)" style={{ cursor: 'pointer' }} />
{/* Progress overlay */}
{t.estimatedHours > 0 && t.actualHours > 0 && (
<rect x={x1} y={y + 9}
width={Math.min(barW * (t.actualHours / t.estimatedHours), barW)}
height={ROW_H - 18} rx={3} fill="#fff" opacity={0.15}
clipPath="url(#clip-chart)" />
)}
{/* Due date dot */}
<circle cx={dayX(due)} cy={y + ROW_H / 2} r={3} fill={barColor} clipPath="url(#clip-chart)" />
</g>
)
})}
{/* 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 (
<g key={ms.id}>
<rect x={0} y={y} width={LEFT_W} height={ROW_H} fill="transparent" />
<rect x={0} y={y} width={3} height={ROW_H} fill="#8b5cf6" opacity={0.7} />
<text x={22} y={y + ROW_H / 2 + 4} fill={ms.completed ? '#475569' : '#c4b5fd'} fontSize={11}
textDecoration={ms.completed ? 'line-through' : 'none'}>
{ms.title.length > 20 ? ms.title.slice(0, 19) + '…' : ms.title}
</text>
{/* Diamond */}
<polygon points={`${mx},${y + ROW_H / 2 - D} ${mx + D},${y + ROW_H / 2} ${mx},${y + ROW_H / 2 + D} ${mx - D},${y + ROW_H / 2}`}
fill={ms.completed ? '#8b5cf6' : 'none'} stroke="#8b5cf6" strokeWidth={1.5}
clipPath="url(#clip-chart)" />
</g>
)
})}
{/* Row dividers */}
{[...taskRows, ...msRows].map((_, i) => (
<line key={i} x1={0} y1={HEAD_H + (i + 1) * ROW_H} x2={SVG_W} y2={HEAD_H + (i + 1) * ROW_H}
stroke="#181828" strokeWidth={0.5} />
))}
{/* Section labels */}
{taskRows.length > 0 && (
<text x={6} y={HEAD_H - 10} fill="#475569" fontSize={9} fontWeight={700} textTransform="uppercase">TASKS</text>
)}
{msRows.length > 0 && (
<text x={6} y={HEAD_H + taskRows.length * ROW_H - 10} fill="#7c3aed" fontSize={9} fontWeight={700}>MILESTONES</text>
)}
</svg>
{/* Tooltip */}
{tooltip && (
<div style={{
position: 'fixed', top: tooltip.y + 10, left: tooltip.x + 10,
background: '#1e2030', border: '1px solid #252540', borderRadius: 8,
padding: '8px 12px', zIndex: 9999, fontSize: 11, color: '#e2e8f0',
pointerEvents: 'none', maxWidth: 200,
}}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>{tooltip.t.title}</div>
<div style={{ color: '#64748b' }}>Status: <span style={{ color: STATUS_COLOR[tooltip.t.status] }}>{tooltip.t.status}</span></div>
{tooltip.t.dueDate && <div style={{ color: '#64748b' }}>Due: {new Date(tooltip.t.dueDate).toLocaleDateString()}</div>}
{tooltip.t.estimatedHours && <div style={{ color: '#64748b' }}>Est: {tooltip.t.estimatedHours}h</div>}
</div>
)}
{taskRows.length === 0 && msRows.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>
Add tasks or milestones with dates to see the Gantt chart.
</div>
)}
</div>
)
}

View File

@@ -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"
}
}

15
scripts/adhoc-sign.js Normal file
View File

@@ -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.');
};

View File

@@ -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);
});

View File

@@ -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);
});