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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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,2 @@
1
+ import type { Config } from "./config.js";
2
+ export declare function fetchClockedHours(config: Config, from: string, to: string): Promise<Map<string, number>>;
@@ -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
+ }