Phase 3-4: Goals AI planner, Team tasks, Push notifications, i18n (CZ/HE/RU/UA)

- Goals CRUD API + AI study plan generator + progress reports
- Goals frontend page with progress bars
- Team tasks: assign, transfer, collaborate endpoints
- Push notifications: web-push, VAPID, subscribe/send
- i18n: 4 languages (cs, he, ru, ua) translation files
- notifications.js + goals.js routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude CLI Agent
2026-03-29 13:12:19 +00:00
parent eac9e72404
commit fea4d38ce8
19 changed files with 1176 additions and 112 deletions

155
api/src/routes/goals.js Normal file
View File

@@ -0,0 +1,155 @@
// Task Team — Goals API + AI Planner — 2026-03-29
const Anthropic = require("@anthropic-ai/sdk");
async function goalRoutes(app) {
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
// List goals
app.get("/goals", async (req) => {
const { rows } = await app.db.query(
"SELECT g.*, tg.name as group_name, tg.color as group_color, tg.icon as group_icon FROM goals g LEFT JOIN task_groups tg ON g.group_id = tg.id ORDER BY g.target_date ASC NULLS LAST"
);
return { data: rows };
});
// Get goal with related tasks
app.get("/goals/:id", async (req) => {
const { rows } = await app.db.query("SELECT * FROM goals WHERE id = $1", [req.params.id]);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
const { rows: tasks } = await app.db.query(
"SELECT * FROM tasks WHERE external_id LIKE $1 ORDER BY scheduled_at ASC",
["goal:" + req.params.id + "%"]
);
return { data: { ...rows[0], tasks } };
});
// Create goal
app.post("/goals", async (req) => {
const { title, target_date, group_id, user_id, plan } = req.body;
const { rows } = await app.db.query(
"INSERT INTO goals (title, target_date, group_id, user_id, plan) VALUES ($1, $2, $3, $4, $5) RETURNING *",
[title, target_date || null, group_id || null, user_id || null, JSON.stringify(plan || {})]
);
return { data: rows[0] };
});
// Update goal progress
app.put("/goals/:id", async (req) => {
const { title, target_date, progress_pct, plan } = req.body;
const { rows } = await app.db.query(
"UPDATE goals SET title=COALESCE($1,title), target_date=COALESCE($2,target_date), progress_pct=COALESCE($3,progress_pct), plan=COALESCE($4,plan), updated_at=NOW() WHERE id=$5 RETURNING *",
[title, target_date, progress_pct, plan ? JSON.stringify(plan) : null, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
return { data: rows[0] };
});
// Delete goal
app.delete("/goals/:id", async (req) => {
const { rowCount } = await app.db.query("DELETE FROM goals WHERE id = $1", [req.params.id]);
if (!rowCount) throw { statusCode: 404, message: "Goal not found" };
return { status: "deleted" };
});
// AI: Generate study plan for a goal
app.post("/goals/:id/plan", async (req) => {
const { rows } = await app.db.query("SELECT * FROM goals WHERE id = $1", [req.params.id]);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
const goal = rows[0];
const { rows: groups } = await app.db.query(
"SELECT name, time_zones FROM task_groups WHERE id = $1", [goal.group_id]
);
const groupInfo = groups[0] || {};
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 2048,
system: "Jsi planovaci AI asistent. Generujes detailni studijni/pracovni plany v JSON formatu. Odpovidej v cestine.",
messages: [{
role: "user",
content: "Vytvor tydenni plan pro tento cil:\nCil: " + goal.title +
"\nTermin: " + (goal.target_date || "bez terminu") +
"\nAktualni progres: " + goal.progress_pct + "%" +
"\nSkupina: " + (groupInfo.name || "obecna") +
"\nCasove zony skupiny: " + JSON.stringify(groupInfo.time_zones || []) +
"\n\nVrat JSON s polem \"weeks\" kde kazdy tyden ma \"week_number\", \"focus\", \"tasks\" (pole s title, description, duration_hours, day_of_week).\nZahrn spaced repetition pro opakovani."
}]
});
let plan;
try {
const text = response.content[0].text;
const jsonMatch = text.match(/\{[\s\S]*\}/);
plan = jsonMatch ? JSON.parse(jsonMatch[0]) : { raw: text };
} catch (e) {
plan = { raw: response.content[0].text };
}
// Save plan
await app.db.query(
"UPDATE goals SET plan = $1, updated_at = NOW() WHERE id = $2",
[JSON.stringify(plan), goal.id]
);
// Create tasks from plan
let tasksCreated = 0;
if (plan.weeks) {
for (const week of plan.weeks) {
for (const task of (week.tasks || [])) {
await app.db.query(
"INSERT INTO tasks (title, description, group_id, external_id, external_source, status, priority) VALUES ($1, $2, $3, $4, $5, $6, $7)",
[
task.title,
task.description || "",
goal.group_id,
"goal:" + goal.id + ":w" + week.week_number,
"goal",
"pending",
"medium"
]
);
tasksCreated++;
}
}
}
return { data: { plan, tasks_created: tasksCreated } };
});
// AI: Progress report for a goal
app.get("/goals/:id/report", async (req) => {
const { rows } = await app.db.query("SELECT * FROM goals WHERE id = $1", [req.params.id]);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
const { rows: tasks } = await app.db.query(
"SELECT title, status, completed_at FROM tasks WHERE external_id LIKE $1",
["goal:" + req.params.id + "%"]
);
const done = tasks.filter(t => t.status === "completed" || t.status === "done").length;
const total = tasks.length;
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
system: "Jsi AI coach. Davas motivacni zpetnou vazbu k plneni cilu. Odpovidej v cestine, strucne.",
messages: [{
role: "user",
content: "Cil: " + rows[0].title +
"\nSplneno: " + done + "/" + total + " ukolu (" + (total ? Math.round(done / total * 100) : 0) + "%)" +
"\nProgres: " + rows[0].progress_pct + "%" +
"\nDej zpetnou vazbu a doporuceni."
}]
});
return {
data: {
report: response.content[0].text,
stats: { done, total, pct: total ? Math.round(done / total * 100) : 0 }
}
};
});
}
module.exports = goalRoutes;

