whstats 1.0.0
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/README.md +72 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +151 -0
- package/dist/lib/config.d.ts +16 -0
- package/dist/lib/config.js +148 -0
- package/dist/lib/mssql.d.ts +2 -0
- package/dist/lib/mssql.js +63 -0
- package/dist/lib/redmine.d.ts +38 -0
- package/dist/lib/redmine.js +28 -0
- package/dist/lib/utils.d.ts +8 -0
- package/dist/lib/utils.js +21 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# WH Stats
|
|
2
|
+
|
|
3
|
+
Compare booked hours (Redmine) vs clocked hours (timelogger) for the last 7 days.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Using bun (recommended)
|
|
9
|
+
bun x whstats
|
|
10
|
+
|
|
11
|
+
# Using npx
|
|
12
|
+
npx whstats
|
|
13
|
+
|
|
14
|
+
# Or install globally
|
|
15
|
+
bun install -g whstats
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# First-time setup (interactive)
|
|
22
|
+
whstats --setup
|
|
23
|
+
|
|
24
|
+
# Show time statistics
|
|
25
|
+
whstats
|
|
26
|
+
|
|
27
|
+
# Show config file location and current settings
|
|
28
|
+
whstats --config
|
|
29
|
+
|
|
30
|
+
# Reset configuration
|
|
31
|
+
whstats --reset
|
|
32
|
+
|
|
33
|
+
# Show help
|
|
34
|
+
whstats --help
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
On first run, use `whstats --setup` to configure your credentials interactively.
|
|
40
|
+
|
|
41
|
+
Configuration is stored in `~/.config/whstats/config.json`.
|
|
42
|
+
|
|
43
|
+
## Example Output
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Fetching time entries for John Doe...
|
|
47
|
+
|
|
48
|
+
2026-02-03 Monday: 8h booked / 8.25h clocked
|
|
49
|
+
- #1234 4h Implemented feature X
|
|
50
|
+
- #1235 4h Code review and testing
|
|
51
|
+
|
|
52
|
+
2026-02-04 Tuesday: 7h booked / 7.5h clocked
|
|
53
|
+
- #1236 3h Bug fixes
|
|
54
|
+
- #1237 4h Documentation updates
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Development
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Install dependencies
|
|
61
|
+
bun install
|
|
62
|
+
|
|
63
|
+
# Run locally
|
|
64
|
+
bun run index.ts
|
|
65
|
+
|
|
66
|
+
# Run with flags
|
|
67
|
+
bun run index.ts --help
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { getConfigOrExit, promptForConfig, loadConfig, deleteConfig, getConfigPath, configExists, } from "./lib/config.js";
|
|
3
|
+
import { fetchCurrentUser, fetchTimeEntries } from "./lib/redmine.js";
|
|
4
|
+
import { fetchClockedHours } from "./lib/mssql.js";
|
|
5
|
+
import { getDateRange, formatHours, truncateComment, getDayName } from "./lib/utils.js";
|
|
6
|
+
const VERSION = "1.0.0";
|
|
7
|
+
function showHelp() {
|
|
8
|
+
console.log(`
|
|
9
|
+
WH Stats v${VERSION}
|
|
10
|
+
|
|
11
|
+
Compare booked hours (Redmine) vs clocked hours (timelogger) for the last 7 days.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
whstats Show time statistics
|
|
15
|
+
whstats --setup Configure credentials (interactive)
|
|
16
|
+
whstats --config Show config file location
|
|
17
|
+
whstats --reset Delete saved configuration
|
|
18
|
+
whstats --help Show this help message
|
|
19
|
+
whstats --version Show version
|
|
20
|
+
|
|
21
|
+
Configuration:
|
|
22
|
+
Run 'whstats --setup' to configure your credentials interactively.
|
|
23
|
+
Credentials are stored in: ~/.config/whstats/config.json
|
|
24
|
+
`);
|
|
25
|
+
}
|
|
26
|
+
function showVersion() {
|
|
27
|
+
console.log(`whstats v${VERSION}`);
|
|
28
|
+
}
|
|
29
|
+
function showConfig() {
|
|
30
|
+
const configPath = getConfigPath();
|
|
31
|
+
const exists = configExists();
|
|
32
|
+
console.log(`\n Config file: ${configPath}`);
|
|
33
|
+
console.log(` Status: ${exists ? "configured" : "not configured"}\n`);
|
|
34
|
+
if (exists) {
|
|
35
|
+
const config = loadConfig();
|
|
36
|
+
if (config) {
|
|
37
|
+
console.log(" Current settings:");
|
|
38
|
+
console.log(` Redmine URL: ${config.redmineUrl}`);
|
|
39
|
+
console.log(` Redmine API: ${config.redmineApiKey.slice(0, 8)}...`);
|
|
40
|
+
console.log(` MSSQL Server: ${config.mssqlServer}`);
|
|
41
|
+
console.log(` MSSQL Database: ${config.mssqlDatabase}`);
|
|
42
|
+
console.log(` MSSQL User: ${config.mssqlUser}`);
|
|
43
|
+
console.log(` User ID: ${config.slackUserId}\n`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function handleSetup() {
|
|
48
|
+
const existing = loadConfig();
|
|
49
|
+
await promptForConfig(existing);
|
|
50
|
+
}
|
|
51
|
+
function handleReset() {
|
|
52
|
+
if (deleteConfig()) {
|
|
53
|
+
console.log("\n Configuration deleted.\n");
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
console.log("\n No configuration file found.\n");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function groupByDate(entries) {
|
|
60
|
+
const grouped = new Map();
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const date = entry.spent_on;
|
|
63
|
+
if (!grouped.has(date)) {
|
|
64
|
+
grouped.set(date, []);
|
|
65
|
+
}
|
|
66
|
+
grouped.get(date).push(entry);
|
|
67
|
+
}
|
|
68
|
+
return grouped;
|
|
69
|
+
}
|
|
70
|
+
function displayResults(entries, clockedHours) {
|
|
71
|
+
const grouped = groupByDate(entries);
|
|
72
|
+
// Combine all dates from both sources
|
|
73
|
+
const allDates = new Set([...grouped.keys(), ...clockedHours.keys()]);
|
|
74
|
+
const sortedDates = Array.from(allDates).sort();
|
|
75
|
+
if (sortedDates.length === 0) {
|
|
76
|
+
console.log("No time entries found for the last 7 days.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
console.log("");
|
|
80
|
+
for (const date of sortedDates) {
|
|
81
|
+
const dayEntries = grouped.get(date) || [];
|
|
82
|
+
const bookedHours = dayEntries.reduce((sum, e) => sum + e.hours, 0);
|
|
83
|
+
const clocked = clockedHours.get(date) || 0;
|
|
84
|
+
const dayName = getDayName(date);
|
|
85
|
+
const clockedStr = clocked > 0 ? formatHours(clocked) : "-";
|
|
86
|
+
console.log(`${date} ${dayName}: ${formatHours(bookedHours)} booked / ${clockedStr} clocked`);
|
|
87
|
+
for (const entry of dayEntries) {
|
|
88
|
+
const issueRef = entry.issue ? `#${entry.issue.id}` : "#N/A";
|
|
89
|
+
const hours = formatHours(entry.hours);
|
|
90
|
+
const comment = truncateComment(entry.comments || "(no comment)");
|
|
91
|
+
console.log(` - ${issueRef} ${hours} ${comment}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
console.log("");
|
|
95
|
+
}
|
|
96
|
+
async function runStats() {
|
|
97
|
+
const config = getConfigOrExit();
|
|
98
|
+
try {
|
|
99
|
+
const user = await fetchCurrentUser(config);
|
|
100
|
+
console.log(`\nFetching time entries for ${user.firstname} ${user.lastname}...`);
|
|
101
|
+
const { from, to } = getDateRange(7);
|
|
102
|
+
const [entries, clockedHours] = await Promise.all([
|
|
103
|
+
fetchTimeEntries(config, user.id, from, to),
|
|
104
|
+
fetchClockedHours(config, from, to),
|
|
105
|
+
]);
|
|
106
|
+
displayResults(entries, clockedHours);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error instanceof Error) {
|
|
110
|
+
console.error(`\n Error: ${error.message}\n`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
console.error("\n An unexpected error occurred.\n");
|
|
114
|
+
}
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function main() {
|
|
119
|
+
const args = process.argv.slice(2);
|
|
120
|
+
const command = args[0];
|
|
121
|
+
switch (command) {
|
|
122
|
+
case "--help":
|
|
123
|
+
case "-h":
|
|
124
|
+
showHelp();
|
|
125
|
+
break;
|
|
126
|
+
case "--version":
|
|
127
|
+
case "-v":
|
|
128
|
+
showVersion();
|
|
129
|
+
break;
|
|
130
|
+
case "--setup":
|
|
131
|
+
case "-s":
|
|
132
|
+
await handleSetup();
|
|
133
|
+
break;
|
|
134
|
+
case "--config":
|
|
135
|
+
case "-c":
|
|
136
|
+
showConfig();
|
|
137
|
+
break;
|
|
138
|
+
case "--reset":
|
|
139
|
+
case "-r":
|
|
140
|
+
handleReset();
|
|
141
|
+
break;
|
|
142
|
+
case undefined:
|
|
143
|
+
await runStats();
|
|
144
|
+
break;
|
|
145
|
+
default:
|
|
146
|
+
console.error(`\n Unknown command: ${command}`);
|
|
147
|
+
console.error(" Run 'whstats --help' for usage.\n");
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
main();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
redmineApiKey: string;
|
|
3
|
+
redmineUrl: string;
|
|
4
|
+
mssqlServer: string;
|
|
5
|
+
mssqlDatabase: string;
|
|
6
|
+
mssqlUser: string;
|
|
7
|
+
mssqlPassword: string;
|
|
8
|
+
slackUserId: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function getConfigPath(): string;
|
|
11
|
+
export declare function configExists(): boolean;
|
|
12
|
+
export declare function loadConfig(): Config | null;
|
|
13
|
+
export declare function saveConfig(config: Config): void;
|
|
14
|
+
export declare function deleteConfig(): boolean;
|
|
15
|
+
export declare function promptForConfig(existingConfig?: Config | null): Promise<Config>;
|
|
16
|
+
export declare function getConfigOrExit(): Config;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
4
|
+
import * as readline from "readline";
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".config", "whstats");
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
7
|
+
export function getConfigPath() {
|
|
8
|
+
return CONFIG_FILE;
|
|
9
|
+
}
|
|
10
|
+
export function configExists() {
|
|
11
|
+
return existsSync(CONFIG_FILE);
|
|
12
|
+
}
|
|
13
|
+
export function loadConfig() {
|
|
14
|
+
if (!existsSync(CONFIG_FILE))
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
const content = readFileSync(CONFIG_FILE, "utf-8");
|
|
18
|
+
return JSON.parse(content);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function saveConfig(config) {
|
|
25
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
26
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
27
|
+
}
|
|
28
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
29
|
+
}
|
|
30
|
+
export function deleteConfig() {
|
|
31
|
+
if (existsSync(CONFIG_FILE)) {
|
|
32
|
+
unlinkSync(CONFIG_FILE);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
function prompt(rl, question, defaultValue) {
|
|
38
|
+
const displayQuestion = defaultValue
|
|
39
|
+
? `${question} [${defaultValue}]: `
|
|
40
|
+
: `${question}: `;
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
rl.question(displayQuestion, (answer) => {
|
|
43
|
+
resolve(answer.trim() || defaultValue || "");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function promptPassword(rl, question) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const stdin = process.stdin;
|
|
50
|
+
const stdout = process.stdout;
|
|
51
|
+
stdout.write(`${question}: `);
|
|
52
|
+
// Disable echo for password input
|
|
53
|
+
if (stdin.isTTY) {
|
|
54
|
+
stdin.setRawMode(true);
|
|
55
|
+
}
|
|
56
|
+
let password = "";
|
|
57
|
+
const onData = (char) => {
|
|
58
|
+
const c = char.toString();
|
|
59
|
+
switch (c) {
|
|
60
|
+
case "\n":
|
|
61
|
+
case "\r":
|
|
62
|
+
case "\u0004": // Ctrl+D
|
|
63
|
+
if (stdin.isTTY) {
|
|
64
|
+
stdin.setRawMode(false);
|
|
65
|
+
}
|
|
66
|
+
stdin.removeListener("data", onData);
|
|
67
|
+
stdout.write("\n");
|
|
68
|
+
resolve(password);
|
|
69
|
+
break;
|
|
70
|
+
case "\u0003": // Ctrl+C
|
|
71
|
+
if (stdin.isTTY) {
|
|
72
|
+
stdin.setRawMode(false);
|
|
73
|
+
}
|
|
74
|
+
process.exit(1);
|
|
75
|
+
break;
|
|
76
|
+
case "\u007F": // Backspace
|
|
77
|
+
if (password.length > 0) {
|
|
78
|
+
password = password.slice(0, -1);
|
|
79
|
+
stdout.write("\b \b");
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
default:
|
|
83
|
+
password += c;
|
|
84
|
+
stdout.write("*");
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
stdin.resume();
|
|
88
|
+
stdin.on("data", onData);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export async function promptForConfig(existingConfig) {
|
|
92
|
+
const rl = readline.createInterface({
|
|
93
|
+
input: process.stdin,
|
|
94
|
+
output: process.stdout,
|
|
95
|
+
});
|
|
96
|
+
console.log("\n WH Stats Configuration\n");
|
|
97
|
+
console.log(" Enter your credentials (press Enter to keep existing values)\n");
|
|
98
|
+
try {
|
|
99
|
+
const config = {
|
|
100
|
+
redmineUrl: await prompt(rl, " Redmine URL", existingConfig?.redmineUrl),
|
|
101
|
+
redmineApiKey: await prompt(rl, " Redmine API Key", existingConfig?.redmineApiKey),
|
|
102
|
+
mssqlServer: await prompt(rl, " MSSQL Server", existingConfig?.mssqlServer),
|
|
103
|
+
mssqlDatabase: await prompt(rl, " MSSQL Database", existingConfig?.mssqlDatabase),
|
|
104
|
+
mssqlUser: await prompt(rl, " MSSQL User", existingConfig?.mssqlUser),
|
|
105
|
+
mssqlPassword: await prompt(rl, " MSSQL Password", existingConfig?.mssqlPassword),
|
|
106
|
+
slackUserId: await prompt(rl, " User ID (in timelogger)", existingConfig?.slackUserId),
|
|
107
|
+
};
|
|
108
|
+
rl.close();
|
|
109
|
+
// Validate required fields
|
|
110
|
+
const missingFields = [];
|
|
111
|
+
if (!config.redmineUrl)
|
|
112
|
+
missingFields.push("Redmine URL");
|
|
113
|
+
if (!config.redmineApiKey)
|
|
114
|
+
missingFields.push("Redmine API Key");
|
|
115
|
+
if (!config.mssqlServer)
|
|
116
|
+
missingFields.push("MSSQL Server");
|
|
117
|
+
if (!config.mssqlDatabase)
|
|
118
|
+
missingFields.push("MSSQL Database");
|
|
119
|
+
if (!config.mssqlUser)
|
|
120
|
+
missingFields.push("MSSQL User");
|
|
121
|
+
if (!config.mssqlPassword)
|
|
122
|
+
missingFields.push("MSSQL Password");
|
|
123
|
+
if (!config.slackUserId)
|
|
124
|
+
missingFields.push("User ID");
|
|
125
|
+
if (missingFields.length > 0) {
|
|
126
|
+
console.error(`\n Error: Missing required fields: ${missingFields.join(", ")}`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
// Normalize URL
|
|
130
|
+
config.redmineUrl = config.redmineUrl.replace(/\/$/, "");
|
|
131
|
+
saveConfig(config);
|
|
132
|
+
console.log(`\n Config saved to ${CONFIG_FILE}\n`);
|
|
133
|
+
return config;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
rl.close();
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function getConfigOrExit() {
|
|
141
|
+
const config = loadConfig();
|
|
142
|
+
if (config) {
|
|
143
|
+
return config;
|
|
144
|
+
}
|
|
145
|
+
console.error("\n No configuration found.\n");
|
|
146
|
+
console.error(" Run 'whstats --setup' to configure your credentials.\n");
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import sql from "mssql";
|
|
2
|
+
import { formatDate } from "./utils.js";
|
|
3
|
+
export async function fetchClockedHours(config, from, to) {
|
|
4
|
+
const sqlConfig = {
|
|
5
|
+
server: config.mssqlServer,
|
|
6
|
+
database: config.mssqlDatabase,
|
|
7
|
+
user: config.mssqlUser,
|
|
8
|
+
password: config.mssqlPassword,
|
|
9
|
+
options: {
|
|
10
|
+
encrypt: false,
|
|
11
|
+
trustServerCertificate: true,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
const pool = await sql.connect(sqlConfig);
|
|
15
|
+
try {
|
|
16
|
+
// Query to calculate clocked-in time per day
|
|
17
|
+
// For each day, we pair clock-in (1) with clock-out (0) events and sum the differences
|
|
18
|
+
const result = await pool.request()
|
|
19
|
+
.input("userId", sql.Int, parseInt(config.slackUserId))
|
|
20
|
+
.input("fromDate", sql.Date, from)
|
|
21
|
+
.input("toDate", sql.Date, to)
|
|
22
|
+
.query(`
|
|
23
|
+
WITH OrderedEvents AS (
|
|
24
|
+
SELECT
|
|
25
|
+
CAST([date] AS DATE) AS day,
|
|
26
|
+
[date] AS event_time,
|
|
27
|
+
[clock],
|
|
28
|
+
ROW_NUMBER() OVER (PARTITION BY CAST([date] AS DATE) ORDER BY [date]) AS rn
|
|
29
|
+
FROM event_logs
|
|
30
|
+
WHERE user_id = @userId
|
|
31
|
+
AND CAST([date] AS DATE) >= @fromDate
|
|
32
|
+
AND CAST([date] AS DATE) <= @toDate
|
|
33
|
+
),
|
|
34
|
+
ClockPairs AS (
|
|
35
|
+
SELECT
|
|
36
|
+
e1.day,
|
|
37
|
+
e1.event_time AS clock_in,
|
|
38
|
+
e2.event_time AS clock_out
|
|
39
|
+
FROM OrderedEvents e1
|
|
40
|
+
LEFT JOIN OrderedEvents e2
|
|
41
|
+
ON e1.day = e2.day
|
|
42
|
+
AND e1.rn + 1 = e2.rn
|
|
43
|
+
AND e2.clock = 0
|
|
44
|
+
WHERE e1.clock = 1
|
|
45
|
+
)
|
|
46
|
+
SELECT
|
|
47
|
+
day,
|
|
48
|
+
SUM(DATEDIFF(MINUTE, clock_in, ISNULL(clock_out, GETDATE()))) / 60.0 AS hours
|
|
49
|
+
FROM ClockPairs
|
|
50
|
+
GROUP BY day
|
|
51
|
+
ORDER BY day
|
|
52
|
+
`);
|
|
53
|
+
const clockedHours = new Map();
|
|
54
|
+
for (const row of result.recordset) {
|
|
55
|
+
const dateStr = formatDate(new Date(row.day));
|
|
56
|
+
clockedHours.set(dateStr, parseFloat(row.hours));
|
|
57
|
+
}
|
|
58
|
+
return clockedHours;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await pool.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Config } from "./config.js";
|
|
2
|
+
export interface TimeEntry {
|
|
3
|
+
id: number;
|
|
4
|
+
project: {
|
|
5
|
+
id: number;
|
|
6
|
+
name: string;
|
|
7
|
+
};
|
|
8
|
+
issue?: {
|
|
9
|
+
id: number;
|
|
10
|
+
};
|
|
11
|
+
user: {
|
|
12
|
+
id: number;
|
|
13
|
+
name: string;
|
|
14
|
+
};
|
|
15
|
+
activity: {
|
|
16
|
+
id: number;
|
|
17
|
+
name: string;
|
|
18
|
+
};
|
|
19
|
+
hours: number;
|
|
20
|
+
comments: string;
|
|
21
|
+
spent_on: string;
|
|
22
|
+
created_on: string;
|
|
23
|
+
updated_on: string;
|
|
24
|
+
}
|
|
25
|
+
export interface TimeEntriesResponse {
|
|
26
|
+
time_entries: TimeEntry[];
|
|
27
|
+
total_count: number;
|
|
28
|
+
offset: number;
|
|
29
|
+
limit: number;
|
|
30
|
+
}
|
|
31
|
+
export interface User {
|
|
32
|
+
id: number;
|
|
33
|
+
login: string;
|
|
34
|
+
firstname: string;
|
|
35
|
+
lastname: string;
|
|
36
|
+
}
|
|
37
|
+
export declare function fetchCurrentUser(config: Config): Promise<User>;
|
|
38
|
+
export declare function fetchTimeEntries(config: Config, userId: number, from: string, to: string): Promise<TimeEntry[]>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export async function fetchCurrentUser(config) {
|
|
2
|
+
const url = `${config.redmineUrl}/my/account.json`;
|
|
3
|
+
const response = await fetch(url, {
|
|
4
|
+
headers: {
|
|
5
|
+
"X-Redmine-API-Key": config.redmineApiKey,
|
|
6
|
+
"Content-Type": "application/json",
|
|
7
|
+
},
|
|
8
|
+
});
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
throw new Error(`Redmine API error: ${response.status} ${response.statusText}`);
|
|
11
|
+
}
|
|
12
|
+
const data = (await response.json());
|
|
13
|
+
return data.user;
|
|
14
|
+
}
|
|
15
|
+
export async function fetchTimeEntries(config, userId, from, to) {
|
|
16
|
+
const url = `${config.redmineUrl}/time_entries.json?user_id=${userId}&from=${from}&to=${to}&limit=100`;
|
|
17
|
+
const response = await fetch(url, {
|
|
18
|
+
headers: {
|
|
19
|
+
"X-Redmine-API-Key": config.redmineApiKey,
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(`Redmine API error: ${response.status} ${response.statusText}`);
|
|
25
|
+
}
|
|
26
|
+
const data = (await response.json());
|
|
27
|
+
return data.time_entries;
|
|
28
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function formatDate(date: Date): string;
|
|
2
|
+
export declare function getDateRange(days?: number): {
|
|
3
|
+
from: string;
|
|
4
|
+
to: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function formatHours(hours: number): string;
|
|
7
|
+
export declare function truncateComment(comment: string, maxLength?: number): string;
|
|
8
|
+
export declare function getDayName(dateStr: string): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function formatDate(date) {
|
|
2
|
+
return date.toISOString().split("T")[0];
|
|
3
|
+
}
|
|
4
|
+
export function getDateRange(days = 7) {
|
|
5
|
+
const today = new Date();
|
|
6
|
+
const fromDate = new Date(today);
|
|
7
|
+
fromDate.setDate(today.getDate() - days);
|
|
8
|
+
return { from: formatDate(fromDate), to: formatDate(today) };
|
|
9
|
+
}
|
|
10
|
+
export function formatHours(hours) {
|
|
11
|
+
return hours % 1 === 0 ? `${hours}h` : `${hours.toFixed(2)}h`;
|
|
12
|
+
}
|
|
13
|
+
export function truncateComment(comment, maxLength = 30) {
|
|
14
|
+
if (comment.length <= maxLength)
|
|
15
|
+
return comment;
|
|
16
|
+
return comment.substring(0, maxLength - 3) + "...";
|
|
17
|
+
}
|
|
18
|
+
export function getDayName(dateStr) {
|
|
19
|
+
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
20
|
+
return days[new Date(dateStr).getDay()];
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "whstats",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "WH Stats - Compare booked hours (Redmine) vs clocked hours (timelogger)",
|
|
5
|
+
"author": "Armin Emmert <emmertarmin@gmail.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"whstats": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist/**/*.js",
|
|
13
|
+
"dist/**/*.d.ts"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"redmine",
|
|
21
|
+
"time-tracking",
|
|
22
|
+
"statistics",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/emmertarmin/whstats.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/emmertarmin/whstats/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/emmertarmin/whstats#readme",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/bun": "latest",
|
|
38
|
+
"@types/mssql": "^9.1.5",
|
|
39
|
+
"typescript": "^5"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"mssql": "^12.2.0"
|
|
43
|
+
}
|
|
44
|
+
}
|