Implement wiring hardening, runtime API config, smoke tests, and build scripts
This commit is contained in:
60
.github/workflows/release.yml
vendored
Normal file
60
.github/workflows/release.yml
vendored
Normal 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
8
.gitignore
vendored
@@ -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
158
README.md
Normal 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 project’s `releases/` directory after packaging.
|
||||
|
||||
- Verified release build (runs persistence check before packaging):
|
||||
|
||||
```bash
|
||||
npm run build:verified
|
||||
```
|
||||
|
||||
- Verified release build with backend restart check:
|
||||
|
||||
```bash
|
||||
npm run build:verified:restart
|
||||
```
|
||||
|
||||
- One-command verified release pipeline (persistence test + frontend build + dist sync + Electron packaging):
|
||||
|
||||
```bash
|
||||
npm run release:verified
|
||||
```
|
||||
|
||||
- Same as above, but also verifies persistence across backend restart:
|
||||
|
||||
```bash
|
||||
npm run release:verified:restart
|
||||
```
|
||||
|
||||
Unsigned macOS Gatekeeper note
|
||||
|
||||
These builds are currently unsigned. macOS Gatekeeper may prevent opening the app. Users can bypass with one of the following (instruct users to accept the risk):
|
||||
|
||||
```bash
|
||||
# Open by right-clicking the app and choosing "Open", or run:
|
||||
xattr -r -d com.apple.quarantine "/Applications/Project Hub.app"
|
||||
# or for a packaged app in releases:
|
||||
xattr -r -d com.apple.quarantine "releases/Project Hub.app"
|
||||
```
|
||||
|
||||
CI / Distribution suggestions
|
||||
|
||||
- Add a GitHub Actions workflow to build and upload unsigned artifacts to GitHub Releases (I can add this).
|
||||
- To avoid Gatekeeper prompts for wide distribution, sign and notarize the app (requires Apple Developer account and credentials).
|
||||
|
||||
Security & production notes
|
||||
|
||||
- `lowdb` (JSON file) is fine short-term for small teams. For production/many users, migrate to a proper DB (Postgres, managed DB).
|
||||
- Use `WRITE_API_KEY` or user accounts to protect write operations; always run behind TLS (reverse proxy like nginx/Traefik) for public hosting.
|
||||
|
||||
Need help?
|
||||
|
||||
I can add a GitHub Actions workflow to build/upload unsigned releases, or add signing/notarization steps if you acquire an Apple Developer account. Tell me which and I’ll scaffold it.
|
||||
|
||||
Persistence self-test
|
||||
|
||||
Run this before release to verify that created records are persisted and can be read back:
|
||||
|
||||
```bash
|
||||
npm run test:persistence
|
||||
```
|
||||
|
||||
If your backend is running under Docker Compose and you also want to verify persistence across a backend restart:
|
||||
|
||||
```bash
|
||||
npm run test:persistence:restart
|
||||
```
|
||||
|
||||
Optional env vars:
|
||||
|
||||
- `API_BASE` (default `http://localhost:4000/api`)
|
||||
- `WRITE_API_KEY` (needed if backend write endpoints are protected)
|
||||
|
||||
Wiring smoke test
|
||||
|
||||
Run a focused integration check for task/member/invite route wiring:
|
||||
|
||||
```bash
|
||||
npm run test:wiring
|
||||
```
|
||||
|
||||
Optional env vars for this test:
|
||||
|
||||
- `API_BASE` (default `http://localhost:4000/api`)
|
||||
- `WRITE_API_KEY` (required if write endpoints are protected)
|
||||
@@ -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"]
|
||||
|
||||
479
backend/index.js
479
backend/index.js
@@ -1,45 +1,254 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { Low } from 'lowdb';
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
import { join, dirname } from 'path';
|
||||
import { join, dirname, isAbsolute, extname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { mkdirSync, unlinkSync, existsSync, readFileSync } from 'fs';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import multer from 'multer';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = join(__dirname, 'data');
|
||||
import { mkdirSync } from 'fs';
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const dbFile = join(dataDir, 'db.json');
|
||||
|
||||
const dbFileEnv = process.env.DB_FILE;
|
||||
const dbFile = dbFileEnv ? (isAbsolute(dbFileEnv) ? dbFileEnv : join(dataDir, dbFileEnv)) : join(dataDir, 'db.json');
|
||||
const legacyDbFile = join(__dirname, 'db.json');
|
||||
mkdirSync(dirname(dbFile), { recursive: true });
|
||||
const adapter = new JSONFile(dbFile);
|
||||
const defaultData = { members: [], projects: [], tasks: [] };
|
||||
const defaultData = { members: [], projects: [], tasks: [], invites: [] };
|
||||
const db = new Low(adapter, defaultData);
|
||||
|
||||
await db.read();
|
||||
const safeReadJson = (filePath) => {
|
||||
try {
|
||||
if (!existsSync(filePath)) return null;
|
||||
const raw = readFileSync(filePath, 'utf8').trim();
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const toInitials = (name = '') =>
|
||||
String(name)
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map(w => w[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
|
||||
const ensureProjectShape = (project) => {
|
||||
if (!Array.isArray(project.members)) project.members = [];
|
||||
if (!Array.isArray(project.tasks)) project.tasks = [];
|
||||
if (!Array.isArray(project.milestones)) project.milestones = [];
|
||||
};
|
||||
|
||||
try {
|
||||
await db.read();
|
||||
} catch (err) {
|
||||
console.warn('Primary db file was unreadable, reinitializing with defaults.', err?.message || err);
|
||||
db.data = { ...defaultData };
|
||||
await db.write();
|
||||
}
|
||||
if (!db.data) db.data = defaultData;
|
||||
if (!db.data.invites) db.data.invites = [];
|
||||
if (!db.data.members) db.data.members = [];
|
||||
if (!db.data.projects) db.data.projects = [];
|
||||
if (!db.data.tasks) db.data.tasks = [];
|
||||
|
||||
// One-time bootstrap from legacy backend/db.json when the runtime db is empty.
|
||||
const runtimeHasData = (db.data.members.length + db.data.projects.length + db.data.invites.length) > 0;
|
||||
if (!runtimeHasData && dbFile !== legacyDbFile) {
|
||||
const legacy = safeReadJson(legacyDbFile);
|
||||
if (legacy && typeof legacy === 'object') {
|
||||
db.data.members = Array.isArray(legacy.members) ? legacy.members : [];
|
||||
db.data.projects = Array.isArray(legacy.projects) ? legacy.projects : [];
|
||||
db.data.invites = Array.isArray(legacy.invites) ? legacy.invites : [];
|
||||
db.data.tasks = Array.isArray(legacy.tasks) ? legacy.tasks : [];
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize projects and migrate any legacy flat tasks into nested project.tasks.
|
||||
for (const project of db.data.projects) ensureProjectShape(project);
|
||||
for (const task of db.data.tasks) {
|
||||
const project = db.data.projects.find(p => p.id === task.projectId);
|
||||
if (!project) continue;
|
||||
ensureProjectShape(project);
|
||||
const existing = project.tasks.find(t => t.id === task.id);
|
||||
if (existing) continue;
|
||||
project.tasks.push({
|
||||
id: task.id || randomUUID(),
|
||||
title: task.title || 'Untitled Task',
|
||||
description: task.description || '',
|
||||
status: task.status || 'todo',
|
||||
priority: task.priority || 'medium',
|
||||
dueDate: task.dueDate || '',
|
||||
startDate: task.startDate || '',
|
||||
assignedTo: task.assignedTo || task.memberId || '',
|
||||
estimatedHours: task.estimatedHours,
|
||||
actualHours: task.actualHours,
|
||||
recurrence: task.recurrence || 'none',
|
||||
subtasks: Array.isArray(task.subtasks) ? task.subtasks : [],
|
||||
attachments: Array.isArray(task.attachments) ? task.attachments : [],
|
||||
});
|
||||
}
|
||||
db.data.tasks = [];
|
||||
|
||||
for (const project of db.data.projects) {
|
||||
ensureProjectShape(project);
|
||||
project.members = project.members
|
||||
.map(member => {
|
||||
if (member && typeof member === 'object') {
|
||||
return {
|
||||
...member,
|
||||
initials: member.initials || toInitials(member.name),
|
||||
};
|
||||
}
|
||||
const roster = db.data.members.find(m => m.id === member);
|
||||
if (!roster) return null;
|
||||
return { ...roster, initials: roster.initials || toInitials(roster.name) };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
await db.write();
|
||||
|
||||
// ── File uploads (multer) ───────────────────────────────────────────────────
|
||||
const UPLOAD_DIR = join(__dirname, 'uploads');
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/jpeg','image/png','image/gif','image/webp','image/svg+xml',
|
||||
'application/pdf','text/plain','text/csv','text/markdown',
|
||||
'application/json',
|
||||
'application/zip','application/x-zip-compressed',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx
|
||||
]);
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
||||
filename: (req, file, cb) => cb(null, randomUUID() + extname(file.originalname).toLowerCase()),
|
||||
});
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (ALLOWED_MIME.has(file.mimetype)) cb(null, true);
|
||||
else cb(new Error('File type not allowed'));
|
||||
},
|
||||
});
|
||||
|
||||
// ── Notification helper ────────────────────────────────────────────────────
|
||||
async function notify({ webhookUrl, notifyEmail, subject, text, payload }) {
|
||||
if (webhookUrl) {
|
||||
try {
|
||||
await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload || { subject, text }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Webhook notification failed', err?.message || err);
|
||||
}
|
||||
}
|
||||
if (notifyEmail && process.env.SMTP_HOST) {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@projecthub.local',
|
||||
to: notifyEmail,
|
||||
subject,
|
||||
text,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Email notification failed', err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(helmet());
|
||||
app.use(morgan(process.env.MORGAN_FORMAT || 'combined'));
|
||||
|
||||
const configuredOrigins = (process.env.CORS_ORIGIN || '')
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(Boolean);
|
||||
const allowedOrigins = configuredOrigins.length
|
||||
? configuredOrigins
|
||||
: ['http://localhost:5173', 'http://localhost:4000', 'null'];
|
||||
|
||||
const corsOptions = {
|
||||
credentials: true,
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
if (origin === 'null' || origin.startsWith('file://')) return callback(null, true);
|
||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||
return callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
};
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: Number(process.env.RATE_WINDOW_MS) || 15 * 60 * 1000,
|
||||
max: Number(process.env.RATE_MAX) || 200,
|
||||
});
|
||||
|
||||
const requireApiKey = (req, res, next) => {
|
||||
const key = process.env.WRITE_API_KEY;
|
||||
if (!key) return next();
|
||||
const provided = req.get('x-api-key') || req.query.api_key;
|
||||
if (provided === key) return next();
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
};
|
||||
|
||||
// Health
|
||||
app.get('/health', async (req, res) => {
|
||||
try {
|
||||
await db.read();
|
||||
return res.json({ status: 'ok', timestamp: Date.now() });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ status: 'error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Member CRUD
|
||||
app.get('/api/members', (req, res) => {
|
||||
res.json(db.data.members);
|
||||
});
|
||||
|
||||
app.post('/api/members', (req, res) => {
|
||||
app.post('/api/members', limiter, requireApiKey, async (req, res) => {
|
||||
const member = { id: Date.now().toString(), ...req.body };
|
||||
member.initials = member.initials || toInitials(member.name);
|
||||
db.data.members.push(member);
|
||||
db.write();
|
||||
await db.write();
|
||||
res.status(201).json(member);
|
||||
});
|
||||
|
||||
app.delete('/api/members/:id', (req, res) => {
|
||||
app.delete('/api/members/:id', limiter, requireApiKey, async (req, res) => {
|
||||
db.data.members = db.data.members.filter(m => m.id !== req.params.id);
|
||||
db.write();
|
||||
for (const project of db.data.projects) {
|
||||
ensureProjectShape(project);
|
||||
project.members = project.members.filter(m => m.id !== req.params.id);
|
||||
}
|
||||
await db.write();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.put('/api/members/:id', async (req, res) => {
|
||||
app.put('/api/members/:id', limiter, requireApiKey, async (req, res) => {
|
||||
const idx = db.data.members.findIndex(m => m.id === req.params.id);
|
||||
if (idx === -1) return res.status(404).json({ error: 'Member not found' });
|
||||
db.data.members[idx] = { ...db.data.members[idx], ...req.body, id: req.params.id };
|
||||
@@ -52,55 +261,263 @@ app.get('/api/projects', (req, res) => {
|
||||
res.json(db.data.projects);
|
||||
});
|
||||
|
||||
app.post('/api/projects', (req, res) => {
|
||||
const project = { id: Date.now().toString(), members: [], ...req.body };
|
||||
app.post('/api/projects', limiter, requireApiKey, async (req, res) => {
|
||||
const project = { id: Date.now().toString(), members: [], tasks: [], milestones: [], ...req.body };
|
||||
ensureProjectShape(project);
|
||||
db.data.projects.push(project);
|
||||
db.write();
|
||||
await db.write();
|
||||
res.status(201).json(project);
|
||||
});
|
||||
|
||||
app.put('/api/projects/:id', async (req, res) => {
|
||||
app.put('/api/projects/:id', limiter, requireApiKey, async (req, res) => {
|
||||
const idx = db.data.projects.findIndex(p => p.id === req.params.id);
|
||||
const updated = { ...req.body, id: req.params.id };
|
||||
ensureProjectShape(updated);
|
||||
if (idx === -1) db.data.projects.push(updated);
|
||||
else db.data.projects[idx] = updated;
|
||||
await db.write();
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:id', (req, res) => {
|
||||
app.delete('/api/projects/:id', limiter, requireApiKey, async (req, res) => {
|
||||
db.data.projects = db.data.projects.filter(p => p.id !== req.params.id);
|
||||
db.write();
|
||||
await db.write();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Assign member to project
|
||||
app.post('/api/projects/:projectId/members/:memberId', (req, res) => {
|
||||
app.post('/api/projects/:projectId/members/:memberId', limiter, requireApiKey, async (req, res) => {
|
||||
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
if (!project.members.includes(req.params.memberId)) {
|
||||
project.members.push(req.params.memberId);
|
||||
db.write();
|
||||
ensureProjectShape(project);
|
||||
const member = db.data.members.find(m => m.id === req.params.memberId);
|
||||
if (!member) return res.status(404).json({ error: 'Member not found' });
|
||||
|
||||
const alreadyAssigned = project.members.some(m => m.id === member.id);
|
||||
if (!alreadyAssigned) {
|
||||
project.members.push({ ...member, initials: member.initials || toInitials(member.name) });
|
||||
await db.write();
|
||||
}
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
// Tasks (assign to member in project)
|
||||
app.post('/api/projects/:projectId/tasks', (req, res) => {
|
||||
const { title, description, memberId } = req.body;
|
||||
app.post('/api/projects/:projectId/tasks', limiter, requireApiKey, async (req, res) => {
|
||||
const { title, description, memberId, assignedTo } = req.body;
|
||||
const assigneeId = assignedTo || memberId || '';
|
||||
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
if (!project.members.includes(memberId)) return res.status(400).json({ error: 'Member not assigned to project' });
|
||||
const task = { id: Date.now().toString(), projectId: project.id, memberId, title, description, status: 'todo' };
|
||||
db.data.tasks.push(task);
|
||||
db.write();
|
||||
ensureProjectShape(project);
|
||||
if (assigneeId && !project.members.some(m => m.id === assigneeId)) {
|
||||
return res.status(400).json({ error: 'Member not assigned to project' });
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: randomUUID(),
|
||||
title: String(title || '').trim() || 'Untitled Task',
|
||||
description: description || '',
|
||||
status: req.body.status || 'todo',
|
||||
priority: req.body.priority || 'medium',
|
||||
dueDate: req.body.dueDate || '',
|
||||
startDate: req.body.startDate || '',
|
||||
assignedTo: assigneeId,
|
||||
estimatedHours: req.body.estimatedHours,
|
||||
actualHours: req.body.actualHours,
|
||||
recurrence: req.body.recurrence || 'none',
|
||||
subtasks: Array.isArray(req.body.subtasks) ? req.body.subtasks : [],
|
||||
attachments: Array.isArray(req.body.attachments) ? req.body.attachments : [],
|
||||
};
|
||||
|
||||
project.tasks.push(task);
|
||||
await db.write();
|
||||
res.status(201).json(task);
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectId/tasks', (req, res) => {
|
||||
const tasks = db.data.tasks.filter(t => t.projectId === req.params.projectId);
|
||||
res.json(tasks);
|
||||
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
ensureProjectShape(project);
|
||||
res.json(project.tasks);
|
||||
});
|
||||
|
||||
app.put('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => {
|
||||
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
ensureProjectShape(project);
|
||||
const idx = project.tasks.findIndex(t => t.id === req.params.taskId);
|
||||
if (idx === -1) return res.status(404).json({ error: 'Task not found' });
|
||||
|
||||
const assigneeId = req.body.assignedTo || req.body.memberId || project.tasks[idx].assignedTo || '';
|
||||
if (assigneeId && !project.members.some(m => m.id === assigneeId)) {
|
||||
return res.status(400).json({ error: 'Member not assigned to project' });
|
||||
}
|
||||
|
||||
project.tasks[idx] = {
|
||||
...project.tasks[idx],
|
||||
...req.body,
|
||||
id: req.params.taskId,
|
||||
assignedTo: assigneeId,
|
||||
};
|
||||
await db.write();
|
||||
res.json(project.tasks[idx]);
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => {
|
||||
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
ensureProjectShape(project);
|
||||
const idx = project.tasks.findIndex(t => t.id === req.params.taskId);
|
||||
if (idx === -1) return res.status(404).json({ error: 'Task not found' });
|
||||
project.tasks.splice(idx, 1);
|
||||
await db.write();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ── Static uploads ──────────────────────────────────────────────────────────
|
||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||
|
||||
// ── File attachments ────────────────────────────────────────────────────────
|
||||
// Attachments are stored inline in project.tasks[].attachments[]
|
||||
|
||||
app.post('/api/projects/:projectId/tasks/:taskId/attachments', limiter, requireApiKey, (req, res, next) => {
|
||||
upload.single('file')(req, res, async err => {
|
||||
if (err) return res.status(400).json({ error: err.message });
|
||||
if (!req.file) return res.status(400).json({ error: 'No file provided' });
|
||||
|
||||
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
ensureProjectShape(project);
|
||||
const task = project.tasks.find(t => t.id === req.params.taskId);
|
||||
if (!task) return res.status(404).json({ error: 'Task not found' });
|
||||
|
||||
if (!task.attachments) task.attachments = [];
|
||||
const attachment = {
|
||||
id: randomUUID(),
|
||||
filename: req.file.filename,
|
||||
originalName: req.file.originalname,
|
||||
mimetype: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
url: `/uploads/${req.file.filename}`,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
task.attachments.push(attachment);
|
||||
await db.write();
|
||||
res.status(201).json(attachment);
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:projectId/tasks/:taskId/attachments/:attachmentId', limiter, requireApiKey, async (req, res) => {
|
||||
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
ensureProjectShape(project);
|
||||
const task = project.tasks.find(t => t.id === req.params.taskId);
|
||||
if (!task) return res.status(404).json({ error: 'Task not found' });
|
||||
|
||||
const idx = task.attachments?.findIndex(a => a.id === req.params.attachmentId);
|
||||
if (idx === undefined || idx === -1) return res.status(404).json({ error: 'Attachment not found' });
|
||||
|
||||
const [removed] = task.attachments.splice(idx, 1);
|
||||
// Delete file from disk
|
||||
const filePath = join(UPLOAD_DIR, removed.filename);
|
||||
if (existsSync(filePath)) { try { unlinkSync(filePath); } catch {} }
|
||||
|
||||
await db.write();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ── Webhook/notification settings on project ────────────────────────────────
|
||||
// Projects can have optional webhookUrl and notifyEmail; these are just
|
||||
// stored as part of the project doc. The POST /api/projects/:id/notify endpoint
|
||||
// fires a test notification.
|
||||
app.post('/api/projects/:id/notify', limiter, requireApiKey, async (req, res) => {
|
||||
const project = db.data.projects.find(p => p.id === req.params.id);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
const { subject = 'Project Hub notification', text = `Update for project: ${project.name}` } = req.body;
|
||||
notify({ webhookUrl: project.webhookUrl, notifyEmail: project.notifyEmail, subject, text, payload: { projectId: project.id, projectName: project.name, subject, text } });
|
||||
res.json({ sent: true });
|
||||
});
|
||||
|
||||
// ── Invite system ────────────────────────────────────────────────────────────
|
||||
// Generate an invite token for a project
|
||||
app.post('/api/projects/:id/invites', limiter, requireApiKey, async (req, res) => {
|
||||
const project = db.data.projects.find(p => p.id === req.params.id);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
const invite = {
|
||||
id: randomUUID(),
|
||||
token,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
email: req.body.email || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
acceptedAt: null,
|
||||
};
|
||||
db.data.invites.push(invite);
|
||||
await db.write();
|
||||
|
||||
// Fire email if SMTP configured and email provided
|
||||
if (req.body.email) {
|
||||
const appUrl = process.env.APP_URL || 'http://localhost:5173';
|
||||
notify({
|
||||
notifyEmail: req.body.email,
|
||||
subject: `You've been invited to "${project.name}"`,
|
||||
text: `You've been invited to join the project "${project.name}".\n\nAccept your invitation:\n${appUrl}/invite/${token}\n\nThis link expires in 7 days.`,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({ token, expiresAt: invite.expiresAt, inviteId: invite.id });
|
||||
});
|
||||
|
||||
// Validate an invite token (public)
|
||||
app.get('/api/invites/:token', (req, res) => {
|
||||
const invite = db.data.invites.find(i => i.token === req.params.token);
|
||||
if (!invite) return res.status(404).json({ error: 'Invite not found or expired' });
|
||||
if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' });
|
||||
if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' });
|
||||
res.json({ projectId: invite.projectId, projectName: invite.projectName, email: invite.email, expiresAt: invite.expiresAt });
|
||||
});
|
||||
|
||||
// Accept an invite (attaches provided member name to project if given)
|
||||
app.post('/api/invites/:token/accept', limiter, async (req, res) => {
|
||||
const invite = db.data.invites.find(i => i.token === req.params.token);
|
||||
if (!invite) return res.status(404).json({ error: 'Invite not found' });
|
||||
if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' });
|
||||
if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' });
|
||||
|
||||
invite.acceptedAt = new Date().toISOString();
|
||||
|
||||
// Optionally add the member to the project
|
||||
if (req.body.name) {
|
||||
const project = db.data.projects.find(p => p.id === invite.projectId);
|
||||
if (project) {
|
||||
ensureProjectShape(project);
|
||||
const initials = req.body.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
const email = (req.body.email || invite.email || '').trim();
|
||||
let rosterMember = db.data.members.find(m => email && m.email === email);
|
||||
if (!rosterMember) {
|
||||
rosterMember = {
|
||||
id: randomUUID(),
|
||||
name: req.body.name,
|
||||
role: req.body.role || 'Team Member',
|
||||
initials,
|
||||
email,
|
||||
};
|
||||
db.data.members.push(rosterMember);
|
||||
}
|
||||
|
||||
if (!project.members.some(m => m.id === rosterMember.id)) {
|
||||
project.members.push({ ...rosterMember, initials: rosterMember.initials || toInitials(rosterMember.name) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.write();
|
||||
res.json({ projectId: invite.projectId, projectName: invite.projectName });
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
app.listen(PORT, () => console.log(`Backend running on port ${PORT}`));
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
app.listen(PORT, HOST, () => console.log(`Backend running on ${HOST}:${PORT}`));
|
||||
|
||||
192
backend/package-lock.json
generated
192
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
{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 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,16 +445,22 @@ 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>
|
||||
<div style={{ padding: '18px 22px', flex: 1 }}>
|
||||
{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 === '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
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
173
frontend/src/components/BurndownChart.jsx
Normal file
173
frontend/src/components/BurndownChart.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
frontend/src/components/CalendarView.jsx
Normal file
147
frontend/src/components/CalendarView.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
231
frontend/src/components/GanttView.jsx
Normal file
231
frontend/src/components/GanttView.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
package.json
28
package.json
@@ -4,10 +4,36 @@
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"build": "electron-builder"
|
||||
"build": "electron-builder",
|
||||
"build:desktop": "npm run build:renderer && npm run sync:dist && npm run build",
|
||||
"build:renderer": "npm run build --prefix frontend",
|
||||
"sync:dist": "rm -rf dist && mkdir -p dist && cp -R frontend/dist/* dist/",
|
||||
"test:wiring": "node scripts/wiring-smoke-test.cjs",
|
||||
"test:persistence": "node scripts/persistence-self-test.cjs",
|
||||
"test:persistence:restart": "node scripts/persistence-self-test.cjs --restart-backend",
|
||||
"build:verified": "npm run test:persistence && npm run build",
|
||||
"build:verified:restart": "npm run test:persistence:restart && npm run build",
|
||||
"release:verified": "npm run test:persistence && npm run build:renderer && npm run sync:dist && npm run build",
|
||||
"release:verified:restart": "npm run test:persistence:restart && npm run build:renderer && npm run sync:dist && npm run build"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.projecthub.app",
|
||||
"productName": "Project Hub",
|
||||
"directories": {
|
||||
"output": "releases"
|
||||
},
|
||||
"mac": {
|
||||
"identity": null,
|
||||
"hardenedRuntime": false,
|
||||
"target": [
|
||||
{ "target": "dmg", "arch": ["arm64"] }
|
||||
]
|
||||
},
|
||||
"afterSign": "scripts/adhoc-sign.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron": "^30.0.0",
|
||||
"electron-builder": "^24.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
scripts/adhoc-sign.js
Normal file
15
scripts/adhoc-sign.js
Normal 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.');
|
||||
};
|
||||
185
scripts/persistence-self-test.cjs
Normal file
185
scripts/persistence-self-test.cjs
Normal 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);
|
||||
});
|
||||
190
scripts/wiring-smoke-test.cjs
Normal file
190
scripts/wiring-smoke-test.cjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user