View File

@@ -0,0 +1,77 @@
// Task Team — Push Notifications — 2026-03-29
const webpush = require("web-push");
async function notificationRoutes(app) {
// Generate VAPID keys if not exist
const vapidKeys = {
publicKey: process.env.VAPID_PUBLIC_KEY || "",
privateKey: process.env.VAPID_PRIVATE_KEY || ""
};
if (!vapidKeys.publicKey) {
const keys = webpush.generateVAPIDKeys();
vapidKeys.publicKey = keys.publicKey;
vapidKeys.privateKey = keys.privateKey;
console.log("VAPID keys generated. Add to env:", JSON.stringify(keys));
}
webpush.setVapidDetails("mailto:admin@hasdo.info", vapidKeys.publicKey, vapidKeys.privateKey);
// Create subscriptions table if not exists
await app.db.query(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
subscription JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
// Get VAPID public key
app.get("/notifications/vapid-key", async () => {
return { data: { publicKey: vapidKeys.publicKey } };
});
// Subscribe
app.post("/notifications/subscribe", async (req) => {
const { subscription, user_id } = req.body;
await app.db.query(
"INSERT INTO push_subscriptions (user_id, subscription) VALUES ($1, $2)",
[user_id, JSON.stringify(subscription)]
);
return { status: "subscribed" };
});
// Send notification (internal use)
app.post("/notifications/send", async (req) => {
const { user_id, title, body, url } = req.body;
const { rows } = await app.db.query(
"SELECT subscription FROM push_subscriptions WHERE user_id = $1", [user_id]
);
let sent = 0;
for (const row of rows) {
try {
await webpush.sendNotification(
JSON.parse(row.subscription),
JSON.stringify({ title, body, url: url || "/tasks" })
);
sent++;
} catch (e) {
if (e.statusCode === 410) {
await app.db.query("DELETE FROM push_subscriptions WHERE subscription = $1", [row.subscription]);
}
}
}
return { status: "sent", count: sent };
});
// Unsubscribe
app.delete("/notifications/unsubscribe", async (req) => {
const { user_id } = req.body;
await app.db.query("DELETE FROM push_subscriptions WHERE user_id = $1", [user_id]);
return { status: "unsubscribed" };
});
}
module.exports = notificationRoutes;

View File

@@ -153,6 +153,62 @@ async function taskRoutes(app) {
);
return { data: rows[0] };
});
// === Team Collaboration Endpoints ===
// Assign task to user
app.post("/tasks/:id/assign", async (req) => {
const { user_id } = req.body;
if (!user_id) throw { statusCode: 400, message: "user_id is required" };
const { rows } = await app.db.query(
"UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid), updated_at = NOW() WHERE id = $2 RETURNING *",
[user_id, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
await invalidateTaskCaches();
return { data: rows[0] };
});
// Transfer task (creates pending transfer)
app.post("/tasks/:id/transfer", async (req) => {
const { to_user_id, message } = req.body;
if (!to_user_id) throw { statusCode: 400, message: "to_user_id is required" };
await app.db.query(
"INSERT INTO task_comments (task_id, content, is_ai) VALUES ($1, $2, true)",
[req.params.id, JSON.stringify({ type: "transfer_request", to: to_user_id, message: message || "", status: "pending" })]
);
return { status: "transfer_requested" };
});
// Accept/reject transfer
app.post("/tasks/:id/transfer/respond", async (req) => {
const { accept, user_id } = req.body;
if (accept) {
if (!user_id) throw { statusCode: 400, message: "user_id is required" };
const { rows } = await app.db.query(
"UPDATE tasks SET user_id = $1, updated_at = NOW() WHERE id = $2 RETURNING *",
[user_id, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
await invalidateTaskCaches();
return { data: rows[0], status: "transferred" };
}
return { status: "rejected" };
});
// Collaborate on task
app.post("/tasks/:id/collaborate", async (req) => {
const { user_id } = req.body;
if (!user_id) throw { statusCode: 400, message: "user_id is required" };
const { rows } = await app.db.query(
"UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid), updated_at = NOW() WHERE id = $2 RETURNING *",
[user_id, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
await invalidateTaskCaches();
return { data: rows[0] };
});
}
module.exports = taskRoutes;