@programinglive/dev-workflow-mcp-server 1.1.7 ā 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/README.md +92 -5
- package/db/index.js +204 -0
- package/exec.js +46 -1
- package/index.js +12 -2
- package/install.js +5 -1
- package/package.json +15 -8
- package/release-wrapper.js +1 -1
- package/tests/build.test.js +37 -0
- package/tests/exec.test.js +32 -0
- package/tests/handlers.test.js +288 -0
- package/tests/release-wrapper.test.js +4 -2
- package/tests/workflowState.test.js +220 -1
- package/tools/definitions.js +103 -0
- package/tools/handlers.js +261 -8
- package/vite.config.js +43 -0
- package/web/index.html +307 -0
- package/web/server.js +57 -0
- package/workflow-state.js +439 -15
package/tools/handlers.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const PROJECT_SUMMARY_FILENAME = "project-summary.json";
|
|
9
|
+
|
|
1
10
|
const textResponse = (text) => ({
|
|
2
11
|
content: [
|
|
3
12
|
{
|
|
@@ -48,6 +57,30 @@ function summarizeHistory(history, limit) {
|
|
|
48
57
|
return `š Workflow History (last ${recent.length} tasks)\n\n${historyText}`;
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
function generateProjectSummary(history) {
|
|
61
|
+
if (!history || history.length === 0) {
|
|
62
|
+
return "No workflow history available.";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const taskTypes = {};
|
|
66
|
+
const recentTasks = history.slice(-20);
|
|
67
|
+
|
|
68
|
+
for (const entry of recentTasks) {
|
|
69
|
+
const type = entry.taskType || "other";
|
|
70
|
+
taskTypes[type] = (taskTypes[type] || 0) + 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const typeSummary = Object.entries(taskTypes)
|
|
74
|
+
.map(([type, count]) => `${type}: ${count}`)
|
|
75
|
+
.join(", ");
|
|
76
|
+
|
|
77
|
+
const totalTasks = history.length;
|
|
78
|
+
const lastTask = history[history.length - 1];
|
|
79
|
+
const lastActive = lastTask ? new Date(lastTask.timestamp).toLocaleDateString() : "Never";
|
|
80
|
+
|
|
81
|
+
return `š§ Project Knowledge Summary\n\nš Stats\n- Total tasks completed: ${totalTasks}\n- Recent task mix: ${typeSummary}\n- Last active: ${lastActive}\n\nš Recent tasks (last 5)\n${history.slice(-5).reverse().map((e, i) => `${i + 1}. ${e.taskDescription} (${e.taskType})`).join("\n")}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
51
84
|
function getContinueGuidance(status) {
|
|
52
85
|
switch (status.currentPhase) {
|
|
53
86
|
case "coding": {
|
|
@@ -162,6 +195,29 @@ async function handleMarkBugFixed(args, workflowState) {
|
|
|
162
195
|
return textResponse("ā ļø Please start a task first using 'start_task'!");
|
|
163
196
|
}
|
|
164
197
|
|
|
198
|
+
// Idempotent behavior: if already marked fixed, do not reset downstream flags.
|
|
199
|
+
if (workflowState.state.bugFixed) {
|
|
200
|
+
const summary = typeof args.summary === "string" ? args.summary : "";
|
|
201
|
+
if (summary && summary.trim() !== "") {
|
|
202
|
+
workflowState.state.fixSummary = summary;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Preserve existing progress and set an appropriate phase.
|
|
206
|
+
if (workflowState.state.testsPassed && workflowState.state.documentationCreated) {
|
|
207
|
+
workflowState.state.currentPhase = "ready";
|
|
208
|
+
workflowState.state.readyToCommit = true;
|
|
209
|
+
} else if (workflowState.state.testsCreated) {
|
|
210
|
+
workflowState.state.currentPhase = "testing";
|
|
211
|
+
}
|
|
212
|
+
workflowState.state.readyCheckCompleted = false;
|
|
213
|
+
workflowState.state.commitAndPushCompleted = false;
|
|
214
|
+
await workflowState.save();
|
|
215
|
+
|
|
216
|
+
return textResponse(
|
|
217
|
+
"ā¹ļø Already marked as fixed; kept existing tests/documentation. Continue with the next step."
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
165
221
|
workflowState.state.bugFixed = true;
|
|
166
222
|
workflowState.state.testsCreated = false;
|
|
167
223
|
workflowState.state.currentPhase = "testing";
|
|
@@ -358,14 +414,12 @@ async function handleReadyCheck(workflowState) {
|
|
|
358
414
|
|
|
359
415
|
async function handleCommitAndPush(args, context) {
|
|
360
416
|
const { workflowState, exec, git, utils } = context;
|
|
361
|
-
if (!workflowState.state.readyToCommit) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
"ā ļø Please run 'check_ready_to_commit' and resolve any outstanding items before committing!"
|
|
368
|
-
);
|
|
417
|
+
if (!workflowState.state.readyToCommit || !workflowState.state.readyCheckCompleted) {
|
|
418
|
+
// Be smart: auto-run the ready check and proceed if it passes.
|
|
419
|
+
const readyResponse = await handleReadyCheck(workflowState);
|
|
420
|
+
if (workflowState.state.currentPhase !== "commit") {
|
|
421
|
+
return readyResponse;
|
|
422
|
+
}
|
|
369
423
|
}
|
|
370
424
|
|
|
371
425
|
const providedCommitMessage =
|
|
@@ -696,6 +750,195 @@ function handleViewHistory(args, workflowState) {
|
|
|
696
750
|
return textResponse(message);
|
|
697
751
|
}
|
|
698
752
|
|
|
753
|
+
function handleProjectSummary(workflowState) {
|
|
754
|
+
const summary = generateProjectSummary(workflowState.state.history);
|
|
755
|
+
return textResponse(summary);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function handleProjectSummaryData(workflowState) {
|
|
759
|
+
const summaryPath = path.join(path.dirname(workflowState.stateFile), PROJECT_SUMMARY_FILENAME);
|
|
760
|
+
try {
|
|
761
|
+
const data = await fs.readFile(summaryPath, "utf-8");
|
|
762
|
+
const parsed = JSON.parse(data);
|
|
763
|
+
const formatted = `š§ Project Knowledge Summary (from persisted data)\n\nš Stats\n- Total tasks completed: ${parsed.totalTasks}\n- Recent task mix: ${Object.entries(parsed.taskTypes).map(([t, c]) => `${t}: ${c}`).join(", ")}\n- Last active: ${parsed.lastActive ? new Date(parsed.lastActive).toLocaleDateString() : "Never"}\n- Updated: ${new Date(parsed.updatedAt).toLocaleString()}\n\nš Recent tasks (last 5)\n${parsed.recentTasks.map((e, i) => `${i + 1}. ${e.description} (${e.type})`).join("\n")}`;
|
|
764
|
+
return textResponse(formatted);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
// Fallback to in-memory generation if file doesn't exist
|
|
767
|
+
return handleProjectSummary(workflowState);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function handleProjectSummaryDb(workflowState) {
|
|
772
|
+
const { getSummaryForUser } = await import("../db/index.js");
|
|
773
|
+
const userId = process.env.DEV_WORKFLOW_USER_ID || "default";
|
|
774
|
+
const summary = getSummaryForUser(userId);
|
|
775
|
+
if (!summary) {
|
|
776
|
+
return textResponse("š No project summary in database yet.");
|
|
777
|
+
}
|
|
778
|
+
const formatted = `š§ Project Knowledge Summary (from SQLite)\n\nš Stats\n- Total tasks completed: ${summary.totalTasks}\n- Recent task mix: ${Object.entries(summary.taskTypes).map(([t, c]) => `${t}: ${c}`).join(", ")}\n- Last active: ${summary.lastActive ? new Date(summary.lastActive).toLocaleDateString() : "Never"}\n- Updated: ${new Date(summary.updatedAt).toLocaleString()}\n\nš Recent tasks (last 5)\n${summary.recentTasks.map((e, i) => `${i + 1}. ${e.description} (${e.type})`).join("\n")}`;
|
|
779
|
+
return textResponse(formatted);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function handleRerunWorkflow(workflowState) {
|
|
783
|
+
const currentDescription = workflowState.state.taskDescription;
|
|
784
|
+
const currentType = workflowState.state.taskType;
|
|
785
|
+
|
|
786
|
+
if (!currentDescription) {
|
|
787
|
+
return textResponse("ā ļø No active task to rerun. Use 'start_task' to begin a new workflow.");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
workflowState.reset();
|
|
791
|
+
workflowState.state.currentPhase = "coding";
|
|
792
|
+
workflowState.state.taskDescription = currentDescription;
|
|
793
|
+
workflowState.state.taskType = currentType;
|
|
794
|
+
await workflowState.save();
|
|
795
|
+
|
|
796
|
+
return textResponse(
|
|
797
|
+
`š Rerunning workflow from the start!\n\nTask: ${currentDescription}\nType: ${currentType}\n\nWorkflow Steps:\n1. ā Start task (current)\n2. ā³ Fix/implement the feature\n3. ā³ Create tests\n4. ā³ Run tests (must pass!)\n5. ā³ Create documentation\n6. ā³ Run 'check_ready_to_commit'\n7. ā³ Run 'commit_and_push' (commits and pushes)\n8. ā³ Run 'perform_release' (handles versioning and tags)\n9. ā³ Complete task\n\nšÆ Be conscious about what you're coding!`
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function isErrorResponse(response) {
|
|
802
|
+
const text = response?.content?.[0]?.text || "";
|
|
803
|
+
return /^\s*[ā ļøā]/u.test(text);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function summarizeResponse(label, response) {
|
|
807
|
+
const text = response?.content?.[0]?.text || "";
|
|
808
|
+
const firstLine = text.split("\n")[0] || "";
|
|
809
|
+
return `${label}: ${firstLine}`;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function handleRunFullWorkflow(args, { workflowState, exec, git, utils }) {
|
|
813
|
+
const description = workflowState.state.taskDescription;
|
|
814
|
+
if (!description) {
|
|
815
|
+
return textResponse("ā ļø Please start a task first using 'start_task' before running the full workflow.");
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
await workflowState.ensurePrimaryFile();
|
|
819
|
+
|
|
820
|
+
const requiredStrings = [
|
|
821
|
+
{ key: "summary", message: "Provide a non-empty 'summary' for mark_bug_fixed." },
|
|
822
|
+
{ key: "testCommand", message: "Provide a non-empty 'testCommand' for run_tests." },
|
|
823
|
+
{ key: "documentationType", message: "Provide 'documentationType' for create_documentation." },
|
|
824
|
+
{ key: "documentationSummary", message: "Provide a non-empty 'documentationSummary'." },
|
|
825
|
+
{ key: "commitMessage", message: "Provide a non-empty 'commitMessage'." },
|
|
826
|
+
{ key: "releaseCommand", message: "Provide a non-empty 'releaseCommand'." },
|
|
827
|
+
];
|
|
828
|
+
|
|
829
|
+
for (const { key, message } of requiredStrings) {
|
|
830
|
+
const value = args[key];
|
|
831
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
832
|
+
return textResponse(`ā ļø ${message}`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const steps = [];
|
|
837
|
+
const testsPassed = typeof args.testsPassed === "boolean" ? args.testsPassed : true;
|
|
838
|
+
const testDetails = typeof args.testDetails === "string" ? args.testDetails : "";
|
|
839
|
+
const releaseArgs = {
|
|
840
|
+
command: args.releaseCommand,
|
|
841
|
+
};
|
|
842
|
+
if (typeof args.releaseNotes === "string" && args.releaseNotes.trim() !== "") {
|
|
843
|
+
releaseArgs.notes = args.releaseNotes;
|
|
844
|
+
}
|
|
845
|
+
if (typeof args.releaseType === "string" && args.releaseType.trim() !== "") {
|
|
846
|
+
releaseArgs.releaseType = args.releaseType;
|
|
847
|
+
}
|
|
848
|
+
if (typeof args.preset === "string" && args.preset.trim() !== "") {
|
|
849
|
+
releaseArgs.preset = args.preset;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Smart flow: detect current phase and run only remaining steps
|
|
853
|
+
async function executeStep(name, handlerFn, handlerArgs = []) {
|
|
854
|
+
if (isErrorResponse(handlerFn)) {
|
|
855
|
+
return handlerFn;
|
|
856
|
+
}
|
|
857
|
+
const response = await handlerFn(...handlerArgs);
|
|
858
|
+
if (isErrorResponse(response)) {
|
|
859
|
+
return response;
|
|
860
|
+
}
|
|
861
|
+
steps.push(summarizeResponse(name, response));
|
|
862
|
+
await workflowState.load();
|
|
863
|
+
return response;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
while (true) {
|
|
867
|
+
const state = workflowState.state;
|
|
868
|
+
|
|
869
|
+
if (!state.bugFixed) {
|
|
870
|
+
const resp = await executeStep("mark_bug_fixed", handleMarkBugFixed, [{ summary: args.summary }, workflowState]);
|
|
871
|
+
if (isErrorResponse(resp)) return resp;
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (!state.testsCreated) {
|
|
876
|
+
const resp = await executeStep("create_tests", handleCreateTests, [workflowState]);
|
|
877
|
+
if (isErrorResponse(resp)) return resp;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (!state.testsPassed && !state.testsSkipped) {
|
|
882
|
+
const resp = await executeStep("run_tests", handleRunTests, [
|
|
883
|
+
{ passed: testsPassed, testCommand: args.testCommand, details: testDetails },
|
|
884
|
+
workflowState,
|
|
885
|
+
]);
|
|
886
|
+
if (isErrorResponse(resp)) return resp;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (!state.documentationCreated) {
|
|
891
|
+
const resp = await executeStep("create_documentation", handleCreateDocumentation, [
|
|
892
|
+
{ documentationType: args.documentationType, summary: args.documentationSummary },
|
|
893
|
+
workflowState,
|
|
894
|
+
]);
|
|
895
|
+
if (isErrorResponse(resp)) return resp;
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (!state.readyCheckCompleted) {
|
|
900
|
+
const resp = await executeStep("check_ready_to_commit", handleReadyCheck, [workflowState]);
|
|
901
|
+
if (isErrorResponse(resp)) return resp;
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (!state.commitAndPushCompleted) {
|
|
906
|
+
const resp = await executeStep("commit_and_push", handleCommitAndPush, [
|
|
907
|
+
{
|
|
908
|
+
commitMessage: args.commitMessage,
|
|
909
|
+
branch: typeof args.branch === "string" ? args.branch : "",
|
|
910
|
+
},
|
|
911
|
+
{ workflowState, exec, git, utils },
|
|
912
|
+
]);
|
|
913
|
+
if (isErrorResponse(resp)) return resp;
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (!state.released) {
|
|
918
|
+
const resp = await executeStep("perform_release", handlePerformRelease, [
|
|
919
|
+
releaseArgs,
|
|
920
|
+
{ workflowState, exec, git, utils },
|
|
921
|
+
]);
|
|
922
|
+
if (isErrorResponse(resp)) return resp;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const completeResponse = await handleCompleteTask({ commitMessage: args.commitMessage }, workflowState);
|
|
927
|
+
if (isErrorResponse(completeResponse)) {
|
|
928
|
+
return completeResponse;
|
|
929
|
+
}
|
|
930
|
+
steps.push(summarizeResponse("complete_task", completeResponse));
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const summary = steps.map((line, index) => `${index + 1}. ${line}`).join("\n");
|
|
935
|
+
const celebration = workflowState.state.history?.length
|
|
936
|
+
? "š Cheers! We solved another issue and captured it in the knowledge base."
|
|
937
|
+
: "";
|
|
938
|
+
const message = [`ā
Full workflow completed successfully!`, celebration, summary].filter(Boolean).join("\n\n");
|
|
939
|
+
return textResponse(message);
|
|
940
|
+
}
|
|
941
|
+
|
|
699
942
|
export async function handleToolCall({
|
|
700
943
|
request,
|
|
701
944
|
normalizeRequestArgs,
|
|
@@ -741,8 +984,18 @@ export async function handleToolCall({
|
|
|
741
984
|
return handleGetWorkflowStatus(workflowState);
|
|
742
985
|
case "view_history":
|
|
743
986
|
return handleViewHistory(args, workflowState);
|
|
987
|
+
case "project_summary":
|
|
988
|
+
return handleProjectSummary(workflowState);
|
|
989
|
+
case "project_summary_data":
|
|
990
|
+
return handleProjectSummaryData(workflowState);
|
|
991
|
+
case "project_summary_db":
|
|
992
|
+
return handleProjectSummaryDb(workflowState);
|
|
744
993
|
case "continue_workflow":
|
|
745
994
|
return handleContinueWorkflow(workflowState, { workflowState, exec, git, utils });
|
|
995
|
+
case "rerun_workflow":
|
|
996
|
+
return handleRerunWorkflow(workflowState);
|
|
997
|
+
case "run_full_workflow":
|
|
998
|
+
return handleRunFullWorkflow(args, { workflowState, exec, git, utils });
|
|
746
999
|
default:
|
|
747
1000
|
throw new Error(`Unknown tool: ${name}`);
|
|
748
1001
|
}
|
package/vite.config.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
build: {
|
|
10
|
+
target: 'node18',
|
|
11
|
+
outDir: 'dist',
|
|
12
|
+
minify: false,
|
|
13
|
+
rollupOptions: {
|
|
14
|
+
input: path.resolve(__dirname, 'index.js'),
|
|
15
|
+
output: {
|
|
16
|
+
entryFileNames: 'index.mjs',
|
|
17
|
+
format: 'es',
|
|
18
|
+
},
|
|
19
|
+
external: [
|
|
20
|
+
'@modelcontextprotocol/sdk',
|
|
21
|
+
// Node.js built-in modules
|
|
22
|
+
'node:fs',
|
|
23
|
+
'node:path',
|
|
24
|
+
'node:url',
|
|
25
|
+
'node:util',
|
|
26
|
+
'node:os',
|
|
27
|
+
'node:process',
|
|
28
|
+
'node:child_process',
|
|
29
|
+
'fs',
|
|
30
|
+
'fs/promises',
|
|
31
|
+
'path',
|
|
32
|
+
'url',
|
|
33
|
+
'util',
|
|
34
|
+
'os',
|
|
35
|
+
'process',
|
|
36
|
+
'child_process',
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
define: {
|
|
41
|
+
__filename: JSON.stringify(path.resolve(__dirname, 'index.js')),
|
|
42
|
+
},
|
|
43
|
+
});
|
package/web/index.html
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Dev Workflow Dashboard</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<script src="https://unpkg.com/lucide@latest"></script>
|
|
9
|
+
</head>
|
|
10
|
+
<body class="bg-gray-50 text-gray-900">
|
|
11
|
+
<div class="container mx-auto p-4 max-w-4xl">
|
|
12
|
+
<header class="mb-6">
|
|
13
|
+
<h1 class="text-3xl font-bold flex items-center gap-2">
|
|
14
|
+
<i data-lucide="activity"></i>
|
|
15
|
+
Dev Workflow Dashboard
|
|
16
|
+
</h1>
|
|
17
|
+
<p class="text-gray-600">Project history and summary from SQLite</p>
|
|
18
|
+
</header>
|
|
19
|
+
|
|
20
|
+
<section class="mb-8">
|
|
21
|
+
<div class="bg-white rounded-lg shadow p-6">
|
|
22
|
+
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
|
|
23
|
+
<i data-lucide="user"></i>
|
|
24
|
+
Filters
|
|
25
|
+
</h2>
|
|
26
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
27
|
+
<label class="flex flex-col gap-1">
|
|
28
|
+
<span class="text-sm font-medium text-gray-600">User ID</span>
|
|
29
|
+
<input id="userId" type="text" placeholder="default" class="border rounded px-3 py-2" />
|
|
30
|
+
</label>
|
|
31
|
+
<label class="flex flex-col gap-1">
|
|
32
|
+
<span class="text-sm font-medium text-gray-600">Start date</span>
|
|
33
|
+
<input id="startDate" type="date" class="border rounded px-3 py-2" />
|
|
34
|
+
</label>
|
|
35
|
+
<label class="flex flex-col gap-1">
|
|
36
|
+
<span class="text-sm font-medium text-gray-600">End date</span>
|
|
37
|
+
<input id="endDate" type="date" class="border rounded px-3 py-2" />
|
|
38
|
+
</label>
|
|
39
|
+
<label class="flex flex-col gap-1">
|
|
40
|
+
<span class="text-sm font-medium text-gray-600">Activity frequency</span>
|
|
41
|
+
<select id="frequency" class="border rounded px-3 py-2">
|
|
42
|
+
<option value="daily">Daily</option>
|
|
43
|
+
<option value="monthly">Monthly</option>
|
|
44
|
+
<option value="yearly">Yearly</option>
|
|
45
|
+
</select>
|
|
46
|
+
</label>
|
|
47
|
+
<label class="flex flex-col gap-1">
|
|
48
|
+
<span class="text-sm font-medium text-gray-600">Page size</span>
|
|
49
|
+
<select id="pageSize" class="border rounded px-3 py-2">
|
|
50
|
+
<option value="10">10</option>
|
|
51
|
+
<option value="20" selected>20</option>
|
|
52
|
+
<option value="50">50</option>
|
|
53
|
+
</select>
|
|
54
|
+
</label>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="flex flex-wrap gap-3 items-center mt-4">
|
|
57
|
+
<button id="applyFiltersBtn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">Apply filters</button>
|
|
58
|
+
<button id="resetFiltersBtn" class="bg-gray-200 text-gray-800 px-4 py-2 rounded hover:bg-gray-300">Reset</button>
|
|
59
|
+
<span class="text-sm text-gray-500">Leave fields empty to view all data for the default user.</span>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</section>
|
|
63
|
+
|
|
64
|
+
<section id="summarySection" class="mb-8 hidden">
|
|
65
|
+
<div class="bg-white rounded-lg shadow p-6">
|
|
66
|
+
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
|
|
67
|
+
<i data-lucide="bar-chart-2"></i>
|
|
68
|
+
Project Summary
|
|
69
|
+
</h2>
|
|
70
|
+
<div id="summaryContent" class="space-y-2"></div>
|
|
71
|
+
<div id="historySummaryCard" class="mt-6 bg-gray-50 border border-gray-200 rounded-md p-4 hidden">
|
|
72
|
+
<div class="flex items-center justify-between mb-2">
|
|
73
|
+
<h3 class="font-medium text-gray-700" id="historySummaryHeading">Activity by period</h3>
|
|
74
|
+
<span class="text-xs uppercase tracking-wide text-gray-500" id="historySummaryMeta"></span>
|
|
75
|
+
</div>
|
|
76
|
+
<div id="historySummaryContent" class="space-y-1 text-sm text-gray-700"></div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</section>
|
|
80
|
+
|
|
81
|
+
<section id="historySection" class="hidden">
|
|
82
|
+
<div class="bg-white rounded-lg shadow p-6">
|
|
83
|
+
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
|
|
84
|
+
<i data-lucide="clock"></i>
|
|
85
|
+
Recent History
|
|
86
|
+
</h2>
|
|
87
|
+
<div id="historyContent" class="space-y-3"></div>
|
|
88
|
+
<div id="paginationControls" class="flex items-center justify-between mt-6 hidden">
|
|
89
|
+
<button id="prevPageBtn" class="bg-gray-200 text-gray-800 px-3 py-1 rounded hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">Previous</button>
|
|
90
|
+
<span id="pageInfo" class="text-sm text-gray-600"></span>
|
|
91
|
+
<button id="nextPageBtn" class="bg-gray-200 text-gray-800 px-3 py-1 rounded hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">Next</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</section>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<script type="module">
|
|
98
|
+
function el(id) { return document.getElementById(id); }
|
|
99
|
+
|
|
100
|
+
const frequencyLabels = {
|
|
101
|
+
daily: "Daily",
|
|
102
|
+
monthly: "Monthly",
|
|
103
|
+
yearly: "Yearly",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const state = {
|
|
107
|
+
userId: "",
|
|
108
|
+
startDate: "",
|
|
109
|
+
endDate: "",
|
|
110
|
+
frequency: "daily",
|
|
111
|
+
page: 1,
|
|
112
|
+
pageSize: 20,
|
|
113
|
+
totalPages: 1,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const controls = {
|
|
117
|
+
userId: el("userId"),
|
|
118
|
+
startDate: el("startDate"),
|
|
119
|
+
endDate: el("endDate"),
|
|
120
|
+
frequency: el("frequency"),
|
|
121
|
+
pageSize: el("pageSize"),
|
|
122
|
+
applyFilters: el("applyFiltersBtn"),
|
|
123
|
+
resetFilters: el("resetFiltersBtn"),
|
|
124
|
+
prevPage: el("prevPageBtn"),
|
|
125
|
+
nextPage: el("nextPageBtn"),
|
|
126
|
+
pageInfo: el("pageInfo"),
|
|
127
|
+
pagination: el("paginationControls"),
|
|
128
|
+
historySummaryCard: el("historySummaryCard"),
|
|
129
|
+
historySummaryContent: el("historySummaryContent"),
|
|
130
|
+
historySummaryMeta: el("historySummaryMeta"),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function formatSummary(data) {
|
|
134
|
+
if (!data) return '<p class="text-gray-500">No summary available.</p>';
|
|
135
|
+
const taskTypes = Object.entries(data.taskTypes || {}).map(([t, c]) => `${t}: ${c}`).join(', ');
|
|
136
|
+
const recent = (data.recentTasks || []).map(t => `<li>${t.description} (${t.type})</li>`).join('');
|
|
137
|
+
return `
|
|
138
|
+
<div><strong>Total tasks:</strong> ${data.totalTasks}</div>
|
|
139
|
+
<div><strong>Task mix:</strong> ${taskTypes || 'ā'}</div>
|
|
140
|
+
<div><strong>Last active:</strong> ${data.lastActive ? new Date(data.lastActive).toLocaleDateString() : 'Never'}</div>
|
|
141
|
+
<div><strong>Updated:</strong> ${data.updatedAt ? new Date(data.updatedAt).toLocaleString() : 'ā'}</div>
|
|
142
|
+
<div class="mt-4"><strong>Recent tasks:</strong><ul class="list-disc list-inside">${recent || '<li>No recent tasks</li>'}</ul></div>
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function formatHistory(rows) {
|
|
147
|
+
if (!rows || rows.length === 0) return '<p class="text-gray-500">No history yet.</p>';
|
|
148
|
+
return rows.map(row => `
|
|
149
|
+
<div class="border-b pb-2">
|
|
150
|
+
<div class="flex justify-between items-start">
|
|
151
|
+
<div>
|
|
152
|
+
<div class="font-medium">${row.task_description}</div>
|
|
153
|
+
<div class="text-sm text-gray-600">${row.task_type} ⢠${new Date(row.timestamp).toLocaleString()}</div>
|
|
154
|
+
<div class="text-sm text-gray-500">Commit: ${row.commit_message || '(none)'}</div>
|
|
155
|
+
</div>
|
|
156
|
+
${row.forced ? '<span class="text-xs bg-orange-100 text-orange-800 px-2 py-1 rounded">Forced</span>' : ''}
|
|
157
|
+
${row.dropped ? '<span class="text-xs bg-red-100 text-red-800 px-2 py-1 rounded">Dropped</span>' : ''}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
`).join('');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatHistorySummary(series) {
|
|
164
|
+
if (!series || series.length === 0) {
|
|
165
|
+
return '<p class="text-gray-500">No activity for the selected range.</p>';
|
|
166
|
+
}
|
|
167
|
+
return series.map(item => `
|
|
168
|
+
<div class="flex justify-between">
|
|
169
|
+
<span>${item.period}</span>
|
|
170
|
+
<span class="font-medium">${item.count}</span>
|
|
171
|
+
</div>
|
|
172
|
+
`).join('');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function fetchJson(path, params = {}) {
|
|
176
|
+
const url = new URL(path, window.location.origin);
|
|
177
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
178
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
179
|
+
url.searchParams.set(key, value);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
const res = await fetch(url.toString());
|
|
183
|
+
if (!res.ok) {
|
|
184
|
+
throw new Error(`Request failed: ${res.status}`);
|
|
185
|
+
}
|
|
186
|
+
return res.json();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function collectFilters() {
|
|
190
|
+
state.userId = controls.userId.value.trim();
|
|
191
|
+
state.startDate = controls.startDate.value;
|
|
192
|
+
state.endDate = controls.endDate.value;
|
|
193
|
+
state.frequency = controls.frequency.value;
|
|
194
|
+
state.pageSize = Number(controls.pageSize.value) || 20;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resetFilters() {
|
|
198
|
+
controls.userId.value = "";
|
|
199
|
+
controls.startDate.value = "";
|
|
200
|
+
controls.endDate.value = "";
|
|
201
|
+
controls.frequency.value = "daily";
|
|
202
|
+
controls.pageSize.value = "20";
|
|
203
|
+
state.userId = "";
|
|
204
|
+
state.startDate = "";
|
|
205
|
+
state.endDate = "";
|
|
206
|
+
state.frequency = "daily";
|
|
207
|
+
state.page = 1;
|
|
208
|
+
state.pageSize = 20;
|
|
209
|
+
load();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function updatePagination(total, page, pageSize) {
|
|
213
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
214
|
+
state.totalPages = totalPages;
|
|
215
|
+
controls.pageInfo.textContent = `Page ${page} of ${totalPages} (${total} entries)`;
|
|
216
|
+
controls.prevPage.disabled = page <= 1;
|
|
217
|
+
controls.nextPage.disabled = page >= totalPages;
|
|
218
|
+
controls.pagination.classList.toggle("hidden", total === 0);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function updateHistorySummary(series) {
|
|
222
|
+
if (!series || series.length === 0) {
|
|
223
|
+
controls.historySummaryCard.classList.add("hidden");
|
|
224
|
+
controls.historySummaryContent.innerHTML = "";
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
controls.historySummaryContent.innerHTML = formatHistorySummary(series);
|
|
228
|
+
const freqLabel = frequencyLabels[state.frequency] || "Daily";
|
|
229
|
+
const rangeParts = [];
|
|
230
|
+
if (state.startDate) rangeParts.push(`from ${state.startDate}`);
|
|
231
|
+
if (state.endDate) rangeParts.push(`to ${state.endDate}`);
|
|
232
|
+
controls.historySummaryMeta.textContent = `${freqLabel}${rangeParts.length ? ` ⢠${rangeParts.join(" ")}` : ""}`;
|
|
233
|
+
controls.historySummaryCard.classList.remove("hidden");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function load() {
|
|
237
|
+
const userId = state.userId || "default";
|
|
238
|
+
try {
|
|
239
|
+
const historyParams = {
|
|
240
|
+
user: userId,
|
|
241
|
+
page: state.page,
|
|
242
|
+
pageSize: state.pageSize,
|
|
243
|
+
startDate: state.startDate,
|
|
244
|
+
endDate: state.endDate,
|
|
245
|
+
};
|
|
246
|
+
const summaryParams = {
|
|
247
|
+
user: userId,
|
|
248
|
+
startDate: state.startDate,
|
|
249
|
+
endDate: state.endDate,
|
|
250
|
+
frequency: state.frequency,
|
|
251
|
+
};
|
|
252
|
+
const [{ summary }, historyData, { summary: historySummary }] = await Promise.all([
|
|
253
|
+
fetchJson('/api/summary', { user: userId }),
|
|
254
|
+
fetchJson('/api/history', historyParams),
|
|
255
|
+
fetchJson('/api/history-summary', summaryParams),
|
|
256
|
+
]);
|
|
257
|
+
|
|
258
|
+
el('summaryContent').innerHTML = formatSummary(summary);
|
|
259
|
+
el('historyContent').innerHTML = formatHistory(historyData.entries);
|
|
260
|
+
updatePagination(historyData.total, historyData.page, historyData.pageSize);
|
|
261
|
+
updateHistorySummary(historySummary);
|
|
262
|
+
|
|
263
|
+
el('summarySection').classList.remove('hidden');
|
|
264
|
+
el('historySection').classList.remove('hidden');
|
|
265
|
+
} catch (error) {
|
|
266
|
+
el('summaryContent').innerHTML = `<p class="text-red-500">Failed to load summary: ${error.message}</p>`;
|
|
267
|
+
el('historyContent').innerHTML = '<p class="text-gray-500">History unavailable.</p>';
|
|
268
|
+
controls.pagination.classList.add("hidden");
|
|
269
|
+
controls.historySummaryCard.classList.add("hidden");
|
|
270
|
+
el('summarySection').classList.remove('hidden');
|
|
271
|
+
el('historySection').classList.remove('hidden');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
controls.applyFilters.addEventListener('click', () => {
|
|
276
|
+
collectFilters();
|
|
277
|
+
state.page = 1;
|
|
278
|
+
load();
|
|
279
|
+
});
|
|
280
|
+
controls.resetFilters.addEventListener('click', resetFilters);
|
|
281
|
+
controls.userId.addEventListener('keydown', (e) => {
|
|
282
|
+
if (e.key === 'Enter') {
|
|
283
|
+
collectFilters();
|
|
284
|
+
state.page = 1;
|
|
285
|
+
load();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
controls.prevPage.addEventListener('click', () => {
|
|
289
|
+
if (state.page > 1) {
|
|
290
|
+
state.page -= 1;
|
|
291
|
+
load();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
controls.nextPage.addEventListener('click', () => {
|
|
295
|
+
if (state.page < state.totalPages) {
|
|
296
|
+
state.page += 1;
|
|
297
|
+
load();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Initialize Lucide icons and initial load
|
|
302
|
+
lucide.createIcons();
|
|
303
|
+
collectFilters();
|
|
304
|
+
load();
|
|
305
|
+
</script>
|
|
306
|
+
</body>
|
|
307
|
+
</html>
|